diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 000000000..ffe730b74 --- /dev/null +++ b/.codespellrc @@ -0,0 +1,7 @@ +[codespell] +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +skip = .git*,*.svg,i18n,*-lock.yaml,*.css,.codespellrc,migrations,*.js,*.map,*.mjs +check-hidden = true +# ignore all CamelCase and camelCase +ignore-regex = \b[A-Za-z][a-z]+[A-Z][a-zA-Z]+\b +ignore-words-list = tread diff --git a/.dockerignore b/.dockerignore index fb5a407e9..fe11e95b6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -61,3 +61,9 @@ temp/ # Misc *.pem *.key + +# React Router - https://github.com/remix-run/react-router-templates/blob/dc79b1a065f59f3bfd840d4ef75cc27689b611e6/default/.dockerignore +.react-router/ +build/ +node_modules/ +README.md \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2bcbf557f..e3aba5cf1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,11 +3,9 @@ name: "CodeQL" on: workflow_dispatch: push: - branches: ["preview", "master"] + branches: ["preview", "canary", "master"] pull_request: - branches: ["develop", "preview", "master"] - schedule: - - cron: "53 19 * * 5" + branches: ["preview", "canary", "master"] jobs: analyze: diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 000000000..ca87dc934 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,25 @@ +# Codespell configuration is within .codespellrc +--- +name: Codespell + +on: + push: + branches: [preview] + pull_request: + branches: [preview] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Annotate locations with typos + uses: codespell-project/codespell-problem-matcher@v1 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/.gitignore b/.gitignore index 4baa3495a..0edc47dcc 100644 --- a/.gitignore +++ b/.gitignore @@ -16,9 +16,10 @@ node_modules /out/ # Production -/build dist/ out/ +build/ +.react-router/ # Misc .DS_Store @@ -99,3 +100,5 @@ dev-editor *.rdb.gz storybook-static + +CLAUDE.md diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 000000000..716b1b5b1 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +node = "22.18.0" diff --git a/apps/admin/.eslintrc.js b/apps/admin/.eslintrc.js index 1662fabf7..a0bc76d5d 100644 --- a/apps/admin/.eslintrc.js +++ b/apps/admin/.eslintrc.js @@ -1,4 +1,18 @@ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], + rules: { + "no-duplicate-imports": "off", + "import/no-duplicates": ["error", { "prefer-inline": false }], + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-import-type-side-effects": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }, + ], + }, }; diff --git a/apps/admin/app/(all)/(dashboard)/ai/form.tsx b/apps/admin/app/(all)/(dashboard)/ai/form.tsx index 8b7d036ad..64970a547 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/form.tsx @@ -1,11 +1,13 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { useForm } from "react-hook-form"; import { Lightbulb } from "lucide-react"; -import { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceAIConfigurationKeys } from "@plane/types"; // components -import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(dashboard)/ai/layout.tsx b/apps/admin/app/(all)/(dashboard)/ai/layout.tsx index 42f379649..303ed5604 100644 --- a/apps/admin/app/(all)/(dashboard)/ai/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/ai/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Artificial Intelligence Settings - God Mode", diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx index 6e5f2a903..ae0f54c4f 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/form.tsx @@ -1,19 +1,25 @@ "use client"; -import { FC, useState } from "react"; -import isEmpty from "lodash/isEmpty"; +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; import Link from "next/link"; import { useForm } from "react-hook-form"; +import { Monitor } from "lucide-react"; // plane internal packages import { API_BASE_URL } from "@plane/constants"; -import { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGithubAuthenticationConfigurationKeys } from "@plane/types"; + import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; -import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; -import { CopyField, TCopyField } from "@/components/common/copy-field"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; // hooks import { useInstance } from "@/hooks/store"; @@ -101,7 +107,7 @@ export const InstanceGithubConfigForm: FC = (props) => { }, ]; - const GITHUB_SERVICE_FIELD: TCopyField[] = [ + const GITHUB_COMMON_SERVICE_DETAILS: TCopyField[] = [ { key: "Origin_URL", label: "Origin URL", @@ -121,6 +127,9 @@ export const InstanceGithubConfigForm: FC = (props) => { ), }, + ]; + + const GITHUB_SERVICE_DETAILS: TCopyField[] = [ { key: "Callback_URI", label: "Callback URI", @@ -208,12 +217,29 @@ export const InstanceGithubConfigForm: FC = (props) => { -
-
-
Plane-provided details for GitHub
- {GITHUB_SERVICE_FIELD.map((field) => ( - - ))} +
+
Plane-provided details for GitHub
+ +
+ {/* common service details */} +
+ {GITHUB_COMMON_SERVICE_DETAILS.map((field) => ( + + ))} +
+ + {/* web service details */} +
+
+ + Web +
+
+ {GITHUB_SERVICE_DETAILS.map((field) => ( + + ))} +
+
diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx index 373f9340a..2da5a9031 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "GitHub Authentication - God Mode", diff --git a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx index 75cb84e4a..5709ba4ba 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/github/page.tsx @@ -6,7 +6,8 @@ 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 { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; import { resolveGeneralTheme } from "@plane/utils"; // components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx index 888b2533c..91e4ee8ec 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/form.tsx @@ -1,17 +1,21 @@ -import { FC, useState } from "react"; -import isEmpty from "lodash/isEmpty"; +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; import Link from "next/link"; import { useForm } from "react-hook-form"; // plane internal packages import { API_BASE_URL } from "@plane/constants"; -import { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGitlabAuthenticationConfigurationKeys } from "@plane/types"; import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; -import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; -import { CopyField, TCopyField } from "@/components/common/copy-field"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx index fc89e9752..79b5de5af 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "GitLab Authentication - God Mode", diff --git a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx index f0b464acb..ae85168ae 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/gitlab/page.tsx @@ -4,7 +4,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import useSWR from "swr"; -import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; // components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; // hooks diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx index f136df308..d9c3646b7 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/form.tsx @@ -1,18 +1,23 @@ "use client"; -import { FC, useState } from "react"; -import isEmpty from "lodash/isEmpty"; +import type { FC } from "react"; +import { useState } from "react"; +import { isEmpty } from "lodash-es"; import Link from "next/link"; import { useForm } from "react-hook-form"; +import { Monitor } from "lucide-react"; // plane internal packages import { API_BASE_URL } from "@plane/constants"; -import { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceGoogleAuthenticationConfigurationKeys } from "@plane/types"; import { cn } from "@plane/utils"; // components import { CodeBlock } from "@/components/common/code-block"; import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal"; -import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; -import { CopyField, TCopyField } from "@/components/common/copy-field"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; +import type { TCopyField } from "@/components/common/copy-field"; +import { CopyField } from "@/components/common/copy-field"; // hooks import { useInstance } from "@/hooks/store"; @@ -90,7 +95,7 @@ export const InstanceGoogleConfigForm: FC = (props) => { }, ]; - const GOOGLE_SERVICE_DETAILS: TCopyField[] = [ + const GOOGLE_COMMON_SERVICE_DETAILS: TCopyField[] = [ { key: "Origin_URL", label: "Origin URL", @@ -110,6 +115,9 @@ export const InstanceGoogleConfigForm: FC = (props) => {

), }, + ]; + + const GOOGLE_SERVICE_DETAILS: TCopyField[] = [ { key: "Callback_URI", label: "Callback URI", @@ -195,12 +203,29 @@ export const InstanceGoogleConfigForm: FC = (props) => {
-
-
-
Plane-provided details for Google
- {GOOGLE_SERVICE_DETAILS.map((field) => ( - - ))} +
+
Plane-provided details for Google
+ +
+ {/* common service details */} +
+ {GOOGLE_COMMON_SERVICE_DETAILS.map((field) => ( + + ))} +
+ + {/* web service details */} +
+
+ + Web +
+
+ {GOOGLE_SERVICE_DETAILS.map((field) => ( + + ))} +
+
diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx index 5b3786b5f..ddc0cff45 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Google Authentication - God Mode", diff --git a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx index 7cf42cb57..d6ca370d4 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/google/page.tsx @@ -4,7 +4,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import useSWR from "swr"; -import { Loader, ToggleSwitch, setPromiseToast } from "@plane/ui"; +import { setPromiseToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; // components import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card"; // hooks diff --git a/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx b/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx index 69753d960..bed80f224 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Authentication Settings - Plane Web", diff --git a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx index 279ff396a..16be71e58 100644 --- a/apps/admin/app/(all)/(dashboard)/authentication/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/authentication/page.tsx @@ -4,8 +4,9 @@ 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"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { TInstanceConfigurationKeys } from "@plane/types"; +import { Loader, ToggleSwitch } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx index 2bfcc4287..450a5f4e9 100644 --- a/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/email-config-form.tsx @@ -1,13 +1,17 @@ "use client"; -import React, { FC, useMemo, useState } from "react"; +import type { FC } from "react"; +import React, { useMemo, useState } from "react"; import { useForm } from "react-hook-form"; // types -import { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceEmailConfigurationKeys } from "@plane/types"; // ui -import { Button, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; +import { CustomSelect } from "@plane/ui"; // components -import { ControllerInput, TControllerInputFormField } from "@/components/common/controller-input"; +import type { TControllerInputFormField } from "@/components/common/controller-input"; +import { ControllerInput } from "@/components/common/controller-input"; // hooks import { useInstance } from "@/hooks/store"; // local components diff --git a/apps/admin/app/(all)/(dashboard)/email/layout.tsx b/apps/admin/app/(all)/(dashboard)/email/layout.tsx index cb3212951..0e6fc06cd 100644 --- a/apps/admin/app/(all)/(dashboard)/email/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; interface EmailLayoutProps { children: ReactNode; diff --git a/apps/admin/app/(all)/(dashboard)/email/page.tsx b/apps/admin/app/(all)/(dashboard)/email/page.tsx index 792bafe35..a509f6d28 100644 --- a/apps/admin/app/(all)/(dashboard)/email/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/page.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; -import { Loader, setToast, TOAST_TYPE, ToggleSwitch } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Loader, ToggleSwitch } from "@plane/ui"; // hooks import { useInstance } from "@/hooks/store"; // components diff --git a/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx b/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx index 676f3b685..091170960 100644 --- a/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx +++ b/apps/admin/app/(all)/(dashboard)/email/test-email-modal.tsx @@ -1,9 +1,11 @@ -import React, { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; // plane imports +import { Button } from "@plane/propel/button"; import { InstanceService } from "@plane/services"; // ui -import { Button, Input } from "@plane/ui"; +import { Input } from "@plane/ui"; type Props = { isOpen: boolean; diff --git a/apps/admin/app/(all)/(dashboard)/general/form.tsx b/apps/admin/app/(all)/(dashboard)/general/form.tsx index 0700c4d0d..c91069b54 100644 --- a/apps/admin/app/(all)/(dashboard)/general/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/form.tsx @@ -1,12 +1,14 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { Telescope } from "lucide-react"; // types -import { IInstance, IInstanceAdmin } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IInstance, IInstanceAdmin } from "@plane/types"; // ui -import { Button, Input, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +import { Input, ToggleSwitch } from "@plane/ui"; // components import { ControllerInput } from "@/components/common/controller-input"; import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx index 37f7e3071..a6f17d629 100644 --- a/apps/admin/app/(all)/(dashboard)/general/intercom.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/intercom.tsx @@ -1,10 +1,11 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; import { MessageSquare } from "lucide-react"; -import { IFormattedInstanceConfiguration } from "@plane/types"; +import type { IFormattedInstanceConfiguration } from "@plane/types"; import { ToggleSwitch } from "@plane/ui"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(dashboard)/general/layout.tsx b/apps/admin/app/(all)/(dashboard)/general/layout.tsx index af3000510..f5167e750 100644 --- a/apps/admin/app/(all)/(dashboard)/general/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/general/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "General Settings - God Mode", diff --git a/apps/admin/app/(all)/(dashboard)/header.tsx b/apps/admin/app/(all)/(dashboard)/header.tsx index af7161c2d..82d7241f6 100644 --- a/apps/admin/app/(all)/(dashboard)/header.tsx +++ b/apps/admin/app/(all)/(dashboard)/header.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; import { Menu, Settings } from "lucide-react"; diff --git a/apps/admin/app/(all)/(dashboard)/image/form.tsx b/apps/admin/app/(all)/(dashboard)/image/form.tsx index be77983ec..f6adcaee4 100644 --- a/apps/admin/app/(all)/(dashboard)/image/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/form.tsx @@ -1,8 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { useForm } from "react-hook-form"; -import { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IFormattedInstanceConfiguration, TInstanceImageConfigurationKeys } from "@plane/types"; // components import { ControllerInput } from "@/components/common/controller-input"; // hooks diff --git a/apps/admin/app/(all)/(dashboard)/image/layout.tsx b/apps/admin/app/(all)/(dashboard)/image/layout.tsx index 7ec0ff54b..559a15f9d 100644 --- a/apps/admin/app/(all)/(dashboard)/image/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/image/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; interface ImageLayoutProps { children: ReactNode; diff --git a/apps/admin/app/(all)/(dashboard)/layout.tsx b/apps/admin/app/(all)/(dashboard)/layout.tsx index 179623783..76d74f463 100644 --- a/apps/admin/app/(all)/(dashboard)/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/layout.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, ReactNode, useEffect } from "react"; +import type { FC, ReactNode } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; // components diff --git a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx index c4421ae5f..cf4791190 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar-help-section.tsx @@ -1,13 +1,14 @@ "use client"; -import { FC, useState, useRef } from "react"; +import type { FC } from "react"; +import { useState, useRef } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; -import { ExternalLink, FileText, HelpCircle, MoveLeft } from "lucide-react"; +import { ExternalLink, HelpCircle, MoveLeft } from "lucide-react"; import { Transition } from "@headlessui/react"; // plane internal packages import { WEB_BASE_URL } from "@plane/constants"; -import { DiscordIcon, GithubIcon } from "@plane/propel/icons"; +import { DiscordIcon, GithubIcon, PageIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks @@ -20,7 +21,7 @@ const helpOptions = [ { name: "Documentation", href: "https://docs.plane.so/", - Icon: FileText, + Icon: PageIcon, }, { name: "Join our Discord", @@ -110,7 +111,7 @@ export const AdminSidebarHelpSection: FC = observer(() => {
- +
{name}
diff --git a/apps/admin/app/(all)/(dashboard)/sidebar.tsx b/apps/admin/app/(all)/(dashboard)/sidebar.tsx index 09dab86ee..e37d6eb5c 100644 --- a/apps/admin/app/(all)/(dashboard)/sidebar.tsx +++ b/apps/admin/app/(all)/(dashboard)/sidebar.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useEffect, useRef } from "react"; +import type { FC } from "react"; +import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; diff --git a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx index d086777fc..6ec3fe4a6 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/create/form.tsx @@ -4,10 +4,12 @@ import { useRouter } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; // plane imports import { WEB_BASE_URL, ORGANIZATION_SIZE, RESTRICTED_URLS } from "@plane/constants"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { InstanceWorkspaceService } from "@plane/services"; -import { IWorkspace } from "@plane/types"; +import type { IWorkspace } from "@plane/types"; // components -import { Button, CustomSelect, getButtonStyling, Input, setToast, TOAST_TYPE } from "@plane/ui"; +import { CustomSelect, Input } from "@plane/ui"; // hooks import { useWorkspace } from "@/hooks/store"; diff --git a/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx b/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx index 78b0f3c40..4749e2f7b 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Workspace Management - God Mode", diff --git a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx index b8f79db04..a03c443d8 100644 --- a/apps/admin/app/(all)/(dashboard)/workspace/page.tsx +++ b/apps/admin/app/(all)/(dashboard)/workspace/page.tsx @@ -6,8 +6,11 @@ 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 { Button, getButtonStyling } from "@plane/propel/button"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { TInstanceConfigurationKeys } from "@plane/types"; +import { Loader, ToggleSwitch } from "@plane/ui"; + import { cn } from "@plane/utils"; // components import { WorkspaceListItem } from "@/components/workspace/list-item"; diff --git a/apps/admin/app/(all)/(home)/auth-banner.tsx b/apps/admin/app/(all)/(home)/auth-banner.tsx index 5d63808f1..c0a9a0e92 100644 --- a/apps/admin/app/(all)/(home)/auth-banner.tsx +++ b/apps/admin/app/(all)/(home)/auth-banner.tsx @@ -1,7 +1,7 @@ -import { FC } from "react"; +import type { FC } from "react"; import { Info, X } from "lucide-react"; // plane constants -import { TAdminAuthErrorInfo } from "@plane/constants"; +import type { TAdminAuthErrorInfo } from "@plane/constants"; type TAuthBanner = { bannerData: TAdminAuthErrorInfo | undefined; diff --git a/apps/admin/app/(all)/(home)/auth-helpers.tsx b/apps/admin/app/(all)/(home)/auth-helpers.tsx index 7613548b9..4da6d7eca 100644 --- a/apps/admin/app/(all)/(home)/auth-helpers.tsx +++ b/apps/admin/app/(all)/(home)/auth-helpers.tsx @@ -1,10 +1,11 @@ -import { ReactNode } from "react"; +import type { 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, TAdminAuthErrorInfo } from "@plane/constants"; -import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; +import type { TAdminAuthErrorInfo } from "@plane/constants"; +import { SUPPORT_EMAIL, EAdminAuthErrorCodes } from "@plane/constants"; +import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types"; import { resolveGeneralTheme } from "@plane/utils"; // components import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch"; diff --git a/apps/admin/app/(all)/(home)/sign-in-form.tsx b/apps/admin/app/(all)/(home)/sign-in-form.tsx index a5a6ca3e3..2049bda61 100644 --- a/apps/admin/app/(all)/(home)/sign-in-form.tsx +++ b/apps/admin/app/(all)/(home)/sign-in-form.tsx @@ -1,12 +1,15 @@ "use client"; -import { FC, useEffect, useMemo, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { Eye, EyeOff } from "lucide-react"; // plane internal packages -import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; +import type { EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants"; +import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; -import { Button, Input, Spinner } from "@plane/ui"; +import { Input, Spinner } from "@plane/ui"; // components import { Banner } from "@/components/common/banner"; // local components diff --git a/apps/admin/app/(all)/instance.provider.tsx b/apps/admin/app/(all)/instance.provider.tsx index ac8fa74e8..19e15ec52 100644 --- a/apps/admin/app/(all)/instance.provider.tsx +++ b/apps/admin/app/(all)/instance.provider.tsx @@ -1,4 +1,4 @@ -import { FC, ReactNode } from "react"; +import type { FC, ReactNode } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // hooks diff --git a/apps/admin/app/(all)/store.provider.tsx b/apps/admin/app/(all)/store.provider.tsx index 7a0d48559..648a37119 100644 --- a/apps/admin/app/(all)/store.provider.tsx +++ b/apps/admin/app/(all)/store.provider.tsx @@ -1,6 +1,7 @@ "use client"; -import { ReactNode, createContext } from "react"; +import type { ReactNode } from "react"; +import { createContext } from "react"; // plane admin store import { RootStore } from "@/plane-admin/store/root.store"; diff --git a/apps/admin/app/(all)/toast.tsx b/apps/admin/app/(all)/toast.tsx index 7d7938a9b..9cd1c46a1 100644 --- a/apps/admin/app/(all)/toast.tsx +++ b/apps/admin/app/(all)/toast.tsx @@ -1,7 +1,7 @@ "use client"; import { useTheme } from "next-themes"; -import { Toast } from "@plane/ui"; +import { Toast } from "@plane/propel/toast"; import { resolveGeneralTheme } from "@plane/utils"; export const ToastWithTheme = () => { diff --git a/apps/admin/app/(all)/user.provider.tsx b/apps/admin/app/(all)/user.provider.tsx index 3a50823dc..e026c31da 100644 --- a/apps/admin/app/(all)/user.provider.tsx +++ b/apps/admin/app/(all)/user.provider.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, ReactNode, useEffect } from "react"; +import type { FC, ReactNode } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // hooks diff --git a/apps/admin/app/layout.tsx b/apps/admin/app/layout.tsx index e73572369..b9cdd17ca 100644 --- a/apps/admin/app/layout.tsx +++ b/apps/admin/app/layout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from "react"; -import { Metadata } from "next"; +import type { ReactNode } from "react"; +import type { Metadata } from "next"; // plane imports import { ADMIN_BASE_PATH } from "@plane/constants"; // styles diff --git a/apps/admin/ce/components/authentication/authentication-modes.tsx b/apps/admin/ce/components/authentication/authentication-modes.tsx index c90016702..386e0c05e 100644 --- a/apps/admin/ce/components/authentication/authentication-modes.tsx +++ b/apps/admin/ce/components/authentication/authentication-modes.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import { useTheme } from "next-themes"; import { KeyRound, Mails } from "lucide-react"; // types -import { +import type { TGetBaseAuthenticationModeProps, TInstanceAuthenticationMethodKeys, TInstanceAuthenticationModes, diff --git a/apps/admin/ce/components/common/upgrade-button.tsx b/apps/admin/ce/components/common/upgrade-button.tsx index 208225e0c..14a955f21 100644 --- a/apps/admin/ce/components/common/upgrade-button.tsx +++ b/apps/admin/ce/components/common/upgrade-button.tsx @@ -4,7 +4,7 @@ import React from "react"; // icons import { SquareArrowOutUpRight } from "lucide-react"; // plane internal packages -import { getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; import { cn } from "@plane/utils"; export const UpgradeButton: React.FC = () => ( diff --git a/apps/admin/core/components/authentication/authentication-method-card.tsx b/apps/admin/core/components/authentication/authentication-method-card.tsx index 566551f48..df8e6dba6 100644 --- a/apps/admin/core/components/authentication/authentication-method-card.tsx +++ b/apps/admin/core/components/authentication/authentication-method-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/admin/core/components/authentication/email-config-switch.tsx b/apps/admin/core/components/authentication/email-config-switch.tsx index 16eb98704..3a2a5f541 100644 --- a/apps/admin/core/components/authentication/email-config-switch.tsx +++ b/apps/admin/core/components/authentication/email-config-switch.tsx @@ -3,7 +3,7 @@ import React from "react"; import { observer } from "mobx-react"; // hooks -import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; import { ToggleSwitch } from "@plane/ui"; import { useInstance } from "@/hooks/store"; // ui diff --git a/apps/admin/core/components/authentication/github-config.tsx b/apps/admin/core/components/authentication/github-config.tsx index 249f1ebc4..332191458 100644 --- a/apps/admin/core/components/authentication/github-config.tsx +++ b/apps/admin/core/components/authentication/github-config.tsx @@ -6,8 +6,9 @@ import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; // plane internal packages -import { TInstanceAuthenticationMethodKeys } from "@plane/types"; -import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/core/components/authentication/gitlab-config.tsx b/apps/admin/core/components/authentication/gitlab-config.tsx index f5586f3f3..6f0294c3c 100644 --- a/apps/admin/core/components/authentication/gitlab-config.tsx +++ b/apps/admin/core/components/authentication/gitlab-config.tsx @@ -6,8 +6,9 @@ import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; // plane internal packages -import { TInstanceAuthenticationMethodKeys } from "@plane/types"; -import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/core/components/authentication/google-config.tsx b/apps/admin/core/components/authentication/google-config.tsx index ec7501b34..ae0cecf33 100644 --- a/apps/admin/core/components/authentication/google-config.tsx +++ b/apps/admin/core/components/authentication/google-config.tsx @@ -6,8 +6,9 @@ import Link from "next/link"; // icons import { Settings2 } from "lucide-react"; // plane internal packages -import { TInstanceAuthenticationMethodKeys } from "@plane/types"; -import { ToggleSwitch, getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useInstance } from "@/hooks/store"; diff --git a/apps/admin/core/components/authentication/password-config-switch.tsx b/apps/admin/core/components/authentication/password-config-switch.tsx index 5cbd9b03c..1126ff4fb 100644 --- a/apps/admin/core/components/authentication/password-config-switch.tsx +++ b/apps/admin/core/components/authentication/password-config-switch.tsx @@ -3,7 +3,7 @@ import React from "react"; import { observer } from "mobx-react"; // hooks -import { TInstanceAuthenticationMethodKeys } from "@plane/types"; +import type { TInstanceAuthenticationMethodKeys } from "@plane/types"; import { ToggleSwitch } from "@plane/ui"; import { useInstance } from "@/hooks/store"; // ui diff --git a/apps/admin/core/components/common/banner.tsx b/apps/admin/core/components/common/banner.tsx index 932a0c629..32bc5bc77 100644 --- a/apps/admin/core/components/common/banner.tsx +++ b/apps/admin/core/components/common/banner.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { AlertCircle, CheckCircle2 } from "lucide-react"; type TBanner = { diff --git a/apps/admin/core/components/common/confirm-discard-modal.tsx b/apps/admin/core/components/common/confirm-discard-modal.tsx index d0ca21fc2..d1931f06f 100644 --- a/apps/admin/core/components/common/confirm-discard-modal.tsx +++ b/apps/admin/core/components/common/confirm-discard-modal.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // ui -import { Button, getButtonStyling } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/propel/button"; type Props = { isOpen: boolean; diff --git a/apps/admin/core/components/common/controller-input.tsx b/apps/admin/core/components/common/controller-input.tsx index cbcbafb2d..4b16ffd07 100644 --- a/apps/admin/core/components/common/controller-input.tsx +++ b/apps/admin/core/components/common/controller-input.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useState } from "react"; -import { Controller, Control } from "react-hook-form"; +import type { Control } from "react-hook-form"; +import { Controller } from "react-hook-form"; // icons import { Eye, EyeOff } from "lucide-react"; // plane internal packages diff --git a/apps/admin/core/components/common/copy-field.tsx b/apps/admin/core/components/common/copy-field.tsx index cd8cfee53..4f4f71753 100644 --- a/apps/admin/core/components/common/copy-field.tsx +++ b/apps/admin/core/components/common/copy-field.tsx @@ -3,8 +3,8 @@ import React from "react"; // ui import { Copy } from "lucide-react"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// icons +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; type Props = { label: string; diff --git a/apps/admin/core/components/common/empty-state.tsx b/apps/admin/core/components/common/empty-state.tsx index 57489ccc6..4bf291f5c 100644 --- a/apps/admin/core/components/common/empty-state.tsx +++ b/apps/admin/core/components/common/empty-state.tsx @@ -2,7 +2,7 @@ import React from "react"; import Image from "next/image"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; type Props = { title: string; diff --git a/apps/admin/core/components/instance/failure.tsx b/apps/admin/core/components/instance/failure.tsx index fac8287a5..97ace834f 100644 --- a/apps/admin/core/components/instance/failure.tsx +++ b/apps/admin/core/components/instance/failure.tsx @@ -1,9 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import { useTheme } from "next-themes"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // assets import { AuthHeader } from "@/app/(all)/(home)/auth-header"; import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg"; diff --git a/apps/admin/core/components/instance/instance-not-ready.tsx b/apps/admin/core/components/instance/instance-not-ready.tsx index 2940e81e7..b01d938bf 100644 --- a/apps/admin/core/components/instance/instance-not-ready.tsx +++ b/apps/admin/core/components/instance/instance-not-ready.tsx @@ -1,9 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import Image from "next/image"; import Link from "next/link"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // assets import PlaneTakeOffImage from "@/public/images/plane-takeoff.png"; diff --git a/apps/admin/core/components/instance/setup-form.tsx b/apps/admin/core/components/instance/setup-form.tsx index 0dbec972d..a4d59b689 100644 --- a/apps/admin/core/components/instance/setup-form.tsx +++ b/apps/admin/core/components/instance/setup-form.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, useEffect, useMemo, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; // icons import { Eye, EyeOff } from "lucide-react"; // plane internal packages import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; -import { Button, Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; +import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; import { getPasswordStrength } from "@plane/utils"; // components import { AuthHeader } from "@/app/(all)/(home)/auth-header"; diff --git a/apps/admin/core/components/new-user-popup.tsx b/apps/admin/core/components/new-user-popup.tsx index 0b974b38c..4f0e0236b 100644 --- a/apps/admin/core/components/new-user-popup.tsx +++ b/apps/admin/core/components/new-user-popup.tsx @@ -6,7 +6,7 @@ import Image from "next/image"; import Link from "next/link"; import { useTheme as nextUseTheme } from "next-themes"; // ui -import { Button, getButtonStyling } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/propel/button"; import { resolveGeneralTheme } from "@plane/utils"; // hooks import { useTheme } from "@/hooks/store"; diff --git a/apps/admin/core/hooks/store/use-instance.tsx b/apps/admin/core/hooks/store/use-instance.tsx index 67ac3bca8..5917df3fa 100644 --- a/apps/admin/core/hooks/store/use-instance.tsx +++ b/apps/admin/core/hooks/store/use-instance.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; // store import { StoreContext } from "@/app/(all)/store.provider"; -import { IInstanceStore } from "@/store/instance.store"; +import type { IInstanceStore } from "@/store/instance.store"; export const useInstance = (): IInstanceStore => { const context = useContext(StoreContext); diff --git a/apps/admin/core/hooks/store/use-theme.tsx b/apps/admin/core/hooks/store/use-theme.tsx index 0f07149b1..d5a1e820e 100644 --- a/apps/admin/core/hooks/store/use-theme.tsx +++ b/apps/admin/core/hooks/store/use-theme.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; // store import { StoreContext } from "@/app/(all)/store.provider"; -import { IThemeStore } from "@/store/theme.store"; +import type { IThemeStore } from "@/store/theme.store"; export const useTheme = (): IThemeStore => { const context = useContext(StoreContext); diff --git a/apps/admin/core/hooks/store/use-user.tsx b/apps/admin/core/hooks/store/use-user.tsx index eaf02862e..56b988eb8 100644 --- a/apps/admin/core/hooks/store/use-user.tsx +++ b/apps/admin/core/hooks/store/use-user.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; // store import { StoreContext } from "@/app/(all)/store.provider"; -import { IUserStore } from "@/store/user.store"; +import type { IUserStore } from "@/store/user.store"; export const useUser = (): IUserStore => { const context = useContext(StoreContext); diff --git a/apps/admin/core/hooks/store/use-workspace.tsx b/apps/admin/core/hooks/store/use-workspace.tsx index 2203ec948..c4578c917 100644 --- a/apps/admin/core/hooks/store/use-workspace.tsx +++ b/apps/admin/core/hooks/store/use-workspace.tsx @@ -1,7 +1,7 @@ import { useContext } from "react"; // store import { StoreContext } from "@/app/(all)/store.provider"; -import { IWorkspaceStore } from "@/store/workspace.store"; +import type { IWorkspaceStore } from "@/store/workspace.store"; export const useWorkspace = (): IWorkspaceStore => { const context = useContext(StoreContext); diff --git a/apps/admin/core/store/instance.store.ts b/apps/admin/core/store/instance.store.ts index 764c95bf2..ec8922920 100644 --- a/apps/admin/core/store/instance.store.ts +++ b/apps/admin/core/store/instance.store.ts @@ -1,9 +1,10 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; // plane internal packages -import { EInstanceStatus, TInstanceStatus } from "@plane/constants"; +import type { TInstanceStatus } from "@plane/constants"; +import { EInstanceStatus } from "@plane/constants"; import { InstanceService } from "@plane/services"; -import { +import type { IInstance, IInstanceAdmin, IInstanceConfiguration, @@ -12,7 +13,7 @@ import { IInstanceConfig, } from "@plane/types"; // root store -import { CoreRootStore } from "@/store/root.store"; +import type { CoreRootStore } from "@/store/root.store"; export interface IInstanceStore { // issues diff --git a/apps/admin/core/store/root.store.ts b/apps/admin/core/store/root.store.ts index 8c53061ab..68d11885b 100644 --- a/apps/admin/core/store/root.store.ts +++ b/apps/admin/core/store/root.store.ts @@ -1,9 +1,13 @@ import { enableStaticRendering } from "mobx-react"; // stores -import { IInstanceStore, InstanceStore } from "./instance.store"; -import { IThemeStore, ThemeStore } from "./theme.store"; -import { IUserStore, UserStore } from "./user.store"; -import { IWorkspaceStore, WorkspaceStore } from "./workspace.store"; +import type { IInstanceStore } from "./instance.store"; +import { InstanceStore } from "./instance.store"; +import type { IThemeStore } from "./theme.store"; +import { ThemeStore } from "./theme.store"; +import type { IUserStore } from "./user.store"; +import { UserStore } from "./user.store"; +import type { IWorkspaceStore } from "./workspace.store"; +import { WorkspaceStore } from "./workspace.store"; enableStaticRendering(typeof window === "undefined"); diff --git a/apps/admin/core/store/theme.store.ts b/apps/admin/core/store/theme.store.ts index f47042d6e..4512facd2 100644 --- a/apps/admin/core/store/theme.store.ts +++ b/apps/admin/core/store/theme.store.ts @@ -1,6 +1,6 @@ import { action, observable, makeObservable } from "mobx"; // root store -import { CoreRootStore } from "@/store/root.store"; +import type { CoreRootStore } from "@/store/root.store"; type TTheme = "dark" | "light"; export interface IThemeStore { diff --git a/apps/admin/core/store/user.store.ts b/apps/admin/core/store/user.store.ts index 85c56495b..1187355a0 100644 --- a/apps/admin/core/store/user.store.ts +++ b/apps/admin/core/store/user.store.ts @@ -1,10 +1,11 @@ import { action, observable, runInAction, makeObservable } from "mobx"; // plane internal packages -import { EUserStatus, TUserStatus } from "@plane/constants"; +import type { TUserStatus } from "@plane/constants"; +import { EUserStatus } from "@plane/constants"; import { AuthService, UserService } from "@plane/services"; -import { IUser } from "@plane/types"; +import type { IUser } from "@plane/types"; // root store -import { CoreRootStore } from "@/store/root.store"; +import type { CoreRootStore } from "@/store/root.store"; export interface IUserStore { // observables diff --git a/apps/admin/core/store/workspace.store.ts b/apps/admin/core/store/workspace.store.ts index 64f7501d3..f9203ed40 100644 --- a/apps/admin/core/store/workspace.store.ts +++ b/apps/admin/core/store/workspace.store.ts @@ -1,10 +1,10 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { action, observable, runInAction, makeObservable, computed } from "mobx"; // plane imports import { InstanceWorkspaceService } from "@plane/services"; -import { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; +import type { IWorkspace, TLoader, TPaginationInfo } from "@plane/types"; // root store -import { CoreRootStore } from "@/store/root.store"; +import type { CoreRootStore } from "@/store/root.store"; export interface IWorkspaceStore { // observables diff --git a/apps/admin/package.json b/apps/admin/package.json index 18acfb50f..dfa57a7cd 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -1,7 +1,7 @@ { "name": "admin", "description": "Admin UI for Plane", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "private": true, "scripts": { @@ -27,7 +27,7 @@ "@plane/utils": "workspace:*", "autoprefixer": "10.4.14", "axios": "catalog:", - "lodash": "catalog:", + "lodash-es": "catalog:", "lucide-react": "catalog:", "mobx": "catalog:", "mobx-react": "catalog:", @@ -45,11 +45,10 @@ "@plane/eslint-config": "workspace:*", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", - "@types/lodash": "catalog:", - "@types/node": "18.16.1", + "@types/lodash-es": "catalog:", + "@types/node": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "@types/uuid": "^9.0.8", "typescript": "catalog:" } } diff --git a/apps/admin/styles/globals.css b/apps/admin/styles/globals.css index 737015d26..86a0b8518 100644 --- a/apps/admin/styles/globals.css +++ b/apps/admin/styles/globals.css @@ -111,6 +111,27 @@ --color-sidebar-shadow-2xl: var(--color-shadow-2xl); --color-sidebar-shadow-3xl: var(--color-shadow-3xl); --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + + /* toast theme */ + --color-toast-success-text: 178, 221, 181; + --color-toast-error-text: 206, 44, 49; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 141, 164, 239; + --color-toast-loading-text: 255, 255, 255; + --color-toast-secondary-text: 185, 187, 198; + --color-toast-tertiary-text: 139, 141, 152; + + --color-toast-success-background: 46, 46, 46; + --color-toast-error-background: 46, 46, 46; + --color-toast-warning-background: 46, 46, 46; + --color-toast-info-background: 46, 46, 46; + --color-toast-loading-background: 46, 46, 46; + + --color-toast-success-border: 42, 126, 59; + --color-toast-error-border: 100, 23, 35; + --color-toast-warning-border: 79, 52, 34; + --color-toast-info-border: 58, 91, 199; + --color-toast-loading-border: 96, 100, 108; } [data-theme="light"], @@ -221,27 +242,6 @@ --color-border-200: 38, 38, 38; /* subtle border- 2 */ --color-border-300: 46, 46, 46; /* strong border- 1 */ --color-border-400: 58, 58, 58; /* strong border- 2 */ - - /* toast theme */ - --color-toast-success-text: 178, 221, 181; - --color-toast-error-text: 206, 44, 49; - --color-toast-warning-text: 255, 186, 24; - --color-toast-info-text: 141, 164, 239; - --color-toast-loading-text: 255, 255, 255; - --color-toast-secondary-text: 185, 187, 198; - --color-toast-tertiary-text: 139, 141, 152; - - --color-toast-success-background: 46, 46, 46; - --color-toast-error-background: 46, 46, 46; - --color-toast-warning-background: 46, 46, 46; - --color-toast-info-background: 46, 46, 46; - --color-toast-loading-background: 46, 46, 46; - - --color-toast-success-border: 42, 126, 59; - --color-toast-error-border: 100, 23, 35; - --color-toast-warning-border: 79, 52, 34; - --color-toast-info-border: 58, 91, 199; - --color-toast-loading-border: 96, 100, 108; } [data-theme="dark-contrast"] { diff --git a/apps/api/package.json b/apps/api/package.json index 97122880f..ffecb3a73 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "plane-api", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "private": true, "description": "API server powering Plane's backend" diff --git a/apps/api/plane/api/apps.py b/apps/api/plane/api/apps.py index b48a9a949..f1f531118 100644 --- a/apps/api/plane/api/apps.py +++ b/apps/api/plane/api/apps.py @@ -9,4 +9,4 @@ class ApiConfig(AppConfig): try: import plane.utils.openapi.auth # noqa except ImportError: - pass \ No newline at end of file + pass diff --git a/apps/api/plane/api/serializers/asset.py b/apps/api/plane/api/serializers/asset.py index b63dc7ebb..6b74b3757 100644 --- a/apps/api/plane/api/serializers/asset.py +++ b/apps/api/plane/api/serializers/asset.py @@ -46,9 +46,7 @@ class AssetUpdateSerializer(serializers.Serializer): and upload confirmation for S3-based file storage workflows. """ - attributes = serializers.JSONField( - required=False, help_text="Additional attributes to update for the asset" - ) + attributes = serializers.JSONField(required=False, help_text="Additional attributes to update for the asset") class GenericAssetUploadSerializer(serializers.Serializer): @@ -85,9 +83,7 @@ class GenericAssetUpdateSerializer(serializers.Serializer): upload completion marking and metadata finalization. """ - is_uploaded = serializers.BooleanField( - default=True, help_text="Whether the asset has been successfully uploaded" - ) + is_uploaded = serializers.BooleanField(default=True, help_text="Whether the asset has been successfully uploaded") class FileAssetSerializer(BaseSerializer): diff --git a/apps/api/plane/api/serializers/base.py b/apps/api/plane/api/serializers/base.py index 46bd398bc..bc790f2cd 100644 --- a/apps/api/plane/api/serializers/base.py +++ b/apps/api/plane/api/serializers/base.py @@ -29,7 +29,8 @@ class BaseSerializer(serializers.ModelSerializer): """ Adjust the serializer's fields based on the provided 'fields' list. - :param fields: List or dictionary specifying which fields to include in the serializer. + :param fields: List or dictionary specifying which + fields to include in the serializer. :return: The updated fields for the serializer. """ # Check each field_name in the provided fields. @@ -102,13 +103,9 @@ class BaseSerializer(serializers.ModelSerializer): # Check if field in expansion then expand the field if expand in expansion: if isinstance(response.get(expand), list): - exp_serializer = expansion[expand]( - getattr(instance, expand), many=True - ) + exp_serializer = expansion[expand](getattr(instance, expand), many=True) else: - exp_serializer = expansion[expand]( - getattr(instance, expand) - ) + exp_serializer = expansion[expand](getattr(instance, expand)) response[expand] = exp_serializer.data else: # You might need to handle this case differently diff --git a/apps/api/plane/api/serializers/cycle.py b/apps/api/plane/api/serializers/cycle.py index cf057d842..6b7bfa442 100644 --- a/apps/api/plane/api/serializers/cycle.py +++ b/apps/api/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.db.models import Cycle, CycleIssue, User from plane.utils.timezone_converter import convert_to_utc @@ -16,6 +16,13 @@ class CycleCreateSerializer(BaseSerializer): and UTC normalization for time-bound iteration planning and sprint management. """ + owned_by = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), + required=False, + allow_null=True, + help_text="User who owns the cycle. If not provided, defaults to the current user.", + ) + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) project = self.context.get("project") @@ -55,14 +62,9 @@ class CycleCreateSerializer(BaseSerializer): ): 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 - ): + 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 - if self.instance and hasattr(self.instance, "project_id") - else None + self.instance.project_id if self.instance and hasattr(self.instance, "project_id") else None ) if not project_id: @@ -77,6 +79,10 @@ class CycleCreateSerializer(BaseSerializer): date=str(data.get("end_date", None).date()), project_id=project_id, ) + + if not data.get("owned_by"): + data["owned_by"] = self.context["request"].user + return data @@ -166,9 +172,7 @@ class CycleIssueRequestSerializer(serializers.Serializer): cycle assignment and sprint planning workflows. """ - issues = serializers.ListField( - child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle" - ) + issues = serializers.ListField(child=serializers.UUIDField(), help_text="List of issue IDs to add to the cycle") class TransferCycleIssueRequestSerializer(serializers.Serializer): @@ -179,6 +183,4 @@ class TransferCycleIssueRequestSerializer(serializers.Serializer): and relationship updates for sprint reallocation workflows. """ - new_cycle_id = serializers.UUIDField( - help_text="ID of the target cycle to transfer issues to" - ) + new_cycle_id = serializers.UUIDField(help_text="ID of the target cycle to transfer issues to") diff --git a/apps/api/plane/api/serializers/intake.py b/apps/api/plane/api/serializers/intake.py index 32f8bf2da..fcfedcbd6 100644 --- a/apps/api/plane/api/serializers/intake.py +++ b/apps/api/plane/api/serializers/intake.py @@ -1,4 +1,4 @@ -# Module improts +# Module imports from .base import BaseSerializer from .issue import IssueExpandSerializer from plane.db.models import IntakeIssue, Issue @@ -98,9 +98,7 @@ class IntakeIssueUpdateSerializer(BaseSerializer): and embedded issue updates for issue queue processing workflows. """ - issue = IssueForIntakeSerializer( - required=False, help_text="Issue data to update in the intake issue" - ) + issue = IssueForIntakeSerializer(required=False, help_text="Issue data to update in the intake issue") class Meta: model = IntakeIssue @@ -132,9 +130,5 @@ class IssueDataSerializer(serializers.Serializer): """ name = serializers.CharField(max_length=255, help_text="Issue name") - description_html = serializers.CharField( - required=False, allow_null=True, help_text="Issue description HTML" - ) - priority = serializers.ChoiceField( - choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority" - ) + description_html = serializers.CharField(required=False, allow_null=True, help_text="Issue description HTML") + priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, default="none", help_text="Issue priority") diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index 075823cbf..d7fc3e911 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -43,21 +43,18 @@ class IssueSerializer(BaseSerializer): Comprehensive work item serializer with full relationship management. Handles complete work item lifecycle including assignees, labels, validation, - and related model updates. Supports dynamic field expansion and HTML content processing. + and related model updates. Supports dynamic field expansion and HTML content + processing. """ assignees = serializers.ListField( - child=serializers.PrimaryKeyRelatedField( - queryset=User.objects.values_list("id", flat=True) - ), + child=serializers.PrimaryKeyRelatedField(queryset=User.objects.values_list("id", flat=True)), write_only=True, required=False, ) labels = serializers.ListField( - child=serializers.PrimaryKeyRelatedField( - queryset=Label.objects.values_list("id", flat=True) - ), + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.values_list("id", flat=True)), write_only=True, required=False, ) @@ -89,13 +86,9 @@ class IssueSerializer(BaseSerializer): # Validate description content for security if data.get("description_html"): - is_valid, error_msg, sanitized_html = validate_html_content( - data["description_html"] - ) + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) if not is_valid: - raise serializers.ValidationError( - {"error": "html content is not valid"} - ) + raise serializers.ValidationError({"error": "html content is not valid"}) # Update the data with sanitized HTML if available if sanitized_html is not None: data["description_html"] = sanitized_html @@ -103,9 +96,7 @@ class IssueSerializer(BaseSerializer): if data.get("description_binary"): is_valid, error_msg = validate_binary_data(data["description_binary"]) if not is_valid: - raise serializers.ValidationError( - {"description_binary": "Invalid binary data"} - ) + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) # Validate assignees are from project if data.get("assignees", []): @@ -125,13 +116,9 @@ class IssueSerializer(BaseSerializer): # Check state is from the project only else raise validation error if ( data.get("state") - and not State.objects.filter( - project_id=self.context.get("project_id"), pk=data.get("state").id - ).exists() + and not State.objects.filter(project_id=self.context.get("project_id"), pk=data.get("state").id).exists() ): - raise serializers.ValidationError( - "State is not valid please pass a valid state_id" - ) + raise serializers.ValidationError("State is not valid please pass a valid state_id") # Check parent issue is from workspace as it can be cross workspace if ( @@ -142,9 +129,7 @@ class IssueSerializer(BaseSerializer): pk=data.get("parent").id, ).exists() ): - raise serializers.ValidationError( - "Parent is not valid issue_id please pass a valid issue_id" - ) + raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id") if ( data.get("estimate_point") @@ -154,9 +139,7 @@ class IssueSerializer(BaseSerializer): pk=data.get("estimate_point").id, ).exists() ): - raise serializers.ValidationError( - "Estimate point is not valid please pass a valid estimate_point_id" - ) + raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") return data @@ -172,14 +155,10 @@ class IssueSerializer(BaseSerializer): if not issue_type: # Get default issue type - issue_type = IssueType.objects.filter( - project_issue_types__project_id=project_id, is_default=True - ).first() + issue_type = IssueType.objects.filter(project_issue_types__project_id=project_id, is_default=True).first() issue_type = issue_type - issue = Issue.objects.create( - **validated_data, project_id=project_id, type=issue_type - ) + issue = Issue.objects.create(**validated_data, project_id=project_id, type=issue_type) # Issue Audit Users created_by_id = issue.created_by_id @@ -311,35 +290,26 @@ class IssueSerializer(BaseSerializer): data["assignees"] = UserLiteSerializer( User.objects.filter( - pk__in=IssueAssignee.objects.filter(issue=instance).values_list( - "assignee_id", flat=True - ) + pk__in=IssueAssignee.objects.filter(issue=instance).values_list("assignee_id", flat=True) ), many=True, ).data else: data["assignees"] = [ str(assignee) - for assignee in IssueAssignee.objects.filter( - issue=instance - ).values_list("assignee_id", flat=True) + 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( Label.objects.filter( - pk__in=IssueLabel.objects.filter(issue=instance).values_list( - "label_id", flat=True - ) + pk__in=IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True) ), many=True, ).data else: data["labels"] = [ - str(label) - for label in IssueLabel.objects.filter(issue=instance).values_list( - "label_id", flat=True - ) + str(label) for label in IssueLabel.objects.filter(issue=instance).values_list("label_id", flat=True) ] return data @@ -451,12 +421,8 @@ class IssueLinkCreateSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): - if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") - ).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) + if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) return IssueLink.objects.create(**validated_data) @@ -477,15 +443,11 @@ class IssueLinkUpdateSerializer(IssueLinkCreateSerializer): def update(self, instance, validated_data): if ( - IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=instance.issue_id - ) + IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id) .exclude(pk=instance.id) .exists() ): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) return super().update(instance, validated_data) @@ -676,17 +638,13 @@ class IssueExpandSerializer(BaseSerializer): expand = self.context.get("expand", []) if "labels" in expand: # Use prefetched data - return LabelLiteSerializer( - [il.label for il in obj.label_issue.all()], many=True - ).data + return LabelLiteSerializer([il.label for il in obj.label_issue.all()], many=True).data return [il.label_id for il in obj.label_issue.all()] def get_assignees(self, obj): expand = self.context.get("expand", []) if "assignees" in expand: - return UserLiteSerializer( - [ia.assignee for ia in obj.issue_assignee.all()], many=True - ).data + return UserLiteSerializer([ia.assignee for ia in obj.issue_assignee.all()], many=True).data return [ia.assignee_id for ia in obj.issue_assignee.all()] class Meta: @@ -734,8 +692,6 @@ class IssueSearchSerializer(serializers.Serializer): id = serializers.CharField(required=True, help_text="Issue ID") name = serializers.CharField(required=True, help_text="Issue name") sequence_id = serializers.CharField(required=True, help_text="Issue sequence ID") - project__identifier = serializers.CharField( - required=True, help_text="Project identifier" - ) + project__identifier = serializers.CharField(required=True, help_text="Project identifier") project_id = serializers.CharField(required=True, help_text="Project ID") workspace__slug = serializers.CharField(required=True, help_text="Workspace slug") diff --git a/apps/api/plane/api/serializers/module.py b/apps/api/plane/api/serializers/module.py index 167386997..77be453c8 100644 --- a/apps/api/plane/api/serializers/module.py +++ b/apps/api/plane/api/serializers/module.py @@ -17,8 +17,9 @@ class ModuleCreateSerializer(BaseSerializer): """ Serializer for creating modules with member validation and date checking. - Handles module creation including member assignment validation, date range verification, - and duplicate name prevention for feature-based project organization setup. + Handles module creation including member assignment validation, date range + verification, and duplicate name prevention for feature-based + project organization setup. """ members = serializers.ListField( @@ -75,9 +76,15 @@ class ModuleCreateSerializer(BaseSerializer): module_name = validated_data.get("name") if module_name: # Lookup for the module name in the module table for that project - if Module.objects.filter(name=module_name, project_id=project_id).exists(): + module = Module.objects.filter(name=module_name, project_id=project_id).first() + if module: raise serializers.ValidationError( - {"error": "Module with this name already exists"} + { + "id": str(module.id), + "code": "MODULE_NAME_ALREADY_EXISTS", + "error": "Module with this name already exists", + "message": "Module with this name already exists", + } ) module = Module.objects.create(**validated_data, project_id=project_id) @@ -105,8 +112,9 @@ class ModuleUpdateSerializer(ModuleCreateSerializer): """ Serializer for updating modules with enhanced validation and member management. - Extends module creation with update-specific validations including member reassignment, - name conflict checking, and relationship management for module modifications. + Extends module creation with update-specific validations including + member reassignment, name conflict checking, + and relationship management for module modifications. """ class Meta(ModuleCreateSerializer.Meta): @@ -121,14 +129,8 @@ class ModuleUpdateSerializer(ModuleCreateSerializer): module_name = validated_data.get("name") if module_name: # Lookup for the module name in the module table for that project - if ( - Module.objects.filter(name=module_name, project=instance.project) - .exclude(id=instance.id) - .exists() - ): - raise serializers.ValidationError( - {"error": "Module with this name already exists"} - ) + if Module.objects.filter(name=module_name, project=instance.project).exclude(id=instance.id).exists(): + raise serializers.ValidationError({"error": "Module with this name already exists"}) if members is not None: ModuleMember.objects.filter(module=instance).delete() @@ -155,8 +157,8 @@ class ModuleSerializer(BaseSerializer): """ Comprehensive module serializer with work item metrics and member management. - Provides complete module data including work item counts by status, member relationships, - and progress tracking for feature-based project organization. + Provides complete module data including work item counts by status, member + relationships, and progress tracking for feature-based project organization. """ members = serializers.ListField( @@ -238,12 +240,8 @@ class ModuleLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): - if ModuleLink.objects.filter( - url=validated_data.get("url"), module_id=validated_data.get("module_id") - ).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) + if ModuleLink.objects.filter(url=validated_data.get("url"), module_id=validated_data.get("module_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) return ModuleLink.objects.create(**validated_data) diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index d860c46b2..3228c5ad9 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -66,9 +66,7 @@ class ProjectCreateSerializer(BaseSerializer): workspace_id=self.context["workspace_id"], member_id=data.get("project_lead"), ).exists(): - raise serializers.ValidationError( - "Project lead should be a user in the workspace" - ) + raise serializers.ValidationError("Project lead should be a user in the workspace") if data.get("default_assignee", None) is not None: # Check if the default assignee is a member of the workspace @@ -76,9 +74,7 @@ class ProjectCreateSerializer(BaseSerializer): workspace_id=self.context["workspace_id"], member_id=data.get("default_assignee"), ).exists(): - raise serializers.ValidationError( - "Default assignee should be a user in the workspace" - ) + raise serializers.ValidationError("Default assignee should be a user in the workspace") return data @@ -87,14 +83,10 @@ class ProjectCreateSerializer(BaseSerializer): if identifier == "": raise serializers.ValidationError(detail="Project Identifier is required") - if ProjectIdentifier.objects.filter( - name=identifier, workspace_id=self.context["workspace_id"] - ).exists(): + if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists(): raise serializers.ValidationError(detail="Project Identifier is taken") - project = Project.objects.create( - **validated_data, workspace_id=self.context["workspace_id"] - ) + project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"]) return project @@ -119,25 +111,17 @@ class ProjectUpdateSerializer(ProjectCreateSerializer): """Update a project""" if ( validated_data.get("default_state", None) is not None - and not State.objects.filter( - project=instance, id=validated_data.get("default_state") - ).exists() + and not State.objects.filter(project=instance, id=validated_data.get("default_state")).exists() ): # Check if the default state is a state in the project - raise serializers.ValidationError( - "Default state should be a state in the project" - ) + raise serializers.ValidationError("Default state should be a state in the project") if ( validated_data.get("estimate", None) is not None - and not Estimate.objects.filter( - project=instance, id=validated_data.get("estimate") - ).exists() + and not Estimate.objects.filter(project=instance, id=validated_data.get("estimate")).exists() ): # Check if the estimate is a estimate in the project - raise serializers.ValidationError( - "Estimate should be a estimate in the project" - ) + raise serializers.ValidationError("Estimate should be a estimate in the project") return super().update(instance, validated_data) @@ -182,9 +166,7 @@ class ProjectSerializer(BaseSerializer): member_id=data.get("project_lead"), ).exists() ): - raise serializers.ValidationError( - "Project lead should be a user in the workspace" - ) + raise serializers.ValidationError("Project lead should be a user in the workspace") # Check default assignee should be a member of the workspace if ( @@ -194,23 +176,17 @@ class ProjectSerializer(BaseSerializer): member_id=data.get("default_assignee"), ).exists() ): - raise serializers.ValidationError( - "Default assignee should be a user in the workspace" - ) + raise serializers.ValidationError("Default assignee should be a user in the workspace") # Validate description content for security if "description_html" in data and data["description_html"]: if isinstance(data["description_html"], dict): - is_valid, error_msg, sanitized_html = validate_html_content( - str(data["description_html"]) - ) + is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"])) # Update the data with sanitized HTML if available if sanitized_html is not None: data["description_html"] = sanitized_html if not is_valid: - raise serializers.ValidationError( - {"error": "html content is not valid"} - ) + raise serializers.ValidationError({"error": "html content is not valid"}) return data @@ -219,14 +195,10 @@ class ProjectSerializer(BaseSerializer): if identifier == "": raise serializers.ValidationError(detail="Project Identifier is required") - if ProjectIdentifier.objects.filter( - name=identifier, workspace_id=self.context["workspace_id"] - ).exists(): + if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists(): raise serializers.ValidationError(detail="Project Identifier is taken") - project = Project.objects.create( - **validated_data, workspace_id=self.context["workspace_id"] - ) + project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"]) _ = ProjectIdentifier.objects.create( name=project.identifier, project=project, diff --git a/apps/api/plane/api/serializers/state.py b/apps/api/plane/api/serializers/state.py index 150c238fc..fc6aac15e 100644 --- a/apps/api/plane/api/serializers/state.py +++ b/apps/api/plane/api/serializers/state.py @@ -14,9 +14,7 @@ class StateSerializer(BaseSerializer): def validate(self, data): # If the default is being provided then make all other states default False if data.get("default", False): - State.objects.filter(project_id=self.context.get("project_id")).update( - default=False - ) + State.objects.filter(project_id=self.context.get("project_id")).update(default=False) return data class Meta: diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index ed187549d..10cad2068 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -1,21 +1,23 @@ +from .asset import urlpatterns as asset_patterns +from .cycle import urlpatterns as cycle_patterns +from .intake import urlpatterns as intake_patterns +from .label import urlpatterns as label_patterns +from .member import urlpatterns as member_patterns +from .module import urlpatterns as module_patterns from .project import urlpatterns as project_patterns from .state import urlpatterns as state_patterns -from .issue import urlpatterns as issue_patterns -from .cycle import urlpatterns as cycle_patterns -from .module import urlpatterns as module_patterns -from .intake import urlpatterns as intake_patterns -from .member import urlpatterns as member_patterns -from .asset import urlpatterns as asset_patterns from .user import urlpatterns as user_patterns +from .work_item import urlpatterns as work_item_patterns urlpatterns = [ *asset_patterns, + *cycle_patterns, + *intake_patterns, + *label_patterns, + *member_patterns, + *module_patterns, *project_patterns, *state_patterns, - *issue_patterns, - *cycle_patterns, - *module_patterns, - *intake_patterns, - *member_patterns, *user_patterns, + *work_item_patterns, ] diff --git a/apps/api/plane/api/urls/intake.py b/apps/api/plane/api/urls/intake.py index 6af4aa4a8..5538467aa 100644 --- a/apps/api/plane/api/urls/intake.py +++ b/apps/api/plane/api/urls/intake.py @@ -14,9 +14,7 @@ urlpatterns = [ ), path( "workspaces//projects//intake-issues//", - IntakeIssueDetailAPIEndpoint.as_view( - http_method_names=["get", "patch", "delete"] - ), + IntakeIssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), name="intake-issue", ), ] diff --git a/apps/api/plane/api/urls/issue.py b/apps/api/plane/api/urls/issue.py deleted file mode 100644 index df64684de..000000000 --- a/apps/api/plane/api/urls/issue.py +++ /dev/null @@ -1,97 +0,0 @@ -from django.urls import path - -from plane.api.views import ( - IssueListCreateAPIEndpoint, - IssueDetailAPIEndpoint, - LabelListCreateAPIEndpoint, - LabelDetailAPIEndpoint, - IssueLinkListCreateAPIEndpoint, - IssueLinkDetailAPIEndpoint, - IssueCommentListCreateAPIEndpoint, - IssueCommentDetailAPIEndpoint, - IssueActivityListAPIEndpoint, - IssueActivityDetailAPIEndpoint, - IssueAttachmentListCreateAPIEndpoint, - IssueAttachmentDetailAPIEndpoint, - WorkspaceIssueAPIEndpoint, - IssueSearchEndpoint, -) - -urlpatterns = [ - path( - "workspaces//issues/search/", - IssueSearchEndpoint.as_view(http_method_names=["get"]), - name="issue-search", - ), - path( - "workspaces//issues/-/", - WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]), - name="issue-by-identifier", - ), - path( - "workspaces//projects//issues/", - IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), - name="issue", - ), - path( - "workspaces//projects//issues//", - IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), - name="issue", - ), - path( - "workspaces//projects//labels/", - LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), - name="label", - ), - path( - "workspaces//projects//labels//", - LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), - name="label", - ), - path( - "workspaces//projects//issues//links/", - IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), - name="link", - ), - path( - "workspaces//projects//issues//links//", - IssueLinkDetailAPIEndpoint.as_view( - http_method_names=["get", "patch", "delete"] - ), - name="link", - ), - path( - "workspaces//projects//issues//comments/", - IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), - name="comment", - ), - path( - "workspaces//projects//issues//comments//", - IssueCommentDetailAPIEndpoint.as_view( - http_method_names=["get", "patch", "delete"] - ), - name="comment", - ), - path( - "workspaces//projects//issues//activities/", - IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]), - name="activity", - ), - path( - "workspaces//projects//issues//activities//", - IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]), - name="activity", - ), - path( - "workspaces//projects//issues//issue-attachments/", - IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), - name="attachment", - ), - path( - "workspaces//projects//issues//issue-attachments//", - IssueAttachmentDetailAPIEndpoint.as_view( - http_method_names=["get", "patch", "delete"] - ), - name="issue-attachment", - ), -] diff --git a/apps/api/plane/api/urls/label.py b/apps/api/plane/api/urls/label.py new file mode 100644 index 000000000..f7ee57b17 --- /dev/null +++ b/apps/api/plane/api/urls/label.py @@ -0,0 +1,17 @@ +from django.urls import path + +from plane.api.views import LabelListCreateAPIEndpoint, LabelDetailAPIEndpoint + + +urlpatterns = [ + path( + "workspaces//projects//labels/", + LabelListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="label", + ), + path( + "workspaces//projects//labels//", + LabelDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="label", + ), +] diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py index 4cfc5a198..9cf9291aa 100644 --- a/apps/api/plane/api/urls/project.py +++ b/apps/api/plane/api/urls/project.py @@ -19,9 +19,7 @@ urlpatterns = [ ), path( "workspaces//projects//archive/", - ProjectArchiveUnarchiveAPIEndpoint.as_view( - http_method_names=["post", "delete"] - ), + ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]), name="project-archive-unarchive", ), ] diff --git a/apps/api/plane/api/urls/work_item.py b/apps/api/plane/api/urls/work_item.py new file mode 100644 index 000000000..7207df957 --- /dev/null +++ b/apps/api/plane/api/urls/work_item.py @@ -0,0 +1,146 @@ +from django.urls import path + +from plane.api.views import ( + IssueListCreateAPIEndpoint, + IssueDetailAPIEndpoint, + IssueLinkListCreateAPIEndpoint, + IssueLinkDetailAPIEndpoint, + IssueCommentListCreateAPIEndpoint, + IssueCommentDetailAPIEndpoint, + IssueActivityListAPIEndpoint, + IssueActivityDetailAPIEndpoint, + IssueAttachmentListCreateAPIEndpoint, + IssueAttachmentDetailAPIEndpoint, + WorkspaceIssueAPIEndpoint, + IssueSearchEndpoint, +) + +# Deprecated url patterns +old_url_patterns = [ + path( + "workspaces//issues/search/", + IssueSearchEndpoint.as_view(http_method_names=["get"]), + name="issue-search", + ), + path( + "workspaces//issues/-/", + WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]), + name="issue-by-identifier", + ), + path( + "workspaces//projects//issues/", + IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="issue", + ), + path( + "workspaces//projects//issues//", + IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="issue", + ), + path( + "workspaces//projects//issues//links/", + IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="link", + ), + path( + "workspaces//projects//issues//links//", + IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="link", + ), + path( + "workspaces//projects//issues//comments/", + IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="comment", + ), + path( + "workspaces//projects//issues//comments//", + IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="comment", + ), + path( + "workspaces//projects//issues//activities/", + IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]), + name="activity", + ), + path( + "workspaces//projects//issues//activities//", + IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]), + name="activity", + ), + path( + "workspaces//projects//issues//issue-attachments/", + IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="attachment", + ), + path( + "workspaces//projects//issues//issue-attachments//", + IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="issue-attachment", + ), +] + +# New url patterns with work-items as the prefix +new_url_patterns = [ + path( + "workspaces//work-items/search/", + IssueSearchEndpoint.as_view(http_method_names=["get"]), + name="work-item-search", + ), + path( + "workspaces//work-items/-/", + WorkspaceIssueAPIEndpoint.as_view(http_method_names=["get"]), + name="work-item-by-identifier", + ), + path( + "workspaces//projects//work-items/", + IssueListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-list", + ), + path( + "workspaces//projects//work-items//", + IssueDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-detail", + ), + path( + "workspaces//projects//work-items//links/", + IssueLinkListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-link-list", + ), + path( + "workspaces//projects//work-items//links//", + IssueLinkDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-link-detail", + ), + path( + "workspaces//projects//work-items//comments/", + IssueCommentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-comment-list", + ), + path( + "workspaces//projects//work-items//comments//", + IssueCommentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-comment-detail", + ), + path( + "workspaces//projects//work-items//activities/", + IssueActivityListAPIEndpoint.as_view(http_method_names=["get"]), + name="work-item-activity-list", + ), + path( + "workspaces//projects//work-items//activities//", + IssueActivityDetailAPIEndpoint.as_view(http_method_names=["get"]), + name="work-item-activity-detail", + ), + path( + "workspaces//projects//work-items//attachments/", + IssueAttachmentListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-attachment-list", + ), + path( + "workspaces//projects//work-items//attachments//", + IssueAttachmentDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="work-item-attachment-detail", + ), +] + +urlpatterns = old_url_patterns + new_url_patterns diff --git a/apps/api/plane/api/views/asset.py b/apps/api/plane/api/views/asset.py index 2e668c15d..a91ebc883 100644 --- a/apps/api/plane/api/views/asset.py +++ b/apps/api/plane/api/views/asset.py @@ -8,7 +8,7 @@ from django.conf import settings # Third party imports from rest_framework import status from rest_framework.response import Response -from drf_spectacular.utils import OpenApiExample, OpenApiRequest, OpenApiTypes +from drf_spectacular.utils import OpenApiExample, OpenApiRequest # Module Imports from plane.bgtasks.storage_metadata_task import get_asset_object_metadata @@ -158,9 +158,7 @@ class UserAssetEndpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -236,9 +234,7 @@ class UserAssetEndpoint(BaseAPIView): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -282,8 +278,9 @@ class UserServerAssetEndpoint(BaseAPIView): def post(self, request): """Generate presigned URL for user server asset upload. - Create a presigned URL for uploading user profile assets (avatar or cover image) using server credentials. - This endpoint generates the necessary credentials for direct S3 upload with server-side authentication. + Create a presigned URL for uploading user profile assets + (avatar or cover image) using server credentials. This endpoint generates the + necessary credentials for direct S3 upload with server-side authentication. """ # get the asset key name = request.data.get("name") @@ -334,9 +331,7 @@ class UserServerAssetEndpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request, is_server=True) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -388,16 +383,15 @@ class UserServerAssetEndpoint(BaseAPIView): def delete(self, request, asset_id): """Delete user server asset. - Delete a user profile asset (avatar or cover image) using server credentials and remove its reference from the user profile. - This performs a soft delete by marking the asset as deleted and updating the user's profile. + Delete a user profile asset (avatar or cover image) using server credentials and + remove its reference from the user profile. This performs a soft delete by marking the + asset as deleted and updating the user's profile. """ asset = FileAsset.objects.get(id=asset_id, user_id=request.user.id) asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -429,9 +423,7 @@ class GenericAssetEndpoint(BaseAPIView): workspace = Workspace.objects.get(slug=slug) # Get the asset - asset = FileAsset.objects.get( - id=asset_id, workspace_id=workspace.id, is_deleted=False - ) + asset = FileAsset.objects.get(id=asset_id, workspace_id=workspace.id, is_deleted=False) # Check if the asset exists and is uploaded if not asset.is_uploaded: @@ -457,13 +449,9 @@ class GenericAssetEndpoint(BaseAPIView): ) except Workspace.DoesNotExist: - return Response( - {"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Workspace not found"}, status=status.HTTP_404_NOT_FOUND) except FileAsset.DoesNotExist: - return Response( - {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) except Exception as e: log_exception(e) return Response( @@ -565,14 +553,12 @@ class GenericAssetEndpoint(BaseAPIView): created_by=request.user, external_id=external_id, external_source=external_source, - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, # Using ISSUE_ATTACHMENT since we'll bind it to issues # noqa: E501 ) # Get the presigned URL storage = S3Storage(request=request, is_server=True) - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) return Response( { @@ -611,9 +597,7 @@ class GenericAssetEndpoint(BaseAPIView): and trigger metadata extraction. """ try: - asset = FileAsset.objects.get( - id=asset_id, workspace__slug=slug, is_deleted=False - ) + asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug, is_deleted=False) # Update is_uploaded status asset.is_uploaded = request.data.get("is_uploaded", asset.is_uploaded) @@ -626,6 +610,4 @@ class GenericAssetEndpoint(BaseAPIView): return Response(status=status.HTTP_204_NO_CONTENT) except FileAsset.DoesNotExist: - return Response( - {"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/apps/api/plane/api/views/base.py b/apps/api/plane/api/views/base.py index ea5bcba02..b3acbab36 100644 --- a/apps/api/plane/api/views/base.py +++ b/apps/api/plane/api/views/base.py @@ -37,9 +37,7 @@ class TimezoneMixin: timezone.deactivate() -class BaseAPIView( - TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator -): +class BaseAPIView(TimezoneMixin, GenericAPIView, ReadReplicaControlMixin, BasePaginator): authentication_classes = [APIKeyAuthentication] permission_classes = [IsAuthenticated] @@ -56,9 +54,7 @@ class BaseAPIView( api_key = self.request.headers.get("X-Api-Key") if api_key: - service_token = APIToken.objects.filter( - token=api_key, is_service=True - ).first() + service_token = APIToken.objects.filter(token=api_key, is_service=True).first() if service_token: throttle_classes.append(ServiceTokenRateThrottle()) @@ -113,9 +109,7 @@ class BaseAPIView( if settings.DEBUG: from django.db import connection - print( - f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" - ) + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") return response except Exception as exc: response = self.handle_exception(exc) @@ -151,14 +145,10 @@ class BaseAPIView( @property def fields(self): - fields = [ - field for field in self.request.GET.get("fields", "").split(",") if field - ] + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] return fields if fields else None @property def expand(self): - expand = [ - expand for expand in self.request.GET.get("expand", "").split(",") if expand - ] + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] return expand if expand else None diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py index e10d3d16e..1908ceada 100644 --- a/apps/api/plane/api/views/cycle.py +++ b/apps/api/plane/api/views/cycle.py @@ -171,7 +171,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): @cycle_docs( operation_id="list_cycles", summary="List cycles", - description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.", + description="Retrieve all cycles in a project. Supports filtering by cycle status like current, upcoming, completed, or draft.", # noqa: E501 parameters=[ CURSOR_PARAMETER, PER_PAGE_PARAMETER, @@ -201,9 +201,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): # Current Cycle if cycle_view == "current": - queryset = queryset.filter( - start_date__lte=timezone.now(), end_date__gte=timezone.now() - ) + queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now()) data = CycleSerializer( queryset, many=True, @@ -260,9 +258,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): # Incomplete Cycles if cycle_view == "incomplete": - queryset = queryset.filter( - Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True) - ) + queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)) return self.paginate( request=request, queryset=(queryset), @@ -289,7 +285,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): @cycle_docs( operation_id="create_cycle", summary="Create cycle", - description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.", + description="Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes.", # noqa: E501 request=OpenApiRequest( request=CycleCreateSerializer, examples=[CYCLE_CREATE_EXAMPLE], @@ -308,14 +304,11 @@ class CycleListCreateAPIEndpoint(BaseAPIView): Create a new development cycle with specified name, description, and date range. Supports external ID tracking for integration purposes. """ - if ( - request.data.get("start_date", None) is None - and request.data.get("end_date", None) is None - ) or ( - request.data.get("start_date", None) is not None - and request.data.get("end_date", None) is not None + if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or ( + request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleCreateSerializer(data=request.data) + + serializer = CycleCreateSerializer(data=request.data, context={"request": request}) if serializer.is_valid(): if ( request.data.get("external_id") @@ -340,7 +333,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): }, status=status.HTTP_409_CONFLICT, ) - serializer.save(project_id=project_id, owned_by=request.user) + serializer.save(project_id=project_id) # Send the model activity model_activity.delay( model_name="cycle", @@ -358,9 +351,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( - { - "error": "Both start date and end date are either required or are to be null" - }, + {"error": "Both start date and end date are either required or are to be null"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -487,7 +478,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): @cycle_docs( operation_id="update_cycle", summary="Update cycle", - description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.", + description="Modify an existing cycle's properties like name, description, or date range. Completed cycles can only have their sort order changed.", # noqa: E501 request=OpenApiRequest( request=CycleUpdateSerializer, examples=[CYCLE_UPDATE_EXAMPLE], @@ -508,9 +499,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): """ cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - current_instance = json.dumps( - CycleSerializer(cycle).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder) if cycle.archived_at: return Response( @@ -523,18 +512,14 @@ class CycleDetailAPIEndpoint(BaseAPIView): if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order - request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) - } + request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)} else: return Response( - { - "error": "The Cycle has already been completed so it cannot be edited" - }, + {"error": "The Cycle has already been completed so it cannot be edited"}, status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True) + serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request}) if serializer.is_valid(): if ( request.data.get("external_id") @@ -542,9 +527,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): and Cycle.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get( - "external_source", cycle.external_source - ), + external_source=request.data.get("external_source", cycle.external_source), external_id=request.data.get("external_id"), ).exists() ): @@ -601,11 +584,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) - cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) - ) + cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True)) issue_activity.delay( type="cycle.activity.deleted", @@ -625,9 +604,7 @@ class CycleDetailAPIEndpoint(BaseAPIView): # Delete the cycle cycle.delete() # Delete the user favorite cycle - UserFavorite.objects.filter( - entity_type="cycle", entity_identifier=pk, project_id=project_id - ).delete() + UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -765,15 +742,13 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(self.get_queryset()), - on_results=lambda cycles: CycleSerializer( - cycles, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda cycles: CycleSerializer(cycles, many=True, fields=self.fields, expand=self.expand).data, ) @cycle_docs( operation_id="archive_cycle", summary="Archive cycle", - description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.", + description="Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived.", # noqa: E501 request={}, responses={ 204: ARCHIVED_RESPONSE, @@ -786,9 +761,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): Move a completed cycle to archived status for historical tracking. Only cycles that have ended can be archived. """ - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) if cycle.end_date >= timezone.now(): return Response( {"error": "Only completed cycles can be archived"}, @@ -819,9 +792,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView): Restore an archived cycle to active status, making it available for regular use. The cycle will reappear in active cycle lists. """ - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) cycle.archived_at = None cycle.save() return Response(status=status.HTTP_204_NO_CONTENT) @@ -884,9 +855,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): # List order_by = request.GET.get("order_by", "created_at") issues = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True - ) + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True) .annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() @@ -923,15 +892,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(issues), - on_results=lambda issues: IssueSerializer( - issues, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, ) @cycle_docs( operation_id="add_cycle_work_items", summary="Add Work Items to Cycle", - description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.", + description="Assign multiple work items to a cycle. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501 request=OpenApiRequest( request=CycleIssueRequestSerializer, examples=[CYCLE_ISSUE_REQUEST_EXAMPLE], @@ -955,22 +922,24 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): if not issues: return Response( - {"error": "Work items are required"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, status=status.HTTP_400_BAD_REQUEST ) - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id) + + if cycle.end_date is not None and cycle.end_date < timezone.now(): + return Response( + { + "code": "CYCLE_COMPLETED", + "message": "The Cycle has already been completed so no new issues can be added", + }, + status=status.HTTP_400_BAD_REQUEST, + ) # Get all CycleWorkItems already created - cycle_issues = list( - CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) - ) - + cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)) existing_issues = [ - str(cycle_issue.issue_id) - for cycle_issue in cycle_issues - if str(cycle_issue.issue_id) in issues + str(cycle_issue.issue_id) for cycle_issue in cycle_issues if str(cycle_issue.issue_id) in issues ] new_issues = list(set(issues) - set(existing_issues)) @@ -1021,9 +990,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView): current_instance=json.dumps( { "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": serializers.serialize( - "json", created_records - ), + "created_cycle_issues": serializers.serialize("json", created_records), } ), epoch=int(timezone.now().timestamp()), @@ -1099,9 +1066,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView): cycle_id=cycle_id, issue_id=issue_id, ) - serializer = CycleIssueSerializer( - cycle_issue, fields=self.fields, expand=self.expand - ) + serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) @cycle_docs( @@ -1154,7 +1119,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): @cycle_docs( operation_id="transfer_cycle_work_items", summary="Transfer cycle work items", - description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.", + description="Move incomplete work items from the current cycle to a new target cycle. Captures progress snapshot and transfers only unfinished work items.", # noqa: E501 request=OpenApiRequest( request=TransferCycleIssueRequestSerializer, examples=[TRANSFER_CYCLE_ISSUE_EXAMPLE], @@ -1207,14 +1172,10 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=new_cycle_id - ).first() + new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first() old_cycle = ( - Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) + Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id) .annotate( total_issues=Count( "issue_cycle", @@ -1324,9 +1285,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): ) ) .values("display_name", "assignee_id", "avatar", "avatar_url") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1353,9 +1312,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): assignee_estimate_distribution = [ { "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), "avatar": item.get("avatar", None), "avatar_url": item.get("avatar_url", None), "total_estimates": item["total_estimates"], @@ -1376,9 +1333,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1445,19 +1400,13 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): ), ), # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, then="assignees__avatar" - ), + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), default=Value(None), output_field=models.CharField(), ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -1484,9 +1433,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): assignee_distribution_data = [ { "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), "avatar": item.get("avatar", None), "avatar_url": item.get("avatar_url", None), "total_issues": item["total_issues"], @@ -1508,11 +1455,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -1558,9 +1501,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): cycle_id=cycle_id, ) - current_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ).first() + current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first() current_cycle.progress_snapshot = { "total_issues": old_cycle.total_issues, @@ -1588,9 +1529,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): return Response( - { - "error": "The cycle where the issues are transferred is already completed" - }, + {"error": "The cycle where the issues are transferred is already completed"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -1614,9 +1553,7 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView): } ) - cycle_issues = CycleIssue.objects.bulk_update( - updated_cycles, ["cycle_id"], batch_size=100 - ) + cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( diff --git a/apps/api/plane/api/views/intake.py b/apps/api/plane/api/views/intake.py index 1ea9c73fd..7a00fa431 100644 --- a/apps/api/plane/api/views/intake.py +++ b/apps/api/plane/api/views/intake.py @@ -62,11 +62,9 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): project_id=self.kwargs.get("project_id"), ).first() - project = Project.objects.get( - workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") - ) + project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")) - if intake is None and not project.intake_view: + if intake is None or not project.intake_view: return IntakeIssue.objects.none() return ( @@ -83,7 +81,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): @intake_docs( operation_id="get_intake_work_items_list", summary="List intake work items", - description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.", + description="Retrieve all work items in the project's intake queue. Returns paginated results when listing all intake work items.", # noqa: E501 parameters=[ WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, @@ -119,7 +117,7 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): @intake_docs( operation_id="create_intake_work_item", summary="Create intake work item", - description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.", + description="Submit a new work item to the project's intake queue for review and triage. Automatically creates the work item with default triage state and tracks activity.", # noqa: E501 parameters=[ WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, @@ -144,22 +142,16 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): Automatically creates the work item with default triage state and tracks activity. """ if not request.data.get("issue", {}).get("name", False): - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) - intake = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() project = Project.objects.get(workspace__slug=slug, pk=project_id) # Intake view if intake is None and not project.intake_view: return Response( - { - "error": "Intake is not enabled for this project enable it through the project's api" - }, + {"error": "Intake is not enabled for this project enable it through the project's api"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -171,17 +163,13 @@ class IntakeIssueListCreateAPIEndpoint(BaseAPIView): "urgent", "none", ]: - return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST) # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), description=request.data.get("issue", {}).get("description", {}), - description_html=request.data.get("issue", {}).get( - "description_html", "

" - ), + description_html=request.data.get("issue", {}).get("description_html", "

"), priority=request.data.get("issue", {}).get("priority", "none"), project_id=project_id, ) @@ -226,11 +214,9 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): project_id=self.kwargs.get("project_id"), ).first() - project = Project.objects.get( - workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id") - ) + project = Project.objects.get(workspace__slug=self.kwargs.get("slug"), pk=self.kwargs.get("project_id")) - if intake is None and not project.intake_view: + if intake is None or not project.intake_view: return IntakeIssue.objects.none() return ( @@ -267,15 +253,13 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): Retrieve details of a specific intake work item. """ intake_issue_queryset = self.get_queryset().get(issue_id=issue_id) - intake_issue_data = IntakeIssueSerializer( - intake_issue_queryset, fields=self.fields, expand=self.expand - ).data + intake_issue_data = IntakeIssueSerializer(intake_issue_queryset, fields=self.fields, expand=self.expand).data return Response(intake_issue_data, status=status.HTTP_200_OK) @intake_docs( operation_id="update_intake_work_item", summary="Update intake work item", - description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.", + description="Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate.", # noqa: E501 parameters=[ WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, @@ -300,18 +284,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): Modify an existing intake work item's properties or status for triage processing. Supports status changes like accept, reject, or mark as duplicate. """ - intake = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() project = Project.objects.get(workspace__slug=slug, pk=project_id) # Intake view if intake is None and not project.intake_view: return Response( - { - "error": "Intake is not enabled for this project enable it through the project's api" - }, + {"error": "Intake is not enabled for this project enable it through the project's api"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -332,9 +312,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): ) # Only project members admins and created_by users can access this endpoint - if project_member.role <= 5 and str(intake_issue.created_by_id) != str( - request.user.id - ): + if project_member.role <= 5 and str(intake_issue.created_by_id) != str(request.user.id): return Response( {"error": "You cannot edit intake work items"}, status=status.HTTP_400_BAD_REQUEST, @@ -349,10 +327,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -373,9 +348,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): if project_member.role <= 5: issue_data = { "name": issue_data.get("name", issue.name), - "description_html": issue_data.get( - "description_html", issue.description_html - ), + "description_html": issue_data.get("description_html", issue.description_html), "description": issue_data.get("description", issue.description), } @@ -401,45 +374,31 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): ) issue_serializer.save() else: - return Response( - issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) # Only project admins and members can edit intake issue attributes if project_member.role > 15: - serializer = IntakeIssueUpdateSerializer( - intake_issue, data=request.data, partial=True - ) - current_instance = json.dumps( - IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder - ) + serializer = IntakeIssueUpdateSerializer(intake_issue, data=request.data, partial=True) + current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder) if serializer.is_valid(): serializer.save() # Update the issue state if the issue is rejected or marked as duplicate if serializer.data["status"] in [-1, 2]: - issue = Issue.objects.get( - pk=issue_id, workspace__slug=slug, project_id=project_id - ) - state = State.objects.filter( - group="cancelled", workspace__slug=slug, project_id=project_id - ).first() + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) + state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first() if state is not None: issue.state = state issue.save() # Update the issue state if it is accepted if serializer.data["status"] in [1]: - issue = Issue.objects.get( - pk=issue_id, workspace__slug=slug, project_id=project_id - ) + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # Update the issue state only if it is in triage state if issue.state.is_triage: # Move to default state - state = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True - ).first() + state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first() if state is not None: issue.state = state issue.save() @@ -461,14 +420,12 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: - return Response( - IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK - ) + return Response(IntakeIssueSerializer(intake_issue).data, status=status.HTTP_200_OK) @intake_docs( operation_id="delete_intake_work_item", summary="Delete intake work item", - description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.", + description="Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet.", # noqa: E501 parameters=[ WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, @@ -484,18 +441,14 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): Permanently remove an intake work item from the triage queue. Also deletes the underlying work item if it hasn't been accepted yet. """ - intake = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() project = Project.objects.get(workspace__slug=slug, pk=project_id) # Intake view if intake is None and not project.intake_view: return Response( - { - "error": "Intake is not enabled for this project enable it through the project's api" - }, + {"error": "Intake is not enabled for this project enable it through the project's api"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -510,9 +463,7 @@ class IntakeIssueDetailAPIEndpoint(BaseAPIView): # Check the issue status if intake_issue.status in [-2, -1, 0, 2]: # Delete the issue also - issue = Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk=issue_id - ).first() + issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).first() if issue.created_by_id != request.user.id and ( not ProjectMember.objects.filter( workspace__slug=slug, diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 489fa4a08..d3686ceea 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -30,12 +30,10 @@ from rest_framework.response import Response # drf-spectacular imports from drf_spectacular.utils import ( extend_schema, - OpenApiParameter, OpenApiResponse, OpenApiExample, OpenApiRequest, ) -from drf_spectacular.types import OpenApiTypes # Module imports from plane.api.serializers import ( @@ -99,7 +97,6 @@ from plane.utils.openapi import ( EXTERNAL_ID_PARAMETER, EXTERNAL_SOURCE_PARAMETER, ORDER_BY_PARAMETER, - SEARCH_PARAMETER, SEARCH_PARAMETER_REQUIRED, LIMIT_PARAMETER, WORKSPACE_SEARCH_PARAMETER, @@ -145,9 +142,8 @@ from plane.utils.openapi import ( ) from plane.bgtasks.work_item_link_task import crawl_work_item_link_title -def user_has_issue_permission( - user_id, project_id, issue=None, allowed_roles=None, allow_creator=True -): + +def user_has_issue_permission(user_id, project_id, issue=None, allowed_roles=None, allow_creator=True): if allow_creator and issue is not None and user_id == issue.created_by_id: return True @@ -272,7 +268,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView): @work_item_docs( operation_id="list_work_items", summary="List work items", - description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.", + description="Retrieve a paginated list of all work items in a project. Supports filtering, ordering, and field selection through query parameters.", # noqa: E501 parameters=[ CURSOR_PARAMETER, PER_PAGE_PARAMETER, @@ -325,9 +321,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView): self.get_queryset() .annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -347,21 +341,14 @@ class IssueListCreateAPIEndpoint(BaseAPIView): ) ) - total_issue_queryset = Issue.issue_objects.filter( - project_id=project_id, workspace__slug=slug - ) + total_issue_queryset = Issue.issue_objects.filter(project_id=project_id, workspace__slug=slug) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order if order_by_param == "priority" else priority_order[::-1] - ) + priority_order = priority_order if order_by_param == "priority" else priority_order[::-1] issue_queryset = issue_queryset.annotate( priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], + *[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)], output_field=CharField(), ) ).order_by("priority_order") @@ -373,17 +360,10 @@ class IssueListCreateAPIEndpoint(BaseAPIView): "-state__name", "-state__group", ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) + state_order = state_order if order_by_param in ["state__name", "state__group"] else state_order[::-1] issue_queryset = issue_queryset.annotate( state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], + *[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)], default=Value(len(state_order)), output_field=CharField(), ) @@ -396,14 +376,8 @@ class IssueListCreateAPIEndpoint(BaseAPIView): "-assignees__first_name", ]: issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" if order_by_param.startswith("-") else "max_values" - ) + max_values=Max(order_by_param[1::] if order_by_param.startswith("-") else order_by_param) + ).order_by("-max_values" if order_by_param.startswith("-") else "max_values") else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -411,9 +385,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView): request=request, queryset=(issue_queryset), total_count_queryset=total_issue_queryset, - on_results=lambda issues: IssueSerializer( - issues, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, ) @work_item_docs( @@ -479,9 +451,7 @@ class IssueListCreateAPIEndpoint(BaseAPIView): serializer.save() # Refetch the issue - issue = Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk=serializer.data["id"] - ).first() + issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=serializer.data["id"]).first() issue.created_at = request.data.get("created_at", timezone.now()) issue.created_by_id = request.data.get("created_by", request.user.id) issue.save(update_fields=["created_at", "created_by"]) @@ -582,7 +552,7 @@ class IssueDetailAPIEndpoint(BaseAPIView): @work_item_docs( operation_id="put_work_item", summary="Update or create work item", - description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.", + description="Update an existing work item identified by external ID and source, or create a new one if it doesn't exist. Requires external_id and external_source parameters for identification.", # noqa: E501 request=OpenApiRequest( request=IssueSerializer, examples=[ISSUE_UPSERT_EXAMPLE], @@ -628,9 +598,7 @@ class IssueDetailAPIEndpoint(BaseAPIView): # Get the current instance of the issue in order to track # changes and dispatch the issue activity - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder) # Get the requested data, encode it as django object and pass it # to serializer to validation @@ -693,16 +661,12 @@ class IssueDetailAPIEndpoint(BaseAPIView): # the issue with the provided data, else return with the # default states given. issue.created_at = request.data.get("created_at", timezone.now()) - issue.created_by_id = request.data.get( - "created_by", request.user.id - ) + issue.created_by_id = request.data.get("created_by", request.user.id) issue.save(update_fields=["created_at", "created_by"]) issue_activity.delay( type="issue.activity.created", - requested_data=json.dumps( - self.request.data, cls=DjangoJSONEncoder - ), + requested_data=json.dumps(self.request.data, cls=DjangoJSONEncoder), actor_id=str(request.user.id), issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), @@ -720,7 +684,7 @@ class IssueDetailAPIEndpoint(BaseAPIView): @work_item_docs( operation_id="update_work_item", summary="Partially update work item", - description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.", + description="Partially update an existing work item with the provided fields. Supports external ID validation to prevent conflicts.", # noqa: E501 parameters=[ PROJECT_ID_PARAMETER, ], @@ -747,9 +711,7 @@ class IssueDetailAPIEndpoint(BaseAPIView): """ issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) project = Project.objects.get(pk=project_id) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueSerializer( issue, @@ -764,9 +726,7 @@ class IssueDetailAPIEndpoint(BaseAPIView): and Issue.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get( - "external_source", issue.external_source - ), + external_source=request.data.get("external_source", issue.external_source), external_id=request.data.get("external_id"), ).exists() ): @@ -794,7 +754,7 @@ class IssueDetailAPIEndpoint(BaseAPIView): @work_item_docs( operation_id="delete_work_item", summary="Delete work item", - description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.", + description="Permanently delete an existing work item from the project. Only admins or the item creator can perform this action.", # noqa: E501 parameters=[ PROJECT_ID_PARAMETER, ], @@ -824,9 +784,7 @@ class IssueDetailAPIEndpoint(BaseAPIView): {"error": "Only admin or creator can delete the work item"}, status=status.HTTP_403_FORBIDDEN, ) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder) issue.delete() issue_activity.delay( type="issue.activity.deleted", @@ -962,9 +920,7 @@ class LabelListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(self.get_queryset()), - on_results=lambda labels: LabelSerializer( - labels, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda labels: LabelSerializer(labels, many=True, fields=self.fields, expand=self.expand).data, ) @@ -1036,9 +992,7 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint): and Label.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get( - "external_source", label.external_source - ), + external_source=request.data.get("external_source", label.external_source), external_id=request.data.get("external_id"), ) .exclude(id=pk) @@ -1165,9 +1119,7 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView): serializer = IssueLinkCreateSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id, issue_id=issue_id) - crawl_work_item_link_title.delay( - serializer.instance.id, serializer.instance.url - ) + crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url) link = IssueLink.objects.get(pk=serializer.instance.id) link.created_by_id = request.data.get("created_by", request.user.id) link.save(update_fields=["created_by"]) @@ -1236,9 +1188,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView): """ if pk is None: issue_links = self.get_queryset() - serializer = IssueLinkSerializer( - issue_links, fields=self.fields, expand=self.expand - ) + serializer = IssueLinkSerializer(issue_links, fields=self.fields, expand=self.expand) return self.paginate( request=request, queryset=(self.get_queryset()), @@ -1247,9 +1197,7 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView): ).data, ) issue_link = self.get_queryset().get(pk=pk) - serializer = IssueLinkSerializer( - issue_link, fields=self.fields, expand=self.expand - ) + serializer = IssueLinkSerializer(issue_link, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) @issue_link_docs( @@ -1279,19 +1227,13 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView): Modify the URL, title, or metadata of an existing issue link. Tracks all changes in issue activity logs. """ - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() - crawl_work_item_link_title.delay( - serializer.data.get("id"), serializer.data.get("url") - ) + crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url")) issue_activity.delay( type="link.activity.updated", requested_data=requested_data, @@ -1323,12 +1265,8 @@ class IssueLinkDetailAPIEndpoint(BaseAPIView): Permanently remove an external link from a work item. Records deletion activity for audit purposes. """ - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder - ) + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) issue_activity.delay( type="link.activity.deleted", requested_data=json.dumps({"link_id": str(pk)}), @@ -1464,15 +1402,12 @@ class IssueCommentListCreateAPIEndpoint(BaseAPIView): serializer = IssueCommentCreateSerializer(data=request.data) if serializer.is_valid(): - serializer.save( - project_id=project_id, issue_id=issue_id, actor=request.user - ) + serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user) issue_comment = IssueComment.objects.get(pk=serializer.instance.id) # Update the created_at and the created_by and save the comment issue_comment.created_at = request.data.get("created_at", timezone.now()) - issue_comment.created_by_id = request.data.get( - "created_by", request.user.id - ) + issue_comment.created_by_id = request.data.get("created_by", request.user.id) + issue_comment.actor_id = request.data.get("created_by", request.user.id) issue_comment.save(update_fields=["created_at", "created_by"]) issue_activity.delay( @@ -1558,9 +1493,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView): Retrieve details of a specific comment. """ issue_comment = self.get_queryset().get(pk=pk) - serializer = IssueCommentSerializer( - issue_comment, fields=self.fields, expand=self.expand - ) + serializer = IssueCommentSerializer(issue_comment, fields=self.fields, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) @issue_comment_docs( @@ -1591,13 +1524,9 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView): Modify the content of an existing comment on a work item. Validates external ID uniqueness if provided. """ - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) # Validation check if the issue already exists if ( @@ -1606,9 +1535,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView): and IssueComment.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get( - "external_source", issue_comment.external_source - ), + external_source=request.data.get("external_source", issue_comment.external_source), external_id=request.data.get("external_id"), ).exists() ): @@ -1620,9 +1547,7 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView): status=status.HTTP_409_CONFLICT, ) - serializer = IssueCommentCreateSerializer( - issue_comment, data=request.data, partial=True - ) + serializer = IssueCommentCreateSerializer(issue_comment, data=request.data, partial=True) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -1668,12 +1593,8 @@ class IssueCommentDetailAPIEndpoint(BaseAPIView): Permanently remove a comment from a work item. Records deletion activity for audit purposes. """ - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder - ) + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) issue_comment.delete() issue_activity.delay( type="comment.activity.deleted", @@ -1720,9 +1641,7 @@ class IssueActivityListAPIEndpoint(BaseAPIView): Excludes comment, vote, reaction, and draft activities. """ issue_activities = ( - IssueActivity.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id - ) + IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id) .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, @@ -1777,9 +1696,7 @@ class IssueActivityDetailAPIEndpoint(BaseAPIView): Excludes comment, vote, reaction, and draft activities. """ issue_activities = ( - IssueActivity.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id - ) + IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id) .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, @@ -1869,12 +1786,8 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): name="Workspace not found", value={"error": "Workspace not found"}, ), - OpenApiExample( - name="Project not found", value={"error": "Project not found"} - ), - OpenApiExample( - name="Issue not found", value={"error": "Issue not found"} - ), + OpenApiExample(name="Project not found", value={"error": "Project not found"}), + OpenApiExample(name="Issue not found", value={"error": "Issue not found"}), ], ), }, @@ -1885,9 +1798,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): Generate presigned URL for uploading file attachments to a work item. Validates file type and size before creating the attachment record. """ - issue = Issue.objects.get( - pk=issue_id, workspace__slug=slug, project_id=project_id - ) + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # if the user is creator or admin,member then allow the upload if not user_has_issue_permission( request.user.id, @@ -1973,9 +1884,7 @@ class IssueAttachmentListCreateAPIEndpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -2035,9 +1944,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): ATTACHMENT_ID_PARAMETER, ], responses={ - 204: OpenApiResponse( - description="Work item attachment deleted successfully" - ), + 204: OpenApiResponse(description="Work item attachment deleted successfully"), 404: ATTACHMENT_NOT_FOUND_RESPONSE, }, ) @@ -2047,9 +1954,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): Soft delete an attachment from a work item by marking it as deleted. Records deletion activity and triggers metadata cleanup. """ - issue = Issue.objects.get( - pk=issue_id, workspace__slug=slug, project_id=project_id - ) + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # if the request user is creator or admin then delete the attachment if not user_has_issue_permission( request.user, @@ -2063,9 +1968,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) - issue_attachment = FileAsset.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id - ) + issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) issue_attachment.is_deleted = True issue_attachment.deleted_at = timezone.now() issue_attachment.save() @@ -2139,9 +2042,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): ) # Get the asset - asset = FileAsset.objects.get( - id=pk, workspace__slug=slug, project_id=project_id - ) + asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) # Check if the asset is uploaded if not asset.is_uploaded: @@ -2179,9 +2080,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): examples=[ATTACHMENT_UPLOAD_CONFIRM_EXAMPLE], ), responses={ - 204: OpenApiResponse( - description="Work item attachment uploaded successfully" - ), + 204: OpenApiResponse(description="Work item attachment uploaded successfully"), 400: INVALID_REQUEST_RESPONSE, 404: ATTACHMENT_NOT_FOUND_RESPONSE, }, @@ -2193,9 +2092,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): Triggers activity logging and metadata extraction. """ - issue = Issue.objects.get( - pk=issue_id, workspace__slug=slug, project_id=project_id - ) + issue = Issue.objects.get(pk=issue_id, workspace__slug=slug, project_id=project_id) # if the user is creator or admin then allow the upload if not user_has_issue_permission( request.user, @@ -2209,9 +2106,7 @@ class IssueAttachmentDetailAPIEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) - issue_attachment = FileAsset.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id - ) + issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) serializer = IssueAttachmentSerializer(issue_attachment) # Send this activity only if the attachment is not uploaded before diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index 8ae662520..f761d5c91 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -74,9 +74,7 @@ class WorkspaceMemberAPIEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - workspace_members = WorkspaceMember.objects.filter( - workspace__slug=slug - ).select_related("member") + workspace_members = WorkspaceMember.objects.filter(workspace__slug=slug).select_related("member") # Get all the users with their roles users_with_roles = [] @@ -125,13 +123,11 @@ class ProjectMemberAPIEndpoint(BaseAPIView): ) # Get the workspace members that are present inside the workspace - project_members = ProjectMember.objects.filter( - project_id=project_id, workspace__slug=slug - ).values_list("member_id", flat=True) + project_members = ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug).values_list( + "member_id", flat=True + ) # Get all the users that are present inside the workspace - users = UserLiteSerializer( - User.objects.filter(id__in=project_members), many=True - ).data + users = UserLiteSerializer(User.objects.filter(id__in=project_members), many=True).data return Response(users, status=status.HTTP_200_OK) diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py index 63112cd66..d79b94084 100644 --- a/apps/api/plane/api/views/module.py +++ b/apps/api/plane/api/views/module.py @@ -10,7 +10,7 @@ from django.core.serializers.json import DjangoJSONEncoder # Third party imports from rest_framework import status from rest_framework.response import Response -from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest # Module imports from plane.api.serializers import ( @@ -41,8 +41,6 @@ from plane.utils.host import base_host from plane.utils.openapi import ( module_docs, module_issue_docs, - WORKSPACE_SLUG_PARAMETER, - PROJECT_ID_PARAMETER, MODULE_ID_PARAMETER, MODULE_PK_PARAMETER, ISSUE_ID_PARAMETER, @@ -396,9 +394,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView): examples=[MODULE_UPDATE_EXAMPLE], ), 404: OpenApiResponse(description="Module not found"), - 409: OpenApiResponse( - description="Module with same external ID already exists" - ), + 409: OpenApiResponse(description="Module with same external ID already exists"), }, ) def patch(self, request, slug, project_id, pk): @@ -409,18 +405,14 @@ class ModuleDetailAPIEndpoint(BaseAPIView): """ module = Module.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) - current_instance = json.dumps( - ModuleSerializer(module).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(ModuleSerializer(module).data, cls=DjangoJSONEncoder) if module.archived_at: return Response( {"error": "Archived module cannot be edited"}, status=status.HTTP_400_BAD_REQUEST, ) - serializer = ModuleSerializer( - module, data=request.data, context={"project_id": project_id}, partial=True - ) + serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True) if serializer.is_valid(): if ( request.data.get("external_id") @@ -428,9 +420,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView): and Module.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get( - "external_source", module.external_source - ), + external_source=request.data.get("external_source", module.external_source), external_id=request.data.get("external_id"), ).exists() ): @@ -516,9 +506,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) - module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) - ) + module_issues = list(ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)) issue_activity.delay( type="module.activity.deleted", requested_data=json.dumps( @@ -539,9 +527,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView): # Delete the module issues ModuleIssue.objects.filter(module=pk, project_id=project_id).delete() # Delete the user favorite module - UserFavorite.objects.filter( - entity_type="module", entity_identifier=pk, project_id=project_id - ).delete() + UserFavorite.objects.filter(entity_type="module", entity_identifier=pk, project_id=project_id).delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -611,9 +597,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView): """ order_by = request.GET.get("order_by", "created_at") issues = ( - Issue.issue_objects.filter( - issue_module__module_id=module_id, issue_module__deleted_at__isnull=True - ) + Issue.issue_objects.filter(issue_module__module_id=module_id, issue_module__deleted_at__isnull=True) .annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() @@ -649,15 +633,13 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(issues), - on_results=lambda issues: IssueSerializer( - issues, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, ) @module_issue_docs( operation_id="add_module_work_items", summary="Add Work Items to Module", - description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.", + description="Assign multiple work items to a module or move them from another module. Automatically handles bulk creation and updates with activity tracking.", # noqa: E501 parameters=[ MODULE_ID_PARAMETER, ], @@ -683,16 +665,12 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView): """ issues = request.data.get("issues", []) if not len(issues): - return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST - ) - module = Module.objects.get( - workspace__slug=slug, project_id=project_id, pk=module_id - ) + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) + module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=module_id) - issues = Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk__in=issues - ).values_list("id", flat=True) + issues = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issues).values_list( + "id", flat=True + ) module_issues = list(ModuleIssue.objects.filter(issue_id__in=issues)) @@ -701,11 +679,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView): record_to_create = [] for issue in issues: - module_issue = [ - module_issue - for module_issue in module_issues - if str(module_issue.issue_id) in issues - ] + module_issue = [module_issue for module_issue in module_issues if str(module_issue.issue_id) in issues] if len(module_issue): if module_issue[0].module_id != module_id: @@ -730,9 +704,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView): ) ) - ModuleIssue.objects.bulk_create( - record_to_create, batch_size=10, ignore_conflicts=True - ) + ModuleIssue.objects.bulk_create(record_to_create, batch_size=10, ignore_conflicts=True) ModuleIssue.objects.bulk_update(records_to_update, ["module"], batch_size=10) @@ -746,9 +718,7 @@ class ModuleIssueListCreateAPIEndpoint(BaseAPIView): current_instance=json.dumps( { "updated_module_issues": update_module_issue_activity, - "created_module_issues": serializers.serialize( - "json", record_to_create - ), + "created_module_issues": serializers.serialize("json", record_to_create), } ), epoch=int(timezone.now().timestamp()), @@ -873,9 +843,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(issues), - on_results=lambda issues: IssueSerializer( - issues, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda issues: IssueSerializer(issues, many=True, fields=self.fields, expand=self.expand).data, ) @module_issue_docs( @@ -906,9 +874,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView): module_issue.delete() issue_activity.delay( type="module.activity.deleted", - requested_data=json.dumps( - {"module_id": str(module_id), "issues": [str(module_issue.issue_id)]} - ), + requested_data=json.dumps({"module_id": str(module_id), "issues": [str(module_issue.issue_id)]}), actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index da946e3c3..131932bf2 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -11,7 +11,7 @@ from django.core.serializers.json import DjangoJSONEncoder from rest_framework import status from rest_framework.response import Response from rest_framework.serializers import ValidationError -from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest # Module imports @@ -79,9 +79,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): ) | Q(network=2) ) - .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" - ) + .select_related("project_lead") .annotate( is_member=Exists( ProjectMember.objects.filter( @@ -170,9 +168,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): .prefetch_related( Prefetch( "project_projectmember", - queryset=ProjectMember.objects.filter( - workspace__slug=slug, is_active=True - ).select_related("member"), + queryset=ProjectMember.objects.filter(workspace__slug=slug, is_active=True).select_related( + "member" + ), ) ) .order_by(request.GET.get("order_by", "sort_order")) @@ -211,24 +209,18 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): """ try: workspace = Workspace.objects.get(slug=slug) - serializer = ProjectCreateSerializer( - data={**request.data}, context={"workspace_id": workspace.id} - ) + serializer = ProjectCreateSerializer(data={**request.data}, context={"workspace_id": workspace.id}) if serializer.is_valid(): serializer.save() # Add the user as Administrator to the project - _ = ProjectMember.objects.create( - project_id=serializer.instance.id, member=request.user, role=20 - ) + _ = ProjectMember.objects.create(project_id=serializer.instance.id, member=request.user, role=20) # Also create the issue property for the user - _ = IssueUserProperty.objects.create( - project_id=serializer.instance.id, user=request.user - ) + _ = IssueUserProperty.objects.create(project_id=serializer.instance.id, user=request.user) - if serializer.instance.project_lead is not None and str( - serializer.instance.project_lead - ) != str(request.user.id): + if serializer.instance.project_lead is not None and str(serializer.instance.project_lead) != str( + request.user.id + ): ProjectMember.objects.create( project_id=serializer.instance.id, member_id=serializer.instance.project_lead, @@ -314,9 +306,7 @@ class ProjectListCreateAPIEndpoint(BaseAPIView): status=status.HTTP_409_CONFLICT, ) except Workspace.DoesNotExist: - return Response( - {"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Workspace does not exist"}, status=status.HTTP_404_NOT_FOUND) except ValidationError: return Response( {"identifier": "The project identifier is already taken"}, @@ -344,9 +334,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView): ) | Q(network=2) ) - .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" - ) + .select_related("workspace", "workspace__owner", "default_assignee", "project_lead") .annotate( is_member=Exists( ProjectMember.objects.filter( @@ -451,9 +439,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView): try: workspace = Workspace.objects.get(slug=slug) project = Project.objects.get(pk=pk) - current_instance = json.dumps( - ProjectSerializer(project).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder) intake_view = request.data.get("intake_view", project.intake_view) @@ -473,9 +459,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView): if serializer.is_valid(): serializer.save() if serializer.data["intake_view"]: - intake = Intake.objects.filter( - project=project, is_default=True - ).first() + intake = Intake.objects.filter(project=project, is_default=True).first() if not intake: Intake.objects.create( name=f"{project.name} Intake", @@ -505,9 +489,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView): status=status.HTTP_409_CONFLICT, ) except (Project.DoesNotExist, Workspace.DoesNotExist): - return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND) except ValidationError: return Response( {"identifier": "The project identifier is already taken"}, @@ -533,9 +515,7 @@ class ProjectDetailAPIEndpoint(BaseAPIView): """ project = Project.objects.get(pk=pk, workspace__slug=slug) # Delete the user favorite cycle - UserFavorite.objects.filter( - entity_type="project", entity_identifier=pk, project_id=pk - ).delete() + UserFavorite.objects.filter(entity_type="project", entity_identifier=pk, project_id=pk).delete() project.delete() webhook_activity.delay( event="project", diff --git a/apps/api/plane/api/views/state.py b/apps/api/plane/api/views/state.py index 7b5d842de..bd91de39a 100644 --- a/apps/api/plane/api/views/state.py +++ b/apps/api/plane/api/views/state.py @@ -4,7 +4,7 @@ from django.db import IntegrityError # Third party imports from rest_framework import status from rest_framework.response import Response -from drf_spectacular.utils import OpenApiResponse, OpenApiExample, OpenApiRequest +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest # Module imports from plane.api.serializers import StateSerializer @@ -80,9 +80,7 @@ class StateListCreateAPIEndpoint(BaseAPIView): Supports external ID tracking for integration purposes. """ try: - serializer = StateSerializer( - data=request.data, context={"project_id": project_id} - ) + serializer = StateSerializer(data=request.data, context={"project_id": project_id}) if serializer.is_valid(): if ( request.data.get("external_id") @@ -153,9 +151,7 @@ class StateListCreateAPIEndpoint(BaseAPIView): return self.paginate( request=request, queryset=(self.get_queryset()), - on_results=lambda states: StateSerializer( - states, many=True, fields=self.fields, expand=self.expand - ).data, + on_results=lambda states: StateSerializer(states, many=True, fields=self.fields, expand=self.expand).data, ) @@ -213,7 +209,7 @@ class StateDetailAPIEndpoint(BaseAPIView): @state_docs( operation_id="delete_state", summary="Delete state", - description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.", + description="Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted.", # noqa: E501 parameters=[ STATE_ID_PARAMETER, ], @@ -228,9 +224,7 @@ class StateDetailAPIEndpoint(BaseAPIView): Permanently remove a workflow state from a project. Default states and states with existing work items cannot be deleted. """ - state = State.objects.get( - is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug - ) + state = State.objects.get(is_triage=False, pk=state_id, project_id=project_id, workspace__slug=slug) if state.default: return Response( @@ -277,9 +271,7 @@ class StateDetailAPIEndpoint(BaseAPIView): Partially update an existing workflow state's properties like name, color, or group. Validates external ID uniqueness if provided. """ - state = State.objects.get( - workspace__slug=slug, project_id=project_id, pk=state_id - ) + state = State.objects.get(workspace__slug=slug, project_id=project_id, pk=state_id) serializer = StateSerializer(state, data=request.data, partial=True) if serializer.is_valid(): if ( @@ -288,9 +280,7 @@ class StateDetailAPIEndpoint(BaseAPIView): and State.objects.filter( project_id=project_id, workspace__slug=slug, - external_source=request.data.get( - "external_source", state.external_source - ), + external_source=request.data.get("external_source", state.external_source), external_id=request.data.get("external_id"), ).exists() ): diff --git a/apps/api/plane/app/permissions/__init__.py b/apps/api/plane/app/permissions/__init__.py index b7a095e74..95ee038e1 100644 --- a/apps/api/plane/app/permissions/__init__.py +++ b/apps/api/plane/app/permissions/__init__.py @@ -13,3 +13,4 @@ from .project import ( ProjectLitePermission, ) from .base import allow_permission, ROLE +from .page import ProjectPagePermission diff --git a/apps/api/plane/app/permissions/base.py b/apps/api/plane/app/permissions/base.py index 7ba12a2e2..a2b1a18ff 100644 --- a/apps/api/plane/app/permissions/base.py +++ b/apps/api/plane/app/permissions/base.py @@ -18,16 +18,12 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): def _wrapped_view(instance, request, *args, **kwargs): # Check for creator if required if creator and model: - obj = model.objects.filter( - id=kwargs["pk"], created_by=request.user - ).exists() + obj = model.objects.filter(id=kwargs["pk"], created_by=request.user).exists() if obj: return view_func(instance, request, *args, **kwargs) # Convert allowed_roles to their values if they are enum members - allowed_role_values = [ - role.value if isinstance(role, ROLE) else role for role in allowed_roles - ] + allowed_role_values = [role.value if isinstance(role, ROLE) else role for role in allowed_roles] # Check role permissions if level == "WORKSPACE": @@ -39,13 +35,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): ).exists(): return view_func(instance, request, *args, **kwargs) else: - if ProjectMember.objects.filter( + is_user_has_allowed_role = ProjectMember.objects.filter( member=request.user, workspace__slug=kwargs["slug"], project_id=kwargs["project_id"], role__in=allowed_role_values, is_active=True, - ).exists(): + ).exists() + + # Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role # noqa: E501 + if is_user_has_allowed_role: + return view_func(instance, request, *args, **kwargs) + elif ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + is_active=True, + ).exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ): return view_func(instance, request, *args, **kwargs) # Return permission denied if no conditions are met diff --git a/apps/api/plane/app/permissions/page.py b/apps/api/plane/app/permissions/page.py new file mode 100644 index 000000000..bea878f4c --- /dev/null +++ b/apps/api/plane/app/permissions/page.py @@ -0,0 +1,121 @@ +from plane.db.models import ProjectMember, Page +from plane.app.permissions import ROLE + + +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +# Permission Mappings for workspace members +ADMIN = ROLE.ADMIN.value +MEMBER = ROLE.MEMBER.value +GUEST = ROLE.GUEST.value + + +class ProjectPagePermission(BasePermission): + """ + Custom permission to control access to pages within a workspace + based on user roles, page visibility (public/private), and feature flags. + """ + + def has_permission(self, request, view): + """ + Check basic project-level permissions before checking object-level permissions. + """ + if request.user.is_anonymous: + return False + + user_id = request.user.id + slug = view.kwargs.get("slug") + page_id = view.kwargs.get("page_id") + project_id = view.kwargs.get("project_id") + + # Hook for extended validation + extended_access, role = self._check_access_and_get_role(request, slug, project_id) + if extended_access is False: + return False + + if page_id: + page = Page.objects.get(id=page_id, workspace__slug=slug) + + # Allow access if the user is the owner of the page + if page.owned_by_id == user_id: + return True + + # Handle private page access + if page.access == Page.PRIVATE_ACCESS: + return self._has_private_page_action_access(request, slug, page, project_id) + + # Handle public page access + return self._has_public_page_action_access(request, role) + + def _check_project_member_access(self, request, slug, project_id): + """ + Check if the user is a project member. + """ + return ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + project_id=project_id, + ) + .values_list("role", flat=True) + .first() + ) + + def _check_access_and_get_role(self, request, slug, project_id): + """ + Hook for extended access checking + Returns: True (allow), False (deny), None (continue with normal flow) + """ + role = self._check_project_member_access(request, slug, project_id) + if not role: + return False, None + return True, role + + def _has_private_page_action_access(self, request, slug, page, project_id): + """ + Check access to private pages. Override for feature flag logic. + """ + # Base implementation: only owner can access private pages + return False + + def _check_project_action_access(self, request, role): + method = request.method + + # Only admins can create (POST) pages + if method == "POST": + if role in [ADMIN, MEMBER]: + return True + return False + + # Safe methods (GET, HEAD, OPTIONS) allowed for all active roles + if method in SAFE_METHODS: + if role in [ADMIN, MEMBER, GUEST]: + return True + return False + + # PUT/PATCH: Admins and members can update + if method in ["PUT", "PATCH"]: + if role in [ADMIN, MEMBER]: + return True + return False + + # DELETE: Only admins can delete + if method == "DELETE": + if role in [ADMIN]: + return True + return False + + # Deny by default + return False + + def _has_public_page_action_access(self, request, role): + """ + Check if the user has permission to access a public page + and can perform operations on the page. + """ + project_member_exists = self._check_project_action_access(request, role) + if not project_member_exists: + return False + return True diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index 1596d90b3..e095ffed4 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission # Module import from plane.db.models import ProjectMember, WorkspaceMember - -# Permission Mappings -Admin = 20 -Member = 15 -Guest = 5 +from plane.db.models.project import ROLE class ProjectBasePermission(BasePermission): @@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission): return WorkspaceMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], is_active=True, ).exists() - ## Only Project Admins can update project attributes - return ProjectMember.objects.filter( + project_member_qs = ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role=Admin, project_id=view.project_id, is_active=True, - ).exists() + ) + + ## Only project admins or workspace admin who is part of the project can access + + if project_member_qs.filter(role=ROLE.ADMIN.value).exists(): + return True + else: + return ( + project_member_qs.exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ) class ProjectMemberPermission(BasePermission): @@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission): return WorkspaceMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], is_active=True, ).exists() @@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission): return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], project_id=view.project_id, is_active=True, ).exists() @@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission): return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], project_id=view.project_id, is_active=True, ).exists() diff --git a/apps/api/plane/app/serializers/__init__.py b/apps/api/plane/app/serializers/__init__.py index 0116b2061..18be363cd 100644 --- a/apps/api/plane/app/serializers/__init__.py +++ b/apps/api/plane/app/serializers/__init__.py @@ -92,8 +92,6 @@ from .importer import ImporterSerializer from .page import ( PageSerializer, - PageLogSerializer, - SubPageSerializer, PageDetailSerializer, PageVersionSerializer, PageBinaryUpdateSerializer, diff --git a/apps/api/plane/app/serializers/base.py b/apps/api/plane/app/serializers/base.py index 715ad6eae..0d8c855c9 100644 --- a/apps/api/plane/app/serializers/base.py +++ b/apps/api/plane/app/serializers/base.py @@ -168,13 +168,9 @@ class DynamicBaseSerializer(BaseSerializer): # Check if field in expansion then expand the field if expand in expansion: if isinstance(response.get(expand), list): - exp_serializer = expansion[expand]( - getattr(instance, expand), many=True - ) + exp_serializer = expansion[expand](getattr(instance, expand), many=True) else: - exp_serializer = expansion[expand]( - getattr(instance, expand) - ) + exp_serializer = expansion[expand](getattr(instance, expand)) response[expand] = exp_serializer.data else: # You might need to handle this case differently @@ -194,9 +190,7 @@ class DynamicBaseSerializer(BaseSerializer): entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, ) # Serialize issue_attachments and add them to the response - response["issue_attachments"] = IssueAttachmentLiteSerializer( - issue_attachments, many=True - ).data + response["issue_attachments"] = IssueAttachmentLiteSerializer(issue_attachments, many=True).data else: response["issue_attachments"] = [] diff --git a/apps/api/plane/app/serializers/cycle.py b/apps/api/plane/app/serializers/cycle.py index 2aa2ac7b7..89a5efc06 100644 --- a/apps/api/plane/app/serializers/cycle.py +++ b/apps/api/plane/app/serializers/cycle.py @@ -16,10 +16,7 @@ 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 - ): + if data.get("start_date", None) is not None and data.get("end_date", None) is not None: project_id = ( self.initial_data.get("project_id", None) or (self.instance and self.instance.project_id) diff --git a/apps/api/plane/app/serializers/draft.py b/apps/api/plane/app/serializers/draft.py index 852caf8bf..b017a03ba 100644 --- a/apps/api/plane/app/serializers/draft.py +++ b/apps/api/plane/app/serializers/draft.py @@ -1,5 +1,3 @@ -from lxml import html - # Django imports from django.utils import timezone @@ -76,13 +74,9 @@ class DraftIssueCreateSerializer(BaseSerializer): # Validate description content for security if "description_html" in attrs and attrs["description_html"]: - is_valid, error_msg, sanitized_html = validate_html_content( - attrs["description_html"] - ) + is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"]) if not is_valid: - raise serializers.ValidationError( - {"error": "html content is not valid"} - ) + raise serializers.ValidationError({"error": "html content is not valid"}) # Update the attrs with sanitized HTML if available if sanitized_html is not None: attrs["description_html"] = sanitized_html @@ -90,9 +84,7 @@ class DraftIssueCreateSerializer(BaseSerializer): if "description_binary" in attrs and attrs["description_binary"]: is_valid, error_msg = validate_binary_data(attrs["description_binary"]) if not is_valid: - raise serializers.ValidationError( - {"description_binary": "Invalid binary data"} - ) + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) # Validate assignees are from project if attrs.get("assignee_ids", []): @@ -107,9 +99,9 @@ class DraftIssueCreateSerializer(BaseSerializer): if attrs.get("label_ids"): label_ids = [label.id for label in attrs["label_ids"]] attrs["label_ids"] = list( - Label.objects.filter( - project_id=self.context.get("project_id"), id__in=label_ids - ).values_list("id", flat=True) + Label.objects.filter(project_id=self.context.get("project_id"), id__in=label_ids).values_list( + "id", flat=True + ) ) # # Check state is from the project only else raise validation error @@ -120,9 +112,7 @@ class DraftIssueCreateSerializer(BaseSerializer): pk=attrs.get("state").id, ).exists() ): - raise serializers.ValidationError( - "State is not valid please pass a valid state_id" - ) + raise serializers.ValidationError("State is not valid please pass a valid state_id") # # Check parent issue is from workspace as it can be cross workspace if ( @@ -132,9 +122,7 @@ class DraftIssueCreateSerializer(BaseSerializer): pk=attrs.get("parent").id, ).exists() ): - raise serializers.ValidationError( - "Parent is not valid issue_id please pass a valid issue_id" - ) + raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id") if ( attrs.get("estimate_point") @@ -143,9 +131,7 @@ class DraftIssueCreateSerializer(BaseSerializer): pk=attrs.get("estimate_point").id, ).exists() ): - raise serializers.ValidationError( - "Estimate point is not valid please pass a valid estimate_point_id" - ) + raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") return attrs @@ -160,9 +146,7 @@ class DraftIssueCreateSerializer(BaseSerializer): project_id = self.context["project_id"] # Create Issue - issue = DraftIssue.objects.create( - **validated_data, workspace_id=workspace_id, project_id=project_id - ) + issue = DraftIssue.objects.create(**validated_data, workspace_id=workspace_id, project_id=project_id) # Issue Audit Users created_by_id = issue.created_by_id diff --git a/apps/api/plane/app/serializers/favorite.py b/apps/api/plane/app/serializers/favorite.py index 940b8ee82..246461f8f 100644 --- a/apps/api/plane/app/serializers/favorite.py +++ b/apps/api/plane/app/serializers/favorite.py @@ -17,9 +17,7 @@ class PageFavoriteLiteSerializer(serializers.ModelSerializer): fields = ["id", "name", "logo_props", "project_id"] def get_project_id(self, obj): - project = ( - obj.projects.first() - ) # This gets the first project related to the Page + project = obj.projects.first() # This gets the first project related to the Page return project.id if project else None diff --git a/apps/api/plane/app/serializers/intake.py b/apps/api/plane/app/serializers/intake.py index 8b8bbacf7..7bc258220 100644 --- a/apps/api/plane/app/serializers/intake.py +++ b/apps/api/plane/app/serializers/intake.py @@ -45,9 +45,7 @@ class IntakeIssueSerializer(BaseSerializer): class IntakeIssueDetailSerializer(BaseSerializer): issue = IssueDetailSerializer(read_only=True) - duplicate_issue_detail = IssueIntakeSerializer( - read_only=True, source="duplicate_to" - ) + duplicate_issue_detail = IssueIntakeSerializer(read_only=True, source="duplicate_to") class Meta: model = IntakeIssue diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 1eda37601..583b62fd6 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -1,5 +1,3 @@ -from lxml import html - # Django imports from django.utils import timezone from django.core.validators import URLValidator @@ -128,13 +126,9 @@ class IssueCreateSerializer(BaseSerializer): # Validate description content for security if "description_html" in attrs and attrs["description_html"]: - is_valid, error_msg, sanitized_html = validate_html_content( - attrs["description_html"] - ) + is_valid, error_msg, sanitized_html = validate_html_content(attrs["description_html"]) if not is_valid: - raise serializers.ValidationError( - {"error": "html content is not valid"} - ) + raise serializers.ValidationError({"error": "html content is not valid"}) # Update the attrs with sanitized HTML if available if sanitized_html is not None: attrs["description_html"] = sanitized_html @@ -142,9 +136,7 @@ class IssueCreateSerializer(BaseSerializer): if "description_binary" in attrs and attrs["description_binary"]: is_valid, error_msg = validate_binary_data(attrs["description_binary"]) if not is_valid: - raise serializers.ValidationError( - {"description_binary": "Invalid binary data"} - ) + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) # Validate assignees are from project if attrs.get("assignee_ids", []): @@ -173,9 +165,7 @@ class IssueCreateSerializer(BaseSerializer): pk=attrs.get("state").id, ).exists() ): - raise serializers.ValidationError( - "State is not valid please pass a valid state_id" - ) + raise serializers.ValidationError("State is not valid please pass a valid state_id") # Check parent issue is from workspace as it can be cross workspace if ( @@ -185,9 +175,7 @@ class IssueCreateSerializer(BaseSerializer): pk=attrs.get("parent").id, ).exists() ): - raise serializers.ValidationError( - "Parent is not valid issue_id please pass a valid issue_id" - ) + raise serializers.ValidationError("Parent is not valid issue_id please pass a valid issue_id") if ( attrs.get("estimate_point") @@ -196,9 +184,7 @@ class IssueCreateSerializer(BaseSerializer): pk=attrs.get("estimate_point").id, ).exists() ): - raise serializers.ValidationError( - "Estimate point is not valid please pass a valid estimate_point_id" - ) + raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id") return attrs @@ -344,11 +330,7 @@ class IssueActivitySerializer(BaseSerializer): source_data = serializers.SerializerMethodField() def get_source_data(self, obj): - if ( - hasattr(obj, "issue") - and hasattr(obj.issue, "source_data") - and obj.issue.source_data - ): + if hasattr(obj, "issue") and hasattr(obj.issue, "source_data") and obj.issue.source_data: return { "source": obj.issue.source_data[0].source, "source_email": obj.issue.source_data[0].source_email, @@ -398,12 +380,8 @@ class IssueLabelSerializer(BaseSerializer): class IssueRelationSerializer(BaseSerializer): id = serializers.UUIDField(source="related_issue.id", read_only=True) - project_id = serializers.PrimaryKeyRelatedField( - source="related_issue.project_id", read_only=True - ) - sequence_id = serializers.IntegerField( - source="related_issue.sequence_id", read_only=True - ) + project_id = serializers.PrimaryKeyRelatedField(source="related_issue.project_id", read_only=True) + sequence_id = serializers.IntegerField(source="related_issue.sequence_id", read_only=True) name = serializers.CharField(source="related_issue.name", read_only=True) relation_type = serializers.CharField(read_only=True) state_id = serializers.UUIDField(source="related_issue.state.id", read_only=True) @@ -442,9 +420,7 @@ class IssueRelationSerializer(BaseSerializer): class RelatedIssueSerializer(BaseSerializer): id = serializers.UUIDField(source="issue.id", read_only=True) - project_id = serializers.PrimaryKeyRelatedField( - source="issue.project_id", read_only=True - ) + project_id = serializers.PrimaryKeyRelatedField(source="issue.project_id", read_only=True) sequence_id = serializers.IntegerField(source="issue.sequence_id", read_only=True) name = serializers.CharField(source="issue.name", read_only=True) relation_type = serializers.CharField(read_only=True) @@ -586,25 +562,17 @@ class IssueLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): - if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") - ).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) + if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) return IssueLink.objects.create(**validated_data) def update(self, instance, validated_data): if ( - IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=instance.issue_id - ) + IssueLink.objects.filter(url=validated_data.get("url"), issue_id=instance.issue_id) .exclude(pk=instance.id) .exists() ): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) return super().update(instance, validated_data) @@ -667,16 +635,33 @@ class IssueReactionSerializer(BaseSerializer): class IssueReactionLiteSerializer(DynamicBaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + class Meta: model = IssueReaction - fields = ["id", "actor", "issue", "reaction"] + fields = ["id", "actor", "issue", "reaction", "display_name"] class CommentReactionSerializer(BaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + class Meta: model = CommentReaction - fields = "__all__" - read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"] + fields = [ + "id", + "actor", + "comment", + "reaction", + "display_name", + "deleted_at", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"] class IssueVoteSerializer(BaseSerializer): @@ -925,9 +910,7 @@ class IssueDetailSerializer(IssueSerializer): class IssuePublicSerializer(BaseSerializer): project_detail = ProjectLiteSerializer(read_only=True, source="project") state_detail = StateLiteSerializer(read_only=True, source="state") - reactions = IssueReactionSerializer( - read_only=True, many=True, source="issue_reactions" - ) + reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") votes = IssueVoteSerializer(read_only=True, many=True) class Meta: diff --git a/apps/api/plane/app/serializers/module.py b/apps/api/plane/app/serializers/module.py index 22ff44279..b5e2953cc 100644 --- a/apps/api/plane/app/serializers/module.py +++ b/apps/api/plane/app/serializers/module.py @@ -65,9 +65,7 @@ class ModuleWriteSerializer(BaseSerializer): if module_name: # Lookup for the module name in the module table for that project if Module.objects.filter(name=module_name, project=project).exists(): - raise serializers.ValidationError( - {"error": "Module with this name already exists"} - ) + raise serializers.ValidationError({"error": "Module with this name already exists"}) module = Module.objects.create(**validated_data, project=project) if members is not None: @@ -94,14 +92,8 @@ class ModuleWriteSerializer(BaseSerializer): module_name = validated_data.get("name") if module_name: # Lookup for the module name in the module table for that project - if ( - Module.objects.filter(name=module_name, project=instance.project) - .exclude(id=instance.id) - .exists() - ): - raise serializers.ValidationError( - {"error": "Module with this name already exists"} - ) + if Module.objects.filter(name=module_name, project=instance.project).exclude(id=instance.id).exists(): + raise serializers.ValidationError({"error": "Module with this name already exists"}) if members is not None: ModuleMember.objects.filter(module=instance).delete() @@ -191,32 +183,24 @@ class ModuleLinkSerializer(BaseSerializer): def create(self, validated_data): validated_data["url"] = self.validate_url(validated_data.get("url")) - if ModuleLink.objects.filter( - url=validated_data.get("url"), module_id=validated_data.get("module_id") - ).exists(): + if ModuleLink.objects.filter(url=validated_data.get("url"), module_id=validated_data.get("module_id")).exists(): raise serializers.ValidationError({"error": "URL already exists."}) return super().create(validated_data) def update(self, instance, validated_data): validated_data["url"] = self.validate_url(validated_data.get("url")) if ( - ModuleLink.objects.filter( - url=validated_data.get("url"), module_id=instance.module_id - ) + ModuleLink.objects.filter(url=validated_data.get("url"), module_id=instance.module_id) .exclude(pk=instance.id) .exists() ): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) return super().update(instance, validated_data) class ModuleSerializer(DynamicBaseSerializer): - member_ids = serializers.ListField( - child=serializers.UUIDField(), required=False, allow_null=True - ) + member_ids = serializers.ListField(child=serializers.UUIDField(), required=False, allow_null=True) is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) diff --git a/apps/api/plane/app/serializers/page.py b/apps/api/plane/app/serializers/page.py index 9ac6cc414..3aecbafda 100644 --- a/apps/api/plane/app/serializers/page.py +++ b/apps/api/plane/app/serializers/page.py @@ -10,7 +10,6 @@ from plane.utils.content_validator import ( ) from plane.db.models import ( Page, - PageLog, PageLabel, Label, ProjectPage, @@ -130,32 +129,6 @@ class PageDetailSerializer(PageSerializer): fields = PageSerializer.Meta.fields + ["description_html"] -class SubPageSerializer(BaseSerializer): - entity_details = serializers.SerializerMethodField() - - class Meta: - model = PageLog - fields = "__all__" - read_only_fields = ["workspace", "page"] - - def get_entity_details(self, obj): - entity_name = obj.entity_name - if entity_name == "forward_link" or entity_name == "back_link": - try: - page = Page.objects.get(pk=obj.entity_identifier) - return PageSerializer(page).data - except Page.DoesNotExist: - return None - return None - - -class PageLogSerializer(BaseSerializer): - class Meta: - model = PageLog - fields = "__all__" - read_only_fields = ["workspace", "page"] - - class PageVersionSerializer(BaseSerializer): class Meta: model = PageVersion @@ -212,9 +185,7 @@ class PageBinaryUpdateSerializer(serializers.Serializer): # Validate the binary data is_valid, error_message = validate_binary_data(binary_data) if not is_valid: - raise serializers.ValidationError( - f"Invalid binary data: {error_message}" - ) + raise serializers.ValidationError(f"Invalid binary data: {error_message}") return binary_data except Exception as e: @@ -235,7 +206,6 @@ class PageBinaryUpdateSerializer(serializers.Serializer): # Return sanitized HTML if available, otherwise return original return sanitized_html if sanitized_html is not None else value - def update(self, instance, validated_data): """Update the page instance with validated data""" if "description_binary" in validated_data: diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index 1d1ea927d..c709093ad 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -15,7 +15,6 @@ from plane.db.models import ( ) from plane.utils.content_validator import ( validate_html_content, - validate_binary_data, ) @@ -48,9 +47,7 @@ class ProjectSerializer(BaseSerializer): project_id = self.instance.id if self.instance else None workspace_id = self.context["workspace_id"] - project = Project.objects.filter( - identifier=identifier, workspace_id=workspace_id - ) + project = Project.objects.filter(identifier=identifier, workspace_id=workspace_id) if project_id: project = project.exclude(id=project_id) @@ -65,17 +62,13 @@ class ProjectSerializer(BaseSerializer): def validate(self, data): # Validate description content for security if "description_html" in data and data["description_html"]: - is_valid, error_msg, sanitized_html = validate_html_content( - str(data["description_html"]) - ) + is_valid, error_msg, sanitized_html = validate_html_content(str(data["description_html"])) # Update the data with sanitized HTML if available if sanitized_html is not None: data["description_html"] = sanitized_html if not is_valid: - raise serializers.ValidationError( - {"error": "html content is not valid"} - ) + raise serializers.ValidationError({"error": "html content is not valid"}) return data @@ -84,9 +77,7 @@ class ProjectSerializer(BaseSerializer): project = Project.objects.create(**validated_data, workspace_id=workspace_id) - ProjectIdentifier.objects.create( - name=project.identifier, project=project, workspace_id=workspace_id - ) + ProjectIdentifier.objects.create(name=project.identifier, project=project, workspace_id=workspace_id) return project @@ -119,11 +110,7 @@ class ProjectListSerializer(DynamicBaseSerializer): project_members = getattr(obj, "members_list", None) if project_members is not None: # Filter members by the project ID - return [ - member.member_id - for member in project_members - if member.is_active and not member.member.is_bot - ] + return [member.member_id for member in project_members if member.is_active and not member.member.is_bot] return [] class Meta: diff --git a/apps/api/plane/app/serializers/user.py b/apps/api/plane/app/serializers/user.py index 7b5453568..670667a85 100644 --- a/apps/api/plane/app/serializers/user.py +++ b/apps/api/plane/app/serializers/user.py @@ -91,9 +91,7 @@ class UserMeSettingsSerializer(BaseSerializer): read_only_fields = fields def get_workspace(self, obj): - workspace_invites = WorkspaceMemberInvite.objects.filter( - email=obj.email - ).count() + workspace_invites = WorkspaceMemberInvite.objects.filter(email=obj.email).count() # profile profile = Profile.objects.get(user=obj) @@ -110,43 +108,27 @@ class UserMeSettingsSerializer(BaseSerializer): workspace_member__member=obj.id, workspace_member__is_active=True, ).first() - logo_asset_url = ( - workspace.logo_asset.asset_url - if workspace.logo_asset is not None - else "" - ) + logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else "" return { "last_workspace_id": profile.last_workspace_id, - "last_workspace_slug": ( - workspace.slug if workspace is not None else "" - ), - "last_workspace_name": ( - workspace.name if workspace is not None else "" - ), + "last_workspace_slug": (workspace.slug if workspace is not None else ""), + "last_workspace_name": (workspace.name if workspace is not None else ""), "last_workspace_logo": (logo_asset_url), "fallback_workspace_id": profile.last_workspace_id, - "fallback_workspace_slug": ( - workspace.slug if workspace is not None else "" - ), + "fallback_workspace_slug": (workspace.slug if workspace is not None else ""), "invites": workspace_invites, } else: fallback_workspace = ( - Workspace.objects.filter( - workspace_member__member_id=obj.id, workspace_member__is_active=True - ) + Workspace.objects.filter(workspace_member__member_id=obj.id, workspace_member__is_active=True) .order_by("created_at") .first() ) return { "last_workspace_id": None, "last_workspace_slug": None, - "fallback_workspace_id": ( - fallback_workspace.id if fallback_workspace is not None else None - ), - "fallback_workspace_slug": ( - fallback_workspace.slug if fallback_workspace is not None else None - ), + "fallback_workspace_id": (fallback_workspace.id if fallback_workspace is not None else None), + "fallback_workspace_slug": (fallback_workspace.slug if fallback_workspace is not None else None), "invites": workspace_invites, } @@ -195,14 +177,10 @@ class ChangePasswordSerializer(serializers.Serializer): def validate(self, data): if data.get("old_password") == data.get("new_password"): - raise serializers.ValidationError( - {"error": "New password cannot be same as old password."} - ) + raise serializers.ValidationError({"error": "New password cannot be same as old password."}) if data.get("new_password") != data.get("confirm_password"): - raise serializers.ValidationError( - {"error": "Confirm password should be same as the new password."} - ) + raise serializers.ValidationError({"error": "Confirm password should be same as the new password."}) return data diff --git a/apps/api/plane/app/serializers/webhook.py b/apps/api/plane/app/serializers/webhook.py index 1036b700c..ef193e24d 100644 --- a/apps/api/plane/app/serializers/webhook.py +++ b/apps/api/plane/app/serializers/webhook.py @@ -21,29 +21,21 @@ class WebhookSerializer(DynamicBaseSerializer): # Extract the hostname from the URL hostname = urlparse(url).hostname if not hostname: - raise serializers.ValidationError( - {"url": "Invalid URL: No hostname found."} - ) + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) # Resolve the hostname to IP addresses try: ip_addresses = socket.getaddrinfo(hostname, None) except socket.gaierror: - raise serializers.ValidationError( - {"url": "Hostname could not be resolved."} - ) + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) if not ip_addresses: - raise serializers.ValidationError( - {"url": "No IP addresses found for the hostname."} - ) + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) for addr in ip_addresses: ip = ipaddress.ip_address(addr[4][0]) if ip.is_loopback: - raise serializers.ValidationError( - {"url": "URL resolves to a blocked IP address."} - ) + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) # Additional validation for multiple request domains and their subdomains request = self.context.get("request") @@ -53,13 +45,8 @@ class WebhookSerializer(DynamicBaseSerializer): disallowed_domains.append(request_host) # Check if hostname is a subdomain or exact match of any disallowed domain - if any( - hostname == domain or hostname.endswith("." + domain) - for domain in disallowed_domains - ): - raise serializers.ValidationError( - {"url": "URL domain or its subdomain is not allowed."} - ) + if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) return Webhook.objects.create(**validated_data) @@ -69,47 +56,32 @@ class WebhookSerializer(DynamicBaseSerializer): # Extract the hostname from the URL hostname = urlparse(url).hostname if not hostname: - raise serializers.ValidationError( - {"url": "Invalid URL: No hostname found."} - ) + raise serializers.ValidationError({"url": "Invalid URL: No hostname found."}) # Resolve the hostname to IP addresses try: ip_addresses = socket.getaddrinfo(hostname, None) except socket.gaierror: - raise serializers.ValidationError( - {"url": "Hostname could not be resolved."} - ) + raise serializers.ValidationError({"url": "Hostname could not be resolved."}) if not ip_addresses: - raise serializers.ValidationError( - {"url": "No IP addresses found for the hostname."} - ) + raise serializers.ValidationError({"url": "No IP addresses found for the hostname."}) for addr in ip_addresses: ip = ipaddress.ip_address(addr[4][0]) if ip.is_loopback: - raise serializers.ValidationError( - {"url": "URL resolves to a blocked IP address."} - ) + raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."}) # Additional validation for multiple request domains and their subdomains request = self.context.get("request") disallowed_domains = ["plane.so"] # Add your disallowed domains here if request: - request_host = request.get_host().split(":")[ - 0 - ] # Remove port if present + request_host = request.get_host().split(":")[0] # Remove port if present disallowed_domains.append(request_host) # Check if hostname is a subdomain or exact match of any disallowed domain - if any( - hostname == domain or hostname.endswith("." + domain) - for domain in disallowed_domains - ): - raise serializers.ValidationError( - {"url": "URL domain or its subdomain is not allowed."} - ) + if any(hostname == domain or hostname.endswith("." + domain) for domain in disallowed_domains): + raise serializers.ValidationError({"url": "URL domain or its subdomain is not allowed."}) return super().update(instance, validated_data) diff --git a/apps/api/plane/app/serializers/workspace.py b/apps/api/plane/app/serializers/workspace.py index 6b22f59e8..ba59f2429 100644 --- a/apps/api/plane/app/serializers/workspace.py +++ b/apps/api/plane/app/serializers/workspace.py @@ -173,9 +173,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer): ) if workspace_user_link.exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this workspace and owner"} - ) + raise serializers.ValidationError({"error": "URL already exists for this workspace and owner"}) return super().create(validated_data) @@ -189,9 +187,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer): ) if workspace_user_link.exclude(pk=instance.id).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this workspace and owner"} - ) + raise serializers.ValidationError({"error": "URL already exists for this workspace and owner"}) return super().update(instance, validated_data) @@ -219,11 +215,7 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer): return project.identifier if project else None def get_assignees(self, obj): - return list( - obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list( - "id", flat=True - ) - ) + return list(obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list("id", flat=True)) class ProjectRecentVisitSerializer(serializers.ModelSerializer): @@ -234,9 +226,9 @@ class ProjectRecentVisitSerializer(serializers.ModelSerializer): fields = ["id", "name", "logo_props", "project_members", "identifier"] def get_project_members(self, obj): - members = ProjectMember.objects.filter( - project_id=obj.id, member__is_bot=False, is_active=True - ).values_list("member", flat=True) + members = ProjectMember.objects.filter(project_id=obj.id, member__is_bot=False, is_active=True).values_list( + "member", flat=True + ) return members @@ -257,11 +249,7 @@ class PageRecentVisitSerializer(serializers.ModelSerializer): ] def get_project_id(self, obj): - return ( - obj.project_id - if hasattr(obj, "project_id") - else obj.projects.values_list("id", flat=True).first() - ) + return obj.project_id if hasattr(obj, "project_id") else obj.projects.values_list("id", flat=True).first() def get_project_identifier(self, obj): project = obj.projects.first() @@ -319,13 +307,9 @@ class StickySerializer(BaseSerializer): def validate(self, data): # Validate description content for security if "description_html" in data and data["description_html"]: - is_valid, error_msg, sanitized_html = validate_html_content( - data["description_html"] - ) + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) if not is_valid: - raise serializers.ValidationError( - {"error": "html content is not valid"} - ) + raise serializers.ValidationError({"error": "html content is not valid"}) # Update the data with sanitized HTML if available if sanitized_html is not None: data["description_html"] = sanitized_html @@ -333,9 +317,7 @@ class StickySerializer(BaseSerializer): if "description_binary" in data and data["description_binary"]: is_valid, error_msg = validate_binary_data(data["description_binary"]) if not is_valid: - raise serializers.ValidationError( - {"description_binary": "Invalid binary data"} - ) + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) return data diff --git a/apps/api/plane/app/urls/__init__.py b/apps/api/plane/app/urls/__init__.py index 8cfc18dbb..3feab4cb5 100644 --- a/apps/api/plane/app/urls/__init__.py +++ b/apps/api/plane/app/urls/__init__.py @@ -17,6 +17,7 @@ from .views import urlpatterns as view_urls from .webhook import urlpatterns as webhook_urls from .workspace import urlpatterns as workspace_urls from .timezone import urlpatterns as timezone_urls +from .exporter import urlpatterns as exporter_urls urlpatterns = [ *analytic_urls, @@ -38,4 +39,5 @@ urlpatterns = [ *api_urls, *webhook_urls, *timezone_urls, + *exporter_urls, ] diff --git a/apps/api/plane/app/urls/analytic.py b/apps/api/plane/app/urls/analytic.py index 3e4172771..df6ad2498 100644 --- a/apps/api/plane/app/urls/analytic.py +++ b/apps/api/plane/app/urls/analytic.py @@ -30,9 +30,7 @@ urlpatterns = [ ), path( "workspaces//analytic-view//", - AnalyticViewViewset.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + AnalyticViewViewset.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="analytic-view", ), path( diff --git a/apps/api/plane/app/urls/estimate.py b/apps/api/plane/app/urls/estimate.py index 8e5af2a85..c77a5b6b6 100644 --- a/apps/api/plane/app/urls/estimate.py +++ b/apps/api/plane/app/urls/estimate.py @@ -21,9 +21,7 @@ urlpatterns = [ ), path( "workspaces//projects//estimates//", - BulkEstimatePointEndpoint.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + BulkEstimatePointEndpoint.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="bulk-create-estimate-points", ), path( diff --git a/apps/api/plane/app/urls/exporter.py b/apps/api/plane/app/urls/exporter.py new file mode 100644 index 000000000..0bcb4621b --- /dev/null +++ b/apps/api/plane/app/urls/exporter.py @@ -0,0 +1,12 @@ +from django.urls import path + +from plane.app.views import ExportIssuesEndpoint + + +urlpatterns = [ + path( + "workspaces//export-issues/", + ExportIssuesEndpoint.as_view(), + name="export-issues", + ), +] \ No newline at end of file diff --git a/apps/api/plane/app/urls/intake.py b/apps/api/plane/app/urls/intake.py index ac4b7ca5c..dd1efc872 100644 --- a/apps/api/plane/app/urls/intake.py +++ b/apps/api/plane/app/urls/intake.py @@ -16,9 +16,7 @@ urlpatterns = [ ), path( "workspaces//projects//intakes//", - IntakeViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + IntakeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="intake", ), path( @@ -28,9 +26,7 @@ urlpatterns = [ ), path( "workspaces//projects//intake-issues//", - IntakeIssueViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + IntakeIssueViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="intake-issue", ), path( @@ -40,9 +36,7 @@ urlpatterns = [ ), path( "workspaces//projects//inboxes//", - IntakeViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + IntakeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="inbox", ), path( @@ -52,9 +46,7 @@ urlpatterns = [ ), path( "workspaces//projects//inbox-issues//", - IntakeIssueViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + IntakeIssueViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="inbox-issue", ), path( diff --git a/apps/api/plane/app/urls/issue.py b/apps/api/plane/app/urls/issue.py index db56a6240..1d809e248 100644 --- a/apps/api/plane/app/urls/issue.py +++ b/apps/api/plane/app/urls/issue.py @@ -7,7 +7,6 @@ from plane.app.views import ( IssueLinkViewSet, IssueAttachmentEndpoint, CommentReactionViewSet, - ExportIssuesEndpoint, IssueActivityEndpoint, IssueArchiveViewSet, IssueCommentViewSet, @@ -141,12 +140,6 @@ urlpatterns = [ IssueAttachmentV2Endpoint.as_view(), name="project-issue-attachments", ), - ## Export Issues - path( - "workspaces//export-issues/", - ExportIssuesEndpoint.as_view(), - name="export-issues", - ), ## End Issues ## Issue Activity path( @@ -187,9 +180,7 @@ urlpatterns = [ ), path( "workspaces//projects//issues//subscribe/", - IssueSubscriberViewSet.as_view( - {"get": "subscription_status", "post": "subscribe", "delete": "unsubscribe"} - ), + IssueSubscriberViewSet.as_view({"get": "subscription_status", "post": "subscribe", "delete": "unsubscribe"}), name="project-issue-subscribers", ), ## End Issue Subscribers @@ -232,9 +223,7 @@ urlpatterns = [ ), path( "workspaces//projects//issues//archive/", - IssueArchiveViewSet.as_view( - {"get": "retrieve", "post": "archive", "delete": "unarchive"} - ), + IssueArchiveViewSet.as_view({"get": "retrieve", "post": "archive", "delete": "unarchive"}), name="project-issue-archive-unarchive", ), ## End Issue Archives diff --git a/apps/api/plane/app/urls/notification.py b/apps/api/plane/app/urls/notification.py index cd5647ea4..0c992d49e 100644 --- a/apps/api/plane/app/urls/notification.py +++ b/apps/api/plane/app/urls/notification.py @@ -17,9 +17,7 @@ urlpatterns = [ ), path( "workspaces//users/notifications//", - NotificationViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + NotificationViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="notifications", ), path( diff --git a/apps/api/plane/app/urls/page.py b/apps/api/plane/app/urls/page.py index f7eb7e424..8cac22a2f 100644 --- a/apps/api/plane/app/urls/page.py +++ b/apps/api/plane/app/urls/page.py @@ -4,68 +4,53 @@ from django.urls import path from plane.app.views import ( PageViewSet, PageFavoriteViewSet, - PageLogEndpoint, - SubPagesEndpoint, PagesDescriptionViewSet, PageVersionEndpoint, PageDuplicateEndpoint, ) - urlpatterns = [ + path( + "workspaces//projects//pages-summary/", + PageViewSet.as_view({"get": "summary"}), + name="project-pages-summary", + ), path( "workspaces//projects//pages/", PageViewSet.as_view({"get": "list", "post": "create"}), name="project-pages", ), path( - "workspaces//projects//pages//", - PageViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + "workspaces//projects//pages//", + PageViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="project-pages", ), # favorite pages path( - "workspaces//projects//favorite-pages//", + "workspaces//projects//favorite-pages//", PageFavoriteViewSet.as_view({"post": "create", "delete": "destroy"}), name="user-favorite-pages", ), # archived pages path( - "workspaces//projects//pages//archive/", + "workspaces//projects//pages//archive/", PageViewSet.as_view({"post": "archive", "delete": "unarchive"}), name="project-page-archive-unarchive", ), # lock and unlock path( - "workspaces//projects//pages//lock/", + "workspaces//projects//pages//lock/", PageViewSet.as_view({"post": "lock", "delete": "unlock"}), name="project-pages-lock-unlock", ), # private and public page path( - "workspaces//projects//pages//access/", + "workspaces//projects//pages//access/", PageViewSet.as_view({"post": "access"}), name="project-pages-access", ), path( - "workspaces//projects//pages//transactions/", - PageLogEndpoint.as_view(), - name="page-transactions", - ), - path( - "workspaces//projects//pages//transactions//", - PageLogEndpoint.as_view(), - name="page-transactions", - ), - path( - "workspaces//projects//pages//sub-pages/", - SubPagesEndpoint.as_view(), - name="sub-page", - ), - path( - "workspaces//projects//pages//description/", + "workspaces//projects//pages//description/", PagesDescriptionViewSet.as_view({"get": "retrieve", "patch": "partial_update"}), name="page-description", ), diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py index d673d191e..61d30f916 100644 --- a/apps/api/plane/app/urls/project.py +++ b/apps/api/plane/app/urls/project.py @@ -77,9 +77,7 @@ urlpatterns = [ ), path( "workspaces//projects//members//", - ProjectMemberViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + ProjectMemberViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="project-member", ), path( @@ -119,9 +117,7 @@ urlpatterns = [ ), path( "workspaces//projects//project-deploy-boards//", - DeployBoardViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + DeployBoardViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="project-deploy-board", ), path( diff --git a/apps/api/plane/app/urls/state.py b/apps/api/plane/app/urls/state.py index b9ffd0341..7dcf01d62 100644 --- a/apps/api/plane/app/urls/state.py +++ b/apps/api/plane/app/urls/state.py @@ -12,9 +12,7 @@ urlpatterns = [ ), path( "workspaces//projects//states//", - StateViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + StateViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="project-state", ), path( diff --git a/apps/api/plane/app/urls/user.py b/apps/api/plane/app/urls/user.py index 443961d0e..ef4162c10 100644 --- a/apps/api/plane/app/urls/user.py +++ b/apps/api/plane/app/urls/user.py @@ -21,9 +21,7 @@ urlpatterns = [ # User Profile path( "users/me/", - UserEndpoint.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "deactivate"} - ), + UserEndpoint.as_view({"get": "retrieve", "patch": "partial_update", "delete": "deactivate"}), name="users", ), path("users/session/", UserSessionEndpoint.as_view(), name="user-session"), @@ -44,21 +42,15 @@ urlpatterns = [ UserEndpoint.as_view({"get": "retrieve_instance_admin"}), name="users", ), - path( - "users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), name="user-onboard" - ), + path("users/me/onboard/", UpdateUserOnBoardedEndpoint.as_view(), name="user-onboard"), path( "users/me/tour-completed/", UpdateUserTourCompletedEndpoint.as_view(), name="user-tour", ), - path( - "users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities" - ), + path("users/me/activities/", UserActivityEndpoint.as_view(), name="user-activities"), # user workspaces - path( - "users/me/workspaces/", UserWorkSpacesEndpoint.as_view(), name="user-workspace" - ), + path("users/me/workspaces/", UserWorkSpacesEndpoint.as_view(), name="user-workspace"), # User Graphs path( "users/me/workspaces//activity-graph/", diff --git a/apps/api/plane/app/urls/workspace.py b/apps/api/plane/app/urls/workspace.py index f16fdb161..016b68088 100644 --- a/apps/api/plane/app/urls/workspace.py +++ b/apps/api/plane/app/urls/workspace.py @@ -65,9 +65,7 @@ urlpatterns = [ ), path( "workspaces//invitations//", - WorkspaceInvitationsViewset.as_view( - {"delete": "destroy", "get": "retrieve", "patch": "partial_update"} - ), + WorkspaceInvitationsViewset.as_view({"delete": "destroy", "get": "retrieve", "patch": "partial_update"}), name="workspace-invitations", ), # user workspace invitations @@ -94,9 +92,7 @@ urlpatterns = [ ), path( "workspaces//members//", - WorkSpaceMemberViewSet.as_view( - {"patch": "partial_update", "delete": "destroy", "get": "retrieve"} - ), + WorkSpaceMemberViewSet.as_view({"patch": "partial_update", "delete": "destroy", "get": "retrieve"}), name="workspace-member", ), path( @@ -126,9 +122,7 @@ urlpatterns = [ ), path( "workspaces//workspace-themes//", - WorkspaceThemeViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + WorkspaceThemeViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="workspace-themes", ), path( @@ -208,9 +202,7 @@ urlpatterns = [ ), path( "workspaces//draft-issues//", - WorkspaceDraftIssueViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + WorkspaceDraftIssueViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="workspace-drafts-issues", ), path( @@ -226,9 +218,7 @@ urlpatterns = [ ), path( "workspaces//quick-links//", - QuickLinkViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + QuickLinkViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="workspace-quick-links", ), # Widgets @@ -254,9 +244,7 @@ urlpatterns = [ ), path( "workspaces//stickies//", - WorkspaceStickyViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + WorkspaceStickyViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="workspace-sticky", ), # User Preference diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 6d56473e3..9d81754e2 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -165,8 +165,6 @@ from .api import ApiTokenEndpoint, ServiceApiTokenEndpoint from .page.base import ( PageViewSet, PageFavoriteViewSet, - PageLogEndpoint, - SubPagesEndpoint, PagesDescriptionViewSet, PageDuplicateEndpoint, ) diff --git a/apps/api/plane/app/views/analytic/advance.py b/apps/api/plane/app/views/analytic/advance.py index 8a47cdd02..1a5b1b34c 100644 --- a/apps/api/plane/app/views/analytic/advance.py +++ b/apps/api/plane/app/views/analytic/advance.py @@ -41,26 +41,16 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): def get_filtered_count() -> int: if self.filters["analytics_date_range"]: return queryset.filter( - created_at__gte=self.filters["analytics_date_range"]["current"][ - "gte" - ], - created_at__lte=self.filters["analytics_date_range"]["current"][ - "lte" - ], + created_at__gte=self.filters["analytics_date_range"]["current"]["gte"], + created_at__lte=self.filters["analytics_date_range"]["current"]["lte"], ).count() return queryset.count() def get_previous_count() -> int: - if self.filters["analytics_date_range"] and self.filters[ - "analytics_date_range" - ].get("previous"): + if self.filters["analytics_date_range"] and self.filters["analytics_date_range"].get("previous"): return queryset.filter( - created_at__gte=self.filters["analytics_date_range"]["previous"][ - "gte" - ], - created_at__lte=self.filters["analytics_date_range"]["previous"][ - "lte" - ], + created_at__gte=self.filters["analytics_date_range"]["previous"]["gte"], + created_at__lte=self.filters["analytics_date_range"]["previous"]["lte"], ).count() return 0 @@ -71,39 +61,27 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): def get_overview_data(self) -> Dict[str, Dict[str, int]]: members_query = WorkspaceMember.objects.filter( - workspace__slug=self._workspace_slug, is_active=True + workspace__slug=self._workspace_slug, is_active=True, member__is_bot=False ) if self.request.GET.get("project_ids", None): project_ids = self.request.GET.get("project_ids", None) project_ids = [str(project_id) for project_id in project_ids.split(",")] members_query = ProjectMember.objects.filter( - project_id__in=project_ids, is_active=True + project_id__in=project_ids, is_active=True, member__is_bot=False ) return { "total_users": self.get_filtered_counts(members_query), - "total_admins": self.get_filtered_counts( - members_query.filter(role=ROLE.ADMIN.value) - ), - "total_members": self.get_filtered_counts( - members_query.filter(role=ROLE.MEMBER.value) - ), - "total_guests": self.get_filtered_counts( - members_query.filter(role=ROLE.GUEST.value) - ), - "total_projects": self.get_filtered_counts( - Project.objects.filter(**self.filters["project_filters"]) - ), - "total_work_items": self.get_filtered_counts( - Issue.issue_objects.filter(**self.filters["base_filters"]) - ), - "total_cycles": self.get_filtered_counts( - Cycle.objects.filter(**self.filters["base_filters"]) - ), + "total_admins": self.get_filtered_counts(members_query.filter(role=ROLE.ADMIN.value)), + "total_members": self.get_filtered_counts(members_query.filter(role=ROLE.MEMBER.value)), + "total_guests": self.get_filtered_counts(members_query.filter(role=ROLE.GUEST.value)), + "total_projects": self.get_filtered_counts(Project.objects.filter(**self.filters["project_filters"])), + "total_work_items": self.get_filtered_counts(Issue.issue_objects.filter(**self.filters["base_filters"])), + "total_cycles": self.get_filtered_counts(Cycle.objects.filter(**self.filters["base_filters"])), "total_intake": self.get_filtered_counts( Issue.objects.filter(**self.filters["base_filters"]).filter( - issue_intake__status__in=["-2", "0"] + issue_intake__status__in=["-2", "-1", "0", "1", "2"] # TODO: Add description for reference. ) ), } @@ -113,18 +91,10 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): return { "total_work_items": self.get_filtered_counts(base_queryset), - "started_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="started") - ), - "backlog_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="backlog") - ), - "un_started_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="unstarted") - ), - "completed_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="completed") - ), + "started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="started")), + "backlog_work_items": self.get_filtered_counts(base_queryset.filter(state__group="backlog")), + "un_started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="unstarted")), + "completed_work_items": self.get_filtered_counts(base_queryset.filter(state__group="completed")), } @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") @@ -153,9 +123,7 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView): # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] - base_queryset = base_queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) + base_queryset = base_queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) return ( base_queryset.values("project_id", "project__name") @@ -212,24 +180,16 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): } total_work_items = base_queryset.filter(**date_filter).count() - total_cycles = Cycle.objects.filter( - **self.filters["base_filters"], **date_filter - ).count() - total_modules = Module.objects.filter( - **self.filters["base_filters"], **date_filter - ).count() + total_cycles = Cycle.objects.filter(**self.filters["base_filters"], **date_filter).count() + total_modules = Module.objects.filter(**self.filters["base_filters"], **date_filter).count() total_intake = Issue.objects.filter( issue_intake__isnull=False, **self.filters["base_filters"], **date_filter ).count() total_members = WorkspaceMember.objects.filter( workspace__slug=self._workspace_slug, is_active=True, **date_filter ).count() - total_pages = ProjectPage.objects.filter( - **self.filters["base_filters"], **date_filter - ).count() - total_views = IssueView.objects.filter( - **self.filters["base_filters"], **date_filter - ).count() + total_pages = ProjectPage.objects.filter(**self.filters["base_filters"], **date_filter).count() + total_views = IssueView.objects.filter(**self.filters["base_filters"], **date_filter).count() data = { "work_items": total_work_items, @@ -255,9 +215,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): queryset = ( Issue.issue_objects.filter(**self.filters["base_filters"]) .select_related("workspace", "state", "parent") - .prefetch_related( - "assignees", "labels", "issue_module__module", "issue_cycle__cycle" - ) + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") ) workspace = Workspace.objects.get(slug=self._workspace_slug) @@ -266,9 +224,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] - queryset = queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) # Annotate by month and count monthly_stats = ( @@ -311,9 +267,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): ) # Move to next month if current_month.month == 12: - current_month = current_month.replace( - year=current_month.year + 1, month=1 - ) + current_month = current_month.replace(year=current_month.year + 1, month=1) else: current_month = current_month.replace(month=current_month.month + 1) @@ -338,17 +292,13 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): queryset = ( Issue.issue_objects.filter(**self.filters["base_filters"]) .select_related("workspace", "state", "parent") - .prefetch_related( - "assignees", "labels", "issue_module__module", "issue_cycle__cycle" - ) + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") ) # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] - queryset = queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) return Response( build_analytics_chart(queryset, x_axis, group_by), diff --git a/apps/api/plane/app/views/analytic/base.py b/apps/api/plane/app/views/analytic/base.py index 631c6884a..6e9311a18 100644 --- a/apps/api/plane/app/views/analytic/base.py +++ b/apps/api/plane/app/views/analytic/base.py @@ -55,25 +55,16 @@ class AnalyticsEndpoint(BaseAPIView): valid_yaxis = ["issue_count", "estimate"] # Check for x-axis and y-axis as thery are required parameters - if ( - not x_axis - or not y_axis - or x_axis not in valid_xaxis_segment - or y_axis not in valid_yaxis - ): + if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis: return Response( - { - "error": "x-axis and y-axis dimensions are required and the values should be valid" - }, + {"error": "x-axis and y-axis dimensions are required and the values should be valid"}, status=status.HTTP_400_BAD_REQUEST, ) # If segment is present it cannot be same as x-axis if segment and (segment not in valid_xaxis_segment or x_axis == segment): return Response( - { - "error": "Both segment and x axis cannot be same and segment should be valid" - }, + {"error": "Both segment and x axis cannot be same and segment should be valid"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -87,9 +78,7 @@ class AnalyticsEndpoint(BaseAPIView): total_issues = queryset.count() # Build the graph payload - distribution = build_graph_plot( - queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment - ) + distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment) state_details = {} if x_axis in ["state_id"] or segment in ["state_id"]: @@ -118,10 +107,7 @@ class AnalyticsEndpoint(BaseAPIView): if x_axis in ["assignees__id"] or segment in ["assignees__id"]: assignee_details = ( Issue.issue_objects.filter( - Q( - Q(assignees__avatar__isnull=False) - | Q(assignees__avatar_asset__isnull=False) - ), + Q(Q(assignees__avatar__isnull=False) | Q(assignees__avatar_asset__isnull=False)), workspace__slug=slug, **filters, ) @@ -171,9 +157,7 @@ class AnalyticsEndpoint(BaseAPIView): ) module_details = {} - if x_axis in ["issue_module__module_id"] or segment in [ - "issue_module__module_id" - ]: + if x_axis in ["issue_module__module_id"] or segment in ["issue_module__module_id"]: module_details = ( Issue.issue_objects.filter( workspace__slug=slug, @@ -212,9 +196,7 @@ class AnalyticViewViewset(BaseViewSet): serializer.save(workspace_id=workspace.id) def get_queryset(self): - return self.filter_queryset( - super().get_queryset().filter(workspace__slug=self.kwargs.get("slug")) - ) + return self.filter_queryset(super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))) class SavedAnalyticEndpoint(BaseAPIView): @@ -235,9 +217,7 @@ class SavedAnalyticEndpoint(BaseAPIView): ) segment = request.GET.get("segment", False) - distribution = build_graph_plot( - queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment - ) + distribution = build_graph_plot(queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment) total_issues = queryset.count() return Response( {"total": total_issues, "distribution": distribution}, @@ -270,36 +250,23 @@ class ExportAnalyticsEndpoint(BaseAPIView): valid_yaxis = ["issue_count", "estimate"] # Check for x-axis and y-axis as thery are required parameters - if ( - not x_axis - or not y_axis - or x_axis not in valid_xaxis_segment - or y_axis not in valid_yaxis - ): + if not x_axis or not y_axis or x_axis not in valid_xaxis_segment or y_axis not in valid_yaxis: return Response( - { - "error": "x-axis and y-axis dimensions are required and the values should be valid" - }, + {"error": "x-axis and y-axis dimensions are required and the values should be valid"}, status=status.HTTP_400_BAD_REQUEST, ) # If segment is present it cannot be same as x-axis if segment and (segment not in valid_xaxis_segment or x_axis == segment): return Response( - { - "error": "Both segment and x axis cannot be same and segment should be valid" - }, + {"error": "Both segment and x axis cannot be same and segment should be valid"}, status=status.HTTP_400_BAD_REQUEST, ) - analytic_export_task.delay( - email=request.user.email, data=request.data, slug=slug - ) + analytic_export_task.delay(email=request.user.email, data=request.data, slug=slug) return Response( - { - "message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}" - }, + {"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"}, status=status.HTTP_200_OK, ) @@ -315,9 +282,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView): state_groups = base_issues.annotate(state_group=F("state__group")) total_issues_classified = ( - state_groups.values("state_group") - .annotate(state_count=Count("state_group")) - .order_by("state_group") + state_groups.values("state_group").annotate(state_count=Count("state_group")).order_by("state_group") ) open_issues_groups = ["backlog", "unstarted", "started"] @@ -362,9 +327,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ), ), # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - created_by__avatar_asset__isnull=True, then="created_by__avatar" - ), + When(created_by__avatar_asset__isnull=True, then="created_by__avatar"), default=Value(None), output_field=models.CharField(), ) @@ -395,9 +358,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ), ), # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, then="assignees__avatar" - ), + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), default=Value(None), output_field=models.CharField(), ) @@ -422,9 +383,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView): ), ), # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, then="assignees__avatar" - ), + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), default=Value(None), output_field=models.CharField(), ) @@ -485,9 +444,7 @@ class ProjectStatsEndpoint(BaseAPIView): if "completed_issues" in requested_fields: annotations["completed_issues"] = ( - Issue.issue_objects.filter( - project_id=OuterRef("pk"), state__group="completed" - ) + Issue.issue_objects.filter(project_id=OuterRef("pk"), state__group__in=["completed", "cancelled"]) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -511,9 +468,7 @@ class ProjectStatsEndpoint(BaseAPIView): if "total_members" in requested_fields: annotations["total_members"] = ( - ProjectMember.objects.filter( - project_id=OuterRef("id"), member__is_bot=False, is_active=True - ) + ProjectMember.objects.filter(project_id=OuterRef("id"), member__is_bot=False, is_active=True) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") diff --git a/apps/api/plane/app/views/analytic/project_analytics.py b/apps/api/plane/app/views/analytic/project_analytics.py index 655f8e989..2529900b0 100644 --- a/apps/api/plane/app/views/analytic/project_analytics.py +++ b/apps/api/plane/app/views/analytic/project_analytics.py @@ -42,12 +42,8 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView): def get_filtered_count() -> int: if self.filters["analytics_date_range"]: return queryset.filter( - created_at__gte=self.filters["analytics_date_range"]["current"][ - "gte" - ], - created_at__lte=self.filters["analytics_date_range"]["current"][ - "lte" - ], + created_at__gte=self.filters["analytics_date_range"]["current"]["gte"], + created_at__lte=self.filters["analytics_date_range"]["current"]["lte"], ).count() return queryset.count() @@ -55,42 +51,30 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView): "count": get_filtered_count(), } - def get_work_items_stats( - self, project_id, cycle_id=None, module_id=None - ) -> Dict[str, Dict[str, int]]: + def get_work_items_stats(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Dict[str, int]]: """ Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided. """ base_queryset = None if cycle_id is not None: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) elif module_id is not None: - module_issues = ModuleIssue.objects.filter( - **self.filters["base_filters"], module_id=module_id - ).values_list("issue_id", flat=True) + module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( + "issue_id", flat=True + ) base_queryset = Issue.issue_objects.filter(id__in=module_issues) else: - base_queryset = Issue.issue_objects.filter( - **self.filters["base_filters"], project_id=project_id - ) + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"], project_id=project_id) return { "total_work_items": self.get_filtered_counts(base_queryset), - "started_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="started") - ), - "backlog_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="backlog") - ), - "un_started_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="unstarted") - ), - "completed_work_items": self.get_filtered_counts( - base_queryset.filter(state__group="completed") - ), + "started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="started")), + "backlog_work_items": self.get_filtered_counts(base_queryset.filter(state__group="backlog")), + "un_started_work_items": self.get_filtered_counts(base_queryset.filter(state__group="unstarted")), + "completed_work_items": self.get_filtered_counts(base_queryset.filter(state__group="completed")), } @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -101,9 +85,7 @@ class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView): cycle_id = request.GET.get("cycle_id", None) module_id = request.GET.get("module_id", None) return Response( - self.get_work_items_stats( - cycle_id=cycle_id, module_id=module_id, project_id=project_id - ), + self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id, project_id=project_id), status=status.HTTP_200_OK, ) @@ -116,9 +98,7 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] - base_queryset = base_queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) + base_queryset = base_queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) return ( base_queryset.values("project_id", "project__name") @@ -132,24 +112,20 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): .order_by("project_id") ) - def get_work_items_stats( - self, project_id, cycle_id=None, module_id=None - ) -> Dict[str, Dict[str, int]]: + def get_work_items_stats(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Dict[str, int]]: base_queryset = None if cycle_id is not None: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) elif module_id is not None: - module_issues = ModuleIssue.objects.filter( - **self.filters["base_filters"], module_id=module_id - ).values_list("issue_id", flat=True) + module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( + "issue_id", flat=True + ) base_queryset = Issue.issue_objects.filter(id__in=module_issues) else: - base_queryset = Issue.issue_objects.filter( - **self.filters["base_filters"], project_id=project_id - ) + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"], project_id=project_id) return ( base_queryset.annotate(display_name=F("assignees__display_name")) .annotate(assignee_id=F("assignees__id")) @@ -166,30 +142,18 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): ), ), # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, then="assignees__avatar" - ), + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), default=Value(None), output_field=models.CharField(), ) ) .values("display_name", "assignee_id", "avatar_url") .annotate( - cancelled_work_items=Count( - "id", filter=Q(state__group="cancelled"), distinct=True - ), - completed_work_items=Count( - "id", filter=Q(state__group="completed"), distinct=True - ), - backlog_work_items=Count( - "id", filter=Q(state__group="backlog"), distinct=True - ), - un_started_work_items=Count( - "id", filter=Q(state__group="unstarted"), distinct=True - ), - started_work_items=Count( - "id", filter=Q(state__group="started"), distinct=True - ), + cancelled_work_items=Count("id", filter=Q(state__group="cancelled"), distinct=True), + completed_work_items=Count("id", filter=Q(state__group="completed"), distinct=True), + backlog_work_items=Count("id", filter=Q(state__group="backlog"), distinct=True), + un_started_work_items=Count("id", filter=Q(state__group="unstarted"), distinct=True), + started_work_items=Count("id", filter=Q(state__group="started"), distinct=True), ) .order_by("display_name") ) @@ -204,9 +168,7 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): cycle_id = request.GET.get("cycle_id", None) module_id = request.GET.get("module_id", None) return Response( - self.get_work_items_stats( - project_id=project_id, cycle_id=cycle_id, module_id=module_id - ), + self.get_work_items_stats(project_id=project_id, cycle_id=cycle_id, module_id=module_id), status=status.HTTP_200_OK, ) @@ -214,23 +176,19 @@ class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): - def work_item_completion_chart( - self, project_id, cycle_id=None, module_id=None - ) -> Dict[str, Any]: + def work_item_completion_chart(self, project_id, cycle_id=None, module_id=None) -> Dict[str, Any]: # Get the base queryset queryset = ( Issue.issue_objects.filter(**self.filters["base_filters"]) .filter(project_id=project_id) .select_related("workspace", "state", "parent") - .prefetch_related( - "assignees", "labels", "issue_module__module", "issue_cycle__cycle" - ) + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") ) if cycle_id is not None: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) cycle = Cycle.objects.filter(id=cycle_id).first() if cycle and cycle.start_date: start_date = cycle.start_date.date() @@ -240,9 +198,9 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): queryset = cycle_issues elif module_id is not None: - module_issues = ModuleIssue.objects.filter( - **self.filters["base_filters"], module_id=module_id - ).values_list("issue_id", flat=True) + module_issues = ModuleIssue.objects.filter(**self.filters["base_filters"], module_id=module_id).values_list( + "issue_id", flat=True + ) module = Module.objects.filter(id=module_id).first() if module and module.start_date: start_date = module.start_date @@ -264,9 +222,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): queryset.values("created_at__date") .annotate( created_count=Count("id"), - completed_count=Count( - "id", filter=Q(issue__state__group="completed") - ), + completed_count=Count("id", filter=Q(issue__state__group="completed")), ) .order_by("created_at__date") ) @@ -285,9 +241,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): current_date = start_date while current_date <= end_date: date_str = current_date.strftime("%Y-%m-%d") - stats = stats_dict.get( - date_str, {"created_count": 0, "completed_count": 0} - ) + stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) data.append( { "key": date_str, @@ -302,9 +256,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] - queryset = queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) # Annotate by month and count monthly_stats = ( @@ -335,9 +287,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): while current_month <= last_month: date_str = current_month.strftime("%Y-%m-%d") - stats = stats_dict.get( - date_str, {"created_count": 0, "completed_count": 0} - ) + stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) data.append( { "key": date_str, @@ -349,9 +299,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): ) # Move to next month if current_month.month == 12: - current_month = current_month.replace( - year=current_month.year + 1, month=1 - ) + current_month = current_month.replace(year=current_month.year + 1, month=1) else: current_month = current_month.replace(month=current_month.month + 1) @@ -376,16 +324,14 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): Issue.issue_objects.filter(**self.filters["base_filters"]) .filter(project_id=project_id) .select_related("workspace", "state", "parent") - .prefetch_related( - "assignees", "labels", "issue_module__module", "issue_cycle__cycle" - ) + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") ) # Apply cycle/module filters if present if cycle_id is not None: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) + cycle_issues = CycleIssue.objects.filter(**self.filters["base_filters"], cycle_id=cycle_id).values_list( + "issue_id", flat=True + ) queryset = queryset.filter(id__in=cycle_issues) elif module_id is not None: @@ -397,9 +343,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] - queryset = queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) + queryset = queryset.filter(created_at__date__gte=start_date, created_at__date__lte=end_date) return Response( build_analytics_chart(queryset, x_axis, group_by), @@ -412,9 +356,7 @@ class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): module_id = request.GET.get("module_id", None) return Response( - self.work_item_completion_chart( - project_id=project_id, cycle_id=cycle_id, module_id=module_id - ), + self.work_item_completion_chart(project_id=project_id, cycle_id=cycle_id, module_id=module_id), status=status.HTTP_200_OK, ) diff --git a/apps/api/plane/app/views/api.py b/apps/api/plane/app/views/api.py index fa7cc7466..419859902 100644 --- a/apps/api/plane/app/views/api.py +++ b/apps/api/plane/app/views/api.py @@ -65,9 +65,7 @@ class ServiceApiTokenEndpoint(BaseAPIView): def post(self, request: Request, slug: str) -> Response: workspace = Workspace.objects.get(slug=slug) - api_token = APIToken.objects.filter( - workspace=workspace, is_service=True - ).first() + api_token = APIToken.objects.filter(workspace=workspace, is_service=True).first() if api_token: return Response({"token": str(api_token.token)}, status=status.HTTP_200_OK) @@ -83,6 +81,4 @@ class ServiceApiTokenEndpoint(BaseAPIView): user_type=user_type, is_service=True, ) - return Response( - {"token": str(api_token.token)}, status=status.HTTP_201_CREATED - ) + return Response({"token": str(api_token.token)}, status=status.HTTP_201_CREATED) diff --git a/apps/api/plane/app/views/asset/base.py b/apps/api/plane/app/views/asset/base.py index d30f0bb26..522d4af75 100644 --- a/apps/api/plane/app/views/asset/base.py +++ b/apps/api/plane/app/views/asset/base.py @@ -20,12 +20,8 @@ class FileAssetEndpoint(BaseAPIView): asset_key = str(workspace_id) + "/" + asset_key files = FileAsset.objects.filter(asset=asset_key) if files.exists(): - serializer = FileAssetSerializer( - files, context={"request": request}, many=True - ) - return Response( - {"data": serializer.data, "status": True}, status=status.HTTP_200_OK - ) + serializer = FileAssetSerializer(files, context={"request": request}, many=True) + return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) else: return Response( {"error": "Asset key does not exist", "status": False}, @@ -65,9 +61,7 @@ class UserAssetsEndpoint(BaseAPIView): files = FileAsset.objects.filter(asset=asset_key, created_by=request.user) if files.exists(): serializer = FileAssetSerializer(files, context={"request": request}) - return Response( - {"data": serializer.data, "status": True}, status=status.HTTP_200_OK - ) + return Response({"data": serializer.data, "status": True}, status=status.HTTP_200_OK) else: return Response( {"error": "Asset key does not exist", "status": False}, diff --git a/apps/api/plane/app/views/asset/v2.py b/apps/api/plane/app/views/asset/v2.py index b69949621..610c5335f 100644 --- a/apps/api/plane/app/views/asset/v2.py +++ b/apps/api/plane/app/views/asset/v2.py @@ -44,9 +44,7 @@ class UserAssetsV2Endpoint(BaseAPIView): # Save the new avatar user.avatar_asset_id = asset_id user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -64,9 +62,7 @@ class UserAssetsV2Endpoint(BaseAPIView): # Save the new cover image user.cover_image_asset_id = asset_id user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -82,9 +78,7 @@ class UserAssetsV2Endpoint(BaseAPIView): user = User.objects.get(id=asset.user_id) user.avatar_asset_id = None user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -97,9 +91,7 @@ class UserAssetsV2Endpoint(BaseAPIView): user = User.objects.get(id=asset.user_id) user.cover_image_asset_id = None user.save() - invalidate_cache_directly( - path="/api/users/me/", url_params=False, user=True, request=request - ) + invalidate_cache_directly(path="/api/users/me/", url_params=False, user=True, request=request) invalidate_cache_directly( path="/api/users/me/settings/", url_params=False, @@ -159,9 +151,7 @@ class UserAssetsV2Endpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -198,9 +188,7 @@ class UserAssetsV2Endpoint(BaseAPIView): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -264,18 +252,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): workspace.logo = "" workspace.logo_asset_id = asset_id workspace.save() - invalidate_cache_directly( - path="/api/workspaces/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request) invalidate_cache_directly( path="/api/users/me/workspaces/", url_params=False, user=True, request=request, ) - invalidate_cache_directly( - path="/api/instances/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request) return # Project Cover @@ -302,18 +286,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): return workspace.logo_asset_id = None workspace.save() - invalidate_cache_directly( - path="/api/workspaces/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/workspaces/", url_params=False, user=False, request=request) invalidate_cache_directly( path="/api/users/me/workspaces/", url_params=False, user=True, request=request, ) - invalidate_cache_directly( - path="/api/instances/", url_params=False, user=False, request=request - ) + invalidate_cache_directly(path="/api/instances/", url_params=False, user=False, request=request) return # Project Cover elif entity_type == FileAsset.EntityTypeContext.PROJECT_COVER: @@ -374,17 +354,13 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): workspace=workspace, created_by=request.user, entity_type=entity_type, - **self.get_entity_id_field( - entity_type=entity_type, entity_id=entity_identifier - ), + **self.get_entity_id_field(entity_type=entity_type, entity_id=entity_identifier), ) # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -421,9 +397,7 @@ class WorkspaceFileAssetEndpoint(BaseAPIView): asset.is_deleted = True asset.deleted_at = timezone.now() # get the entity and save the asset id for the request field - self.entity_asset_delete( - entity_type=asset.entity_type, asset=asset, request=request - ) + self.entity_asset_delete(entity_type=asset.entity_type, asset=asset, request=request) asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @@ -586,9 +560,7 @@ class ProjectAssetEndpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( { @@ -618,9 +590,7 @@ class ProjectAssetEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def delete(self, request, slug, project_id, pk): # Get the asset - asset = FileAsset.objects.get( - id=pk, workspace__slug=slug, project_id=project_id - ) + asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) # Check deleted assets asset.is_deleted = True asset.deleted_at = timezone.now() @@ -631,9 +601,7 @@ class ProjectAssetEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, pk): # get the asset id - asset = FileAsset.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + asset = FileAsset.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) # Check if the asset is uploaded if not asset.is_uploaded: @@ -666,9 +634,7 @@ class ProjectBulkAssetEndpoint(BaseAPIView): # Check if the asset ids are provided if not asset_ids: - return Response( - {"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST) # get the asset id assets = FileAsset.objects.filter(id__in=asset_ids, workspace__slug=slug) @@ -722,9 +688,7 @@ class AssetCheckEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug, asset_id): - asset = FileAsset.all_objects.filter( - id=asset_id, workspace__slug=slug, deleted_at__isnull=True - ).exists() + asset = FileAsset.all_objects.filter(id=asset_id, workspace__slug=slug, deleted_at__isnull=True).exists() return Response({"exists": asset}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/base.py b/apps/api/plane/app/views/base.py index 4cefb75a1..0323302c5 100644 --- a/apps/api/plane/app/views/base.py +++ b/apps/api/plane/app/views/base.py @@ -72,11 +72,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi response = super().handle_exception(exc) return response except Exception as e: - ( - print(e, traceback.format_exc()) - if settings.DEBUG - else print("Server Error") - ) + (print(e, traceback.format_exc()) if settings.DEBUG else print("Server Error")) if isinstance(e, IntegrityError): return Response( {"error": "The payload is not valid"}, @@ -115,9 +111,7 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi if settings.DEBUG: from django.db import connection - print( - f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" - ) + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") return response except Exception as exc: @@ -139,16 +133,12 @@ class BaseViewSet(TimezoneMixin, ReadReplicaControlMixin, ModelViewSet, BasePagi @property def fields(self): - fields = [ - field for field in self.request.GET.get("fields", "").split(",") if field - ] + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] return fields if fields else None @property def expand(self): - expand = [ - expand for expand in self.request.GET.get("expand", "").split(",") if expand - ] + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] return expand if expand else None @@ -216,9 +206,7 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator if settings.DEBUG: from django.db import connection - print( - f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" - ) + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") return response except Exception as exc: @@ -235,14 +223,10 @@ class BaseAPIView(TimezoneMixin, ReadReplicaControlMixin, APIView, BasePaginator @property def fields(self): - fields = [ - field for field in self.request.GET.get("fields", "").split(",") if field - ] + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] return fields if fields else None @property def expand(self): - expand = [ - expand for expand in self.request.GET.get("expand", "").split(",") if expand - ] + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] return expand if expand else None diff --git a/apps/api/plane/app/views/cycle/archive.py b/apps/api/plane/app/views/cycle/archive.py index f58ad9aea..a2f89d53f 100644 --- a/apps/api/plane/app/views/cycle/archive.py +++ b/apps/api/plane/app/views/cycle/archive.py @@ -50,9 +50,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") - .annotate( - backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("backlog_estimate_point")[:1] ) unstarted_estimate_point = ( @@ -63,11 +61,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") - .annotate( - unstarted_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(unstarted_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("unstarted_estimate_point")[:1] ) started_estimate_point = ( @@ -78,9 +72,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") - .annotate( - started_estimate_point=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(started_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("started_estimate_point")[:1] ) cancelled_estimate_point = ( @@ -91,11 +83,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") - .annotate( - cancelled_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(cancelled_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("cancelled_estimate_point")[:1] ) completed_estimate_point = ( @@ -106,11 +94,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") - .annotate( - completed_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) .values("completed_estimate_points")[:1] ) total_estimate_point = ( @@ -120,9 +104,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): issue_cycle__deleted_at__isnull=True, ) .values("issue_cycle__cycle_id") - .annotate( - total_estimate_points=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) .values("total_estimate_points")[:1] ) return ( @@ -138,9 +120,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .prefetch_related( Prefetch( "issue_cycle__issue__assignees", - queryset=User.objects.only( - "avatar_asset", "first_name", "id" - ).distinct(), + queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(), ) ) .prefetch_related( @@ -224,8 +204,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), + Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()), then=Value("CURRENT"), ), When(start_date__gt=timezone.now(), then=Value("UPCOMING")), @@ -279,9 +258,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ) ) .annotate( - total_estimate_points=Coalesce( - Subquery(total_estimate_point), Value(0, output_field=FloatField()) - ) + total_estimate_points=Coalesce(Subquery(total_estimate_point), Value(0, output_field=FloatField())) ) .order_by("-is_favorite", "name") .distinct() @@ -322,9 +299,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ).order_by("-is_favorite", "-created_at") return Response(queryset, status=status.HTTP_200_OK) else: - queryset = ( - self.get_queryset().filter(archived_at__isnull=False).filter(pk=pk) - ) + queryset = self.get_queryset().filter(archived_at__isnull=False).filter(pk=pk) data = ( self.get_queryset() .filter(pk=pk) @@ -415,9 +390,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -452,9 +425,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -531,11 +502,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): "avatar_url", "display_name", ) - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -571,11 +538,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -618,9 +581,7 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def post(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) if cycle.end_date >= timezone.now(): return Response( @@ -636,15 +597,11 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): project_id=project_id, workspace__slug=slug, ).delete() - return Response( - {"archived_at": str(cycle.archived_at)}, status=status.HTTP_200_OK - ) + return Response({"archived_at": str(cycle.archived_at)}, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def delete(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.get( - pk=cycle_id, project_id=project_id, workspace__slug=slug - ) + cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug) cycle.archived_at = None cycle.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/cycle/base.py b/apps/api/plane/app/views/cycle/base.py index bcce69bf8..711d6724a 100644 --- a/apps/api/plane/app/views/cycle/base.py +++ b/apps/api/plane/app/views/cycle/base.py @@ -46,7 +46,6 @@ from plane.db.models import ( Label, User, Project, - ProjectMember, UserRecentVisit, ) from plane.utils.analytics_plot import burndown_plot @@ -97,9 +96,7 @@ class CycleViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_cycle__issue__assignees", - queryset=User.objects.only( - "avatar_asset", "first_name", "id" - ).distinct(), + queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(), ) ) .prefetch_related( @@ -150,8 +147,7 @@ class CycleViewSet(BaseViewSet): .annotate( status=Case( When( - Q(start_date__lte=current_time_in_utc) - & Q(end_date__gte=current_time_in_utc), + Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc), then=Value("CURRENT"), ), When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")), @@ -170,11 +166,7 @@ class CycleViewSet(BaseViewSet): "issue_cycle__issue__assignees__id", distinct=True, filter=~Q(issue_cycle__issue__assignees__id__isnull=True) - & ( - Q( - issue_cycle__issue__issue_assignee__deleted_at__isnull=True - ) - ), + & (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ) @@ -205,9 +197,7 @@ class CycleViewSet(BaseViewSet): # Current Cycle if cycle_view == "current": - queryset = queryset.filter( - start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc - ) + queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc) data = queryset.values( # necessary fields @@ -274,16 +264,10 @@ class CycleViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): - if ( - request.data.get("start_date", None) is None - and request.data.get("end_date", None) is None - ) or ( - request.data.get("start_date", None) is not None - and request.data.get("end_date", None) is not None + if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or ( + request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleWriteSerializer( - data=request.data, context={"project_id": project_id} - ) + serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id}) if serializer.is_valid(): serializer.save(project_id=project_id, owned_by=request.user) cycle = ( @@ -323,9 +307,7 @@ class CycleViewSet(BaseViewSet): project_timezone = project.timezone datetime_fields = ["start_date", "end_date"] - cycle = user_timezone_converter( - cycle, datetime_fields, project_timezone - ) + cycle = user_timezone_converter(cycle, datetime_fields, project_timezone) # Send the model activity model_activity.delay( @@ -341,17 +323,13 @@ class CycleViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) else: return Response( - { - "error": "Both start date and end date are either required or are to be null" - }, + {"error": "Both start date and end date are either required or are to be null"}, status=status.HTTP_400_BAD_REQUEST, ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def partial_update(self, request, slug, project_id, pk): - queryset = self.get_queryset().filter( - workspace__slug=slug, project_id=project_id, pk=pk - ) + queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk) cycle = queryset.first() if cycle.archived_at: return Response( @@ -359,29 +337,21 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - current_instance = json.dumps( - CycleSerializer(cycle).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder) request_data = request.data if cycle.end_date is not None and cycle.end_date < timezone.now(): if "sort_order" in request_data: # Can only change sort order for a completed cycle`` - request_data = { - "sort_order": request_data.get("sort_order", cycle.sort_order) - } + request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)} else: return Response( - { - "error": "The Cycle has already been completed so it cannot be edited" - }, + {"error": "The Cycle has already been completed so it cannot be edited"}, status=status.HTTP_400_BAD_REQUEST, ) - serializer = CycleWriteSerializer( - cycle, data=request.data, partial=True, context={"project_id": project_id} - ) + serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id}) if serializer.is_valid(): serializer.save() cycle = queryset.values( @@ -481,9 +451,7 @@ class CycleViewSet(BaseViewSet): ) if data is None: - return Response( - {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND) queryset = queryset.first() # Fetch the project timezone @@ -504,25 +472,8 @@ class CycleViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN], creator=True, model=Cycle) def destroy(self, request, slug, project_id, pk): cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - if cycle.owned_by_id != request.user.id and not ( - ProjectMember.objects.filter( - workspace__slug=slug, - member=request.user, - role=20, - project_id=project_id, - is_active=True, - ).exists() - ): - return Response( - {"error": "Only admin or owner can delete the cycle"}, - status=status.HTTP_403_FORBIDDEN, - ) - cycle_issues = list( - CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list( - "issue", flat=True - ) - ) + cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True)) issue_activity.delay( type="cycle.activity.deleted", @@ -573,9 +524,7 @@ class CycleDateCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - start_date = convert_to_utc( - date=str(start_date), project_id=project_id, is_start_date=True - ) + start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True) end_date = convert_to_utc( date=str(end_date), project_id=project_id, @@ -594,7 +543,7 @@ class CycleDateCheckEndpoint(BaseAPIView): if cycles.exists(): return Response( { - "error": "You have a cycle already on the given dates, if you want to create a draft cycle you can do that by removing dates", + "error": "You have a cycle already on the given dates, if you want to create a draft cycle you can do that by removing dates", # noqa: E501 "status": False, } ) @@ -648,14 +597,10 @@ class TransferCycleIssueEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - new_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=new_cycle_id - ).first() + new_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=new_cycle_id).first() old_cycle = ( - Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) + Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id) .annotate( total_issues=Count( "issue_cycle", @@ -768,9 +713,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -797,9 +740,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): assignee_estimate_distribution = [ { "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), "avatar": item.get("avatar"), "avatar_url": item.get("avatar_url"), "total_estimates": item["total_estimates"], @@ -820,9 +761,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -888,19 +827,13 @@ class TransferCycleIssueEndpoint(BaseAPIView): ), ), # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, then="assignees__avatar" - ), + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), default=Value(None), output_field=models.CharField(), ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -927,9 +860,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): assignee_distribution_data = [ { "display_name": item["display_name"], - "assignee_id": ( - str(item["assignee_id"]) if item["assignee_id"] else None - ), + "assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None), "avatar": item.get("avatar"), "avatar_url": item.get("avatar_url"), "total_issues": item["total_issues"], @@ -951,11 +882,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -1001,9 +928,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): cycle_id=cycle_id, ) - current_cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ).first() + current_cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id).first() current_cycle.progress_snapshot = { "total_issues": old_cycle.total_issues, @@ -1031,9 +956,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): if new_cycle.end_date is not None and new_cycle.end_date < timezone.now(): return Response( - { - "error": "The cycle where the issues are transferred is already completed" - }, + {"error": "The cycle where the issues are transferred is already completed"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -1057,9 +980,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): } ) - cycle_issues = CycleIssue.objects.bulk_update( - updated_cycles, ["cycle_id"], batch_size=100 - ) + cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( @@ -1093,9 +1014,8 @@ class CycleUserPropertiesEndpoint(BaseAPIView): ) cycle_properties.filters = request.data.get("filters", cycle_properties.filters) - cycle_properties.display_filters = request.data.get( - "display_filters", cycle_properties.display_filters - ) + cycle_properties.rich_filters = request.data.get("rich_filters", cycle_properties.rich_filters) + cycle_properties.display_filters = request.data.get("display_filters", cycle_properties.display_filters) cycle_properties.display_properties = request.data.get( "display_properties", cycle_properties.display_properties ) @@ -1119,13 +1039,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView): class CycleProgressEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, cycle_id): - cycle = Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, id=cycle_id - ).first() + cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first() if not cycle: - return Response( - {"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND) aggregate_estimates = ( Issue.issue_objects.filter( estimate_point__estimate__type="points", @@ -1171,9 +1087,7 @@ class CycleProgressEndpoint(BaseAPIView): output_field=FloatField(), ) ), - total_estimate_points=Sum( - "value_as_float", default=Value(0), output_field=FloatField() - ), + total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()), ) ) if cycle.progress_snapshot: @@ -1233,22 +1147,11 @@ class CycleProgressEndpoint(BaseAPIView): return Response( { - "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] - or 0, - "unstarted_estimate_points": aggregate_estimates[ - "unstarted_estimate_point" - ] - or 0, - "started_estimate_points": aggregate_estimates["started_estimate_point"] - or 0, - "cancelled_estimate_points": aggregate_estimates[ - "cancelled_estimate_point" - ] - or 0, - "completed_estimate_points": aggregate_estimates[ - "completed_estimate_points" - ] - or 0, + "backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0, + "unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0, + "started_estimate_points": aggregate_estimates["started_estimate_point"] or 0, + "cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0, + "completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0, "total_estimate_points": aggregate_estimates["total_estimate_points"], "backlog_issues": backlog_issues, "total_issues": total_issues, @@ -1266,9 +1169,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): def get(self, request, slug, project_id, cycle_id): analytic_type = request.GET.get("type", "issues") cycle = ( - Cycle.objects.filter( - workspace__slug=slug, project_id=project_id, id=cycle_id - ) + Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id) .annotate( total_issues=Count( "issue_cycle__issue__id", @@ -1295,7 +1196,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): if the issues were transferred to the new cycle, then the progress_snapshot will be present return the progress_snapshot data in the analytics for each date - else issues were not transferred to the new cycle then generate the stats from the cycle isssue bridge tables + else issues were not transferred to the new cycle then generate the stats from the cycle issue bridge tables """ if cycle.progress_snapshot: @@ -1351,9 +1252,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): ) ) .values("display_name", "assignee_id", "avatar_url") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1388,9 +1287,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -1492,11 +1389,7 @@ class CycleAnalyticsEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "label_id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "label_id", diff --git a/apps/api/plane/app/views/cycle/issue.py b/apps/api/plane/app/views/cycle/issue.py index ad7762629..ad3923b17 100644 --- a/apps/api/plane/app/views/cycle/issue.py +++ b/apps/api/plane/app/views/cycle/issue.py @@ -1,4 +1,5 @@ # Python imports +import copy import json # Django imports @@ -28,11 +29,15 @@ from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator from plane.app.permissions import allow_permission, ROLE from plane.utils.host import base_host +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer model = CycleIssue + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet webhook_event = "cycle_issue" bulk = True @@ -65,28 +70,11 @@ class CycleIssueViewSet(BaseViewSet): .distinct() ) - @method_decorator(gzip_page) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def list(self, request, slug, project_id, cycle_id): - order_by_param = request.GET.get("order_by", "created_at") - filters = issue_filters(request.query_params, "GET") - issue_queryset = ( - Issue.issue_objects.filter( - issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True - ) - .filter(project_id=project_id) - .filter(workspace__slug=slug) - .filter(**filters) - .select_related("workspace", "project", "state", "parent") - .prefetch_related( - "assignees", "labels", "issue_module__module", "issue_cycle__cycle" - ) - .filter(**filters) - .annotate( + def apply_annotations(self, issues): + return ( + issues.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -110,11 +98,32 @@ class CycleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .prefetch_related("assignees", "labels", "issue_module__module", "issue_cycle__cycle") ) + + @method_decorator(gzip_page) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def list(self, request, slug, project_id, cycle_id): filters = issue_filters(request.query_params, "GET") + issue_queryset = ( + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True) + .filter(project_id=project_id) + .filter(workspace__slug=slug) + ) + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Applying annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = issue_queryset.filter(**filters) # Issue queryset issue_queryset, order_by_param = order_issue_queryset( issue_queryset=issue_queryset, order_by_param=order_by_param @@ -125,18 +134,14 @@ class CycleIssueViewSet(BaseViewSet): sub_group_by = request.GET.get("sub_group_by", False) # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by - ) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) if group_by: # Check group and sub group value paginate if sub_group_by: if group_by == sub_group_by: return Response( - { - "error": "Group by and sub group by cannot have same parameters" - }, + {"error": "Group by and sub group by cannot have same parameters"}, status=status.HTTP_400_BAD_REQUEST, ) else: @@ -145,6 +150,7 @@ class CycleIssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -179,6 +185,7 @@ class CycleIssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -205,9 +212,8 @@ class CycleIssueViewSet(BaseViewSet): order_by=order_by_param, request=request, queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -215,26 +221,18 @@ class CycleIssueViewSet(BaseViewSet): issues = request.data.get("issues", []) if not issues: - return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=cycle_id - ) + cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=cycle_id) if cycle.end_date is not None and cycle.end_date < timezone.now(): return Response( - { - "error": "The Cycle has already been completed so no new issues can be added" - }, + {"error": "The Cycle has already been completed so no new issues can be added"}, status=status.HTTP_400_BAD_REQUEST, ) # Get all CycleIssues already created - cycle_issues = list( - CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues) - ) + cycle_issues = list(CycleIssue.objects.filter(~Q(cycle_id=cycle_id), issue_id__in=issues)) existing_issues = [str(cycle_issue.issue_id) for cycle_issue in cycle_issues] new_issues = list(set(issues) - set(existing_issues)) @@ -285,9 +283,7 @@ class CycleIssueViewSet(BaseViewSet): current_instance=json.dumps( { "updated_cycle_issues": update_cycle_issue_activity, - "created_cycle_issues": serializers.serialize( - "json", created_records - ), + "created_cycle_issues": serializers.serialize("json", created_records), } ), epoch=int(timezone.now().timestamp()), diff --git a/apps/api/plane/app/views/estimate/base.py b/apps/api/plane/app/views/estimate/base.py index c0e931ca6..f54115a4f 100644 --- a/apps/api/plane/app/views/estimate/base.py +++ b/apps/api/plane/app/views/estimate/base.py @@ -56,9 +56,7 @@ class BulkEstimatePointEndpoint(BaseViewSet): serializer = EstimateReadSerializer(estimates, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - @invalidate_cache( - path="/api/workspaces/:slug/estimates/", url_params=True, user=False - ) + @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) def create(self, request, slug, project_id): estimate = request.data.get("estimate") estimate_name = estimate.get("name", generate_random_name()) @@ -73,9 +71,7 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate_points = request.data.get("estimate_points", []) - serializer = EstimatePointSerializer( - data=request.data.get("estimate_points"), many=True - ) + serializer = EstimatePointSerializer(data=request.data.get("estimate_points"), many=True) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -101,15 +97,11 @@ class BulkEstimatePointEndpoint(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, estimate_id): - estimate = Estimate.objects.get( - pk=estimate_id, workspace__slug=slug, project_id=project_id - ) + estimate = Estimate.objects.get(pk=estimate_id, workspace__slug=slug, project_id=project_id) serializer = EstimateReadSerializer(estimate) return Response(serializer.data, status=status.HTTP_200_OK) - @invalidate_cache( - path="/api/workspaces/:slug/estimates/", url_params=True, user=False - ) + @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) def partial_update(self, request, slug, project_id, estimate_id): if not len(request.data.get("estimate_points", [])): return Response( @@ -127,9 +119,7 @@ class BulkEstimatePointEndpoint(BaseViewSet): estimate_points_data = request.data.get("estimate_points", []) estimate_points = EstimatePoint.objects.filter( - pk__in=[ - estimate_point.get("id") for estimate_point in estimate_points_data - ], + pk__in=[estimate_point.get("id") for estimate_point in estimate_points_data], workspace__slug=slug, project_id=project_id, estimate_id=estimate_id, @@ -138,34 +128,20 @@ class BulkEstimatePointEndpoint(BaseViewSet): updated_estimate_points = [] for estimate_point in estimate_points: # Find the data for that estimate point - estimate_point_data = [ - point - for point in estimate_points_data - if point.get("id") == str(estimate_point.id) - ] + estimate_point_data = [point for point in estimate_points_data if point.get("id") == str(estimate_point.id)] if len(estimate_point_data): - estimate_point.value = estimate_point_data[0].get( - "value", estimate_point.value - ) - estimate_point.key = estimate_point_data[0].get( - "key", estimate_point.key - ) + estimate_point.value = estimate_point_data[0].get("value", estimate_point.value) + estimate_point.key = estimate_point_data[0].get("key", estimate_point.key) updated_estimate_points.append(estimate_point) - EstimatePoint.objects.bulk_update( - updated_estimate_points, ["key", "value"], batch_size=10 - ) + EstimatePoint.objects.bulk_update(updated_estimate_points, ["key", "value"], batch_size=10) estimate_serializer = EstimateReadSerializer(estimate) return Response(estimate_serializer.data, status=status.HTTP_200_OK) - @invalidate_cache( - path="/api/workspaces/:slug/estimates/", url_params=True, user=False - ) + @invalidate_cache(path="/api/workspaces/:slug/estimates/", url_params=True, user=False) def destroy(self, request, slug, project_id, estimate_id): - estimate = Estimate.objects.get( - pk=estimate_id, workspace__slug=slug, project_id=project_id - ) + estimate = Estimate.objects.get(pk=estimate_id, workspace__slug=slug, project_id=project_id) estimate.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -196,9 +172,7 @@ class EstimatePointEndpoint(BaseViewSet): project_id=project_id, workspace__slug=slug, ) - serializer = EstimatePointSerializer( - estimate_point, data=request.data, partial=True - ) + serializer = EstimatePointSerializer(estimate_point, data=request.data, partial=True) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) serializer.save() @@ -220,24 +194,12 @@ class EstimatePointEndpoint(BaseViewSet): for issue in issues: issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps( - { - "estimate_point": ( - str(new_estimate_id) if new_estimate_id else None - ) - } - ), + requested_data=json.dumps({"estimate_point": (str(new_estimate_id) if new_estimate_id else None)}), actor_id=str(request.user.id), issue_id=issue.id, project_id=str(project_id), current_instance=json.dumps( - { - "estimate_point": ( - str(issue.estimate_point_id) - if issue.estimate_point_id - else None - ) - } + {"estimate_point": (str(issue.estimate_point_id) if issue.estimate_point_id else None)} ), epoch=int(timezone.now().timestamp()), ) @@ -256,13 +218,7 @@ class EstimatePointEndpoint(BaseViewSet): issue_id=issue.id, project_id=str(project_id), current_instance=json.dumps( - { - "estimate_point": ( - str(issue.estimate_point_id) - if issue.estimate_point_id - else None - ) - } + {"estimate_point": (str(issue.estimate_point_id) if issue.estimate_point_id else None)} ), epoch=int(timezone.now().timestamp()), ) @@ -277,9 +233,7 @@ class EstimatePointEndpoint(BaseViewSet): estimate_point.key -= 1 updated_estimate_points.append(estimate_point) - EstimatePoint.objects.bulk_update( - updated_estimate_points, ["key"], batch_size=10 - ) + EstimatePoint.objects.bulk_update(updated_estimate_points, ["key"], batch_size=10) old_estimate_point.delete() diff --git a/apps/api/plane/app/views/exporter/base.py b/apps/api/plane/app/views/exporter/base.py index 8e683e56d..5f446ff94 100644 --- a/apps/api/plane/app/views/exporter/base.py +++ b/apps/api/plane/app/views/exporter/base.py @@ -62,18 +62,16 @@ class ExportIssuesEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): - exporter_history = ExporterHistory.objects.filter( - workspace__slug=slug, type="issue_exports" - ).select_related("workspace", "initiated_by") + exporter_history = ExporterHistory.objects.filter(workspace__slug=slug, type="issue_exports").select_related( + "workspace", "initiated_by" + ) if request.GET.get("per_page", False) and request.GET.get("cursor", False): return self.paginate( order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=exporter_history, - on_results=lambda exporter_history: ExporterHistorySerializer( - exporter_history, many=True - ).data, + on_results=lambda exporter_history: ExporterHistorySerializer(exporter_history, many=True).data, ) else: return Response( diff --git a/apps/api/plane/app/views/external/base.py b/apps/api/plane/app/views/external/base.py index 864d0ff8c..2c554bbc8 100644 --- a/apps/api/plane/app/views/external/base.py +++ b/apps/api/plane/app/views/external/base.py @@ -108,8 +108,7 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]: if model not in provider.models: log_exception( ValueError( - f"Model {model} not supported by {provider.name}. " - f"Supported models: {', '.join(provider.models)}" + f"Model {model} not supported by {provider.name}. Supported models: {', '.join(provider.models)}" ) ) return None, None, None @@ -117,9 +116,7 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]: return api_key, model, provider_key -def get_llm_response( - task, prompt, api_key: str, model: str, provider: str -) -> Tuple[str | None, str | None]: +def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]: """Helper to get LLM completion response""" final_text = task + "\n" + prompt try: @@ -157,13 +154,9 @@ class GPTIntegrationEndpoint(BaseAPIView): task = request.data.get("task", False) if not task: - return Response( - {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST) - text, error = get_llm_response( - task, request.data.get("prompt", False), api_key, model, provider - ) + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) if not text and error: return Response( {"error": "An internal error has occurred."}, @@ -197,13 +190,9 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView): task = request.data.get("task", False) if not task: - return Response( - {"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST) - text, error = get_llm_response( - task, request.data.get("prompt", False), api_key, model, provider - ) + text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider) if not text and error: return Response( {"error": "An internal error has occurred."}, diff --git a/apps/api/plane/app/views/intake/base.py b/apps/api/plane/app/views/intake/base.py index 1ca9e3970..cc6379131 100644 --- a/apps/api/plane/app/views/intake/base.py +++ b/apps/api/plane/app/views/intake/base.py @@ -28,6 +28,7 @@ from plane.db.models import ( ProjectMember, CycleIssue, IssueDescriptionVersion, + WorkspaceMember, ) from plane.app.serializers import ( IssueCreateSerializer, @@ -59,11 +60,7 @@ class IntakeViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) - .annotate( - pending_issue_count=Count( - "issue_intake", filter=Q(issue_intake__status=-2) - ) - ) + .annotate(pending_issue_count=Count("issue_intake", filter=Q(issue_intake__status=-2))) .select_related("workspace", "project") ) @@ -78,9 +75,7 @@ class IntakeViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def destroy(self, request, slug, project_id, pk): - intake = Intake.objects.filter( - workspace__slug=slug, project_id=project_id, pk=pk - ).first() + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk).first() # Handle default intake delete if intake.is_default: return Response( @@ -108,16 +103,12 @@ class IntakeIssueViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_intake", - queryset=IntakeIssue.objects.only( - "status", "duplicate_to", "snoozed_till", "source" - ), + queryset=IntakeIssue.objects.only("status", "duplicate_to", "snoozed_till", "source"), ) ) .annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -146,10 +137,7 @@ class IntakeIssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -182,20 +170,14 @@ class IntakeIssueViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): - intake = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() if not intake: - return Response( - {"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Intake not found"}, status=status.HTTP_404_NOT_FOUND) project = Project.objects.get(pk=project_id) filters = issue_filters(request.GET, "GET", "issue__") intake_issue = ( - IntakeIssue.objects.filter( - intake_id=intake.id, project_id=project_id, **filters - ) + IntakeIssue.objects.filter(intake_id=intake.id, project_id=project_id, **filters) .select_related("issue") .prefetch_related("issue__labels") .annotate( @@ -203,21 +185,14 @@ class IntakeIssueViewSet(BaseViewSet): ArrayAgg( "issue__labels__id", distinct=True, - filter=Q( - ~Q(issue__labels__id__isnull=True) - & Q(issue__label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ) ) ).order_by(request.GET.get("order_by", "-issue__created_at")) # Intake status filter - intake_status = [ - item - for item in request.GET.get("status", "-2").split(",") - if item != "null" - ] + intake_status = [item for item in request.GET.get("status", "-2").split(",") if item != "null"] if intake_status: intake_issue = intake_issue.filter(status__in=intake_status) @@ -235,17 +210,13 @@ class IntakeIssueViewSet(BaseViewSet): return self.paginate( request=request, queryset=(intake_issue), - on_results=lambda intake_issues: IntakeIssueSerializer( - intake_issues, many=True - ).data, + on_results=lambda intake_issues: IntakeIssueSerializer(intake_issues, many=True).data, ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def create(self, request, slug, project_id): if not request.data.get("issue", {}).get("name", False): - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) # Check for valid priority if request.data.get("issue", {}).get("priority", "none") not in [ @@ -255,9 +226,7 @@ class IntakeIssueViewSet(BaseViewSet): "urgent", "none", ]: - return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST) # create an issue project = Project.objects.get(pk=project_id) @@ -271,9 +240,7 @@ class IntakeIssueViewSet(BaseViewSet): ) if serializer.is_valid(): serializer.save() - intake_id = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() # create an intake issue intake_issue = IntakeIssue.objects.create( intake_id=intake_id.id, @@ -310,8 +277,7 @@ class IntakeIssueViewSet(BaseViewSet): "issue__labels__id", distinct=True, filter=Q( - ~Q(issue__labels__id__isnull=True) - & Q(issue__label_issue__deleted_at__isnull=True) + ~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True) ), ), Value([], output_field=ArrayField(UUIDField())), @@ -339,26 +305,38 @@ class IntakeIssueViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue) def partial_update(self, request, slug, project_id, pk): - intake_id = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() intake_issue = IntakeIssue.objects.get( issue_id=pk, workspace__slug=slug, project_id=project_id, intake_id=intake_id, ) - # Get the project member - project_member = ProjectMember.objects.get( + + project_member = ProjectMember.objects.filter( workspace__slug=slug, project_id=project_id, member=request.user, is_active=True, - ) + ).first() + + is_workspace_admin = WorkspaceMember.objects.filter( + workspace__slug=slug, + is_active=True, + member=request.user, + role=ROLE.ADMIN.value, + ).exists() + + if not project_member and not is_workspace_admin: + return Response( + {"error": "Only admin or creator can update the intake work items"}, + status=status.HTTP_403_FORBIDDEN, + ) + # Only project members admins and created_by users can access this endpoint - if project_member.role <= 5 and str(intake_issue.created_by_id) != str( - request.user.id - ): + if ((project_member and project_member.role <= ROLE.GUEST.value) and not is_workspace_admin) and str( + intake_issue.created_by_id + ) != str(request.user.id): return Response( {"error": "You cannot edit intake issues"}, status=status.HTTP_400_BAD_REQUEST, @@ -372,10 +350,7 @@ class IntakeIssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -383,26 +358,20 @@ class IntakeIssueViewSet(BaseViewSet): ArrayAgg( "assignees__id", distinct=True, - filter=Q( - ~Q(assignees__id__isnull=True) - & Q(issue_assignee__deleted_at__isnull=True) - ), + filter=Q(~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), ).get(pk=intake_issue.issue_id, workspace__slug=slug, project_id=project_id) - # Only allow guests to edit name and description - if project_member.role <= 5: + + if project_member and project_member.role <= ROLE.GUEST.value: issue_data = { "name": issue_data.get("name", issue.name), - "description_html": issue_data.get( - "description_html", issue.description_html - ), + "description_html": issue_data.get("description_html", issue.description_html), "description": issue_data.get("description", issue.description), } - current_instance = json.dumps( - IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder - ) + + current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) issue_serializer = IssueCreateSerializer( issue, data=issue_data, partial=True, context={"project_id": project_id} @@ -432,18 +401,12 @@ class IntakeIssueViewSet(BaseViewSet): ) issue_serializer.save() else: - return Response( - issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST - ) + return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) - # Only project admins and members can edit intake issue attributes - if project_member.role > 15: - serializer = IntakeIssueSerializer( - intake_issue, data=request.data, partial=True - ) - current_instance = json.dumps( - IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder - ) + # Only project admins can edit intake issue attributes + if (project_member and project_member.role > ROLE.MEMBER.value) or is_workspace_admin: + serializer = IntakeIssueSerializer(intake_issue, data=request.data, partial=True) + current_instance = json.dumps(IntakeIssueSerializer(intake_issue).data, cls=DjangoJSONEncoder) if serializer.is_valid(): serializer.save() # Update the issue state if the issue is rejected or marked as duplicate @@ -453,9 +416,7 @@ class IntakeIssueViewSet(BaseViewSet): workspace__slug=slug, project_id=project_id, ) - state = State.objects.filter( - group="cancelled", workspace__slug=slug, project_id=project_id - ).first() + state = State.objects.filter(group="cancelled", workspace__slug=slug, project_id=project_id).first() if state is not None: issue.state = state issue.save() @@ -471,9 +432,7 @@ class IntakeIssueViewSet(BaseViewSet): # Update the issue state only if it is in triage state if issue.state.is_triage: # Move to default state - state = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True - ).first() + state = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).first() if state is not None: issue.state = state issue.save() @@ -500,8 +459,7 @@ class IntakeIssueViewSet(BaseViewSet): "issue__labels__id", distinct=True, filter=Q( - ~Q(issue__labels__id__isnull=True) - & Q(issue__label_issue__deleted_at__isnull=True) + ~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True) ), ), Value([], output_field=ArrayField(UUIDField())), @@ -527,13 +485,9 @@ class IntakeIssueViewSet(BaseViewSet): serializer = IntakeIssueDetailSerializer(intake_issue).data return Response(serializer, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue) def retrieve(self, request, slug, project_id, pk): - intake_id = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() project = Project.objects.get(pk=project_id) intake_issue = ( IntakeIssue.objects.select_related("issue") @@ -543,10 +497,7 @@ class IntakeIssueViewSet(BaseViewSet): ArrayAgg( "issue__labels__id", distinct=True, - filter=Q( - ~Q(issue__labels__id__isnull=True) - & Q(issue__label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(issue__labels__id__isnull=True) & Q(issue__label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -555,8 +506,7 @@ class IntakeIssueViewSet(BaseViewSet): "issue__assignees__id", distinct=True, filter=Q( - ~Q(issue__assignees__id__isnull=True) - & Q(issue__issue_assignee__deleted_at__isnull=True) + ~Q(issue__assignees__id__isnull=True) & Q(issue__issue_assignee__deleted_at__isnull=True) ), ), Value([], output_field=ArrayField(UUIDField())), @@ -584,9 +534,7 @@ class IntakeIssueViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue) def destroy(self, request, slug, project_id, pk): - intake_id = Intake.objects.filter( - workspace__slug=slug, project_id=project_id - ).first() + intake_id = Intake.objects.filter(workspace__slug=slug, project_id=project_id).first() intake_issue = IntakeIssue.objects.get( issue_id=pk, workspace__slug=slug, @@ -597,9 +545,7 @@ class IntakeIssueViewSet(BaseViewSet): # Check the issue status if intake_issue.status in [-2, -1, 0, 2]: # Delete the issue also - issue = Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk=pk - ).first() + issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk).first() issue.delete() intake_issue.delete() @@ -611,18 +557,14 @@ class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView): paginated_data = results.values(*fields) datetime_fields = ["created_at", "updated_at"] - paginated_data = user_timezone_converter( - paginated_data, datetime_fields, timezone - ) + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) return paginated_data @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, work_item_id, pk=None): project = Project.objects.get(pk=project_id) - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=work_item_id - ) + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=work_item_id) if ( ProjectMember.objects.filter( @@ -648,9 +590,7 @@ class IntakeWorkItemDescriptionVersionEndpoint(BaseAPIView): pk=pk, ) - serializer = IssueDescriptionVersionDetailSerializer( - issue_description_version - ) + serializer = IssueDescriptionVersionDetailSerializer(issue_description_version) return Response(serializer.data, status=status.HTTP_200_OK) cursor = request.GET.get("cursor", None) diff --git a/apps/api/plane/app/views/issue/activity.py b/apps/api/plane/app/views/issue/activity.py index b9ef58ffd..fdfcd129a 100644 --- a/apps/api/plane/app/views/issue/activity.py +++ b/apps/api/plane/app/views/issue/activity.py @@ -63,9 +63,7 @@ class IssueActivityEndpoint(BaseAPIView): issue_activities = issue_activities.prefetch_related( Prefetch( "issue__issue_intake", - queryset=IntakeIssue.objects.only( - "source_email", "source", "extra" - ), + queryset=IntakeIssue.objects.only("source_email", "source", "extra"), to_attr="source_data", ) ) diff --git a/apps/api/plane/app/views/issue/archive.py b/apps/api/plane/app/views/issue/archive.py index 122f4bdc8..b8f858969 100644 --- a/apps/api/plane/app/views/issue/archive.py +++ b/apps/api/plane/app/views/issue/archive.py @@ -1,9 +1,10 @@ # Python imports +import copy import json # Django imports from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery, Count +from django.db.models import OuterRef, Q, Prefetch, Exists, Subquery, Count from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -41,31 +42,22 @@ from plane.utils.host import base_host # Module imports from .. import BaseViewSet, BaseAPIView +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet class IssueArchiveViewSet(BaseViewSet): serializer_class = IssueFlatSerializer model = Issue - def get_queryset(self): + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + + def apply_annotations(self, issues): return ( - Issue.objects.annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(deleted_at__isnull=True) - .filter(archived_at__isnull=False) - .filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( + issues.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -95,6 +87,15 @@ class IssueArchiveViewSet(BaseViewSet): .values("count") ) ) + .prefetch_related("assignees", "labels", "issue_module__module") + ) + + def get_queryset(self): + return ( + Issue.objects.filter(Q(type__isnull=True) | Q(type__is_epic=False)) + .filter(archived_at__isnull=False) + .filter(project_id=self.kwargs.get("project_id")) + .filter(workspace__slug=self.kwargs.get("slug")) ) @method_decorator(gzip_page) @@ -105,26 +106,21 @@ class IssueArchiveViewSet(BaseViewSet): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = self.get_queryset().filter(**filters) + issue_queryset = self.get_queryset() - total_issue_queryset = Issue.objects.filter( - deleted_at__isnull=True, - archived_at__isnull=False, - project_id=project_id, - workspace__slug=slug, - ).filter(**filters) + issue_queryset = issue_queryset if show_sub_issues == "true" else issue_queryset.filter(parent__isnull=True) + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) - total_issue_queryset = ( - total_issue_queryset - if show_sub_issues == "true" - else total_issue_queryset.filter(parent__isnull=True) - ) + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Applying annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) - issue_queryset = ( - issue_queryset - if show_sub_issues == "true" - else issue_queryset.filter(parent__isnull=True) - ) # Issue queryset issue_queryset, order_by_param = order_issue_queryset( issue_queryset=issue_queryset, order_by_param=order_by_param @@ -135,18 +131,14 @@ class IssueArchiveViewSet(BaseViewSet): sub_group_by = request.GET.get("sub_group_by", False) # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by - ) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) if group_by: # Check group and sub group value paginate if sub_group_by: if group_by == sub_group_by: return Response( - { - "error": "Group by and sub group by cannot have same parameters" - }, + {"error": "Group by and sub group by cannot have same parameters"}, status=status.HTTP_400_BAD_REQUEST, ) else: @@ -218,9 +210,7 @@ class IssueArchiveViewSet(BaseViewSet): request=request, queryset=issue_queryset, total_count_queryset=total_issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -261,9 +251,7 @@ class IssueArchiveViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def archive(self, request, slug, project_id, pk=None): - issue = Issue.issue_objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + issue = Issue.issue_objects.get(workspace__slug=slug, project_id=project_id, pk=pk) if issue.state.group not in ["completed", "cancelled"]: return Response( {"error": "Can only archive completed or cancelled state group issue"}, @@ -271,15 +259,11 @@ class IssueArchiveViewSet(BaseViewSet): ) issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps( - {"archived_at": str(timezone.now().date()), "automation": False} - ), + requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}), actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ), + current_instance=json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, origin=base_host(request=request, is_app=True), @@ -287,9 +271,7 @@ class IssueArchiveViewSet(BaseViewSet): issue.archived_at = timezone.now().date() issue.save() - return Response( - {"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK - ) + return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def unarchive(self, request, slug, project_id, pk=None): @@ -305,9 +287,7 @@ class IssueArchiveViewSet(BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ), + current_instance=json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, origin=base_host(request=request, is_app=True), @@ -326,13 +306,11 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): issue_ids = request.data.get("issue_ids", []) if not len(issue_ids): - return Response( - {"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST) - issues = Issue.objects.filter( - workspace__slug=slug, project_id=project_id, pk__in=issue_ids - ).select_related("state") + issues = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids).select_related( + "state" + ) bulk_archive_issues = [] for issue in issues: if issue.state.group not in ["completed", "cancelled"]: @@ -345,15 +323,11 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): ) issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps( - {"archived_at": str(timezone.now().date()), "automation": False} - ), + requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}), actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project_id), - current_instance=json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ), + current_instance=json.dumps(IssueSerializer(issue).data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), notification=True, origin=base_host(request=request, is_app=True), @@ -362,6 +336,4 @@ class BulkArchiveIssuesEndpoint(BaseAPIView): bulk_archive_issues.append(issue) Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"]) - return Response( - {"archived_at": str(timezone.now().date())}, status=status.HTTP_200_OK - ) + return Response({"archived_at": str(timezone.now().date())}, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/issue/attachment.py b/apps/api/plane/app/views/issue/attachment.py index 423710e4a..7b7ecf378 100644 --- a/apps/api/plane/app/views/issue/attachment.py +++ b/apps/api/plane/app/views/issue/attachment.py @@ -75,9 +75,7 @@ class IssueAttachmentEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, issue_id): - issue_attachments = FileAsset.objects.filter( - issue_id=issue_id, workspace__slug=slug, project_id=project_id - ) + issue_attachments = FileAsset.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id) serializer = IssueAttachmentSerializer(issue_attachments, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -123,9 +121,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size_limit - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size_limit) # Return the presigned URL return Response( @@ -140,9 +136,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): @allow_permission([ROLE.ADMIN], creator=True, model=FileAsset) def delete(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id - ) + issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) issue_attachment.is_deleted = True issue_attachment.deleted_at = timezone.now() issue_attachment.save() @@ -165,9 +159,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): def get(self, request, slug, project_id, issue_id, pk=None): if pk: # Get the asset - asset = FileAsset.objects.get( - id=pk, workspace__slug=slug, project_id=project_id - ) + asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id) # Check if the asset is uploaded if not asset.is_uploaded: @@ -198,9 +190,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def patch(self, request, slug, project_id, issue_id, pk): - issue_attachment = FileAsset.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id - ) + issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) serializer = IssueAttachmentSerializer(issue_attachment) # Send this activity only if the attachment is not uploaded before diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 4d0d4457e..c24db6169 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -1,4 +1,5 @@ # Python imports +import copy import json # Django imports @@ -6,16 +7,16 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( + Count, Exists, F, Func, OuterRef, Prefetch, Q, + Subquery, UUIDField, Value, - Subquery, - Count, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -27,73 +28,85 @@ from rest_framework import status from rest_framework.response import Response # Module imports -from plane.app.permissions import allow_permission, ROLE +from plane.app.permissions import ROLE, allow_permission from plane.app.serializers import ( IssueCreateSerializer, IssueDetailSerializer, - IssueUserPropertySerializer, - IssueSerializer, IssueListDetailSerializer, + IssueSerializer, + IssueUserPropertySerializer, ) from plane.bgtasks.issue_activities_task import issue_activity +from plane.bgtasks.issue_description_version_task import issue_description_version_task +from plane.bgtasks.recent_visited_task import recent_visited_task +from plane.bgtasks.webhook_task import model_activity from plane.db.models import ( - Issue, - FileAsset, - IssueLink, - IssueUserProperty, - IssueReaction, - IssueSubscriber, - Project, - ProjectMember, CycleIssue, - UserRecentVisit, - ModuleIssue, - IssueRelation, + FileAsset, + IntakeIssue, + Issue, IssueAssignee, IssueLabel, - IntakeIssue, + IssueLink, + IssueReaction, + IssueRelation, + IssueSubscriber, + IssueUserProperty, + ModuleIssue, + Project, + ProjectMember, + UserRecentVisit, ) +from plane.utils.filters import ComplexFilterBackend, IssueFilterSet +from plane.utils.global_paginator import paginate from plane.utils.grouper import ( issue_group_values, issue_on_results, issue_queryset_grouper, ) +from plane.utils.host import base_host 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.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 -from plane.utils.host import base_host + +from .. import BaseAPIView, BaseViewSet class IssueListEndpoint(BaseAPIView): + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id): issue_ids = request.GET.get("issues", False) if not issue_ids: - return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""] - queryset = ( - Issue.issue_objects.filter( - workspace__slug=slug, project_id=project_id, pk__in=issue_ids + # Base queryset with basic filters + queryset = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids) + + # Apply filtering from filterset + queryset = self.filter_queryset(queryset) + + # Apply legacy filters + filters = issue_filters(request.query_params, "GET") + issue_queryset = queryset.filter(**filters) + + # Add select_related, prefetch_related if fields or expand is not None + if self.fields or self.expand: + issue_queryset = issue_queryset.select_related("workspace", "project", "state", "parent").prefetch_related( + "assignees", "labels", "issue_module__module" ) - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( + + # Add annotations + issue_queryset = ( + issue_queryset.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -117,25 +130,19 @@ class IssueListEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - ).distinct() - - filters = issue_filters(request.query_params, "GET") + .distinct() + ) order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = queryset.filter(**filters) # Issue queryset - issue_queryset, _ = order_issue_queryset( - issue_queryset=issue_queryset, order_by_param=order_by_param - ) + issue_queryset, _ = order_issue_queryset(issue_queryset=issue_queryset, order_by_param=order_by_param) # Group by group_by = request.GET.get("group_by", False) sub_group_by = request.GET.get("sub_group_by", False) # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by - ) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) recent_visited_task.delay( slug=slug, @@ -146,9 +153,7 @@ class IssueListEndpoint(BaseAPIView): ) if self.fields or self.expand: - issues = IssueSerializer( - queryset, many=True, fields=self.fields, expand=self.expand - ).data + issues = IssueSerializer(queryset, many=True, fields=self.fields, expand=self.expand).data else: issues = issue_queryset.values( "id", @@ -179,38 +184,33 @@ class IssueListEndpoint(BaseAPIView): "deleted_at", ) datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone) return Response(issues, status=status.HTTP_200_OK) class IssueViewSet(BaseViewSet): - def get_serializer_class(self): - return ( - IssueCreateSerializer - if self.action in ["create", "update", "partial_update"] - else IssueSerializer - ) - model = Issue webhook_event = "issue" - search_fields = ["name"] + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet - filterset_fields = ["state__name", "assignees__id", "workspace__id"] + def get_serializer_class(self): + return IssueCreateSerializer if self.action in ["create", "update", "partial_update"] else IssueSerializer def get_queryset(self): - return ( - Issue.issue_objects.filter(project_id=self.kwargs.get("project_id")) - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( + issues = Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + ).distinct() + + return issues + + def apply_annotations(self, issues): + issues = ( + issues.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -242,6 +242,8 @@ class IssueViewSet(BaseViewSet): ) ) + return issues + @method_decorator(gzip_page) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): @@ -250,15 +252,24 @@ class IssueViewSet(BaseViewSet): extra_filters = {"updated_at__gt": request.GET.get("updated_at__gt")} project = Project.objects.get(pk=project_id, workspace__slug=slug) - filters = issue_filters(request.query_params, "GET") + query_params = request.query_params.copy() + + filters = issue_filters(query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = self.get_queryset().filter(**filters, **extra_filters) - # Custom ordering for priority and state + issue_queryset = self.get_queryset() - total_issue_queryset = Issue.issue_objects.filter( - project_id=project_id, workspace__slug=slug - ).filter(**filters, **extra_filters) + # Apply rich filters + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters, **extra_filters) + + # Keeping a copy of the queryset before applying annotations + filtered_issue_queryset = copy.deepcopy(issue_queryset) + + # Applying annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) # Issue queryset issue_queryset, order_by_param = order_issue_queryset( @@ -270,9 +281,7 @@ class IssueViewSet(BaseViewSet): sub_group_by = request.GET.get("sub_group_by", False) # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by - ) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) recent_visited_task.delay( slug=slug, @@ -292,14 +301,14 @@ class IssueViewSet(BaseViewSet): and not project.guest_view_all_features ): issue_queryset = issue_queryset.filter(created_by=request.user) - total_issue_queryset = total_issue_queryset.filter(created_by=request.user) + filtered_issue_queryset = filtered_issue_queryset.filter(created_by=request.user) if group_by: if sub_group_by: if group_by == sub_group_by: return Response( { - "error": "Group by and sub group by cannot have same parameters" + "error": "Group by and sub group by cannot have same parameters" # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -308,7 +317,7 @@ class IssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, - total_count_queryset=total_issue_queryset, + total_count_queryset=filtered_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -318,12 +327,14 @@ class IssueViewSet(BaseViewSet): slug=slug, project_id=project_id, filters=filters, + queryset=filtered_issue_queryset, ), sub_group_by_fields=issue_group_values( field=sub_group_by, slug=slug, project_id=project_id, filters=filters, + queryset=filtered_issue_queryset, ), group_by_field_name=group_by, sub_group_by_field_name=sub_group_by, @@ -342,7 +353,7 @@ class IssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, - total_count_queryset=total_issue_queryset, + total_count_queryset=filtered_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -352,6 +363,7 @@ class IssueViewSet(BaseViewSet): slug=slug, project_id=project_id, filters=filters, + queryset=filtered_issue_queryset, ), group_by_field_name=group_by, count_filter=Q( @@ -368,10 +380,8 @@ class IssueViewSet(BaseViewSet): order_by=order_by_param, request=request, queryset=issue_queryset, - total_count_queryset=total_issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), + total_count_queryset=filtered_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -402,9 +412,11 @@ class IssueViewSet(BaseViewSet): notification=True, origin=base_host(request=request, is_app=True), ) + queryset = self.get_queryset() + queryset = self.apply_annotations(queryset) issue = ( issue_queryset_grouper( - queryset=self.get_queryset().filter(pk=serializer.data["id"]), + queryset=queryset.filter(pk=serializer.data["id"]), group_by=None, sub_group_by=None, ) @@ -439,9 +451,7 @@ class IssueViewSet(BaseViewSet): .first() ) datetime_fields = ["created_at", "updated_at"] - issue = user_timezone_converter( - issue, datetime_fields, request.user.user_timezone - ) + issue = user_timezone_converter(issue, datetime_fields, request.user.user_timezone) # Send the model activity model_activity.delay( model_name="issue", @@ -462,9 +472,7 @@ class IssueViewSet(BaseViewSet): return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], creator=True, model=Issue) def retrieve(self, request, slug, project_id, pk=None): project = Project.objects.get(pk=project_id, workspace__slug=slug) @@ -475,13 +483,7 @@ class IssueViewSet(BaseViewSet): pk=pk, ) .select_related("state") - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[ - :1 - ] - ) - ) + .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) .annotate( link_count=Subquery( IssueLink.objects.filter(issue=OuterRef("id")) @@ -605,21 +607,17 @@ class IssueViewSet(BaseViewSet): serializer = IssueDetailSerializer(issue, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], creator=True, model=Issue) def partial_update(self, request, slug, project_id, pk=None): + queryset = self.get_queryset() + queryset = self.apply_annotations(queryset) issue = ( - self.get_queryset() - .annotate( + queryset.annotate( label_ids=Coalesce( ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -653,18 +651,12 @@ class IssueViewSet(BaseViewSet): ) if not issue: - return Response( - {"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) - current_instance = json.dumps( - IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - serializer = IssueCreateSerializer( - issue, data=request.data, partial=True, context={"project_id": project_id} - ) + serializer = IssueCreateSerializer(issue, data=request.data, partial=True, context={"project_id": project_id}) if serializer.is_valid(): serializer.save() issue_activity.delay( @@ -726,26 +718,19 @@ class IssueViewSet(BaseViewSet): class IssueUserDisplayPropertyEndpoint(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def patch(self, request, slug, project_id): - issue_property = IssueUserProperty.objects.get( - user=request.user, project_id=project_id - ) + issue_property = IssueUserProperty.objects.get(user=request.user, project_id=project_id) + issue_property.rich_filters = request.data.get("rich_filters", issue_property.rich_filters) issue_property.filters = request.data.get("filters", issue_property.filters) - issue_property.display_filters = request.data.get( - "display_filters", issue_property.display_filters - ) - issue_property.display_properties = request.data.get( - "display_properties", issue_property.display_properties - ) + issue_property.display_filters = request.data.get("display_filters", issue_property.display_filters) + issue_property.display_properties = request.data.get("display_properties", issue_property.display_properties) issue_property.save() serializer = IssueUserPropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_201_CREATED) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id): - issue_property, _ = IssueUserProperty.objects.get_or_create( - user=request.user, project_id=project_id - ) + issue_property, _ = IssueUserProperty.objects.get_or_create(user=request.user, project_id=project_id) serializer = IssueUserPropertySerializer(issue_property) return Response(serializer.data, status=status.HTTP_200_OK) @@ -756,13 +741,9 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): issue_ids = request.data.get("issue_ids", []) if not len(issue_ids): - return Response( - {"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Issue IDs are required"}, status=status.HTTP_400_BAD_REQUEST) - issues = Issue.issue_objects.filter( - workspace__slug=slug, project_id=project_id, pk__in=issue_ids - ) + issues = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk__in=issue_ids) total_issues = len(issues) @@ -802,19 +783,11 @@ class IssuePaginatedViewSet(BaseViewSet): workspace_slug = self.kwargs.get("slug") project_id = self.kwargs.get("project_id") - issue_queryset = Issue.issue_objects.filter( - workspace__slug=workspace_slug, project_id=project_id - ) + issue_queryset = Issue.issue_objects.filter(workspace__slug=workspace_slug, project_id=project_id) return ( issue_queryset.select_related("state") - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[ - :1 - ] - ) - ) + .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) .annotate( link_count=Subquery( IssueLink.objects.filter(issue=OuterRef("id")) @@ -849,9 +822,7 @@ class IssuePaginatedViewSet(BaseViewSet): # converting the datetime fields in paginated data datetime_fields = ["created_at", "updated_at"] - paginated_data = user_timezone_converter( - paginated_data, datetime_fields, timezone - ) + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) return paginated_data @@ -895,9 +866,7 @@ class IssuePaginatedViewSet(BaseViewSet): required_fields.append("description_html") # querying issues - base_queryset = Issue.issue_objects.filter( - workspace__slug=slug, project_id=project_id - ) + base_queryset = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) base_queryset = base_queryset.order_by("updated_at") queryset = self.get_queryset().order_by("updated_at") @@ -969,65 +938,14 @@ class IssuePaginatedViewSet(BaseViewSet): class IssueDetailEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def get(self, request, slug, project_id): - filters = issue_filters(request.query_params, "GET") + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet - # check for the project member role, if the role is 5 then check for the guest_view_all_features - # if it is true then show all the issues else show only the issues created by the user - permission_subquery = ( - Issue.issue_objects.filter( - workspace__slug=slug, project_id=project_id, id=OuterRef("id") - ) - .filter( - Q( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__project_projectmember__role__gt=ROLE.GUEST.value, - ) - | Q( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__project_projectmember__role=ROLE.GUEST.value, - project__guest_view_all_features=True, - ) - | Q( - project__project_projectmember__member=self.request.user, - project__project_projectmember__is_active=True, - project__project_projectmember__role=ROLE.GUEST.value, - project__guest_view_all_features=False, - created_by=self.request.user, - ) - ) - .values("id") - ) - # Main issue query - issue = ( - Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id) - .filter(Exists(permission_subquery)) - .prefetch_related( - Prefetch( - "issue_assignee", - queryset=IssueAssignee.objects.all(), - ) - ) - .prefetch_related( - Prefetch( - "label_issue", - queryset=IssueLabel.objects.all(), - ) - ) - .prefetch_related( - Prefetch( - "issue_module", - queryset=ModuleIssue.objects.all(), - ) - ) - .annotate( + def apply_annotations(self, issues): + return ( + issues.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -1051,6 +969,59 @@ class IssueDetailEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "label_issue", + queryset=IssueLabel.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.all(), + ) + ) + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + + # check for the project member role, if the role is 5 then check for the guest_view_all_features + # if it is true then show all the issues else show only the issues created by the user + permission_subquery = ( + Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, id=OuterRef("id")) + .filter( + Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role__gt=ROLE.GUEST.value, + ) + | Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role=ROLE.GUEST.value, + project__guest_view_all_features=True, + ) + | Q( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__project_projectmember__role=ROLE.GUEST.value, + project__guest_view_all_features=False, + created_by=self.request.user, + ) + ) + .values("id") + ) + # Main issue query + issue = Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id).filter( + Exists(permission_subquery) ) # Add additional prefetch based on expand parameter @@ -1070,16 +1041,27 @@ class IssueDetailEndpoint(BaseAPIView): ) ) + # Apply filtering from filterset + issue = self.filter_queryset(issue) + + # Apply legacy filters issue = issue.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue) + + # Applying annotations to the issue queryset + issue = self.apply_annotations(issue) + order_by_param = request.GET.get("order_by", "-created_at") + # Issue queryset - issue, order_by_param = order_issue_queryset( - issue_queryset=issue, order_by_param=order_by_param - ) + issue, order_by_param = order_issue_queryset(issue_queryset=issue, order_by_param=order_by_param) return self.paginate( request=request, order_by=order_by_param, - queryset=(issue), + queryset=issue, + total_count_queryset=total_issue_queryset, on_results=lambda issue: IssueListDetailSerializer( issue, many=True, fields=self.fields, expand=self.expand ).data, @@ -1127,9 +1109,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView): start_date = update.get("start_date") target_date = update.get("target_date") - validate_dates = self.validate_dates( - issue.start_date, issue.target_date, start_date, target_date - ) + validate_dates = self.validate_dates(issue.start_date, issue.target_date, start_date, target_date) if not validate_dates: return Response( {"message": "Start date cannot exceed target date"}, @@ -1152,12 +1132,8 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView): if target_date: issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps( - {"target_date": update.get("target_date")} - ), - current_instance=json.dumps( - {"target_date": str(issue.target_date)} - ), + requested_data=json.dumps({"target_date": update.get("target_date")}), + current_instance=json.dumps({"target_date": str(issue.target_date)}), issue_id=str(issue_id), actor_id=str(request.user.id), project_id=str(project_id), @@ -1169,9 +1145,7 @@ class IssueBulkUpdateDateEndpoint(BaseAPIView): # Bulk update issues Issue.objects.bulk_update(issues_to_update, ["start_date", "target_date"]) - return Response( - {"message": "Issues updated successfully"}, status=status.HTTP_200_OK - ) + return Response({"message": "Issues updated successfully"}, status=status.HTTP_200_OK) class IssueMetaEndpoint(BaseAPIView): @@ -1206,9 +1180,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): ) # Fetch the project - project = Project.objects.get( - identifier__iexact=project_identifier, workspace__slug=slug - ) + project = Project.objects.get(identifier__iexact=project_identifier, workspace__slug=slug) # Check if the user is a member of the project if not ProjectMember.objects.filter( @@ -1228,13 +1200,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): .filter(workspace__slug=slug) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( - cycle_id=Subquery( - CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[ - :1 - ] - ) - ) + .annotate(cycle_id=Subquery(CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[:1])) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1262,10 +1228,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), diff --git a/apps/api/plane/app/views/issue/comment.py b/apps/api/plane/app/views/issue/comment.py index 3dbe55eb6..72a986fea 100644 --- a/apps/api/plane/app/views/issue/comment.py +++ b/apps/api/plane/app/views/issue/comment.py @@ -77,9 +77,7 @@ class IssueCommentViewSet(BaseViewSet): ) serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): - serializer.save( - project_id=project_id, issue_id=issue_id, actor=request.user - ) + serializer.save(project_id=project_id, issue_id=issue_id, actor=request.user) issue_activity.delay( type="comment.activity.created", requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), @@ -106,21 +104,12 @@ class IssueCommentViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment) def partial_update(self, request, slug, project_id, issue_id, pk): - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder - ) - serializer = IssueCommentSerializer( - issue_comment, data=request.data, partial=True - ) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) + serializer = IssueCommentSerializer(issue_comment, data=request.data, partial=True) if serializer.is_valid(): - if ( - "comment_html" in request.data - and request.data["comment_html"] != issue_comment.comment_html - ): + if "comment_html" in request.data and request.data["comment_html"] != issue_comment.comment_html: serializer.save(edited_at=timezone.now()) else: serializer.save() @@ -150,12 +139,8 @@ class IssueCommentViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueComment) def destroy(self, request, slug, project_id, issue_id, pk): - issue_comment = IssueComment.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder - ) + issue_comment = IssueComment.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder) issue_comment.delete() issue_activity.delay( type="comment.activity.deleted", diff --git a/apps/api/plane/app/views/issue/label.py b/apps/api/plane/app/views/issue/label.py index 79a8a7770..85e79c011 100644 --- a/apps/api/plane/app/views/issue/label.py +++ b/apps/api/plane/app/views/issue/label.py @@ -35,9 +35,7 @@ class LabelViewSet(BaseViewSet): .order_by("sort_order") ) - @invalidate_cache( - path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True - ) + @invalidate_cache(path="/api/workspaces/:slug/labels/", url_params=True, user=False, multiple=True) @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): try: @@ -58,9 +56,7 @@ class LabelViewSet(BaseViewSet): # Check if the label name is unique within the project if ( "name" in request.data - and Label.objects.filter( - project_id=kwargs["project_id"], name=request.data["name"] - ) + and Label.objects.filter(project_id=kwargs["project_id"], name=request.data["name"]) .exclude(pk=kwargs["pk"]) .exists() ): diff --git a/apps/api/plane/app/views/issue/link.py b/apps/api/plane/app/views/issue/link.py index 0a574dc19..ee209f9fa 100644 --- a/apps/api/plane/app/views/issue/link.py +++ b/apps/api/plane/app/views/issue/link.py @@ -45,9 +45,7 @@ class IssueLinkViewSet(BaseViewSet): serializer = IssueLinkSerializer(data=request.data) if serializer.is_valid(): serializer.save(project_id=project_id, issue_id=issue_id) - crawl_work_item_link_title.delay( - serializer.data.get("id"), serializer.data.get("url") - ) + crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url")) issue_activity.delay( type="link.activity.created", requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder), @@ -67,20 +65,14 @@ class IssueLinkViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) requested_data = json.dumps(request.data, cls=DjangoJSONEncoder) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() - crawl_work_item_link_title.delay( - serializer.data.get("id"), serializer.data.get("url") - ) + crawl_work_item_link_title.delay(serializer.data.get("id"), serializer.data.get("url")) issue_activity.delay( type="link.activity.updated", @@ -100,12 +92,8 @@ class IssueLinkViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, issue_id, pk): - issue_link = IssueLink.objects.get( - workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk - ) - current_instance = json.dumps( - IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder - ) + issue_link = IssueLink.objects.get(workspace__slug=slug, project_id=project_id, issue_id=issue_id, pk=pk) + current_instance = json.dumps(IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder) issue_activity.delay( type="link.activity.deleted", requested_data=json.dumps({"link_id": str(pk)}), diff --git a/apps/api/plane/app/views/issue/reaction.py b/apps/api/plane/app/views/issue/reaction.py index 8700b6345..fe8a63b13 100644 --- a/apps/api/plane/app/views/issue/reaction.py +++ b/apps/api/plane/app/views/issue/reaction.py @@ -42,9 +42,7 @@ class IssueReactionViewSet(BaseViewSet): def create(self, request, slug, project_id, issue_id): serializer = IssueReactionSerializer(data=request.data) if serializer.is_valid(): - serializer.save( - issue_id=issue_id, project_id=project_id, actor=request.user - ) + serializer.save(issue_id=issue_id, project_id=project_id, actor=request.user) issue_activity.delay( type="issue_reaction.activity.created", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), @@ -74,9 +72,7 @@ class IssueReactionViewSet(BaseViewSet): actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - {"reaction": str(reaction_code), "identifier": str(issue_reaction.id)} - ), + current_instance=json.dumps({"reaction": str(reaction_code), "identifier": str(issue_reaction.id)}), epoch=int(timezone.now().timestamp()), notification=True, origin=base_host(request=request, is_app=True), diff --git a/apps/api/plane/app/views/issue/relation.py b/apps/api/plane/app/views/issue/relation.py index 50d319a88..0dfd686d2 100644 --- a/apps/api/plane/app/views/issue/relation.py +++ b/apps/api/plane/app/views/issue/relation.py @@ -37,9 +37,7 @@ class IssueRelationViewSet(BaseViewSet): def list(self, request, slug, project_id, issue_id): issue_relations = ( - IssueRelation.objects.filter( - Q(issue_id=issue_id) | Q(related_issue=issue_id) - ) + IssueRelation.objects.filter(Q(issue_id=issue_id) | Q(related_issue=issue_id)) .filter(workspace__slug=self.kwargs.get("slug")) .select_related("project") .select_related("workspace") @@ -48,19 +46,19 @@ class IssueRelationViewSet(BaseViewSet): .distinct() ) # get all blocking issues - blocking_issues = issue_relations.filter( - relation_type="blocked_by", related_issue_id=issue_id - ).values_list("issue_id", flat=True) + blocking_issues = issue_relations.filter(relation_type="blocked_by", related_issue_id=issue_id).values_list( + "issue_id", flat=True + ) # get all blocked by issues - blocked_by_issues = issue_relations.filter( - relation_type="blocked_by", issue_id=issue_id - ).values_list("related_issue_id", flat=True) + blocked_by_issues = issue_relations.filter(relation_type="blocked_by", issue_id=issue_id).values_list( + "related_issue_id", flat=True + ) # get all duplicate issues - duplicate_issues = issue_relations.filter( - issue_id=issue_id, relation_type="duplicate" - ).values_list("related_issue_id", flat=True) + duplicate_issues = issue_relations.filter(issue_id=issue_id, relation_type="duplicate").values_list( + "related_issue_id", flat=True + ) # get all relates to issues duplicate_issues_related = issue_relations.filter( @@ -68,9 +66,9 @@ class IssueRelationViewSet(BaseViewSet): ).values_list("issue_id", flat=True) # get all relates to issues - relates_to_issues = issue_relations.filter( - issue_id=issue_id, relation_type="relates_to" - ).values_list("related_issue_id", flat=True) + relates_to_issues = issue_relations.filter(issue_id=issue_id, relation_type="relates_to").values_list( + "related_issue_id", flat=True + ) # get all relates to issues relates_to_issues_related = issue_relations.filter( @@ -83,9 +81,9 @@ class IssueRelationViewSet(BaseViewSet): ).values_list("issue_id", flat=True) # get all start_before issues - start_before_issues = issue_relations.filter( - relation_type="start_before", issue_id=issue_id - ).values_list("related_issue_id", flat=True) + start_before_issues = issue_relations.filter(relation_type="start_before", issue_id=issue_id).values_list( + "related_issue_id", flat=True + ) # get all finish after issues finish_after_issues = issue_relations.filter( @@ -93,9 +91,9 @@ class IssueRelationViewSet(BaseViewSet): ).values_list("issue_id", flat=True) # get all finish before issues - finish_before_issues = issue_relations.filter( - relation_type="finish_before", issue_id=issue_id - ).values_list("related_issue_id", flat=True) + finish_before_issues = issue_relations.filter(relation_type="finish_before", issue_id=issue_id).values_list( + "related_issue_id", flat=True + ) queryset = ( Issue.issue_objects.filter(workspace__slug=slug) @@ -103,9 +101,7 @@ class IssueRelationViewSet(BaseViewSet): .prefetch_related("assignees", "labels", "issue_module__module") .annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -134,10 +130,7 @@ class IssueRelationViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & (Q(label_issue__deleted_at__isnull=True)) - ), + filter=Q(~Q(labels__id__isnull=True) & (Q(label_issue__deleted_at__isnull=True))), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -223,15 +216,9 @@ class IssueRelationViewSet(BaseViewSet): issue_relation = IssueRelation.objects.bulk_create( [ IssueRelation( - issue_id=( - issue - if relation_type in ["blocking", "start_after", "finish_after"] - else issue_id - ), + issue_id=(issue if relation_type in ["blocking", "start_after", "finish_after"] else issue_id), related_issue_id=( - issue_id - if relation_type in ["blocking", "start_after", "finish_after"] - else issue + issue_id if relation_type in ["blocking", "start_after", "finish_after"] else issue ), relation_type=(get_actual_relation(relation_type)), project_id=project_id, @@ -274,13 +261,10 @@ class IssueRelationViewSet(BaseViewSet): issue_relations = IssueRelation.objects.filter( workspace__slug=slug, ).filter( - Q(issue_id=related_issue, related_issue_id=issue_id) - | Q(issue_id=issue_id, related_issue_id=related_issue) + Q(issue_id=related_issue, related_issue_id=issue_id) | Q(issue_id=issue_id, related_issue_id=related_issue) ) issue_relations = issue_relations.first() - current_instance = json.dumps( - IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder - ) + current_instance = json.dumps(IssueRelationSerializer(issue_relations).data, cls=DjangoJSONEncoder) issue_relations.delete() issue_activity.delay( type="issue_relation.activity.deleted", diff --git a/apps/api/plane/app/views/issue/sub_issue.py b/apps/api/plane/app/views/issue/sub_issue.py index 0843a9a51..2fa244dcf 100644 --- a/apps/api/plane/app/views/issue/sub_issue.py +++ b/apps/api/plane/app/views/issue/sub_issue.py @@ -37,9 +37,7 @@ class SubIssuesEndpoint(BaseAPIView): .prefetch_related("assignees", "labels", "issue_module__module") .annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -68,10 +66,7 @@ class SubIssuesEndpoint(BaseAPIView): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -109,9 +104,7 @@ class SubIssuesEndpoint(BaseAPIView): group_by = request.GET.get("group_by", False) if order_by_param: - sub_issues, order_by_param = order_issue_queryset( - sub_issues, order_by_param - ) + sub_issues, order_by_param = order_issue_queryset(sub_issues, order_by_param) # create's a dict with state group name with their respective issue id's result = defaultdict(list) @@ -146,9 +139,7 @@ class SubIssuesEndpoint(BaseAPIView): "archived_at", ) datetime_fields = ["created_at", "updated_at"] - sub_issues = user_timezone_converter( - sub_issues, datetime_fields, request.user.user_timezone - ) + sub_issues = user_timezone_converter(sub_issues, datetime_fields, request.user.user_timezone) # Grouping if group_by: result_dict = defaultdict(list) @@ -192,9 +183,7 @@ class SubIssuesEndpoint(BaseAPIView): _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) - updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids).annotate( - state_group=F("state__group") - ) + updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids).annotate(state_group=F("state__group")) # Track the issue _ = [ diff --git a/apps/api/plane/app/views/issue/version.py b/apps/api/plane/app/views/issue/version.py index 9f8d5c29d..358271ac8 100644 --- a/apps/api/plane/app/views/issue/version.py +++ b/apps/api/plane/app/views/issue/version.py @@ -25,9 +25,7 @@ class IssueVersionEndpoint(BaseAPIView): paginated_data = results.values(*fields) datetime_fields = ["created_at", "updated_at"] - paginated_data = user_timezone_converter( - paginated_data, datetime_fields, timezone - ) + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) return paginated_data @@ -77,18 +75,14 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView): paginated_data = results.values(*fields) datetime_fields = ["created_at", "updated_at"] - paginated_data = user_timezone_converter( - paginated_data, datetime_fields, timezone - ) + paginated_data = user_timezone_converter(paginated_data, datetime_fields, timezone) return paginated_data @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def get(self, request, slug, project_id, work_item_id, pk=None): project = Project.objects.get(pk=project_id) - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=work_item_id - ) + issue = Issue.objects.get(workspace__slug=slug, project_id=project_id, pk=work_item_id) if ( ProjectMember.objects.filter( @@ -114,9 +108,7 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView): pk=pk, ) - serializer = IssueDescriptionVersionDetailSerializer( - issue_description_version - ) + serializer = IssueDescriptionVersionDetailSerializer(issue_description_version) return Response(serializer.data, status=status.HTTP_200_OK) cursor = request.GET.get("cursor", None) diff --git a/apps/api/plane/app/views/module/archive.py b/apps/api/plane/app/views/module/archive.py index d5c632f96..129ff0dac 100644 --- a/apps/api/plane/app/views/module/archive.py +++ b/apps/api/plane/app/views/module/archive.py @@ -113,11 +113,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - completed_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) .values("completed_estimate_points")[:1] ) @@ -128,9 +124,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - total_estimate_points=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) .values("total_estimate_points")[:1] ) backlog_estimate_point = ( @@ -141,9 +135,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("backlog_estimate_point")[:1] ) unstarted_estimate_point = ( @@ -154,11 +146,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - unstarted_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(unstarted_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("unstarted_estimate_point")[:1] ) started_estimate_point = ( @@ -169,9 +157,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - started_estimate_point=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(started_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("started_estimate_point")[:1] ) cancelled_estimate_point = ( @@ -182,11 +168,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - cancelled_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(cancelled_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("cancelled_estimate_point")[:1] ) return ( @@ -214,27 +196,15 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): Value(0, output_field=IntegerField()), ) ) - .annotate( - started_issues=Coalesce( - Subquery(started_issues[:1]), Value(0, output_field=IntegerField()) - ) - ) + .annotate(started_issues=Coalesce(Subquery(started_issues[:1]), Value(0, output_field=IntegerField()))) .annotate( unstarted_issues=Coalesce( Subquery(unstarted_issues[:1]), Value(0, output_field=IntegerField()), ) ) - .annotate( - backlog_issues=Coalesce( - Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()) - ) - ) - .annotate( - total_issues=Coalesce( - Subquery(total_issues[:1]), Value(0, output_field=IntegerField()) - ) - ) + .annotate(backlog_issues=Coalesce(Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()))) + .annotate(total_issues=Coalesce(Subquery(total_issues[:1]), Value(0, output_field=IntegerField()))) .annotate( backlog_estimate_points=Coalesce( Subquery(backlog_estimate_point), @@ -266,9 +236,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) ) .annotate( - total_estimate_points=Coalesce( - Subquery(total_estimate_point), Value(0, output_field=FloatField()) - ) + total_estimate_points=Coalesce(Subquery(total_estimate_point), Value(0, output_field=FloatField())) ) .annotate( member_ids=Coalesce( @@ -317,9 +285,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): "archived_at", ) datetime_fields = ["created_at", "updated_at"] - modules = user_timezone_converter( - modules, datetime_fields, request.user.user_timezone - ) + modules = user_timezone_converter(modules, datetime_fields, request.user.user_timezone) return Response(modules, status=status.HTTP_200_OK) else: queryset = ( @@ -389,9 +355,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): "avatar_url", "display_name", ) - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -425,9 +389,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -500,11 +462,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): "avatar_url", "display_name", ) - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -539,11 +497,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -584,9 +538,7 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): return Response(data, status=status.HTTP_200_OK) def post(self, request, slug, project_id, module_id): - module = Module.objects.get( - pk=module_id, project_id=project_id, workspace__slug=slug - ) + module = Module.objects.get(pk=module_id, project_id=project_id, workspace__slug=slug) if module.status not in ["completed", "cancelled"]: return Response( {"error": "Only completed or cancelled modules can be archived"}, @@ -600,14 +552,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): project_id=project_id, workspace__slug=slug, ).delete() - return Response( - {"archived_at": str(module.archived_at)}, status=status.HTTP_200_OK - ) + return Response({"archived_at": str(module.archived_at)}, status=status.HTTP_200_OK) def delete(self, request, slug, project_id, module_id): - module = Module.objects.get( - pk=module_id, project_id=project_id, workspace__slug=slug - ) + module = Module.objects.get(pk=module_id, project_id=project_id, workspace__slug=slug) module.archived_at = None module.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/app/views/module/base.py b/apps/api/plane/app/views/module/base.py index 69d48ae59..ae429e750 100644 --- a/apps/api/plane/app/views/module/base.py +++ b/apps/api/plane/app/views/module/base.py @@ -69,11 +69,7 @@ class ModuleViewSet(BaseViewSet): webhook_event = "module" def get_serializer_class(self): - return ( - ModuleWriteSerializer - if self.action in ["create", "update", "partial_update"] - else ModuleSerializer - ) + return ModuleWriteSerializer if self.action in ["create", "update", "partial_update"] else ModuleSerializer def get_queryset(self): favorite_subquery = UserFavorite.objects.filter( @@ -150,11 +146,7 @@ class ModuleViewSet(BaseViewSet): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - completed_estimate_points=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(completed_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) .values("completed_estimate_points")[:1] ) @@ -165,9 +157,7 @@ class ModuleViewSet(BaseViewSet): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - total_estimate_points=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimate_points=Sum(Cast("estimate_point__value", FloatField()))) .values("total_estimate_points")[:1] ) backlog_estimate_point = ( @@ -178,9 +168,7 @@ class ModuleViewSet(BaseViewSet): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(backlog_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("backlog_estimate_point")[:1] ) unstarted_estimate_point = ( @@ -191,11 +179,7 @@ class ModuleViewSet(BaseViewSet): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - unstarted_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(unstarted_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("unstarted_estimate_point")[:1] ) started_estimate_point = ( @@ -206,9 +190,7 @@ class ModuleViewSet(BaseViewSet): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - started_estimate_point=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(started_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("started_estimate_point")[:1] ) cancelled_estimate_point = ( @@ -219,11 +201,7 @@ class ModuleViewSet(BaseViewSet): issue_module__deleted_at__isnull=True, ) .values("issue_module__module_id") - .annotate( - cancelled_estimate_point=Sum( - Cast("estimate_point__value", FloatField()) - ) - ) + .annotate(cancelled_estimate_point=Sum(Cast("estimate_point__value", FloatField()))) .values("cancelled_estimate_point")[:1] ) return ( @@ -232,9 +210,6 @@ class ModuleViewSet(BaseViewSet): .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .annotate(is_favorite=Exists(favorite_subquery)) - .select_related("project") - .select_related("workspace") - .select_related("lead") .prefetch_related("members") .prefetch_related( Prefetch( @@ -254,27 +229,15 @@ class ModuleViewSet(BaseViewSet): Value(0, output_field=IntegerField()), ) ) - .annotate( - started_issues=Coalesce( - Subquery(started_issues[:1]), Value(0, output_field=IntegerField()) - ) - ) + .annotate(started_issues=Coalesce(Subquery(started_issues[:1]), Value(0, output_field=IntegerField()))) .annotate( unstarted_issues=Coalesce( Subquery(unstarted_issues[:1]), Value(0, output_field=IntegerField()), ) ) - .annotate( - backlog_issues=Coalesce( - Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()) - ) - ) - .annotate( - total_issues=Coalesce( - Subquery(total_issues[:1]), Value(0, output_field=IntegerField()) - ) - ) + .annotate(backlog_issues=Coalesce(Subquery(backlog_issues[:1]), Value(0, output_field=IntegerField()))) + .annotate(total_issues=Coalesce(Subquery(total_issues[:1]), Value(0, output_field=IntegerField()))) .annotate( backlog_estimate_points=Coalesce( Subquery(backlog_estimate_point), @@ -306,16 +269,17 @@ class ModuleViewSet(BaseViewSet): ) ) .annotate( - total_estimate_points=Coalesce( - Subquery(total_estimate_point), Value(0, output_field=FloatField()) - ) + total_estimate_points=Coalesce(Subquery(total_estimate_point), Value(0, output_field=FloatField())) ) .annotate( member_ids=Coalesce( ArrayAgg( "members__id", distinct=True, - filter=~Q(members__id__isnull=True), + filter=Q( + members__id__isnull=False, + modulemember__deleted_at__isnull=True, + ), ), Value([], output_field=ArrayField(UUIDField())), ) @@ -326,9 +290,7 @@ class ModuleViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): project = Project.objects.get(workspace__slug=slug, pk=project_id) - serializer = ModuleWriteSerializer( - data=request.data, context={"project": project} - ) + serializer = ModuleWriteSerializer(data=request.data, context={"project": project}) if serializer.is_valid(): serializer.save() @@ -380,9 +342,7 @@ class ModuleViewSet(BaseViewSet): origin=base_host(request=request, is_app=True), ) datetime_fields = ["created_at", "updated_at"] - module = user_timezone_converter( - module, datetime_fields, request.user.user_timezone - ) + module = user_timezone_converter(module, datetime_fields, request.user.user_timezone) return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -425,9 +385,7 @@ class ModuleViewSet(BaseViewSet): "updated_at", ) datetime_fields = ["created_at", "updated_at"] - modules = user_timezone_converter( - modules, datetime_fields, request.user.user_timezone - ) + modules = user_timezone_converter(modules, datetime_fields, request.user.user_timezone) return Response(modules, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -450,9 +408,7 @@ class ModuleViewSet(BaseViewSet): ) if not queryset.exists(): - return Response( - {"error": "Module not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Module not found"}, status=status.HTTP_404_NOT_FOUND) estimate_type = Project.objects.filter( workspace__slug=slug, @@ -505,9 +461,7 @@ class ModuleViewSet(BaseViewSet): "avatar_url", "display_name", ) - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -542,9 +496,7 @@ class ModuleViewSet(BaseViewSet): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_estimates=Sum(Cast("estimate_point__value", FloatField())) - ) + .annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField()))) .annotate( completed_estimates=Sum( Cast("estimate_point__value", FloatField()), @@ -602,21 +554,13 @@ class ModuleViewSet(BaseViewSet): ), ), # If `avatar_asset` is None, fall back to using `avatar` field directly - When( - assignees__avatar_asset__isnull=True, then="assignees__avatar" - ), + When(assignees__avatar_asset__isnull=True, then="assignees__avatar"), default=Value(None), output_field=models.CharField(), ) ) - .values( - "first_name", "last_name", "assignee_id", "avatar_url", "display_name" - ) - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .values("first_name", "last_name", "assignee_id", "avatar_url", "display_name") + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -651,11 +595,7 @@ class ModuleViewSet(BaseViewSet): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate( - total_issues=Count( - "id", filter=Q(archived_at__isnull=True, is_draft=False) - ) - ) + .annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))) .annotate( completed_issues=Count( "id", @@ -685,12 +625,7 @@ class ModuleViewSet(BaseViewSet): "completion_chart": {}, } - if ( - modules - and modules.start_date - and modules.target_date - and modules.total_issues > 0 - ): + if modules and modules.start_date and modules.target_date and modules.total_issues > 0: data["distribution"]["completion_chart"] = burndown_plot( queryset=modules, slug=slug, @@ -726,12 +661,8 @@ class ModuleViewSet(BaseViewSet): {"error": "Archived module cannot be updated"}, status=status.HTTP_400_BAD_REQUEST, ) - current_instance = json.dumps( - ModuleSerializer(current_module).data, cls=DjangoJSONEncoder - ) - serializer = ModuleWriteSerializer( - current_module, data=request.data, partial=True - ) + current_instance = json.dumps(ModuleSerializer(current_module).data, cls=DjangoJSONEncoder) + serializer = ModuleWriteSerializer(current_module, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -781,9 +712,7 @@ class ModuleViewSet(BaseViewSet): ) datetime_fields = ["created_at", "updated_at"] - module = user_timezone_converter( - module, datetime_fields, request.user.user_timezone - ) + module = user_timezone_converter(module, datetime_fields, request.user.user_timezone) return Response(module, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -791,9 +720,7 @@ class ModuleViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): module = Module.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) - module_issues = list( - ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True) - ) + module_issues = list(ModuleIssue.objects.filter(module_id=pk).values_list("issue", flat=True)) _ = [ issue_activity.delay( type="module.activity.deleted", @@ -901,12 +828,9 @@ class ModuleUserPropertiesEndpoint(BaseAPIView): workspace__slug=slug, ) - module_properties.filters = request.data.get( - "filters", module_properties.filters - ) - module_properties.display_filters = request.data.get( - "display_filters", module_properties.display_filters - ) + module_properties.filters = request.data.get("filters", module_properties.filters) + module_properties.rich_filters = request.data.get("rich_filters", module_properties.rich_filters) + module_properties.display_filters = request.data.get("display_filters", module_properties.display_filters) module_properties.display_properties = request.data.get( "display_properties", module_properties.display_properties ) diff --git a/apps/api/plane/app/views/module/issue.py b/apps/api/plane/app/views/module/issue.py index 96d1f550a..672bf4e1a 100644 --- a/apps/api/plane/app/views/module/issue.py +++ b/apps/api/plane/app/views/module/issue.py @@ -1,4 +1,5 @@ # Python imports +import copy import json from django.db.models import F, Func, OuterRef, Q, Subquery @@ -31,8 +32,8 @@ from plane.utils.grouper import ( from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator - -# Module imports +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet from .. import BaseViewSet from plane.utils.host import base_host @@ -42,24 +43,14 @@ class ModuleIssueViewSet(BaseViewSet): model = ModuleIssue webhook_event = "module_issue" bulk = True + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet - filterset_fields = ["issue__labels__id", "issue__assignees__id"] - - def get_queryset(self): + def apply_annotations(self, issues): return ( - Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id"), - workspace__slug=self.kwargs.get("slug"), - issue_module__module_id=self.kwargs.get("module_id"), - issue_module__deleted_at__isnull=True, - ) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( + issues.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -83,13 +74,37 @@ class ModuleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .prefetch_related("assignees", "labels", "issue_module__module") + ) + + def get_queryset(self): + return ( + Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + workspace__slug=self.kwargs.get("slug"), + issue_module__module_id=self.kwargs.get("module_id"), + issue_module__deleted_at__isnull=True, + ) ).distinct() @method_decorator(gzip_page) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def list(self, request, slug, project_id, module_id): filters = issue_filters(request.query_params, "GET") - issue_queryset = self.get_queryset().filter(**filters) + issue_queryset = self.get_queryset() + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Apply annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + order_by_param = request.GET.get("order_by", "created_at") # Issue queryset @@ -102,18 +117,14 @@ class ModuleIssueViewSet(BaseViewSet): sub_group_by = request.GET.get("sub_group_by", False) # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by - ) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) if group_by: # Check group and sub group value paginate if sub_group_by: if group_by == sub_group_by: return Response( - { - "error": "Group by and sub group by cannot have same parameters" - }, + {"error": "Group by and sub group by cannot have same parameters"}, status=status.HTTP_400_BAD_REQUEST, ) else: @@ -122,6 +133,7 @@ class ModuleIssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -131,12 +143,14 @@ class ModuleIssueViewSet(BaseViewSet): slug=slug, project_id=project_id, filters=filters, + queryset=total_issue_queryset, ), sub_group_by_fields=issue_group_values( field=sub_group_by, slug=slug, project_id=project_id, filters=filters, + queryset=total_issue_queryset, ), group_by_field_name=group_by, sub_group_by_field_name=sub_group_by, @@ -156,6 +170,7 @@ class ModuleIssueViewSet(BaseViewSet): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), @@ -165,6 +180,7 @@ class ModuleIssueViewSet(BaseViewSet): slug=slug, project_id=project_id, filters=filters, + queryset=total_issue_queryset, ), group_by_field_name=group_by, count_filter=Q( @@ -182,9 +198,8 @@ class ModuleIssueViewSet(BaseViewSet): order_by=order_by_param, request=request, queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), ) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) @@ -192,9 +207,7 @@ class ModuleIssueViewSet(BaseViewSet): def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) if not issues: - return Response( - {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST) project = Project.objects.get(pk=project_id) _ = ModuleIssue.objects.bulk_create( [ @@ -282,9 +295,11 @@ class ModuleIssueViewSet(BaseViewSet): project_id=str(project_id), current_instance=json.dumps( { - "module_name": module_issue.first().module.name - if (module_issue.first() and module_issue.first().module) - else None + "module_name": ( + module_issue.first().module.name + if (module_issue.first() and module_issue.first().module) + else None + ) } ), epoch=int(timezone.now().timestamp()), @@ -309,9 +324,7 @@ class ModuleIssueViewSet(BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), - current_instance=json.dumps( - {"module_name": module_issue.first().module.name} - ), + current_instance=json.dumps({"module_name": module_issue.first().module.name}), epoch=int(timezone.now().timestamp()), notification=True, origin=base_host(request=request, is_app=True), diff --git a/apps/api/plane/app/views/notification/base.py b/apps/api/plane/app/views/notification/base.py index 329599c15..a11c12d16 100644 --- a/apps/api/plane/app/views/notification/base.py +++ b/apps/api/plane/app/views/notification/base.py @@ -40,9 +40,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): .select_related("workspace", "project", "triggered_by", "receiver") ) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): # Get query parameters snoozed = request.GET.get("snoozed", "false") @@ -59,9 +57,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): ) notifications = ( - Notification.objects.filter( - workspace__slug=slug, receiver_id=request.user.id - ) + Notification.objects.filter(workspace__slug=slug, receiver_id=request.user.id) .filter(entity_name="issue") .annotate(is_inbox_issue=Exists(intake_issue)) .annotate(is_intake_issue=Exists(intake_issue)) @@ -106,23 +102,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Subscribed issues if "subscribed" in type: issue_ids = ( - IssueSubscriber.objects.filter( - workspace__slug=slug, subscriber_id=request.user.id - ) - .annotate( - created=Exists( - Issue.objects.filter( - created_by=request.user, pk=OuterRef("issue_id") - ) - ) - ) - .annotate( - assigned=Exists( - IssueAssignee.objects.filter( - pk=OuterRef("issue_id"), assignee=request.user - ) - ) - ) + IssueSubscriber.objects.filter(workspace__slug=slug, subscriber_id=request.user.id) + .annotate(created=Exists(Issue.objects.filter(created_by=request.user, pk=OuterRef("issue_id")))) + .annotate(assigned=Exists(IssueAssignee.objects.filter(pk=OuterRef("issue_id"), assignee=request.user))) .filter(created=False, assigned=False) .values_list("issue_id", flat=True) ) @@ -130,9 +112,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Assigned Issues if "assigned" in type: - issue_ids = IssueAssignee.objects.filter( - workspace__slug=slug, assignee_id=request.user.id - ).values_list("issue_id", flat=True) + issue_ids = IssueAssignee.objects.filter(workspace__slug=slug, assignee_id=request.user.id).values_list( + "issue_id", flat=True + ) q_filters |= Q(entity_identifier__in=issue_ids) # Created issues @@ -142,9 +124,9 @@ class NotificationViewSet(BaseViewSet, BasePaginator): ).exists(): notifications = notifications.none() else: - issue_ids = Issue.objects.filter( - workspace__slug=slug, created_by=request.user - ).values_list("pk", flat=True) + issue_ids = Issue.objects.filter(workspace__slug=slug, created_by=request.user).values_list( + "pk", flat=True + ) q_filters |= Q(entity_identifier__in=issue_ids) # Apply the combined Q object filters @@ -156,75 +138,51 @@ class NotificationViewSet(BaseViewSet, BasePaginator): order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(notifications), - on_results=lambda notifications: NotificationSerializer( - notifications, many=True - ).data, + on_results=lambda notifications: NotificationSerializer(notifications, many=True).data, ) serializer = NotificationSerializer(notifications, many=True) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def partial_update(self, request, slug, pk): - notification = Notification.objects.get( - workspace__slug=slug, pk=pk, receiver=request.user - ) + notification = Notification.objects.get(workspace__slug=slug, pk=pk, receiver=request.user) # Only read_at and snoozed_till can be updated notification_data = {"snoozed_till": request.data.get("snoozed_till", None)} - serializer = NotificationSerializer( - notification, data=notification_data, partial=True - ) + serializer = NotificationSerializer(notification, data=notification_data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def mark_read(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) notification.read_at = timezone.now() notification.save() serializer = NotificationSerializer(notification) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def mark_unread(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) notification.read_at = None notification.save() serializer = NotificationSerializer(notification) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def archive(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) notification.archived_at = timezone.now() notification.save() serializer = NotificationSerializer(notification) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def unarchive(self, request, slug, pk): - notification = Notification.objects.get( - receiver=request.user, workspace__slug=slug, pk=pk - ) + notification = Notification.objects.get(receiver=request.user, workspace__slug=slug, pk=pk) notification.archived_at = None notification.save() serializer = NotificationSerializer(notification) @@ -234,9 +192,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): class UnreadNotificationEndpoint(BaseAPIView): use_read_replica = True - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug): # Watching Issues Count unread_notifications_count = ( @@ -270,31 +226,23 @@ class UnreadNotificationEndpoint(BaseAPIView): class MarkAllReadNotificationViewSet(BaseViewSet): - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def create(self, request, slug): snoozed = request.data.get("snoozed", False) archived = request.data.get("archived", False) type = request.data.get("type", "all") notifications = ( - Notification.objects.filter( - workspace__slug=slug, receiver_id=request.user.id, read_at__isnull=True - ) + Notification.objects.filter(workspace__slug=slug, receiver_id=request.user.id, read_at__isnull=True) .select_related("workspace", "project", "triggered_by", "receiver") .order_by("snoozed_till", "-created_at") ) # Filter for snoozed notifications if snoozed: - notifications = notifications.filter( - Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False) - ) + notifications = notifications.filter(Q(snoozed_till__lt=timezone.now()) | Q(snoozed_till__isnull=False)) else: - notifications = notifications.filter( - Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True) - ) + notifications = notifications.filter(Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True)) # Filter for archived or unarchive if archived: @@ -304,16 +252,16 @@ class MarkAllReadNotificationViewSet(BaseViewSet): # Subscribed issues if type == "watching": - issue_ids = IssueSubscriber.objects.filter( - workspace__slug=slug, subscriber_id=request.user.id - ).values_list("issue_id", flat=True) + issue_ids = IssueSubscriber.objects.filter(workspace__slug=slug, subscriber_id=request.user.id).values_list( + "issue_id", flat=True + ) notifications = notifications.filter(entity_identifier__in=issue_ids) # Assigned Issues if type == "assigned": - issue_ids = IssueAssignee.objects.filter( - workspace__slug=slug, assignee_id=request.user.id - ).values_list("issue_id", flat=True) + issue_ids = IssueAssignee.objects.filter(workspace__slug=slug, assignee_id=request.user.id).values_list( + "issue_id", flat=True + ) notifications = notifications.filter(entity_identifier__in=issue_ids) # Created issues @@ -323,18 +271,16 @@ class MarkAllReadNotificationViewSet(BaseViewSet): ).exists(): notifications = Notification.objects.none() else: - issue_ids = Issue.objects.filter( - workspace__slug=slug, created_by=request.user - ).values_list("pk", flat=True) + issue_ids = Issue.objects.filter(workspace__slug=slug, created_by=request.user).values_list( + "pk", flat=True + ) notifications = notifications.filter(entity_identifier__in=issue_ids) updated_notifications = [] for notification in notifications: notification.read_at = timezone.now() updated_notifications.append(notification) - Notification.objects.bulk_update( - updated_notifications, ["read_at"], batch_size=100 - ) + Notification.objects.bulk_update(updated_notifications, ["read_at"], batch_size=100) return Response({"message": "Successful"}, status=status.HTTP_200_OK) @@ -344,20 +290,14 @@ class UserNotificationPreferenceEndpoint(BaseAPIView): # request the object def get(self, request): - user_notification_preference = UserNotificationPreference.objects.get( - user=request.user - ) + user_notification_preference = UserNotificationPreference.objects.get(user=request.user) serializer = UserNotificationPreferenceSerializer(user_notification_preference) return Response(serializer.data, status=status.HTTP_200_OK) # update the object def patch(self, request): - user_notification_preference = UserNotificationPreference.objects.get( - user=request.user - ) - serializer = UserNotificationPreferenceSerializer( - user_notification_preference, data=request.data, partial=True - ) + user_notification_preference = UserNotificationPreference.objects.get(user=request.user) + serializer = UserNotificationPreferenceSerializer(user_notification_preference, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index e4ee1890b..72fb4ef8e 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -1,14 +1,21 @@ # Python imports import json -import base64 from datetime import datetime from django.core.serializers.json import DjangoJSONEncoder # Django imports from django.db import connection -from django.db.models import Exists, OuterRef, Q, Value, UUIDField -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page +from django.db.models import ( + Exists, + OuterRef, + Q, + Value, + UUIDField, + Count, + Case, + When, + IntegerField, +) from django.http import StreamingHttpResponse from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField @@ -21,9 +28,7 @@ from rest_framework.response import Response # Module imports from plane.app.permissions import allow_permission, ROLE from plane.app.serializers import ( - PageLogSerializer, PageSerializer, - SubPageSerializer, PageDetailSerializer, PageBinaryUpdateSerializer, ) @@ -37,11 +42,14 @@ from plane.db.models import ( UserRecentVisit, ) from plane.utils.error_codes import ERROR_CODES + +# Local imports from ..base import BaseAPIView, BaseViewSet from plane.bgtasks.page_transaction_task import page_transaction from plane.bgtasks.page_version_task import page_version from plane.bgtasks.recent_visited_task import recent_visited_task from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets +from plane.app.permissions import ProjectPagePermission def unarchive_archive_page_and_descendants(page_id, archived_at): @@ -63,6 +71,7 @@ def unarchive_archive_page_and_descendants(page_id, archived_at): class PageViewSet(BaseViewSet): serializer_class = PageSerializer model = Page + permission_classes = [ProjectPagePermission] search_fields = ["name"] def get_queryset(self): @@ -92,9 +101,7 @@ class PageViewSet(BaseViewSet): .order_by("-is_favorite", "-created_at") .annotate( project=Exists( - ProjectPage.objects.filter( - page_id=OuterRef("id"), project_id=self.kwargs.get("project_id") - ) + ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) ) ) .annotate( @@ -107,9 +114,7 @@ class PageViewSet(BaseViewSet): Value([], output_field=ArrayField(UUIDField())), ), project_ids=Coalesce( - ArrayAgg( - "projects__id", distinct=True, filter=~Q(projects__id=True) - ), + ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), Value([], output_field=ArrayField(UUIDField())), ), ) @@ -117,7 +122,6 @@ class PageViewSet(BaseViewSet): .distinct() ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def create(self, request, slug, project_id): serializer = PageSerializer( data=request.data, @@ -139,33 +143,21 @@ 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]) - def partial_update(self, request, slug, project_id, pk): + def partial_update(self, request, slug, project_id, page_id): try: - page = Page.objects.get( - pk=pk, workspace__slug=slug, projects__id=project_id - ) + page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id) if page.is_locked: - return Response( - {"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST) parent = request.data.get("parent", None) if parent: - _ = Page.objects.get( - pk=parent, workspace__slug=slug, projects__id=project_id - ) + _ = Page.objects.get(pk=parent, workspace__slug=slug, projects__id=project_id) # Only update access if the page owner is the requesting user - if ( - page.access != request.data.get("access", page.access) - and page.owned_by_id != request.user.id - ): + if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, + {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -181,22 +173,19 @@ class PageViewSet(BaseViewSet): {"description_html": page_description}, cls=DjangoJSONEncoder, ), - page_id=pk, + page_id=page_id, ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except Page.DoesNotExist: return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, + {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def retrieve(self, request, slug, project_id, pk=None): - page = self.get_queryset().filter(pk=pk).first() + def retrieve(self, request, slug, project_id, page_id=None): + page = self.get_queryset().filter(pk=page_id).first() project = Project.objects.get(pk=project_id) track_visit = request.query_params.get("track_visit", "true").lower() == "true" @@ -222,62 +211,46 @@ class PageViewSet(BaseViewSet): ) if page is None: - return Response( - {"error": "Page not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Page not found"}, status=status.HTTP_404_NOT_FOUND) else: - issue_ids = PageLog.objects.filter( - page_id=pk, entity_name="issue" - ).values_list("entity_identifier", flat=True) + issue_ids = PageLog.objects.filter(page_id=page_id, entity_name="issue").values_list( + "entity_identifier", flat=True + ) data = PageDetailSerializer(page).data data["issue_ids"] = issue_ids if track_visit: recent_visited_task.delay( slug=slug, entity_name="page", - entity_identifier=pk, + entity_identifier=page_id, user_id=request.user.id, project_id=project_id, ) return Response(data, status=status.HTTP_200_OK) - @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 - ).first() + def lock(self, request, slug, project_id, page_id): + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() page.is_locked = True page.save() return Response(status=status.HTTP_204_NO_CONTENT) - @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 - ).first() + def unlock(self, request, slug, project_id, page_id): + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() page.is_locked = False page.save() return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN], model=Page, creator=True) - def access(self, request, slug, project_id, pk): + def access(self, request, slug, project_id, page_id): access = request.data.get("access", 0) - page = Page.objects.filter( - pk=pk, workspace__slug=slug, projects__id=project_id - ).first() + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() # Only update access if the page owner is the requesting user - if ( - page.access != request.data.get("access", page.access) - and page.owned_by_id != request.user.id - ): + if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: return Response( - { - "error": "Access cannot be updated since this page is owned by someone else" - }, + {"error": "Access cannot be updated since this page is owned by someone else"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -285,7 +258,6 @@ class PageViewSet(BaseViewSet): page.save() return Response(status=status.HTTP_204_NO_CONTENT) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def list(self, request, slug, project_id): queryset = self.get_queryset() project = Project.objects.get(pk=project_id) @@ -303,9 +275,8 @@ class PageViewSet(BaseViewSet): pages = PageSerializer(queryset, many=True).data return Response(pages, status=status.HTTP_200_OK) - @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) + def archive(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id) # only the owner or admin can archive the page if ( @@ -321,18 +292,17 @@ class PageViewSet(BaseViewSet): UserFavorite.objects.filter( entity_type="page", - entity_identifier=pk, + entity_identifier=page_id, project_id=project_id, workspace__slug=slug, ).delete() - unarchive_archive_page_and_descendants(pk, datetime.now()) + unarchive_archive_page_and_descendants(page_id, datetime.now()) return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK) - @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) + def unarchive(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id) # only the owner or admin can un archive the page if ( @@ -346,18 +316,17 @@ class PageViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - # if parent page is archived then the page will be un archived breaking the hierarchy + # if parent archived then page will be un archived breaking hierarchy if page.parent_id and page.parent.archived_at: page.parent = None page.save(update_fields=["parent"]) - unarchive_archive_page_and_descendants(pk, None) + unarchive_archive_page_and_descendants(page_id, None) return Response(status=status.HTTP_204_NO_CONTENT) - @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) + def destroy(self, request, slug, project_id, page_id): + page = Page.objects.get(pk=page_id, workspace__slug=slug, projects__id=project_id) if page.archived_at is None: return Response( @@ -380,114 +349,108 @@ class PageViewSet(BaseViewSet): ) # remove parent from all the children - _ = Page.objects.filter( - parent_id=pk, projects__id=project_id, workspace__slug=slug - ).update(parent=None) + _ = Page.objects.filter(parent_id=page_id, projects__id=project_id, workspace__slug=slug).update(parent=None) page.delete() # Delete the user favorite page UserFavorite.objects.filter( project=project_id, workspace__slug=slug, - entity_identifier=pk, + entity_identifier=page_id, entity_type="page", ).delete() # Delete the page from recent visit UserRecentVisit.objects.filter( project_id=project_id, workspace__slug=slug, - entity_identifier=pk, + entity_identifier=page_id, entity_name="page", ).delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) + def summary(self, request, slug, project_id): + queryset = ( + Page.objects.filter(workspace__slug=slug) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + ) + .filter(parent__isnull=True) + .filter(Q(owned_by=request.user) | Q(access=0)) + .annotate( + project=Exists( + ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=self.kwargs.get("project_id")) + ) + ) + .filter(project=True) + .distinct() + ) + + project = Project.objects.get(pk=project_id) + if ( + ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member=request.user, + role=ROLE.GUEST.value, + is_active=True, + ).exists() + and not project.guest_view_all_features + ): + queryset = queryset.filter(owned_by=request.user) + + stats = queryset.aggregate( + public_pages=Count( + Case( + When(access=Page.PUBLIC_ACCESS, archived_at__isnull=True, then=1), + output_field=IntegerField(), + ) + ), + private_pages=Count( + Case( + When(access=Page.PRIVATE_ACCESS, archived_at__isnull=True, then=1), + output_field=IntegerField(), + ) + ), + archived_pages=Count(Case(When(archived_at__isnull=False, then=1), output_field=IntegerField())), + ) + + return Response(stats, status=status.HTTP_200_OK) + class PageFavoriteViewSet(BaseViewSet): model = UserFavorite @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def create(self, request, slug, project_id, pk): + def create(self, request, slug, project_id, page_id): _ = UserFavorite.objects.create( project_id=project_id, - entity_identifier=pk, + entity_identifier=page_id, entity_type="page", user=request.user, ) return Response(status=status.HTTP_204_NO_CONTENT) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) - def destroy(self, request, slug, project_id, pk): + def destroy(self, request, slug, project_id, page_id): page_favorite = UserFavorite.objects.get( project=project_id, user=request.user, workspace__slug=slug, - entity_identifier=pk, + entity_identifier=page_id, entity_type="page", ) page_favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) -class PageLogEndpoint(BaseAPIView): - serializer_class = PageLogSerializer - model = PageLog - - def post(self, request, slug, project_id, page_id): - serializer = PageLogSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(project_id=project_id, page_id=page_id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def patch(self, request, slug, project_id, page_id, transaction): - page_transaction = PageLog.objects.get( - workspace__slug=slug, - project_id=project_id, - page_id=page_id, - transaction=transaction, - ) - serializer = PageLogSerializer( - page_transaction, data=request.data, partial=True - ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, slug, project_id, page_id, transaction): - transaction = PageLog.objects.get( - workspace__slug=slug, - project_id=project_id, - page_id=page_id, - transaction=transaction, - ) - # Delete the transaction object - transaction.delete() - return Response(status=status.HTTP_204_NO_CONTENT) - - -class SubPagesEndpoint(BaseAPIView): - @method_decorator(gzip_page) - def get(self, request, slug, project_id, page_id): - pages = ( - PageLog.objects.filter( - page_id=page_id, - workspace__slug=slug, - entity_name__in=["forward_link", "back_link"], - ) - .select_related("project") - .select_related("workspace") - ) - return Response( - SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK - ) - - class PagesDescriptionViewSet(BaseViewSet): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def retrieve(self, request, slug, project_id, pk): + permission_classes = [ProjectPagePermission] + + def retrieve(self, request, slug, project_id, page_id): page = ( - Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) + Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id) .filter(Q(owned_by=self.request.user) | Q(access=0)) .first() ) @@ -501,16 +464,13 @@ class PagesDescriptionViewSet(BaseViewSet): else: yield b"" - response = StreamingHttpResponse( - stream_data(), content_type="application/octet-stream" - ) + response = StreamingHttpResponse(stream_data(), content_type="application/octet-stream") response["Content-Disposition"] = 'attachment; filename="page_description.bin"' return response - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) - def partial_update(self, request, slug, project_id, pk): + def partial_update(self, request, slug, project_id, page_id): page = ( - Page.objects.filter(pk=pk, workspace__slug=slug, projects__id=project_id) + Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id) .filter(Q(owned_by=self.request.user) | Q(access=0)) .first() ) @@ -537,18 +497,14 @@ class PagesDescriptionViewSet(BaseViewSet): ) # Serialize the existing instance - existing_instance = json.dumps( - {"description_html": page.description_html}, cls=DjangoJSONEncoder - ) + existing_instance = json.dumps({"description_html": page.description_html}, cls=DjangoJSONEncoder) # Use serializer for validation and update serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True) if serializer.is_valid(): # Capture the page transaction if request.data.get("description_html"): - page_transaction.delay( - new_value=request.data, old_value=existing_instance, page_id=pk - ) + page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id) # Update the page using serializer updated_page = serializer.save() @@ -565,22 +521,17 @@ class PagesDescriptionViewSet(BaseViewSet): class PageDuplicateEndpoint(BaseAPIView): - @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + permission_classes = [ProjectPagePermission] + def post(self, request, slug, project_id, page_id): - page = Page.objects.filter( - pk=page_id, workspace__slug=slug, projects__id=project_id - ).first() + page = Page.objects.filter(pk=page_id, workspace__slug=slug, projects__id=project_id).first() # check for permission if page.access == Page.PRIVATE_ACCESS and page.owned_by_id != request.user.id: - return Response( - {"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN - ) + return Response({"error": "Permission denied"}, status=status.HTTP_403_FORBIDDEN) # get all the project ids where page is present - project_ids = ProjectPage.objects.filter(page_id=page_id).values_list( - "project_id", flat=True - ) + project_ids = ProjectPage.objects.filter(page_id=page_id).values_list("project_id", flat=True) page.pk = None page.name = f"{page.name} (Copy)" @@ -599,9 +550,7 @@ class PageDuplicateEndpoint(BaseAPIView): updated_by_id=page.updated_by_id, ) - page_transaction.delay( - {"description_html": page.description_html}, None, page.id - ) + page_transaction.delay({"description_html": page.description_html}, None, page.id) # Copy the s3 objects uploaded in the page copy_s3_objects_of_description_and_assets.delay( @@ -616,9 +565,7 @@ class PageDuplicateEndpoint(BaseAPIView): Page.objects.filter(pk=page.id) .annotate( project_ids=Coalesce( - ArrayAgg( - "projects__id", distinct=True, filter=~Q(projects__id=True) - ), + ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), Value([], output_field=ArrayField(UUIDField())), ) ) diff --git a/apps/api/plane/app/views/page/version.py b/apps/api/plane/app/views/page/version.py index bcf2f4f5b..1b285c966 100644 --- a/apps/api/plane/app/views/page/version.py +++ b/apps/api/plane/app/views/page/version.py @@ -6,25 +6,22 @@ from rest_framework.response import Response from plane.db.models import PageVersion from ..base import BaseAPIView from plane.app.serializers import PageVersionSerializer, PageVersionDetailSerializer -from plane.app.permissions import allow_permission, ROLE +from plane.app.permissions import ProjectPagePermission class PageVersionEndpoint(BaseAPIView): - @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + permission_classes = [ProjectPagePermission] + def get(self, request, slug, project_id, page_id, pk=None): # Check if pk is provided if pk: # Return a single page version - page_version = PageVersion.objects.get( - workspace__slug=slug, page_id=page_id, pk=pk - ) + page_version = PageVersion.objects.get(workspace__slug=slug, page_id=page_id, pk=pk) # Serialize the page version serializer = PageVersionDetailSerializer(page_version) return Response(serializer.data, status=status.HTTP_200_OK) # Return all page versions - page_versions = PageVersion.objects.filter( - workspace__slug=slug, page_id=page_id - ) + page_versions = PageVersion.objects.filter(workspace__slug=slug, page_id=page_id) # Serialize the page versions serializer = PageVersionSerializer(page_versions, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index b4ee113c4..84b2a5629 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -5,13 +5,12 @@ from django.utils import timezone import json # Django imports -from django.db import IntegrityError from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response -from rest_framework import serializers, status +from rest_framework import status from rest_framework.permissions import AllowAny # Module imports @@ -59,9 +58,7 @@ class ProjectViewSet(BaseViewSet): super() .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) - .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" - ) + .select_related("workspace", "workspace__owner", "default_assignee", "project_lead") .annotate( is_favorite=Exists( UserFavorite.objects.filter( @@ -99,14 +96,15 @@ class ProjectViewSet(BaseViewSet): .distinct() ) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list_detail(self, request, slug): fields = [field for field in request.GET.get("fields", "").split(",") if field] projects = self.get_queryset().order_by("sort_order", "name") if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.GUEST.value, ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, @@ -114,7 +112,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, ).exists(): projects = projects.filter( Q( @@ -129,19 +130,13 @@ class ProjectViewSet(BaseViewSet): order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(projects), - on_results=lambda projects: ProjectListSerializer( - projects, many=True - ).data, + on_results=lambda projects: ProjectListSerializer(projects, many=True).data, ) - projects = ProjectListSerializer( - projects, many=True, fields=fields if fields else None - ).data + projects = ProjectListSerializer(projects, many=True, fields=fields if fields else None).data return Response(projects, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): sort_order = ProjectMember.objects.filter( member=self.request.user, @@ -152,9 +147,7 @@ class ProjectViewSet(BaseViewSet): projects = ( Project.objects.filter(workspace__slug=self.kwargs.get("slug")) - .select_related( - "workspace", "workspace__owner", "default_assignee", "project_lead" - ) + .select_related("workspace", "workspace__owner", "default_assignee", "project_lead") .annotate( member_role=ProjectMember.objects.filter( project_id=OuterRef("pk"), @@ -189,7 +182,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.GUEST.value, ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, @@ -197,7 +193,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, ).exists(): projects = projects.filter( Q( @@ -208,9 +207,7 @@ class ProjectViewSet(BaseViewSet): ) return Response(projects, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def retrieve(self, request, slug, pk): project = ( self.get_queryset() @@ -223,9 +220,7 @@ class ProjectViewSet(BaseViewSet): ).first() if project is None: - return Response( - {"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project does not exist"}, status=status.HTTP_404_NOT_FOUND) recent_visited_task.delay( slug=slug, @@ -242,28 +237,26 @@ class ProjectViewSet(BaseViewSet): def create(self, request, slug): workspace = Workspace.objects.get(slug=slug) - serializer = ProjectSerializer( - data={**request.data}, context={"workspace_id": workspace.id} - ) + serializer = ProjectSerializer(data={**request.data}, context={"workspace_id": workspace.id}) if serializer.is_valid(): serializer.save() # Add the user as Administrator to the project _ = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 + project_id=serializer.data["id"], + member=request.user, + role=ROLE.ADMIN.value, ) # Also create the issue property for the user - _ = IssueUserProperty.objects.create( - project_id=serializer.data["id"], user=request.user - ) + _ = IssueUserProperty.objects.create(project_id=serializer.data["id"], user=request.user) - if serializer.data["project_lead"] is not None and str( - serializer.data["project_lead"] - ) != str(request.user.id): + if serializer.data["project_lead"] is not None and str(serializer.data["project_lead"]) != str( + request.user.id + ): ProjectMember.objects.create( project_id=serializer.data["id"], member_id=serializer.data["project_lead"], - role=20, + role=ROLE.ADMIN.value, ) # Also create the issue property for the user IssueUserProperty.objects.create( @@ -341,13 +334,23 @@ class ProjectViewSet(BaseViewSet): def partial_update(self, request, slug, pk=None): # try: - if not ProjectMember.objects.filter( + is_workspace_admin = WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, + ).exists() + + is_project_admin = ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, - role=20, + role=ROLE.ADMIN.value, is_active=True, - ).exists(): + ).exists() + + # Return error for if the user is neither workspace admin nor project admin + if not is_project_admin and not is_workspace_admin: return Response( {"error": "You don't have the required permissions."}, status=status.HTTP_403_FORBIDDEN, @@ -357,9 +360,7 @@ class ProjectViewSet(BaseViewSet): 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 - ) + current_instance = json.dumps(ProjectSerializer(project).data, cls=DjangoJSONEncoder) if project.archived_at: return Response( {"error": "Archived projects cannot be updated"}, @@ -402,13 +403,16 @@ class ProjectViewSet(BaseViewSet): def destroy(self, request, slug, pk): if ( WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=20 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, ).exists() or ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, - role=20, + role=ROLE.ADMIN.value, is_active=True, ).exists() ): @@ -448,9 +452,7 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView): project.archived_at = timezone.now() project.save() UserFavorite.objects.filter(workspace__slug=slug, project=project_id).delete() - return Response( - {"archived_at": str(project.archived_at)}, status=status.HTTP_200_OK - ) + return Response({"archived_at": str(project.archived_at)}, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) def delete(self, request, slug, project_id): @@ -466,26 +468,18 @@ class ProjectIdentifierEndpoint(BaseAPIView): name = request.GET.get("name", "").strip().upper() if name == "": - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) - exists = ProjectIdentifier.objects.filter( - name=name, workspace__slug=slug - ).values("id", "name", "project") + exists = ProjectIdentifier.objects.filter(name=name, workspace__slug=slug).values("id", "name", "project") - return Response( - {"exists": len(exists), "identifiers": exists}, status=status.HTTP_200_OK - ) + return Response({"exists": len(exists), "identifiers": exists}, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def delete(self, request, slug): name = request.data.get("name", "").strip().upper() if name == "": - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) if Project.objects.filter(identifier=name, workspace__slug=slug).exists(): return Response( @@ -502,9 +496,7 @@ class ProjectUserViewsEndpoint(BaseAPIView): def post(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) - project_member = ProjectMember.objects.filter( - member=request.user, project=project, is_active=True - ).first() + project_member = ProjectMember.objects.filter(member=request.user, project=project, is_active=True).first() if project_member is None: return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) @@ -533,9 +525,7 @@ class ProjectFavoritesViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(user=self.request.user) - .select_related( - "project", "project__project_lead", "project__default_assignee" - ) + .select_related("project", "project__project_lead", "project__default_assignee") .select_related("workspace", "workspace__owner") ) diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py index c7ae8b19c..cc5b3f4b5 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -52,9 +52,7 @@ class ProjectInvitationsViewset(BaseViewSet): # Check if email is provided if not emails: - return Response( - {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST) for email in emails: workspace_role = WorkspaceMember.objects.filter( @@ -62,11 +60,7 @@ class ProjectInvitationsViewset(BaseViewSet): ).role if workspace_role in [5, 20] and workspace_role != email.get("role", 5): - return Response( - { - "error": "You cannot invite a user with different role than workspace role" - } - ) + return Response({"error": "You cannot invite a user with different role than workspace role"}) workspace = Workspace.objects.get(slug=slug) @@ -91,7 +85,7 @@ class ProjectInvitationsViewset(BaseViewSet): except ValidationError: return Response( { - "error": f"Invalid email - {email} provided a valid email address is required to send the invite" + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -112,9 +106,7 @@ class ProjectInvitationsViewset(BaseViewSet): request.user.email, ) - return Response( - {"message": "Email sent successfully"}, status=status.HTTP_200_OK - ) + return Response({"message": "Email sent successfully"}, status=status.HTTP_200_OK) class UserProjectInvitationsViewset(BaseViewSet): @@ -134,20 +126,13 @@ class UserProjectInvitationsViewset(BaseViewSet): project_ids = request.data.get("project_ids", []) # Get the workspace user role - workspace_member = WorkspaceMember.objects.get( - member=request.user, workspace__slug=slug, is_active=True - ) + workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True) # Get all the projects - projects = Project.objects.filter( - id__in=project_ids, workspace__slug=slug - ).only("id", "network") + projects = Project.objects.filter(id__in=project_ids, workspace__slug=slug).only("id", "network") # Check if user has permission to join each project for project in projects: - if ( - project.network == ProjectNetwork.SECRET.value - and workspace_member.role != ROLE.ADMIN.value - ): + if project.network == ProjectNetwork.SECRET.value and workspace_member.role != ROLE.ADMIN.value: return Response( {"error": "Only workspace admins can join private project"}, status=status.HTTP_403_FORBIDDEN, @@ -157,9 +142,9 @@ class UserProjectInvitationsViewset(BaseViewSet): workspace = workspace_member.workspace # If the user was already part of workspace - _ = ProjectMember.objects.filter( - workspace__slug=slug, project_id__in=project_ids, member=request.user - ).update(is_active=True) + _ = ProjectMember.objects.filter(workspace__slug=slug, project_id__in=project_ids, member=request.user).update( + is_active=True + ) ProjectMember.objects.bulk_create( [ @@ -188,18 +173,14 @@ class UserProjectInvitationsViewset(BaseViewSet): ignore_conflicts=True, ) - return Response( - {"message": "Projects joined successfully"}, status=status.HTTP_201_CREATED - ) + return Response({"message": "Projects joined successfully"}, status=status.HTTP_201_CREATED) class ProjectJoinEndpoint(BaseAPIView): permission_classes = [AllowAny] def post(self, request, slug, project_id, pk): - project_invite = ProjectMemberInvite.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug - ) + project_invite = ProjectMemberInvite.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) email = request.data.get("email", "") @@ -219,9 +200,7 @@ class ProjectJoinEndpoint(BaseAPIView): user = User.objects.filter(email=email).first() # Check if user is a part of workspace - workspace_member = WorkspaceMember.objects.filter( - workspace__slug=slug, member=user - ).first() + workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first() # Add him to workspace if workspace_member is None: _ = WorkspaceMember.objects.create( @@ -266,8 +245,6 @@ class ProjectJoinEndpoint(BaseAPIView): ) def get(self, request, slug, project_id, pk): - project_invitation = ProjectMemberInvite.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + project_invitation = ProjectMemberInvite.objects.get(workspace__slug=slug, project_id=project_id, pk=pk) serializer = ProjectMemberInviteSerializer(project_invitation) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/project/member.py b/apps/api/plane/app/views/project/member.py index 0b09c1366..0fc19adeb 100644 --- a/apps/api/plane/app/views/project/member.py +++ b/apps/api/plane/app/views/project/member.py @@ -48,7 +48,7 @@ class ProjectMemberViewSet(BaseViewSet): # Check if the members array is empty if not len(members): return Response( - {"error": "Atleast one member is required"}, + {"error": "At least one member is required"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -57,9 +57,7 @@ class ProjectMemberViewSet(BaseViewSet): bulk_issue_props = [] # Create a dictionary of the member_id and their roles - member_roles = { - member.get("member_id"): member.get("role") for member in members - } + member_roles = {member.get("member_id"): member.get("role") for member in members} # check the workspace role of the new user for member in member_roles: @@ -68,17 +66,13 @@ class ProjectMemberViewSet(BaseViewSet): ).role if workspace_member_role in [20] and member_roles.get(member) in [5, 15]: return Response( - { - "error": "You cannot add a user with role lower than the workspace role" - }, + {"error": "You cannot add a user with role lower than the workspace role"}, status=status.HTTP_400_BAD_REQUEST, ) if workspace_member_role in [5] and member_roles.get(member) in [15, 20]: return Response( - { - "error": "You cannot add a user with role higher than the workspace role" - }, + {"error": "You cannot add a user with role higher than the workspace role"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -92,9 +86,7 @@ class ProjectMemberViewSet(BaseViewSet): bulk_project_members.append(project_member) # Update the roles of the existing members - ProjectMember.objects.bulk_update( - bulk_project_members, ["is_active", "role"], batch_size=100 - ) + ProjectMember.objects.bulk_update(bulk_project_members, ["is_active", "role"], batch_size=100) # Get the list of project members of the requested workspace with the given slug project_members = ( @@ -134,13 +126,9 @@ class ProjectMemberViewSet(BaseViewSet): ) # Bulk create the project members and issue properties - project_members = ProjectMember.objects.bulk_create( - bulk_project_members, batch_size=10, ignore_conflicts=True - ) + project_members = ProjectMember.objects.bulk_create(bulk_project_members, batch_size=10, ignore_conflicts=True) - _ = IssueUserProperty.objects.bulk_create( - bulk_issue_props, batch_size=10, ignore_conflicts=True - ) + _ = IssueUserProperty.objects.bulk_create(bulk_issue_props, batch_size=10, ignore_conflicts=True) project_members = ProjectMember.objects.filter( project_id=project_id, @@ -172,16 +160,12 @@ class ProjectMemberViewSet(BaseViewSet): member__member_workspace__is_active=True, ).select_related("project", "member", "workspace") - serializer = ProjectMemberRoleSerializer( - project_members, fields=("id", "member", "role"), many=True - ) + serializer = ProjectMemberRoleSerializer(project_members, fields=("id", "member", "role"), many=True) return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): - project_member = ProjectMember.objects.get( - pk=pk, workspace__slug=slug, project_id=project_id, is_active=True - ) + project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id, is_active=True) # Fetch the workspace role of the project member workspace_role = WorkspaceMember.objects.get( @@ -203,20 +187,15 @@ class ProjectMemberViewSet(BaseViewSet): is_active=True, ) - if workspace_role in [5] and int( - request.data.get("role", project_member.role) - ) in [15, 20]: + if workspace_role in [5] and int(request.data.get("role", project_member.role)) in [15, 20]: return Response( - { - "error": "You cannot add a user with role higher than the workspace role" - }, + {"error": "You cannot add a user with role higher than the workspace role"}, status=status.HTTP_400_BAD_REQUEST, ) if ( "role" in request.data - and int(request.data.get("role", project_member.role)) - > requested_project_member.role + and int(request.data.get("role", project_member.role)) > requested_project_member.role and not is_workspace_admin ): return Response( @@ -224,9 +203,7 @@ class ProjectMemberViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = ProjectMemberSerializer( - project_member, data=request.data, partial=True - ) + serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -252,9 +229,7 @@ class ProjectMemberViewSet(BaseViewSet): # User cannot remove himself if str(project_member.id) == str(requesting_project_member.id): return Response( - { - "error": "You cannot remove yourself from the workspace. Please use leave workspace" - }, + {"error": "You cannot remove yourself from the workspace. Please use leave workspace"}, status=status.HTTP_400_BAD_REQUEST, ) # User cannot deactivate higher role @@ -287,7 +262,7 @@ class ProjectMemberViewSet(BaseViewSet): ): return Response( { - "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin" + "error": "You cannot leave the project as your the only admin of the project you will have to either delete the project or create an another admin" # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -323,7 +298,5 @@ class UserProjectRolesEndpoint(BaseAPIView): member__member_workspace__is_active=True, ).values("project_id", "role") - project_members = { - str(member["project_id"]): member["role"] for member in project_members - } + project_members = {str(member["project_id"]): member["role"] for member in project_members} return Response(project_members, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/search/base.py b/apps/api/plane/app/views/search/base.py index b98e2855f..a598d1ee1 100644 --- a/apps/api/plane/app/views/search/base.py +++ b/apps/api/plane/app/views/search/base.py @@ -120,9 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView): if workspace_search == "false" and project_id: cycles = cycles.filter(project_id=project_id) - return cycles.distinct().values( - "name", "id", "project_id", "project__identifier", "workspace__slug" - ) + return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") def filter_modules(self, query, slug, project_id, workspace_search): fields = ["name"] @@ -141,9 +139,7 @@ class GlobalSearchEndpoint(BaseAPIView): if workspace_search == "false" and project_id: modules = modules.filter(project_id=project_id) - return modules.distinct().values( - "name", "id", "project_id", "project__identifier", "workspace__slug" - ) + return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") def filter_pages(self, query, slug, project_id, workspace_search): fields = ["name"] @@ -161,9 +157,7 @@ class GlobalSearchEndpoint(BaseAPIView): ) .annotate( project_ids=Coalesce( - ArrayAgg( - "projects__id", distinct=True, filter=~Q(projects__id=True) - ), + ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)), Value([], output_field=ArrayField(UUIDField())), ) ) @@ -180,17 +174,13 @@ class GlobalSearchEndpoint(BaseAPIView): ) if workspace_search == "false" and project_id: - project_subquery = ProjectPage.objects.filter( - page_id=OuterRef("id"), project_id=project_id - ).values_list("project_id", flat=True)[:1] + project_subquery = ProjectPage.objects.filter(page_id=OuterRef("id"), project_id=project_id).values_list( + "project_id", flat=True + )[:1] - pages = pages.annotate(project_id=Subquery(project_subquery)).filter( - project_id=project_id - ) + pages = pages.annotate(project_id=Subquery(project_subquery)).filter(project_id=project_id) - return pages.distinct().values( - "name", "id", "project_ids", "project_identifiers", "workspace__slug" - ) + return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug") def filter_views(self, query, slug, project_id, workspace_search): fields = ["name"] @@ -209,9 +199,7 @@ class GlobalSearchEndpoint(BaseAPIView): if workspace_search == "false" and project_id: issue_views = issue_views.filter(project_id=project_id) - return issue_views.distinct().values( - "name", "id", "project_id", "project__identifier", "workspace__slug" - ) + return issue_views.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug") def get(self, request, slug): query = request.query_params.get("search", False) @@ -308,9 +296,7 @@ class SearchEndpoint(BaseAPIView): if issue_id: issue_created_by = ( - Issue.objects.filter(id=issue_id) - .values_list("created_by_id", flat=True) - .first() + Issue.objects.filter(id=issue_id).values_list("created_by_id", flat=True).first() ) users = ( users.filter(Q(role__gt=10) | Q(member_id=issue_created_by)) @@ -344,15 +330,12 @@ class SearchEndpoint(BaseAPIView): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) - | Q(network=2), + 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] + .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] ) response_data["project"] = list(projects) @@ -411,20 +394,16 @@ class SearchEndpoint(BaseAPIView): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), + 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( - end_date__lt=timezone.now(), then=Value("COMPLETED") - ), - When( - Q(start_date__isnull=True) - & Q(end_date__isnull=True), + Q(start_date__isnull=True) & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), @@ -542,9 +521,7 @@ class SearchEndpoint(BaseAPIView): ) ) .order_by("-created_at") - .values( - "member__avatar_url", "member__display_name", "member__id" - )[:count] + .values("member__avatar_url", "member__display_name", "member__id")[:count] ) response_data["user_mention"] = list(users) @@ -558,15 +535,12 @@ class SearchEndpoint(BaseAPIView): projects = ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) - | Q(network=2), + 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] + .values("name", "id", "identifier", "logo_props", "workspace__slug")[:count] ) response_data["project"] = list(projects) @@ -623,20 +597,16 @@ class SearchEndpoint(BaseAPIView): .annotate( status=Case( When( - Q(start_date__lte=timezone.now()) - & Q(end_date__gte=timezone.now()), + 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( - end_date__lt=timezone.now(), then=Value("COMPLETED") - ), - When( - Q(start_date__isnull=True) - & Q(end_date__isnull=True), + Q(start_date__isnull=True) & Q(end_date__isnull=True), then=Value("DRAFT"), ), default=Value("DRAFT"), diff --git a/apps/api/plane/app/views/search/issue.py b/apps/api/plane/app/views/search/issue.py index b3bce1eda..ac9d783a9 100644 --- a/apps/api/plane/app/views/search/issue.py +++ b/apps/api/plane/app/views/search/issue.py @@ -30,23 +30,17 @@ class IssueSearchEndpoint(BaseAPIView): return issues - def search_issues_and_excluding_parent( - self, issues: QuerySet, issue_id: str - ) -> QuerySet: + def search_issues_and_excluding_parent(self, issues: QuerySet, issue_id: str) -> QuerySet: """ Search issues and epics by query excluding the parent """ issue = Issue.issue_objects.filter(pk=issue_id).first() if issue: - issues = issues.filter( - ~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id) - ) + issues = issues.filter(~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)) return issues - def filter_issues_excluding_related_issues( - self, issue_id: str, issues: QuerySet - ) -> QuerySet: + def filter_issues_excluding_related_issues(self, issue_id: str, issues: QuerySet) -> QuerySet: """ Filter issues excluding related issues """ @@ -81,18 +75,14 @@ class IssueSearchEndpoint(BaseAPIView): """ Exclude issues in cycles """ - issues = issues.exclude( - Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True) - ) + issues = issues.exclude(Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)) return issues def exclude_issues_in_module(self, issues: QuerySet, module: str) -> QuerySet: """ Exclude issues in a module """ - issues = issues.exclude( - Q(issue_module__module=module) & Q(issue_module__deleted_at__isnull=True) - ) + issues = issues.exclude(Q(issue_module__module=module) & Q(issue_module__deleted_at__isnull=True)) return issues def filter_issues_without_target_date(self, issues: QuerySet) -> QuerySet: diff --git a/apps/api/plane/app/views/state/base.py b/apps/api/plane/app/views/state/base.py index b735659c5..de8d93953 100644 --- a/apps/api/plane/app/views/state/base.py +++ b/apps/api/plane/app/views/state/base.py @@ -57,9 +57,7 @@ class StateViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) def partial_update(self, request, slug, project_id, pk): try: - state = State.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug - ) + state = State.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) serializer = StateSerializer(state, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -103,20 +101,14 @@ class StateViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN]) def mark_as_default(self, request, slug, project_id, pk): # Select all the states which are marked as default - _ = State.objects.filter( - workspace__slug=slug, project_id=project_id, default=True - ).update(default=False) - _ = State.objects.filter( - workspace__slug=slug, project_id=project_id, pk=pk - ).update(default=True) + _ = State.objects.filter(workspace__slug=slug, project_id=project_id, default=True).update(default=False) + _ = State.objects.filter(workspace__slug=slug, project_id=project_id, pk=pk).update(default=True) return Response(status=status.HTTP_204_NO_CONTENT) @invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False) @allow_permission([ROLE.ADMIN]) def destroy(self, request, slug, project_id, pk): - state = State.objects.get( - is_triage=False, pk=pk, project_id=project_id, workspace__slug=slug - ) + state = State.objects.get(is_triage=False, pk=pk, project_id=project_id, workspace__slug=slug) if state.default: return Response( diff --git a/apps/api/plane/app/views/timezone/base.py b/apps/api/plane/app/views/timezone/base.py index bb3f10c0b..fc0121179 100644 --- a/apps/api/plane/app/views/timezone/base.py +++ b/apps/api/plane/app/views/timezone/base.py @@ -99,7 +99,7 @@ class TimezoneEndpoint(APIView): ("Tunis", "Africa/Tunis"), # UTC+01:00 ( "Eastern European Time (Cairo, Helsinki, Kyiv)", - "Europe/Kiev", + "Europe/Kyiv", ), # UTC+02:00 (DST: UTC+03:00) ("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00) ("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00) @@ -187,10 +187,7 @@ class TimezoneEndpoint(APIView): total_seconds = int(current_utc_offset.total_seconds()) hours_offset = total_seconds // 3600 minutes_offset = abs(total_seconds % 3600) // 60 - offset = ( - f"{'+' if hours_offset >= 0 else '-'}" - f"{abs(hours_offset):02}:{minutes_offset:02}" - ) + offset = f"{'+' if hours_offset >= 0 else '-'}{abs(hours_offset):02}:{minutes_offset:02}" timezone_value = { "offset": int(current_offset), diff --git a/apps/api/plane/app/views/user/base.py b/apps/api/plane/app/views/user/base.py index 08389d50c..c26b63c13 100644 --- a/apps/api/plane/app/views/user/base.py +++ b/apps/api/plane/app/views/user/base.py @@ -63,9 +63,7 @@ class UserEndpoint(BaseViewSet): def retrieve_instance_admin(self, request): instance = Instance.objects.first() - is_admin = InstanceAdmin.objects.filter( - instance=instance, user=request.user - ).exists() + is_admin = InstanceAdmin.objects.filter(instance=instance, user=request.user).exists() return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) def partial_update(self, request, *args, **kwargs): @@ -78,18 +76,14 @@ class UserEndpoint(BaseViewSet): # Instance admin check if InstanceAdmin.objects.filter(user=user).exists(): return Response( - { - "error": "You cannot deactivate your account since you are an instance admin" - }, + {"error": "You cannot deactivate your account since you are an instance admin"}, status=status.HTTP_400_BAD_REQUEST, ) projects_to_deactivate = [] workspaces_to_deactivate = [] - projects = ProjectMember.objects.filter( - member=request.user, is_active=True - ).annotate( + projects = ProjectMember.objects.filter(member=request.user, is_active=True).annotate( other_admin_exists=Count( Case( When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), @@ -106,15 +100,11 @@ class UserEndpoint(BaseViewSet): projects_to_deactivate.append(project) else: return Response( - { - "error": "You cannot deactivate account as you are the only admin in some projects." - }, + {"error": "You cannot deactivate account as you are the only admin in some projects."}, status=status.HTTP_400_BAD_REQUEST, ) - workspaces = WorkspaceMember.objects.filter( - member=request.user, is_active=True - ).annotate( + workspaces = WorkspaceMember.objects.filter(member=request.user, is_active=True).annotate( other_admin_exists=Count( Case( When(Q(role=20, is_active=True) & ~Q(member=request.user), then=1), @@ -131,19 +121,13 @@ class UserEndpoint(BaseViewSet): workspaces_to_deactivate.append(workspace) else: return Response( - { - "error": "You cannot deactivate account as you are the only admin in some workspaces." - }, + {"error": "You cannot deactivate account as you are the only admin in some workspaces."}, status=status.HTTP_400_BAD_REQUEST, ) - ProjectMember.objects.bulk_update( - projects_to_deactivate, ["is_active"], batch_size=100 - ) + ProjectMember.objects.bulk_update(projects_to_deactivate, ["is_active"], batch_size=100) - WorkspaceMember.objects.bulk_update( - workspaces_to_deactivate, ["is_active"], batch_size=100 - ) + WorkspaceMember.objects.bulk_update(workspaces_to_deactivate, ["is_active"], batch_size=100) # Delete all workspace invites WorkspaceMemberInvite.objects.filter(email=user.email).delete() @@ -224,9 +208,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, - on_results=lambda issue_activities: IssueActivitySerializer( - issue_activities, many=True - ).data, + on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data, ) diff --git a/apps/api/plane/app/views/view/base.py b/apps/api/plane/app/views/view/base.py index c1dd2631d..98fe04c62 100644 --- a/apps/api/plane/app/views/view/base.py +++ b/apps/api/plane/app/views/view/base.py @@ -1,3 +1,5 @@ +import copy + # Django imports from django.db.models import ( Exists, @@ -39,6 +41,8 @@ from plane.utils.order_queryset import order_issue_queryset from plane.bgtasks.recent_visited_task import recent_visited_task from .. import BaseViewSet from plane.db.models import UserFavorite +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet class WorkspaceViewViewSet(BaseViewSet): @@ -56,39 +60,26 @@ class WorkspaceViewViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=1)) - .select_related("workspace") .order_by(self.request.GET.get("order_by", "-created_at")) .distinct() ) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): queryset = self.get_queryset() fields = [field for field in request.GET.get("fields", "").split(",") if field] - if WorkspaceMember.objects.filter( - workspace__slug=slug, member=request.user, role=5, is_active=True - ).exists(): + if WorkspaceMember.objects.filter(workspace__slug=slug, member=request.user, role=5, is_active=True).exists(): queryset = queryset.filter(owned_by=request.user) - views = IssueViewSerializer( - queryset, many=True, fields=fields if fields else None - ).data + views = IssueViewSerializer(queryset, many=True, fields=fields if fields else None).data return Response(views, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView - ) + @allow_permission(allowed_roles=[], level="WORKSPACE", creator=True, model=IssueView) def partial_update(self, request, slug, pk): with transaction.atomic(): - workspace_view = IssueView.objects.select_for_update().get( - pk=pk, workspace__slug=slug - ) + workspace_view = IssueView.objects.select_for_update().get(pk=pk, workspace__slug=slug) if workspace_view.is_locked: - return Response( - {"error": "view is locked"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "view is locked"}, status=status.HTTP_400_BAD_REQUEST) # Only update the view if owner is updating if workspace_view.owned_by_id != request.user.id: @@ -97,9 +88,7 @@ class WorkspaceViewViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = IssueViewSerializer( - workspace_view, data=request.data, partial=True - ) + serializer = IssueViewSerializer(workspace_view, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -118,9 +107,7 @@ class WorkspaceViewViewSet(BaseViewSet): ) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN], level="WORKSPACE", creator=True, model=IssueView - ) + @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE", creator=True, model=IssueView) def destroy(self, request, slug, pk): workspace_view = IssueView.objects.get(pk=pk, workspace__slug=slug) @@ -145,6 +132,9 @@ class WorkspaceViewViewSet(BaseViewSet): class WorkspaceViewIssuesViewSet(BaseViewSet): + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet + def _get_project_permission_filters(self): """ Get common project permission filters for guest users and role-based access control. @@ -167,39 +157,11 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): project__project_projectmember__is_active=True, ) - def get_queryset(self): + def apply_annotations(self, issues): return ( - Issue.issue_objects.annotate( - sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .filter(workspace__slug=self.kwargs.get("slug")) - .select_related("state") - .prefetch_related( - Prefetch( - "issue_assignee", - queryset=IssueAssignee.objects.all(), - ) - ) - .prefetch_related( - Prefetch( - "label_issue", - queryset=IssueLabel.objects.all(), - ) - ) - .prefetch_related( - Prefetch( - "issue_module", - queryset=ModuleIssue.objects.all(), - ) - ) - .annotate( + issues.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -223,32 +185,55 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .prefetch_related( + Prefetch( + "issue_assignee", + queryset=IssueAssignee.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "label_issue", + queryset=IssueLabel.objects.all(), + ) + ) + .prefetch_related( + Prefetch( + "issue_module", + queryset=ModuleIssue.objects.all(), + ) + ) ) + def get_queryset(self): + return Issue.issue_objects.filter(workspace__slug=self.kwargs.get("slug")) + @method_decorator(gzip_page) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): - filters = issue_filters(request.query_params, "GET") + issue_queryset = self.get_queryset() + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = self.get_queryset().filter(**filters) + # Apply legacy filters + filters = issue_filters(request.query_params, "GET") + issue_queryset = issue_queryset.filter(**filters) # Get common project permission filters permission_filters = self._get_project_permission_filters() - - # Base query for the counts - total_issue_count = ( - Issue.issue_objects.filter(**filters) - .filter(workspace__slug=slug) - .filter(permission_filters) - .only("id") - ) - # Apply project permission filters to the issue queryset issue_queryset = issue_queryset.filter(permission_filters) + # Base query for the counts + total_issue_count_queryset = copy.deepcopy(issue_queryset) + total_issue_count_queryset = total_issue_count_queryset.only("id") + + # Apply annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) + # Issue queryset issue_queryset, order_by_param = order_issue_queryset( issue_queryset=issue_queryset, order_by_param=order_by_param @@ -260,7 +245,7 @@ class WorkspaceViewIssuesViewSet(BaseViewSet): request=request, queryset=issue_queryset, on_results=lambda issues: ViewIssueListSerializer(issues, many=True).data, - total_count_queryset=total_issue_count, + total_count_queryset=total_issue_count_queryset, ) @@ -269,9 +254,7 @@ class IssueViewViewSet(BaseViewSet): model = IssueView def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), owned_by=self.request.user - ) + serializer.save(project_id=self.kwargs.get("project_id"), owned_by=self.request.user) def get_queryset(self): subquery = UserFavorite.objects.filter( @@ -315,9 +298,7 @@ class IssueViewViewSet(BaseViewSet): ): queryset = queryset.filter(owned_by=request.user) fields = [field for field in request.GET.get("fields", "").split(",") if field] - views = IssueViewSerializer( - queryset, many=True, fields=fields if fields else None - ).data + views = IssueViewSerializer(queryset, many=True, fields=fields if fields else None).data return Response(views, status=status.HTTP_200_OK) @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @@ -358,14 +339,10 @@ class IssueViewViewSet(BaseViewSet): @allow_permission(allowed_roles=[], creator=True, model=IssueView) def partial_update(self, request, slug, project_id, pk): with transaction.atomic(): - issue_view = IssueView.objects.select_for_update().get( - pk=pk, workspace__slug=slug, project_id=project_id - ) + issue_view = IssueView.objects.select_for_update().get(pk=pk, workspace__slug=slug, project_id=project_id) if issue_view.is_locked: - return Response( - {"error": "view is locked"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "view is locked"}, status=status.HTTP_400_BAD_REQUEST) # Only update the view if owner is updating if issue_view.owned_by_id != request.user.id: @@ -374,9 +351,7 @@ class IssueViewViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) - serializer = IssueViewSerializer( - issue_view, data=request.data, partial=True - ) + serializer = IssueViewSerializer(issue_view, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -385,9 +360,7 @@ class IssueViewViewSet(BaseViewSet): @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=IssueView) def destroy(self, request, slug, project_id, pk): - project_view = IssueView.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug - ) + project_view = IssueView.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) if ( ProjectMember.objects.filter( workspace__slug=slug, diff --git a/apps/api/plane/app/views/webhook/base.py b/apps/api/plane/app/views/webhook/base.py index 0ed4ba9e0..e857c3e08 100644 --- a/apps/api/plane/app/views/webhook/base.py +++ b/apps/api/plane/app/views/webhook/base.py @@ -18,9 +18,7 @@ class WebhookEndpoint(BaseAPIView): def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) try: - serializer = WebhookSerializer( - data=request.data, context={"request": request} - ) + serializer = WebhookSerializer(data=request.data, context={"request": request}) if serializer.is_valid(): serializer.save(workspace_id=workspace.id) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -119,8 +117,6 @@ class WebhookSecretRegenerateEndpoint(BaseAPIView): class WebhookLogsEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") def get(self, request, slug, webhook_id): - webhook_logs = WebhookLog.objects.filter( - workspace__slug=slug, webhook=webhook_id - ) + webhook_logs = WebhookLog.objects.filter(workspace__slug=slug, webhook=webhook_id) serializer = WebhookLogSerializer(webhook_logs, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/base.py b/apps/api/plane/app/views/workspace/base.py index a37624d2a..c27b7adbb 100644 --- a/apps/api/plane/app/views/workspace/base.py +++ b/apps/api/plane/app/views/workspace/base.py @@ -39,9 +39,6 @@ from plane.db.models import ( Profile, ) from plane.app.permissions import ROLE, allow_permission -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 from plane.bgtasks.workspace_seed_task import workspace_seed @@ -60,9 +57,7 @@ class WorkSpaceViewSet(BaseViewSet): def get_queryset(self): member_count = ( - WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member__is_bot=False, is_active=True - ) + WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -129,9 +124,7 @@ class WorkSpaceViewSet(BaseViewSet): ) # Get total members and role - total_members = WorkspaceMember.objects.filter( - workspace_id=serializer.data["id"] - ).count() + total_members = WorkspaceMember.objects.filter(workspace_id=serializer.data["id"]).count() data = serializer.data data["total_members"] = total_members data["role"] = 20 @@ -182,31 +175,25 @@ class UserWorkSpacesEndpoint(BaseAPIView): def get(self, request): fields = [field for field in request.GET.get("fields", "").split(",") if field] member_count = ( - WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member__is_bot=False, is_active=True - ) + WorkspaceMember.objects.filter(workspace=OuterRef("id"), member__is_bot=False, is_active=True) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") ) - role = WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member=request.user, is_active=True - ).values("role") + role = WorkspaceMember.objects.filter(workspace=OuterRef("id"), member=request.user, is_active=True).values( + "role" + ) workspace = ( Workspace.objects.prefetch_related( Prefetch( "workspace_member", - queryset=WorkspaceMember.objects.filter( - member=request.user, is_active=True - ), + queryset=WorkspaceMember.objects.filter(member=request.user, is_active=True), ) ) .annotate(role=role, total_members=member_count) - .filter( - workspace_member__member=request.user, workspace_member__is_active=True - ) + .filter(workspace_member__member=request.user, workspace_member__is_active=True) .distinct() ) @@ -229,10 +216,7 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - workspace = ( - Workspace.objects.filter(slug=slug).exists() - or slug in RESTRICTED_WORKSPACE_SLUGS - ) + workspace = Workspace.objects.filter(slug=slug).exists() or slug in RESTRICTED_WORKSPACE_SLUGS return Response({"status": not workspace}, status=status.HTTP_200_OK) @@ -271,9 +255,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): .order_by("week_in_month") ) - assigned_issues = Issue.issue_objects.filter( - workspace__slug=slug, assignees__in=[request.user] - ).count() + assigned_issues = Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]).count() pending_issues_count = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), @@ -286,18 +268,14 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): ).count() issues_due_week = ( - Issue.issue_objects.filter( - workspace__slug=slug, assignees__in=[request.user] - ) + Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]) .annotate(target_week=ExtractWeek("target_date")) .filter(target_week=timezone.now().date().isocalendar()[1]) .count() ) state_distribution = ( - Issue.issue_objects.filter( - workspace__slug=slug, assignees__in=[request.user] - ) + Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]) .annotate(state_group=F("state__group")) .values("state_group") .annotate(state_count=Count("state_group")) @@ -366,9 +344,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView): def post(self, request, slug, user_id): if not request.data.get("date"): - return Response( - {"error": "Date is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Date is required"}, status=status.HTTP_400_BAD_REQUEST) user_activities = IssueActivity.objects.filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), @@ -406,7 +382,5 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView): ] csv_buffer = self.generate_csv_from_rows([header] + rows) response = HttpResponse(csv_buffer.getvalue(), content_type="text/csv") - response["Content-Disposition"] = ( - 'attachment; filename="workspace-user-activity.csv"' - ) + response["Content-Disposition"] = 'attachment; filename="workspace-user-activity.csv"' return response diff --git a/apps/api/plane/app/views/workspace/draft.py b/apps/api/plane/app/views/workspace/draft.py index e4b032725..c89fe4a73 100644 --- a/apps/api/plane/app/views/workspace/draft.py +++ b/apps/api/plane/app/views/workspace/draft.py @@ -49,9 +49,9 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): .prefetch_related("assignees", "labels", "draft_issue_module__module") .annotate( cycle_id=Subquery( - DraftIssueCycle.objects.filter( - draft_issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + DraftIssueCycle.objects.filter(draft_issue=OuterRef("id"), deleted_at__isnull=True).values( + "cycle_id" + )[:1] ) ) .annotate( @@ -59,10 +59,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & (Q(draft_label_issue__deleted_at__isnull=True)) - ), + filter=Q(~Q(labels__id__isnull=True) & (Q(draft_label_issue__deleted_at__isnull=True))), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -94,14 +91,10 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): ).distinct() @method_decorator(gzip_page) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): filters = issue_filters(request.query_params, "GET") - issues = ( - self.get_queryset().filter(created_by=request.user).order_by("-created_at") - ) + issues = self.get_queryset().filter(created_by=request.user).order_by("-created_at") issues = issues.filter(**filters) # List Paginate @@ -111,9 +104,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): on_results=lambda issues: DraftIssueSerializer(issues, many=True).data, ) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def create(self, request, slug): workspace = Workspace.objects.get(slug=slug) @@ -168,9 +159,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): issue = self.get_queryset().filter(pk=pk, created_by=request.user).first() if not issue: - return Response( - {"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND) project_id = request.data.get("project_id", issue.project_id) @@ -190,9 +179,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - allowed_roles=[ROLE.ADMIN], creator=True, model=Issue, level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue, level="WORKSPACE") def retrieve(self, request, slug, pk=None): issue = self.get_queryset().filter(pk=pk, created_by=request.user).first() @@ -205,9 +192,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): serializer = DraftIssueDetailSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) - @allow_permission( - allowed_roles=[ROLE.ADMIN], creator=True, model=DraftIssue, level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=DraftIssue, level="WORKSPACE") def destroy(self, request, slug, pk=None): draft_issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk) draft_issue.delete() @@ -266,9 +251,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet): current_instance=json.dumps( { "updated_cycle_issues": None, - "created_cycle_issues": serializers.serialize( - "json", [created_records] - ), + "created_cycle_issues": serializers.serialize("json", [created_records]), } ), epoch=int(timezone.now().timestamp()), diff --git a/apps/api/plane/app/views/workspace/estimate.py b/apps/api/plane/app/views/workspace/estimate.py index 8b0981f9e..8cba3d170 100644 --- a/apps/api/plane/app/views/workspace/estimate.py +++ b/apps/api/plane/app/views/workspace/estimate.py @@ -16,9 +16,9 @@ class WorkspaceEstimatesEndpoint(BaseAPIView): @cache_response(60 * 60 * 2) def get(self, request, slug): - estimate_ids = Project.objects.filter( - workspace__slug=slug, estimate__isnull=False - ).values_list("estimate_id", flat=True) + estimate_ids = Project.objects.filter(workspace__slug=slug, estimate__isnull=False).values_list( + "estimate_id", flat=True + ) estimates = ( Estimate.objects.filter(pk__in=estimate_ids, workspace__slug=slug) .prefetch_related("points") diff --git a/apps/api/plane/app/views/workspace/favorite.py b/apps/api/plane/app/views/workspace/favorite.py index ee126fa5b..8a8bfed6c 100644 --- a/apps/api/plane/app/views/workspace/favorite.py +++ b/apps/api/plane/app/views/workspace/favorite.py @@ -19,9 +19,7 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): # the second filter is to check if the user is a member of the project - favorites = UserFavorite.objects.filter( - user=request.user, workspace__slug=slug, parent__isnull=True - ).filter( + favorites = UserFavorite.objects.filter(user=request.user, workspace__slug=slug, parent__isnull=True).filter( Q(project__isnull=True) & ~Q(entity_type="page") | ( Q(project__isnull=False) @@ -62,15 +60,11 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) except IntegrityError: - return Response( - {"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Favorite already exists"}, status=status.HTTP_400_BAD_REQUEST) @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def patch(self, request, slug, favorite_id): - favorite = UserFavorite.objects.get( - user=request.user, workspace__slug=slug, pk=favorite_id - ) + favorite = UserFavorite.objects.get(user=request.user, workspace__slug=slug, pk=favorite_id) serializer = UserFavoriteSerializer(favorite, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -79,9 +73,7 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def delete(self, request, slug, favorite_id): - favorite = UserFavorite.objects.get( - user=request.user, workspace__slug=slug, pk=favorite_id - ) + favorite = UserFavorite.objects.get(user=request.user, workspace__slug=slug, pk=favorite_id) favorite.delete(soft=False) return Response(status=status.HTTP_204_NO_CONTENT) @@ -89,9 +81,7 @@ class WorkspaceFavoriteEndpoint(BaseAPIView): class WorkspaceFavoriteGroupEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug, favorite_id): - favorites = UserFavorite.objects.filter( - user=request.user, workspace__slug=slug, parent_id=favorite_id - ).filter( + favorites = UserFavorite.objects.filter(user=request.user, workspace__slug=slug, parent_id=favorite_id).filter( Q(project__isnull=True) | ( Q(project__isnull=False) diff --git a/apps/api/plane/app/views/workspace/home.py b/apps/api/plane/app/views/workspace/home.py index 5ee9b0a39..731164eb1 100644 --- a/apps/api/plane/app/views/workspace/home.py +++ b/apps/api/plane/app/views/workspace/home.py @@ -20,9 +20,7 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView): def get(self, request, slug): workspace = Workspace.objects.get(slug=slug) - get_preference = WorkspaceHomePreference.objects.filter( - user=request.user, workspace_id=workspace.id - ) + get_preference = WorkspaceHomePreference.objects.filter(user=request.user, workspace_id=workspace.id) create_preference_keys = [] @@ -55,9 +53,7 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView): ) sort_order_counter += 1 - preference = WorkspaceHomePreference.objects.filter( - user=request.user, workspace_id=workspace.id - ) + preference = WorkspaceHomePreference.objects.filter(user=request.user, workspace_id=workspace.id) return Response( preference.values("key", "is_enabled", "config", "sort_order"), @@ -66,20 +62,14 @@ class WorkspaceHomePreferenceViewSet(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def patch(self, request, slug, key): - preference = WorkspaceHomePreference.objects.filter( - key=key, workspace__slug=slug, user=request.user - ).first() + preference = WorkspaceHomePreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first() if preference: - serializer = WorkspaceHomePreferenceSerializer( - preference, data=request.data, partial=True - ) + serializer = WorkspaceHomePreferenceSerializer(preference, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response( - {"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"detail": "Preference not found"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/app/views/workspace/invite.py b/apps/api/plane/app/views/workspace/invite.py index 84ef7c361..48bcf7eba 100644 --- a/apps/api/plane/app/views/workspace/invite.py +++ b/apps/api/plane/app/views/workspace/invite.py @@ -50,23 +50,13 @@ class WorkspaceInvitationsViewset(BaseViewSet): emails = request.data.get("emails", []) # Check if email is provided if not emails: - return Response( - {"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Emails are required"}, status=status.HTTP_400_BAD_REQUEST) # check for role level of the requesting user - requesting_user = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user, is_active=True - ) + requesting_user = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user, is_active=True) # Check if any invited user has an higher role - if len( - [ - email - for email in emails - if int(email.get("role", 5)) > requesting_user.role - ] - ): + if len([email for email in emails if int(email.get("role", 5)) > requesting_user.role]): return Response( {"error": "You cannot invite a user with higher role"}, status=status.HTTP_400_BAD_REQUEST, @@ -86,9 +76,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): return Response( { "error": "Some users are already member of workspace", - "workspace_users": WorkSpaceMemberSerializer( - workspace_members, many=True - ).data, + "workspace_users": WorkSpaceMemberSerializer(workspace_members, many=True).data, }, status=status.HTTP_400_BAD_REQUEST, ) @@ -113,7 +101,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): except ValidationError: return Response( { - "error": f"Invalid email - {email} provided a valid email address is required to send the invite" + "error": f"Invalid email - {email} provided a valid email address is required to send the invite" # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -134,14 +122,10 @@ class WorkspaceInvitationsViewset(BaseViewSet): request.user.email, ) - return Response( - {"message": "Emails sent successfully"}, status=status.HTTP_200_OK - ) + return Response({"message": "Emails sent successfully"}, status=status.HTTP_200_OK) def destroy(self, request, slug, pk): - workspace_member_invite = WorkspaceMemberInvite.objects.get( - pk=pk, workspace__slug=slug - ) + workspace_member_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug) workspace_member_invite.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -160,9 +144,7 @@ class WorkspaceJoinEndpoint(BaseAPIView): ) @invalidate_cache(path="/api/users/me/settings/", multiple=True) def post(self, request, slug, pk): - workspace_invite = WorkspaceMemberInvite.objects.get( - pk=pk, workspace__slug=slug - ) + workspace_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug) email = request.data.get("email", "") @@ -235,9 +217,7 @@ class WorkspaceJoinEndpoint(BaseAPIView): ) def get(self, request, slug, pk): - workspace_invitation = WorkspaceMemberInvite.objects.get( - workspace__slug=slug, pk=pk - ) + workspace_invitation = WorkspaceMemberInvite.objects.get(workspace__slug=slug, pk=pk) serializer = WorkSpaceMemberInviteSerializer(workspace_invitation) return Response(serializer.data, status=status.HTTP_200_OK) @@ -248,10 +228,7 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): def get_queryset(self): return self.filter_queryset( - super() - .get_queryset() - .filter(email=self.request.user.email) - .select_related("workspace") + super().get_queryset().filter(email=self.request.user.email).select_related("workspace") ) @invalidate_cache(path="/api/workspaces/", user=False) @@ -271,9 +248,9 @@ class UserWorkspaceInvitationsViewSet(BaseViewSet): multiple=True, ) # Update the WorkspaceMember for this specific invitation - WorkspaceMember.objects.filter( - workspace_id=invitation.workspace_id, member=request.user - ).update(is_active=True, role=invitation.role) + WorkspaceMember.objects.filter(workspace_id=invitation.workspace_id, member=request.user).update( + is_active=True, role=invitation.role + ) # Bulk create the user for all the workspaces WorkspaceMember.objects.bulk_create( diff --git a/apps/api/plane/app/views/workspace/member.py b/apps/api/plane/app/views/workspace/member.py index 84985cec3..d81a647f6 100644 --- a/apps/api/plane/app/views/workspace/member.py +++ b/apps/api/plane/app/views/workspace/member.py @@ -38,24 +38,16 @@ class WorkSpaceMemberViewSet(BaseViewSet): .select_related("member", "member__avatar_asset") ) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - member=request.user, workspace__slug=slug, is_active=True - ) + workspace_member = WorkspaceMember.objects.get(member=request.user, workspace__slug=slug, is_active=True) # Get all active workspace members workspace_members = self.get_queryset() if workspace_member.role > 5: - serializer = WorkspaceMemberAdminSerializer( - workspace_members, fields=("id", "member", "role"), many=True - ) + serializer = WorkspaceMemberAdminSerializer(workspace_members, fields=("id", "member", "role"), many=True) else: - serializer = WorkSpaceMemberSerializer( - workspace_members, fields=("id", "member", "role"), many=True - ) + serializer = WorkSpaceMemberSerializer(workspace_members, fields=("id", "member", "role"), many=True) return Response(serializer.data, status=status.HTTP_200_OK) @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") @@ -71,13 +63,9 @@ class WorkSpaceMemberViewSet(BaseViewSet): # If a user is moved to a guest role he can't have any other role in projects if "role" in request.data and int(request.data.get("role")) == 5: - ProjectMember.objects.filter( - workspace__slug=slug, member_id=workspace_member.member_id - ).update(role=5) + ProjectMember.objects.filter(workspace__slug=slug, member_id=workspace_member.member_id).update(role=5) - serializer = WorkSpaceMemberSerializer( - workspace_member, data=request.data, partial=True - ) + serializer = WorkSpaceMemberSerializer(workspace_member, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -98,9 +86,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): if str(workspace_member.id) == str(requesting_workspace_member.id): return Response( - { - "error": "You cannot remove yourself from the workspace. Please use leave workspace" - }, + {"error": "You cannot remove yourself from the workspace. Please use leave workspace"}, status=status.HTTP_400_BAD_REQUEST, ) @@ -126,7 +112,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin." + "error": "User is a part of some projects where they are the only admin, they should either leave that project or promote another user to admin." # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -148,25 +134,18 @@ class WorkSpaceMemberViewSet(BaseViewSet): ) @invalidate_cache(path="/api/users/me/settings/") @invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def leave(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user, is_active=True - ) + workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user, is_active=True) # Check if the leaving user is the only admin of the workspace if ( workspace_member.role == 20 - and not WorkspaceMember.objects.filter( - workspace__slug=slug, role=20, is_active=True - ).count() - > 1 + and not WorkspaceMember.objects.filter(workspace__slug=slug, role=20, is_active=True).count() > 1 ): return Response( { - "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." + "error": "You cannot leave the workspace as you are the only admin of the workspace you will have to either delete the workspace or promote another user to admin." # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -187,7 +166,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): ): return Response( { - "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." + "error": "You are a part of some projects where you are the only admin, you should either leave the project or promote another user to admin." # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -205,9 +184,7 @@ class WorkSpaceMemberViewSet(BaseViewSet): class WorkspaceMemberUserViewsEndpoint(BaseAPIView): def post(self, request, slug): - workspace_member = WorkspaceMember.objects.get( - workspace__slug=slug, member=request.user, is_active=True - ) + workspace_member = WorkspaceMember.objects.get(workspace__slug=slug, member=request.user, is_active=True) workspace_member.view_props = request.data.get("view_props", {}) workspace_member.save() @@ -219,23 +196,15 @@ class WorkspaceMemberUserEndpoint(BaseAPIView): def get(self, request, slug): draft_issue_count = ( - DraftIssue.objects.filter( - created_by=request.user, workspace_id=OuterRef("workspace_id") - ) + DraftIssue.objects.filter(created_by=request.user, workspace_id=OuterRef("workspace_id")) .values("workspace_id") .annotate(count=Count("id")) .values("count") ) workspace_member = ( - WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True - ) - .annotate( - draft_issue_count=Coalesce( - Subquery(draft_issue_count, output_field=IntegerField()), 0 - ) - ) + WorkspaceMember.objects.filter(member=request.user, workspace__slug=slug, is_active=True) + .annotate(draft_issue_count=Coalesce(Subquery(draft_issue_count, output_field=IntegerField()), 0)) .first() ) serializer = WorkspaceMemberMeSerializer(workspace_member) diff --git a/apps/api/plane/app/views/workspace/quick_link.py b/apps/api/plane/app/views/workspace/quick_link.py index 104ca00d2..82c104573 100644 --- a/apps/api/plane/app/views/workspace/quick_link.py +++ b/apps/api/plane/app/views/workspace/quick_link.py @@ -28,48 +28,34 @@ class QuickLinkViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def partial_update(self, request, slug, pk): - quick_link = WorkspaceUserLink.objects.filter( - pk=pk, workspace__slug=slug, owner=request.user - ).first() + quick_link = WorkspaceUserLink.objects.filter(pk=pk, workspace__slug=slug, owner=request.user).first() if quick_link: - serializer = WorkspaceUserLinkSerializer( - quick_link, data=request.data, partial=True - ) + serializer = WorkspaceUserLinkSerializer(quick_link, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response( - {"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"detail": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def retrieve(self, request, slug, pk): try: - quick_link = WorkspaceUserLink.objects.get( - pk=pk, workspace__slug=slug, owner=request.user - ) + quick_link = WorkspaceUserLink.objects.get(pk=pk, workspace__slug=slug, owner=request.user) serializer = WorkspaceUserLinkSerializer(quick_link) return Response(serializer.data, status=status.HTTP_200_OK) except WorkspaceUserLink.DoesNotExist: - return Response( - {"error": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Quick link not found."}, status=status.HTTP_404_NOT_FOUND) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def destroy(self, request, slug, pk): - quick_link = WorkspaceUserLink.objects.get( - pk=pk, workspace__slug=slug, owner=request.user - ) + quick_link = WorkspaceUserLink.objects.get(pk=pk, workspace__slug=slug, owner=request.user) quick_link.delete() return Response(status=status.HTTP_204_NO_CONTENT) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): - quick_links = WorkspaceUserLink.objects.filter( - workspace__slug=slug, owner=request.user - ) + quick_links = WorkspaceUserLink.objects.filter(workspace__slug=slug, owner=request.user) serializer = WorkspaceUserLinkSerializer(quick_links, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/recent_visit.py b/apps/api/plane/app/views/workspace/recent_visit.py index e1c50c8b6..0d9c1ef9b 100644 --- a/apps/api/plane/app/views/workspace/recent_visit.py +++ b/apps/api/plane/app/views/workspace/recent_visit.py @@ -19,18 +19,14 @@ class UserRecentVisitViewSet(BaseViewSet): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): - user_recent_visits = UserRecentVisit.objects.filter( - workspace__slug=slug, user=request.user - ) + user_recent_visits = UserRecentVisit.objects.filter(workspace__slug=slug, user=request.user) entity_name = request.query_params.get("entity_name") if entity_name: user_recent_visits = user_recent_visits.filter(entity_name=entity_name) - user_recent_visits = user_recent_visits.filter( - entity_name__in=["issue", "page", "project"] - ) + user_recent_visits = user_recent_visits.filter(entity_name__in=["issue", "page", "project"]) serializer = WorkspaceRecentVisitSerializer(user_recent_visits[:20], many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apps/api/plane/app/views/workspace/sticky.py b/apps/api/plane/app/views/workspace/sticky.py index 8b9654716..8ab6c5c98 100644 --- a/apps/api/plane/app/views/workspace/sticky.py +++ b/apps/api/plane/app/views/workspace/sticky.py @@ -24,9 +24,7 @@ class WorkspaceStickyViewSet(BaseViewSet): .distinct() ) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def create(self, request, slug): workspace = Workspace.objects.get(slug=slug) serializer = StickySerializer(data=request.data) @@ -35,9 +33,7 @@ class WorkspaceStickyViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @allow_permission( - allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" - ) + @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def list(self, request, slug): query = request.query_params.get("query", False) stickies = self.get_queryset().order_by("-sort_order") diff --git a/apps/api/plane/app/views/workspace/user.py b/apps/api/plane/app/views/workspace/user.py index cc1caa92c..0d4f152ee 100644 --- a/apps/api/plane/app/views/workspace/user.py +++ b/apps/api/plane/app/views/workspace/user.py @@ -1,4 +1,5 @@ # Python imports +import copy from datetime import date from dateutil.relativedelta import relativedelta @@ -56,6 +57,8 @@ from plane.utils.grouper import ( from plane.utils.issue_filters import issue_filters from plane.utils.order_queryset import order_issue_queryset from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPaginator +from plane.utils.filters import ComplexFilterBackend +from plane.utils.filters import IssueFilterSet class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): @@ -91,27 +94,14 @@ class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): permission_classes = [WorkspaceViewerPermission] - def get(self, request, slug, user_id): - filters = issue_filters(request.query_params, "GET") + filter_backends = (ComplexFilterBackend,) + filterset_class = IssueFilterSet - order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - Issue.issue_objects.filter( - Q(assignees__in=[user_id]) - | Q(created_by_id=user_id) - | Q(issue_subscribers__subscriber_id=user_id), - workspace__slug=slug, - project__project_projectmember__member=request.user, - project__project_projectmember__is_active=True, - ) - .filter(**filters) - .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") - .annotate( + def apply_annotations(self, issues): + return ( + issues.annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -135,8 +125,34 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .order_by("created_at") - ).distinct() + .prefetch_related("assignees", "labels", "issue_module__module") + ) + + def get(self, request, slug, user_id): + filters = issue_filters(request.query_params, "GET") + + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = Issue.issue_objects.filter( + id__in=Issue.issue_objects.filter( + Q(assignees__in=[user_id]) | Q(created_by_id=user_id) | Q(issue_subscribers__subscriber_id=user_id), + workspace__slug=slug, + ).values_list("id", flat=True), + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + + # Apply filtering from filterset + issue_queryset = self.filter_queryset(issue_queryset) + + # Apply legacy filters + issue_queryset = issue_queryset.filter(**filters) + + # Total count queryset + total_issue_queryset = copy.deepcopy(issue_queryset) + + # Apply annotations to the issue queryset + issue_queryset = self.apply_annotations(issue_queryset) # Issue queryset issue_queryset, order_by_param = order_issue_queryset( @@ -148,16 +164,14 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): sub_group_by = request.GET.get("sub_group_by", False) # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by - ) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) if group_by: if sub_group_by: if group_by == sub_group_by: return Response( { - "error": "Group by and sub group by cannot have same parameters" + "error": "Group by and sub group by cannot have same parameters" # noqa: E501 }, status=status.HTTP_400_BAD_REQUEST, ) @@ -166,15 +180,22 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=SubGroupedOffsetPaginator, group_by_fields=issue_group_values( - field=group_by, slug=slug, filters=filters + field=group_by, + slug=slug, + filters=filters, + queryset=total_issue_queryset, ), sub_group_by_fields=issue_group_values( - field=sub_group_by, slug=slug, filters=filters + field=sub_group_by, + slug=slug, + filters=filters, + queryset=total_issue_queryset, ), group_by_field_name=group_by, sub_group_by_field_name=sub_group_by, @@ -193,12 +214,16 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): request=request, order_by=order_by_param, queryset=issue_queryset, + total_count_queryset=total_issue_queryset, on_results=lambda issues: issue_on_results( group_by=group_by, issues=issues, sub_group_by=sub_group_by ), paginator_cls=GroupedOffsetPaginator, group_by_fields=issue_group_values( - field=group_by, slug=slug, filters=filters + field=group_by, + slug=slug, + filters=filters, + queryset=total_issue_queryset, ), group_by_field_name=group_by, count_filter=Q( @@ -215,9 +240,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): order_by=order_by_param, request=request, queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), + total_count_queryset=total_issue_queryset, + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), ) @@ -225,16 +249,11 @@ class WorkspaceUserPropertiesEndpoint(BaseAPIView): permission_classes = [WorkspaceViewerPermission] def patch(self, request, slug): - workspace_properties = WorkspaceUserProperties.objects.get( - user=request.user, workspace__slug=slug - ) + workspace_properties = WorkspaceUserProperties.objects.get(user=request.user, workspace__slug=slug) - workspace_properties.filters = request.data.get( - "filters", workspace_properties.filters - ) - workspace_properties.display_filters = request.data.get( - "display_filters", workspace_properties.display_filters - ) + workspace_properties.filters = request.data.get("filters", workspace_properties.filters) + workspace_properties.rich_filters = request.data.get("rich_filters", workspace_properties.rich_filters) + workspace_properties.display_filters = request.data.get("display_filters", workspace_properties.display_filters) workspace_properties.display_properties = request.data.get( "display_properties", workspace_properties.display_properties ) @@ -363,9 +382,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, - on_results=lambda issue_activities: IssueActivitySerializer( - issue_activities, many=True - ).data, + on_results=lambda issue_activities: IssueActivitySerializer(issue_activities, many=True).data, ) @@ -375,10 +392,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): state_distribution = ( Issue.issue_objects.filter( - ( - Q(assignees__in=[user_id]) - & Q(issue_assignee__deleted_at__isnull=True) - ), + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, @@ -394,10 +408,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): priority_distribution = ( Issue.issue_objects.filter( - ( - Q(assignees__in=[user_id]) - & Q(issue_assignee__deleted_at__isnull=True) - ), + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, @@ -408,10 +419,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): .filter(priority_count__gte=1) .annotate( priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], + *[When(priority=p, then=Value(i)) for i, p in enumerate(priority_order)], default=Value(len(priority_order)), output_field=IntegerField(), ) @@ -432,10 +440,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): assigned_issues_count = ( Issue.issue_objects.filter( - ( - Q(assignees__in=[user_id]) - & Q(issue_assignee__deleted_at__isnull=True) - ), + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, @@ -447,10 +452,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): pending_issues_count = ( Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), - ( - Q(assignees__in=[user_id]) - & Q(issue_assignee__deleted_at__isnull=True) - ), + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), workspace__slug=slug, project__project_projectmember__member=request.user, project__project_projectmember__is_active=True, @@ -461,10 +463,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): completed_issues_count = ( Issue.issue_objects.filter( - ( - Q(assignees__in=[user_id]) - & Q(issue_assignee__deleted_at__isnull=True) - ), + (Q(assignees__in=[user_id]) & Q(issue_assignee__deleted_at__isnull=True)), workspace__slug=slug, state__group="completed", project__project_projectmember__member=request.user, diff --git a/apps/api/plane/app/views/workspace/user_preference.py b/apps/api/plane/app/views/workspace/user_preference.py index 8bcf6b309..30c6ab97a 100644 --- a/apps/api/plane/app/views/workspace/user_preference.py +++ b/apps/api/plane/app/views/workspace/user_preference.py @@ -22,9 +22,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView): def get(self, request, slug): workspace = Workspace.objects.get(slug=slug) - get_preference = WorkspaceUserPreference.objects.filter( - user=request.user, workspace_id=workspace.id - ) + get_preference = WorkspaceUserPreference.objects.filter(user=request.user, workspace_id=workspace.id) create_preference_keys = [] @@ -49,9 +47,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView): ) preferences = ( - WorkspaceUserPreference.objects.filter( - user=request.user, workspace_id=workspace.id - ) + WorkspaceUserPreference.objects.filter(user=request.user, workspace_id=workspace.id) .order_by("sort_order") .values("key", "is_pinned", "sort_order") ) @@ -70,20 +66,14 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView): @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def patch(self, request, slug, key): - preference = WorkspaceUserPreference.objects.filter( - key=key, workspace__slug=slug, user=request.user - ).first() + preference = WorkspaceUserPreference.objects.filter(key=key, workspace__slug=slug, user=request.user).first() if preference: - serializer = WorkspaceUserPreferenceSerializer( - preference, data=request.data, partial=True - ) + serializer = WorkspaceUserPreferenceSerializer(preference, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - return Response( - {"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"detail": "Preference not found"}, status=status.HTTP_404_NOT_FOUND) diff --git a/apps/api/plane/authentication/adapter/base.py b/apps/api/plane/authentication/adapter/base.py index b28735120..bbb50eb76 100644 --- a/apps/api/plane/authentication/adapter/base.py +++ b/apps/api/plane/authentication/adapter/base.py @@ -91,10 +91,7 @@ class Adapter: ) # Check if sign up is disabled and invite is present or not - if ( - ENABLE_SIGNUP == "0" - and not WorkspaceMemberInvite.objects.filter(email=email).exists() - ): + if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists(): # Raise exception raise AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"], diff --git a/apps/api/plane/authentication/adapter/error.py b/apps/api/plane/authentication/adapter/error.py index 7c629b441..c8622277e 100644 --- a/apps/api/plane/authentication/adapter/error.py +++ b/apps/api/plane/authentication/adapter/error.py @@ -48,7 +48,7 @@ AUTHENTICATION_ERROR_CODES = { "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/apps/api/plane/authentication/adapter/oauth.py b/apps/api/plane/authentication/adapter/oauth.py index e89383837..ed1201097 100644 --- a/apps/api/plane/authentication/adapter/oauth.py +++ b/apps/api/plane/authentication/adapter/oauth.py @@ -73,9 +73,7 @@ class OauthAdapter(Adapter): return response.json() except requests.RequestException: code = self.authentication_error_code() - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code) - ) + raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code)) def get_user_response(self): try: @@ -85,9 +83,7 @@ class OauthAdapter(Adapter): return response.json() except requests.RequestException: code = self.authentication_error_code() - raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code) - ) + raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code)) def set_user_data(self, data): self.user_data = data @@ -104,12 +100,8 @@ class OauthAdapter(Adapter): if account: account.access_token = self.token_data.get("access_token") account.refresh_token = self.token_data.get("refresh_token", None) - account.access_token_expired_at = self.token_data.get( - "access_token_expired_at" - ) - account.refresh_token_expired_at = self.token_data.get( - "refresh_token_expired_at" - ) + account.access_token_expired_at = self.token_data.get("access_token_expired_at") + account.refresh_token_expired_at = self.token_data.get("refresh_token_expired_at") account.last_connected_at = timezone.now() account.id_token = self.token_data.get("id_token", "") account.save() @@ -118,17 +110,11 @@ class OauthAdapter(Adapter): Account.objects.create( user=user, provider=self.provider, - provider_account_id=self.user_data.get("user", {}).get( - "provider_id" - ), + provider_account_id=self.user_data.get("user", {}).get("provider_id"), access_token=self.token_data.get("access_token"), refresh_token=self.token_data.get("refresh_token", None), - access_token_expired_at=self.token_data.get( - "access_token_expired_at" - ), - refresh_token_expired_at=self.token_data.get( - "refresh_token_expired_at" - ), + access_token_expired_at=self.token_data.get("access_token_expired_at"), + refresh_token_expired_at=self.token_data.get("refresh_token_expired_at"), last_connected_at=timezone.now(), id_token=self.token_data.get("id_token", ""), ) diff --git a/apps/api/plane/authentication/middleware/session.py b/apps/api/plane/authentication/middleware/session.py index 822c88316..c367a15d3 100644 --- a/apps/api/plane/authentication/middleware/session.py +++ b/apps/api/plane/authentication/middleware/session.py @@ -37,11 +37,7 @@ class SessionMiddleware(MiddlewareMixin): # First check if we need to delete this cookie. # The session should be deleted only if the session is entirely empty. is_admin_path = "instances" in request.path - cookie_name = ( - settings.ADMIN_SESSION_COOKIE_NAME - if is_admin_path - else settings.SESSION_COOKIE_NAME - ) + cookie_name = settings.ADMIN_SESSION_COOKIE_NAME if is_admin_path else settings.SESSION_COOKIE_NAME if cookie_name in request.COOKIES and empty: response.delete_cookie( diff --git a/apps/api/plane/authentication/provider/credentials/email.py b/apps/api/plane/authentication/provider/credentials/email.py index 4b8ae0595..c3d19a80e 100644 --- a/apps/api/plane/authentication/provider/credentials/email.py +++ b/apps/api/plane/authentication/provider/credentials/email.py @@ -31,9 +31,7 @@ class EmailProvider(CredentialAdapter): if ENABLE_EMAIL_PASSWORD == "0": raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EMAIL_PASSWORD_AUTHENTICATION_DISABLED" - ], + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_PASSWORD_AUTHENTICATION_DISABLED"], error_message="EMAIL_PASSWORD_AUTHENTICATION_DISABLED", ) @@ -74,16 +72,10 @@ class EmailProvider(CredentialAdapter): if not user.check_password(self.code): raise AuthenticationException( error_message=( - "AUTHENTICATION_FAILED_SIGN_UP" - if self.is_signup - else "AUTHENTICATION_FAILED_SIGN_IN" + "AUTHENTICATION_FAILED_SIGN_UP" if self.is_signup else "AUTHENTICATION_FAILED_SIGN_IN" ), error_code=AUTHENTICATION_ERROR_CODES[ - ( - "AUTHENTICATION_FAILED_SIGN_UP" - if self.is_signup - else "AUTHENTICATION_FAILED_SIGN_IN" - ) + ("AUTHENTICATION_FAILED_SIGN_UP" if self.is_signup else "AUTHENTICATION_FAILED_SIGN_IN") ], payload={"email": self.key}, ) diff --git a/apps/api/plane/authentication/provider/credentials/magic_code.py b/apps/api/plane/authentication/provider/credentials/magic_code.py index 4fe8924f3..3f03572a4 100644 --- a/apps/api/plane/authentication/provider/credentials/magic_code.py +++ b/apps/api/plane/authentication/provider/credentials/magic_code.py @@ -72,17 +72,13 @@ class MagicCodeProvider(CredentialAdapter): email = str(self.key).replace("magic_", "", 1) if User.objects.filter(email=email).exists(): raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN" - ], + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN"], error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN", payload={"email": str(email)}, ) else: raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP" - ], + error_code=AUTHENTICATION_ERROR_CODES["EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP"], error_message="EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP", payload={"email": self.key}, ) @@ -128,17 +124,13 @@ class MagicCodeProvider(CredentialAdapter): email = str(self.key).replace("magic_", "", 1) if User.objects.filter(email=email).exists(): raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_MAGIC_CODE_SIGN_IN" - ], + error_code=AUTHENTICATION_ERROR_CODES["INVALID_MAGIC_CODE_SIGN_IN"], error_message="INVALID_MAGIC_CODE_SIGN_IN", payload={"email": str(email)}, ) else: raise AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "INVALID_MAGIC_CODE_SIGN_UP" - ], + error_code=AUTHENTICATION_ERROR_CODES["INVALID_MAGIC_CODE_SIGN_UP"], error_message="INVALID_MAGIC_CODE_SIGN_UP", payload={"email": str(email)}, ) diff --git a/apps/api/plane/authentication/provider/oauth/github.py b/apps/api/plane/authentication/provider/oauth/github.py index ecf7ed183..54c48018e 100644 --- a/apps/api/plane/authentication/provider/oauth/github.py +++ b/apps/api/plane/authentication/provider/oauth/github.py @@ -26,23 +26,21 @@ class GitHubOAuthProvider(OauthAdapter): organization_scope = "read:org" def __init__(self, request, code=None, state=None, callback=None): - GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = ( - get_configuration_value( - [ - { - "key": "GITHUB_CLIENT_ID", - "default": os.environ.get("GITHUB_CLIENT_ID"), - }, - { - "key": "GITHUB_CLIENT_SECRET", - "default": os.environ.get("GITHUB_CLIENT_SECRET"), - }, - { - "key": "GITHUB_ORGANIZATION_ID", - "default": os.environ.get("GITHUB_ORGANIZATION_ID"), - }, - ] - ) + GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value( + [ + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID"), + }, + { + "key": "GITHUB_CLIENT_SECRET", + "default": os.environ.get("GITHUB_CLIENT_SECRET"), + }, + { + "key": "GITHUB_ORGANIZATION_ID", + "default": os.environ.get("GITHUB_ORGANIZATION_ID"), + }, + ] ) if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET): @@ -87,24 +85,18 @@ class GitHubOAuthProvider(OauthAdapter): "code": self.code, "redirect_uri": self.redirect_uri, } - token_response = self.get_user_token( - data=data, headers={"Accept": "application/json"} - ) + token_response = self.get_user_token(data=data, headers={"Accept": "application/json"}) super().set_token_data( { "access_token": token_response.get("access_token"), "refresh_token": token_response.get("refresh_token", None), "access_token_expired_at": ( - datetime.fromtimestamp( - token_response.get("expires_in"), tz=pytz.utc - ) + datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc) if token_response.get("expires_in") else None ), "refresh_token_expired_at": ( - datetime.fromtimestamp( - token_response.get("refresh_token_expired_at"), tz=pytz.utc - ) + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) if token_response.get("refresh_token_expired_at") else None ), @@ -117,9 +109,7 @@ class GitHubOAuthProvider(OauthAdapter): # Github does not provide email in user response emails_url = "https://api.github.com/user/emails" emails_response = requests.get(emails_url, headers=headers).json() - email = next( - (email["email"] for email in emails_response if email["primary"]), None - ) + email = next((email["email"] for email in emails_response if email["primary"]), None) return email except requests.RequestException: raise AuthenticationException( diff --git a/apps/api/plane/authentication/provider/oauth/gitlab.py b/apps/api/plane/authentication/provider/oauth/gitlab.py index df6fb7c44..de4a3515e 100644 --- a/apps/api/plane/authentication/provider/oauth/gitlab.py +++ b/apps/api/plane/authentication/provider/oauth/gitlab.py @@ -80,26 +80,21 @@ class GitLabOAuthProvider(OauthAdapter): "redirect_uri": self.redirect_uri, "grant_type": "authorization_code", } - token_response = self.get_user_token( - data=data, headers={"Accept": "application/json"} - ) + token_response = self.get_user_token(data=data, headers={"Accept": "application/json"}) super().set_token_data( { "access_token": token_response.get("access_token"), "refresh_token": token_response.get("refresh_token", None), "access_token_expired_at": ( datetime.fromtimestamp( - token_response.get("created_at") - + token_response.get("expires_in"), + token_response.get("created_at") + token_response.get("expires_in"), tz=pytz.utc, ) if token_response.get("expires_in") else None ), "refresh_token_expired_at": ( - datetime.fromtimestamp( - token_response.get("refresh_token_expired_at"), tz=pytz.utc - ) + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) if token_response.get("refresh_token_expired_at") else None ), diff --git a/apps/api/plane/authentication/provider/oauth/google.py b/apps/api/plane/authentication/provider/oauth/google.py index d3f683619..41293782f 100644 --- a/apps/api/plane/authentication/provider/oauth/google.py +++ b/apps/api/plane/authentication/provider/oauth/google.py @@ -53,9 +53,7 @@ class GoogleOAuthProvider(OauthAdapter): "prompt": "consent", "state": state, } - auth_url = ( - f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}" - ) + auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(url_params)}" super().__init__( request, @@ -85,16 +83,12 @@ class GoogleOAuthProvider(OauthAdapter): "access_token": token_response.get("access_token"), "refresh_token": token_response.get("refresh_token", None), "access_token_expired_at": ( - datetime.fromtimestamp( - token_response.get("expires_in"), tz=pytz.utc - ) + datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc) if token_response.get("expires_in") else None ), "refresh_token_expired_at": ( - datetime.fromtimestamp( - token_response.get("refresh_token_expired_at"), tz=pytz.utc - ) + datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc) if token_response.get("refresh_token_expired_at") else None ), diff --git a/apps/api/plane/authentication/rate_limit.py b/apps/api/plane/authentication/rate_limit.py index 744bd38fe..09c3381ce 100644 --- a/apps/api/plane/authentication/rate_limit.py +++ b/apps/api/plane/authentication/rate_limit.py @@ -21,6 +21,4 @@ class AuthenticationThrottle(AnonRateThrottle): error_message="RATE_LIMIT_EXCEEDED", ) except AuthenticationException as e: - return Response( - e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS - ) + return Response(e.get_error_dict(), status=status.HTTP_429_TOO_MANY_REQUESTS) diff --git a/apps/api/plane/authentication/utils/login.py b/apps/api/plane/authentication/utils/login.py index e9437ae44..fe6fdad93 100644 --- a/apps/api/plane/authentication/utils/login.py +++ b/apps/api/plane/authentication/utils/login.py @@ -17,9 +17,7 @@ def user_login(request, user, is_app=False, is_admin=False, is_space=False): device_info = { "user_agent": request.META.get("HTTP_USER_AGENT", ""), "ip_address": get_client_ip(request=request), - "domain": base_host( - request=request, is_app=is_app, is_admin=is_admin, is_space=is_space - ), + "domain": base_host(request=request, is_app=is_app, is_admin=is_admin, is_space=is_space), } request.session["device_info"] = device_info request.session.save() diff --git a/apps/api/plane/authentication/utils/redirection_path.py b/apps/api/plane/authentication/utils/redirection_path.py index 459ad7434..82139b821 100644 --- a/apps/api/plane/authentication/utils/redirection_path.py +++ b/apps/api/plane/authentication/utils/redirection_path.py @@ -26,9 +26,7 @@ def get_redirection_path(user): return f"{workspace.slug}" fallback_workspace = ( - Workspace.objects.filter( - workspace_member__member_id=user.id, workspace_member__is_active=True - ) + Workspace.objects.filter(workspace_member__member_id=user.id, workspace_member__is_active=True) .order_by("created_at") .first() ) diff --git a/apps/api/plane/authentication/utils/workspace_project_join.py b/apps/api/plane/authentication/utils/workspace_project_join.py index 4544d9998..bd5ad8501 100644 --- a/apps/api/plane/authentication/utils/workspace_project_join.py +++ b/apps/api/plane/authentication/utils/workspace_project_join.py @@ -11,9 +11,7 @@ def process_workspace_project_invitations(user): """This function takes in User and adds him to all workspace and projects that the user has accepted invited of""" # Check if user has any accepted invites for workspace and add them to workspace - workspace_member_invites = WorkspaceMemberInvite.objects.filter( - email=user.email, accepted=True - ) + workspace_member_invites = WorkspaceMemberInvite.objects.filter(email=user.email, accepted=True) WorkspaceMember.objects.bulk_create( [ @@ -38,20 +36,14 @@ def process_workspace_project_invitations(user): ] # Check if user has any project invites - project_member_invites = ProjectMemberInvite.objects.filter( - email=user.email, accepted=True - ) + project_member_invites = ProjectMemberInvite.objects.filter(email=user.email, accepted=True) # Add user to workspace WorkspaceMember.objects.bulk_create( [ WorkspaceMember( workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 15] - else 15 - ), + role=(project_member_invite.role if project_member_invite.role in [5, 15] else 15), member=user, created_by_id=project_member_invite.created_by_id, ) @@ -65,11 +57,7 @@ def process_workspace_project_invitations(user): [ ProjectMember( workspace_id=project_member_invite.workspace_id, - role=( - project_member_invite.role - if project_member_invite.role in [5, 15] - else 15 - ), + role=(project_member_invite.role if project_member_invite.role in [5, 15] else 15), member=user, created_by_id=project_member_invite.created_by_id, ) diff --git a/apps/api/plane/authentication/views/app/check.py b/apps/api/plane/authentication/views/app/check.py index 0ad1db61f..10457b45a 100644 --- a/apps/api/plane/authentication/views/app/check.py +++ b/apps/api/plane/authentication/views/app/check.py @@ -83,9 +83,7 @@ class EmailCheckEndpoint(APIView): "existing": True, "status": ( "MAGIC_CODE" - if existing_user.is_password_autoset - and smtp_configured - and is_magic_login_enabled + if existing_user.is_password_autoset and smtp_configured and is_magic_login_enabled else "CREDENTIAL" ), }, @@ -95,11 +93,7 @@ class EmailCheckEndpoint(APIView): return Response( { "existing": False, - "status": ( - "MAGIC_CODE" - if smtp_configured and is_magic_login_enabled - else "CREDENTIAL" - ), + "status": ("MAGIC_CODE" if smtp_configured and is_magic_login_enabled else "CREDENTIAL"), }, status=status.HTTP_200_OK, ) diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py index 0ac51265e..864ff102b 100644 --- a/apps/api/plane/authentication/views/app/email.py +++ b/apps/api/plane/authentication/views/app/email.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -19,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignInAuthEndpoint(View): @@ -34,11 +31,11 @@ class SignInAuthEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) # Base URL join - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -50,18 +47,16 @@ class SignInAuthEndpoint(View): if not email or not password: # Redirection params exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "REQUIRED_EMAIL_PASSWORD_SIGN_IN" - ], + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_IN"], error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", payload={"email": str(email)}, ) params = exc.get_error_dict() # Next path - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -76,10 +71,10 @@ class SignInAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -92,10 +87,10 @@ class SignInAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -112,19 +107,23 @@ class SignInAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + # Get the safe redirect URL + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -141,10 +140,10 @@ class SignUpAuthEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -154,17 +153,15 @@ class SignUpAuthEndpoint(View): if not email or not password: # Redirection params exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "REQUIRED_EMAIL_PASSWORD_SIGN_UP" - ], + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_UP"], error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP", payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) # Validate the email @@ -179,10 +176,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -197,10 +194,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -217,17 +214,21 @@ class SignUpAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py index 18cbe7b6c..4720fc7da 100644 --- a/apps/api/plane/authentication/views/app/github.py +++ b/apps/api/plane/authentication/views/app/github.py @@ -1,5 +1,5 @@ +# Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -16,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitHubOauthInitiateEndpoint(View): @@ -35,10 +35,8 @@ class GitHubOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params ) return HttpResponseRedirect(url) try: @@ -49,10 +47,8 @@ class GitHubOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params ) return HttpResponseRedirect(url) @@ -61,7 +57,6 @@ class GitHubCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): @@ -70,9 +65,9 @@ class GitHubCallbackEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) if not code: @@ -81,29 +76,27 @@ class GitHubCallbackEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: - provider = GitHubOAuthProvider( - request=request, code=code, callback=post_user_auth_workflow - ) + provider = GitHubOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_app=True) - # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host, path) + + # Get the safe redirect URL + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=path, params={}) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py index d6479e954..665af00c1 100644 --- a/apps/api/plane/authentication/views/app/gitlab.py +++ b/apps/api/plane/authentication/views/app/gitlab.py @@ -1,5 +1,5 @@ +# Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -16,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitLabOauthInitiateEndpoint(View): @@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View): request.session["host"] = base_host(request=request, is_app=True) next_path = request.GET.get("next_path") if next_path: - request.session["next_path"] = str(validate_next_path(next_path)) + request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -35,10 +35,8 @@ class GitLabOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params ) return HttpResponseRedirect(url) try: @@ -49,10 +47,8 @@ class GitLabOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params ) return HttpResponseRedirect(url) @@ -61,7 +57,6 @@ class GitLabCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): @@ -70,9 +65,9 @@ class GitLabCallbackEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) if not code: @@ -81,29 +76,28 @@ class GitLabCallbackEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: - provider = GitLabOAuthProvider( - request=request, code=code, callback=post_user_auth_workflow - ) + provider = GitLabOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_app=True) # Get the redirection path + if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host, path) + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=path, params={}) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py index 66b6f7662..0ee81c768 100644 --- a/apps/api/plane/authentication/views/app/google.py +++ b/apps/api/plane/authentication/views/app/google.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -18,7 +17,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GoogleOauthInitiateEndpoint(View): @@ -36,10 +35,8 @@ class GoogleOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params ) return HttpResponseRedirect(url) @@ -51,10 +48,8 @@ class GoogleOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params ) return HttpResponseRedirect(url) @@ -63,7 +58,6 @@ class GoogleCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): @@ -72,9 +66,9 @@ class GoogleCallbackEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( @@ -82,27 +76,25 @@ class GoogleCallbackEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: - provider = GoogleOAuthProvider( - request=request, code=code, callback=post_user_auth_workflow - ) + provider = GoogleOAuthProvider(request=request, code=code, callback=post_user_auth_workflow) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_app=True) # Get the redirection path - path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin( - base_host, str(validate_next_path(next_path)) if next_path else path - ) + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + url = get_safe_redirect_url(base_url=base_host(request=request, is_app=True), next_path=path, params={}) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py index 4b1bdb02e..518a5cdea 100644 --- a/apps/api/plane/authentication/views/app/magic.py +++ b/apps/api/plane/authentication/views/app/magic.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect @@ -26,7 +23,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, ) from plane.authentication.rate_limit import AuthenticationThrottle -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class MagicGenerateEndpoint(APIView): @@ -66,16 +63,14 @@ class MagicSignInEndpoint(View): if code == "" or email == "": exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" - ], + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"], error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -88,10 +83,10 @@ class MagicSignInEndpoint(View): error_message="USER_DOES_NOT_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -107,24 +102,25 @@ class MagicSignInEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_app=True) if user.is_password_autoset and profile.is_onboarded: - path = "accounts/set-password" + # Redirect to the home page + path = "/" else: # Get the redirection path - path = ( - str(next_path) - if next_path - else str(get_redirection_path(user=user)) - ) + path = str(next_path) if next_path else str(get_redirection_path(user=user)) # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -138,16 +134,14 @@ class MagicSignUpEndpoint(View): if code == "" or email == "": exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" - ], + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"], error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) # Existing user @@ -158,10 +152,10 @@ class MagicSignUpEndpoint(View): error_message="USER_ALREADY_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -177,18 +171,22 @@ class MagicSignUpEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/password_management.py b/apps/api/plane/authentication/views/app/password_management.py index bbc5658c4..de0baa71b 100644 --- a/apps/api/plane/authentication/views/app/password_management.py +++ b/apps/api/plane/authentication/views/app/password_management.py @@ -55,9 +55,7 @@ class ForgotPasswordEndpoint(APIView): ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) - (EMAIL_HOST,) = get_configuration_value( - [{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}] - ) + (EMAIL_HOST,) = get_configuration_value([{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST")}]) if not (EMAIL_HOST): exc = AuthenticationException( @@ -82,9 +80,7 @@ class ForgotPasswordEndpoint(APIView): uidb64, token = generate_password_token(user=user) current_site = base_host(request=request, is_app=True) # send the forgot password email - forgot_password.delay( - user.first_name, user.email, uidb64, token, current_site - ) + forgot_password.delay(user.first_name, user.email, uidb64, token, current_site) return Response( {"message": "Check your email to reset your password"}, status=status.HTTP_200_OK, diff --git a/apps/api/plane/authentication/views/common.py b/apps/api/plane/authentication/views/common.py index ab60e6d04..c5dd1714c 100644 --- a/apps/api/plane/authentication/views/common.py +++ b/apps/api/plane/authentication/views/common.py @@ -53,9 +53,7 @@ class ChangePasswordEndpoint(APIView): error_message="MISSING_PASSWORD", payload={"error": "Old password is missing"}, ) - return Response( - exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST - ) + return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) # Get the new password new_password = request.data.get("new_password", False) @@ -91,9 +89,7 @@ class ChangePasswordEndpoint(APIView): user.is_password_autoset = False user.save() user_login(user=user, request=request, is_app=True) - return Response( - {"message": "Password updated successfully"}, status=status.HTTP_200_OK - ) + return Response({"message": "Password updated successfully"}, status=status.HTTP_200_OK) class SetUserPasswordEndpoint(APIView): @@ -107,9 +103,7 @@ class SetUserPasswordEndpoint(APIView): exc = AuthenticationException( error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_ALREADY_SET"], error_message="PASSWORD_ALREADY_SET", - payload={ - "error": "Your password is already set please change your password from profile" - }, + payload={"error": "Your password is already set please change your password from profile"}, ) return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST) diff --git a/apps/api/plane/authentication/views/space/check.py b/apps/api/plane/authentication/views/space/check.py index c8a4539b7..95a5e68df 100644 --- a/apps/api/plane/authentication/views/space/check.py +++ b/apps/api/plane/authentication/views/space/check.py @@ -81,9 +81,7 @@ class EmailCheckSpaceEndpoint(APIView): "existing": True, "status": ( "MAGIC_CODE" - if existing_user.is_password_autoset - and smtp_configured - and is_magic_login_enabled + if existing_user.is_password_autoset and smtp_configured and is_magic_login_enabled else "CREDENTIAL" ), }, @@ -93,11 +91,7 @@ class EmailCheckSpaceEndpoint(APIView): return Response( { "existing": False, - "status": ( - "MAGIC_CODE" - if smtp_configured and is_magic_login_enabled - else "CREDENTIAL" - ), + "status": ("MAGIC_CODE" if smtp_configured and is_magic_login_enabled else "CREDENTIAL"), }, status=status.HTTP_200_OK, ) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index 6fa2d4517..3d092591a 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -1,11 +1,9 @@ -# Python imports -from urllib.parse import urlencode - # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.credentials.email import EmailProvider @@ -17,7 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class SignInAuthSpaceEndpoint(View): @@ -32,9 +30,9 @@ class SignInAuthSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) # set the referer as session to redirect after login @@ -44,16 +42,14 @@ class SignInAuthSpaceEndpoint(View): ## Raise exception if any of the above are missing if not email or not password: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "REQUIRED_EMAIL_PASSWORD_SIGN_IN" - ], + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_IN"], error_message="REQUIRED_EMAIL_PASSWORD_SIGN_IN", payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) # Validate email @@ -67,9 +63,9 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) # Existing User @@ -82,26 +78,28 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: - provider = EmailProvider( - request=request, key=email, code=password, is_signup=False - ) + provider = EmailProvider(request=request, key=email, code=password, is_signup=False) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_space=True) - # redirect to next path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" - return HttpResponseRedirect(url) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) @@ -117,9 +115,9 @@ class SignUpAuthSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) email = request.POST.get("email", False) @@ -128,16 +126,14 @@ class SignUpAuthSpaceEndpoint(View): if not email or not password: # Redirection params exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "REQUIRED_EMAIL_PASSWORD_SIGN_UP" - ], + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_EMAIL_PASSWORD_SIGN_UP"], error_message="REQUIRED_EMAIL_PASSWORD_SIGN_UP", payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) # Validate the email email = email.strip().lower() @@ -151,9 +147,9 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) # Existing User @@ -166,24 +162,26 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: - provider = EmailProvider( - request=request, key=email, code=password, is_signup=True - ) + provider = EmailProvider(request=request, key=email, code=password, is_signup=True) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" - return HttpResponseRedirect(url) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py index fec71cb48..f12498d3b 100644 --- a/apps/api/plane/authentication/views/space/github.py +++ b/apps/api/plane/authentication/views/space/github.py @@ -1,10 +1,10 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.oauth.github import GitHubOAuthProvider @@ -15,7 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class GitHubOauthInitiateSpaceEndpoint(View): @@ -23,9 +23,6 @@ class GitHubOauthInitiateSpaceEndpoint(View): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) - # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: @@ -34,9 +31,9 @@ class GitHubOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: @@ -47,9 +44,9 @@ class GitHubOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) @@ -66,9 +63,9 @@ class GitHubCallbackSpaceEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) if not code: @@ -77,9 +74,9 @@ class GitHubCallbackSpaceEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: @@ -89,11 +86,16 @@ class GitHubCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" - return HttpResponseRedirect(url) + next_path = validate_next_path(next_path=next_path) + + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py index 4bdcf9514..498916b34 100644 --- a/apps/api/plane/authentication/views/space/gitlab.py +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -1,10 +1,10 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider @@ -15,7 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class GitLabOauthInitiateSpaceEndpoint(View): @@ -23,8 +23,6 @@ class GitLabOauthInitiateSpaceEndpoint(View): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -34,9 +32,9 @@ class GitLabOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: @@ -47,9 +45,9 @@ class GitLabOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) @@ -66,9 +64,9 @@ class GitLabCallbackSpaceEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) if not code: @@ -77,9 +75,9 @@ class GitLabCallbackSpaceEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: @@ -89,11 +87,16 @@ class GitLabCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" - return HttpResponseRedirect(url) + next_path = validate_next_path(next_path=next_path) + + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py index 03ad97793..0f02c1f93 100644 --- a/apps/api/plane/authentication/views/space/google.py +++ b/apps/api/plane/authentication/views/space/google.py @@ -1,10 +1,10 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.oauth.google import GoogleOAuthProvider @@ -15,15 +15,13 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class GoogleOauthInitiateSpaceEndpoint(View): def get(self, request): request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -33,9 +31,9 @@ class GoogleOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: @@ -46,9 +44,9 @@ class GoogleOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) @@ -65,9 +63,9 @@ class GoogleCallbackSpaceEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( @@ -75,9 +73,9 @@ class GoogleCallbackSpaceEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) try: provider = GoogleOAuthProvider(request=request, code=code) @@ -85,11 +83,16 @@ class GoogleCallbackSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" - return HttpResponseRedirect(url) + next_path = validate_next_path(next_path=next_path) + + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), next_path=next_path, params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index d230af7ed..df940b327 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -1,10 +1,8 @@ -# Python imports -from urllib.parse import urlencode - # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Third party imports from rest_framework import status @@ -23,7 +21,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class MagicGenerateSpaceEndpoint(APIView): @@ -60,15 +58,15 @@ class MagicSignInSpaceEndpoint(View): if code == "" or email == "": exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" - ], + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED"], error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) existing_user = User.objects.filter(email=email).first() @@ -79,29 +77,34 @@ class MagicSignInSpaceEndpoint(View): error_message="USER_DOES_NOT_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) # Active User try: - provider = MagicCodeProvider( - request=request, key=f"magic_{email}", code=code - ) + provider = MagicCodeProvider(request=request, key=f"magic_{email}", code=code) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - path = str(next_path) if next_path else "" - url = f"{base_host(request=request, is_space=True)}{path}" - return HttpResponseRedirect(url) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) @@ -114,15 +117,15 @@ class MagicSignUpSpaceEndpoint(View): if code == "" or email == "": exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" - ], + error_code=AUTHENTICATION_ERROR_CODES["MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED"], error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) # Existing User existing_user = User.objects.filter(email=email).first() @@ -133,25 +136,31 @@ class MagicSignUpSpaceEndpoint(View): error_message="USER_ALREADY_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) try: - provider = MagicCodeProvider( - request=request, key=f"magic_{email}", code=code - ) + provider = MagicCodeProvider(request=request, key=f"magic_{email}", code=code) user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" - return HttpResponseRedirect(url) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/password_management.py b/apps/api/plane/authentication/views/space/password_management.py index bff3e3485..12cc88f63 100644 --- a/apps/api/plane/authentication/views/space/password_management.py +++ b/apps/api/plane/authentication/views/space/password_management.py @@ -92,9 +92,7 @@ class ForgotPasswordSpaceEndpoint(APIView): uidb64, token = generate_password_token(user=user) current_site = base_host(request=request, is_space=True) # send the forgot password email - forgot_password.delay( - user.first_name, user.email, uidb64, token, current_site - ) + forgot_password.delay(user.first_name, user.email, uidb64, token, current_site) return Response( {"message": "Check your email to reset your password"}, status=status.HTTP_200_OK, @@ -130,7 +128,7 @@ class ResetPasswordSpaceEndpoint(View): error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], error_message="INVALID_PASSWORD", ) - url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 return HttpResponseRedirect(url) # Check the password complexity @@ -140,7 +138,7 @@ class ResetPasswordSpaceEndpoint(View): error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"], error_message="INVALID_PASSWORD", ) - url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 return HttpResponseRedirect(url) # set_password also hashes the password that the user will get @@ -154,5 +152,5 @@ class ResetPasswordSpaceEndpoint(View): error_code=AUTHENTICATION_ERROR_CODES["EXPIRED_PASSWORD_TOKEN"], error_message="EXPIRED_PASSWORD_TOKEN", ) - url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" + url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501 return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/signout.py b/apps/api/plane/authentication/views/space/signout.py index 11e617436..aa890f978 100644 --- a/apps/api/plane/authentication/views/space/signout.py +++ b/apps/api/plane/authentication/views/space/signout.py @@ -7,7 +7,7 @@ from django.utils import timezone # Module imports from plane.authentication.utils.host import base_host, user_ip from plane.db.models import User -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignOutAuthSpaceEndpoint(View): @@ -22,8 +22,8 @@ class SignOutAuthSpaceEndpoint(View): user.save() # Log the user out logout(request) - url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path) return HttpResponseRedirect(url) except Exception: - url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + url = get_safe_redirect_url(base_url=base_host(request=request, is_space=True), next_path=next_path) return HttpResponseRedirect(url) diff --git a/apps/api/plane/bgtasks/analytic_plot_export.py b/apps/api/plane/bgtasks/analytic_plot_export.py index 0f07ccc85..845fb50dd 100644 --- a/apps/api/plane/bgtasks/analytic_plot_export.py +++ b/apps/api/plane/bgtasks/analytic_plot_export.py @@ -87,10 +87,7 @@ def get_assignee_details(slug, filters): """Fetch assignee details if required.""" return ( Issue.issue_objects.filter( - Q( - Q(assignees__avatar__isnull=False) - | Q(assignees__avatar_asset__isnull=False) - ), + Q(Q(assignees__avatar__isnull=False) | Q(assignees__avatar_asset__isnull=False)), workspace__slug=slug, **filters, ) @@ -195,11 +192,7 @@ def generate_segmented_rows( cycle_details, module_details, ): - segment_zero = list( - set( - item.get("segment") for sublist in distribution.values() for item in sublist - ) - ) + segment_zero = list(set(item.get("segment") for sublist in distribution.values() for item in sublist)) segmented = segment @@ -221,38 +214,26 @@ def generate_segmented_rows( if x_axis == ASSIGNEE_ID: assignee = next( - ( - user - for user in assignee_details - if str(user[ASSIGNEE_ID]) == str(item) - ), + (user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(item)), None, ) if assignee: - generated_row[0] = ( - f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" - ) + generated_row[0] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" if x_axis == LABEL_ID: - label = next( - (lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None - ) + label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None) if label: generated_row[0] = f"{label['labels__name']}" if x_axis == STATE_ID: - state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None - ) + state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None) if state: generated_row[0] = f"{state['state__name']}" if x_axis == CYCLE_ID: - cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None - ) + cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None) if cycle: generated_row[0] = f"{cycle['issue_cycle__cycle__name']}" @@ -271,47 +252,33 @@ def generate_segmented_rows( if segmented == ASSIGNEE_ID: for index, segm in enumerate(row_zero[2:]): assignee = next( - ( - user - for user in assignee_details - if str(user[ASSIGNEE_ID]) == str(segm) - ), + (user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(segm)), None, ) if assignee: - row_zero[index + 2] = ( - f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" - ) + row_zero[index + 2] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" if segmented == LABEL_ID: for index, segm in enumerate(row_zero[2:]): - label = next( - (lab for lab in label_details if str(lab[LABEL_ID]) == str(segm)), None - ) + label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(segm)), None) if label: row_zero[index + 2] = label["labels__name"] if segmented == STATE_ID: for index, segm in enumerate(row_zero[2:]): - state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(segm)), None - ) + state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(segm)), None) if state: row_zero[index + 2] = state["state__name"] if segmented == MODULE_ID: for index, segm in enumerate(row_zero[2:]): - module = next( - (mod for mod in label_details if str(mod[MODULE_ID]) == str(segm)), None - ) + module = next((mod for mod in label_details if str(mod[MODULE_ID]) == str(segm)), None) if module: row_zero[index + 2] = module["issue_module__module__name"] if segmented == CYCLE_ID: for index, segm in enumerate(row_zero[2:]): - cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(segm)), None - ) + cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(segm)), None) if cycle: row_zero[index + 2] = cycle["issue_cycle__cycle__name"] @@ -335,38 +302,26 @@ def generate_non_segmented_rows( if x_axis == ASSIGNEE_ID: assignee = next( - ( - user - for user in assignee_details - if str(user[ASSIGNEE_ID]) == str(item) - ), + (user for user in assignee_details if str(user[ASSIGNEE_ID]) == str(item)), None, ) if assignee: - row[0] = ( - f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" - ) + row[0] = f"{assignee['assignees__first_name']} {assignee['assignees__last_name']}" if x_axis == LABEL_ID: - label = next( - (lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None - ) + label = next((lab for lab in label_details if str(lab[LABEL_ID]) == str(item)), None) if label: row[0] = f"{label['labels__name']}" if x_axis == STATE_ID: - state = next( - (sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None - ) + state = next((sta for sta in state_details if str(sta[STATE_ID]) == str(item)), None) if state: row[0] = f"{state['state__name']}" if x_axis == CYCLE_ID: - cycle = next( - (cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None - ) + cycle = next((cyc for cyc in cycle_details if str(cyc[CYCLE_ID]) == str(item)), None) if cycle: row[0] = f"{cycle['issue_cycle__cycle__name']}" @@ -396,40 +351,20 @@ def analytic_export_task(email, data, slug): y_axis = data.get("y_axis", False) segment = data.get("segment", False) - distribution = build_graph_plot( - queryset, x_axis=x_axis, y_axis=y_axis, segment=segment - ) + distribution = build_graph_plot(queryset, x_axis=x_axis, y_axis=y_axis, segment=segment) key = "count" if y_axis == "issue_count" else "estimate" assignee_details = ( - get_assignee_details(slug, filters) - if x_axis == ASSIGNEE_ID or segment == ASSIGNEE_ID - else {} + get_assignee_details(slug, filters) if x_axis == ASSIGNEE_ID or segment == ASSIGNEE_ID else {} ) - label_details = ( - get_label_details(slug, filters) - if x_axis == LABEL_ID or segment == LABEL_ID - else {} - ) + label_details = get_label_details(slug, filters) if x_axis == LABEL_ID or segment == LABEL_ID else {} - state_details = ( - get_state_details(slug, filters) - if x_axis == STATE_ID or segment == STATE_ID - else {} - ) + state_details = get_state_details(slug, filters) if x_axis == STATE_ID or segment == STATE_ID else {} - cycle_details = ( - get_cycle_details(slug, filters) - if x_axis == CYCLE_ID or segment == CYCLE_ID - else {} - ) + cycle_details = get_cycle_details(slug, filters) if x_axis == CYCLE_ID or segment == CYCLE_ID else {} - module_details = ( - get_module_details(slug, filters) - if x_axis == MODULE_ID or segment == MODULE_ID - else {} - ) + module_details = get_module_details(slug, filters) if x_axis == MODULE_ID or segment == MODULE_ID else {} if segment: rows = generate_segmented_rows( diff --git a/apps/api/plane/bgtasks/cleanup_task.py b/apps/api/plane/bgtasks/cleanup_task.py index c9d86b639..6b23f2571 100644 --- a/apps/api/plane/bgtasks/cleanup_task.py +++ b/apps/api/plane/bgtasks/cleanup_task.py @@ -21,13 +21,14 @@ from plane.db.models import ( PageVersion, APIActivityLog, IssueDescriptionVersion, + WebhookLog, ) from plane.settings.mongo import MongoConnection from plane.utils.exception_logger import log_exception logger = logging.getLogger("plane.worker") -BATCH_SIZE = 1000 +BATCH_SIZE = 500 def get_mongo_collection(collection_name: str) -> Optional[Collection]: @@ -60,9 +61,7 @@ def flush_to_mongo_and_delete( logger.debug("No records to flush - buffer is empty") return - logger.info( - f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete" - ) + logger.info(f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete") mongo_archival_failed = False @@ -82,9 +81,7 @@ def flush_to_mongo_and_delete( # Delete from PostgreSQL - delete() returns (count, {model: count}) delete_result = model.all_objects.filter(id__in=ids_to_delete).delete() - deleted_count = ( - delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0 - ) + deleted_count = delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0 logger.info(f"Batch flush completed: {deleted_count} records deleted") @@ -192,9 +189,7 @@ def transform_email_log(record: Dict) -> Dict: "entity_identifier": str(record["entity_identifier"]), "entity_name": record["entity_name"], "data": record["data"], - "processed_at": ( - str(record["processed_at"]) if record.get("processed_at") else None - ), + "processed_at": (str(record["processed_at"]) if record.get("processed_at") else None), "sent_at": str(record["sent_at"]) if record.get("sent_at") else None, "entity": record["entity"], "old_value": str(record["old_value"]), @@ -219,9 +214,7 @@ def transform_page_version(record: Dict) -> Dict: "created_by_id": str(record["created_by_id"]), "updated_by_id": str(record["updated_by_id"]), "deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None, - "last_saved_at": ( - str(record["last_saved_at"]) if record.get("last_saved_at") else None - ), + "last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None), } @@ -236,9 +229,7 @@ def transform_issue_description_version(record: Dict) -> Dict: "created_by_id": str(record["created_by_id"]), "updated_by_id": str(record["updated_by_id"]), "owned_by_id": str(record["owned_by_id"]), - "last_saved_at": ( - str(record["last_saved_at"]) if record.get("last_saved_at") else None - ), + "last_saved_at": (str(record["last_saved_at"]) if record.get("last_saved_at") else None), "description_binary": record["description_binary"], "description_html": record["description_html"], "description_stripped": record["description_stripped"], @@ -247,6 +238,27 @@ def transform_issue_description_version(record: Dict) -> Dict: } +def transform_webhook_log(record: Dict): + """Transfer webhook logs to a new destination.""" + return { + "id": str(record["id"]), + "created_at": str(record["created_at"]) if record.get("created_at") else None, + "workspace_id": str(record["workspace_id"]), + "webhook": str(record["webhook"]), + # Request + "event_type": str(record["event_type"]), + "request_method": str(record["request_method"]), + "request_headers": str(record["request_headers"]), + "request_body": str(record["request_body"]), + # Response + "response_status": str(record["response_status"]), + "response_body": str(record["response_body"]), + "response_headers": str(record["response_headers"]), + # retry count + "retry_count": str(record["retry_count"]), + } + + # Queryset functions for each cleanup task def get_api_logs_queryset(): """Get API logs older than cutoff days.""" @@ -374,7 +386,35 @@ def get_issue_description_versions_queryset(): ) -# Celery tasks - now much simpler! +def get_webhook_logs_queryset(): + """Get email logs older than cutoff days.""" + cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30)) + cutoff_time = timezone.now() - timedelta(days=cutoff_days) + logger.info(f"Webhook logs cutoff time: {cutoff_time}") + + return ( + WebhookLog.all_objects.filter(created_at__lte=cutoff_time) + .values( + "id", + "created_at", + "workspace_id", + "webhook", + "event_type", + # Request + "request_method", + "request_headers", + "request_body", + # Response + "response_status", + "response_body", + "response_headers", + "retry_count", + ) + .order_by("created_at") + .iterator(chunk_size=100) + ) + + @shared_task def delete_api_logs(): """Delete old API activity logs.""" @@ -421,3 +461,15 @@ def delete_issue_description_versions(): task_name="Issue Description Version", collection_name="issue_description_versions", ) + + +@shared_task +def delete_webhook_logs(): + """Delete old webhook logs""" + process_cleanup_task( + queryset_func=get_webhook_logs_queryset, + transform_func=transform_webhook_log, + model=WebhookLog, + task_name="Webhook Log", + collection_name="webhook_logs", + ) diff --git a/apps/api/plane/bgtasks/copy_s3_object.py b/apps/api/plane/bgtasks/copy_s3_object.py index c8d9fc480..e7ef09e35 100644 --- a/apps/api/plane/bgtasks/copy_s3_object.py +++ b/apps/api/plane/bgtasks/copy_s3_object.py @@ -25,9 +25,7 @@ def get_entity_id_field(entity_type, entity_id): FileAsset.EntityTypeContext.ISSUE_DESCRIPTION: {"issue_id": entity_id}, FileAsset.EntityTypeContext.PAGE_DESCRIPTION: {"page_id": entity_id}, FileAsset.EntityTypeContext.COMMENT_DESCRIPTION: {"comment_id": entity_id}, - FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: { - "draft_issue_id": entity_id - }, + FileAsset.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION: {"draft_issue_id": entity_id}, } return entity_mapping.get(entity_type, {}) @@ -87,14 +85,10 @@ def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id): duplicated_assets = [] workspace = entity.workspace storage = S3Storage() - original_assets = FileAsset.objects.filter( - workspace=workspace, project_id=project_id, id__in=asset_ids - ) + original_assets = FileAsset.objects.filter(workspace=workspace, project_id=project_id, id__in=asset_ids) for original_asset in original_assets: - destination_key = ( - f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" - ) + destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}" duplicated_asset = FileAsset.objects.create( attributes={ "name": original_asset.attributes.get("name"), @@ -118,17 +112,13 @@ def copy_assets(entity, entity_identifier, project_id, asset_ids, user_id): } ) if duplicated_assets: - FileAsset.objects.filter( - pk__in=[item["new_asset_id"] for item in duplicated_assets] - ).update(is_uploaded=True) + FileAsset.objects.filter(pk__in=[item["new_asset_id"] for item in duplicated_assets]).update(is_uploaded=True) return duplicated_assets @shared_task -def copy_s3_objects_of_description_and_assets( - entity_name, entity_identifier, project_id, slug, user_id -): +def copy_s3_objects_of_description_and_assets(entity_name, entity_identifier, project_id, slug, user_id): """ Step 1: Extract asset ids from the description_html of the entity Step 2: Duplicate the assets @@ -144,9 +134,7 @@ def copy_s3_objects_of_description_and_assets( entity = model_class.objects.get(id=entity_identifier) asset_ids = extract_asset_ids(entity.description_html, "image-component") - duplicated_assets = copy_assets( - entity, entity_identifier, project_id, asset_ids, user_id - ) + duplicated_assets = copy_assets(entity, entity_identifier, project_id, asset_ids, user_id) updated_html = update_description(entity, duplicated_assets, "image-component") @@ -154,9 +142,7 @@ def copy_s3_objects_of_description_and_assets( if external_data: entity.description = external_data.get("description") - entity.description_binary = base64.b64decode( - external_data.get("description_binary") - ) + entity.description_binary = base64.b64decode(external_data.get("description_binary")) entity.save() return diff --git a/apps/api/plane/bgtasks/deletion_task.py b/apps/api/plane/bgtasks/deletion_task.py index ef57873cf..932a1fce0 100644 --- a/apps/api/plane/bgtasks/deletion_task.py +++ b/apps/api/plane/bgtasks/deletion_task.py @@ -26,9 +26,7 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None): # 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 + 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 @@ -40,11 +38,7 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None): continue # Get the on_delete behavior name - on_delete_name = ( - relation.on_delete.__name__ - if hasattr(relation.on_delete, "__name__") - else "" - ) + on_delete_name = relation.on_delete.__name__ if hasattr(relation.on_delete, "__name__") else "" if on_delete_name == "DO_NOTHING": continue @@ -82,9 +76,7 @@ def soft_delete_related_objects(app_label, model_name, instance_pk, using=None): ) else: # Handle other relationships - related_queryset = getattr(instance, related_name)( - manager="objects" - ).all() + related_queryset = getattr(instance, related_name)(manager="objects").all() for related_obj in related_queryset: if hasattr(related_obj, "deleted_at"): @@ -139,85 +131,49 @@ def hard_delete(): days = settings.HARD_DELETE_AFTER_DAYS # check delete workspace - _ = Workspace.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Workspace.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete project - _ = Project.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Project.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete cycle - _ = Cycle.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Cycle.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete module - _ = Module.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Module.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete issue - _ = Issue.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Issue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete page - _ = Page.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Page.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete view - _ = IssueView.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = IssueView.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete label - _ = Label.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Label.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # check delete state - _ = State.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = State.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = IssueActivity.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = IssueActivity.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = IssueComment.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = IssueComment.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = IssueLink.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = IssueLink.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = IssueReaction.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = IssueReaction.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = UserFavorite.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = UserFavorite.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = ModuleIssue.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = ModuleIssue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = CycleIssue.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = CycleIssue.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = Estimate.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = Estimate.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() - _ = EstimatePoint.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = EstimatePoint.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() # at last, check for every thing which ever is left and delete it # Get all Django models @@ -228,8 +184,6 @@ def hard_delete(): # Check if the model has a 'deleted_at' field if hasattr(model, "deleted_at"): # Get all instances where 'deleted_at' is greater than 30 days ago - _ = model.all_objects.filter( - deleted_at__lt=timezone.now() - timezone.timedelta(days=days) - ).delete() + _ = model.all_objects.filter(deleted_at__lt=timezone.now() - timezone.timedelta(days=days)).delete() return diff --git a/apps/api/plane/bgtasks/dummy_data_task.py b/apps/api/plane/bgtasks/dummy_data_task.py index 03ac55b4c..3220ef0c0 100644 --- a/apps/api/plane/bgtasks/dummy_data_task.py +++ b/apps/api/plane/bgtasks/dummy_data_task.py @@ -44,9 +44,7 @@ def create_project(workspace, user_id): project = Project.objects.create( workspace=workspace, name=f"{name}_{unique_id}", - identifier=name[ - : random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1) - ].upper(), + identifier=name[: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)].upper(), created_by_id=user_id, intake_view=True, ) @@ -163,17 +161,11 @@ def create_cycles(workspace, project, user_id, cycle_count): ) # Ensure end_date is strictly after start_date if start_date is not None - while start_date is not None and ( - end_date <= start_date or (start_date, end_date) in used_date_ranges - ): + while start_date is not None and (end_date <= start_date or (start_date, end_date) in used_date_ranges): end_date = fake.date_this_year() # Add the unique date range to the set - ( - used_date_ranges.add((start_date, end_date)) - if (end_date is not None and start_date is not None) - else None - ) + (used_date_ranges.add((start_date, end_date)) if (end_date is not None and start_date is not None) else None) # Append the cycle with unique date range cycles.append( @@ -244,10 +236,7 @@ def create_pages(workspace, project, user_id, pages_count): pages = Page.objects.bulk_create(pages, ignore_conflicts=True) # Add Page to project ProjectPage.objects.bulk_create( - [ - ProjectPage(page=page, project=project, workspace=workspace) - for page in pages - ], + [ProjectPage(page=page, project=project, workspace=workspace) for page in pages], batch_size=1000, ) @@ -264,14 +253,10 @@ def create_page_labels(workspace, project, user_id, pages_count): bulk_page_labels = [] for page in pages: for label in random.sample(list(labels), random.randint(0, len(labels) - 1)): - bulk_page_labels.append( - PageLabel(page_id=page, label_id=label, workspace=workspace) - ) + bulk_page_labels.append(PageLabel(page_id=page, label_id=label, workspace=workspace)) # Page labels - PageLabel.objects.bulk_create( - bulk_page_labels, batch_size=1000, ignore_conflicts=True - ) + PageLabel.objects.bulk_create(bulk_page_labels, batch_size=1000, ignore_conflicts=True) def create_issues(workspace, project, user_id, issue_count): @@ -279,20 +264,14 @@ def create_issues(workspace, project, user_id, issue_count): Faker.seed(0) states = ( - State.objects.filter(workspace=workspace, project=project) - .exclude(group="Triage") - .values_list("id", flat=True) + State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True) ) - creators = ProjectMember.objects.filter( - workspace=workspace, project=project - ).values_list("member_id", flat=True) + creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True) issues = [] # Get the maximum sequence_id - last_id = IssueSequence.objects.filter(project=project).aggregate( - largest=Max("sequence") - )["largest"] + last_id = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))["largest"] last_id = 1 if last_id is None else last_id + 1 @@ -301,9 +280,7 @@ def create_issues(workspace, project, user_id, issue_count): project=project, state_id=states[random.randint(0, len(states) - 1)] ).aggregate(largest=Max("sort_order"))["largest"] - largest_sort_order = ( - 65535 if largest_sort_order is None else largest_sort_order + 10000 - ) + largest_sort_order = 65535 if largest_sort_order is None else largest_sort_order + 10000 for _ in range(0, issue_count): start_date = [None, fake.date_this_year()][random.randint(0, 1)] @@ -329,9 +306,7 @@ def create_issues(workspace, project, user_id, issue_count): sort_order=largest_sort_order, start_date=start_date, target_date=end_date, - priority=["urgent", "high", "medium", "low", "none"][ - random.randint(0, 4) - ], + priority=["urgent", "high", "medium", "low", "none"][random.randint(0, 4)], created_by_id=creators[random.randint(0, len(creators) - 1)], ) ) @@ -375,20 +350,14 @@ def create_issues(workspace, project, user_id, issue_count): def create_intake_issues(workspace, project, user_id, intake_issue_count): issues = create_issues(workspace, project, user_id, intake_issue_count) - intake, create = Intake.objects.get_or_create( - name="Intake", project=project, is_default=True - ) + intake, create = Intake.objects.get_or_create(name="Intake", project=project, is_default=True) IntakeIssue.objects.bulk_create( [ IntakeIssue( issue=issue, intake=intake, status=(status := [-2, -1, 0, 1, 2][random.randint(0, 4)]), - snoozed_till=( - datetime.now() + timedelta(days=random.randint(1, 30)) - if status == 0 - else None - ), + snoozed_till=(datetime.now() + timedelta(days=random.randint(1, 30)) if status == 0 else None), source=SourceType.IN_APP, workspace=workspace, project=project, @@ -402,12 +371,8 @@ def create_intake_issues(workspace, project, user_id, intake_issue_count): def create_issue_parent(workspace, project, user_id, issue_count): parent_count = issue_count / 4 - parent_issues = Issue.objects.filter(project=project).values_list("id", flat=True)[ - : int(parent_count) - ] - sub_issues = Issue.objects.filter(project=project).exclude(pk__in=parent_issues)[ - : int(issue_count / 2) - ] + parent_issues = Issue.objects.filter(project=project).values_list("id", flat=True)[: int(parent_count)] + sub_issues = Issue.objects.filter(project=project).exclude(pk__in=parent_issues)[: int(issue_count / 2)] bulk_sub_issues = [] for sub_issue in sub_issues: @@ -418,9 +383,7 @@ def create_issue_parent(workspace, project, user_id, issue_count): def create_issue_assignees(workspace, project, user_id, issue_count): # assignees - assignees = ProjectMember.objects.filter(project=project).values_list( - "member_id", flat=True - ) + assignees = ProjectMember.objects.filter(project=project).values_list("member_id", flat=True) issues = random.sample( list(Issue.objects.filter(project=project).values_list("id", flat=True)), int(issue_count / 2), @@ -429,9 +392,7 @@ def create_issue_assignees(workspace, project, user_id, issue_count): # Bulk issue bulk_issue_assignees = [] for issue in issues: - for assignee in random.sample( - list(assignees), random.randint(0, len(assignees) - 1) - ): + for assignee in random.sample(list(assignees), random.randint(0, len(assignees) - 1)): bulk_issue_assignees.append( IssueAssignee( issue_id=issue, @@ -442,9 +403,7 @@ def create_issue_assignees(workspace, project, user_id, issue_count): ) # Issue assignees - IssueAssignee.objects.bulk_create( - bulk_issue_assignees, batch_size=1000, ignore_conflicts=True - ) + IssueAssignee.objects.bulk_create(bulk_issue_assignees, batch_size=1000, ignore_conflicts=True) def create_issue_labels(workspace, project, user_id, issue_count): @@ -464,16 +423,10 @@ def create_issue_labels(workspace, project, user_id, issue_count): for issue in issues: random.shuffle(shuffled_labels) for label in random.sample(shuffled_labels, random.randint(0, 5)): - bulk_issue_labels.append( - IssueLabel( - issue_id=issue, label_id=label, project=project, workspace=workspace - ) - ) + bulk_issue_labels.append(IssueLabel(issue_id=issue, label_id=label, project=project, workspace=workspace)) # Issue labels - IssueLabel.objects.bulk_create( - bulk_issue_labels, batch_size=1000, ignore_conflicts=True - ) + IssueLabel.objects.bulk_create(bulk_issue_labels, batch_size=1000, ignore_conflicts=True) def create_cycle_issues(workspace, project, user_id, issue_count): @@ -488,16 +441,10 @@ def create_cycle_issues(workspace, project, user_id, issue_count): bulk_cycle_issues = [] for issue in issues: cycle = cycles[random.randint(0, len(cycles) - 1)] - bulk_cycle_issues.append( - CycleIssue( - cycle_id=cycle, issue_id=issue, project=project, workspace=workspace - ) - ) + bulk_cycle_issues.append(CycleIssue(cycle_id=cycle, issue_id=issue, project=project, workspace=workspace)) # Issue assignees - CycleIssue.objects.bulk_create( - bulk_cycle_issues, batch_size=1000, ignore_conflicts=True - ) + CycleIssue.objects.bulk_create(bulk_cycle_issues, batch_size=1000, ignore_conflicts=True) def create_module_issues(workspace, project, user_id, issue_count): @@ -527,9 +474,7 @@ def create_module_issues(workspace, project, user_id, issue_count): ) ) # Issue assignees - ModuleIssue.objects.bulk_create( - bulk_module_issues, batch_size=1000, ignore_conflicts=True - ) + ModuleIssue.objects.bulk_create(bulk_module_issues, batch_size=1000, ignore_conflicts=True) @shared_task @@ -561,29 +506,19 @@ def create_dummy_data( create_labels(workspace=workspace, project=project, user_id=user_id) # create cycles - create_cycles( - workspace=workspace, project=project, user_id=user_id, cycle_count=cycle_count - ) + create_cycles(workspace=workspace, project=project, user_id=user_id, cycle_count=cycle_count) # create modules - create_modules( - workspace=workspace, project=project, user_id=user_id, module_count=module_count - ) + create_modules(workspace=workspace, project=project, user_id=user_id, module_count=module_count) # create pages - create_pages( - workspace=workspace, project=project, user_id=user_id, pages_count=pages_count - ) + create_pages(workspace=workspace, project=project, user_id=user_id, pages_count=pages_count) # create page labels - create_page_labels( - workspace=workspace, project=project, user_id=user_id, pages_count=pages_count - ) + create_page_labels(workspace=workspace, project=project, user_id=user_id, pages_count=pages_count) # create issues - create_issues( - workspace=workspace, project=project, user_id=user_id, issue_count=issue_count - ) + create_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) # create intake issues create_intake_issues( @@ -594,28 +529,18 @@ def create_dummy_data( ) # create issue parent - create_issue_parent( - workspace=workspace, project=project, user_id=user_id, issue_count=issue_count - ) + create_issue_parent(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) # create issue assignees - create_issue_assignees( - workspace=workspace, project=project, user_id=user_id, issue_count=issue_count - ) + create_issue_assignees(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) # create issue labels - create_issue_labels( - workspace=workspace, project=project, user_id=user_id, issue_count=issue_count - ) + create_issue_labels(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) # create cycle issues - create_cycle_issues( - workspace=workspace, project=project, user_id=user_id, issue_count=issue_count - ) + create_cycle_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) # create module issues - create_module_issues( - workspace=workspace, project=project, user_id=user_id, issue_count=issue_count - ) + create_module_issues(workspace=workspace, project=project, user_id=user_id, issue_count=issue_count) return diff --git a/apps/api/plane/bgtasks/email_notification_task.py b/apps/api/plane/bgtasks/email_notification_task.py index 141bb2f71..1402adc41 100644 --- a/apps/api/plane/bgtasks/email_notification_task.py +++ b/apps/api/plane/bgtasks/email_notification_task.py @@ -42,42 +42,27 @@ def release_lock(lock_id): @shared_task def stack_email_notification(): # get all email notifications - email_notifications = ( - EmailNotificationLog.objects.filter(processed_at__isnull=True) - .order_by("receiver") - .values() - ) + email_notifications = EmailNotificationLog.objects.filter(processed_at__isnull=True).order_by("receiver").values() # Create the below format for each of the issues # {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }} # Convert to unique receivers list - receivers = list( - set( - [ - str(notification.get("receiver_id")) - for notification in email_notifications - ] - ) - ) + receivers = list(set([str(notification.get("receiver_id")) for notification in email_notifications])) processed_notifications = [] # Loop through all the issues to create the emails for receiver_id in receivers: # Notification triggered for the receiver receiver_notifications = [ - notification - for notification in email_notifications - if str(notification.get("receiver_id")) == receiver_id + notification for notification in email_notifications if str(notification.get("receiver_id")) == receiver_id ] # create payload for all issues payload = {} email_notification_ids = [] for receiver_notification in receiver_notifications: - payload.setdefault( - receiver_notification.get("entity_identifier"), {} - ).setdefault(str(receiver_notification.get("triggered_by_id")), []).append( - receiver_notification.get("data") - ) + payload.setdefault(receiver_notification.get("entity_identifier"), {}).setdefault( + str(receiver_notification.get("triggered_by_id")), [] + ).append(receiver_notification.get("data")) # append processed notifications processed_notifications.append(receiver_notification.get("id")) email_notification_ids.append(receiver_notification.get("id")) @@ -92,9 +77,7 @@ def stack_email_notification(): ) # Update the email notification log - EmailNotificationLog.objects.filter(pk__in=processed_notifications).update( - processed_at=timezone.now() - ) + EmailNotificationLog.objects.filter(pk__in=processed_notifications).update(processed_at=timezone.now()) def create_payload(notification_data): @@ -115,10 +98,7 @@ def create_payload(notification_data): .setdefault(field, {}) .setdefault("old_value", []) .append(old_value) - if old_value - not in data.setdefault(actor_id, {}) - .setdefault(field, {}) - .get("old_value", []) + if old_value not in data.setdefault(actor_id, {}).setdefault(field, {}).get("old_value", []) else None ) @@ -129,18 +109,15 @@ def create_payload(notification_data): .setdefault(field, {}) .setdefault("new_value", []) .append(new_value) - if new_value - not in data.setdefault(actor_id, {}) - .setdefault(field, {}) - .get("new_value", []) + if new_value not in data.setdefault(actor_id, {}).setdefault(field, {}).get("new_value", []) else None ) if not data.get("actor_id", {}).get("activity_time", False): data[actor_id]["activity_time"] = str( - datetime.fromisoformat( - issue_activity.get("activity_time").rstrip("Z") - ).strftime("%Y-%m-%d %H:%M:%S") + datetime.fromisoformat(issue_activity.get("activity_time").rstrip("Z")).strftime( + "%Y-%m-%d %H:%M:%S" + ) ) return data @@ -169,9 +146,7 @@ def process_html_content(content): @shared_task -def send_email_notification( - issue_id, notification_data, receiver_id, email_notification_ids -): +def send_email_notification(issue_id, notification_data, receiver_id, email_notification_ids): # Convert UUIDs to a sorted, concatenated string sorted_ids = sorted(email_notification_ids) ids_str = "_".join(str(id) for id in sorted_ids) @@ -225,12 +200,8 @@ def send_email_notification( } ) if mention: - mention["new_value"] = process_html_content( - mention.get("new_value") - ) - mention["old_value"] = process_html_content( - mention.get("old_value") - ) + mention["new_value"] = process_html_content(mention.get("new_value")) + mention["old_value"] = process_html_content(mention.get("old_value")) comments.append( { "actor_comments": mention, @@ -243,9 +214,7 @@ def send_email_notification( ) activity_time = changes.pop("activity_time") # Parse the input string into a datetime object - formatted_time = datetime.strptime( - activity_time, "%Y-%m-%d %H:%M:%S" - ).strftime("%H:%M %p") + formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") if changes: template_data.append( @@ -275,20 +244,18 @@ def send_email_notification( "issue": { "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", "name": issue.name, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", # noqa: E501 }, "receiver": {"email": receiver.email}, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", # noqa: E501 + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", # noqa: E501 "workspace": str(issue.project.workspace.slug), "project": str(issue.project.name), "user_preference": f"{base_api}/{str(issue.project.workspace.slug)}/settings/account/notifications/", "comments": comments, "entity_type": "issue", } - html_content = render_to_string( - "emails/notifications/issue-updates.html", context - ) + html_content = render_to_string("emails/notifications/issue-updates.html", context) text_content = strip_tags(html_content) try: @@ -313,9 +280,7 @@ def send_email_notification( logging.getLogger("plane.worker").info("Email Sent Successfully") # Update the logs - EmailNotificationLog.objects.filter( - pk__in=email_notification_ids - ).update(sent_at=timezone.now()) + EmailNotificationLog.objects.filter(pk__in=email_notification_ids).update(sent_at=timezone.now()) # release the lock release_lock(lock_id=lock_id) diff --git a/apps/api/plane/bgtasks/export_task.py b/apps/api/plane/bgtasks/export_task.py index 4d7fcd5ff..d8aad5f69 100644 --- a/apps/api/plane/bgtasks/export_task.py +++ b/apps/api/plane/bgtasks/export_task.py @@ -1,82 +1,24 @@ # Python imports -import csv import io -import json import zipfile from typing import List +from collections import defaultdict import boto3 from botocore.client import Config from uuid import UUID -from datetime import datetime, date # Third party imports from celery import shared_task - # Django imports from django.conf import settings from django.utils import timezone -from openpyxl import Workbook -from django.db.models import F, Prefetch - -from collections import defaultdict +from django.db.models import Prefetch # Module imports -from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment +from plane.db.models import ExporterHistory, Issue, IssueRelation from plane.utils.exception_logger import log_exception - - -def dateTimeConverter(time: datetime) -> str | None: - """ - Convert a datetime object to a formatted string. - """ - if time: - return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z") - - -def dateConverter(time: date) -> str | None: - """ - Convert a date object to a formatted string. - """ - if time: - return time.strftime("%a, %d %b %Y") - - -def create_csv_file(data: List[List[str]]) -> str: - """ - Create a CSV file from the provided data. - """ - csv_buffer = io.StringIO() - csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) - - for row in data: - csv_writer.writerow(row) - - csv_buffer.seek(0) - return csv_buffer.getvalue() - - -def create_json_file(data: List[dict]) -> str: - """ - Create a JSON file from the provided data. - """ - return json.dumps(data) - - -def create_xlsx_file(data: List[List[str]]) -> bytes: - """ - Create an XLSX file from the provided data. - """ - workbook = Workbook() - sheet = workbook.active - - for row in data: - sheet.append(row) - - xlsx_buffer = io.BytesIO() - workbook.save(xlsx_buffer) - xlsx_buffer.seek(0) - return xlsx_buffer.getvalue() +from plane.utils.exporters import Exporter, IssueExportSchema def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO: @@ -93,15 +35,11 @@ def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO: # TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table -def upload_to_s3( - zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str -) -> None: +def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str) -> None: """ Upload a ZIP file to S3 and generate a presigned URL. """ - file_name = ( - f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip" - ) + file_name = f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip" expires_in = 7 * 24 * 60 * 60 if settings.USE_MINIO: @@ -122,7 +60,9 @@ def upload_to_s3( # Generate presigned url for the uploaded file with different base presign_s3 = boto3.client( "s3", - endpoint_url=f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/", + endpoint_url=( + f"{settings.AWS_S3_URL_PROTOCOL}//{str(settings.AWS_S3_CUSTOM_DOMAIN).replace('/uploads', '')}/" + ), aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, config=Config(signature_version="s3v4"), @@ -180,199 +120,6 @@ def upload_to_s3( exporter_instance.save(update_fields=["status", "url", "key"]) -def generate_table_row(issue: dict) -> List[str]: - """ - Generate a table row from an issue dictionary. - """ - return [ - f"""{issue["project_identifier"]}-{issue["sequence_id"]}""", - issue["project_name"], - issue["name"], - issue["description"], - issue["state_name"], - dateConverter(issue["start_date"]), - dateConverter(issue["target_date"]), - issue["priority"], - issue["created_by"], - ", ".join(issue["labels"]) if issue["labels"] else "", - issue["cycle_name"], - issue["cycle_start_date"], - issue["cycle_end_date"], - ", ".join(issue.get("module_name", "")) if issue.get("module_name") else "", - dateTimeConverter(issue["created_at"]), - dateTimeConverter(issue["updated_at"]), - dateTimeConverter(issue["completed_at"]), - dateTimeConverter(issue["archived_at"]), - ( - ", ".join( - [ - f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})" - for comment in issue["comments"] - ] - ) - if issue["comments"] - else "" - ), - issue["estimate"] if issue["estimate"] else "", - ", ".join(issue["link"]) if issue["link"] else "", - ", ".join(issue["assignees"]) if issue["assignees"] else "", - issue["subscribers_count"] if issue["subscribers_count"] else "", - issue["attachment_count"] if issue["attachment_count"] else "", - ", ".join(issue["attachment_links"]) if issue["attachment_links"] else "", - ] - - -def generate_json_row(issue: dict) -> dict: - """ - Generate a JSON row from an issue dictionary. - """ - return { - "ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""", - "Project": issue["project_name"], - "Name": issue["name"], - "Description": issue["description"], - "State": issue["state_name"], - "Start Date": dateConverter(issue["start_date"]), - "Target Date": dateConverter(issue["target_date"]), - "Priority": issue["priority"], - "Created By": (f"{issue['created_by']}" if issue["created_by"] else ""), - "Assignee": issue["assignees"], - "Labels": issue["labels"], - "Cycle Name": issue["cycle_name"], - "Cycle Start Date": issue["cycle_start_date"], - "Cycle End Date": issue["cycle_end_date"], - "Module Name": issue["module_name"], - "Created At": dateTimeConverter(issue["created_at"]), - "Updated At": dateTimeConverter(issue["updated_at"]), - "Completed At": dateTimeConverter(issue["completed_at"]), - "Archived At": dateTimeConverter(issue["archived_at"]), - "Comments": issue["comments"], - "Estimate": issue["estimate"], - "Link": issue["link"], - "Subscribers Count": issue["subscribers_count"], - "Attachment Count": issue["attachment_count"], - "Attachment Links": issue["attachment_links"], - } - - -def update_json_row(rows: List[dict], row: dict) -> None: - """ - Update the json row with the new assignee and label. - """ - matched_index = next( - ( - index - for index, existing_row in enumerate(rows) - if existing_row["ID"] == row["ID"] - ), - None, - ) - - if matched_index is not None: - existing_assignees, existing_labels = ( - rows[matched_index]["Assignee"], - rows[matched_index]["Labels"], - ) - assignee, label = row["Assignee"], row["Labels"] - - if assignee is not None and ( - existing_assignees is None or label not in existing_assignees - ): - rows[matched_index]["Assignee"] += f", {assignee}" - if label is not None and ( - existing_labels is None or label not in existing_labels - ): - rows[matched_index]["Labels"] += f", {label}" - else: - rows.append(row) - - -def update_table_row(rows: List[List[str]], row: List[str]) -> None: - """ - Update the table row with the new assignee and label. - """ - matched_index = next( - (index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]), - None, - ) - - if matched_index is not None: - existing_assignees, existing_labels = rows[matched_index][7:9] - assignee, label = row[7:9] - - if assignee is not None and ( - existing_assignees is None or label not in existing_assignees - ): - rows[matched_index][8] += f", {assignee}" - if label is not None and ( - existing_labels is None or label not in existing_labels - ): - rows[matched_index][8] += f", {label}" - else: - rows.append(row) - - -def generate_csv( - header: List[str], - project_id: str, - issues: List[dict], - files: List[tuple[str, str | bytes]], -) -> None: - """ - Generate CSV export for all the passed issues. - """ - rows = [header] - for issue in issues: - row = generate_table_row(issue) - update_table_row(rows, row) - csv_file = create_csv_file(rows) - files.append((f"{project_id}.csv", csv_file)) - - -def generate_json( - header: List[str], - project_id: str, - issues: List[dict], - files: List[tuple[str, str | bytes]], -) -> None: - """ - Generate JSON export for all the passed issues. - """ - rows = [] - for issue in issues: - row = generate_json_row(issue) - update_json_row(rows, row) - json_file = create_json_file(rows) - files.append((f"{project_id}.json", json_file)) - - -def generate_xlsx( - header: List[str], - project_id: str, - issues: List[dict], - files: List[tuple[str, str | bytes]], -) -> None: - """ - Generate XLSX export for all the passed issues. - """ - rows = [header] - for issue in issues: - row = generate_table_row(issue) - - update_table_row(rows, row) - xlsx_file = create_xlsx_file(rows) - files.append((f"{project_id}.xlsx", xlsx_file)) - - -def get_created_by(obj: Issue | IssueComment) -> str: - """ - Get the created by user for the given object. - """ - if obj.created_by: - return f"{obj.created_by.first_name} {obj.created_by.last_name}" - return "" - - @shared_task def issue_export_task( provider: str, @@ -393,7 +140,7 @@ def issue_export_task( exporter_instance.status = "processing" exporter_instance.save(update_fields=["status"]) - # Base query to get the issues + # Build base queryset for issues workspace_issues = ( Issue.objects.filter( workspace__id=workspace_id, @@ -406,7 +153,6 @@ def issue_export_task( "project", "workspace", "state", - "parent", "created_by", "estimate_point", ) @@ -416,151 +162,51 @@ def issue_export_task( "issue_module__module", "issue_comments", "assignees", - Prefetch( - "assignees", - queryset=User.objects.only("first_name", "last_name").distinct(), - to_attr="assignee_details", - ), - Prefetch( - "labels", - queryset=Label.objects.only("name").distinct(), - to_attr="label_details", - ), "issue_subscribers", "issue_link", + Prefetch( + "issue_relation", + queryset=IssueRelation.objects.select_related("related_issue", "related_issue__project"), + ), + Prefetch( + "issue_related", + queryset=IssueRelation.objects.select_related("issue", "issue__project"), + ), + Prefetch( + "parent", + queryset=Issue.objects.select_related("type", "project"), + ), ) ) - # Get the attachments for the issues - file_assets = FileAsset.objects.filter( - issue_id__in=workspace_issues.values_list("id", flat=True), - entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, - ).annotate(work_item_id=F("issue_id"), asset_id=F("id")) - - # Create a dictionary to store the attachments for the issues - attachment_dict = defaultdict(list) - for asset in file_assets: - attachment_dict[asset.work_item_id].append(asset.asset_id) - - # Create a list to store the issues data - issues_data = [] - - # Iterate over the issues - for issue in workspace_issues: - attachments = attachment_dict.get(issue.id, []) - - issue_data = { - "id": issue.id, - "project_identifier": issue.project.identifier, - "project_name": issue.project.name, - "project_id": issue.project.id, - "sequence_id": issue.sequence_id, - "name": issue.name, - "description": issue.description_stripped, - "priority": issue.priority, - "start_date": issue.start_date, - "target_date": issue.target_date, - "state_name": issue.state.name if issue.state else None, - "created_at": issue.created_at, - "updated_at": issue.updated_at, - "completed_at": issue.completed_at, - "archived_at": issue.archived_at, - "module_name": [ - module.module.name for module in issue.issue_module.all() - ], - "created_by": get_created_by(issue), - "labels": [label.name for label in issue.label_details], - "comments": [ - { - "comment": comment.comment_stripped, - "created_at": dateConverter(comment.created_at), - "created_by": get_created_by(comment), - } - for comment in issue.issue_comments.all() - ], - "estimate": issue.estimate_point.value - if issue.estimate_point and issue.estimate_point.value - else "", - "link": [link.url for link in issue.issue_link.all()], - "assignees": [ - f"{assignee.first_name} {assignee.last_name}" - for assignee in issue.assignee_details - ], - "subscribers_count": issue.issue_subscribers.count(), - "attachment_count": len(attachments), - "attachment_links": [ - f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/" - for asset in attachments - ], - } - - # Get Cycles data for the issue - cycle = issue.issue_cycle.last() - if cycle: - # Update cycle data - issue_data["cycle_name"] = cycle.cycle.name - issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date) - issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date) - else: - issue_data["cycle_name"] = "" - issue_data["cycle_start_date"] = "" - issue_data["cycle_end_date"] = "" - - issues_data.append(issue_data) - - # CSV header - header = [ - "ID", - "Project", - "Name", - "Description", - "State", - "Start Date", - "Target Date", - "Priority", - "Created By", - "Labels", - "Cycle Name", - "Cycle Start Date", - "Cycle End Date", - "Module Name", - "Created At", - "Updated At", - "Completed At", - "Archived At", - "Comments", - "Estimate", - "Link", - "Assignees", - "Subscribers Count", - "Attachment Count", - "Attachment Links", - ] - - # Map the provider to the function - EXPORTER_MAPPER = { - "csv": generate_csv, - "json": generate_json, - "xlsx": generate_xlsx, - } + # Create exporter for the specified format + try: + exporter = Exporter( + format_type=provider, + schema_class=IssueExportSchema, + options={"list_joiner": ", "}, + ) + except ValueError as e: + # Invalid format type + exporter_instance = ExporterHistory.objects.get(token=token_id) + exporter_instance.status = "failed" + exporter_instance.reason = str(e) + exporter_instance.save(update_fields=["status", "reason"]) + return files = [] if multiple: - project_dict = defaultdict(list) - for issue in issues_data: - project_dict[str(issue["project_id"])].append(issue) - + # Export each project separately with its own queryset for project_id in project_ids: - issues = project_dict.get(str(project_id), []) - - exporter = EXPORTER_MAPPER.get(provider) - if exporter is not None: - exporter(header, project_id, issues, files) - + project_issues = workspace_issues.filter(project_id=project_id) + export_filename = f"{slug}-{project_id}" + filename, content = exporter.export(export_filename, project_issues) + files.append((filename, content)) else: - exporter = EXPORTER_MAPPER.get(provider) - if exporter is not None: - exporter(header, workspace_id, issues_data, files) + # Export all issues in a single file + export_filename = f"{slug}-{workspace_id}" + filename, content = exporter.export(export_filename, workspace_issues) + files.append((filename, content)) zip_buffer = create_zip_file(files) upload_to_s3(zip_buffer, workspace_id, token_id, slug) diff --git a/apps/api/plane/bgtasks/file_asset_task.py b/apps/api/plane/bgtasks/file_asset_task.py index b7b05df3b..d6eccf735 100644 --- a/apps/api/plane/bgtasks/file_asset_task.py +++ b/apps/api/plane/bgtasks/file_asset_task.py @@ -17,9 +17,6 @@ from plane.db.models import FileAsset def delete_unuploaded_file_asset(): """This task deletes unuploaded file assets older than a certain number of days.""" FileAsset.objects.filter( - Q( - created_at__lt=timezone.now() - - timedelta(days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7"))) - ) + Q(created_at__lt=timezone.now() - timedelta(days=int(os.environ.get("UNUPLOADED_ASSET_DELETE_DAYS", "7")))) & Q(is_uploaded=False) ).delete() diff --git a/apps/api/plane/bgtasks/forgot_password_task.py b/apps/api/plane/bgtasks/forgot_password_task.py index 4e80f2cc1..ffaba9937 100644 --- a/apps/api/plane/bgtasks/forgot_password_task.py +++ b/apps/api/plane/bgtasks/forgot_password_task.py @@ -18,9 +18,7 @@ from plane.utils.exception_logger import log_exception @shared_task def forgot_password(first_name, email, uidb64, token, current_site): try: - relative_link = ( - f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}" - ) + relative_link = f"/accounts/reset-password/?uidb64={uidb64}&token={token}&email={email}" abs_url = str(current_site) + relative_link ( diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py index f768feac3..a886305fd 100644 --- a/apps/api/plane/bgtasks/issue_activities_task.py +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -34,6 +34,14 @@ from plane.utils.issue_relation_mapper import get_inverse_relation from plane.utils.uuid import is_valid_uuid +def extract_ids(data: dict | None, primary_key: str, fallback_key: str) -> set[str]: + if not data: + return set() + if primary_key in data: + return {str(x) for x in data.get(primary_key, [])} + return {str(x) for x in data.get(fallback_key, [])} + + # Track Changes in name def track_name( requested_data, @@ -73,14 +81,8 @@ def track_description( issue_activities, epoch, ): - if current_instance.get("description_html") != requested_data.get( - "description_html" - ): - last_activity = ( - IssueActivity.objects.filter(issue_id=issue_id) - .order_by("-created_at") - .first() - ) + if current_instance.get("description_html") != requested_data.get("description_html"): + last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first() if ( last_activity is not None and last_activity.field == "description" @@ -116,17 +118,18 @@ def track_parent( issue_activities, epoch, ): - if current_instance.get("parent_id") != requested_data.get("parent_id"): - old_parent = ( - Issue.objects.filter(pk=current_instance.get("parent_id")).first() - if current_instance.get("parent_id") is not None - else None - ) - new_parent = ( - Issue.objects.filter(pk=requested_data.get("parent_id")).first() - if requested_data.get("parent_id") is not None - else None - ) + current_parent_id = current_instance.get("parent_id") or current_instance.get("parent") + requested_parent_id = requested_data.get("parent_id") or requested_data.get("parent") + + # Validate UUIDs before database queries + if current_parent_id is not None and not is_valid_uuid(current_parent_id): + return + if requested_parent_id is not None and not is_valid_uuid(requested_parent_id): + return + + if current_parent_id != requested_parent_id: + old_parent = Issue.objects.filter(pk=current_parent_id).first() if current_parent_id is not None else None + new_parent = Issue.objects.filter(pk=requested_parent_id).first() if requested_parent_id is not None else None issue_activities.append( IssueActivity( @@ -134,14 +137,10 @@ def track_parent( actor_id=actor_id, verb="updated", old_value=( - f"{old_parent.project.identifier}-{old_parent.sequence_id}" - if old_parent is not None - else "" + f"{old_parent.project.identifier}-{old_parent.sequence_id}" if old_parent is not None else "" ), new_value=( - f"{new_parent.project.identifier}-{new_parent.sequence_id}" - if new_parent is not None - else "" + f"{new_parent.project.identifier}-{new_parent.sequence_id}" if new_parent is not None else "" ), field="parent", project_id=project_id, @@ -193,23 +192,31 @@ def track_state( issue_activities, epoch, ): - if current_instance.get("state_id") != requested_data.get("state_id"): - new_state = State.objects.get(pk=requested_data.get("state_id", None)) - old_state = State.objects.get(pk=current_instance.get("state_id", None)) + current_state_id = current_instance.get("state_id") or current_instance.get("state") + requested_state_id = requested_data.get("state_id") or requested_data.get("state") + + if current_state_id is not None and not is_valid_uuid(current_state_id): + current_state_id = None + if requested_state_id is not None and not is_valid_uuid(requested_state_id): + requested_state_id = None + + if current_state_id != requested_state_id: + new_state = State.objects.filter(pk=requested_state_id, project_id=project_id).first() + old_state = State.objects.filter(pk=current_state_id, project_id=project_id).first() issue_activities.append( IssueActivity( issue_id=issue_id, actor_id=actor_id, verb="updated", - old_value=old_state.name, - new_value=new_state.name, + old_value=old_state.name if old_state else None, + new_value=new_state.name if new_state else None, field="state", project_id=project_id, workspace_id=workspace_id, comment="updated the state to", - old_identifier=old_state.id, - new_identifier=new_state.id, + old_identifier=old_state.id if old_state else None, + new_identifier=new_state.id if new_state else None, epoch=epoch, ) ) @@ -233,15 +240,9 @@ def track_target_date( actor_id=actor_id, verb="updated", old_value=( - current_instance.get("target_date") - if current_instance.get("target_date") is not None - else "" - ), - new_value=( - requested_data.get("target_date") - if requested_data.get("target_date") is not None - else "" + current_instance.get("target_date") if current_instance.get("target_date") is not None else "" ), + new_value=(requested_data.get("target_date") if requested_data.get("target_date") is not None else ""), field="target_date", project_id=project_id, workspace_id=workspace_id, @@ -269,15 +270,9 @@ def track_start_date( actor_id=actor_id, verb="updated", old_value=( - current_instance.get("start_date") - if current_instance.get("start_date") is not None - else "" - ), - new_value=( - requested_data.get("start_date") - if requested_data.get("start_date") is not None - else "" + current_instance.get("start_date") if current_instance.get("start_date") is not None else "" ), + new_value=(requested_data.get("start_date") if requested_data.get("start_date") is not None else ""), field="start_date", project_id=project_id, workspace_id=workspace_id, @@ -298,8 +293,9 @@ def track_labels( issue_activities, epoch, ): - requested_labels = set([str(lab) for lab in requested_data.get("label_ids", [])]) - current_labels = set([str(lab) for lab in current_instance.get("label_ids", [])]) + # Labels + requested_labels = extract_ids(requested_data, "label_ids", "labels") + current_labels = extract_ids(current_instance, "label_ids", "labels") added_labels = requested_labels - current_labels dropped_labels = current_labels - requested_labels @@ -364,16 +360,9 @@ def track_assignees( issue_activities, epoch, ): - requested_assignees = ( - set([str(asg) for asg in requested_data.get("assignee_ids", [])]) - if requested_data is not None - else set() - ) - current_assignees = ( - set([str(asg) for asg in current_instance.get("assignee_ids", [])]) - if current_instance is not None - else set() - ) + # Assignees + requested_assignees = extract_ids(requested_data, "assignee_ids", "assignees") + current_assignees = extract_ids(current_instance, "assignee_ids", "assignees") added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees @@ -412,9 +401,7 @@ def track_assignees( ) # Create assignees subscribers to the issue and ignore if already - IssueSubscriber.objects.bulk_create( - bulk_subscribers, batch_size=10, ignore_conflicts=True - ) + IssueSubscriber.objects.bulk_create(bulk_subscribers, batch_size=10, ignore_conflicts=True) for dropped_assignee in dropped_assginees: # validate uuids @@ -451,16 +438,12 @@ def track_estimate_points( ): if current_instance.get("estimate_point") != requested_data.get("estimate_point"): old_estimate = ( - EstimatePoint.objects.filter( - pk=current_instance.get("estimate_point") - ).first() + EstimatePoint.objects.filter(pk=current_instance.get("estimate_point")).first() if current_instance.get("estimate_point") is not None else None ) new_estimate = ( - EstimatePoint.objects.filter( - pk=requested_data.get("estimate_point") - ).first() + EstimatePoint.objects.filter(pk=requested_data.get("estimate_point")).first() if requested_data.get("estimate_point") is not None else None ) @@ -475,9 +458,7 @@ def track_estimate_points( else None ), new_identifier=( - requested_data.get("estimate_point") - if requested_data.get("estimate_point") is not None - else None + requested_data.get("estimate_point") if requested_data.get("estimate_point") is not None else None ), old_value=old_estimate.value if old_estimate else None, new_value=new_estimate.value if new_estimate else None, @@ -550,9 +531,7 @@ def track_closed_to( epoch, ): if requested_data.get("closed_to") is not None: - updated_state = State.objects.get( - pk=requested_data.get("closed_to"), project_id=project_id - ) + updated_state = State.objects.get(pk=requested_data.get("closed_to"), project_id=project_id) issue_activities.append( IssueActivity( issue_id=issue_id, @@ -631,12 +610,15 @@ def update_issue_activity( "estimate_point": track_estimate_points, "archived_at": track_archive_at, "closed_to": track_closed_to, + # External endpoint keys + "parent": track_parent, + "state": track_state, + "assignees": track_assignees, + "labels": track_labels, } requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None for key in requested_data: func = ISSUE_ACTIVITY_MAPPER.get(key) @@ -688,9 +670,7 @@ def create_comment_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None issue_activities.append( IssueActivity( @@ -720,9 +700,7 @@ def update_comment_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None if current_instance.get("comment_html") != requested_data.get("comment_html"): issue_activities.append( @@ -781,21 +759,15 @@ def create_cycle_issue_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None # Updated Records: updated_records = current_instance.get("updated_cycle_issues", []) created_records = json.loads(current_instance.get("created_cycle_issues", [])) for updated_record in updated_records: - old_cycle = Cycle.objects.filter( - pk=updated_record.get("old_cycle_id", None) - ).first() - new_cycle = Cycle.objects.filter( - pk=updated_record.get("new_cycle_id", None) - ).first() + old_cycle = Cycle.objects.filter(pk=updated_record.get("old_cycle_id", None)).first() + new_cycle = Cycle.objects.filter(pk=updated_record.get("new_cycle_id", None)).first() issue = Issue.objects.filter(pk=updated_record.get("issue_id")).first() if issue: issue.updated_at = timezone.now() @@ -820,12 +792,8 @@ def create_cycle_issue_activity( ) for created_record in created_records: - cycle = Cycle.objects.filter( - pk=created_record.get("fields").get("cycle") - ).first() - issue = Issue.objects.filter( - pk=created_record.get("fields").get("issue") - ).first() + cycle = Cycle.objects.filter(pk=created_record.get("fields").get("cycle")).first() + issue = Issue.objects.filter(pk=created_record.get("fields").get("issue")).first() if issue: issue.updated_at = timezone.now() issue.save(update_fields=["updated_at"]) @@ -858,9 +826,7 @@ def delete_cycle_issue_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None cycle_id = requested_data.get("cycle_id", "") cycle_name = requested_data.get("cycle_name", "") @@ -932,9 +898,7 @@ def delete_module_issue_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None module_name = current_instance.get("module_name") current_issue = Issue.objects.filter(pk=issue_id).first() if current_issue: @@ -951,11 +915,7 @@ def delete_module_issue_activity( project_id=project_id, workspace_id=workspace_id, comment=f"removed this issue from {module_name}", - old_identifier=( - requested_data.get("module_id") - if requested_data.get("module_id") is not None - else None - ), + old_identifier=(requested_data.get("module_id") if requested_data.get("module_id") is not None else None), epoch=epoch, ) ) @@ -972,9 +932,7 @@ def create_link_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None issue_activities.append( IssueActivity( @@ -1003,9 +961,7 @@ def update_link_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None if current_instance.get("url") != requested_data.get("url"): issue_activities.append( @@ -1036,9 +992,7 @@ def delete_link_activity( issue_activities, epoch, ): - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None issue_activities.append( IssueActivity( @@ -1067,9 +1021,7 @@ def create_attachment_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None issue_activities.append( IssueActivity( @@ -1161,9 +1113,7 @@ def delete_issue_reaction_activity( issue_activities, epoch, ): - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None if current_instance and current_instance.get("reaction") is not None: issue_activities.append( IssueActivity( @@ -1205,11 +1155,7 @@ def create_comment_reaction_activity( .first() ) comment = IssueComment.objects.get(pk=comment_id, project_id=project_id) - if ( - comment is not None - and comment_reaction_id is not None - and comment_id is not None - ): + if comment is not None and comment_reaction_id is not None and comment_id is not None: issue_activities.append( IssueActivity( issue_id=comment.issue_id, @@ -1238,14 +1184,10 @@ def delete_comment_reaction_activity( issue_activities, epoch, ): - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None if current_instance and current_instance.get("reaction") is not None: issue_id = ( - IssueComment.objects.filter( - pk=current_instance.get("comment_id"), project_id=project_id - ) + IssueComment.objects.filter(pk=current_instance.get("comment_id"), project_id=project_id) .values_list("issue_id", flat=True) .first() ) @@ -1308,9 +1250,7 @@ def delete_issue_vote_activity( issue_activities, epoch, ): - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None if current_instance and current_instance.get("vote") is not None: issue_activities.append( IssueActivity( @@ -1341,9 +1281,7 @@ def create_issue_relation_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None if current_instance is None and requested_data.get("issues") is not None: for related_issue in requested_data.get("issues"): issue = Issue.objects.get(pk=related_issue) @@ -1392,9 +1330,7 @@ def delete_issue_relation_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None issue = Issue.objects.get(pk=requested_data.get("related_issue")) issue_activities.append( IssueActivity( @@ -1472,13 +1408,8 @@ def update_draft_issue_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) - if ( - requested_data.get("is_draft") is not None - and requested_data.get("is_draft") is False - ): + current_instance = json.loads(current_instance) if current_instance is not None else None + if requested_data.get("is_draft") is not None and requested_data.get("is_draft") is False: issue_activities.append( IssueActivity( issue_id=issue_id, @@ -1539,9 +1470,7 @@ def create_intake_activity( epoch, ): requested_data = json.loads(requested_data) if requested_data is not None else None - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None status_dict = { -2: "Pending", -1: "Rejected", diff --git a/apps/api/plane/bgtasks/issue_automation_task.py b/apps/api/plane/bgtasks/issue_automation_task.py index 68f3d32da..1cc303b57 100644 --- a/apps/api/plane/bgtasks/issue_automation_task.py +++ b/apps/api/plane/bgtasks/issue_automation_task.py @@ -39,15 +39,9 @@ def archive_old_issues(): state__group__in=["completed", "cancelled"], ), Q(issue_cycle__isnull=True) - | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now()) - & Q(issue_cycle__isnull=False) - ), + | (Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False)), Q(issue_module__isnull=True) - | ( - Q(issue_module__module__target_date__lt=timezone.now()) - & Q(issue_module__isnull=False) - ), + | (Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False)), ).filter( Q(issue_intake__status=1) | Q(issue_intake__status=-1) @@ -67,15 +61,11 @@ def archive_old_issues(): # Bulk Update the issues and log the activity if issues_to_update: - Issue.objects.bulk_update( - issues_to_update, ["archived_at"], batch_size=100 - ) + Issue.objects.bulk_update(issues_to_update, ["archived_at"], batch_size=100) _ = [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps( - {"archived_at": str(archive_at), "automation": True} - ), + requested_data=json.dumps({"archived_at": str(archive_at), "automation": True}), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, @@ -95,9 +85,7 @@ def archive_old_issues(): def close_old_issues(): try: # Get all the projects whose close_in is greater than 0 - projects = Project.objects.filter(close_in__gt=0).select_related( - "default_state" - ) + projects = Project.objects.filter(close_in__gt=0).select_related("default_state") for project in projects: project_id = project.id @@ -112,15 +100,9 @@ def close_old_issues(): state__group__in=["backlog", "unstarted", "started"], ), Q(issue_cycle__isnull=True) - | ( - Q(issue_cycle__cycle__end_date__lt=timezone.now()) - & Q(issue_cycle__isnull=False) - ), + | (Q(issue_cycle__cycle__end_date__lt=timezone.now()) & Q(issue_cycle__isnull=False)), Q(issue_module__isnull=True) - | ( - Q(issue_module__module__target_date__lt=timezone.now()) - & Q(issue_module__isnull=False) - ), + | (Q(issue_module__module__target_date__lt=timezone.now()) & Q(issue_module__isnull=False)), ).filter( Q(issue_intake__status=1) | Q(issue_intake__status=-1) @@ -142,15 +124,11 @@ def close_old_issues(): # Bulk Update the issues and log the activity if issues_to_update: - Issue.objects.bulk_update( - issues_to_update, ["state"], batch_size=100 - ) + Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps( - {"closed_to": str(issue.state_id)} - ), + requested_data=json.dumps({"closed_to": str(issue.state_id)}), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, diff --git a/apps/api/plane/bgtasks/issue_description_version_sync.py b/apps/api/plane/bgtasks/issue_description_version_sync.py index 14956cb50..d10ebfcba 100644 --- a/apps/api/plane/bgtasks/issue_description_version_sync.py +++ b/apps/api/plane/bgtasks/issue_description_version_sync.py @@ -70,9 +70,7 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): 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" - ) + logging.warning(f"Skipping {issue.id} - missing workspace_id or project_id") continue # Determine owned_by_id @@ -120,6 +118,4 @@ def sync_issue_description_version(batch_size=5000, offset=0, countdown=300): @shared_task def schedule_issue_description_version(batch_size=5000, countdown=300): - sync_issue_description_version.delay( - batch_size=int(batch_size), countdown=countdown - ) + sync_issue_description_version.delay(batch_size=int(batch_size), countdown=countdown) diff --git a/apps/api/plane/bgtasks/issue_description_version_task.py b/apps/api/plane/bgtasks/issue_description_version_task.py index a29fb6c57..06d15705a 100644 --- a/apps/api/plane/bgtasks/issue_description_version_task.py +++ b/apps/api/plane/bgtasks/issue_description_version_task.py @@ -15,10 +15,7 @@ def should_update_existing_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 - ) + return str(version.owned_by_id) == str(user_id) and time_difference <= max_time_difference def update_existing_version(version: IssueDescriptionVersion, issue) -> None: @@ -40,9 +37,7 @@ def update_existing_version(version: IssueDescriptionVersion, issue) -> None: @shared_task -def issue_description_version_task( - updated_issue, issue_id, user_id, is_creating=False -) -> Optional[bool]: +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 {} @@ -51,18 +46,13 @@ def issue_description_version_task( 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 - ): + 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() + IssueDescriptionVersion.objects.filter(issue_id=issue_id).order_by("-last_saved_at").first() ) # Determine whether to update existing or create new version diff --git a/apps/api/plane/bgtasks/issue_version_sync.py b/apps/api/plane/bgtasks/issue_version_sync.py index 698cedf15..761c26bc2 100644 --- a/apps/api/plane/bgtasks/issue_version_sync.py +++ b/apps/api/plane/bgtasks/issue_version_sync.py @@ -38,24 +38,17 @@ def issue_task(updated_issue, issue_id, user_id): updated_current_issue[key] = value if updated_current_issue: - issue_version = ( - IssueVersion.objects.filter(issue_id=issue_id) - .order_by("-last_saved_at") - .first() - ) + 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 + 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"] - ) + issue_version.save(update_fields=list(updated_current_issue.keys()) + ["last_saved_at"]) else: IssueVersion.log_issue_version(issue, user_id) @@ -88,16 +81,11 @@ def get_owner_id(issue: Issue) -> Optional[int]: 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) - } + 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") + 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]): @@ -105,9 +93,7 @@ def get_related_data(issue_ids: List[UUID]) -> Dict: # 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") + 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]): @@ -115,9 +101,7 @@ def get_related_data(issue_ids: List[UUID]) -> Dict: # 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") + 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]): @@ -125,9 +109,7 @@ def get_related_data(issue_ids: List[UUID]) -> Dict: # Get latest activities latest_activities = {} - activities = IssueActivity.objects.filter(issue_id__in=issue_ids).order_by( - "issue_id", "-created_at" - ) + 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: @@ -147,9 +129,7 @@ def create_issue_version(issue: Issue, related_data: Dict) -> Optional[IssueVers try: if not issue.workspace_id or not issue.project_id: - logging.warning( - f"Skipping issue {issue.id} - missing workspace_id or project_id" - ) + logging.warning(f"Skipping issue {issue.id} - missing workspace_id or project_id") return None owned_by_id = get_owner_id(issue) @@ -209,9 +189,7 @@ def sync_issue_version(batch_size=5000, offset=0, countdown=300): # Get issues batch with optimized queries issues_batch = list( - base_query.order_by("created_at") - .select_related("workspace", "project") - .all()[offset:end_offset] + base_query.order_by("created_at").select_related("workspace", "project").all()[offset:end_offset] ) if not issues_batch: diff --git a/apps/api/plane/bgtasks/notification_task.py b/apps/api/plane/bgtasks/notification_task.py index e58344bbf..6e571c0b1 100644 --- a/apps/api/plane/bgtasks/notification_task.py +++ b/apps/api/plane/bgtasks/notification_task.py @@ -56,9 +56,7 @@ def get_new_mentions(requested_instance, current_instance): mentions_newer = extract_mentions(requested_instance) # Getting Set Difference from mentions_newer - new_mentions = [ - mention for mention in mentions_newer if mention not in mentions_older - ] + new_mentions = [mention for mention in mentions_newer if mention not in mentions_older] return new_mentions @@ -73,9 +71,7 @@ def get_removed_mentions(requested_instance, current_instance): mentions_newer = extract_mentions(requested_instance) # Getting Set Difference from mentions_newer - removed_mentions = [ - mention for mention in mentions_older if mention not in mentions_newer - ] + removed_mentions = [mention for mention in mentions_older if mention not in mentions_newer] return removed_mentions @@ -87,7 +83,7 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): bulk_mention_subscribers = [] for mention_id in mentions: - # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification + # If the particular mention has not already been subscribed to the issue, he must be sent the mentioned notification # noqa: E501 if ( not IssueSubscriber.objects.filter( issue_id=issue_id, subscriber_id=mention_id, project_id=project_id @@ -95,12 +91,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): and not IssueAssignee.objects.filter( project_id=project_id, issue_id=issue_id, assignee_id=mention_id ).exists() - and not Issue.objects.filter( - project_id=project_id, pk=issue_id, created_by_id=mention_id - ).exists() - and ProjectMember.objects.filter( - project_id=project_id, member_id=mention_id, is_active=True - ).exists() + and not Issue.objects.filter(project_id=project_id, pk=issue_id, created_by_id=mention_id).exists() + and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists() ): project = Project.objects.get(pk=project_id) @@ -118,15 +110,13 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): # Parse Issue Description & extracts mentions def extract_mentions(issue_instance): try: - # issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. + # issue_instance has to be a dictionary passed, containing the description_html and other set of activity data. # noqa: E501 mentions = [] # Convert string to dictionary data = json.loads(issue_instance) html = data.get("description_html") soup = BeautifulSoup(html, "html.parser") - mention_tags = soup.find_all( - "mention-component", attrs={"entity_name": "user_mention"} - ) + mention_tags = soup.find_all("mention-component", attrs={"entity_name": "user_mention"}) mentions = [mention_tag["entity_identifier"] for mention_tag in mention_tags] @@ -140,9 +130,7 @@ def extract_comment_mentions(comment_value): try: mentions = [] soup = BeautifulSoup(comment_value, "html.parser") - mentions_tags = soup.find_all( - "mention-component", attrs={"entity_name": "user_mention"} - ) + 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)) @@ -157,16 +145,12 @@ def get_new_comment_mentions(new_value, old_value): mentions_older = extract_comment_mentions(old_value) # Getting Set Difference from mentions_newer - new_mentions = [ - mention for mention in mentions_newer if mention not in mentions_older - ] + new_mentions = [mention for mention in mentions_newer if mention not in mentions_older] return new_mentions -def create_mention_notification( - project, notification_comment, issue, actor_id, mention_id, issue_id, activity -): +def create_mention_notification(project, notification_comment, issue, actor_id, mention_id, issue_id, activity): return Notification( workspace=project.workspace, sender="in_app:issue_activities:mentioned", @@ -192,16 +176,8 @@ def create_mention_notification( "actor": str(activity.get("actor_id")), "new_value": str(activity.get("new_value")), "old_value": str(activity.get("old_value")), - "old_identifier": ( - str(activity.get("old_identifier")) - if activity.get("old_identifier") - else None - ), - "new_identifier": ( - str(activity.get("new_identifier")) - if activity.get("new_identifier") - else None - ), + "old_identifier": (str(activity.get("old_identifier")) if activity.get("old_identifier") else None), + "new_identifier": (str(activity.get("new_identifier")) if activity.get("new_identifier") else None), }, }, ) @@ -220,9 +196,7 @@ def notifications( ): try: issue_activities_created = ( - json.loads(issue_activities_created) - if issue_activities_created is not None - else None + json.loads(issue_activities_created) if issue_activities_created is not None else None ) if type not in [ "cycle.activity.created", @@ -250,20 +224,14 @@ def notifications( """ # 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) + 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( - set(new_mentions) & {str(member) for member in project_members} - ) - removed_mention = get_removed_mentions( - requested_instance=requested_data, current_instance=current_instance - ) + new_mentions = get_new_mentions(requested_instance=requested_data, current_instance=current_instance) + 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) comment_mentions = [] all_comment_mentions = [] @@ -281,10 +249,7 @@ def notifications( if issue_comment is not None: # TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well. - all_comment_mentions = ( - all_comment_mentions - + extract_comment_mentions(issue_comment_new_value) - ) + all_comment_mentions = all_comment_mentions + extract_comment_mentions(issue_comment_new_value) new_comment_mentions = get_new_comment_mentions( old_value=issue_comment_old_value, @@ -292,9 +257,7 @@ def notifications( ) comment_mentions = comment_mentions + new_comment_mentions comment_mentions = [ - mention - for mention in comment_mentions - if UUID(mention) in set(project_members) + mention for mention in comment_mentions if UUID(mention) in set(project_members) ] comment_mention_subscribers = extract_mentions_as_subscribers( @@ -303,20 +266,20 @@ def notifications( """ We will not send subscription activity notification to the below mentioned user sets - Those who have been newly mentioned in the issue description, we will send mention notification to them. - - When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification - - When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification + - When the activity is a comment_created and there exist a mention in the comment, + then we have to send the "mention_in_comment" notification + - When the activity is a comment_updated and there exist a mention change, + then also we have to send the "mention_in_comment" notification """ - # ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- # + # --------------------------------------------------------------------------------------------------------- issue_subscribers = list( 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]) - ) + .exclude(subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])) .values_list("subscriber", flat=True) ) @@ -344,10 +307,7 @@ def notifications( for subscriber in issue_subscribers: if issue.created_by_id and issue.created_by_id == subscriber: sender = "in_app:issue_activities:created" - elif ( - subscriber in issue_assignees - and issue.created_by_id not in issue_assignees - ): + elif subscriber in issue_assignees and issue.created_by_id not in issue_assignees: sender = "in_app:issue_activities:assigned" else: sender = "in_app:issue_activities:subscribed" @@ -365,10 +325,7 @@ def notifications( # Check if the value should be sent or not send_email = False - if ( - issue_activity.get("field") == "state" - and preference.state_change - ): + if issue_activity.get("field") == "state" and preference.state_change: send_email = True elif ( issue_activity.get("field") == "state" @@ -380,9 +337,7 @@ def notifications( ).exists() ): send_email = True - elif ( - issue_activity.get("field") == "comment" and preference.comment - ): + elif issue_activity.get("field") == "comment" and preference.comment: send_email = True elif preference.property_change: send_email = True @@ -429,9 +384,7 @@ def notifications( "new_value": str(issue_activity.get("new_value")), "old_value": str(issue_activity.get("old_value")), "issue_comment": str( - issue_comment.comment_stripped - if issue_comment is not None - else "" + issue_comment.comment_stripped if issue_comment is not None else "" ), "old_identifier": ( str(issue_activity.get("old_identifier")) @@ -461,9 +414,7 @@ def notifications( "name": str(issue.name), "identifier": str(issue.project.identifier), "project_id": str(issue.project.id), - "workspace_slug": str( - issue.project.workspace.slug - ), + "workspace_slug": str(issue.project.workspace.slug), "sequence_id": issue.sequence_id, "state_name": issue.state.name, "state_group": issue.state.group, @@ -473,16 +424,10 @@ def notifications( "verb": str(issue_activity.get("verb")), "field": str(issue_activity.get("field")), "actor": str(issue_activity.get("actor_id")), - "new_value": str( - issue_activity.get("new_value") - ), - "old_value": str( - issue_activity.get("old_value") - ), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), "issue_comment": str( - issue_comment.comment_stripped - if issue_comment is not None - else "" + issue_comment.comment_stripped if issue_comment is not None else "" ), "old_identifier": ( str(issue_activity.get("old_identifier")) @@ -494,15 +439,13 @@ def notifications( if issue_activity.get("new_identifier") else None ), - "activity_time": issue_activity.get( - "created_at" - ), + "activity_time": issue_activity.get("created_at"), }, }, ) ) - # ----------------------------------------------------------------------------------------------------------------- # + # -------------------------------------------------------------------------------------------------------- # # Add Mentioned as Issue Subscribers IssueSubscriber.objects.bulk_create( @@ -511,24 +454,18 @@ def notifications( ignore_conflicts=True, ) - last_activity = ( - IssueActivity.objects.filter(issue_id=issue_id) - .order_by("-created_at") - .first() - ) + last_activity = IssueActivity.objects.filter(issue_id=issue_id).order_by("-created_at").first() actor = User.objects.get(pk=actor_id) for mention_id in comment_mentions: if mention_id != actor_id: - preference = UserNotificationPreference.objects.get( - user_id=mention_id - ) + preference = UserNotificationPreference.objects.get(user_id=mention_id) for issue_activity in issue_activities_created: notification = create_mention_notification( project=project, issue=issue, - notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", + notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", # noqa: E501 actor_id=actor_id, mention_id=mention_id, issue_id=issue_id, @@ -552,40 +489,26 @@ def notifications( "state_name": issue.state.name, "state_group": issue.state.group, "project_id": str(issue.project.id), - "workspace_slug": str( - issue.project.workspace.slug - ), + "workspace_slug": str(issue.project.workspace.slug), }, "issue_activity": { "id": str(issue_activity.get("id")), "verb": str(issue_activity.get("verb")), "field": str("mention"), - "actor": str( - issue_activity.get("actor_id") - ), - "new_value": str( - issue_activity.get("new_value") - ), - "old_value": str( - issue_activity.get("old_value") - ), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), "old_identifier": ( - str( - issue_activity.get("old_identifier") - ) + str(issue_activity.get("old_identifier")) if issue_activity.get("old_identifier") else None ), "new_identifier": ( - str( - issue_activity.get("new_identifier") - ) + str(issue_activity.get("new_identifier")) if issue_activity.get("new_identifier") else None ), - "activity_time": issue_activity.get( - "created_at" - ), + "activity_time": issue_activity.get("created_at"), }, }, ) @@ -594,9 +517,7 @@ def notifications( for mention_id in new_mentions: if mention_id != actor_id: - preference = UserNotificationPreference.objects.get( - user_id=mention_id - ) + preference = UserNotificationPreference.objects.get(user_id=mention_id) if ( last_activity is not None and last_activity.field == "description" @@ -621,9 +542,7 @@ def notifications( "state_name": issue.state.name, "state_group": issue.state.group, "project_id": str(issue.project.id), - "workspace_slug": str( - issue.project.workspace.slug - ), + "workspace_slug": str(issue.project.workspace.slug), }, "issue_activity": { "id": str(last_activity.id), @@ -670,22 +589,16 @@ def notifications( "new_value": str(last_activity.new_value), "old_value": str(last_activity.old_value), "old_identifier": ( - str( - issue_activity.get("old_identifier") - ) + str(issue_activity.get("old_identifier")) if issue_activity.get("old_identifier") else None ), "new_identifier": ( - str( - issue_activity.get("new_identifier") - ) + str(issue_activity.get("new_identifier")) if issue_activity.get("new_identifier") else None ), - "activity_time": str( - last_activity.created_at - ), + "activity_time": str(last_activity.created_at), }, }, ) @@ -712,9 +625,7 @@ def notifications( "issue": { "id": str(issue_id), "name": str(issue.name), - "identifier": str( - issue.project.identifier - ), + "identifier": str(issue.project.identifier), "sequence_id": issue.sequence_id, "state_name": issue.state.name, "state_group": issue.state.group, @@ -723,47 +634,27 @@ def notifications( "id": str(issue_activity.get("id")), "verb": str(issue_activity.get("verb")), "field": str("mention"), - "actor": str( - issue_activity.get("actor_id") - ), - "new_value": str( - issue_activity.get("new_value") - ), - "old_value": str( - issue_activity.get("old_value") - ), + "actor": str(issue_activity.get("actor_id")), + "new_value": str(issue_activity.get("new_value")), + "old_value": str(issue_activity.get("old_value")), "old_identifier": ( - str( - issue_activity.get( - "old_identifier" - ) - ) - if issue_activity.get( - "old_identifier" - ) + str(issue_activity.get("old_identifier")) + if issue_activity.get("old_identifier") else None ), "new_identifier": ( - str( - issue_activity.get( - "new_identifier" - ) - ) - if issue_activity.get( - "new_identifier" - ) + str(issue_activity.get("new_identifier")) + if issue_activity.get("new_identifier") else None ), - "activity_time": issue_activity.get( - "created_at" - ), + "activity_time": issue_activity.get("created_at"), }, }, ) ) bulk_notifications.append(notification) - # save new mentions for the particular issue and remove the mentions that has been deleted from the description + # save new mentions for the particular issue and remove the mentions that has been deleted from the description # noqa: E501 update_mentions_for_issue( issue=issue, project=project, @@ -772,9 +663,7 @@ def notifications( ) # Bulk create notifications Notification.objects.bulk_create(bulk_notifications, batch_size=100) - EmailNotificationLog.objects.bulk_create( - bulk_email_logs, batch_size=100, ignore_conflicts=True - ) + EmailNotificationLog.objects.bulk_create(bulk_email_logs, batch_size=100, ignore_conflicts=True) return except Exception as e: print(e) diff --git a/apps/api/plane/bgtasks/page_transaction_task.py b/apps/api/plane/bgtasks/page_transaction_task.py index 893bbf972..09e2cb2ad 100644 --- a/apps/api/plane/bgtasks/page_transaction_task.py +++ b/apps/api/plane/bgtasks/page_transaction_task.py @@ -69,9 +69,7 @@ def page_transaction(new_value, old_value, page_id): ) # Create new PageLog objects for new transactions - PageLog.objects.bulk_create( - new_transactions, batch_size=10, ignore_conflicts=True - ) + PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True) # Delete the removed transactions PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete() diff --git a/apps/api/plane/bgtasks/page_version_task.py b/apps/api/plane/bgtasks/page_version_task.py index ec1f6c3ca..4de2387be 100644 --- a/apps/api/plane/bgtasks/page_version_task.py +++ b/apps/api/plane/bgtasks/page_version_task.py @@ -16,9 +16,7 @@ def page_version(page_id, existing_instance, user_id): page = Page.objects.get(id=page_id) # Get the current instance - current_instance = ( - json.loads(existing_instance) if existing_instance is not None else {} - ) + current_instance = json.loads(existing_instance) if existing_instance is not None else {} # Create a version if description_html is updated if current_instance.get("description_html") != page.description_html: @@ -37,9 +35,7 @@ def page_version(page_id, existing_instance, user_id): # If page versions are greater than 20 delete the oldest one if PageVersion.objects.filter(page_id=page_id).count() > 20: # Delete the old page version - PageVersion.objects.filter(page_id=page_id).order_by( - "last_saved_at" - ).first().delete() + PageVersion.objects.filter(page_id=page_id).order_by("last_saved_at").first().delete() return except Page.DoesNotExist: diff --git a/apps/api/plane/bgtasks/project_add_user_email_task.py b/apps/api/plane/bgtasks/project_add_user_email_task.py index ab1eb0394..af6014695 100644 --- a/apps/api/plane/bgtasks/project_add_user_email_task.py +++ b/apps/api/plane/bgtasks/project_add_user_email_task.py @@ -54,9 +54,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id): subject = "You have been invited to a Plane project" # Render the email template - html_content = render_to_string( - "emails/notifications/project_addition.html", context - ) + html_content = render_to_string("emails/notifications/project_addition.html", context) text_content = strip_tags(html_content) # Initialize the connection connection = get_connection( diff --git a/apps/api/plane/bgtasks/project_invitation_task.py b/apps/api/plane/bgtasks/project_invitation_task.py index 179dfa00f..b8eed5e45 100644 --- a/apps/api/plane/bgtasks/project_invitation_task.py +++ b/apps/api/plane/bgtasks/project_invitation_task.py @@ -21,11 +21,9 @@ def project_invitation(email, project_id, token, current_site, invitor): try: user = User.objects.get(email=invitor) project = Project.objects.get(pk=project_id) - project_member_invite = ProjectMemberInvite.objects.get( - token=token, email=email - ) + project_member_invite = ProjectMemberInvite.objects.get(token=token, email=email) - relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" + relativelink = f"/project-invitations/?invitation_id={project_member_invite.id}&email={email}&slug={project.workspace.slug}&project_id={str(project_id)}" # noqa: E501 abs_url = current_site + relativelink subject = f"{user.first_name or user.display_name or user.email} invited you to join {project.name} on Plane" @@ -37,9 +35,7 @@ def project_invitation(email, project_id, token, current_site, invitor): "invitation_url": abs_url, } - html_content = render_to_string( - "emails/invitations/project_invitation.html", context - ) + html_content = render_to_string("emails/invitations/project_invitation.html", context) text_content = strip_tags(html_content) diff --git a/apps/api/plane/bgtasks/recent_visited_task.py b/apps/api/plane/bgtasks/recent_visited_task.py index 4203867da..eda297ce4 100644 --- a/apps/api/plane/bgtasks/recent_visited_task.py +++ b/apps/api/plane/bgtasks/recent_visited_task.py @@ -30,14 +30,10 @@ def recent_visited_task(entity_name, entity_identifier, user_id, project_id, slu except DatabaseError: pass else: - recent_visited_count = UserRecentVisit.objects.filter( - user_id=user_id, workspace_id=workspace.id - ).count() + recent_visited_count = UserRecentVisit.objects.filter(user_id=user_id, workspace_id=workspace.id).count() if recent_visited_count == 20: recent_visited = ( - UserRecentVisit.objects.filter( - user_id=user_id, workspace_id=workspace.id - ) + UserRecentVisit.objects.filter(user_id=user_id, workspace_id=workspace.id) .order_by("created_at") .first() ) diff --git a/apps/api/plane/bgtasks/storage_metadata_task.py b/apps/api/plane/bgtasks/storage_metadata_task.py index f5daf73ba..ea745053f 100644 --- a/apps/api/plane/bgtasks/storage_metadata_task.py +++ b/apps/api/plane/bgtasks/storage_metadata_task.py @@ -15,9 +15,7 @@ def get_asset_object_metadata(asset_id): # Create an instance of the S3 storage storage = S3Storage() # Get the storage - asset.storage_metadata = storage.get_object_metadata( - object_name=asset.asset.name - ) + asset.storage_metadata = storage.get_object_metadata(object_name=asset.asset.name) # Save the asset asset.save(update_fields=["storage_metadata"]) return diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py index ae7c30ac9..2504eb734 100644 --- a/apps/api/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -48,6 +48,8 @@ from plane.db.models import ( ) from plane.license.utils.instance_value import get_email_configuration from plane.utils.exception_logger import log_exception +from plane.settings.mongo import MongoConnection + SERIALIZER_MAPPER = { "project": ProjectSerializer, @@ -80,15 +82,63 @@ logger = logging.getLogger("plane.worker") def get_issue_prefetches(): return [ Prefetch("label_issue", queryset=IssueLabel.objects.select_related("label")), - Prefetch( - "issue_assignee", queryset=IssueAssignee.objects.select_related("assignee") - ), + Prefetch("issue_assignee", queryset=IssueAssignee.objects.select_related("assignee")), ] -def get_model_data( - event: str, event_id: Union[str, List[str]], many: bool = False -) -> Dict[str, Any]: + +def save_webhook_log( + webhook: Webhook, + request_method: str, + request_headers: str, + request_body: str, + response_status: str, + response_headers: str, + response_body: str, + retry_count: int, + event_type: str, +) -> None: + + # webhook_logs + mongo_collection = MongoConnection.get_collection("webhook_logs") + + log_data = { + "workspace_id": str(webhook.workspace_id), + "webhook": str(webhook.id), + "event_type": str(event_type), + "request_method": str(request_method), + "request_headers": str(request_headers), + "request_body": str(request_body), + "response_status": str(response_status), + "response_headers": str(response_headers), + "response_body": str(response_body), + "retry_count": retry_count, + } + + mongo_save_success = False + if mongo_collection is not None: + try: + # insert the log data into the mongo collection + mongo_collection.insert_one(log_data) + logger.info("Webhook log saved successfully to mongo") + mongo_save_success = True + except Exception as e: + log_exception(e) + logger.error(f"Failed to save webhook log: {e}") + mongo_save_success = False + + # if the mongo save is not successful, save the log data into the database + if not mongo_save_success: + try: + # insert the log data into the database + WebhookLog.objects.create(**log_data) + logger.info("Webhook log saved successfully to database") + except Exception as e: + log_exception(e) + logger.error(f"Failed to save webhook log: {e}") + + +def get_model_data(event: str, event_id: Union[str, List[str]], many: bool = False) -> Dict[str, Any]: """ Retrieve and serialize model data based on the event type. @@ -125,15 +175,9 @@ def get_model_data( queryset = queryset.prefetch_related(*issue_prefetches) else: issue_id = queryset.id - queryset = ( - model.objects.filter(pk=issue_id) - .prefetch_related(*issue_prefetches) - .first() - ) + queryset = model.objects.filter(pk=issue_id).prefetch_related(*issue_prefetches).first() - return serializer( - queryset, many=many, context={"expand": ["labels", "assignees"]} - ).data + return serializer(queryset, many=many, context={"expand": ["labels", "assignees"]}).data else: return serializer(queryset, many=many).data except ObjectDoesNotExist: @@ -141,9 +185,7 @@ def get_model_data( @shared_task -def send_webhook_deactivation_email( - webhook_id: str, receiver_id: str, current_site: str, reason: str -) -> None: +def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_site: str, reason: str) -> None: """ Send an email notification when a webhook is deactivated. @@ -177,9 +219,7 @@ def send_webhook_deactivation_email( "message": message, "webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}", } - html_content = render_to_string( - "emails/notifications/webhook-deactivate.html", context - ) + html_content = render_to_string("emails/notifications/webhook-deactivate.html", context) text_content = strip_tags(html_content) # Set the email connection @@ -248,17 +288,9 @@ def webhook_send_task( } # # Your secret key - event_data = ( - json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) - if event_data is not None - else None - ) + event_data = json.loads(json.dumps(event_data, cls=DjangoJSONEncoder)) if event_data is not None else None - activity = ( - json.loads(json.dumps(activity, cls=DjangoJSONEncoder)) - if activity is not None - else None - ) + activity = json.loads(json.dumps(activity, cls=DjangoJSONEncoder)) if activity is not None else None action = { "POST": "create", @@ -295,32 +327,30 @@ def webhook_send_task( response = requests.post(webhook.url, headers=headers, json=payload, timeout=30) # Log the webhook request - WebhookLog.objects.create( - workspace_id=str(webhook.workspace_id), - webhook=str(webhook.id), - event_type=str(event), - request_method=str(action), - request_headers=str(headers), - request_body=str(payload), - response_status=str(response.status_code), - response_headers=str(response.headers), - response_body=str(response.text), - retry_count=str(self.request.retries), + save_webhook_log( + webhook=webhook, + request_method=action, + request_headers=headers, + request_body=payload, + response_status=response.status_code, + response_headers=response.headers, + response_body=response.text, + retry_count=self.request.retries, + event_type=event, ) logger.info(f"Webhook {webhook.id} sent successfully") except requests.RequestException as e: # Log the failed webhook request - WebhookLog.objects.create( - workspace_id=str(webhook.workspace_id), - webhook=str(webhook.id), - event_type=str(event), - request_method=str(action), - request_headers=str(headers), - request_body=str(payload), + save_webhook_log( + webhook=webhook, + request_method=action, + request_headers=headers, + request_body=payload, response_status=500, response_headers="", response_body=str(e), - retry_count=str(self.request.retries), + retry_count=self.request.retries, + event_type=event, ) logger.error(f"Webhook {webhook.id} failed with error: {e}") # Retry logic @@ -405,11 +435,7 @@ def webhook_activity( webhook_id=webhook.id, slug=slug, event=event, - event_data=( - {"id": event_id} - if verb == "deleted" - else get_model_data(event=event, event_id=event_id) - ), + event_data=({"id": event_id} if verb == "deleted" else get_model_data(event=event, event_id=event_id)), action=verb, current_site=current_site, activity={ @@ -433,9 +459,7 @@ def webhook_activity( @shared_task -def model_activity( - model_name, model_id, requested_data, current_instance, actor_id, slug, origin=None -): +def model_activity(model_name, model_id, requested_data, current_instance, actor_id, slug, origin=None): """Function takes in two json and computes differences between keys of both the json""" if current_instance is None: webhook_activity.delay( @@ -454,9 +478,7 @@ def model_activity( return # Load the current instance - current_instance = ( - json.loads(current_instance) if current_instance is not None else None - ) + current_instance = json.loads(current_instance) if current_instance is not None else None # Loop through all keys in requested data and check the current value and requested value for key in requested_data: diff --git a/apps/api/plane/bgtasks/workspace_invitation_task.py b/apps/api/plane/bgtasks/workspace_invitation_task.py index c855a8ce6..f7480b36a 100644 --- a/apps/api/plane/bgtasks/workspace_invitation_task.py +++ b/apps/api/plane/bgtasks/workspace_invitation_task.py @@ -21,12 +21,12 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter): user = User.objects.get(email=inviter) workspace = Workspace.objects.get(pk=workspace_id) - workspace_member_invite = WorkspaceMemberInvite.objects.get( - token=token, email=email - ) + workspace_member_invite = WorkspaceMemberInvite.objects.get(token=token, email=email) # Relative link - relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501 + relative_link = ( + f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501 + ) # The complete url including the domain abs_url = str(current_site) + relative_link @@ -51,9 +51,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter): "abs_url": abs_url, } - html_content = render_to_string( - "emails/invitations/workspace_invitation.html", context - ) + html_content = render_to_string("emails/invitations/workspace_invitation.html", context) text_content = strip_tags(html_content) diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index c2fbfb065..fb9980c3f 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -5,9 +5,11 @@ import time import uuid from typing import Dict import logging +from datetime import timedelta # Django imports from django.conf import settings +from django.utils import timezone # Third party imports from celery import shared_task @@ -25,6 +27,13 @@ from plane.db.models import ( IssueLabel, IssueSequence, IssueActivity, + Page, + ProjectPage, + Cycle, + Module, + CycleIssue, + ModuleIssue, + IssueView, ) logger = logging.getLogger("plane.worker") @@ -68,16 +77,12 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: project_identifier = "".join(ch for ch in workspace.name if ch.isalnum())[:5] # Create members - workspace_members = WorkspaceMember.objects.filter(workspace=workspace).values( - "member_id", "role" - ) + workspace_members = WorkspaceMember.objects.filter(workspace=workspace).values("member_id", "role") projects_map: Dict[int, uuid.UUID] = {} if not project_seeds: - logger.warning( - "Task: workspace_seed_task -> No project seeds found. Skipping project creation." - ) + logger.warning("Task: workspace_seed_task -> No project seeds found. Skipping project creation.") return projects_map for project_seed in project_seeds: @@ -92,6 +97,10 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: name=workspace.name, # Use workspace name identifier=project_identifier, created_by_id=workspace.created_by_id, + # Enable all views in seed data + cycle_view=True, + module_view=True, + issue_views_view=True, ) # Create project members @@ -116,13 +125,33 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: user_id=workspace_member["member_id"], workspace_id=workspace.id, display_filters={ - "group_by": None, - "order_by": "sort_order", - "type": None, - "sub_issue": True, - "show_empty_groups": True, "layout": "list", - "calendar_date_range": "", + "calendar": {"layout": "month", "show_weekends": False}, + "group_by": "state", + "order_by": "sort_order", + "sub_issue": True, + "sub_group_by": None, + "show_empty_groups": True, + }, + display_properties={ + "key": True, + "link": True, + "cycle": False, + "state": True, + "labels": False, + "modules": False, + "assignee": True, + "due_date": False, + "estimate": True, + "priority": True, + "created_on": True, + "issue_type": True, + "start_date": False, + "updated_on": True, + "customer_count": True, + "sub_issue_count": False, + "attachment_count": False, + "customer_request_count": True, }, created_by_id=workspace.created_by_id, ) @@ -136,9 +165,7 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: return projects_map -def create_project_states( - workspace: Workspace, project_map: Dict[int, uuid.UUID] -) -> Dict[int, uuid.UUID]: +def create_project_states(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]: """Creates states for each project in the workspace. Args: @@ -171,9 +198,7 @@ def create_project_states( return state_map -def create_project_labels( - workspace: Workspace, project_map: Dict[int, uuid.UUID] -) -> Dict[int, uuid.UUID]: +def create_project_labels(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]: """Creates labels for each project in the workspace. Args: @@ -209,6 +234,8 @@ def create_project_issues( project_map: Dict[int, uuid.UUID], states_map: Dict[int, uuid.UUID], labels_map: Dict[int, uuid.UUID], + cycles_map: Dict[int, uuid.UUID], + module_map: Dict[int, uuid.UUID], ) -> None: """Creates issues and their associated records for each project. @@ -230,9 +257,7 @@ def create_project_issues( # get the values for field in required_fields: if field not in issue_seed: - logger.error( - f"Task: workspace_seed_task -> Required field '{field}' missing in issue seed" - ) + logger.error(f"Task: workspace_seed_task -> Required field '{field}' missing in issue seed") continue # get the values @@ -240,6 +265,8 @@ def create_project_issues( labels = issue_seed.pop("labels") project_id = issue_seed.pop("project_id") state_id = issue_seed.pop("state_id") + cycle_id = issue_seed.pop("cycle_id") + module_ids = issue_seed.pop("module_ids") issue = Issue.objects.create( **issue_seed, @@ -265,6 +292,7 @@ def create_project_issues( epoch=time.time(), ) + # Create issue labels for label_id in labels: IssueLabel.objects.create( issue=issue, @@ -274,10 +302,172 @@ def create_project_issues( created_by_id=workspace.created_by_id, ) + # Create cycle issues + if cycle_id: + CycleIssue.objects.create( + issue=issue, + cycle_id=cycles_map[cycle_id], + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + + # Create module issues + if module_ids: + for module_id in module_ids: + ModuleIssue.objects.create( + issue=issue, + module_id=module_map[module_id], + project_id=project_map[project_id], + workspace_id=workspace.id, + created_by_id=workspace.created_by_id, + ) + logger.info(f"Task: workspace_seed_task -> Issue {issue_id} created") return +def create_pages(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None: + """Creates pages for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + """ + page_seeds = read_seed_file("pages.json") + + if not page_seeds: + return + + for page_seed in page_seeds: + page_id = page_seed.pop("id") + + page = Page.objects.create( + workspace_id=workspace.id, + is_global=False, + access=page_seed.get("access", Page.PUBLIC_ACCESS), + name=page_seed.get("name"), + description=page_seed.get("description", {}), + description_html=page_seed.get("description_html", "

"), + description_binary=page_seed.get("description_binary", None), + description_stripped=page_seed.get("description_stripped", None), + created_by_id=workspace.created_by_id, + updated_by_id=workspace.created_by_id, + owned_by_id=workspace.created_by_id, + ) + + logger.info(f"Task: workspace_seed_task -> Page {page_id} created") + if page_seed.get("project_id") and page_seed.get("type") == "PROJECT": + ProjectPage.objects.create( + workspace_id=workspace.id, + project_id=project_map[page_seed.get("project_id")], + page_id=page.id, + created_by_id=workspace.created_by_id, + updated_by_id=workspace.created_by_id, + ) + + logger.info(f"Task: workspace_seed_task -> Project Page {page_id} created") + return + + +def create_cycles(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> Dict[int, uuid.UUID]: + # Create cycles + cycle_seeds = read_seed_file("cycles.json") + if not cycle_seeds: + return + + cycle_map: Dict[int, uuid.UUID] = {} + + for cycle_seed in cycle_seeds: + cycle_id = cycle_seed.pop("id") + project_id = cycle_seed.pop("project_id") + type = cycle_seed.pop("type") + + if type == "CURRENT": + start_date = timezone.now() + end_date = start_date + timedelta(days=14) + + if type == "UPCOMING": + # Get the last cycle + last_cycle = Cycle.objects.filter(project_id=project_map[project_id]).order_by("-end_date").first() + if last_cycle: + start_date = last_cycle.end_date + timedelta(days=1) + end_date = start_date + timedelta(days=14) + else: + start_date = timezone.now() + timedelta(days=14) + end_date = start_date + timedelta(days=14) + + cycle = Cycle.objects.create( + **cycle_seed, + start_date=start_date, + end_date=end_date, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + owned_by_id=workspace.created_by_id, + ) + + cycle_map[cycle_id] = cycle.id + logger.info(f"Task: workspace_seed_task -> Cycle {cycle_id} created") + return cycle_map + + +def create_modules(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None: + """Creates modules for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + """ + module_seeds = read_seed_file("modules.json") + if not module_seeds: + return + + module_map: Dict[int, uuid.UUID] = {} + + for index, module_seed in enumerate(module_seeds): + module_id = module_seed.pop("id") + project_id = module_seed.pop("project_id") + + start_date = timezone.now() + timedelta(days=index * 2) + end_date = start_date + timedelta(days=14) + + module = Module.objects.create( + **module_seed, + start_date=start_date, + target_date=end_date, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + ) + module_map[module_id] = module.id + logger.info(f"Task: workspace_seed_task -> Module {module_id} created") + return module_map + + +def create_views(workspace: Workspace, project_map: Dict[int, uuid.UUID]) -> None: + """Creates views for each project in the workspace. + + Args: + workspace: The workspace containing the projects + project_map: Mapping of seed project IDs to actual project IDs + """ + + view_seeds = read_seed_file("views.json") + if not view_seeds: + return + + for view_seed in view_seeds: + project_id = view_seed.pop("project_id") + IssueView.objects.create( + **view_seed, + project_id=project_map[project_id], + workspace=workspace, + created_by_id=workspace.created_by_id, + owned_by_id=workspace.created_by_id, + ) + + @shared_task def workspace_seed(workspace_id: uuid.UUID) -> None: """Seeds a new workspace with initial project data. @@ -305,15 +495,23 @@ def workspace_seed(workspace_id: uuid.UUID) -> None: # Create project labels label_map = create_project_labels(workspace, project_map) - # create project issues - create_project_issues(workspace, project_map, state_map, label_map) + # Create project cycles + cycle_map = create_cycles(workspace, project_map) - logger.info( - f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully" - ) + # Create project modules + module_map = create_modules(workspace, project_map) + + # create project issues + create_project_issues(workspace, project_map, state_map, label_map, cycle_map, module_map) + + # create project views + create_views(workspace, project_map) + + # create project pages + create_pages(workspace, project_map) + + logger.info(f"Task: workspace_seed_task -> Workspace {workspace_id} seeded successfully") return except Exception as e: - logger.error( - f"Task: workspace_seed_task -> Failed to seed workspace {workspace_id}: {str(e)}" - ) + logger.error(f"Task: workspace_seed_task -> Failed to seed workspace {workspace_id}: {str(e)}") raise e diff --git a/apps/api/plane/celery.py b/apps/api/plane/celery.py index 2eeac358c..828f4a6d5 100644 --- a/apps/api/plane/celery.py +++ b/apps/api/plane/celery.py @@ -55,15 +55,23 @@ app.conf.beat_schedule = { }, "check-every-day-to-delete-email-notification-logs": { "task": "plane.bgtasks.cleanup_task.delete_email_notification_logs", - "schedule": crontab(hour=3, minute=0), # UTC 03:00 + "schedule": crontab(hour=2, minute=45), # UTC 02:45 }, "check-every-day-to-delete-page-versions": { "task": "plane.bgtasks.cleanup_task.delete_page_versions", - "schedule": crontab(hour=3, minute=30), # UTC 03:30 + "schedule": crontab(hour=3, minute=0), # UTC 03:00 }, "check-every-day-to-delete-issue-description-versions": { "task": "plane.bgtasks.cleanup_task.delete_issue_description_versions", - "schedule": crontab(hour=4, minute=0), # UTC 04:00 + "schedule": crontab(hour=3, minute=15), # UTC 03:15 + }, + "check-every-day-to-delete-webhook-logs": { + "task": "plane.bgtasks.cleanup_task.delete_webhook_logs", + "schedule": crontab(hour=3, minute=30), # UTC 03:30 + }, + "check-every-day-to-delete-exporter-history": { + "task": "plane.bgtasks.exporter_expired_task.delete_old_s3_link", + "schedule": crontab(hour=3, minute=45), # UTC 03:45 }, } @@ -71,9 +79,7 @@ app.conf.beat_schedule = { # Setup logging @after_setup_logger.connect def setup_loggers(logger, *args, **kwargs): - formatter = JsonFormatter( - '"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s' - ) + formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s') handler = logging.StreamHandler() handler.setFormatter(fmt=formatter) logger.addHandler(handler) @@ -81,9 +87,7 @@ def setup_loggers(logger, *args, **kwargs): @after_setup_task_logger.connect def setup_task_loggers(logger, *args, **kwargs): - formatter = JsonFormatter( - '"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s' - ) + formatter = JsonFormatter('"%(levelname)s %(asctime)s %(module)s %(name)s %(message)s') handler = logging.StreamHandler() handler.setFormatter(fmt=formatter) logger.addHandler(handler) diff --git a/apps/api/plane/db/management/commands/activate_user.py b/apps/api/plane/db/management/commands/activate_user.py index 29123b4e5..5ebe8b740 100644 --- a/apps/api/plane/db/management/commands/activate_user.py +++ b/apps/api/plane/db/management/commands/activate_user.py @@ -31,4 +31,4 @@ class Command(BaseCommand): user.is_active = True user.save() - self.stdout.write(self.style.SUCCESS("User activated succesfully")) + self.stdout.write(self.style.SUCCESS("User activated successfully")) diff --git a/apps/api/plane/db/management/commands/clear_cache.py b/apps/api/plane/db/management/commands/clear_cache.py index c9189ca32..1c66b3eaf 100644 --- a/apps/api/plane/db/management/commands/clear_cache.py +++ b/apps/api/plane/db/management/commands/clear_cache.py @@ -14,9 +14,7 @@ class Command(BaseCommand): try: if options["key"]: cache.delete(options["key"]) - self.stdout.write( - self.style.SUCCESS(f"Cache Cleared for key: {options['key']}") - ) + self.stdout.write(self.style.SUCCESS(f"Cache Cleared for key: {options['key']}")) return cache.clear() diff --git a/apps/api/plane/db/management/commands/create_bucket.py b/apps/api/plane/db/management/commands/create_bucket.py index 838edd6c6..555fe0aa8 100644 --- a/apps/api/plane/db/management/commands/create_bucket.py +++ b/apps/api/plane/db/management/commands/create_bucket.py @@ -16,12 +16,8 @@ class Command(BaseCommand): s3_client = boto3.client( "s3", endpoint_url=os.environ.get("AWS_S3_ENDPOINT_URL"), # MinIO endpoint - aws_access_key_id=os.environ.get( - "AWS_ACCESS_KEY_ID" - ), # MinIO access key - aws_secret_access_key=os.environ.get( - "AWS_SECRET_ACCESS_KEY" - ), # MinIO secret key + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), # MinIO access key + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), # MinIO secret key region_name=os.environ.get("AWS_REGION"), # MinIO region config=boto3.session.Config(signature_version="s3v4"), ) @@ -38,32 +34,20 @@ class Command(BaseCommand): bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") if error_code == 404: # Bucket does not exist, create it - self.stdout.write( - self.style.WARNING( - f"Bucket '{bucket_name}' does not exist. Creating bucket..." - ) - ) + self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket...")) try: s3_client.create_bucket(Bucket=bucket_name) - self.stdout.write( - self.style.SUCCESS( - f"Bucket '{bucket_name}' created successfully." - ) - ) + self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' created successfully.")) # Handle the exception if the bucket creation fails except ClientError as create_error: - self.stdout.write( - self.style.ERROR(f"Failed to create bucket: {create_error}") - ) + self.stdout.write(self.style.ERROR(f"Failed to create bucket: {create_error}")) # Handle the exception if access to the bucket is forbidden elif error_code == 403: # Access to the bucket is forbidden self.stdout.write( - self.style.ERROR( - f"Access to the bucket '{bucket_name}' is forbidden. Check permissions." - ) + self.style.ERROR(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.") ) else: # Another ClientError occurred diff --git a/apps/api/plane/db/management/commands/create_dummy_data.py b/apps/api/plane/db/management/commands/create_dummy_data.py index 0915cd9d8..220576b8f 100644 --- a/apps/api/plane/db/management/commands/create_dummy_data.py +++ b/apps/api/plane/db/management/commands/create_dummy_data.py @@ -23,27 +23,20 @@ class Command(BaseCommand): creator = input("Your email: ") if creator == "" or not User.objects.filter(email=creator).exists(): - raise CommandError( - "User email is required and should have signed in plane" - ) + raise CommandError("User email is required and should have signed in plane") user = User.objects.get(email=creator) members = input("Enter Member emails (comma separated): ") members = members.split(",") if members != "" else [] # Create workspace - workspace = Workspace.objects.create( - slug=workspace_slug, name=workspace_name, owner=user - ) + workspace = Workspace.objects.create(slug=workspace_slug, name=workspace_name, owner=user) # Create workspace member WorkspaceMember.objects.create(workspace=workspace, role=20, member=user) user_ids = User.objects.filter(email__in=members) _ = WorkspaceMember.objects.bulk_create( - [ - WorkspaceMember(workspace=workspace, member=user_id, role=20) - for user_id in user_ids - ], + [WorkspaceMember(workspace=workspace, member=user_id, role=20) for user_id in user_ids], ignore_conflicts=True, ) @@ -55,9 +48,7 @@ class Command(BaseCommand): cycle_count = int(input("Number of cycles to be created: ")) module_count = int(input("Number of modules to be created: ")) pages_count = int(input("Number of pages to be created: ")) - intake_issue_count = int( - input("Number of intake issues to be created: ") - ) + intake_issue_count = int(input("Number of intake issues to be created: ")) from plane.bgtasks.dummy_data_task import create_dummy_data diff --git a/apps/api/plane/db/management/commands/create_instance_admin.py b/apps/api/plane/db/management/commands/create_instance_admin.py index 8b957f7fc..8d5a912e0 100644 --- a/apps/api/plane/db/management/commands/create_instance_admin.py +++ b/apps/api/plane/db/management/commands/create_instance_admin.py @@ -28,9 +28,7 @@ class Command(BaseCommand): instance = Instance.objects.last() # Get or create an instance admin - _, created = InstanceAdmin.objects.get_or_create( - user=user, instance=instance, role=20 - ) + _, created = InstanceAdmin.objects.get_or_create(user=user, instance=instance, role=20) if not created: raise CommandError("The provided email is already an instance admin.") diff --git a/apps/api/plane/db/management/commands/create_project_member.py b/apps/api/plane/db/management/commands/create_project_member.py index 927f97e9d..d9b46524c 100644 --- a/apps/api/plane/db/management/commands/create_project_member.py +++ b/apps/api/plane/db/management/commands/create_project_member.py @@ -19,9 +19,7 @@ class Command(BaseCommand): # 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( - "--role", type=int, nargs="?", help="Role of the user in the project" - ) + parser.add_argument("--role", type=int, nargs="?", help="Role of the user in the project") def handle(self, *args: Any, **options: Any): try: @@ -46,16 +44,12 @@ class Command(BaseCommand): raise CommandError("Project not found") # Check if the user exists in the workspace - if not WorkspaceMember.objects.filter( - workspace=project.workspace, member=user, is_active=True - ).exists(): + if not WorkspaceMember.objects.filter(workspace=project.workspace, member=user, is_active=True).exists(): raise CommandError("User not member in workspace") # Get the smallest sort order smallest_sort_order = ( - ProjectMember.objects.filter(workspace_id=project.workspace_id) - .order_by("sort_order") - .first() + ProjectMember.objects.filter(workspace_id=project.workspace_id).order_by("sort_order").first() ) if smallest_sort_order: @@ -70,17 +64,13 @@ class Command(BaseCommand): ) else: # Create the project member - ProjectMember.objects.create( - project=project, member=user, role=role, sort_order=sort_order - ) + ProjectMember.objects.create(project=project, member=user, role=role, sort_order=sort_order) # Issue Property IssueUserProperty.objects.get_or_create(user=user, project=project) # Success message - self.stdout.write( - self.style.SUCCESS(f"User {user_email} added to project {project_id}") - ) + self.stdout.write(self.style.SUCCESS(f"User {user_email} added to project {project_id}")) return except CommandError as e: self.stdout.write(self.style.ERROR(e)) diff --git a/apps/api/plane/db/management/commands/fix_duplicate_sequences.py b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py index 1bf4d4452..2b262606a 100644 --- a/apps/api/plane/db/management/commands/fix_duplicate_sequences.py +++ b/apps/api/plane/db/management/commands/fix_duplicate_sequences.py @@ -43,23 +43,15 @@ class Command(BaseCommand): issue_sequence = self.strict_str_to_int(identifier[1]) # Fetch the project - project = Project.objects.get( - identifier__iexact=project_identifier, workspace__slug=workspace_slug - ) + project = Project.objects.get(identifier__iexact=project_identifier, workspace__slug=workspace_slug) # Get the issues issues = Issue.objects.filter(project=project, sequence_id=issue_sequence) # Check if there are duplicate issues if not issues.count() > 1: - raise CommandError( - "No duplicate issues found with the given identifier" - ) + raise CommandError("No duplicate issues found with the given identifier") - self.stdout.write( - self.style.SUCCESS( - f"{issues.count()} issues found with identifier {issue_identifier}" - ) - ) + self.stdout.write(self.style.SUCCESS(f"{issues.count()} issues found with identifier {issue_identifier}")) with transaction.atomic(): # This ensures only one transaction per project can execute this code at a time lock_key = convert_uuid_to_integer(project.id) @@ -70,17 +62,14 @@ class Command(BaseCommand): cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) # Get the maximum sequence ID for the project - last_sequence = IssueSequence.objects.filter(project=project).aggregate( - largest=Max("sequence") - )["largest"] + last_sequence = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))[ + "largest" + ] bulk_issues = [] bulk_issue_sequences = [] - issue_sequence_map = { - isq.issue_id: isq - for isq in IssueSequence.objects.filter(project=project) - } + issue_sequence_map = {isq.issue_id: isq for isq in IssueSequence.objects.filter(project=project)} # change the ids of duplicate issues for index, issue in enumerate(issues[1:]): diff --git a/apps/api/plane/db/management/commands/reset_password.py b/apps/api/plane/db/management/commands/reset_password.py index 8ec472bfa..9e483f51e 100644 --- a/apps/api/plane/db/management/commands/reset_password.py +++ b/apps/api/plane/db/management/commands/reset_password.py @@ -59,4 +59,4 @@ class Command(BaseCommand): user.is_password_autoset = False user.save() - self.stdout.write(self.style.SUCCESS("User password updated succesfully")) + self.stdout.write(self.style.SUCCESS("User password updated successfully")) diff --git a/apps/api/plane/db/management/commands/sync_issue_description_version.py b/apps/api/plane/db/management/commands/sync_issue_description_version.py index 7ff2fc391..04e608a3c 100644 --- a/apps/api/plane/db/management/commands/sync_issue_description_version.py +++ b/apps/api/plane/db/management/commands/sync_issue_description_version.py @@ -14,10 +14,6 @@ class Command(BaseCommand): 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) - ) + 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") - ) + self.stdout.write(self.style.SUCCESS("Successfully created issue description version task")) diff --git a/apps/api/plane/db/management/commands/sync_issue_version.py b/apps/api/plane/db/management/commands/sync_issue_version.py index 2b6632f26..6c9a2cdac 100644 --- a/apps/api/plane/db/management/commands/sync_issue_version.py +++ b/apps/api/plane/db/management/commands/sync_issue_version.py @@ -12,8 +12,6 @@ class Command(BaseCommand): 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) - ) + 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/apps/api/plane/db/management/commands/test_email.py b/apps/api/plane/db/management/commands/test_email.py index 2ed20eeb3..22841a671 100644 --- a/apps/api/plane/db/management/commands/test_email.py +++ b/apps/api/plane/db/management/commands/test_email.py @@ -60,6 +60,4 @@ class Command(BaseCommand): msg.send() self.stdout.write(self.style.SUCCESS("Email successfully sent")) except Exception as e: - self.stdout.write( - self.style.ERROR(f"Error: Email could not be delivered due to {e}") - ) + self.stdout.write(self.style.ERROR(f"Error: Email could not be delivered due to {e}")) diff --git a/apps/api/plane/db/management/commands/update_bucket.py b/apps/api/plane/db/management/commands/update_bucket.py index 27eb5c83e..47c28ff73 100644 --- a/apps/api/plane/db/management/commands/update_bucket.py +++ b/apps/api/plane/db/management/commands/update_bucket.py @@ -16,9 +16,7 @@ class Command(BaseCommand): "s3", endpoint_url=os.environ.get("AWS_S3_ENDPOINT_URL"), # MinIO endpoint aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), # MinIO access key - aws_secret_access_key=os.environ.get( - "AWS_SECRET_ACCESS_KEY" - ), # MinIO secret key + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), # MinIO secret key region_name=os.environ.get("AWS_REGION"), # MinIO region config=boto3.session.Config(signature_version="s3v4"), ) @@ -59,9 +57,7 @@ class Command(BaseCommand): # 3. Test s3:PutObject (attempt to upload an object) try: - s3_client.put_object( - Bucket=bucket_name, Key="test_permission_check.txt", Body=b"Test" - ) + s3_client.put_object(Bucket=bucket_name, Key="test_permission_check.txt", Body=b"Test") permissions["s3:PutObject"] = True # Clean up except ClientError as e: @@ -74,7 +70,7 @@ class Command(BaseCommand): try: s3_client.delete_object(Bucket=bucket_name, Key="test_permission_check.txt") except ClientError: - self.stdout.write("Coudn't delete test object") + self.stdout.write("Couldn't delete test object") # 4. Test s3:PutBucketPolicy (attempt to put a bucket policy) try: @@ -106,9 +102,7 @@ class Command(BaseCommand): if "Contents" in response: for obj in response["Contents"]: object_key = obj["Key"] - public_object_resource.append( - f"arn:aws:s3:::{bucket_name}/{object_key}" - ) + public_object_resource.append(f"arn:aws:s3:::{bucket_name}/{object_key}") bucket_policy = { "Version": "2012-10-17", "Statement": [ @@ -128,9 +122,7 @@ class Command(BaseCommand): # Get the bucket policy bucket_policy = self.generate_bucket_policy(bucket_name) # Apply the policy to the bucket - s3_client.put_bucket_policy( - Bucket=bucket_name, Policy=json.dumps(bucket_policy) - ) + s3_client.put_bucket_policy(Bucket=bucket_name, Policy=json.dumps(bucket_policy)) # Print a success message self.stdout.write("Bucket is private, but existing objects remain public.") return @@ -144,11 +136,7 @@ class Command(BaseCommand): bucket_name = os.environ.get("AWS_S3_BUCKET_NAME") if not bucket_name: - self.stdout.write( - self.style.ERROR( - "Please set the AWS_S3_BUCKET_NAME environment variable." - ) - ) + self.stdout.write(self.style.ERROR("Please set the AWS_S3_BUCKET_NAME environment variable.")) return self.stdout.write(self.style.NOTICE("Checking bucket...")) @@ -158,9 +146,7 @@ class Command(BaseCommand): except ClientError as e: error_code = e.response["Error"]["Code"] if error_code == "404": - self.stdout.write( - self.style.ERROR(f"Bucket '{bucket_name}' does not exist.") - ) + self.stdout.write(self.style.ERROR(f"Bucket '{bucket_name}' does not exist.")) return else: self.stdout.write(f"Error: {e}") @@ -177,9 +163,7 @@ class Command(BaseCommand): # If the access key has the required permissions try: if all(permissions.values()): - self.stdout.write( - self.style.SUCCESS("Access key has the required permissions.") - ) + self.stdout.write(self.style.SUCCESS("Access key has the required permissions.")) # Making the existing objects public self.make_objects_public(bucket_name) return @@ -187,18 +171,12 @@ class Command(BaseCommand): self.stdout.write(f"Error: {e}") # write the bucket policy to a file - self.stdout.write( - self.style.WARNING( - "Generating permissions.json for manual bucket policy update." - ) - ) + self.stdout.write(self.style.WARNING("Generating permissions.json for manual bucket policy update.")) try: # Writing to a file with open("permissions.json", "w") as f: f.write(json.dumps(self.generate_bucket_policy(bucket_name))) - self.stdout.write( - self.style.WARNING("Permissions have been written to permissions.json.") - ) + self.stdout.write(self.style.WARNING("Permissions have been written to permissions.json.")) return except IOError as e: self.stdout.write(f"Error writing permissions.json: {e}") diff --git a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py index f4a9285ee..838325354 100644 --- a/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py +++ b/apps/api/plane/db/management/commands/update_deleted_workspace_slug.py @@ -4,9 +4,7 @@ from plane.db.models import Workspace class Command(BaseCommand): - help = ( - "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" - ) + help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp" def add_arguments(self, parser): parser.add_argument( @@ -28,17 +26,13 @@ class Command(BaseCommand): try: workspace = Workspace.all_objects.get(slug=slug) except Workspace.DoesNotExist: - self.stdout.write( - self.style.ERROR(f"Workspace with slug '{slug}' not found.") - ) + self.stdout.write(self.style.ERROR(f"Workspace with slug '{slug}' not found.")) return # Check if the workspace is soft-deleted if workspace.deleted_at is None: self.stdout.write( - self.style.WARNING( - f"Workspace '{workspace.name}' (slug: {workspace.slug}) is not deleted." - ) + self.style.WARNING(f"Workspace '{workspace.name}' (slug: {workspace.slug}) is not deleted.") ) return @@ -58,9 +52,7 @@ class Command(BaseCommand): new_slug = f"{workspace.slug}__{deletion_timestamp}" if dry_run: - self.stdout.write( - f"Would update workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'" - ) + self.stdout.write(f"Would update workspace '{workspace.name}' slug from '{workspace.slug}' to '{new_slug}'") else: try: with transaction.atomic(): @@ -72,8 +64,4 @@ class Command(BaseCommand): ) ) except Exception as e: - self.stdout.write( - self.style.ERROR( - f"Error updating workspace '{workspace.name}': {str(e)}" - ) - ) + self.stdout.write(self.style.ERROR(f"Error updating workspace '{workspace.name}': {str(e)}")) diff --git a/apps/api/plane/db/management/commands/wait_for_migrations.py b/apps/api/plane/db/management/commands/wait_for_migrations.py index 91c8a4ce8..13b251de5 100644 --- a/apps/api/plane/db/management/commands/wait_for_migrations.py +++ b/apps/api/plane/db/management/commands/wait_for_migrations.py @@ -13,9 +13,7 @@ class Command(BaseCommand): self.stdout.write("Waiting for database migrations to complete...") time.sleep(10) # wait for 10 seconds before checking again - self.stdout.write( - self.style.SUCCESS("No migrations Pending. Starting processes ...") - ) + self.stdout.write(self.style.SUCCESS("No migrations Pending. Starting processes ...")) def _pending_migrations(self): connection = connections[DEFAULT_DB_ALIAS] diff --git a/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py new file mode 100644 index 000000000..ef477fbc1 --- /dev/null +++ b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.22 on 2025-09-10 09:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0104_cycleuserproperties_rich_filters_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="cycle_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="issue_views_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="module_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="session", + name="user_id", + field=models.CharField(db_index=True, max_length=50, null=True), + ), + ] diff --git a/apps/api/plane/db/migrations/0106_auto_20250912_0845.py b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py new file mode 100644 index 000000000..8a0813fc1 --- /dev/null +++ b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py @@ -0,0 +1,152 @@ +# Generated by Django 4.2.22 on 2025-09-12 08:45 +import uuid +import django +from django.conf import settings +from django.db import migrations, models + + +def set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + + batch_size = 3000 + sort_order = 100 + + # Get page IDs ordered by name using the historical model + # This should include all pages regardless of soft-delete status + page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True)) + + updated_pages = [] + for page_id in page_ids: + # Create page instance with minimal data + updated_pages.append(Page(id=page_id, sort_order=sort_order)) + sort_order += 100 + + # Bulk update when batch is full + if len(updated_pages) >= batch_size: + Page.objects.bulk_update( + updated_pages, ["sort_order"], batch_size=batch_size + ) + updated_pages = [] + + # Update remaining pages + if updated_pages: + Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size) + + +def reverse_set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0105_alter_project_cycle_view_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectWebhook", + 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, + ), + ), + ( + "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", + ), + ), + ( + "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", + ), + ), + ( + "webhook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_webhooks", + to="db.webhook", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Webhook", + "verbose_name_plural": "Project Webhooks", + "db_table": "project_webhooks", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="projectwebhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "webhook"), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="projectwebhook", + unique_together={("project", "webhook", "deleted_at")}, + ), + migrations.AlterField( + model_name="issuerelation", + name="relation_type", + field=models.CharField( + default="blocked_by", max_length=20, verbose_name="Issue Relation Type" + ), + ), + migrations.RunPython( + set_page_sort_order, reverse_code=reverse_set_page_sort_order + ), + ] diff --git a/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py b/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py new file mode 100644 index 000000000..3048dd86f --- /dev/null +++ b/apps/api/plane/db/migrations/0107_migrate_filters_to_rich_filters.py @@ -0,0 +1,74 @@ +from django.db import migrations + +from plane.utils.filters import LegacyToRichFiltersConverter +from plane.utils.filters.filter_migrations import ( + migrate_models_filters_to_rich_filters, + clear_models_rich_filters, +) + + +# Define all models that need migration in one place +MODEL_NAMES = [ + "IssueView", + "WorkspaceUserProperties", + "ModuleUserProperties", + "IssueUserProperty", + "CycleUserProperties", +] + + +def migrate_filters_to_rich_filters(apps, schema_editor): + """ + Migrate legacy filters to rich_filters format for all models that have both fields. + """ + # Get the model classes from model names + models_to_migrate = {} + + for model_name in MODEL_NAMES: + try: + model_class = apps.get_model("db", model_name) + models_to_migrate[model_name] = model_class + except Exception as e: + # Log error but continue with other models + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to get model {model_name}: {str(e)}") + + converter = LegacyToRichFiltersConverter() + # Migrate all models + migrate_models_filters_to_rich_filters(models_to_migrate, converter) + + +def reverse_migrate_rich_filters_to_filters(apps, schema_editor): + """ + Reverse migration to clear rich_filters field for all models. + """ + # Get the model classes from model names + models_to_clear = {} + + for model_name in MODEL_NAMES: + try: + model_class = apps.get_model("db", model_name) + models_to_clear[model_name] = model_class + except Exception as e: + # Log error but continue with other models + import logging + logger = logging.getLogger(__name__) + logger.error(f"Failed to get model {model_name}: {str(e)}") + + # Clear rich_filters for all models + clear_models_rich_filters(models_to_clear) + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0106_auto_20250912_0845'), + ] + + operations = [ + migrations.RunPython( + migrate_filters_to_rich_filters, + reverse_code=reverse_migrate_rich_filters_to_filters, + ), + ] \ No newline at end of file diff --git a/apps/api/plane/db/mixins.py b/apps/api/plane/db/mixins.py index b198de121..ca3c9a2d3 100644 --- a/apps/api/plane/db/mixins.py +++ b/apps/api/plane/db/mixins.py @@ -48,9 +48,7 @@ class SoftDeletionQuerySet(models.QuerySet): class SoftDeletionManager(models.Manager): def get_queryset(self): - return SoftDeletionQuerySet(self.model, using=self._db).filter( - deleted_at__isnull=True - ) + return SoftDeletionQuerySet(self.model, using=self._db).filter(deleted_at__isnull=True) class SoftDeleteModel(models.Model): @@ -70,9 +68,7 @@ class SoftDeleteModel(models.Model): self.deleted_at = timezone.now() self.save(using=using) - soft_delete_related_objects.delay( - self._meta.app_label, self._meta.model_name, self.pk, using=using - ) + soft_delete_related_objects.delay(self._meta.app_label, self._meta.model_name, self.pk, using=using) else: # Perform hard delete if soft deletion is not enabled diff --git a/apps/api/plane/db/models/__init__.py b/apps/api/plane/db/models/__init__.py index de8af54e4..fcf77b936 100644 --- a/apps/api/plane/db/models/__init__.py +++ b/apps/api/plane/db/models/__init__.py @@ -84,4 +84,4 @@ from .device import Device, DeviceSession from .sticky import Sticky -from .description import Description, DescriptionVersion \ No newline at end of file +from .description import Description, DescriptionVersion diff --git a/apps/api/plane/db/models/analytic.py b/apps/api/plane/db/models/analytic.py index 68747e8c4..0efcb957f 100644 --- a/apps/api/plane/db/models/analytic.py +++ b/apps/api/plane/db/models/analytic.py @@ -5,9 +5,7 @@ from .base import BaseModel class AnalyticView(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", related_name="analytics", on_delete=models.CASCADE - ) + workspace = models.ForeignKey("db.Workspace", related_name="analytics", on_delete=models.CASCADE) name = models.CharField(max_length=255) description = models.TextField(blank=True) query = models.JSONField() diff --git a/apps/api/plane/db/models/api.py b/apps/api/plane/db/models/api.py index 01be8e643..7d040ebc2 100644 --- a/apps/api/plane/db/models/api.py +++ b/apps/api/plane/db/models/api.py @@ -24,20 +24,12 @@ class APIToken(BaseModel): last_used = models.DateTimeField(null=True) # Token - token = models.CharField( - max_length=255, unique=True, default=generate_token, db_index=True - ) + token = models.CharField(max_length=255, unique=True, default=generate_token, db_index=True) # User Information - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bot_tokens" - ) - user_type = models.PositiveSmallIntegerField( - choices=((0, "Human"), (1, "Bot")), default=0 - ) - workspace = models.ForeignKey( - "db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True - ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bot_tokens") + user_type = models.PositiveSmallIntegerField(choices=((0, "Human"), (1, "Bot")), default=0) + workspace = models.ForeignKey("db.Workspace", related_name="api_tokens", on_delete=models.CASCADE, null=True) expired_at = models.DateTimeField(blank=True, null=True) is_service = models.BooleanField(default=False) diff --git a/apps/api/plane/db/models/asset.py b/apps/api/plane/db/models/asset.py index 965262482..1de0f18b4 100644 --- a/apps/api/plane/db/models/asset.py +++ b/apps/api/plane/db/models/asset.py @@ -40,27 +40,13 @@ class FileAsset(BaseModel): attributes = models.JSONField(default=dict) asset = models.FileField(upload_to=get_upload_path, max_length=800) - user = models.ForeignKey( - "db.User", on_delete=models.CASCADE, null=True, related_name="assets" - ) - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets" - ) - draft_issue = models.ForeignKey( - "db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets" - ) - project = models.ForeignKey( - "db.Project", on_delete=models.CASCADE, null=True, related_name="assets" - ) - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, null=True, related_name="assets" - ) - comment = models.ForeignKey( - "db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets" - ) - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, null=True, related_name="assets" - ) + user = models.ForeignKey("db.User", on_delete=models.CASCADE, null=True, related_name="assets") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets") + draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets") + project = models.ForeignKey("db.Project", on_delete=models.CASCADE, null=True, related_name="assets") + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, null=True, related_name="assets") + comment = models.ForeignKey("db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets") + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, null=True, related_name="assets") entity_type = models.CharField(max_length=255, null=True, blank=True) entity_identifier = models.CharField(max_length=255, null=True, blank=True) is_deleted = models.BooleanField(default=False) @@ -78,12 +64,8 @@ class FileAsset(BaseModel): ordering = ("-created_at",) indexes = [ models.Index(fields=["entity_type"], name="asset_entity_type_idx"), - models.Index( - fields=["entity_identifier"], name="asset_entity_identifier_idx" - ), - models.Index( - fields=["entity_type", "entity_identifier"], name="asset_entity_idx" - ), + models.Index(fields=["entity_identifier"], name="asset_entity_identifier_idx"), + models.Index(fields=["entity_type", "entity_identifier"], name="asset_entity_idx"), ] def __str__(self): @@ -100,7 +82,7 @@ class FileAsset(BaseModel): return f"/api/assets/v2/static/{self.id}/" if self.entity_type == self.EntityTypeContext.ISSUE_ATTACHMENT: - return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/issues/{self.issue_id}/attachments/{self.id}/" + return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/issues/{self.issue_id}/attachments/{self.id}/" # noqa: E501 if self.entity_type in [ self.EntityTypeContext.ISSUE_DESCRIPTION, diff --git a/apps/api/plane/db/models/base.py b/apps/api/plane/db/models/base.py index 558c25a40..468af8261 100644 --- a/apps/api/plane/db/models/base.py +++ b/apps/api/plane/db/models/base.py @@ -11,9 +11,7 @@ from ..mixins import AuditModel class BaseModel(AuditModel): - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) class Meta: abstract = True diff --git a/apps/api/plane/db/models/cycle.py b/apps/api/plane/db/models/cycle.py index 9e45028c5..bdffd283d 100644 --- a/apps/api/plane/db/models/cycle.py +++ b/apps/api/plane/db/models/cycle.py @@ -102,12 +102,8 @@ class CycleIssue(ProjectBaseModel): Cycle Issues """ - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="issue_cycle" - ) - cycle = models.ForeignKey( - Cycle, on_delete=models.CASCADE, related_name="issue_cycle" - ) + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_cycle") + cycle = models.ForeignKey(Cycle, on_delete=models.CASCADE, related_name="issue_cycle") class Meta: unique_together = ["issue", "cycle", "deleted_at"] @@ -128,9 +124,7 @@ class CycleIssue(ProjectBaseModel): class CycleUserProperties(ProjectBaseModel): - cycle = models.ForeignKey( - "db.Cycle", on_delete=models.CASCADE, related_name="cycle_user_properties" - ) + cycle = models.ForeignKey("db.Cycle", on_delete=models.CASCADE, related_name="cycle_user_properties") user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, diff --git a/apps/api/plane/db/models/deploy_board.py b/apps/api/plane/db/models/deploy_board.py index f053f4a82..da9c0d698 100644 --- a/apps/api/plane/db/models/deploy_board.py +++ b/apps/api/plane/db/models/deploy_board.py @@ -25,14 +25,10 @@ class DeployBoard(WorkspaceBaseModel): entity_identifier = models.UUIDField(null=True) entity_name = models.CharField(max_length=30, null=True, blank=True) - anchor = models.CharField( - max_length=255, default=get_anchor, unique=True, db_index=True - ) + anchor = models.CharField(max_length=255, default=get_anchor, unique=True, db_index=True) is_comments_enabled = models.BooleanField(default=False) is_reactions_enabled = models.BooleanField(default=False) - intake = models.ForeignKey( - "db.Intake", related_name="publish_intake", on_delete=models.SET_NULL, null=True - ) + intake = models.ForeignKey("db.Intake", related_name="publish_intake", on_delete=models.SET_NULL, null=True) is_votes_enabled = models.BooleanField(default=False) view_props = models.JSONField(default=dict) is_activity_enabled = models.BooleanField(default=True) diff --git a/apps/api/plane/db/models/description.py b/apps/api/plane/db/models/description.py index 24c15d395..6c298546a 100644 --- a/apps/api/plane/db/models/description.py +++ b/apps/api/plane/db/models/description.py @@ -4,8 +4,6 @@ from .workspace import WorkspaceBaseModel class Description(WorkspaceBaseModel): - - description_json = models.JSONField(default=dict, blank=True) description_html = models.TextField(blank=True, default="

") description_binary = models.BinaryField(null=True) @@ -32,9 +30,7 @@ class DescriptionVersion(WorkspaceBaseModel): DescriptionVersion is a model used to store historical versions of a Description. """ - description = models.ForeignKey( - "db.Description", on_delete=models.CASCADE, related_name="versions" - ) + description = models.ForeignKey("db.Description", on_delete=models.CASCADE, related_name="versions") description_json = models.JSONField(default=dict, blank=True) description_html = models.TextField(blank=True, default="

") description_binary = models.BinaryField(null=True) diff --git a/apps/api/plane/db/models/device.py b/apps/api/plane/db/models/device.py index 055d8ccc4..adcf7974a 100644 --- a/apps/api/plane/db/models/device.py +++ b/apps/api/plane/db/models/device.py @@ -11,9 +11,7 @@ class Device(BaseModel): WEB = "WEB", "Web" DESKTOP = "DESKTOP", "Desktop" - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="devices" - ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="devices") device_id = models.CharField(max_length=255, blank=True, null=True) device_type = models.CharField(max_length=255, choices=DeviceType.choices) push_token = models.CharField(max_length=255, blank=True, null=True) @@ -26,12 +24,8 @@ class Device(BaseModel): class DeviceSession(BaseModel): - device = models.ForeignKey( - Device, on_delete=models.CASCADE, related_name="sessions" - ) - session = models.ForeignKey( - "db.Session", on_delete=models.CASCADE, related_name="device_sessions" - ) + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="sessions") + session = models.ForeignKey("db.Session", on_delete=models.CASCADE, related_name="device_sessions") is_active = models.BooleanField(default=True) user_agent = models.CharField(max_length=255, null=True, blank=True) ip_address = models.GenericIPAddressField(null=True, blank=True) diff --git a/apps/api/plane/db/models/draft.py b/apps/api/plane/db/models/draft.py index 42148d5bb..55dbb61df 100644 --- a/apps/api/plane/db/models/draft.py +++ b/apps/api/plane/db/models/draft.py @@ -38,9 +38,7 @@ class DraftIssue(WorkspaceBaseModel): null=True, blank=True, ) - name = models.CharField( - max_length=255, verbose_name="Issue Name", blank=True, null=True - ) + name = models.CharField(max_length=255, verbose_name="Issue Name", blank=True, null=True) description = models.JSONField(blank=True, default=dict) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) @@ -60,9 +58,7 @@ class DraftIssue(WorkspaceBaseModel): through="DraftIssueAssignee", through_fields=("draft_issue", "assignee"), ) - labels = models.ManyToManyField( - "db.Label", blank=True, related_name="draft_labels", through="DraftIssueLabel" - ) + labels = models.ManyToManyField("db.Label", blank=True, related_name="draft_labels", through="DraftIssueLabel") sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) external_source = models.CharField(max_length=255, null=True, blank=True) @@ -90,9 +86,7 @@ class DraftIssue(WorkspaceBaseModel): ~models.Q(is_triage=True), project=self.project, default=True ).first() if default_state is None: - random_state = State.objects.filter( - ~models.Q(is_triage=True), project=self.project - ).first() + random_state = State.objects.filter(~models.Q(is_triage=True), project=self.project).first() self.state = random_state else: self.state = default_state @@ -116,9 +110,9 @@ class DraftIssue(WorkspaceBaseModel): if (self.description_html == "" or self.description_html is None) else strip_tags(self.description_html) ) - largest_sort_order = DraftIssue.objects.filter( - project=self.project, state=self.state - ).aggregate(largest=models.Max("sort_order"))["largest"] + largest_sort_order = DraftIssue.objects.filter(project=self.project, state=self.state).aggregate( + largest=models.Max("sort_order") + )["largest"] if largest_sort_order is not None: self.sort_order = largest_sort_order + 10000 @@ -139,9 +133,7 @@ class DraftIssue(WorkspaceBaseModel): class DraftIssueAssignee(WorkspaceBaseModel): - draft_issue = models.ForeignKey( - DraftIssue, on_delete=models.CASCADE, related_name="draft_issue_assignee" - ) + draft_issue = models.ForeignKey(DraftIssue, on_delete=models.CASCADE, related_name="draft_issue_assignee") assignee = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -167,12 +159,8 @@ class DraftIssueAssignee(WorkspaceBaseModel): class DraftIssueLabel(WorkspaceBaseModel): - draft_issue = models.ForeignKey( - "db.DraftIssue", on_delete=models.CASCADE, related_name="draft_label_issue" - ) - label = models.ForeignKey( - "db.Label", on_delete=models.CASCADE, related_name="draft_label_issue" - ) + draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, related_name="draft_label_issue") + label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="draft_label_issue") class Meta: verbose_name = "Draft Issue Label" @@ -185,12 +173,8 @@ class DraftIssueLabel(WorkspaceBaseModel): class DraftIssueModule(WorkspaceBaseModel): - module = models.ForeignKey( - "db.Module", on_delete=models.CASCADE, related_name="draft_issue_module" - ) - draft_issue = models.ForeignKey( - "db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_module" - ) + module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="draft_issue_module") + draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_module") class Meta: unique_together = ["draft_issue", "module", "deleted_at"] @@ -215,12 +199,8 @@ class DraftIssueCycle(WorkspaceBaseModel): Draft Issue Cycles """ - draft_issue = models.ForeignKey( - "db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_cycle" - ) - cycle = models.ForeignKey( - "db.Cycle", on_delete=models.CASCADE, related_name="draft_issue_cycle" - ) + draft_issue = models.ForeignKey("db.DraftIssue", on_delete=models.CASCADE, related_name="draft_issue_cycle") + cycle = models.ForeignKey("db.Cycle", on_delete=models.CASCADE, related_name="draft_issue_cycle") class Meta: unique_together = ["draft_issue", "cycle", "deleted_at"] diff --git a/apps/api/plane/db/models/estimate.py b/apps/api/plane/db/models/estimate.py index b0097562d..9373fb320 100644 --- a/apps/api/plane/db/models/estimate.py +++ b/apps/api/plane/db/models/estimate.py @@ -33,12 +33,8 @@ class Estimate(ProjectBaseModel): class EstimatePoint(ProjectBaseModel): - estimate = models.ForeignKey( - "db.Estimate", on_delete=models.CASCADE, related_name="points" - ) - key = models.IntegerField( - default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] - ) + estimate = models.ForeignKey("db.Estimate", on_delete=models.CASCADE, related_name="points") + key = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) description = models.TextField(blank=True) value = models.CharField(max_length=255) diff --git a/apps/api/plane/db/models/exporter.py b/apps/api/plane/db/models/exporter.py index 40c13576d..8ad9daad7 100644 --- a/apps/api/plane/db/models/exporter.py +++ b/apps/api/plane/db/models/exporter.py @@ -18,9 +18,7 @@ def generate_token(): class ExporterHistory(BaseModel): - name = models.CharField( - max_length=255, verbose_name="Exporter Name", null=True, blank=True - ) + name = models.CharField(max_length=255, verbose_name="Exporter Name", null=True, blank=True) type = models.CharField( max_length=50, default="issue_exports", @@ -29,13 +27,9 @@ class ExporterHistory(BaseModel): ("issue_worklogs", "Issue Worklogs"), ), ) - workspace = models.ForeignKey( - "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters" - ) + workspace = models.ForeignKey("db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_exporters") project = ArrayField(models.UUIDField(default=uuid.uuid4), blank=True, null=True) - provider = models.CharField( - max_length=50, choices=(("json", "json"), ("csv", "csv"), ("xlsx", "xlsx")) - ) + provider = models.CharField(max_length=50, choices=(("json", "json"), ("csv", "csv"), ("xlsx", "xlsx"))) status = models.CharField( max_length=50, choices=( diff --git a/apps/api/plane/db/models/favorite.py b/apps/api/plane/db/models/favorite.py index 165072088..de2b101a0 100644 --- a/apps/api/plane/db/models/favorite.py +++ b/apps/api/plane/db/models/favorite.py @@ -12,9 +12,7 @@ class UserFavorite(WorkspaceBaseModel): UserFavorite (model): To store all the favorites of the user """ - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="favorites" - ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="favorites") entity_type = models.CharField(max_length=100) entity_identifier = models.UUIDField(null=True, blank=True) name = models.CharField(max_length=255, blank=True, null=True) @@ -43,24 +41,20 @@ class UserFavorite(WorkspaceBaseModel): ordering = ("-created_at",) indexes = [ models.Index(fields=["entity_type"], name="fav_entity_type_idx"), - models.Index( - fields=["entity_identifier"], name="fav_entity_identifier_idx" - ), - models.Index( - fields=["entity_type", "entity_identifier"], name="fav_entity_idx" - ), + models.Index(fields=["entity_identifier"], name="fav_entity_identifier_idx"), + models.Index(fields=["entity_type", "entity_identifier"], name="fav_entity_idx"), ] def save(self, *args, **kwargs): if self._state.adding: if self.project: - largest_sequence = UserFavorite.objects.filter( - workspace=self.project.workspace - ).aggregate(largest=models.Max("sequence"))["largest"] + largest_sequence = UserFavorite.objects.filter(workspace=self.project.workspace).aggregate( + largest=models.Max("sequence") + )["largest"] else: - largest_sequence = UserFavorite.objects.filter( - workspace=self.workspace - ).aggregate(largest=models.Max("sequence"))["largest"] + largest_sequence = UserFavorite.objects.filter(workspace=self.workspace).aggregate( + largest=models.Max("sequence") + )["largest"] if largest_sequence is not None: self.sequence = largest_sequence + 10000 diff --git a/apps/api/plane/db/models/importer.py b/apps/api/plane/db/models/importer.py index df93b95d1..9bcea8cf0 100644 --- a/apps/api/plane/db/models/importer.py +++ b/apps/api/plane/db/models/importer.py @@ -7,9 +7,7 @@ from .project import ProjectBaseModel class Importer(ProjectBaseModel): - service = models.CharField( - max_length=50, choices=(("github", "GitHub"), ("jira", "Jira")) - ) + service = models.CharField(max_length=50, choices=(("github", "GitHub"), ("jira", "Jira"))) status = models.CharField( max_length=50, choices=( @@ -20,15 +18,11 @@ class Importer(ProjectBaseModel): ), default="queued", ) - initiated_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports" - ) + initiated_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="imports") metadata = models.JSONField(default=dict) config = models.JSONField(default=dict) data = models.JSONField(default=dict) - token = models.ForeignKey( - "db.APIToken", on_delete=models.CASCADE, related_name="importer" - ) + token = models.ForeignKey("db.APIToken", on_delete=models.CASCADE, related_name="importer") imported_data = models.JSONField(null=True) class Meta: diff --git a/apps/api/plane/db/models/intake.py b/apps/api/plane/db/models/intake.py index c6c366c9e..c3369ae1d 100644 --- a/apps/api/plane/db/models/intake.py +++ b/apps/api/plane/db/models/intake.py @@ -44,12 +44,8 @@ class IntakeIssueStatus(models.IntegerChoices): class IntakeIssue(ProjectBaseModel): - intake = models.ForeignKey( - "db.Intake", related_name="issue_intake", on_delete=models.CASCADE - ) - issue = models.ForeignKey( - "db.Issue", related_name="issue_intake", on_delete=models.CASCADE - ) + intake = models.ForeignKey("db.Intake", related_name="issue_intake", on_delete=models.CASCADE) + issue = models.ForeignKey("db.Issue", related_name="issue_intake", on_delete=models.CASCADE) status = models.IntegerField( choices=( (-2, "Pending"), diff --git a/apps/api/plane/db/models/integration/base.py b/apps/api/plane/db/models/integration/base.py index 61dad67b0..296c3cf6d 100644 --- a/apps/api/plane/db/models/integration/base.py +++ b/apps/api/plane/db/models/integration/base.py @@ -10,14 +10,10 @@ from plane.db.mixins import AuditModel class Integration(AuditModel): - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) title = models.CharField(max_length=400) provider = models.CharField(max_length=400, unique=True) - network = models.PositiveIntegerField( - default=1, choices=((1, "Private"), (2, "Public")) - ) + network = models.PositiveIntegerField(default=1, choices=((1, "Private"), (2, "Public"))) description = models.JSONField(default=dict) author = models.CharField(max_length=400, blank=True) webhook_url = models.TextField(blank=True) @@ -39,19 +35,11 @@ class Integration(AuditModel): class WorkspaceIntegration(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE - ) + workspace = models.ForeignKey("db.Workspace", related_name="workspace_integrations", on_delete=models.CASCADE) # Bot user - actor = models.ForeignKey( - "db.User", related_name="integrations", on_delete=models.CASCADE - ) - integration = models.ForeignKey( - "db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE - ) - api_token = models.ForeignKey( - "db.APIToken", related_name="integrations", on_delete=models.CASCADE - ) + actor = models.ForeignKey("db.User", related_name="integrations", on_delete=models.CASCADE) + integration = models.ForeignKey("db.Integration", related_name="integrated_workspaces", on_delete=models.CASCADE) + api_token = models.ForeignKey("db.APIToken", related_name="integrations", on_delete=models.CASCADE) metadata = models.JSONField(default=dict) config = models.JSONField(default=dict) diff --git a/apps/api/plane/db/models/integration/github.py b/apps/api/plane/db/models/integration/github.py index 410972404..ba278497e 100644 --- a/apps/api/plane/db/models/integration/github.py +++ b/apps/api/plane/db/models/integration/github.py @@ -26,20 +26,14 @@ class GithubRepository(ProjectBaseModel): class GithubRepositorySync(ProjectBaseModel): - repository = models.OneToOneField( - "db.GithubRepository", on_delete=models.CASCADE, related_name="syncs" - ) + repository = models.OneToOneField("db.GithubRepository", on_delete=models.CASCADE, related_name="syncs") credentials = models.JSONField(default=dict) # Bot user - actor = models.ForeignKey( - "db.User", related_name="user_syncs", on_delete=models.CASCADE - ) + actor = models.ForeignKey("db.User", related_name="user_syncs", on_delete=models.CASCADE) workspace_integration = models.ForeignKey( "db.WorkspaceIntegration", related_name="github_syncs", on_delete=models.CASCADE ) - label = models.ForeignKey( - "db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs" - ) + label = models.ForeignKey("db.Label", on_delete=models.SET_NULL, null=True, related_name="repo_syncs") def __str__(self): """Return the repo sync""" @@ -57,12 +51,8 @@ class GithubIssueSync(ProjectBaseModel): repo_issue_id = models.BigIntegerField() github_issue_id = models.BigIntegerField() issue_url = models.URLField(blank=False) - issue = models.ForeignKey( - "db.Issue", related_name="github_syncs", on_delete=models.CASCADE - ) - repository_sync = models.ForeignKey( - "db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE - ) + issue = models.ForeignKey("db.Issue", related_name="github_syncs", on_delete=models.CASCADE) + repository_sync = models.ForeignKey("db.GithubRepositorySync", related_name="issue_syncs", on_delete=models.CASCADE) def __str__(self): """Return the github issue sync""" @@ -78,12 +68,8 @@ class GithubIssueSync(ProjectBaseModel): class GithubCommentSync(ProjectBaseModel): repo_comment_id = models.BigIntegerField() - comment = models.ForeignKey( - "db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE - ) - issue_sync = models.ForeignKey( - "db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE - ) + comment = models.ForeignKey("db.IssueComment", related_name="comment_syncs", on_delete=models.CASCADE) + issue_sync = models.ForeignKey("db.GithubIssueSync", related_name="comment_syncs", on_delete=models.CASCADE) def __str__(self): """Return the github issue sync""" diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index b8efd6ae7..c495fdb57 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -123,9 +123,7 @@ class Issue(ProjectBaseModel): blank=True, related_name="state_issue", ) - point = models.IntegerField( - validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True - ) + point = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(12)], null=True, blank=True) estimate_point = models.ForeignKey( "db.EstimatePoint", on_delete=models.SET_NULL, @@ -154,9 +152,7 @@ class Issue(ProjectBaseModel): through_fields=("issue", "assignee"), ) sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID") - labels = models.ManyToManyField( - "db.Label", blank=True, related_name="labels", through="IssueLabel" - ) + labels = models.ManyToManyField("db.Label", blank=True, related_name="labels", through="IssueLabel") sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) archived_at = models.DateField(null=True) @@ -188,9 +184,7 @@ class Issue(ProjectBaseModel): ~models.Q(is_triage=True), project=self.project, default=True ).first() if default_state is None: - random_state = State.objects.filter( - ~models.Q(is_triage=True), project=self.project - ).first() + random_state = State.objects.filter(~models.Q(is_triage=True), project=self.project).first() self.state = random_state else: self.state = default_state @@ -219,29 +213,25 @@ class Issue(ProjectBaseModel): try: # Get the last sequence for the project - last_sequence = IssueSequence.objects.filter( - project=self.project - ).aggregate(largest=models.Max("sequence"))["largest"] + last_sequence = IssueSequence.objects.filter(project=self.project).aggregate( + largest=models.Max("sequence") + )["largest"] self.sequence_id = last_sequence + 1 if last_sequence else 1 # Strip the html tags using html parser self.description_stripped = ( None - if ( - self.description_html == "" or self.description_html is None - ) + if (self.description_html == "" or self.description_html is None) else strip_tags(self.description_html) ) - largest_sort_order = Issue.objects.filter( - project=self.project, state=self.state - ).aggregate(largest=models.Max("sort_order"))["largest"] + largest_sort_order = Issue.objects.filter(project=self.project, state=self.state).aggregate( + largest=models.Max("sort_order") + )["largest"] if largest_sort_order is not None: self.sort_order = largest_sort_order + 10000 super(Issue, self).save(*args, **kwargs) - IssueSequence.objects.create( - issue=self, sequence=self.sequence_id, project=self.project - ) + IssueSequence.objects.create(issue=self, sequence=self.sequence_id, project=self.project) finally: # Release the lock with connection.cursor() as cursor: @@ -261,12 +251,8 @@ class Issue(ProjectBaseModel): class IssueBlocker(ProjectBaseModel): - block = models.ForeignKey( - Issue, related_name="blocker_issues", on_delete=models.CASCADE - ) - blocked_by = models.ForeignKey( - Issue, related_name="blocked_issues", on_delete=models.CASCADE - ) + block = models.ForeignKey(Issue, related_name="blocker_issues", on_delete=models.CASCADE) + blocked_by = models.ForeignKey(Issue, related_name="blocked_issues", on_delete=models.CASCADE) class Meta: verbose_name = "Issue Blocker" @@ -284,18 +270,29 @@ class IssueRelationChoices(models.TextChoices): BLOCKED_BY = "blocked_by", "Blocked By" START_BEFORE = "start_before", "Start Before" FINISH_BEFORE = "finish_before", "Finish Before" + IMPLEMENTED_BY = "implemented_by", "Implemented By" + + +# Bidirectional relation pairs: (forward, reverse) +# Defined after class to avoid enum metaclass conflicts +IssueRelationChoices._RELATION_PAIRS = ( + ("blocked_by", "blocking"), + ("relates_to", "relates_to"), # symmetric + ("duplicate", "duplicate"), # symmetric + ("start_before", "start_after"), + ("finish_before", "finish_after"), + ("implemented_by", "implements"), +) + +# Generate reverse mapping from pairs +IssueRelationChoices._REVERSE_MAPPING = {forward: reverse for forward, reverse in IssueRelationChoices._RELATION_PAIRS} class IssueRelation(ProjectBaseModel): - issue = models.ForeignKey( - Issue, related_name="issue_relation", on_delete=models.CASCADE - ) - related_issue = models.ForeignKey( - Issue, related_name="issue_related", on_delete=models.CASCADE - ) + issue = models.ForeignKey(Issue, related_name="issue_relation", on_delete=models.CASCADE) + related_issue = models.ForeignKey(Issue, related_name="issue_related", on_delete=models.CASCADE) relation_type = models.CharField( max_length=20, - choices=IssueRelationChoices.choices, verbose_name="Issue Relation Type", default=IssueRelationChoices.BLOCKED_BY, ) @@ -319,12 +316,8 @@ class IssueRelation(ProjectBaseModel): class IssueMention(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_mention" - ) - mention = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="issue_mention" - ) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_mention") + mention = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="issue_mention") class Meta: unique_together = ["issue", "mention", "deleted_at"] @@ -345,9 +338,7 @@ class IssueMention(ProjectBaseModel): class IssueAssignee(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_assignee" - ) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_assignee") assignee = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -375,9 +366,7 @@ class IssueAssignee(ProjectBaseModel): class IssueLink(ProjectBaseModel): title = models.CharField(max_length=255, null=True, blank=True) url = models.TextField() - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="issue_link" - ) + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_link") metadata = models.JSONField(default=dict) class Meta: @@ -403,9 +392,7 @@ def file_size(value): class IssueAttachment(ProjectBaseModel): attributes = models.JSONField(default=dict) asset = models.FileField(upload_to=get_upload_path, validators=[file_size]) - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="issue_attachment" - ) + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_attachment") external_source = models.CharField(max_length=255, null=True, blank=True) external_id = models.CharField(max_length=255, blank=True, null=True) @@ -420,13 +407,9 @@ class IssueAttachment(ProjectBaseModel): class IssueActivity(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity" - ) + issue = models.ForeignKey(Issue, on_delete=models.SET_NULL, null=True, related_name="issue_activity") verb = models.CharField(max_length=255, verbose_name="Action", default="created") - field = models.CharField( - max_length=255, verbose_name="Field Name", blank=True, null=True - ) + field = models.CharField(max_length=255, verbose_name="Field Name", blank=True, null=True) old_value = models.TextField(verbose_name="Old Value", blank=True, null=True) new_value = models.TextField(verbose_name="New Value", blank=True, null=True) @@ -464,9 +447,7 @@ class IssueComment(ProjectBaseModel): comment_json = models.JSONField(blank=True, default=dict) comment_html = models.TextField(blank=True, default="

") attachments = ArrayField(models.URLField(), size=10, blank=True, default=list) - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_comments" - ) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_comments") # System can also create comment actor = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -484,9 +465,7 @@ class IssueComment(ProjectBaseModel): edited_at = models.DateTimeField(null=True, blank=True) def save(self, *args, **kwargs): - self.comment_stripped = ( - strip_tags(self.comment_html) if self.comment_html != "" else "" - ) + self.comment_stripped = strip_tags(self.comment_html) if self.comment_html != "" else "" return super(IssueComment, self).save(*args, **kwargs) class Meta: @@ -531,12 +510,8 @@ class IssueUserProperty(ProjectBaseModel): class IssueLabel(ProjectBaseModel): - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="label_issue" - ) - label = models.ForeignKey( - "db.Label", on_delete=models.CASCADE, related_name="label_issue" - ) + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="label_issue") + label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="label_issue") class Meta: verbose_name = "Issue Label" @@ -566,9 +541,7 @@ class IssueSequence(ProjectBaseModel): class IssueSubscriber(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_subscribers" - ) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_subscribers") subscriber = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -599,9 +572,7 @@ class IssueReaction(ProjectBaseModel): on_delete=models.CASCADE, related_name="issue_reactions", ) - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_reactions" - ) + issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="issue_reactions") reaction = models.TextField() class Meta: @@ -628,9 +599,7 @@ class CommentReaction(ProjectBaseModel): on_delete=models.CASCADE, related_name="comment_reactions", ) - comment = models.ForeignKey( - IssueComment, on_delete=models.CASCADE, related_name="comment_reactions" - ) + comment = models.ForeignKey(IssueComment, on_delete=models.CASCADE, related_name="comment_reactions") reaction = models.TextField() class Meta: @@ -653,9 +622,7 @@ class CommentReaction(ProjectBaseModel): class IssueVote(ProjectBaseModel): issue = models.ForeignKey(Issue, on_delete=models.CASCADE, related_name="votes") - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes" - ) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="votes") vote = models.IntegerField(choices=((-1, "DOWNVOTE"), (1, "UPVOTE")), default=1) class Meta: @@ -713,9 +680,7 @@ class IssueVersion(ProjectBaseModel): 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" - ) + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="versions") activity = models.ForeignKey( "db.IssueActivity", on_delete=models.SET_NULL, @@ -760,17 +725,9 @@ class IssueVersion(ProjectBaseModel): 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 - ) - ), + 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 - ) - ), + 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, @@ -779,9 +736,7 @@ class IssueVersion(ProjectBaseModel): 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) - ), + modules=list(Module.objects.filter(issue=issue).values_list("id", flat=True)), properties={}, meta={}, last_saved_at=timezone.now(), @@ -794,9 +749,7 @@ class IssueVersion(ProjectBaseModel): class IssueDescriptionVersion(ProjectBaseModel): - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="description_versions" - ) + 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) diff --git a/apps/api/plane/db/models/issue_type.py b/apps/api/plane/db/models/issue_type.py index 953afcc8b..4f3dc08de 100644 --- a/apps/api/plane/db/models/issue_type.py +++ b/apps/api/plane/db/models/issue_type.py @@ -8,9 +8,7 @@ from .base import BaseModel class IssueType(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", related_name="issue_types", on_delete=models.CASCADE - ) + workspace = models.ForeignKey("db.Workspace", related_name="issue_types", on_delete=models.CASCADE) name = models.CharField(max_length=255) description = models.TextField(blank=True) logo_props = models.JSONField(default=dict) @@ -31,9 +29,7 @@ class IssueType(BaseModel): class ProjectIssueType(ProjectBaseModel): - issue_type = models.ForeignKey( - "db.IssueType", related_name="project_issue_types", on_delete=models.CASCADE - ) + issue_type = models.ForeignKey("db.IssueType", related_name="project_issue_types", on_delete=models.CASCADE) level = models.PositiveIntegerField(default=0) is_default = models.BooleanField(default=False) diff --git a/apps/api/plane/db/models/label.py b/apps/api/plane/db/models/label.py index 11e2da8c3..76ecf10e6 100644 --- a/apps/api/plane/db/models/label.py +++ b/apps/api/plane/db/models/label.py @@ -42,9 +42,7 @@ class Label(WorkspaceBaseModel): def save(self, *args, **kwargs): if self._state.adding: # Get the maximum sequence value from the database - last_id = Label.objects.filter(project=self.project).aggregate( - largest=models.Max("sort_order") - )["largest"] + last_id = Label.objects.filter(project=self.project).aggregate(largest=models.Max("sort_order"))["largest"] # if last_id is not None if last_id is not None: self.sort_order = last_id + 10000 diff --git a/apps/api/plane/db/models/module.py b/apps/api/plane/db/models/module.py index 897cf26b1..ab62f2df5 100644 --- a/apps/api/plane/db/models/module.py +++ b/apps/api/plane/db/models/module.py @@ -63,12 +63,8 @@ class ModuleStatus(models.TextChoices): class Module(ProjectBaseModel): name = models.CharField(max_length=255, verbose_name="Module Name") description = models.TextField(verbose_name="Module Description", blank=True) - description_text = models.JSONField( - verbose_name="Module Description RT", blank=True, null=True - ) - description_html = models.JSONField( - verbose_name="Module Description HTML", blank=True, null=True - ) + description_text = models.JSONField(verbose_name="Module Description RT", blank=True, null=True) + description_html = models.JSONField(verbose_name="Module Description HTML", blank=True, null=True) start_date = models.DateField(null=True) target_date = models.DateField(null=True) status = models.CharField( @@ -83,9 +79,7 @@ class Module(ProjectBaseModel): default="planned", max_length=20, ) - lead = models.ForeignKey( - "db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True - ) + lead = models.ForeignKey("db.User", on_delete=models.SET_NULL, related_name="module_leads", null=True) members = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, @@ -152,12 +146,8 @@ class ModuleMember(ProjectBaseModel): class ModuleIssue(ProjectBaseModel): - module = models.ForeignKey( - "db.Module", on_delete=models.CASCADE, related_name="issue_module" - ) - issue = models.ForeignKey( - "db.Issue", on_delete=models.CASCADE, related_name="issue_module" - ) + module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="issue_module") + issue = models.ForeignKey("db.Issue", on_delete=models.CASCADE, related_name="issue_module") class Meta: unique_together = ["issue", "module", "deleted_at"] @@ -180,9 +170,7 @@ class ModuleIssue(ProjectBaseModel): class ModuleLink(ProjectBaseModel): title = models.CharField(max_length=255, blank=True, null=True) url = models.URLField() - module = models.ForeignKey( - Module, on_delete=models.CASCADE, related_name="link_module" - ) + module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="link_module") metadata = models.JSONField(default=dict) class Meta: @@ -196,9 +184,7 @@ class ModuleLink(ProjectBaseModel): class ModuleUserProperties(ProjectBaseModel): - module = models.ForeignKey( - "db.Module", on_delete=models.CASCADE, related_name="module_user_properties" - ) + module = models.ForeignKey("db.Module", on_delete=models.CASCADE, related_name="module_user_properties") user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, diff --git a/apps/api/plane/db/models/notification.py b/apps/api/plane/db/models/notification.py index a57e288ab..aa58bc30c 100644 --- a/apps/api/plane/db/models/notification.py +++ b/apps/api/plane/db/models/notification.py @@ -7,12 +7,8 @@ from .base import BaseModel class Notification(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", related_name="notifications", on_delete=models.CASCADE - ) - project = models.ForeignKey( - "db.Project", related_name="notifications", on_delete=models.CASCADE, null=True - ) + workspace = models.ForeignKey("db.Workspace", related_name="notifications", on_delete=models.CASCADE) + project = models.ForeignKey("db.Project", related_name="notifications", on_delete=models.CASCADE, null=True) data = models.JSONField(null=True) entity_identifier = models.UUIDField(null=True) entity_name = models.CharField(max_length=255) @@ -27,9 +23,7 @@ class Notification(BaseModel): on_delete=models.SET_NULL, null=True, ) - receiver = models.ForeignKey( - "db.User", related_name="received_notifications", on_delete=models.CASCADE - ) + receiver = models.ForeignKey("db.User", related_name="received_notifications", on_delete=models.CASCADE) read_at = models.DateTimeField(null=True) snoozed_till = models.DateTimeField(null=True) archived_at = models.DateTimeField(null=True) @@ -40,9 +34,7 @@ class Notification(BaseModel): db_table = "notifications" ordering = ("-created_at",) indexes = [ - models.Index( - fields=["entity_identifier"], name="notif_entity_identifier_idx" - ), + models.Index(fields=["entity_identifier"], name="notif_entity_identifier_idx"), models.Index(fields=["entity_name"], name="notif_entity_name_idx"), models.Index(fields=["read_at"], name="notif_read_at_idx"), models.Index(fields=["receiver", "read_at"], name="notif_entity_idx"), diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py index 71fc49c45..213954d14 100644 --- a/apps/api/plane/db/models/page.py +++ b/apps/api/plane/db/models/page.py @@ -19,27 +19,20 @@ def get_view_props(): class Page(BaseModel): PRIVATE_ACCESS = 1 PUBLIC_ACCESS = 0 + DEFAULT_SORT_ORDER = 65535 ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public")) - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="pages" - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="pages") name = models.TextField(blank=True) description = models.JSONField(default=dict, blank=True) description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) - owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages" - ) - access = models.PositiveSmallIntegerField( - choices=((0, "Public"), (1, "Private")), default=0 - ) + owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="pages") + access = models.PositiveSmallIntegerField(choices=((0, "Public"), (1, "Private")), default=0) color = models.CharField(max_length=255, blank=True) - labels = models.ManyToManyField( - "db.Label", blank=True, related_name="pages", through="db.PageLabel" - ) + labels = models.ManyToManyField("db.Label", blank=True, related_name="pages", through="db.PageLabel") parent = models.ForeignKey( "self", on_delete=models.CASCADE, @@ -52,12 +45,10 @@ class Page(BaseModel): view_props = models.JSONField(default=get_view_props) logo_props = models.JSONField(default=dict) is_global = models.BooleanField(default=False) - projects = models.ManyToManyField( - "db.Project", related_name="pages", through="db.ProjectPage" - ) + projects = models.ManyToManyField("db.Project", related_name="pages", through="db.ProjectPage") moved_to_page = models.UUIDField(null=True, blank=True) moved_to_project = models.UUIDField(null=True, blank=True) - sort_order = models.FloatField(default=65535) + sort_order = models.FloatField(default=DEFAULT_SORT_ORDER) external_id = models.CharField(max_length=255, null=True, blank=True) external_source = models.CharField(max_length=255, null=True, blank=True) @@ -101,12 +92,8 @@ class PageLog(BaseModel): page = models.ForeignKey(Page, related_name="page_log", on_delete=models.CASCADE) entity_identifier = models.UUIDField(null=True, blank=True) entity_name = models.CharField(max_length=30, verbose_name="Transaction Type") - entity_type = models.CharField( - max_length=30, verbose_name="Entity Type", null=True, blank=True - ) - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log" - ) + entity_type = models.CharField(max_length=30, verbose_name="Entity Type", null=True, blank=True) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_log") class Meta: unique_together = ["page", "transaction"] @@ -118,12 +105,8 @@ class PageLog(BaseModel): models.Index(fields=["entity_type"], name="pagelog_entity_type_idx"), models.Index(fields=["entity_identifier"], name="pagelog_entity_id_idx"), models.Index(fields=["entity_name"], name="pagelog_entity_name_idx"), - models.Index( - fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx" - ), - models.Index( - fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx" - ), + models.Index(fields=["entity_type", "entity_identifier"], name="pagelog_type_id_idx"), + models.Index(fields=["entity_name", "entity_identifier"], name="pagelog_name_id_idx"), ] def __str__(self): @@ -131,15 +114,9 @@ class PageLog(BaseModel): class PageLabel(BaseModel): - label = models.ForeignKey( - "db.Label", on_delete=models.CASCADE, related_name="page_labels" - ) - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="page_labels" - ) - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_label" - ) + label = models.ForeignKey("db.Label", on_delete=models.CASCADE, related_name="page_labels") + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="page_labels") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_page_label") class Meta: verbose_name = "Page Label" @@ -152,15 +129,9 @@ class PageLabel(BaseModel): class ProjectPage(BaseModel): - project = models.ForeignKey( - "db.Project", on_delete=models.CASCADE, related_name="project_pages" - ) - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="project_pages" - ) - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="project_pages" - ) + project = models.ForeignKey("db.Project", on_delete=models.CASCADE, related_name="project_pages") + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="project_pages") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="project_pages") class Meta: unique_together = ["project", "page", "deleted_at"] @@ -181,16 +152,10 @@ class ProjectPage(BaseModel): class PageVersion(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="page_versions" - ) - page = models.ForeignKey( - "db.Page", on_delete=models.CASCADE, related_name="page_versions" - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="page_versions") + page = models.ForeignKey("db.Page", on_delete=models.CASCADE, related_name="page_versions") last_saved_at = models.DateTimeField(default=timezone.now) - owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="page_versions" - ) + owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="page_versions") description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index e58f60e80..ed5a08772 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -18,6 +18,12 @@ from .base import BaseModel ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + GUEST = 5 + + class ProjectNetwork(Enum): SECRET = 0 PUBLIC = 2 @@ -60,19 +66,11 @@ class Project(BaseModel): NETWORK_CHOICES = ((0, "Secret"), (2, "Public")) name = models.CharField(max_length=255, verbose_name="Project Name") description = models.TextField(verbose_name="Project Description", blank=True) - description_text = models.JSONField( - verbose_name="Project Description RT", blank=True, null=True - ) - description_html = models.JSONField( - verbose_name="Project Description HTML", blank=True, null=True - ) + description_text = models.JSONField(verbose_name="Project Description RT", blank=True, null=True) + description_html = models.JSONField(verbose_name="Project Description HTML", blank=True, null=True) network = models.PositiveSmallIntegerField(default=2, choices=NETWORK_CHOICES) - workspace = models.ForeignKey( - "db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project" - ) - identifier = models.CharField( - max_length=12, verbose_name="Project Identifier", db_index=True - ) + workspace = models.ForeignKey("db.WorkSpace", on_delete=models.CASCADE, related_name="workspace_project") + identifier = models.CharField(max_length=12, verbose_name="Project Identifier", db_index=True) default_assignee = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -89,9 +87,9 @@ class Project(BaseModel): ) emoji = models.CharField(max_length=255, null=True, blank=True) icon_prop = models.JSONField(null=True) - module_view = models.BooleanField(default=True) - cycle_view = models.BooleanField(default=True) - issue_views_view = models.BooleanField(default=True) + module_view = models.BooleanField(default=False) + cycle_view = models.BooleanField(default=False) + issue_views_view = models.BooleanField(default=False) page_view = models.BooleanField(default=True) intake_view = models.BooleanField(default=False) is_time_tracking_enabled = models.BooleanField(default=False) @@ -105,19 +103,11 @@ class Project(BaseModel): blank=True, related_name="project_cover_image", ) - estimate = models.ForeignKey( - "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True - ) - archive_in = models.IntegerField( - default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] - ) - close_in = models.IntegerField( - default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] - ) + estimate = models.ForeignKey("db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True) + archive_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) + close_in = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(12)]) logo_props = models.JSONField(default=dict) - default_state = models.ForeignKey( - "db.State", on_delete=models.SET_NULL, null=True, related_name="default_state" - ) + default_state = models.ForeignKey("db.State", on_delete=models.SET_NULL, null=True, related_name="default_state") archived_at = models.DateTimeField(null=True) # timezone TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) @@ -170,12 +160,8 @@ class Project(BaseModel): class ProjectBaseModel(BaseModel): - project = models.ForeignKey( - Project, on_delete=models.CASCADE, related_name="project_%(class)s" - ) - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_%(class)s" - ) + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="project_%(class)s") + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_%(class)s") class Meta: abstract = True @@ -252,12 +238,8 @@ class ProjectMember(ProjectBaseModel): # TODO: Remove workspace relation later class ProjectIdentifier(AuditModel): - workspace = models.ForeignKey( - "db.Workspace", models.CASCADE, related_name="project_identifiers", null=True - ) - project = models.OneToOneField( - Project, on_delete=models.CASCADE, related_name="project_identifier" - ) + workspace = models.ForeignKey("db.Workspace", models.CASCADE, related_name="project_identifiers", null=True) + project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="project_identifier") name = models.CharField(max_length=12, db_index=True) class Meta: @@ -292,14 +274,10 @@ def get_default_views(): # DEPRECATED TODO: # used to get the old anchors for the project deploy boards class ProjectDeployBoard(ProjectBaseModel): - anchor = models.CharField( - max_length=255, default=get_anchor, unique=True, db_index=True - ) + anchor = models.CharField(max_length=255, default=get_anchor, unique=True, db_index=True) comments = models.BooleanField(default=False) reactions = models.BooleanField(default=False) - intake = models.ForeignKey( - "db.Intake", related_name="board_intake", on_delete=models.SET_NULL, null=True - ) + intake = models.ForeignKey("db.Intake", related_name="board_intake", on_delete=models.SET_NULL, null=True) votes = models.BooleanField(default=False) views = models.JSONField(default=get_default_views) diff --git a/apps/api/plane/db/models/session.py b/apps/api/plane/db/models/session.py index 3b35ebc70..e884498bf 100644 --- a/apps/api/plane/db/models/session.py +++ b/apps/api/plane/db/models/session.py @@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits class Session(AbstractBaseSession): device_info = models.JSONField(null=True, blank=True, default=None) session_key = models.CharField(max_length=128, primary_key=True) - user_id = models.CharField(null=True, max_length=50) + user_id = models.CharField(null=True, max_length=50, db_index=True) @classmethod def get_session_store_class(cls): diff --git a/apps/api/plane/db/models/state.py b/apps/api/plane/db/models/state.py index 3478d70d2..e9d56acf9 100644 --- a/apps/api/plane/db/models/state.py +++ b/apps/api/plane/db/models/state.py @@ -52,9 +52,7 @@ class State(ProjectBaseModel): self.slug = slugify(self.name) if self._state.adding: # Get the maximum sequence value from the database - last_id = State.objects.filter(project=self.project).aggregate( - largest=models.Max("sequence") - )["largest"] + last_id = State.objects.filter(project=self.project).aggregate(largest=models.Max("sequence"))["largest"] # if last_id is not None if last_id is not None: self.sequence = last_id + 15000 diff --git a/apps/api/plane/db/models/sticky.py b/apps/api/plane/db/models/sticky.py index 34f37b81e..157077eb8 100644 --- a/apps/api/plane/db/models/sticky.py +++ b/apps/api/plane/db/models/sticky.py @@ -21,12 +21,8 @@ class Sticky(BaseModel): 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" - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="stickies") + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="stickies") sort_order = models.FloatField(default=65535) class Meta: @@ -44,9 +40,9 @@ class Sticky(BaseModel): ) if self._state.adding: # Get the maximum sequence value from the database - last_id = Sticky.objects.filter(workspace=self.workspace).aggregate( - largest=models.Max("sort_order") - )["largest"] + last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(largest=models.Max("sort_order"))[ + "largest" + ] # if last_id is not None if last_id is not None: self.sort_order = last_id + 10000 diff --git a/apps/api/plane/db/models/user.py b/apps/api/plane/db/models/user.py index 2e7d2a7b8..c9f0df9b0 100644 --- a/apps/api/plane/db/models/user.py +++ b/apps/api/plane/db/models/user.py @@ -36,9 +36,7 @@ def get_mobile_default_onboarding(): class User(AbstractBaseUser, PermissionsMixin): - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) username = models.CharField(max_length=128, unique=True) # user fields mobile_number = models.CharField(max_length=255, blank=True, null=True) @@ -97,15 +95,11 @@ class User(AbstractBaseUser, PermissionsMixin): # my_issues_prop = models.JSONField(null=True) is_bot = models.BooleanField(default=False) - bot_type = models.CharField( - max_length=30, verbose_name="Bot Type", blank=True, null=True - ) + bot_type = models.CharField(max_length=30, verbose_name="Bot Type", blank=True, null=True) # timezone USER_TIMEZONE_CHOICES = tuple(zip(pytz.common_timezones, pytz.common_timezones)) - user_timezone = models.CharField( - max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES - ) + user_timezone = models.CharField(max_length=255, default="UTC", choices=USER_TIMEZONE_CHOICES) # email validation is_email_valid = models.BooleanField(default=False) @@ -189,13 +183,9 @@ class Profile(TimeAuditModel): (SATURDAY, "Saturday"), ) - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) # User - user = models.OneToOneField( - "db.User", on_delete=models.CASCADE, related_name="profile" - ) + user = models.OneToOneField("db.User", on_delete=models.CASCADE, related_name="profile") # General theme = models.JSONField(default=dict) is_app_rail_docked = models.BooleanField(default=True) @@ -220,9 +210,7 @@ class Profile(TimeAuditModel): mobile_timezone_auto_set = models.BooleanField(default=False) # language language = models.CharField(max_length=255, default="en") - start_of_the_week = models.PositiveSmallIntegerField( - choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY - ) + start_of_the_week = models.PositiveSmallIntegerField(choices=START_OF_THE_WEEK_CHOICES, default=SUNDAY) goals = models.JSONField(default=dict) background_color = models.CharField(max_length=255, default=get_random_color) @@ -243,12 +231,8 @@ class Account(TimeAuditModel): ("gitlab", "GitLab"), ) - id = models.UUIDField( - default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True - ) - user = models.ForeignKey( - "db.User", on_delete=models.CASCADE, related_name="accounts" - ) + id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True) + user = models.ForeignKey("db.User", on_delete=models.CASCADE, related_name="accounts") provider_account_id = models.CharField(max_length=255) provider = models.CharField(choices=PROVIDER_CHOICES) access_token = models.TextField() diff --git a/apps/api/plane/db/models/view.py b/apps/api/plane/db/models/view.py index 87d22e44f..d430cd5f9 100644 --- a/apps/api/plane/db/models/view.py +++ b/apps/api/plane/db/models/view.py @@ -59,14 +59,10 @@ class IssueView(WorkspaceBaseModel): display_filters = models.JSONField(default=get_default_display_filters) display_properties = models.JSONField(default=get_default_display_properties) rich_filters = models.JSONField(default=dict) - access = models.PositiveSmallIntegerField( - default=1, choices=((0, "Private"), (1, "Public")) - ) + access = models.PositiveSmallIntegerField(default=1, choices=((0, "Private"), (1, "Public"))) sort_order = models.FloatField(default=65535) logo_props = models.JSONField(default=dict) - owned_by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views" - ) + owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views") is_locked = models.BooleanField(default=False) class Meta: @@ -81,13 +77,13 @@ class IssueView(WorkspaceBaseModel): if self._state.adding: if self.project: - largest_sort_order = IssueView.objects.filter( - project=self.project - ).aggregate(largest=models.Max("sort_order"))["largest"] + largest_sort_order = IssueView.objects.filter(project=self.project).aggregate( + largest=models.Max("sort_order") + )["largest"] else: - largest_sort_order = IssueView.objects.filter( - workspace=self.workspace, project__isnull=True - ).aggregate(largest=models.Max("sort_order"))["largest"] + largest_sort_order = IssueView.objects.filter(workspace=self.workspace, project__isnull=True).aggregate( + largest=models.Max("sort_order") + )["largest"] if largest_sort_order is not None: self.sort_order = largest_sort_order + 10000 diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py index b1428523b..8872d0bb2 100644 --- a/apps/api/plane/db/models/webhook.py +++ b/apps/api/plane/db/models/webhook.py @@ -7,7 +7,7 @@ from django.db import models from django.core.exceptions import ValidationError # Module imports -from plane.db.models import BaseModel +from plane.db.models import BaseModel, ProjectBaseModel def generate_token(): @@ -28,12 +28,8 @@ def validate_domain(value): class Webhook(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks" - ) - url = models.URLField( - validators=[validate_schema, validate_domain], max_length=1024 - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks") + 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) @@ -62,9 +58,7 @@ class Webhook(BaseModel): class WebhookLog(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs" - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="webhook_logs") # Associated webhook webhook = models.UUIDField() @@ -90,3 +84,21 @@ class WebhookLog(BaseModel): def __str__(self): return f"{self.event_type} {str(self.webhook)}" + + +class ProjectWebhook(ProjectBaseModel): + webhook = models.ForeignKey("db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks") + + class Meta: + unique_together = ["project", "webhook", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "webhook"], + condition=models.Q(deleted_at__isnull=True), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ) + ] + verbose_name = "Project Webhook" + verbose_name_plural = "Project Webhooks" + db_table = "project_webhooks" + ordering = ("-created_at",) diff --git a/apps/api/plane/db/models/workspace.py b/apps/api/plane/db/models/workspace.py index 75a45f72c..0f5c09760 100644 --- a/apps/api/plane/db/models/workspace.py +++ b/apps/api/plane/db/models/workspace.py @@ -129,9 +129,7 @@ class Workspace(BaseModel): on_delete=models.CASCADE, related_name="owner_workspace", ) - slug = models.SlugField( - max_length=48, db_index=True, unique=True, validators=[slug_validator] - ) + slug = models.SlugField(max_length=48, db_index=True, unique=True, validators=[slug_validator]) organization_size = models.CharField(max_length=20, blank=True, null=True) timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES) background_color = models.CharField(max_length=255, default=get_random_color) @@ -151,9 +149,7 @@ class Workspace(BaseModel): return self.logo return None - def delete( - self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any - ): + def delete(self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any): """ Override the delete method to append epoch timestamp to the slug when soft deleting. @@ -183,12 +179,8 @@ class Workspace(BaseModel): class WorkspaceBaseModel(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", models.CASCADE, related_name="workspace_%(class)s" - ) - project = models.ForeignKey( - "db.Project", models.CASCADE, related_name="project_%(class)s", null=True - ) + workspace = models.ForeignKey("db.Workspace", models.CASCADE, related_name="workspace_%(class)s") + project = models.ForeignKey("db.Project", models.CASCADE, related_name="project_%(class)s", null=True) class Meta: abstract = True @@ -200,9 +192,7 @@ class WorkspaceBaseModel(BaseModel): class WorkspaceMember(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member" - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member") member = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -235,9 +225,7 @@ class WorkspaceMember(BaseModel): class WorkspaceMemberInvite(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite" - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite") email = models.CharField(max_length=255) accepted = models.BooleanField(default=False) token = models.CharField(max_length=255) @@ -266,9 +254,7 @@ 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) - workspace = models.ForeignKey( - Workspace, on_delete=models.CASCADE, related_name="workspace_team" - ) + workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name="workspace_team") logo_props = models.JSONField(default=dict) def __str__(self): @@ -291,13 +277,9 @@ class Team(BaseModel): class WorkspaceTheme(BaseModel): - workspace = models.ForeignKey( - "db.Workspace", on_delete=models.CASCADE, related_name="themes" - ) + workspace = models.ForeignKey("db.Workspace", on_delete=models.CASCADE, related_name="themes") name = models.CharField(max_length=300) - actor = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes" - ) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes") colors = models.JSONField(default=dict) def __str__(self): diff --git a/apps/api/plane/license/api/permissions/instance.py b/apps/api/plane/license/api/permissions/instance.py index 848de4d7b..a430b688b 100644 --- a/apps/api/plane/license/api/permissions/instance.py +++ b/apps/api/plane/license/api/permissions/instance.py @@ -11,6 +11,4 @@ class InstanceAdminPermission(BasePermission): return False instance = Instance.objects.first() - return InstanceAdmin.objects.filter( - role__gte=15, instance=instance, user=request.user - ).exists() + return InstanceAdmin.objects.filter(role__gte=15, instance=instance, user=request.user).exists() diff --git a/apps/api/plane/license/api/serializers/instance.py b/apps/api/plane/license/api/serializers/instance.py index 49c5194c8..c75c62e50 100644 --- a/apps/api/plane/license/api/serializers/instance.py +++ b/apps/api/plane/license/api/serializers/instance.py @@ -5,9 +5,7 @@ from plane.app.serializers import UserAdminLiteSerializer class InstanceSerializer(BaseSerializer): - primary_owner_details = UserAdminLiteSerializer( - source="primary_owner", read_only=True - ) + primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) class Meta: model = Instance diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py index e1e386082..72c976116 100644 --- a/apps/api/plane/license/api/views/admin.py +++ b/apps/api/plane/license/api/views/admin.py @@ -34,6 +34,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, ) from plane.utils.ip_address import get_client_ip +from plane.utils.path_validator import get_safe_redirect_url class InstanceAdminEndpoint(BaseAPIView): @@ -46,9 +47,7 @@ class InstanceAdminEndpoint(BaseAPIView): role = request.data.get("role", 20) if not email: - return Response( - {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST) instance = Instance.objects.first() if instance is None: @@ -60,9 +59,7 @@ class InstanceAdminEndpoint(BaseAPIView): # Fetch the user user = User.objects.get(email=email) - instance_admin = InstanceAdmin.objects.create( - instance=instance, user=user, role=role - ) + instance_admin = InstanceAdmin.objects.create(instance=instance, user=user, role=role) serializer = InstanceAdminSerializer(instance_admin) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -126,9 +123,7 @@ class InstanceAdminSignUpEndpoint(View): # return error if the email and password is not present if not email or not password or not first_name: exc = AuthenticationException( - error_code=AUTHENTICATION_ERROR_CODES[ - "REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME" - ], + error_code=AUTHENTICATION_ERROR_CODES["REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME"], error_message="REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME", payload={ "email": email, @@ -368,10 +363,7 @@ class InstanceAdminUserSessionEndpoint(BaseAPIView): permission_classes = [AllowAny] def get(self, request): - if ( - request.user.is_authenticated - and InstanceAdmin.objects.filter(user=request.user).exists() - ): + if request.user.is_authenticated and InstanceAdmin.objects.filter(user=request.user).exists(): serializer = InstanceAdminMeSerializer(request.user) data = {"is_authenticated": True} data["user"] = serializer.data @@ -392,7 +384,8 @@ class InstanceAdminSignOutEndpoint(View): user.save() # Log the user out logout(request) - url = urljoin(base_host(request=request, is_admin=True)) + url = get_safe_redirect_url(base_url=base_host(request=request, is_admin=True), next_path="") return HttpResponseRedirect(url) except Exception: - return HttpResponseRedirect(base_host(request=request, is_admin=True)) + url = get_safe_redirect_url(base_url=base_host(request=request, is_admin=True), next_path="") + return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/api/views/base.py b/apps/api/plane/license/api/views/base.py index 05b42b801..d209bd6bf 100644 --- a/apps/api/plane/license/api/views/base.py +++ b/apps/api/plane/license/api/views/base.py @@ -97,9 +97,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): if settings.DEBUG: from django.db import connection - print( - f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" - ) + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") return response except Exception as exc: @@ -108,14 +106,10 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): @property def fields(self): - fields = [ - field for field in self.request.GET.get("fields", "").split(",") if field - ] + fields = [field for field in self.request.GET.get("fields", "").split(",") if field] return fields if fields else None @property def expand(self): - expand = [ - expand for expand in self.request.GET.get("expand", "").split(",") if expand - ] + expand = [expand for expand in self.request.GET.get("expand", "").split(",") if expand] return expand if expand else None diff --git a/apps/api/plane/license/api/views/configuration.py b/apps/api/plane/license/api/views/configuration.py index 3bf996db9..8bb953565 100644 --- a/apps/api/plane/license/api/views/configuration.py +++ b/apps/api/plane/license/api/views/configuration.py @@ -37,9 +37,7 @@ class InstanceConfigurationEndpoint(BaseAPIView): @invalidate_cache(path="/api/instances/configurations/", user=False) @invalidate_cache(path="/api/instances/", user=False) def patch(self, request): - configurations = InstanceConfiguration.objects.filter( - key__in=request.data.keys() - ) + configurations = InstanceConfiguration.objects.filter(key__in=request.data.keys()) bulk_configurations = [] for configuration in configurations: @@ -50,9 +48,7 @@ class InstanceConfigurationEndpoint(BaseAPIView): configuration.value = value bulk_configurations.append(configuration) - InstanceConfiguration.objects.bulk_update( - bulk_configurations, ["value"], batch_size=100 - ) + InstanceConfiguration.objects.bulk_update(bulk_configurations, ["value"], batch_size=100) serializer = InstanceConfigurationSerializer(configurations, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -75,9 +71,7 @@ class DisableEmailFeatureEndpoint(BaseAPIView): "EMAIL_FROM", ] ) - ).update( - value=Case(When(key="ENABLE_SMTP", then=Value("0")), default=Value("")) - ) + ).update(value=Case(When(key="ENABLE_SMTP", then=Value("0")), default=Value(""))) return Response(status=status.HTTP_200_OK) except Exception: return Response( @@ -127,13 +121,9 @@ class EmailCredentialCheckEndpoint(BaseAPIView): connection=connection, ) msg.send(fail_silently=False) - return Response( - {"message": "Email successfully sent."}, status=status.HTTP_200_OK - ) + return Response({"message": "Email successfully sent."}, status=status.HTTP_200_OK) except BadHeaderError: - return Response( - {"error": "Invalid email header."}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Invalid email header."}, status=status.HTTP_400_BAD_REQUEST) except SMTPAuthenticationError: return Response( {"error": "Invalid credentials provided"}, @@ -166,9 +156,7 @@ class EmailCredentialCheckEndpoint(BaseAPIView): ) except ConnectionError: return Response( - { - "error": "Network connection error. Please check your internet connection." - }, + {"error": "Network connection error. Please check your internet connection."}, status=status.HTTP_400_BAD_REQUEST, ) except Exception: diff --git a/apps/api/plane/license/api/views/workspace.py b/apps/api/plane/license/api/views/workspace.py index 8b5eaac6b..5d1a2f24b 100644 --- a/apps/api/plane/license/api/views/workspace.py +++ b/apps/api/plane/license/api/views/workspace.py @@ -24,10 +24,7 @@ class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - workspace = ( - Workspace.objects.filter(slug__iexact=slug).exists() - or slug in RESTRICTED_WORKSPACE_SLUGS - ) + workspace = Workspace.objects.filter(slug__iexact=slug).exists() or slug in RESTRICTED_WORKSPACE_SLUGS return Response({"status": not workspace}, status=status.HTTP_200_OK) @@ -45,18 +42,14 @@ class InstanceWorkSpaceEndpoint(BaseAPIView): ) member_count = ( - WorkspaceMember.objects.filter( - workspace=OuterRef("id"), member__is_bot=False, is_active=True - ) + 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 - ) + workspaces = Workspace.objects.annotate(total_projects=project_count, total_members=member_count) # Add search functionality search = request.query_params.get("search", None) diff --git a/apps/api/plane/license/bgtasks/tracer.py b/apps/api/plane/license/bgtasks/tracer.py index 47e74c83a..055c45d6c 100644 --- a/apps/api/plane/license/bgtasks/tracer.py +++ b/apps/api/plane/license/bgtasks/tracer.py @@ -51,14 +51,10 @@ def instance_traces(): 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_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_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) @@ -80,16 +76,10 @@ def instance_traces(): 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() + 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() + member_count = WorkspaceMember.objects.filter(workspace=workspace).count() # Set span attributes with tracer.start_as_current_span("workspace_details") as span: diff --git a/apps/api/plane/license/management/commands/configure_instance.py b/apps/api/plane/license/management/commands/configure_instance.py index 1414c970c..5611eec52 100644 --- a/apps/api/plane/license/management/commands/configure_instance.py +++ b/apps/api/plane/license/management/commands/configure_instance.py @@ -190,9 +190,7 @@ class Command(BaseCommand): ] for item in config_keys: - obj, created = InstanceConfiguration.objects.get_or_create( - key=item.get("key") - ) + obj, created = InstanceConfiguration.objects.get_or_create(key=item.get("key")) if created: obj.category = item.get("category") obj.is_encrypted = item.get("is_encrypted", False) @@ -201,15 +199,9 @@ class Command(BaseCommand): else: obj.value = item.get("value") obj.save() - self.stdout.write( - self.style.SUCCESS( - f"{obj.key} loaded with value from environment variable." - ) - ) + self.stdout.write(self.style.SUCCESS(f"{obj.key} loaded with value from environment variable.")) else: - self.stdout.write( - self.style.WARNING(f"{obj.key} configuration already exists") - ) + self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists")) keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"] if not InstanceConfiguration.objects.filter(key__in=keys).exists(): @@ -237,11 +229,7 @@ class Command(BaseCommand): category="AUTHENTICATION", is_encrypted=False, ) - self.stdout.write( - self.style.SUCCESS( - f"{key} loaded with value from environment variable." - ) - ) + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) if key == "IS_GITHUB_ENABLED": GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET = get_configuration_value( [ @@ -265,39 +253,25 @@ class Command(BaseCommand): category="AUTHENTICATION", is_encrypted=False, ) - self.stdout.write( - self.style.SUCCESS( - f"{key} loaded with value from environment variable." - ) - ) + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) if key == "IS_GITLAB_ENABLED": - GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = ( - get_configuration_value( - [ - { - "key": "GITLAB_HOST", - "default": os.environ.get( - "GITLAB_HOST", "https://gitlab.com" - ), - }, - { - "key": "GITLAB_CLIENT_ID", - "default": os.environ.get("GITLAB_CLIENT_ID", ""), - }, - { - "key": "GITLAB_CLIENT_SECRET", - "default": os.environ.get( - "GITLAB_CLIENT_SECRET", "" - ), - }, - ] - ) + GITLAB_HOST, GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET = get_configuration_value( + [ + { + "key": "GITLAB_HOST", + "default": os.environ.get("GITLAB_HOST", "https://gitlab.com"), + }, + { + "key": "GITLAB_CLIENT_ID", + "default": os.environ.get("GITLAB_CLIENT_ID", ""), + }, + { + "key": "GITLAB_CLIENT_SECRET", + "default": os.environ.get("GITLAB_CLIENT_SECRET", ""), + }, + ] ) - if ( - bool(GITLAB_HOST) - and bool(GITLAB_CLIENT_ID) - and bool(GITLAB_CLIENT_SECRET) - ): + if bool(GITLAB_HOST) and bool(GITLAB_CLIENT_ID) and bool(GITLAB_CLIENT_SECRET): value = "1" else: value = "0" @@ -307,13 +281,7 @@ class Command(BaseCommand): category="AUTHENTICATION", is_encrypted=False, ) - self.stdout.write( - self.style.SUCCESS( - f"{key} loaded with value from environment variable." - ) - ) + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) else: for key in keys: - self.stdout.write( - self.style.WARNING(f"{key} configuration already exists") - ) + self.stdout.write(self.style.WARNING(f"{key} configuration already exists")) diff --git a/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py new file mode 100644 index 000000000..f8c2c30bc --- /dev/null +++ b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.22 on 2025-09-11 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0005_rename_product_instance_edition_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="instance", + name="is_current_version_deprecated", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/license/models/instance.py b/apps/api/plane/license/models/instance.py index 113b59ce4..1767d8c22 100644 --- a/apps/api/plane/license/models/instance.py +++ b/apps/api/plane/license/models/instance.py @@ -22,9 +22,7 @@ class Instance(BaseModel): instance_id = models.CharField(max_length=255, unique=True) current_version = models.CharField(max_length=255) latest_version = models.CharField(max_length=255, null=True, blank=True) - edition = models.CharField( - max_length=255, default=InstanceEdition.PLANE_COMMUNITY.value - ) + edition = models.CharField(max_length=255, default=InstanceEdition.PLANE_COMMUNITY.value) domain = models.TextField(blank=True) # Instance specifics last_checked_at = models.DateTimeField() @@ -38,6 +36,8 @@ class Instance(BaseModel): is_signup_screen_visited = models.BooleanField(default=False) is_verified = models.BooleanField(default=False) is_test = models.BooleanField(default=False) + # field for validating if the current version is deprecated + is_current_version_deprecated = models.BooleanField(default=False) class Meta: verbose_name = "Instance" @@ -53,9 +53,7 @@ class InstanceAdmin(BaseModel): null=True, related_name="instance_owner", ) - instance = models.ForeignKey( - Instance, on_delete=models.CASCADE, related_name="admins" - ) + instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=20) is_verified = models.BooleanField(default=False) diff --git a/apps/api/plane/license/utils/encryption.py b/apps/api/plane/license/utils/encryption.py index 6781605dd..d56766d1e 100644 --- a/apps/api/plane/license/utils/encryption.py +++ b/apps/api/plane/license/utils/encryption.py @@ -31,9 +31,7 @@ def decrypt_data(encrypted_data): try: if encrypted_data: cipher_suite = Fernet(derive_key(settings.SECRET_KEY)) - decrypted_data = cipher_suite.decrypt( - encrypted_data.encode() - ) # Convert string back to bytes + decrypted_data = cipher_suite.decrypt(encrypted_data.encode()) # Convert string back to bytes return decrypted_data.decode() else: return "" diff --git a/apps/api/plane/license/utils/instance_value.py b/apps/api/plane/license/utils/instance_value.py index 72241fe54..8901bc814 100644 --- a/apps/api/plane/license/utils/instance_value.py +++ b/apps/api/plane/license/utils/instance_value.py @@ -14,9 +14,7 @@ def get_configuration_value(keys): environment_list = [] if settings.SKIP_ENV_VAR: # Get the configurations - instance_configuration = InstanceConfiguration.objects.values( - "key", "value", "is_encrypted" - ) + instance_configuration = InstanceConfiguration.objects.values("key", "value", "is_encrypted") for key in keys: for item in instance_configuration: @@ -51,9 +49,7 @@ def get_email_configuration(): {"key": "EMAIL_USE_SSL", "default": os.environ.get("EMAIL_USE_SSL", "0")}, { "key": "EMAIL_FROM", - "default": os.environ.get( - "EMAIL_FROM", "Team Plane " - ), + "default": os.environ.get("EMAIL_FROM", "Team Plane "), }, ] ) diff --git a/apps/api/plane/middleware/db_routing.py b/apps/api/plane/middleware/db_routing.py index dc7ff3fa3..68b5c4491 100644 --- a/apps/api/plane/middleware/db_routing.py +++ b/apps/api/plane/middleware/db_routing.py @@ -154,9 +154,7 @@ class ReadReplicaRoutingMiddleware: # The try/finally in __call__ should handle most cases, but this # provides extra protection specifically for view exceptions clear_read_replica_context() - logger.debug( - f"Cleaned up read replica context due to exception: {type(exception).__name__}" - ) + logger.debug(f"Cleaned up read replica context due to exception: {type(exception).__name__}") # Return None to let the exception continue propagating return None diff --git a/apps/api/plane/middleware/logger.py b/apps/api/plane/middleware/logger.py index 7481c3992..d513ee3e3 100644 --- a/apps/api/plane/middleware/logger.py +++ b/apps/api/plane/middleware/logger.py @@ -12,7 +12,6 @@ from rest_framework.request import Request from plane.utils.ip_address import get_client_ip from plane.db.models import APIActivityLog - api_logger = logging.getLogger("plane.api.request") @@ -47,10 +46,7 @@ class RequestLoggerMiddleware: return response user_id = ( - request.user.id - if getattr(request, "user") - and getattr(request.user, "is_authenticated", False) - else None + request.user.id if getattr(request, "user") and getattr(request.user, "is_authenticated", False) else None ) user_agent = request.META.get("HTTP_USER_AGENT", "") @@ -97,11 +93,7 @@ class APITokenLogMiddleware: return None # Check if content is binary by looking for common binary file signatures - if ( - content.startswith(b"\x89PNG") - or content.startswith(b"\xff\xd8\xff") - or content.startswith(b"%PDF") - ): + if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"): return "[Binary Content]" try: @@ -121,14 +113,8 @@ class APITokenLogMiddleware: method=request.method, query_params=request.META.get("QUERY_STRING", ""), headers=str(request.headers), - body=( - self._safe_decode_body(request_body) if request_body else None - ), - response_body=( - self._safe_decode_body(response.content) - if response.content - else None - ), + body=(self._safe_decode_body(request_body) if request_body else None), + response_body=(self._safe_decode_body(response.content) if response.content else None), response_code=response.status_code, ip_address=get_client_ip(request=request), user_agent=request.META.get("HTTP_USER_AGENT", None), diff --git a/apps/api/plane/middleware/request_body_size.py b/apps/api/plane/middleware/request_body_size.py new file mode 100644 index 000000000..9807c5715 --- /dev/null +++ b/apps/api/plane/middleware/request_body_size.py @@ -0,0 +1,27 @@ +from django.core.exceptions import RequestDataTooBig +from django.http import JsonResponse + + +class RequestBodySizeLimitMiddleware: + """ + Middleware to catch RequestDataTooBig exceptions and return + 413 Request Entity Too Large instead of 400 Bad Request. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + try: + _ = request.body + except RequestDataTooBig: + return JsonResponse( + { + "error": "REQUEST_BODY_TOO_LARGE", + "detail": "The size of the request body exceeds the maximum allowed size.", + }, + status=413, + ) + + # If body size is OK, continue with the request + return self.get_response(request) diff --git a/apps/api/plane/seeds/data/cycles.json b/apps/api/plane/seeds/data/cycles.json new file mode 100644 index 000000000..484508f70 --- /dev/null +++ b/apps/api/plane/seeds/data/cycles.json @@ -0,0 +1,18 @@ +[ + { + "id": 1, + "name": "Cycle 1: Getting Started with Plane", + "project_id": 1, + "sort_order": 1, + "timezone": "UTC", + "type": "CURRENT" + }, + { + "id": 2, + "name": "Cycle 2: Collaboration & Customization", + "project_id": 1, + "sort_order": 2, + "timezone": "UTC", + "type": "UPCOMING" + } +] \ No newline at end of file diff --git a/apps/api/plane/seeds/data/issues.json b/apps/api/plane/seeds/data/issues.json index ca341304b..badd0e611 100644 --- a/apps/api/plane/seeds/data/issues.json +++ b/apps/api/plane/seeds/data/issues.json @@ -6,10 +6,12 @@ "description_html": "

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

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

First thing to try

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

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

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

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

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

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

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

  3. Set up the essentials:

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

    • Choose a priority level

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

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

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

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

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

Switch between layouts

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

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

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

Filter and display options

Need to focus on specific work?

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

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

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

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

", "description_stripped": "Plane offers multiple ways to look at your work items depending on what you need to see. Let's explore how to change views and customize them!Switch between layoutsLook at the top toolbar in your project. You'll see several layout icons.Click any of these icons to instantly switch between layouts.Tip: Different layouts work best for different needs. Try Board view for tracking progress, Calendar for deadline management, and Gantt for timeline planning! See Layouts for more info.Filter and display optionsNeed to focus on specific work?Click the Filters dropdown in the toolbar. Select criteria and choose which items to show.Click the Display dropdown to tailor how the information appears in your layoutCreated the perfect setup? Save it for later by clicking the the Save View button.Access saved views anytime from the Views section in your sidebar.", "sort_order": 5000, - "state_id": 1, + "state_id": 3, "labels": [], "priority": "none", - "project_id": 1 + "project_id": 1, + "cycle_id": 2, + "module_ids": [2] }, { "id": 6, @@ -67,8 +77,10 @@ "sort_order": 6000, "state_id": 1, "labels": [2], - "priority": "none", - "project_id": 1 + "priority": "low", + "project_id": 1, + "cycle_id": 2, + "module_ids": [2, 3] }, { "id": 7, @@ -80,6 +92,8 @@ "state_id": 1, "labels": [], "priority": "none", - "project_id": 1 + "project_id": 1, + "cycle_id": 2, + "module_ids": [2, 3] } ] diff --git a/apps/api/plane/seeds/data/modules.json b/apps/api/plane/seeds/data/modules.json new file mode 100644 index 000000000..f770276d7 --- /dev/null +++ b/apps/api/plane/seeds/data/modules.json @@ -0,0 +1,26 @@ +[ + { + "id": 1, + "name": "Core Workflow (System)", + "project_id": 1, + "sort_order": 1, + "status": "planned", + "description": "Manage, visualize, and track your work items across views." + }, + { + "id": 2, + "name": "Onboarding Flow (Feature)", + "project_id": 1, + "sort_order": 2, + "status": "backlog", + "description": "Everything about getting started - creating a project, inviting teammates." + }, + { + "id": 3, + "name": "Workspace Setup (Area)", + "project_id": 1, + "sort_order": 3, + "status": "in-progress", + "description": "The personalization layer - settings, labels, automations." + } +] \ No newline at end of file diff --git a/apps/api/plane/seeds/data/pages.json b/apps/api/plane/seeds/data/pages.json new file mode 100644 index 000000000..d719220bf --- /dev/null +++ b/apps/api/plane/seeds/data/pages.json @@ -0,0 +1,30 @@ +[ + { + "id": 1, + "name": "Project Design Spec", + "project_id": 1, + "description_html": "

Welcome to your Project Pages — the documentation hub for this specific project.
Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.

🧭 Project Summary

Field

Details

Project Name

Add your project name

Owner

Add project owner(s)

Status

🟢 Active / 🟡 In Progress / 🔴 Blocked

Start Date

Target Release

Linked Modules

Engineering, Security

Cycle(s)

Cycle 1, Cycle 2

🧩 Use tables to summarize key project metadata or links.

🎯 Goals & Objectives

🎯 Primary Goals

  • Deliver MVP with all core features

  • Validate feature adoption with early users

  • Prepare launch plan for v1 release

Success Metrics

Metric

Target

Owner

User adoption

100 active users

Growth

Performance

< 200ms latency

Backend

Design feedback

≥ 8/10 average rating

Design

📈 Define measurable outcomes and track progress alongside issues.

🧩 Scope & Deliverables

Deliverable

Owner

Status

Authentication flow

Backend

Done

Issue board UI

Frontend

🏗 In Progress

API integration

Backend

Pending

Documentation

PM

📝 Drafting

🧩 Use tables or checklists to track scope and ownership.

🧱 Architecture or System Design

Use this section for technical deep dives or diagrams.

Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs

", + "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Welcome to your \", \"type\": \"text\"}, {\"text\": \"Project Pages\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — the documentation hub for this specific project.\", \"type\": \"text\"}, {\"type\": \"hardBreak\"}, {\"text\": \"Each project in Plane can have its own Wiki space where you track \", \"type\": \"text\"}, {\"text\": \"plans, specs, updates, and learnings\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — all connected to your issues and modules.\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"compass\"}}, {\"text\": \" Project Summary\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Field\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Details\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Project Name\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"pencil\"}}, {\"text\": \" Add your project name\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Add project owner(s)\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"green_circle\"}}, {\"text\": \" Active / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"yellow_circle\"}}, {\"text\": \" In Progress / \", \"type\": \"text\"}, {\"type\": \"emoji\", \"attrs\": {\"name\": \"red_circle\"}}, {\"text\": \" Blocked\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Start Date\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target Release\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"—\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Linked Modules\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Engineering, Security\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle(s)\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [666], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Cycle 1, Cycle 2\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables to summarize key project metadata or links.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Goals & Objectives\", \"type\": \"text\"}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bullseye\"}}, {\"text\": \" Primary Goals\", \"type\": \"text\"}]}, {\"type\": \"bulletList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliver MVP with all core features\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Validate feature adoption with early users\", \"type\": \"text\"}]}]}, {\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Prepare launch plan for v1 release\", \"type\": \"text\"}]}]}]}, {\"type\": \"heading\", \"attrs\": {\"level\": 3, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"gear\"}}, {\"text\": \" Success Metrics\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Metric\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Target\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"User adoption\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"100 active users\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Growth\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Performance\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"< 200ms latency\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design feedback\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"≥ 8/10 average rating\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Design\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"chart_increasing\"}}, {\"text\": \" Define measurable outcomes and track progress alongside issues.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Scope & Deliverables\", \"type\": \"text\"}]}, {\"type\": \"table\", \"content\": [{\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Deliverable\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Owner\", \"type\": \"text\"}]}]}, {\"type\": \"tableHeader\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"background\": \"none\", \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Status\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Authentication flow\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Done\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Issue board UI\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Frontend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"building_construction\"}}, {\"text\": \" In Progress\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"API integration\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Backend\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"hourglass_flowing_sand\"}}, {\"text\": \" Pending\", \"type\": \"text\"}]}]}]}, {\"type\": \"tableRow\", \"attrs\": {\"textColor\": null, \"background\": null}, \"content\": [{\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Documentation\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"PM\", \"type\": \"text\"}]}]}, {\"type\": \"tableCell\", \"attrs\": {\"colspan\": 1, \"rowspan\": 1, \"colwidth\": [150], \"textColor\": null, \"background\": null, \"hideContent\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"memo\"}}, {\"text\": \" Drafting\", \"type\": \"text\"}]}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"jigsaw\"}}, {\"text\": \" Use tables or checklists to track scope and ownership.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Architecture or System Design\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"Use this section for \", \"type\": \"text\"}, {\"text\": \"technical deep dives\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" or diagrams.\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"Frontend → GraphQL → Backend → PostgreSQL\\nRedis for caching | RabbitMQ for background jobs\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", + "description_stripped": "Welcome to your Project Pages — the documentation hub for this specific project.Each project in Plane can have its own Wiki space where you track plans, specs, updates, and learnings — all connected to your issues and modules.🧭 Project SummaryFieldDetailsProject Name✏ Add your project nameOwnerAdd project owner(s)Status🟢 Active / 🟡 In Progress / 🔴 BlockedStart Date—Target Release—Linked ModulesEngineering, SecurityCycle(s)Cycle 1, Cycle 2🧩 Use tables to summarize key project metadata or links.🎯 Goals & Objectives🎯 Primary GoalsDeliver MVP with all core featuresValidate feature adoption with early usersPrepare launch plan for v1 release⚙ Success MetricsMetricTargetOwnerUser adoption100 active usersGrowthPerformance< 200ms latencyBackendDesign feedback≥ 8/10 average ratingDesign📈 Define measurable outcomes and track progress alongside issues.🧩 Scope & DeliverablesDeliverableOwnerStatusAuthentication flowBackend✅ DoneIssue board UIFrontend🏗 In ProgressAPI integrationBackend⏳ PendingDocumentationPM📝 Drafting🧩 Use tables or checklists to track scope and ownership.🧱 Architecture or System DesignUse this section for technical deep dives or diagrams.Frontend → GraphQL → Backend → PostgreSQL\nRedis for caching | RabbitMQ for background jobs", + "type": "PROJECT", + "access": 0, + "logo_props": { + "emoji": { + "url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f680.png", + "value": "128640" + }, + "in_use": "emoji" + } + }, + { + "id": 2, + "name": "Project Draft proposal", + "project_id": 1, + "description": "{\"type\": \"doc\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"This is your \", \"type\": \"text\"}, {\"text\": \"Project Draft area\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"It’s visible only to you (and collaborators you explicitly share with).\", \"type\": \"text\"}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"writing_hand\"}}, {\"text\": \" Current Work in Progress\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"speech_balloon\"}}, {\"text\": \" Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.\", \"type\": \"text\"}]}]}, {\"type\": \"taskList\", \"content\": [{\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Outline project summary and goals\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Draft new feature spec\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Review dependency list\", \"type\": \"text\"}]}]}, {\"type\": \"taskItem\", \"attrs\": {\"checked\": false}, \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \" Collect team feedback for next iteration\", \"type\": \"text\"}]}]}]}, {\"type\": \"blockquote\", \"content\": [{\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"check_mark_button\"}}, {\"text\": \" Tip: Turn these items into actionable issues when finalized.\", \"type\": \"text\"}]}]}, {\"type\": \"horizontalRule\"}, {\"type\": \"heading\", \"attrs\": {\"level\": 2, \"textAlign\": null}, \"content\": [{\"type\": \"emoji\", \"attrs\": {\"name\": \"bricks\"}}, {\"text\": \" Prototype Commands (if technical)\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}, \"content\": [{\"text\": \"You can also use \", \"type\": \"text\"}, {\"text\": \"code blocks\", \"type\": \"text\", \"marks\": [{\"type\": \"bold\"}]}, {\"text\": \" to store snippets, scripts, or notes:\", \"type\": \"text\"}]}, {\"type\": \"codeBlock\", \"attrs\": {\"language\": \"bash\"}, \"content\": [{\"text\": \"# Rebuild Docker containers\\ndocker compose build backend frontend\", \"type\": \"text\"}]}, {\"type\": \"paragraph\", \"attrs\": {\"textAlign\": null}}]}", + "description_html": "

This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.

It’s visible only to you (and collaborators you explicitly share with).

Current Work in Progress

💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet.

  • Outline project summary and goals

  • Draft new feature spec

  • Review dependency list

  • Collect team feedback for next iteration

Tip: Turn these items into actionable issues when finalized.

🧱 Prototype Commands (if technical)

You can also use code blocks to store snippets, scripts, or notes:

# Rebuild Docker containers\ndocker compose build backend frontend

", + "description_stripped": "This is your Project Draft area — a private space inside the project where you can experiment, outline ideas, or prepare content before sharing it on the public Project Page.It’s visible only to you (and collaborators you explicitly share with).✍ Current Work in Progress💬 Use this section to jot down raw ideas, rough notes, or specs that aren’t ready yet. Outline project summary and goals Draft new feature spec Review dependency list Collect team feedback for next iteration✅ Tip: Turn these items into actionable issues when finalized.🧱 Prototype Commands (if technical)You can also use code blocks to store snippets, scripts, or notes:# Rebuild Docker containers\ndocker compose build backend frontend", + "type": "PROJECT", + "access": 1, + "logo_props": "{\"emoji\": {\"url\": \"https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f9f1.png\", \"value\": \"129521\"}, \"in_use\": \"emoji\"}" + } +] \ No newline at end of file diff --git a/apps/api/plane/seeds/data/views.json b/apps/api/plane/seeds/data/views.json new file mode 100644 index 000000000..f9d182324 --- /dev/null +++ b/apps/api/plane/seeds/data/views.json @@ -0,0 +1,14 @@ +[ + { + "id": 1, + "name": "Project Urgent Tasks", + "description": "Project Urgent Tasks", + "access": 1, + "filters": {}, + "project_id": 1, + "display_filters": {"layout": "list", "calendar": {"layout": "month", "show_weekends": false}, "group_by": "state", "order_by": "sort_order", "sub_issue": false, "sub_group_by": null, "show_empty_groups": false}, + "display_properties": {"key": true, "link": true, "cycle": true, "state": true, "labels": true, "modules": true, "assignee": true, "due_date": true, "estimate": true, "priority": true, "created_on": true, "issue_type": true, "start_date": true, "updated_on": true, "customer_count": true, "sub_issue_count": true, "attachment_count": true, "customer_request_count": true}, + "sort_order": 75535, + "rich_filters": {"priority__in": "urgent"} + } +] \ No newline at end of file diff --git a/apps/api/plane/settings/common.py b/apps/api/plane/settings/common.py index 3c3410107..d47bf6293 100644 --- a/apps/api/plane/settings/common.py +++ b/apps/api/plane/settings/common.py @@ -62,15 +62,14 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", "crum.CurrentRequestUserMiddleware", "django.middleware.gzip.GZipMiddleware", + "plane.middleware.request_body_size.RequestBodySizeLimitMiddleware", "plane.middleware.logger.APITokenLogMiddleware", "plane.middleware.logger.RequestLoggerMiddleware", ] # Rest Framework settings REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework.authentication.SessionAuthentication", - ), + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework.authentication.SessionAuthentication",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",), "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), @@ -107,16 +106,10 @@ TEMPLATES = [ CORS_ALLOW_CREDENTIALS = True cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") # filter out empty strings -cors_allowed_origins = [ - origin.strip() for origin in cors_origins_raw.split(",") if origin.strip() -] +cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()] if cors_allowed_origins: CORS_ALLOWED_ORIGINS = cors_allowed_origins - secure_origins = ( - False - if [origin for origin in cors_allowed_origins if "http:" in origin] - else True - ) + secure_origins = False if [origin for origin in cors_allowed_origins if "http:" in origin] else True else: CORS_ALLOW_ALL_ORIGINS = True secure_origins = False @@ -153,9 +146,7 @@ else: if os.environ.get("ENABLE_READ_REPLICA", "0") == "1": if bool(os.environ.get("DATABASE_READ_REPLICA_URL")): # Parse database configuration from $DATABASE_URL - DATABASES["replica"] = dj_database_url.parse( - os.environ.get("DATABASE_READ_REPLICA_URL") - ) + DATABASES["replica"] = dj_database_url.parse(os.environ.get("DATABASE_READ_REPLICA_URL")) else: DATABASES["replica"] = { "ENGINE": "django.db.backends.postgresql", @@ -198,9 +189,7 @@ else: # Password validations AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" - }, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, @@ -237,11 +226,7 @@ EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" # Use Minio settings USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 -STORAGES = { - "staticfiles": { - "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" - } -} +STORAGES = {"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}} STORAGES["default"] = {"BACKEND": "plane.settings.storage.S3Storage"} AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") @@ -250,9 +235,7 @@ AWS_REGION = os.environ.get("AWS_REGION", "") AWS_DEFAULT_ACL = "public-read" AWS_QUERYSTRING_AUTH = False AWS_S3_FILE_OVERWRITE = False -AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.get( - "MINIO_ENDPOINT_URL", None -) +AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.get("MINIO_ENDPOINT_URL", None) if AWS_S3_ENDPOINT_URL and USE_MINIO: parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" @@ -317,14 +300,14 @@ DATA_UPLOAD_MAX_MEMORY_SIZE = int(os.environ.get("FILE_SIZE_LIMIT", 5242880)) SESSION_COOKIE_SECURE = secure_origins SESSION_COOKIE_HTTPONLY = True SESSION_ENGINE = "plane.db.models.session" -SESSION_COOKIE_AGE = os.environ.get("SESSION_COOKIE_AGE", 604800) +SESSION_COOKIE_AGE = int(os.environ.get("SESSION_COOKIE_AGE", 604800)) SESSION_COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "session-id") SESSION_COOKIE_DOMAIN = os.environ.get("COOKIE_DOMAIN", None) SESSION_SAVE_EVERY_REQUEST = os.environ.get("SESSION_SAVE_EVERY_REQUEST", "0") == "1" # Admin Cookie ADMIN_SESSION_COOKIE_NAME = "admin-session-id" -ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600) +ADMIN_SESSION_COOKIE_AGE = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)) # CSRF cookies CSRF_COOKIE_SECURE = secure_origins diff --git a/apps/api/plane/settings/local.py b/apps/api/plane/settings/local.py index 15af36a2d..84737712b 100644 --- a/apps/api/plane/settings/local.py +++ b/apps/api/plane/settings/local.py @@ -13,9 +13,7 @@ MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) # noqa DEBUG_TOOLBAR_PATCH_SETTINGS = False # Only show emails in console don't send it to smtp -EMAIL_BACKEND = os.environ.get( - "EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend" -) +EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend") CACHES = { "default": { diff --git a/apps/api/plane/settings/mongo.py b/apps/api/plane/settings/mongo.py index 57d25b477..879d0c436 100644 --- a/apps/api/plane/settings/mongo.py +++ b/apps/api/plane/settings/mongo.py @@ -101,9 +101,7 @@ class MongoConnection: try: db = cls.get_db() if db is None: - logger.warning( - f"Cannot access collection '{collection_name}': MongoDB not configured" - ) + logger.warning(f"Cannot access collection '{collection_name}': MongoDB not configured") return None return db[collection_name] except Exception as e: diff --git a/apps/api/plane/settings/production.py b/apps/api/plane/settings/production.py index 4f4e99bdb..4725db38a 100644 --- a/apps/api/plane/settings/production.py +++ b/apps/api/plane/settings/production.py @@ -28,9 +28,7 @@ LOGGING = { "version": 1, "disable_existing_loggers": True, "formatters": { - "verbose": { - "format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s" - }, + "verbose": {"format": "%(asctime)s [%(process)d] %(levelname)s %(name)s: %(message)s"}, "json": { "()": "pythonjsonlogger.jsonlogger.JsonFormatter", "fmt": "%(levelname)s %(asctime)s %(module)s %(name)s %(message)s", diff --git a/apps/api/plane/settings/storage.py b/apps/api/plane/settings/storage.py index 71709ebe0..0a0720086 100644 --- a/apps/api/plane/settings/storage.py +++ b/apps/api/plane/settings/storage.py @@ -28,9 +28,7 @@ class S3Storage(S3Boto3Storage): # Use the AWS_REGION environment variable for the region self.aws_region = os.environ.get("AWS_REGION") # Use the AWS_S3_ENDPOINT_URL environment variable for the endpoint URL - self.aws_s3_endpoint_url = os.environ.get( - "AWS_S3_ENDPOINT_URL" - ) or os.environ.get("MINIO_ENDPOINT_URL") + self.aws_s3_endpoint_url = os.environ.get("AWS_S3_ENDPOINT_URL") or os.environ.get("MINIO_ENDPOINT_URL") if os.environ.get("USE_MINIO") == "1": # Determine protocol based on environment variable @@ -44,11 +42,7 @@ class S3Storage(S3Boto3Storage): aws_access_key_id=self.aws_access_key_id, aws_secret_access_key=self.aws_secret_access_key, region_name=self.aws_region, - endpoint_url=( - f"{endpoint_protocol}://{request.get_host()}" - if request - else self.aws_s3_endpoint_url - ), + endpoint_url=(f"{endpoint_protocol}://{request.get_host()}" if request else self.aws_s3_endpoint_url), config=boto3.session.Config(signature_version="s3v4"), ) else: @@ -62,9 +56,7 @@ class S3Storage(S3Boto3Storage): config=boto3.session.Config(signature_version="s3v4"), ) - def generate_presigned_post( - self, object_name, file_type, file_size, expiration=3600 - ): + def generate_presigned_post(self, object_name, file_type, file_size, expiration=3600): """Generate a presigned URL to upload an S3 object""" fields = {"Content-Type": file_type} @@ -76,9 +68,7 @@ class S3Storage(S3Boto3Storage): # Add condition for the object name (key) if object_name.startswith("${filename}"): - conditions.append( - ["starts-with", "$key", object_name[: -len("${filename}")]] - ) + conditions.append(["starts-with", "$key", object_name[: -len("${filename}")]]) else: fields["key"] = object_name conditions.append({"key": object_name}) @@ -142,9 +132,7 @@ class S3Storage(S3Boto3Storage): def get_object_metadata(self, object_name): """Get the metadata for an S3 object""" try: - response = self.s3_client.head_object( - Bucket=self.aws_storage_bucket_name, Key=object_name - ) + response = self.s3_client.head_object(Bucket=self.aws_storage_bucket_name, Key=object_name) except ClientError as e: log_exception(e) return None @@ -152,11 +140,7 @@ class S3Storage(S3Boto3Storage): return { "ContentType": response.get("ContentType"), "ContentLength": response.get("ContentLength"), - "LastModified": ( - response.get("LastModified").isoformat() - if response.get("LastModified") - else None - ), + "LastModified": (response.get("LastModified").isoformat() if response.get("LastModified") else None), "ETag": response.get("ETag"), "Metadata": response.get("Metadata", {}), } diff --git a/apps/api/plane/space/serializer/issue.py b/apps/api/plane/space/serializer/issue.py index 64f151a2d..a89846cfc 100644 --- a/apps/api/plane/space/serializer/issue.py +++ b/apps/api/plane/space/serializer/issue.py @@ -130,12 +130,8 @@ class IssueLinkSerializer(BaseSerializer): # Validation if url already exists def create(self, validated_data): - if IssueLink.objects.filter( - url=validated_data.get("url"), issue_id=validated_data.get("issue_id") - ).exists(): - raise serializers.ValidationError( - {"error": "URL already exists for this Issue"} - ) + if IssueLink.objects.filter(url=validated_data.get("url"), issue_id=validated_data.get("issue_id")).exists(): + raise serializers.ValidationError({"error": "URL already exists for this Issue"}) return IssueLink.objects.create(**validated_data) @@ -167,12 +163,8 @@ class IssueSerializer(BaseSerializer): parent_detail = IssueStateFlatSerializer(read_only=True, source="parent") label_details = LabelSerializer(read_only=True, source="labels", many=True) assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) - related_issues = IssueRelationSerializer( - read_only=True, source="issue_relation", many=True - ) - issue_relations = RelatedIssueSerializer( - read_only=True, source="issue_related", many=True - ) + related_issues = IssueRelationSerializer(read_only=True, source="issue_relation", many=True) + issue_relations = RelatedIssueSerializer(read_only=True, source="issue_related", many=True) issue_cycle = IssueCycleDetailSerializer(read_only=True) issue_module = IssueModuleDetailSerializer(read_only=True) issue_link = IssueLinkSerializer(read_only=True, many=True) @@ -290,13 +282,9 @@ class IssueCreateSerializer(BaseSerializer): # Validate description content for security if "description_html" in data and data["description_html"]: - is_valid, error_msg, sanitized_html = validate_html_content( - data["description_html"] - ) + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) if not is_valid: - raise serializers.ValidationError( - {"error": "html content is not valid"} - ) + raise serializers.ValidationError({"error": "html content is not valid"}) # Update the data with sanitized HTML if available if sanitized_html is not None: data["description_html"] = sanitized_html @@ -431,9 +419,7 @@ class IssueVoteSerializer(BaseSerializer): class IssuePublicSerializer(BaseSerializer): - reactions = IssueReactionSerializer( - read_only=True, many=True, source="issue_reactions" - ) + reactions = IssueReactionSerializer(read_only=True, many=True, source="issue_reactions") votes = IssueVoteSerializer(read_only=True, many=True) module_ids = serializers.ListField(child=serializers.UUIDField(), required=False) label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) diff --git a/apps/api/plane/space/urls/intake.py b/apps/api/plane/space/urls/intake.py index 09aca16df..59fda12e2 100644 --- a/apps/api/plane/space/urls/intake.py +++ b/apps/api/plane/space/urls/intake.py @@ -20,9 +20,7 @@ urlpatterns = [ ), path( "anchor//intakes//intake-issues//", - IntakeIssuePublicViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + IntakeIssuePublicViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="intake-issue", ), path( diff --git a/apps/api/plane/space/urls/issue.py b/apps/api/plane/space/urls/issue.py index 2391fd38a..bb63e6695 100644 --- a/apps/api/plane/space/urls/issue.py +++ b/apps/api/plane/space/urls/issue.py @@ -22,9 +22,7 @@ urlpatterns = [ ), path( "anchor//issues//comments//", - IssueCommentPublicViewSet.as_view( - {"get": "retrieve", "patch": "partial_update", "delete": "destroy"} - ), + IssueCommentPublicViewSet.as_view({"get": "retrieve", "patch": "partial_update", "delete": "destroy"}), name="issue-comments-project-board", ), path( @@ -49,9 +47,7 @@ urlpatterns = [ ), path( "anchor//issues//votes/", - IssueVotePublicViewSet.as_view( - {"get": "list", "post": "create", "delete": "destroy"} - ), + IssueVotePublicViewSet.as_view({"get": "list", "post": "create", "delete": "destroy"}), name="issue-vote-project-board", ), ] diff --git a/apps/api/plane/space/utils/grouper.py b/apps/api/plane/space/utils/grouper.py index 4dd956b9f..f8e2c50a4 100644 --- a/apps/api/plane/space/utils/grouper.py +++ b/apps/api/plane/space/utils/grouper.py @@ -169,9 +169,7 @@ def issue_on_results( default=None, output_field=JSONField(), ), - filter=Q( - issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True - ), + filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True), distinct=True, ), ).values(*required_fields, "vote_items", "reaction_items") @@ -184,19 +182,16 @@ def issue_group_values( slug: str, project_id: Optional[str] = None, filters: Dict[str, Any] = {}, + queryset: Optional[QuerySet] = None, ) -> List[Union[str, Any]]: if field == "state_id": - queryset = State.objects.filter( - is_triage=False, workspace__slug=slug - ).values_list("id", flat=True) + queryset = State.objects.filter(is_triage=False, workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) else: return list(queryset) if field == "labels__id": - queryset = Label.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Label.objects.filter(workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] else: @@ -208,65 +203,42 @@ def issue_group_values( ).values_list("member_id", flat=True) else: return list( - WorkspaceMember.objects.filter( - workspace__slug=slug, is_active=True - ).values_list("member_id", flat=True) + WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True).values_list("member_id", flat=True) ) if field == "issue_module__module_id": - queryset = Module.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Module.objects.filter(workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] else: return list(queryset) + ["None"] if field == "cycle_id": - queryset = Cycle.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Cycle.objects.filter(workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] else: return list(queryset) + ["None"] if field == "project_id": - queryset = Project.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Project.objects.filter(workspace__slug=slug).values_list("id", flat=True) return list(queryset) if field == "priority": return ["low", "medium", "high", "urgent", "none"] if field == "state__group": return ["backlog", "unstarted", "started", "completed", "cancelled"] if field == "target_date": - queryset = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(**filters) - .values_list("target_date", flat=True) - .distinct() - ) + queryset = queryset.values_list("target_date", flat=True).distinct() if project_id: return list(queryset.filter(project_id=project_id)) else: return list(queryset) if field == "start_date": - queryset = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(**filters) - .values_list("start_date", flat=True) - .distinct() - ) + queryset = queryset.values_list("start_date", flat=True).distinct() if project_id: return list(queryset.filter(project_id=project_id)) else: return list(queryset) if field == "created_by": - queryset = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(**filters) - .values_list("created_by", flat=True) - .distinct() - ) + queryset = queryset.values_list("created_by", flat=True).distinct() if project_id: return list(queryset.filter(project_id=project_id)) else: diff --git a/apps/api/plane/space/views/asset.py b/apps/api/plane/space/views/asset.py index d2537671f..6ed5ab9b6 100644 --- a/apps/api/plane/space/views/asset.py +++ b/apps/api/plane/space/views/asset.py @@ -62,14 +62,10 @@ class EntityAssetEndpoint(BaseAPIView): def post(self, request, anchor): # Get the deploy board - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).first() + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() # Check if the project is published if not deploy_board: - return Response( - {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) # Get the asset name = request.data.get("name") @@ -120,9 +116,7 @@ class EntityAssetEndpoint(BaseAPIView): # Get the presigned URL storage = S3Storage(request=request) # Generate a presigned URL to share an S3 object - presigned_url = storage.generate_presigned_post( - object_name=asset_key, file_type=type, file_size=size - ) + presigned_url = storage.generate_presigned_post(object_name=asset_key, file_type=type, file_size=size) # Return the presigned URL return Response( { @@ -135,14 +129,10 @@ class EntityAssetEndpoint(BaseAPIView): def patch(self, request, anchor, pk): # Get the deploy board - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).first() + deploy_board = DeployBoard.objects.filter(anchor=anchor).first() # Check if the project is published if not deploy_board: - return Response( - {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) # get the asset id asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace) @@ -160,18 +150,12 @@ class EntityAssetEndpoint(BaseAPIView): def delete(self, request, anchor, pk): # Get the deploy board - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).first() + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first() # Check if the project is published if not deploy_board: - return Response( - {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) # Get the asset - asset = FileAsset.objects.get( - id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id - ) + asset = FileAsset.objects.get(id=pk, workspace=deploy_board.workspace, project_id=deploy_board.project_id) # Check deleted assets asset.is_deleted = True asset.deleted_at = timezone.now() @@ -185,14 +169,10 @@ class AssetRestoreEndpoint(BaseAPIView): def post(self, request, anchor, asset_id): # Get the deploy board - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).first() + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first() # Check if the project is published if not deploy_board: - return Response( - {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) # Get the asset asset = FileAsset.all_objects.get(id=asset_id, workspace=deploy_board.workspace) @@ -207,22 +187,16 @@ class EntityBulkAssetEndpoint(BaseAPIView): def post(self, request, anchor, entity_id): # Get the deploy board - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).first() + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first() # Check if the project is published if not deploy_board: - return Response( - {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) asset_ids = request.data.get("asset_ids", []) # Check if the asset ids are provided if not asset_ids: - return Response( - {"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "No asset ids provided."}, status=status.HTTP_400_BAD_REQUEST) # get the asset id assets = FileAsset.objects.filter( diff --git a/apps/api/plane/space/views/base.py b/apps/api/plane/space/views/base.py index 82809f08d..9be6a2e10 100644 --- a/apps/api/plane/space/views/base.py +++ b/apps/api/plane/space/views/base.py @@ -105,9 +105,7 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): if settings.DEBUG: from django.db import connection - print( - f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" - ) + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") return response except Exception as exc: @@ -190,9 +188,7 @@ class BaseAPIView(TimezoneMixin, APIView, BasePaginator): if settings.DEBUG: from django.db import connection - print( - f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}" - ) + print(f"{request.method} - {request.get_full_path()} of Queries: {len(connection.queries)}") return response except Exception as exc: diff --git a/apps/api/plane/space/views/cycle.py b/apps/api/plane/space/views/cycle.py index 399d626a1..505c17ba4 100644 --- a/apps/api/plane/space/views/cycle.py +++ b/apps/api/plane/space/views/cycle.py @@ -14,9 +14,7 @@ class ProjectCyclesEndpoint(BaseAPIView): def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: - return Response( - {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND) cycles = Cycle.objects.filter( workspace__slug=deploy_board.workspace.slug, diff --git a/apps/api/plane/space/views/intake.py b/apps/api/plane/space/views/intake.py index 83ec354c6..60d4443b5 100644 --- a/apps/api/plane/space/views/intake.py +++ b/apps/api/plane/space/views/intake.py @@ -50,9 +50,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): return IntakeIssue.objects.none() def list(self, request, anchor, intake_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if project_deploy_board.intake is None: return Response( {"error": "Intake is not enabled for this Project Board"}, @@ -95,9 +93,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): .prefetch_related( Prefetch( "issue_intake", - queryset=IntakeIssue.objects.only( - "status", "duplicate_to", "snoozed_till", "source" - ), + queryset=IntakeIssue.objects.only("status", "duplicate_to", "snoozed_till", "source"), ) ) ) @@ -105,9 +101,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): return Response(issues_data, status=status.HTTP_200_OK) def create(self, request, anchor, intake_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if project_deploy_board.intake is None: return Response( {"error": "Intake is not enabled for this Project Board"}, @@ -115,9 +109,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): ) if not request.data.get("issue", {}).get("name", False): - return Response( - {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST) # Check for valid priority if request.data.get("issue", {}).get("priority", "none") not in [ @@ -127,17 +119,13 @@ class IntakeIssuePublicViewSet(BaseViewSet): "urgent", "none", ]: - return Response( - {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST - ) + return Response({"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST) # create an issue issue = Issue.objects.create( name=request.data.get("issue", {}).get("name"), description=request.data.get("issue", {}).get("description", {}), - description_html=request.data.get("issue", {}).get( - "description_html", "

" - ), + description_html=request.data.get("issue", {}).get("description_html", "

"), priority=request.data.get("issue", {}).get("priority", "low"), project_id=project_deploy_board.project_id, ) @@ -164,9 +152,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, anchor, intake_id, pk): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if project_deploy_board.intake is None: return Response( {"error": "Intake is not enabled for this Project Board"}, @@ -197,9 +183,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): # viewers and guests since only viewers and guests issue_data = { "name": issue_data.get("name", issue.name), - "description_html": issue_data.get( - "description_html", issue.description_html - ), + "description_html": issue_data.get("description_html", issue.description_html), "description": issue_data.get("description", issue.description), } @@ -221,9 +205,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue.id), project_id=str(project_deploy_board.project_id), - current_instance=json.dumps( - IssueSerializer(current_instance).data, cls=DjangoJSONEncoder - ), + current_instance=json.dumps(IssueSerializer(current_instance).data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), ) issue_serializer.save() @@ -231,9 +213,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): return Response(issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, anchor, intake_id, pk): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if project_deploy_board.intake is None: return Response( {"error": "Intake is not enabled for this Project Board"}, @@ -255,9 +235,7 @@ class IntakeIssuePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, anchor, intake_id, pk): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if project_deploy_board.intake is None: return Response( {"error": "Intake is not enabled for this Project Board"}, diff --git a/apps/api/plane/space/views/issue.py b/apps/api/plane/space/views/issue.py index 93aaaa7b9..220fc1307 100644 --- a/apps/api/plane/space/views/issue.py +++ b/apps/api/plane/space/views/issue.py @@ -73,13 +73,9 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).first() + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").first() if not deploy_board: - return Response( - {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND) project_id = deploy_board.entity_identifier slug = deploy_board.workspace.slug @@ -94,14 +90,10 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): queryset=IssueReaction.objects.select_related("actor"), ) ) - .prefetch_related( - Prefetch("votes", queryset=IssueVote.objects.select_related("actor")) - ) + .prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor"))) .annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -139,17 +131,13 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): sub_group_by = request.GET.get("sub_group_by", False) # issue queryset - issue_queryset = issue_queryset_grouper( - queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by - ) + issue_queryset = issue_queryset_grouper(queryset=issue_queryset, group_by=group_by, sub_group_by=sub_group_by) if group_by: if sub_group_by: if group_by == sub_group_by: return Response( - { - "error": "Group by and sub group by cannot have same parameters" - }, + {"error": "Group by and sub group by cannot have same parameters"}, status=status.HTTP_400_BAD_REQUEST, ) else: @@ -215,9 +203,7 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): order_by=order_by_param, request=request, queryset=issue_queryset, - on_results=lambda issues: issue_on_results( - group_by=group_by, issues=issues, sub_group_by=sub_group_by - ), + on_results=lambda issues: issue_on_results(group_by=group_by, issues=issues, sub_group_by=sub_group_by), ) @@ -237,9 +223,7 @@ class IssueCommentPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = DeployBoard.objects.get( - anchor=self.kwargs.get("anchor"), entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project") if project_deploy_board.is_comments_enabled: return self.filter_queryset( super() @@ -267,9 +251,7 @@ class IssueCommentPublicViewSet(BaseViewSet): return IssueComment.objects.none() def create(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if not project_deploy_board.is_comments_enabled: return Response( @@ -308,9 +290,7 @@ class IssueCommentPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, anchor, issue_id, pk): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if not project_deploy_board.is_comments_enabled: return Response( @@ -327,18 +307,14 @@ class IssueCommentPublicViewSet(BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_deploy_board.project_id), - current_instance=json.dumps( - IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder - ), + current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, anchor, issue_id, pk): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if not project_deploy_board.is_comments_enabled: return Response( @@ -352,9 +328,7 @@ class IssueCommentPublicViewSet(BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_deploy_board.project_id), - current_instance=json.dumps( - IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder - ), + current_instance=json.dumps(IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder), epoch=int(timezone.now().timestamp()), ) comment.delete() @@ -386,9 +360,7 @@ class IssueReactionPublicViewSet(BaseViewSet): return IssueReaction.objects.none() def create(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if not project_deploy_board.is_reactions_enabled: return Response( @@ -425,9 +397,7 @@ class IssueReactionPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, anchor, issue_id, reaction_code): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if not project_deploy_board.is_reactions_enabled: return Response( @@ -446,9 +416,7 @@ class IssueReactionPublicViewSet(BaseViewSet): actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(project_deploy_board.project_id), - current_instance=json.dumps( - {"reaction": str(reaction_code), "identifier": str(issue_reaction.id)} - ), + current_instance=json.dumps({"reaction": str(reaction_code), "identifier": str(issue_reaction.id)}), epoch=int(timezone.now().timestamp()), ) issue_reaction.delete() @@ -461,9 +429,7 @@ class CommentReactionPublicViewSet(BaseViewSet): def get_queryset(self): try: - project_deploy_board = DeployBoard.objects.get( - anchor=self.kwargs.get("anchor"), entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=self.kwargs.get("anchor"), entity_name="project") if project_deploy_board.is_reactions_enabled: return ( super() @@ -479,9 +445,7 @@ class CommentReactionPublicViewSet(BaseViewSet): return CommentReaction.objects.none() def create(self, request, anchor, comment_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if not project_deploy_board.is_reactions_enabled: return Response( @@ -518,9 +482,7 @@ class CommentReactionPublicViewSet(BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, anchor, comment_id, reaction_code): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") if not project_deploy_board.is_reactions_enabled: return Response( {"error": "Reactions are not enabled for this board"}, @@ -575,9 +537,7 @@ class IssueVotePublicViewSet(BaseViewSet): return IssueVote.objects.none() def create(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") issue_vote, _ = IssueVote.objects.get_or_create( actor_id=request.user.id, project_id=project_deploy_board.project_id, @@ -607,9 +567,7 @@ class IssueVotePublicViewSet(BaseViewSet): return Response(serializer.data, status=status.HTTP_201_CREATED) def destroy(self, request, anchor, issue_id): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") issue_vote = IssueVote.objects.get( issue_id=issue_id, actor_id=request.user.id, @@ -622,9 +580,7 @@ class IssueVotePublicViewSet(BaseViewSet): actor_id=str(self.request.user.id), issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(project_deploy_board.project_id), - current_instance=json.dumps( - {"vote": str(issue_vote.vote), "identifier": str(issue_vote.id)} - ), + current_instance=json.dumps({"vote": str(issue_vote.vote), "identifier": str(issue_vote.id)}), epoch=int(timezone.now().timestamp()), ) issue_vote.delete() @@ -647,9 +603,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): .prefetch_related("assignees", "labels", "issue_module__module") .annotate( cycle_id=Subquery( - CycleIssue.objects.filter( - issue=OuterRef("id"), deleted_at__isnull=True - ).values("cycle_id")[:1] + CycleIssue.objects.filter(issue=OuterRef("id"), deleted_at__isnull=True).values("cycle_id")[:1] ) ) .annotate( @@ -657,10 +611,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): ArrayAgg( "labels__id", distinct=True, - filter=Q( - ~Q(labels__id__isnull=True) - & Q(label_issue__deleted_at__isnull=True) - ), + filter=Q(~Q(labels__id__isnull=True) & Q(label_issue__deleted_at__isnull=True)), ), Value([], output_field=ArrayField(UUIDField())), ), @@ -693,9 +644,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): queryset=IssueReaction.objects.select_related("issue", "actor"), ) ) - .prefetch_related( - Prefetch("votes", queryset=IssueVote.objects.select_related("actor")) - ) + .prefetch_related(Prefetch("votes", queryset=IssueVote.objects.select_related("actor"))) .annotate( vote_items=ArrayAgg( Case( @@ -771,9 +720,7 @@ class IssueRetrievePublicEndpoint(BaseAPIView): default=Value(None), output_field=CharField(), ), - display_name=F( - "issue_reactions__actor__display_name" - ), + display_name=F("issue_reactions__actor__display_name"), ), ), ), diff --git a/apps/api/plane/space/views/label.py b/apps/api/plane/space/views/label.py index ad0c8f0ca..51ddb832e 100644 --- a/apps/api/plane/space/views/label.py +++ b/apps/api/plane/space/views/label.py @@ -14,9 +14,7 @@ class ProjectLabelsEndpoint(BaseAPIView): def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: - return Response( - {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND) labels = Label.objects.filter( workspace__slug=deploy_board.workspace.slug, diff --git a/apps/api/plane/space/views/meta.py b/apps/api/plane/space/views/meta.py index dc7ecb648..be612db70 100644 --- a/apps/api/plane/space/views/meta.py +++ b/apps/api/plane/space/views/meta.py @@ -16,17 +16,13 @@ class ProjectMetaDataEndpoint(BaseAPIView): try: deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") except DeployBoard.DoesNotExist: - return Response( - {"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND - ) + 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 - ) + 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/apps/api/plane/space/views/module.py b/apps/api/plane/space/views/module.py index 7db676537..7c4628f64 100644 --- a/apps/api/plane/space/views/module.py +++ b/apps/api/plane/space/views/module.py @@ -14,9 +14,7 @@ class ProjectModulesEndpoint(BaseAPIView): def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: - return Response( - {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND) modules = Module.objects.filter( workspace__slug=deploy_board.workspace.slug, diff --git a/apps/api/plane/space/views/project.py b/apps/api/plane/space/views/project.py index 1574871ef..6f332781f 100644 --- a/apps/api/plane/space/views/project.py +++ b/apps/api/plane/space/views/project.py @@ -16,9 +16,7 @@ class ProjectDeployBoardPublicSettingsEndpoint(BaseAPIView): permission_classes = [AllowAny] def get(self, request, anchor): - project_deploy_board = DeployBoard.objects.get( - anchor=anchor, entity_name="project" - ) + project_deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project") serializer = DeployBoardSerializer(project_deploy_board) return Response(serializer.data, status=status.HTTP_200_OK) @@ -27,16 +25,12 @@ class WorkspaceProjectDeployBoardEndpoint(BaseAPIView): permission_classes = [AllowAny] def get(self, request, anchor): - deploy_board = DeployBoard.objects.filter( - anchor=anchor, entity_name="project" - ).values_list + deploy_board = DeployBoard.objects.filter(anchor=anchor, entity_name="project").values_list projects = ( Project.objects.filter(workspace=deploy_board.workspace) .annotate( is_public=Exists( - DeployBoard.objects.filter( - anchor=anchor, project_id=OuterRef("pk"), entity_name="project" - ) + DeployBoard.objects.filter(anchor=anchor, project_id=OuterRef("pk"), entity_name="project") ) ) .filter(is_public=True) diff --git a/apps/api/plane/space/views/state.py b/apps/api/plane/space/views/state.py index 39f2b1bfd..c13186600 100644 --- a/apps/api/plane/space/views/state.py +++ b/apps/api/plane/space/views/state.py @@ -17,9 +17,7 @@ class ProjectStatesEndpoint(BaseAPIView): def get(self, request, anchor): deploy_board = DeployBoard.objects.filter(anchor=anchor).first() if not deploy_board: - return Response( - {"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND - ) + return Response({"error": "Invalid anchor"}, status=status.HTTP_404_NOT_FOUND) states = State.objects.filter( ~Q(name="Triage"), diff --git a/apps/api/plane/tests/conftest.py b/apps/api/plane/tests/conftest.py index 15f3a8a28..abfede197 100644 --- a/apps/api/plane/tests/conftest.py +++ b/apps/api/plane/tests/conftest.py @@ -131,8 +131,6 @@ def workspace(create_user): slug="test-workspace", ) - WorkspaceMember.objects.create( - workspace=created_workspace, member=create_user, role=20 - ) + WorkspaceMember.objects.create(workspace=created_workspace, member=create_user, role=20) return created_workspace diff --git a/apps/api/plane/tests/conftest_external.py b/apps/api/plane/tests/conftest_external.py index b4853e531..cebb768ca 100644 --- a/apps/api/plane/tests/conftest_external.py +++ b/apps/api/plane/tests/conftest_external.py @@ -66,27 +66,15 @@ def mock_mongodb(): # Configure common MongoDB collection operations mock_mongo_collection.find_one.return_value = None - mock_mongo_collection.find.return_value = MagicMock( - __iter__=lambda x: iter([]), count=lambda: 0 - ) - mock_mongo_collection.insert_one.return_value = MagicMock( - inserted_id="mock_id_123", acknowledged=True - ) + mock_mongo_collection.find.return_value = MagicMock(__iter__=lambda x: iter([]), count=lambda: 0) + mock_mongo_collection.insert_one.return_value = MagicMock(inserted_id="mock_id_123", acknowledged=True) mock_mongo_collection.insert_many.return_value = MagicMock( inserted_ids=["mock_id_123", "mock_id_456"], acknowledged=True ) - mock_mongo_collection.update_one.return_value = MagicMock( - modified_count=1, matched_count=1, acknowledged=True - ) - mock_mongo_collection.update_many.return_value = MagicMock( - modified_count=2, matched_count=2, acknowledged=True - ) - mock_mongo_collection.delete_one.return_value = MagicMock( - deleted_count=1, acknowledged=True - ) - mock_mongo_collection.delete_many.return_value = MagicMock( - deleted_count=2, acknowledged=True - ) + mock_mongo_collection.update_one.return_value = MagicMock(modified_count=1, matched_count=1, acknowledged=True) + mock_mongo_collection.update_many.return_value = MagicMock(modified_count=2, matched_count=2, acknowledged=True) + mock_mongo_collection.delete_one.return_value = MagicMock(deleted_count=1, acknowledged=True) + mock_mongo_collection.delete_many.return_value = MagicMock(deleted_count=2, acknowledged=True) mock_mongo_collection.count_documents.return_value = 0 # Start the patch diff --git a/apps/api/plane/tests/contract/api/test_cycles.py b/apps/api/plane/tests/contract/api/test_cycles.py new file mode 100644 index 000000000..fb4ad3f33 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_cycles.py @@ -0,0 +1,382 @@ +import pytest +from rest_framework import status +from django.db import IntegrityError +from django.utils import timezone +from datetime import datetime, timedelta +from uuid import uuid4 + +from plane.db.models import Cycle, Project, ProjectMember + + +@pytest.fixture +def project(db, workspace, create_user): + """Create a test project with the user as a member""" + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin role + is_active=True, + ) + return project + + +@pytest.fixture +def cycle_data(): + """Sample cycle data for tests""" + return { + "name": "Test Cycle", + "description": "A test cycle for unit tests", + } + + +@pytest.fixture +def draft_cycle_data(): + """Sample draft cycle data (no dates)""" + return { + "name": "Draft Cycle", + "description": "A draft cycle without dates", + } + + +@pytest.fixture +def create_cycle(db, project, create_user): + """Create a test cycle""" + return Cycle.objects.create( + name="Existing Cycle", + description="An existing cycle", + start_date=timezone.now() + timedelta(days=1), + end_date=timezone.now() + timedelta(days=7), + project=project, + workspace=project.workspace, + owned_by=create_user, + ) + + + + +@pytest.mark.contract +class TestCycleListCreateAPIEndpoint: + """Test Cycle List and Create API Endpoint""" + + def get_cycle_url(self, workspace_slug, project_id): + """Helper to get cycle endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/" + + @pytest.mark.django_db + def test_create_cycle_success(self, api_key_client, workspace, project, cycle_data): + """Test successful cycle creation""" + url = self.get_cycle_url(workspace.slug, project.id) + + response = api_key_client.post(url, cycle_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + assert Cycle.objects.count() == 1 + + created_cycle = Cycle.objects.first() + assert created_cycle.name == cycle_data["name"] + assert created_cycle.description == cycle_data["description"] + assert created_cycle.project == project + assert created_cycle.owned_by_id is not None + + + @pytest.mark.django_db + def test_create_cycle_invalid_data(self, api_key_client, workspace, project): + """Test cycle creation with invalid data""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Test with empty data + response = api_key_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Test with missing name + response = api_key_client.post(url, {"description": "Test cycle"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_cycle_invalid_date_combination(self, api_key_client, workspace, project): + """Test cycle creation with invalid date combination (only start_date)""" + url = self.get_cycle_url(workspace.slug, project.id) + + invalid_data = { + "name": "Invalid Cycle", + "start_date": (timezone.now() + timedelta(days=1)).isoformat(), + # Missing end_date + } + + response = api_key_client.post(url, invalid_data, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Both start date and end date are either required or are to be null" in response.data["error"] + + @pytest.mark.django_db + def test_create_cycle_with_external_id(self, api_key_client, workspace, project): + """Test creating cycle with external ID""" + url = self.get_cycle_url(workspace.slug, project.id) + + cycle_data = { + "name": "External Cycle", + "description": "A cycle with external ID", + "external_id": "ext-123", + "external_source": "github", + } + + response = api_key_client.post(url, cycle_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + created_cycle = Cycle.objects.first() + assert created_cycle.external_id == "ext-123" + assert created_cycle.external_source == "github" + + @pytest.mark.django_db + def test_create_cycle_duplicate_external_id(self, api_key_client, workspace, project, create_user): + """Test creating cycle with duplicate external ID""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Create first cycle + Cycle.objects.create( + name="First Cycle", + project=project, + workspace=workspace, + external_id="ext-123", + external_source="github", + owned_by=create_user, + ) + + # Try to create second cycle with same external ID + cycle_data = { + "name": "Second Cycle", + "external_id": "ext-123", + "external_source": "github", + "owned_by": create_user.id, + } + + response = api_key_client.post(url, cycle_data, format="json") + + assert response.status_code == status.HTTP_409_CONFLICT + assert "same external id" in response.data["error"] + + @pytest.mark.django_db + def test_list_cycles_success(self, api_key_client, workspace, project, create_cycle, create_user): + """Test successful cycle listing""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Create additional cycles + Cycle.objects.create( + name="Cycle 2", + project=project, + workspace=workspace, + start_date=timezone.now() + timedelta(days=10), + end_date=timezone.now() + timedelta(days=17), + owned_by=create_user, + ) + Cycle.objects.create( + name="Cycle 3", + project=project, + workspace=workspace, + start_date=timezone.now() + timedelta(days=20), + end_date=timezone.now() + timedelta(days=27), + owned_by=create_user, + ) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + assert len(response.data["results"]) == 3 # Including create_cycle fixture + + @pytest.mark.django_db + def test_list_cycles_with_view_filter(self, api_key_client, workspace, project, create_user): + """Test cycle listing with different view filters""" + url = self.get_cycle_url(workspace.slug, project.id) + + # Create cycles in different states + now = timezone.now() + + # Current cycle (started but not ended) + Cycle.objects.create( + name="Current Cycle", + project=project, + workspace=workspace, + start_date=now - timedelta(days=1), + end_date=now + timedelta(days=6), + owned_by=create_user, + ) + + # Upcoming cycle + Cycle.objects.create( + name="Upcoming Cycle", + project=project, + workspace=workspace, + start_date=now + timedelta(days=1), + end_date=now + timedelta(days=8), + owned_by=create_user, + ) + + # Completed cycle + Cycle.objects.create( + name="Completed Cycle", + project=project, + workspace=workspace, + start_date=now - timedelta(days=10), + end_date=now - timedelta(days=3), + owned_by=create_user, + ) + + # Draft cycle + Cycle.objects.create( + name="Draft Cycle", + project=project, + workspace=workspace, + owned_by=create_user, + ) + + # Test current cycles + response = api_key_client.get(url, {"cycle_view": "current"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["name"] == "Current Cycle" + + # Test upcoming cycles + response = api_key_client.get(url, {"cycle_view": "upcoming"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["name"] == "Upcoming Cycle" + + # Test completed cycles + response = api_key_client.get(url, {"cycle_view": "completed"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["name"] == "Completed Cycle" + + # Test draft cycles + response = api_key_client.get(url, {"cycle_view": "draft"}) + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + assert response.data["results"][0]["name"] == "Draft Cycle" + + +@pytest.mark.contract +class TestCycleDetailAPIEndpoint: + """Test Cycle Detail API Endpoint""" + + def get_cycle_detail_url(self, workspace_slug, project_id, cycle_id): + """Helper to get cycle detail endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/cycles/{cycle_id}/" + + @pytest.mark.django_db + def test_get_cycle_success(self, api_key_client, workspace, project, create_cycle): + """Test successful cycle retrieval""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(create_cycle.id) + assert response.data["name"] == create_cycle.name + assert response.data["description"] == create_cycle.description + + @pytest.mark.django_db + def test_get_cycle_not_found(self, api_key_client, workspace, project): + """Test getting non-existent cycle""" + fake_id = uuid4() + url = self.get_cycle_detail_url(workspace.slug, project.id, fake_id) + + response = api_key_client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_update_cycle_success(self, api_key_client, workspace, project, create_cycle): + """Test successful cycle update""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + update_data = { + "name": f"Updated Cycle {uuid4()}", + "description": "Updated description", + } + + response = api_key_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_200_OK + + create_cycle.refresh_from_db() + assert create_cycle.name == update_data["name"] + assert create_cycle.description == update_data["description"] + + @pytest.mark.django_db + def test_update_cycle_invalid_data(self, api_key_client, workspace, project, create_cycle): + """Test cycle update with invalid data""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + update_data = {"name": ""} + response = api_key_client.patch(url, update_data, format="json") + + # This might be 400 if name is required, or 200 if empty names are allowed + assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK] + + @pytest.mark.django_db + def test_update_cycle_with_external_id_conflict(self, api_key_client, workspace, project, create_cycle, create_user ): + """Test cycle update with conflicting external ID""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + # Create another cycle with external ID + Cycle.objects.create( + name="Another Cycle", + project=project, + workspace=workspace, + external_id="ext-456", + external_source="github", + owned_by=create_user, + ) + + # Try to update cycle with same external ID + update_data = { + "external_id": "ext-456", + "external_source": "github", + } + + response = api_key_client.patch(url, update_data, format="json") + + assert response.status_code == status.HTTP_409_CONFLICT + assert "same external id" in response.data["error"] + + @pytest.mark.django_db + def test_delete_cycle_success(self, api_key_client, workspace, project, create_cycle): + """Test successful cycle deletion""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + response = api_key_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Cycle.objects.filter(id=create_cycle.id).exists() + + @pytest.mark.django_db + def test_cycle_metrics_annotation(self, api_key_client, workspace, project, create_cycle): + """Test that cycle includes issue metrics annotations""" + url = self.get_cycle_detail_url(workspace.slug, project.id, create_cycle.id) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + + # Check that metrics are included in response + cycle_data = response.data + assert "total_issues" in cycle_data + assert "completed_issues" in cycle_data + assert "cancelled_issues" in cycle_data + assert "started_issues" in cycle_data + assert "unstarted_issues" in cycle_data + assert "backlog_issues" in cycle_data + + # All should be 0 for a new cycle + assert cycle_data["total_issues"] == 0 + assert cycle_data["completed_issues"] == 0 + assert cycle_data["cancelled_issues"] == 0 + assert cycle_data["started_issues"] == 0 + assert cycle_data["unstarted_issues"] == 0 + assert cycle_data["backlog_issues"] == 0 \ No newline at end of file diff --git a/apps/api/plane/tests/contract/api/test_labels.py b/apps/api/plane/tests/contract/api/test_labels.py index a27bc31dc..a3a43d90a 100644 --- a/apps/api/plane/tests/contract/api/test_labels.py +++ b/apps/api/plane/tests/contract/api/test_labels.py @@ -1,6 +1,5 @@ import pytest from rest_framework import status -from django.db import IntegrityError from uuid import uuid4 from plane.db.models import Label, Project, ProjectMember @@ -104,9 +103,7 @@ class TestLabelListCreateAPIEndpoint: assert created_label.external_source == "github" @pytest.mark.django_db - def test_create_label_duplicate_external_id( - self, api_key_client, workspace, project - ): + def test_create_label_duplicate_external_id(self, api_key_client, workspace, project): """Test creating label with duplicate external ID""" url = self.get_label_url(workspace.slug, project.id) @@ -132,19 +129,13 @@ class TestLabelListCreateAPIEndpoint: assert "same external id" in response.data["error"] @pytest.mark.django_db - def test_list_labels_success( - self, api_key_client, workspace, project, create_label - ): + def test_list_labels_success(self, api_key_client, workspace, project, create_label): """Test successful label listing""" url = self.get_label_url(workspace.slug, project.id) # Create additional labels - Label.objects.create( - name="Label 2", project=project, workspace=workspace, color="#00FF00" - ) - Label.objects.create( - name="Label 3", project=project, workspace=workspace, color="#0000FF" - ) + Label.objects.create(name="Label 2", project=project, workspace=workspace, color="#00FF00") + Label.objects.create(name="Label 3", project=project, workspace=workspace, color="#0000FF") response = api_key_client.get(url) @@ -185,9 +176,7 @@ class TestLabelDetailAPIEndpoint: assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.django_db - def test_update_label_success( - self, api_key_client, workspace, project, create_label - ): + def test_update_label_success(self, api_key_client, workspace, project, create_label): """Test successful label update""" url = self.get_label_detail_url(workspace.slug, project.id, create_label.id) @@ -203,9 +192,7 @@ class TestLabelDetailAPIEndpoint: assert create_label.name == update_data["name"] @pytest.mark.django_db - def test_update_label_invalid_data( - self, api_key_client, workspace, project, create_label - ): + def test_update_label_invalid_data(self, api_key_client, workspace, project, create_label): """Test label update with invalid data""" url = self.get_label_detail_url(workspace.slug, project.id, create_label.id) @@ -216,9 +203,7 @@ class TestLabelDetailAPIEndpoint: assert response.status_code in [status.HTTP_400_BAD_REQUEST, status.HTTP_200_OK] @pytest.mark.django_db - def test_delete_label_success( - self, api_key_client, workspace, project, create_label - ): + def test_delete_label_success(self, api_key_client, workspace, project, create_label): """Test successful label deletion""" url = self.get_label_detail_url(workspace.slug, project.id, create_label.id) diff --git a/apps/api/plane/tests/contract/app/test_api_token.py b/apps/api/plane/tests/contract/app/test_api_token.py index 5160788de..35d92b11e 100644 --- a/apps/api/plane/tests/contract/app/test_api_token.py +++ b/apps/api/plane/tests/contract/app/test_api_token.py @@ -14,9 +14,7 @@ class TestApiTokenEndpoint: # POST /user/api-tokens/ tests @pytest.mark.django_db - def test_create_api_token_success( - self, session_client, create_user, api_token_data - ): + def test_create_api_token_success(self, session_client, create_user, api_token_data): """Test successful API token creation""" # Arrange session_client.force_authenticate(user=create_user) @@ -38,9 +36,7 @@ class TestApiTokenEndpoint: assert token.label == api_token_data["label"] @pytest.mark.django_db - def test_create_api_token_for_bot_user( - self, session_client, create_bot_user, api_token_data - ): + def test_create_api_token_for_bot_user(self, session_client, create_bot_user, api_token_data): """Test API token creation for bot user""" # Arrange session_client.force_authenticate(user=create_bot_user) @@ -111,9 +107,7 @@ class TestApiTokenEndpoint: APIToken.objects.create(label="Token 1", user=create_user, user_type=0) APIToken.objects.create(label="Token 2", user=create_user, user_type=0) # Create a service token (should be excluded) - APIToken.objects.create( - label="Service Token", user=create_user, user_type=0, is_service=True - ) + APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True) url = reverse("api-tokens") # Act @@ -140,9 +134,7 @@ class TestApiTokenEndpoint: # GET /user/api-tokens// tests @pytest.mark.django_db - def test_get_specific_api_token( - self, session_client, create_user, create_api_token_for_user - ): + def test_get_specific_api_token(self, session_client, create_user, create_api_token_for_user): """Test retrieving a specific API token""" # Arrange session_client.force_authenticate(user=create_user) @@ -155,9 +147,7 @@ class TestApiTokenEndpoint: assert response.status_code == status.HTTP_200_OK assert str(response.data["id"]) == str(create_api_token_for_user.pk) assert response.data["label"] == create_api_token_for_user.label - assert ( - "token" not in response.data - ) # Token should not be visible in read serializer + assert "token" not in response.data # Token should not be visible in read serializer @pytest.mark.django_db def test_get_nonexistent_api_token(self, session_client, create_user): @@ -182,9 +172,7 @@ class TestApiTokenEndpoint: unique_email = f"other-{unique_id}@plane.so" unique_username = f"other_user_{unique_id}" other_user = User.objects.create(email=unique_email, username=unique_username) - other_token = APIToken.objects.create( - label="Other Token", user=other_user, user_type=0 - ) + other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0) session_client.force_authenticate(user=create_user) url = reverse("api-tokens", kwargs={"pk": other_token.pk}) @@ -196,9 +184,7 @@ class TestApiTokenEndpoint: # DELETE /user/api-tokens// tests @pytest.mark.django_db - def test_delete_api_token_success( - self, session_client, create_user, create_api_token_for_user - ): + def test_delete_api_token_success(self, session_client, create_user, create_api_token_for_user): """Test successful API token deletion""" # Arrange session_client.force_authenticate(user=create_user) @@ -234,9 +220,7 @@ class TestApiTokenEndpoint: unique_email = f"delete-other-{unique_id}@plane.so" unique_username = f"delete_other_user_{unique_id}" other_user = User.objects.create(email=unique_email, username=unique_username) - other_token = APIToken.objects.create( - label="Other Token", user=other_user, user_type=0 - ) + other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0) session_client.force_authenticate(user=create_user) url = reverse("api-tokens", kwargs={"pk": other_token.pk}) @@ -252,9 +236,7 @@ class TestApiTokenEndpoint: def test_delete_service_api_token_forbidden(self, session_client, create_user): """Test deleting a service API token (should fail)""" # Arrange - service_token = APIToken.objects.create( - label="Service Token", user=create_user, user_type=0, is_service=True - ) + service_token = APIToken.objects.create(label="Service Token", user=create_user, user_type=0, is_service=True) session_client.force_authenticate(user=create_user) url = reverse("api-tokens", kwargs={"pk": service_token.pk}) @@ -268,9 +250,7 @@ class TestApiTokenEndpoint: # PATCH /user/api-tokens// tests @pytest.mark.django_db - def test_patch_api_token_success( - self, session_client, create_user, create_api_token_for_user - ): + def test_patch_api_token_success(self, session_client, create_user, create_api_token_for_user): """Test successful API token update""" # Arrange session_client.force_authenticate(user=create_user) @@ -294,9 +274,7 @@ class TestApiTokenEndpoint: assert create_api_token_for_user.description == update_data["description"] @pytest.mark.django_db - def test_patch_api_token_partial_update( - self, session_client, create_user, create_api_token_for_user - ): + def test_patch_api_token_partial_update(self, session_client, create_user, create_api_token_for_user): """Test partial API token update""" # Arrange session_client.force_authenticate(user=create_user) @@ -336,9 +314,7 @@ class TestApiTokenEndpoint: unique_email = f"patch-other-{unique_id}@plane.so" unique_username = f"patch_other_user_{unique_id}" other_user = User.objects.create(email=unique_email, username=unique_username) - other_token = APIToken.objects.create( - label="Other Token", user=other_user, user_type=0 - ) + other_token = APIToken.objects.create(label="Other Token", user=other_user, user_type=0) session_client.force_authenticate(user=create_user) url = reverse("api-tokens", kwargs={"pk": other_token.pk}) update_data = {"label": "Hacked Label"} diff --git a/apps/api/plane/tests/contract/app/test_authentication.py b/apps/api/plane/tests/contract/app/test_authentication.py index b44f5f3fc..1c044f192 100644 --- a/apps/api/plane/tests/contract/app/test_authentication.py +++ b/apps/api/plane/tests/contract/app/test_authentication.py @@ -16,9 +16,7 @@ from plane.license.models import Instance @pytest.fixture def setup_instance(db): """Create and configure an instance for authentication tests""" - instance_id = ( - uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id - ) + instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id # Create or update instance with all required fields instance, _ = Instance.objects.update_or_create( @@ -38,9 +36,7 @@ def setup_instance(db): @pytest.fixture def django_client(): """Return a Django test client with User-Agent header for handling redirects""" - client = Client( - HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1" - ) + client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1") return client @@ -83,9 +79,7 @@ class TestMagicLinkGenerate: @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_generate( - self, mock_magic_link, api_client, setup_user, setup_instance - ): + def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance): """Test successful magic link generation""" url = reverse("magic-generate") @@ -103,9 +97,7 @@ class TestMagicLinkGenerate: @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_max_generate_attempt( - self, mock_magic_link, api_client, setup_user, setup_instance - ): + def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance): """Test exceeding maximum magic link generation attempts""" url = reverse("magic-generate") @@ -145,9 +137,7 @@ class TestSignInEndpoint: def test_email_validity(self, django_client, setup_user, setup_instance): """Test sign-in with invalid email format""" url = reverse("sign-in") - response = django_client.post( - url, {"email": "useremail.com", "password": "user@123"}, follow=True - ) + response = django_client.post(url, {"email": "useremail.com", "password": "user@123"}, follow=True) # Check redirect contains error code assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0] @@ -156,9 +146,7 @@ class TestSignInEndpoint: def test_user_exists(self, django_client, setup_user, setup_instance): """Test sign-in with non-existent user""" url = reverse("sign-in") - response = django_client.post( - url, {"email": "user@email.so", "password": "user123"}, follow=True - ) + response = django_client.post(url, {"email": "user@email.so", "password": "user123"}, follow=True) # Check redirect contains error code assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] @@ -167,9 +155,7 @@ class TestSignInEndpoint: def test_password_validity(self, django_client, setup_user, setup_instance): """Test sign-in with incorrect password""" url = reverse("sign-in") - response = django_client.post( - url, {"email": "user@plane.so", "password": "user123"}, follow=True - ) + response = django_client.post(url, {"email": "user@plane.so", "password": "user123"}, follow=True) # Check for the specific authentication error in the URL redirect_urls = [url for url, _ in response.redirect_chain] @@ -184,9 +170,7 @@ class TestSignInEndpoint: url = reverse("sign-in") # First make the request without following redirects - response = django_client.post( - url, {"email": "user@plane.so", "password": "user@123"}, follow=False - ) + response = django_client.post(url, {"email": "user@plane.so", "password": "user@123"}, follow=False) # Check that the initial response is a redirect (302) without error code assert response.status_code == 302 @@ -243,27 +227,20 @@ class TestMagicSignIn: assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] @pytest.mark.django_db - def test_expired_invalid_magic_link( - self, django_client, setup_user, setup_instance - ): + def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance): """Test magic link sign-in with expired/invalid link""" ri = redis_instance() ri.delete("magic_user@plane.so") url = reverse("magic-sign-in") - response = django_client.post( - url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False - ) + response = django_client.post(url, {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False) # Check that we get a redirect assert response.status_code == 302 # The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist) # or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match) - assert ( - "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url - or "INVALID_MAGIC_CODE_SIGN_IN" in response.url - ) + assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url @pytest.mark.django_db def test_user_does_not_exist(self, django_client, setup_instance): @@ -280,9 +257,7 @@ class TestMagicSignIn: @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_code_sign_in( - self, mock_magic_link, django_client, api_client, setup_user, setup_instance - ): + def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): """Test successful magic link sign-in process""" # First generate a magic link token gen_url = reverse("magic-generate") @@ -298,9 +273,7 @@ class TestMagicSignIn: # Use Django client to test the redirect flow without following redirects url = reverse("magic-sign-in") - response = django_client.post( - url, {"email": "user@plane.so", "code": token}, follow=False - ) + response = django_client.post(url, {"email": "user@plane.so", "code": token}, follow=False) # Check that the initial response is a redirect without error code assert response.status_code == 302 @@ -311,9 +284,7 @@ class TestMagicSignIn: @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_sign_in_with_next_path( - self, mock_magic_link, django_client, api_client, setup_user, setup_instance - ): + def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): """Test magic sign-in with next_path parameter""" # First generate a magic link token gen_url = reverse("magic-generate") @@ -367,9 +338,7 @@ class TestMagicSignUp: User.objects.create(email="existing@plane.so") url = reverse("magic-sign-up") - response = django_client.post( - url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True - ) + response = django_client.post(url, {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=True) # Check redirect contains error code assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0] @@ -378,25 +347,18 @@ class TestMagicSignUp: def test_expired_invalid_magic_link(self, django_client, setup_instance): """Test magic link sign-up with expired/invalid link""" url = reverse("magic-sign-up") - response = django_client.post( - url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False - ) + response = django_client.post(url, {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, follow=False) # Check that we get a redirect assert response.status_code == 302 # The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist) # or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match) - assert ( - "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url - or "INVALID_MAGIC_CODE_SIGN_UP" in response.url - ) + assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_code_sign_up( - self, mock_magic_link, django_client, api_client, setup_instance - ): + def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance): """Test successful magic link sign-up process""" email = "newuser@plane.so" @@ -414,9 +376,7 @@ class TestMagicSignUp: # Use Django client to test the redirect flow without following redirects url = reverse("magic-sign-up") - response = django_client.post( - url, {"email": email, "code": token}, follow=False - ) + response = django_client.post(url, {"email": email, "code": token}, follow=False) # Check that the initial response is a redirect without error code assert response.status_code == 302 @@ -430,9 +390,7 @@ class TestMagicSignUp: @pytest.mark.django_db @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") - def test_magic_sign_up_with_next_path( - self, mock_magic_link, django_client, api_client, setup_instance - ): + def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance): """Test magic sign-up with next_path parameter""" email = "newuser2@plane.so" @@ -451,9 +409,7 @@ class TestMagicSignUp: # Use Django client to test the redirect flow without following redirects url = reverse("magic-sign-up") next_path = "onboarding" - response = django_client.post( - url, {"email": email, "code": token, "next_path": next_path}, follow=False - ) + response = django_client.post(url, {"email": email, "code": token, "next_path": next_path}, follow=False) # Check that the initial response is a redirect without error code assert response.status_code == 302 diff --git a/apps/api/plane/tests/contract/app/test_project_app.py b/apps/api/plane/tests/contract/app/test_project_app.py index 78bcd7aea..38b0f51f3 100644 --- a/apps/api/plane/tests/contract/app/test_project_app.py +++ b/apps/api/plane/tests/contract/app/test_project_app.py @@ -14,9 +14,7 @@ from plane.db.models import ( class TestProjectBase: - def get_project_url( - self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False - ) -> str: + def get_project_url(self, workspace_slug: str, pk: uuid.UUID = None, details: bool = False) -> str: """ Constructs the project endpoint URL for the given workspace as reverse() is unreliable due to duplicate 'name' values in URL patterns ('api' and 'app'). @@ -80,9 +78,7 @@ class TestProjectAPIPost(TestProjectBase): # Check if the member is created with the correct role assert ProjectMember.objects.count() == 1 - project_member = ProjectMember.objects.filter( - project=project, member=user - ).first() + project_member = ProjectMember.objects.filter(project=project, member=user).first() assert project_member.role == 20 # Administrator assert project_member.is_active is True @@ -97,19 +93,13 @@ class TestProjectAPIPost(TestProjectBase): assert set(state_names) == set(expected_states) @pytest.mark.django_db - def test_create_project_with_project_lead( - self, session_client, workspace, create_user - ): + def test_create_project_with_project_lead(self, session_client, workspace, create_user): """Test creating project with a different project lead""" # Create another user to be project lead - project_lead = User.objects.create_user( - email="lead@example.com", username="projectlead" - ) + project_lead = User.objects.create_user(email="lead@example.com", username="projectlead") # Add project lead to workspace - WorkspaceMember.objects.create( - workspace=workspace, member=project_lead, role=15 - ) + WorkspaceMember.objects.create(workspace=workspace, member=project_lead, role=15) url = self.get_project_url(workspace.slug) project_data = { @@ -132,9 +122,7 @@ class TestProjectAPIPost(TestProjectBase): @pytest.mark.django_db def test_create_project_guest_forbidden(self, session_client, workspace): """Test that guests cannot create projects""" - guest_user = User.objects.create_user( - email="guest@example.com", username="guest" - ) + guest_user = User.objects.create_user(email="guest@example.com", username="guest") WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5) session_client.force_authenticate(user=guest_user) @@ -164,14 +152,10 @@ class TestProjectAPIPost(TestProjectBase): assert response.status_code == status.HTTP_401_UNAUTHORIZED @pytest.mark.django_db - def test_create_project_duplicate_name( - self, session_client, workspace, create_user - ): + def test_create_project_duplicate_name(self, session_client, workspace, create_user): """Test creating project with duplicate name""" # Create first project - Project.objects.create( - name="Duplicate Name", identifier="DN1", workspace=workspace - ) + Project.objects.create(name="Duplicate Name", identifier="DN1", workspace=workspace) url = self.get_project_url(workspace.slug) project_data = { @@ -184,13 +168,9 @@ class TestProjectAPIPost(TestProjectBase): assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db - def test_create_project_duplicate_identifier( - self, session_client, workspace, create_user - ): + def test_create_project_duplicate_identifier(self, session_client, workspace, create_user): """Test creating project with duplicate identifier""" - Project.objects.create( - name="First Project", identifier="DUP", workspace=workspace - ) + Project.objects.create(name="First Project", identifier="DUP", workspace=workspace) url = self.get_project_url(workspace.slug) project_data = { @@ -203,9 +183,7 @@ class TestProjectAPIPost(TestProjectBase): assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db - def test_create_project_missing_required_fields( - self, session_client, workspace, create_user - ): + def test_create_project_missing_required_fields(self, session_client, workspace, create_user): """Test validation with missing required fields""" url = self.get_project_url(workspace.slug) @@ -214,15 +192,11 @@ class TestProjectAPIPost(TestProjectBase): assert response.status_code == status.HTTP_400_BAD_REQUEST # Test missing identifier - response = session_client.post( - url, {"name": "Missing Identifier"}, format="json" - ) + response = session_client.post(url, {"name": "Missing Identifier"}, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db - def test_create_project_with_all_optional_fields( - self, session_client, workspace, create_user - ): + def test_create_project_with_all_optional_fields(self, session_client, workspace, create_user): """Test creating project with all optional fields""" url = self.get_project_url(workspace.slug) project_data = { @@ -256,19 +230,13 @@ class TestProjectAPIGet(TestProjectBase): """Test project GET operations""" @pytest.mark.django_db - def test_list_projects_authenticated_admin( - self, session_client, workspace, create_user - ): + def test_list_projects_authenticated_admin(self, session_client, workspace, create_user): """Test listing projects as workspace admin""" # Create a project - project = Project.objects.create( - name="Test Project", identifier="TP", workspace=workspace - ) + project = Project.objects.create(name="Test Project", identifier="TP", workspace=workspace) # Add user as project member - ProjectMember.objects.create( - project=project, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug) response = session_client.get(url) @@ -283,24 +251,16 @@ class TestProjectAPIGet(TestProjectBase): def test_list_projects_authenticated_guest(self, session_client, workspace): """Test listing projects as workspace guest""" # Create a guest user - guest_user = User.objects.create_user( - email="guest@example.com", username="guest" - ) - WorkspaceMember.objects.create( - workspace=workspace, member=guest_user, role=5, is_active=True - ) + guest_user = User.objects.create_user(email="guest@example.com", username="guest") + WorkspaceMember.objects.create(workspace=workspace, member=guest_user, role=5, is_active=True) # Create projects - project1 = Project.objects.create( - name="Project 1", identifier="P1", workspace=workspace - ) + project1 = Project.objects.create(name="Project 1", identifier="P1", workspace=workspace) Project.objects.create(name="Project 2", identifier="P2", workspace=workspace) # Add guest to only one project - ProjectMember.objects.create( - project=project1, member=guest_user, role=10, is_active=True - ) + ProjectMember.objects.create(project=project1, member=guest_user, role=10, is_active=True) session_client.force_authenticate(user=guest_user) @@ -333,9 +293,7 @@ class TestProjectAPIGet(TestProjectBase): ) # Add user as project member - ProjectMember.objects.create( - project=project, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, details=True) response = session_client.get(url) @@ -358,9 +316,7 @@ class TestProjectAPIGet(TestProjectBase): ) # Add user as project member - ProjectMember.objects.create( - project=project, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, pk=project.id) response = session_client.get(url) @@ -392,9 +348,7 @@ class TestProjectAPIGet(TestProjectBase): ) # Add user as project member - ProjectMember.objects.create( - project=project, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, pk=project.id) response = session_client.get(url) @@ -407,9 +361,7 @@ class TestProjectAPIPatchDelete(TestProjectBase): """Test project PATCH, and DELETE operations""" @pytest.mark.django_db - def test_partial_update_project_success( - self, session_client, workspace, create_user - ): + def test_partial_update_project_success(self, session_client, workspace, create_user): """Test successful partial update of project""" # Create a project project = Project.objects.create( @@ -420,9 +372,7 @@ class TestProjectAPIPatchDelete(TestProjectBase): ) # Add user as project administrator - ProjectMember.objects.create( - project=project, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, pk=project.id) update_data = { @@ -444,25 +394,15 @@ class TestProjectAPIPatchDelete(TestProjectBase): assert project.module_view is False @pytest.mark.django_db - def test_partial_update_project_forbidden_non_admin( - self, session_client, workspace - ): + def test_partial_update_project_forbidden_non_admin(self, session_client, workspace): """Test that non-admin project members cannot update project""" # Create a project - project = Project.objects.create( - name="Protected Project", identifier="PP", workspace=workspace - ) + project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace) # Create a member user (not admin) - member_user = User.objects.create_user( - email="member@example.com", username="member" - ) - WorkspaceMember.objects.create( - workspace=workspace, member=member_user, role=15, is_active=True - ) - ProjectMember.objects.create( - project=project, member=member_user, role=15, is_active=True - ) + member_user = User.objects.create_user(email="member@example.com", username="member") + WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True) + ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True) session_client.force_authenticate(user=member_user) @@ -474,19 +414,13 @@ class TestProjectAPIPatchDelete(TestProjectBase): assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.django_db - def test_partial_update_duplicate_name_conflict( - self, session_client, workspace, create_user - ): + def test_partial_update_duplicate_name_conflict(self, session_client, workspace, create_user): """Test updating project with duplicate name returns conflict""" # Create two projects Project.objects.create(name="Project One", identifier="P1", workspace=workspace) - project2 = Project.objects.create( - name="Project Two", identifier="P2", workspace=workspace - ) + project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace) - ProjectMember.objects.create( - project=project2, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, pk=project2.id) update_data = {"name": "Project One"} # Duplicate name @@ -496,19 +430,13 @@ class TestProjectAPIPatchDelete(TestProjectBase): assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db - def test_partial_update_duplicate_identifier_conflict( - self, session_client, workspace, create_user - ): + def test_partial_update_duplicate_identifier_conflict(self, session_client, workspace, create_user): """Test updating project with duplicate identifier returns conflict""" # Create two projects Project.objects.create(name="Project One", identifier="P1", workspace=workspace) - project2 = Project.objects.create( - name="Project Two", identifier="P2", workspace=workspace - ) + project2 = Project.objects.create(name="Project Two", identifier="P2", workspace=workspace) - ProjectMember.objects.create( - project=project2, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project2, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, pk=project2.id) update_data = {"identifier": "P1"} # Duplicate identifier @@ -520,13 +448,9 @@ class TestProjectAPIPatchDelete(TestProjectBase): @pytest.mark.django_db def test_partial_update_invalid_data(self, session_client, workspace, create_user): """Test partial update with invalid data""" - project = Project.objects.create( - name="Valid Project", identifier="VP", workspace=workspace - ) + project = Project.objects.create(name="Valid Project", identifier="VP", workspace=workspace) - ProjectMember.objects.create( - project=project, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, pk=project.id) update_data = {"name": ""} @@ -536,17 +460,11 @@ class TestProjectAPIPatchDelete(TestProjectBase): assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.django_db - def test_delete_project_success_project_admin( - self, session_client, workspace, create_user - ): + def test_delete_project_success_project_admin(self, session_client, workspace, create_user): """Test successful project deletion by project admin""" - project = Project.objects.create( - name="Delete Me", identifier="DM", workspace=workspace - ) + project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace) - ProjectMember.objects.create( - project=project, member=create_user, role=20, is_active=True - ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) url = self.get_project_url(workspace.slug, pk=project.id) response = session_client.delete(url) @@ -558,16 +476,10 @@ class TestProjectAPIPatchDelete(TestProjectBase): def test_delete_project_success_workspace_admin(self, session_client, workspace): """Test successful project deletion by workspace admin""" # Create workspace admin user - workspace_admin = User.objects.create_user( - email="admin@example.com", username="admin" - ) - WorkspaceMember.objects.create( - workspace=workspace, member=workspace_admin, role=20, is_active=True - ) + workspace_admin = User.objects.create_user(email="admin@example.com", username="admin") + WorkspaceMember.objects.create(workspace=workspace, member=workspace_admin, role=20, is_active=True) - project = Project.objects.create( - name="Delete Me", identifier="DM", workspace=workspace - ) + project = Project.objects.create(name="Delete Me", identifier="DM", workspace=workspace) session_client.force_authenticate(user=workspace_admin) @@ -581,20 +493,12 @@ class TestProjectAPIPatchDelete(TestProjectBase): def test_delete_project_forbidden_non_admin(self, session_client, workspace): """Test that non-admin users cannot delete projects""" # Create a member user (not admin) - member_user = User.objects.create_user( - email="member@example.com", username="member" - ) - WorkspaceMember.objects.create( - workspace=workspace, member=member_user, role=15, is_active=True - ) + member_user = User.objects.create_user(email="member@example.com", username="member") + WorkspaceMember.objects.create(workspace=workspace, member=member_user, role=15, is_active=True) - project = Project.objects.create( - name="Protected Project", identifier="PP", workspace=workspace - ) + project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace) - ProjectMember.objects.create( - project=project, member=member_user, role=15, is_active=True - ) + ProjectMember.objects.create(project=project, member=member_user, role=15, is_active=True) session_client.force_authenticate(user=member_user) @@ -607,9 +511,7 @@ class TestProjectAPIPatchDelete(TestProjectBase): @pytest.mark.django_db def test_delete_project_unauthenticated(self, client, workspace): """Test unauthenticated project deletion""" - project = Project.objects.create( - name="Protected Project", identifier="PP", workspace=workspace - ) + project = Project.objects.create(name="Protected Project", identifier="PP", workspace=workspace) url = self.get_project_url(workspace.slug, pk=project.id) response = client.delete(url) diff --git a/apps/api/plane/tests/contract/app/test_workspace_app.py b/apps/api/plane/tests/contract/app/test_workspace_app.py index 9d4c560e5..47b049795 100644 --- a/apps/api/plane/tests/contract/app/test_workspace_app.py +++ b/apps/api/plane/tests/contract/app/test_workspace_app.py @@ -21,9 +21,7 @@ class TestWorkspaceAPI: @pytest.mark.django_db @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") - def test_create_workspace_valid_data( - self, mock_workspace_seed, session_client, create_user - ): + def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user): """Test creating a workspace with valid data""" url = reverse("workspace") user = create_user # Use the create_user fixture directly as it returns a user object @@ -49,9 +47,7 @@ class TestWorkspaceAPI: # Check other values workspace = Workspace.objects.get(slug=workspace_data["slug"]) - workspace_member = WorkspaceMember.objects.filter( - workspace=workspace, member=user - ).first() + workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user).first() assert workspace.owner == user assert workspace_member.role == 20 @@ -68,9 +64,7 @@ class TestWorkspaceAPI: session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json") # Try to create a workspace with the same slug - response = session_client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) + response = session_client.post(url, {"name": "Plane", "slug": "pla-ne"}, format="json") # The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT assert response.status_code == status.HTTP_400_BAD_REQUEST diff --git a/apps/api/plane/tests/smoke/test_auth_smoke.py b/apps/api/plane/tests/smoke/test_auth_smoke.py index 85ca476b4..c5a671e9a 100644 --- a/apps/api/plane/tests/smoke/test_auth_smoke.py +++ b/apps/api/plane/tests/smoke/test_auth_smoke.py @@ -15,15 +15,11 @@ class TestAuthSmoke: url = f"{plane_server.url}{relative_url}" # 1. Test bad login - test with wrong password - response = requests.post( - url, data={"email": user_data["email"], "password": "wrong-password"} - ) + response = requests.post(url, data={"email": user_data["email"], "password": "wrong-password"}) # For bad credentials, any of these status codes would be valid # The test shouldn't be brittle to minor implementation changes - assert response.status_code != 500, ( - "Authentication should not cause server errors" - ) + assert response.status_code != 500, "Authentication should not cause server errors" assert response.status_code != 404, "Authentication endpoint should exist" if response.status_code == 200: @@ -33,10 +29,7 @@ class TestAuthSmoke: data = response.json() # JSON response might indicate error in its structure assert ( - "error" in data - or "error_code" in data - or "detail" in data - or response.url.endswith("sign-in") + "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in") ), "Error response should contain error details" except ValueError: # It's ok if response isn't JSON format @@ -75,20 +68,17 @@ class TestAuthSmoke: data = response.json() # If it's a token response if "access_token" in data: - assert "refresh_token" in data, ( - "JWT auth should return both access and refresh tokens" - ) + assert "refresh_token" in data, "JWT auth should return both access and refresh tokens" # If it's a user session response elif "user" in data: - assert ( - "is_authenticated" in data and data["is_authenticated"] - ), "User session response should indicate authentication" + assert "is_authenticated" in data and data["is_authenticated"], ( + "User session response should indicate authentication" + ) # Otherwise it should at least indicate success else: - assert not any( - error_key in data - for error_key in ["error", "error_code", "detail"] - ), "Success response should not contain error keys" + assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), ( + "Success response should not contain error keys" + ) except ValueError: # Non-JSON is acceptable if it's a redirect or HTML response pass diff --git a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py index bbb98e6b1..988603659 100644 --- a/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py +++ b/apps/api/plane/tests/unit/bg_tasks/test_copy_s3_objects.py @@ -14,9 +14,7 @@ class TestCopyS3Objects: @pytest.fixture def project(self, create_user, workspace): - project = Project.objects.create( - name="Test Project", identifier="test-project", workspace=workspace - ) + project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace) ProjectMember.objects.create(project=project, member=create_user) return project @@ -27,7 +25,7 @@ class TestCopyS3Objects: name="Test Issue", workspace=workspace, project_id=project.id, - description_html=f'
', + description_html='
', # noqa: E501 ) @pytest.fixture @@ -72,18 +70,14 @@ class TestCopyS3Objects: mock_s3_storage.return_value = mock_storage_instance # Mock the external service call to avoid actual HTTP requests - with patch( - "plane.bgtasks.copy_s3_object.sync_with_external_service" - ) as mock_sync: + with patch("plane.bgtasks.copy_s3_object.sync_with_external_service") as mock_sync: mock_sync.return_value = { "description": "test description", "description_binary": base64.b64encode(b"test binary").decode(), } # Call the actual function (not .delay()) - copy_s3_objects_of_description_and_assets( - "ISSUE", issue.id, project.id, "test-workspace", create_user.id - ) + copy_s3_objects_of_description_and_assets("ISSUE", issue.id, project.id, "test-workspace", create_user.id) # Assert that copy_object was called for each asset assert mock_storage_instance.copy_object.call_count == 2 @@ -100,9 +94,7 @@ class TestCopyS3Objects: @pytest.mark.django_db @patch("plane.bgtasks.copy_s3_object.S3Storage") - def test_copy_assets_successful( - self, mock_s3_storage, workspace, project, issue, file_asset - ): + def test_copy_assets_successful(self, mock_s3_storage, workspace, project, issue, file_asset): """Test successful copying of assets""" # Arrange mock_storage_instance = MagicMock() @@ -136,9 +128,7 @@ class TestCopyS3Objects: @pytest.mark.django_db @patch("plane.bgtasks.copy_s3_object.S3Storage") - def test_copy_assets_empty_asset_ids( - self, mock_s3_storage, workspace, project, issue - ): + def test_copy_assets_empty_asset_ids(self, mock_s3_storage, workspace, project, issue): """Test copying with empty asset_ids list""" # Arrange mock_storage_instance = MagicMock() @@ -159,9 +149,7 @@ class TestCopyS3Objects: @pytest.mark.django_db @patch("plane.bgtasks.copy_s3_object.S3Storage") - def test_copy_assets_nonexistent_asset( - self, mock_s3_storage, workspace, project, issue - ): + def test_copy_assets_nonexistent_asset(self, mock_s3_storage, workspace, project, issue): """Test copying with non-existent asset ID""" # Arrange mock_storage_instance = MagicMock() diff --git a/apps/api/plane/tests/unit/middleware/test_db_routing.py b/apps/api/plane/tests/unit/middleware/test_db_routing.py index 73f222140..5ac71696a 100644 --- a/apps/api/plane/tests/unit/middleware/test_db_routing.py +++ b/apps/api/plane/tests/unit/middleware/test_db_routing.py @@ -111,9 +111,7 @@ class TestReadReplicaRoutingMiddleware: mock_clear.assert_called_once() @patch("plane.middleware.db_routing.clear_read_replica_context") - def test_call_cleans_up_context_on_exception( - self, mock_clear, middleware, get_request, mock_get_response - ): + def test_call_cleans_up_context_on_exception(self, mock_clear, middleware, get_request, mock_get_response): """Test __call__ cleans up context even if get_response raises.""" mock_get_response.side_effect = Exception("Test exception") @@ -139,9 +137,7 @@ class TestProcessView: assert result is None @patch("plane.middleware.db_routing.set_use_read_replica") - def test_with_read_method_and_replica_false( - self, mock_set, middleware, get_request - ): + def test_with_read_method_and_replica_false(self, mock_set, middleware, get_request): """Test process_view with GET request and use_read_replica=False.""" view_func = Mock() view_func.use_read_replica = False @@ -152,9 +148,7 @@ class TestProcessView: assert result is None @patch("plane.middleware.db_routing.set_use_read_replica") - def test_with_read_method_and_no_replica_attribute( - self, mock_set, middleware, get_request - ): + def test_with_read_method_and_no_replica_attribute(self, mock_set, middleware, get_request): """Test process_view with GET request and no use_read_replica attr.""" view_func = Mock(spec=[]) # No use_read_replica attribute @@ -287,9 +281,7 @@ class TestAttributeDetection: (None, False), ], ) - def test_should_use_read_replica_truthy_falsy_values( - self, middleware, value, expected - ): + def test_should_use_read_replica_truthy_falsy_values(self, middleware, value, expected): """Test _should_use_read_replica with various truthy/falsy values.""" # Create a real object to test the attribute handling @@ -309,9 +301,7 @@ class TestExceptionHandling: """Test cases for exception handling and cleanup.""" @patch("plane.middleware.db_routing.clear_read_replica_context") - def test_process_exception_cleans_up_context( - self, mock_clear, middleware, request_factory - ): + def test_process_exception_cleans_up_context(self, mock_clear, middleware, request_factory): """Test process_exception cleans up context.""" request = request_factory.get("/api/test/") exception = Exception("Test exception") @@ -323,9 +313,7 @@ class TestExceptionHandling: @patch("plane.middleware.db_routing.set_use_read_replica") @patch("plane.middleware.db_routing.clear_read_replica_context") - def test_integration_full_request_cycle( - self, mock_clear, mock_set, middleware, request_factory, mock_get_response - ): + def test_integration_full_request_cycle(self, mock_clear, mock_set, middleware, request_factory, mock_get_response): """Test complete request cycle from __call__ through process_view.""" request = request_factory.get("/api/test/") view_func = Mock() @@ -410,9 +398,7 @@ class TestEdgeCases: def __getattr__(self, name): if name == "use_read_replica": raise AttributeError("Simulated attribute error") - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") view_func = ProblematicView() diff --git a/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py index 72d1f3384..eac92384b 100644 --- a/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py +++ b/apps/api/plane/tests/unit/serializers/test_issue_recent_visit.py @@ -20,9 +20,7 @@ class TestIssueRecentVisitSerializer: def test_issue_recent_visit_serializer_fields(self, db): """Test that the serializer includes the correct fields""" - test_user_1 = User.objects.create( - email="test_user_1@example.com", first_name="Test", last_name="User" - ) + test_user_1 = User.objects.create(email="test_user_1@example.com", first_name="Test", last_name="User") # To test for deleted issue assignee test_user_2 = User.objects.create( @@ -32,15 +30,11 @@ class TestIssueRecentVisitSerializer: username="some user name", ) - workspace = Workspace.objects.create( - name="Test Workspace", slug="test-workspace", owner=test_user_1 - ) + workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", owner=test_user_1) WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace) - project = Project.objects.create( - name="Test Project", identifier="test-project", workspace=workspace - ) + project = Project.objects.create(name="Test Project", identifier="test-project", workspace=workspace) ProjectMember.objects.create(project=project, member=test_user_2) issue = Issue.objects.create( diff --git a/apps/api/plane/tests/unit/serializers/test_workspace.py b/apps/api/plane/tests/unit/serializers/test_workspace.py index 28e6c8d75..21844c714 100644 --- a/apps/api/plane/tests/unit/serializers/test_workspace.py +++ b/apps/api/plane/tests/unit/serializers/test_workspace.py @@ -12,15 +12,11 @@ class TestWorkspaceLiteSerializer: def test_workspace_lite_serializer_fields(self, db): """Test that the serializer includes the correct fields""" # Create a user to be the owner - owner = User.objects.create( - email="test@example.com", first_name="Test", last_name="User" - ) + owner = User.objects.create(email="test@example.com", first_name="Test", last_name="User") # Create a workspace with explicit ID to test serialization workspace_id = uuid4() - workspace = Workspace.objects.create( - name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner - ) + workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=workspace_id, owner=owner) # Serialize the workspace serialized_data = WorkspaceLiteSerializer(workspace).data @@ -37,19 +33,13 @@ class TestWorkspaceLiteSerializer: def test_workspace_lite_serializer_read_only(self, db): """Test that the serializer fields are read-only""" # Create a user to be the owner - owner = User.objects.create( - email="test2@example.com", first_name="Test", last_name="User" - ) + owner = User.objects.create(email="test2@example.com", first_name="Test", last_name="User") # Create a workspace - workspace = Workspace.objects.create( - name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner - ) + workspace = Workspace.objects.create(name="Test Workspace", slug="test-workspace", id=uuid4(), owner=owner) # Try to update via serializer - serializer = WorkspaceLiteSerializer( - workspace, data={"name": "Updated Name", "slug": "updated-slug"} - ) + serializer = WorkspaceLiteSerializer(workspace, data={"name": "Updated Name", "slug": "updated-slug"}) # Serializer should be valid (since read-only fields are ignored) assert serializer.is_valid() diff --git a/apps/api/plane/tests/unit/utils/test_url.py b/apps/api/plane/tests/unit/utils/test_url.py index ec3ef7a73..465cb3023 100644 --- a/apps/api/plane/tests/unit/utils/test_url.py +++ b/apps/api/plane/tests/unit/utils/test_url.py @@ -2,7 +2,6 @@ import pytest from plane.utils.url import ( contains_url, is_valid_url, - get_url_components, normalize_url_path, ) @@ -62,9 +61,7 @@ class TestContainsURL: assert contains_url("example.c") is False # TLD too short assert contains_url("999.999.999.999") is False # Invalid IP (octets > 255) assert contains_url("just-a-hyphen") is False # No domain - assert ( - contains_url("www.") is False - ) # Incomplete www - needs at least one char after dot + assert contains_url("www.") is False # Incomplete www - needs at least one char after dot def test_contains_url_length_limit_under_1000(self): """Test contains_url with input under 1000 characters containing URLs""" @@ -109,9 +106,7 @@ class TestContainsURL: assert contains_url(multiline_short) is True # Multiple lines under total limit - multiline_text = ( - "a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200 - ) + multiline_text = "a" * 200 + "\n" + "b" * 200 + "https://example.com\n" + "c" * 200 assert len(multiline_text) < 1000 assert contains_url(multiline_text) is True @@ -208,9 +203,7 @@ class TestNormalizeURLPath: def test_normalize_url_path_with_query_and_fragment(self): """Test normalize_url_path preserves query and fragment""" - result = normalize_url_path( - "https://example.com//foo///bar//baz?x=1&y=2#fragment" - ) + result = normalize_url_path("https://example.com//foo///bar//baz?x=1&y=2#fragment") assert result == "https://example.com/foo/bar/baz?x=1&y=2#fragment" def test_normalize_url_path_with_no_redundant_slashes(self): @@ -231,9 +224,7 @@ class TestNormalizeURLPath: def test_normalize_url_path_with_complex_path(self): """Test normalize_url_path with complex path structure""" - result = normalize_url_path( - "https://example.com///api//v1///users//123//profile" - ) + result = normalize_url_path("https://example.com///api//v1///users//123//profile") assert result == "https://example.com/api/v1/users/123/profile" def test_normalize_url_path_with_different_schemes(self): diff --git a/apps/api/plane/tests/unit/utils/test_uuid.py b/apps/api/plane/tests/unit/utils/test_uuid.py index 5503f2bc3..d47e59c4b 100644 --- a/apps/api/plane/tests/unit/utils/test_uuid.py +++ b/apps/api/plane/tests/unit/utils/test_uuid.py @@ -19,9 +19,7 @@ class TestUUIDUtils: assert is_valid_uuid("not-a-uuid") is False assert is_valid_uuid("123456789") is False assert is_valid_uuid("") is False - assert ( - is_valid_uuid("00000000-0000-0000-0000-000000000000") is False - ) # This is a valid UUID but version 1 + assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1 def test_convert_uuid_to_integer(self): """Test convert_uuid_to_integer function""" @@ -48,6 +46,4 @@ class TestUUIDUtils: test_uuid = uuid.UUID(test_uuid_str) # Should get the same result whether passing UUID or string - assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer( - test_uuid_str - ) + assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str) diff --git a/apps/api/plane/urls.py b/apps/api/plane/urls.py index c06e67158..4b1062559 100644 --- a/apps/api/plane/urls.py +++ b/apps/api/plane/urls.py @@ -38,8 +38,6 @@ if settings.DEBUG: try: import debug_toolbar - urlpatterns = [ - re_path(r"^__debug__/", include(debug_toolbar.urls)) - ] + urlpatterns + urlpatterns = [re_path(r"^__debug__/", include(debug_toolbar.urls))] + urlpatterns except ImportError: pass diff --git a/apps/api/plane/utils/analytics_plot.py b/apps/api/plane/utils/analytics_plot.py index 43c465e7c..12fa39cc0 100644 --- a/apps/api/plane/utils/analytics_plot.py +++ b/apps/api/plane/utils/analytics_plot.py @@ -73,30 +73,19 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): dimension_ex=Coalesce("dimension", Value("null")), ).values("dimension") queryset = queryset.annotate(segment=F(segment)) if segment else queryset - queryset = ( - queryset.values("dimension", "segment") - if segment - else queryset.values("dimension") - ) + queryset = queryset.values("dimension", "segment") if segment else queryset.values("dimension") queryset = queryset.annotate(count=Count("*")).order_by("dimension") # Estimate else: - queryset = queryset.annotate( - estimate=Sum(Cast("estimate_point__value", FloatField())) - ).order_by(x_axis) + queryset = queryset.annotate(estimate=Sum(Cast("estimate_point__value", FloatField()))).order_by(x_axis) queryset = queryset.annotate(segment=F(segment)) if segment else queryset queryset = ( - queryset.values("dimension", "segment", "estimate") - if segment - else queryset.values("dimension", "estimate") + queryset.values("dimension", "segment", "estimate") if segment else queryset.values("dimension", "estimate") ) result_values = list(queryset) - grouped_data = { - str(key): list(items) - for key, items in groupby(result_values, key=lambda x: x[str("dimension")]) - } + grouped_data = {str(key): list(items) for key, items in groupby(result_values, key=lambda x: x[str("dimension")])} return sort_data(grouped_data, temp_axis) @@ -140,9 +129,7 @@ def burndown_plot(queryset, slug, project_id, plot_type, cycle_id=None, module_i # Get all dates between the two dates date_range = [ (queryset.start_date + timedelta(days=x)).date() - for x in range( - (queryset.end_date.date() - queryset.start_date.date()).days + 1 - ) + for x in range((queryset.end_date.date() - queryset.start_date.date()).days + 1) ] else: date_range = [] diff --git a/apps/api/plane/utils/build_chart.py b/apps/api/plane/utils/build_chart.py index be5bb7753..9a2d9c3a0 100644 --- a/apps/api/plane/utils/build_chart.py +++ b/apps/api/plane/utils/build_chart.py @@ -82,11 +82,7 @@ def process_grouped_data( if key not in response: response[key] = { "key": key if key else "none", - "name": ( - item.get("display_name", key) - if item.get("display_name", key) - else "None" - ), + "name": (item.get("display_name", key) if item.get("display_name", key) else "None"), "count": 0, } group_key = str(item["group_key"]) if item["group_key"] else "none" @@ -104,9 +100,7 @@ def build_number_chart_response( y_axis: str, aggregate_func: Aggregate, ) -> List[Dict[str, Any]]: - count = ( - queryset.filter(**y_axis_filter).aggregate(total=aggregate_func).get("total", 0) - ) + count = queryset.filter(**y_axis_filter).aggregate(total=aggregate_func).get("total", 0) return [{"key": y_axis, "name": y_axis, "count": count}] @@ -136,9 +130,7 @@ def build_simple_chart_response( queryset: QuerySet, id_field: str, name_field: str, aggregate_func: Aggregate ) -> List[Dict[str, Any]]: data = ( - queryset.annotate( - key=F(id_field), display_name=F(name_field) if name_field else F(id_field) - ) + queryset.annotate(key=F(id_field), display_name=F(name_field) if name_field else F(id_field)) .values("key", "display_name") .annotate(count=aggregate_func) .order_by("key") @@ -170,12 +162,8 @@ def build_analytics_chart( field_mapping = get_x_axis_field() - id_field, name_field, additional_filter = field_mapping.get( - x_axis, (None, None, {}) - ) - group_field, group_name_field, group_additional_filter = field_mapping.get( - group_by, (None, None, {}) - ) + id_field, name_field, additional_filter = field_mapping.get(x_axis, (None, None, {})) + group_field, group_name_field, group_additional_filter = field_mapping.get(group_by, (None, None, {})) # Apply additional filters if they exist if additional_filter or {}: @@ -196,9 +184,7 @@ def build_analytics_chart( aggregate_func, ) else: - response = build_simple_chart_response( - queryset, id_field, name_field, aggregate_func - ) + response = build_simple_chart_response(queryset, id_field, name_field, aggregate_func) schema = {} return {"data": response, "schema": schema} diff --git a/apps/api/plane/utils/cache.py b/apps/api/plane/utils/cache.py index 1b3e2cb1c..da3fd4517 100644 --- a/apps/api/plane/utils/cache.py +++ b/apps/api/plane/utils/cache.py @@ -25,13 +25,7 @@ def cache_response(timeout=60 * 60, path=None, user=True): @wraps(view_func) def _wrapped_view(instance, request, *args, **kwargs): # Function to generate cache key - auth_header = ( - None - if request.user.is_anonymous - else str(request.user.id) - if user - else None - ) + auth_header = None if request.user.is_anonymous else str(request.user.id) if user else None custom_path = path if path is not None else request.get_full_path() key = generate_cache_key(custom_path, auth_header) cached_result = cache.get(key) @@ -53,9 +47,7 @@ def cache_response(timeout=60 * 60, path=None, user=True): return decorator -def invalidate_cache_directly( - path=None, url_params=False, user=True, request=None, multiple=False -): +def invalidate_cache_directly(path=None, url_params=False, user=True, request=None, multiple=False): if url_params and path: path_with_values = path # Assuming `kwargs` could be passed directly if needed, otherwise, skip this part @@ -64,13 +56,7 @@ def invalidate_cache_directly( custom_path = path_with_values else: custom_path = path if path is not None else request.get_full_path() - auth_header = ( - None - if request and request.user.is_anonymous - else str(request.user.id) - if user - else None - ) + auth_header = None if request and request.user.is_anonymous else str(request.user.id) if user else None key = generate_cache_key(custom_path, auth_header) if multiple: diff --git a/apps/api/plane/utils/content_validator.py b/apps/api/plane/utils/content_validator.py index cf7c235ee..5163fad7d 100644 --- a/apps/api/plane/utils/content_validator.py +++ b/apps/api/plane/utils/content_validator.py @@ -54,9 +54,7 @@ def validate_binary_data(data): # Check for suspicious text patterns (HTML/JS) try: decoded_text = binary_data.decode("utf-8", errors="ignore")[:200] - if any( - pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS - ): + if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS): return False, "Binary data contains suspicious content patterns" except Exception: pass # Binary data might not be decodable as text, which is fine @@ -86,6 +84,7 @@ ATTRIBUTES = { "style", "start", "type", + "xmlns", # common editor data-* attributes seen in stored HTML # (wildcards like data-* are NOT supported by nh3; we add known keys # here and dynamically include all data-* seen in the input below) diff --git a/apps/api/plane/utils/core/dbrouters.py b/apps/api/plane/utils/core/dbrouters.py index 2c5b67a27..e17568331 100644 --- a/apps/api/plane/utils/core/dbrouters.py +++ b/apps/api/plane/utils/core/dbrouters.py @@ -53,9 +53,7 @@ class ReadReplicaRouter: logger.debug(f"Routing write for {model._meta.label} to primary database") return "default" - def allow_migrate( - self, db: str, app_label: str, model_name: str = None, **hints - ) -> bool: + def allow_migrate(self, db: str, app_label: str, model_name: str = None, **hints) -> bool: """ Ensure migrations only run on the primary database. Args: diff --git a/apps/api/plane/utils/date_utils.py b/apps/api/plane/utils/date_utils.py index 4225e70b5..f15e7f119 100644 --- a/apps/api/plane/utils/date_utils.py +++ b/apps/api/plane/utils/date_utils.py @@ -42,44 +42,30 @@ def get_analytics_date_range( "lte": datetime.combine(today, datetime.max.time()), }, "previous": { - "gte": datetime.combine( - today - timedelta(days=14), datetime.min.time() - ), + "gte": datetime.combine(today - timedelta(days=14), datetime.min.time()), "lte": datetime.combine(today - timedelta(days=8), datetime.max.time()), }, } elif date_filter == "last_30_days": return { "current": { - "gte": datetime.combine( - today - timedelta(days=30), datetime.min.time() - ), + "gte": datetime.combine(today - timedelta(days=30), datetime.min.time()), "lte": datetime.combine(today, datetime.max.time()), }, "previous": { - "gte": datetime.combine( - today - timedelta(days=60), datetime.min.time() - ), - "lte": datetime.combine( - today - timedelta(days=31), datetime.max.time() - ), + "gte": datetime.combine(today - timedelta(days=60), datetime.min.time()), + "lte": datetime.combine(today - timedelta(days=31), datetime.max.time()), }, } elif date_filter == "last_3_months": return { "current": { - "gte": datetime.combine( - today - timedelta(days=90), datetime.min.time() - ), + "gte": datetime.combine(today - timedelta(days=90), datetime.min.time()), "lte": datetime.combine(today, datetime.max.time()), }, "previous": { - "gte": datetime.combine( - today - timedelta(days=180), datetime.min.time() - ), - "lte": datetime.combine( - today - timedelta(days=91), datetime.max.time() - ), + "gte": datetime.combine(today - timedelta(days=180), datetime.min.time()), + "lte": datetime.combine(today - timedelta(days=91), datetime.max.time()), }, } elif date_filter == "custom" and start_date and end_date: diff --git a/apps/api/plane/utils/exporters/README.md b/apps/api/plane/utils/exporters/README.md new file mode 100644 index 000000000..cbecaaa4b --- /dev/null +++ b/apps/api/plane/utils/exporters/README.md @@ -0,0 +1,496 @@ +# 📊 Exporters + +A flexible and extensible data export utility for exporting Django model data in multiple formats (CSV, JSON, XLSX). + +## 🎯 Overview + +The exporters module provides a schema-based approach to exporting data with support for: + +- **📄 Multiple formats**: CSV, JSON, and XLSX (Excel) +- **🔒 Type-safe field definitions**: StringField, NumberField, DateField, DateTimeField, BooleanField, ListField, JSONField +- **⚡ Custom transformations**: Field-level transformations and custom preparer methods +- **🔗 Dotted path notation**: Easy access to nested attributes and related models +- **🎨 Format-specific handling**: Automatic formatting based on export format (e.g., lists as arrays in JSON, comma-separated in CSV) + +## 🚀 Quick Start + +### Basic Usage + +```python +from plane.utils.exporters import Exporter, ExportSchema, StringField, NumberField + +# Define a schema +class UserExportSchema(ExportSchema): + name = StringField(source="username", label="User Name") + email = StringField(source="email", label="Email Address") + posts_count = NumberField(label="Total Posts") + + def prepare_posts_count(self, obj): + return obj.posts.count() + +# Export data - just pass the queryset! +users = User.objects.all() +exporter = Exporter(format_type="csv", schema_class=UserExportSchema) +filename, content = exporter.export("users_export", users) +``` + +### Exporting Issues + +```python +from plane.utils.exporters import Exporter, IssueExportSchema + +# Get issues with prefetched relations +issues = Issue.objects.filter(project_id=project_id).prefetch_related( + 'assignee_details', + 'label_details', + 'issue_module', + # ... other relations +) + +# Export as XLSX - pass the queryset directly! +exporter = Exporter(format_type="xlsx", schema_class=IssueExportSchema) +filename, content = exporter.export("issues", issues) + +# Export with custom fields only +exporter = Exporter(format_type="json", schema_class=IssueExportSchema) +filename, content = exporter.export("issues_filtered", issues, fields=["id", "name", "state_name", "assignees"]) +``` + +### Exporting Multiple Projects Separately + +```python +# Export each project to a separate file +for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + exporter = Exporter(format_type="csv", schema_class=IssueExportSchema) + filename, content = exporter.export(f"issues-{project_id}", project_issues) + # Save or upload the file +``` + +## 📝 Schema Definition + +### Field Types + +#### 📝 StringField + +Converts values to strings. + +```python +name = StringField(source="name", label="Name", default="N/A") +``` + +#### 🔢 NumberField + +Handles numeric values (int, float). + +```python +count = NumberField(source="items_count", label="Count", default=0) +``` + +#### 📅 DateField + +Formats date objects as `%a, %d %b %Y` (e.g., "Mon, 01 Jan 2024"). + +```python +start_date = DateField(source="start_date", label="Start Date") +``` + +#### ⏰ DateTimeField + +Formats datetime objects as `%a, %d %b %Y %I:%M:%S %Z%z`. + +```python +created_at = DateTimeField(source="created_at", label="Created At") +``` + +#### ✅ BooleanField + +Converts values to boolean. + +```python +is_active = BooleanField(source="is_active", label="Active", default=False) +``` + +#### 📋 ListField + +Handles list/array values. In CSV/XLSX, lists are joined with a separator (default: `", "`). In JSON, they remain as arrays. + +```python +tags = ListField(source="tags", label="Tags") +assignees = ListField(label="Assignees") # Custom preparer can populate this +``` + +#### 🗂️ JSONField + +Handles complex JSON-serializable objects (dicts, lists of dicts). In CSV/XLSX, they're serialized as JSON strings. In JSON, they remain as objects. + +```python +metadata = JSONField(source="metadata", label="Metadata") +comments = JSONField(label="Comments") +``` + +### ⚙️ Field Parameters + +All field types support these parameters: + +- **`source`**: Dotted path string to the attribute (e.g., `"project.name"`) +- **`default`**: Default value when field is None +- **`label`**: Display name in export headers + +### 🔗 Dotted Path Notation + +Access nested attributes using dot notation: + +```python +project_name = StringField(source="project.name", label="Project") +owner_email = StringField(source="created_by.email", label="Owner Email") +``` + +### 🎯 Custom Preparers + +For complex logic, define `prepare_{field_name}` methods: + +```python +class MySchema(ExportSchema): + assignees = ListField(label="Assignees") + + def prepare_assignees(self, obj): + return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details] +``` + +Preparers take precedence over field definitions. + +### ⚡ Custom Transformations with Preparer Methods + +For any custom logic or transformations, use `prepare_` methods: + +```python +class MySchema(ExportSchema): + name = StringField(source="name", label="Name (Uppercase)") + status = StringField(label="Status") + + def prepare_name(self, obj): + """Transform the name field to uppercase.""" + return obj.name.upper() if obj.name else "" + + def prepare_status(self, obj): + """Compute status based on model state.""" + return "Active" if obj.is_active else "Inactive" +``` + +## 📦 Export Formats + +### 📊 CSV Format + +- Fields are quoted with `QUOTE_ALL` +- Lists are joined with `", "` (customizable with `list_joiner` option) +- JSON objects are serialized as JSON strings +- File extension: `.csv` + +```python +exporter = Exporter( + format_type="csv", + schema_class=MySchema, + options={"list_joiner": "; "} # Custom separator +) +``` + +### 📋 JSON Format + +- Lists remain as arrays +- Objects remain as nested structures +- Preserves data types +- File extension: `.json` + +```python +exporter = Exporter(format_type="json", schema_class=MySchema) +filename, content = exporter.export("data", records) +# content is a JSON string: '[{"field": "value"}, ...]' +``` + +### 📗 XLSX Format + +- Creates Excel-compatible files using openpyxl +- Lists are joined with `", "` (customizable with `list_joiner` option) +- JSON objects are serialized as JSON strings +- File extension: `.xlsx` +- Returns binary content (bytes) + +```python +exporter = Exporter(format_type="xlsx", schema_class=MySchema) +filename, content = exporter.export("data", records) +# content is bytes +``` + +## 🔧 Advanced Usage + +### 📦 Using Context for Pre-fetched Data + +Pass context data to schemas to avoid N+1 queries. Override `get_context_data()` in your schema: + +```python +class MySchema(ExportSchema): + attachment_count = NumberField(label="Attachments") + + def prepare_attachment_count(self, obj): + attachments_dict = self.context.get("attachments_dict", {}) + return len(attachments_dict.get(obj.id, [])) + + @classmethod + def get_context_data(cls, queryset): + """Pre-fetch all attachments in one query.""" + attachments_dict = get_attachments_dict(queryset) + return {"attachments_dict": attachments_dict} + +# The Exporter automatically uses get_context_data() when serializing +queryset = MyModel.objects.all() +exporter = Exporter(format_type="csv", schema_class=MySchema) +filename, content = exporter.export("data", queryset) +``` + +### 🔌 Registering Custom Formatters + +Add support for new export formats: + +```python +from plane.utils.exporters import Exporter, BaseFormatter + +class XMLFormatter(BaseFormatter): + def format(self, filename, records, schema_class, options=None): + # Implementation + return (f"{filename}.xml", xml_content) + +# Register the formatter +Exporter.register_formatter("xml", XMLFormatter) + +# Use it +exporter = Exporter(format_type="xml", schema_class=MySchema) +``` + +### ✅ Checking Available Formats + +```python +formats = Exporter.get_available_formats() +# Returns: ['csv', 'json', 'xlsx'] +``` + +### 🔍 Filtering Fields + +Pass a `fields` parameter to export only specific fields: + +```python +# Export only specific fields +exporter = Exporter(format_type="csv", schema_class=MySchema) +filename, content = exporter.export( + "filtered_data", + queryset, + fields=["id", "name", "email"] +) +``` + +### 🎯 Extending Schemas + +Create extended schemas by inheriting from existing ones and overriding `get_context_data()`: + +```python +class ExtendedIssueExportSchema(IssueExportSchema): + custom_field = JSONField(label="Custom Data") + + def prepare_custom_field(self, obj): + # Use pre-fetched data from context + return self.context.get("custom_data", {}).get(obj.id, {}) + + @classmethod + def get_context_data(cls, queryset): + # Get parent context (attachments, etc.) + context = super().get_context_data(queryset) + + # Add your custom pre-fetched data + context["custom_data"] = fetch_custom_data(queryset) + + return context +``` + +### 💾 Manual Serialization + +If you need to serialize data without exporting, you can use the schema directly: + +```python +# Serialize a queryset to a list of dicts +data = MySchema.serialize_queryset(queryset, fields=["id", "name"]) + +# Or serialize a single object +schema = MySchema() +obj_data = schema.serialize(obj) +``` + +## 💡 Example: IssueExportSchema + +The `IssueExportSchema` demonstrates a complete implementation: + +```python +from plane.utils.exporters import Exporter, IssueExportSchema + +# Simple export - just pass the queryset! +issues = Issue.objects.filter(project_id=project_id) +exporter = Exporter(format_type="csv", schema_class=IssueExportSchema) +filename, content = exporter.export("issues", issues) + +# Export specific fields only +filename, content = exporter.export( + "issues_filtered", + issues, + fields=["id", "name", "state_name", "assignees", "labels"] +) + +# Export multiple projects to separate files +for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + filename, content = exporter.export(f"issues-{project_id}", project_issues) + # Save or upload each file +``` + +Key features: + +- 🔗 Access to related models via dotted paths +- 🎯 Custom preparers for complex fields +- 📎 Context-based attachment handling via `get_context_data()` +- 📋 List and JSON field handling +- 📅 Date/datetime formatting + +## ✨ Best Practices + +1. **🚄 Avoid N+1 Queries**: Override `get_context_data()` to pre-fetch related data: + + ```python + @classmethod + def get_context_data(cls, queryset): + return { + "attachments": get_attachments_dict(queryset), + "comments": get_comments_dict(queryset), + } + ``` + +2. **🏷️ Use Labels**: Provide descriptive labels for better export headers: + + ```python + created_at = DateTimeField(source="created_at", label="Created At") + ``` + +3. **🛡️ Handle None Values**: Set appropriate defaults for fields that might be None: + + ```python + count = NumberField(source="count", default=0) + ``` + +4. **🎯 Use Preparers for Complex Logic**: Keep field definitions simple and use preparers for complex transformations: + + ```python + def prepare_assignees(self, obj): + return [f"{u.first_name} {u.last_name}" for u in obj.assignee_details] + ``` + +5. **⚡ Pass QuerySets Directly**: Let the Exporter handle serialization: + + ```python + # Good - Exporter handles serialization + exporter.export("data", queryset) + + # Avoid - Manual serialization unless needed + data = MySchema.serialize_queryset(queryset) + exporter.export("data", data) + ``` + +6. **📦 Filter QuerySets, Not Data**: For multiple exports, filter the queryset instead of the serialized data: + + ```python + # Good - efficient, only serializes what's needed + for project_id in project_ids: + project_issues = issues.filter(project_id=project_id) + exporter.export(f"project-{project_id}", project_issues) + + # Avoid - serializes all data upfront + all_data = MySchema.serialize_queryset(issues) + for project_id in project_ids: + project_data = [d for d in all_data if d['project_id'] == project_id] + exporter.export(f"project-{project_id}", project_data) + ``` + +## 📚 API Reference + +### 📊 Exporter + +**`__init__(format_type, schema_class, options=None)`** + +- `format_type`: Export format ('csv', 'json', 'xlsx') +- `schema_class`: Schema class defining fields +- `options`: Optional dict of format-specific options + +**`export(filename, data, fields=None)`** + +- `filename`: Filename without extension +- `data`: Django QuerySet or list of dicts +- `fields`: Optional list of field names to include +- Returns: `(filename_with_extension, content)` +- `content` is str for CSV/JSON, bytes for XLSX + +**`get_available_formats()`** (class method) + +- Returns: List of available format types + +**`register_formatter(format_type, formatter_class)`** (class method) + +- Register a custom formatter + +### 📝 ExportSchema + +**`__init__(context=None)`** + +- `context`: Optional dict accessible in preparer methods via `self.context` for pre-fetched data + +**`serialize(obj, fields=None)`** + +- Returns: Dict of serialized field values for a single object + +**`serialize_queryset(queryset, fields=None)`** (class method) + +- `queryset`: QuerySet of objects to serialize +- `fields`: Optional list of field names to include +- Returns: List of dicts with serialized data + +**`get_context_data(queryset)`** (class method) + +- Override to pre-fetch related data for the queryset +- Returns: Dict of context data + +### 🔧 ExportField + +Base class for all field types. Subclass to create custom field types. + +**`get_value(obj, context)`** + +- Returns: Formatted value for the field + +**`_format_value(raw)`** + +- Override in subclasses for type-specific formatting + +## 🧪 Testing + +```python +# Test exporting a queryset +queryset = MyModel.objects.all() +exporter = Exporter(format_type="json", schema_class=MySchema) +filename, content = exporter.export("test", queryset) +assert filename == "test.json" +assert isinstance(content, str) + +# Test with field filtering +filename, content = exporter.export("test", queryset, fields=["id", "name"]) +data = json.loads(content) +assert all(set(item.keys()) == {"id", "name"} for item in data) + +# Test manual serialization +data = MySchema.serialize_queryset(queryset) +assert len(data) == queryset.count() +``` diff --git a/apps/api/plane/utils/exporters/__init__.py b/apps/api/plane/utils/exporters/__init__.py new file mode 100644 index 000000000..9e7b1a9d5 --- /dev/null +++ b/apps/api/plane/utils/exporters/__init__.py @@ -0,0 +1,38 @@ +"""Export utilities for various data formats.""" + +from .exporter import Exporter +from .formatters import BaseFormatter, CSVFormatter, JSONFormatter, XLSXFormatter +from .schemas import ( + BooleanField, + DateField, + DateTimeField, + ExportField, + ExportSchema, + IssueExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) + +__all__ = [ + # Core Exporter + "Exporter", + # Schemas + "ExportSchema", + "ExportField", + "StringField", + "NumberField", + "DateField", + "DateTimeField", + "BooleanField", + "ListField", + "JSONField", + # Formatters + "BaseFormatter", + "CSVFormatter", + "JSONFormatter", + "XLSXFormatter", + # Issue Schema + "IssueExportSchema", +] diff --git a/apps/api/plane/utils/exporters/exporter.py b/apps/api/plane/utils/exporters/exporter.py new file mode 100644 index 000000000..75b396cb4 --- /dev/null +++ b/apps/api/plane/utils/exporters/exporter.py @@ -0,0 +1,72 @@ +from typing import Any, Dict, List, Type, Union + +from django.db.models import QuerySet + +from .formatters import CSVFormatter, JSONFormatter, XLSXFormatter + + +class Exporter: + """Generic exporter class that handles data exports using different formatters.""" + + # Available formatters + FORMATTERS = { + "csv": CSVFormatter, + "json": JSONFormatter, + "xlsx": XLSXFormatter, + } + + def __init__(self, format_type: str, schema_class: Type, options: Dict[str, Any] = None): + """Initialize exporter with specified format type and schema. + + Args: + format_type: The export format (csv, json, xlsx) + schema_class: The schema class to use for field definitions + options: Optional formatting options + """ + if format_type not in self.FORMATTERS: + raise ValueError(f"Unsupported format: {format_type}. Available: {list(self.FORMATTERS.keys())}") + + self.format_type = format_type + self.schema_class = schema_class + self.formatter = self.FORMATTERS[format_type]() + self.options = options or {} + + def export( + self, + filename: str, + data: Union[QuerySet, List[dict]], + fields: List[str] = None, + ) -> tuple[str, str | bytes]: + """Export data using the configured formatter and return (filename, content). + + Args: + filename: The filename for the export (without extension) + data: Either a Django QuerySet or a list of already-serialized dicts + fields: Optional list of field names to include in export + + Returns: + Tuple of (filename_with_extension, content) + """ + # Serialize the queryset if needed + if isinstance(data, QuerySet): + records = self.schema_class.serialize_queryset(data, fields=fields) + else: + # Already serialized data + records = data + + # Merge fields into options for the formatter + format_options = {**self.options} + if fields: + format_options["fields"] = fields + + return self.formatter.format(filename, records, self.schema_class, format_options) + + @classmethod + def get_available_formats(cls) -> List[str]: + """Get list of available export formats.""" + return list(cls.FORMATTERS.keys()) + + @classmethod + def register_formatter(cls, format_type: str, formatter_class: type) -> None: + """Register a new formatter for a format type.""" + cls.FORMATTERS[format_type] = formatter_class diff --git a/apps/api/plane/utils/exporters/formatters.py b/apps/api/plane/utils/exporters/formatters.py new file mode 100644 index 000000000..fc7c23528 --- /dev/null +++ b/apps/api/plane/utils/exporters/formatters.py @@ -0,0 +1,199 @@ +import csv +import io +import json +from typing import Any, Dict, List, Type + +from openpyxl import Workbook + + +class BaseFormatter: + """Base class for export formatters.""" + + def format( + self, + filename: str, + records: List[dict], + schema_class: Type, + options: Dict[str, Any] | None = None, + ) -> tuple[str, str | bytes]: + """Format records for export. + + Args: + filename: The filename for the export (without extension) + records: List of records to export + schema_class: Schema class to extract field order and labels + options: Optional formatting options + + Returns: + Tuple of (filename_with_extension, content) + """ + raise NotImplementedError + + @staticmethod + def _get_field_info(schema_class: Type) -> tuple[List[str], Dict[str, str]]: + """Extract field order and labels from schema. + + Args: + schema_class: Schema class with field definitions + + Returns: + Tuple of (field_order, field_labels) + """ + if not hasattr(schema_class, "_declared_fields"): + raise ValueError(f"Schema class {schema_class.__name__} must have _declared_fields attribute") + + # Get order and labels from schema + field_order = list(schema_class._declared_fields.keys()) + field_labels = { + name: field.label if field.label else name.replace("_", " ").title() + for name, field in schema_class._declared_fields.items() + } + + return field_order, field_labels + + +class CSVFormatter(BaseFormatter): + """Formatter for CSV exports.""" + + @staticmethod + def _format_field_value(value: Any, list_joiner: str = ", ") -> str: + """Format a field value for CSV output.""" + if value is None: + return "" + if isinstance(value, list): + return list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + # For complex objects, serialize as JSON + return json.dumps(value) + return str(value) + + def _generate_table_row( + self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None + ) -> List[str]: + """Generate a CSV row from a record.""" + opts = options or {} + list_joiner = opts.get("list_joiner", ", ") + return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order] + + def _create_csv_file(self, data: List[List[str]]) -> str: + """Create CSV file content from row data.""" + buf = io.StringIO() + writer = csv.writer(buf, delimiter=",", quoting=csv.QUOTE_ALL) + for row in data: + writer.writerow(row) + buf.seek(0) + return buf.getvalue() + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]: + if not records: + return (f"{filename}.csv", "") + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + header = [field_labels[field] for field in field_order] + + rows = [header] + for record in records: + row = self._generate_table_row(record, field_order, options) + rows.append(row) + content = self._create_csv_file(rows) + return (f"{filename}.csv", content) + + +class JSONFormatter(BaseFormatter): + """Formatter for JSON exports.""" + + def _generate_json_row( + self, record: dict, field_labels: Dict[str, str], field_order: List[str], options: Dict[str, Any] | None = None + ) -> dict: + """Generate a JSON object from a record. + + Preserves data types - lists stay as arrays, dicts stay as objects. + """ + return {field_labels[field]: record.get(field) for field in field_order if field in record} + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, str]: + if not records: + return (f"{filename}.json", "[]") + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + rows: List[dict] = [] + for record in records: + row = self._generate_json_row(record, field_labels, field_order, options) + rows.append(row) + content = json.dumps(rows) + return (f"{filename}.json", content) + + +class XLSXFormatter(BaseFormatter): + """Formatter for XLSX (Excel) exports.""" + + @staticmethod + def _format_field_value(value: Any, list_joiner: str = ", ") -> str: + """Format a field value for XLSX output.""" + if value is None: + return "" + if isinstance(value, list): + return list_joiner.join(str(v) for v in value) + if isinstance(value, dict): + # For complex objects, serialize as JSON + return json.dumps(value) + return str(value) + + def _generate_table_row( + self, record: dict, field_order: List[str], options: Dict[str, Any] | None = None + ) -> List[str]: + """Generate an XLSX row from a record.""" + opts = options or {} + list_joiner = opts.get("list_joiner", ", ") + return [self._format_field_value(record.get(field, ""), list_joiner) for field in field_order] + + def _create_xlsx_file(self, data: List[List[str]]) -> bytes: + """Create XLSX file content from row data.""" + wb = Workbook() + sh = wb.active + for row in data: + sh.append(row) + out = io.BytesIO() + wb.save(out) + out.seek(0) + return out.getvalue() + + def format(self, filename, records, schema_class, options: Dict[str, Any] | None = None) -> tuple[str, bytes]: + if not records: + # Create empty workbook + content = self._create_xlsx_file([]) + return (f"{filename}.xlsx", content) + + # Get field order and labels from schema + field_order, field_labels = self._get_field_info(schema_class) + + # Filter to requested fields if specified + opts = options or {} + requested_fields = opts.get("fields") + if requested_fields: + field_order = [f for f in field_order if f in requested_fields] + + header = [field_labels[field] for field in field_order] + + rows = [header] + for record in records: + row = self._generate_table_row(record, field_order, options) + rows.append(row) + content = self._create_xlsx_file(rows) + return (f"{filename}.xlsx", content) diff --git a/apps/api/plane/utils/exporters/schemas/__init__.py b/apps/api/plane/utils/exporters/schemas/__init__.py new file mode 100644 index 000000000..98b2623ae --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/__init__.py @@ -0,0 +1,30 @@ +"""Export schemas for various data types.""" + +from .base import ( + BooleanField, + DateField, + DateTimeField, + ExportField, + ExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) +from .issue import IssueExportSchema + +__all__ = [ + # Base field types + "ExportField", + "StringField", + "NumberField", + "DateField", + "DateTimeField", + "BooleanField", + "ListField", + "JSONField", + # Base schema + "ExportSchema", + # Issue schema + "IssueExportSchema", +] diff --git a/apps/api/plane/utils/exporters/schemas/base.py b/apps/api/plane/utils/exporters/schemas/base.py new file mode 100644 index 000000000..4e67c6980 --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/base.py @@ -0,0 +1,234 @@ +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from django.db.models import QuerySet + + +@dataclass +class ExportField: + """Base export field class for generic fields.""" + + source: Optional[str] = None + default: Any = "" + label: Optional[str] = None # Display name for export headers + + def get_value(self, obj: Any, context: Dict[str, Any]) -> Any: + raw: Any + if self.source: + raw = self._resolve_dotted_path(obj, self.source) + else: + raw = obj + + return self._format_value(raw) + + def _format_value(self, raw: Any) -> Any: + """Format the raw value. Override in subclasses for type-specific formatting.""" + return raw if raw is not None else self.default + + def _resolve_dotted_path(self, obj: Any, path: str) -> Any: + current = obj + for part in path.split("."): + if current is None: + return None + if hasattr(current, part): + current = getattr(current, part) + elif isinstance(current, dict): + current = current.get(part) + else: + return None + return current + + +@dataclass +class StringField(ExportField): + """Export field for string values.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + return str(raw) + + +@dataclass +class DateField(ExportField): + """Export field for date values with automatic conversion.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + # Convert date to formatted string + if hasattr(raw, "strftime"): + return raw.strftime("%a, %d %b %Y") + return str(raw) + + +@dataclass +class DateTimeField(ExportField): + """Export field for datetime values with automatic conversion.""" + + default: str = "" + + def _format_value(self, raw: Any) -> str: + if raw is None: + return self.default + # Convert datetime to formatted string + if hasattr(raw, "strftime"): + return raw.strftime("%a, %d %b %Y %I:%M:%S %Z%z") + return str(raw) + + +@dataclass +class NumberField(ExportField): + """Export field for numeric values.""" + + default: Any = "" + + def _format_value(self, raw: Any) -> Any: + if raw is None: + return self.default + return raw + + +@dataclass +class BooleanField(ExportField): + """Export field for boolean values.""" + + default: bool = False + + def _format_value(self, raw: Any) -> bool: + if raw is None: + return self.default + return bool(raw) + + +@dataclass +class ListField(ExportField): + """Export field for list/array values. + + Returns the list as-is by default. The formatter will handle conversion to strings + when needed (e.g., CSV/XLSX will join with separator, JSON will keep as array). + """ + + default: Optional[List] = field(default_factory=list) + + def _format_value(self, raw: Any) -> List[Any]: + if raw is None: + return self.default if self.default is not None else [] + if isinstance(raw, (list, tuple)): + return list(raw) + return [raw] # Wrap single items in a list + + +@dataclass +class JSONField(ExportField): + """Export field for complex JSON-serializable values (dicts, lists of dicts, etc). + + Preserves the structure as-is for JSON exports. For CSV/XLSX, the formatter + will handle serialization (e.g., JSON stringify). + """ + + default: Any = field(default_factory=dict) + + def _format_value(self, raw: Any) -> Any: + if raw is None: + return self.default + # Return as-is - should be JSON-serializable + return raw + + +class ExportSchemaMeta(type): + def __new__(mcls, name, bases, attrs): + declared: Dict[str, ExportField] = { + key: value for key, value in list(attrs.items()) if isinstance(value, ExportField) + } + for key in declared.keys(): + attrs.pop(key) + cls = super().__new__(mcls, name, bases, attrs) + base_fields: Dict[str, ExportField] = {} + for base in bases: + if hasattr(base, "_declared_fields"): + base_fields.update(base._declared_fields) + base_fields.update(declared) + cls._declared_fields = base_fields + return cls + + +class ExportSchema(metaclass=ExportSchemaMeta): + """Base schema for exporting data in various formats. + + Subclasses should define fields as class attributes and can override: + - prepare_ methods for custom field serialization + - get_context_data() class method to pre-fetch related data for the queryset + """ + + def __init__(self, context: Optional[Dict[str, Any]] = None) -> None: + self.context = context or {} + + def serialize(self, obj: Any, fields: Optional[List[str]] = None) -> Dict[str, Any]: + """Serialize a single object. + + Args: + obj: The object to serialize + fields: Optional list of field names to include. If None, all fields are serialized. + + Returns: + Dictionary of serialized data + """ + output: Dict[str, Any] = {} + # Determine which fields to process + fields_to_process = fields if fields else list(self._declared_fields.keys()) + + for field_name in fields_to_process: + # Skip if field doesn't exist in schema + if field_name not in self._declared_fields: + continue + + export_field = self._declared_fields[field_name] + + # Prefer explicit preparer methods if present + preparer = getattr(self, f"prepare_{field_name}", None) + if callable(preparer): + output[field_name] = preparer(obj) + continue + + output[field_name] = export_field.get_value(obj, self.context) + return output + + @classmethod + def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]: + """Get context data for serialization. Override in subclasses to pre-fetch related data. + + Args: + queryset: QuerySet of objects to be serialized + + Returns: + Dictionary of context data to be passed to the schema instance + """ + return {} + + @classmethod + def serialize_queryset(cls, queryset: QuerySet, fields: List[str] = None) -> List[Dict[str, Any]]: + """Serialize a queryset of objects to export data. + + Args: + queryset: QuerySet of objects to serialize + fields: Optional list of field names to include. Defaults to all fields. + + Returns: + List of dictionaries containing serialized data + """ + # Get context data (can be extended by subclasses) + context = cls.get_context_data(queryset) + + # Serialize each object, passing fields to only process requested fields + schema = cls(context=context) + data = [] + for obj in queryset: + obj_data = schema.serialize(obj, fields=fields) + data.append(obj_data) + + return data diff --git a/apps/api/plane/utils/exporters/schemas/issue.py b/apps/api/plane/utils/exporters/schemas/issue.py new file mode 100644 index 000000000..744e33052 --- /dev/null +++ b/apps/api/plane/utils/exporters/schemas/issue.py @@ -0,0 +1,210 @@ +from collections import defaultdict +from typing import Any, Dict, List, Optional + +from django.db.models import F, QuerySet + +from plane.db.models import CycleIssue, FileAsset + +from .base import ( + DateField, + DateTimeField, + ExportSchema, + JSONField, + ListField, + NumberField, + StringField, +) + + +def get_issue_attachments_dict(issues_queryset: QuerySet) -> Dict[str, List[str]]: + """Get attachments dictionary for the given issues queryset. + + Args: + issues_queryset: Queryset of Issue objects + + Returns: + Dictionary mapping issue IDs to lists of attachment IDs + """ + file_assets = FileAsset.objects.filter( + issue_id__in=issues_queryset.values_list("id", flat=True), + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + ).annotate(work_item_id=F("issue_id"), asset_id=F("id")) + + attachment_dict = defaultdict(list) + for asset in file_assets: + attachment_dict[asset.work_item_id].append(asset.asset_id) + + return attachment_dict + + +def get_issue_last_cycles_dict(issues_queryset: QuerySet) -> Dict[str, Optional[CycleIssue]]: + """Get the last cycle for each issue in the given queryset. + + Args: + issues_queryset: Queryset of Issue objects + + Returns: + Dictionary mapping issue IDs to their last CycleIssue object + """ + # Fetch all cycle issues for the given issues, ordered by created_at descending + # select_related is used to fetch cycle data in the same query + cycle_issues = ( + CycleIssue.objects.filter(issue_id__in=issues_queryset.values_list("id", flat=True)) + .select_related("cycle") + .order_by("issue_id", "-created_at") + ) + + # Keep only the last (most recent) cycle for each issue + last_cycles_dict = {} + for cycle_issue in cycle_issues: + if cycle_issue.issue_id not in last_cycles_dict: + last_cycles_dict[cycle_issue.issue_id] = cycle_issue + + return last_cycles_dict + + +class IssueExportSchema(ExportSchema): + """Schema for exporting issue data in various formats.""" + + @staticmethod + def _get_created_by(obj) -> str: + """Get the created by user for the given object.""" + try: + if getattr(obj, "created_by", None): + return f"{obj.created_by.first_name} {obj.created_by.last_name}" + except Exception: + pass + return "" + + @staticmethod + def _format_date(date_obj) -> str: + """Format date object to string.""" + if date_obj and hasattr(date_obj, "strftime"): + return date_obj.strftime("%a, %d %b %Y") + return "" + + # Field definitions with display labels + id = StringField(label="ID") + project_identifier = StringField(source="project.identifier", label="Project Identifier") + project_name = StringField(source="project.name", label="Project") + project_id = StringField(source="project.id", label="Project ID") + sequence_id = NumberField(source="sequence_id", label="Sequence ID") + name = StringField(source="name", label="Name") + description = StringField(source="description_stripped", label="Description") + priority = StringField(source="priority", label="Priority") + start_date = DateField(source="start_date", label="Start Date") + target_date = DateField(source="target_date", label="Target Date") + state_name = StringField(label="State") + created_at = DateTimeField(source="created_at", label="Created At") + updated_at = DateTimeField(source="updated_at", label="Updated At") + completed_at = DateTimeField(source="completed_at", label="Completed At") + archived_at = DateTimeField(source="archived_at", label="Archived At") + module_name = ListField(label="Module Name") + created_by = StringField(label="Created By") + labels = ListField(label="Labels") + comments = JSONField(label="Comments") + estimate = StringField(label="Estimate") + link = ListField(label="Link") + assignees = ListField(label="Assignees") + subscribers_count = NumberField(label="Subscribers Count") + attachment_count = NumberField(label="Attachment Count") + attachment_links = ListField(label="Attachment Links") + cycle_name = StringField(label="Cycle Name") + cycle_start_date = DateField(label="Cycle Start Date") + cycle_end_date = DateField(label="Cycle End Date") + parent = StringField(label="Parent") + relations = JSONField(label="Relations") + + def prepare_id(self, i): + return f"{i.project.identifier}-{i.sequence_id}" + + def prepare_state_name(self, i): + return i.state.name if i.state else None + + def prepare_module_name(self, i): + return [m.module.name for m in i.issue_module.all()] + + def prepare_created_by(self, i): + return self._get_created_by(i) + + def prepare_labels(self, i): + return [label.name for label in i.labels.all()] + + def prepare_comments(self, i): + return [ + { + "comment": comment.comment_stripped, + "created_at": self._format_date(comment.created_at), + "created_by": self._get_created_by(comment), + } + for comment in i.issue_comments.all() + ] + + def prepare_estimate(self, i): + return i.estimate_point.value if i.estimate_point and i.estimate_point.value else "" + + def prepare_link(self, i): + return [link.url for link in i.issue_link.all()] + + def prepare_assignees(self, i): + return [f"{u.first_name} {u.last_name}" for u in i.assignees.all()] + + def prepare_subscribers_count(self, i): + return i.issue_subscribers.count() + + def prepare_attachment_count(self, i): + return len((self.context.get("attachments_dict") or {}).get(i.id, [])) + + def prepare_attachment_links(self, i): + return [ + f"/api/assets/v2/workspaces/{i.workspace.slug}/projects/{i.project_id}/issues/{i.id}/attachments/{asset}/" + for asset in (self.context.get("attachments_dict") or {}).get(i.id, []) + ] + + def prepare_cycle_name(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + return last_cycle.cycle.name if last_cycle else "" + + def prepare_cycle_start_date(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + if last_cycle and last_cycle.cycle.start_date: + return self._format_date(last_cycle.cycle.start_date) + return "" + + def prepare_cycle_end_date(self, i): + cycles_dict = self.context.get("cycles_dict") or {} + last_cycle = cycles_dict.get(i.id) + if last_cycle and last_cycle.cycle.end_date: + return self._format_date(last_cycle.cycle.end_date) + return "" + + def prepare_parent(self, i): + if not i.parent: + return "" + return f"{i.parent.project.identifier}-{i.parent.sequence_id}" + + def prepare_relations(self, i): + # Should show reverse relation as well + from plane.db.models.issue import IssueRelationChoices + + relations = { + r.relation_type: f"{r.related_issue.project.identifier}-{r.related_issue.sequence_id}" + for r in i.issue_relation.all() + } + reverse_relations = {} + for relation in i.issue_related.all(): + reverse_relations[IssueRelationChoices._REVERSE_MAPPING[relation.relation_type]] = ( + f"{relation.issue.project.identifier}-{relation.issue.sequence_id}" + ) + relations.update(reverse_relations) + return relations + + @classmethod + def get_context_data(cls, queryset: QuerySet) -> Dict[str, Any]: + """Get context data for issue serialization.""" + return { + "attachments_dict": get_issue_attachments_dict(queryset), + "cycles_dict": get_issue_last_cycles_dict(queryset), + } diff --git a/apps/api/plane/utils/filters/__init__.py b/apps/api/plane/utils/filters/__init__.py new file mode 100644 index 000000000..76a96c82c --- /dev/null +++ b/apps/api/plane/utils/filters/__init__.py @@ -0,0 +1,10 @@ +# Filters module for handling complex filtering operations + +# Import all utilities from base modules +from .filter_backend import ComplexFilterBackend +from .converters import LegacyToRichFiltersConverter +from .filterset import BaseFilterSet, IssueFilterSet + + +# Public API exports +__all__ = ["ComplexFilterBackend", "LegacyToRichFiltersConverter", "BaseFilterSet", "IssueFilterSet"] diff --git a/apps/api/plane/utils/filters/converters.py b/apps/api/plane/utils/filters/converters.py new file mode 100644 index 000000000..f7693b40e --- /dev/null +++ b/apps/api/plane/utils/filters/converters.py @@ -0,0 +1,420 @@ +import re +import uuid +from datetime import datetime +from typing import Any, Dict, List, Union + +from dateutil.parser import parse as dateutil_parse + + +class LegacyToRichFiltersConverter: + # Default mapping from legacy filter names to new rich filter field names + DEFAULT_FIELD_MAPPINGS = { + "state": "state_id", + "labels": "label_id", + "cycle": "cycle_id", + "module": "module_id", + "assignees": "assignee_id", + "mentions": "mention_id", + "created_by": "created_by_id", + "state_group": "state_group", + "priority": "priority", + "project": "project_id", + "start_date": "start_date", + "target_date": "target_date", + } + + # Default fields that expect UUID values + DEFAULT_UUID_FIELDS = { + "state_id", + "label_id", + "cycle_id", + "module_id", + "assignee_id", + "mention_id", + "created_by_id", + "project_id", + } + + # Default valid choices for choice fields + DEFAULT_VALID_CHOICES = { + "state_group": ["backlog", "unstarted", "started", "completed", "cancelled"], + "priority": ["urgent", "high", "medium", "low", "none"], + } + + # Default date fields + DEFAULT_DATE_FIELDS = {"start_date", "target_date"} + + # Pattern for relative date strings like "2_weeks" or "3_months" + DATE_PATTERN = re.compile(r"(\d+)_(weeks|months)$") + + def __init__( + self, + field_mappings: Dict[str, str] = None, + uuid_fields: set = None, + valid_choices: Dict[str, List[str]] = None, + date_fields: set = None, + extend_defaults: bool = True, + ): + """ + Initialize the converter with optional custom configurations. + + Args: + field_mappings: Custom field mappings (legacy_key -> rich_field_name) + uuid_fields: Set of field names that should be validated as UUIDs + valid_choices: Dict of valid choices for choice fields + date_fields: Set of field names that should be treated as dates + extend_defaults: If True, merge with defaults; if False, replace defaults + + Examples: + # Use defaults + converter = LegacyToRichFiltersConverter() + + # Add custom field mapping + converter = LegacyToRichFiltersConverter( + field_mappings={"custom_field": "custom_field_id"} + ) + + # Override priority choices + converter = LegacyToRichFiltersConverter( + valid_choices={"priority": ["critical", "high", "medium", "low"]} + ) + + # Complete replacement (not extending defaults) + converter = LegacyToRichFiltersConverter( + field_mappings={"state": "status_id"}, + extend_defaults=False + ) + """ + if extend_defaults: + # Merge with defaults + self.FIELD_MAPPINGS = {**self.DEFAULT_FIELD_MAPPINGS} + if field_mappings: + self.FIELD_MAPPINGS.update(field_mappings) + + self.UUID_FIELDS = {*self.DEFAULT_UUID_FIELDS} + if uuid_fields: + self.UUID_FIELDS.update(uuid_fields) + + self.VALID_CHOICES = {**self.DEFAULT_VALID_CHOICES} + if valid_choices: + self.VALID_CHOICES.update(valid_choices) + + self.DATE_FIELDS = {*self.DEFAULT_DATE_FIELDS} + if date_fields: + self.DATE_FIELDS.update(date_fields) + else: + # Replace defaults entirely + self.FIELD_MAPPINGS = field_mappings or {} + self.UUID_FIELDS = uuid_fields or set() + self.VALID_CHOICES = valid_choices or {} + self.DATE_FIELDS = date_fields or set() + + def add_field_mapping(self, legacy_key: str, rich_field_name: str) -> None: + """Add or update a single field mapping.""" + self.FIELD_MAPPINGS[legacy_key] = rich_field_name + + def add_uuid_field(self, field_name: str) -> None: + """Add a field that should be validated as UUID.""" + self.UUID_FIELDS.add(field_name) + + def add_choice_field(self, field_name: str, choices: List[str]) -> None: + """Add or update valid choices for a choice field.""" + self.VALID_CHOICES[field_name] = choices + + def add_date_field(self, field_name: str) -> None: + """Add a field that should be treated as a date field.""" + self.DATE_FIELDS.add(field_name) + + def update_mappings( + self, + field_mappings: Dict[str, str] = None, + uuid_fields: set = None, + valid_choices: Dict[str, List[str]] = None, + date_fields: set = None, + ) -> None: + """ + Update multiple configurations at once. + + Args: + field_mappings: Additional field mappings to add/update + uuid_fields: Additional UUID fields to add + valid_choices: Additional choice fields to add/update + date_fields: Additional date fields to add + """ + if field_mappings: + self.FIELD_MAPPINGS.update(field_mappings) + if uuid_fields: + self.UUID_FIELDS.update(uuid_fields) + if valid_choices: + self.VALID_CHOICES.update(valid_choices) + if date_fields: + self.DATE_FIELDS.update(date_fields) + + def _validate_uuid(self, value: str) -> bool: + """Validate if a string is a valid UUID""" + try: + uuid.UUID(str(value)) + return True + except (ValueError, TypeError): + return False + + def _validate_choice(self, field_name: str, value: str) -> bool: + """Validate if a value is valid for a choice field""" + if field_name not in self.VALID_CHOICES: + return True # No validation needed for this field + return value in self.VALID_CHOICES[field_name] + + def _validate_date(self, value: Union[str, datetime]) -> bool: + """Validate if a value is a valid date using dateutil parser""" + if isinstance(value, datetime): + return True + if isinstance(value, str): + try: + # Use dateutil for flexible date parsing + dateutil_parse(value) + return True + except (ValueError, TypeError): + return False + return False + + def _validate_value(self, rich_field_name: str, value: Any) -> bool: + """Validate a single value based on field type""" + if rich_field_name in self.UUID_FIELDS: + return self._validate_uuid(value) + elif rich_field_name in self.VALID_CHOICES: + return self._validate_choice(rich_field_name, value) + elif rich_field_name in self.DATE_FIELDS: + return self._validate_date(value) + return True # No specific validation needed + + def _filter_valid_values(self, rich_field_name: str, values: List[Any]) -> List[Any]: + """Filter out invalid values from a list and return only valid ones""" + valid_values = [] + for value in values: + if self._validate_value(rich_field_name, value): + valid_values.append(value) + return valid_values + + def _add_validation_error(self, strict: bool, validation_errors: List[str], message: str) -> None: + """Add validation error if in strict mode.""" + if strict: + validation_errors.append(message) + + def _add_rich_filter(self, rich_filters: Dict[str, Any], field_name: str, operator: str, value: Any) -> None: + """Add a rich filter with proper field name formatting.""" + # Convert lists to comma-separated strings for 'in' and 'range' operations + if operator in ("in", "range") and isinstance(value, list): + value = ",".join(str(v) for v in value) + rich_filters[f"{field_name}__{operator}"] = value + + def _handle_value_error(self, e: ValueError, strict: bool, validation_errors: List[str]) -> None: + """Handle ValueError with consistent strict/non-strict behavior.""" + if strict: + validation_errors.append(str(e)) + # In non-strict mode, we just skip (no action needed) + + def _process_date_field( + self, + rich_field_name: str, + values: List[str], + strict: bool, + validation_errors: List[str], + rich_filters: Dict[str, Any], + ) -> bool: + """Process date field with basic functionality (exact, range).""" + if rich_field_name not in self.DATE_FIELDS: + return False + + try: + date_filter_result = self._convert_date_value(rich_field_name, values, strict) + if date_filter_result: + rich_filters.update(date_filter_result) + return True + except ValueError as e: + self._handle_value_error(e, strict, validation_errors) + return True + + def _convert_date_value(self, field_name: str, values: List[str], strict: bool = False) -> Dict[str, Any]: + """ + Convert legacy date values to rich filter format - basic implementation. + + Supports: + - Simple dates: "2023-01-01" -> __exact + - Basic ranges: ["2023-01-01;after", "2023-12-31;before"] -> __range + - Skips complex or relative date patterns + + Args: + field_name: Name of the rich filter field + values: List of legacy date values + strict: If True, raise errors for validation failures + + Raises: + ValueError: For malformed date patterns (strict mode) + """ + # Check for relative dates and skip the entire field if found + for value in values: + if ";" in value: + parts = value.split(";") + if len(parts) > 0 and self.DATE_PATTERN.match(parts[0]): + # Skip relative date patterns entirely + return {} + + # Skip complex conditions (more than 2 values) + if len(values) > 2: + return {} + + # Process each date value + exact_dates = [] + after_dates = [] + before_dates = [] + + for value in values: + if ";" not in value: + # Simple date string + if not self._validate_date(value): + if strict: + raise ValueError(f"Invalid date format: {value}") + continue + exact_dates.append(value) + else: + # Directional date - only handle basic after/before + parts = value.split(";") + if len(parts) < 2: + if strict: + raise ValueError(f"Invalid date format: {value}") + continue + + date_part = parts[0] + direction = parts[1] + + if not self._validate_date(date_part): + if strict: + raise ValueError(f"Invalid date format: {date_part}") + continue + + if direction == "after": + after_dates.append(date_part) + elif direction == "before": + before_dates.append(date_part) + # Skip unsupported directions + + # Determine return format + result = {} + if len(after_dates) == 1 and len(before_dates) == 1 and len(exact_dates) == 0: + # Simple range: one after and one before + start_date = min(after_dates[0], before_dates[0]) + end_date = max(after_dates[0], before_dates[0]) + self._add_rich_filter(result, field_name, "range", [start_date, end_date]) + elif len(exact_dates) == 1 and len(after_dates) == 0 and len(before_dates) == 0: + # Single exact date + self._add_rich_filter(result, field_name, "exact", exact_dates[0]) + # Skip all other combinations + + return result + + def convert(self, legacy_filters: dict, strict: bool = False) -> Dict[str, Any]: + """ + Convert legacy filters to rich filters format with validation + + Args: + legacy_filters: Dictionary of legacy filters + strict: If True, raise exception on validation errors. + If False, skip invalid values (default behavior) + + Returns: + Dictionary of rich filters + + Raises: + ValueError: If strict=True and validation fails + """ + rich_filters = {} + validation_errors = [] + + for legacy_key, value in legacy_filters.items(): + # Skip if value is None or empty + if value is None or (isinstance(value, list) and len(value) == 0): + continue + + # Skip if legacy key is not in our mappings (not supported in filterset) + if legacy_key not in self.FIELD_MAPPINGS: + self._add_validation_error(strict, validation_errors, f"Unsupported filter key: {legacy_key}") + continue + + # Get the new field name + rich_field_name = self.FIELD_MAPPINGS[legacy_key] + + # Handle list values + if isinstance(value, list): + # Process date fields with helper method + if self._process_date_field(rich_field_name, value, strict, validation_errors, rich_filters): + continue + + # Regular non-date field processing + # Filter out invalid values + valid_values = self._filter_valid_values(rich_field_name, value) + + if not valid_values: + self._add_validation_error( + strict, + validation_errors, + f"No valid values found for {legacy_key}: {value}", + ) + continue + + # Check for invalid values if in strict mode + if strict and len(valid_values) != len(value): + invalid_values = [v for v in value if v not in valid_values] + self._add_validation_error( + strict, + validation_errors, + f"Invalid values for {legacy_key}: {invalid_values}", + ) + + # For list values, always use __in operator for non-date fields + self._add_rich_filter(rich_filters, rich_field_name, "in", valid_values) + + else: + # Handle single values + # Process date fields with helper method + if self._process_date_field(rich_field_name, [value], strict, validation_errors, rich_filters): + continue + + # For non-list values, use __exact operator for non-date fields + if self._validate_value(rich_field_name, value): + self._add_rich_filter(rich_filters, rich_field_name, "exact", value) + else: + error_msg = f"Invalid value for {legacy_key}: {value}" + self._add_validation_error(strict, validation_errors, error_msg) + + # Raise validation errors if in strict mode + if strict and validation_errors: + error_message = f"Filter validation errors: {'; '.join(validation_errors)}" + raise ValueError(error_message) + + # Convert flat dict to rich filter format + return self._format_as_rich_filter(rich_filters) + + def _format_as_rich_filter(self, flat_filters: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert a flat dictionary of filters to the proper rich filter format. + + Args: + flat_filters: Dictionary with field__lookup keys and values + + Returns: + Rich filter format using logical operators (and/or/not) + """ + if not flat_filters: + return {} + + # If only one filter, return as leaf node + if len(flat_filters) == 1: + key, value = next(iter(flat_filters.items())) + return {key: value} + + # Multiple filters: wrap in 'and' operator + filter_conditions = [] + for key, value in flat_filters.items(): + filter_conditions.append({key: value}) + + return {"and": filter_conditions} diff --git a/apps/api/plane/utils/filters/filter_backend.py b/apps/api/plane/utils/filters/filter_backend.py new file mode 100644 index 000000000..2f7a27d36 --- /dev/null +++ b/apps/api/plane/utils/filters/filter_backend.py @@ -0,0 +1,313 @@ +import json + +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.http import QueryDict +from django_filters.utils import translate_validation +from rest_framework import filters + + +class ComplexFilterBackend(filters.BaseFilterBackend): + """ + Filter backend that supports complex JSON filtering. + + For full, up-to-date examples and usage, see the package README + at `plane/utils/filters/README.md`. + """ + + filter_param = "filters" + default_max_depth = 5 + + def filter_queryset(self, request, queryset, view, filter_data=None): + """Normalize filter input and apply JSON-based filtering. + + Accepts explicit `filter_data` (dict or JSON string) or reads the + `filter` query parameter. Enforces JSON-only filtering. + """ + try: + if filter_data is not None: + normalized = self._normalize_filter_data(filter_data, "filter_data") + return self._apply_json_filter(queryset, normalized, view) + + filter_string = request.query_params.get(self.filter_param, None) + if not filter_string: + return queryset + + normalized = self._normalize_filter_data(filter_string, "filter") + return self._apply_json_filter(queryset, normalized, view) + except ValidationError: + # Propagate validation errors unchanged + raise + except Exception as e: + # Convert unexpected errors to ValidationError to keep response consistent + raise ValidationError(f"Filter error: {str(e)}") + + def _normalize_filter_data(self, raw_filter, source_label): + """Return a dict from raw filter input or raise a ValidationError. + + - raw_filter may be a dict or a JSON string + - source_label is used in error messages (e.g., 'filter_data' or 'filter') + """ + try: + if isinstance(raw_filter, str): + return json.loads(raw_filter) + if isinstance(raw_filter, dict): + return raw_filter + raise ValidationError(f"'{source_label}' must be a dict or a JSON string.") + except json.JSONDecodeError: + raise ValidationError(f"Invalid JSON for '{source_label}'. Expected a valid JSON object.") + + def _apply_json_filter(self, queryset, filter_data, view): + """Process a JSON filter structure using Q object composition.""" + if not filter_data: + return queryset + + # Validate structure and depth before field allowlist checks + max_depth = self._get_max_depth(view) + self._validate_structure(filter_data, max_depth=max_depth, current_depth=1) + + # Validate against the view's FilterSet (only declared filters are allowed) + self._validate_fields(filter_data, view) + + # Build combined Q object from the filter tree + combined_q = self._evaluate_node(filter_data, view, queryset) + if combined_q is None: + return queryset + + # Apply the combined Q object to the queryset once + return queryset.filter(combined_q) + + def _validate_fields(self, filter_data, view): + """Validate that filtered fields are defined in the view's FilterSet.""" + filterset_class = getattr(view, "filterset_class", None) + allowed_fields = set(filterset_class.base_filters.keys()) if filterset_class else None + if not allowed_fields: + # If no FilterSet is configured, reject filtering to avoid unintended exposure # noqa: E501 + raise ValidationError("Filtering is not enabled for this endpoint (missing filterset_class)") + + # Extract field names from the filter data + fields = self._extract_field_names(filter_data) + + # Check if all fields are allowed + for field in fields: + # Field keys must match FilterSet filter names (including any lookups) + # Example: 'sequence_id__gte' should be declared in base_filters + # Special-case __range: require the '__range' filter itself + if field not in allowed_fields: + raise ValidationError(f"Filtering on field '{field}' is not allowed") + + def _extract_field_names(self, filter_data): + """Extract all field names from a nested filter structure""" + if isinstance(filter_data, dict): + fields = [] + for key, value in filter_data.items(): + if key.lower() in ("or", "and", "not"): + # This is a logical operator, process its children + if key.lower() == "not": + # 'not' has a dict as its value, not a list + if isinstance(value, dict): + fields.extend(self._extract_field_names(value)) + else: + # 'or' and 'and' have lists as their values + for item in value: + fields.extend(self._extract_field_names(item)) + else: + # This is a field name + fields.append(key) + return fields + return [] + + def _evaluate_node(self, node, view, queryset): + """ + Recursively evaluate a JSON node into a combined Q object. + + Rules: + - leaf dict → evaluated through FilterSet to produce a Q object + - {"or": [...]} → Q() | Q() | ... (OR of children) + - {"and": [...]} → Q() & Q() & ... (AND of children) + - {"not": {...}} → ~Q() (negation of child) + + Returns a Q object that can be applied to a queryset. + """ + if not isinstance(node, dict): + return None + + # 'or' combination - OR of child Q objects + if "or" in node: + children = node["or"] + if not isinstance(children, list) or not children: + return None + combined_q = Q() + for child in children: + child_q = self._evaluate_node(child, view, queryset) + if child_q is None: + continue + combined_q |= child_q + return combined_q + + # 'and' combination - AND of child Q objects + if "and" in node: + children = node["and"] + if not isinstance(children, list) or not children: + return None + combined_q = Q() + for child in children: + child_q = self._evaluate_node(child, view, queryset) + if child_q is None: + continue + combined_q &= child_q + return combined_q + + # 'not' negation - negate the child Q object + if "not" in node: + child = node["not"] + if not isinstance(child, dict): + return None + child_q = self._evaluate_node(child, view, queryset) + if child_q is None: + return None + return ~child_q + + # Leaf dict: evaluate via FilterSet to get a Q object + return self._build_leaf_q(node, view, queryset) + + def _build_leaf_q(self, leaf_conditions, view, queryset): + """Build a Q object from leaf filter conditions using the view's FilterSet. + + We serialize the leaf dict into a QueryDict and let the view's + filterset_class perform validation and build a combined Q object + from all the field filters. + + Returns a Q object representing all the field conditions in the leaf. + """ + if not leaf_conditions: + return Q() + + # Get the filterset class from the view + filterset_class = getattr(view, "filterset_class", None) + if not filterset_class: + raise ValidationError("Filtering requires a filterset_class to be defined on the view") + + # Build a QueryDict from the leaf conditions + qd = QueryDict(mutable=True) + for key, value in leaf_conditions.items(): + # Default serialization to string; QueryDict expects strings + if isinstance(value, list): + # Repeat key for list values (e.g., __in) + qd.setlist(key, [str(v) for v in value]) + else: + qd[key] = "" if value is None else str(value) + + qd = qd.copy() + qd._mutable = False + + # Instantiate the filterset with the actual queryset + # Custom filter methods may need access to the queryset for filtering + fs = filterset_class(data=qd, queryset=queryset) + + if not fs.is_valid(): + raise translate_validation(fs.errors) + + # Build and return the combined Q object + if not hasattr(fs, "build_combined_q"): + raise ValidationError("FilterSet must have build_combined_q method for complex filtering") + + return fs.build_combined_q() + + def _get_max_depth(self, view): + """Return the maximum allowed nesting depth for complex filters. + + Falls back to class default if the view does not specify it or has + an invalid value. + """ + value = getattr(view, "complex_filter_max_depth", self.default_max_depth) + try: + value_int = int(value) + if value_int <= 0: + return self.default_max_depth + return value_int + except Exception: + return self.default_max_depth + + def _validate_structure(self, node, max_depth, current_depth): + """Validate JSON structure and enforce nesting depth. + + Rules: + - Each object may contain only one logical operator: + or/and/not (case-insensitive) + - Logical operator objects cannot contain field keys alongside the + operator + - or/and values must be non-empty lists of dicts + - not value must be a dict + - Leaf objects must only contain field keys and acceptable values + - Depth must not exceed max_depth + """ + if current_depth > max_depth: + raise ValidationError(f"Filter nesting is too deep (max {max_depth}); found depth {current_depth}") + + if not isinstance(node, dict): + raise ValidationError("Each filter node must be a JSON object") + + if not node: + raise ValidationError("Filter objects must not be empty") + + logical_keys = [k for k in node.keys() if isinstance(k, str) and k.lower() in ("or", "and", "not")] + + if len(logical_keys) > 1: + raise ValidationError("A filter object cannot contain multiple logical operators at the same level") + + if len(logical_keys) == 1: + op_key = logical_keys[0] + # must not mix operator with other keys + if len(node) != 1: + raise ValidationError(f"Cannot mix logical operator '{op_key}' with field keys at the same level") + + op = op_key.lower() + value = node[op_key] + + if op in ("or", "and"): + if not isinstance(value, list) or len(value) == 0: + raise ValidationError(f"'{op}' must be a non-empty list of filter objects") + for child in value: + if not isinstance(child, dict): + raise ValidationError(f"All children of '{op}' must be JSON objects") + self._validate_structure( + child, + max_depth=max_depth, + current_depth=current_depth + 1, + ) + return + + if op == "not": + if not isinstance(value, dict): + raise ValidationError("'not' must be a single JSON object") + self._validate_structure(value, max_depth=max_depth, current_depth=current_depth + 1) + return + + # Leaf node: validate fields and values + self._validate_leaf(node) + + def _validate_leaf(self, leaf): + """Validate a leaf dict containing field lookups and values.""" + if not isinstance(leaf, dict) or not leaf: + raise ValidationError("Leaf filter must be a non-empty JSON object") + + for key, value in leaf.items(): + if isinstance(key, str) and key.lower() in ("or", "and", "not"): + raise ValidationError("Logical operators cannot appear in a leaf filter object") + + # Lists/Tuples must contain only scalar values + if isinstance(value, (list, tuple)): + if len(value) == 0: + raise ValidationError(f"List value for '{key}' must not be empty") + for item in value: + if not self._is_scalar(item): + raise ValidationError(f"List value for '{key}' must contain only scalar items") + continue + + # Scalars and None are allowed + if not self._is_scalar(value): + raise ValidationError(f"Value for '{key}' must be a scalar, null, or list/tuple of scalars") + + def _is_scalar(self, value): + return value is None or isinstance(value, (str, int, float, bool)) diff --git a/apps/api/plane/utils/filters/filter_migrations.py b/apps/api/plane/utils/filters/filter_migrations.py new file mode 100644 index 000000000..3e424b6e6 --- /dev/null +++ b/apps/api/plane/utils/filters/filter_migrations.py @@ -0,0 +1,133 @@ +""" +Utilities for migrating legacy filters to rich filters format. + +This module contains helper functions for data migrations that convert +filters fields to rich_filters fields using the LegacyToRichFiltersConverter. +""" + +import logging +from typing import Any, Dict, Tuple + +from .converters import LegacyToRichFiltersConverter + + +logger = logging.getLogger("plane.api.filters.migration") + + +def migrate_single_model_filters( + model_class, model_name: str, converter: LegacyToRichFiltersConverter +) -> Tuple[int, int]: + """ + Migrate filters to rich_filters for a single model. + + Args: + model_class: Django model class + model_name: Human-readable name for logging + converter: Instance of LegacyToRichFiltersConverter + + Returns: + Tuple of (updated_count, error_count) + """ + # Find records that need migration - have filters but empty rich_filters + records_to_migrate = model_class.objects.exclude(filters={}).filter(rich_filters={}) + + if records_to_migrate.count() == 0: + logger.info(f"No {model_name} records need migration") + return 0, 0 + + logger.info(f"Found {records_to_migrate.count()} {model_name} records to migrate") + + updated_records = [] + conversion_errors = 0 + + for record in records_to_migrate: + try: + if record.filters: # Double check that filters is not empty + rich_filters = converter.convert(record.filters, strict=False) + record.rich_filters = rich_filters + updated_records.append(record) + + except Exception as e: + logger.warning(f"Failed to convert filters for {model_name} ID {record.id}: {str(e)}") + conversion_errors += 1 + continue + + # Bulk update all successfully converted records + if updated_records: + model_class.objects.bulk_update(updated_records, ["rich_filters"], batch_size=1000) + logger.info(f"Successfully updated {len(updated_records)} {model_name} records") + + return len(updated_records), conversion_errors + + +def migrate_models_filters_to_rich_filters( + models_to_migrate: Dict[str, Any], + converter: LegacyToRichFiltersConverter, +) -> Dict[str, Tuple[int, int]]: + """ + Migrate legacy filters to rich_filters format for provided models. + + Args: + models_to_migrate: Dict mapping model names to model classes + + Returns: + Dictionary mapping model names to (updated_count, error_count) tuples + """ + # Initialize the converter with default settings + + logger.info("Starting filters to rich_filters migration for all models") + + results = {} + total_updated = 0 + total_errors = 0 + + for model_name, model_class in models_to_migrate.items(): + try: + updated_count, error_count = migrate_single_model_filters(model_class, model_name, converter) + + results[model_name] = (updated_count, error_count) + total_updated += updated_count + total_errors += error_count + + except Exception as e: + logger.error(f"Failed to migrate {model_name}: {str(e)}") + results[model_name] = (0, 1) + total_errors += 1 + continue + + # Log final summary + logger.info(f"Migration completed for all models. Total updated: {total_updated}, Total errors: {total_errors}") + + return results + + +def clear_models_rich_filters(models_to_clear: Dict[str, Any]) -> Dict[str, int]: + """ + Clear rich_filters field for provided models (for reverse migration). + + Args: + models_to_clear: Dictionary mapping model names to model classes + + Returns: + Dictionary mapping model names to count of cleared records + """ + logger.info("Starting reverse migration - clearing rich_filters for all models") + + results = {} + total_cleared = 0 + + for model_name, model_class in models_to_clear.items(): + try: + # Clear rich_filters for all records that have them + updated_count = model_class.objects.exclude(rich_filters={}).update(rich_filters={}) + results[model_name] = updated_count + total_cleared += updated_count + logger.info(f"Cleared rich_filters for {updated_count} {model_name} records") + + except Exception as e: + logger.error(f"Failed to clear rich_filters for {model_name}: {str(e)}") + results[model_name] = 0 + continue + + logger.info(f"Reverse migration completed. Total cleared: {total_cleared}") + return results diff --git a/apps/api/plane/utils/filters/filterset.py b/apps/api/plane/utils/filters/filterset.py new file mode 100644 index 000000000..0099b83d0 --- /dev/null +++ b/apps/api/plane/utils/filters/filterset.py @@ -0,0 +1,262 @@ +import copy + +from django.db import models +from django.db.models import Q +from django_filters import FilterSet, filters + +from plane.db.models import Issue + + +class UUIDInFilter(filters.BaseInFilter, filters.UUIDFilter): + pass + + +class CharInFilter(filters.BaseInFilter, filters.CharFilter): + pass + + +class BaseFilterSet(FilterSet): + @classmethod + def get_filters(cls): + """ + Get all filters for the filterset, including dynamically created __exact filters. + """ + # Get the standard filters first + filters = super().get_filters() + + # Add __exact versions for filters that have 'exact' lookup + exact_filters = {} + for filter_name, filter_obj in filters.items(): + if hasattr(filter_obj, "lookup_expr") and filter_obj.lookup_expr == "exact": + exact_field_name = f"{filter_name}__exact" + if exact_field_name not in filters: + # Copy the filter object as-is and assign it to the new name + exact_filters[exact_field_name] = copy.deepcopy(filter_obj) + + # Add the exact filters to the main filters dict + filters.update(exact_filters) + return filters + + def build_combined_q(self): + """ + Build a combined Q object from all bound filters. + + For filters with custom methods, we call them and expect Q objects (or wrap + QuerySets as subqueries for backward compatibility). + For standard field filters, we build Q objects directly from field lookups. + + Returns: + Q object representing all filter conditions combined. + """ + # Ensure form validation has occurred + self.errors + + combined_q = Q() + + # Handle case where cleaned_data might be None or empty + if not self.form.cleaned_data: + return combined_q + + # Only process filters that were actually provided in the request data + # This avoids processing all declared filters with None/empty default values + provided_filters = set(self.data.keys()) if self.data else set() + + for name, value in self.form.cleaned_data.items(): + # Skip filters that weren't provided in the request + if name not in provided_filters: + continue + + f = self.filters[name] + + # Build the Q object for this filter + if f.method is not None: + # Custom filter method - call it to get Q object + res = f.filter(self.queryset, value) + if isinstance(res, Q): + q_piece = res + elif isinstance(res, models.QuerySet): + # Backward compatibility: wrap QuerySet as subquery + q_piece = Q(pk__in=res.values("pk")) + else: + raise TypeError( + f"Filter method '{name}' must return Q object or QuerySet, got {type(res).__name__}" + ) + else: + # Standard field filter - build Q object directly + lookup = f"{f.field_name}__{f.lookup_expr}" + q_piece = Q(**{lookup: value}) + + # Apply exclude/include logic + if getattr(f, "exclude", False): + combined_q &= ~q_piece + else: + combined_q &= q_piece + + return combined_q + + def filter_queryset(self, queryset): + """ + Override to use Q-based filtering for compatibility with DjangoFilterBackend. + + This allows the same filterset to work with both ComplexFilterBackend + (which calls build_combined_q directly) and DjangoFilterBackend + (which calls this method). + """ + # Ensure form validation + self.errors + + # Build combined Q and apply to queryset + combined_q = self.build_combined_q() + qs = queryset.filter(combined_q) + + # Apply distinct if any filter requires it (typically for many-to-many relations) + for f in self.filters.values(): + if getattr(f, "distinct", False): + return qs.distinct() + + return qs + + +class IssueFilterSet(BaseFilterSet): + # Custom filter methods to handle soft delete exclusion for relations + + assignee_id = filters.UUIDFilter(method="filter_assignee_id") + assignee_id__in = UUIDInFilter(method="filter_assignee_id_in", lookup_expr="in") + + cycle_id = filters.UUIDFilter(method="filter_cycle_id") + cycle_id__in = UUIDInFilter(method="filter_cycle_id_in", lookup_expr="in") + + module_id = filters.UUIDFilter(method="filter_module_id") + module_id__in = UUIDInFilter(method="filter_module_id_in", lookup_expr="in") + + mention_id = filters.UUIDFilter(method="filter_mention_id") + mention_id__in = UUIDInFilter(method="filter_mention_id_in", lookup_expr="in") + + label_id = filters.UUIDFilter(method="filter_label_id") + label_id__in = UUIDInFilter(method="filter_label_id_in", lookup_expr="in") + + # Direct field lookups remain the same + created_by_id = filters.UUIDFilter(field_name="created_by_id") + created_by_id__in = UUIDInFilter(field_name="created_by_id", lookup_expr="in") + + is_archived = filters.BooleanFilter(method="filter_is_archived") + + state_group = filters.CharFilter(field_name="state__group") + state_group__in = CharInFilter(field_name="state__group", lookup_expr="in") + + state_id = filters.UUIDFilter(field_name="state_id") + state_id__in = UUIDInFilter(field_name="state_id", lookup_expr="in") + + project_id = filters.UUIDFilter(field_name="project_id") + project_id__in = UUIDInFilter(field_name="project_id", lookup_expr="in") + + subscriber_id = filters.UUIDFilter(method="filter_subscriber_id") + subscriber_id__in = UUIDInFilter(method="filter_subscriber_id_in", lookup_expr="in") + + class Meta: + model = Issue + fields = { + "start_date": ["exact", "range"], + "target_date": ["exact", "range"], + "created_at": ["exact", "range"], + "updated_at": ["exact", "range"], + "is_draft": ["exact"], + "priority": ["exact", "in"], + } + + def filter_is_archived(self, queryset, name, value): + """ + Convenience filter: archived=true -> archived_at is not null, + archived=false -> archived_at is null + """ + if value in (True, "true", "True", 1, "1"): + return Q(archived_at__isnull=False) + if value in (False, "false", "False", 0, "0"): + return Q(archived_at__isnull=True) + return Q() # No filter + + # Filter methods with soft delete exclusion for relations + + def filter_assignee_id(self, queryset, name, value): + """Filter by assignee ID, excluding soft deleted users""" + return Q( + issue_assignee__assignee_id=value, + issue_assignee__deleted_at__isnull=True, + ) + + def filter_assignee_id_in(self, queryset, name, value): + """Filter by assignee IDs (in), excluding soft deleted users""" + return Q( + issue_assignee__assignee_id__in=value, + issue_assignee__deleted_at__isnull=True, + ) + + def filter_cycle_id(self, queryset, name, value): + """Filter by cycle ID, excluding soft deleted cycles""" + return Q( + issue_cycle__cycle_id=value, + issue_cycle__deleted_at__isnull=True, + ) + + def filter_cycle_id_in(self, queryset, name, value): + """Filter by cycle IDs (in), excluding soft deleted cycles""" + return Q( + issue_cycle__cycle_id__in=value, + issue_cycle__deleted_at__isnull=True, + ) + + def filter_module_id(self, queryset, name, value): + """Filter by module ID, excluding soft deleted modules""" + return Q( + issue_module__module_id=value, + issue_module__deleted_at__isnull=True, + ) + + def filter_module_id_in(self, queryset, name, value): + """Filter by module IDs (in), excluding soft deleted modules""" + return Q( + issue_module__module_id__in=value, + issue_module__deleted_at__isnull=True, + ) + + def filter_mention_id(self, queryset, name, value): + """Filter by mention ID, excluding soft deleted users""" + return Q( + issue_mention__mention_id=value, + issue_mention__deleted_at__isnull=True, + ) + + def filter_mention_id_in(self, queryset, name, value): + """Filter by mention IDs (in), excluding soft deleted users""" + return Q( + issue_mention__mention_id__in=value, + issue_mention__deleted_at__isnull=True, + ) + + def filter_label_id(self, queryset, name, value): + """Filter by label ID, excluding soft deleted labels""" + return Q( + label_issue__label_id=value, + label_issue__deleted_at__isnull=True, + ) + + def filter_label_id_in(self, queryset, name, value): + """Filter by label IDs (in), excluding soft deleted labels""" + return Q( + label_issue__label_id__in=value, + label_issue__deleted_at__isnull=True, + ) + + def filter_subscriber_id(self, queryset, name, value): + """Filter by subscriber ID, excluding soft deleted users""" + return Q( + issue_subscribers__subscriber_id=value, + issue_subscribers__deleted_at__isnull=True, + ) + + def filter_subscriber_id_in(self, queryset, name, value): + """Filter by subscriber IDs (in), excluding soft deleted users""" + return Q( + issue_subscribers__subscriber_id__in=value, + issue_subscribers__deleted_at__isnull=True, + ) diff --git a/apps/api/plane/utils/grouper.py b/apps/api/plane/utils/grouper.py index d69a1f583..1ec004e95 100644 --- a/apps/api/plane/utils/grouper.py +++ b/apps/api/plane/utils/grouper.py @@ -71,15 +71,9 @@ def issue_queryset_grouper( ) annotations_map: Dict[str, Tuple[str, Q]] = { - "assignee_ids": Coalesce( - issue_assignee_subquery, Value([], output_field=ArrayField(UUIDField())) - ), - "label_ids": Coalesce( - issue_label_subquery, Value([], output_field=ArrayField(UUIDField())) - ), - "module_ids": Coalesce( - issue_module_subquery, Value([], output_field=ArrayField(UUIDField())) - ), + "assignee_ids": Coalesce(issue_assignee_subquery, Value([], output_field=ArrayField(UUIDField()))), + "label_ids": Coalesce(issue_label_subquery, Value([], output_field=ArrayField(UUIDField()))), + "module_ids": Coalesce(issue_module_subquery, Value([], output_field=ArrayField(UUIDField()))), } default_annotations: Dict[str, Any] = {} @@ -148,19 +142,16 @@ def issue_group_values( slug: str, project_id: Optional[str] = None, filters: Dict[str, Any] = {}, + queryset: Optional[QuerySet] = None, ) -> List[Union[str, Any]]: if field == "state_id": - queryset = State.objects.filter( - is_triage=False, workspace__slug=slug - ).values_list("id", flat=True) + queryset = State.objects.filter(is_triage=False, workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) return list(queryset) if field == "labels__id": - queryset = Label.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Label.objects.filter(workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] return list(queryset) + ["None"] @@ -168,36 +159,28 @@ def issue_group_values( if field == "assignees__id": if project_id: return list( - ProjectMember.objects.filter( - workspace__slug=slug, project_id=project_id, is_active=True - ).values_list("member_id", flat=True) + ProjectMember.objects.filter(workspace__slug=slug, project_id=project_id, is_active=True).values_list( + "member_id", flat=True + ) ) return list( - WorkspaceMember.objects.filter( - workspace__slug=slug, is_active=True - ).values_list("member_id", flat=True) + WorkspaceMember.objects.filter(workspace__slug=slug, is_active=True).values_list("member_id", flat=True) ) if field == "issue_module__module_id": - queryset = Module.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Module.objects.filter(workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] return list(queryset) + ["None"] if field == "cycle_id": - queryset = Cycle.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Cycle.objects.filter(workspace__slug=slug).values_list("id", flat=True) if project_id: return list(queryset.filter(project_id=project_id)) + ["None"] return list(queryset) + ["None"] if field == "project_id": - queryset = Project.objects.filter(workspace__slug=slug).values_list( - "id", flat=True - ) + queryset = Project.objects.filter(workspace__slug=slug).values_list("id", flat=True) return list(queryset) if field == "priority": @@ -207,36 +190,24 @@ def issue_group_values( return ["backlog", "unstarted", "started", "completed", "cancelled"] if field == "target_date": - queryset = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(**filters) - .values_list("target_date", flat=True) - .distinct() - ) + queryset = queryset.values_list("target_date", flat=True).distinct() if project_id: return list(queryset.filter(project_id=project_id)) - return list(queryset) + else: + return list(queryset) if field == "start_date": - queryset = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(**filters) - .values_list("start_date", flat=True) - .distinct() - ) + queryset = queryset.values_list("start_date", flat=True).distinct() if project_id: return list(queryset.filter(project_id=project_id)) - return list(queryset) + else: + return list(queryset) if field == "created_by": - queryset = ( - Issue.issue_objects.filter(workspace__slug=slug) - .filter(**filters) - .values_list("created_by", flat=True) - .distinct() - ) + queryset = queryset.values_list("created_by", flat=True).distinct() if project_id: return list(queryset.filter(project_id=project_id)) - return list(queryset) + else: + return list(queryset) return [] diff --git a/apps/api/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py index 1c9619890..8d56bc389 100644 --- a/apps/api/plane/utils/issue_filters.py +++ b/apps/api/plane/utils/issue_filters.py @@ -27,22 +27,14 @@ def string_date_filter(issue_filter, duration, subsequent, term, date_filter, of if term == "months": if subsequent == "after": if offset == "fromnow": - issue_filter[f"{date_filter}__gte"] = now + timedelta( - days=duration * 30 - ) + issue_filter[f"{date_filter}__gte"] = now + timedelta(days=duration * 30) else: - issue_filter[f"{date_filter}__gte"] = now - timedelta( - days=duration * 30 - ) + issue_filter[f"{date_filter}__gte"] = now - timedelta(days=duration * 30) else: if offset == "fromnow": - issue_filter[f"{date_filter}__lte"] = now + timedelta( - days=duration * 30 - ) + issue_filter[f"{date_filter}__lte"] = now + timedelta(days=duration * 30) else: - issue_filter[f"{date_filter}__lte"] = now - timedelta( - days=duration * 30 - ) + issue_filter[f"{date_filter}__lte"] = now - timedelta(days=duration * 30) if term == "weeks": if subsequent == "after": if offset == "fromnow": @@ -92,37 +84,25 @@ def filter_state(params, issue_filter, method, prefix=""): if len(states) and "" not in states: issue_filter[f"{prefix}state__in"] = states else: - if ( - params.get("state", None) - and len(params.get("state")) - and params.get("state") != "null" - ): + if params.get("state", None) and len(params.get("state")) and params.get("state") != "null": issue_filter[f"{prefix}state__in"] = params.get("state") return issue_filter def filter_state_group(params, issue_filter, method, prefix=""): if method == "GET": - state_group = [ - item for item in params.get("state_group").split(",") if item != "null" - ] + state_group = [item for item in params.get("state_group").split(",") if item != "null"] if len(state_group) and "" not in state_group: issue_filter[f"{prefix}state__group__in"] = state_group else: - if ( - params.get("state_group", None) - and len(params.get("state_group")) - and params.get("state_group") != "null" - ): + if params.get("state_group", None) and len(params.get("state_group")) and params.get("state_group") != "null": issue_filter[f"{prefix}state__group__in"] = params.get("state_group") return issue_filter def filter_estimate_point(params, issue_filter, method, prefix=""): if method == "GET": - estimate_points = [ - item for item in params.get("estimate_point").split(",") if item != "null" - ] + estimate_points = [item for item in params.get("estimate_point").split(",") if item != "null"] if len(estimate_points) and "" not in estimate_points: issue_filter[f"{prefix}estimate_point__in"] = estimate_points else: @@ -137,17 +117,11 @@ def filter_estimate_point(params, issue_filter, method, prefix=""): def filter_priority(params, issue_filter, method, prefix=""): if method == "GET": - priorities = [ - item for item in params.get("priority").split(",") if item != "null" - ] + priorities = [item for item in params.get("priority").split(",") if item != "null"] if len(priorities) and "" not in priorities: issue_filter[f"{prefix}priority__in"] = priorities else: - if ( - params.get("priority", None) - and len(params.get("priority")) - and params.get("priority") != "null" - ): + if params.get("priority", None) and len(params.get("priority")) and params.get("priority") != "null": issue_filter[f"{prefix}priority__in"] = params.get("priority") return issue_filter @@ -161,11 +135,7 @@ def filter_parent(params, issue_filter, method, prefix=""): if len(parents) and "" not in parents: issue_filter[f"{prefix}parent__in"] = parents else: - if ( - params.get("parent", None) - and len(params.get("parent")) - and params.get("parent") != "null" - ): + if params.get("parent", None) and len(params.get("parent")) and params.get("parent") != "null": issue_filter[f"{prefix}parent__in"] = params.get("parent") return issue_filter @@ -179,11 +149,7 @@ def filter_labels(params, issue_filter, method, prefix=""): if len(labels) and "" not in labels: issue_filter[f"{prefix}labels__in"] = labels else: - if ( - params.get("labels", None) - and len(params.get("labels")) - and params.get("labels") != "null" - ): + if params.get("labels", None) and len(params.get("labels")) and params.get("labels") != "null": issue_filter[f"{prefix}labels__in"] = params.get("labels") issue_filter[f"{prefix}label_issue__deleted_at__isnull"] = True return issue_filter @@ -191,20 +157,14 @@ def filter_labels(params, issue_filter, method, prefix=""): def filter_assignees(params, issue_filter, method, prefix=""): if method == "GET": - assignees = [ - item for item in params.get("assignees").split(",") if item != "null" - ] + assignees = [item for item in params.get("assignees").split(",") if item != "null"] if "None" in assignees: issue_filter[f"{prefix}assignees__isnull"] = True assignees = filter_valid_uuids(assignees) if len(assignees) and "" not in assignees: issue_filter[f"{prefix}assignees__in"] = assignees else: - if ( - params.get("assignees", None) - and len(params.get("assignees")) - and params.get("assignees") != "null" - ): + if params.get("assignees", None) and len(params.get("assignees")) and params.get("assignees") != "null": issue_filter[f"{prefix}assignees__in"] = params.get("assignees") issue_filter[f"{prefix}issue_assignee__deleted_at__isnull"] = True return issue_filter @@ -212,40 +172,26 @@ def filter_assignees(params, issue_filter, method, prefix=""): def filter_mentions(params, issue_filter, method, prefix=""): if method == "GET": - mentions = [ - item for item in params.get("mentions").split(",") if item != "null" - ] + mentions = [item for item in params.get("mentions").split(",") if item != "null"] mentions = filter_valid_uuids(mentions) if len(mentions) and "" not in mentions: issue_filter[f"{prefix}issue_mention__mention__id__in"] = mentions else: - if ( - params.get("mentions", None) - and len(params.get("mentions")) - and params.get("mentions") != "null" - ): - issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get( - "mentions" - ) + if params.get("mentions", None) and len(params.get("mentions")) and params.get("mentions") != "null": + issue_filter[f"{prefix}issue_mention__mention__id__in"] = params.get("mentions") return issue_filter def filter_created_by(params, issue_filter, method, prefix=""): if method == "GET": - created_bys = [ - item for item in params.get("created_by").split(",") if item != "null" - ] + created_bys = [item for item in params.get("created_by").split(",") if item != "null"] if "None" in created_bys: issue_filter[f"{prefix}created_by__isnull"] = True created_bys = filter_valid_uuids(created_bys) if len(created_bys) and "" not in created_bys: issue_filter[f"{prefix}created_by__in"] = created_bys else: - if ( - params.get("created_by", None) - and len(params.get("created_by")) - and params.get("created_by") != "null" - ): + if params.get("created_by", None) and len(params.get("created_by")) and params.get("created_by") != "null": issue_filter[f"{prefix}created_by__in"] = params.get("created_by") return issue_filter @@ -362,11 +308,7 @@ def filter_project(params, issue_filter, method, prefix=""): if len(projects) and "" not in projects: issue_filter[f"{prefix}project__in"] = projects else: - if ( - params.get("project", None) - and len(params.get("project")) - and params.get("project") != "null" - ): + if params.get("project", None) and len(params.get("project")) and params.get("project") != "null": issue_filter[f"{prefix}project__in"] = params.get("project") return issue_filter @@ -380,11 +322,7 @@ def filter_cycle(params, issue_filter, method, prefix=""): if len(cycles) and "" not in cycles: issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = cycles else: - if ( - params.get("cycle", None) - and len(params.get("cycle")) - and params.get("cycle") != "null" - ): + if params.get("cycle", None) and len(params.get("cycle")) and params.get("cycle") != "null": issue_filter[f"{prefix}issue_cycle__cycle_id__in"] = params.get("cycle") issue_filter[f"{prefix}issue_cycle__deleted_at__isnull"] = True return issue_filter @@ -399,11 +337,7 @@ def filter_module(params, issue_filter, method, prefix=""): if len(modules) and "" not in modules: issue_filter[f"{prefix}issue_module__module_id__in"] = modules else: - if ( - params.get("module", None) - and len(params.get("module")) - and params.get("module") != "null" - ): + if params.get("module", None) and len(params.get("module")) and params.get("module") != "null": issue_filter[f"{prefix}issue_module__module_id__in"] = params.get("module") issue_filter[f"{prefix}issue_module__deleted_at__isnull"] = True return issue_filter @@ -411,9 +345,7 @@ def filter_module(params, issue_filter, method, prefix=""): def filter_intake_status(params, issue_filter, method, prefix=""): if method == "GET": - status = [ - item for item in params.get("intake_status").split(",") if item != "null" - ] + status = [item for item in params.get("intake_status").split(",") if item != "null"] if len(status) and "" not in status: issue_filter[f"{prefix}issue_intake__status__in"] = status else: @@ -422,17 +354,13 @@ def filter_intake_status(params, issue_filter, method, prefix=""): and len(params.get("intake_status")) and params.get("intake_status") != "null" ): - issue_filter[f"{prefix}issue_intake__status__in"] = params.get( - "inbox_status" - ) + issue_filter[f"{prefix}issue_intake__status__in"] = params.get("inbox_status") return issue_filter def filter_inbox_status(params, issue_filter, method, prefix=""): if method == "GET": - status = [ - item for item in params.get("inbox_status").split(",") if item != "null" - ] + status = [item for item in params.get("inbox_status").split(",") if item != "null"] if len(status) and "" not in status: issue_filter[f"{prefix}issue_intake__status__in"] = status else: @@ -441,9 +369,7 @@ def filter_inbox_status(params, issue_filter, method, prefix=""): and len(params.get("inbox_status")) and params.get("inbox_status") != "null" ): - issue_filter[f"{prefix}issue_intake__status__in"] = params.get( - "inbox_status" - ) + issue_filter[f"{prefix}issue_intake__status__in"] = params.get("inbox_status") return issue_filter @@ -461,21 +387,15 @@ def filter_sub_issue_toggle(params, issue_filter, method, prefix=""): def filter_subscribed_issues(params, issue_filter, method, prefix=""): if method == "GET": - subscribers = [ - item for item in params.get("subscriber").split(",") if item != "null" - ] + subscribers = [item for item in params.get("subscriber").split(",") if item != "null"] subscribers = filter_valid_uuids(subscribers) if len(subscribers) and "" not in subscribers: issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = subscribers else: - if ( - params.get("subscriber", None) - and len(params.get("subscriber")) - and params.get("subscriber") != "null" - ): - issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get( - "subscriber" - ) + if params.get("subscriber", None) and len(params.get("subscriber")) and params.get("subscriber") != "null": + issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get("subscriber") + issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True + return issue_filter @@ -489,20 +409,14 @@ def filter_start_target_date_issues(params, issue_filter, method, prefix=""): def filter_logged_by(params, issue_filter, method, prefix=""): if method == "GET": - logged_bys = [ - item for item in params.get("logged_by").split(",") if item != "null" - ] + logged_bys = [item for item in params.get("logged_by").split(",") if item != "null"] if "None" in logged_bys: issue_filter[f"{prefix}logged_by__isnull"] = True logged_bys = filter_valid_uuids(logged_bys) if len(logged_bys) and "" not in logged_bys: issue_filter[f"{prefix}logged_by__in"] = logged_bys else: - if ( - params.get("logged_by", None) - and len(params.get("logged_by")) - and params.get("logged_by") != "null" - ): + if params.get("logged_by", None) and len(params.get("logged_by")) and params.get("logged_by") != "null": issue_filter[f"{prefix}logged_by__in"] = params.get("logged_by") return issue_filter diff --git a/apps/api/plane/utils/issue_relation_mapper.py b/apps/api/plane/utils/issue_relation_mapper.py index f3188eb26..19d65c111 100644 --- a/apps/api/plane/utils/issue_relation_mapper.py +++ b/apps/api/plane/utils/issue_relation_mapper.py @@ -6,12 +6,14 @@ def get_inverse_relation(relation_type): "blocking": "blocked_by", "start_before": "start_after", "finish_before": "finish_after", + "implemented_by": "implements", + "implements": "implemented_by", } return relation_mapping.get(relation_type, relation_type) def get_actual_relation(relation_type): - # This function is used to get the actual relation type which is store in database + # This function is used to get the actual relation type which is stored in database actual_relation = { "start_after": "start_before", "finish_after": "finish_before", @@ -19,6 +21,8 @@ def get_actual_relation(relation_type): "blocked_by": "blocked_by", "start_before": "start_before", "finish_before": "finish_before", + "implemented_by": "implemented_by", + "implements": "implemented_by", } return actual_relation.get(relation_type, relation_type) diff --git a/apps/api/plane/utils/logging.py b/apps/api/plane/utils/logging.py index 8021689e9..083132f16 100644 --- a/apps/api/plane/utils/logging.py +++ b/apps/api/plane/utils/logging.py @@ -20,9 +20,7 @@ class SizedTimedRotatingFileHandler(handlers.TimedRotatingFileHandler): interval=1, utc=False, ): - handlers.TimedRotatingFileHandler.__init__( - self, filename, when, interval, backupCount, encoding, delay, utc - ) + handlers.TimedRotatingFileHandler.__init__(self, filename, when, interval, backupCount, encoding, delay, utc) self.maxBytes = maxBytes def shouldRollover(self, record): diff --git a/apps/api/plane/utils/openapi/auth.py b/apps/api/plane/utils/openapi/auth.py index e6012cc4e..9434956fe 100644 --- a/apps/api/plane/utils/openapi/auth.py +++ b/apps/api/plane/utils/openapi/auth.py @@ -10,7 +10,8 @@ from drf_spectacular.extensions import OpenApiAuthenticationExtension class APIKeyAuthenticationExtension(OpenApiAuthenticationExtension): """ - OpenAPI authentication extension for plane.api.middleware.api_authentication.APIKeyAuthentication + OpenAPI authentication extension for + plane.api.middleware.api_authentication.APIKeyAuthentication """ target_class = "plane.api.middleware.api_authentication.APIKeyAuthentication" @@ -25,5 +26,5 @@ class APIKeyAuthenticationExtension(OpenApiAuthenticationExtension): "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "API key authentication. Provide your API key in the X-API-Key header.", + "description": "API key authentication. Provide your API key in the X-API-Key header.", # noqa: E501 } diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py index 136669159..db7ee50c4 100644 --- a/apps/api/plane/utils/openapi/examples.py +++ b/apps/api/plane/utils/openapi/examples.py @@ -499,7 +499,7 @@ ISSUE_COMMENT_EXAMPLE = OpenApiExample( name="IssueComment", value={ "id": "550e8400-e29b-41d4-a716-446655440000", - "comment_html": "

This issue has been resolved by implementing OAuth 2.0 flow.

", + "comment_html": "

This issue has been resolved by implementing OAuth 2.0 flow.

", # noqa: E501 "comment_json": { "type": "doc", "content": [ @@ -508,7 +508,7 @@ ISSUE_COMMENT_EXAMPLE = OpenApiExample( "content": [ { "type": "text", - "text": "This issue has been resolved by implementing OAuth 2.0 flow.", + "text": "This issue has been resolved by implementing OAuth 2.0 flow.", # noqa: E501 } ], } @@ -551,7 +551,7 @@ ISSUE_ATTACHMENT_NOT_UPLOADED_EXAMPLE = OpenApiExample( "error": "The asset is not uploaded.", "status": False, }, - description="Error when trying to download an attachment that hasn't been uploaded yet", + description="Error when trying to download an attachment that hasn't been uploaded yet", # noqa: E501 ) # Intake Issue Response Examples @@ -733,7 +733,7 @@ SAMPLE_STATE = { SAMPLE_COMMENT = { "id": "550e8400-e29b-41d4-a716-446655440000", - "comment_html": "

This issue needs more investigation. I'll look into the database connection timeout.

", + "comment_html": "

This issue needs more investigation. I'll look into the database connection timeout.

", # noqa: E501 "created_at": "2024-01-15T14:20:00Z", "actor": {"id": "550e8400-e29b-41d4-a716-446655440002", "display_name": "John Doe"}, } diff --git a/apps/api/plane/utils/openapi/hooks.py b/apps/api/plane/utils/openapi/hooks.py index 3cd7eaf7a..f136324c0 100644 --- a/apps/api/plane/utils/openapi/hooks.py +++ b/apps/api/plane/utils/openapi/hooks.py @@ -14,11 +14,7 @@ def preprocess_filter_api_v1_paths(endpoints): filtered = [] for path, path_regex, method, callback in endpoints: # Only include paths that start with /api/v1/ and exclude PUT methods - if ( - path.startswith("/api/v1/") - and method.upper() != "PUT" - and "server" not in path.lower() - ): + if path.startswith("/api/v1/") and method.upper() != "PUT" and "server" not in path.lower(): filtered.append((path, path_regex, method, callback)) return filtered @@ -46,11 +42,11 @@ def generate_operation_summary(method, path, tag): # Handle specific cases if "archive" in path.lower(): if method == "POST": - return f'Archive {tag.rstrip("s")}' + return f"Archive {tag.rstrip('s')}" elif method == "DELETE": - return f'Unarchive {tag.rstrip("s")}' + return f"Unarchive {tag.rstrip('s')}" if "transfer" in path.lower(): - return f'Transfer {tag.rstrip("s")}' + return f"Transfer {tag.rstrip('s')}" return method_summaries.get(method, f"{method} {resource}") diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py index 0d7f3a3d1..47db747ac 100644 --- a/apps/api/plane/utils/openapi/parameters.py +++ b/apps/api/plane/utils/openapi/parameters.py @@ -336,7 +336,7 @@ ORDER_BY_PARAMETER = OpenApiParameter( OpenApiExample( name="State group", value="state__group", - description="Order by state group (backlog, unstarted, started, completed, cancelled)", + description="Order by state group (backlog, unstarted, started, completed, cancelled)", # noqa: E501 ), OpenApiExample( name="Assignee name", diff --git a/apps/api/plane/utils/openapi/responses.py b/apps/api/plane/utils/openapi/responses.py index a70a749f3..2a569e377 100644 --- a/apps/api/plane/utils/openapi/responses.py +++ b/apps/api/plane/utils/openapi/responses.py @@ -221,7 +221,7 @@ EXTERNAL_ID_EXISTS_RESPONSE = OpenApiResponse( OpenApiExample( name="External ID Exists", value={ - "error": "Resource with the same external id and external source already exists", + "error": "Resource with the same external id and external source already exists", # noqa: E501 "id": "550e8400-e29b-41d4-a716-446655440000", }, ) @@ -402,9 +402,7 @@ def create_paginated_response( # Asset-specific Responses -PRESIGNED_URL_SUCCESS_RESPONSE = OpenApiResponse( - description="Presigned URL generated successfully" -) +PRESIGNED_URL_SUCCESS_RESPONSE = OpenApiResponse(description="Presigned URL generated successfully") GENERIC_ASSET_UPLOAD_SUCCESS_RESPONSE = OpenApiResponse( description="Presigned URL generated successfully", @@ -474,9 +472,7 @@ ASSET_DOWNLOAD_SUCCESS_RESPONSE = OpenApiResponse( ASSET_DOWNLOAD_ERROR_RESPONSE = OpenApiResponse( description="Bad request", examples=[ - OpenApiExample( - name="Asset not uploaded", value={"error": "Asset not yet uploaded"} - ), + OpenApiExample(name="Asset not uploaded", value={"error": "Asset not yet uploaded"}), ], ) @@ -486,7 +482,5 @@ ASSET_DELETED_RESPONSE = OpenApiResponse(description="Asset deleted successfully ASSET_NOT_FOUND_RESPONSE = OpenApiResponse( description="Asset not found", - examples=[ - OpenApiExample(name="Asset not found", value={"error": "Asset not found"}) - ], + examples=[OpenApiExample(name="Asset not found", value={"error": "Asset not found"})], ) diff --git a/apps/api/plane/utils/order_queryset.py b/apps/api/plane/utils/order_queryset.py index 9138cb31e..167cd0693 100644 --- a/apps/api/plane/utils/order_queryset.py +++ b/apps/api/plane/utils/order_queryset.py @@ -10,36 +10,22 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"): if order_by_param == "priority" or order_by_param == "-priority": issue_queryset = issue_queryset.annotate( priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(PRIORITY_ORDER) - ], + *[When(priority=p, then=Value(i)) for i, p in enumerate(PRIORITY_ORDER)], output_field=CharField(), ) ).order_by("priority_order", "-created_at") - order_by_param = ( - "priority_order" if order_by_param.startswith("-") else "-priority_order" - ) + order_by_param = "priority_order" if order_by_param.startswith("-") else "-priority_order" # State Ordering elif order_by_param in ["state__group", "-state__group"]: - state_order = ( - STATE_ORDER - if order_by_param in ["state__name", "state__group"] - else STATE_ORDER[::-1] - ) + state_order = STATE_ORDER if order_by_param in ["state__name", "state__group"] else STATE_ORDER[::-1] issue_queryset = issue_queryset.annotate( state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], + *[When(state__group=state_group, then=Value(i)) for i, state_group in enumerate(state_order)], default=Value(len(state_order)), output_field=CharField(), ) ).order_by("state_order", "-created_at") - order_by_param = ( - "-state_order" if order_by_param.startswith("-") else "state_order" - ) + order_by_param = "-state_order" if order_by_param.startswith("-") else "state_order" # assignee and label ordering elif order_by_param in [ "labels__name", @@ -50,18 +36,12 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"): "-issue_module__module__name", ]: issue_queryset = issue_queryset.annotate( - min_values=Min( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) + min_values=Min(order_by_param[1::] if order_by_param.startswith("-") else order_by_param) ).order_by( "-min_values" if order_by_param.startswith("-") else "min_values", "-created_at", ) - order_by_param = ( - "-min_values" if order_by_param.startswith("-") else "min_values" - ) + order_by_param = "-min_values" if order_by_param.startswith("-") else "min_values" else: # If the order_by_param is created_at, then don't add the -created_at if "created_at" in order_by_param: diff --git a/apps/api/plane/utils/paginator.py b/apps/api/plane/utils/paginator.py index 0d065e253..f3a794756 100644 --- a/apps/api/plane/utils/paginator.py +++ b/apps/api/plane/utils/paginator.py @@ -29,8 +29,7 @@ class Cursor: # Return the cursor value def __eq__(self, other): return all( - getattr(self, attr) == getattr(other, attr) - for attr in ("value", "offset", "is_prev", "has_results") + getattr(self, attr) == getattr(other, attr) for attr in ("value", "offset", "is_prev", "has_results") ) # Return the representation of the cursor @@ -131,11 +130,7 @@ class OffsetPaginator: queryset = self.queryset if self.key: queryset = queryset.order_by( - ( - F(*self.key).desc(nulls_last=True) - if self.desc - else F(*self.key).asc(nulls_last=True) - ), + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), "-created_at", ) # The current page @@ -157,11 +152,7 @@ class OffsetPaginator: if cursor.value != limit and cursor.is_prev: results = results[-(limit + 1) :] - total_count = ( - self.total_count_queryset.count() - if self.total_count_queryset - else queryset.count() - ) + total_count = self.total_count_queryset.count() if self.total_count_queryset else queryset.count() # Check if there are more results available after the current page @@ -221,7 +212,8 @@ class GroupedOffsetPaginator(OffsetPaginator): self.group_by_field_name = group_by_field_name # Set the group by fields self.group_by_fields = group_by_fields - # Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters + # Set the count filter - this are extra filters that need to be passed + # to calculate the counts with the filters self.count_filter = count_filter def get_result(self, limit=50, cursor=None): @@ -256,9 +248,7 @@ class GroupedOffsetPaginator(OffsetPaginator): partition_by=[F(self.group_by_field_name)], order_by=( ( - F(*self.key).desc( - nulls_last=True - ) # order by desc if desc is set + F(*self.key).desc(nulls_last=True) # order by desc if desc is set if self.desc else F(*self.key).asc(nulls_last=True) # Order by asc if set ), @@ -268,18 +258,12 @@ class GroupedOffsetPaginator(OffsetPaginator): ) # Filter the results by row number results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by( - ( - F(*self.key).desc(nulls_last=True) - if self.desc - else F(*self.key).asc(nulls_last=True) - ), + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), F("created_at").desc(), ) # Adjust cursors based on the grouped results for pagination - next_cursor = Cursor( - limit, page + 1, False, queryset.filter(row_number__gte=stop).exists() - ) + next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists()) # Add previous cursors prev_cursor = Cursor(limit, page - 1, True, page > 0) @@ -318,10 +302,9 @@ class GroupedOffsetPaginator(OffsetPaginator): # Convert the total into dictionary of keys as group name and value as the total total_group_dict = {} for group in self.__get_total_queryset(): - total_group_dict[str(group.get(self.group_by_field_name))] = ( - total_group_dict.get(str(group.get(self.group_by_field_name)), 0) - + (1 if group.get("count") == 0 else group.get("count")) - ) + total_group_dict[str(group.get(self.group_by_field_name))] = total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + (1 if group.get("count") == 0 else group.get("count")) return total_group_dict def __get_field_dict(self): @@ -361,14 +344,10 @@ class GroupedOffsetPaginator(OffsetPaginator): for result in results: result_id = result["id"] group_ids = list(result_group_mapping[str(result_id)]) - result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( - [] if "None" in group_ids else group_ids - ) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = [] if "None" in group_ids else group_ids # If a result belongs to multiple groups, add it to each group for group_id in group_ids: - if not self.__result_already_added( - result, grouped_by_field_name[group_id] - ): + if not self.__result_already_added(result, grouped_by_field_name[group_id]): grouped_by_field_name[group_id].append(result) # Convert grouped_by_field_name back to a list for each group @@ -434,7 +413,8 @@ class SubGroupedOffsetPaginator(OffsetPaginator): self.sub_group_by_field_name = sub_group_by_field_name self.sub_group_by_fields = sub_group_by_fields - # Set the count filter - this are extra filters that need to be passed to calculate the counts with the filters + # Set the count filter - this are extra filters that need + # to be passed to calculate the counts with the filters self.count_filter = count_filter def get_result(self, limit=30, cursor=None): @@ -475,11 +455,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): F(self.sub_group_by_field_name), ], order_by=( - ( - F(*self.key).desc(nulls_last=True) - if self.desc - else F(*self.key).asc(nulls_last=True) - ), + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), "-created_at", ), ) @@ -487,18 +463,12 @@ class SubGroupedOffsetPaginator(OffsetPaginator): # Filter the results results = queryset.filter(row_number__gt=offset, row_number__lt=stop).order_by( - ( - F(*self.key).desc(nulls_last=True) - if self.desc - else F(*self.key).asc(nulls_last=True) - ), + (F(*self.key).desc(nulls_last=True) if self.desc else F(*self.key).asc(nulls_last=True)), F("created_at").desc(), ) # Adjust cursors based on the grouped results for pagination - next_cursor = Cursor( - limit, page + 1, False, queryset.filter(row_number__gte=stop).exists() - ) + next_cursor = Cursor(limit, page + 1, False, queryset.filter(row_number__gte=stop).exists()) # Add previous cursors prev_cursor = Cursor(limit, page - 1, True, page > 0) @@ -548,10 +518,9 @@ class SubGroupedOffsetPaginator(OffsetPaginator): total_group_dict = {} total_sub_group_dict = {} for group in self.__get_group_total_queryset(): - total_group_dict[str(group.get(self.group_by_field_name))] = ( - total_group_dict.get(str(group.get(self.group_by_field_name)), 0) - + (1 if group.get("count") == 0 else group.get("count")) - ) + total_group_dict[str(group.get(self.group_by_field_name))] = total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + (1 if group.get("count") == 0 else group.get("count")) # Sub group total values for item in self.__get_subgroup_total_queryset(): @@ -582,9 +551,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): "results": { str(sub_group): { "results": [], - "total_results": total_sub_group_dict.get(str(group)).get( - str(sub_group), 0 - ), + "total_results": total_sub_group_dict.get(str(group)).get(str(sub_group), 0), } for sub_group in total_sub_group_dict.get(str(group), []) }, @@ -622,16 +589,11 @@ class SubGroupedOffsetPaginator(OffsetPaginator): # Check if the group value is in the processed results result_id = result["id"] - if ( - group_value in processed_results - and sub_group_value in processed_results[str(group_value)]["results"] - ): + if group_value in processed_results and sub_group_value in processed_results[str(group_value)]["results"]: if self.group_by_field_name in self.FIELD_MAPPER: # for multi grouper group_ids = list(result_group_mapping[str(result_id)]) - result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( - [] if "None" in group_ids else group_ids - ) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = [] if "None" in group_ids else group_ids if self.sub_group_by_field_name in self.FIELD_MAPPER: sub_group_ids = list(result_sub_group_mapping[str(result_id)]) # for multi groups @@ -639,9 +601,7 @@ class SubGroupedOffsetPaginator(OffsetPaginator): [] if "None" in sub_group_ids else sub_group_ids ) # If a result belongs to multiple groups, add it to each group - processed_results[str(group_value)]["results"][str(sub_group_value)][ - "results" - ].append(result) + processed_results[str(group_value)]["results"][str(sub_group_value)]["results"].append(result) return processed_results @@ -651,18 +611,13 @@ class SubGroupedOffsetPaginator(OffsetPaginator): for result in results: group_value = str(result.get(self.group_by_field_name)) sub_group_value = str(result.get(self.sub_group_by_field_name)) - processed_results[group_value]["results"][sub_group_value][ - "results" - ].append(result) + processed_results[group_value]["results"][sub_group_value]["results"].append(result) return processed_results def process_results(self, results): if results: - if ( - self.group_by_field_name in self.FIELD_MAPPER - or self.sub_group_by_field_name in self.FIELD_MAPPER - ): + if self.group_by_field_name in self.FIELD_MAPPER or self.sub_group_by_field_name in self.FIELD_MAPPER: # if the grouping is done through m2m then processed_results = self.__query_multi_grouper(results=results) else: @@ -688,9 +643,7 @@ class BasePaginator: max_per_page = max(max_per_page, default_per_page) if per_page > max_per_page: - raise ParseError( - detail=f"Invalid per_page value. Cannot exceed {max_per_page}." - ) + raise ParseError(detail=f"Invalid per_page value. Cannot exceed {max_per_page}.") return per_page @@ -718,9 +671,7 @@ class BasePaginator: # Convert the cursor value to integer and float from string input_cursor = None try: - input_cursor = cursor_cls.from_string( - request.GET.get(self.cursor_name, f"{per_page}:0:0") - ) + input_cursor = cursor_cls.from_string(request.GET.get(self.cursor_name, f"{per_page}:0:0")) except ValueError: raise ParseError(detail="Invalid cursor parameter.") @@ -731,9 +682,7 @@ class BasePaginator: paginator_kwargs["count_filter"] = count_filter if sub_group_by_field_name: - paginator_kwargs["sub_group_by_field_name"] = ( - sub_group_by_field_name - ) + paginator_kwargs["sub_group_by_field_name"] = sub_group_by_field_name paginator_kwargs["sub_group_by_fields"] = sub_group_by_fields paginator_kwargs["total_count_queryset"] = total_count_queryset diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index ba81e9cab..ede3f1161 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -1,21 +1,141 @@ +# Django imports +from django.utils.http import url_has_allowed_host_and_scheme +from django.conf import settings + # Python imports from urllib.parse import urlparse +def _contains_suspicious_patterns(path: str) -> bool: + """ + Check for suspicious patterns that might indicate malicious intent. + + Args: + path (str): The path to check + + Returns: + bool: True if suspicious patterns found, False otherwise + """ + suspicious_patterns = [ + r"javascript:", # JavaScript injection + r"data:", # Data URLs + r"vbscript:", # VBScript injection + r"file:", # File protocol + r"ftp:", # FTP protocol + r"%2e%2e", # URL encoded path traversal + r"%2f%2f", # URL encoded double slash + r"%5c%5c", # URL encoded backslashes + r" str: - """Validates that next_path is a valid path and extracts only the path component.""" + """Validates that next_path is a safe relative path for redirection.""" + # Browsers interpret backslashes as forward slashes. Remove all backslashes. + if not next_path or not isinstance(next_path, str): + return "" + + # Limit input length to prevent DoS attacks + if len(next_path) > 500: + return "" + + next_path = next_path.replace("\\", "") parsed_url = urlparse(next_path) - # Ensure next_path is not an absolute URL + # Block absolute URLs or anything with scheme/netloc if parsed_url.scheme or parsed_url.netloc: next_path = parsed_url.path # Extract only the path component - # Ensure it starts with a forward slash (indicating a valid relative path) - if not next_path.startswith("/"): + # Must start with a forward slash and not be empty + if not next_path or not next_path.startswith("/"): return "" - # Ensure it does not contain dangerous path traversal sequences + # Prevent path traversal if ".." in next_path: return "" + # Additional security checks + if _contains_suspicious_patterns(next_path): + return "" + return next_path + + +def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str: + """ + Safely construct a redirect URL with validated next_path. + + Args: + base_url (str): The base URL to redirect to + next_path (str): The next path to append + params (dict): The parameters to append + Returns: + str: The safe redirect URL + """ + from urllib.parse import urlencode + + # Validate the next path + validated_path = validate_next_path(next_path) + + # Add the next path to the parameters + base_url = base_url.rstrip("/") + + # Prepare the query parameters + query_parts = [] + encoded_params = "" + + # Add the next path to the parameters + if validated_path: + query_parts.append(f"next_path={validated_path}") + + # Add additional parameters + if params: + encoded_params = urlencode(params) + query_parts.append(encoded_params) + + # Construct the url query string + if query_parts: + query_string = "&".join(query_parts) + url = f"{base_url}/?{query_string}" + else: + url = base_url + + # Check if the URL is allowed + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return url + + # Return the base URL if the URL is not allowed + return base_url + (f"?{encoded_params}" if encoded_params else "") diff --git a/apps/api/plane/utils/url.py b/apps/api/plane/utils/url.py index 6c196c298..773608bd3 100644 --- a/apps/api/plane/utils/url.py +++ b/apps/api/plane/utils/url.py @@ -10,11 +10,11 @@ URL_PATTERN = re.compile( r"(?:" # Non-capturing group for alternatives r"https?://[^\s]+" # http:// or https:// followed by non-whitespace r"|" - r"www\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" # www.domain with proper length limits + r"www\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*" # noqa: E501 r"|" - r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}" # domain.tld with length limits + r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,6}" # noqa: E501 r"|" - r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" # IP address with proper validation + r"(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)" # noqa: E501 r")" ) @@ -85,7 +85,10 @@ def get_url_components(url: str) -> Optional[dict]: Example: >>> get_url_components("https://example.com/path?query=1") - {'scheme': 'https', 'netloc': 'example.com', 'path': '/path', 'params': '', 'query': 'query=1', 'fragment': ''} + { + 'scheme': 'https', 'netloc': 'example.com', + 'path': '/path', 'params': '', + 'query': 'query=1', 'fragment': ''} """ if not is_valid_url(url): return None @@ -102,9 +105,11 @@ def get_url_components(url: str) -> Optional[dict]: def normalize_url_path(url: str) -> str: """ - Normalize the path component of a URL by replacing multiple consecutive slashes with a single slash. + Normalize the path component of a URL by + replacing multiple consecutive slashes with a single slash. - This function preserves the protocol, domain, query parameters, and fragments of the URL, + This function preserves the protocol, domain, + query parameters, and fragments of the URL, only modifying the path portion to ensure there are no duplicate slashes. Args: diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml index 099d5e36e..428aabba7 100644 --- a/apps/api/pyproject.toml +++ b/apps/api/pyproject.toml @@ -31,7 +31,7 @@ exclude = [ ] # Same as Black. -line-length = 88 +line-length = 120 indent-width = 4 [tool.ruff.format] diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt index 28fede97f..cde403b04 100644 --- a/apps/api/requirements/base.txt +++ b/apps/api/requirements/base.txt @@ -1,13 +1,13 @@ # base requirements # django -Django==4.2.24 +Django==4.2.25 # rest framework djangorestframework==3.15.2 # postgres -psycopg==3.1.18 -psycopg-binary==3.1.18 -psycopg-c==3.1.18 +psycopg==3.2.9 +psycopg-binary==3.2.9 +psycopg-c==3.2.9 dj-database-url==2.1.0 # mongo pymongo==4.6.3 @@ -53,7 +53,7 @@ posthog==3.5.0 # crypto cryptography==44.0.1 # html validator -lxml==5.2.1 +lxml==6.0.0 # s3 boto3==1.34.96 # password validator diff --git a/apps/api/run_tests.py b/apps/api/run_tests.py index 6f42229c9..b92f9fe5b 100755 --- a/apps/api/run_tests.py +++ b/apps/api/run_tests.py @@ -7,18 +7,10 @@ import sys def main(): parser = argparse.ArgumentParser(description="Run Plane tests") parser.add_argument("-u", "--unit", action="store_true", help="Run unit tests only") - parser.add_argument( - "-c", "--contract", action="store_true", help="Run contract tests only" - ) - parser.add_argument( - "-s", "--smoke", action="store_true", help="Run smoke tests only" - ) - parser.add_argument( - "-o", "--coverage", action="store_true", help="Generate coverage report" - ) - parser.add_argument( - "-p", "--parallel", action="store_true", help="Run tests in parallel" - ) + parser.add_argument("-c", "--contract", action="store_true", help="Run contract tests only") + parser.add_argument("-s", "--smoke", action="store_true", help="Run smoke tests only") + parser.add_argument("-o", "--coverage", action="store_true", help="Generate coverage report") + parser.add_argument("-p", "--parallel", action="store_true", help="Run tests in parallel") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") args = parser.parse_args() diff --git a/apps/live/Dockerfile.live b/apps/live/Dockerfile.live index a353357cd..92fbee6a1 100644 --- a/apps/live/Dockerfile.live +++ b/apps/live/Dockerfile.live @@ -61,4 +61,4 @@ ENV TURBO_TELEMETRY_DISABLED=1 EXPOSE 3000 -CMD ["node", "apps/live/dist/server.js"] +CMD ["node", "apps/live/dist/start.js"] diff --git a/apps/live/package.json b/apps/live/package.json index 97e5c50d7..6f866e8a9 100644 --- a/apps/live/package.json +++ b/apps/live/package.json @@ -1,15 +1,15 @@ { "name": "live", - "version": "1.0.0", + "version": "1.1.0", "license": "AGPL-3.0", "description": "A realtime collaborative server powers Plane's rich text editor", - "main": "./src/server.ts", + "main": "./dist/start.js", "private": true, "type": "module", "scripts": { - "build": "tsdown", - "dev": "tsdown --watch", - "start": "node --env-file=.env dist/server.js", + "build": "tsc --noEmit && tsdown", + "dev": "tsdown --watch --onSuccess \"node --env-file=.env dist/start.js\"", + "start": "node --env-file=.env dist/start.js", "check:lint": "eslint . --max-warnings 10", "check:types": "tsc --noEmit", "check:format": "prettier --check \"**/*.{ts,tsx,md,json,css,scss}\"", @@ -17,17 +17,22 @@ "fix:format": "prettier --write \"**/*.{ts,tsx,md,json,css,scss}\"", "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist" }, - "keywords": [], - "author": "", + "author": "Plane Software Inc.", "dependencies": { - "@hocuspocus/extension-database": "^2.15.0", - "@hocuspocus/extension-logger": "^2.15.0", - "@hocuspocus/extension-redis": "^2.15.0", - "@hocuspocus/server": "^2.15.0", + "@dotenvx/dotenvx": "^1.49.0", + "@hocuspocus/extension-database": "2.15.2", + "@hocuspocus/extension-logger": "2.15.2", + "@hocuspocus/extension-redis": "2.15.2", + "@hocuspocus/server": "2.15.2", + "@hocuspocus/transformer": "2.15.2", + "@plane/decorators": "workspace:*", "@plane/editor": "workspace:*", + "@plane/logger": "workspace:*", "@plane/types": "workspace:*", - "@tiptap/core": "^2.22.3", - "@tiptap/html": "^2.22.3", + "@sentry/node": "catalog:", + "@sentry/profiling-node": "catalog:", + "@tiptap/core": "catalog:", + "@tiptap/html": "catalog:", "axios": "catalog:", "compression": "1.8.1", "cors": "^2.8.5", @@ -35,15 +40,13 @@ "express": "^4.21.2", "express-ws": "^5.0.2", "helmet": "^7.1.0", - "ioredis": "^5.4.1", - "lodash": "catalog:", - "morgan": "1.10.1", - "pino-http": "^10.3.0", - "pino-pretty": "^11.2.2", + "ioredis": "5.7.0", "uuid": "catalog:", - "y-prosemirror": "^1.2.15", + "ws": "^8.18.3", + "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", - "yjs": "^13.6.20" + "yjs": "^13.6.20", + "zod": "^3.25.76" }, "devDependencies": { "@plane/eslint-config": "workspace:*", @@ -52,14 +55,9 @@ "@types/cors": "^2.8.17", "@types/express": "^4.17.23", "@types/express-ws": "^3.0.5", - "@types/node": "^20.14.9", - "@types/pino-http": "^5.8.4", - "@types/uuid": "^9.0.1", - "concurrently": "^9.0.1", - "nodemon": "^3.1.7", - "ts-node": "^10.9.2", + "@types/node": "catalog:", + "@types/ws": "^8.18.1", "tsdown": "catalog:", - "typescript": "catalog:", - "ws": "^8.18.3" + "typescript": "catalog:" } } diff --git a/apps/live/src/ce/lib/fetch-document.ts b/apps/live/src/ce/lib/fetch-document.ts deleted file mode 100644 index f7b4d8ea6..000000000 --- a/apps/live/src/ce/lib/fetch-document.ts +++ /dev/null @@ -1,14 +0,0 @@ -// types -import { TDocumentTypes } from "@/core/types/common.js"; - -type TArgs = { - cookie: string | undefined; - documentType: TDocumentTypes | undefined; - pageId: string; - params: URLSearchParams; -}; - -export const fetchDocument = async (args: TArgs): Promise => { - const { documentType } = args; - throw Error(`Fetch failed: Invalid document type ${documentType} provided.`); -}; diff --git a/apps/live/src/ce/lib/update-document.ts b/apps/live/src/ce/lib/update-document.ts deleted file mode 100644 index cbef54e74..000000000 --- a/apps/live/src/ce/lib/update-document.ts +++ /dev/null @@ -1,15 +0,0 @@ -// types -import { TDocumentTypes } from "@/core/types/common.js"; - -type TArgs = { - cookie: string | undefined; - documentType: TDocumentTypes | undefined; - pageId: string; - params: URLSearchParams; - updatedDescription: Uint8Array; -}; - -export const updateDocument = async (args: TArgs): Promise => { - const { documentType } = args; - throw Error(`Update failed: Invalid document type ${documentType} provided.`); -}; diff --git a/apps/live/src/ce/types/common.d.ts b/apps/live/src/ce/types/common.d.ts deleted file mode 100644 index ffc9e1053..000000000 --- a/apps/live/src/ce/types/common.d.ts +++ /dev/null @@ -1 +0,0 @@ -export type TAdditionalDocumentTypes = never; diff --git a/apps/live/src/controllers/collaboration.controller.ts b/apps/live/src/controllers/collaboration.controller.ts new file mode 100644 index 000000000..59bfe7b0c --- /dev/null +++ b/apps/live/src/controllers/collaboration.controller.ts @@ -0,0 +1,33 @@ +import type { Hocuspocus } from "@hocuspocus/server"; +import type { Request } from "express"; +import type WebSocket from "ws"; +// plane imports +import { Controller, WebSocket as WSDecorator } from "@plane/decorators"; +import { logger } from "@plane/logger"; + +@Controller("/collaboration") +export class CollaborationController { + [key: string]: unknown; + private readonly hocusPocusServer: Hocuspocus; + + constructor(hocusPocusServer: Hocuspocus) { + this.hocusPocusServer = hocusPocusServer; + } + + @WSDecorator("/") + handleConnection(ws: WebSocket, req: Request) { + try { + // Initialize the connection with Hocuspocus + this.hocusPocusServer.handleConnection(ws, req); + + // Set up error handling for the connection + ws.on("error", (error: Error) => { + logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error); + ws.close(1011, "Internal server error"); + }); + } catch (error) { + logger.error("COLLABORATION_CONTROLLER: WebSocket connection error:", error); + ws.close(1011, "Internal server error"); + } + } +} diff --git a/apps/live/src/controllers/document.controller.ts b/apps/live/src/controllers/document.controller.ts new file mode 100644 index 000000000..3b45c4e92 --- /dev/null +++ b/apps/live/src/controllers/document.controller.ts @@ -0,0 +1,63 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +// helpers +import { Controller, Post } from "@plane/decorators"; +import { convertHTMLDocumentToAllFormats } from "@plane/editor"; +// logger +import { logger } from "@plane/logger"; +import { type TConvertDocumentRequestBody } from "@/types"; + +// Define the schema with more robust validation +const convertDocumentSchema = z.object({ + description_html: z + .string() + .min(1, "HTML content cannot be empty") + .refine((html) => html.trim().length > 0, "HTML content cannot be just whitespace") + .refine((html) => html.includes("<") && html.includes(">"), "Content must be valid HTML"), + variant: z.enum(["rich", "document"]), +}); + +@Controller("/convert-document") +export class DocumentController { + @Post("/") + async convertDocument(req: Request, res: Response) { + try { + // Validate request body + const validatedData = convertDocumentSchema.parse(req.body as TConvertDocumentRequestBody); + const { description_html, variant } = validatedData; + + // Process document conversion + const { description, description_binary } = convertHTMLDocumentToAllFormats({ + document_html: description_html, + variant, + }); + + // Return successful response + res.status(200).json({ + description, + description_binary, + }); + } catch (error) { + if (error instanceof z.ZodError) { + const validationErrors = error.errors.map((err) => ({ + path: err.path.join("."), + message: err.message, + })); + logger.error("DOCUMENT_CONTROLLER: Validation error", { + validationErrors, + }); + return res.status(400).json({ + message: `Validation error`, + context: { + validationErrors, + }, + }); + } else { + logger.error("DOCUMENT_CONTROLLER: Internal server error", error); + return res.status(500).json({ + message: `Internal server error.`, + }); + } + } + } +} diff --git a/apps/live/src/controllers/health.controller.ts b/apps/live/src/controllers/health.controller.ts new file mode 100644 index 000000000..34026c04b --- /dev/null +++ b/apps/live/src/controllers/health.controller.ts @@ -0,0 +1,15 @@ +import type { Request, Response } from "express"; +import { Controller, Get } from "@plane/decorators"; +import { env } from "@/env"; + +@Controller("/health") +export class HealthController { + @Get("/") + async healthCheck(_req: Request, res: Response) { + res.status(200).json({ + status: "OK", + timestamp: new Date().toISOString(), + version: env.APP_VERSION, + }); + } +} diff --git a/apps/live/src/controllers/index.ts b/apps/live/src/controllers/index.ts new file mode 100644 index 000000000..3b45cb1ed --- /dev/null +++ b/apps/live/src/controllers/index.ts @@ -0,0 +1,5 @@ +import { CollaborationController } from "./collaboration.controller"; +import { DocumentController } from "./document.controller"; +import { HealthController } from "./health.controller"; + +export const CONTROLLERS = [CollaborationController, DocumentController, HealthController]; diff --git a/apps/live/src/core/extensions/index.ts b/apps/live/src/core/extensions/index.ts deleted file mode 100644 index 1d14d41b8..000000000 --- a/apps/live/src/core/extensions/index.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Database } from "@hocuspocus/extension-database"; -import { Logger } from "@hocuspocus/extension-logger"; -import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis"; -import { Extension } from "@hocuspocus/server"; -import { Redis } from "ioredis"; -// core helpers and utilities -import { manualLogger } from "@/core/helpers/logger.js"; -// core libraries -import { fetchPageDescriptionBinary, updatePageDescription } from "@/core/lib/page.js"; -import { getRedisUrl } from "@/core/lib/utils/redis-url.js"; -import { type HocusPocusServerContext, type TDocumentTypes } from "@/core/types/common.js"; -// plane live libraries -import { fetchDocument } from "@/plane-live/lib/fetch-document.js"; -import { updateDocument } from "@/plane-live/lib/update-document.js"; - -export const getExtensions: () => Promise = async () => { - const extensions: Extension[] = [ - new Logger({ - onChange: false, - log: (message) => { - manualLogger.info(message); - }, - }), - new Database({ - fetch: async ({ context, documentName: pageId, requestParameters }) => { - const cookie = (context as HocusPocusServerContext).cookie; - // query params - const params = requestParameters; - const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined; - // TODO: Fix this lint error. - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve) => { - try { - let fetchedData = null; - if (documentType === "project_page") { - fetchedData = await fetchPageDescriptionBinary(params, pageId, cookie); - } else { - fetchedData = await fetchDocument({ - cookie, - documentType, - pageId, - params, - }); - } - resolve(fetchedData); - } catch (error) { - manualLogger.error("Error in fetching document", error); - } - }); - }, - store: async ({ context, state, documentName: pageId, requestParameters }) => { - const cookie = (context as HocusPocusServerContext).cookie; - // query params - const params = requestParameters; - const documentType = params.get("documentType")?.toString() as TDocumentTypes | undefined; - - // TODO: Fix this lint error. - // eslint-disable-next-line no-async-promise-executor - return new Promise(async () => { - try { - if (documentType === "project_page") { - await updatePageDescription(params, pageId, state, cookie); - } else { - await updateDocument({ - cookie, - documentType, - pageId, - params, - updatedDescription: state, - }); - } - } catch (error) { - manualLogger.error("Error in updating document:", error); - } - }); - }, - }), - ]; - - const redisUrl = getRedisUrl(); - - if (redisUrl) { - try { - const redisClient = new Redis(redisUrl); - - await new Promise((resolve, reject) => { - redisClient.on("error", (error: any) => { - if (error?.code === "ENOTFOUND" || error.message.includes("WRONGPASS") || error.message.includes("NOAUTH")) { - redisClient.disconnect(); - } - manualLogger.warn( - `Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`, - error - ); - reject(error); - }); - - redisClient.on("ready", () => { - extensions.push(new HocusPocusRedis({ redis: redisClient })); - manualLogger.info("Redis Client connected ✅"); - resolve(); - }); - }); - } catch (error) { - manualLogger.warn( - `Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`, - error - ); - } - } else { - manualLogger.warn( - "Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)" - ); - } - - return extensions; -}; diff --git a/apps/live/src/core/helpers/convert-document.ts b/apps/live/src/core/helpers/convert-document.ts deleted file mode 100644 index 123989190..000000000 --- a/apps/live/src/core/helpers/convert-document.ts +++ /dev/null @@ -1,44 +0,0 @@ -// plane editor -import { - getAllDocumentFormatsFromDocumentEditorBinaryData, - getAllDocumentFormatsFromRichTextEditorBinaryData, - getBinaryDataFromDocumentEditorHTMLString, - getBinaryDataFromRichTextEditorHTMLString, -} from "@plane/editor"; -// plane types -import { TDocumentPayload } from "@plane/types"; - -type TArgs = { - document_html: string; - variant: "rich" | "document"; -}; - -export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => { - const { document_html, variant } = args; - - let allFormats: TDocumentPayload; - - if (variant === "rich") { - const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html); - const { contentBinaryEncoded, contentHTML, contentJSON } = - getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary); - allFormats = { - description: contentJSON, - description_html: contentHTML, - description_binary: contentBinaryEncoded, - }; - } else if (variant === "document") { - const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html); - const { contentBinaryEncoded, contentHTML, contentJSON } = - getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary); - allFormats = { - description: contentJSON, - description_html: contentHTML, - description_binary: contentBinaryEncoded, - }; - } else { - throw new Error(`Invalid variant provided: ${variant}`); - } - - return allFormats; -}; diff --git a/apps/live/src/core/helpers/error-handler.ts b/apps/live/src/core/helpers/error-handler.ts deleted file mode 100644 index fac75f92f..000000000 --- a/apps/live/src/core/helpers/error-handler.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ErrorRequestHandler } from "express"; -import { manualLogger } from "@/core/helpers/logger.js"; - -export const errorHandler: ErrorRequestHandler = (err, _req, res) => { - // Log the error - manualLogger.error(err); - - // Set the response status - res.status(err.status || 500); - - // Send the response - res.json({ - error: { - message: process.env.NODE_ENV === "production" ? "An unexpected error occurred" : err.message, - ...(process.env.NODE_ENV !== "production" && { stack: err.stack }), - }, - }); -}; diff --git a/apps/live/src/core/helpers/logger.ts b/apps/live/src/core/helpers/logger.ts deleted file mode 100644 index f93c9e5ff..000000000 --- a/apps/live/src/core/helpers/logger.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { pinoHttp } from "pino-http"; - -const transport = { - target: "pino-pretty", - options: { - colorize: true, - }, -}; - -const hooks = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - logMethod(inputArgs: any, method: any): any { - if (inputArgs.length >= 2) { - const arg1 = inputArgs.shift(); - const arg2 = inputArgs.shift(); - return method.apply(this, [arg2, arg1, ...inputArgs]); - } - return method.apply(this, inputArgs); - }, -}; - -export const logger = pinoHttp({ - level: "info", - transport: transport, - hooks: hooks, - serializers: { - req(req) { - return `${req.method} ${req.url}`; - }, - res(res) { - return `${res.statusCode} ${res?.statusMessage || ""}`; - }, - responseTime(time) { - return `${time}ms`; - }, - }, -}); - -export const manualLogger: typeof logger.logger = logger.logger; diff --git a/apps/live/src/core/helpers/page.ts b/apps/live/src/core/helpers/page.ts deleted file mode 100644 index d4322d1ad..000000000 --- a/apps/live/src/core/helpers/page.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { getSchema } from "@tiptap/core"; -import { generateHTML, generateJSON } from "@tiptap/html"; -import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror"; -import * as Y from "yjs"; -// plane editor -import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib"; - -const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps]; -const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS); - -export const getAllDocumentFormatsFromBinaryData = ( - description: Uint8Array -): { - contentBinaryEncoded: string; - contentJSON: object; - contentHTML: string; -} => { - // encode binary description data - const base64Data = Buffer.from(description).toString("base64"); - const yDoc = new Y.Doc(); - Y.applyUpdate(yDoc, description); - // convert to JSON - const type = yDoc.getXmlFragment("default"); - const contentJSON = yXmlFragmentToProseMirrorRootNode(type, documentEditorSchema).toJSON(); - // convert to HTML - const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS); - - return { - contentBinaryEncoded: base64Data, - contentJSON, - contentHTML, - }; -}; - -export const getBinaryDataFromHTMLString = ( - descriptionHTML: string -): { - contentBinary: Uint8Array; -} => { - // convert HTML to JSON - const contentJSON = generateJSON(descriptionHTML ?? "

", DOCUMENT_EDITOR_EXTENSIONS); - // convert JSON to Y.Doc format - const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default"); - // convert Y.Doc to Uint8Array format - const encodedData = Y.encodeStateAsUpdate(transformedData); - - return { - contentBinary: encodedData, - }; -}; diff --git a/apps/live/src/core/hocuspocus-server.ts b/apps/live/src/core/hocuspocus-server.ts deleted file mode 100644 index df69c2cb6..000000000 --- a/apps/live/src/core/hocuspocus-server.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Server } from "@hocuspocus/server"; -import { v4 as uuidv4 } from "uuid"; -// editor types -import { TUserDetails } from "@plane/editor"; -import { DocumentCollaborativeEvents, TDocumentEventsServer } from "@plane/editor/lib"; -// extensions -import { getExtensions } from "@/core/extensions/index.js"; -// lib -import { handleAuthentication } from "@/core/lib/authentication.js"; -// types -import { type HocusPocusServerContext } from "@/core/types/common.js"; - -export const getHocusPocusServer = async () => { - const extensions = await getExtensions(); - const serverName = process.env.HOSTNAME || uuidv4(); - return Server.configure({ - name: serverName, - onAuthenticate: async ({ - requestHeaders, - context, - // user id used as token for authentication - token, - }) => { - let cookie: string | undefined = undefined; - let userId: string | undefined = undefined; - - // Extract cookie (fallback to request headers) and userId from token (for scenarios where - // the cookies are not passed in the request headers) - try { - const parsedToken = JSON.parse(token) as TUserDetails; - userId = parsedToken.id; - cookie = parsedToken.cookie; - } catch (error) { - // If token parsing fails, fallback to request headers - console.error("Token parsing failed, using request headers:", error); - } finally { - // If cookie is still not found, fallback to request headers - if (!cookie) { - cookie = requestHeaders.cookie?.toString(); - } - } - - if (!cookie || !userId) { - throw new Error("Credentials not provided"); - } - - // set cookie in context, so it can be used throughout the ws connection - (context as HocusPocusServerContext).cookie = cookie; - - try { - await handleAuthentication({ - cookie, - userId, - }); - } catch (_error) { - 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/apps/live/src/core/lib/authentication.ts b/apps/live/src/core/lib/authentication.ts deleted file mode 100644 index c7f190e3a..000000000 --- a/apps/live/src/core/lib/authentication.ts +++ /dev/null @@ -1,33 +0,0 @@ -// core helpers -import { manualLogger } from "@/core/helpers/logger.js"; -// services -import { UserService } from "@/core/services/user.service.js"; - -const userService = new UserService(); - -type Props = { - cookie: string; - userId: string; -}; - -export const handleAuthentication = async (props: Props) => { - const { cookie, userId } = props; - // fetch current user info - let response; - try { - response = await userService.currentUser(cookie); - } catch (error) { - manualLogger.error("Failed to fetch current user:", error); - throw error; - } - if (response.id !== userId) { - throw Error("Authentication failed: Token doesn't match the current user."); - } - - return { - user: { - id: response.id, - name: response.display_name, - }, - }; -}; diff --git a/apps/live/src/core/lib/page.ts b/apps/live/src/core/lib/page.ts deleted file mode 100644 index 7d23d8b19..000000000 --- a/apps/live/src/core/lib/page.ts +++ /dev/null @@ -1,80 +0,0 @@ -// helpers -import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js"; -// services -import { PageService } from "@/core/services/page.service.js"; -import { manualLogger } from "../helpers/logger.js"; -const pageService = new PageService(); - -export const updatePageDescription = async ( - params: URLSearchParams, - pageId: string, - updatedDescription: Uint8Array, - cookie: string | undefined -) => { - if (!(updatedDescription instanceof Uint8Array)) { - throw new Error("Invalid updatedDescription: must be an instance of Uint8Array"); - } - - const workspaceSlug = params.get("workspaceSlug")?.toString(); - const projectId = params.get("projectId")?.toString(); - if (!workspaceSlug || !projectId || !cookie) return; - - const { contentBinaryEncoded, contentHTML, contentJSON } = getAllDocumentFormatsFromBinaryData(updatedDescription); - try { - const payload = { - description_binary: contentBinaryEncoded, - description_html: contentHTML, - description: contentJSON, - }; - - await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie); - } catch (error) { - manualLogger.error("Update error:", error); - throw error; - } -}; - -const fetchDescriptionHTMLAndTransform = async ( - workspaceSlug: string, - projectId: string, - pageId: string, - cookie: string -) => { - if (!workspaceSlug || !projectId || !cookie) return; - - try { - const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie); - const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "

"); - return contentBinary; - } catch (error) { - manualLogger.error("Error while transforming from HTML to Uint8Array", error); - throw error; - } -}; - -export const fetchPageDescriptionBinary = async ( - params: URLSearchParams, - pageId: string, - cookie: string | undefined -) => { - const workspaceSlug = params.get("workspaceSlug")?.toString(); - const projectId = params.get("projectId")?.toString(); - if (!workspaceSlug || !projectId || !cookie) return null; - - try { - const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie); - const binaryData = new Uint8Array(response); - - if (binaryData.byteLength === 0) { - const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie); - if (binary) { - return binary; - } - } - - return binaryData; - } catch (error) { - manualLogger.error("Fetch error:", error); - throw error; - } -}; diff --git a/apps/live/src/core/lib/utils/redis-url.ts b/apps/live/src/core/lib/utils/redis-url.ts deleted file mode 100644 index e2f9c995f..000000000 --- a/apps/live/src/core/lib/utils/redis-url.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function getRedisUrl() { - const redisUrl = process.env.REDIS_URL?.trim(); - const redisHost = process.env.REDIS_HOST?.trim(); - const redisPort = process.env.REDIS_PORT?.trim(); - - if (redisUrl) { - return redisUrl; - } - - if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) { - return `redis://${redisHost}:${redisPort}`; - } - - return ""; -} diff --git a/apps/live/src/core/services/page.service.ts b/apps/live/src/core/services/page.service.ts deleted file mode 100644 index 9c1ed8237..000000000 --- a/apps/live/src/core/services/page.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -// types -import { TPage } from "@plane/types"; -// services -import { API_BASE_URL, APIService } from "@/core/services/api.service.js"; - -export class PageService extends APIService { - constructor() { - super(API_BASE_URL); - } - - async fetchDetails(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`, { - headers: { - Cookie: cookie, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async fetchDescriptionBinary(workspaceSlug: string, projectId: string, pageId: string, cookie: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, { - headers: { - "Content-Type": "application/octet-stream", - Cookie: cookie, - }, - responseType: "arraybuffer", - }) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async updateDescription( - workspaceSlug: string, - projectId: string, - pageId: string, - data: { - description_binary: string; - description_html: string; - description: object; - }, - cookie: string - ): Promise { - return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`, data, { - headers: { - Cookie: cookie, - }, - }) - .then((response) => response?.data) - .catch((error) => { - throw error; - }); - } -} diff --git a/apps/live/src/core/types/common.d.ts b/apps/live/src/core/types/common.d.ts deleted file mode 100644 index 90fd335ae..000000000 --- a/apps/live/src/core/types/common.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// types -import { TAdditionalDocumentTypes } from "@/plane-live/types/common.js"; - -export type TDocumentTypes = "project_page" | TAdditionalDocumentTypes; - -export type HocusPocusServerContext = { - cookie: string; -}; - -export type TConvertDocumentRequestBody = { - description_html: string; - variant: "rich" | "document"; -}; diff --git a/apps/live/src/ee/lib/fetch-document.ts b/apps/live/src/ee/lib/fetch-document.ts deleted file mode 100644 index 33aa90bba..000000000 --- a/apps/live/src/ee/lib/fetch-document.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../ce/lib/fetch-document.js"; diff --git a/apps/live/src/ee/lib/update-document.ts b/apps/live/src/ee/lib/update-document.ts deleted file mode 100644 index 0f9c964e7..000000000 --- a/apps/live/src/ee/lib/update-document.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../ce/lib/update-document.js"; diff --git a/apps/live/src/ee/types/common.d.ts b/apps/live/src/ee/types/common.d.ts deleted file mode 100644 index 4f11c54d0..000000000 --- a/apps/live/src/ee/types/common.d.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../ce/types/common.js"; diff --git a/apps/live/src/env.ts b/apps/live/src/env.ts new file mode 100644 index 000000000..3c1a91ec9 --- /dev/null +++ b/apps/live/src/env.ts @@ -0,0 +1,36 @@ +import * as dotenv from "@dotenvx/dotenvx"; +import { z } from "zod"; + +dotenv.config(); + +// Environment variable validation +const envSchema = z.object({ + APP_VERSION: z.string().default("1.0.0"), + HOSTNAME: z.string().optional(), + PORT: z.string().default("3000"), + API_BASE_URL: z.string().url("API_BASE_URL must be a valid URL"), + // CORS configuration + CORS_ALLOWED_ORIGINS: z.string().default(""), + // Live running location + LIVE_BASE_PATH: z.string().default("/live"), + // Compression options + COMPRESSION_LEVEL: z.string().default("6").transform(Number), + COMPRESSION_THRESHOLD: z.string().default("5000").transform(Number), + // secret + LIVE_SERVER_SECRET_KEY: z.string(), + // Redis configuration + REDIS_HOST: z.string().optional(), + REDIS_PORT: z.string().default("6379").transform(Number), + REDIS_URL: z.string().optional(), +}); + +const validateEnv = () => { + const result = envSchema.safeParse(process.env); + if (!result.success) { + console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4)); + process.exit(1); + } + return result.data; +}; + +export const env = validateEnv(); diff --git a/apps/live/src/extensions/database.ts b/apps/live/src/extensions/database.ts new file mode 100644 index 000000000..be7a3139c --- /dev/null +++ b/apps/live/src/extensions/database.ts @@ -0,0 +1,112 @@ +import { Database as HocuspocusDatabase } from "@hocuspocus/extension-database"; +// utils +import { + getAllDocumentFormatsFromDocumentEditorBinaryData, + getBinaryDataFromDocumentEditorHTMLString, +} from "@plane/editor"; +// logger +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +// services +import { getPageService } from "@/services/page/handler"; +// type +import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types"; +import { ForceCloseReason, CloseCode } from "@/types/admin-commands"; +import { broadcastError } from "@/utils/broadcast-error"; +// force close utility +import { forceCloseDocumentAcrossServers } from "./force-close-handler"; + +const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => { + try { + const service = getPageService(context.documentType, context); + // fetch details + const response = await service.fetchDescriptionBinary(pageId); + const binaryData = new Uint8Array(response); + // if binary data is empty, convert HTML to binary data + if (binaryData.byteLength === 0) { + const pageDetails = await service.fetchDetails(pageId); + const convertedBinaryData = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "

"); + if (convertedBinaryData) { + return convertedBinaryData; + } + } + // return binary data + return binaryData; + } catch (error) { + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in fetching document", appError); + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context); + + throw appError; + } +}; + +const storeDocument = async ({ + context, + state: pageBinaryData, + documentName: pageId, + instance, +}: StorePayloadWithContext) => { + try { + const service = getPageService(context.documentType, context); + // convert binary data to all formats + const { contentBinaryEncoded, contentHTML, contentJSON } = + getAllDocumentFormatsFromDocumentEditorBinaryData(pageBinaryData); + // create payload + const payload = { + description_binary: contentBinaryEncoded, + description_html: contentHTML, + description: contentJSON, + }; + await service.updateDescriptionBinary(pageId, payload); + } catch (error) { + const appError = new AppError(error, { context: { pageId } }); + logger.error("Error in updating document:", appError); + + // Check error types + const isContentTooLarge = appError.statusCode === 413; + + // Determine if we should disconnect and unload + const shouldDisconnect = isContentTooLarge; + + // Determine error message and code + let errorMessage: string; + let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined; + + if (isContentTooLarge) { + errorMessage = "Document is too large to save. Please reduce the content size."; + errorCode = "content_too_large"; + } else { + errorMessage = "Unable to save the page. Please try again."; + } + + // Broadcast error to frontend for user document types + await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect); + + // If we should disconnect, close connections and unload document + if (shouldDisconnect) { + // Map error code to ForceCloseReason with proper types + const reason = + errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR; + + const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE; + + // force close connections and unload document + await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode); + + // Don't throw after force close - document is already unloaded + // Throwing would cause hocuspocus's finally block to access the null document + return; + } + + throw appError; + } +}; + +export class Database extends HocuspocusDatabase { + constructor() { + super({ fetch: fetchDocument, store: storeDocument }); + } +} diff --git a/apps/live/src/extensions/force-close-handler.ts b/apps/live/src/extensions/force-close-handler.ts new file mode 100644 index 000000000..522d0909a --- /dev/null +++ b/apps/live/src/extensions/force-close-handler.ts @@ -0,0 +1,203 @@ +import type { Connection, Extension, Hocuspocus, onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { + AdminCommand, + CloseCode, + ForceCloseReason, + getForceCloseMessage, + isForceCloseCommand, + type ClientForceCloseMessage, + type ForceCloseCommandData, +} from "@/types/admin-commands"; + +/** + * Extension to handle force close commands from other servers via Redis admin channel + */ +export class ForceCloseHandler implements Extension { + name = "ForceCloseHandler"; + priority = 999; + + async onConfigure({ instance }: onConfigurePayload) { + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined; + + if (!redisExt) { + logger.warn("[FORCE_CLOSE_HANDLER] Redis extension not found"); + return; + } + + // Register handler for force_close admin command + redisExt.onAdminCommand(AdminCommand.FORCE_CLOSE, async (data) => { + // Type guard for safety + if (!isForceCloseCommand(data)) { + logger.error("[FORCE_CLOSE_HANDLER] Received invalid force close command"); + return; + } + + const { docId, reason, code } = data; + + const document = instance.documents.get(docId); + if (!document) { + // Not our document, ignore + return; + } + + const connectionCount = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE_HANDLER] Sending force close message to ${connectionCount} clients...`); + + // Step 1: Send force close message to ALL clients first + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSent = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSent++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to send message:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Sent force close message to ${messageSent}/${connectionCount} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Step 2: Close connections + logger.info(`[FORCE_CLOSE_HANDLER] Closing ${connectionCount} connections...`); + + let closed = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closed++; + } catch (error) { + logger.error("[FORCE_CLOSE_HANDLER] Failed to close connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE_HANDLER] Closed ${closed}/${connectionCount} connections for ${docId}`); + }); + + logger.info("[FORCE_CLOSE_HANDLER] Registered with Redis extension"); + } +} + +/** + * Force close all connections to a document across all servers and unload it from memory. + * Used for critical errors or admin operations. + * + * @param instance - The Hocuspocus server instance + * @param pageId - The document ID to force close + * @param reason - The reason for force closing + * @param code - Optional WebSocket close code (defaults to FORCE_CLOSE) + * @returns Promise that resolves when document is closed and unloaded + * @throws Error if document not found in memory + */ +export const forceCloseDocumentAcrossServers = async ( + instance: Hocuspocus, + pageId: string, + reason: ForceCloseReason, + code: CloseCode = CloseCode.FORCE_CLOSE +): Promise => { + // STEP 1: VERIFY DOCUMENT EXISTS + const document = instance.documents.get(pageId); + + if (!document) { + logger.info(`[FORCE_CLOSE] Document ${pageId} already unloaded - no action needed`); + return; // Document already cleaned up, nothing to do + } + + const connectionsBefore = document.getConnectionsCount(); + logger.info(`[FORCE_CLOSE] Sending force close message to ${connectionsBefore} local clients...`); + + const forceCloseMessage: ClientForceCloseMessage = { + type: "force_close", + reason, + code, + message: getForceCloseMessage(reason), + timestamp: new Date().toISOString(), + }; + + let messageSentCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.sendStateless(JSON.stringify(forceCloseMessage)); + messageSentCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to send message to client:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Sent force close message to ${messageSentCount}/${connectionsBefore} clients`); + + // Wait a moment for messages to be delivered + await new Promise((resolve) => setTimeout(resolve, 50)); + + // STEP 3: CLOSE LOCAL CONNECTIONS + logger.info(`[FORCE_CLOSE] Closing ${connectionsBefore} local connections...`); + + let closedCount = 0; + document.connections.forEach(({ connection }: { connection: Connection }) => { + try { + connection.close({ code, reason }); + closedCount++; + } catch (error) { + logger.error("[FORCE_CLOSE] Failed to close local connection:", error); + } + }); + + logger.info(`[FORCE_CLOSE] Closed ${closedCount}/${connectionsBefore} local connections`); + + // STEP 4: BROADCAST TO OTHER SERVERS + const redisExt = instance.configuration.extensions.find((ext) => ext instanceof Redis) as Redis | undefined; + + if (redisExt) { + const commandData: ForceCloseCommandData = { + command: AdminCommand.FORCE_CLOSE, + docId: pageId, + reason, + code, + originServer: instance.configuration.name || "unknown", + timestamp: new Date().toISOString(), + }; + + const receivers = await redisExt.publishAdminCommand(commandData); + logger.info(`[FORCE_CLOSE] Notified ${receivers} other server(s)`); + } else { + logger.warn("[FORCE_CLOSE] Redis extension not found, cannot notify other servers"); + } + + // STEP 5: WAIT FOR OTHER SERVERS + const waitTime = 800; + logger.info(`[FORCE_CLOSE] Waiting ${waitTime}ms for other servers to close connections...`); + await new Promise((resolve) => setTimeout(resolve, waitTime)); + + // STEP 6: UNLOAD DOCUMENT after closing all the connections + logger.info(`[FORCE_CLOSE] Unloading document from memory...`); + + try { + await instance.unloadDocument(document); + logger.info(`[FORCE_CLOSE] Document unloaded successfully ✅`); + } catch (unloadError: unknown) { + logger.error("[FORCE_CLOSE] UNLOAD FAILED:", unloadError); + logger.error(` Error: ${unloadError instanceof Error ? unloadError.message : "unknown"}`); + } + + // STEP 7: VERIFY UNLOAD + const documentAfterUnload = instance.documents.get(pageId); + + if (documentAfterUnload) { + logger.error( + `❌ [FORCE_CLOSE] Document still in memory!, Document ID: ${pageId}, Connections: ${documentAfterUnload.getConnectionsCount()}` + ); + } else { + logger.info(`✅ [FORCE_CLOSE] COMPLETE, Document: ${pageId}, Status: Successfully closed and unloaded`); + } +}; diff --git a/apps/live/src/extensions/index.ts b/apps/live/src/extensions/index.ts new file mode 100644 index 000000000..e82b1fb60 --- /dev/null +++ b/apps/live/src/extensions/index.ts @@ -0,0 +1,5 @@ +import { Database } from "./database"; +import { Logger } from "./logger"; +import { Redis } from "./redis"; + +export const getExtensions = () => [new Logger(), new Database(), new Redis()]; diff --git a/apps/live/src/extensions/logger.ts b/apps/live/src/extensions/logger.ts new file mode 100644 index 000000000..34a4f6a41 --- /dev/null +++ b/apps/live/src/extensions/logger.ts @@ -0,0 +1,13 @@ +import { Logger as HocuspocusLogger } from "@hocuspocus/extension-logger"; +import { logger } from "@plane/logger"; + +export class Logger extends HocuspocusLogger { + constructor() { + super({ + onChange: false, + log: (message) => { + logger.info(message); + }, + }); + } +} diff --git a/apps/live/src/extensions/redis.ts b/apps/live/src/extensions/redis.ts new file mode 100644 index 000000000..66c728f2b --- /dev/null +++ b/apps/live/src/extensions/redis.ts @@ -0,0 +1,134 @@ +import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis"; +import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { redisManager } from "@/redis"; +import { AdminCommand } from "@/types/admin-commands"; +import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands"; + +const getRedisClient = () => { + const redisClient = redisManager.getClient(); + if (!redisClient) { + throw new AppError("Redis client not initialized"); + } + return redisClient; +}; + +export class Redis extends HocuspocusRedis { + private adminHandlers = new Map(); + private readonly ADMIN_CHANNEL = "hocuspocus:admin"; + + constructor() { + super({ redis: getRedisClient() }); + } + + async onConfigure(payload: onConfigurePayload) { + await super.onConfigure(payload); + + // Subscribe to admin channel + await new Promise((resolve, reject) => { + this.sub.subscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Failed to subscribe to admin channel:`, error); + reject(error); + } else { + logger.info(`[Redis] Subscribed to admin channel: ${this.ADMIN_CHANNEL}`); + resolve(); + } + }); + }); + + // Listen for admin messages + this.sub.on("message", this.handleAdminMessage); + logger.info(`[Redis] Attached admin message listener`); + } + + private handleAdminMessage = async (channel: string, message: string) => { + if (channel !== this.ADMIN_CHANNEL) return; + + try { + const data = JSON.parse(message) as AdminCommandData; + + // Validate command + if (!data.command || !Object.values(AdminCommand).includes(data.command as AdminCommand)) { + logger.warn(`[Redis] Invalid admin command received: ${data.command}`); + return; + } + + const handler = this.adminHandlers.get(data.command); + + if (handler) { + await handler(data); + } else { + logger.warn(`[Redis] No handler registered for admin command: ${data.command}`); + } + } catch (error) { + logger.error("[Redis] Error handling admin message:", error); + } + }; + + /** + * Register handler for an admin command + */ + public onAdminCommand( + command: AdminCommand, + handler: AdminCommandHandler + ) { + this.adminHandlers.set(command, handler as AdminCommandHandler); + logger.info(`[Redis] Registered admin command: ${command}`); + } + + /** + * Publish admin command to global channel + */ + public async publishAdminCommand(data: T): Promise { + // Validate command data + if (!data.command || !Object.values(AdminCommand).includes(data.command)) { + throw new AppError(`Invalid admin command: ${data.command}`); + } + + const message = JSON.stringify(data); + const receivers = await this.pub.publish(this.ADMIN_CHANNEL, message); + + logger.info(`[Redis] Published "${data.command}" command, received by ${receivers} server(s)`); + return receivers; + } + + async onDestroy() { + // Unsubscribe from admin channel + await new Promise((resolve) => { + this.sub.unsubscribe(this.ADMIN_CHANNEL, (error: Error) => { + if (error) { + logger.error(`[Redis] Error unsubscribing from admin channel:`, error); + } + resolve(); + }); + }); + + // Remove the message listener to prevent memory leaks + this.sub.removeListener("message", this.handleAdminMessage); + logger.info(`[Redis] Removed admin message listener`); + + await super.onDestroy(); + } + + /** + * Broadcast a message to a document across all servers via Redis. + * Uses empty identifier so ALL servers process the message. + */ + public async broadcastToDocument(documentName: string, payload: unknown): Promise { + const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload); + + const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload); + + const emptyPrefix = Buffer.concat([Buffer.from([0])]); + const channel = this["pubKey"](documentName); + const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]); + + const result = await this.pub.publishBuffer(channel, encodedMessage); + + logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`); + + return result; + } +} diff --git a/apps/live/src/hocuspocus.ts b/apps/live/src/hocuspocus.ts new file mode 100644 index 000000000..1b3b07a7a --- /dev/null +++ b/apps/live/src/hocuspocus.ts @@ -0,0 +1,63 @@ +import { Hocuspocus } from "@hocuspocus/server"; +import { v4 as uuidv4 } from "uuid"; +// env +import { env } from "@/env"; +// extensions +import { getExtensions } from "@/extensions"; +// lib +import { onAuthenticate } from "@/lib/auth"; +import { onStateless } from "@/lib/stateless"; + +export class HocusPocusServerManager { + private static instance: HocusPocusServerManager | null = null; + private server: Hocuspocus | null = null; + // server options + private serverName = env.HOSTNAME || uuidv4(); + + private constructor() { + // Private constructor to prevent direct instantiation + } + + /** + * Get the singleton instance of HocusPocusServerManager + */ + public static getInstance(): HocusPocusServerManager { + if (!HocusPocusServerManager.instance) { + HocusPocusServerManager.instance = new HocusPocusServerManager(); + } + return HocusPocusServerManager.instance; + } + + /** + * Initialize and configure the HocusPocus server + */ + public async initialize(): Promise { + if (this.server) { + return this.server; + } + + this.server = new Hocuspocus({ + name: this.serverName, + onAuthenticate, + onStateless, + extensions: getExtensions(), + debounce: 10000, + }); + + return this.server; + } + + /** + * Get the configured server instance + */ + public getServer(): Hocuspocus | null { + return this.server; + } + + /** + * Reset the singleton instance (useful for testing) + */ + public static resetInstance(): void { + HocusPocusServerManager.instance = null; + } +} diff --git a/apps/live/src/instrument.ts b/apps/live/src/instrument.ts new file mode 100644 index 000000000..a49016eb1 --- /dev/null +++ b/apps/live/src/instrument.ts @@ -0,0 +1,15 @@ +import * as Sentry from "@sentry/node"; +import { nodeProfilingIntegration } from "@sentry/profiling-node"; + +export const setupSentry = () => { + if (process.env.SENTRY_DSN) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + integrations: [Sentry.httpIntegration(), Sentry.expressIntegration(), nodeProfilingIntegration()], + tracesSampleRate: process.env.SENTRY_TRACES_SAMPLE_RATE ? parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE) : 0.5, + environment: process.env.SENTRY_ENVIRONMENT || "development", + release: process.env.APP_VERSION || "v1.0.0", + sendDefaultPii: true, + }); + } +}; diff --git a/apps/live/src/lib/auth-middleware.ts b/apps/live/src/lib/auth-middleware.ts new file mode 100644 index 000000000..8cdfc1b32 --- /dev/null +++ b/apps/live/src/lib/auth-middleware.ts @@ -0,0 +1,50 @@ +import type { Request, Response, NextFunction } from "express"; +import { logger } from "@plane/logger"; +import { env } from "@/env"; + +/** + * Express middleware to verify secret key authentication for protected endpoints + * + * Checks for secret key in headers: + * - x-admin-secret-key (preferred for admin endpoints) + * - live-server-secret-key (for backward compatibility) + * + * @param req - Express request object + * @param res - Express response object + * @param next - Express next function + * + * @example + * ```typescript + * import { Middleware } from "@plane/decorators"; + * import { requireSecretKey } from "@/lib/auth-middleware"; + * + * @Get("/protected") + * @Middleware(requireSecretKey) + * async protectedEndpoint(req: Request, res: Response) { + * // This will only execute if secret key is valid + * } + * ``` + */ +// TODO - Move to hmac +export const requireSecretKey = (req: Request, res: Response, next: NextFunction): void => { + const secretKey = req.headers["live-server-secret-key"]; + + if (!secretKey || secretKey !== env.LIVE_SERVER_SECRET_KEY) { + logger.warn(` + ⚠️ [AUTH] Unauthorized access attempt + Endpoint: ${req.path} + Method: ${req.method} + IP: ${req.ip} + User-Agent: ${req.headers["user-agent"]} + `); + + res.status(401).json({ + error: "Unauthorized", + status: 401, + }); + return; + } + + // Secret key is valid, proceed to the route handler + next(); +}; diff --git a/apps/live/src/lib/auth.ts b/apps/live/src/lib/auth.ts new file mode 100644 index 000000000..a1e82314a --- /dev/null +++ b/apps/live/src/lib/auth.ts @@ -0,0 +1,91 @@ +// plane imports +import type { IncomingHttpHeaders } from "http"; +import type { TUserDetails } from "@plane/editor"; +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +// services +import { UserService } from "@/services/user.service"; +// types +import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; + +/** + * Authenticate the user + * @param requestHeaders - The request headers + * @param context - The context + * @param token - The token + * @returns The authenticated user + */ +export const onAuthenticate = async ({ + requestHeaders, + requestParameters, + context, + token, +}: { + requestHeaders: IncomingHttpHeaders; + context: HocusPocusServerContext; + requestParameters: URLSearchParams; + token: string; +}) => { + let cookie: string | undefined = undefined; + let userId: string | undefined = undefined; + + // Extract cookie (fallback to request headers) and userId from token (for scenarios where + // the cookies are not passed in the request headers) + try { + const parsedToken = JSON.parse(token) as TUserDetails; + userId = parsedToken.id; + cookie = parsedToken.cookie; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "onAuthenticate" }, + }); + logger.error("Token parsing failed, using request headers", appError); + } finally { + // If cookie is still not found, fallback to request headers + if (!cookie) { + cookie = requestHeaders.cookie?.toString(); + } + } + + if (!cookie || !userId) { + const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" }); + logger.error("Credentials not provided", appError); + throw appError; + } + + // set cookie in context, so it can be used throughout the ws connection + context.cookie = cookie ?? requestParameters.get("cookie") ?? ""; + context.documentType = requestParameters.get("documentType")?.toString() as TDocumentTypes; + context.projectId = requestParameters.get("projectId"); + context.userId = userId; + context.workspaceSlug = requestParameters.get("workspaceSlug"); + + return await handleAuthentication({ + cookie: context.cookie, + userId: context.userId, + }); +}; + +export const handleAuthentication = async ({ cookie, userId }: { cookie: string; userId: string }) => { + // fetch current user info + try { + const userService = new UserService(); + const user = await userService.currentUser(cookie); + if (user.id !== userId) { + throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" }); + } + + return { + user: { + id: user.id, + name: user.display_name, + }, + }; + } catch (error) { + const appError = new AppError(error, { + context: { operation: "handleAuthentication" }, + }); + logger.error("Authentication failed", appError); + throw new AppError("Authentication unsuccessful", { code: appError.code }); + } +}; diff --git a/apps/live/src/lib/errors.ts b/apps/live/src/lib/errors.ts new file mode 100644 index 000000000..480d4317b --- /dev/null +++ b/apps/live/src/lib/errors.ts @@ -0,0 +1,73 @@ +import { AxiosError } from "axios"; + +/** + * Application error class that sanitizes and standardizes errors across the app. + * Extracts only essential information from AxiosError to prevent massive log bloat + * and sensitive data leaks (cookies, tokens, etc). + * + * Usage: + * new AppError("Simple error message") + * new AppError("Custom error", { code: "MY_CODE", statusCode: 400 }) + * new AppError(axiosError) // Auto-extracts essential info + * new AppError(anyError) // Works with any error type + */ +export class AppError extends Error { + statusCode?: number; + method?: string; + url?: string; + code?: string; + context?: Record; + + constructor(messageOrError: string | unknown, data?: Partial>) { + // Handle error objects - extract essential info + const error = messageOrError; + + // Already AppError - return immediately for performance (no need to re-process) + if (error instanceof AppError) { + return error; + } + + // Handle string message (simple case like regular Error) + if (typeof messageOrError === "string") { + super(messageOrError); + this.name = "AppError"; + if (data) { + Object.assign(this, data); + } + return; + } + + // AxiosError - extract ONLY essential info (no config, no headers, no cookies) + if (error && typeof error === "object" && "isAxiosError" in error) { + const axiosError = error as AxiosError; + const responseData = axiosError.response?.data as any; + super(responseData?.message || axiosError.message); + this.name = "AppError"; + this.statusCode = axiosError.response?.status; + this.method = axiosError.config?.method?.toUpperCase(); + this.url = axiosError.config?.url; + this.code = axiosError.code; + return; + } + + // DOMException (AbortError from cancelled requests) + if (error instanceof DOMException && error.name === "AbortError") { + super(error.message); + this.name = "AppError"; + this.code = "ABORT_ERROR"; + return; + } + + // Standard Error objects + if (error instanceof Error) { + super(error.message); + this.name = "AppError"; + this.code = error.name; + return; + } + + // Unknown error types - safe fallback + super("Unknown error occurred"); + this.name = "AppError"; + } +} diff --git a/apps/live/src/lib/stateless.ts b/apps/live/src/lib/stateless.ts new file mode 100644 index 000000000..2f74f0e94 --- /dev/null +++ b/apps/live/src/lib/stateless.ts @@ -0,0 +1,13 @@ +import type { onStatelessPayload } from "@hocuspocus/server"; +import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/editor/lib"; + +/** + * Broadcast the client event to all the clients so that they can update their state + * @param param0 + */ +export const onStateless = async ({ payload, document }: onStatelessPayload) => { + const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client; + if (response) { + document.broadcastStateless(response); + } +}; diff --git a/apps/live/src/redis.ts b/apps/live/src/redis.ts new file mode 100644 index 000000000..aac0eb712 --- /dev/null +++ b/apps/live/src/redis.ts @@ -0,0 +1,214 @@ +import Redis from "ioredis"; +import { logger } from "@plane/logger"; +import { env } from "./env"; + +export class RedisManager { + private static instance: RedisManager; + private redisClient: Redis | null = null; + private isConnected: boolean = false; + private connectionPromise: Promise | null = null; + + private constructor() {} + + public static getInstance(): RedisManager { + if (!RedisManager.instance) { + RedisManager.instance = new RedisManager(); + } + return RedisManager.instance; + } + + public async initialize(): Promise { + if (this.redisClient && this.isConnected) { + logger.info("REDIS_MANAGER: client already initialized and connected"); + return; + } + + if (this.connectionPromise) { + logger.info("REDIS_MANAGER: Redis connection already in progress, waiting..."); + await this.connectionPromise; + return; + } + + this.connectionPromise = this.connect(); + await this.connectionPromise; + } + + private getRedisUrl(): string { + const redisUrl = env.REDIS_URL; + const redisHost = env.REDIS_HOST; + const redisPort = env.REDIS_PORT; + + if (redisUrl) { + return redisUrl; + } + + if (redisHost && redisPort && !Number.isNaN(Number(redisPort))) { + return `redis://${redisHost}:${redisPort}`; + } + + return ""; + } + + private async connect(): Promise { + try { + const redisUrl = this.getRedisUrl(); + + if (!redisUrl) { + logger.warn("REDIS_MANAGER: No Redis URL provided, Redis functionality will be disabled"); + this.isConnected = false; + return; + } + + // Configuration optimized for BOTH regular operations AND pub/sub + // HocuspocusRedis uses .duplicate() which inherits these settings + this.redisClient = new Redis(redisUrl, { + lazyConnect: false, // Connect immediately for reliability (duplicates inherit this) + keepAlive: 30000, + connectTimeout: 10000, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, // Keep commands queued during reconnection + retryStrategy: (times: number) => { + // Exponential backoff with max 2 seconds + const delay = Math.min(times * 50, 2000); + logger.info(`REDIS_MANAGER: Reconnection attempt ${times}, delay: ${delay}ms`); + return delay; + }, + }); + + // Set up event listeners + this.redisClient.on("connect", () => { + logger.info("REDIS_MANAGER: Redis client connected"); + this.isConnected = true; + }); + + this.redisClient.on("ready", () => { + logger.info("REDIS_MANAGER: Redis client ready"); + this.isConnected = true; + }); + + this.redisClient.on("error", (error) => { + logger.error("REDIS_MANAGER: Redis client error:", error); + this.isConnected = false; + }); + + this.redisClient.on("close", () => { + logger.warn("REDIS_MANAGER: Redis client connection closed"); + this.isConnected = false; + }); + + this.redisClient.on("reconnecting", () => { + logger.info("REDIS_MANAGER: Redis client reconnecting..."); + this.isConnected = false; + }); + + await this.redisClient.ping(); + logger.info("REDIS_MANAGER: Redis connection test successful"); + } catch (error) { + logger.error("REDIS_MANAGER: Failed to initialize Redis client:", error); + this.isConnected = false; + throw error; + } finally { + this.connectionPromise = null; + } + } + + public getClient(): Redis | null { + if (!this.redisClient || !this.isConnected) { + logger.warn("REDIS_MANAGER: Redis client not available or not connected"); + return null; + } + return this.redisClient; + } + + public isClientConnected(): boolean { + return this.isConnected && this.redisClient !== null; + } + + public async disconnect(): Promise { + if (this.redisClient) { + try { + await this.redisClient.quit(); + logger.info("REDIS_MANAGER: Redis client disconnected gracefully"); + } catch (error) { + logger.error("REDIS_MANAGER: Error disconnecting Redis client:", error); + // Force disconnect if quit fails + this.redisClient.disconnect(); + } finally { + this.redisClient = null; + this.isConnected = false; + } + } + } + + // Convenience methods for common Redis operations + public async set(key: string, value: string, ttl?: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + if (ttl) { + await client.setex(key, ttl, value); + } else { + await client.set(key, value); + } + return true; + } catch (error) { + logger.error(`REDIS_MANAGER: Error setting Redis key ${key}:`, error); + return false; + } + } + + public async get(key: string): Promise { + const client = this.getClient(); + if (!client) return null; + + try { + return await client.get(key); + } catch (error) { + logger.error(`REDIS_MANAGER: Error getting Redis key ${key}:`, error); + return null; + } + } + + public async del(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + await client.del(key); + return true; + } catch (error) { + logger.error(`REDIS_MANAGER: Error deleting Redis key ${key}:`, error); + return false; + } + } + + public async exists(key: string): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.exists(key); + return result === 1; + } catch (error) { + logger.error(`REDIS_MANAGER: Error checking Redis key ${key}:`, error); + return false; + } + } + + public async expire(key: string, ttl: number): Promise { + const client = this.getClient(); + if (!client) return false; + + try { + const result = await client.expire(key, ttl); + return result === 1; + } catch (error) { + logger.error(`REDIS_MANAGER: Error setting expiry for Redis key ${key}:`, error); + return false; + } + } +} + +// Export a default instance for convenience +export const redisManager = RedisManager.getInstance(); diff --git a/apps/live/src/server.ts b/apps/live/src/server.ts index 69d0e642e..1535844fe 100644 --- a/apps/live/src/server.ts +++ b/apps/live/src/server.ts @@ -1,93 +1,79 @@ +import { Server as HttpServer } from "http"; +import { type Hocuspocus } from "@hocuspocus/server"; import compression from "compression"; import cors from "cors"; -import express, { Request, Response } from "express"; +import express, { Express, Request, Response, Router } from "express"; import expressWs from "express-ws"; import helmet from "helmet"; +// plane imports +import { registerController } from "@plane/decorators"; +import { logger, loggerMiddleware } from "@plane/logger"; +// controllers +import { CONTROLLERS } from "@/controllers"; +// env +import { env } from "@/env"; // hocuspocus server -// helpers -import { convertHTMLDocumentToAllFormats } from "@/core/helpers/convert-document.js"; -import { logger, manualLogger } from "@/core/helpers/logger.js"; -import { getHocusPocusServer } from "@/core/hocuspocus-server.js"; -// types -import { TConvertDocumentRequestBody } from "@/core/types/common.js"; +import { HocusPocusServerManager } from "@/hocuspocus"; +// redis +import { redisManager } from "@/redis"; export class Server { - private app: any; - private router: any; - private hocuspocusServer: any; - private serverInstance: any; + private app: Express; + private router: Router; + private hocuspocusServer: Hocuspocus | undefined; + private httpServer: HttpServer | undefined; constructor() { this.app = express(); - this.router = express.Router(); expressWs(this.app); - this.app.set("port", process.env.PORT || 3000); this.setupMiddleware(); - this.setupHocusPocus(); - this.setupRoutes(); + this.router = express.Router(); + this.app.set("port", env.PORT || 3000); + this.app.use(env.LIVE_BASE_PATH, this.router); + } + + public async initialize(): Promise { + try { + await redisManager.initialize(); + logger.info("SERVER: Redis setup completed"); + const manager = HocusPocusServerManager.getInstance(); + this.hocuspocusServer = await manager.initialize(); + logger.info("SERVER: HocusPocus setup completed"); + this.setupRoutes(this.hocuspocusServer); + this.setupNotFoundHandler(); + } catch (error) { + logger.error("SERVER: Failed to initialize live server dependencies:", error); + throw error; + } } private setupMiddleware() { // Security middleware this.app.use(helmet()); // Middleware for response compression - this.app.use(compression({ level: 6, threshold: 5 * 1000 })); + this.app.use(compression({ level: env.COMPRESSION_LEVEL, threshold: env.COMPRESSION_THRESHOLD })); // Logging middleware - this.app.use(logger); + this.app.use(loggerMiddleware); // Body parsing middleware this.app.use(express.json()); this.app.use(express.urlencoded({ extended: true })); // cors middleware - this.app.use(cors()); - this.app.use(process.env.LIVE_BASE_PATH || "/live", this.router); + this.setupCors(); } - private async setupHocusPocus() { - this.hocuspocusServer = await getHocusPocusServer().catch((err) => { - manualLogger.error("Failed to initialize HocusPocusServer:", err); - process.exit(1); - }); + private setupCors() { + const allowedOrigins = env.CORS_ALLOWED_ORIGINS.split(",").map((s) => s.trim()); + this.app.use( + cors({ + origin: allowedOrigins.length > 0 ? allowedOrigins : false, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization", "x-api-key"], + }) + ); } - private setupRoutes() { - this.router.get("/health", (_req: Request, res: Response) => { - res.status(200).json({ status: "OK" }); - }); - - this.router.ws("/collaboration", (ws: any, req: Request) => { - try { - this.hocuspocusServer.handleConnection(ws, req); - } catch (err) { - manualLogger.error("WebSocket connection error:", err); - ws.close(); - } - }); - - this.router.post("/convert-document", (req: Request, res: Response) => { - const { description_html, variant } = req.body as TConvertDocumentRequestBody; - try { - if (description_html === undefined || variant === undefined) { - res.status(400).send({ - message: "Missing required fields", - }); - return; - } - const { description, description_binary } = convertHTMLDocumentToAllFormats({ - document_html: description_html, - variant, - }); - res.status(200).json({ - description, - description_binary, - }); - } catch (error) { - manualLogger.error("Error in /convert-document endpoint:", error); - res.status(500).json({ - message: `Internal server error.`, - }); - } - }); - + private setupNotFoundHandler() { this.app.use((_req: Request, res: Response) => { res.status(404).json({ message: "Not Found", @@ -95,37 +81,41 @@ export class Server { }); } + private setupRoutes(hocuspocusServer: Hocuspocus) { + CONTROLLERS.forEach((controller) => registerController(this.router, controller, [hocuspocusServer])); + } + public listen() { - this.serverInstance = this.app.listen(this.app.get("port"), () => { - manualLogger.info(`Plane Live server has started at port ${this.app.get("port")}`); - }); + this.httpServer = this.app + .listen(this.app.get("port"), () => { + logger.info(`SERVER: Express server has started at port ${this.app.get("port")}`); + }) + .on("error", (err) => { + logger.error("SERVER: Failed to start server:", err); + throw err; + }); } public async destroy() { - // Close the HocusPocus server WebSocket connections - await this.hocuspocusServer.destroy(); - manualLogger.info("HocusPocus server WebSocket connections closed gracefully."); - // Close the Express server - this.serverInstance.close(() => { - manualLogger.info("Express server closed gracefully."); - process.exit(1); - }); + if (this.hocuspocusServer) { + this.hocuspocusServer.closeConnections(); + logger.info("SERVER: HocusPocus connections closed gracefully."); + } + + await redisManager.disconnect(); + logger.info("SERVER: Redis connection closed gracefully."); + + if (this.httpServer) { + await new Promise((resolve, reject) => { + this.httpServer!.close((err) => { + if (err) { + reject(err); + } else { + logger.info("SERVER: Express server closed gracefully."); + resolve(); + } + }); + }); + } } } - -const server = new Server(); -server.listen(); - -// Graceful shutdown on unhandled rejection -process.on("unhandledRejection", async (err: any) => { - manualLogger.info("Unhandled Rejection: ", err); - manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`); - await server.destroy(); -}); - -// Graceful shutdown on uncaught exception -process.on("uncaughtException", async (err: any) => { - manualLogger.info("Uncaught Exception: ", err); - manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`); - await server.destroy(); -}); diff --git a/apps/live/src/core/services/api.service.ts b/apps/live/src/services/api.service.ts similarity index 61% rename from apps/live/src/core/services/api.service.ts rename to apps/live/src/services/api.service.ts index dbef2ae17..8c2cb2e31 100644 --- a/apps/live/src/core/services/api.service.ts +++ b/apps/live/src/services/api.service.ts @@ -1,21 +1,37 @@ import axios, { AxiosInstance } from "axios"; -import { config } from "dotenv"; - -config(); - -export const API_BASE_URL = process.env.API_BASE_URL ?? ""; +import { env } from "@/env"; +import { AppError } from "@/lib/errors"; export abstract class APIService { protected baseURL: string; private axiosInstance: AxiosInstance; + private header: Record = {}; - constructor(baseURL: string) { - this.baseURL = baseURL; + constructor(baseURL?: string) { + this.baseURL = baseURL || env.API_BASE_URL; this.axiosInstance = axios.create({ - baseURL, + baseURL: this.baseURL, withCredentials: true, timeout: 20000, }); + this.setupInterceptors(); + } + + private setupInterceptors() { + this.axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + return Promise.reject(new AppError(error)); + } + ); + } + + setHeader(key: string, value: string) { + this.header[key] = value; + } + + getHeader() { + return this.header; } get(url: string, params = {}, config = {}) { diff --git a/apps/live/src/services/page/core.service.ts b/apps/live/src/services/page/core.service.ts new file mode 100644 index 000000000..ca4d5d28b --- /dev/null +++ b/apps/live/src/services/page/core.service.ts @@ -0,0 +1,119 @@ +import { logger } from "@plane/logger"; +import { TPage } from "@plane/types"; +// services +import { AppError } from "@/lib/errors"; +import { APIService } from "../api.service"; + +export type TPageDescriptionPayload = { + description_binary: string; + description_html: string; + description: object; +}; + +export abstract class PageCoreService extends APIService { + protected abstract basePath: string; + + constructor() { + super(); + } + + async fetchDetails(pageId: string): Promise { + return this.get(`${this.basePath}/pages/${pageId}/`, { + headers: this.getHeader(), + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "fetchDetails", pageId }, + }); + logger.error("Failed to fetch page details", appError); + throw appError; + }); + } + + async fetchDescriptionBinary(pageId: string): Promise { + return this.get(`${this.basePath}/pages/${pageId}/description/`, { + headers: { + ...this.getHeader(), + "Content-Type": "application/octet-stream", + }, + responseType: "arraybuffer", + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "fetchDescriptionBinary", pageId }, + }); + logger.error("Failed to fetch page description binary", appError); + throw appError; + }); + } + + /** + * Updates the title of a page + */ + async updatePageProperties( + pageId: string, + params: { data: Partial; abortSignal?: AbortSignal } + ): Promise { + const { data, abortSignal } = params; + + // Early abort check + if (abortSignal?.aborted) { + throw new AppError(new DOMException("Aborted", "AbortError")); + } + + // Create an abort listener that will reject the pending promise + let abortListener: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + if (abortSignal) { + abortListener = () => { + reject(new AppError(new DOMException("Aborted", "AbortError"))); + }; + abortSignal.addEventListener("abort", abortListener); + } + }); + + try { + return await Promise.race([ + this.patch(`${this.basePath}/pages/${pageId}/`, data, { + headers: this.getHeader(), + signal: abortSignal, + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "updatePageProperties", pageId }, + }); + + if (appError.code === "ABORT_ERROR") { + throw appError; + } + + logger.error("Failed to update page properties", appError); + throw appError; + }), + abortPromise, + ]); + } finally { + // Clean up abort listener + if (abortSignal && abortListener) { + abortSignal.removeEventListener("abort", abortListener); + } + } + } + + async updateDescriptionBinary(pageId: string, data: TPageDescriptionPayload): Promise { + return this.patch(`${this.basePath}/pages/${pageId}/description/`, data, { + headers: this.getHeader(), + }) + .then((response) => response?.data) + .catch((error) => { + const appError = new AppError(error, { + context: { operation: "updateDescriptionBinary", pageId }, + }); + logger.error("Failed to update page description binary", appError); + throw appError; + }); + } +} diff --git a/apps/live/src/services/page/extended.service.ts b/apps/live/src/services/page/extended.service.ts new file mode 100644 index 000000000..29ef316db --- /dev/null +++ b/apps/live/src/services/page/extended.service.ts @@ -0,0 +1,12 @@ +import { PageCoreService } from "./core.service"; + +/** + * This is the extended service for the page service. + * It extends the core service and adds additional functionality. + * Implementation for this is found in the enterprise repository. + */ +export abstract class PageService extends PageCoreService { + constructor() { + super(); + } +} diff --git a/apps/live/src/services/page/handler.ts b/apps/live/src/services/page/handler.ts new file mode 100644 index 000000000..9b2f5adac --- /dev/null +++ b/apps/live/src/services/page/handler.ts @@ -0,0 +1,16 @@ +import { AppError } from "@/lib/errors"; +import type { HocusPocusServerContext, TDocumentTypes } from "@/types"; +// services +import { ProjectPageService } from "./project-page.service"; + +export const getPageService = (documentType: TDocumentTypes, context: HocusPocusServerContext) => { + if (documentType === "project_page") { + return new ProjectPageService({ + workspaceSlug: context.workspaceSlug, + projectId: context.projectId, + cookie: context.cookie, + }); + } + + throw new AppError(`Invalid document type ${documentType} provided.`); +}; diff --git a/apps/live/src/services/page/project-page.service.ts b/apps/live/src/services/page/project-page.service.ts new file mode 100644 index 000000000..89a115627 --- /dev/null +++ b/apps/live/src/services/page/project-page.service.ts @@ -0,0 +1,25 @@ +import { AppError } from "@/lib/errors"; +import { PageService } from "./extended.service"; + +interface ProjectPageServiceParams { + workspaceSlug: string | null; + projectId: string | null; + cookie: string | null; + [key: string]: unknown; +} + +export class ProjectPageService extends PageService { + protected basePath: string; + + constructor(params: ProjectPageServiceParams) { + super(); + const { workspaceSlug, projectId } = params; + if (!workspaceSlug || !projectId) throw new AppError("Missing required fields."); + // validate cookie + if (!params.cookie) throw new AppError("Cookie is required."); + // set cookie + this.setHeader("Cookie", params.cookie); + // set base path + this.basePath = `/api/workspaces/${workspaceSlug}/projects/${projectId}`; + } +} diff --git a/apps/live/src/core/services/user.service.ts b/apps/live/src/services/user.service.ts similarity index 57% rename from apps/live/src/core/services/user.service.ts rename to apps/live/src/services/user.service.ts index 39d200919..272d7543c 100644 --- a/apps/live/src/core/services/user.service.ts +++ b/apps/live/src/services/user.service.ts @@ -1,11 +1,13 @@ // types +import { logger } from "@plane/logger"; import type { IUser } from "@plane/types"; // services -import { API_BASE_URL, APIService } from "@/core/services/api.service.js"; +import { AppError } from "@/lib/errors"; +import { APIService } from "@/services/api.service"; export class UserService extends APIService { constructor() { - super(API_BASE_URL); + super(); } currentUserConfig() { @@ -22,7 +24,11 @@ export class UserService extends APIService { }) .then((response) => response?.data) .catch((error) => { - throw error; + const appError = new AppError(error, { + context: { operation: "currentUser" }, + }); + logger.error("Failed to fetch current user", appError); + throw appError; }); } } diff --git a/apps/live/src/start.ts b/apps/live/src/start.ts new file mode 100644 index 000000000..7929b9b9b --- /dev/null +++ b/apps/live/src/start.ts @@ -0,0 +1,61 @@ +// eslint-disable-next-line import/order +import { setupSentry } from "./instrument"; +setupSentry(); + +import { logger } from "@plane/logger"; +import { AppError } from "@/lib/errors"; +import { Server } from "./server"; + +let server: Server; + +async function startServer() { + server = new Server(); + try { + await server.initialize(); + server.listen(); + } catch (error) { + logger.error("Failed to start server:", error); + process.exit(1); + } +} + +startServer(); + +// Handle process signals +process.on("SIGTERM", async () => { + logger.info("Received SIGTERM signal. Initiating graceful shutdown..."); + try { + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); + } + process.exit(0); +}); + +process.on("SIGINT", async () => { + logger.info("Received SIGINT signal. Killing node process..."); + try { + if (server) { + await server.destroy(); + } + logger.info("Server shut down gracefully"); + } catch (error) { + logger.error("Error during graceful shutdown:", error); + process.exit(1); + } + process.exit(1); +}); + +process.on("unhandledRejection", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNHANDLED_REJECTION]`, error); +}); + +process.on("uncaughtException", (err: Error) => { + const error = new AppError(err); + logger.error(`[UNCAUGHT_EXCEPTION]`, error); +}); diff --git a/apps/live/src/types/admin-commands.ts b/apps/live/src/types/admin-commands.ts new file mode 100644 index 000000000..bd8e5cd59 --- /dev/null +++ b/apps/live/src/types/admin-commands.ts @@ -0,0 +1,143 @@ +/** + * Type-safe admin commands for server-to-server communication + */ + +/** + * Force close error codes - reasons why a document is being force closed + */ +export enum ForceCloseReason { + CRITICAL_ERROR = "critical_error", + MEMORY_LEAK = "memory_leak", + DOCUMENT_TOO_LARGE = "document_too_large", + ADMIN_REQUEST = "admin_request", + SERVER_SHUTDOWN = "server_shutdown", + SECURITY_VIOLATION = "security_violation", + CORRUPTION_DETECTED = "corruption_detected", +} + +/** + * WebSocket close codes + * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code + */ +export enum CloseCode { + /** Normal closure; the connection successfully completed */ + NORMAL = 1000, + /** The endpoint is going away (server shutdown or browser navigating away) */ + GOING_AWAY = 1001, + /** Protocol error */ + PROTOCOL_ERROR = 1002, + /** Unsupported data */ + UNSUPPORTED_DATA = 1003, + /** Reserved (no status code was present) */ + NO_STATUS = 1005, + /** Abnormal closure */ + ABNORMAL = 1006, + /** Invalid frame payload data */ + INVALID_DATA = 1007, + /** Policy violation */ + POLICY_VIOLATION = 1008, + /** Message too big */ + MESSAGE_TOO_BIG = 1009, + /** Client expected extension not negotiated */ + MANDATORY_EXTENSION = 1010, + /** Server encountered unexpected condition */ + INTERNAL_ERROR = 1011, + /** Custom: Force close requested */ + FORCE_CLOSE = 4000, + /** Custom: Document too large */ + DOCUMENT_TOO_LARGE = 4001, + /** Custom: Memory pressure */ + MEMORY_PRESSURE = 4002, + /** Custom: Security violation */ + SECURITY_VIOLATION = 4003, +} + +/** + * Admin command types + */ +export enum AdminCommand { + FORCE_CLOSE = "force_close", + HEALTH_CHECK = "health_check", + RESTART_DOCUMENT = "restart_document", +} + +/** + * Force close command data structure + */ +export interface ForceCloseCommandData { + command: AdminCommand.FORCE_CLOSE; + docId: string; + reason: ForceCloseReason; + code: CloseCode; + originServer: string; + timestamp?: string; +} + +/** + * Health check command data structure + */ +export interface HealthCheckCommandData { + command: AdminCommand.HEALTH_CHECK; + originServer: string; + timestamp: string; +} + +/** + * Union type for all admin commands + */ +export type AdminCommandData = ForceCloseCommandData | HealthCheckCommandData; + +/** + * Client force close message structure (sent to clients via sendStateless) + */ +export interface ClientForceCloseMessage { + type: "force_close"; + reason: ForceCloseReason; + code: CloseCode; + message?: string; + timestamp?: string; +} + +/** + * Admin command handler function type + */ +export type AdminCommandHandler = (data: T) => Promise | void; + +/** + * Type guard to check if data is a ForceCloseCommandData + */ +export function isForceCloseCommand(data: AdminCommandData): data is ForceCloseCommandData { + return data.command === AdminCommand.FORCE_CLOSE; +} + +/** + * Type guard to check if data is a HealthCheckCommandData + */ +export function isHealthCheckCommand(data: AdminCommandData): data is HealthCheckCommandData { + return data.command === AdminCommand.HEALTH_CHECK; +} + +/** + * Validate force close reason + */ +export function isValidForceCloseReason(reason: string): reason is ForceCloseReason { + return Object.values(ForceCloseReason).includes(reason as ForceCloseReason); +} + +/** + * Get human-readable message for force close reason + */ +export function getForceCloseMessage(reason: ForceCloseReason): string { + const messages: Record = { + [ForceCloseReason.CRITICAL_ERROR]: "A critical error occurred. Please refresh the page.", + [ForceCloseReason.MEMORY_LEAK]: "Memory limit exceeded. Please refresh the page.", + [ForceCloseReason.DOCUMENT_TOO_LARGE]: + "Content limit reached and live sync is off. Create a new page or use nested pages to continue syncing.", + [ForceCloseReason.ADMIN_REQUEST]: "Connection closed by administrator. Please try again later.", + [ForceCloseReason.SERVER_SHUTDOWN]: "Server is shutting down. Please reconnect in a moment.", + [ForceCloseReason.SECURITY_VIOLATION]: "Security violation detected. Connection terminated.", + [ForceCloseReason.CORRUPTION_DETECTED]: "Data corruption detected. Please refresh the page.", + }; + + return messages[reason] || "Connection closed. Please refresh the page."; +} diff --git a/apps/live/src/types/index.ts b/apps/live/src/types/index.ts new file mode 100644 index 000000000..6c05fb835 --- /dev/null +++ b/apps/live/src/types/index.ts @@ -0,0 +1,29 @@ +import type { fetchPayload, onLoadDocumentPayload, storePayload } from "@hocuspocus/server"; + +export type TConvertDocumentRequestBody = { + description_html: string; + variant: "rich" | "document"; +}; + +export interface OnLoadDocumentPayloadWithContext extends onLoadDocumentPayload { + context: HocusPocusServerContext; +} + +export interface FetchPayloadWithContext extends fetchPayload { + context: HocusPocusServerContext; +} + +export interface StorePayloadWithContext extends storePayload { + context: HocusPocusServerContext; +} + +export type TDocumentTypes = "project_page"; + +// Additional Hocuspocus types that are not exported from the main package +export type HocusPocusServerContext = { + projectId: string | null; + cookie: string; + documentType: TDocumentTypes; + workspaceSlug: string | null; + userId: string; +}; diff --git a/apps/live/src/utils/broadcast-error.ts b/apps/live/src/utils/broadcast-error.ts new file mode 100644 index 000000000..3dfa9da41 --- /dev/null +++ b/apps/live/src/utils/broadcast-error.ts @@ -0,0 +1,38 @@ +import { type Hocuspocus } from "@hocuspocus/server"; +import { createRealtimeEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types"; +import { broadcastMessageToPage } from "./broadcast-message"; + +// Helper to broadcast error to frontend +export const broadcastError = async ( + hocuspocusServerInstance: Hocuspocus, + pageId: string, + errorMessage: string, + errorType: "fetch" | "store", + context: FetchPayloadWithContext["context"] | StorePayloadWithContext["context"], + errorCode?: "content_too_large" | "page_locked" | "page_archived", + shouldDisconnect?: boolean +) => { + try { + const errorEvent = createRealtimeEvent({ + action: "error", + page_id: pageId, + parent_id: undefined, + descendants_ids: [], + data: { + error_message: errorMessage, + error_type: errorType, + error_code: errorCode, + should_disconnect: shouldDisconnect, + user_id: context.userId || "", + }, + workspace_slug: context.workspaceSlug || "", + user_id: context.userId || "", + }); + + await broadcastMessageToPage(hocuspocusServerInstance, pageId, errorEvent); + } catch (broadcastError) { + logger.error("Error broadcasting error message to frontend:", broadcastError); + } +}; diff --git a/apps/live/src/utils/broadcast-message.ts b/apps/live/src/utils/broadcast-message.ts new file mode 100644 index 000000000..7c9a3ced6 --- /dev/null +++ b/apps/live/src/utils/broadcast-message.ts @@ -0,0 +1,34 @@ +import { Hocuspocus } from "@hocuspocus/server"; +import { BroadcastedEvent } from "@plane/editor"; +import { logger } from "@plane/logger"; +import { Redis } from "@/extensions/redis"; +import { AppError } from "@/lib/errors"; + +export const broadcastMessageToPage = async ( + hocuspocusServerInstance: Hocuspocus, + documentName: string, + eventData: BroadcastedEvent +): Promise => { + if (!hocuspocusServerInstance || !hocuspocusServerInstance.documents) { + const appError = new AppError("HocusPocus server not available or initialized", { + context: { operation: "broadcastMessageToPage", documentName }, + }); + logger.error("Error while broadcasting message:", appError); + return false; + } + + const redisExtension = hocuspocusServerInstance.configuration.extensions.find((ext) => ext instanceof Redis); + + if (!redisExtension) { + logger.error("BROADCAST_MESSAGE_TO_PAGE: Redis extension not found"); + return false; + } + + try { + await redisExtension.broadcastToDocument(documentName, eventData); + return true; + } catch (error) { + logger.error(`BROADCAST_MESSAGE_TO_PAGE: Error broadcasting to ${documentName}:`, error); + return false; + } +}; diff --git a/apps/live/src/utils/document.ts b/apps/live/src/utils/document.ts new file mode 100644 index 000000000..318a506e0 --- /dev/null +++ b/apps/live/src/utils/document.ts @@ -0,0 +1,21 @@ +export const generateTitleProsemirrorJson = (text: string) => { + return { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 1 }, + ...(text + ? { + content: [ + { + type: "text", + text, + }, + ], + } + : {}), + }, + ], + }; +}; diff --git a/apps/live/src/utils/index.ts b/apps/live/src/utils/index.ts new file mode 100644 index 000000000..fe6d89c0e --- /dev/null +++ b/apps/live/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./document"; diff --git a/apps/live/tsconfig.json b/apps/live/tsconfig.json index 57d47a3d8..cdfe5996e 100644 --- a/apps/live/tsconfig.json +++ b/apps/live/tsconfig.json @@ -19,8 +19,9 @@ "inlineSources": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, - "sourceRoot": "/" + "sourceRoot": "/", + "types": ["node"] }, "include": ["src/**/*.ts", "tsdown.config.ts"], - "exclude": ["./dist", "./build", "./node_modules"] + "exclude": ["./dist", "./build", "./node_modules", "**/*.d.ts"] } diff --git a/apps/live/tsdown.config.ts b/apps/live/tsdown.config.ts index 2b97503a6..d8c788263 100644 --- a/apps/live/tsdown.config.ts +++ b/apps/live/tsdown.config.ts @@ -1,7 +1,10 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/server.ts"], + entry: ["src/start.ts"], outDir: "dist", - format: ["esm", "cjs"], + format: ["esm"], + dts: false, + clean: true, + sourcemap: false, }); diff --git a/apps/space/.eslintrc.js b/apps/space/.eslintrc.js index 1662fabf7..a0bc76d5d 100644 --- a/apps/space/.eslintrc.js +++ b/apps/space/.eslintrc.js @@ -1,4 +1,18 @@ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], + rules: { + "no-duplicate-imports": "off", + "import/no-duplicates": ["error", { "prefer-inline": false }], + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-import-type-side-effects": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }, + ], + }, }; diff --git a/apps/space/app/[workspaceSlug]/[projectId]/page.ts b/apps/space/app/[workspaceSlug]/[projectId]/page.ts index 0badbe64f..94c4152e4 100644 --- a/apps/space/app/[workspaceSlug]/[projectId]/page.ts +++ b/apps/space/app/[workspaceSlug]/[projectId]/page.ts @@ -1,7 +1,7 @@ import { notFound, redirect } from "next/navigation"; // plane imports import { SitesProjectPublishService } from "@plane/services"; -import { TProjectPublishSettings } from "@plane/types"; +import type { TProjectPublishSettings } from "@plane/types"; const publishService = new SitesProjectPublishService(); diff --git a/apps/space/app/error.tsx b/apps/space/app/error.tsx index 1f9e1ca19..98fe1cd0a 100644 --- a/apps/space/app/error.tsx +++ b/apps/space/app/error.tsx @@ -1,7 +1,7 @@ "use client"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; const ErrorPage = () => { const handleRetry = () => { diff --git a/apps/space/app/layout.tsx b/apps/space/app/layout.tsx index d0c7435da..05d54bd07 100644 --- a/apps/space/app/layout.tsx +++ b/apps/space/app/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; // helpers import { SPACE_BASE_PATH } from "@plane/constants"; // styles diff --git a/apps/space/app/page.tsx b/apps/space/app/page.tsx index a75275e0d..f544bcb10 100644 --- a/apps/space/app/page.tsx +++ b/apps/space/app/page.tsx @@ -1,6 +1,9 @@ "use client"; - +import { useEffect } from "react"; import { observer } from "mobx-react"; +import { useSearchParams, useRouter } from "next/navigation"; +// plane imports +import { isValidNextPath } from "@plane/utils"; // components import { UserLoggedIn } from "@/components/account/user-logged-in"; import { LogoSpinner } from "@/components/common/logo-spinner"; @@ -10,6 +13,15 @@ import { useUser } from "@/hooks/store/use-user"; const HomePage = observer(() => { const { data: currentUser, isAuthenticated, isInitializing } = useUser(); + const searchParams = useSearchParams(); + const router = useRouter(); + const nextPath = searchParams.get("next_path"); + + useEffect(() => { + if (currentUser && isAuthenticated && nextPath && isValidNextPath(nextPath)) { + router.replace(nextPath); + } + }, [currentUser, isAuthenticated, nextPath, router]); if (isInitializing) return ( @@ -18,7 +30,16 @@ const HomePage = observer(() => {
); - if (currentUser && isAuthenticated) return ; + if (currentUser && isAuthenticated) { + if (nextPath && isValidNextPath(nextPath)) { + return ( +
+ +
+ ); + } + return ; + } return ; }); diff --git a/apps/space/app/provider.tsx b/apps/space/app/provider.tsx index af4940e24..4a0a483ad 100644 --- a/apps/space/app/provider.tsx +++ b/apps/space/app/provider.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, ReactNode } from "react"; +import type { ReactNode, FC } from "react"; import { ThemeProvider } from "next-themes"; // components import { TranslationProvider } from "@plane/i18n"; diff --git a/apps/space/ce/components/editor/embeds/mentions/root.tsx b/apps/space/ce/components/editor/embeds/mentions/root.tsx index 16e21f848..23f15fe27 100644 --- a/apps/space/ce/components/editor/embeds/mentions/root.tsx +++ b/apps/space/ce/components/editor/embeds/mentions/root.tsx @@ -1,4 +1,4 @@ // plane editor -import { TMentionComponentProps } from "@plane/editor"; +import type { TMentionComponentProps } from "@plane/editor"; export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/apps/space/ce/hooks/use-editor-flagging.ts b/apps/space/ce/hooks/use-editor-flagging.ts index 7b4bc38c3..9e80c35aa 100644 --- a/apps/space/ce/hooks/use-editor-flagging.ts +++ b/apps/space/ce/hooks/use-editor-flagging.ts @@ -1,5 +1,5 @@ // editor -import { TExtensions } from "@plane/editor"; +import type { TExtensions } from "@plane/editor"; export type TEditorFlaggingHookReturnType = { document: { diff --git a/apps/space/core/components/account/auth-forms/auth-banner.tsx b/apps/space/core/components/account/auth-forms/auth-banner.tsx index 20b9ca819..30cd6e093 100644 --- a/apps/space/core/components/account/auth-forms/auth-banner.tsx +++ b/apps/space/core/components/account/auth-forms/auth-banner.tsx @@ -1,9 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { Info, X } from "lucide-react"; // helpers -import { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; type TAuthBanner = { bannerData: TAuthErrorInfo | undefined; diff --git a/apps/space/core/components/account/auth-forms/auth-header.tsx b/apps/space/core/components/account/auth-forms/auth-header.tsx index f75dccecf..7996feedf 100644 --- a/apps/space/core/components/account/auth-forms/auth-header.tsx +++ b/apps/space/core/components/account/auth-forms/auth-header.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // helpers import { EAuthModes } from "@/types/auth"; diff --git a/apps/space/core/components/account/auth-forms/auth-root.tsx b/apps/space/core/components/account/auth-forms/auth-root.tsx index e71a3a08d..86452a3c6 100644 --- a/apps/space/core/components/account/auth-forms/auth-root.tsx +++ b/apps/space/core/components/account/auth-forms/auth-root.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import { useSearchParams } from "next/navigation"; @@ -8,16 +9,12 @@ import { useTheme } from "next-themes"; // plane imports import { API_BASE_URL } from "@plane/constants"; import { SitesAuthService } from "@plane/services"; -import { IEmailCheckData } from "@plane/types"; +import type { IEmailCheckData } from "@plane/types"; import { OAuthOptions } from "@plane/ui"; // components // helpers -import { - EAuthenticationErrorCodes, - EErrorAlertType, - TAuthErrorInfo, - authErrorHandler, -} from "@/helpers/authentication.helper"; +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { EErrorAlertType, authErrorHandler, EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; // hooks import { useInstance } from "@/hooks/store/use-instance"; // types diff --git a/apps/space/core/components/account/auth-forms/email.tsx b/apps/space/core/components/account/auth-forms/email.tsx index 6fb08ff7a..7abaef6f9 100644 --- a/apps/space/core/components/account/auth-forms/email.tsx +++ b/apps/space/core/components/account/auth-forms/email.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, FormEvent, useMemo, useRef, useState } from "react"; +import type { FC, FormEvent } from "react"; +import { useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons import { CircleAlert, XCircle } from "lucide-react"; // types -import { IEmailCheckData } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import type { IEmailCheckData } from "@plane/types"; // ui -import { Button, Input, Spinner } from "@plane/ui"; +import { Input, Spinner } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; import { checkEmailValidity } from "@/helpers/string.helper"; diff --git a/apps/space/core/components/account/auth-forms/password.tsx b/apps/space/core/components/account/auth-forms/password.tsx index 9b3397cd5..acd081bfc 100644 --- a/apps/space/core/components/account/auth-forms/password.tsx +++ b/apps/space/core/components/account/auth-forms/password.tsx @@ -5,8 +5,9 @@ import { observer } from "mobx-react"; import { Eye, EyeOff, XCircle } from "lucide-react"; // plane imports import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; +import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; -import { Button, Input, Spinner, PasswordStrengthIndicator } from "@plane/ui"; +import { Input, Spinner, PasswordStrengthIndicator } from "@plane/ui"; import { getPasswordStrength } from "@plane/utils"; // types import { EAuthModes, EAuthSteps } from "@/types/auth"; diff --git a/apps/space/core/components/account/auth-forms/unique-code.tsx b/apps/space/core/components/account/auth-forms/unique-code.tsx index ef96fb280..8e691cf8e 100644 --- a/apps/space/core/components/account/auth-forms/unique-code.tsx +++ b/apps/space/core/components/account/auth-forms/unique-code.tsx @@ -4,8 +4,9 @@ import React, { useEffect, useState } from "react"; import { CircleCheck, XCircle } from "lucide-react"; // plane imports import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; -import { Button, Input, Spinner } from "@plane/ui"; +import { Input, Spinner } from "@plane/ui"; // hooks import useTimer from "@/hooks/use-timer"; // types diff --git a/apps/space/core/components/account/terms-and-conditions.tsx b/apps/space/core/components/account/terms-and-conditions.tsx index 8725eb7b2..09611d926 100644 --- a/apps/space/core/components/account/terms-and-conditions.tsx +++ b/apps/space/core/components/account/terms-and-conditions.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import Link from "next/link"; type Props = { diff --git a/apps/space/core/components/common/powered-by.tsx b/apps/space/core/components/common/powered-by.tsx index be4e5bf73..653c150f9 100644 --- a/apps/space/core/components/common/powered-by.tsx +++ b/apps/space/core/components/common/powered-by.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { WEBSITE_URL } from "@plane/constants"; // assets import { PlaneLogo } from "@plane/propel/icons"; diff --git a/apps/space/core/components/common/project-logo.tsx b/apps/space/core/components/common/project-logo.tsx index 2dfc04b38..bea8e213f 100644 --- a/apps/space/core/components/common/project-logo.tsx +++ b/apps/space/core/components/common/project-logo.tsx @@ -1,5 +1,5 @@ // types -import { TLogoProps } from "@plane/types"; +import type { TLogoProps } from "@plane/types"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/space/core/components/editor/embeds/mentions/root.tsx b/apps/space/core/components/editor/embeds/mentions/root.tsx index 9ea5ef6fb..95149b926 100644 --- a/apps/space/core/components/editor/embeds/mentions/root.tsx +++ b/apps/space/core/components/editor/embeds/mentions/root.tsx @@ -1,5 +1,5 @@ // plane editor -import { TMentionComponentProps } from "@plane/editor"; +import type { TMentionComponentProps } from "@plane/editor"; // plane web components import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor"; // local components diff --git a/apps/space/core/components/editor/lite-text-editor.tsx b/apps/space/core/components/editor/lite-text-editor.tsx index a9ff57a82..5b33b5811 100644 --- a/apps/space/core/components/editor/lite-text-editor.tsx +++ b/apps/space/core/components/editor/lite-text-editor.tsx @@ -1,6 +1,7 @@ import React from "react"; // plane imports -import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor"; +import { LiteTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor"; import type { MakeOptional } from "@plane/types"; import { cn, isCommentEmpty } from "@plane/utils"; // helpers diff --git a/apps/space/core/components/editor/rich-text-editor.tsx b/apps/space/core/components/editor/rich-text-editor.tsx index c058d3e88..1d48d7da2 100644 --- a/apps/space/core/components/editor/rich-text-editor.tsx +++ b/apps/space/core/components/editor/rich-text-editor.tsx @@ -1,6 +1,7 @@ import React, { forwardRef } from "react"; // plane imports -import { type EditorRefApi, type IRichTextEditorProps, RichTextEditorWithRef, type TFileHandler } from "@plane/editor"; +import { RichTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor"; import type { MakeOptional } from "@plane/types"; // helpers import { getEditorFileHandlers } from "@/helpers/editor.helper"; diff --git a/apps/space/core/components/editor/toolbar.tsx b/apps/space/core/components/editor/toolbar.tsx index 48fec23ea..bb6347f42 100644 --- a/apps/space/core/components/editor/toolbar.tsx +++ b/apps/space/core/components/editor/toolbar.tsx @@ -2,9 +2,10 @@ import React, { useEffect, useState, useCallback } from "react"; // plane imports -import { TOOLBAR_ITEMS, type ToolbarMenuItem, type EditorRefApi } from "@plane/editor"; +import { TOOLBAR_ITEMS } from "@plane/editor"; +import type { ToolbarMenuItem, EditorRefApi } from "@plane/editor"; +import { Button } from "@plane/propel/button"; import { Tooltip } from "@plane/propel/tooltip"; -import { Button } from "@plane/ui"; import { cn } from "@plane/utils"; type Props = { diff --git a/apps/space/core/components/instance/instance-failure-view.tsx b/apps/space/core/components/instance/instance-failure-view.tsx index ed4c36f35..b1190285f 100644 --- a/apps/space/core/components/instance/instance-failure-view.tsx +++ b/apps/space/core/components/instance/instance-failure-view.tsx @@ -1,9 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import Image from "next/image"; import { useTheme } from "next-themes"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // assets import InstanceFailureDarkImage from "public/instance/instance-failure-dark.svg"; import InstanceFailureImage from "public/instance/instance-failure.svg"; diff --git a/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx index d9d03de15..cb542face 100644 --- a/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/filters-list.tsx @@ -4,7 +4,7 @@ import { observer } from "mobx-react"; import { X } from "lucide-react"; // types import { useTranslation } from "@plane/i18n"; -import { TFilters } from "@/types/issue"; +import type { TFilters } from "@/types/issue"; // components import { AppliedPriorityFilters } from "./priority"; import { AppliedStateFilters } from "./state"; diff --git a/apps/space/core/components/issues/filters/applied-filters/label.tsx b/apps/space/core/components/issues/filters/applied-filters/label.tsx index 86f65f867..5abbd54ba 100644 --- a/apps/space/core/components/issues/filters/applied-filters/label.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/label.tsx @@ -2,7 +2,7 @@ import { X } from "lucide-react"; // types -import { IIssueLabel } from "@/types/issue"; +import type { IIssueLabel } from "@/types/issue"; type Props = { handleRemove: (val: string) => void; diff --git a/apps/space/core/components/issues/filters/applied-filters/priority.tsx b/apps/space/core/components/issues/filters/applied-filters/priority.tsx index 7fdf900bb..a687cb67c 100644 --- a/apps/space/core/components/issues/filters/applied-filters/priority.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/priority.tsx @@ -1,7 +1,8 @@ "use client"; import { X } from "lucide-react"; -import { PriorityIcon, type TIssuePriorities } from "@plane/propel/icons"; +import { PriorityIcon } from "@plane/propel/icons"; +import type { TIssuePriorities } from "@plane/propel/icons"; type Props = { handleRemove: (val: string) => void; diff --git a/apps/space/core/components/issues/filters/applied-filters/root.tsx b/apps/space/core/components/issues/filters/applied-filters/root.tsx index c509ee3ae..f67749f99 100644 --- a/apps/space/core/components/issues/filters/applied-filters/root.tsx +++ b/apps/space/core/components/issues/filters/applied-filters/root.tsx @@ -1,7 +1,8 @@ "use client"; -import { FC, useCallback } from "react"; -import cloneDeep from "lodash/cloneDeep"; +import type { FC } from "react"; +import { useCallback } from "react"; +import { cloneDeep } from "lodash-es"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; // hooks diff --git a/apps/space/core/components/issues/filters/helpers/dropdown.tsx b/apps/space/core/components/issues/filters/helpers/dropdown.tsx index a5e257e07..e9f025b2b 100644 --- a/apps/space/core/components/issues/filters/helpers/dropdown.tsx +++ b/apps/space/core/components/issues/filters/helpers/dropdown.tsx @@ -1,11 +1,11 @@ "use client"; import React, { Fragment, useState } from "react"; -import { Placement } from "@popperjs/core"; +import type { Placement } from "@popperjs/core"; import { usePopper } from "react-popper"; import { Popover, Transition } from "@headlessui/react"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; type Props = { children: React.ReactNode; diff --git a/apps/space/core/components/issues/filters/root.tsx b/apps/space/core/components/issues/filters/root.tsx index 7e6dcd803..8899a3378 100644 --- a/apps/space/core/components/issues/filters/root.tsx +++ b/apps/space/core/components/issues/filters/root.tsx @@ -1,7 +1,8 @@ "use client"; -import { FC, useCallback } from "react"; -import cloneDeep from "lodash/cloneDeep"; +import type { FC } from "react"; +import { useCallback } from "react"; +import { cloneDeep } from "lodash-es"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; // constants @@ -14,7 +15,7 @@ import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useIssueFilter } from "@/hooks/store/use-issue-filter"; // types -import { TIssueQueryFilters } from "@/types/issue"; +import type { TIssueQueryFilters } from "@/types/issue"; type IssueFiltersDropdownProps = { anchor: string; diff --git a/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 9dd7f898a..b65b93924 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -1,10 +1,10 @@ "use client"; import { useCallback, useMemo, useRef } from "react"; -import debounce from "lodash/debounce"; +import { debounce } from "lodash-es"; import { observer } from "mobx-react"; // types -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // components import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; // hooks diff --git a/apps/space/core/components/issues/issue-layouts/kanban/block.tsx b/apps/space/core/components/issues/issue-layouts/kanban/block.tsx index 7511d9aaf..e98502b3f 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/block.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/block.tsx @@ -1,12 +1,12 @@ "use client"; -import { MutableRefObject } from "react"; +import type { MutableRefObject } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; // plane types import { Tooltip } from "@plane/propel/tooltip"; -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // plane ui // plane utils import { cn } from "@plane/utils"; @@ -18,7 +18,7 @@ import { queryParamGenerator } from "@/helpers/query-param-generator"; import { usePublish } from "@/hooks/store/publish"; import { useIssueDetails } from "@/hooks/store/use-issue-details"; // -import { IIssue } from "@/types/issue"; +import type { IIssue } from "@/types/issue"; import { IssueProperties } from "../properties/all-properties"; import { getIssueBlockId } from "../utils"; import { BlockReactions } from "./block-reactions"; diff --git a/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx b/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx index c0a58325b..c5cea19cc 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,7 +1,7 @@ -import { MutableRefObject } from "react"; +import type { MutableRefObject } from "react"; import { observer } from "mobx-react"; //types -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // components import { KanbanIssueBlock } from "./block"; diff --git a/apps/space/core/components/issues/issue-layouts/kanban/default.tsx b/apps/space/core/components/issues/issue-layouts/kanban/default.tsx index db48e6e0f..e5e622e8a 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/default.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/default.tsx @@ -1,8 +1,8 @@ -import { MutableRefObject } from "react"; -import isNil from "lodash/isNil"; +import type { MutableRefObject } from "react"; +import { isNil } from "lodash-es"; import { observer } from "mobx-react"; // types -import { +import type { GroupByColumnTypes, IGroupByColumn, TGroupedIssues, diff --git a/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index a36d9f922..5f56b9c2d 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -1,10 +1,11 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { Circle } from "lucide-react"; // types -import { TIssueGroupByOptions } from "@plane/types"; +import type { TIssueGroupByOptions } from "@plane/types"; interface IHeaderGroupByCard { groupBy: TIssueGroupByOptions | undefined; diff --git a/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx index 2e91624d1..fd7ba5f0d 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -1,4 +1,5 @@ -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { Circle, ChevronDown, ChevronUp } from "lucide-react"; // mobx diff --git a/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx b/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx index 26383006e..7fbcc5ae6 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -1,6 +1,7 @@ "use client"; -import { MutableRefObject, forwardRef, useCallback, useRef, useState } from "react"; +import type { MutableRefObject } from "react"; +import { forwardRef, useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; //types import type { diff --git a/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx b/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx index a25d19c14..2799ee283 100644 --- a/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/apps/space/core/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -1,7 +1,8 @@ -import { MutableRefObject, useState } from "react"; +import type { MutableRefObject } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // types -import { +import type { GroupByColumnTypes, IGroupByColumn, TGroupedIssues, diff --git a/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx b/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx index b810452da..c3d498e31 100644 --- a/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx +++ b/apps/space/core/components/issues/issue-layouts/list/base-list-root.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; // types -import { IIssueDisplayProperties, TGroupedIssues } from "@plane/types"; +import type { IIssueDisplayProperties, TGroupedIssues } from "@plane/types"; // constants // components import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC"; diff --git a/apps/space/core/components/issues/issue-layouts/list/block.tsx b/apps/space/core/components/issues/issue-layouts/list/block.tsx index a56c3d5b9..522d46a9e 100644 --- a/apps/space/core/components/issues/issue-layouts/list/block.tsx +++ b/apps/space/core/components/issues/issue-layouts/list/block.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { useParams, useSearchParams } from "next/navigation"; // plane types import { Tooltip } from "@plane/propel/tooltip"; -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // plane ui // plane utils import { cn } from "@plane/utils"; diff --git a/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx b/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx index bb25e5c16..bf1b202f5 100644 --- a/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx +++ b/apps/space/core/components/issues/issue-layouts/list/blocks-list.tsx @@ -1,6 +1,6 @@ -import { FC, MutableRefObject } from "react"; +import type { FC, MutableRefObject } from "react"; // types -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; import { IssueBlock } from "./block"; interface Props { diff --git a/apps/space/core/components/issues/issue-layouts/list/default.tsx b/apps/space/core/components/issues/issue-layouts/list/default.tsx index 4beee971a..a10333a61 100644 --- a/apps/space/core/components/issues/issue-layouts/list/default.tsx +++ b/apps/space/core/components/issues/issue-layouts/list/default.tsx @@ -1,7 +1,7 @@ import { useRef } from "react"; import { observer } from "mobx-react"; // types -import { +import type { GroupByColumnTypes, TGroupedIssues, IIssueDisplayProperties, diff --git a/apps/space/core/components/issues/issue-layouts/list/list-group.tsx b/apps/space/core/components/issues/issue-layouts/list/list-group.tsx index 0a8c5ebb0..f62cd0232 100644 --- a/apps/space/core/components/issues/issue-layouts/list/list-group.tsx +++ b/apps/space/core/components/issues/issue-layouts/list/list-group.tsx @@ -1,10 +1,17 @@ "use client"; -import { Fragment, MutableRefObject, forwardRef, useRef, useState } from "react"; +import type { MutableRefObject } from "react"; +import { Fragment, forwardRef, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // plane types -import { IGroupByColumn, TIssueGroupByOptions, IIssueDisplayProperties, TPaginationData, TLoader } from "@plane/types"; +import type { + IGroupByColumn, + TIssueGroupByOptions, + IIssueDisplayProperties, + TPaginationData, + TLoader, +} from "@plane/types"; // plane utils import { cn } from "@plane/utils"; // hooks diff --git a/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx b/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx index e8a8ead17..1162331d0 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -1,7 +1,8 @@ "use client"; import { observer } from "mobx-react"; -import { Layers, Link, Paperclip } from "lucide-react"; +import { Link, Paperclip } from "lucide-react"; +import { ViewsIcon } from "@plane/propel/icons"; // plane imports import { Tooltip } from "@plane/propel/tooltip"; import type { IIssueDisplayProperties } from "@plane/types"; @@ -11,7 +12,7 @@ import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with // helpers import { getDate } from "@/helpers/date-time.helper"; //// hooks -import { IIssue } from "@/types/issue"; +import type { IIssue } from "@/types/issue"; import { IssueBlockCycle } from "./cycle"; import { IssueBlockDate } from "./due-date"; import { IssueBlockLabels } from "./labels"; @@ -142,7 +143,7 @@ export const IssueProperties: React.FC = observer((props) => { } )} > - +
{issue.sub_issues_count}
diff --git a/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx index c58badcf9..0df14f1ed 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/cycle.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; // plane ui -import { ContrastIcon } from "@plane/propel/icons"; +import { CycleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; // plane utils import { cn } from "@plane/utils"; @@ -28,7 +28,7 @@ export const IssueBlockCycle = observer(({ cycleId, shouldShowBorder = true }: P )} >
- +
{cycle?.name ?? "No Cycle"}
diff --git a/apps/space/core/components/issues/issue-layouts/properties/member.tsx b/apps/space/core/components/issues/issue-layouts/properties/member.tsx index 9ae3314f1..a5baae8a3 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/member.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/member.tsx @@ -2,7 +2,8 @@ import { observer } from "mobx-react"; // icons -import { LucideIcon, Users } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Users } from "lucide-react"; // plane ui import { Avatar, AvatarGroup } from "@plane/ui"; // plane utils @@ -10,7 +11,7 @@ import { cn } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; // -import { TPublicMember } from "@/types/member"; +import type { TPublicMember } from "@/types/member"; type Props = { memberIds: string[]; diff --git a/apps/space/core/components/issues/issue-layouts/properties/modules.tsx b/apps/space/core/components/issues/issue-layouts/properties/modules.tsx index c5e250f08..29c7a4cee 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/modules.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/modules.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; // plane ui -import { DiceIcon } from "@plane/propel/icons"; +import { ModuleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; // plane utils import { cn } from "@plane/utils"; @@ -32,7 +32,7 @@ export const IssueBlockModules = observer(({ moduleIds, shouldShowBorder = true })} >
- +
{modules?.[0]?.name ?? "No Modules"}
diff --git a/apps/space/core/components/issues/issue-layouts/properties/priority.tsx b/apps/space/core/components/issues/issue-layouts/properties/priority.tsx index 8514c0595..720d9b468 100644 --- a/apps/space/core/components/issues/issue-layouts/properties/priority.tsx +++ b/apps/space/core/components/issues/issue-layouts/properties/priority.tsx @@ -5,7 +5,7 @@ import { useTranslation } from "@plane/i18n"; // types import { PriorityIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { TIssuePriorities } from "@plane/types"; +import type { TIssuePriorities } from "@plane/types"; // constants import { cn, getIssuePriorityFilters } from "@plane/utils"; diff --git a/apps/space/core/components/issues/issue-layouts/root.tsx b/apps/space/core/components/issues/issue-layouts/root.tsx index 9dad67aa3..3f9ee8d6a 100644 --- a/apps/space/core/components/issues/issue-layouts/root.tsx +++ b/apps/space/core/components/issues/issue-layouts/root.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useEffect } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // components diff --git a/apps/space/core/components/issues/issue-layouts/utils.tsx b/apps/space/core/components/issues/issue-layouts/utils.tsx index 4733dc78a..f36a76c21 100644 --- a/apps/space/core/components/issues/issue-layouts/utils.tsx +++ b/apps/space/core/components/issues/issue-layouts/utils.tsx @@ -1,11 +1,10 @@ "use client"; -import isNil from "lodash/isNil"; -import { ContrastIcon } from "lucide-react"; +import { isNil } from "lodash-es"; // types import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants"; -import { CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; -import { +import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import type { GroupByColumnTypes, IGroupByColumn, TCycleGroups, @@ -17,11 +16,11 @@ import { Avatar } from "@plane/ui"; // components // constants // stores -import { ICycleStore } from "@/store/cycle.store"; -import { IIssueLabelStore } from "@/store/label.store"; -import { IIssueMemberStore } from "@/store/members.store"; -import { IIssueModuleStore } from "@/store/module.store"; -import { IStateStore } from "@/store/state.store"; +import type { ICycleStore } from "@/store/cycle.store"; +import type { IIssueLabelStore } from "@/store/label.store"; +import type { IIssueMemberStore } from "@/store/members.store"; +import type { IIssueModuleStore } from "@/store/module.store"; +import type { IStateStore } from "@/store/state.store"; export const HIGHLIGHT_CLASS = "highlight"; export const HIGHLIGHT_WITH_LINE = "highlight-with-line"; @@ -76,7 +75,7 @@ const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined cycleGroups.push({ id: "None", name: "None", - icon: , + icon: , payload: { cycle_id: null }, }); @@ -95,14 +94,14 @@ const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | un moduleGroups.push({ id: moduleInfo.id, name: moduleInfo.name, - icon: , + icon: , payload: { module_ids: [moduleInfo.id] }, }); }) as any; moduleGroups.push({ id: "None", name: "None", - icon: , + icon: , payload: { module_ids: [] }, }); diff --git a/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx b/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx index 3bc0a610c..159b92a4d 100644 --- a/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx +++ b/apps/space/core/components/issues/issue-layouts/with-display-properties-HOC.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; // plane imports import type { IIssueDisplayProperties } from "@plane/types"; diff --git a/apps/space/core/components/issues/navbar/controls.tsx b/apps/space/core/components/issues/navbar/controls.tsx index 65eb73d37..0616d8b5d 100644 --- a/apps/space/core/components/issues/navbar/controls.tsx +++ b/apps/space/core/components/issues/navbar/controls.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, FC } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; import { useRouter, useSearchParams } from "next/navigation"; // components diff --git a/apps/space/core/components/issues/navbar/layout-icon.tsx b/apps/space/core/components/issues/navbar/layout-icon.tsx index cf3b76093..e9aed2b26 100644 --- a/apps/space/core/components/issues/navbar/layout-icon.tsx +++ b/apps/space/core/components/issues/navbar/layout-icon.tsx @@ -1,5 +1,6 @@ -import { List, Kanban, LucideProps } from "lucide-react"; -import { TIssueLayout } from "@plane/constants"; +import type { LucideProps } from "lucide-react"; +import { List, Kanban } from "lucide-react"; +import type { TIssueLayout } from "@plane/constants"; export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => { switch (layout) { diff --git a/apps/space/core/components/issues/navbar/layout-selection.tsx b/apps/space/core/components/issues/navbar/layout-selection.tsx index 143d81d82..8c3c2d042 100644 --- a/apps/space/core/components/issues/navbar/layout-selection.tsx +++ b/apps/space/core/components/issues/navbar/layout-selection.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { useRouter, useSearchParams } from "next/navigation"; // ui @@ -13,7 +13,7 @@ import { queryParamGenerator } from "@/helpers/query-param-generator"; // hooks import { useIssueFilter } from "@/hooks/store/use-issue-filter"; // mobx -import { TIssueLayout } from "@/types/issue"; +import type { TIssueLayout } from "@/types/issue"; import { IssueLayoutIcon } from "./layout-icon"; type Props = { diff --git a/apps/space/core/components/issues/navbar/root.tsx b/apps/space/core/components/issues/navbar/root.tsx index 4751047a3..20f407e9d 100644 --- a/apps/space/core/components/issues/navbar/root.tsx +++ b/apps/space/core/components/issues/navbar/root.tsx @@ -1,8 +1,8 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -import { Briefcase } from "lucide-react"; +import { ProjectIcon } from "@plane/propel/icons"; // components import { ProjectLogo } from "@/components/common/project-logo"; // store @@ -29,7 +29,7 @@ export const IssuesNavbarRoot: FC = observer((props) => { ) : ( - + )}
diff --git a/apps/space/core/components/issues/navbar/user-avatar.tsx b/apps/space/core/components/issues/navbar/user-avatar.tsx index 0ac09b59b..b5538cf70 100644 --- a/apps/space/core/components/issues/navbar/user-avatar.tsx +++ b/apps/space/core/components/issues/navbar/user-avatar.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, Fragment, useEffect, useState } from "react"; +import type { FC } from "react"; +import { Fragment, useEffect, useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; @@ -9,8 +10,9 @@ import { LogOut } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; // plane imports import { API_BASE_URL } from "@plane/constants"; +import { Button } from "@plane/propel/button"; import { AuthService } from "@plane/services"; -import { Avatar, Button } from "@plane/ui"; +import { Avatar } from "@plane/ui"; import { getFileURL } from "@plane/utils"; // helpers import { queryParamGenerator } from "@/helpers/query-param-generator"; diff --git a/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx b/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx index 3ab42144a..3a6e6f1d6 100644 --- a/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx +++ b/apps/space/core/components/issues/peek-overview/comment/add-comment.tsx @@ -4,10 +4,10 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; import { useForm, Controller } from "react-hook-form"; // plane imports -import { EditorRefApi } from "@plane/editor"; +import type { EditorRefApi } from "@plane/editor"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { SitesFileService } from "@plane/services"; -import { TIssuePublicComment } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import type { TIssuePublicComment } from "@plane/types"; // editor components import { LiteTextEditor } from "@/components/editor/lite-text-editor"; // hooks diff --git a/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx b/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx index d9b760297..6e97ed6ea 100644 --- a/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/apps/space/core/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -4,8 +4,8 @@ import { Controller, useForm } from "react-hook-form"; import { Check, MessageSquare, MoreVertical, X } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; // plane imports -import { EditorRefApi } from "@plane/editor"; -import { TIssuePublicComment } from "@plane/types"; +import type { EditorRefApi } from "@plane/editor"; +import type { TIssuePublicComment } from "@plane/types"; import { getFileURL } from "@plane/utils"; // components import { LiteTextEditor } from "@/components/editor/lite-text-editor"; diff --git a/apps/space/core/components/issues/peek-overview/header.tsx b/apps/space/core/components/issues/peek-overview/header.tsx index 4791f1749..0bf33066a 100644 --- a/apps/space/core/components/issues/peek-overview/header.tsx +++ b/apps/space/core/components/issues/peek-overview/header.tsx @@ -6,14 +6,14 @@ import { Link2, MoveRight } from "lucide-react"; import { Listbox, Transition } from "@headlessui/react"; // ui import { CenterPanelIcon, FullScreenPanelIcon, SidePanelIcon } from "@plane/propel/icons"; -import { setToast, TOAST_TYPE } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // helpers import { copyTextToClipboard } from "@/helpers/string.helper"; // hooks import { useIssueDetails } from "@/hooks/store/use-issue-details"; import useClipboardWritePermission from "@/hooks/use-clipboard-write-permission"; // types -import { IIssue, IPeekMode } from "@/types/issue"; +import type { IIssue, IPeekMode } from "@/types/issue"; type Props = { handleClose: () => void; diff --git a/apps/space/core/components/issues/peek-overview/issue-activity.tsx b/apps/space/core/components/issues/peek-overview/issue-activity.tsx index f63af3b53..c31ca24f2 100644 --- a/apps/space/core/components/issues/peek-overview/issue-activity.tsx +++ b/apps/space/core/components/issues/peek-overview/issue-activity.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; // plane imports -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // components import { AddComment } from "@/components/issues/peek-overview/comment/add-comment"; import { CommentCard } from "@/components/issues/peek-overview/comment/comment-detail-card"; @@ -16,7 +16,7 @@ import { useIssueDetails } from "@/hooks/store/use-issue-details"; import { useUser } from "@/hooks/store/use-user"; import useIsInIframe from "@/hooks/use-is-in-iframe"; // types -import { IIssue } from "@/types/issue"; +import type { IIssue } from "@/types/issue"; type Props = { anchor: string; diff --git a/apps/space/core/components/issues/peek-overview/issue-details.tsx b/apps/space/core/components/issues/peek-overview/issue-details.tsx index 49c9d0a68..2ca097b23 100644 --- a/apps/space/core/components/issues/peek-overview/issue-details.tsx +++ b/apps/space/core/components/issues/peek-overview/issue-details.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { RichTextEditor } from "@/components/editor/rich-text-editor"; import { usePublish } from "@/hooks/store/publish"; // types -import { IIssue } from "@/types/issue"; +import type { IIssue } from "@/types/issue"; // local imports import { IssueReactions } from "./issue-reaction"; @@ -25,18 +25,12 @@ export const PeekOverviewIssueDetails: React.FC = observer((props) => { {project_details?.identifier}-{issueDetails?.sequence_id}

{issueDetails.name}

- {description !== "" && description !== "

" && ( + {description && description !== "" && description !== "

" && (

" - : description - } + initialValue={description} workspaceId={workspaceID?.toString() ?? ""} /> )} diff --git a/apps/space/core/components/issues/peek-overview/issue-properties.tsx b/apps/space/core/components/issues/peek-overview/issue-properties.tsx index 3bee765ca..6ea937d19 100644 --- a/apps/space/core/components/issues/peek-overview/issue-properties.tsx +++ b/apps/space/core/components/issues/peek-overview/issue-properties.tsx @@ -6,7 +6,7 @@ import { CalendarCheck2, Signal } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { cn, getIssuePriorityFilters } from "@plane/utils"; // components import { Icon } from "@/components/ui"; @@ -18,7 +18,7 @@ import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helpe import { usePublish } from "@/hooks/store/publish"; import { useStates } from "@/hooks/store/use-state"; // types -import { IIssue, IPeekMode } from "@/types/issue"; +import type { IIssue, IPeekMode } from "@/types/issue"; type Props = { issueDetails: IIssue; diff --git a/apps/space/core/components/issues/peek-overview/layout.tsx b/apps/space/core/components/issues/peek-overview/layout.tsx index 2a2b95d27..817700bfa 100644 --- a/apps/space/core/components/issues/peek-overview/layout.tsx +++ b/apps/space/core/components/issues/peek-overview/layout.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, Fragment, useEffect, useState } from "react"; +import type { FC } from "react"; +import { Fragment, useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useRouter, useSearchParams } from "next/navigation"; import { Dialog, Transition } from "@headlessui/react"; diff --git a/apps/space/core/components/issues/peek-overview/side-peek-view.tsx b/apps/space/core/components/issues/peek-overview/side-peek-view.tsx index 8dbbaa0ee..047989083 100644 --- a/apps/space/core/components/issues/peek-overview/side-peek-view.tsx +++ b/apps/space/core/components/issues/peek-overview/side-peek-view.tsx @@ -6,7 +6,7 @@ import { Loader } from "@plane/ui"; // store hooks import { usePublish } from "@/hooks/store/publish"; // types -import { IIssue } from "@/types/issue"; +import type { IIssue } from "@/types/issue"; // local imports import { PeekOverviewHeader } from "./header"; import { PeekOverviewIssueActivity } from "./issue-activity"; diff --git a/apps/space/core/hooks/use-intersection-observer.tsx b/apps/space/core/hooks/use-intersection-observer.tsx index bce8b5dd0..0fb1a2666 100644 --- a/apps/space/core/hooks/use-intersection-observer.tsx +++ b/apps/space/core/hooks/use-intersection-observer.tsx @@ -1,4 +1,5 @@ -import { RefObject, useEffect } from "react"; +import type { RefObject } from "react"; +import { useEffect } from "react"; export type UseIntersectionObserverProps = { containerRef: RefObject | undefined; diff --git a/apps/space/core/hooks/use-mention.tsx b/apps/space/core/hooks/use-mention.tsx index e3819d805..fc66eda1c 100644 --- a/apps/space/core/hooks/use-mention.tsx +++ b/apps/space/core/hooks/use-mention.tsx @@ -2,7 +2,7 @@ import { useRef, useEffect } from "react"; import useSWR from "swr"; // plane imports import { UserService } from "@plane/services"; -import { IUser } from "@plane/types"; +import type { IUser } from "@plane/types"; export const useMention = () => { const userService = new UserService(); diff --git a/apps/space/core/lib/instance-provider.tsx b/apps/space/core/lib/instance-provider.tsx index 3356834e9..8ea988086 100644 --- a/apps/space/core/lib/instance-provider.tsx +++ b/apps/space/core/lib/instance-provider.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import Link from "next/link"; diff --git a/apps/space/core/lib/store-provider.tsx b/apps/space/core/lib/store-provider.tsx index 88095fbc9..b017f90c4 100644 --- a/apps/space/core/lib/store-provider.tsx +++ b/apps/space/core/lib/store-provider.tsx @@ -1,6 +1,7 @@ "use client"; -import { ReactNode, createContext } from "react"; +import type { ReactNode } from "react"; +import { createContext } from "react"; // plane web store import { RootStore } from "@/plane-web/store/root.store"; diff --git a/apps/space/core/lib/toast-provider.tsx b/apps/space/core/lib/toast-provider.tsx index 20a37c3e9..e76c7e01e 100644 --- a/apps/space/core/lib/toast-provider.tsx +++ b/apps/space/core/lib/toast-provider.tsx @@ -1,9 +1,9 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { useTheme } from "next-themes"; // plane imports -import { Toast } from "@plane/ui"; +import { Toast } from "@plane/propel/toast"; import { resolveGeneralTheme } from "@plane/utils"; export const ToastProvider = ({ children }: { children: ReactNode }) => { diff --git a/apps/space/core/store/cycle.store.ts b/apps/space/core/store/cycle.store.ts index 6b624c92a..0f71d8841 100644 --- a/apps/space/core/store/cycle.store.ts +++ b/apps/space/core/store/cycle.store.ts @@ -1,7 +1,7 @@ import { action, makeObservable, observable, runInAction } from "mobx"; // plane imports import { SitesCycleService } from "@plane/services"; -import { TPublicCycle } from "@/types/cycle"; +import type { TPublicCycle } from "@/types/cycle"; // store import type { CoreRootStore } from "./root.store"; diff --git a/apps/space/core/store/helpers/base-issues.store.ts b/apps/space/core/store/helpers/base-issues.store.ts index 32f5abf9d..01d4d706b 100644 --- a/apps/space/core/store/helpers/base-issues.store.ts +++ b/apps/space/core/store/helpers/base-issues.store.ts @@ -1,8 +1,4 @@ -import concat from "lodash/concat"; -import get from "lodash/get"; -import set from "lodash/set"; -import uniq from "lodash/uniq"; -import update from "lodash/update"; +import { concat, get, set, uniq, update } from "lodash-es"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // plane imports diff --git a/apps/space/core/store/helpers/filter.helpers.ts b/apps/space/core/store/helpers/filter.helpers.ts index fd949efef..342f1ee7b 100644 --- a/apps/space/core/store/helpers/filter.helpers.ts +++ b/apps/space/core/store/helpers/filter.helpers.ts @@ -1,5 +1,5 @@ import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants"; -import { IssuePaginationOptions, TIssueParams } from "@plane/types"; +import type { IssuePaginationOptions, TIssueParams } from "@plane/types"; /** * This Method is used to construct the url params along with paginated values diff --git a/apps/space/core/store/instance.store.ts b/apps/space/core/store/instance.store.ts index 9887e5b8a..a1a49118d 100644 --- a/apps/space/core/store/instance.store.ts +++ b/apps/space/core/store/instance.store.ts @@ -1,8 +1,8 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { observable, action, makeObservable, runInAction } from "mobx"; // plane imports import { InstanceService } from "@plane/services"; -import { IInstance, IInstanceConfig } from "@plane/types"; +import type { IInstance, IInstanceConfig } from "@plane/types"; // store import type { CoreRootStore } from "@/store/root.store"; diff --git a/apps/space/core/store/issue-detail.store.ts b/apps/space/core/store/issue-detail.store.ts index c6c8d8eee..a9b2431ab 100644 --- a/apps/space/core/store/issue-detail.store.ts +++ b/apps/space/core/store/issue-detail.store.ts @@ -1,11 +1,11 @@ -import isEmpty from "lodash/isEmpty"; -import set from "lodash/set"; +import { isEmpty, set } from "lodash-es"; import { makeObservable, observable, action, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; import { v4 as uuidv4 } from "uuid"; // plane imports import { SitesFileService, SitesIssueService } from "@plane/services"; -import { EFileAssetType, TFileSignedURLResponse, TIssuePublicComment } from "@plane/types"; +import type { TFileSignedURLResponse, TIssuePublicComment } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; // store import type { CoreRootStore } from "@/store/root.store"; // types diff --git a/apps/space/core/store/issue-filters.store.ts b/apps/space/core/store/issue-filters.store.ts index e5df3447e..a48b07a79 100644 --- a/apps/space/core/store/issue-filters.store.ts +++ b/apps/space/core/store/issue-filters.store.ts @@ -1,6 +1,4 @@ -import cloneDeep from "lodash/cloneDeep"; -import isEqual from "lodash/isEqual"; -import set from "lodash/set"; +import { cloneDeep, isEqual, set } from "lodash-es"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // plane internal diff --git a/apps/space/core/store/issue.store.ts b/apps/space/core/store/issue.store.ts index 5e90ce9e4..ed4112a82 100644 --- a/apps/space/core/store/issue.store.ts +++ b/apps/space/core/store/issue.store.ts @@ -5,7 +5,8 @@ import type { IssuePaginationOptions, TLoader } from "@plane/types"; // store import type { CoreRootStore } from "@/store/root.store"; // types -import { BaseIssuesStore, type IBaseIssuesStore } from "./helpers/base-issues.store"; +import { BaseIssuesStore } from "./helpers/base-issues.store"; +import type { IBaseIssuesStore } from "./helpers/base-issues.store"; export interface IIssueStore extends IBaseIssuesStore { // actions diff --git a/apps/space/core/store/label.store.ts b/apps/space/core/store/label.store.ts index eed442aaa..53ffcbc6f 100644 --- a/apps/space/core/store/label.store.ts +++ b/apps/space/core/store/label.store.ts @@ -1,4 +1,4 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // plane imports import { SitesLabelService } from "@plane/services"; diff --git a/apps/space/core/store/members.store.ts b/apps/space/core/store/members.store.ts index a8f775d89..45abdc711 100644 --- a/apps/space/core/store/members.store.ts +++ b/apps/space/core/store/members.store.ts @@ -1,4 +1,4 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // plane imports import { SitesMemberService } from "@plane/services"; diff --git a/apps/space/core/store/module.store.ts b/apps/space/core/store/module.store.ts index 66449a408..0635d6f8c 100644 --- a/apps/space/core/store/module.store.ts +++ b/apps/space/core/store/module.store.ts @@ -1,4 +1,4 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // plane imports import { SitesModuleService } from "@plane/services"; diff --git a/apps/space/core/store/profile.store.ts b/apps/space/core/store/profile.store.ts index e231853c0..009b46ca4 100644 --- a/apps/space/core/store/profile.store.ts +++ b/apps/space/core/store/profile.store.ts @@ -1,8 +1,9 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { action, makeObservable, observable, runInAction } from "mobx"; // plane imports import { UserService } from "@plane/services"; -import { EStartOfTheWeek, TUserProfile } from "@plane/types"; +import type { TUserProfile } from "@plane/types"; +import { EStartOfTheWeek } from "@plane/types"; // store import type { CoreRootStore } from "@/store/root.store"; diff --git a/apps/space/core/store/publish/publish.store.ts b/apps/space/core/store/publish/publish.store.ts index ad5394797..49148d555 100644 --- a/apps/space/core/store/publish/publish.store.ts +++ b/apps/space/core/store/publish/publish.store.ts @@ -1,6 +1,6 @@ import { observable, makeObservable, computed } from "mobx"; // types -import { +import type { IWorkspaceLite, TProjectDetails, TPublishEntityType, diff --git a/apps/space/core/store/publish/publish_list.store.ts b/apps/space/core/store/publish/publish_list.store.ts index 3dba131bb..9cb9085f2 100644 --- a/apps/space/core/store/publish/publish_list.store.ts +++ b/apps/space/core/store/publish/publish_list.store.ts @@ -1,8 +1,8 @@ -import set from "lodash/set"; +import { set } from "lodash-es"; import { makeObservable, observable, runInAction, action } from "mobx"; // plane imports import { SitesProjectPublishService } from "@plane/services"; -import { TProjectPublishSettings } from "@plane/types"; +import type { TProjectPublishSettings } from "@plane/types"; // store import { PublishStore } from "@/store/publish/publish.store"; import type { CoreRootStore } from "@/store/root.store"; diff --git a/apps/space/core/store/root.store.ts b/apps/space/core/store/root.store.ts index db9e26566..047e8582d 100644 --- a/apps/space/core/store/root.store.ts +++ b/apps/space/core/store/root.store.ts @@ -1,16 +1,27 @@ import { enableStaticRendering } from "mobx-react"; // store imports -import { IInstanceStore, InstanceStore } from "@/store/instance.store"; -import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store"; -import { IssueStore, IIssueStore } from "@/store/issue.store"; -import { IUserStore, UserStore } from "@/store/user.store"; -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 { IIssueModuleStore, ModuleStore } from "./module.store"; -import { IPublishListStore, PublishListStore } from "./publish/publish_list.store"; -import { IStateStore, StateStore } from "./state.store"; +import type { IInstanceStore } from "@/store/instance.store"; +import { InstanceStore } from "@/store/instance.store"; +import type { IIssueDetailStore } from "@/store/issue-detail.store"; +import { IssueDetailStore } from "@/store/issue-detail.store"; +import type { IIssueStore } from "@/store/issue.store"; +import { IssueStore } from "@/store/issue.store"; +import type { IUserStore } from "@/store/user.store"; +import { UserStore } from "@/store/user.store"; +import type { ICycleStore } from "./cycle.store"; +import { CycleStore } from "./cycle.store"; +import type { IIssueFilterStore } from "./issue-filters.store"; +import { IssueFilterStore } from "./issue-filters.store"; +import type { IIssueLabelStore } from "./label.store"; +import { LabelStore } from "./label.store"; +import type { IIssueMemberStore } from "./members.store"; +import { MemberStore } from "./members.store"; +import type { IIssueModuleStore } from "./module.store"; +import { ModuleStore } from "./module.store"; +import type { IPublishListStore } from "./publish/publish_list.store"; +import { PublishListStore } from "./publish/publish_list.store"; +import type { IStateStore } from "./state.store"; +import { StateStore } from "./state.store"; enableStaticRendering(typeof window === "undefined"); diff --git a/apps/space/core/store/state.store.ts b/apps/space/core/store/state.store.ts index a7171b19d..1de0fd6ab 100644 --- a/apps/space/core/store/state.store.ts +++ b/apps/space/core/store/state.store.ts @@ -1,8 +1,8 @@ -import clone from "lodash/clone"; +import { clone } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // plane imports import { SitesStateService } from "@plane/services"; -import { IState } from "@plane/types"; +import type { IState } from "@plane/types"; // helpers import { sortStates } from "@/helpers/state.helper"; // store diff --git a/apps/space/core/store/user.store.ts b/apps/space/core/store/user.store.ts index 2b864ea5a..611afa483 100644 --- a/apps/space/core/store/user.store.ts +++ b/apps/space/core/store/user.store.ts @@ -1,11 +1,12 @@ import { AxiosError } from "axios"; -import set from "lodash/set"; +import { set } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // plane imports import { UserService } from "@plane/services"; -import { ActorDetail, IUser } from "@plane/types"; +import type { ActorDetail, IUser } from "@plane/types"; // store types -import { ProfileStore, IProfileStore } from "@/store/profile.store"; +import type { IProfileStore } from "@/store/profile.store"; +import { ProfileStore } from "@/store/profile.store"; // store import type { CoreRootStore } from "@/store/root.store"; diff --git a/apps/space/core/types/issue.d.ts b/apps/space/core/types/issue.d.ts index ac7549a8a..66542317e 100644 --- a/apps/space/core/types/issue.d.ts +++ b/apps/space/core/types/issue.d.ts @@ -1,4 +1,4 @@ -import { ActorDetail, TIssue, TIssuePriorities, TStateGroups, TIssuePublicComment } from "@plane/types"; +import type { ActorDetail, TIssue, TIssuePriorities, TStateGroups, TIssuePublicComment } from "@plane/types"; export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt"; export type TIssueLayoutOptions = { diff --git a/apps/space/helpers/authentication.helper.tsx b/apps/space/helpers/authentication.helper.tsx index 409a75150..8c8f09c54 100644 --- a/apps/space/helpers/authentication.helper.tsx +++ b/apps/space/helpers/authentication.helper.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import Link from "next/link"; // helpers import { SUPPORT_EMAIL } from "./common.helper"; @@ -66,7 +66,7 @@ export enum EAuthenticationErrorCodes { 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/apps/space/helpers/common.helper.ts b/apps/space/helpers/common.helper.ts index 3ffc59573..cbb90199e 100644 --- a/apps/space/helpers/common.helper.ts +++ b/apps/space/helpers/common.helper.ts @@ -1,4 +1,5 @@ -import { clsx, type ClassValue } from "clsx"; +import { clsx } from "clsx"; +import type { ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || ""; diff --git a/apps/space/helpers/date-time.helper.ts b/apps/space/helpers/date-time.helper.ts index 2e7a03b82..f0bb64892 100644 --- a/apps/space/helpers/date-time.helper.ts +++ b/apps/space/helpers/date-time.helper.ts @@ -1,5 +1,5 @@ import { format, isValid } from "date-fns"; -import isNumber from "lodash/isNumber"; +import { isNumber } from "lodash-es"; export const timeAgo = (time: any) => { switch (typeof time) { diff --git a/apps/space/helpers/editor.helper.ts b/apps/space/helpers/editor.helper.ts index 0315b9caf..43b265af5 100644 --- a/apps/space/helpers/editor.helper.ts +++ b/apps/space/helpers/editor.helper.ts @@ -1,6 +1,6 @@ // plane imports import { MAX_FILE_SIZE } from "@plane/constants"; -import { TFileHandler } from "@plane/editor"; +import type { TFileHandler } from "@plane/editor"; import { SitesFileService } from "@plane/services"; import { getFileURL } from "@plane/utils"; // services diff --git a/apps/space/helpers/file.helper.ts b/apps/space/helpers/file.helper.ts index f693940ab..0d396bbd2 100644 --- a/apps/space/helpers/file.helper.ts +++ b/apps/space/helpers/file.helper.ts @@ -1,18 +1,5 @@ +// plane imports import { API_BASE_URL } from "@plane/constants"; -import { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types"; - -/** - * @description from the provided signed URL response, generate a payload to be used to upload the file - * @param {TFileSignedURLResponse} signedURLResponse - * @param {File} file - * @returns {FormData} file upload request payload - */ -export const generateFileUploadPayload = (signedURLResponse: TFileSignedURLResponse, file: File): FormData => { - const formData = new FormData(); - Object.entries(signedURLResponse.upload_data.fields).forEach(([key, value]) => formData.append(key, value)); - formData.append("file", file); - return formData; -}; /** * @description combine the file path with the base URL @@ -26,17 +13,6 @@ export const getFileURL = (path: string): string | undefined => { return `${API_BASE_URL}${path}`; }; -/** - * @description returns the necessary file meta data to upload a file - * @param {File} file - * @returns {TFileMetaDataLite} payload with file info - */ -export const getFileMetaDataForUpload = (file: File): TFileMetaDataLite => ({ - name: file.name, - size: file.size, - type: file.type, -}); - /** * @description this function returns the assetId from the asset source * @param {string} src diff --git a/apps/space/helpers/issue.helper.ts b/apps/space/helpers/issue.helper.ts index a7129ca49..7971b4a51 100644 --- a/apps/space/helpers/issue.helper.ts +++ b/apps/space/helpers/issue.helper.ts @@ -1,7 +1,7 @@ import { differenceInCalendarDays } from "date-fns/differenceInCalendarDays"; // plane internal import { STATE_GROUPS } from "@plane/constants"; -import { TStateGroups } from "@plane/types"; +import type { TStateGroups } from "@plane/types"; // helpers import { getDate } from "@/helpers/date-time.helper"; diff --git a/apps/space/helpers/state.helper.ts b/apps/space/helpers/state.helper.ts index 8d97c39f6..f5a8a88e5 100644 --- a/apps/space/helpers/state.helper.ts +++ b/apps/space/helpers/state.helper.ts @@ -1,5 +1,5 @@ import { STATE_GROUPS } from "@plane/constants"; -import { IState } from "@plane/types"; +import type { IState } from "@plane/types"; export const sortStates = (states: IState[]) => { if (!states || states.length === 0) return; diff --git a/apps/space/package.json b/apps/space/package.json index 58caf798d..ea9e022c4 100644 --- a/apps/space/package.json +++ b/apps/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "1.0.0", + "version": "1.1.0", "private": true, "license": "AGPL-3.0", "scripts": { @@ -33,7 +33,7 @@ "date-fns": "^4.1.0", "dompurify": "^3.0.11", "dotenv": "^16.3.1", - "lodash": "catalog:", + "lodash-es": "catalog:", "lowlight": "^2.9.0", "lucide-react": "catalog:", "mobx": "catalog:", @@ -56,12 +56,11 @@ "@plane/eslint-config": "workspace:*", "@plane/tailwind-config": "workspace:*", "@plane/typescript-config": "workspace:*", - "@types/lodash": "catalog:", - "@types/node": "18.14.1", + "@types/lodash-es": "catalog:", + "@types/node": "catalog:", "@types/nprogress": "^0.2.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "@types/uuid": "^9.0.1", "typescript": "catalog:" } } diff --git a/apps/space/styles/globals.css b/apps/space/styles/globals.css index 870f137e4..5f2e91ed2 100644 --- a/apps/space/styles/globals.css +++ b/apps/space/styles/globals.css @@ -100,6 +100,27 @@ --color-sidebar-shadow-2xl: var(--color-shadow-2xl); --color-sidebar-shadow-3xl: var(--color-shadow-3xl); --color-sidebar-shadow-4xl: var(--color-shadow-4xl); + + /* toast theme */ + --color-toast-success-text: 178, 221, 181; + --color-toast-error-text: 206, 44, 49; + --color-toast-warning-text: 255, 186, 24; + --color-toast-info-text: 141, 164, 239; + --color-toast-loading-text: 255, 255, 255; + --color-toast-secondary-text: 185, 187, 198; + --color-toast-tertiary-text: 139, 141, 152; + + --color-toast-success-background: 46, 46, 46; + --color-toast-error-background: 46, 46, 46; + --color-toast-warning-background: 46, 46, 46; + --color-toast-info-background: 46, 46, 46; + --color-toast-loading-background: 46, 46, 46; + + --color-toast-success-border: 42, 126, 59; + --color-toast-error-border: 100, 23, 35; + --color-toast-warning-border: 79, 52, 34; + --color-toast-info-border: 58, 91, 199; + --color-toast-loading-border: 96, 100, 108; } [data-theme="light"], @@ -253,27 +274,6 @@ --color-onboarding-border-300: 34, 35, 38, 0.5; --color-onboarding-shadow-sm: 0px 4px 20px 0px rgba(39, 44, 56, 0.1); - - /* toast theme */ - --color-toast-success-text: 178, 221, 181; - --color-toast-error-text: 206, 44, 49; - --color-toast-warning-text: 255, 186, 24; - --color-toast-info-text: 141, 164, 239; - --color-toast-loading-text: 255, 255, 255; - --color-toast-secondary-text: 185, 187, 198; - --color-toast-tertiary-text: 139, 141, 152; - - --color-toast-success-background: 46, 46, 46; - --color-toast-error-background: 46, 46, 46; - --color-toast-warning-background: 46, 46, 46; - --color-toast-info-background: 46, 46, 46; - --color-toast-loading-background: 46, 46, 46; - - --color-toast-success-border: 42, 126, 59; - --color-toast-error-border: 100, 23, 35; - --color-toast-warning-border: 79, 52, 34; - --color-toast-info-border: 58, 91, 199; - --color-toast-loading-border: 96, 100, 108; } [data-theme="dark-contrast"] { diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js index 1662fabf7..a0bc76d5d 100644 --- a/apps/web/.eslintrc.js +++ b/apps/web/.eslintrc.js @@ -1,4 +1,18 @@ module.exports = { root: true, extends: ["@plane/eslint-config/next.js"], + rules: { + "no-duplicate-imports": "off", + "import/no-duplicates": ["error", { "prefer-inline": false }], + "import/consistent-type-specifier-style": ["error", "prefer-top-level"], + "@typescript-eslint/no-import-type-side-effects": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + prefer: "type-imports", + fixStyle: "separate-type-imports", + disallowTypeAnnotations: false, + }, + ], + }, }; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx index aaa7f6e51..d81d2c148 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/_sidebar.tsx @@ -1,5 +1,6 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // plane imports import { SIDEBAR_WIDTH } from "@plane/constants"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx index d147d0ed1..8408a9706 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // ui -import { ContrastIcon } from "@plane/propel/icons"; +import { CycleIcon } from "@plane/propel/icons"; import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; @@ -19,7 +19,7 @@ export const WorkspaceActiveCycleHeader = observer(() => { component={ } + icon={} /> } /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx index 8872b4cca..8171d55a8 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/header.tsx @@ -1,9 +1,9 @@ "use client"; import { observer } from "mobx-react"; -import { BarChart2 } from "lucide-react"; -// plane imports import { useTranslation } from "@plane/i18n"; +import { AnalyticsIcon } from "@plane/propel/icons"; +// plane imports import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; @@ -18,7 +18,7 @@ export const WorkspaceAnalyticsHeader = observer(() => { component={ } + icon={} /> } /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx index f75edf89e..5c3f94e32 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/analytics/[tabId]/page.tsx @@ -6,7 +6,8 @@ import { useRouter } from "next/navigation"; // plane package imports import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { type TabItem, Tabs } from "@plane/ui"; +import { Tabs } from "@plane/ui"; +import type { TabItem } from "@plane/ui"; // components import AnalyticsFilterActions from "@/components/analytics/analytics-filter-actions"; import { PageHead } from "@/components/core/page-title"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx index 14d7b6555..9e8472822 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx @@ -2,12 +2,13 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { PenSquare } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui +import { Button } from "@plane/propel/button"; +import { DraftIcon } from "@plane/propel/icons"; import { EIssuesStoreType } from "@plane/types"; -import { Breadcrumbs, Button, Header } from "@plane/ui"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { CountChip } from "@/components/common/count-chip"; @@ -47,7 +48,7 @@ export const WorkspaceDraftHeader = observer(() => { } /> + } /> } /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx index de71b5e95..b193dbe24 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx @@ -7,8 +7,8 @@ import { useParams } from "next/navigation"; import { Plus, Search } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { setToast, TOAST_TYPE } from "@plane/ui"; import { copyUrlToClipboard, orderJoinedProjects } from "@plane/utils"; // components import { CreateProjectModal } from "@/components/project/create-project-modal"; @@ -17,7 +17,7 @@ import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useProject } from "@/hooks/store/use-project"; import { useUserPermissions } from "@/hooks/store/user"; -import { TProject } from "@/plane-web/types"; +import type { TProject } from "@/plane-web/types"; import { ExtendedSidebarWrapper } from "./extended-sidebar-wrapper"; export const ExtendedProjectSidebar = observer(() => { diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx index bf5fdb4e2..878f9ee3f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar-wrapper.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // plane imports import { EXTENDED_SIDEBAR_WIDTH, SIDEBAR_WIDTH } from "@plane/constants"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx index 6aceeea95..108de05f0 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports import { WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants"; -import { EUserWorkspaceRoles } from "@plane/types"; +import type { EUserWorkspaceRoles } from "@plane/types"; // hooks import { useAppTheme } from "@/hooks/store/use-app-theme"; import { useWorkspace } from "@/hooks/store/use-workspace"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx index 27ef0ad42..18629c172 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/header.tsx @@ -1,10 +1,12 @@ "use client"; import { observer } from "mobx-react"; -import { Home, Shapes } from "lucide-react"; +import { Shapes } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Button, Header } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { HomeIcon } from "@plane/propel/icons"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; // hooks @@ -26,7 +28,10 @@ export const WorkspaceDashboardHeader = observer(() => { } /> + } + /> } /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx index 31c96cd43..cc9a789ec 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // components import { PageHead } from "@/components/core/page-title"; import { DownloadActivityButton } from "@/components/profile/activity/download-button"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx index 8e839c0ee..ef49c8ffb 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx @@ -1,15 +1,14 @@ "use client"; // ui -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { ChevronDown, PanelRight } from "lucide-react"; import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { UserActivityIcon } from "@plane/propel/icons"; -import { IUserProfileProjectSegregation } from "@plane/types"; +import { YourWorkIcon } from "@plane/propel/icons"; +import type { IUserProfileProjectSegregation } from "@plane/types"; import { Breadcrumbs, Header, CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils"; // components @@ -29,6 +28,7 @@ export const UserProfileHeader: FC = observer((props) => { const { userProjectsData, type = undefined, showProfileIssuesFilter } = props; // router const { workspaceSlug, userId } = useParams(); + const router = useRouter(); // store hooks const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme(); const { data: currentUser } = useUser(); @@ -59,7 +59,7 @@ export const UserProfileHeader: FC = observer((props) => { } + icon={} /> } /> @@ -83,14 +83,12 @@ export const UserProfileHeader: FC = observer((props) => { > <> {tabsList.map((tab) => ( - - - {t(tab.i18n_label)} - + router.push(`/${workspaceSlug}/profile/${userId}/${tab.route}`)} + > + {t(tab.i18n_label)} ))} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx index 69c8ea3cd..5b59b39d4 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx @@ -10,23 +10,20 @@ import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from " // plane i18n import { useTranslation } from "@plane/i18n"; // types -import { - EIssuesStoreType, +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, - IIssueFilterOptions, TIssueLayouts, EIssueLayoutTypes, } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components -import { isIssueFilterActive } from "@plane/utils"; -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; // hooks import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; export const ProfileIssuesMobileHeader = observer(() => { // plane i18n @@ -37,14 +34,7 @@ export const ProfileIssuesMobileHeader = observer(() => { const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.PROFILE); - - const { workspaceLabels } = useLabel(); // derived values - const states = undefined; - // const members = undefined; - // const activeLayout = issueFilters?.displayFilters?.layout; - // const states = undefined; - const members = undefined; const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( @@ -61,31 +51,6 @@ export const ProfileIssuesMobileHeader = observer(() => { [workspaceSlug, updateFilters, userId] ); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !userId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters( - workspaceSlug.toString(), - undefined, - EIssueFilterType.FILTERS, - { [key]: newValues }, - userId.toString() - ); - }, - [workspaceSlug, issueFilters, updateFilters, userId] - ); - const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !userId) return; @@ -145,32 +110,6 @@ export const ProfileIssuesMobileHeader = observer(() => { ); })} -
- - {t("common.filters")} - -
- } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { } + icon={} /> } /> diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx index 7fe963c84..885d6bc68 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx @@ -4,7 +4,7 @@ import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons -import { ChartNoAxesColumn, ListFilter, PanelRight, SlidersHorizontal } from "lucide-react"; +import { ChartNoAxesColumn, PanelRight, SlidersHorizontal } from "lucide-react"; // plane imports import { EIssueFilterType, @@ -16,18 +16,13 @@ import { } from "@plane/constants"; import { usePlatformOS } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; -import { ContrastIcon } from "@plane/propel/icons"; +import { Button } from "@plane/propel/button"; +import { CycleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { - EIssuesStoreType, - ICustomSearchSelectOption, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - IIssueFilterOptions, - EIssueLayoutTypes, -} from "@plane/types"; -import { Breadcrumbs, Button, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui"; -import { cn, isIssueFilterActive } from "@plane/utils"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types"; +import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui"; +import { cn } from "@plane/utils"; // components import { WorkItemsModal } from "@/components/analytics/work-items/modal"; import { SwitcherLabel } from "@/components/common/switcher-label"; @@ -35,18 +30,15 @@ import { CycleQuickActions } from "@/components/cycles/quick-actions"; import { DisplayFiltersSelection, FiltersDropdown, - FilterSelection, LayoutSelection, MobileLayoutSelection, } from "@/components/issues/issue-layouts/filters"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useCycle } from "@/hooks/store/use-cycle"; import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; -import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; -import { useProjectState } from "@/hooks/store/use-project-state"; import { useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; import useLocalStorage from "@/hooks/use-local-storage"; @@ -75,11 +67,6 @@ export const CycleIssuesHeader: React.FC = observer(() => { const { currentProjectCycleIds, getCycleById } = useCycle(); const { toggleCreateIssueModal } = useCommandPalette(); const { currentProjectDetails, loader } = useProject(); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); - const { - project: { projectMemberIds }, - } = useMember(); const { isMobile } = usePlatformOS(); const { allowPermissions } = useUserPermissions(); @@ -100,27 +87,6 @@ export const CycleIssuesHeader: React.FC = observer(() => { [workspaceSlug, projectId, cycleId, updateFilters] ); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId); - }, - [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] - ); - const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; @@ -152,7 +118,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { return { value: _cycle.id, query: _cycle.name, - content: , + content: , }; }) .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; @@ -187,7 +153,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { title={cycleDetails?.name} icon={ - + } isLast @@ -239,27 +205,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { activeLayout={activeLayout} />
- } - > - - + { > { - // i18n - const { t } = useTranslation(); - - const [analyticsModal, setAnalyticsModal] = useState(false); - const { getCycleById } = useCycle(); - const layouts = [ - { key: "list", titleTranslationKey: "issue.layouts.list", icon: List }, - { key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban }, - { key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar }, - ]; - + // router const { workspaceSlug, projectId, cycleId } = useParams(); - const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; - + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // plane hooks + const { t } = useTranslation(); // store hooks const { currentProjectDetails } = useProject(); + const { getCycleById } = useCycle(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.CYCLE); + // derived values const activeLayout = issueFilters?.displayFilters?.layout; + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const handleLayoutChange = useCallback( (layout: EIssueLayoutTypes) => { @@ -64,37 +56,6 @@ export const CycleIssuesMobileHeader = () => { [workspaceSlug, projectId, cycleId, updateFilters] ); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); - const { - project: { projectMemberIds }, - } = useMember(); - - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId || !cycleId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { [key]: newValues }, - cycleId.toString() - ); - }, - [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] - ); - const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId || !cycleId) return; @@ -142,7 +103,7 @@ export const CycleIssuesMobileHeader = () => { customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" closeOnSelect > - {layouts.map((layout, index) => ( + {SUPPORTED_LAYOUTS.map((layout, index) => ( { @@ -155,34 +116,6 @@ export const CycleIssuesMobileHeader = () => { ))} -
- - {t("common.filters")} - - - } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { // i18n @@ -39,16 +29,11 @@ export const ProjectIssuesMobileHeader = observer(() => { projectId: string; }; const { currentProjectDetails } = useProject(); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); // store hooks const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.PROJECT); - const { - project: { projectMemberIds }, - } = useMember(); const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( @@ -59,27 +44,6 @@ export const ProjectIssuesMobileHeader = observer(() => { [workspaceSlug, projectId, updateFilters] ); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); - }, - [workspaceSlug, projectId, issueFilters, updateFilters] - ); - const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; @@ -108,34 +72,6 @@ export const ProjectIssuesMobileHeader = observer(() => { layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]} onChange={handleLayoutChange} /> -
- - {t("common.filters")} - - - } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { const [analyticsModal, setAnalyticsModal] = useState(false); // router const router = useAppRouter(); - const { workspaceSlug, projectId, moduleId } = useParams(); + const { workspaceSlug, projectId, moduleId: routerModuleId } = useParams(); + const moduleId = routerModuleId ? routerModuleId.toString() : undefined; // hooks const { isMobile } = usePlatformOS(); // store hooks @@ -74,21 +66,22 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const { toggleCreateIssueModal } = useCommandPalette(); const { allowPermissions } = useUserPermissions(); const { currentProjectDetails, loader } = useProject(); - const { projectLabels } = useLabel(); - const { projectStates } = useProjectState(); - const { - project: { projectMemberIds }, - } = useMember(); - + // local storage const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false"); - + // derived values const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false; + const activeLayout = issueFilters?.displayFilters?.layout; + const moduleDetails = moduleId ? getModuleById(moduleId) : undefined; + const canUserCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.PROJECT + ); + const workItemsCount = getGroupIssueCount(undefined, undefined, false); + const toggleSidebar = () => { setValue(`${!isSidebarCollapsed}`); }; - const activeLayout = issueFilters?.displayFilters?.layout; - const handleLayoutChange = useCallback( (layout: EIssueLayoutTypes) => { if (!projectId) return; @@ -97,27 +90,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => { [projectId, updateFilters] ); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues }); - }, - [projectId, issueFilters, updateFilters] - ); - const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!projectId) return; @@ -134,15 +106,6 @@ export const ModuleIssuesHeader: React.FC = observer(() => { [projectId, updateFilters] ); - // derived values - const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; - const canUserCreateIssue = allowPermissions( - [EUserPermissions.ADMIN, EUserPermissions.MEMBER], - EUserPermissionsLevel.PROJECT - ); - - const workItemsCount = getGroupIssueCount(undefined, undefined, false); - const switcherOptions = projectModuleIds ?.map((id) => { const _module = id === moduleId ? moduleDetails : getModuleById(id); @@ -150,7 +113,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { return { value: _module.id, query: _module.name, - content: , + content: , }; }) .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; @@ -181,7 +144,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`); }} title={moduleDetails?.name} - icon={} + icon={} isLast /> } @@ -230,27 +193,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { activeLayout={activeLayout} />
- } - > - - + {moduleId && } { > { > - + {moduleId && ( + + )} diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx index c99653744..77b0e2b24 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx @@ -8,53 +8,44 @@ import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // plane imports import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { - EIssuesStoreType, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - IIssueFilterOptions, - EIssueLayoutTypes, -} from "@plane/types"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; import { CustomMenu } from "@plane/ui"; -import { isIssueFilterActive } from "@plane/utils"; // components import { WorkItemsModal } from "@/components/analytics/work-items/modal"; -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon"; // hooks import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; -import { useMember } from "@/hooks/store/use-member"; import { useModule } from "@/hooks/store/use-module"; import { useProject } from "@/hooks/store/use-project"; -import { useProjectState } from "@/hooks/store/use-project-state"; + +const SUPPORTED_LAYOUTS = [ + { key: "list", i18n_title: "issue.layouts.list", icon: List }, + { key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban }, + { key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar }, +]; export const ModuleIssuesMobileHeader = observer(() => { - const [analyticsModal, setAnalyticsModal] = useState(false); - const { currentProjectDetails } = useProject(); - const { getModuleById } = useModule(); - const { t } = useTranslation(); - const layouts = [ - { key: "list", i18n_title: "issue.layouts.list", icon: List }, - { key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban }, - { key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar }, - ]; + // router const { workspaceSlug, projectId, moduleId } = useParams() as { workspaceSlug: string; projectId: string; moduleId: string; }; - const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; - + // states + const [analyticsModal, setAnalyticsModal] = useState(false); + // plane hooks + const { t } = useTranslation(); + // store hooks + const { currentProjectDetails } = useProject(); + const { getModuleById } = useModule(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.MODULE); + // derived values const activeLayout = issueFilters?.displayFilters?.layout; - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); - const { - project: { projectMemberIds }, - } = useMember(); + const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const handleLayoutChange = useCallback( (layout: EIssueLayoutTypes) => { @@ -64,27 +55,6 @@ export const ModuleIssuesMobileHeader = observer(() => { [workspaceSlug, projectId, moduleId, updateFilters] ); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId); - }, - [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] - ); - const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; @@ -118,7 +88,7 @@ export const ModuleIssuesMobileHeader = observer(() => { customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" closeOnSelect > - {layouts.map((layout, index) => ( + {SUPPORTED_LAYOUTS.map((layout, index) => ( { @@ -131,34 +101,6 @@ export const ModuleIssuesMobileHeader = observer(() => { ))} -
- - Filters - - - } - isFiltersApplied={isIssueFilterActive(issueFilters)} - > - - -
{ > { query: _page.name, content: (
- +
), @@ -83,7 +83,7 @@ export const PageDetailsHeader = observer(() => { title={getPageName(page?.name)} icon={ - + } isLast diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx index ba3314eb8..4aa12716b 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx @@ -11,9 +11,11 @@ import { PROJECT_TRACKER_ELEMENTS, } from "@plane/constants"; // plane types -import { TPage } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TPage } from "@plane/types"; // plane ui -import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui"; +import { Breadcrumbs, Header } from "@plane/ui"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // hooks diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx index a74a9797b..6c62d42c3 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; // components import { AppHeader } from "@/components/core/app-header"; import { ContentWrapper } from "@/components/core/content-wrapper"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx index 756a2dac6..f5fb1f4ca 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx @@ -5,7 +5,8 @@ import { useParams, useSearchParams } from "next/navigation"; // plane imports import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EUserProjectRoles, TPageNavigationTabs } from "@plane/types"; +import type { TPageNavigationTabs } from "@plane/types"; +import { EUserProjectRoles } from "@plane/types"; // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx index 70a530f39..33a84241a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx @@ -3,7 +3,7 @@ import { useCallback, useRef } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { Layers, Lock } from "lucide-react"; +import { Lock } from "lucide-react"; // plane constants import { EIssueFilterType, @@ -14,36 +14,23 @@ import { WORK_ITEM_TRACKER_ELEMENTS, } from "@plane/constants"; // types +import { Button } from "@plane/propel/button"; +import { ViewsIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { - EIssuesStoreType, - EViewAccess, - ICustomSearchSelectOption, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - IIssueFilterOptions, - EIssueLayoutTypes, -} from "@plane/types"; +import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types"; // ui -import { Breadcrumbs, Button, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; +import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components -import { isIssueFilterActive } from "@plane/utils"; import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label"; -import { - DisplayFiltersSelection, - FiltersDropdown, - FilterSelection, - LayoutSelection, -} from "@/components/issues/issue-layouts/filters"; +import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters"; // constants import { ViewQuickActions } from "@/components/views/quick-actions"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; -import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; -import { useProjectState } from "@/hooks/store/use-project-state"; import { useProjectView } from "@/hooks/store/use-project-view"; import { useUserPermissions } from "@/hooks/store/user"; // plane web @@ -54,8 +41,9 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { // refs const parentRef = useRef(null); // router - const { workspaceSlug, projectId, viewId } = useParams(); const router = useAppRouter(); + const { workspaceSlug, projectId, viewId: routerViewId } = useParams(); + const viewId = routerViewId ? routerViewId.toString() : undefined; // store hooks const { issuesFilter: { issueFilters, updateFilters }, @@ -65,11 +53,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { const { currentProjectDetails, loader } = useProject(); const { projectViewIds, getViewById } = useProjectView(); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); - const { - project: { projectMemberIds }, - } = useMember(); const activeLayout = issueFilters?.displayFilters?.layout; @@ -87,33 +70,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { [workspaceSlug, projectId, viewId, updateFilters] ); - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId || !viewId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { [key]: newValues }, - viewId.toString() - ); - }, - [workspaceSlug, projectId, viewId, issueFilters, updateFilters] - ); - const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId || !viewId) return; @@ -158,7 +114,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { return { value: _view.id, query: _view.name, - content: , + content: , }; }) .filter((option) => option !== undefined) as ICustomSearchSelectOption[]; @@ -184,7 +140,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { title={viewDetails?.name} icon={ - + } isLast @@ -204,8 +160,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { )} - {!viewDetails?.is_locked ? ( - <> + <> + {!viewDetails.is_locked && ( { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - - - - + )} + {viewId && } + {!viewDetails.is_locked && ( { moduleViewDisabled={!currentProjectDetails?.module_view} /> - - ) : ( - <> - )} + )} + {canUserCreateIssue ? ( + )} +
- {canPerformWorkspaceAdminActions && ( - - )} -
diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx index 09a4de1d5..bda42ccc3 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/sidebar.tsx @@ -7,7 +7,7 @@ import { EUserPermissions, WORKSPACE_SETTINGS_CATEGORY, } from "@plane/constants"; -import { EUserWorkspaceRoles } from "@plane/types"; +import type { EUserWorkspaceRoles } from "@plane/types"; import { SettingsSidebar } from "@/components/settings/sidebar"; import { useUserPermissions } from "@/hooks/store/user"; import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx index 0bf1acdc9..7605f2276 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx @@ -5,9 +5,9 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; -import { IWebhook } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IWebhook } from "@plane/types"; // ui -import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; import { PageHead } from "@/components/core/page-title"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx index 8d4f8b133..799c29e17 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/activity/page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx index 4ca617cd5..61f227f46 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; import { usePathname } from "next/navigation"; // components diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx index 3d0c29858..b4815a69f 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/account/security/page.tsx @@ -7,13 +7,16 @@ import { Eye, EyeOff } from "lucide-react"; // plane imports import { E_PASSWORD_STRENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; import { getPasswordStrength } from "@plane/utils"; // components import { PageHead } from "@/components/core/page-title"; import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; // helpers -import { authErrorHandler, type EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; // hooks import { useUser } from "@/hooks/store/user"; // services diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx index b2eb7127c..0d9de2f2a 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/[projectId]/automations/page.tsx @@ -5,9 +5,9 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IProject } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IProject } from "@plane/types"; // ui -import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view"; import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation"; diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx index fd521b9cd..e82348c69 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/layout.tsx @@ -1,6 +1,7 @@ "use client"; -import { ReactNode, useEffect } from "react"; +import type { ReactNode } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; // components diff --git a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx index 7715afecd..2812d278e 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/projects/page.tsx @@ -3,7 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; -import { Button, getButtonStyling } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/propel/button"; import { cn } from "@plane/utils"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; diff --git a/apps/web/app/(all)/accounts/forgot-password/layout.tsx b/apps/web/app/(all)/accounts/forgot-password/layout.tsx index 7ba8e8ded..eb7439541 100644 --- a/apps/web/app/(all)/accounts/forgot-password/layout.tsx +++ b/apps/web/app/(all)/accounts/forgot-password/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Forgot Password - Plane", diff --git a/apps/web/app/(all)/accounts/reset-password/layout.tsx b/apps/web/app/(all)/accounts/reset-password/layout.tsx index dbc0a29b4..54488aa38 100644 --- a/apps/web/app/(all)/accounts/reset-password/layout.tsx +++ b/apps/web/app/(all)/accounts/reset-password/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Reset Password - Plane", diff --git a/apps/web/app/(all)/accounts/set-password/layout.tsx b/apps/web/app/(all)/accounts/set-password/layout.tsx index dbd32e9e8..89bf9748d 100644 --- a/apps/web/app/(all)/accounts/set-password/layout.tsx +++ b/apps/web/app/(all)/accounts/set-password/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Set Password - Plane", diff --git a/apps/web/app/(all)/create-workspace/layout.tsx b/apps/web/app/(all)/create-workspace/layout.tsx index 32a220df7..991c9c759 100644 --- a/apps/web/app/(all)/create-workspace/layout.tsx +++ b/apps/web/app/(all)/create-workspace/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Create Workspace", diff --git a/apps/web/app/(all)/create-workspace/page.tsx b/apps/web/app/(all)/create-workspace/page.tsx index 92c2ba3e1..db497729f 100644 --- a/apps/web/app/(all)/create-workspace/page.tsx +++ b/apps/web/app/(all)/create-workspace/page.tsx @@ -6,9 +6,9 @@ import Image from "next/image"; import Link from "next/link"; // plane imports import { useTranslation } from "@plane/i18n"; +import { Button, getButtonStyling } from "@plane/propel/button"; import { PlaneLogo } from "@plane/propel/icons"; -import { IWorkspace } from "@plane/types"; -import { Button, getButtonStyling } from "@plane/ui"; +import type { IWorkspace } from "@plane/types"; // components import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form"; // hooks diff --git a/apps/web/app/(all)/invitations/layout.tsx b/apps/web/app/(all)/invitations/layout.tsx index 2d9a7e688..0f05c3444 100644 --- a/apps/web/app/(all)/invitations/layout.tsx +++ b/apps/web/app/(all)/invitations/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Invitations", diff --git a/apps/web/app/(all)/invitations/page.tsx b/apps/web/app/(all)/invitations/page.tsx index 82201b0f2..080322ba8 100644 --- a/apps/web/app/(all)/invitations/page.tsx +++ b/apps/web/app/(all)/invitations/page.tsx @@ -10,10 +10,10 @@ import { CheckCircle2 } from "lucide-react"; import { ROLE, MEMBER_TRACKER_EVENTS, MEMBER_TRACKER_ELEMENTS, GROUP_WORKSPACE_TRACKER_EVENT } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // types +import { Button } from "@plane/propel/button"; import { PlaneLogo } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IWorkspaceMemberInvitation } from "@plane/types"; -// ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { truncateText } from "@plane/utils"; // components import { EmptyState } from "@/components/common/empty-state"; diff --git a/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx index 32589c4bf..2775b1b33 100644 --- a/apps/web/app/(all)/layout.tsx +++ b/apps/web/app/(all)/layout.tsx @@ -1,11 +1,11 @@ -import { Metadata, Viewport } from "next"; +import type { Metadata, Viewport } from "next"; import { PreloadResources } from "./layout.preload"; // styles import "@/styles/command-pallette.css"; import "@/styles/emoji.css"; -import "@/styles/react-day-picker.css"; +import "@plane/propel/styles/react-day-picker"; export const metadata: Metadata = { robots: { diff --git a/apps/web/app/(all)/onboarding/layout.tsx b/apps/web/app/(all)/onboarding/layout.tsx index 492ebc402..cad1f92cf 100644 --- a/apps/web/app/(all)/onboarding/layout.tsx +++ b/apps/web/app/(all)/onboarding/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Onboarding", diff --git a/apps/web/app/(all)/profile/activity/page.tsx b/apps/web/app/(all)/profile/activity/page.tsx index 4fa1039ca..d978b9a04 100644 --- a/apps/web/app/(all)/profile/activity/page.tsx +++ b/apps/web/app/(all)/profile/activity/page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // components import { PageHead } from "@/components/core/page-title"; import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; diff --git a/apps/web/app/(all)/profile/appearance/page.tsx b/apps/web/app/(all)/profile/appearance/page.tsx index 679ea2292..bbcbe2439 100644 --- a/apps/web/app/(all)/profile/appearance/page.tsx +++ b/apps/web/app/(all)/profile/appearance/page.tsx @@ -4,10 +4,11 @@ import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // plane imports -import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IUserTheme } from "@plane/types"; -import { setPromiseToast } from "@plane/ui"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { IUserTheme } from "@plane/types"; // components import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; import { LogoSpinner } from "@/components/common/logo-spinner"; diff --git a/apps/web/app/(all)/profile/layout.tsx b/apps/web/app/(all)/profile/layout.tsx index d9a440fb4..fdc086765 100644 --- a/apps/web/app/(all)/profile/layout.tsx +++ b/apps/web/app/(all)/profile/layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; // components import { CommandPalette } from "@/components/command-palette"; // wrappers diff --git a/apps/web/app/(all)/profile/security/page.tsx b/apps/web/app/(all)/profile/security/page.tsx index cff764ca8..4f0ad13c7 100644 --- a/apps/web/app/(all)/profile/security/page.tsx +++ b/apps/web/app/(all)/profile/security/page.tsx @@ -7,14 +7,17 @@ import { Eye, EyeOff } from "lucide-react"; // plane imports import { E_PASSWORD_STRENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; // components import { getPasswordStrength } from "@plane/utils"; import { PageHead } from "@/components/core/page-title"; import { ProfileSettingContentHeader } from "@/components/profile/profile-setting-content-header"; import { ProfileSettingContentWrapper } from "@/components/profile/profile-setting-content-wrapper"; // helpers -import { authErrorHandler, type EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes } from "@/helpers/authentication.helper"; // hooks import { useUser } from "@/hooks/store/user"; // services diff --git a/apps/web/app/(all)/profile/sidebar.tsx b/apps/web/app/(all)/profile/sidebar.tsx index 6d4c8ce36..acf25bb18 100644 --- a/apps/web/app/(all)/profile/sidebar.tsx +++ b/apps/web/app/(all)/profile/sidebar.tsx @@ -21,8 +21,8 @@ import { import { PROFILE_ACTION_LINKS } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { TOAST_TYPE, setToast } from "@plane/ui"; import { cn, getFileURL } from "@plane/utils"; // components import { SidebarNavItem } from "@/components/sidebar/sidebar-navigation"; diff --git a/apps/web/app/(all)/sign-up/layout.tsx b/apps/web/app/(all)/sign-up/layout.tsx index 9e259b304..815fe08fc 100644 --- a/apps/web/app/(all)/sign-up/layout.tsx +++ b/apps/web/app/(all)/sign-up/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Sign up - Plane", diff --git a/apps/web/app/(all)/workspace-invitations/layout.tsx b/apps/web/app/(all)/workspace-invitations/layout.tsx index 8361dddfa..535b2f62f 100644 --- a/apps/web/app/(all)/workspace-invitations/layout.tsx +++ b/apps/web/app/(all)/workspace-invitations/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata } from "next"; +import type { Metadata } from "next"; export const metadata: Metadata = { title: "Workspace Invitations", diff --git a/apps/web/app/(home)/layout.tsx b/apps/web/app/(home)/layout.tsx index af7645f3c..d50131fc0 100644 --- a/apps/web/app/(home)/layout.tsx +++ b/apps/web/app/(home)/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata, Viewport } from "next"; +import type { Metadata, Viewport } from "next"; export const metadata: Metadata = { robots: { diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 565c362d5..a6fa660a7 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -1,69 +1,83 @@ "use client"; -import Link from "next/link"; -// plane imports -import { API_BASE_URL } from "@plane/constants"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -import { cn } from "@plane/utils"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; +import Image from "next/image"; +import { useTheme } from "next-themes"; // layouts +import { Button } from "@plane/propel/button"; +import { useAppRouter } from "@/hooks/use-app-router"; import DefaultLayout from "@/layouts/default-layout"; -// services -import { AuthService } from "@/services/auth.service"; +// images +import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg"; +import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg"; -// services -const authService = new AuthService(); +const linkMap = [ + { + key: "mail_to", + label: "Contact Support", + value: "mailto:support@plane.so", + }, + { + key: "status", + label: "Status Page", + value: "https://status.plane.so/", + }, + { + key: "twitter_handle", + label: "@planepowers", + value: "https://x.com/planepowers", + }, +]; export default function CustomErrorComponent() { + // hooks + const { resolvedTheme } = useTheme(); const router = useAppRouter(); - const handleSignOut = async () => { - await authService - .signOut(API_BASE_URL) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to sign out. Please try again.", - }) - ) - .finally(() => router.push("/")); - }; + // derived values + const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; return ( -
-
-
-
-

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

-

- That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more - details, please write to{" "} - - support@plane.so - {" "} - or on our{" "} +

+
+ ProjectSettingImg +
+
+
+

+ 🚧 Looks like something went wrong! +

+ + We track these errors automatically and working on getting things back up and running. If the problem + persists feel free to contact us. In the meantime, try refreshing. + +
+ +
+ {linkMap.map((link) => ( + -
- - Go to home - - -
+
+ ))} +
+ +
+
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 433dea7f9..b2b274c73 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,4 @@ -import { Metadata, Viewport } from "next"; +import type { Metadata, Viewport } from "next"; import Script from "next/script"; // styles diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index 1f1ec0e2c..3d58991ba 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,11 +1,11 @@ "use client"; import React from "react"; -import { Metadata } from "next"; +import type { Metadata } from "next"; import Image from "next/image"; import Link from "next/link"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // images import Image404 from "@/public/404.svg"; diff --git a/apps/web/app/provider.tsx b/apps/web/app/provider.tsx index d7fbfd3b0..a83c75f9b 100644 --- a/apps/web/app/provider.tsx +++ b/apps/web/app/provider.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, ReactNode } from "react"; +import type { FC, ReactNode } from "react"; import { AppProgressProvider as ProgressProvider } from "@bprogress/next"; import dynamic from "next/dynamic"; import { useTheme, ThemeProvider } from "next-themes"; @@ -8,7 +8,7 @@ import { SWRConfig } from "swr"; // Plane Imports import { WEB_SWR_CONFIG } from "@plane/constants"; import { TranslationProvider } from "@plane/i18n"; -import { Toast } from "@plane/ui"; +import { Toast } from "@plane/propel/toast"; //helpers import { resolveGeneralTheme } from "@plane/utils"; // polyfills diff --git a/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx index 9e0289a3c..1ce060b01 100644 --- a/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx +++ b/apps/web/ce/components/active-cycles/workspace-active-cycles-upgrade.tsx @@ -7,7 +7,8 @@ import { AlertOctagon, BarChart4, CircleDashed, Folder, Microscope, Search } fro // plane imports import { MARKETING_PRICING_PAGE_LINK } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { ContentWrapper, getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; +import { ContentWrapper } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { ProIcon } from "@/components/common/pro-icon"; diff --git a/apps/web/ce/components/analytics/tabs.tsx b/apps/web/ce/components/analytics/tabs.tsx index eb8344c05..3cca97399 100644 --- a/apps/web/ce/components/analytics/tabs.tsx +++ b/apps/web/ce/components/analytics/tabs.tsx @@ -1,4 +1,4 @@ -import { AnalyticsTab } from "@plane/types"; +import type { AnalyticsTab } from "@plane/types"; import { Overview } from "@/components/analytics/overview"; import { WorkItems } from "@/components/analytics/work-items"; diff --git a/apps/web/ce/components/automations/root.tsx b/apps/web/ce/components/automations/root.tsx index 658580911..e7f15288b 100644 --- a/apps/web/ce/components/automations/root.tsx +++ b/apps/web/ce/components/automations/root.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; export type TCustomAutomationsRootProps = { projectId: string; diff --git a/apps/web/ce/components/breadcrumbs/common.tsx b/apps/web/ce/components/breadcrumbs/common.tsx index abcb5cb3d..86a123915 100644 --- a/apps/web/ce/components/breadcrumbs/common.tsx +++ b/apps/web/ce/components/breadcrumbs/common.tsx @@ -1,8 +1,8 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // plane imports -import { EProjectFeatureKey } from "@plane/constants"; +import type { EProjectFeatureKey } from "@plane/constants"; // local components import { ProjectBreadcrumb } from "./project"; import { ProjectFeatureBreadcrumb } from "./project-feature"; diff --git a/apps/web/ce/components/breadcrumbs/project-feature.tsx b/apps/web/ce/components/breadcrumbs/project-feature.tsx index ba67aa9cb..cad4338d3 100644 --- a/apps/web/ce/components/breadcrumbs/project-feature.tsx +++ b/apps/web/ce/components/breadcrumbs/project-feature.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { EProjectFeatureKey } from "@plane/constants"; -import { ISvgIcons } from "@plane/propel/icons"; +import type { ISvgIcons } from "@plane/propel/icons"; import { BreadcrumbNavigationDropdown, Breadcrumbs } from "@plane/ui"; // components import { SwitcherLabel } from "@/components/common/switcher-label"; diff --git a/apps/web/ce/components/breadcrumbs/project.tsx b/apps/web/ce/components/breadcrumbs/project.tsx index 18c4fea76..2f6c67bd7 100644 --- a/apps/web/ce/components/breadcrumbs/project.tsx +++ b/apps/web/ce/components/breadcrumbs/project.tsx @@ -1,9 +1,9 @@ "use client"; import { observer } from "mobx-react"; -import { Briefcase } from "lucide-react"; +import { ProjectIcon } from "@plane/propel/icons"; // plane imports -import { ICustomSearchSelectOption } from "@plane/types"; +import type { ICustomSearchSelectOption } from "@plane/types"; import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui"; // components import { Logo } from "@/components/common/logo"; @@ -11,7 +11,7 @@ import { SwitcherLabel } from "@/components/common/switcher-label"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; -import { TProject } from "@/plane-web/types"; +import type { TProject } from "@/plane-web/types"; type TProjectBreadcrumbProps = { workspaceSlug: string; @@ -39,7 +39,12 @@ export const ProjectBreadcrumb = observer((props: TProjectBreadcrumbProps) => { value: projectId, query: project?.name, content: ( - + ), }; }) diff --git a/apps/web/ce/components/command-palette/helpers.tsx b/apps/web/ce/components/command-palette/helpers.tsx index d098b1a48..865aa9e53 100644 --- a/apps/web/ce/components/command-palette/helpers.tsx +++ b/apps/web/ce/components/command-palette/helpers.tsx @@ -1,17 +1,15 @@ "use client"; -// types -import { Briefcase, FileText, Layers, LayoutGrid } from "lucide-react"; -import { ContrastIcon, DiceIcon } from "@plane/propel/icons"; -import { +import { LayoutGrid } from "lucide-react"; +// plane imports +import { CycleIcon, ModuleIcon, PageIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons"; +import type { IWorkspaceDefaultSearchResult, IWorkspaceIssueSearchResult, IWorkspacePageSearchResult, IWorkspaceProjectSearchResult, IWorkspaceSearchResult, } from "@plane/types"; -// ui -// helpers import { generateWorkItemLink } from "@plane/utils"; // plane web components import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; @@ -27,7 +25,7 @@ export type TCommandGroups = { export const commandGroups: TCommandGroups = { cycle: { - icon: , + icon: , itemName: (cycle: IWorkspaceDefaultSearchResult) => (
{cycle.project__identifier} {cycle.name} @@ -62,7 +60,7 @@ export const commandGroups: TCommandGroups = { title: "Work items", }, issue_view: { - icon: , + icon: , itemName: (view: IWorkspaceDefaultSearchResult) => (
{view.project__identifier} {view.name} @@ -73,7 +71,7 @@ export const commandGroups: TCommandGroups = { title: "Views", }, module: { - icon: , + icon: , itemName: (module: IWorkspaceDefaultSearchResult) => (
{module.project__identifier} {module.name} @@ -84,7 +82,7 @@ export const commandGroups: TCommandGroups = { title: "Modules", }, page: { - icon: , + icon: , itemName: (page: IWorkspacePageSearchResult) => (
{page.project__identifiers?.[0]} {page.name} @@ -100,7 +98,7 @@ export const commandGroups: TCommandGroups = { title: "Pages", }, project: { - icon: , + icon: , itemName: (project: IWorkspaceProjectSearchResult) => project?.name, path: (project: IWorkspaceProjectSearchResult) => `/${project?.workspace__slug}/projects/${project?.id}/issues/`, title: "Projects", diff --git a/apps/web/ce/components/command-palette/modals/issue-level.tsx b/apps/web/ce/components/command-palette/modals/issue-level.tsx index b30d5ec30..f720e38ea 100644 --- a/apps/web/ce/components/command-palette/modals/issue-level.tsx +++ b/apps/web/ce/components/command-palette/modals/issue-level.tsx @@ -1,8 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { EIssueServiceType, EIssuesStoreType, TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; // components import { BulkDeleteIssuesModal } from "@/components/core/modals/bulk-delete-issues-modal"; import { DeleteIssueModal } from "@/components/issues/delete-issue-modal"; diff --git a/apps/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx index 42c9dbfd1..c5c9b442a 100644 --- a/apps/web/ce/components/comments/comment-block.tsx +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -1,10 +1,11 @@ -import { FC, ReactNode, useRef } from "react"; +import type { FC, ReactNode } from "react"; +import { useRef } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Tooltip } from "@plane/propel/tooltip"; -import { TIssueComment } from "@plane/types"; -import { Avatar } from "@plane/ui"; +import type { TIssueComment } from "@plane/types"; +import { EIssueCommentAccessSpecifier } from "@plane/types"; +import { Avatar, Tooltip } from "@plane/ui"; import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; @@ -27,7 +28,13 @@ export const CommentBlock: FC = observer((props) => { // translation const { t } = useTranslation(); - if (!comment || !userDetails) return null; + const displayName = comment?.actor_detail?.is_bot + ? comment?.actor_detail?.first_name + ` ${t("bot")}` + : (userDetails?.display_name ?? comment?.actor_detail?.display_name); + + const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url; + + if (!comment) return null; return (
= observer((props) => { "flex-shrink-0 relative w-7 h-6 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] uppercase font-medium" )} > - +
-
- {comment?.actor_detail?.is_bot - ? comment?.actor_detail?.first_name + ` ${t("bot")}` - : comment?.actor_detail?.display_name || userDetails.display_name} +
+ + {`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`} +
commented{" "} diff --git a/apps/web/ce/components/common/extended-app-header.tsx b/apps/web/ce/components/common/extended-app-header.tsx index 5a2df91cb..59dbf3394 100644 --- a/apps/web/ce/components/common/extended-app-header.tsx +++ b/apps/web/ce/components/common/extended-app-header.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button"; import { useAppTheme } from "@/hooks/store/use-app-theme"; diff --git a/apps/web/ce/components/common/subscription/subscription-pill.tsx b/apps/web/ce/components/common/subscription/subscription-pill.tsx index 836ffae2b..ba30d3ad6 100644 --- a/apps/web/ce/components/common/subscription/subscription-pill.tsx +++ b/apps/web/ce/components/common/subscription/subscription-pill.tsx @@ -1,4 +1,4 @@ -import { IWorkspace } from "@plane/types"; +import type { IWorkspace } from "@plane/types"; type TProps = { workspace?: IWorkspace; diff --git a/apps/web/ce/components/cycles/active-cycle/root.tsx b/apps/web/ce/components/cycles/active-cycle/root.tsx index 66a580cbd..8ac331988 100644 --- a/apps/web/ce/components/cycles/active-cycle/root.tsx +++ b/apps/web/ce/components/cycles/active-cycle/root.tsx @@ -17,7 +17,7 @@ import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-stat // hooks import { useCycle } from "@/hooks/store/use-cycle"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; -import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; +import type { ActiveCycleIssueDetails } from "@/store/issue/cycle"; interface IActiveCycleDetails { workspaceSlug: string; diff --git a/apps/web/ce/components/cycles/additional-actions.tsx b/apps/web/ce/components/cycles/additional-actions.tsx index 1fcb7146f..0fd9efb31 100644 --- a/apps/web/ce/components/cycles/additional-actions.tsx +++ b/apps/web/ce/components/cycles/additional-actions.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; type Props = { cycleId: string; diff --git a/apps/web/ce/components/cycles/analytics-sidebar/base.tsx b/apps/web/ce/components/cycles/analytics-sidebar/base.tsx index c9c56990a..37a070774 100644 --- a/apps/web/ce/components/cycles/analytics-sidebar/base.tsx +++ b/apps/web/ce/components/cycles/analytics-sidebar/base.tsx @@ -1,9 +1,10 @@ "use client"; -import { FC, Fragment } from "react"; +import type { FC } from "react"; +import { Fragment } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TCycleEstimateType } from "@plane/types"; +import type { TCycleEstimateType } from "@plane/types"; import { Loader } from "@plane/ui"; import { getDate } from "@plane/utils"; // components diff --git a/apps/web/ce/components/cycles/analytics-sidebar/root.tsx b/apps/web/ce/components/cycles/analytics-sidebar/root.tsx index d18f9168d..6be4361ef 100644 --- a/apps/web/ce/components/cycles/analytics-sidebar/root.tsx +++ b/apps/web/ce/components/cycles/analytics-sidebar/root.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // components import { SidebarChart } from "./base"; diff --git a/apps/web/ce/components/de-dupe/de-dupe-button.tsx b/apps/web/ce/components/de-dupe/de-dupe-button.tsx index eaa4e3b7c..94d800ca8 100644 --- a/apps/web/ce/components/de-dupe/de-dupe-button.tsx +++ b/apps/web/ce/components/de-dupe/de-dupe-button.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // local components type TDeDupeButtonRoot = { diff --git a/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx b/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx index 42284c6ed..55eb084fd 100644 --- a/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx +++ b/apps/web/ce/components/de-dupe/duplicate-modal/root.tsx @@ -1,8 +1,8 @@ "use-client"; -import { FC } from "react"; +import type { FC } from "react"; // types -import { TDeDupeIssue } from "@plane/types"; +import type { TDeDupeIssue } from "@plane/types"; type TDuplicateModalRootProps = { workspaceSlug: string; diff --git a/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx index e1b8e2168..3dd227cc8 100644 --- a/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx +++ b/apps/web/ce/components/de-dupe/duplicate-popover/root.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // types -import { TDeDupeIssue } from "@plane/types"; +import type { TDeDupeIssue } from "@plane/types"; import type { TIssueOperations } from "@/components/issues/issue-detail"; type TDeDupeIssuePopoverRootProps = { diff --git a/apps/web/ce/components/de-dupe/issue-block/button-label.tsx b/apps/web/ce/components/de-dupe/issue-block/button-label.tsx index 303b0cec6..d6e363456 100644 --- a/apps/web/ce/components/de-dupe/issue-block/button-label.tsx +++ b/apps/web/ce/components/de-dupe/issue-block/button-label.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; type TDeDupeIssueButtonLabelProps = { isOpen: boolean; diff --git a/apps/web/ce/components/epics/epic-modal/modal.tsx b/apps/web/ce/components/epics/epic-modal/modal.tsx index 9c76b7bda..f1fec6ba8 100644 --- a/apps/web/ce/components/epics/epic-modal/modal.tsx +++ b/apps/web/ce/components/epics/epic-modal/modal.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; -import { TIssue } from "@plane/types"; +import type { FC } from "react"; +import React from "react"; +import type { TIssue } from "@plane/types"; export interface EpicModalProps { data?: Partial; diff --git a/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx index 7cde59b8a..936fdc622 100644 --- a/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx +++ b/apps/web/ce/components/estimates/estimate-list-item-buttons.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { Pen, Trash } from "lucide-react"; import { PROJECT_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; diff --git a/apps/web/ce/components/estimates/helper.tsx b/apps/web/ce/components/estimates/helper.tsx index 5a1d9eaf4..71b5be8a1 100644 --- a/apps/web/ce/components/estimates/helper.tsx +++ b/apps/web/ce/components/estimates/helper.tsx @@ -1,4 +1,5 @@ -import { TEstimateSystemKeys, EEstimateSystem } from "@plane/types"; +import type { TEstimateSystemKeys } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; export const isEstimateSystemEnabled = (key: TEstimateSystemKeys) => { switch (key) { diff --git a/apps/web/ce/components/estimates/inputs/time-input.tsx b/apps/web/ce/components/estimates/inputs/time-input.tsx index 0e5156cb6..39341ac3b 100644 --- a/apps/web/ce/components/estimates/inputs/time-input.tsx +++ b/apps/web/ce/components/estimates/inputs/time-input.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; export type TEstimateTimeInputProps = { value?: number; diff --git a/apps/web/ce/components/estimates/points/delete.tsx b/apps/web/ce/components/estimates/points/delete.tsx index 84791392b..522a28c96 100644 --- a/apps/web/ce/components/estimates/points/delete.tsx +++ b/apps/web/ce/components/estimates/points/delete.tsx @@ -1,8 +1,8 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; -import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; export type TEstimatePointDelete = { workspaceSlug: string; diff --git a/apps/web/ce/components/estimates/update/modal.tsx b/apps/web/ce/components/estimates/update/modal.tsx index 12b4ea6f6..fd4317196 100644 --- a/apps/web/ce/components/estimates/update/modal.tsx +++ b/apps/web/ce/components/estimates/update/modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; type TUpdateEstimateModal = { diff --git a/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx index ec7e52fd1..ecab672dd 100644 --- a/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx +++ b/apps/web/ce/components/gantt-chart/blocks/block-row-list.tsx @@ -1,11 +1,11 @@ -import { FC } from "react"; +import type { FC } from "react"; // components import type { IBlockUpdateData, IGanttBlock } from "@plane/types"; import RenderIfVisible from "@/components/core/render-if-visible-HOC"; // hooks import { BlockRow } from "@/components/gantt-chart/blocks/block-row"; import { BLOCK_HEIGHT } from "@/components/gantt-chart/constants"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; // types export type GanttChartBlocksProps = { diff --git a/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx index 0c8f2a7c0..593e80502 100644 --- a/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx +++ b/apps/web/ce/components/gantt-chart/blocks/blocks-list.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; // import type { IBlockUpdateDependencyData } from "@plane/types"; import { GanttChartBlock } from "@/components/gantt-chart/blocks/block"; diff --git a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx index cb1f33f79..a68118b6c 100644 --- a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx +++ b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/left-draggable.tsx @@ -1,4 +1,4 @@ -import { RefObject } from "react"; +import type { RefObject } from "react"; import type { IGanttBlock } from "@plane/types"; type LeftDependencyDraggableProps = { diff --git a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx index 29c731c9d..7a36ec9b3 100644 --- a/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx +++ b/apps/web/ce/components/gantt-chart/dependency/blockDraggables/right-draggable.tsx @@ -1,4 +1,4 @@ -import { RefObject } from "react"; +import type { RefObject } from "react"; import type { IGanttBlock } from "@plane/types"; type RightDependencyDraggableProps = { diff --git a/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx b/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx index 6feb208a8..e52805e17 100644 --- a/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx +++ b/apps/web/ce/components/gantt-chart/dependency/dependency-paths.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; type Props = { isEpic?: boolean; diff --git a/apps/web/ce/components/inbox/source-pill.tsx b/apps/web/ce/components/inbox/source-pill.tsx index 07e721780..77d3038cb 100644 --- a/apps/web/ce/components/inbox/source-pill.tsx +++ b/apps/web/ce/components/inbox/source-pill.tsx @@ -1,4 +1,4 @@ -import { EInboxIssueSource } from "@plane/types"; +import type { EInboxIssueSource } from "@plane/types"; export type TInboxSourcePill = { source: EInboxIssueSource; diff --git a/apps/web/ce/components/issues/bulk-operations/root.tsx b/apps/web/ce/components/issues/bulk-operations/root.tsx index dbd145506..fe7fcfe1b 100644 --- a/apps/web/ce/components/issues/bulk-operations/root.tsx +++ b/apps/web/ce/components/issues/bulk-operations/root.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import { BulkOperationsUpgradeBanner } from "@/components/issues/bulk-operations/upgrade-banner"; // hooks import { useMultipleSelectStore } from "@/hooks/store/use-multiple-select-store"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; type Props = { className?: string; diff --git a/apps/web/ce/components/issues/filters/issue-types.tsx b/apps/web/ce/components/issues/filters/issue-types.tsx index bc364c8f8..4d983bb7d 100644 --- a/apps/web/ce/components/issues/filters/issue-types.tsx +++ b/apps/web/ce/components/issues/filters/issue-types.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import type React from "react"; import { observer } from "mobx-react"; type Props = { diff --git a/apps/web/ce/components/issues/filters/team-project.tsx b/apps/web/ce/components/issues/filters/team-project.tsx index 4f4787fef..c8975deb4 100644 --- a/apps/web/ce/components/issues/filters/team-project.tsx +++ b/apps/web/ce/components/issues/filters/team-project.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import type React from "react"; import { observer } from "mobx-react"; type Props = { diff --git a/apps/web/ce/components/issues/header.tsx b/apps/web/ce/components/issues/header.tsx index 8bdcc3292..58f699be5 100644 --- a/apps/web/ce/components/issues/header.tsx +++ b/apps/web/ce/components/issues/header.tsx @@ -14,9 +14,10 @@ import { EProjectFeatureKey, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType } from "@plane/types"; -import { Breadcrumbs, Button, Header } from "@plane/ui"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { CountChip } from "@/components/common/count-chip"; // constants diff --git a/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx b/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx index 1312c0839..b0f33932f 100644 --- a/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx +++ b/apps/web/ce/components/issues/issue-detail-widgets/action-buttons.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane types -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; export type TWorkItemAdditionalWidgetActionButtonsProps = { disabled: boolean; diff --git a/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx b/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx index a9a6a1b29..2632987f8 100644 --- a/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx +++ b/apps/web/ce/components/issues/issue-detail-widgets/collapsibles.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane types -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; export type TWorkItemAdditionalWidgetCollapsiblesProps = { disabled: boolean; diff --git a/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx b/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx index 2e9dfe40d..b478cf898 100644 --- a/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx +++ b/apps/web/ce/components/issues/issue-detail-widgets/modals.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane types -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; export type TWorkItemAdditionalWidgetModalsProps = { hideWidgets: TWorkItemWidgets[]; diff --git a/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx b/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx index cd0383e6e..448deabc8 100644 --- a/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx +++ b/apps/web/ce/components/issues/issue-details/additional-activity-root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; export type TAdditionalActivityRoot = { diff --git a/apps/web/ce/components/issues/issue-details/additional-properties.tsx b/apps/web/ce/components/issues/issue-details/additional-properties.tsx index 64b8caa97..2e0a14707 100644 --- a/apps/web/ce/components/issues/issue-details/additional-properties.tsx +++ b/apps/web/ce/components/issues/issue-details/additional-properties.tsx @@ -1,4 +1,5 @@ -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // plane imports export type TWorkItemAdditionalSidebarProperties = { diff --git a/apps/web/ce/components/issues/issue-details/issue-creator.tsx b/apps/web/ce/components/issues/issue-details/issue-creator.tsx index c1e65ff5f..f3435e288 100644 --- a/apps/web/ce/components/issues/issue-details/issue-creator.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-creator.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import Link from "next/link"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/ce/components/issues/issue-details/issue-identifier.tsx b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx index c69704c9e..c81b0075b 100644 --- a/apps/web/ce/components/issues/issue-details/issue-identifier.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-identifier.tsx @@ -1,10 +1,10 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // types +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // ui -import { setToast, TOAST_TYPE } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; // hooks diff --git a/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx b/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx index cac755676..6aeb6eda0 100644 --- a/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-properties-activity/root.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; type TIssueAdditionalPropertiesActivity = { activityId: string; diff --git a/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx b/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx index 8def50f48..5796555ce 100644 --- a/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx +++ b/apps/web/ce/components/issues/issue-details/issue-type-activity.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; export type TIssueTypeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; diff --git a/apps/web/ce/components/issues/issue-details/parent-select-root.tsx b/apps/web/ce/components/issues/issue-details/parent-select-root.tsx index 3582d053f..54ae11689 100644 --- a/apps/web/ce/components/issues/issue-details/parent-select-root.tsx +++ b/apps/web/ce/components/issues/issue-details/parent-select-root.tsx @@ -4,7 +4,7 @@ import React from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // components import type { TIssueOperations } from "@/components/issues/issue-detail"; import { IssueParentSelect } from "@/components/issues/issue-detail/parent-select"; diff --git a/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx b/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx index 8f8b8ac04..1c397c572 100644 --- a/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx +++ b/apps/web/ce/components/issues/issue-layouts/additional-properties.tsx @@ -1,5 +1,6 @@ -import React, { FC } from "react"; -import { IIssueDisplayProperties, TIssue } from "@plane/types"; +import type { FC } from "react"; +import React from "react"; +import type { IIssueDisplayProperties, TIssue } from "@plane/types"; export type TWorkItemLayoutAdditionalProperties = { displayProperties: IIssueDisplayProperties; diff --git a/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx b/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx index 13542280d..b6e80910c 100644 --- a/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx +++ b/apps/web/ce/components/issues/issue-layouts/issue-stats.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; type Props = { issueId: string; diff --git a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx index 28ac44dc9..7f4bb031b 100644 --- a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/copy-menu-helper.tsx @@ -1,5 +1,5 @@ -import { Copy } from "lucide-react"; -import { TContextMenuItem } from "@plane/ui"; +import type { Copy } from "lucide-react"; +import type { TContextMenuItem } from "@plane/ui"; export interface CopyMenuHelperProps { baseItem: { diff --git a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx index 1ea30e26e..761317a01 100644 --- a/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx +++ b/apps/web/ce/components/issues/issue-layouts/quick-action-dropdowns/duplicate-modal.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; type TDuplicateWorkItemModalProps = { workItemId: string; diff --git a/apps/web/ce/components/issues/issue-layouts/utils.tsx b/apps/web/ce/components/issues/issue-layouts/utils.tsx index fcf1cd91b..61a32c4fd 100644 --- a/apps/web/ce/components/issues/issue-layouts/utils.tsx +++ b/apps/web/ce/components/issues/issue-layouts/utils.tsx @@ -1,9 +1,8 @@ -import { FC } from "react"; +import type { FC } from "react"; import { CalendarCheck2, CalendarClock, CalendarDays, - ContrastIcon, LayersIcon, Link2, Paperclip, @@ -13,8 +12,9 @@ import { Users, } from "lucide-react"; // types -import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/propel/icons"; -import { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types"; +import type { ISvgIcons } from "@plane/propel/icons"; +import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons"; +import type { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types"; // components import { SpreadsheetAssigneeColumn, @@ -71,8 +71,8 @@ export const SpreadSheetPropertyIconMap: Record> = { CalenderCheck2: CalendarCheck2, Triangle: Triangle, Tag: Tag, - DiceIcon: DiceIcon, - ContrastIcon: ContrastIcon, + ModuleIcon: ModuleIcon, + ContrastIcon: CycleIcon, Signal: Signal, CalendarClock: CalendarClock, DoubleCircleIcon: DoubleCircleIcon, diff --git a/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx b/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx index 00a192be1..ab73750ed 100644 --- a/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx +++ b/apps/web/ce/components/issues/issue-modal/issue-type-select.tsx @@ -1,8 +1,8 @@ -import { Control } from "react-hook-form"; +import type { Control } from "react-hook-form"; // plane imports import type { EditorRefApi } from "@plane/editor"; // types -import { TBulkIssueProperties, TIssue } from "@plane/types"; +import type { TBulkIssueProperties, TIssue } from "@plane/types"; export type TIssueFields = TIssue & TBulkIssueProperties; diff --git a/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx index ad35b4892..091d0c7ae 100644 --- a/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx +++ b/apps/web/ce/components/issues/issue-modal/modal-additional-properties.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import type React from "react"; export type TWorkItemModalAdditionalPropertiesProps = { isDraft?: boolean; diff --git a/apps/web/ce/components/issues/issue-modal/provider.tsx b/apps/web/ce/components/issues/issue-modal/provider.tsx index 55b0d4bb7..bd623cdda 100644 --- a/apps/web/ce/components/issues/issue-modal/provider.tsx +++ b/apps/web/ce/components/issues/issue-modal/provider.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // plane imports -import { ISearchIssueResponse, TIssue } from "@plane/types"; +import type { ISearchIssueResponse, TIssue } from "@plane/types"; // components import { IssueModalContext } from "@/components/issues/issue-modal/context"; // hooks diff --git a/apps/web/ce/components/issues/quick-add/root.tsx b/apps/web/ce/components/issues/quick-add/root.tsx index d74c3bf75..e01d4bd21 100644 --- a/apps/web/ce/components/issues/quick-add/root.tsx +++ b/apps/web/ce/components/issues/quick-add/root.tsx @@ -1,19 +1,21 @@ -import { FC, useEffect, useRef } from "react"; +import type { FC } from "react"; +import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; -import { UseFormRegister, UseFormSetFocus } from "react-hook-form"; +import type { UseFormRegister, UseFormSetFocus } from "react-hook-form"; // plane constants // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; // types -import { TIssue, EIssueLayoutTypes } from "@plane/types"; +import type { TIssue } from "@plane/types"; +import { EIssueLayoutTypes } from "@plane/types"; // components +import type { TQuickAddIssueForm } from "@/components/issues/issue-layouts/quick-add"; import { CalendarQuickAddIssueForm, GanttQuickAddIssueForm, KanbanQuickAddIssueForm, ListQuickAddIssueForm, SpreadsheetQuickAddIssueForm, - TQuickAddIssueForm, } from "@/components/issues/issue-layouts/quick-add"; // hooks import { useProject } from "@/hooks/store/use-project"; diff --git a/apps/web/ce/components/issues/worklog/activity/filter-root.tsx b/apps/web/ce/components/issues/worklog/activity/filter-root.tsx index a2fe9910f..cbc4607fb 100644 --- a/apps/web/ce/components/issues/worklog/activity/filter-root.tsx +++ b/apps/web/ce/components/issues/worklog/activity/filter-root.tsx @@ -1,8 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // plane imports -import { TActivityFilters, ACTIVITY_FILTER_TYPE_OPTIONS, TActivityFilterOption } from "@plane/constants"; +import type { TActivityFilters, TActivityFilterOption } from "@plane/constants"; +import { ACTIVITY_FILTER_TYPE_OPTIONS } from "@plane/constants"; // components import { ActivityFilter } from "@/components/issues/issue-detail/issue-activity"; diff --git a/apps/web/ce/components/issues/worklog/activity/root.tsx b/apps/web/ce/components/issues/worklog/activity/root.tsx index 0342999d3..42a0a28eb 100644 --- a/apps/web/ce/components/issues/worklog/activity/root.tsx +++ b/apps/web/ce/components/issues/worklog/activity/root.tsx @@ -1,7 +1,7 @@ "use client"; -import { FC } from "react"; -import { TIssueActivityComment } from "@plane/types"; +import type { FC } from "react"; +import type { TIssueActivityComment } from "@plane/types"; type TIssueActivityWorklog = { workspaceSlug: string; diff --git a/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx b/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx index 3a57b53df..17acfb2f4 100644 --- a/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx +++ b/apps/web/ce/components/issues/worklog/activity/worklog-create-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; type TIssueActivityWorklogCreateButton = { workspaceSlug: string; diff --git a/apps/web/ce/components/issues/worklog/property/root.tsx b/apps/web/ce/components/issues/worklog/property/root.tsx index 5ccc9ebaa..a0a4d2899 100644 --- a/apps/web/ce/components/issues/worklog/property/root.tsx +++ b/apps/web/ce/components/issues/worklog/property/root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; type TIssueWorklogProperty = { workspaceSlug: string; diff --git a/apps/web/ce/components/license/modal/upgrade-modal.tsx b/apps/web/ce/components/license/modal/upgrade-modal.tsx index 531925c04..5917fc764 100644 --- a/apps/web/ce/components/license/modal/upgrade-modal.tsx +++ b/apps/web/ce/components/license/modal/upgrade-modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { @@ -17,7 +17,7 @@ import { EModalWidth, ModalCore } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { FreePlanCard, PlanUpgradeCard } from "@/components/license"; -import { TCheckoutParams } from "@/components/license/modal/card/checkout-button"; +import type { TCheckoutParams } from "@/components/license/modal/card/checkout-button"; // Constants const COMMON_CARD_CLASSNAME = "flex flex-col w-full h-full justify-end col-span-12 sm:col-span-6 xl:col-span-3"; diff --git a/apps/web/ce/components/pages/editor/ai/menu.tsx b/apps/web/ce/components/pages/editor/ai/menu.tsx index 6d79584ab..109af7974 100644 --- a/apps/web/ce/components/pages/editor/ai/menu.tsx +++ b/apps/web/ce/components/pages/editor/ai/menu.tsx @@ -1,7 +1,8 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { ChevronRight, CornerDownRight, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react"; // plane editor import type { EditorRefApi } from "@plane/editor"; // plane ui @@ -12,7 +13,8 @@ import { RichTextEditor } from "@/components/editor/rich-text"; // plane web constants import { AI_EDITOR_TASKS, LOADING_TEXTS } from "@/plane-web/constants/ai"; // plane web services -import { AIService, TTaskPayload } from "@/services/ai.service"; +import type { TTaskPayload } from "@/services/ai.service"; +import { AIService } from "@/services/ai.service"; import { AskPiMenu } from "./ask-pi-menu"; const aiService = new AIService(); diff --git a/apps/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx b/apps/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx index f1ed1787b..bd61ceaa8 100644 --- a/apps/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx +++ b/apps/web/ce/components/pages/editor/embed/issue-embed-upgrade-card.tsx @@ -1,5 +1,5 @@ // plane imports -import { getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; import { cn } from "@plane/utils"; // components import { ProIcon } from "@/components/common/pro-icon"; diff --git a/apps/web/ce/components/pages/extra-actions.tsx b/apps/web/ce/components/pages/extra-actions.tsx index ed728c0e2..9e70d8d87 100644 --- a/apps/web/ce/components/pages/extra-actions.tsx +++ b/apps/web/ce/components/pages/extra-actions.tsx @@ -1,5 +1,5 @@ // store -import { EPageStoreType } from "@/plane-web/hooks/store"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; import type { TPageInstance } from "@/store/pages/base-page"; export type TPageHeaderExtraActionsProps = { diff --git a/apps/web/ce/components/pages/header/share-control.tsx b/apps/web/ce/components/pages/header/share-control.tsx index e85a4a320..4a79e0397 100644 --- a/apps/web/ce/components/pages/header/share-control.tsx +++ b/apps/web/ce/components/pages/header/share-control.tsx @@ -1,6 +1,6 @@ "use client"; -import { type EPageStoreType } from "@/plane-web/hooks/store"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; // store import type { TPageInstance } from "@/store/pages/base-page"; diff --git a/apps/web/ce/components/pages/modals/modals.tsx b/apps/web/ce/components/pages/modals/modals.tsx index 780dc8531..d47dbae32 100644 --- a/apps/web/ce/components/pages/modals/modals.tsx +++ b/apps/web/ce/components/pages/modals/modals.tsx @@ -1,11 +1,11 @@ "use client"; -import React from "react"; +import type React from "react"; import { observer } from "mobx-react"; // components -import { EPageStoreType } from "@/plane-web/hooks/store"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; // store -import { TPageInstance } from "@/store/pages/base-page"; +import type { TPageInstance } from "@/store/pages/base-page"; export type TPageModalsProps = { page: TPageInstance; diff --git a/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx b/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx index 93419437a..1581b4948 100644 --- a/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx +++ b/apps/web/ce/components/pages/navigation-pane/tab-panels/root.tsx @@ -1,7 +1,7 @@ // store import type { TPageInstance } from "@/store/pages/base-page"; // local imports -import { TPageNavigationPaneTab } from ".."; +import type { TPageNavigationPaneTab } from ".."; export type TPageNavigationPaneAdditionalTabPanelsRootProps = { activeTab: TPageNavigationPaneTab; diff --git a/apps/web/ce/components/preferences/theme-switcher.tsx b/apps/web/ce/components/preferences/theme-switcher.tsx index c9f005ee3..0460fbcaf 100644 --- a/apps/web/ce/components/preferences/theme-switcher.tsx +++ b/apps/web/ce/components/preferences/theme-switcher.tsx @@ -4,10 +4,11 @@ import { useEffect, useState, useCallback } from "react"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; // plane imports -import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IUserTheme } from "@plane/types"; -import { setPromiseToast } from "@plane/ui"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { IUserTheme } from "@plane/types"; import { applyTheme, unsetCustomCssVariables } from "@plane/utils"; // components import { CustomThemeSelector } from "@/components/core/theme/custom-theme-selector"; diff --git a/apps/web/ce/components/projects/create/attributes.tsx b/apps/web/ce/components/projects/create/attributes.tsx index e44551033..e1119f052 100644 --- a/apps/web/ce/components/projects/create/attributes.tsx +++ b/apps/web/ce/components/projects/create/attributes.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { Controller, useFormContext } from "react-hook-form"; // plane imports import { NETWORK_CHOICES, ETabIndices } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IProject } from "@plane/types"; +import type { IProject } from "@plane/types"; import { CustomSelect } from "@plane/ui"; import { getTabIndex } from "@plane/utils"; // components diff --git a/apps/web/ce/components/projects/create/root.tsx b/apps/web/ce/components/projects/create/root.tsx index d0c53dfab..27abe7aad 100644 --- a/apps/web/ce/components/projects/create/root.tsx +++ b/apps/web/ce/components/projects/create/root.tsx @@ -1,12 +1,13 @@ "use client"; -import { useState, FC } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { FormProvider, useForm } from "react-hook-form"; import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui -import { setToast, TOAST_TYPE } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // constants import ProjectCommonAttributes from "@/components/project/create/common-attributes"; import ProjectCreateHeader from "@/components/project/create/header"; @@ -16,7 +17,7 @@ import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useProject } from "@/hooks/store/use-project"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web types -import { TProject } from "@/plane-web/types/projects"; +import type { TProject } from "@/plane-web/types/projects"; import ProjectAttributes from "./attributes"; export type TCreateProjectFormProps = { diff --git a/apps/web/ce/components/projects/mobile-header.tsx b/apps/web/ce/components/projects/mobile-header.tsx index d9b84f3b5..829367437 100644 --- a/apps/web/ce/components/projects/mobile-header.tsx +++ b/apps/web/ce/components/projects/mobile-header.tsx @@ -5,7 +5,7 @@ import { useParams } from "next/navigation"; import { ChevronDown, ListFilter } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TProjectFilters } from "@plane/types"; +import type { TProjectFilters } from "@plane/types"; import { calculateTotalFilters } from "@plane/utils"; // components import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/ce/components/projects/navigation/helper.tsx b/apps/web/ce/components/projects/navigation/helper.tsx index b48668415..811eb9a17 100644 --- a/apps/web/ce/components/projects/navigation/helper.tsx +++ b/apps/web/ce/components/projects/navigation/helper.tsx @@ -1,7 +1,6 @@ -import { FileText, Layers } from "lucide-react"; // plane imports import { EUserPermissions, EProjectFeatureKey } from "@plane/constants"; -import { ContrastIcon, DiceIcon, LayersIcon, Intake } from "@plane/propel/icons"; +import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon, WorkItemsIcon } from "@plane/propel/icons"; // components import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; @@ -21,7 +20,7 @@ export const getProjectFeatureNavigation = ( key: EProjectFeatureKey.WORK_ITEMS, name: "Work items", href: `/${workspaceSlug}/projects/${projectId}/issues`, - icon: LayersIcon, + icon: WorkItemsIcon, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], shouldRender: true, sortOrder: 1, @@ -31,7 +30,7 @@ export const getProjectFeatureNavigation = ( key: EProjectFeatureKey.CYCLES, name: "Cycles", href: `/${workspaceSlug}/projects/${projectId}/cycles`, - icon: ContrastIcon, + icon: CycleIcon, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], shouldRender: project.cycle_view, sortOrder: 2, @@ -41,7 +40,7 @@ export const getProjectFeatureNavigation = ( key: EProjectFeatureKey.MODULES, name: "Modules", href: `/${workspaceSlug}/projects/${projectId}/modules`, - icon: DiceIcon, + icon: ModuleIcon, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER], shouldRender: project.module_view, sortOrder: 3, @@ -51,7 +50,7 @@ export const getProjectFeatureNavigation = ( key: EProjectFeatureKey.VIEWS, name: "Views", href: `/${workspaceSlug}/projects/${projectId}/views`, - icon: Layers, + icon: ViewsIcon, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], shouldRender: project.issue_views_view, sortOrder: 4, @@ -61,7 +60,7 @@ export const getProjectFeatureNavigation = ( key: EProjectFeatureKey.PAGES, name: "Pages", href: `/${workspaceSlug}/projects/${projectId}/pages`, - icon: FileText, + icon: PageIcon, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], shouldRender: project.page_view, sortOrder: 5, @@ -71,7 +70,7 @@ export const getProjectFeatureNavigation = ( key: EProjectFeatureKey.INTAKE, name: "Intake", href: `/${workspaceSlug}/projects/${projectId}/intake`, - icon: Intake, + icon: IntakeIcon, access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], shouldRender: project.inbox_view, sortOrder: 6, diff --git a/apps/web/ce/components/projects/settings/intake/header.tsx b/apps/web/ce/components/projects/settings/intake/header.tsx index 75c8bc535..692eecd14 100644 --- a/apps/web/ce/components/projects/settings/intake/header.tsx +++ b/apps/web/ce/components/projects/settings/intake/header.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { RefreshCcw } from "lucide-react"; // ui import { EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Breadcrumbs, Button, Header } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { Breadcrumbs, Header } from "@plane/ui"; // components import { InboxIssueCreateModalRoot } from "@/components/inbox/modals/create-modal"; // hooks diff --git a/apps/web/ce/components/projects/settings/useProjectColumns.tsx b/apps/web/ce/components/projects/settings/useProjectColumns.tsx index 1d6256045..43f8983c6 100644 --- a/apps/web/ce/components/projects/settings/useProjectColumns.tsx +++ b/apps/web/ce/components/projects/settings/useProjectColumns.tsx @@ -1,11 +1,15 @@ import { useState } from "react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { IWorkspaceMember, TProjectMembership } from "@plane/types"; +import type { IWorkspaceMember, TProjectMembership } from "@plane/types"; +import { renderFormattedDate } from "@plane/utils"; // components +import { MemberHeaderColumn } from "@/components/project/member-header-column"; import { AccountTypeColumn, NameColumn } from "@/components/project/settings/member-columns"; // hooks +import { useMember } from "@/hooks/store/use-member"; import { useUser, useUserPermissions } from "@/hooks/store/user"; +import type { IMemberFilters } from "@/store/member/utils"; export interface RowData extends Pick { member: IWorkspaceMember; @@ -20,9 +24,15 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => { const { projectId, workspaceSlug } = props; // states const [removeMemberModal, setRemoveMemberModal] = useState(null); + // store hooks const { data: currentUser } = useUser(); const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); + const { + project: { + filters: { getFilters, updateFilters }, + }, + } = useMember(); // derived values const isAdmin = allowPermissions( [EUserPermissions.ADMIN], @@ -33,11 +43,11 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => { const currentProjectRole = getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug.toString(), projectId.toString()) ?? EUserPermissions.GUEST; - const getFormattedDate = (dateStr: string) => { - const date = new Date(dateStr); + const displayFilters = getFilters(projectId); - const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; - return date.toLocaleDateString("en-US", options); + // handlers + const handleDisplayFilterUpdate = (filters: Partial) => { + updateFilters(projectId, filters); }; const columns = [ @@ -45,6 +55,13 @@ export const useProjectColumns = (props: TUseProjectColumnsProps) => { key: "Full Name", content: "Full name", thClassName: "text-left", + thRender: () => ( + + ), tdRender: (rowData: RowData) => ( { { key: "Display Name", content: "Display name", + thRender: () => ( + + ), tdRender: (rowData: RowData) =>
{rowData.member.display_name}
, }, - + { + key: "Email", + content: "Email", + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
{rowData.member.email}
, + }, { key: "Account Type", content: "Account type", + thRender: () => ( + + ), tdRender: (rowData: RowData) => ( { { key: "Joining Date", content: "Joining date", - tdRender: (rowData: RowData) =>
{getFormattedDate(rowData?.member?.joining_date || "")}
, + thRender: () => ( + + ), + tdRender: (rowData: RowData) =>
{renderFormattedDate(rowData?.member?.joining_date)}
, }, ]; - return { columns, removeMemberModal, setRemoveMemberModal }; + return { + columns, + removeMemberModal, + setRemoveMemberModal, + displayFilters, + handleDisplayFilterUpdate, + }; }; diff --git a/apps/web/ce/components/relations/activity.ts b/apps/web/ce/components/relations/activity.ts index 3b39ae5fe..820966f4d 100644 --- a/apps/web/ce/components/relations/activity.ts +++ b/apps/web/ce/components/relations/activity.ts @@ -1,4 +1,4 @@ -import { TIssueActivity } from "@plane/types"; +import type { TIssueActivity } from "@plane/types"; export const getRelationActivityContent = (activity: TIssueActivity | undefined): string | undefined => { if (!activity) return; diff --git a/apps/web/ce/components/rich-filters/filter-value-input/root.tsx b/apps/web/ce/components/rich-filters/filter-value-input/root.tsx new file mode 100644 index 000000000..f2ef9aba4 --- /dev/null +++ b/apps/web/ce/components/rich-filters/filter-value-input/root.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import type { TFilterValue, TFilterProperty } from "@plane/types"; +// local imports +import type { TFilterValueInputProps } from "@/components/rich-filters/shared"; + +export const AdditionalFilterValueInput = observer( +

(_props: TFilterValueInputProps) => ( + // Fallback +

+ Filter type not supported +
+ ) +); diff --git a/apps/web/ce/components/sidebar/project-navigation-root.tsx b/apps/web/ce/components/sidebar/project-navigation-root.tsx index 89972c23d..d4ca7bc32 100644 --- a/apps/web/ce/components/sidebar/project-navigation-root.tsx +++ b/apps/web/ce/components/sidebar/project-navigation-root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // components import { ProjectNavigation } from "@/components/workspace/sidebar/project-navigation"; diff --git a/apps/web/ce/components/views/helper.tsx b/apps/web/ce/components/views/helper.tsx index 5905f74ea..d2932ddac 100644 --- a/apps/web/ce/components/views/helper.tsx +++ b/apps/web/ce/components/views/helper.tsx @@ -1,5 +1,8 @@ -import { EIssueLayoutTypes } from "@plane/types"; -import { TWorkspaceLayoutProps } from "@/components/views/helper"; +import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; +import { useTranslation } from "@plane/i18n"; +import type { EIssueLayoutTypes, IProjectView } from "@plane/types"; +import type { TContextMenuItem } from "@plane/ui"; +import type { TWorkspaceLayoutProps } from "@/components/views/helper"; export type TLayoutSelectionProps = { onChange: (layout: EIssueLayoutTypes) => void; @@ -10,3 +13,68 @@ export type TLayoutSelectionProps = { export const GlobalViewLayoutSelection = (props: TLayoutSelectionProps) => <>; export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <>; + +export type TMenuItemsFactoryProps = { + isOwner: boolean; + isAdmin: boolean; + setDeleteViewModal: (open: boolean) => void; + setCreateUpdateViewModal: (open: boolean) => void; + handleOpenInNewTab: () => void; + handleCopyText: () => void; + isLocked: boolean; + workspaceSlug: string; + projectId?: string; + viewId: string; +}; + +export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => { + const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props; + + const { t } = useTranslation(); + + const editMenuItem = () => ({ + key: "edit", + action: () => setCreateUpdateViewModal(true), + title: t("edit"), + icon: Pencil, + shouldRender: isOwner, + }); + + const openInNewTabMenuItem = () => ({ + key: "open-new-tab", + action: handleOpenInNewTab, + title: t("open_in_new_tab"), + icon: ExternalLink, + }); + + const copyLinkMenuItem = () => ({ + key: "copy-link", + action: handleCopyText, + title: t("copy_link"), + icon: Link, + }); + + const deleteMenuItem = () => ({ + key: "delete", + action: () => setDeleteViewModal(true), + title: t("delete"), + icon: Trash2, + shouldRender: isOwner || isAdmin, + }); + + return { + editMenuItem, + openInNewTabMenuItem, + copyLinkMenuItem, + deleteMenuItem, + }; +}; + +export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => { + const factory = useMenuItemsFactory(props); + + return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()]; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const AdditionalHeaderItems = (view: IProjectView) => <>; diff --git a/apps/web/ce/components/views/publish/modal.tsx b/apps/web/ce/components/views/publish/modal.tsx index 0951de093..f92b3138d 100644 --- a/apps/web/ce/components/views/publish/modal.tsx +++ b/apps/web/ce/components/views/publish/modal.tsx @@ -1,6 +1,6 @@ "use client"; -import { IProjectView } from "@plane/types"; +import type { IProjectView } from "@plane/types"; type Props = { isOpen: boolean; diff --git a/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts b/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts index f6117a0d8..20c97eb4f 100644 --- a/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts +++ b/apps/web/ce/components/workflow/use-workflow-drag-n-drop.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { TIssueGroupByOptions } from "@plane/types"; +import type { TIssueGroupByOptions } from "@plane/types"; export const useWorkFlowFDragNDrop = ( groupBy: TIssueGroupByOptions | undefined, diff --git a/apps/web/ce/components/workflow/workflow-group-tree.tsx b/apps/web/ce/components/workflow/workflow-group-tree.tsx index 5caed4170..bc4cf9b1c 100644 --- a/apps/web/ce/components/workflow/workflow-group-tree.tsx +++ b/apps/web/ce/components/workflow/workflow-group-tree.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { TIssueGroupByOptions } from "@plane/types"; +import type { TIssueGroupByOptions } from "@plane/types"; type Props = { groupBy?: TIssueGroupByOptions; diff --git a/apps/web/ce/components/workspace-notifications/notification-card/root.tsx b/apps/web/ce/components/workspace-notifications/notification-card/root.tsx index ef2eb11de..214b0fb9c 100644 --- a/apps/web/ce/components/workspace-notifications/notification-card/root.tsx +++ b/apps/web/ce/components/workspace-notifications/notification-card/root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants"; diff --git a/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx b/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx index a4fec060f..2993f3292 100644 --- a/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx +++ b/apps/web/ce/components/workspace/billing/comparison/frequency-toggle.tsx @@ -1,7 +1,7 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane imports import { observer } from "mobx-react"; -import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; import { getSubscriptionBackgroundColor, getDiscountPillStyle } from "@plane/ui"; import { calculateYearlyDiscount, cn } from "@plane/utils"; diff --git a/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx b/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx index 9067e124e..03af2fa32 100644 --- a/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx +++ b/apps/web/ce/components/workspace/billing/comparison/plan-detail.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { @@ -9,12 +9,14 @@ import { WORKSPACE_SETTINGS_TRACKER_EVENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; -import { getButtonStyling, getUpgradeButtonStyle } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; +import type { TBillingFrequency } from "@plane/types"; +import { EProductSubscriptionEnum } from "@plane/types"; +import { getUpgradeButtonStyle } from "@plane/ui"; import { cn, getSubscriptionName } from "@plane/utils"; // components import { DiscountInfo } from "@/components/license/modal/card/discount-info"; -import { TPlanDetail } from "@/constants/plans"; +import type { TPlanDetail } from "@/constants/plans"; // local imports import { captureSuccess } from "@/helpers/event-tracker.helper"; import { PlanFrequencyToggle } from "./frequency-toggle"; diff --git a/apps/web/ce/components/workspace/billing/comparison/root.tsx b/apps/web/ce/components/workspace/billing/comparison/root.tsx index cf8f3dec5..3c0958275 100644 --- a/apps/web/ce/components/workspace/billing/comparison/root.tsx +++ b/apps/web/ce/components/workspace/billing/comparison/root.tsx @@ -1,9 +1,10 @@ import { observer } from "mobx-react"; // plane imports -import { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; +import type { EProductSubscriptionEnum, TBillingFrequency } from "@plane/types"; // components import { PlansComparisonBase, shouldRenderPlanDetail } from "@/components/workspace/billing/comparison/base"; -import { PLANE_PLANS, TPlanePlans } from "@/constants/plans"; +import type { TPlanePlans } from "@/constants/plans"; +import { PLANE_PLANS } from "@/constants/plans"; // plane web imports import { PlanDetail } from "./plan-detail"; diff --git a/apps/web/ce/components/workspace/billing/root.tsx b/apps/web/ce/components/workspace/billing/root.tsx index d5bc225d4..e9ac6e189 100644 --- a/apps/web/ce/components/workspace/billing/root.tsx +++ b/apps/web/ce/components/workspace/billing/root.tsx @@ -3,7 +3,8 @@ import { observer } from "mobx-react"; // plane imports import { DEFAULT_PRODUCT_BILLING_FREQUENCY, SUBSCRIPTION_WITH_BILLING_FREQUENCY } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EProductSubscriptionEnum, TBillingFrequency, TProductBillingFrequency } from "@plane/types"; +import type { TBillingFrequency, TProductBillingFrequency } from "@plane/types"; +import { EProductSubscriptionEnum } from "@plane/types"; import { getSubscriptionTextColor } from "@plane/ui"; import { cn } from "@plane/utils"; // components diff --git a/apps/web/ce/components/workspace/delete-workspace-section.tsx b/apps/web/ce/components/workspace/delete-workspace-section.tsx index 33c85e5a9..aa72fdc32 100644 --- a/apps/web/ce/components/workspace/delete-workspace-section.tsx +++ b/apps/web/ce/components/workspace/delete-workspace-section.tsx @@ -1,12 +1,14 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { ChevronDown, ChevronUp } from "lucide-react"; // types import { WORKSPACE_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IWorkspace } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import type { IWorkspace } from "@plane/types"; // ui -import { Button, Collapsible } from "@plane/ui"; +import { Collapsible } from "@plane/ui"; import { DeleteWorkspaceModal } from "./delete-workspace-modal"; // components diff --git a/apps/web/ce/components/workspace/edition-badge.tsx b/apps/web/ce/components/workspace/edition-badge.tsx index 7af2cf71e..bd846c932 100644 --- a/apps/web/ce/components/workspace/edition-badge.tsx +++ b/apps/web/ce/components/workspace/edition-badge.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; // ui import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; import { Tooltip } from "@plane/propel/tooltip"; -import { Button } from "@plane/ui"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; import packageJson from "package.json"; diff --git a/apps/web/ce/components/workspace/members/invite-modal.tsx b/apps/web/ce/components/workspace/members/invite-modal.tsx index 8641847bd..f83234e21 100644 --- a/apps/web/ce/components/workspace/members/invite-modal.tsx +++ b/apps/web/ce/components/workspace/members/invite-modal.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports import { useTranslation } from "@plane/i18n"; -import { IWorkspaceBulkInviteFormData } from "@plane/types"; +import type { IWorkspaceBulkInviteFormData } from "@plane/types"; import { EModalWidth, EModalPosition, ModalCore } from "@plane/ui"; // components import { InvitationModalActions } from "@/components/workspace/invite-modal/actions"; diff --git a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx index 10ce47824..8f2286a6f 100644 --- a/apps/web/ce/components/workspace/settings/useMemberColumns.tsx +++ b/apps/web/ce/components/workspace/settings/useMemberColumns.tsx @@ -2,8 +2,13 @@ import { useState } from "react"; import { useParams } from "next/navigation"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { AccountTypeColumn, NameColumn, RowData } from "@/components/workspace/settings/member-columns"; +import { renderFormattedDate } from "@plane/utils"; +import { MemberHeaderColumn } from "@/components/project/member-header-column"; +import type { RowData } from "@/components/workspace/settings/member-columns"; +import { AccountTypeColumn, NameColumn } from "@/components/workspace/settings/member-columns"; +import { useMember } from "@/hooks/store/use-member"; import { useUser, useUserPermissions } from "@/hooks/store/user"; +import type { IMemberFilters } from "@/store/member/utils"; export const useMemberColumns = () => { // states @@ -13,23 +18,34 @@ export const useMemberColumns = () => { const { data: currentUser } = useUser(); const { allowPermissions } = useUserPermissions(); + const { + workspace: { + filtersStore: { filters, updateFilters }, + }, + } = useMember(); const { t } = useTranslation(); - const getFormattedDate = (dateStr: string) => { - const date = new Date(dateStr); - - const options: Intl.DateTimeFormatOptions = { year: "numeric", month: "long", day: "numeric" }; - return date.toLocaleDateString("en-US", options); - }; - // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); + const isSuspended = (rowData: RowData) => rowData.is_active === false; + // handlers + const handleDisplayFilterUpdate = (filterUpdates: Partial) => { + updateFilters(filterUpdates); + }; + const columns = [ { key: "Full name", content: t("workspace_settings.settings.members.details.full_name"), thClassName: "text-left", + thRender: () => ( + + ), tdRender: (rowData: RowData) => ( { { key: "Display name", content: t("workspace_settings.settings.members.details.display_name"), - tdRender: (rowData: RowData) =>
{rowData.member.display_name}
, + tdRender: (rowData: RowData) => ( +
+ {rowData.member.display_name} +
+ ), + thRender: () => ( + + ), }, { key: "Email address", content: t("workspace_settings.settings.members.details.email_address"), - tdRender: (rowData: RowData) =>
{rowData.member.email}
, + tdRender: (rowData: RowData) => ( +
+ {rowData.member.email} +
+ ), + thRender: () => ( + + ), }, { key: "Account type", content: t("workspace_settings.settings.members.details.account_type"), + thRender: () => ( + + ), tdRender: (rowData: RowData) => , }, { key: "Authentication", content: t("workspace_settings.settings.members.details.authentication"), - tdRender: (rowData: RowData) => ( -
{rowData.member.last_login_medium?.replace("-", " ")}
- ), + tdRender: (rowData: RowData) => + isSuspended(rowData) ? null : ( +
{rowData.member.last_login_medium?.replace("-", " ")}
+ ), }, { key: "Joining date", content: t("workspace_settings.settings.members.details.joining_date"), - tdRender: (rowData: RowData) =>
{getFormattedDate(rowData?.member?.joining_date || "")}
, + tdRender: (rowData: RowData) => + isSuspended(rowData) ? null :
{renderFormattedDate(rowData?.member?.joining_date)}
, + thRender: () => ( + + ), }, ]; return { columns, workspaceSlug, removeMemberModal, setRemoveMemberModal }; diff --git a/apps/web/ce/components/workspace/sidebar/app-search.tsx b/apps/web/ce/components/workspace/sidebar/app-search.tsx index 77a359373..9e0f4cd95 100644 --- a/apps/web/ce/components/workspace/sidebar/app-search.tsx +++ b/apps/web/ce/components/workspace/sidebar/app-search.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react"; -import { Search } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; // hooks +import { SidebarSearchButton } from "@/components/sidebar/search-button"; import { useCommandPalette } from "@/hooks/store/use-command-palette"; export const AppSearch = observer(() => { @@ -14,11 +14,10 @@ export const AppSearch = observer(() => { return ( ); }); diff --git a/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx index ea16bc6b3..44b47c706 100644 --- a/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx +++ b/apps/web/ce/components/workspace/sidebar/extended-sidebar-item.tsx @@ -1,4 +1,5 @@ -import { FC, useEffect, useRef, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; @@ -7,7 +8,8 @@ import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { Pin, PinOff } from "lucide-react"; // plane imports -import { EUserPermissionsLevel, IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import type { IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import { EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/propel/tooltip"; import { DragHandle, DropIndicator } from "@plane/ui"; diff --git a/apps/web/ce/components/workspace/sidebar/helper.tsx b/apps/web/ce/components/workspace/sidebar/helper.tsx index 0821a0ef4..316f77b5d 100644 --- a/apps/web/ce/components/workspace/sidebar/helper.tsx +++ b/apps/web/ce/components/workspace/sidebar/helper.tsx @@ -1,25 +1,34 @@ -import { BarChart2, Briefcase, Home, Inbox, Layers, PenSquare } from "lucide-react"; -import { ArchiveIcon, ContrastIcon, UserActivityIcon } from "@plane/propel/icons"; +import { + AnalyticsIcon, + ArchiveIcon, + CycleIcon, + DraftIcon, + HomeIcon, + InboxIcon, + ProjectIcon, + ViewsIcon, + YourWorkIcon, +} from "@plane/propel/icons"; import { cn } from "@plane/utils"; export const getSidebarNavigationItemIcon = (key: string, className: string = "") => { switch (key) { case "home": - return ; + return ; case "inbox": - return ; + return ; case "projects": - return ; + return ; case "views": - return ; + return ; case "active_cycles": - return ; + return ; case "analytics": - return ; + return ; case "your_work": - return ; + return ; case "drafts": - return ; + return ; case "archives": return ; } diff --git a/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx index 349042806..3fbc8d0a2 100644 --- a/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx +++ b/apps/web/ce/components/workspace/sidebar/sidebar-item.tsx @@ -1,5 +1,5 @@ -import { FC } from "react"; -import { IWorkspaceSidebarNavigationItem } from "@plane/constants"; +import type { FC } from "react"; +import type { IWorkspaceSidebarNavigationItem } from "@plane/constants"; import { SidebarItemBase } from "@/components/workspace/sidebar/sidebar-item"; type Props = { diff --git a/apps/web/ce/components/workspace/upgrade-badge.tsx b/apps/web/ce/components/workspace/upgrade-badge.tsx index 8c198dd2e..17efeb015 100644 --- a/apps/web/ce/components/workspace/upgrade-badge.tsx +++ b/apps/web/ce/components/workspace/upgrade-badge.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; // helpers import { useTranslation } from "@plane/i18n"; import { cn } from "@plane/utils"; diff --git a/apps/web/ce/constants/editor.ts b/apps/web/ce/constants/editor.ts index b9a6d5d38..f01879602 100644 --- a/apps/web/ce/constants/editor.ts +++ b/apps/web/ce/constants/editor.ts @@ -1,4 +1,4 @@ // plane types -import { TSearchEntities } from "@plane/types"; +import type { TSearchEntities } from "@plane/types"; export const EDITOR_MENTION_TYPES: TSearchEntities[] = ["user_mention"]; diff --git a/apps/web/ce/constants/gantt-chart.ts b/apps/web/ce/constants/gantt-chart.ts index 228b94b5b..95e39bcc8 100644 --- a/apps/web/ce/constants/gantt-chart.ts +++ b/apps/web/ce/constants/gantt-chart.ts @@ -1,4 +1,4 @@ -import { TIssueRelationTypes } from "../types"; +import type { TIssueRelationTypes } from "../types"; export const REVERSE_RELATIONS: { [key in TIssueRelationTypes]: TIssueRelationTypes } = { blocked_by: "blocking", diff --git a/apps/web/ce/constants/project/settings/features.tsx b/apps/web/ce/constants/project/settings/features.tsx index 3ad27ddbb..b86135f08 100644 --- a/apps/web/ce/constants/project/settings/features.tsx +++ b/apps/web/ce/constants/project/settings/features.tsx @@ -1,8 +1,8 @@ -import { ReactNode } from "react"; -import { FileText, Layers, Timer } from "lucide-react"; +import type { ReactNode } from "react"; +import { Timer } from "lucide-react"; // plane imports -import { ContrastIcon, DiceIcon, Intake } from "@plane/propel/icons"; -import { IProject } from "@plane/types"; +import { CycleIcon, IntakeIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons"; +import type { IProject } from "@plane/types"; export type TProperties = { key: string; @@ -28,7 +28,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { property: "cycle_view", title: "Cycles", description: "Timebox work as you see fit per project and change frequency from one period to the next.", - icon: , + icon: , isPro: false, isEnabled: true, }, @@ -37,7 +37,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { property: "module_view", title: "Modules", description: "Group work into sub-project-like set-ups with their own leads and assignees.", - icon: , + icon: , isPro: false, isEnabled: true, }, @@ -46,7 +46,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { property: "issue_views_view", title: "Views", description: "Save sorts, filters, and display options for later or share them.", - icon: , + icon: , isPro: false, isEnabled: true, }, @@ -55,7 +55,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { property: "page_view", title: "Pages", description: "Write anything like you write anything.", - icon: , + icon: , isPro: false, isEnabled: true, }, @@ -64,7 +64,7 @@ export const PROJECT_BASE_FEATURES_LIST: TBaseFeatureList = { property: "inbox_view", title: "Intake", description: "Consider and discuss work items before you add them to your project.", - icon: , + icon: , isPro: false, isEnabled: true, }, diff --git a/apps/web/ce/constants/project/settings/tabs.ts b/apps/web/ce/constants/project/settings/tabs.ts index 5443c6424..f78b51a74 100644 --- a/apps/web/ce/constants/project/settings/tabs.ts +++ b/apps/web/ce/constants/project/settings/tabs.ts @@ -2,7 +2,7 @@ import { EUserPermissions } from "@plane/constants"; import { SettingIcon } from "@/components/icons/attachment"; // types -import { Props } from "@/components/icons/types"; +import type { Props } from "@/components/icons/types"; // constants export const PROJECT_SETTINGS = { diff --git a/apps/web/ce/constants/sidebar-favorites.ts b/apps/web/ce/constants/sidebar-favorites.ts index 4ad0c0463..aaa615e8a 100644 --- a/apps/web/ce/constants/sidebar-favorites.ts +++ b/apps/web/ce/constants/sidebar-favorites.ts @@ -1,14 +1,15 @@ -import { Briefcase, FileText, Layers, LucideIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; // plane imports -import { ContrastIcon, DiceIcon, FavoriteFolderIcon, ISvgIcons } from "@plane/propel/icons"; -import { IFavorite } from "@plane/types"; +import type { ISvgIcons } from "@plane/propel/icons"; +import { CycleIcon, FavoriteFolderIcon, ModuleIcon, PageIcon, ProjectIcon, ViewsIcon } from "@plane/propel/icons"; +import type { IFavorite } from "@plane/types"; export const FAVORITE_ITEM_ICONS: Record | LucideIcon> = { - page: FileText, - project: Briefcase, - view: Layers, - module: DiceIcon, - cycle: ContrastIcon, + page: PageIcon, + project: ProjectIcon, + view: ViewsIcon, + module: ModuleIcon, + cycle: CycleIcon, folder: FavoriteFolderIcon, }; diff --git a/apps/web/ce/helpers/command-palette.ts b/apps/web/ce/helpers/command-palette.ts index 875f85b25..d29660a16 100644 --- a/apps/web/ce/helpers/command-palette.ts +++ b/apps/web/ce/helpers/command-palette.ts @@ -7,7 +7,7 @@ import { PROJECT_VIEW_TRACKER_ELEMENTS, WORK_ITEM_TRACKER_ELEMENTS, } from "@plane/constants"; -import { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types"; +import type { TCommandPaletteActionList, TCommandPaletteShortcut, TCommandPaletteShortcutList } from "@plane/types"; // store import { captureClick } from "@/helpers/event-tracker.helper"; import { store } from "@/lib/store-context"; diff --git a/apps/web/ce/helpers/epic-analytics.ts b/apps/web/ce/helpers/epic-analytics.ts index 43e6ffef0..1a7a9df41 100644 --- a/apps/web/ce/helpers/epic-analytics.ts +++ b/apps/web/ce/helpers/epic-analytics.ts @@ -1,4 +1,4 @@ -import { TEpicAnalyticsGroup } from "@plane/types"; +import type { TEpicAnalyticsGroup } from "@plane/types"; export const updateEpicAnalytics = () => { const updateAnalytics = ( diff --git a/apps/web/ce/helpers/issue-action-helper.ts b/apps/web/ce/helpers/issue-action-helper.ts index fdee6bcc9..a3c66e273 100644 --- a/apps/web/ce/helpers/issue-action-helper.ts +++ b/apps/web/ce/helpers/issue-action-helper.ts @@ -1,4 +1,4 @@ -import { IssueActions } from "@/hooks/use-issues-actions"; +import type { IssueActions } from "@/hooks/use-issues-actions"; export const useTeamIssueActions: () => IssueActions = () => ({ fetchIssues: () => Promise.resolve(undefined), diff --git a/apps/web/ce/helpers/issue-filter.helper.ts b/apps/web/ce/helpers/issue-filter.helper.ts index 925c4a63c..48a893d3c 100644 --- a/apps/web/ce/helpers/issue-filter.helper.ts +++ b/apps/web/ce/helpers/issue-filter.helper.ts @@ -1,5 +1,5 @@ // types -import { IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayProperties } from "@plane/types"; // lib import { store } from "@/lib/store-context"; diff --git a/apps/web/ce/helpers/work-item-filters/project-level.ts b/apps/web/ce/helpers/work-item-filters/project-level.ts new file mode 100644 index 000000000..be0bc64ec --- /dev/null +++ b/apps/web/ce/helpers/work-item-filters/project-level.ts @@ -0,0 +1,20 @@ +// plane imports +import type { EIssuesStoreType } from "@plane/types"; +// plane web imports +import type { TWorkItemFiltersEntityProps } from "@/plane-web/hooks/work-item-filters/use-work-item-filters-config"; + +export type TGetAdditionalPropsForProjectLevelFiltersHOCParams = { + entityType: EIssuesStoreType; + workspaceSlug: string; + projectId: string; +}; + +export type TGetAdditionalPropsForProjectLevelFiltersHOC = ( + params: TGetAdditionalPropsForProjectLevelFiltersHOCParams +) => TWorkItemFiltersEntityProps; + +export const getAdditionalProjectLevelFiltersHOCProps: TGetAdditionalPropsForProjectLevelFiltersHOC = ({ + workspaceSlug, +}) => ({ + workspaceSlug, +}); diff --git a/apps/web/ce/hooks/editor/use-extended-editor-config.ts b/apps/web/ce/hooks/editor/use-extended-editor-config.ts new file mode 100644 index 000000000..9ca7b74a0 --- /dev/null +++ b/apps/web/ce/hooks/editor/use-extended-editor-config.ts @@ -0,0 +1,23 @@ +import { useCallback } from "react"; +// plane imports +import type { TExtendedFileHandler } from "@plane/editor"; + +export type TExtendedEditorFileHandlersArgs = { + projectId?: string; + workspaceSlug: string; +}; + +export type TExtendedEditorConfig = { + getExtendedEditorFileHandlers: (args: TExtendedEditorFileHandlersArgs) => TExtendedFileHandler; +}; + +export const useExtendedEditorConfig = (): TExtendedEditorConfig => { + const getExtendedEditorFileHandlers: TExtendedEditorConfig["getExtendedEditorFileHandlers"] = useCallback( + () => ({}), + [] + ); + + return { + getExtendedEditorFileHandlers, + }; +}; diff --git a/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts b/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts index 028e16c88..737578481 100644 --- a/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts +++ b/apps/web/ce/hooks/pages/use-extended-editor-extensions.ts @@ -1,7 +1,7 @@ import type { IEditorPropsExtended } from "@plane/editor"; import type { TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; import type { TPageInstance } from "@/store/pages/base-page"; -import { EPageStoreType } from "../store"; +import type { EPageStoreType } from "../store"; export type TExtendedEditorExtensionsHookParams = { workspaceSlug: string; diff --git a/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts index cd405c1ca..5aef069cf 100644 --- a/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts +++ b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts @@ -1,14 +1,16 @@ -import { useCallback, useMemo, type RefObject } from "react"; +import { useCallback, useMemo } from "react"; +import type { RefObject } from "react"; import { useSearchParams } from "next/navigation"; import type { EditorRefApi } from "@plane/editor"; import { PAGE_NAVIGATION_PANE_TAB_KEYS, PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, + PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, } from "@/components/pages/navigation-pane"; import { useAppRouter } from "@/hooks/use-app-router"; import { useQueryParams } from "@/hooks/use-query-params"; import type { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane"; -import { INavigationPaneExtension } from "@/plane-web/types/pages/pane-extensions"; +import type { INavigationPaneExtension } from "@/plane-web/types/pages/pane-extensions"; import type { TPageInstance } from "@/store/pages/base-page"; export type TPageExtensionHookParams = { @@ -43,10 +45,18 @@ export const usePagesPaneExtensions = (_params: TPageExtensionHookParams) => { const navigationPaneExtensions: INavigationPaneExtension[] = []; + const handleCloseNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + router.push(updatedRoute); + }, [router, updateQueryParams]); + return { editorExtensionHandlers, navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen, + handleCloseNavigationPane, }; }; diff --git a/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts new file mode 100644 index 000000000..0c65a4de8 --- /dev/null +++ b/apps/web/ce/hooks/rich-filters/use-filters-operator-configs.ts @@ -0,0 +1,16 @@ +import type { TSupportedOperators } from "@plane/types"; +import { CORE_OPERATORS } from "@plane/types"; + +export type TFiltersOperatorConfigs = { + allowedOperators: Set; + allowNegative: boolean; +}; + +export type TUseFiltersOperatorConfigsProps = { + workspaceSlug: string; +}; + +export const useFiltersOperatorConfigs = (_props: TUseFiltersOperatorConfigsProps): TFiltersOperatorConfigs => ({ + allowedOperators: new Set(Object.values(CORE_OPERATORS)), + allowNegative: false, +}); diff --git a/apps/web/ce/hooks/store/use-page-store.ts b/apps/web/ce/hooks/store/use-page-store.ts index 91bf9306b..025e03836 100644 --- a/apps/web/ce/hooks/store/use-page-store.ts +++ b/apps/web/ce/hooks/store/use-page-store.ts @@ -2,7 +2,7 @@ import { useContext } from "react"; // context import { StoreContext } from "@/lib/store-context"; // mobx store -import { IProjectPageStore } from "@/store/pages/project-page.store"; +import type { IProjectPageStore } from "@/store/pages/project-page.store"; export enum EPageStoreType { PROJECT = "PROJECT_PAGE", diff --git a/apps/web/ce/hooks/store/use-page.ts b/apps/web/ce/hooks/store/use-page.ts index c7bd7ceed..d4c531fe4 100644 --- a/apps/web/ce/hooks/store/use-page.ts +++ b/apps/web/ce/hooks/store/use-page.ts @@ -2,7 +2,8 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "@/lib/store-context"; // plane web hooks -import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; +import { usePageStore } from "@/plane-web/hooks/store"; export type TArgs = { pageId: string; diff --git a/apps/web/ce/hooks/use-additional-favorite-item-details.ts b/apps/web/ce/hooks/use-additional-favorite-item-details.ts index 412f4a39a..7d1a6d36f 100644 --- a/apps/web/ce/hooks/use-additional-favorite-item-details.ts +++ b/apps/web/ce/hooks/use-additional-favorite-item-details.ts @@ -1,5 +1,5 @@ // plane imports -import { IFavorite } from "@plane/types"; +import type { IFavorite } from "@plane/types"; // components import { getFavoriteItemIcon } from "@/components/workspace/sidebar/favorites/favorite-items/common"; diff --git a/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx b/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx index 8028a6191..b8c32d1bd 100644 --- a/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx +++ b/apps/web/ce/hooks/use-debounced-duplicate-issues.tsx @@ -1,4 +1,4 @@ -import { TDeDupeIssue } from "@plane/types"; +import type { TDeDupeIssue } from "@plane/types"; export const useDebouncedDuplicateIssues = ( workspaceSlug: string | undefined, diff --git a/apps/web/ce/hooks/use-editor-flagging.ts b/apps/web/ce/hooks/use-editor-flagging.ts index 0fc8a6eb4..731c6715e 100644 --- a/apps/web/ce/hooks/use-editor-flagging.ts +++ b/apps/web/ce/hooks/use-editor-flagging.ts @@ -1,6 +1,6 @@ // editor import type { TExtensions } from "@plane/editor"; -import { EPageStoreType } from "@/plane-web/hooks/store"; +import type { EPageStoreType } from "@/plane-web/hooks/store"; export type TEditorFlaggingHookReturnType = { document: { @@ -25,7 +25,7 @@ export type TEditorFlaggingHookProps = { /** * @description extensions disabled in various editors */ -export const useEditorFlagging = (props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({ +export const useEditorFlagging = (_props: TEditorFlaggingHookProps): TEditorFlaggingHookReturnType => ({ document: { disabled: ["ai", "collaboration-cursor"], flagged: [], diff --git a/apps/web/ce/hooks/use-issue-properties.tsx b/apps/web/ce/hooks/use-issue-properties.tsx index c4d35d6ad..12a020c8c 100644 --- a/apps/web/ce/hooks/use-issue-properties.tsx +++ b/apps/web/ce/hooks/use-issue-properties.tsx @@ -1,4 +1,4 @@ -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; export const useWorkItemProperties = ( projectId: string | null | undefined, diff --git a/apps/web/ce/hooks/use-notification-preview.tsx b/apps/web/ce/hooks/use-notification-preview.tsx index 7492ea105..6e21868a5 100644 --- a/apps/web/ce/hooks/use-notification-preview.tsx +++ b/apps/web/ce/hooks/use-notification-preview.tsx @@ -1,7 +1,8 @@ -import { EIssueServiceType, IWorkItemPeekOverview } from "@plane/types"; +import type { IWorkItemPeekOverview } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { IssuePeekOverview } from "@/components/issues/peek-overview"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { TPeekIssue } from "@/store/issue/issue-details/root.store"; +import type { TPeekIssue } from "@/store/issue/issue-details/root.store"; export type TNotificationPreview = { isWorkItem: boolean; diff --git a/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx new file mode 100644 index 000000000..edc6f54fa --- /dev/null +++ b/apps/web/ce/hooks/work-item-filters/use-work-item-filters-config.tsx @@ -0,0 +1,401 @@ +import { useCallback, useMemo } from "react"; +import { + AtSign, + Briefcase, + Calendar, + CalendarCheck2, + CalendarClock, + CircleUserRound, + SignalHigh, + Tag, + Users, +} from "lucide-react"; +// plane imports +import { + CycleGroupIcon, + CycleIcon, + ModuleIcon, + DoubleCircleIcon, + PriorityIcon, + StateGroupIcon, +} from "@plane/propel/icons"; +import type { + ICycle, + IState, + IUserLite, + TFilterConfig, + TFilterValue, + IIssueLabel, + IModule, + IProject, + TWorkItemFilterProperty, +} from "@plane/types"; +import { Avatar, Logo } from "@plane/ui"; +import { + getAssigneeFilterConfig, + getCreatedAtFilterConfig, + getCreatedByFilterConfig, + getCycleFilterConfig, + getFileURL, + getLabelFilterConfig, + getMentionFilterConfig, + getModuleFilterConfig, + getPriorityFilterConfig, + getProjectFilterConfig, + getStartDateFilterConfig, + getStateFilterConfig, + getStateGroupFilterConfig, + getSubscriberFilterConfig, + getTargetDateFilterConfig, + getUpdatedAtFilterConfig, + isLoaderReady, +} from "@plane/utils"; +// store hooks +import { useCycle } from "@/hooks/store/use-cycle"; +import { useLabel } from "@/hooks/store/use-label"; +import { useMember } from "@/hooks/store/use-member"; +import { useModule } from "@/hooks/store/use-module"; +import { useProject } from "@/hooks/store/use-project"; +import { useProjectState } from "@/hooks/store/use-project-state"; +// plane web imports +import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs"; + +export type TWorkItemFiltersEntityProps = { + workspaceSlug: string; + cycleIds?: string[]; + labelIds?: string[]; + memberIds?: string[]; + moduleIds?: string[]; + projectId?: string; + projectIds?: string[]; + stateIds?: string[]; +}; + +export type TUseWorkItemFiltersConfigProps = { + allowedFilters: TWorkItemFilterProperty[]; +} & TWorkItemFiltersEntityProps; + +export type TWorkItemFiltersConfig = { + areAllConfigsInitialized: boolean; + configs: TFilterConfig[]; + configMap: { + [key in TWorkItemFilterProperty]?: TFilterConfig; + }; + isFilterEnabled: (key: TWorkItemFilterProperty) => boolean; + members: IUserLite[]; +}; + +export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): TWorkItemFiltersConfig => { + const { allowedFilters, cycleIds, labelIds, memberIds, moduleIds, projectId, projectIds, stateIds, workspaceSlug } = + props; + // store hooks + const { loader: projectLoader, getProjectById } = useProject(); + const { getCycleById } = useCycle(); + const { getLabelById } = useLabel(); + const { getModuleById } = useModule(); + const { getStateById } = useProjectState(); + const { getUserDetails } = useMember(); + // derived values + const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug }); + const filtersToShow = useMemo(() => new Set(allowedFilters), [allowedFilters]); + const project = useMemo(() => getProjectById(projectId), [projectId, getProjectById]); + const members: IUserLite[] | undefined = useMemo( + () => + memberIds + ? (memberIds.map((memberId) => getUserDetails(memberId)).filter((member) => member) as IUserLite[]) + : undefined, + [memberIds, getUserDetails] + ); + const workItemStates: IState[] | undefined = useMemo( + () => + stateIds ? (stateIds.map((stateId) => getStateById(stateId)).filter((state) => state) as IState[]) : undefined, + [stateIds, getStateById] + ); + const workItemLabels: IIssueLabel[] | undefined = useMemo( + () => + labelIds + ? (labelIds.map((labelId) => getLabelById(labelId)).filter((label) => label) as IIssueLabel[]) + : undefined, + [labelIds, getLabelById] + ); + const cycles = useMemo( + () => (cycleIds ? (cycleIds.map((cycleId) => getCycleById(cycleId)).filter((cycle) => cycle) as ICycle[]) : []), + [cycleIds, getCycleById] + ); + const modules = useMemo( + () => + moduleIds ? (moduleIds.map((moduleId) => getModuleById(moduleId)).filter((module) => module) as IModule[]) : [], + [moduleIds, getModuleById] + ); + const projects = useMemo( + () => + projectIds + ? (projectIds.map((projectId) => getProjectById(projectId)).filter((project) => project) as IProject[]) + : [], + [projectIds, getProjectById] + ); + const areAllConfigsInitialized = useMemo(() => isLoaderReady(projectLoader), [projectLoader]); + + /** + * Checks if a filter is enabled based on the filters to show. + * @param key - The filter key. + * @param level - The level of the filter. + * @returns True if the filter is enabled, false otherwise. + */ + const isFilterEnabled = useCallback((key: TWorkItemFilterProperty) => filtersToShow.has(key), [filtersToShow]); + + // state group filter config + const stateGroupFilterConfig = useMemo( + () => + getStateGroupFilterConfig("state_group")({ + isEnabled: isFilterEnabled("state_group"), + filterIcon: DoubleCircleIcon, + getOptionIcon: (stateGroupKey) => , + ...operatorConfigs, + }), + [isFilterEnabled, operatorConfigs] + ); + + // state filter config + const stateFilterConfig = useMemo( + () => + getStateFilterConfig("state_id")({ + isEnabled: isFilterEnabled("state_id") && workItemStates !== undefined, + filterIcon: DoubleCircleIcon, + getOptionIcon: (state) => , + states: workItemStates ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, workItemStates, operatorConfigs] + ); + + // label filter config + const labelFilterConfig = useMemo( + () => + getLabelFilterConfig("label_id")({ + isEnabled: isFilterEnabled("label_id") && workItemLabels !== undefined, + filterIcon: Tag, + labels: workItemLabels ?? [], + getOptionIcon: (color) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, workItemLabels, operatorConfigs] + ); + + // cycle filter config + const cycleFilterConfig = useMemo( + () => + getCycleFilterConfig("cycle_id")({ + isEnabled: isFilterEnabled("cycle_id") && project?.cycle_view === true && cycles !== undefined, + filterIcon: CycleIcon, + getOptionIcon: (cycleGroup) => , + cycles: cycles ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, project?.cycle_view, cycles, operatorConfigs] + ); + + // module filter config + const moduleFilterConfig = useMemo( + () => + getModuleFilterConfig("module_id")({ + isEnabled: isFilterEnabled("module_id") && project?.module_view === true && modules !== undefined, + filterIcon: ModuleIcon, + getOptionIcon: () => , + modules: modules ?? [], + ...operatorConfigs, + }), + [isFilterEnabled, project?.module_view, modules, operatorConfigs] + ); + + // assignee filter config + const assigneeFilterConfig = useMemo( + () => + getAssigneeFilterConfig("assignee_id")({ + isEnabled: isFilterEnabled("assignee_id") && members !== undefined, + filterIcon: Users, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // mention filter config + const mentionFilterConfig = useMemo( + () => + getMentionFilterConfig("mention_id")({ + isEnabled: isFilterEnabled("mention_id") && members !== undefined, + filterIcon: AtSign, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // created by filter config + const createdByFilterConfig = useMemo( + () => + getCreatedByFilterConfig("created_by_id")({ + isEnabled: isFilterEnabled("created_by_id") && members !== undefined, + filterIcon: CircleUserRound, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // subscriber filter config + const subscriberFilterConfig = useMemo( + () => + getSubscriberFilterConfig("subscriber_id")({ + isEnabled: isFilterEnabled("subscriber_id") && members !== undefined, + filterIcon: Users, + members: members ?? [], + getOptionIcon: (memberDetails) => ( + + ), + ...operatorConfigs, + }), + [isFilterEnabled, members, operatorConfigs] + ); + + // priority filter config + const priorityFilterConfig = useMemo( + () => + getPriorityFilterConfig("priority")({ + isEnabled: isFilterEnabled("priority"), + filterIcon: SignalHigh, + getOptionIcon: (priority) => , + ...operatorConfigs, + }), + [isFilterEnabled, operatorConfigs] + ); + + // start date filter config + const startDateFilterConfig = useMemo( + () => + getStartDateFilterConfig("start_date")({ + isEnabled: true, + filterIcon: CalendarClock, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // target date filter config + const targetDateFilterConfig = useMemo( + () => + getTargetDateFilterConfig("target_date")({ + isEnabled: true, + filterIcon: CalendarCheck2, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // created at filter config + const createdAtFilterConfig = useMemo( + () => + getCreatedAtFilterConfig("created_at")({ + isEnabled: true, + filterIcon: Calendar, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // updated at filter config + const updatedAtFilterConfig = useMemo( + () => + getUpdatedAtFilterConfig("updated_at")({ + isEnabled: true, + filterIcon: Calendar, + ...operatorConfigs, + }), + [operatorConfigs] + ); + + // project filter config + const projectFilterConfig = useMemo( + () => + getProjectFilterConfig("project_id")({ + isEnabled: isFilterEnabled("project_id") && projects !== undefined, + filterIcon: Briefcase, + projects: projects, + getOptionIcon: (project) => , + ...operatorConfigs, + }), + [isFilterEnabled, projects, operatorConfigs] + ); + + return { + areAllConfigsInitialized, + configs: [ + stateFilterConfig, + stateGroupFilterConfig, + assigneeFilterConfig, + priorityFilterConfig, + projectFilterConfig, + mentionFilterConfig, + labelFilterConfig, + cycleFilterConfig, + moduleFilterConfig, + startDateFilterConfig, + targetDateFilterConfig, + createdAtFilterConfig, + updatedAtFilterConfig, + createdByFilterConfig, + subscriberFilterConfig, + ], + configMap: { + project_id: projectFilterConfig, + state_group: stateGroupFilterConfig, + state_id: stateFilterConfig, + label_id: labelFilterConfig, + cycle_id: cycleFilterConfig, + module_id: moduleFilterConfig, + assignee_id: assigneeFilterConfig, + mention_id: mentionFilterConfig, + created_by_id: createdByFilterConfig, + subscriber_id: subscriberFilterConfig, + priority: priorityFilterConfig, + start_date: startDateFilterConfig, + target_date: targetDateFilterConfig, + created_at: createdAtFilterConfig, + updated_at: updatedAtFilterConfig, + }, + isFilterEnabled, + members: members ?? [], + }; +}; diff --git a/apps/web/ce/layouts/project-wrapper.tsx b/apps/web/ce/layouts/project-wrapper.tsx index 6c566b0a3..71dba5044 100644 --- a/apps/web/ce/layouts/project-wrapper.tsx +++ b/apps/web/ce/layouts/project-wrapper.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // layouts import { ProjectAuthWrapper as CoreProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper"; diff --git a/apps/web/ce/layouts/workspace-wrapper.tsx b/apps/web/ce/layouts/workspace-wrapper.tsx index 3fa52a5d1..47a272d63 100644 --- a/apps/web/ce/layouts/workspace-wrapper.tsx +++ b/apps/web/ce/layouts/workspace-wrapper.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // layouts import { WorkspaceAuthWrapper as CoreWorkspaceAuthWrapper } from "@/layouts/auth-layout/workspace-wrapper"; diff --git a/apps/web/ce/services/index.ts b/apps/web/ce/services/index.ts index d0c059461..7e406b1b4 100644 --- a/apps/web/ce/services/index.ts +++ b/apps/web/ce/services/index.ts @@ -1,2 +1,2 @@ export * from "./project"; -export * from "./workspace.service"; +export * from "@/services/workspace.service"; diff --git a/apps/web/ce/services/project/estimate.service.ts b/apps/web/ce/services/project/estimate.service.ts index f33c15efb..95b9a39a4 100644 --- a/apps/web/ce/services/project/estimate.service.ts +++ b/apps/web/ce/services/project/estimate.service.ts @@ -2,7 +2,7 @@ // types import { API_BASE_URL } from "@plane/constants"; -import { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types"; +import type { IEstimate, IEstimateFormData, IEstimatePoint } from "@plane/types"; // helpers // services import { APIService } from "@/services/api.service"; diff --git a/apps/web/ce/services/project/index.ts b/apps/web/ce/services/project/index.ts index 15e12c5fd..8b75f6bf5 100644 --- a/apps/web/ce/services/project/index.ts +++ b/apps/web/ce/services/project/index.ts @@ -1,2 +1,2 @@ export * from "./estimate.service"; -export * from "./view.service"; +export * from "@/services/view.service"; diff --git a/apps/web/ce/services/project/view.service.ts b/apps/web/ce/services/project/view.service.ts deleted file mode 100644 index 5ab65a1b6..000000000 --- a/apps/web/ce/services/project/view.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { EViewAccess, TPublishViewSettings } from "@plane/types"; -import { ViewService as CoreViewService } from "@/services/view.service"; - -export class ViewService extends CoreViewService { - constructor() { - super(API_BASE_URL); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async updateViewAccess(workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async lockView(workspaceSlug: string, projectId: string, viewId: string) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async unLockView(workspaceSlug: string, projectId: string, viewId: string) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getPublishDetails(workspaceSlug: string, projectId: string, viewId: string): Promise { - return Promise.resolve({}); - } - - async publishView( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - workspaceSlug: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - projectId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - viewId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - data: TPublishViewSettings - ): Promise { - return Promise.resolve(); - } - - async updatePublishedView( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - workspaceSlug: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - projectId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - viewId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - data: Partial - ): Promise { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async unPublishView(workspaceSlug: string, projectId: string, viewId: string): Promise { - return Promise.resolve(); - } -} diff --git a/apps/web/ce/services/workspace.service.ts b/apps/web/ce/services/workspace.service.ts deleted file mode 100644 index d1e175c81..000000000 --- a/apps/web/ce/services/workspace.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { EViewAccess } from "@plane/types"; -import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service"; - -export class WorkspaceService extends CoreWorkspaceService { - constructor() { - super(API_BASE_URL); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async lockView(workspaceSlug: string, viewId: string) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async unLockView(workspaceSlug: string, viewId: string) { - return Promise.resolve(); - } -} diff --git a/apps/web/ce/store/analytics.store.ts b/apps/web/ce/store/analytics.store.ts index ef866f65a..9556dcf3a 100644 --- a/apps/web/ce/store/analytics.store.ts +++ b/apps/web/ce/store/analytics.store.ts @@ -1,4 +1,5 @@ -import { BaseAnalyticsStore, IBaseAnalyticsStore } from "@/store/analytics.store"; +import type { IBaseAnalyticsStore } from "@/store/analytics.store"; +import { BaseAnalyticsStore } from "@/store/analytics.store"; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface IAnalyticsStore extends IBaseAnalyticsStore { diff --git a/apps/web/ce/store/command-palette.store.ts b/apps/web/ce/store/command-palette.store.ts index 1b6fabf18..6a3f8abae 100644 --- a/apps/web/ce/store/command-palette.store.ts +++ b/apps/web/ce/store/command-palette.store.ts @@ -1,6 +1,7 @@ import { computed, makeObservable } from "mobx"; // types / constants -import { BaseCommandPaletteStore, IBaseCommandPaletteStore } from "@/store/base-command-palette.store"; +import type { IBaseCommandPaletteStore } from "@/store/base-command-palette.store"; +import { BaseCommandPaletteStore } from "@/store/base-command-palette.store"; export interface ICommandPaletteStore extends IBaseCommandPaletteStore { // computed diff --git a/apps/web/ce/store/estimates/estimate.ts b/apps/web/ce/store/estimates/estimate.ts index 0be2f1dd3..8a32799bc 100644 --- a/apps/web/ce/store/estimates/estimate.ts +++ b/apps/web/ce/store/estimates/estimate.ts @@ -1,16 +1,17 @@ -/* eslint-disable no-useless-catch */ - -import orderBy from "lodash/orderBy"; -import set from "lodash/set"; -// import unset from "lodash/unset"; +import { orderBy, set } from "lodash-es"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { IEstimate as IEstimateType, IEstimatePoint as IEstimatePointType, TEstimateSystemKeys } from "@plane/types"; +import type { + IEstimate as IEstimateType, + IEstimatePoint as IEstimatePointType, + TEstimateSystemKeys, +} from "@plane/types"; // plane web services import estimateService from "@/plane-web/services/project/estimate.service"; // store -import { IEstimatePoint, EstimatePoint } from "@/store/estimates/estimate-point"; +import type { IEstimatePoint } from "@/store/estimates/estimate-point"; +import { EstimatePoint } from "@/store/estimates/estimate-point"; import type { CoreRootStore } from "@/store/root.store"; type TErrorCodes = { @@ -140,19 +141,15 @@ export class Estimate implements IEstimate { projectId: string, payload: Partial ): Promise => { - try { - if (!this.id || !payload) return; + if (!this.id || !payload) return; - const estimatePoint = await estimateService.createEstimatePoint(workspaceSlug, projectId, this.id, payload); - if (estimatePoint) { - runInAction(() => { - if (estimatePoint.id) { - set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint)); - } - }); - } - } catch (error) { - throw error; + const estimatePoint = await estimateService.createEstimatePoint(workspaceSlug, projectId, this.id, payload); + if (estimatePoint) { + runInAction(() => { + if (estimatePoint.id) { + set(this.estimatePoints, [estimatePoint.id], new EstimatePoint(this.store, this.data, estimatePoint)); + } + }); } }; } diff --git a/apps/web/ce/store/global-view.store.ts b/apps/web/ce/store/global-view.store.ts new file mode 100644 index 000000000..f0d5cdfb4 --- /dev/null +++ b/apps/web/ce/store/global-view.store.ts @@ -0,0 +1 @@ +export * from "@/store/global-view.store"; diff --git a/apps/web/ce/store/issue/epic/filter.store.ts b/apps/web/ce/store/issue/epic/filter.store.ts index a4733c60a..999e1515d 100644 --- a/apps/web/ce/store/issue/epic/filter.store.ts +++ b/apps/web/ce/store/issue/epic/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { 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; diff --git a/apps/web/ce/store/issue/epic/issue.store.ts b/apps/web/ce/store/issue/epic/issue.store.ts index 90ccee84d..702a4c05c 100644 --- a/apps/web/ce/store/issue/epic/issue.store.ts +++ b/apps/web/ce/store/issue/epic/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { IProjectEpicsFilter } from "./filter.store"; +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectEpicsFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors diff --git a/apps/web/ce/store/issue/helpers/base-issue-store.ts b/apps/web/ce/store/issue/helpers/base-issue-store.ts index b75a4916a..eac5cec17 100644 --- a/apps/web/ce/store/issue/helpers/base-issue-store.ts +++ b/apps/web/ce/store/issue/helpers/base-issue-store.ts @@ -1,4 +1,4 @@ -import { TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; import { getIssueIds } from "@/store/issue/helpers/base-issues-utils"; export const workItemSortWithOrderByExtended = (array: TIssue[], key?: string) => getIssueIds(array); diff --git a/apps/web/ce/store/issue/helpers/base-issue.store.ts b/apps/web/ce/store/issue/helpers/base-issue.store.ts index b75a4916a..eac5cec17 100644 --- a/apps/web/ce/store/issue/helpers/base-issue.store.ts +++ b/apps/web/ce/store/issue/helpers/base-issue.store.ts @@ -1,4 +1,4 @@ -import { TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; import { getIssueIds } from "@/store/issue/helpers/base-issues-utils"; export const workItemSortWithOrderByExtended = (array: TIssue[], key?: string) => getIssueIds(array); diff --git a/apps/web/ce/store/issue/issue-details/activity.store.ts b/apps/web/ce/store/issue/issue-details/activity.store.ts index 95f133cf9..ab86bc1c1 100644 --- a/apps/web/ce/store/issue/issue-details/activity.store.ts +++ b/apps/web/ce/store/issue/issue-details/activity.store.ts @@ -1,22 +1,17 @@ -/* eslint-disable no-useless-catch */ - -import concat from "lodash/concat"; -import orderBy from "lodash/orderBy"; -import set from "lodash/set"; -import uniq from "lodash/uniq"; -import update from "lodash/update"; +import { concat, orderBy, set, uniq, update } from "lodash-es"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // plane package imports -import { E_SORT_ORDER, EActivityFilterType } from "@plane/constants"; -import { - EIssueServiceType, +import type { E_SORT_ORDER } from "@plane/constants"; +import { EActivityFilterType } from "@plane/constants"; +import type { TIssueActivityComment, TIssueActivity, TIssueActivityMap, TIssueActivityIdMap, TIssueServiceType, } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // plane web constants // services import { IssueActivityService } from "@/services/issue"; @@ -51,7 +46,6 @@ export class IssueActivityStore implements IIssueActivityStore { loader: TActivityLoader = "fetch"; activities: TIssueActivityIdMap = {}; activityMap: TIssueActivityMap = {}; - // services serviceType; issueActivityService; @@ -84,10 +78,10 @@ export class IssueActivityStore implements IIssueActivityStore { return this.activityMap[activityId] ?? undefined; }; - getActivityAndCommentsByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { + protected buildActivityAndCommentItems(issueId: string): TIssueActivityComment[] | undefined { if (!issueId) return undefined; - let activityComments: TIssueActivityComment[] = []; + const activityComments: TIssueActivityComment[] = []; const currentStore = this.serviceType === EIssueServiceType.EPICS ? this.store.issue.epicDetail : this.store.issue.issueDetail; @@ -97,17 +91,25 @@ export class IssueActivityStore implements IIssueActivityStore { if (!activities || !comments) return undefined; - activities?.forEach((activityId) => { + activities.forEach((activityId) => { const activity = this.getActivityById(activityId); if (!activity) return; + const type = + activity.field === "state" + ? EActivityFilterType.STATE + : activity.field === "assignees" + ? EActivityFilterType.ASSIGNEE + : activity.field === null + ? EActivityFilterType.DEFAULT + : EActivityFilterType.ACTIVITY; activityComments.push({ id: activity.id, - activity_type: EActivityFilterType.ACTIVITY, + activity_type: type, created_at: activity.created_at, }); }); - comments?.forEach((commentId) => { + comments.forEach((commentId) => { const comment = currentStore.comment.getCommentById(commentId); if (!comment) return; activityComments.push({ @@ -117,9 +119,17 @@ export class IssueActivityStore implements IIssueActivityStore { }); }); - activityComments = orderBy(activityComments, (e) => new Date(e.created_at || 0), sortOrder); - return activityComments; + } + + protected sortActivityComments(items: TIssueActivityComment[], sortOrder: E_SORT_ORDER): TIssueActivityComment[] { + return orderBy(items, (e) => new Date(e.created_at || 0), sortOrder); + } + + getActivityAndCommentsByIssueId = computedFn((issueId: string, sortOrder: E_SORT_ORDER) => { + const baseItems = this.buildActivityAndCommentItems(issueId); + if (!baseItems) return undefined; + return this.sortActivityComments(baseItems, sortOrder); }); // actions diff --git a/apps/web/ce/store/issue/issue-details/root.store.ts b/apps/web/ce/store/issue/issue-details/root.store.ts index bbea3f46b..2bc4f03ed 100644 --- a/apps/web/ce/store/issue/issue-details/root.store.ts +++ b/apps/web/ce/store/issue/issue-details/root.store.ts @@ -1,10 +1,8 @@ import { makeObservable } from "mobx"; -import { TIssueServiceType } from "@plane/types"; -import { - IssueDetail as IssueDetailCore, - IIssueDetail as IIssueDetailCore, -} from "@/store/issue/issue-details/root.store"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { TIssueServiceType } from "@plane/types"; +import type { IIssueDetail as IIssueDetailCore } from "@/store/issue/issue-details/root.store"; +import { IssueDetail as IssueDetailCore } from "@/store/issue/issue-details/root.store"; +import type { IIssueRootStore } from "@/store/issue/root.store"; export type IIssueDetail = IIssueDetailCore; diff --git a/apps/web/ce/store/issue/team-project/filter.store.ts b/apps/web/ce/store/issue/team-project/filter.store.ts index 7905325bc..8cdb7787d 100644 --- a/apps/web/ce/store/issue/team-project/filter.store.ts +++ b/apps/web/ce/store/issue/team-project/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamProjectWorkItemsFilter = IProjectIssuesFilter; diff --git a/apps/web/ce/store/issue/team-project/issue.store.ts b/apps/web/ce/store/issue/team-project/issue.store.ts index 2b2d408b6..496d5fda6 100644 --- a/apps/web/ce/store/issue/team-project/issue.store.ts +++ b/apps/web/ce/store/issue/team-project/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { ITeamProjectWorkItemsFilter } from "./filter.store"; +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamProjectWorkItemsFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamProjectWorkItems = IProjectIssues; diff --git a/apps/web/ce/store/issue/team-views/filter.store.ts b/apps/web/ce/store/issue/team-views/filter.store.ts index 9c33f9405..a40a3eaa2 100644 --- a/apps/web/ce/store/issue/team-views/filter.store.ts +++ b/apps/web/ce/store/issue/team-views/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectViewIssuesFilter, ProjectViewIssuesFilter } from "@/store/issue/project-views"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { ProjectViewIssuesFilter } from "@/store/issue/project-views"; +import type { 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; diff --git a/apps/web/ce/store/issue/team-views/issue.store.ts b/apps/web/ce/store/issue/team-views/issue.store.ts index 328370f85..8bcfbc67e 100644 --- a/apps/web/ce/store/issue/team-views/issue.store.ts +++ b/apps/web/ce/store/issue/team-views/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectViewIssues, ProjectViewIssues } from "@/store/issue/project-views"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { ITeamViewIssuesFilter } from "./filter.store"; +import type { IProjectViewIssues } from "@/store/issue/project-views"; +import { ProjectViewIssues } from "@/store/issue/project-views"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamViewIssuesFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamViewIssues = IProjectViewIssues; diff --git a/apps/web/ce/store/issue/team/filter.store.ts b/apps/web/ce/store/issue/team/filter.store.ts index 42b2d5dd2..62e1f2eb6 100644 --- a/apps/web/ce/store/issue/team/filter.store.ts +++ b/apps/web/ce/store/issue/team/filter.store.ts @@ -1,5 +1,6 @@ -import { IProjectIssuesFilter, ProjectIssuesFilter } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; +import type { IProjectIssuesFilter } from "@/store/issue/project"; +import { ProjectIssuesFilter } from "@/store/issue/project"; +import type { 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; diff --git a/apps/web/ce/store/issue/team/issue.store.ts b/apps/web/ce/store/issue/team/issue.store.ts index 2e3979436..446332c58 100644 --- a/apps/web/ce/store/issue/team/issue.store.ts +++ b/apps/web/ce/store/issue/team/issue.store.ts @@ -1,6 +1,7 @@ -import { IProjectIssues, ProjectIssues } from "@/store/issue/project"; -import { IIssueRootStore } from "@/store/issue/root.store"; -import { ITeamIssuesFilter } from "./filter.store"; +import type { IProjectIssues } from "@/store/issue/project"; +import { ProjectIssues } from "@/store/issue/project"; +import type { IIssueRootStore } from "@/store/issue/root.store"; +import type { ITeamIssuesFilter } from "./filter.store"; // @ts-nocheck - This class will never be used, extending similar class to avoid type errors export type ITeamIssues = IProjectIssues; diff --git a/apps/web/ce/store/member/project-member.store.ts b/apps/web/ce/store/member/project-member.store.ts index 10aeb842a..f0e5b3069 100644 --- a/apps/web/ce/store/member/project-member.store.ts +++ b/apps/web/ce/store/member/project-member.store.ts @@ -1,11 +1,12 @@ import { computedFn } from "mobx-utils"; -import { EUserProjectRoles } from "@plane/types"; +import type { EUserProjectRoles } from "@plane/types"; // plane imports // plane web imports import type { RootStore } from "@/plane-web/store/root.store"; // store import type { IMemberRootStore } from "@/store/member"; -import { BaseProjectMemberStore, IBaseProjectMemberStore } from "@/store/member/base-project-member.store"; +import type { IBaseProjectMemberStore } from "@/store/member/project/base-project-member.store"; +import { BaseProjectMemberStore } from "@/store/member/project/base-project-member.store"; export type IProjectMemberStore = IBaseProjectMemberStore; diff --git a/apps/web/ce/store/project-view.store.ts b/apps/web/ce/store/project-view.store.ts new file mode 100644 index 000000000..41d7ba1ca --- /dev/null +++ b/apps/web/ce/store/project-view.store.ts @@ -0,0 +1 @@ +export * from "@/store/project-view.store"; diff --git a/apps/web/ce/store/root.store.ts b/apps/web/ce/store/root.store.ts index 01e6513dc..ca6caff8c 100644 --- a/apps/web/ce/store/root.store.ts +++ b/apps/web/ce/store/root.store.ts @@ -1,6 +1,7 @@ // store import { CoreRootStore } from "@/store/root.store"; -import { ITimelineStore, TimeLineStore } from "./timeline"; +import type { ITimelineStore } from "./timeline"; +import { TimeLineStore } from "./timeline"; export class RootStore extends CoreRootStore { timelineStore: ITimelineStore; diff --git a/apps/web/ce/store/timeline/base-timeline.store.ts b/apps/web/ce/store/timeline/base-timeline.store.ts index 35b1a10f4..37a75d3de 100644 --- a/apps/web/ce/store/timeline/base-timeline.store.ts +++ b/apps/web/ce/store/timeline/base-timeline.store.ts @@ -1,5 +1,4 @@ -import isEqual from "lodash/isEqual"; -import set from "lodash/set"; +import { isEqual, set } from "lodash-es"; import { action, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // components @@ -314,7 +313,7 @@ export class BaseTimeLineStore implements IBaseTimelineStore { }); /** - * updates the block's position such as marginLeft and width wile dragging + * updates the block's position such as marginLeft and width while dragging * @param id * @param deltaLeft * @param deltaWidth diff --git a/apps/web/ce/store/timeline/index.ts b/apps/web/ce/store/timeline/index.ts index 49a3c120b..a6afa124c 100644 --- a/apps/web/ce/store/timeline/index.ts +++ b/apps/web/ce/store/timeline/index.ts @@ -1,7 +1,10 @@ import type { RootStore } from "@/plane-web/store/root.store"; -import { type IIssuesTimeLineStore, IssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; -import { type IModulesTimeLineStore, ModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; -import { BaseTimeLineStore, type IBaseTimelineStore } from "./base-timeline.store"; +import { IssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; +import type { IIssuesTimeLineStore } from "@/store/timeline/issues-timeline.store"; +import { ModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; +import type { IModulesTimeLineStore } from "@/store/timeline/modules-timeline.store"; +import { BaseTimeLineStore } from "./base-timeline.store"; +import type { IBaseTimelineStore } from "./base-timeline.store"; export interface ITimelineStore { issuesTimeLineStore: IIssuesTimeLineStore; diff --git a/apps/web/ce/store/user/permission.store.ts b/apps/web/ce/store/user/permission.store.ts index 3a1977a92..11ce45478 100644 --- a/apps/web/ce/store/user/permission.store.ts +++ b/apps/web/ce/store/user/permission.store.ts @@ -1,7 +1,8 @@ import { computedFn } from "mobx-utils"; -import { EUserPermissions } from "@plane/constants"; +import type { EUserPermissions } from "@plane/constants"; import type { RootStore } from "@/plane-web/store/root.store"; -import { BaseUserPermissionStore, type IBaseUserPermissionStore } from "@/store/user/base-permissions.store"; +import { BaseUserPermissionStore } from "@/store/user/base-permissions.store"; +import type { IBaseUserPermissionStore } from "@/store/user/base-permissions.store"; export type IUserPermissionStore = IBaseUserPermissionStore; diff --git a/apps/web/ce/types/pages/pane-extensions.ts b/apps/web/ce/types/pages/pane-extensions.ts index 0f64b3019..dcfae12e8 100644 --- a/apps/web/ce/types/pages/pane-extensions.ts +++ b/apps/web/ce/types/pages/pane-extensions.ts @@ -1,6 +1,6 @@ -import { - type INavigationPaneExtension as ICoreNavigationPaneExtension, - type INavigationPaneExtensionComponent, +import type { + INavigationPaneExtension as ICoreNavigationPaneExtension, + INavigationPaneExtensionComponent, } from "@/components/pages/navigation-pane"; // EE Union/map of extension data types (keyed by extension id) diff --git a/apps/web/ce/types/projects/project-activity.ts b/apps/web/ce/types/projects/project-activity.ts index bd61cf5ef..766b0adaf 100644 --- a/apps/web/ce/types/projects/project-activity.ts +++ b/apps/web/ce/types/projects/project-activity.ts @@ -1,4 +1,4 @@ -import { TProjectBaseActivity } from "@plane/types"; +import type { TProjectBaseActivity } from "@plane/types"; export type TProjectActivity = TProjectBaseActivity & { content: string; diff --git a/apps/web/ce/types/projects/projects.ts b/apps/web/ce/types/projects/projects.ts index 462192e26..51427282a 100644 --- a/apps/web/ce/types/projects/projects.ts +++ b/apps/web/ce/types/projects/projects.ts @@ -1,4 +1,4 @@ -import { IPartialProject, IProject } from "@plane/types"; +import type { IPartialProject, IProject } from "@plane/types"; export type TPartialProject = IPartialProject; diff --git a/apps/web/core/components/account/auth-forms/auth-banner.tsx b/apps/web/core/components/account/auth-forms/auth-banner.tsx index da1c57c4a..95022dcb2 100644 --- a/apps/web/core/components/account/auth-forms/auth-banner.tsx +++ b/apps/web/core/components/account/auth-forms/auth-banner.tsx @@ -1,9 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { Info, X } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; // helpers -import { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; type TAuthBanner = { bannerData: TAuthErrorInfo | undefined; diff --git a/apps/web/core/components/account/auth-forms/auth-header.tsx b/apps/web/core/components/account/auth-forms/auth-header.tsx index 65e6aceaf..3ca9c38b6 100644 --- a/apps/web/core/components/account/auth-forms/auth-header.tsx +++ b/apps/web/core/components/account/auth-forms/auth-header.tsx @@ -1,8 +1,8 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; import { useTranslation } from "@plane/i18n"; -import { IWorkspaceMemberInvitation } from "@plane/types"; +import type { IWorkspaceMemberInvitation } from "@plane/types"; // components import { LogoSpinner } from "@/components/common/logo-spinner"; import { WorkspaceLogo } from "@/components/workspace/logo"; diff --git a/apps/web/core/components/account/auth-forms/auth-root.tsx b/apps/web/core/components/account/auth-forms/auth-root.tsx index 639e329bf..9d01a2d3c 100644 --- a/apps/web/core/components/account/auth-forms/auth-root.tsx +++ b/apps/web/core/components/account/auth-forms/auth-root.tsx @@ -1,4 +1,5 @@ -import React, { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; import { useSearchParams } from "next/navigation"; @@ -12,12 +13,12 @@ import GithubDarkLogo from "/public/logos/github-dark.svg"; import GitlabLogo from "/public/logos/gitlab-logo.svg"; import GoogleLogo from "/public/logos/google-logo.svg"; // helpers +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; import { EAuthModes, EAuthSteps, EAuthenticationErrorCodes, EErrorAlertType, - TAuthErrorInfo, authErrorHandler, } from "@/helpers/authentication.helper"; // hooks diff --git a/apps/web/core/components/account/auth-forms/email.tsx b/apps/web/core/components/account/auth-forms/email.tsx index c4d3d890e..97a01dfba 100644 --- a/apps/web/core/components/account/auth-forms/email.tsx +++ b/apps/web/core/components/account/auth-forms/email.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, FormEvent, useMemo, useRef, useState } from "react"; +import type { FC, FormEvent } from "react"; +import { useMemo, useRef, useState } from "react"; import { observer } from "mobx-react"; // icons import { CircleAlert, XCircle } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { IEmailCheckData } from "@plane/types"; -import { Button, Input, Spinner } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import type { IEmailCheckData } from "@plane/types"; +import { Input, Spinner } from "@plane/ui"; import { cn, checkEmailValidity } from "@plane/utils"; // helpers type TAuthEmailForm = { diff --git a/apps/web/core/components/account/auth-forms/forgot-password.tsx b/apps/web/core/components/account/auth-forms/forgot-password.tsx index 39ac9b9f3..018ff9514 100644 --- a/apps/web/core/components/account/auth-forms/forgot-password.tsx +++ b/apps/web/core/components/account/auth-forms/forgot-password.tsx @@ -9,7 +9,9 @@ import { CircleCheck } from "lucide-react"; // plane imports import { AUTH_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, Input, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; +import { Button, getButtonStyling } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input } from "@plane/ui"; import { cn, checkEmailValidity } from "@plane/utils"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/account/auth-forms/form-root.tsx b/apps/web/core/components/account/auth-forms/form-root.tsx index d23afbfcc..4edbcc045 100644 --- a/apps/web/core/components/account/auth-forms/form-root.tsx +++ b/apps/web/core/components/account/auth-forms/form-root.tsx @@ -4,9 +4,10 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { EAuthModes, EAuthSteps } from "@plane/constants"; -import { IEmailCheckData } from "@plane/types"; +import type { IEmailCheckData } from "@plane/types"; // helpers -import { authErrorHandler, TAuthErrorInfo } from "@/helpers/authentication.helper"; +import type { TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { authErrorHandler } from "@/helpers/authentication.helper"; // hooks import { useInstance } from "@/hooks/store/use-instance"; import { useAppRouter } from "@/hooks/use-app-router"; diff --git a/apps/web/core/components/account/auth-forms/password.tsx b/apps/web/core/components/account/auth-forms/password.tsx index 578c47ed3..52d32dc5d 100644 --- a/apps/web/core/components/account/auth-forms/password.tsx +++ b/apps/web/core/components/account/auth-forms/password.tsx @@ -8,7 +8,8 @@ import { Eye, EyeOff, Info, X, XCircle } from "lucide-react"; // plane imports import { API_BASE_URL, E_PASSWORD_STRENGTH, AUTH_TRACKER_EVENTS, AUTH_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { Input, PasswordStrengthIndicator, Spinner } from "@plane/ui"; import { getPasswordStrength } from "@plane/utils"; // components import { ForgotPasswordPopover } from "@/components/account/auth-forms/forgot-password-popover"; diff --git a/apps/web/core/components/account/auth-forms/reset-password.tsx b/apps/web/core/components/account/auth-forms/reset-password.tsx index be9c734e9..ffe40d7e6 100644 --- a/apps/web/core/components/account/auth-forms/reset-password.tsx +++ b/apps/web/core/components/account/auth-forms/reset-password.tsx @@ -8,16 +8,13 @@ import { Eye, EyeOff } from "lucide-react"; // ui import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, Input, PasswordStrengthIndicator } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; // components import { getPasswordStrength } from "@plane/utils"; // helpers -import { - EAuthenticationErrorCodes, - EErrorAlertType, - TAuthErrorInfo, - authErrorHandler, -} from "@/helpers/authentication.helper"; +import type { EAuthenticationErrorCodes, TAuthErrorInfo } from "@/helpers/authentication.helper"; +import { EErrorAlertType, authErrorHandler } from "@/helpers/authentication.helper"; // services import { AuthService } from "@/services/auth.service"; // local imports diff --git a/apps/web/core/components/account/auth-forms/set-password.tsx b/apps/web/core/components/account/auth-forms/set-password.tsx index 8c5ee1ee3..a84ee913d 100644 --- a/apps/web/core/components/account/auth-forms/set-password.tsx +++ b/apps/web/core/components/account/auth-forms/set-password.tsx @@ -1,6 +1,7 @@ "use client"; -import { FormEvent, useEffect, useMemo, useState } from "react"; +import type { FormEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // icons @@ -8,7 +9,9 @@ import { Eye, EyeOff } from "lucide-react"; // plane imports import { AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS, E_PASSWORD_STRENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, Input, PasswordStrengthIndicator, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { Input, PasswordStrengthIndicator } from "@plane/ui"; // components import { getPasswordStrength } from "@plane/utils"; // helpers diff --git a/apps/web/core/components/account/auth-forms/unique-code.tsx b/apps/web/core/components/account/auth-forms/unique-code.tsx index a8aac8dfb..c009fdf17 100644 --- a/apps/web/core/components/account/auth-forms/unique-code.tsx +++ b/apps/web/core/components/account/auth-forms/unique-code.tsx @@ -4,7 +4,8 @@ import React, { useEffect, useState } from "react"; import { CircleCheck, XCircle } from "lucide-react"; import { API_BASE_URL, AUTH_TRACKER_ELEMENTS, AUTH_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, Input, Spinner } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { Input, Spinner } from "@plane/ui"; // constants // helpers import { EAuthModes } from "@/helpers/authentication.helper"; diff --git a/apps/web/core/components/account/deactivate-account-modal.tsx b/apps/web/core/components/account/deactivate-account-modal.tsx index 97ad8139d..548434ddc 100644 --- a/apps/web/core/components/account/deactivate-account-modal.tsx +++ b/apps/web/core/components/account/deactivate-account-modal.tsx @@ -6,7 +6,8 @@ import { Dialog, Transition } from "@headlessui/react"; import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useUser } from "@/hooks/store/user"; diff --git a/apps/web/core/components/analytics/export.ts b/apps/web/core/components/analytics/export.ts index f0a448979..8a8307857 100644 --- a/apps/web/core/components/analytics/export.ts +++ b/apps/web/core/components/analytics/export.ts @@ -1,4 +1,4 @@ -import { ColumnDef, Row } from "@tanstack/react-table"; +import type { ColumnDef, Row } from "@tanstack/react-table"; import { download, generateCsv, mkConfig } from "export-to-csv"; export const csvConfig = (workspaceSlug: string) => diff --git a/apps/web/core/components/analytics/insight-card.tsx b/apps/web/core/components/analytics/insight-card.tsx index 739de6645..115cdd2c6 100644 --- a/apps/web/core/components/analytics/insight-card.tsx +++ b/apps/web/core/components/analytics/insight-card.tsx @@ -1,28 +1,17 @@ // plane package imports -import React, { useMemo } from "react"; -import { IAnalyticsResponseFields } from "@plane/types"; +import React from "react"; +import type { IAnalyticsResponseFields } from "@plane/types"; import { Loader } from "@plane/ui"; -// components -import TrendPiece from "./trend-piece"; export type InsightCardProps = { data?: IAnalyticsResponseFields; label: string; isLoading?: boolean; - versus?: string | null; }; const InsightCard = (props: InsightCardProps) => { - const { data, label, isLoading, versus } = props; - const { count, filter_count } = data || {}; - const percentage = useMemo(() => { - if (count != null && filter_count != null) { - const result = ((count - filter_count) / count) * 100; - const isFiniteAndNotNaNOrZero = Number.isFinite(result) && !Number.isNaN(result) && result !== 0; - return isFiniteAndNotNaNOrZero ? result : null; - } - return null; - }, [count, filter_count]); + const { data, label, isLoading = false } = props; + const count = data?.count ?? 0; return (
@@ -30,12 +19,6 @@ const InsightCard = (props: InsightCardProps) => { {!isLoading ? (
{count}
- {/* {percentage && ( -
- - {versus &&
vs {versus}
} -
- )} */}
) : ( diff --git a/apps/web/core/components/analytics/insight-table/data-table.tsx b/apps/web/core/components/analytics/insight-table/data-table.tsx index 8b1c1bab2..be802457e 100644 --- a/apps/web/core/components/analytics/insight-table/data-table.tsx +++ b/apps/web/core/components/analytics/insight-table/data-table.tsx @@ -1,12 +1,14 @@ "use client"; import * as React from "react"; -import { +import type { ColumnDef, ColumnFiltersState, SortingState, VisibilityState, Table as TanstackTable, +} from "@tanstack/react-table"; +import { flexRender, getCoreRowModel, getFacetedRowModel, diff --git a/apps/web/core/components/analytics/insight-table/loader.tsx b/apps/web/core/components/analytics/insight-table/loader.tsx index 0f7f9dc35..0ccff9a9b 100644 --- a/apps/web/core/components/analytics/insight-table/loader.tsx +++ b/apps/web/core/components/analytics/insight-table/loader.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ColumnDef } from "@tanstack/react-table"; +import type { ColumnDef } from "@tanstack/react-table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@plane/propel/table"; import { Loader } from "@plane/ui"; diff --git a/apps/web/core/components/analytics/insight-table/root.tsx b/apps/web/core/components/analytics/insight-table/root.tsx index 2fd19393b..465353496 100644 --- a/apps/web/core/components/analytics/insight-table/root.tsx +++ b/apps/web/core/components/analytics/insight-table/root.tsx @@ -1,8 +1,8 @@ -import { ColumnDef, Row, Table } from "@tanstack/react-table"; +import type { ColumnDef, Row, Table } from "@tanstack/react-table"; import { Download } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import type { AnalyticsTableDataMap, TAnalyticsTabsBase } from "@plane/types"; import { DataTable } from "./data-table"; import { TableLoader } from "./loader"; interface InsightTableProps> { diff --git a/apps/web/core/components/analytics/overview/active-project-item.tsx b/apps/web/core/components/analytics/overview/active-project-item.tsx index b40d65359..b551f4945 100644 --- a/apps/web/core/components/analytics/overview/active-project-item.tsx +++ b/apps/web/core/components/analytics/overview/active-project-item.tsx @@ -1,4 +1,4 @@ -import { Briefcase } from "lucide-react"; +import { ProjectIcon } from "@plane/propel/icons"; // plane package imports import { cn } from "@plane/utils"; import { Logo } from "@/components/common/logo"; @@ -40,7 +40,7 @@ const ActiveProjectItem = (props: Props) => { ) : ( - + )} diff --git a/apps/web/core/components/analytics/overview/project-insights.tsx b/apps/web/core/components/analytics/overview/project-insights.tsx index 994e6e9b7..a72c79b83 100644 --- a/apps/web/core/components/analytics/overview/project-insights.tsx +++ b/apps/web/core/components/analytics/overview/project-insights.tsx @@ -4,7 +4,7 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // plane package imports import { useTranslation } from "@plane/i18n"; -import { TChartData } from "@plane/types"; +import type { TChartData } from "@plane/types"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; // services diff --git a/apps/web/core/components/analytics/select/analytics-params.tsx b/apps/web/core/components/analytics/select/analytics-params.tsx index edcebf78b..5e9535da2 100644 --- a/apps/web/core/components/analytics/select/analytics-params.tsx +++ b/apps/web/core/components/analytics/select/analytics-params.tsx @@ -1,10 +1,12 @@ import { useMemo } from "react"; import { observer } from "mobx-react"; -import { Control, Controller, UseFormSetValue } from "react-hook-form"; +import type { Control, UseFormSetValue } from "react-hook-form"; +import { Controller } from "react-hook-form"; import { Calendar, SlidersHorizontal } from "lucide-react"; // plane package imports import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES } from "@plane/constants"; -import { ChartYAxisMetric, IAnalyticsParams } from "@plane/types"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartYAxisMetric } from "@plane/types"; import { cn } from "@plane/utils"; // plane web components import { SelectXAxis } from "./select-x-axis"; diff --git a/apps/web/core/components/analytics/select/duration.tsx b/apps/web/core/components/analytics/select/duration.tsx index 5c99a61b0..f668f3c47 100644 --- a/apps/web/core/components/analytics/select/duration.tsx +++ b/apps/web/core/components/analytics/select/duration.tsx @@ -1,12 +1,13 @@ // plane package imports -import React, { ReactNode } from "react"; +import type { ReactNode } from "react"; +import React from "react"; import { Calendar } from "lucide-react"; // plane package imports import { ANALYTICS_DURATION_FILTER_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { CustomSearchSelect } from "@plane/ui"; // types -import { TDropdownProps } from "@/components/dropdowns/types"; +import type { TDropdownProps } from "@/components/dropdowns/types"; type Props = TDropdownProps & { value: string | null; diff --git a/apps/web/core/components/analytics/select/project.tsx b/apps/web/core/components/analytics/select/project.tsx index 1c97e8b94..aee309026 100644 --- a/apps/web/core/components/analytics/select/project.tsx +++ b/apps/web/core/components/analytics/select/project.tsx @@ -1,7 +1,7 @@ "use client"; import { observer } from "mobx-react"; -import { Briefcase } from "lucide-react"; +import { ProjectIcon } from "@plane/propel/icons"; // plane package imports import { CustomSearchSelect } from "@plane/ui"; // components @@ -30,7 +30,7 @@ export const ProjectSelect: React.FC = observer((props) => { {projectDetails?.logo_props ? ( ) : ( - + )} {projectDetails?.name}
@@ -45,7 +45,7 @@ export const ProjectSelect: React.FC = observer((props) => { options={options} label={
- + {value && value.length > 3 ? `3+ projects` : value && value.length > 0 diff --git a/apps/web/core/components/analytics/select/select-x-axis.tsx b/apps/web/core/components/analytics/select/select-x-axis.tsx index 9fc846c30..041fc8fee 100644 --- a/apps/web/core/components/analytics/select/select-x-axis.tsx +++ b/apps/web/core/components/analytics/select/select-x-axis.tsx @@ -1,6 +1,6 @@ "use client"; // plane package imports -import { ChartXAxisProperty } from "@plane/types"; +import type { ChartXAxisProperty } from "@plane/types"; import { CustomSelect } from "@plane/ui"; type Props = { diff --git a/apps/web/core/components/analytics/select/select-y-axis.tsx b/apps/web/core/components/analytics/select/select-y-axis.tsx index 0a3ef5742..2295f0632 100644 --- a/apps/web/core/components/analytics/select/select-y-axis.tsx +++ b/apps/web/core/components/analytics/select/select-y-axis.tsx @@ -2,9 +2,9 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { Briefcase } from "lucide-react"; import { EEstimateSystem } from "@plane/constants"; -import { ChartYAxisMetric } from "@plane/types"; +import { ProjectIcon } from "@plane/propel/icons"; +import type { ChartYAxisMetric } from "@plane/types"; // plane package imports import { CustomSelect } from "@plane/ui"; // hooks @@ -44,7 +44,7 @@ export const SelectYAxis: React.FC = observer(({ value, onChange, hiddenO value={value} label={
- + {options.find((v) => v.value === value)?.label ?? "Add Metric"}
} diff --git a/apps/web/core/components/analytics/total-insights.tsx b/apps/web/core/components/analytics/total-insights.tsx index 258ac11e1..5dfc6bb99 100644 --- a/apps/web/core/components/analytics/total-insights.tsx +++ b/apps/web/core/components/analytics/total-insights.tsx @@ -2,9 +2,10 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { IInsightField, ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants"; +import type { IInsightField } from "@plane/constants"; +import { ANALYTICS_INSIGHTS_FIELDS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types"; +import type { IAnalyticsResponse, TAnalyticsTabsBase } from "@plane/types"; import { cn } from "@plane/utils"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; @@ -19,7 +20,7 @@ const getInsightLabel = ( analyticsType: TAnalyticsTabsBase, item: IInsightField, isEpic: boolean | undefined, - t: (key: string, options?: any) => string + t: (key: string, params?: Record) => string ) => { if (analyticsType === "work-items") { return isEpic @@ -50,15 +51,7 @@ const TotalInsights: React.FC<{ const params = useParams(); const workspaceSlug = params.workspaceSlug.toString(); const { t } = useTranslation(); - const { - selectedDuration, - selectedProjects, - selectedDurationLabel, - selectedCycle, - selectedModule, - isPeekView, - isEpic, - } = useAnalytics(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); const { data: totalInsightsData, isLoading } = useSWR( `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`, () => @@ -92,7 +85,6 @@ const TotalInsights: React.FC<{ isLoading={isLoading} data={totalInsightsData?.[item.key]} label={getInsightLabel(analyticsType, item, isEpic, t)} - versus={selectedDurationLabel} /> ))}
diff --git a/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx index 5db79a815..56c7bbd80 100644 --- a/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx +++ b/apps/web/core/components/analytics/work-items/created-vs-resolved.tsx @@ -5,7 +5,7 @@ import useSWR from "swr"; // plane package imports import { useTranslation } from "@plane/i18n"; import { AreaChart } from "@plane/propel/charts/area-chart"; -import { IChartResponse, TChartData } from "@plane/types"; +import type { IChartResponse, TChartData } from "@plane/types"; import { renderFormattedDate } from "@plane/utils"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; diff --git a/apps/web/core/components/analytics/work-items/customized-insights.tsx b/apps/web/core/components/analytics/work-items/customized-insights.tsx index 42fa6e84c..22c698b0c 100644 --- a/apps/web/core/components/analytics/work-items/customized-insights.tsx +++ b/apps/web/core/components/analytics/work-items/customized-insights.tsx @@ -3,7 +3,8 @@ import { useParams } from "next/navigation"; import { useForm } from "react-hook-form"; // plane package imports import { useTranslation } from "@plane/i18n"; -import { ChartXAxisProperty, ChartYAxisMetric, IAnalyticsParams } from "@plane/types"; +import type { IAnalyticsParams } from "@plane/types"; +import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; import { cn } from "@plane/utils"; // plane web components import AnalyticsSectionWrapper from "../analytics-section-wrapper"; diff --git a/apps/web/core/components/analytics/work-items/modal/content.tsx b/apps/web/core/components/analytics/work-items/modal/content.tsx index 3f590b112..d13de2a78 100644 --- a/apps/web/core/components/analytics/work-items/modal/content.tsx +++ b/apps/web/core/components/analytics/work-items/modal/content.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { Tab } from "@headlessui/react"; // plane package imports -import { ICycle, IModule, IProject } from "@plane/types"; +import type { ICycle, IModule, IProject } from "@plane/types"; import { Spinner } from "@plane/ui"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; diff --git a/apps/web/core/components/analytics/work-items/modal/header.tsx b/apps/web/core/components/analytics/work-items/modal/header.tsx index 1aa2c1b66..734eebbf8 100644 --- a/apps/web/core/components/analytics/work-items/modal/header.tsx +++ b/apps/web/core/components/analytics/work-items/modal/header.tsx @@ -1,7 +1,7 @@ import { observer } from "mobx-react"; // plane package imports import { Expand, Shrink, X } from "lucide-react"; -import { ICycle, IModule } from "@plane/types"; +import type { ICycle, IModule } from "@plane/types"; // icons type Props = { diff --git a/apps/web/core/components/analytics/work-items/modal/index.tsx b/apps/web/core/components/analytics/work-items/modal/index.tsx index 895e23eed..129dd5f5e 100644 --- a/apps/web/core/components/analytics/work-items/modal/index.tsx +++ b/apps/web/core/components/analytics/work-items/modal/index.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react"; // plane package imports -import { createPortal } from "react-dom"; -import { ICycle, IModule, IProject } from "@plane/types"; -import { cn } from "@plane/utils"; +import { ModalPortal, EPortalWidth, EPortalPosition } from "@plane/propel/portal"; +import type { ICycle, IModule, IProject } from "@plane/types"; import { useAnalytics } from "@/hooks/store/use-analytics"; // plane web components import { WorkItemsModalMainContent } from "./content"; @@ -32,41 +31,35 @@ export const WorkItemsModal: React.FC = observer((props) => { updateIsEpic(isPeekView ? (isEpic ?? false) : false); }, [isEpic, updateIsEpic, isPeekView]); - const portalContainer = document.getElementById("full-screen-portal") as HTMLElement; - - if (!isOpen) return null; - - const content = ( -
+ return ( +
-
- - -
+ +
-
+ ); - - return portalContainer ? createPortal(content, portalContainer) : content; }); diff --git a/apps/web/core/components/analytics/work-items/priority-chart.tsx b/apps/web/core/components/analytics/work-items/priority-chart.tsx index bedb357ca..f77248d49 100644 --- a/apps/web/core/components/analytics/work-items/priority-chart.tsx +++ b/apps/web/core/components/analytics/work-items/priority-chart.tsx @@ -1,23 +1,18 @@ import { useMemo } from "react"; -import { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; +import type { ColumnDef, Row, RowData, Table } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useTheme } from "next-themes"; import useSWR from "swr"; // plane package imports import { Download } from "lucide-react"; -import { - ANALYTICS_X_AXIS_VALUES, - ANALYTICS_Y_AXIS_VALUES, - CHART_COLOR_PALETTES, - ChartXAxisDateGrouping, - EChartModels, -} from "@plane/constants"; +import type { ChartXAxisDateGrouping } from "@plane/constants"; +import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, CHART_COLOR_PALETTES, EChartModels } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; import { BarChart } from "@plane/propel/charts/bar-chart"; -import { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; +import type { TBarItem, TChart, TChartDatum, ChartXAxisProperty, ChartYAxisMetric } from "@plane/types"; // plane web components -import { Button } from "@plane/ui"; import { generateExtendedColors, parseChartData } from "@/components/chart/utils"; // hooks import { useAnalytics } from "@/hooks/store/use-analytics"; diff --git a/apps/web/core/components/analytics/work-items/utils.ts b/apps/web/core/components/analytics/work-items/utils.ts index 37c74ec81..613fa6b62 100644 --- a/apps/web/core/components/analytics/work-items/utils.ts +++ b/apps/web/core/components/analytics/work-items/utils.ts @@ -1,5 +1,6 @@ // plane package imports -import { ChartXAxisProperty, ChartYAxisMetric, IState } from "@plane/types"; +import type { ChartYAxisMetric, IState } from "@plane/types"; +import { ChartXAxisProperty } from "@plane/types"; interface ParamsProps { x_axis: ChartXAxisProperty; diff --git a/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx index d0b6262fd..0d0d69cb1 100644 --- a/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx +++ b/apps/web/core/components/analytics/work-items/workitems-insight-table.tsx @@ -1,12 +1,13 @@ import { useMemo } from "react"; -import { ColumnDef, Row, RowData } from "@tanstack/react-table"; +import type { ColumnDef, Row, RowData } from "@tanstack/react-table"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { Briefcase, UserRound } from "lucide-react"; -// plane package imports +import { UserRound } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types"; +import { ProjectIcon } from "@plane/propel/icons"; +// plane package imports +import type { AnalyticsTableDataMap, WorkItemInsightColumns } from "@plane/types"; // plane web components import { Avatar } from "@plane/ui"; import { getFileURL } from "@plane/utils"; @@ -82,7 +83,7 @@ const WorkItemsInsightTable = observer(() => { {project?.logo_props ? ( ) : ( - + )} {project?.name}
diff --git a/apps/web/core/components/api-token/delete-token-modal.tsx b/apps/web/core/components/api-token/delete-token-modal.tsx index f139a2783..22b5f5dd6 100644 --- a/apps/web/core/components/api-token/delete-token-modal.tsx +++ b/apps/web/core/components/api-token/delete-token-modal.tsx @@ -1,14 +1,16 @@ "use client"; -import { useState, FC } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { mutate } from "swr"; // types import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { APITokenService } from "@plane/services"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; // ui -import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +import { AlertModalCore } from "@plane/ui"; // fetch-keys import { API_TOKENS_LIST } from "@/constants/fetch-keys"; import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/api-token/empty-state.tsx b/apps/web/core/components/api-token/empty-state.tsx index 9c2da3bb7..194966df5 100644 --- a/apps/web/core/components/api-token/empty-state.tsx +++ b/apps/web/core/components/api-token/empty-state.tsx @@ -3,7 +3,7 @@ import React from "react"; import Image from "next/image"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // assets import emptyApiTokens from "@/public/empty-state/api-token.svg"; diff --git a/apps/web/core/components/api-token/modal/create-token-modal.tsx b/apps/web/core/components/api-token/modal/create-token-modal.tsx index af04b31c2..ac27873e9 100644 --- a/apps/web/core/components/api-token/modal/create-token-modal.tsx +++ b/apps/web/core/components/api-token/modal/create-token-modal.tsx @@ -4,9 +4,10 @@ import React, { useState } from "react"; import { mutate } from "swr"; // plane imports import { PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { APITokenService } from "@plane/services"; -import { IApiToken } from "@plane/types"; -import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +import type { IApiToken } from "@plane/types"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { renderFormattedDate, csvDownload } from "@plane/utils"; // constants import { API_TOKENS_LIST } from "@/constants/fetch-keys"; diff --git a/apps/web/core/components/api-token/modal/form.tsx b/apps/web/core/components/api-token/modal/form.tsx index 57f74cd53..a39636e90 100644 --- a/apps/web/core/components/api-token/modal/form.tsx +++ b/apps/web/core/components/api-token/modal/form.tsx @@ -6,9 +6,11 @@ import { Controller, useForm } from "react-hook-form"; import { Calendar } from "lucide-react"; // types import { useTranslation } from "@plane/i18n"; -import { IApiToken } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IApiToken } from "@plane/types"; // ui -import { Button, CustomSelect, Input, TextArea, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; +import { CustomSelect, Input, TextArea, ToggleSwitch } from "@plane/ui"; import { cn, renderFormattedDate, renderFormattedTime } from "@plane/utils"; // components import { DateDropdown } from "@/components/dropdowns/date"; diff --git a/apps/web/core/components/api-token/modal/generated-token-details.tsx b/apps/web/core/components/api-token/modal/generated-token-details.tsx index 6f4254833..a2a4dff78 100644 --- a/apps/web/core/components/api-token/modal/generated-token-details.tsx +++ b/apps/web/core/components/api-token/modal/generated-token-details.tsx @@ -2,10 +2,11 @@ import { Copy } from "lucide-react"; import { useTranslation } from "@plane/i18n"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; // ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { renderFormattedDate, renderFormattedTime, copyTextToClipboard } from "@plane/utils"; // helpers // types diff --git a/apps/web/core/components/api-token/token-list-item.tsx b/apps/web/core/components/api-token/token-list-item.tsx index 165ca0718..46ad6d9f1 100644 --- a/apps/web/core/components/api-token/token-list-item.tsx +++ b/apps/web/core/components/api-token/token-list-item.tsx @@ -5,7 +5,7 @@ import { XCircle } from "lucide-react"; // plane imports import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; import { Tooltip } from "@plane/propel/tooltip"; -import { IApiToken } from "@plane/types"; +import type { IApiToken } from "@plane/types"; import { renderFormattedDate, calculateTimeAgo, renderFormattedTime } from "@plane/utils"; // components import { DeleteApiTokenModal } from "@/components/api-token/delete-token-modal"; diff --git a/apps/web/core/components/archives/archive-tabs-list.tsx b/apps/web/core/components/archives/archive-tabs-list.tsx index d68144691..db94d191a 100644 --- a/apps/web/core/components/archives/archive-tabs-list.tsx +++ b/apps/web/core/components/archives/archive-tabs-list.tsx @@ -1,9 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; // types -import { IProject } from "@plane/types"; +import type { IProject } from "@plane/types"; // hooks import { useProject } from "@/hooks/store/use-project"; @@ -48,7 +48,7 @@ export const ArchiveTabsList: FC = observer(() => { tab.shouldRender(projectDetails) && ( = observer((props) => { className="focus:outline-none" >
- + Open Plane documentation
diff --git a/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx b/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx index 7b75fc1bd..0af68d137 100644 --- a/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx +++ b/apps/web/core/components/command-palette/actions/issue-actions/actions-list.tsx @@ -5,9 +5,10 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2, Users } from "lucide-react"; import { DoubleCircleIcon } from "@plane/propel/icons"; -import { EIssueServiceType, TIssue } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // hooks -import { TOAST_TYPE, setToast } from "@plane/ui"; // helpers import { copyTextToClipboard } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx index 13b5fc284..d68f43776 100644 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx +++ b/apps/web/core/components/command-palette/actions/issue-actions/change-assignee.tsx @@ -5,7 +5,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Check } from "lucide-react"; // plane types -import { EIssueServiceType, TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // plane ui import { Avatar } from "@plane/ui"; // helpers diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx index ef89a0706..fe983faaa 100644 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx +++ b/apps/web/core/components/command-palette/actions/issue-actions/change-priority.tsx @@ -8,7 +8,8 @@ import { Check } from "lucide-react"; import { ISSUE_PRIORITIES } from "@plane/constants"; // plane types import { PriorityIcon } from "@plane/propel/icons"; -import { EIssueServiceType, TIssue, TIssuePriorities } from "@plane/types"; +import type { TIssue, TIssuePriorities } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // mobx store import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // ui diff --git a/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx b/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx index 64d9e4449..336eb231b 100644 --- a/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx +++ b/apps/web/core/components/command-palette/actions/issue-actions/change-state.tsx @@ -3,7 +3,8 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { EIssueServiceType, TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // store hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // plane web imports diff --git a/apps/web/core/components/command-palette/actions/project-actions.tsx b/apps/web/core/components/command-palette/actions/project-actions.tsx index 9776db33d..9d9047146 100644 --- a/apps/web/core/components/command-palette/actions/project-actions.tsx +++ b/apps/web/core/components/command-palette/actions/project-actions.tsx @@ -1,7 +1,6 @@ "use client"; import { Command } from "cmdk"; -import { ContrastIcon, FileText, Layers } from "lucide-react"; // hooks import { CYCLE_TRACKER_ELEMENTS, @@ -9,7 +8,7 @@ import { PROJECT_PAGE_TRACKER_ELEMENTS, PROJECT_VIEW_TRACKER_ELEMENTS, } from "@plane/constants"; -import { DiceIcon } from "@plane/propel/icons"; +import { CycleIcon, ModuleIcon, PageIcon, ViewsIcon } from "@plane/propel/icons"; // hooks import { useCommandPalette } from "@/hooks/store/use-command-palette"; // ui @@ -36,7 +35,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { className="focus:outline-none" >
- + Create new cycle
Q @@ -52,7 +51,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { className="focus:outline-none" >
- + Create new module
M @@ -68,7 +67,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { className="focus:outline-none" >
- + Create new view
V @@ -84,7 +83,7 @@ export const CommandPaletteProjectActions: React.FC = (props) => { className="focus:outline-none" >
- + Create new page
D diff --git a/apps/web/core/components/command-palette/actions/search-results.tsx b/apps/web/core/components/command-palette/actions/search-results.tsx index a33d85ff7..e73ed71f0 100644 --- a/apps/web/core/components/command-palette/actions/search-results.tsx +++ b/apps/web/core/components/command-palette/actions/search-results.tsx @@ -4,7 +4,7 @@ import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { IWorkspaceSearchResults } from "@plane/types"; +import type { IWorkspaceSearchResults } from "@plane/types"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; // plane web imports diff --git a/apps/web/core/components/command-palette/actions/theme-actions.tsx b/apps/web/core/components/command-palette/actions/theme-actions.tsx index 108deba2c..062d7fd0b 100644 --- a/apps/web/core/components/command-palette/actions/theme-actions.tsx +++ b/apps/web/core/components/command-palette/actions/theme-actions.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import React, { useEffect, useState } from "react"; import { Command } from "cmdk"; import { observer } from "mobx-react"; import { useTheme } from "next-themes"; @@ -8,7 +9,7 @@ import { Settings } from "lucide-react"; // plane imports import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // hooks import { useUserProfile } from "@/hooks/store/user"; diff --git a/apps/web/core/components/command-palette/command-modal.tsx b/apps/web/core/components/command-palette/command-modal.tsx index e482438f6..280b2d3bc 100644 --- a/apps/web/core/components/command-palette/command-modal.tsx +++ b/apps/web/core/components/command-palette/command-modal.tsx @@ -16,8 +16,8 @@ import { WORKSPACE_DEFAULT_SEARCH_RESULT, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { LayersIcon } from "@plane/propel/icons"; -import { IWorkspaceSearchResults } from "@plane/types"; +import { WorkItemsIcon } from "@plane/propel/icons"; +import type { IWorkspaceSearchResults } from "@plane/types"; import { Loader, ToggleSwitch } from "@plane/ui"; import { cn, getTabIndex } from "@plane/utils"; // components @@ -357,7 +357,7 @@ export const CommandModal: React.FC = observer(() => { className="focus:bg-custom-background-80" >
- + Create new work item
C diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index 85115a3d7..b79b731cb 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -1,12 +1,13 @@ "use client"; -import React, { useCallback, useEffect, FC, useMemo } from "react"; +import type { FC } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; // ui import { COMMAND_PALETTE_TRACKER_ELEMENTS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // components import { copyTextToClipboard } from "@plane/utils"; import { CommandModal, ShortcutsModal } from "@/components/command-palette"; @@ -38,7 +39,7 @@ export const CommandPalette: FC = observer(() => { const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams(); // store hooks const { fetchIssueWithIdentifier } = useIssueDetail(); - const { toggleSidebar } = useAppTheme(); + const { toggleSidebar, toggleExtendedSidebar } = useAppTheme(); const { platform } = usePlatformOS(); const { data: currentUser, canPerformAnyCreateAction } = useUser(); const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette(); @@ -197,6 +198,7 @@ export const CommandPalette: FC = observer(() => { } else if (keyPressed === "b") { e.preventDefault(); toggleSidebar(); + toggleExtendedSidebar(false); } } else if (!isAnyModalOpen) { captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY }); @@ -242,6 +244,7 @@ export const CommandPalette: FC = observer(() => { toggleCommandPaletteModal, toggleShortcutModal, toggleSidebar, + toggleExtendedSidebar, workspaceSlug, ] ); diff --git a/apps/web/core/components/command-palette/shortcuts-modal/modal.tsx b/apps/web/core/components/command-palette/shortcuts-modal/modal.tsx index 0cf5f3ab9..ffd443074 100644 --- a/apps/web/core/components/command-palette/shortcuts-modal/modal.tsx +++ b/apps/web/core/components/command-palette/shortcuts-modal/modal.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useState, Fragment } from "react"; +import type { FC } from "react"; +import { useState, Fragment } from "react"; import { Search, X } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; // components diff --git a/apps/web/core/components/comments/card/display.tsx b/apps/web/core/components/comments/card/display.tsx index 68ceb09d1..16ea26378 100644 --- a/apps/web/core/components/comments/card/display.tsx +++ b/apps/web/core/components/comments/card/display.tsx @@ -5,7 +5,8 @@ import { Globe2, Lock } from "lucide-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; import { useHashScroll } from "@plane/hooks"; -import { EIssueCommentAccessSpecifier, type TCommentsOperations, type TIssueComment } from "@plane/types"; +import { EIssueCommentAccessSpecifier } from "@plane/types"; +import type { TCommentsOperations, TIssueComment } from "@plane/types"; import { cn } from "@plane/utils"; // components import { LiteTextEditor } from "@/components/editor/lite-text"; diff --git a/apps/web/core/components/comments/card/edit-form.tsx b/apps/web/core/components/comments/card/edit-form.tsx index a9bac4109..b370db0a2 100644 --- a/apps/web/core/components/comments/card/edit-form.tsx +++ b/apps/web/core/components/comments/card/edit-form.tsx @@ -93,7 +93,7 @@ export const CommentCardEditForm: React.FC = observer((props) => { const { asset_id } = await activityOperations.uploadCommentAsset(blockId, file, comment.id); return asset_id; }} - projectId={projectId?.toString() ?? ""} + projectId={projectId} parentClassName="p-2" displayConfig={{ fontSize: "small-font", diff --git a/apps/web/core/components/comments/card/root.tsx b/apps/web/core/components/comments/card/root.tsx index 2b6296dbb..f55d3fd17 100644 --- a/apps/web/core/components/comments/card/root.tsx +++ b/apps/web/core/components/comments/card/root.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useRef, useState } from "react"; +import type { FC } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; // plane imports import type { EditorRefApi } from "@plane/editor"; @@ -66,6 +67,7 @@ export const CommentCard: FC = observer((props) => { isEditing readOnlyEditorRef={readOnlyEditorRef.current} setIsEditing={setIsEditing} + projectId={projectId} workspaceId={workspaceId} workspaceSlug={workspaceSlug} /> diff --git a/apps/web/core/components/comments/comment-create.tsx b/apps/web/core/components/comments/comment-create.tsx index fd15336a0..a9b2423f9 100644 --- a/apps/web/core/components/comments/comment-create.tsx +++ b/apps/web/core/components/comments/comment-create.tsx @@ -1,4 +1,5 @@ -import { FC, useRef, useState } from "react"; +import type { FC } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; import { useForm, Controller } from "react-hook-form"; // plane imports @@ -114,6 +115,7 @@ export const CommentCreate: FC = observer((props) => { id={"add_comment_" + entityId} value={"

"} workspaceSlug={workspaceSlug} + projectId={projectId} onEnterKeyPress={(e) => { if (!isEmpty && !isSubmitting) { handleSubmit(onSubmit)(e); diff --git a/apps/web/core/components/comments/comment-reaction.tsx b/apps/web/core/components/comments/comment-reaction.tsx index 4ab763116..8e3e04b94 100644 --- a/apps/web/core/components/comments/comment-reaction.tsx +++ b/apps/web/core/components/comments/comment-reaction.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { Tooltip } from "@plane/propel/tooltip"; -import { TCommentsOperations, TIssueComment } from "@plane/types"; +import type { TCommentsOperations, TIssueComment } from "@plane/types"; import { cn } from "@plane/utils"; // helpers import { renderEmoji } from "@/helpers/emoji.helper"; diff --git a/apps/web/core/components/comments/comments.tsx b/apps/web/core/components/comments/comments.tsx index bc15adcc1..9f2cfc6c5 100644 --- a/apps/web/core/components/comments/comments.tsx +++ b/apps/web/core/components/comments/comments.tsx @@ -1,11 +1,12 @@ "use client"; -import React, { FC, useMemo } from "react"; +import type { FC } from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // plane imports -import { E_SORT_ORDER } from "@plane/constants"; -import { TCommentsOperations, TIssueComment } from "@plane/types"; +import type { E_SORT_ORDER } from "@plane/constants"; +import type { TCommentsOperations, TIssueComment } from "@plane/types"; // local components import { CommentCard } from "./card/root"; import { CommentCreate } from "./comment-create"; diff --git a/apps/web/core/components/comments/quick-actions.tsx b/apps/web/core/components/comments/quick-actions.tsx index 4f62c359d..32f176544 100644 --- a/apps/web/core/components/comments/quick-actions.tsx +++ b/apps/web/core/components/comments/quick-actions.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, useMemo } from "react"; +import type { FC } from "react"; +import { useMemo } from "react"; import { observer } from "mobx-react"; import { Globe2, Link, Lock, Pencil, Trash2 } from "lucide-react"; // plane imports import { EIssueCommentAccessSpecifier } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import type { TIssueComment, TCommentsOperations } from "@plane/types"; -import { CustomMenu, TContextMenuItem } from "@plane/ui"; +import type { TContextMenuItem } from "@plane/ui"; +import { CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks import { useUser } from "@/hooks/store/user"; diff --git a/apps/web/core/components/common/access-field.tsx b/apps/web/core/components/common/access-field.tsx index 0a96d7c3c..42006e9f7 100644 --- a/apps/web/core/components/common/access-field.tsx +++ b/apps/web/core/components/common/access-field.tsx @@ -1,4 +1,4 @@ -import { LucideIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; // plane ui import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/propel/tooltip"; diff --git a/apps/web/core/components/common/activity/activity-block.tsx b/apps/web/core/components/common/activity/activity-block.tsx index 7fc8d1d56..caf48975f 100644 --- a/apps/web/core/components/common/activity/activity-block.tsx +++ b/apps/web/core/components/common/activity/activity-block.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC, ReactNode } from "react"; +import type { FC, ReactNode } from "react"; import { Network } from "lucide-react"; // types import { Tooltip } from "@plane/propel/tooltip"; -import { TWorkspaceBaseActivity } from "@plane/types"; +import type { TWorkspaceBaseActivity } from "@plane/types"; // ui // helpers import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@plane/utils"; diff --git a/apps/web/core/components/common/activity/activity-item.tsx b/apps/web/core/components/common/activity/activity-item.tsx index 643d89738..6df28e1d8 100644 --- a/apps/web/core/components/common/activity/activity-item.tsx +++ b/apps/web/core/components/common/activity/activity-item.tsx @@ -1,9 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -import { TProjectActivity } from "@/plane-web/types"; +import type { TProjectActivity } from "@/plane-web/types"; import { ActivityBlockComponent } from "./activity-block"; import { iconsMap, messages } from "./helper"; diff --git a/apps/web/core/components/common/activity/helper.tsx b/apps/web/core/components/common/activity/helper.tsx index 8ce307597..48cc39b87 100644 --- a/apps/web/core/components/common/activity/helper.tsx +++ b/apps/web/core/components/common/activity/helper.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { Signal, RotateCcw, @@ -25,9 +25,9 @@ import { } from "lucide-react"; // components -import { ArchiveIcon, DoubleCircleIcon, ContrastIcon, DiceIcon, Intake } from "@plane/propel/icons"; +import { ArchiveIcon, CycleIcon, DoubleCircleIcon, IntakeIcon, ModuleIcon } from "@plane/propel/icons"; import { store } from "@/lib/store-context"; -import { TProjectActivity } from "@/plane-web/types"; +import type { TProjectActivity } from "@/plane-web/types"; type ActivityIconMap = { [key: string]: ReactNode; @@ -47,8 +47,8 @@ export const iconsMap: ActivityIconMap = { name: , state: , estimate: , - cycle: , - module: , + cycle: , + module: , page: , network: , identifier: , @@ -59,11 +59,11 @@ export const iconsMap: ActivityIconMap = { is_time_tracking_enabled: , is_issue_type_enabled: , default: , - module_view: , - cycle_view: , + module_view: , + cycle_view: , issue_views_view: , page_view: , - intake_view: , + intake_view: , }; export const messages = (activity: TProjectActivity): { message: string | ReactNode; customUserName?: string } => { diff --git a/apps/web/core/components/common/activity/user.tsx b/apps/web/core/components/common/activity/user.tsx index 7eba5cfd1..5da5b305f 100644 --- a/apps/web/core/components/common/activity/user.tsx +++ b/apps/web/core/components/common/activity/user.tsx @@ -1,8 +1,8 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // types -import { TWorkspaceBaseActivity } from "@plane/types"; +import type { TWorkspaceBaseActivity } from "@plane/types"; // store hooks import { useMember } from "@/hooks/store/use-member"; import { useWorkspace } from "@/hooks/store/use-workspace"; diff --git a/apps/web/core/components/common/breadcrumb-link.tsx b/apps/web/core/components/common/breadcrumb-link.tsx index a0fda7c14..25ee4ee1c 100644 --- a/apps/web/core/components/common/breadcrumb-link.tsx +++ b/apps/web/core/components/common/breadcrumb-link.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { ReactNode, useMemo, FC } from "react"; +import type { ReactNode, FC } from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { Breadcrumbs } from "@plane/ui"; diff --git a/apps/web/core/components/common/count-chip.tsx b/apps/web/core/components/common/count-chip.tsx index f44f349bf..ff15e5011 100644 --- a/apps/web/core/components/common/count-chip.tsx +++ b/apps/web/core/components/common/count-chip.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/common/empty-state.tsx b/apps/web/core/components/common/empty-state.tsx index 5fdd210d9..f83b3e204 100644 --- a/apps/web/core/components/common/empty-state.tsx +++ b/apps/web/core/components/common/empty-state.tsx @@ -4,7 +4,7 @@ import React from "react"; import Image from "next/image"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; type Props = { title: string; diff --git a/apps/web/core/components/common/filters/created-by.tsx b/apps/web/core/components/common/filters/created-by.tsx index e6bfa5823..bd39056c8 100644 --- a/apps/web/core/components/common/filters/created-by.tsx +++ b/apps/web/core/components/common/filters/created-by.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo, useState } from "react"; -import sortBy from "lodash/sortBy"; +import { sortBy } from "lodash-es"; import { observer } from "mobx-react"; // ui import { Avatar, Loader } from "@plane/ui"; diff --git a/apps/web/core/components/common/logo.tsx b/apps/web/core/components/common/logo.tsx index c03f05734..12a7ff06b 100644 --- a/apps/web/core/components/common/logo.tsx +++ b/apps/web/core/components/common/logo.tsx @@ -1,13 +1,13 @@ "use client"; -import { FC } from "react"; +import type { FC } from "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"; // plane imports import { getEmojiSize, LUCIDE_ICONS_LIST, stringToEmoji } from "@plane/propel/emoji-icon-picker"; -import { TLogoProps } from "@plane/types"; +import type { TLogoProps } from "@plane/types"; type Props = { logo: TLogoProps; diff --git a/apps/web/core/components/common/new-empty-state.tsx b/apps/web/core/components/common/new-empty-state.tsx index e04cb3ed0..9e9137266 100644 --- a/apps/web/core/components/common/new-empty-state.tsx +++ b/apps/web/core/components/common/new-empty-state.tsx @@ -4,7 +4,7 @@ import React, { useState } from "react"; import Image from "next/image"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; type Props = { title: string; diff --git a/apps/web/core/components/common/page-access-icon.tsx b/apps/web/core/components/common/page-access-icon.tsx index 9f547a116..bb61dc62b 100644 --- a/apps/web/core/components/common/page-access-icon.tsx +++ b/apps/web/core/components/common/page-access-icon.tsx @@ -1,6 +1,6 @@ import { ArchiveIcon, Earth, Lock } from "lucide-react"; import { EPageAccess } from "@plane/constants"; -import { TPage } from "@plane/types"; +import type { TPage } from "@plane/types"; export const PageAccessIcon = (page: TPage) => (
diff --git a/apps/web/core/components/common/pro-icon.tsx b/apps/web/core/components/common/pro-icon.tsx index 47300b6d6..45f3a698c 100644 --- a/apps/web/core/components/common/pro-icon.tsx +++ b/apps/web/core/components/common/pro-icon.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { Crown } from "lucide-react"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/common/switcher-label.tsx b/apps/web/core/components/common/switcher-label.tsx index 14ed1bb54..7f88c6cdd 100644 --- a/apps/web/core/components/common/switcher-label.tsx +++ b/apps/web/core/components/common/switcher-label.tsx @@ -1,6 +1,6 @@ -import { FC } from "react"; -import { ISvgIcons } from "@plane/propel/icons"; -import { TLogoProps } from "@plane/types"; +import type { FC } from "react"; +import type { ISvgIcons } from "@plane/propel/icons"; +import type { TLogoProps } from "@plane/types"; import { getFileURL, truncateText } from "@plane/utils"; import { Logo } from "@/components/common/logo"; diff --git a/apps/web/core/components/core/activity.tsx b/apps/web/core/components/core/activity.tsx index 3c773899e..d50a565e5 100644 --- a/apps/web/core/components/core/activity.tsx +++ b/apps/web/core/components/core/activity.tsx @@ -13,16 +13,24 @@ import { Users2Icon, ArchiveIcon, PaperclipIcon, - ContrastIcon, TriangleIcon, LayoutGridIcon, SignalMediumIcon, MessageSquareIcon, UsersIcon, } from "lucide-react"; -import { BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon, EpicIcon, Intake } from "@plane/propel/icons"; +import { + BlockedIcon, + BlockerIcon, + CycleIcon, + EpicIcon, + IntakeIcon, + ModuleIcon, + RelatedIcon, + WorkItemsIcon, +} from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { IIssueActivity } from "@plane/types"; +import type { IIssueActivity } from "@plane/types"; import { renderFormattedDate, generateWorkItemLink, capitalizeFirstLetter } from "@plane/utils"; // helpers import { useLabel } from "@/hooks/store/use-label"; @@ -283,7 +291,7 @@ const activityDetails: { ); }, - icon:
+ } + completed={assignee?.completed ?? 0} + total={assignee?.total ?? 0} + /> + ); + }) + ) : ( +
+
+ empty members +
+
{t("no_assignee")}
+
+ )} +
+ ); +}); diff --git a/apps/web/core/components/core/sidebar/progress-stats/label.tsx b/apps/web/core/components/core/sidebar/progress-stats/label.tsx new file mode 100644 index 000000000..3897ec13e --- /dev/null +++ b/apps/web/core/components/core/sidebar/progress-stats/label.tsx @@ -0,0 +1,86 @@ +import { observer } from "mobx-react"; +import Image from "next/image"; +// plane imports +import { useTranslation } from "@plane/i18n"; +// components +import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; +// public +import emptyLabel from "@/public/empty-state/empty_label.svg"; + +export type TLabelData = { + id: string | undefined; + title: string | undefined; + color: string | undefined; + completed: number; + total: number; +}[]; + +type TLabelStatComponent = { + selectedLabelIds: string[]; + handleLabelFiltersUpdate: (labelId: string | undefined) => void; + distribution: TLabelData; + isEditable?: boolean; +}; + +export const LabelStatComponent = observer((props: TLabelStatComponent) => { + const { distribution, isEditable, selectedLabelIds, handleLabelFiltersUpdate } = props; + const { t } = useTranslation(); + return ( +
+ {distribution && distribution.length > 0 ? ( + distribution.map((label, index) => { + if (label.id) { + return ( + + + {label.title ?? t("no_labels_yet")} +
+ } + completed={label.completed} + total={label.total} + {...(isEditable && { + onClick: () => handleLabelFiltersUpdate(label.id), + selected: label.id ? selectedLabelIds.includes(label.id) : false, + })} + /> + ); + } else { + return ( + + + {label.title ?? t("no_labels_yet")} +
+ } + completed={label.completed} + total={label.total} + /> + ); + } + }) + ) : ( +
+
+ empty label +
+
{t("no_labels_yet")}
+
+ )} +
+ ); +}); diff --git a/apps/web/core/components/core/sidebar/progress-stats/shared.ts b/apps/web/core/components/core/sidebar/progress-stats/shared.ts new file mode 100644 index 000000000..38f2352f3 --- /dev/null +++ b/apps/web/core/components/core/sidebar/progress-stats/shared.ts @@ -0,0 +1,45 @@ +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { TFilterConditionNodeForDisplay, TFilterValue, TWorkItemFilterProperty } from "@plane/types"; + +export const PROGRESS_STATS = [ + { + key: "stat-states", + i18n_title: "common.states", + }, + { + key: "stat-assignees", + i18n_title: "common.assignees", + }, + { + key: "stat-labels", + i18n_title: "common.labels", + }, +]; + +type TSelectedFilterProgressStatsType = TFilterConditionNodeForDisplay; + +export type TSelectedFilterProgressStats = { + assignees: TSelectedFilterProgressStatsType | undefined; + labels: TSelectedFilterProgressStatsType | undefined; + stateGroups: TSelectedFilterProgressStatsType | undefined; +}; + +export const createFilterUpdateHandler = + ( + property: TWorkItemFilterProperty, + selectedValues: T[], + handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void + ) => + (value: T | undefined) => { + const updatedValues = value ? [...selectedValues] : []; + + if (value) { + if (updatedValues.includes(value)) { + updatedValues.splice(updatedValues.indexOf(value), 1); + } else { + updatedValues.push(value); + } + } + + handleFiltersUpdate({ property, operator: "in", value: updatedValues }); + }; diff --git a/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx b/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx new file mode 100644 index 000000000..317d426d6 --- /dev/null +++ b/apps/web/core/components/core/sidebar/progress-stats/state_group.tsx @@ -0,0 +1,46 @@ +import { observer } from "mobx-react"; +// plane imports +import { StateGroupIcon } from "@plane/propel/icons"; +import type { TStateGroups } from "@plane/types"; +// components +import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; + +export type TStateGroupData = { + state: string | undefined; + completed: number; + total: number; +}[]; + +type TStateGroupStatComponent = { + selectedStateGroups: string[]; + handleStateGroupFiltersUpdate: (stateGroup: string | undefined) => void; + distribution: TStateGroupData; + totalIssuesCount: number; + isEditable?: boolean; +}; + +export const StateGroupStatComponent = observer((props: TStateGroupStatComponent) => { + const { distribution, isEditable, totalIssuesCount, selectedStateGroups, handleStateGroupFiltersUpdate } = props; + + return ( +
+ {distribution.map((group, index) => ( + + + {group.state} +
+ } + completed={group.completed} + total={totalIssuesCount} + {...(isEditable && { + onClick: () => group.state && handleStateGroupFiltersUpdate(group.state), + selected: group.state ? selectedStateGroups.includes(group.state) : false, + })} + /> + ))} +
+ ); +}); diff --git a/apps/web/core/components/core/sidebar/single-progress-stats.tsx b/apps/web/core/components/core/sidebar/single-progress-stats.tsx index 047d8bc04..a0443f06e 100644 --- a/apps/web/core/components/core/sidebar/single-progress-stats.tsx +++ b/apps/web/core/components/core/sidebar/single-progress-stats.tsx @@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC = ({
{title}
diff --git a/apps/web/core/components/core/theme/color-picker-input.tsx b/apps/web/core/components/core/theme/color-picker-input.tsx index dbaa13aac..be9bab9d5 100644 --- a/apps/web/core/components/core/theme/color-picker-input.tsx +++ b/apps/web/core/components/core/theme/color-picker-input.tsx @@ -1,10 +1,11 @@ "use client"; -import { FC, Fragment } from "react"; +import type { FC } from "react"; +import { Fragment } from "react"; // react-form -import { ColorResult, SketchPicker } from "react-color"; -import { +import type { ColorResult } from "react-color"; +import { SketchPicker } from "react-color"; +import type { Control, - Controller, FieldError, FieldErrorsImpl, Merge, @@ -12,11 +13,12 @@ import { UseFormSetValue, UseFormWatch, } from "react-hook-form"; +import { Controller } from "react-hook-form"; // react-color // component import { Palette } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; -import { IUserTheme } from "@plane/types"; +import type { IUserTheme } from "@plane/types"; import { Input } from "@plane/ui"; // icons // types diff --git a/apps/web/core/components/core/theme/custom-theme-selector.tsx b/apps/web/core/components/core/theme/custom-theme-selector.tsx index ca1907bad..c9284755e 100644 --- a/apps/web/core/components/core/theme/custom-theme-selector.tsx +++ b/apps/web/core/components/core/theme/custom-theme-selector.tsx @@ -6,9 +6,11 @@ import { Controller, useForm } from "react-hook-form"; // types import { PROFILE_SETTINGS_TRACKER_ELEMENTS, PROFILE_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IUserTheme } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import { setPromiseToast } from "@plane/propel/toast"; +import type { IUserTheme } from "@plane/types"; // ui -import { Button, InputColorPicker, setPromiseToast } from "@plane/ui"; +import { InputColorPicker } from "@plane/ui"; // hooks import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; import { useUserProfile } from "@/hooks/store/user"; diff --git a/apps/web/core/components/core/theme/theme-switch.tsx b/apps/web/core/components/core/theme/theme-switch.tsx index 1a188fa03..29a2efe96 100644 --- a/apps/web/core/components/core/theme/theme-switch.tsx +++ b/apps/web/core/components/core/theme/theme-switch.tsx @@ -1,8 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // plane imports -import { I_THEME_OPTION, THEME_OPTIONS } from "@plane/constants"; +import type { I_THEME_OPTION } from "@plane/constants"; +import { THEME_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // constants import { CustomSelect } from "@plane/ui"; diff --git a/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx b/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx index e0f71dc3d..8bdd0ad6c 100644 --- a/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/apps/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -1,7 +1,8 @@ "use client"; -import { FC, Fragment, useCallback, useRef, useState } from "react"; -import isEmpty from "lodash/isEmpty"; +import type { FC } from "react"; +import { Fragment, useCallback, useRef, useState } from "react"; +import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; import { CalendarCheck } from "lucide-react"; // headless ui @@ -10,7 +11,9 @@ import { Tab } from "@headlessui/react"; import { useTranslation } from "@plane/i18n"; import { PriorityIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { EIssuesStoreType, ICycle, IIssueFilterOptions } from "@plane/types"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { ICycle } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; // ui import { Loader, Avatar } from "@plane/ui"; import { cn, renderFormattedDate, renderFormattedDateWithoutYear, getFileURL } from "@plane/utils"; @@ -28,14 +31,14 @@ import useLocalStorage from "@/hooks/use-local-storage"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; // store -import { ActiveCycleIssueDetails } from "@/store/issue/cycle"; +import type { ActiveCycleIssueDetails } from "@/store/issue/cycle"; export type ActiveCycleStatsProps = { workspaceSlug: string; projectId: string; cycle: ICycle | null; cycleId?: string | null; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void; + handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void; cycleIssueDetails: ActiveCycleIssueDetails; }; @@ -185,7 +188,9 @@ export const ActiveCycleStats: FC = observer((props) => { issueId: issue.id, isArchived: !!issue.archived_at, }); - handleFiltersUpdate("priority", ["urgent", "high"], true); + handleFiltersUpdate([ + { property: "priority", operator: "in", value: ["urgent", "high"] }, + ]); } }} > @@ -275,7 +280,9 @@ export const ActiveCycleStats: FC = observer((props) => { total={assignee.total_issues} onClick={() => { if (assignee.assignee_id) { - handleFiltersUpdate("assignees", [assignee.assignee_id], true); + handleFiltersUpdate([ + { property: "assignee_id", operator: "in", value: [assignee.assignee_id] }, + ]); } }} /> @@ -332,11 +339,15 @@ export const ActiveCycleStats: FC = observer((props) => { } completed={label.completed_issues} total={label.total_issues} - onClick={() => { - if (label.label_id) { - handleFiltersUpdate("labels", [label.label_id], true); - } - }} + onClick={ + label.label_id + ? () => { + if (label.label_id) { + handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]); + } + } + : undefined + } /> )) ) : ( diff --git a/apps/web/core/components/cycles/active-cycle/productivity.tsx b/apps/web/core/components/cycles/active-cycle/productivity.tsx index 63d085098..d7677cbf8 100644 --- a/apps/web/core/components/cycles/active-cycle/productivity.tsx +++ b/apps/web/core/components/cycles/active-cycle/productivity.tsx @@ -1,9 +1,10 @@ -import { FC, Fragment } from "react"; +import type { FC } from "react"; +import { Fragment } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; // plane imports import { useTranslation } from "@plane/i18n"; -import { ICycle, TCycleEstimateType } from "@plane/types"; +import type { ICycle, TCycleEstimateType } from "@plane/types"; import { Loader } from "@plane/ui"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; diff --git a/apps/web/core/components/cycles/active-cycle/progress.tsx b/apps/web/core/components/cycles/active-cycle/progress.tsx index b7fadd903..d2e53c09f 100644 --- a/apps/web/core/components/cycles/active-cycle/progress.tsx +++ b/apps/web/core/components/cycles/active-cycle/progress.tsx @@ -1,31 +1,29 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -// plane package imports +// plane imports import { PROGRESS_STATE_GROUPS_DETAILS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { ICycle, IIssueFilterOptions } from "@plane/types"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { ICycle } from "@plane/types"; import { LinearProgressIndicator, Loader } from "@plane/ui"; // components import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; // hooks -import { useProjectState } from "@/hooks/store/use-project-state"; import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path"; export type ActiveCycleProgressProps = { cycle: ICycle | null; workspaceSlug: string; projectId: string; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void; + handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void; }; export const ActiveCycleProgress: FC = observer((props) => { const { handleFiltersUpdate, cycle } = props; // plane hooks const { t } = useTranslation(); - // store hooks - const { groupedProjectStates } = useProjectState(); // derived values const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({ id: index, @@ -68,10 +66,7 @@ export const ActiveCycleProgress: FC = observer((props
{ - if (groupedProjectStates) { - const states = groupedProjectStates[group].map((state) => state.id); - handleFiltersUpdate("state", states, true); - } + handleFiltersUpdate([{ property: "state_group", operator: "in", value: [group] }]); }} >
diff --git a/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts b/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts index 74ea52e7d..7f9154320 100644 --- a/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts +++ b/apps/web/core/components/cycles/active-cycle/use-cycles-details.ts @@ -1,12 +1,15 @@ import { useCallback } from "react"; -import isEqual from "lodash/isEqual"; import { useRouter } from "next/navigation"; import useSWR from "swr"; -import { EIssueFilterType } from "@plane/constants"; -import { EIssuesStoreType, IIssueFilterOptions } from "@plane/types"; +// plane imports +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import { EIssuesStoreType } from "@plane/types"; +// constants import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; +// hooks import { useCycle } from "@/hooks/store/use-cycle"; import { useIssues } from "@/hooks/store/use-issues"; +import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters"; interface IActiveCycleDetails { workspaceSlug: string; @@ -21,9 +24,10 @@ const useCyclesDetails = (props: IActiveCycleDetails) => { const router = useRouter(); // store hooks const { - issuesFilter: { issueFilters, updateFilters }, + issuesFilter: { updateFilterExpression }, issues: { getActiveCycleById: getActiveCycleByIdFromIssue, fetchActiveCycleIssues }, } = useIssues(EIssuesStoreType.CYCLE); + const { updateFilterExpressionFromConditions } = useWorkItemFilters(); const { fetchActiveCycleProgress, getCycleById, fetchActiveCycleAnalytics } = useCycle(); // derived values @@ -62,29 +66,19 @@ const useCyclesDetails = (props: IActiveCycleDetails) => { const cycleIssueDetails = cycle?.id ? getActiveCycleByIdFromIssue(cycle?.id) : { nextPageResults: false }; const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => { + async (conditions: TWorkItemFilterCondition[]) => { if (!workspaceSlug || !projectId || !cycleId) return; - const newFilters: IIssueFilterOptions = {}; - Object.keys(issueFilters?.filters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = []; - }); - - let newValues: string[] = []; - - if (isEqual(newValues, value)) newValues = []; - else newValues = value; - - updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { ...newFilters, [key]: newValues }, - cycleId.toString() + await updateFilterExpressionFromConditions( + EIssuesStoreType.CYCLE, + cycleId, + conditions, + updateFilterExpression.bind(updateFilterExpression, workspaceSlug, projectId, cycleId) ); - if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`); + + router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`); }, - [workspaceSlug, projectId, cycleId, issueFilters, updateFilters, router] + [workspaceSlug, projectId, cycleId, updateFilterExpressionFromConditions, updateFilterExpression, router] ); return { cycle, diff --git a/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx b/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx index 70ed49f4c..224e1ff67 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/issue-progress.tsx @@ -1,21 +1,21 @@ "use client"; -import { FC, useCallback, useMemo } from "react"; -import isEmpty from "lodash/isEmpty"; -import isEqual from "lodash/isEqual"; +import type { FC } from "react"; +import { useMemo } from "react"; +import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; import { ChevronUp, ChevronDown } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; // plane imports -import { EIssueFilterType } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EIssuesStoreType, ICycle, IIssueFilterOptions, TCyclePlotType, TProgressSnapshot } from "@plane/types"; +import type { ICycle, TCyclePlotType, TProgressSnapshot } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; import { getDate } from "@plane/utils"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; -import { useIssues } from "@/hooks/store/use-issues"; // plane web components +import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters"; import { SidebarChartRoot } from "@/plane-web/components/cycles"; // local imports import { CycleProgressStats } from "./progress-stats"; @@ -60,23 +60,23 @@ export const CycleAnalyticsProgress: FC = observer((pro // router const searchParams = useSearchParams(); const peekCycle = searchParams.get("peekCycle") || undefined; - const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle(); - const { - issuesFilter: { issueFilters, updateFilters }, - } = useIssues(EIssuesStoreType.CYCLE); + // plane hooks const { t } = useTranslation(); - + // store hooks + const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle(); + const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters(); // derived values + const cycleFilter = getFilter(EIssuesStoreType.CYCLE, cycleId); + const selectedAssignees = cycleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in"); + const selectedLabels = cycleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in"); + const selectedStateGroups = cycleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in"); const cycleDetails = validateCycleSnapshot(getCycleById(cycleId)); const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId); const estimateType = getEstimateTypeByCycleId(cycleId); - const totalIssues = cycleDetails?.total_issues || 0; const totalEstimatePoints = cycleDetails?.total_estimate_points || 0; - const chartDistributionData = estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined; - const groupedIssues = useMemo( () => ({ backlog: @@ -92,44 +92,12 @@ export const CycleAnalyticsProgress: FC = observer((pro }), [estimateType, cycleDetails] ); - const cycleStartDate = getDate(cycleDetails?.start_date); const cycleEndDate = getDate(cycleDetails?.end_date); const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date(); const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate; const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid; - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - - let newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - if (key === "state") { - if (isEqual(newValues, value)) newValues = []; - else newValues = value; - } else { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { [key]: newValues }, - cycleId - ); - }, - [workspaceSlug, projectId, cycleId, issueFilters, updateFilters] - ); - if (!cycleDetails) return <>; return (
@@ -159,7 +127,6 @@ export const CycleAnalyticsProgress: FC = observer((pro
)} - {cycleStartDate && cycleEndDate ? ( @@ -172,16 +139,24 @@ export const CycleAnalyticsProgress: FC = observer((pro
)} diff --git a/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx b/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx index 65a1cd10f..2072a30df 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/progress-stats.tsx @@ -1,281 +1,68 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -import Image from "next/image"; import { Tab } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { StateGroupIcon } from "@plane/propel/icons"; -import { - IIssueFilterOptions, - IIssueFilters, - TCycleDistribution, - TCycleEstimateDistribution, - TCyclePlotType, - TStateGroups, -} from "@plane/types"; -import { Avatar } from "@plane/ui"; -import { cn, getFileURL } from "@plane/utils"; +import type { TWorkItemFilterCondition } from "@plane/shared-state"; +import type { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } from "@plane/types"; +import { cn, toFilterArray } from "@plane/utils"; // components -import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats"; +import type { TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee"; +import { AssigneeStatComponent } from "@/components/core/sidebar/progress-stats/assignee"; +import type { TLabelData } from "@/components/core/sidebar/progress-stats/label"; +import { LabelStatComponent } from "@/components/core/sidebar/progress-stats/label"; +import type { TSelectedFilterProgressStats } from "@/components/core/sidebar/progress-stats/shared"; +import { createFilterUpdateHandler, PROGRESS_STATS } from "@/components/core/sidebar/progress-stats/shared"; +import type { TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group"; +import { StateGroupStatComponent } from "@/components/core/sidebar/progress-stats/state_group"; +// helpers // hooks -import { useProjectState } from "@/hooks/store/use-project-state"; import useLocalStorage from "@/hooks/use-local-storage"; -// public -import emptyLabel from "@/public/empty-state/empty_label.svg"; -import emptyMembers from "@/public/empty-state/empty_members.svg"; - -// assignee types -type TAssigneeData = { - id: string | undefined; - title: string | undefined; - avatar_url: string | undefined; - completed: number; - total: number; -}[]; - -type TAssigneeStatComponent = { - distribution: TAssigneeData; - isEditable?: boolean; - filters?: IIssueFilters | undefined; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; -}; - -// labelTypes -type TLabelData = { - id: string | undefined; - title: string | undefined; - color: string | undefined; - completed: number; - total: number; -}[]; - -type TLabelStatComponent = { - distribution: TLabelData; - isEditable?: boolean; - filters?: IIssueFilters | undefined; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; -}; - -// stateTypes -type TStateData = { - state: string | undefined; - completed: number; - total: number; -}[]; - -type TStateStatComponent = { - distribution: TStateData; - totalIssuesCount: number; - isEditable?: boolean; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; -}; - -export const AssigneeStatComponent = observer((props: TAssigneeStatComponent) => { - const { distribution, isEditable, filters, handleFiltersUpdate } = props; - const { t } = useTranslation(); - return ( -
- {distribution && distribution.length > 0 ? ( - distribution.map((assignee, index) => { - if (assignee?.id) - return ( - - - {assignee?.title ?? ""} -
- } - completed={assignee?.completed ?? 0} - total={assignee?.total ?? 0} - {...(isEditable && { - onClick: () => handleFiltersUpdate("assignees", assignee.id ?? ""), - selected: filters?.filters?.assignees?.includes(assignee.id ?? ""), - })} - /> - ); - else - return ( - -
- User -
- {t("no_assignee")} -
- } - completed={assignee?.completed ?? 0} - total={assignee?.total ?? 0} - /> - ); - }) - ) : ( -
-
- empty members -
-
{t("no_assignee")}
-
- )} -
- ); -}); - -export const LabelStatComponent = observer((props: TLabelStatComponent) => { - const { distribution, isEditable, filters, handleFiltersUpdate } = props; - const { t } = useTranslation(); - return ( -
- {distribution && distribution.length > 0 ? ( - distribution.map((label, index) => { - if (label.id) { - return ( - - - {label.title ?? t("no_labels_yet")} -
- } - completed={label.completed} - total={label.total} - {...(isEditable && { - onClick: () => handleFiltersUpdate("labels", label.id ?? ""), - selected: filters?.filters?.labels?.includes(label.id ?? `no-label-${index}`), - })} - /> - ); - } else { - return ( - - - {label.title ?? t("no_labels_yet")} -
- } - completed={label.completed} - total={label.total} - /> - ); - } - }) - ) : ( -
-
- empty label -
-
{t("no_labels_yet")}
-
- )} -
- ); -}); - -export const StateStatComponent = observer((props: TStateStatComponent) => { - const { distribution, isEditable, totalIssuesCount, handleFiltersUpdate } = props; - // hooks - const { groupedProjectStates } = useProjectState(); - // derived values - const getStateGroupState = (stateGroup: string) => { - const stateGroupStates = groupedProjectStates?.[stateGroup]; - const stateGroupStatesId = stateGroupStates?.map((state) => state.id); - return stateGroupStatesId; - }; - - return ( -
- {distribution.map((group, index) => ( - - - {group.state} -
- } - completed={group.completed} - total={totalIssuesCount} - {...(isEditable && { - onClick: () => group.state && handleFiltersUpdate("state", getStateGroupState(group.state) ?? []), - })} - /> - ))} -
- ); -}); - -const progressStats = [ - { - key: "stat-states", - i18n_title: "common.states", - }, - { - key: "stat-assignees", - i18n_title: "common.assignees", - }, - { - key: "stat-labels", - i18n_title: "common.labels", - }, -]; type TCycleProgressStats = { cycleId: string; - plotType: TCyclePlotType; distribution: TCycleDistribution | TCycleEstimateDistribution | undefined; groupedIssues: Record; - totalIssuesCount: number; + handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void; isEditable?: boolean; - filters?: IIssueFilters | undefined; - handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; - size?: "xs" | "sm"; - roundedTab?: boolean; noBackground?: boolean; + plotType: TCyclePlotType; + roundedTab?: boolean; + selectedFilters: TSelectedFilterProgressStats; + size?: "xs" | "sm"; + totalIssuesCount: number; }; export const CycleProgressStats: FC = observer((props) => { const { cycleId, - plotType, distribution, groupedIssues, - totalIssuesCount, - isEditable = false, - filters, handleFiltersUpdate, - size = "sm", - roundedTab = false, + isEditable = false, noBackground = false, + plotType, + roundedTab = false, + selectedFilters, + size = "sm", + totalIssuesCount, } = props; - // hooks + // plane imports + const { t } = useTranslation(); + // store imports const { storedValue: currentTab, setValue: setCycleTab } = useLocalStorage( `cycle-analytics-tab-${cycleId}`, "stat-assignees" ); - const { t } = useTranslation(); // derived values - const currentTabIndex = (tab: string): number => progressStats.findIndex((stat) => stat.key === tab); - + const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab); const currentDistribution = distribution as TCycleDistribution; const currentEstimateDistribution = distribution as TCycleEstimateDistribution; + const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[]; + const selectedLabelIds = toFilterArray(selectedFilters?.labels?.value || []) as string[]; + const selectedStateGroups = toFilterArray(selectedFilters?.stateGroups?.value || []) as string[]; const distributionAssigneeData: TAssigneeData = plotType === "burndown" @@ -311,12 +98,24 @@ export const CycleProgressStats: FC = observer((props) => { total: label.total_estimates, })); - const distributionStateData: TStateData = Object.keys(groupedIssues || {}).map((state) => ({ + const distributionStateData: TStateGroupData = Object.keys(groupedIssues || {}).map((state) => ({ state: state, completed: groupedIssues?.[state] || 0, total: totalIssuesCount || 0, })); + const handleAssigneeFiltersUpdate = createFilterUpdateHandler( + "assignee_id", + selectedAssigneeIds, + handleFiltersUpdate + ); + const handleLabelFiltersUpdate = createFilterUpdateHandler("label_id", selectedLabelIds, handleFiltersUpdate); + const handleStateGroupFiltersUpdate = createFilterUpdateHandler( + "state_group", + selectedStateGroups, + handleFiltersUpdate + ); + return (
@@ -329,7 +128,7 @@ export const CycleProgressStats: FC = observer((props) => { size === "xs" ? `text-xs` : `text-sm` )} > - {progressStats.map((stat) => ( + {PROGRESS_STATS.map((stat) => ( = observer((props) => { - diff --git a/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx b/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx index 29ddfe1eb..e3e83216a 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/sidebar-details.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { FC } from "react"; -import isEmpty from "lodash/isEmpty"; +import type { FC } from "react"; +import React from "react"; +import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; -import { LayersIcon, SquareUser, Users } from "lucide-react"; +import { SquareUser, Users } from "lucide-react"; // plane types import { EEstimateSystem } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { ICycle } from "@plane/types"; +import { WorkItemsIcon } from "@plane/propel/icons"; +import type { ICycle } from "@plane/types"; // plane ui import { Avatar, AvatarGroup, TextArea } from "@plane/ui"; // helpers @@ -115,7 +117,7 @@ export const CycleSidebarDetails: FC = observer((props) => {
- + {t("work_items")}
@@ -129,7 +131,7 @@ export const CycleSidebarDetails: FC = observer((props) => { {isEstimatePointValid && !isCompleted && (
- + {t("points")}
diff --git a/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx b/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx index 21aaa1ade..0c84571c9 100644 --- a/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx +++ b/apps/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC, useEffect } from "react"; +import type { FC } from "react"; +import React, { useEffect } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; import { ArrowRight, ChevronRight } from "lucide-react"; @@ -13,8 +14,8 @@ import { CYCLE_TRACKER_ELEMENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { ICycle } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { ICycle } from "@plane/types"; import { getDate, renderFormattedPayloadDate } from "@plane/utils"; // components import { DateRangeDropdown } from "@/components/dropdowns/date-range"; diff --git a/apps/web/core/components/cycles/applied-filters/root.tsx b/apps/web/core/components/cycles/applied-filters/root.tsx index 77fa8f160..1ee80e738 100644 --- a/apps/web/core/components/cycles/applied-filters/root.tsx +++ b/apps/web/core/components/cycles/applied-filters/root.tsx @@ -3,7 +3,7 @@ import { X } from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TCycleFilters } from "@plane/types"; +import type { TCycleFilters } from "@plane/types"; import { Tag } from "@plane/ui"; import { replaceUnderscoreIfSnakeCase } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/cycles/archived-cycles/header.tsx b/apps/web/core/components/cycles/archived-cycles/header.tsx index 15a2b4c6d..dbbb93dc1 100644 --- a/apps/web/core/components/cycles/archived-cycles/header.tsx +++ b/apps/web/core/components/cycles/archived-cycles/header.tsx @@ -1,4 +1,5 @@ -import { FC, useCallback, useRef, useState } from "react"; +import type { FC } from "react"; +import { useCallback, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // icons diff --git a/apps/web/core/components/cycles/archived-cycles/modal.tsx b/apps/web/core/components/cycles/archived-cycles/modal.tsx index 5b0f98f0f..3f83ad540 100644 --- a/apps/web/core/components/cycles/archived-cycles/modal.tsx +++ b/apps/web/core/components/cycles/archived-cycles/modal.tsx @@ -4,7 +4,8 @@ import { useState, Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; // ui import { CYCLE_TRACKER_EVENTS } from "@plane/constants"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useCycle } from "@/hooks/store/use-cycle"; diff --git a/apps/web/core/components/cycles/archived-cycles/root.tsx b/apps/web/core/components/cycles/archived-cycles/root.tsx index ca8c8dc5b..93c18a048 100644 --- a/apps/web/core/components/cycles/archived-cycles/root.tsx +++ b/apps/web/core/components/cycles/archived-cycles/root.tsx @@ -4,7 +4,7 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TCycleFilters } from "@plane/types"; +import type { TCycleFilters } from "@plane/types"; import { calculateTotalFilters } from "@plane/utils"; // components import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root"; diff --git a/apps/web/core/components/cycles/archived-cycles/view.tsx b/apps/web/core/components/cycles/archived-cycles/view.tsx index fd0c8fe41..e5a2fd0db 100644 --- a/apps/web/core/components/cycles/archived-cycles/view.tsx +++ b/apps/web/core/components/cycles/archived-cycles/view.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; // components diff --git a/apps/web/core/components/cycles/cycles-view-header.tsx b/apps/web/core/components/cycles/cycles-view-header.tsx index 4976a33f5..4276c1ed2 100644 --- a/apps/web/core/components/cycles/cycles-view-header.tsx +++ b/apps/web/core/components/cycles/cycles-view-header.tsx @@ -6,7 +6,7 @@ import { ListFilter, Search, X } from "lucide-react"; import { useOutsideClickDetector } from "@plane/hooks"; // types import { useTranslation } from "@plane/i18n"; -import { TCycleFilters } from "@plane/types"; +import type { TCycleFilters } from "@plane/types"; import { cn, calculateTotalFilters } from "@plane/utils"; // components import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/cycles/cycles-view.tsx b/apps/web/core/components/cycles/cycles-view.tsx index a15596b17..2f9216454 100644 --- a/apps/web/core/components/cycles/cycles-view.tsx +++ b/apps/web/core/components/cycles/cycles-view.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import Image from "next/image"; // components diff --git a/apps/web/core/components/cycles/delete-modal.tsx b/apps/web/core/components/cycles/delete-modal.tsx index 4d0e8447f..2a4067252 100644 --- a/apps/web/core/components/cycles/delete-modal.tsx +++ b/apps/web/core/components/cycles/delete-modal.tsx @@ -6,9 +6,10 @@ import { useParams, useSearchParams } from "next/navigation"; // types import { PROJECT_ERROR_MESSAGES, CYCLE_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { ICycle } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { ICycle } from "@plane/types"; // ui -import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +import { AlertModalCore } from "@plane/ui"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; // hooks diff --git a/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx b/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx index 278d0fb12..56761b3ee 100644 --- a/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx +++ b/apps/web/core/components/cycles/dropdowns/estimate-type-dropdown.tsx @@ -1,6 +1,7 @@ import React from "react"; import { observer } from "mobx-react"; -import { EEstimateSystem, TCycleEstimateType } from "@plane/types"; +import type { TCycleEstimateType } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; import { CustomSelect } from "@plane/ui"; import { useProjectEstimates } from "@/hooks/store/estimates"; import { useCycle } from "@/hooks/store/use-cycle"; diff --git a/apps/web/core/components/cycles/dropdowns/filters/root.tsx b/apps/web/core/components/cycles/dropdowns/filters/root.tsx index ff32a4b23..550d7daf7 100644 --- a/apps/web/core/components/cycles/dropdowns/filters/root.tsx +++ b/apps/web/core/components/cycles/dropdowns/filters/root.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; import { Search, X } from "lucide-react"; // plane imports -import { TCycleFilters, TCycleGroups } from "@plane/types"; +import type { TCycleFilters, TCycleGroups } from "@plane/types"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // local imports diff --git a/apps/web/core/components/cycles/dropdowns/filters/status.tsx b/apps/web/core/components/cycles/dropdowns/filters/status.tsx index 70cde0b66..482aaabd6 100644 --- a/apps/web/core/components/cycles/dropdowns/filters/status.tsx +++ b/apps/web/core/components/cycles/dropdowns/filters/status.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; import { CYCLE_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TCycleGroups } from "@plane/types"; +import type { TCycleGroups } from "@plane/types"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; // types diff --git a/apps/web/core/components/cycles/form.tsx b/apps/web/core/components/cycles/form.tsx index 6efbb8140..8206501c9 100644 --- a/apps/web/core/components/cycles/form.tsx +++ b/apps/web/core/components/cycles/form.tsx @@ -6,9 +6,10 @@ import { Controller, useForm } from "react-hook-form"; import { ETabIndices } from "@plane/constants"; // types import { useTranslation } from "@plane/i18n"; -import { ICycle } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import type { ICycle } from "@plane/types"; // ui -import { Button, Input, TextArea } from "@plane/ui"; +import { Input, TextArea } from "@plane/ui"; import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; // components import { DateRangeDropdown } from "@/components/dropdowns/date-range"; diff --git a/apps/web/core/components/cycles/list/cycle-list-group-header.tsx b/apps/web/core/components/cycles/list/cycle-list-group-header.tsx index 11e2f3968..5ab265dcf 100644 --- a/apps/web/core/components/cycles/list/cycle-list-group-header.tsx +++ b/apps/web/core/components/cycles/list/cycle-list-group-header.tsx @@ -1,10 +1,11 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { ChevronDown } from "lucide-react"; // types import { CycleGroupIcon } from "@plane/propel/icons"; -import { TCycleGroups } from "@plane/types"; +import type { TCycleGroups } from "@plane/types"; // icons import { Row } from "@plane/ui"; // helpers diff --git a/apps/web/core/components/cycles/list/cycle-list-item-action.tsx b/apps/web/core/components/cycles/list/cycle-list-item-action.tsx index 457854a3b..9a3e256b0 100644 --- a/apps/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/apps/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react"; +import type { FC, MouseEvent } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useForm } from "react-hook-form"; @@ -15,10 +16,11 @@ import { } from "@plane/constants"; import { useLocalStorage } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; -import { LayersIcon, TransferIcon } from "@plane/propel/icons"; +import { TransferIcon, WorkItemsIcon } from "@plane/propel/icons"; +import { setPromiseToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { ICycle, TCycleGroups } from "@plane/types"; -import { Avatar, AvatarGroup, FavoriteStar, setPromiseToast } from "@plane/ui"; +import type { ICycle, TCycleGroups } from "@plane/types"; +import { Avatar, AvatarGroup, FavoriteStar } from "@plane/ui"; import { getDate, getFileURL, generateQueryParams } from "@plane/utils"; // components import { DateRangeDropdown } from "@/components/dropdowns/date-range"; @@ -216,7 +218,7 @@ export const CycleListItemAction: FC = observer((props) => { {showIssueCount && (
- + {cycleDetails.total_issues}
)} diff --git a/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx b/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx index 702edd6aa..fdf9b1d42 100644 --- a/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx +++ b/apps/web/core/components/cycles/list/cycle-list-project-group-header.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { ChevronRight } from "lucide-react"; // icons diff --git a/apps/web/core/components/cycles/list/cycles-list-item.tsx b/apps/web/core/components/cycles/list/cycles-list-item.tsx index 18e6d90bb..6f8aec9c7 100644 --- a/apps/web/core/components/cycles/list/cycles-list-item.tsx +++ b/apps/web/core/components/cycles/list/cycles-list-item.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, MouseEvent, useRef } from "react"; -import isEmpty from "lodash/isEmpty"; +import type { FC, MouseEvent } from "react"; +import { useRef } from "react"; import { observer } from "mobx-react"; import { usePathname, useSearchParams } from "next/navigation"; import { Check } from "lucide-react"; @@ -8,7 +8,7 @@ import { Check } from "lucide-react"; import type { TCycleGroups } from "@plane/types"; import { CircularProgressIndicator } from "@plane/ui"; // components -import { generateQueryParams } from "@plane/utils"; +import { generateQueryParams, calculateCycleProgress } from "@plane/utils"; import { ListItem } from "@/components/core/list"; // hooks import { useCycle } from "@/hooks/store/use-cycle"; @@ -50,7 +50,6 @@ export const CyclesListItem: FC = observer((props) => { // computed // TODO: change this logic once backend fix the response const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; - const isCompleted = cycleStatus === "completed"; const isActive = cycleStatus === "current"; // handlers @@ -73,20 +72,7 @@ export const CyclesListItem: FC = observer((props) => { const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined; - const getCycleProgress = () => { - let completionPercentage = - ((cycleDetails.completed_issues + cycleDetails.cancelled_issues) / cycleDetails.total_issues) * 100; - - if (isCompleted && !isEmpty(cycleDetails.progress_snapshot)) { - completionPercentage = - ((cycleDetails.progress_snapshot.completed_issues + cycleDetails.progress_snapshot.cancelled_issues) / - cycleDetails.progress_snapshot.total_issues) * - 100; - } - return isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage); - }; - - const progress = getCycleProgress(); + const progress = calculateCycleProgress(cycleDetails); return ( = observer((props) => { className={className} prependTitleElement={ - {isCompleted ? ( - progress === 100 ? ( - - ) : ( - {`!`} - ) - ) : progress === 100 ? ( + {progress === 100 ? ( ) : ( - {`${progress}%`} + {`${progress}%`} )} } diff --git a/apps/web/core/components/cycles/list/root.tsx b/apps/web/core/components/cycles/list/root.tsx index 4e59d2c5d..f50363e24 100644 --- a/apps/web/core/components/cycles/list/root.tsx +++ b/apps/web/core/components/cycles/list/root.tsx @@ -1,4 +1,5 @@ -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { Disclosure } from "@headlessui/react"; // components diff --git a/apps/web/core/components/cycles/modal.tsx b/apps/web/core/components/cycles/modal.tsx index a53e3bcbf..4a6d3cbe2 100644 --- a/apps/web/core/components/cycles/modal.tsx +++ b/apps/web/core/components/cycles/modal.tsx @@ -1,14 +1,14 @@ "use client"; - import React, { useEffect, useState } from "react"; -import { format } from "date-fns"; import { mutate } from "swr"; // types import { CYCLE_TRACKER_EVENTS } from "@plane/constants"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types"; // ui -import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // hooks +import { renderFormattedPayloadDate } from "@plane/utils"; import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useCycle } from "@/hooks/store/use-cycle"; import { useProject } from "@/hooks/store/use-project"; @@ -129,26 +129,31 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const payload: Partial = { ...formData, + start_date: renderFormattedPayloadDate(formData.start_date) ?? null, + end_date: renderFormattedPayloadDate(formData.end_date) ?? null, }; let isDateValid: boolean = true; if (payload.start_date && payload.end_date) { - if (data?.start_date && data?.end_date) - isDateValid = await dateChecker(payload.project_id ?? projectId, { - start_date: format(payload.start_date, "yyyy-MM-dd"), - end_date: format(payload.end_date, "yyyy-MM-dd"), + if (data?.id) { + // Update existing cycle - always include cycle_id for validation + isDateValid = await dateChecker(projectId, { + start_date: payload.start_date, + end_date: payload.end_date, cycle_id: data.id, }); - else - isDateValid = await dateChecker(payload.project_id ?? projectId, { + } else { + // Create new cycle - no cycle_id needed + isDateValid = await dateChecker(projectId, { start_date: payload.start_date, end_date: payload.end_date, }); + } } if (isDateValid) { - if (data) await handleUpdateCycle(data.id, payload); + if (data?.id) await handleUpdateCycle(data.id, payload); else { await handleCreateCycle(payload).then(() => { setCycleTab("all"); diff --git a/apps/web/core/components/cycles/quick-actions.tsx b/apps/web/core/components/cycles/quick-actions.tsx index 778e78115..242e91eca 100644 --- a/apps/web/core/components/cycles/quick-actions.tsx +++ b/apps/web/core/components/cycles/quick-actions.tsx @@ -14,7 +14,9 @@ import { } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { ArchiveIcon } from "@plane/propel/icons"; -import { ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TContextMenuItem } from "@plane/ui"; +import { ContextMenu, CustomMenu } from "@plane/ui"; import { copyUrlToClipboard, cn } from "@plane/utils"; // helpers // hooks @@ -223,9 +225,7 @@ export const CycleQuickActions: React.FC = observer((props) => { return ( { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { captureClick({ elementName: CYCLE_TRACKER_ELEMENTS.QUICK_ACTIONS, }); diff --git a/apps/web/core/components/cycles/transfer-issues-modal.tsx b/apps/web/core/components/cycles/transfer-issues-modal.tsx index e3a322eec..7a1f60e55 100644 --- a/apps/web/core/components/cycles/transfer-issues-modal.tsx +++ b/apps/web/core/components/cycles/transfer-issues-modal.tsx @@ -5,12 +5,9 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { AlertCircle, Search, X } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; -import { ContrastIcon, TransferIcon } from "@plane/propel/icons"; +import { CycleIcon, TransferIcon } from "@plane/propel/icons"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { EIssuesStoreType } from "@plane/types"; -// hooks -// ui -//icons -import { TOAST_TYPE, setToast } from "@plane/ui"; import { useCycle } from "@/hooks/store/use-cycle"; import { useIssues } from "@/hooks/store/use-issues"; //icons @@ -150,7 +147,7 @@ export const TransferIssuesModal: React.FC = observer((props) => { handleClose(); }} > - +
{cycleDetails?.name} {cycleDetails.status && ( diff --git a/apps/web/core/components/cycles/transfer-issues.tsx b/apps/web/core/components/cycles/transfer-issues.tsx index 467ec3082..bcc1b7615 100644 --- a/apps/web/core/components/cycles/transfer-issues.tsx +++ b/apps/web/core/components/cycles/transfer-issues.tsx @@ -2,8 +2,8 @@ import React from "react"; import { AlertCircle } from "lucide-react"; // ui +import { Button } from "@plane/propel/button"; import { TransferIcon } from "@plane/propel/icons"; -import { Button } from "@plane/ui"; type Props = { handleClick: () => void; diff --git a/apps/web/core/components/dropdowns/buttons.tsx b/apps/web/core/components/dropdowns/buttons.tsx index 245fd7ccc..b3d5355bd 100644 --- a/apps/web/core/components/dropdowns/buttons.tsx +++ b/apps/web/core/components/dropdowns/buttons.tsx @@ -6,7 +6,7 @@ import { cn } from "@plane/utils"; // types import { usePlatformOS } from "@/hooks/use-platform-os"; import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS } from "./constants"; -import { TButtonVariants } from "./types"; +import type { TButtonVariants } from "./types"; export type DropdownButtonProps = { children: React.ReactNode; diff --git a/apps/web/core/components/dropdowns/constants.ts b/apps/web/core/components/dropdowns/constants.ts index ce52ad505..9451b1fca 100644 --- a/apps/web/core/components/dropdowns/constants.ts +++ b/apps/web/core/components/dropdowns/constants.ts @@ -1,5 +1,5 @@ // types -import { TButtonVariants } from "./types"; +import type { TButtonVariants } from "./types"; export const BORDER_BUTTON_VARIANTS: TButtonVariants[] = ["border-with-text", "border-without-text"]; diff --git a/apps/web/core/components/dropdowns/cycle/cycle-options.tsx b/apps/web/core/components/dropdowns/cycle/cycle-options.tsx index 4c3a0effd..3bde0dfca 100644 --- a/apps/web/core/components/dropdowns/cycle/cycle-options.tsx +++ b/apps/web/core/components/dropdowns/cycle/cycle-options.tsx @@ -1,7 +1,8 @@ "use client"; -import { FC, useEffect, useRef, useState } from "react"; -import { Placement } from "@popperjs/core"; +import type { FC } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { usePopper } from "react-popper"; @@ -11,8 +12,8 @@ import { Combobox } from "@headlessui/react"; // i18n import { useTranslation } from "@plane/i18n"; // icon -import { ContrastIcon, CycleGroupIcon } from "@plane/propel/icons"; -import { TCycleGroups } from "@plane/types"; +import { CycleGroupIcon, CycleIcon } from "@plane/propel/icons"; +import type { TCycleGroups } from "@plane/types"; // ui // store hooks import { useCycle } from "@/hooks/store/use-cycle"; @@ -110,7 +111,7 @@ export const CycleOptions: FC = observer((props) => { query: t("cycle.no_cycle"), content: (
- + {t("cycle.no_cycle")}
), diff --git a/apps/web/core/components/dropdowns/cycle/index.tsx b/apps/web/core/components/dropdowns/cycle/index.tsx index 4326064e6..15569315c 100644 --- a/apps/web/core/components/dropdowns/cycle/index.tsx +++ b/apps/web/core/components/dropdowns/cycle/index.tsx @@ -1,11 +1,12 @@ "use client"; -import { ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; import { ChevronDown } from "lucide-react"; import { useTranslation } from "@plane/i18n"; // ui -import { ContrastIcon } from "@plane/propel/icons"; +import { CycleIcon } from "@plane/propel/icons"; import { ComboDropDown } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; @@ -15,7 +16,7 @@ import { useDropdown } from "@/hooks/use-dropdown"; // local components and constants import { DropdownButton } from "../buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; -import { TDropdownProps } from "../types"; +import type { TDropdownProps } from "../types"; import { CycleOptions } from "./cycle-options"; type Props = TDropdownProps & { @@ -120,7 +121,7 @@ export const CycleDropdown: React.FC = observer((props) => { variant={buttonVariant} renderToolTipByDefault={renderByDefault} > - {!hideIcon && } + {!hideIcon && } {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (!!selectedName || !!placeholder) && ( {selectedName ?? placeholder} )} diff --git a/apps/web/core/components/dropdowns/date-range.tsx b/apps/web/core/components/dropdowns/date-range.tsx index 09f0419cc..83f0c904e 100644 --- a/apps/web/core/components/dropdowns/date-range.tsx +++ b/apps/web/core/components/dropdowns/date-range.tsx @@ -1,16 +1,18 @@ "use client"; import React, { useEffect, useRef, useState } from "react"; -import { Placement } from "@popperjs/core"; +import type { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; -import { DateRange, Matcher } from "react-day-picker"; +import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; // ui -import { ComboDropDown, Calendar } from "@plane/ui"; +import type { DateRange, Matcher } from "@plane/propel/calendar"; +import { Calendar } from "@plane/propel/calendar"; +import { ComboDropDown } from "@plane/ui"; import { cn, renderFormattedDate } from "@plane/utils"; // helpers // hooks @@ -20,7 +22,7 @@ import { useDropdown } from "@/hooks/use-dropdown"; import { DropdownButton } from "./buttons"; import { MergedDateDisplay } from "./merged-date"; // types -import { TButtonVariants } from "./types"; +import type { TButtonVariants } from "./types"; type Props = { applyButtonText?: string; @@ -59,6 +61,8 @@ type Props = { renderPlaceholder?: boolean; customTooltipContent?: React.ReactNode; customTooltipHeading?: string; + defaultOpen?: boolean; + renderInPortal?: boolean; }; export const DateRangeDropdown: React.FC = observer((props) => { @@ -93,9 +97,11 @@ export const DateRangeDropdown: React.FC = observer((props) => { renderPlaceholder = true, customTooltipContent, customTooltipHeading, + defaultOpen = false, + renderInPortal = false, } = props; // states - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); const [dateRange, setDateRange] = useState(value); // hooks const { data } = useUserProfile(); @@ -193,7 +199,9 @@ export const DateRangeDropdown: React.FC = observer((props) => { renderPlaceholder && ( <> {placeholder.from} - + {placeholder.from && placeholder.to && ( + + )} {placeholder.to} ) @@ -247,6 +255,34 @@ export const DateRangeDropdown: React.FC = observer((props) => { ); + const comboOptions = ( + +
+ { + onSelect?.(val); + }} + mode="range" + disabled={disabledDays} + showOutsideDays + fixedWeeks + weekStartsOn={startOfWeek} + initialFocus + /> +
+
+ ); + + const Options = renderInPortal ? createPortal(comboOptions, document.body) : comboOptions; + return ( = observer((props) => { disabled={disabled} renderByDefault={renderByDefault} > - {isOpen && ( - -
- { - onSelect?.(val); - }} - mode="range" - disabled={disabledDays} - showOutsideDays - fixedWeeks - weekStartsOn={startOfWeek} - initialFocus - /> -
-
- )} + {isOpen && Options}
); }); diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index f01f1ea50..2600d42b7 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -1,12 +1,15 @@ +"use client"; + import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; -import { Matcher } from "react-day-picker"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; // ui -import { ComboDropDown, Calendar } from "@plane/ui"; +import type { Matcher } from "@plane/propel/calendar"; +import { Calendar } from "@plane/propel/calendar"; +import { ComboDropDown } from "@plane/ui"; import { cn, renderFormattedDate, getDate } from "@plane/utils"; // helpers // hooks @@ -17,10 +20,11 @@ import { DropdownButton } from "./buttons"; // constants import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; // types -import { TDropdownProps } from "./types"; +import type { TDropdownProps } from "./types"; type Props = TDropdownProps & { clearIconClassName?: string; + defaultOpen?: boolean; optionsClassName?: string; icon?: React.ReactNode; isClearable?: boolean; @@ -41,6 +45,7 @@ export const DateDropdown: React.FC = observer((props) => { buttonVariant, className = "", clearIconClassName = "", + defaultOpen = false, optionsClassName = "", closeOnSelect = true, disabled = false, @@ -60,7 +65,7 @@ export const DateDropdown: React.FC = observer((props) => { renderByDefault = true, } = props; // states - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); // refs const dropdownRef = useRef(null); // hooks @@ -178,11 +183,11 @@ export const DateDropdown: React.FC = observer((props) => { {...attributes.popper} > { + onSelect={(date: Date | undefined) => { dropdownOnChange(date ?? null); }} showOutsideDays diff --git a/apps/web/core/components/dropdowns/estimate.tsx b/apps/web/core/components/dropdowns/estimate.tsx index 296fbe601..3a8ec9174 100644 --- a/apps/web/core/components/dropdowns/estimate.tsx +++ b/apps/web/core/components/dropdowns/estimate.tsx @@ -1,4 +1,5 @@ -import { ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { usePopper } from "react-popper"; @@ -17,7 +18,7 @@ import { useDropdown } from "@/hooks/use-dropdown"; import { DropdownButton } from "./buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; // types -import { TDropdownProps } from "./types"; +import type { TDropdownProps } from "./types"; type Props = TDropdownProps & { button?: ReactNode; diff --git a/apps/web/core/components/dropdowns/member/avatar.tsx b/apps/web/core/components/dropdowns/member/avatar.tsx index 9c8cb8775..5dc01268a 100644 --- a/apps/web/core/components/dropdowns/member/avatar.tsx +++ b/apps/web/core/components/dropdowns/member/avatar.tsx @@ -1,7 +1,8 @@ "use client"; import { observer } from "mobx-react"; -import { LucideIcon, Users } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Users } from "lucide-react"; // plane ui import { Avatar, AvatarGroup } from "@plane/ui"; import { cn, getFileURL } from "@plane/utils"; diff --git a/apps/web/core/components/dropdowns/member/base.tsx b/apps/web/core/components/dropdowns/member/base.tsx index ef17e9482..64ee88450 100644 --- a/apps/web/core/components/dropdowns/member/base.tsx +++ b/apps/web/core/components/dropdowns/member/base.tsx @@ -1,9 +1,10 @@ import { useRef, useState } from "react"; import { observer } from "mobx-react"; -import { ChevronDown, LucideIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { ChevronDown } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { IUserLite } from "@plane/types"; +import type { IUserLite } from "@plane/types"; import { ComboDropDown } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; @@ -14,7 +15,7 @@ import { DropdownButton } from "../buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; import { ButtonAvatars } from "./avatar"; import { MemberOptions } from "./member-options"; -import { MemberDropdownProps } from "./types"; +import type { MemberDropdownProps } from "./types"; type TMemberDropdownBaseProps = { getUserDetails: (userId: string) => IUserLite | undefined; diff --git a/apps/web/core/components/dropdowns/member/dropdown.tsx b/apps/web/core/components/dropdowns/member/dropdown.tsx index 47347c370..8708b880c 100644 --- a/apps/web/core/components/dropdowns/member/dropdown.tsx +++ b/apps/web/core/components/dropdowns/member/dropdown.tsx @@ -1,11 +1,11 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { LucideIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; // hooks import { useMember } from "@/hooks/store/use-member"; // local imports import { MemberDropdownBase } from "./base"; -import { MemberDropdownProps } from "./types"; +import type { MemberDropdownProps } from "./types"; type TMemberDropdownProps = { icon?: LucideIcon; diff --git a/apps/web/core/components/dropdowns/member/member-options.tsx b/apps/web/core/components/dropdowns/member/member-options.tsx index f49cd8438..f89396225 100644 --- a/apps/web/core/components/dropdowns/member/member-options.tsx +++ b/apps/web/core/components/dropdowns/member/member-options.tsx @@ -1,18 +1,22 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Placement } from "@popperjs/core"; +import type { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { Check, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { IUserLite } from "@plane/types"; +import { SuspendedUserIcon } from "@plane/propel/icons"; +import { EPillSize, EPillVariant, Pill } from "@plane/propel/pill"; +import type { IUserLite } from "@plane/types"; import { Avatar } from "@plane/ui"; import { cn, getFileURL } from "@plane/utils"; // hooks +import { useMember } from "@/hooks/store/use-member"; import { useUser } from "@/hooks/store/user"; import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -37,6 +41,8 @@ export const MemberOptions: React.FC = observer((props: Props) => { placement, referenceElement, } = props; + // router + const { workspaceSlug } = useParams(); // refs const inputRef = useRef(null); // states @@ -46,6 +52,9 @@ export const MemberOptions: React.FC = observer((props: Props) => { const { t } = useTranslation(); // store hooks const { data: currentUser } = useUser(); + const { + workspace: { isUserSuspended }, + } = useMember(); const { isMobile } = usePlatformOS(); // popper-js init const { styles, attributes } = usePopper(referenceElement, popperElement, { @@ -84,8 +93,19 @@ export const MemberOptions: React.FC = observer((props: Props) => { query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, content: (
- - +
+ {isUserSuspended(userId, workspaceSlug?.toString()) ? ( + + ) : ( + + )} +
+ {currentUser?.id === userId ? t("you") : userDetails?.display_name}
@@ -133,15 +153,26 @@ export const MemberOptions: React.FC = observer((props: Props) => { key={option.value} value={option.value} className={({ active, selected }) => - `flex w-full cursor-pointer select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5 ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + cn( + "flex w-full select-none items-center justify-between gap-2 truncate rounded px-1 py-1.5", + active && "bg-custom-background-80", + selected ? "text-custom-text-100" : "text-custom-text-200", + isUserSuspended(option.value, workspaceSlug?.toString()) + ? "cursor-not-allowed" + : "cursor-pointer" + ) } + disabled={isUserSuspended(option.value, workspaceSlug?.toString())} > {({ selected }) => ( <> {option.content} {selected && } + {isUserSuspended(option.value, workspaceSlug?.toString()) && ( + + Suspended + + )} )} diff --git a/apps/web/core/components/dropdowns/member/types.d.ts b/apps/web/core/components/dropdowns/member/types.d.ts index 9bdc5192c..926183016 100644 --- a/apps/web/core/components/dropdowns/member/types.d.ts +++ b/apps/web/core/components/dropdowns/member/types.d.ts @@ -1,4 +1,4 @@ -import { TDropdownProps } from "../types"; +import type { TDropdownProps } from "../types"; export type MemberDropdownProps = TDropdownProps & { button?: React.ReactNode; diff --git a/apps/web/core/components/dropdowns/module/base.tsx b/apps/web/core/components/dropdowns/module/base.tsx index 5e0965f9a..7743c28ea 100644 --- a/apps/web/core/components/dropdowns/module/base.tsx +++ b/apps/web/core/components/dropdowns/module/base.tsx @@ -1,10 +1,11 @@ "use client"; -import { ReactNode, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { IModule } from "@plane/types"; +import type { IModule } from "@plane/types"; import { ComboDropDown } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks @@ -13,7 +14,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; // local imports import { DropdownButton } from "../buttons"; import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants"; -import { TDropdownProps } from "../types"; +import type { TDropdownProps } from "../types"; import { ModuleButtonContent } from "./button-content"; import { ModuleOptions } from "./module-options"; diff --git a/apps/web/core/components/dropdowns/module/button-content.tsx b/apps/web/core/components/dropdowns/module/button-content.tsx index 6b8ecc176..c2dcabb3d 100644 --- a/apps/web/core/components/dropdowns/module/button-content.tsx +++ b/apps/web/core/components/dropdowns/module/button-content.tsx @@ -2,7 +2,7 @@ import { ChevronDown, X } from "lucide-react"; // plane imports -import { DiceIcon } from "@plane/propel/icons"; +import { ModuleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { cn } from "@plane/utils"; // hooks @@ -46,7 +46,7 @@ export const ModuleButtonContent: React.FC = (props) = <> {showCount ? (
- {!hideIcon && } + {!hideIcon && } {(value.length > 0 || !!placeholder) && (
{value.length > 0 @@ -69,7 +69,7 @@ export const ModuleButtonContent: React.FC = (props) = className )} > - {!hideIcon && } + {!hideIcon && } {!hideText && ( = (props) =
) : ( <> - {!hideIcon && } + {!hideIcon && } {placeholder} )} @@ -118,7 +118,7 @@ export const ModuleButtonContent: React.FC = (props) = else return ( <> - {!hideIcon && } + {!hideIcon && } {!hideText && ( {value ? getModuleById(value)?.name : placeholder} )} diff --git a/apps/web/core/components/dropdowns/module/dropdown.tsx b/apps/web/core/components/dropdowns/module/dropdown.tsx index 03a265ae9..8465765a0 100644 --- a/apps/web/core/components/dropdowns/module/dropdown.tsx +++ b/apps/web/core/components/dropdowns/module/dropdown.tsx @@ -1,12 +1,12 @@ "use client"; -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // hooks import { useModule } from "@/hooks/store/use-module"; // types -import { TDropdownProps } from "../types"; +import type { TDropdownProps } from "../types"; // local imports import { ModuleDropdownBase } from "./base"; diff --git a/apps/web/core/components/dropdowns/module/module-options.tsx b/apps/web/core/components/dropdowns/module/module-options.tsx index aab6568a2..6cc629012 100644 --- a/apps/web/core/components/dropdowns/module/module-options.tsx +++ b/apps/web/core/components/dropdowns/module/module-options.tsx @@ -1,15 +1,15 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Placement } from "@popperjs/core"; +import type { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; import { Check, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { DiceIcon } from "@plane/propel/icons"; -import { IModule } from "@plane/types"; +import { ModuleIcon } from "@plane/propel/icons"; +import type { IModule } from "@plane/types"; import { cn } from "@plane/utils"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; @@ -85,7 +85,7 @@ export const ModuleOptions = observer((props: Props) => { query: `${moduleDetails?.name}`, content: (
- + {moduleDetails?.name}
), @@ -97,7 +97,7 @@ export const ModuleOptions = observer((props: Props) => { query: t("module.no_module"), content: (
- + {t("module.no_module")}
), diff --git a/apps/web/core/components/dropdowns/priority.tsx b/apps/web/core/components/dropdowns/priority.tsx index d2485d112..24ce73fb9 100644 --- a/apps/web/core/components/dropdowns/priority.tsx +++ b/apps/web/core/components/dropdowns/priority.tsx @@ -1,6 +1,7 @@ "use client"; -import { Fragment, ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { Fragment, useRef, useState } from "react"; import { useTheme } from "next-themes"; import { usePopper } from "react-popper"; import { Check, ChevronDown, Search, SignalHigh } from "lucide-react"; @@ -10,7 +11,7 @@ import { useTranslation } from "@plane/i18n"; // types import { PriorityIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { TIssuePriorities } from "@plane/types"; +import type { TIssuePriorities } from "@plane/types"; // ui import { ComboDropDown } from "@plane/ui"; // helpers @@ -21,7 +22,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os"; // constants import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS, BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; // types -import { TDropdownProps } from "./types"; +import type { TDropdownProps } from "./types"; type Props = TDropdownProps & { button?: ReactNode; diff --git a/apps/web/core/components/dropdowns/project/base.tsx b/apps/web/core/components/dropdowns/project/base.tsx index c06c217b7..77a2e85fe 100644 --- a/apps/web/core/components/dropdowns/project/base.tsx +++ b/apps/web/core/components/dropdowns/project/base.tsx @@ -1,10 +1,12 @@ -import { ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; -import { Briefcase, Check, ChevronDown, Search } from "lucide-react"; +import { Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; +import { ProjectIcon } from "@plane/propel/icons"; import { ComboDropDown } from "@plane/ui"; import { cn } from "@plane/utils"; // components @@ -12,11 +14,11 @@ import { Logo } from "@/components/common/logo"; // hooks import { useDropdown } from "@/hooks/use-dropdown"; // plane web imports -import { TProject } from "@/plane-web/types"; +import type { TProject } from "@/plane-web/types"; // local imports import { DropdownButton } from "../buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; -import { TDropdownProps } from "../types"; +import type { TDropdownProps } from "../types"; type Props = TDropdownProps & { button?: ReactNode; @@ -154,7 +156,7 @@ export const ProjectDropdownBase: React.FC = observer((props) => { return projectDetails?.logo_props ? renderIcon(projectDetails.logo_props) : null; }) ) : ( - + )}
); diff --git a/apps/web/core/components/dropdowns/project/dropdown.tsx b/apps/web/core/components/dropdowns/project/dropdown.tsx index 3c82b1b49..61a6d58a8 100644 --- a/apps/web/core/components/dropdowns/project/dropdown.tsx +++ b/apps/web/core/components/dropdowns/project/dropdown.tsx @@ -1,9 +1,9 @@ -import { ReactNode } from "react"; +import type { ReactNode } from "react"; import { observer } from "mobx-react"; // hooks import { useProject } from "@/hooks/store/use-project"; // local imports -import { TDropdownProps } from "../types"; +import type { TDropdownProps } from "../types"; import { ProjectDropdownBase } from "./base"; type Props = TDropdownProps & { diff --git a/apps/web/core/components/dropdowns/state/base.tsx b/apps/web/core/components/dropdowns/state/base.tsx index 0fd654d91..cfa51f603 100644 --- a/apps/web/core/components/dropdowns/state/base.tsx +++ b/apps/web/core/components/dropdowns/state/base.tsx @@ -1,6 +1,7 @@ "use client"; -import { ReactNode, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useRef, useState } from "react"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; import { ChevronDown, Search } from "lucide-react"; @@ -8,13 +9,13 @@ import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; import { StateGroupIcon } from "@plane/propel/icons"; -import { IState } from "@plane/types"; +import type { IState } from "@plane/types"; import { ComboDropDown, Spinner } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { DropdownButton } from "@/components/dropdowns/buttons"; import { BUTTON_VARIANTS_WITH_TEXT } from "@/components/dropdowns/constants"; -import { TDropdownProps } from "@/components/dropdowns/types"; +import type { TDropdownProps } from "@/components/dropdowns/types"; // hooks import { useDropdown } from "@/hooks/use-dropdown"; // plane web imports diff --git a/apps/web/core/components/dropdowns/state/dropdown.tsx b/apps/web/core/components/dropdowns/state/dropdown.tsx index 3b816ebd9..b119a20ad 100644 --- a/apps/web/core/components/dropdowns/state/dropdown.tsx +++ b/apps/web/core/components/dropdowns/state/dropdown.tsx @@ -6,7 +6,8 @@ import { useParams } from "next/navigation"; // hooks import { useProjectState } from "@/hooks/store/use-project-state"; // local imports -import { WorkItemStateDropdownBase, TWorkItemStateDropdownBaseProps } from "./base"; +import type { TWorkItemStateDropdownBaseProps } from "./base"; +import { WorkItemStateDropdownBase } from "./base"; type TWorkItemStateDropdownProps = Omit< TWorkItemStateDropdownBaseProps, @@ -28,7 +29,7 @@ export const StateDropdown: React.FC = observer((pr // fetch states if not provided const onDropdownOpen = async () => { - if (stateIds === undefined && workspaceSlug && projectId) { + if ((stateIds === undefined || stateIds.length === 0) && workspaceSlug && projectId) { setStateLoader(true); await fetchProjectStates(workspaceSlug.toString(), projectId); setStateLoader(false); diff --git a/apps/web/core/components/dropdowns/types.d.ts b/apps/web/core/components/dropdowns/types.d.ts index 128c7a525..0aa0b1153 100644 --- a/apps/web/core/components/dropdowns/types.d.ts +++ b/apps/web/core/components/dropdowns/types.d.ts @@ -1,4 +1,4 @@ -import { Placement } from "@popperjs/core"; +import type { Placement } from "@popperjs/core"; export type TButtonVariants = | "border-with-text" diff --git a/apps/web/core/components/editor/document/editor.tsx b/apps/web/core/components/editor/document/editor.tsx index 19679895d..9695088b4 100644 --- a/apps/web/core/components/editor/document/editor.tsx +++ b/apps/web/core/components/editor/document/editor.tsx @@ -1,13 +1,8 @@ import React, { forwardRef } from "react"; // plane imports -import { - DocumentEditorWithRef, - IEditorPropsExtended, - type EditorRefApi, - type IDocumentEditorProps, - type TFileHandler, -} from "@plane/editor"; -import { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; +import { DocumentEditorWithRef } from "@plane/editor"; +import type { IEditorPropsExtended, EditorRefApi, IDocumentEditorProps, TFileHandler } from "@plane/editor"; +import type { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; import { cn } from "@plane/utils"; // hooks import { useEditorConfig, useEditorMention } from "@/hooks/editor"; diff --git a/apps/web/core/components/editor/lite-text/editor.tsx b/apps/web/core/components/editor/lite-text/editor.tsx index 42d6f0bd9..6ae3edf41 100644 --- a/apps/web/core/components/editor/lite-text/editor.tsx +++ b/apps/web/core/components/editor/lite-text/editor.tsx @@ -1,8 +1,9 @@ import React, { useState } from "react"; // plane constants -import { EIssueCommentAccessSpecifier } from "@plane/constants"; +import type { EIssueCommentAccessSpecifier } from "@plane/constants"; // plane imports -import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor"; +import { LiteTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; import type { MakeOptional } from "@plane/types"; import { cn, isCommentEmpty } from "@plane/utils"; @@ -14,8 +15,9 @@ import { useEditorConfig, useEditorMention } from "@/hooks/editor"; import { useMember } from "@/hooks/store/use-member"; // plane web hooks import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; -// plane web services +// plane web service import { WorkspaceService } from "@/plane-web/services"; +import { LiteToolbar } from "./lite-toolbar"; const workspaceService = new WorkspaceService(); type LiteTextEditorWrapperProps = MakeOptional< @@ -31,9 +33,10 @@ type LiteTextEditorWrapperProps = MakeOptional< showSubmitButton?: boolean; isSubmitting?: boolean; showToolbarInitially?: boolean; - showToolbar?: boolean; + variant?: "full" | "lite" | "none"; issue_id?: string; parentClassName?: string; + editorClassName?: string; } & ( | { editable: false; @@ -59,14 +62,17 @@ export const LiteTextEditor = React.forwardRef !showToolbarInitially && setIsFocused(true)} - onBlur={() => !showToolbarInitially && setIsFocused(false)} + onFocus={() => isFullVariant && !showToolbarInitially && setIsFocused(true)} + onBlur={() => isFullVariant && !showToolbarInitially && setIsFocused(false)} > - "", - workspaceId, - workspaceSlug, - })} - mentionHandler={{ - searchCallback: async (query) => { - const res = await fetchMentions(query); - if (!res) throw new Error("Failed in fetching mentions"); - return res; - }, - renderComponent: EditorMentionsRoot, - getMentionedEntityDetails: (id) => ({ - display_name: getUserDetails(id)?.display_name ?? "", - }), - }} - placeholder={placeholder} - containerClassName={cn(containerClassName, "relative", { - "p-2": !editable, - })} - extendedEditorProps={{}} - {...rest} - /> - {showToolbar && editable && ( + {/* Wrapper for lite toolbar layout */} +
+ {/* Main Editor - always rendered once */} +
+ "", + workspaceId, + workspaceSlug, + })} + mentionHandler={{ + searchCallback: async (query) => { + const res = await fetchMentions(query); + if (!res) throw new Error("Failed in fetching mentions"); + return res; + }, + renderComponent: EditorMentionsRoot, + getMentionedEntityDetails: (id) => ({ + display_name: getUserDetails(id)?.display_name ?? "", + }), + }} + placeholder={placeholder} + containerClassName={cn(containerClassName, "relative", { + "p-2": !editable, + })} + extendedEditorProps={{}} + editorClassName={editorClassName} + {...rest} + /> +
+ + {/* Lite Toolbar - conditionally rendered */} + {isLiteVariant && editable && ( + { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }} + onSubmit={(e) => rest.onEnterKeyPress?.(e)} + isSubmitting={isSubmitting} + isEmpty={isEmpty} + /> + )} +
+ + {/* Full Toolbar - conditionally rendered */} + {isFullVariant && editable && (
| React.MouseEvent) => void; + isSubmitting: boolean; + isEmpty: boolean; + executeCommand: (item: ToolbarMenuItem) => void; +}; + +export const LiteToolbar = ({ onSubmit, isSubmitting, isEmpty, executeCommand }: LiteToolbarProps) => ( +
+ + +
+); + +export type { LiteToolbarProps }; diff --git a/apps/web/core/components/editor/lite-text/toolbar.tsx b/apps/web/core/components/editor/lite-text/toolbar.tsx index 9b230f01c..3d1b11df7 100644 --- a/apps/web/core/components/editor/lite-text/toolbar.tsx +++ b/apps/web/core/components/editor/lite-text/toolbar.tsx @@ -1,18 +1,20 @@ "use client"; import React, { useEffect, useState, useCallback } from "react"; -import { Globe2, Lock, LucideIcon } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import { Globe2, Lock } from "lucide-react"; import { EIssueCommentAccessSpecifier } from "@plane/constants"; // editor import type { EditorRefApi } from "@plane/editor"; // i18n import { useTranslation } from "@plane/i18n"; // ui +import { Button } from "@plane/propel/button"; import { Tooltip } from "@plane/propel/tooltip"; -import { Button } from "@plane/ui"; // constants import { cn } from "@plane/utils"; -import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; +import type { ToolbarMenuItem } from "@/constants/editor"; +import { TOOLBAR_ITEMS } from "@/constants/editor"; // helpers type Props = { diff --git a/apps/web/core/components/editor/pdf/document.tsx b/apps/web/core/components/editor/pdf/document.tsx index 4dca9e6d5..88b0b08ef 100644 --- a/apps/web/core/components/editor/pdf/document.tsx +++ b/apps/web/core/components/editor/pdf/document.tsx @@ -1,6 +1,7 @@ "use client"; -import { Document, Font, Page, PageProps } from "@react-pdf/renderer"; +import type { PageProps } from "@react-pdf/renderer"; +import { Document, Font, Page } from "@react-pdf/renderer"; import { Html } from "react-pdf-html"; // constants import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor"; diff --git a/apps/web/core/components/editor/rich-text/editor.tsx b/apps/web/core/components/editor/rich-text/editor.tsx index 67abc21d2..612522dcc 100644 --- a/apps/web/core/components/editor/rich-text/editor.tsx +++ b/apps/web/core/components/editor/rich-text/editor.tsx @@ -1,6 +1,7 @@ import React, { forwardRef } from "react"; // plane imports -import { type EditorRefApi, type IRichTextEditorProps, RichTextEditorWithRef, type TFileHandler } from "@plane/editor"; +import { RichTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor"; import type { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types"; import { cn } from "@plane/utils"; // components diff --git a/apps/web/core/components/editor/sticky-editor/color-palette.tsx b/apps/web/core/components/editor/sticky-editor/color-palette.tsx index a4fe05b77..0403d41b5 100644 --- a/apps/web/core/components/editor/sticky-editor/color-palette.tsx +++ b/apps/web/core/components/editor/sticky-editor/color-palette.tsx @@ -1,4 +1,4 @@ -import { TSticky } from "@plane/types"; +import type { TSticky } from "@plane/types"; export const STICKY_COLORS_LIST: { key: string; diff --git a/apps/web/core/components/editor/sticky-editor/editor.tsx b/apps/web/core/components/editor/sticky-editor/editor.tsx index 237266529..7a6d52614 100644 --- a/apps/web/core/components/editor/sticky-editor/editor.tsx +++ b/apps/web/core/components/editor/sticky-editor/editor.tsx @@ -1,10 +1,11 @@ import React, { useState } from "react"; // plane constants -import { EIssueCommentAccessSpecifier } from "@plane/constants"; +import type { EIssueCommentAccessSpecifier } from "@plane/constants"; // plane editor -import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor"; +import { LiteTextEditorWithRef } from "@plane/editor"; +import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor"; // components -import { TSticky } from "@plane/types"; +import type { TSticky } from "@plane/types"; // helpers import { cn } from "@plane/utils"; // hooks diff --git a/apps/web/core/components/editor/sticky-editor/toolbar.tsx b/apps/web/core/components/editor/sticky-editor/toolbar.tsx index 9626fa46e..0a41515a0 100644 --- a/apps/web/core/components/editor/sticky-editor/toolbar.tsx +++ b/apps/web/core/components/editor/sticky-editor/toolbar.tsx @@ -7,10 +7,11 @@ import type { EditorRefApi } from "@plane/editor"; // ui import { useOutsideClickDetector } from "@plane/hooks"; import { Tooltip } from "@plane/propel/tooltip"; -import { TSticky } from "@plane/types"; +import type { TSticky } from "@plane/types"; // constants import { cn } from "@plane/utils"; -import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; +import type { ToolbarMenuItem } from "@/constants/editor"; +import { TOOLBAR_ITEMS } from "@/constants/editor"; // helpers import { ColorPalette } from "./color-palette"; diff --git a/apps/web/core/components/empty-state/comic-box-button.tsx b/apps/web/core/components/empty-state/comic-box-button.tsx index 6a1a1e59c..1ef4ef312 100644 --- a/apps/web/core/components/empty-state/comic-box-button.tsx +++ b/apps/web/core/components/empty-state/comic-box-button.tsx @@ -1,11 +1,12 @@ "use client"; -import { Fragment, Ref, useState } from "react"; +import type { Ref } from "react"; +import { Fragment, useState } from "react"; import { usePopper } from "react-popper"; import { Popover } from "@headlessui/react"; // popper // helper -import { getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; type Props = { label: string; diff --git a/apps/web/core/components/empty-state/detailed-empty-state-root.tsx b/apps/web/core/components/empty-state/detailed-empty-state-root.tsx index 4ae97e839..1776e72ae 100644 --- a/apps/web/core/components/empty-state/detailed-empty-state-root.tsx +++ b/apps/web/core/components/empty-state/detailed-empty-state-root.tsx @@ -4,7 +4,7 @@ import React from "react"; import { observer } from "mobx-react"; import Image from "next/image"; // ui -import { Button } from "@plane/ui/src/button"; +import { Button } from "@plane/propel/button"; // utils import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/empty-state/section-empty-state-root.tsx b/apps/web/core/components/empty-state/section-empty-state-root.tsx index e415db53c..164663f07 100644 --- a/apps/web/core/components/empty-state/section-empty-state-root.tsx +++ b/apps/web/core/components/empty-state/section-empty-state-root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { cn } from "@plane/utils"; type Props = { diff --git a/apps/web/core/components/estimates/create/modal.tsx b/apps/web/core/components/estimates/create/modal.tsx index 396ba572e..a999d0a44 100644 --- a/apps/web/core/components/estimates/create/modal.tsx +++ b/apps/web/core/components/estimates/create/modal.tsx @@ -1,13 +1,16 @@ "use client"; -import { FC, useEffect, useMemo, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react"; import { ChevronLeft } from "lucide-react"; // plane imports import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IEstimateFormData, TEstimateSystemKeys, TEstimatePointsObject, TEstimateTypeError } from "@plane/types"; -import { Button, EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IEstimateFormData, TEstimateSystemKeys, TEstimatePointsObject, TEstimateTypeError } from "@plane/types"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // hooks import { useProjectEstimates } from "@/hooks/store/estimates"; // local imports diff --git a/apps/web/core/components/estimates/create/stage-one.tsx b/apps/web/core/components/estimates/create/stage-one.tsx index 78628996a..94fc26463 100644 --- a/apps/web/core/components/estimates/create/stage-one.tsx +++ b/apps/web/core/components/estimates/create/stage-one.tsx @@ -1,12 +1,12 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { Info } from "lucide-react"; // plane imports import { EEstimateSystem, ESTIMATE_SYSTEMS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/propel/tooltip"; -import { TEstimateSystemKeys } from "@plane/types"; +import type { TEstimateSystemKeys } from "@plane/types"; // components import { convertMinutesToHoursMinutesString } from "@plane/utils"; // plane web imports diff --git a/apps/web/core/components/estimates/delete/modal.tsx b/apps/web/core/components/estimates/delete/modal.tsx index 152133802..ea38b3f36 100644 --- a/apps/web/core/components/estimates/delete/modal.tsx +++ b/apps/web/core/components/estimates/delete/modal.tsx @@ -1,10 +1,13 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // ui import { PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; -import { Button, EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useProjectEstimates } from "@/hooks/store/estimates"; diff --git a/apps/web/core/components/estimates/empty-screen.tsx b/apps/web/core/components/estimates/empty-screen.tsx index edc1c4f07..706888ce0 100644 --- a/apps/web/core/components/estimates/empty-screen.tsx +++ b/apps/web/core/components/estimates/empty-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { useTheme } from "next-themes"; import { PROJECT_SETTINGS_TRACKER_ELEMENTS, PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; // plane imports diff --git a/apps/web/core/components/estimates/estimate-disable-switch.tsx b/apps/web/core/components/estimates/estimate-disable-switch.tsx index 9a951e02c..31bcb369c 100644 --- a/apps/web/core/components/estimates/estimate-disable-switch.tsx +++ b/apps/web/core/components/estimates/estimate-disable-switch.tsx @@ -1,10 +1,11 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { PROJECT_SETTINGS_TRACKER_ELEMENTS, PROJECT_SETTINGS_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { ToggleSwitch } from "@plane/ui"; // hooks import { captureElementAndEvent } from "@/helpers/event-tracker.helper"; import { useProjectEstimates } from "@/hooks/store/estimates"; diff --git a/apps/web/core/components/estimates/estimate-list-item.tsx b/apps/web/core/components/estimates/estimate-list-item.tsx index fe0ef0709..aa8c06a69 100644 --- a/apps/web/core/components/estimates/estimate-list-item.tsx +++ b/apps/web/core/components/estimates/estimate-list-item.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { EEstimateSystem } from "@plane/constants"; import { convertMinutesToHoursMinutesString, cn } from "@plane/utils"; diff --git a/apps/web/core/components/estimates/estimate-list.tsx b/apps/web/core/components/estimates/estimate-list.tsx index c493352c1..699e6a685 100644 --- a/apps/web/core/components/estimates/estimate-list.tsx +++ b/apps/web/core/components/estimates/estimate-list.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // local imports import { EstimateListItem } from "./estimate-list-item"; diff --git a/apps/web/core/components/estimates/estimate-search.tsx b/apps/web/core/components/estimates/estimate-search.tsx index 7c94fc8b8..78e22237f 100644 --- a/apps/web/core/components/estimates/estimate-search.tsx +++ b/apps/web/core/components/estimates/estimate-search.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; export const EstimateSearch: FC = observer(() => { diff --git a/apps/web/core/components/estimates/inputs/number-input.tsx b/apps/web/core/components/estimates/inputs/number-input.tsx index bf9abf145..63f4d2118 100644 --- a/apps/web/core/components/estimates/inputs/number-input.tsx +++ b/apps/web/core/components/estimates/inputs/number-input.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { useTranslation } from "@plane/i18n"; type TEstimateNumberInputProps = { value?: number; diff --git a/apps/web/core/components/estimates/inputs/root.tsx b/apps/web/core/components/estimates/inputs/root.tsx index 56ad098d9..bd32c2e29 100644 --- a/apps/web/core/components/estimates/inputs/root.tsx +++ b/apps/web/core/components/estimates/inputs/root.tsx @@ -1,6 +1,7 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane imports -import { EEstimateSystem, TEstimateSystemKeys } from "@plane/types"; +import type { TEstimateSystemKeys } from "@plane/types"; +import { EEstimateSystem } from "@plane/types"; // plane web imports import { EstimateTimeInput } from "@/plane-web/components/estimates/inputs"; // local imports diff --git a/apps/web/core/components/estimates/inputs/text-input.tsx b/apps/web/core/components/estimates/inputs/text-input.tsx index e6e930abe..989798ea4 100644 --- a/apps/web/core/components/estimates/inputs/text-input.tsx +++ b/apps/web/core/components/estimates/inputs/text-input.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { useTranslation } from "@plane/i18n"; type TEstimateTextInputProps = { value?: string; diff --git a/apps/web/core/components/estimates/loader-screen.tsx b/apps/web/core/components/estimates/loader-screen.tsx index 2231c6d84..6e1fc5c21 100644 --- a/apps/web/core/components/estimates/loader-screen.tsx +++ b/apps/web/core/components/estimates/loader-screen.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { Loader } from "@plane/ui"; export const EstimateLoaderScreen: FC = () => ( diff --git a/apps/web/core/components/estimates/points/create-root.tsx b/apps/web/core/components/estimates/points/create-root.tsx index 9d10c5582..99958c8d1 100644 --- a/apps/web/core/components/estimates/points/create-root.tsx +++ b/apps/web/core/components/estimates/points/create-root.tsx @@ -1,12 +1,14 @@ "use client"; -import { Dispatch, FC, SetStateAction, useCallback, useState } from "react"; +import type { Dispatch, FC, SetStateAction } from "react"; +import { useCallback, useState } from "react"; import { observer } from "mobx-react"; import { Plus } from "lucide-react"; // plane imports import { estimateCount } from "@plane/constants"; -import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeError } from "@plane/types"; -import { Button, Sortable } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeError } from "@plane/types"; +import { Sortable } from "@plane/ui"; // local imports import { EstimatePointCreate } from "./create"; import { EstimatePointItemPreview } from "./preview"; diff --git a/apps/web/core/components/estimates/points/create.tsx b/apps/web/core/components/estimates/points/create.tsx index 695e1b09a..7baf24152 100644 --- a/apps/web/core/components/estimates/points/create.tsx +++ b/apps/web/core/components/estimates/points/create.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, useState, FormEvent } from "react"; +import type { FC, FormEvent } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { Check, Info, X } from "lucide-react"; import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; -import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; +import { Spinner } from "@plane/ui"; import { cn, isEstimatePointValuesRepeated } from "@plane/utils"; import { EstimateInputRoot } from "@/components/estimates/inputs/root"; // helpers diff --git a/apps/web/core/components/estimates/points/preview.tsx b/apps/web/core/components/estimates/points/preview.tsx index 7792a79d3..6c21bea05 100644 --- a/apps/web/core/components/estimates/points/preview.tsx +++ b/apps/web/core/components/estimates/points/preview.tsx @@ -1,10 +1,11 @@ -import { FC, useEffect, useRef, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { GripVertical, Pencil, Trash2 } from "lucide-react"; // plane imports import { EEstimateSystem, estimateCount } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; import { convertMinutesToHoursMinutesString } from "@plane/utils"; // plane web imports import { EstimatePointDelete } from "@/plane-web/components/estimates"; diff --git a/apps/web/core/components/estimates/points/update.tsx b/apps/web/core/components/estimates/points/update.tsx index d36a57e1f..bbcc4751e 100644 --- a/apps/web/core/components/estimates/points/update.tsx +++ b/apps/web/core/components/estimates/points/update.tsx @@ -1,13 +1,15 @@ "use client"; -import { FC, useEffect, useState, FormEvent } from "react"; +import type { FC, FormEvent } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { Check, Info, X } from "lucide-react"; import { EEstimateSystem, MAX_ESTIMATE_POINT_INPUT_LENGTH } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; -import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +import type { TEstimatePointsObject, TEstimateSystemKeys, TEstimateTypeErrorObject } from "@plane/types"; +import { Spinner } from "@plane/ui"; import { cn, isEstimatePointValuesRepeated } from "@plane/utils"; import { EstimateInputRoot } from "@/components/estimates/inputs/root"; // helpers diff --git a/apps/web/core/components/estimates/root.tsx b/apps/web/core/components/estimates/root.tsx index 461a863d9..f562d924c 100644 --- a/apps/web/core/components/estimates/root.tsx +++ b/apps/web/core/components/estimates/root.tsx @@ -1,4 +1,5 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; // plane imports diff --git a/apps/web/core/components/exporter/column.tsx b/apps/web/core/components/exporter/column.tsx index 113478c9d..429d0bb29 100644 --- a/apps/web/core/components/exporter/column.tsx +++ b/apps/web/core/components/exporter/column.tsx @@ -1,5 +1,5 @@ import { Download } from "lucide-react"; -import { IExportData } from "@plane/types"; +import type { IExportData } from "@plane/types"; import { getDate, getFileURL, renderFormattedDate } from "@plane/utils"; type RowData = IExportData; diff --git a/apps/web/core/components/exporter/export-form.tsx b/apps/web/core/components/exporter/export-form.tsx index 1b43d7784..1005a81e7 100644 --- a/apps/web/core/components/exporter/export-form.tsx +++ b/apps/web/core/components/exporter/export-form.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { intersection } from "lodash"; +import { intersection } from "lodash-es"; import { Controller, useForm } from "react-hook-form"; import { EUserPermissions, @@ -9,7 +9,9 @@ import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { Button, CustomSearchSelect, CustomSelect, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { CustomSearchSelect, CustomSelect } from "@plane/ui"; import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useProject } from "@/hooks/store/use-project"; import { useUser, useUserPermissions } from "@/hooks/store/user"; diff --git a/apps/web/core/components/exporter/export-modal.tsx b/apps/web/core/components/exporter/export-modal.tsx index 86bd6bd86..73cb92ad8 100644 --- a/apps/web/core/components/exporter/export-modal.tsx +++ b/apps/web/core/components/exporter/export-modal.tsx @@ -1,15 +1,17 @@ "use client"; import React, { useState } from "react"; -import intersection from "lodash/intersection"; +import { intersection } from "lodash-es"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { Dialog, Transition } from "@headlessui/react"; // types import { useTranslation } from "@plane/i18n"; -import { IUser, IImporterService } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IUser, IImporterService } from "@plane/types"; // ui -import { Button, CustomSearchSelect, TOAST_TYPE, setToast } from "@plane/ui"; +import { CustomSearchSelect } from "@plane/ui"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useUser } from "@/hooks/store/user"; diff --git a/apps/web/core/components/exporter/single-export.tsx b/apps/web/core/components/exporter/single-export.tsx index 6e8dde3da..0731bc47a 100644 --- a/apps/web/core/components/exporter/single-export.tsx +++ b/apps/web/core/components/exporter/single-export.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, FC } from "react"; +import type { FC } from "react"; +import { useState } from "react"; // ui -import { IExportData } from "@plane/types"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import type { IExportData } from "@plane/types"; // helpers import { getDate, renderFormattedDate } from "@plane/utils"; // types diff --git a/apps/web/core/components/gantt-chart/blocks/block-row.tsx b/apps/web/core/components/gantt-chart/blocks/block-row.tsx index e24fe2ee7..d281c062f 100644 --- a/apps/web/core/components/gantt-chart/blocks/block-row.tsx +++ b/apps/web/core/components/gantt-chart/blocks/block-row.tsx @@ -6,7 +6,7 @@ import type { IBlockUpdateData, IGanttBlock } from "@plane/types"; import { cn } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // import { BLOCK_HEIGHT, SIDEBAR_WIDTH } from "../constants"; diff --git a/apps/web/core/components/gantt-chart/blocks/block.tsx b/apps/web/core/components/gantt-chart/blocks/block.tsx index 0087a3896..7be9e9c27 100644 --- a/apps/web/core/components/gantt-chart/blocks/block.tsx +++ b/apps/web/core/components/gantt-chart/blocks/block.tsx @@ -1,4 +1,5 @@ -import { RefObject, useRef } from "react"; +import type { RefObject } from "react"; +import { useRef } from "react"; import { observer } from "mobx-react"; // components import type { IBlockUpdateDependencyData } from "@plane/types"; diff --git a/apps/web/core/components/gantt-chart/chart/main-content.tsx b/apps/web/core/components/gantt-chart/chart/main-content.tsx index 23b46da63..3e1ebafba 100644 --- a/apps/web/core/components/gantt-chart/chart/main-content.tsx +++ b/apps/web/core/components/gantt-chart/chart/main-content.tsx @@ -2,7 +2,13 @@ import { useEffect, useRef } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; import { observer } from "mobx-react"; -import { ChartDataType, IBlockUpdateData, IBlockUpdateDependencyData, IGanttBlock, TGanttViews } from "@plane/types"; +import type { + ChartDataType, + IBlockUpdateData, + IBlockUpdateDependencyData, + IGanttBlock, + TGanttViews, +} from "@plane/types"; import { cn, getDate } from "@plane/utils"; // components import { MultipleSelectGroup } from "@/components/core/multiple-select"; diff --git a/apps/web/core/components/gantt-chart/chart/root.tsx b/apps/web/core/components/gantt-chart/chart/root.tsx index 364260d16..8231507d5 100644 --- a/apps/web/core/components/gantt-chart/chart/root.tsx +++ b/apps/web/core/components/gantt-chart/chart/root.tsx @@ -1,4 +1,5 @@ -import { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { createPortal } from "react-dom"; // plane imports @@ -13,15 +14,8 @@ import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // import { SIDEBAR_WIDTH } from "../constants"; import { currentViewDataWithView } from "../data"; -import { - getNumberOfDaysBetweenTwoDates, - IMonthBlock, - IMonthView, - IWeekBlock, - monthView, - quarterView, - weekView, -} from "../views"; +import type { IMonthBlock, IMonthView, IWeekBlock } from "../views"; +import { getNumberOfDaysBetweenTwoDates, monthView, quarterView, weekView } from "../views"; type ChartViewRootProps = { border: boolean; diff --git a/apps/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx b/apps/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx index e9896d87e..68e77741d 100644 --- a/apps/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx +++ b/apps/web/core/components/gantt-chart/chart/timeline-drag-helper.tsx @@ -1,4 +1,4 @@ -import { RefObject } from "react"; +import type { RefObject } from "react"; import { observer } from "mobx-react"; // hooks import { useAutoScroller } from "@/hooks/use-auto-scroller"; diff --git a/apps/web/core/components/gantt-chart/chart/views/month.tsx b/apps/web/core/components/gantt-chart/chart/views/month.tsx index 01f6aa801..ad87337fb 100644 --- a/apps/web/core/components/gantt-chart/chart/views/month.tsx +++ b/apps/web/core/components/gantt-chart/chart/views/month.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // components import { cn } from "@plane/utils"; @@ -7,7 +7,7 @@ import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "@/components/gantt-chart/constants // hooks import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // types -import { IMonthView } from "../../views"; +import type { IMonthView } from "../../views"; import { getNumberOfDaysBetweenTwoDates } from "../../views/helpers"; export const MonthChartView: FC = observer(() => { diff --git a/apps/web/core/components/gantt-chart/chart/views/quarter.tsx b/apps/web/core/components/gantt-chart/chart/views/quarter.tsx index ee1a11c57..aed12a74a 100644 --- a/apps/web/core/components/gantt-chart/chart/views/quarter.tsx +++ b/apps/web/core/components/gantt-chart/chart/views/quarter.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane utils import { cn } from "@plane/utils"; @@ -6,7 +6,8 @@ import { cn } from "@plane/utils"; import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants"; -import { groupMonthsToQuarters, IMonthBlock, IQuarterMonthBlock } from "../../views"; +import type { IMonthBlock, IQuarterMonthBlock } from "../../views"; +import { groupMonthsToQuarters } from "../../views"; export const QuarterChartView: FC = observer(() => { const { currentViewData, renderView } = useTimeLineChartStore(); diff --git a/apps/web/core/components/gantt-chart/chart/views/week.tsx b/apps/web/core/components/gantt-chart/chart/views/week.tsx index 7c38d97ee..2a6e64ba0 100644 --- a/apps/web/core/components/gantt-chart/chart/views/week.tsx +++ b/apps/web/core/components/gantt-chart/chart/views/week.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane utils import { cn } from "@plane/utils"; @@ -6,7 +6,7 @@ import { cn } from "@plane/utils"; import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "../../constants"; -import { IWeekBlock } from "../../views"; +import type { IWeekBlock } from "../../views"; export const WeekChartView: FC = observer(() => { const { currentViewData, renderView } = useTimeLineChartStore(); diff --git a/apps/web/core/components/gantt-chart/data/index.ts b/apps/web/core/components/gantt-chart/data/index.ts index 897af9ebd..4c11b865e 100644 --- a/apps/web/core/components/gantt-chart/data/index.ts +++ b/apps/web/core/components/gantt-chart/data/index.ts @@ -1,5 +1,6 @@ // types -import { EStartOfTheWeek, WeekMonthDataType, ChartDataType, TGanttViews } from "@plane/types"; +import type { WeekMonthDataType, ChartDataType, TGanttViews } from "@plane/types"; +import { EStartOfTheWeek } from "@plane/types"; // constants export const generateWeeks = (startOfWeek: EStartOfTheWeek = EStartOfTheWeek.SUNDAY): WeekMonthDataType[] => [ diff --git a/apps/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts b/apps/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts index b96e58390..2a6814ff3 100644 --- a/apps/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts +++ b/apps/web/core/components/gantt-chart/helpers/blockResizables/use-gantt-resizable.ts @@ -1,7 +1,7 @@ import { useRef, useState } from "react"; // Plane +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { IBlockUpdateDependencyData, IGanttBlock } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; // hooks import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // diff --git a/apps/web/core/components/gantt-chart/helpers/draggable.tsx b/apps/web/core/components/gantt-chart/helpers/draggable.tsx index 98eda46c4..f365903e0 100644 --- a/apps/web/core/components/gantt-chart/helpers/draggable.tsx +++ b/apps/web/core/components/gantt-chart/helpers/draggable.tsx @@ -1,4 +1,5 @@ -import React, { RefObject } from "react"; +import type { RefObject } from "react"; +import React from "react"; import { observer } from "mobx-react"; // hooks import type { IGanttBlock } from "@plane/types"; diff --git a/apps/web/core/components/gantt-chart/root.tsx b/apps/web/core/components/gantt-chart/root.tsx index da25611a8..e59edfede 100644 --- a/apps/web/core/components/gantt-chart/root.tsx +++ b/apps/web/core/components/gantt-chart/root.tsx @@ -1,4 +1,5 @@ -import { FC, useEffect } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; // components import type { IBlockUpdateData, IBlockUpdateDependencyData } from "@plane/types"; diff --git a/apps/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx b/apps/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx index 5f1a8f8b8..a83c07850 100644 --- a/apps/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx +++ b/apps/web/core/components/gantt-chart/sidebar/gantt-dnd-HOC.tsx @@ -6,7 +6,8 @@ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-d import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { observer } from "mobx-react"; import { useOutsideClickDetector } from "@plane/hooks"; -import { DropIndicator, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import { DropIndicator } from "@plane/ui"; import { HIGHLIGHT_WITH_LINE, highlightIssueOnDrop } from "@/components/issues/issue-layouts/utils"; type Props = { diff --git a/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx b/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx index 2c64f4116..b5e3a11ea 100644 --- a/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx +++ b/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx @@ -8,7 +8,7 @@ import { MultipleSelectEntityAction } from "@/components/core/multiple-select"; import { IssueGanttSidebarBlock } from "@/components/issues/issue-layouts/gantt/blocks"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; import { useTimeLineChartStore } from "@/hooks/use-timeline-chart"; // local imports import { BLOCK_HEIGHT, GANTT_SELECT_GROUP } from "../../constants"; diff --git a/apps/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx b/apps/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx index 2a720929a..a08651e4a 100644 --- a/apps/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx +++ b/apps/web/core/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -1,6 +1,7 @@ "use client"; -import { RefObject, useState } from "react"; +import type { RefObject } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // ui import type { IBlockUpdateData } from "@plane/types"; @@ -11,7 +12,7 @@ import { GanttLayoutListItemLoader } from "@/components/ui/loader/layouts/gantt- //hooks import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import { useIssuesStore } from "@/hooks/use-issue-layout-store"; -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; // local imports import { useTimeLineChart } from "../../../../hooks/use-timeline-chart"; import { ETimeLineTypeType } from "../../contexts"; diff --git a/apps/web/core/components/gantt-chart/sidebar/root.tsx b/apps/web/core/components/gantt-chart/sidebar/root.tsx index 39243b282..acd777832 100644 --- a/apps/web/core/components/gantt-chart/sidebar/root.tsx +++ b/apps/web/core/components/gantt-chart/sidebar/root.tsx @@ -1,4 +1,4 @@ -import { RefObject } from "react"; +import type { RefObject } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // components @@ -8,7 +8,7 @@ import { cn } from "@plane/utils"; import { MultipleSelectGroupAction } from "@/components/core/multiple-select"; // helpers // hooks -import { TSelectionHelper } from "@/hooks/use-multiple-select"; +import type { TSelectionHelper } from "@/hooks/use-multiple-select"; // constants import { GANTT_SELECT_GROUP, HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants"; diff --git a/apps/web/core/components/gantt-chart/views/month-view.ts b/apps/web/core/components/gantt-chart/views/month-view.ts index 178a68d67..ffd12d9e9 100644 --- a/apps/web/core/components/gantt-chart/views/month-view.ts +++ b/apps/web/core/components/gantt-chart/views/month-view.ts @@ -1,10 +1,11 @@ -import cloneDeep from "lodash/cloneDeep"; -import uniqBy from "lodash/uniqBy"; -// +import { cloneDeep, uniqBy } from "lodash-es"; +// plane imports import type { ChartDataType } from "@plane/types"; +// local imports import { months } from "../data"; import { getNumberOfDaysBetweenTwoDates, getNumberOfDaysInMonth } from "./helpers"; -import { getWeeksBetweenTwoDates, IWeekBlock } from "./week-view"; +import type { IWeekBlock } from "./week-view"; +import { getWeeksBetweenTwoDates } from "./week-view"; export interface IMonthBlock { today: boolean; diff --git a/apps/web/core/components/gantt-chart/views/quarter-view.ts b/apps/web/core/components/gantt-chart/views/quarter-view.ts index b8541bf98..ab307dee3 100644 --- a/apps/web/core/components/gantt-chart/views/quarter-view.ts +++ b/apps/web/core/components/gantt-chart/views/quarter-view.ts @@ -2,7 +2,8 @@ import type { ChartDataType } from "@plane/types"; import { quarters } from "../data"; import { getNumberOfDaysBetweenTwoDates } from "./helpers"; -import { getMonthsBetweenTwoDates, IMonthBlock } from "./month-view"; +import type { IMonthBlock } from "./month-view"; +import { getMonthsBetweenTwoDates } from "./month-view"; export interface IQuarterMonthBlock { children: IMonthBlock[]; diff --git a/apps/web/core/components/gantt-chart/views/week-view.ts b/apps/web/core/components/gantt-chart/views/week-view.ts index a1e2b9db7..f50afeeae 100644 --- a/apps/web/core/components/gantt-chart/views/week-view.ts +++ b/apps/web/core/components/gantt-chart/views/week-view.ts @@ -1,5 +1,6 @@ // -import { EStartOfTheWeek, ChartDataType } from "@plane/types"; +import type { ChartDataType } from "@plane/types"; +import { EStartOfTheWeek } from "@plane/types"; import { months, generateWeeks } from "../data"; import { getNumberOfDaysBetweenTwoDates, getWeekNumberByDate } from "./helpers"; export interface IDayBlock { diff --git a/apps/web/core/components/global/product-updates/footer.tsx b/apps/web/core/components/global/product-updates/footer.tsx index ab679d56a..9c21ea785 100644 --- a/apps/web/core/components/global/product-updates/footer.tsx +++ b/apps/web/core/components/global/product-updates/footer.tsx @@ -1,8 +1,8 @@ import { USER_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // ui +import { getButtonStyling } from "@plane/propel/button"; import { PlaneLogo } from "@plane/propel/icons"; -import { getButtonStyling } from "@plane/ui"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/global/product-updates/modal.tsx b/apps/web/core/components/global/product-updates/modal.tsx index 77a1df9b7..45b2781e1 100644 --- a/apps/web/core/components/global/product-updates/modal.tsx +++ b/apps/web/core/components/global/product-updates/modal.tsx @@ -1,4 +1,5 @@ -import { FC, useEffect } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; import { USER_TRACKER_ELEMENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; diff --git a/apps/web/core/components/global/timezone-select.tsx b/apps/web/core/components/global/timezone-select.tsx index af20a7c88..73817cc0d 100644 --- a/apps/web/core/components/global/timezone-select.tsx +++ b/apps/web/core/components/global/timezone-select.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { CustomSearchSelect } from "@plane/ui"; import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/home/home-dashboard-widgets.tsx b/apps/web/core/components/home/home-dashboard-widgets.tsx index cfb36d2c5..e1fa834d2 100644 --- a/apps/web/core/components/home/home-dashboard-widgets.tsx +++ b/apps/web/core/components/home/home-dashboard-widgets.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; // plane imports import { useTranslation } from "@plane/i18n"; -import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types"; +import type { THomeWidgetKeys, THomeWidgetProps } from "@plane/types"; // components import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; // hooks diff --git a/apps/web/core/components/home/user-greetings.tsx b/apps/web/core/components/home/user-greetings.tsx index d88041d0a..de935ea98 100644 --- a/apps/web/core/components/home/user-greetings.tsx +++ b/apps/web/core/components/home/user-greetings.tsx @@ -1,7 +1,7 @@ -import { FC } from "react"; +import type { FC } from "react"; // plane types import { useTranslation } from "@plane/i18n"; -import { IUser } from "@plane/types"; +import type { IUser } from "@plane/types"; // plane ui // hooks import { useCurrentTime } from "@/hooks/use-current-time"; diff --git a/apps/web/core/components/home/widgets/empty-states/no-projects.tsx b/apps/web/core/components/home/widgets/empty-states/no-projects.tsx index a3e702ff1..1ea8a985f 100644 --- a/apps/web/core/components/home/widgets/empty-states/no-projects.tsx +++ b/apps/web/core/components/home/widgets/empty-states/no-projects.tsx @@ -3,11 +3,12 @@ import React from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; -import { Briefcase, Check, Hotel, Users, X } from "lucide-react"; +import { Check, Hotel, Users, X } from "lucide-react"; // plane ui import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; import { useLocalStorage } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; +import { ProjectIcon } from "@plane/propel/icons"; import { cn, getFileURL } from "@plane/utils"; // helpers // hooks @@ -47,7 +48,7 @@ export const NoProjectsEmptyState = observer(() => { id: "create-project", title: "home.empty.create_project.title", description: "home.empty.create_project.description", - icon: , + icon: , flag: "projects", cta: { text: "home.empty.create_project.cta", diff --git a/apps/web/core/components/home/widgets/empty-states/recents.tsx b/apps/web/core/components/home/widgets/empty-states/recents.tsx index c939cae61..4839e9bfa 100644 --- a/apps/web/core/components/home/widgets/empty-states/recents.tsx +++ b/apps/web/core/components/home/widgets/empty-states/recents.tsx @@ -1,27 +1,27 @@ -import { Briefcase, FileText, History } from "lucide-react"; +import { History } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { LayersIcon } from "@plane/propel/icons"; +import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons"; const getDisplayContent = (type: string) => { switch (type) { case "project": return { - icon: , + icon: , text: "home.recents.empty.project", }; case "page": return { - icon: , + icon: , text: "home.recents.empty.page", }; case "issue": return { - icon: , + icon: , text: "home.recents.empty.issue", }; default: return { - icon: , + icon: , text: "home.recents.empty.default", }; } diff --git a/apps/web/core/components/home/widgets/links/create-update-link-modal.tsx b/apps/web/core/components/home/widgets/links/create-update-link-modal.tsx index 354fdae0c..5039402cf 100644 --- a/apps/web/core/components/home/widgets/links/create-update-link-modal.tsx +++ b/apps/web/core/components/home/widgets/links/create-update-link-modal.tsx @@ -1,14 +1,17 @@ "use client"; -import { FC, useEffect } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // plane types // plane ui import { useTranslation } from "@plane/i18n"; -import { TLink, TLinkEditableFields } from "@plane/types"; -import { Button, Input, ModalCore } from "@plane/ui"; -import { TLinkOperations } from "./use-links"; +import { Button } from "@plane/propel/button"; +import type { TLinkEditableFields } from "@plane/types"; +import { TLink } from "@plane/types"; +import { Input, ModalCore } from "@plane/ui"; +import type { TLinkOperations } from "./use-links"; export type TLinkOperationsModal = Exclude; diff --git a/apps/web/core/components/home/widgets/links/link-detail.tsx b/apps/web/core/components/home/widgets/links/link-detail.tsx index 3461dcc87..2a47a12d2 100644 --- a/apps/web/core/components/home/widgets/links/link-detail.tsx +++ b/apps/web/core/components/home/widgets/links/link-detail.tsx @@ -1,16 +1,19 @@ "use client"; -import { FC, useCallback, useMemo } from "react"; +import type { FC } from "react"; +import { useCallback, useMemo } from "react"; import { observer } from "mobx-react"; import { Pencil, ExternalLink, Link, Trash2 } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { TOAST_TYPE, setToast, TContextMenuItem, LinkItemBlock } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TContextMenuItem } from "@plane/ui"; +import { LinkItemBlock } from "@plane/ui"; // plane utils import { copyTextToClipboard } from "@plane/utils"; // hooks import { useHome } from "@/hooks/store/use-home"; // types -import { TLinkOperations } from "./use-links"; +import type { TLinkOperations } from "./use-links"; export type TProjectLinkDetail = { linkId: string; diff --git a/apps/web/core/components/home/widgets/links/links.tsx b/apps/web/core/components/home/widgets/links/links.tsx index 7e61bce55..1abeba10f 100644 --- a/apps/web/core/components/home/widgets/links/links.tsx +++ b/apps/web/core/components/home/widgets/links/links.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // computed import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC"; @@ -6,7 +6,7 @@ import { useHome } from "@/hooks/store/use-home"; import { LinksEmptyState } from "../empty-states/links"; import { EWidgetKeys, WidgetLoader } from "../loaders"; import { ProjectLinkDetail } from "./link-detail"; -import { TLinkOperations } from "./use-links"; +import type { TLinkOperations } from "./use-links"; export type TLinkOperationsModal = Exclude; diff --git a/apps/web/core/components/home/widgets/links/root.tsx b/apps/web/core/components/home/widgets/links/root.tsx index e9097361b..de8f6e612 100644 --- a/apps/web/core/components/home/widgets/links/root.tsx +++ b/apps/web/core/components/home/widgets/links/root.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react"; import useSWR from "swr"; import { Plus } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { THomeWidgetProps } from "@plane/types"; +import type { THomeWidgetProps } from "@plane/types"; import { useHome } from "@/hooks/store/use-home"; import { LinkCreateUpdateModal } from "./create-update-link-modal"; import { ProjectLinkList } from "./links"; diff --git a/apps/web/core/components/home/widgets/links/use-links.tsx b/apps/web/core/components/home/widgets/links/use-links.tsx index 2f2a9c78f..441325875 100644 --- a/apps/web/core/components/home/widgets/links/use-links.tsx +++ b/apps/web/core/components/home/widgets/links/use-links.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "@plane/i18n"; -import { TProjectLink } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TProjectLink } from "@plane/types"; import { useHome } from "@/hooks/store/use-home"; export type TLinkOperations = { diff --git a/apps/web/core/components/home/widgets/loaders/home-loader.tsx b/apps/web/core/components/home/widgets/loaders/home-loader.tsx index 56d32725b..579d7497f 100644 --- a/apps/web/core/components/home/widgets/loaders/home-loader.tsx +++ b/apps/web/core/components/home/widgets/loaders/home-loader.tsx @@ -1,6 +1,6 @@ "use client"; -import range from "lodash/range"; +import { range } from "lodash-es"; // ui import { Loader } from "@plane/ui"; diff --git a/apps/web/core/components/home/widgets/loaders/quick-links.tsx b/apps/web/core/components/home/widgets/loaders/quick-links.tsx index 3037bf39c..67e4b4cf6 100644 --- a/apps/web/core/components/home/widgets/loaders/quick-links.tsx +++ b/apps/web/core/components/home/widgets/loaders/quick-links.tsx @@ -1,6 +1,6 @@ "use client"; -import range from "lodash/range"; +import { range } from "lodash-es"; // ui import { Loader } from "@plane/ui"; diff --git a/apps/web/core/components/home/widgets/loaders/recent-activity.tsx b/apps/web/core/components/home/widgets/loaders/recent-activity.tsx index 741ef29eb..9dc7a6fa0 100644 --- a/apps/web/core/components/home/widgets/loaders/recent-activity.tsx +++ b/apps/web/core/components/home/widgets/loaders/recent-activity.tsx @@ -1,6 +1,6 @@ "use client"; -import range from "lodash/range"; +import { range } from "lodash-es"; // ui import { Loader } from "@plane/ui"; diff --git a/apps/web/core/components/home/widgets/manage/index.tsx b/apps/web/core/components/home/widgets/manage/index.tsx index 8834ca69d..1c4eb4543 100644 --- a/apps/web/core/components/home/widgets/manage/index.tsx +++ b/apps/web/core/components/home/widgets/manage/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane types // plane ui diff --git a/apps/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx b/apps/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx index b19f74d24..7caf82da9 100644 --- a/apps/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx +++ b/apps/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // ui import { DragHandle } from "@plane/ui"; diff --git a/apps/web/core/components/home/widgets/manage/widget-item.tsx b/apps/web/core/components/home/widgets/manage/widget-item.tsx index 42cb7f5c0..1979d52fa 100644 --- a/apps/web/core/components/home/widgets/manage/widget-item.tsx +++ b/apps/web/core/components/home/widgets/manage/widget-item.tsx @@ -1,13 +1,14 @@ "use client"; -import React, { FC, useEffect, useRef, useState } from "react"; +import type { FC } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; -import { DropTargetRecord, DragLocationHistory } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"; -import { - draggable, - dropTargetForElements, - ElementDragPayload, -} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import type { + DropTargetRecord, + DragLocationHistory, +} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"; +import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"; import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; @@ -16,7 +17,7 @@ import { useParams } from "next/navigation"; import { createRoot } from "react-dom/client"; // plane types import { useTranslation } from "@plane/i18n"; -import { InstructionType, TWidgetEntityData } from "@plane/types"; +import type { InstructionType, TWidgetEntityData } from "@plane/types"; // plane ui import { DropIndicator, ToggleSwitch } from "@plane/ui"; // plane utils diff --git a/apps/web/core/components/home/widgets/manage/widget-list.tsx b/apps/web/core/components/home/widgets/manage/widget-list.tsx index ff7db11c2..7312fc47c 100644 --- a/apps/web/core/components/home/widgets/manage/widget-list.tsx +++ b/apps/web/core/components/home/widgets/manage/widget-list.tsx @@ -1,14 +1,15 @@ -import { +import type { DragLocationHistory, DropTargetRecord, ElementDragPayload, } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; -import { setToast, TOAST_TYPE } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { useHome } from "@/hooks/store/use-home"; import { WidgetItem } from "./widget-item"; -import { getInstructionFromPayload, TargetData } from "./widget.helpers"; +import type { TargetData } from "./widget.helpers"; +import { getInstructionFromPayload } from "./widget.helpers"; export const WidgetList = observer(({ workspaceSlug }: { workspaceSlug: string }) => { const { orderedWidgets, reorderWidget, toggleWidget } = useHome(); diff --git a/apps/web/core/components/home/widgets/manage/widget.helpers.ts b/apps/web/core/components/home/widgets/manage/widget.helpers.ts index a72ee8028..e9429e13e 100644 --- a/apps/web/core/components/home/widgets/manage/widget.helpers.ts +++ b/apps/web/core/components/home/widgets/manage/widget.helpers.ts @@ -1,5 +1,5 @@ import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; -import { InstructionType, IPragmaticPayloadLocation, TDropTarget, TWidgetEntityData } from "@plane/types"; +import type { InstructionType, IPragmaticPayloadLocation, TDropTarget, TWidgetEntityData } from "@plane/types"; export type TargetData = { id: string; diff --git a/apps/web/core/components/home/widgets/recents/filters.tsx b/apps/web/core/components/home/widgets/recents/filters.tsx index fc1b42517..401915580 100644 --- a/apps/web/core/components/home/widgets/recents/filters.tsx +++ b/apps/web/core/components/home/widgets/recents/filters.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { ChevronDown } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { TRecentActivityFilterKeys } from "@plane/types"; +import type { TRecentActivityFilterKeys } from "@plane/types"; import { CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/home/widgets/recents/index.tsx b/apps/web/core/components/home/widgets/recents/index.tsx index f02317aa8..c10b715fc 100644 --- a/apps/web/core/components/home/widgets/recents/index.tsx +++ b/apps/web/core/components/home/widgets/recents/index.tsx @@ -3,11 +3,10 @@ import { useRef, useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; -import { Briefcase, FileText } from "lucide-react"; import { useTranslation } from "@plane/i18n"; // plane types -import { LayersIcon } from "@plane/propel/icons"; -import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; +import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons"; +import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; // plane ui // components import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC"; @@ -24,9 +23,9 @@ const WIDGET_KEY = EWidgetKeys.RECENT_ACTIVITY; const workspaceService = new WorkspaceService(); const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_key: string }[] = [ { name: "all item", i18n_key: "home.recents.filters.all" }, - { name: "issue", icon: , i18n_key: "home.recents.filters.issues" }, - { name: "page", icon: , i18n_key: "home.recents.filters.pages" }, - { name: "project", icon: , i18n_key: "home.recents.filters.projects" }, + { name: "issue", icon: , i18n_key: "home.recents.filters.issues" }, + { name: "page", icon: , i18n_key: "home.recents.filters.pages" }, + { name: "project", icon: , i18n_key: "home.recents.filters.projects" }, ]; type TRecentWidgetProps = THomeWidgetProps & { diff --git a/apps/web/core/components/home/widgets/recents/issue.tsx b/apps/web/core/components/home/widgets/recents/issue.tsx index 8cad58785..9b9191358 100644 --- a/apps/web/core/components/home/widgets/recents/issue.tsx +++ b/apps/web/core/components/home/widgets/recents/issue.tsx @@ -1,8 +1,9 @@ import { observer } from "mobx-react"; // plane types -import { LayersIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import { PriorityIcon, StateGroupIcon, WorkItemsIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { EIssueServiceType, TActivityEntityData, TIssueEntityData } from "@plane/types"; +import type { TActivityEntityData, TIssueEntityData } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // plane ui import { calculateTimeAgo, generateWorkItemLink } from "@plane/utils"; // components @@ -77,7 +78,7 @@ export const RecentIssue = observer((props: BlockProps) => { ) : (
- +
{issueDetails?.project_identifier}-{issueDetails?.sequence_id} diff --git a/apps/web/core/components/home/widgets/recents/page.tsx b/apps/web/core/components/home/widgets/recents/page.tsx index 0d21062e6..14a9f0467 100644 --- a/apps/web/core/components/home/widgets/recents/page.tsx +++ b/apps/web/core/components/home/widgets/recents/page.tsx @@ -1,5 +1,5 @@ import { useRouter } from "next/navigation"; -import { FileText } from "lucide-react"; +import { PageIcon } from "@plane/propel/icons"; // plane import import type { TActivityEntityData, TPageEntityData } from "@plane/types"; import { Avatar } from "@plane/ui"; @@ -43,7 +43,7 @@ export const RecentPage = (props: BlockProps) => { {pageDetails?.logo_props?.in_use ? ( ) : ( - + )}
{pageDetails?.project_identifier && ( diff --git a/apps/web/core/components/home/widgets/recents/project.tsx b/apps/web/core/components/home/widgets/recents/project.tsx index ab2699113..e9e68a49f 100644 --- a/apps/web/core/components/home/widgets/recents/project.tsx +++ b/apps/web/core/components/home/widgets/recents/project.tsx @@ -1,6 +1,6 @@ import { useRouter } from "next/navigation"; // plane types -import { TActivityEntityData, TProjectEntityData } from "@plane/types"; +import type { TActivityEntityData, TProjectEntityData } from "@plane/types"; import { calculateTimeAgo } from "@plane/utils"; // components import { Logo } from "@/components/common/logo"; diff --git a/apps/web/core/components/inbox/content/inbox-issue-header.tsx b/apps/web/core/components/inbox/content/inbox-issue-header.tsx index bb68cdba6..7f375664e 100644 --- a/apps/web/core/components/inbox/content/inbox-issue-header.tsx +++ b/apps/web/core/components/inbox/content/inbox-issue-header.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useCallback, useEffect, useState } from "react"; +import type { FC } from "react"; +import { useCallback, useEffect, useState } from "react"; import { observer } from "mobx-react"; import { CircleCheck, @@ -18,8 +19,11 @@ import { // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EInboxIssueStatus, TNameDescriptionLoader } from "@plane/types"; -import { Button, ControlLink, CustomMenu, Row, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TNameDescriptionLoader } from "@plane/types"; +import { EInboxIssueStatus } from "@plane/types"; +import { ControlLink, CustomMenu, Row } from "@plane/ui"; import { copyUrlToClipboard, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils"; // components import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; diff --git a/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx b/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx index f0e0fbbc6..d38975864 100644 --- a/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx +++ b/apps/web/core/components/inbox/content/inbox-issue-mobile-header.tsx @@ -15,7 +15,7 @@ import { PanelLeft, MoveRight, } from "lucide-react"; -import { TNameDescriptionLoader } from "@plane/types"; +import type { TNameDescriptionLoader } from "@plane/types"; import { Header, CustomMenu, EHeaderVariant } from "@plane/ui"; import { cn, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils"; // components diff --git a/apps/web/core/components/inbox/content/issue-properties.tsx b/apps/web/core/components/inbox/content/issue-properties.tsx index 47ea8f6ae..41a1677d5 100644 --- a/apps/web/core/components/inbox/content/issue-properties.tsx +++ b/apps/web/core/components/inbox/content/issue-properties.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { CalendarCheck2, CopyPlus, Signal, Tag, Users } from "lucide-react"; import { DoubleCircleIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; +import type { TInboxDuplicateIssueDetails, TIssue } from "@plane/types"; import { ControlLink } from "@plane/ui"; import { getDate, renderFormattedPayloadDate, generateWorkItemLink } from "@plane/utils"; // components diff --git a/apps/web/core/components/inbox/content/issue-root.tsx b/apps/web/core/components/inbox/content/issue-root.tsx index 390aed13a..ddc829a74 100644 --- a/apps/web/core/components/inbox/content/issue-root.tsx +++ b/apps/web/core/components/inbox/content/issue-root.tsx @@ -1,12 +1,15 @@ "use client"; -import { Dispatch, SetStateAction, useEffect, useMemo, useRef } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { observer } from "mobx-react"; // plane imports import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import type { EditorRefApi } from "@plane/editor"; -import { EInboxIssueSource, TIssue, TNameDescriptionLoader } from "@plane/types"; -import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue, TNameDescriptionLoader } from "@plane/types"; +import { EInboxIssueSource } from "@plane/types"; +import { Loader } from "@plane/ui"; import { getTextContent } from "@plane/utils"; // components import { DescriptionVersionsRoot } from "@/components/core/description-versions"; diff --git a/apps/web/core/components/inbox/content/root.tsx b/apps/web/core/components/inbox/content/root.tsx index 7a64f02b8..696af4afb 100644 --- a/apps/web/core/components/inbox/content/root.tsx +++ b/apps/web/core/components/inbox/content/root.tsx @@ -1,8 +1,9 @@ -import { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import { TNameDescriptionLoader } from "@plane/types"; +import type { TNameDescriptionLoader } from "@plane/types"; // components import { ContentWrapper } from "@plane/ui"; // hooks diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx index 58799eb3c..6d123f7d3 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx @@ -1,8 +1,8 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants"; -import { TInboxIssueFilterDateKeys } from "@plane/types"; +import type { TInboxIssueFilterDateKeys } from "@plane/types"; // helpers import { Tag } from "@plane/ui"; import { renderFormattedDate } from "@plane/utils"; diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx index 50b38b6fc..3183927d5 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; // hooks diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx index 6a105b7d5..65e64f069 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx @@ -1,10 +1,10 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; // plane types -import { TInboxIssueFilterMemberKeys } from "@plane/types"; +import type { TInboxIssueFilterMemberKeys } from "@plane/types"; // plane ui import { Avatar, Tag } from "@plane/ui"; // helpers diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx index d6886eaf4..d290d9255 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx @@ -1,12 +1,12 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; import { ISSUE_PRIORITIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { PriorityIcon } from "@plane/propel/icons"; -import { TIssuePriorities } from "@plane/types"; +import type { TIssuePriorities } from "@plane/types"; import { Tag } from "@plane/ui"; // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx index b2a517802..8ae9e17e0 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { Header, EHeaderVariant } from "@plane/ui"; diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx index 0871a17da..dd743f90c 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; import { EIconSize } from "@plane/constants"; diff --git a/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx b/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx index 1ecbf5efd..0ef4f9974 100644 --- a/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx +++ b/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx @@ -1,9 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { X } from "lucide-react"; import { INBOX_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TInboxIssueStatus } from "@plane/types"; +import type { TInboxIssueStatus } from "@plane/types"; // constants import { Tag } from "@plane/ui"; // hooks diff --git a/apps/web/core/components/inbox/inbox-filter/filters/date.tsx b/apps/web/core/components/inbox/inbox-filter/filters/date.tsx index 7382a028d..4e1dd286b 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/date.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/date.tsx @@ -1,9 +1,9 @@ -import { FC, useState } from "react"; -import concat from "lodash/concat"; -import uniq from "lodash/uniq"; +import type { FC } from "react"; +import { useState } from "react"; +import { concat, uniq } from "lodash-es"; import { observer } from "mobx-react"; import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants"; -import { TInboxIssueFilterDateKeys } from "@plane/types"; +import type { TInboxIssueFilterDateKeys } from "@plane/types"; // components import { DateFilterModal } from "@/components/core/filters/date-filter-modal"; import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx b/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx index 60c7ce16b..c890ea10d 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx @@ -1,4 +1,5 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { Search, X } from "lucide-react"; // hooks diff --git a/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx b/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx index 1bb86c931..eddfbf6f8 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx @@ -1,8 +1,9 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; -import { IIssueLabel } from "@plane/types"; +import type { IIssueLabel } from "@plane/types"; import { Loader } from "@plane/ui"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/inbox/inbox-filter/filters/members.tsx b/apps/web/core/components/inbox/inbox-filter/filters/members.tsx index 070ceb00b..80566808b 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/members.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/members.tsx @@ -1,10 +1,11 @@ "use client"; -import { FC, useMemo, useState } from "react"; -import sortBy from "lodash/sortBy"; +import type { FC } from "react"; +import { useMemo, useState } from "react"; +import { sortBy } from "lodash-es"; import { observer } from "mobx-react"; // plane types -import { TInboxIssueFilterMemberKeys } from "@plane/types"; +import type { TInboxIssueFilterMemberKeys } from "@plane/types"; // plane ui import { Avatar, Loader } from "@plane/ui"; // components diff --git a/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx b/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx index 580fa3978..aa2608ff2 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/priority.tsx @@ -1,11 +1,12 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { ISSUE_PRIORITIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { PriorityIcon } from "@plane/propel/icons"; -import { TIssuePriorities } from "@plane/types"; +import type { TIssuePriorities } from "@plane/types"; // plane constants // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/inbox/inbox-filter/filters/state.tsx b/apps/web/core/components/inbox/inbox-filter/filters/state.tsx index 4180ec363..3fb6586f3 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/state.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/state.tsx @@ -1,10 +1,11 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { EIconSize } from "@plane/constants"; import { StateGroupIcon } from "@plane/propel/icons"; -import { IState } from "@plane/types"; +import type { IState } from "@plane/types"; import { Loader } from "@plane/ui"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/inbox/inbox-filter/filters/status.tsx b/apps/web/core/components/inbox/inbox-filter/filters/status.tsx index 250ba128b..b0d9217e1 100644 --- a/apps/web/core/components/inbox/inbox-filter/filters/status.tsx +++ b/apps/web/core/components/inbox/inbox-filter/filters/status.tsx @@ -1,9 +1,10 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // types import { INBOX_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TInboxIssueStatus } from "@plane/types"; +import type { TInboxIssueStatus } from "@plane/types"; // components import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters"; // constants diff --git a/apps/web/core/components/inbox/inbox-filter/root.tsx b/apps/web/core/components/inbox/inbox-filter/root.tsx index d6d8567d4..7b6c08072 100644 --- a/apps/web/core/components/inbox/inbox-filter/root.tsx +++ b/apps/web/core/components/inbox/inbox-filter/root.tsx @@ -1,7 +1,7 @@ -import { FC } from "react"; +import type { FC } from "react"; import { ChevronDown, ListFilter } from "lucide-react"; // plane imports -import { getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; import { cn } from "@plane/utils"; // components import { FiltersDropdown } from "@/components/issues/issue-layouts/filters"; diff --git a/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx b/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx index fc984c0f0..5678994b7 100644 --- a/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx +++ b/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx @@ -1,12 +1,13 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react"; import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys } from "@plane/types"; -import { CustomMenu, getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; +import type { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys } from "@plane/types"; +import { CustomMenu } from "@plane/ui"; // constants // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/inbox/inbox-issue-status.tsx b/apps/web/core/components/inbox/inbox-issue-status.tsx index 00a7d0887..fbafb6af5 100644 --- a/apps/web/core/components/inbox/inbox-issue-status.tsx +++ b/apps/web/core/components/inbox/inbox-issue-status.tsx @@ -6,7 +6,7 @@ import { INBOX_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { cn, findHowManyDaysLeft } from "@plane/utils"; // store -import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; +import type { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; import { ICON_PROPERTIES, InboxStatusIcon } from "./inbox-status-icon"; type Props = { diff --git a/apps/web/core/components/inbox/inbox-status-icon.tsx b/apps/web/core/components/inbox/inbox-status-icon.tsx index 8cf91df0c..7235b32e7 100644 --- a/apps/web/core/components/inbox/inbox-status-icon.tsx +++ b/apps/web/core/components/inbox/inbox-status-icon.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, XCircle } from "lucide-react"; -import { TInboxIssueStatus, EInboxIssueStatus } from "@plane/types"; +import type { TInboxIssueStatus } from "@plane/types"; +import { EInboxIssueStatus } from "@plane/types"; import { cn } from "@plane/utils"; export const ICON_PROPERTIES = { diff --git a/apps/web/core/components/inbox/modals/create-modal/create-root.tsx b/apps/web/core/components/inbox/modals/create-modal/create-root.tsx index 66eb349ae..d10094fa2 100644 --- a/apps/web/core/components/inbox/modals/create-modal/create-root.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/create-root.tsx @@ -1,13 +1,16 @@ "use client"; -import { FC, FormEvent, useCallback, useEffect, useRef, useState } from "react"; +import type { FC, FormEvent } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; // plane imports import { ETabIndices, WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; -import { TIssue } from "@plane/types"; -import { Button, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue } from "@plane/types"; +import { ToggleSwitch } from "@plane/ui"; import { renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; // helpers import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx b/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx index 95f1a827a..702c1fa9e 100644 --- a/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/issue-description.tsx @@ -1,12 +1,13 @@ "use client"; -import { FC, RefObject } from "react"; +import type { FC, RefObject } from "react"; import { observer } from "mobx-react"; // plane imports import { ETabIndices } from "@plane/constants"; import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; -import { EFileAssetType, TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; import { Loader } from "@plane/ui"; import { getDescriptionPlaceholderI18n, getTabIndex } from "@plane/utils"; // components diff --git a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx index 2a3609937..92b823d3a 100644 --- a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx @@ -1,9 +1,10 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { LayoutPanelTop } from "lucide-react"; // plane imports import { ETabIndices } from "@plane/constants"; -import { ISearchIssueResponse, TIssue } from "@plane/types"; +import type { ISearchIssueResponse, TIssue } from "@plane/types"; import { CustomMenu } from "@plane/ui"; import { renderFormattedPayloadDate, getDate, getTabIndex } from "@plane/utils"; // components @@ -89,7 +90,6 @@ export const InboxIssueProperties: FC = observer((props) {/* labels */}
{}} value={data?.label_ids || []} onChange={(labelIds) => handleData("label_ids", labelIds)} projectId={projectId} diff --git a/apps/web/core/components/inbox/modals/create-modal/issue-title.tsx b/apps/web/core/components/inbox/modals/create-modal/issue-title.tsx index 18a38e1f5..711efdfd7 100644 --- a/apps/web/core/components/inbox/modals/create-modal/issue-title.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/issue-title.tsx @@ -1,11 +1,11 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { ETabIndices } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TIssue } from "@plane/types"; +import type { TIssue } from "@plane/types"; import { Input } from "@plane/ui"; // helpers import { getTabIndex } from "@plane/utils"; diff --git a/apps/web/core/components/inbox/modals/create-modal/modal.tsx b/apps/web/core/components/inbox/modals/create-modal/modal.tsx index 42e3dd879..acce369bb 100644 --- a/apps/web/core/components/inbox/modals/create-modal/modal.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/modal.tsx @@ -1,6 +1,7 @@ "use-client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; // plane imports import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; // hooks diff --git a/apps/web/core/components/inbox/modals/delete-issue-modal.tsx b/apps/web/core/components/inbox/modals/delete-issue-modal.tsx index b2fd74e83..0b3538fd0 100644 --- a/apps/web/core/components/inbox/modals/delete-issue-modal.tsx +++ b/apps/web/core/components/inbox/modals/delete-issue-modal.tsx @@ -3,9 +3,10 @@ import { observer } from "mobx-react"; // types import { PROJECT_ERROR_MESSAGES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TIssue } from "@plane/types"; // ui -import { AlertModalCore, setToast, TOAST_TYPE } from "@plane/ui"; +import { AlertModalCore } from "@plane/ui"; // constants // hooks import { useProject } from "@/hooks/store/use-project"; diff --git a/apps/web/core/components/inbox/modals/select-duplicate.tsx b/apps/web/core/components/inbox/modals/select-duplicate.tsx index 040d0f5c1..3d89eb68c 100644 --- a/apps/web/core/components/inbox/modals/select-duplicate.tsx +++ b/apps/web/core/components/inbox/modals/select-duplicate.tsx @@ -6,8 +6,9 @@ import { Search } from "lucide-react"; import { Combobox, Dialog, Transition } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { ISearchIssueResponse } from "@plane/types"; -import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { ISearchIssueResponse } from "@plane/types"; +import { Loader } from "@plane/ui"; // components import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root"; // hooks diff --git a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx index 6d53a0234..513bb0f13 100644 --- a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx +++ b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx @@ -1,10 +1,12 @@ "use client"; -import { FC, Fragment, useState } from "react"; +import type { FC } from "react"; +import { Fragment, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; // ui import { useTranslation } from "@plane/i18n"; -import { Button, Calendar } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { Calendar } from "@plane/propel/calendar"; export type InboxIssueSnoozeModalProps = { isOpen: boolean; @@ -48,11 +50,11 @@ export const InboxIssueSnoozeModal: FC = (props) =>
{ + onSelect={(date: Date | undefined) => { if (!date) return; setDate(date); }} diff --git a/apps/web/core/components/inbox/root.tsx b/apps/web/core/components/inbox/root.tsx index 2b7dbe443..4327c3aef 100644 --- a/apps/web/core/components/inbox/root.tsx +++ b/apps/web/core/components/inbox/root.tsx @@ -1,9 +1,10 @@ -import { FC, useEffect, useState } from "react"; +import type { FC } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react"; import { PanelLeft } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Intake } from "@plane/propel/icons"; +import { IntakeIcon } from "@plane/propel/icons"; import { EInboxIssueCurrentTab } from "@plane/types"; import { cn } from "@plane/utils"; // components @@ -61,7 +62,7 @@ export const InboxIssueRoot: FC = observer((props) => { if (error && error?.status === "init-error") return (
- +
{error?.message}
); diff --git a/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx b/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx index a99e459e4..2326824c9 100644 --- a/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx +++ b/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC, MouseEvent } from "react"; +import type { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; diff --git a/apps/web/core/components/inbox/sidebar/inbox-list.tsx b/apps/web/core/components/inbox/sidebar/inbox-list.tsx index cabd16616..808ffdadb 100644 --- a/apps/web/core/components/inbox/sidebar/inbox-list.tsx +++ b/apps/web/core/components/inbox/sidebar/inbox-list.tsx @@ -1,4 +1,5 @@ -import { FC, Fragment } from "react"; +import type { FC } from "react"; +import { Fragment } from "react"; import { observer } from "mobx-react"; // local imports import { InboxIssueListItem } from "./inbox-list-item"; diff --git a/apps/web/core/components/inbox/sidebar/root.tsx b/apps/web/core/components/inbox/sidebar/root.tsx index 93d377ac7..b76c63834 100644 --- a/apps/web/core/components/inbox/sidebar/root.tsx +++ b/apps/web/core/components/inbox/sidebar/root.tsx @@ -1,9 +1,11 @@ "use client"; -import { FC, useCallback, useEffect, useRef, useState } from "react"; +import type { FC } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; -import { TInboxIssueCurrentTab, EInboxIssueCurrentTab } from "@plane/types"; +import type { TInboxIssueCurrentTab } from "@plane/types"; +import { EInboxIssueCurrentTab } from "@plane/types"; // plane imports import { Header, Loader, EHeaderVariant } from "@plane/ui"; import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/instance/maintenance-view.tsx b/apps/web/core/components/instance/maintenance-view.tsx index 2fdc7f635..87f243497 100644 --- a/apps/web/core/components/instance/maintenance-view.tsx +++ b/apps/web/core/components/instance/maintenance-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import Image from "next/image"; import { useTheme } from "next-themes"; // layouts diff --git a/apps/web/core/components/instance/not-ready-view.tsx b/apps/web/core/components/instance/not-ready-view.tsx index ccbb2377f..b44a8b3e0 100644 --- a/apps/web/core/components/instance/not-ready-view.tsx +++ b/apps/web/core/components/instance/not-ready-view.tsx @@ -1,33 +1,30 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import Image from "next/image"; import Link from "next/link"; import { useTheme } from "next-themes"; import { GOD_MODE_URL } from "@plane/constants"; -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { PlaneLockup } from "@plane/propel/icons"; // helpers // images // 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 PlaneTakeOffImage from "@/public/plane-takeoff.png"; export const InstanceNotReady: FC = () => { const { resolvedTheme } = useTheme(); const patternBackground = resolvedTheme === "dark" ? PlaneBackgroundPatternDark : PlaneBackgroundPattern; - const logo = resolvedTheme === "light" ? BlackHorizontalLogo : WhiteHorizontalLogo; - return (
- - Plane logo + +
diff --git a/apps/web/core/components/integration/delete-import-modal.tsx b/apps/web/core/components/integration/delete-import-modal.tsx index 772538188..04b06b77a 100644 --- a/apps/web/core/components/integration/delete-import-modal.tsx +++ b/apps/web/core/components/integration/delete-import-modal.tsx @@ -10,8 +10,10 @@ import { mutate } from "swr"; import { AlertTriangle } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; // services -import { IUser, IImporterService } from "@plane/types"; -import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IUser, IImporterService } from "@plane/types"; +import { Input } from "@plane/ui"; import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; import { IntegrationService } from "@/services/integrations/integration.service"; // ui diff --git a/apps/web/core/components/integration/github/auth.tsx b/apps/web/core/components/integration/github/auth.tsx index b141ff798..6706e6aa0 100644 --- a/apps/web/core/components/integration/github/auth.tsx +++ b/apps/web/core/components/integration/github/auth.tsx @@ -2,9 +2,9 @@ import { observer } from "mobx-react"; // types -import { IWorkspaceIntegration } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import type { IWorkspaceIntegration } from "@plane/types"; // ui -import { Button } from "@plane/ui"; // hooks import { useInstance } from "@/hooks/store/use-instance"; import useIntegrationPopup from "@/hooks/use-integration-popup"; diff --git a/apps/web/core/components/integration/github/import-configure.tsx b/apps/web/core/components/integration/github/import-configure.tsx index 851c20655..28a9e30bf 100644 --- a/apps/web/core/components/integration/github/import-configure.tsx +++ b/apps/web/core/components/integration/github/import-configure.tsx @@ -1,9 +1,10 @@ "use client"; // components -import { IAppIntegration, IWorkspaceIntegration } from "@plane/types"; -import { Button } from "@plane/ui"; -import { GithubAuth, TIntegrationSteps } from "@/components/integration"; +import { Button } from "@plane/propel/button"; +import type { IAppIntegration, IWorkspaceIntegration } from "@plane/types"; +import type { TIntegrationSteps } from "@/components/integration"; +import { GithubAuth } from "@/components/integration"; // types type Props = { diff --git a/apps/web/core/components/integration/github/import-confirm.tsx b/apps/web/core/components/integration/github/import-confirm.tsx index 94b25a2a2..7ee58f70a 100644 --- a/apps/web/core/components/integration/github/import-confirm.tsx +++ b/apps/web/core/components/integration/github/import-confirm.tsx @@ -1,13 +1,13 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // react-hook-form -import { UseFormWatch } from "react-hook-form"; +import type { UseFormWatch } from "react-hook-form"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // types -import { TFormValues, TIntegrationSteps } from "@/components/integration"; +import type { TFormValues, TIntegrationSteps } from "@/components/integration"; type Props = { handleStepChange: (value: TIntegrationSteps) => void; diff --git a/apps/web/core/components/integration/github/import-data.tsx b/apps/web/core/components/integration/github/import-data.tsx index 082a0ac22..a5f72f9ea 100644 --- a/apps/web/core/components/integration/github/import-data.tsx +++ b/apps/web/core/components/integration/github/import-data.tsx @@ -1,14 +1,17 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; -import { Control, Controller, UseFormWatch } from "react-hook-form"; -import { IWorkspaceIntegration } from "@plane/types"; +import type { Control, UseFormWatch } from "react-hook-form"; +import { Controller } from "react-hook-form"; +import { Button } from "@plane/propel/button"; +import type { IWorkspaceIntegration } from "@plane/types"; // hooks // components -import { Button, CustomSearchSelect, ToggleSwitch } from "@plane/ui"; +import { CustomSearchSelect, ToggleSwitch } from "@plane/ui"; import { truncateText } from "@plane/utils"; -import { SelectRepository, TFormValues, TIntegrationSteps } from "@/components/integration"; +import type { TFormValues, TIntegrationSteps } from "@/components/integration"; +import { SelectRepository } from "@/components/integration"; // ui // helpers import { useProject } from "@/hooks/store/use-project"; diff --git a/apps/web/core/components/integration/github/import-users.tsx b/apps/web/core/components/integration/github/import-users.tsx index 4387e8749..2057af739 100644 --- a/apps/web/core/components/integration/github/import-users.tsx +++ b/apps/web/core/components/integration/github/import-users.tsx @@ -1,13 +1,14 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; // react-hook-form -import { UseFormWatch } from "react-hook-form"; +import type { UseFormWatch } from "react-hook-form"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; // types -import { IUserDetails, SingleUserSelect, TFormValues, TIntegrationSteps } from "@/components/integration"; +import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration"; +import { SingleUserSelect } from "@/components/integration"; type Props = { handleStepChange: (value: TIntegrationSteps) => void; diff --git a/apps/web/core/components/integration/github/repo-details.tsx b/apps/web/core/components/integration/github/repo-details.tsx index 5d58ab4c9..73ae1dae8 100644 --- a/apps/web/core/components/integration/github/repo-details.tsx +++ b/apps/web/core/components/integration/github/repo-details.tsx @@ -1,17 +1,19 @@ "use client"; -import { FC, useEffect } from "react"; +import type { FC } from "react"; +import { useEffect } from "react"; import { useParams } from "next/navigation"; // react-hook-form -import { UseFormSetValue } from "react-hook-form"; +import type { UseFormSetValue } from "react-hook-form"; import useSWR from "swr"; // services // ui -import { Button, Loader } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { Loader } from "@plane/ui"; // types -import { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration"; +import type { IUserDetails, TFormValues, TIntegrationSteps } from "@/components/integration"; // fetch-keys import { GITHUB_REPOSITORY_INFO } from "@/constants/fetch-keys"; import { GithubIntegrationService } from "@/services/integrations"; diff --git a/apps/web/core/components/integration/github/root.tsx b/apps/web/core/components/integration/github/root.tsx index 53df6c11f..1a8fbb472 100644 --- a/apps/web/core/components/integration/github/root.tsx +++ b/apps/web/core/components/integration/github/root.tsx @@ -8,9 +8,9 @@ import { useForm } from "react-hook-form"; import useSWR, { mutate } from "swr"; import { ArrowLeft, Check, List, Settings, UploadCloud, Users } from "lucide-react"; // types -import { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { IGithubRepoCollaborator, IGithubServiceImportFormData } from "@plane/types"; // ui -import { TOAST_TYPE, setToast } from "@plane/ui"; // components import { GithubImportConfigure, diff --git a/apps/web/core/components/integration/github/select-repository.tsx b/apps/web/core/components/integration/github/select-repository.tsx index 2d8042f2a..a294eb0a9 100644 --- a/apps/web/core/components/integration/github/select-repository.tsx +++ b/apps/web/core/components/integration/github/select-repository.tsx @@ -3,7 +3,7 @@ import React from "react"; import { useParams } from "next/navigation"; import useSWRInfinite from "swr/infinite"; -import { IWorkspaceIntegration } from "@plane/types"; +import type { IWorkspaceIntegration } from "@plane/types"; // services // ui import { CustomSearchSelect } from "@plane/ui"; diff --git a/apps/web/core/components/integration/github/single-user-select.tsx b/apps/web/core/components/integration/github/single-user-select.tsx index 114535518..04212ccdd 100644 --- a/apps/web/core/components/integration/github/single-user-select.tsx +++ b/apps/web/core/components/integration/github/single-user-select.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import useSWR from "swr"; // plane types -import { IGithubRepoCollaborator } from "@plane/types"; +import type { IGithubRepoCollaborator } from "@plane/types"; // plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input } from "@plane/ui"; // constants @@ -13,7 +13,7 @@ import { WORKSPACE_MEMBERS } from "@/constants/fetch-keys"; // plane web services import { WorkspaceService } from "@/plane-web/services"; // types -import { IUserDetails } from "./root"; +import type { IUserDetails } from "./root"; type Props = { collaborator: IGithubRepoCollaborator; @@ -92,7 +92,6 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, newUsers[index].email = ""; setUsers(newUsers); }} - optionsClassName="w-full" noChevron > {importOptions.map((option) => ( diff --git a/apps/web/core/components/integration/guide.tsx b/apps/web/core/components/integration/guide.tsx index d6e216109..d5ef4625b 100644 --- a/apps/web/core/components/integration/guide.tsx +++ b/apps/web/core/components/integration/guide.tsx @@ -12,9 +12,9 @@ import { RefreshCw } from "lucide-react"; import { IMPORTERS_LIST } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; // types -import { IImporterService } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import type { IImporterService } from "@plane/types"; // ui -import { Button } from "@plane/ui"; // components import { DeleteImportModal, GithubImporterRoot, JiraImporterRoot, SingleImport } from "@/components/integration"; import { ImportExportSettingsLoader } from "@/components/ui/loader/settings/import-and-export"; diff --git a/apps/web/core/components/integration/jira/confirm-import.tsx b/apps/web/core/components/integration/jira/confirm-import.tsx index efb26afe1..a2f493db0 100644 --- a/apps/web/core/components/integration/jira/confirm-import.tsx +++ b/apps/web/core/components/integration/jira/confirm-import.tsx @@ -2,7 +2,7 @@ import React from "react"; // react hook form import { useFormContext } from "react-hook-form"; -import { IJiraImporterForm } from "@plane/types"; +import type { IJiraImporterForm } from "@plane/types"; // types diff --git a/apps/web/core/components/integration/jira/give-details.tsx b/apps/web/core/components/integration/jira/give-details.tsx index 5f7e699d8..5067dc816 100644 --- a/apps/web/core/components/integration/jira/give-details.tsx +++ b/apps/web/core/components/integration/jira/give-details.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { useFormContext, Controller } from "react-hook-form"; import { Plus } from "lucide-react"; import { PROJECT_TRACKER_ELEMENTS } from "@plane/constants"; -import { IJiraImporterForm } from "@plane/types"; +import type { IJiraImporterForm } from "@plane/types"; // hooks // components import { CustomSelect, Input } from "@plane/ui"; @@ -181,7 +181,6 @@ export const JiraGetImportDetail: React.FC = observer(() => { )} } - optionsClassName="w-full" > {workspaceProjectIds && workspaceProjectIds.length > 0 ? ( workspaceProjectIds.map((projectId) => { diff --git a/apps/web/core/components/integration/jira/import-users.tsx b/apps/web/core/components/integration/jira/import-users.tsx index 2adaf3ba3..f9c543646 100644 --- a/apps/web/core/components/integration/jira/import-users.tsx +++ b/apps/web/core/components/integration/jira/import-users.tsx @@ -1,11 +1,11 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { useParams } from "next/navigation"; import { useFormContext, useFieldArray, Controller } from "react-hook-form"; import useSWR from "swr"; // plane types -import { IJiraImporterForm } from "@plane/types"; +import type { IJiraImporterForm } from "@plane/types"; // plane ui import { Avatar, CustomSelect, CustomSearchSelect, Input, ToggleSwitch } from "@plane/ui"; // constants @@ -96,7 +96,6 @@ export const JiraImportUsers: FC = () => { input value={value} onChange={onChange} - optionsClassName="w-full" label={{Boolean(value) ? value : ("Ignore" as any)}} > Invite by email diff --git a/apps/web/core/components/integration/jira/index.ts b/apps/web/core/components/integration/jira/index.ts index e3ba5d685..bc22a8246 100644 --- a/apps/web/core/components/integration/jira/index.ts +++ b/apps/web/core/components/integration/jira/index.ts @@ -4,7 +4,7 @@ export * from "./jira-project-detail"; export * from "./import-users"; export * from "./confirm-import"; -import { IJiraImporterForm } from "@plane/types"; +import type { IJiraImporterForm } from "@plane/types"; export type TJiraIntegrationSteps = | "import-configure" diff --git a/apps/web/core/components/integration/jira/jira-project-detail.tsx b/apps/web/core/components/integration/jira/jira-project-detail.tsx index e1808ccac..56384276f 100644 --- a/apps/web/core/components/integration/jira/jira-project-detail.tsx +++ b/apps/web/core/components/integration/jira/jira-project-detail.tsx @@ -8,7 +8,7 @@ import { useParams } from "next/navigation"; // swr import { useFormContext, Controller } from "react-hook-form"; import useSWR from "swr"; -import { IJiraImporterForm, IJiraMetadata } from "@plane/types"; +import type { IJiraImporterForm, IJiraMetadata } from "@plane/types"; // react hook form diff --git a/apps/web/core/components/integration/jira/root.tsx b/apps/web/core/components/integration/jira/root.tsx index 22ba3407d..ed9fa09ba 100644 --- a/apps/web/core/components/integration/jira/root.tsx +++ b/apps/web/core/components/integration/jira/root.tsx @@ -9,9 +9,9 @@ import { mutate } from "swr"; // icons import { ArrowLeft, Check, List, Settings, Users } from "lucide-react"; // types -import { IJiraImporterForm } from "@plane/types"; +import { Button } from "@plane/propel/button"; +import type { IJiraImporterForm } from "@plane/types"; // ui -import { Button } from "@plane/ui"; // fetch keys import { IMPORTER_SERVICES_LIST } from "@/constants/fetch-keys"; // hooks @@ -21,15 +21,8 @@ import JiraLogo from "@/public/services/jira.svg"; // services import { JiraImporterService } from "@/services/integrations"; // components -import { - JiraGetImportDetail, - JiraProjectDetail, - JiraImportUsers, - JiraConfirmImport, - jiraFormDefaultValues, - TJiraIntegrationSteps, - IJiraIntegrationData, -} from "."; +import type { TJiraIntegrationSteps, IJiraIntegrationData } from "."; +import { JiraGetImportDetail, JiraProjectDetail, JiraImportUsers, JiraConfirmImport, jiraFormDefaultValues } from "."; const integrationWorkflowData: Array<{ title: string; diff --git a/apps/web/core/components/integration/single-import.tsx b/apps/web/core/components/integration/single-import.tsx index 247fb0f8e..ae8c6b01b 100644 --- a/apps/web/core/components/integration/single-import.tsx +++ b/apps/web/core/components/integration/single-import.tsx @@ -5,7 +5,7 @@ import { Trash2 } from "lucide-react"; // plane imports import { IMPORTERS_LIST } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { IImporterService } from "@plane/types"; +import type { IImporterService } from "@plane/types"; import { CustomMenu } from "@plane/ui"; // icons // helpers diff --git a/apps/web/core/components/integration/single-integration-card.tsx b/apps/web/core/components/integration/single-integration-card.tsx index b4ce693d8..b6169d83d 100644 --- a/apps/web/core/components/integration/single-integration-card.tsx +++ b/apps/web/core/components/integration/single-integration-card.tsx @@ -7,10 +7,12 @@ import { useParams } from "next/navigation"; import useSWR, { mutate } from "swr"; import { CheckCircle } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; -import { IAppIntegration, IWorkspaceIntegration } from "@plane/types"; +import type { IAppIntegration, IWorkspaceIntegration } from "@plane/types"; // ui -import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; +import { Loader } from "@plane/ui"; // constants import { WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys"; // hooks diff --git a/apps/web/core/components/integration/slack/select-channel.tsx b/apps/web/core/components/integration/slack/select-channel.tsx index 4a762a2c0..fb51f4a1b 100644 --- a/apps/web/core/components/integration/slack/select-channel.tsx +++ b/apps/web/core/components/integration/slack/select-channel.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR, { mutate } from "swr"; // types -import { IWorkspaceIntegration, ISlackIntegration } from "@plane/types"; +import type { IWorkspaceIntegration, ISlackIntegration } from "@plane/types"; // ui import { Loader } from "@plane/ui"; // fetch-keys diff --git a/apps/web/core/components/issues/archive-issue-modal.tsx b/apps/web/core/components/issues/archive-issue-modal.tsx index b4f12c375..98204aa18 100644 --- a/apps/web/core/components/issues/archive-issue-modal.tsx +++ b/apps/web/core/components/issues/archive-issue-modal.tsx @@ -5,9 +5,9 @@ import { Dialog, Transition } from "@headlessui/react"; // i18n import { useTranslation } from "@plane/i18n"; // types -import { TDeDupeIssue, TIssue } from "@plane/types"; -// ui -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { Button } from "@plane/propel/button"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TDeDupeIssue, TIssue } from "@plane/types"; // hooks import { useIssues } from "@/hooks/store/use-issues"; import { useProject } from "@/hooks/store/use-project"; diff --git a/apps/web/core/components/issues/archived-issues-header.tsx b/apps/web/core/components/issues/archived-issues-header.tsx index 4bbc63a23..324130573 100644 --- a/apps/web/core/components/issues/archived-issues-header.tsx +++ b/apps/web/core/components/issues/archived-issues-header.tsx @@ -1,70 +1,39 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -// plane constants +// plane imports import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; -// i18n import { useTranslation } from "@plane/i18n"; -// types -import { - EIssuesStoreType, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - IIssueFilterOptions, -} from "@plane/types"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; +import { EHeaderVariant, Header } from "@plane/ui"; // components -import { isIssueFilterActive } from "@plane/utils"; import { ArchiveTabsList } from "@/components/archives"; -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; -// helpers +import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters"; +import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle"; // hooks import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; -import { useMember } from "@/hooks/store/use-member"; import { useProject } from "@/hooks/store/use-project"; -import { useProjectState } from "@/hooks/store/use-project-state"; export const ArchivedIssuesHeader: FC = observer(() => { // router - const { workspaceSlug, projectId } = useParams(); + const { workspaceSlug: routerWorkspaceSlug, projectId: routerProjectId } = useParams(); + const workspaceSlug = routerWorkspaceSlug ? routerWorkspaceSlug.toString() : undefined; + const projectId = routerProjectId ? routerProjectId.toString() : undefined; // store hooks const { currentProjectDetails } = useProject(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.ARCHIVED); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); - const { - project: { projectMemberIds }, - } = useMember(); // i18n const { t } = useTranslation(); // for archived issues list layout is the only option const activeLayout = "list"; - // hooks - const handleFiltersUpdate = (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { - [key]: newValues, - }); - }; const handleDisplayFiltersUpdate = (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { ...issueFilters?.displayFilters, ...updatedDisplayFilter, }); @@ -73,46 +42,31 @@ export const ArchivedIssuesHeader: FC = observer(() => { const handleDisplayPropertiesUpdate = (property: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); + updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property); }; + if (!workspaceSlug || !projectId) return null; return ( -
-
+
+ -
- {/* filter options */} -
- - - + + + -
-
+ + ); }); diff --git a/apps/web/core/components/issues/attachment/attachment-detail.tsx b/apps/web/core/components/issues/attachment/attachment-detail.tsx index eede3c1ff..cfd1869c7 100644 --- a/apps/web/core/components/issues/attachment/attachment-detail.tsx +++ b/apps/web/core/components/issues/attachment/attachment-detail.tsx @@ -1,6 +1,7 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { AlertCircle, X } from "lucide-react"; @@ -25,7 +26,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; import { usePlatformOS } from "@/hooks/use-platform-os"; // types -import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; +import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; type TAttachmentOperationsRemoveModal = Exclude; diff --git a/apps/web/core/components/issues/attachment/attachment-item-list.tsx b/apps/web/core/components/issues/attachment/attachment-item-list.tsx index d10f9fa36..25da16388 100644 --- a/apps/web/core/components/issues/attachment/attachment-item-list.tsx +++ b/apps/web/core/components/issues/attachment/attachment-item-list.tsx @@ -1,16 +1,19 @@ -import { FC, useCallback, useState } from "react"; +import type { FC } from "react"; +import { useCallback, useState } from "react"; import { observer } from "mobx-react"; -import { FileRejection, useDropzone } from "react-dropzone"; +import type { FileRejection } from "react-dropzone"; +import { useDropzone } from "react-dropzone"; import { UploadCloud } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // hooks -import { TOAST_TYPE, setToast } from "@plane/ui"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // plane web hooks import { useFileSize } from "@/plane-web/hooks/use-file-size"; // types -import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; +import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; // components import { IssueAttachmentsListItem } from "./attachment-list-item"; import { IssueAttachmentsUploadItem } from "./attachment-list-upload-item"; diff --git a/apps/web/core/components/issues/attachment/attachment-list-item.tsx b/apps/web/core/components/issues/attachment/attachment-list-item.tsx index fc071750a..d4610ff0a 100644 --- a/apps/web/core/components/issues/attachment/attachment-list-item.tsx +++ b/apps/web/core/components/issues/attachment/attachment-list-item.tsx @@ -1,11 +1,12 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; import { Trash } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/propel/tooltip"; -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils"; @@ -83,9 +84,7 @@ export const IssueAttachmentsListItem: FC = observer( { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { toggleDeleteAttachmentModal(attachmentId); }} > diff --git a/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx b/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx index 710689635..af7b092e1 100644 --- a/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx +++ b/apps/web/core/components/issues/attachment/attachment-list-upload-item.tsx @@ -11,7 +11,7 @@ import { getFileIcon } from "@/components/icons"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // types -import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; +import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; type Props = { uploadStatus: TAttachmentUploadStatus; diff --git a/apps/web/core/components/issues/attachment/attachment-upload-details.tsx b/apps/web/core/components/issues/attachment/attachment-upload-details.tsx index ea868c8d2..adee71737 100644 --- a/apps/web/core/components/issues/attachment/attachment-upload-details.tsx +++ b/apps/web/core/components/issues/attachment/attachment-upload-details.tsx @@ -11,7 +11,7 @@ import { getFileIcon } from "@/components/icons"; // hooks import { usePlatformOS } from "@/hooks/use-platform-os"; // types -import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; +import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; type Props = { uploadStatus: TAttachmentUploadStatus; diff --git a/apps/web/core/components/issues/attachment/attachment-upload.tsx b/apps/web/core/components/issues/attachment/attachment-upload.tsx index 2de55ac00..959a07d5d 100644 --- a/apps/web/core/components/issues/attachment/attachment-upload.tsx +++ b/apps/web/core/components/issues/attachment/attachment-upload.tsx @@ -4,7 +4,7 @@ import { useDropzone } from "react-dropzone"; // plane web hooks import { useFileSize } from "@/plane-web/hooks/use-file-size"; // types -import { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; +import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; type TAttachmentOperationsModal = Pick; diff --git a/apps/web/core/components/issues/attachment/attachments-list.tsx b/apps/web/core/components/issues/attachment/attachments-list.tsx index b4f74c691..3ce00e0e1 100644 --- a/apps/web/core/components/issues/attachment/attachments-list.tsx +++ b/apps/web/core/components/issues/attachment/attachments-list.tsx @@ -1,9 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // types -import { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; +import type { TAttachmentHelpers } from "../issue-detail-widgets/attachments/helper"; // components import { IssueAttachmentsDetail } from "./attachment-detail"; import { IssueAttachmentsUploadDetails } from "./attachment-upload-details"; diff --git a/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx b/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx index f06b83941..4eb79fc39 100644 --- a/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx +++ b/apps/web/core/components/issues/attachment/delete-attachment-modal.tsx @@ -1,9 +1,11 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // plane-i18n import { useTranslation } from "@plane/i18n"; // types -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // ui import { AlertModalCore } from "@plane/ui"; // helper @@ -11,7 +13,7 @@ import { getFileName } from "@plane/utils"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // types -import { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; +import type { TAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; export type TAttachmentOperationsRemoveModal = Pick; diff --git a/apps/web/core/components/issues/attachment/root.tsx b/apps/web/core/components/issues/attachment/root.tsx index 700b35bb8..a0bc00241 100644 --- a/apps/web/core/components/issues/attachment/root.tsx +++ b/apps/web/core/components/issues/attachment/root.tsx @@ -1,6 +1,6 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // hooks import { useAttachmentOperations } from "../issue-detail-widgets/attachments/helper"; diff --git a/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx b/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx index b7ecc9cf9..d53da7dfb 100644 --- a/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx +++ b/apps/web/core/components/issues/bulk-operations/upgrade-banner.tsx @@ -1,7 +1,7 @@ "use client"; import { MARKETING_PLANE_ONE_PAGE_LINK } from "@plane/constants"; -import { getButtonStyling } from "@plane/ui"; +import { getButtonStyling } from "@plane/propel/button"; import { cn } from "@plane/utils"; type Props = { diff --git a/apps/web/core/components/issues/confirm-issue-discard.tsx b/apps/web/core/components/issues/confirm-issue-discard.tsx index 5e6f06fa1..d5bb1bcac 100644 --- a/apps/web/core/components/issues/confirm-issue-discard.tsx +++ b/apps/web/core/components/issues/confirm-issue-discard.tsx @@ -5,7 +5,7 @@ import React, { useState } from "react"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // ui -import { Button } from "@plane/ui"; +import { Button } from "@plane/propel/button"; type Props = { isOpen: boolean; diff --git a/apps/web/core/components/issues/create-issue-toast-action-items.tsx b/apps/web/core/components/issues/create-issue-toast-action-items.tsx index 1695ede70..4c2d05163 100644 --- a/apps/web/core/components/issues/create-issue-toast-action-items.tsx +++ b/apps/web/core/components/issues/create-issue-toast-action-items.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { FC, useState } from "react"; +import type { FC } from "react"; +import React, { useState } from "react"; import { observer } from "mobx-react"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; // plane imports diff --git a/apps/web/core/components/issues/delete-issue-modal.tsx b/apps/web/core/components/issues/delete-issue-modal.tsx index 7fc2aa2b9..b23fd0d6b 100644 --- a/apps/web/core/components/issues/delete-issue-modal.tsx +++ b/apps/web/core/components/issues/delete-issue-modal.tsx @@ -6,9 +6,10 @@ import { useParams } from "next/navigation"; // types import { PROJECT_ERROR_MESSAGES, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TDeDupeIssue, TIssue } from "@plane/types"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TDeDupeIssue, TIssue } from "@plane/types"; // ui -import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui"; +import { AlertModalCore } from "@plane/ui"; // constants // hooks import { useIssues } from "@/hooks/store/use-issues"; diff --git a/apps/web/core/components/issues/description-input.tsx b/apps/web/core/components/issues/description-input.tsx index c320daf27..dca9623c7 100644 --- a/apps/web/core/components/issues/description-input.tsx +++ b/apps/web/core/components/issues/description-input.tsx @@ -1,18 +1,20 @@ "use client"; -import { FC, useCallback, useEffect, useRef, useState } from "react"; -import debounce from "lodash/debounce"; +import type { FC } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { debounce } from "lodash-es"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // plane imports import type { EditorRefApi } from "@plane/editor"; import { useTranslation } from "@plane/i18n"; -import { EFileAssetType, TIssue, TNameDescriptionLoader } from "@plane/types"; +import type { TIssue, TNameDescriptionLoader } from "@plane/types"; +import { EFileAssetType } from "@plane/types"; import { Loader } from "@plane/ui"; // components import { getDescriptionPlaceholderI18n } from "@plane/utils"; import { RichTextEditor } from "@/components/editor/rich-text"; -import { TIssueOperations } from "@/components/issues/issue-detail"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; // helpers // hooks import { useEditorAsset } from "@/hooks/store/use-editor-asset"; diff --git a/apps/web/core/components/issues/filters.tsx b/apps/web/core/components/issues/filters.tsx index 4340b41f1..55abf99df 100644 --- a/apps/web/core/components/issues/filters.tsx +++ b/apps/web/core/components/issues/filters.tsx @@ -2,35 +2,23 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react"; -import { ChartNoAxesColumn, ListFilter, SlidersHorizontal } from "lucide-react"; -// plane constants +import { ChartNoAxesColumn, SlidersHorizontal } from "lucide-react"; +// plane imports import { EIssueFilterType, ISSUE_STORE_TO_FILTERS_MAP } from "@plane/constants"; -// i18n import { useTranslation } from "@plane/i18n"; -// types -import { - EIssuesStoreType, - IIssueDisplayFilterOptions, - IIssueDisplayProperties, - IIssueFilterOptions, - EIssueLayoutTypes, -} from "@plane/types"; -import { Button } from "@plane/ui"; -// components -import { isIssueFilterActive } from "@plane/utils"; -// helpers +import { Button } from "@plane/propel/button"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@plane/types"; // hooks import { useIssues } from "@/hooks/store/use-issues"; -import { useLabel } from "@/hooks/store/use-label"; -import { useMember } from "@/hooks/store/use-member"; -import { useProjectState } from "@/hooks/store/use-project-state"; -// plane web types -import { TProject } from "@/plane-web/types"; +// plane web imports +import type { TProject } from "@/plane-web/types"; +// local imports import { WorkItemsModal } from "../analytics/work-items/modal"; +import { WorkItemFiltersToggle } from "../work-item-filters/filters-toggle"; import { DisplayFiltersSelection, FiltersDropdown, - FilterSelection, LayoutSelection, MobileLayoutSelection, } from "./issue-layouts/filters"; @@ -63,38 +51,13 @@ export const HeaderFilters = observer((props: Props) => { // states const [analyticsModal, setAnalyticsModal] = useState(false); // store hooks - const { - project: { projectMemberIds }, - } = useMember(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(storeType); - const { projectStates } = useProjectState(); - const { projectLabels } = useLabel(); // derived values const activeLayout = issueFilters?.displayFilters?.layout; - const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.[activeLayout]; + const layoutDisplayFiltersOptions = ISSUE_STORE_TO_FILTERS_MAP[storeType]?.layoutOptions[activeLayout]; - const handleFiltersUpdate = useCallback( - (key: keyof IIssueFilterOptions, value: string | string[]) => { - if (!workspaceSlug || !projectId) return; - const newValues = issueFilters?.filters?.[key] ?? []; - - if (Array.isArray(value)) { - // this validation is majorly for the filter start_date, target_date custom - value.forEach((val) => { - if (!newValues.includes(val)) newValues.push(val); - else newValues.splice(newValues.indexOf(val), 1); - }); - } else { - if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); - else newValues.push(value); - } - - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }); - }, - [workspaceSlug, projectId, issueFilters, updateFilters] - ); const handleLayoutChange = useCallback( (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; @@ -141,27 +104,7 @@ export const HeaderFilters = observer((props: Props) => { activeLayout={activeLayout} />
- } - > - - + } title={t("common.display")} diff --git a/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx b/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx index 090701f32..ae8387efc 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/action-buttons.tsx @@ -1,10 +1,12 @@ "use client"; -import React, { FC } from "react"; -import { Layers, Link, Paperclip, Waypoints } from "lucide-react"; -// plane imports +import type { FC } from "react"; +import React from "react"; +import { Link, Paperclip, Waypoints } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import { ViewsIcon } from "@plane/propel/icons"; +// plane imports +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; // plane web imports import { WorkItemAdditionalWidgetActionButtons } from "@/plane-web/components/issues/issue-detail-widgets/action-buttons"; // local imports @@ -36,7 +38,7 @@ export const IssueDetailWidgetActionButtons: FC = (props) => { customButton={ } + icon={} disabled={disabled} /> } diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx index b62de354b..ff986df04 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/content.tsx @@ -1,7 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // local imports import { IssueAttachmentItemList } from "../../attachment/attachment-item-list"; import { useAttachmentOperations } from "./helper"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx index 29fc7f0b6..4f2c0d35f 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/helper.tsx @@ -1,14 +1,14 @@ "use client"; import { useMemo } from "react"; import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; -// plane ui -import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/ui"; +import { setPromiseToast, TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // types -import { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; +import type { TAttachmentUploadStatus } from "@/store/issue/issue-details/attachment.store"; export type TAttachmentOperations = { create: (file: File) => Promise; diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx index 9d308defb..423045df7 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/quick-action-button.tsx @@ -1,12 +1,14 @@ "use client"; -import React, { FC, useCallback, useState } from "react"; +import type { FC } from "react"; +import React, { useCallback, useState } from "react"; import { observer } from "mobx-react"; -import { FileRejection, useDropzone } from "react-dropzone"; +import type { FileRejection } from "react-dropzone"; +import { useDropzone } from "react-dropzone"; import { Plus } from "lucide-react"; // plane imports -import { TIssueServiceType } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssueServiceType } from "@plane/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // plane web hooks diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx index 22a79a29c..a40fc909e 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/root.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // plane imports -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; import { Collapsible } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/attachments/title.tsx b/apps/web/core/components/issues/issue-detail-widgets/attachments/title.tsx index ef436f25d..fc3a03030 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/attachments/title.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/attachments/title.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { FC, useMemo } from "react"; +import type { FC } from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { CollapsibleButton } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx index f9fb24372..1e138f854 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-collapsibles.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // plane imports -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // Plane-web diff --git a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx index 31b21e45f..c2cdc0c57 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/issue-detail-widget-modals.tsx @@ -1,7 +1,8 @@ -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; -import { ISearchIssueResponse, TIssue, TIssueServiceType, TWorkItemWidgets } from "@plane/types"; -import { setToast, TOAST_TYPE } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { ISearchIssueResponse, TIssue, TIssueServiceType, TWorkItemWidgets } from "@plane/types"; // components import { ExistingIssuesListModal } from "@/components/core/modals/existing-issues-list-modal"; // hooks diff --git a/apps/web/core/components/issues/issue-detail-widgets/links/content.tsx b/apps/web/core/components/issues/issue-detail-widgets/links/content.tsx index 014cf7c02..3b1bc20bb 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/links/content.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/links/content.tsx @@ -1,6 +1,7 @@ "use client"; -import React, { FC } from "react"; -import { TIssueServiceType } from "@plane/types"; +import type { FC } from "react"; +import React from "react"; +import type { TIssueServiceType } from "@plane/types"; // components import { LinkList } from "../../issue-detail/links"; // helper diff --git a/apps/web/core/components/issues/issue-detail-widgets/links/helper.tsx b/apps/web/core/components/issues/issue-detail-widgets/links/helper.tsx index 76bbb5c2b..5bf6c6821 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/links/helper.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/links/helper.tsx @@ -2,12 +2,12 @@ import { useMemo } from "react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TIssueLink, TIssueServiceType } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssueLink, TIssueServiceType } from "@plane/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // local imports -import { TLinkOperations } from "../../issue-detail/links"; +import type { TLinkOperations } from "../../issue-detail/links"; export const useLinkOperations = ( workspaceSlug: string, diff --git a/apps/web/core/components/issues/issue-detail-widgets/links/quick-action-button.tsx b/apps/web/core/components/issues/issue-detail-widgets/links/quick-action-button.tsx index 25ffc02ad..ee068640d 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/links/quick-action-button.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/links/quick-action-button.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { Plus } from "lucide-react"; // plane imports -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/links/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/links/root.tsx index 3cd211a04..8fb3cc069 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/links/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/links/root.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // plane imports -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; import { Collapsible } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/links/title.tsx b/apps/web/core/components/issues/issue-detail-widgets/links/title.tsx index e1aa75b43..0e9eec1f5 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/links/title.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/links/title.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { FC, useMemo } from "react"; +import type { FC } from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; import { CollapsibleButton } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx b/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx index f5862a705..01fea1d60 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/relations/content.tsx @@ -1,9 +1,11 @@ "use client"; -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, TIssue, TIssueServiceType } from "@plane/types"; +import type { TIssue, TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { Collapsible } from "@plane/ui"; // components import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; @@ -12,7 +14,7 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // Plane-web import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal"; import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; -import { TIssueRelationTypes } from "@/plane-web/types"; +import type { TIssueRelationTypes } from "@/plane-web/types"; // helper import { DeleteIssueModal } from "../../delete-issue-modal"; import { RelationIssueList } from "../../relations/issue-list"; @@ -135,7 +137,7 @@ export const RelationsCollapsibleContent: FC = observer((props) => { +
{relation.icon ? relation.icon(14) : null} {relation.label}
diff --git a/apps/web/core/components/issues/issue-detail-widgets/relations/helper.tsx b/apps/web/core/components/issues/issue-detail-widgets/relations/helper.tsx index 91ed4bb66..f351cef79 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/relations/helper.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/relations/helper.tsx @@ -3,8 +3,9 @@ import { useMemo } from "react"; // plane imports import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, TIssue, TIssueServiceType } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue, TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { copyUrlToClipboard } from "@plane/utils"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx b/apps/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx index 18a3d1cdc..8de45b38e 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/relations/quick-action-button.tsx @@ -1,16 +1,17 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { Plus } from "lucide-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; import { CustomMenu } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; // Plane-web import { useTimeLineRelationOptions } from "@/plane-web/components/relations"; -import { TIssueRelationTypes } from "@/plane-web/types"; +import type { TIssueRelationTypes } from "@/plane-web/types"; type Props = { issueId: string; @@ -50,9 +51,7 @@ export const RelationActionButton: FC = observer((props) => { return ( { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleOnClick(item.key as TIssueRelationTypes); }} > diff --git a/apps/web/core/components/issues/issue-detail-widgets/relations/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/relations/root.tsx index 576e1e8f7..3305cff94 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/relations/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/relations/root.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // plane imports -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; import { Collapsible } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/relations/title.tsx b/apps/web/core/components/issues/issue-detail-widgets/relations/title.tsx index 3f6365e6b..abe06701f 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/relations/title.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/relations/title.tsx @@ -1,8 +1,10 @@ "use client"; -import React, { FC, useMemo } from "react"; +import type { FC } from "react"; +import React, { useMemo } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { CollapsibleButton } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/root.tsx index 4f4473088..12ac49744 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/root.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // plane imports -import { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; +import type { TIssueServiceType, TWorkItemWidgets } from "@plane/types"; // local imports import { IssueDetailWidgetActionButtons } from "./action-buttons"; import { IssueDetailWidgetCollapsibles } from "./issue-detail-widget-collapsibles"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx index d98e80f3f..ad76e6fe2 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/content.tsx @@ -1,7 +1,9 @@ "use client"; -import React, { FC, useEffect, useState, useCallback } from "react"; +import type { FC } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { observer } from "mobx-react"; -import { EIssueServiceType, EIssuesStoreType, TIssue, TIssueServiceType } from "@plane/types"; +import type { TIssue, TIssueServiceType } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; // components import { DeleteIssueModal } from "@/components/issues/delete-issue-modal"; // hooks @@ -122,7 +124,7 @@ export const SubIssuesCollapsibleContent: FC = observer((props) => { parentIssueId={parentIssueId} rootIssueId={parentIssueId} spacingLeft={6} - disabled={!disabled} + canEdit={!disabled} handleIssueCrudState={handleIssueCrudState} subIssueOperations={subIssueOperations} issueServiceType={issueServiceType} diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx index dfb246d54..a1c15187f 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/display-filters.tsx @@ -1,9 +1,10 @@ -import { FC, useMemo } from "react"; -import isEmpty from "lodash/isEmpty"; +import type { FC } from "react"; +import { useMemo } from "react"; +import { isEmpty } from "lodash-es"; import { observer } from "mobx-react"; import { SlidersHorizontal } from "lucide-react"; // plane imports -import { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types"; +import type { IIssueDisplayFilterOptions, ILayoutDisplayFiltersOptions, IIssueDisplayProperties } from "@plane/types"; import { cn } from "@plane/utils"; // components import { @@ -13,7 +14,6 @@ import { FiltersDropdown, } from "@/components/issues/issue-layouts/filters"; import { isDisplayFiltersApplied } from "@/components/issues/issue-layouts/utils"; - type TSubIssueDisplayFiltersProps = { displayProperties: IIssueDisplayProperties; displayFilters: IIssueDisplayFilterOptions; diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx index bf76004f9..82135efb4 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/filters.tsx @@ -1,8 +1,9 @@ -import { FC, useMemo, useState } from "react"; +import type { FC } from "react"; +import { useMemo, useState } from "react"; import { observer } from "mobx-react"; import { ListFilter, Search, X } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { IIssueFilterOptions, ILayoutDisplayFiltersOptions, IState } from "@plane/types"; +import type { IIssueFilterOptions, IState } from "@plane/types"; import { cn } from "@plane/utils"; import { FilterAssignees, @@ -16,26 +17,24 @@ import { } from "@/components/issues/issue-layouts/filters"; import { isFiltersApplied } from "@/components/issues/issue-layouts/utils"; import { FilterIssueTypes } from "@/plane-web/components/issues/filters/issue-types"; - type TSubIssueFiltersProps = { handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void; filters: IIssueFilterOptions; memberIds: string[] | undefined; states?: IState[]; - layoutDisplayFiltersOptions: ILayoutDisplayFiltersOptions | undefined; + availableFilters: (keyof IIssueFilterOptions)[]; }; export const SubIssueFilters: FC = observer((props) => { - const { handleFiltersUpdate, filters, memberIds, states, layoutDisplayFiltersOptions } = props; - + const { handleFiltersUpdate, filters, memberIds, states, availableFilters } = props; + // plane hooks + const { t } = useTranslation(); // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); - const isFilterEnabled = (filter: keyof IIssueFilterOptions) => - !!layoutDisplayFiltersOptions?.filters.includes(filter); + const isFilterEnabled = (filter: keyof IIssueFilterOptions) => !!availableFilters.includes(filter); + const isFilterApplied = useMemo(() => isFiltersApplied(filters), [filters]); - // hooks - const { t } = useTranslation(); return ( <> diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts index 0f4f8fdd5..61129d4f2 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/helper.ts @@ -5,8 +5,9 @@ import { useParams } from "next/navigation"; // plane imports import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, TIssueServiceType, TSubIssueOperations } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssueServiceType, TSubIssueOperations } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { copyUrlToClipboard } from "@plane/utils"; // hooks import { captureError, captureSuccess } from "@/helpers/event-tracker.helper"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts index 5fb3c6334..0bcdfe96a 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/index.ts @@ -3,3 +3,4 @@ export * from "./title"; export * from "./root"; export * from "./quick-action-button"; export * from "./display-filters"; +export * from "./content"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx index 7bee0f513..b5e3a0603 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-group.tsx @@ -1,8 +1,10 @@ -import { FC, useState } from "react"; +import type { FC } from "react"; +import { useState } from "react"; import { observer } from "mobx-react"; import { ChevronRight, CircleDashed } from "lucide-react"; import { ALL_ISSUES } from "@plane/constants"; -import { EIssuesStoreType, IGroupByColumn, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; +import type { IGroupByColumn, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; +import { EIssuesStoreType } from "@plane/types"; import { Collapsible } from "@plane/ui"; import { cn } from "@plane/utils"; import { SubIssuesListItem } from "./list-item"; @@ -13,7 +15,7 @@ interface TSubIssuesListGroupProps { workspaceSlug: string; group: IGroupByColumn; serviceType: TIssueServiceType; - disabled: boolean; + canEdit: boolean; parentIssueId: string; rootIssueId: string; handleIssueCrudState: ( @@ -30,7 +32,7 @@ export const SubIssuesListGroup: FC = observer((props) const { group, serviceType, - disabled, + canEdit, parentIssueId, rootIssueId, projectId, @@ -73,25 +75,22 @@ export const SubIssuesListGroup: FC = observer((props) } buttonClassName={cn("hidden", !isAllIssues && "block")} > - {/* Work items list */} -
- {workItemIds?.map((workItemId) => ( - - ))} -
+ {workItemIds?.map((workItemId) => ( + + ))}
); diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx index ad6cc6537..2d6cc6852 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/list-item.tsx @@ -5,7 +5,8 @@ import { ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide // plane imports import { useTranslation } from "@plane/i18n"; import { Tooltip } from "@plane/propel/tooltip"; -import { EIssueServiceType, EIssuesStoreType, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; +import type { TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; import { ControlLink, CustomMenu } from "@plane/ui"; import { cn, generateWorkItemLink } from "@plane/utils"; // helpers @@ -28,7 +29,7 @@ type Props = { parentIssueId: string; rootIssueId: string; spacingLeft: number; - disabled: boolean; + canEdit: boolean; handleIssueCrudState: ( key: "create" | "existing" | "update" | "delete", issueId: string, @@ -48,7 +49,7 @@ export const SubIssuesListItem: React.FC = observer((props) => { rootIssueId, issueId, spacingLeft = 10, - disabled, + canEdit, handleIssueCrudState, subIssueOperations, issueServiceType = EIssueServiceType.ISSUES, @@ -107,7 +108,7 @@ export const SubIssuesListItem: React.FC = observer((props) => { > {issue && (
@@ -174,7 +175,7 @@ export const SubIssuesListItem: React.FC = observer((props) => { workspaceSlug={workspaceSlug} parentIssueId={parentIssueId} issueId={issueId} - disabled={disabled} + canEdit={canEdit} updateSubIssue={subIssueOperations.updateSubIssue} displayProperties={displayProperties} issue={issue} @@ -183,11 +184,9 @@ export const SubIssuesListItem: React.FC = observer((props) => {
- {disabled && ( + {canEdit && ( { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { handleIssueCrudState("update", parentIssueId, { ...issue }); toggleCreateIssueModal(true); }} @@ -200,9 +199,7 @@ export const SubIssuesListItem: React.FC = observer((props) => { )} { - e.stopPropagation(); - e.preventDefault(); + onClick={() => { subIssueOperations.copyLink(workItemLink); }} > @@ -212,11 +209,9 @@ export const SubIssuesListItem: React.FC = observer((props) => {
- {disabled && ( + {canEdit && ( { - e.stopPropagation(); - e.preventDefault(); + onClick={() => { if (issue.project_id) subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id); }} @@ -230,11 +225,9 @@ export const SubIssuesListItem: React.FC = observer((props) => { )} - {disabled && ( + {canEdit && ( { - e.stopPropagation(); - e.preventDefault(); + onClick={() => { handleIssueCrudState("delete", parentIssueId, issue); toggleDeleteIssueModal(issue.id); }} @@ -263,7 +256,7 @@ export const SubIssuesListItem: React.FC = observer((props) => { parentIssueId={issue.id} rootIssueId={rootIssueId} spacingLeft={spacingLeft + 22} - disabled={disabled} + canEdit={canEdit} handleIssueCrudState={handleIssueCrudState} subIssueOperations={subIssueOperations} /> diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx index a9436cc61..6295c8538 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx @@ -1,9 +1,10 @@ // plane imports -import { SyntheticEvent, useMemo } from "react"; +import type { SyntheticEvent } from "react"; +import { useMemo } from "react"; import { observer } from "mobx-react"; import { CalendarCheck2, CalendarClock } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { IIssueDisplayProperties, TIssue } from "@plane/types"; +import type { IIssueDisplayProperties, TIssue } from "@plane/types"; import { getDate, renderFormattedPayloadDate, shouldHighlightIssueDueDate } from "@plane/utils"; // components import { DateDropdown } from "@/components/dropdowns/date"; @@ -19,7 +20,7 @@ type Props = { workspaceSlug: string; parentIssueId: string; issueId: string; - disabled: boolean; + canEdit: boolean; updateSubIssue: ( workspaceSlug: string, projectId: string, @@ -33,7 +34,7 @@ type Props = { }; export const SubIssuesListItemProperties: React.FC = observer((props) => { - const { workspaceSlug, parentIssueId, issueId, disabled, updateSubIssue, displayProperties, issue } = props; + const { workspaceSlug, parentIssueId, issueId, canEdit, updateSubIssue, displayProperties, issue } = props; const { t } = useTranslation(); const { getStateById } = useProjectState(); @@ -94,7 +95,7 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => { ...issue } ) } - disabled={!disabled} + disabled={!canEdit} buttonVariant="transparent-without-text" buttonClassName="hover:bg-transparent px-0" iconSize="size-5" @@ -113,7 +114,7 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => priority: val, }) } - disabled={!disabled} + disabled={!canEdit} buttonVariant="border-without-text" buttonClassName="border" showTooltip @@ -144,7 +145,7 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => mergeDates buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"} buttonClassName={shouldHighlight ? "text-red-500" : ""} - disabled={!disabled} + disabled={!canEdit} showTooltip customTooltipHeading="Date Range" renderPlaceholder={false} @@ -167,7 +168,7 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => icon={} buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} optionsClassName="z-30" - disabled={!disabled} + disabled={!canEdit} showTooltip />
@@ -190,7 +191,7 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => buttonClassName={shouldHighlight ? "text-red-500" : ""} clearIconClassName="text-custom-text-100" optionsClassName="z-30" - disabled={!disabled} + disabled={!canEdit} showTooltip />
@@ -207,7 +208,7 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => assignee_ids: val, }) } - disabled={!disabled} + disabled={!canEdit} multiple buttonVariant={(issue?.assignee_ids || []).length > 0 ? "transparent-without-text" : "border-without-text"} buttonClassName={(issue?.assignee_ids || []).length > 0 ? "hover:bg-transparent px-0" : ""} diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx index 2d1fa603f..aa1b152cb 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx @@ -3,16 +3,10 @@ import { observer } from "mobx-react"; // plane imports import { ListFilter } from "lucide-react"; import { useTranslation } from "@plane/i18n"; -import { - EIssueServiceType, - EIssuesStoreType, - GroupByColumnTypes, - TIssue, - TIssueServiceType, - TSubIssueOperations, -} from "@plane/types"; +import { Button } from "@plane/propel/button"; +import type { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; +import { EIssueServiceType, EIssuesStoreType } from "@plane/types"; // hooks -import { Button } from "@plane/ui"; import { SectionEmptyState } from "@/components/empty-state/section-empty-state-root"; import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; @@ -24,7 +18,7 @@ type Props = { parentIssueId: string; rootIssueId: string; spacingLeft: number; - disabled: boolean; + canEdit: boolean; handleIssueCrudState: ( key: "create" | "existing" | "update" | "delete", issueId: string, @@ -41,7 +35,7 @@ export const SubIssuesListRoot: React.FC = observer((props) => { projectId, parentIssueId, rootIssueId, - disabled, + canEdit, handleIssueCrudState, subIssueOperations, issueServiceType = EIssueServiceType.ISSUES, @@ -116,7 +110,7 @@ export const SubIssuesListRoot: React.FC = observer((props) => { workspaceSlug={workspaceSlug} group={group} serviceType={issueServiceType} - disabled={disabled} + canEdit={canEdit} parentIssueId={parentIssueId} rootIssueId={rootIssueId} handleIssueCrudState={handleIssueCrudState} diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx index 454752445..e8a64735d 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/quick-action-button.tsx @@ -1,11 +1,13 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; -import { LayersIcon, Plus } from "lucide-react"; +import { Plus } from "lucide-react"; // plane imports import { WORK_ITEM_TRACKER_EVENTS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TIssue, TIssueServiceType } from "@plane/types"; +import { WorkItemsIcon } from "@plane/propel/icons"; +import type { TIssue, TIssueServiceType } from "@plane/types"; import { CustomMenu } from "@plane/ui"; // hooks import { captureClick } from "@/helpers/event-tracker.helper"; @@ -73,7 +75,7 @@ export const SubIssuesActionButton: FC = observer((props) => { }, { i18n_label: "common.add_existing", - icon: , + icon: , onClick: handleAddExisting, }, ]; @@ -86,9 +88,7 @@ export const SubIssuesActionButton: FC = observer((props) => { {optionItems.map((item, index) => ( { - e.preventDefault(); - e.stopPropagation(); + onClick={() => { item.onClick(); }} > diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx index 738414e80..8830750e5 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/root.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; import { observer } from "mobx-react"; // plane imports -import { TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; import { Collapsible } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx index 9cf0d8d5e..b5d4e7ea0 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title-actions.tsx @@ -1,14 +1,19 @@ -import { FC, useCallback } from "react"; -import cloneDeep from "lodash/cloneDeep"; +import type { FC } from "react"; +import { useCallback } from "react"; +import { cloneDeep } from "lodash-es"; import { observer } from "mobx-react"; -import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants"; import { - EIssueServiceType, + EIssueFilterType, + ISSUE_DISPLAY_FILTERS_BY_PAGE, + SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE, +} from "@plane/constants"; +import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueServiceType, } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useMember } from "@/hooks/store/use-member"; import { useProjectState } from "@/hooks/store/use-project-state"; @@ -38,11 +43,10 @@ export const SubWorkItemTitleActions: FC = observ } = useMember(); // derived values - const subIssueFilters = getSubIssueFilters(parentId); const projectStates = getProjectStates(projectId); const projectMemberIds = getProjectMemberIds(projectId, false); - - const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].list; + const subIssueFilters = getSubIssueFilters(parentId); + const layoutDisplayFiltersOptions = ISSUE_DISPLAY_FILTERS_BY_PAGE["sub_work_items"].layoutOptions.list; const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { @@ -72,7 +76,6 @@ export const SubWorkItemTitleActions: FC = observ if (subIssueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); } - updateSubWorkItemFilters(EIssueFilterType.FILTERS, { [key]: newValues }, parentId); }, [subIssueFilters?.filters, updateSubWorkItemFilters, parentId] @@ -100,7 +103,7 @@ export const SubWorkItemTitleActions: FC = observ filters={subIssueFilters?.filters ?? {}} memberIds={projectMemberIds ?? undefined} states={projectStates} - layoutDisplayFiltersOptions={layoutDisplayFiltersOptions} + availableFilters={SUB_WORK_ITEM_AVAILABLE_FILTERS_FOR_WORK_ITEM_PAGE} /> {!disabled && ( diff --git a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx index 56a29d525..c3e57dea8 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/sub-issues/title.tsx @@ -1,10 +1,11 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { EIssueServiceType, TIssueServiceType } from "@plane/types"; +import type { TIssueServiceType } from "@plane/types"; +import { EIssueServiceType } from "@plane/types"; import { CircularProgressIndicator, CollapsibleButton } from "@plane/ui"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; diff --git a/apps/web/core/components/issues/issue-detail-widgets/widget-button.tsx b/apps/web/core/components/issues/issue-detail-widgets/widget-button.tsx index cded28d30..725ab475f 100644 --- a/apps/web/core/components/issues/issue-detail-widgets/widget-button.tsx +++ b/apps/web/core/components/issues/issue-detail-widgets/widget-button.tsx @@ -1,5 +1,6 @@ "use client"; -import React, { FC } from "react"; +import type { FC } from "react"; +import React from "react"; // helpers import { cn } from "@plane/utils"; diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx index 8dd4e681b..24b762788 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity-comment-root.tsx @@ -1,8 +1,9 @@ -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports -import { E_SORT_ORDER, TActivityFilters, filterActivityOnSelectedFilters } from "@plane/constants"; -import { TCommentsOperations } from "@plane/types"; +import type { E_SORT_ORDER, TActivityFilters } from "@plane/constants"; +import { EActivityFilterType, filterActivityOnSelectedFilters } from "@plane/constants"; +import type { TCommentsOperations } from "@plane/types"; // components import { CommentCard } from "@/components/comments/card/root"; // hooks @@ -52,6 +53,13 @@ export const IssueActivityCommentRoot: FC = observer( const filteredActivityAndComments = filterActivityOnSelectedFilters(activityAndComments, selectedFilters); + const BASE_ACTIVITY_FILTER_TYPES = [ + EActivityFilterType.ACTIVITY, + EActivityFilterType.STATE, + EActivityFilterType.ASSIGNEE, + EActivityFilterType.DEFAULT, + ]; + return (
{filteredActivityAndComments.map((activityComment, index) => { @@ -68,7 +76,7 @@ export const IssueActivityCommentRoot: FC = observer( disabled={disabled} projectId={projectId} /> - ) : activityComment.activity_type === "ACTIVITY" ? ( + ) : BASE_ACTIVITY_FILTER_TYPES.includes(activityComment.activity_type as EActivityFilterType) ? ( = observer((props) => { if (!activity) return <>; return ( } + icon={} activityId={activityId} ends={ends} > diff --git a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx index 39272c6ca..bdc52e482 100644 --- a/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx +++ b/apps/web/core/components/issues/issue-detail/issue-activity/activity/actions/default.tsx @@ -1,9 +1,9 @@ "use client"; -import { FC } from "react"; +import type { FC } from "react"; import { observer } from "mobx-react"; // plane imports -import { LayersIcon } from "@plane/propel/icons"; +import { WorkItemsIcon } from "@plane/propel/icons"; import { EInboxIssueSource } from "@plane/types"; // hooks import { capitalizeFirstLetter } from "@plane/utils"; @@ -28,7 +28,7 @@ export const IssueDefaultActivity: FC = observer((props) return (