Compare commits

...

53 Commits

Author SHA1 Message Date
sriramveeraghanta 667fc7e3ab chore: update next path redirections with validation 2025-10-30 18:56:38 +05:30
Aaryan Khandelwal 350107d6c1 [WEB-5088] feat: Power K v2 (#7905)
* feat: add project shortcut in command palette

* feat: global project switcher shortcut

* refactor: generalize command palette entity handling

* feat: extend command palette navigation

* feat: add issue shortcut to command palette

* feat: add modular project selection for cycle navigation

* chore: add reusable command palette utilities

* fix: update key sequence handling to use window methods for timeout management

* fix: build errors

* chore: minor ux copy improvements

* feat: implement a new command registry and renderer for enhanced command palette functionality

* feat: introduce new command palette components and enhance search functionality

* feat: enhance command palette components with improved initialization and loading indicators

* feat: Implement new command palette architecture with multi-step commands, context-aware filtering, and reusable components. Add comprehensive documentation and integration guides. Enhance command execution with a dedicated executor and context provider. Introduce new command types and improve existing command definitions for better usability and maintainability.

* refactor: hook arguments

* refactor: folder structure

* refactor: update import paths

* fix: context prop drilling

* refactor: update search components

* refactor: create actions

* chore: add type to pages

* chore: init contextual actions

* refactor: context based actions code split

* chore: module context-based actions

* refactor: streamline command execution flow and enhance multi-step handling in command palette

* refactor: remove placeholder management from command execution and implement centralized placeholder mapping

* chore: cycle context based actions

* refactor: simplify command execution by consolidating selection steps and adding page change handling

* chore: added more options to work item contextual actions

* chore: page context actions

* refactor: update step type definitions and enhance page mapping for command execution

* feat: implement Command Palette V2 with global shortcuts and enhanced context handling

* refactor: power k v2

* refactor: creation commands

* feat: add navigation utility for Power K context handling

* feat: implement comprehensive navigation commands for Power K

* refactor: work item contextual actions

* fix: build errors

* refactor: remaining contextual actions

* refactor: remove old code

* chore: update placeholder

* refactor: enhance command registry with observable properties and context-aware shortcut handling

* refactor: improve command filtering logic in CommandPaletteModal

* chore: context indicator

* chore: misc actions

* style: shortcut badge

* feat: add open entity actions and enhance navigation commands for Power K

* refactor: rename and reorganize Power K components for improved clarity and structure

* refactor: update CommandPalette components and streamline global shortcuts handling

* refactor: adjust debounce timing in CommandPaletteModal for improved responsiveness

* feat: implement shortcuts modal and enhance command registry for better shortcut management

* fix: search implemented

* refactor: search results code split

* refactor: search results code split

* feat: introduce creation and navigation command modules for Power K, enhancing command organization and functionality

* chore: update menu logos

* refactor: remove unused PowerKOpenEntityActionsExtended component from command palette

* refactor: search menu

* fix: clear context on backspace and manual clear

* refactor: rename creation command keys for consistency and clarity in Power K

* chore: added intake in global search

* chore: preferences menu

* chore: removed the empty serach params

* revert: command palette changes

* cleanup

* refactor: update command IDs to use underscores for consistency across Power K components

* refactor: extended context based actions

* chore: modal command item status props

* refactor: replace CommandPalette with CommandPaletteProvider in settings and profile layouts

* refactor: update settings menu to use translated labels instead of i18n labels

* refactor: update command titles to use translation keys for creation actions

* refactor: update navigation command titles to use translation keys for consistency

* chore: minor cleanup

* chore: misc commands added

* chore: code split for no search results command

* chore: state menu items for work item context based commands

* chore: add more props to no search results command

* chore: add more props to no search results command

* refactor: remove shortcut key for create workspace command

* Refactor command palette to use PowerK store

- Replaced instances of `useCommandPalette` with `usePowerK` across various components, including `AppSearch`, `CommandModal`, and `CommandPalette`.
- Introduced `PowerKStore` to manage modal states and commands, enhancing the command palette functionality.
- Updated modal handling to toggle `PowerKModal` and `ShortcutsListModal` instead of the previous command palette modals.
- Refactored related components to ensure compatibility with the new store structure and maintain functionality.

* Refactor PowerK command handling to remove context dependency

- Updated `usePowerKCommands` and `usePowerKCreationCommands` to eliminate the need for a context parameter, simplifying their usage.
- Adjusted related command records to utilize the new structure, ensuring consistent access to command configurations.
- Enhanced permission checks in creation commands to utilize user project roles for better access control.

* chore: add context indicator

* chore: update type import

* chore: migrate toast implementation from @plane/ui to @plane/propel/toast across multiple command files

* refactor: power k modal wrapper and provider

* fix: type imports

* chore: update creation command shortcuts

* fix: page context commands

* chore: update navigation and open command shortcuts

* fix: work item standalone page modals

* fix: context indicator visibility

* fix: potential error points

* fix: build errors

* fix: lint errors

* fix: import order

---------

Co-authored-by: Vihar Kurama <vihar.kurama@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-10-29 15:38:30 +05:30
Jayash Tripathy 73e0e8d529 [WEB-4944] feat: add base layouts for kanban and list with drag-and-drop support (#8032) 2025-10-29 14:49:34 +05:30
Anmol Singh Bhatia 5247fedd23 [WEB-5271] chore: your work empty state updated #8029 2025-10-29 13:34:04 +05:30
Anmol Singh Bhatia 0560849f88 [WEB-5271] fix: empty state translation error and code refactoring (#8027)
* fix: workspace view empty state translation

* chore: search empty state translation fix

* chore: workspace view empty state translation
2025-10-28 20:14:07 +05:30
sriram veeraghanta 79537cd1df chore: code splitting instance config variables (#8028)
* chore: code splitting instance config variables

* chore: typo fixes
2025-10-28 19:52:23 +05:30
Nikhil 1126ca30b0 [WEB-5262] feat: gitea sso (#8022)
* Feature/7137/gitea sso (#7940)

* added gitea auth to admin panel with configs , added api calls

* added gitea to oauth root (for signup and signin)

* removed log

* replace github oauth with gitea ouath error messages

* added gitea to auth root

* fix: update token expiration handling and remove unused variable in Gitea callback

* fix: include Gitea in OAuth enabled checks

* fix: improve error handling when fetching emails from Gitea

* chore : remove logs and add semicolons

* refactor: update Gitea authentication components and imports for consistency

* fix: enhance Gitea authentication form to auto-populate host value and improve OAuth checks

* refactor: enhance Gitea OAuth provider with improved error handling and URL validation

* fix: update authentication success messages to check for string value "1"

---------

Co-authored-by: Shivam Jain <shivam.clgstash@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
2025-10-28 18:53:54 +05:30
Sangeetha 69fe581fd8 [WEB-5228] chore: IssueLink.DoesNotExist on crawl_work_item_link_title (#8003)
* chore: wrap IssueLink logic in a try-except block

* chore: user logger.warning

* fix: add id and url on the error message
2025-10-28 18:18:36 +05:30
Bavisetti Narayan e09d986497 [WEB-5263] chore: removed the tracking of sanitized HTML (#8023)
* chore: removed the html sanitized tracking

* chore: added the greater than sign
2025-10-28 16:57:07 +05:30
Sangeetha 044003e7ec [WEB-5211] fix: accessing NoneType current_instance for delete_module_issue_activity bgtask (#7993)
* fix: accessing NoneType current_instance

* fix: return module_name

* fix: add none check
2025-10-28 16:56:39 +05:30
Sangeetha a8b6930486 [WEB-5224] chore: raise warning instead of logging an exception for webhook_send_task (#7996) 2025-10-28 16:49:19 +05:30
Anmol Singh Bhatia 685e3fa2f8 [WEB-5236] chore: attachment icon revamp (#8009)
* chore: iconwrapper component added

* chore: attachments icon added to propel

* chore: code refactor

* chore: code refactor
2025-10-28 16:47:43 +05:30
Anmol Singh Bhatia ec6e682044 [WEB-5255] fix: calendar layout weekly view (#8018)
* fix: calendar layout weekly view

* chore: code refactor
2025-10-28 16:47:10 +05:30
Anmol Singh Bhatia b7aa25f2c6 [WEB-5248] chore: translations and empty state refactor (#8021)
* chore: translations code refactoring

* chore: empty state translation refactor

* chore: code refactor
2025-10-28 13:31:06 +05:30
Anmol Singh Bhatia cf7f891bcb [WEB-5248] chore: empty state code refactor and translation fix (#8017)
* chore: empty state code refactor and translation fix

* chore: code refactor
2025-10-27 19:55:46 +05:30
Anmol Singh Bhatia 3faf768112 [WEB-3567] chore: empty state refactoring and translation fix (#8014)
* chore: empty state component improvement and code refactor

* chore: translation code refactor

* chore: empty state code refactor
2025-10-27 17:03:35 +05:30
Vamsi Krishna be0d1871f0 [WEB-5241] fix: project features toggle #8015 2025-10-27 17:02:22 +05:30
Surya Prashanth c07f7b7c1e [WEB-5245] fix: add is_time_tracking_enabled field in project serializer (#8016) 2025-10-27 16:54:03 +05:30
Surya Prashanth 1d4cde9ba0 [WEB-5245] feat: auto-populate logo_props in project creation #8013 2025-10-27 16:19:52 +05:30
Nikhil c4dd4bd02f [WEB-5155]refactor: simplify user filtering logic in SearchEndpoint #8012 2025-10-27 16:17:19 +05:30
Anmol Singh Bhatia d71dfe8f86 [WEB-5188 | WEB-5190] chore: layout and properties icon revamp (#7980) 2025-10-24 19:53:36 +05:30
Anmol Singh Bhatia 33b6405695 [WEB-5160] chore: propel banner and archived work item improvements (#7999) 2025-10-24 19:48:28 +05:30
Anmol Singh Bhatia 68fd2463f4 [WEB-5230 | WEB-5231] chore: new empty state implementation (#7972) 2025-10-24 17:21:14 +05:30
Vihar Kurama a60d74a3c0 [WEB-5235] Fix activity timeline ordering for edited comments #8002 2025-10-24 16:17:51 +05:30
Anmol Singh Bhatia 76ffe52cd5 [WEB-5220] fix: intake actions btn ui #7995 2025-10-24 16:05:46 +05:30
sriram veeraghanta b65b1b4828 fix: use issue store was not loading up properly (#8006)
* use issue store was not loading up properly , and hence created data issues for rendering filters options

* Added React.FC for better type suggestions

* chore: observer implementation change

---------

Co-authored-by: shivam-jainn <shivam.clgstash@gmail.com>
2025-10-23 21:45:25 +05:30
sriram veeraghanta e5063cd280 fix: get work item activity external endpoint (#8005)
* fix: activity get endpoint fixes

* chore: missing pk added
2025-10-23 21:37:30 +05:30
sriramveeraghanta 9448f642f0 sync: master branch with preview 2025-10-23 20:29:18 +05:30
Akshat Jain 443f8b793d [INFRA-252] fix: Add missing Redis environment variables in live service Docker Compose 2025-10-23 20:28:39 +05:30
Akshat Jain 96fa9ab15b [INFRA-252] fix: Add missing Redis environment variables in live service Docker Compose 2025-10-23 19:26:27 +05:30
sriram veeraghanta d5bad5aedc release: v1.1.0 #7991 2025-10-23 18:24:42 +05:30
Aaron e710f5b278 [WEB-5196] chore: switch from isomorphic-dompurify to dompurify (#7983)
* [WEB-5196] chore: switch from isomorphic-dompurify to dompurify

Replace isomorphic-dompurify with dompurify package in utils.
This change simplifies the dependency and uses the canonical
DOMPurify package directly.

* fix: removing dompurify from the space app as dependency

* chore: remove unused import

---------

Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
2025-10-23 16:33:32 +05:30
sriramveeraghanta 80bd1b6dcd Merge branch 'canary' of github.com:makeplane/plane into preview 2025-10-23 15:43:16 +05:30
sriram veeraghanta b34c9ca04f fix: live server secret key app version env variables (#7997)
* fix: live server secret key app version env variables

* fix: revert to stable version

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-23 15:42:43 +05:30
sriramveeraghanta ad16ea1440 Merge branch 'canary' of github.com:makeplane/plane into preview 2025-10-23 14:54:09 +05:30
sriramveeraghanta addf3c4104 chore: added live server secret key in community docker file 2025-10-23 14:53:41 +05:30
Anmol Singh Bhatia 38cdf756a5 [WEB-5158] chore: calendar start of week alignment fix and code refactoring #7978 2025-10-23 13:59:29 +05:30
Bavisetti Narayan f94da68597 [WEB-5153] chore: optimised the cycle transfer issues (#7969)
* chore: optimised the cycle transfer issues

* chore: added more vlaidation in transfer

* chore: improve the comments
2025-10-23 00:29:32 +05:30
Bavisetti Narayan 68aa2fe0b8 [WIKI-553] chore: improved pages components tracking (#7966)
* chore: page components tracking

* chore: changed the transaction task

* chore: added logger for description html
2025-10-23 00:29:05 +05:30
Vamsi Krishna 5fa9943b66 [WEB-5141] chore: project features refactor (#7960)
* chore: project features refactor

* fix: enable/disable condition for toggle

* fix: dependencies

* chore: lint fix

* chore: lint fix

* chore: added fallback exports
2025-10-23 00:27:16 +05:30
Anmol Singh Bhatia 772b5c5719 [WEB-5201] chore: work item and inbox modal refactor (#7990)
* chore: work item and inbbox modal refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-10-22 21:11:08 +05:30
Nikhil d6657d5168 [WEB-5207] fix: update date formatting in IssueExportSchema for cycle start and end dates #7992 2025-10-22 17:11:43 +05:30
sriramveeraghanta 1b43efbc2a chore: version bump 2025-10-21 19:14:43 +05:30
Aaryan Khandelwal 64781be7d2 regression: downgrade to tiptap v2 (#7982)
* chore: downgrade to tiptap v2

* fix: revert back to hocuspocus

* fix: collaboration events added

* fix: lock unlock issues

* fix: build errors

* fix: type errors

* fix: graceful shutdown

---------

Co-authored-by: Palanikannan M <akashmalinimurugu@gmail.com>
2025-10-21 18:28:16 +05:30
sriramveeraghanta 59022b6beb fix: vite package upgrade 2025-10-21 14:41:34 +05:30
Nikhil f266cd8414 [WEB-5162] fix: add exporter URL patterns for exporting issues (#7977)
* feat: add exporter URL patterns for exporting issues and update session cookie age handling

* fix: ensure ADMIN_SESSION_COOKIE_AGE is an integer for consistent session handling
2025-10-16 15:36:48 +05:30
sriram veeraghanta 63096ceb8c release: v1.0.0 #7711 2025-09-10 14:53:52 +05:30
sriram veeraghanta 9b58b9259e fix: adding proxy command in compose file #7519 2025-07-30 21:03:03 +05:30
sriram veeraghanta 1e235600b7 release: v0.28.0 #7488 2025-07-30 16:51:00 +05:30
sriram veeraghanta 04b0d7ee9f release: v0.27.1 #7328 2025-07-04 13:31:00 +05:30
sriram veeraghanta 5a3f709e72 release: v0.27.0 #7296 2025-07-01 17:40:56 +05:30
sriram veeraghanta 5ef33ab798 release: v0.26.1 #7181 2025-06-06 17:55:03 +05:30
sriram veeraghanta 27806fbe20 release: v0.26.1 #7179 2025-06-06 17:27:49 +05:30
499 changed files with 18025 additions and 5564 deletions
+3
View File
@@ -102,3 +102,6 @@ dev-editor
storybook-static
CLAUDE.md
AGENTS.md
temp/
@@ -0,0 +1,210 @@
"use client";
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 { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IFormattedInstanceConfiguration, TInstanceGiteaAuthenticationConfigurationKeys } from "@plane/types";
import { Button, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { CodeBlock } from "@/components/common/code-block";
import { ConfirmDiscardModal } from "@/components/common/confirm-discard-modal";
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";
type Props = {
config: IFormattedInstanceConfiguration;
};
type GiteaConfigFormValues = Record<TInstanceGiteaAuthenticationConfigurationKeys, string>;
export const InstanceGiteaConfigForm: FC<Props> = (props) => {
const { config } = props;
// states
const [isDiscardChangesModalOpen, setIsDiscardChangesModalOpen] = useState(false);
// store hooks
const { updateInstanceConfigurations } = useInstance();
// form data
const {
handleSubmit,
control,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<GiteaConfigFormValues>({
defaultValues: {
GITEA_HOST: config["GITEA_HOST"] || "https://gitea.com",
GITEA_CLIENT_ID: config["GITEA_CLIENT_ID"],
GITEA_CLIENT_SECRET: config["GITEA_CLIENT_SECRET"],
},
});
const originURL = !isEmpty(API_BASE_URL) ? API_BASE_URL : typeof window !== "undefined" ? window.location.origin : "";
const GITEA_FORM_FIELDS: TControllerInputFormField[] = [
{
key: "GITEA_HOST",
type: "text",
label: "Gitea Host",
description: (
<>Use the URL of your Gitea instance. For the official Gitea instance, use &quot;https://gitea.com&quot;.</>
),
placeholder: "https://gitea.com",
error: Boolean(errors.GITEA_HOST),
required: true,
},
{
key: "GITEA_CLIENT_ID",
type: "text",
label: "Client ID",
description: (
<>
You will get this from your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "70a44354520df8bd9bcd",
error: Boolean(errors.GITEA_CLIENT_ID),
required: true,
},
{
key: "GITEA_CLIENT_SECRET",
type: "password",
label: "Client secret",
description: (
<>
Your client secret is also found in your{" "}
<a
tabIndex={-1}
href="https://gitea.com/user/settings/applications"
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
Gitea OAuth application settings.
</a>
</>
),
placeholder: "9b0050f94ec1b744e32ce79ea4ffacd40d4119cb",
error: Boolean(errors.GITEA_CLIENT_SECRET),
required: true,
},
];
const GITEA_SERVICE_FIELD: TCopyField[] = [
{
key: "Callback_URI",
label: "Callback URI",
url: `${originURL}/auth/gitea/callback/`,
description: (
<>
We will auto-generate this. Paste this into your <CodeBlock darkerShade>Authorized Callback URI</CodeBlock>{" "}
field{" "}
<a
tabIndex={-1}
href={`${control._formValues.GITEA_HOST || "https://gitea.com"}/user/settings/applications`}
target="_blank"
className="text-custom-primary-100 hover:underline"
rel="noreferrer"
>
here.
</a>
</>
),
},
];
const onSubmit = async (formData: GiteaConfigFormValues) => {
const payload: Partial<GiteaConfigFormValues> = { ...formData };
await updateInstanceConfigurations(payload)
.then((response = []) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Done!",
message: "Your Gitea authentication is configured. You should test it now.",
});
reset({
GITEA_HOST: response.find((item) => item.key === "GITEA_HOST")?.value,
GITEA_CLIENT_ID: response.find((item) => item.key === "GITEA_CLIENT_ID")?.value,
GITEA_CLIENT_SECRET: response.find((item) => item.key === "GITEA_CLIENT_SECRET")?.value,
});
})
.catch((err) => console.error(err));
};
const handleGoBack = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
if (isDirty) {
e.preventDefault();
setIsDiscardChangesModalOpen(true);
}
};
return (
<>
<ConfirmDiscardModal
isOpen={isDiscardChangesModalOpen}
onDiscardHref="/authentication"
handleClose={() => setIsDiscardChangesModalOpen(false)}
/>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
<div className="pt-2.5 text-xl font-medium">Gitea-provided details for Plane</div>
{GITEA_FORM_FIELDS.map((field) => (
<ControllerInput
key={field.key}
control={control}
type={field.type}
name={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
error={field.error}
required={field.required}
/>
))}
<div className="flex flex-col gap-1 pt-4">
<div className="flex items-center gap-4">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
<Link
href="/authentication"
className={cn(getButtonStyling("neutral-primary", "md"), "font-medium")}
onClick={handleGoBack}
>
Go back
</Link>
</div>
</div>
</div>
<div className="col-span-2 md:col-span-1">
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-custom-background-80/60 rounded-lg">
<div className="pt-2 text-xl font-medium">Plane-provided details for Gitea</div>
{GITEA_SERVICE_FIELD.map((field) => (
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
))}
</div>
</div>
</div>
</div>
</>
);
};
@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Gitea Authentication - God Mode",
};
export default function GiteaAuthenticationLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import useSWR from "swr";
// plane internal packages
import { setPromiseToast } from "@plane/propel/toast";
import { Loader, ToggleSwitch } from "@plane/ui";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
// hooks
import { useInstance } from "@/hooks/store";
// icons
import giteaLogo from "@/public/logos/gitea-logo.svg";
//local components
import { InstanceGiteaConfigForm } from "./form";
const InstanceGiteaAuthenticationPage = observer(() => {
// store
const { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations } = useInstance();
// state
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// theme
const { resolvedTheme } = useTheme();
// config
const enableGiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? "";
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
const updateConfig = async (key: "IS_GITEA_ENABLED", value: string) => {
setIsSubmitting(true);
const payload = {
[key]: value,
};
const updateConfigPromise = updateInstanceConfigurations(payload);
setPromiseToast(updateConfigPromise, {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Gitea authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
message: () => "Failed to save configuration",
},
});
await updateConfigPromise
.then(() => {
setIsSubmitting(false);
})
.catch((err) => {
console.error(err);
setIsSubmitting(false);
});
};
const isGiteaEnabled = enableGiteaConfig === "1";
return (
<>
<div className="relative container mx-auto w-full h-full p-4 py-4 space-y-6 flex flex-col">
<div className="border-b border-custom-border-100 mx-4 py-4 space-y-1 flex-shrink-0">
<AuthenticationMethodCard
name="Gitea"
description="Allow members to login or sign up to plane with their Gitea accounts."
icon={<Image src={giteaLogo} height={24} width={24} alt="Gitea Logo" />}
config={
<ToggleSwitch
value={isGiteaEnabled}
onChange={() => {
updateConfig("IS_GITEA_ENABLED", isGiteaEnabled ? "0" : "1");
}}
size="sm"
disabled={isSubmitting || !formattedConfig}
/>
}
disabled={isSubmitting || !formattedConfig}
withBorder={false}
/>
</div>
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-4">
{formattedConfig ? (
<InstanceGiteaConfigForm config={formattedConfig} />
) : (
<Loader className="space-y-8">
<Loader.Item height="50px" width="25%" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" />
<Loader.Item height="50px" width="50%" />
</Loader>
)}
</div>
</div>
</>
);
});
export default InstanceGiteaAuthenticationPage;
@@ -44,7 +44,7 @@ const InstanceGithubAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitHub authentication is now ${value ? "active" : "disabled"}.`,
message: () => `GitHub authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -38,7 +38,7 @@ const InstanceGitlabAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `GitLab authentication is now ${value ? "active" : "disabled"}.`,
message: () => `GitLab authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -38,7 +38,7 @@ const InstanceGoogleAuthenticationPage = observer(() => {
loading: "Saving Configuration...",
success: {
title: "Configuration saved",
message: () => `Google authentication is now ${value ? "active" : "disabled"}.`,
message: () => `Google authentication is now ${value === "1" ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -12,6 +12,7 @@ import { resolveGeneralTheme } from "@plane/utils";
// components
import { AuthenticationMethodCard } from "@/components/authentication/authentication-method-card";
import { EmailCodesConfiguration } from "@/components/authentication/email-config-switch";
import { GiteaConfiguration } from "@/components/authentication/gitea-config";
import { GithubConfiguration } from "@/components/authentication/github-config";
import { GitlabConfiguration } from "@/components/authentication/gitlab-config";
import { GoogleConfiguration } from "@/components/authentication/google-config";
@@ -19,6 +20,7 @@ import { PasswordLoginConfiguration } from "@/components/authentication/password
// plane admin components
import { UpgradeButton } from "@/plane-admin/components/common";
// assets
import giteaLogo from "@/public/logos/gitea-logo.svg";
import githubLightModeImage from "@/public/logos/github-black.png";
import githubDarkModeImage from "@/public/logos/github-white.png";
import GitlabLogo from "@/public/logos/gitlab-logo.svg";
@@ -80,6 +82,13 @@ export const getAuthenticationModes: (props: TGetBaseAuthenticationModeProps) =>
icon: <Image src={GitlabLogo} height={20} width={20} alt="GitLab Logo" />,
config: <GitlabConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "gitea",
name: "Gitea",
description: "Allow members to log in or sign up to plane with their Gitea accounts.",
icon: <Image src={giteaLogo} height={20} width={20} alt="Gitea Logo" />,
config: <GiteaConfiguration disabled={disabled} updateConfig={updateConfig} />,
},
{
key: "oidc",
name: "OIDC",
@@ -0,0 +1,58 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// icons
import { Settings2 } from "lucide-react";
// plane internal packages
import type { TInstanceAuthenticationMethodKeys } from "@plane/types";
import { ToggleSwitch, getButtonStyling } from "@plane/ui";
import { cn } from "@plane/utils";
// hooks
import { useInstance } from "@/hooks/store";
type Props = {
disabled: boolean;
updateConfig: (key: TInstanceAuthenticationMethodKeys, value: string) => void;
};
export const GiteaConfiguration: React.FC<Props> = observer((props) => {
const { disabled, updateConfig } = props;
// store
const { formattedConfig } = useInstance();
// derived values
const GiteaConfig = formattedConfig?.IS_GITEA_ENABLED ?? "";
const GiteaConfigured =
!!formattedConfig?.GITEA_HOST && !!formattedConfig?.GITEA_CLIENT_ID && !!formattedConfig?.GITEA_CLIENT_SECRET;
return (
<>
{GiteaConfigured ? (
<div className="flex items-center gap-4">
<Link href="/authentication/gitea" className={cn(getButtonStyling("link-primary", "md"), "font-medium")}>
Edit
</Link>
<ToggleSwitch
value={Boolean(parseInt(GiteaConfig))}
onChange={() => {
Boolean(parseInt(GiteaConfig)) === true
? updateConfig("IS_GITEA_ENABLED", "0")
: updateConfig("IS_GITEA_ENABLED", "1");
}}
size="sm"
disabled={disabled}
/>
</div>
) : (
<Link
href="/authentication/gitea"
className={cn(getButtonStyling("neutral-primary", "sm"), "text-custom-text-300")}
>
<Settings2 className="h-4 w-4 p-0.5 text-custom-text-300/80" />
Configure
</Link>
)}
</>
);
});
+1 -1
View File
@@ -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": {
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+1 -1
View File
@@ -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"
+54 -1
View File
@@ -1,4 +1,5 @@
# Third party imports
import random
from rest_framework import serializers
# Module imports
@@ -24,6 +25,47 @@ class ProjectCreateSerializer(BaseSerializer):
and workspace association for new project initialization.
"""
PROJECT_ICON_DEFAULT_COLORS = [
"#95999f",
"#6d7b8a",
"#5e6ad2",
"#02b5ed",
"#02b55c",
"#f2be02",
"#e57a00",
"#f38e82",
]
PROJECT_ICON_DEFAULT_ICONS = [
"home",
"apps",
"settings",
"star",
"favorite",
"done",
"check_circle",
"add_task",
"create_new_folder",
"dataset",
"terminal",
"key",
"rocket",
"public",
"quiz",
"mood",
"gavel",
"eco",
"diamond",
"forest",
"bolt",
"sync",
"cached",
"library_add",
"view_timeline",
"view_kanban",
"empty_dashboard",
"cycle",
]
class Meta:
model = Project
fields = [
@@ -44,10 +86,10 @@ class ProjectCreateSerializer(BaseSerializer):
"archive_in",
"close_in",
"timezone",
"logo_props",
"external_source",
"external_id",
"is_issue_type_enabled",
"is_time_tracking_enabled",
]
read_only_fields = [
@@ -57,6 +99,7 @@ class ProjectCreateSerializer(BaseSerializer):
"updated_at",
"created_by",
"updated_by",
"logo_props",
]
def validate(self, data):
@@ -86,6 +129,16 @@ class ProjectCreateSerializer(BaseSerializer):
if ProjectIdentifier.objects.filter(name=identifier, workspace_id=self.context["workspace_id"]).exists():
raise serializers.ValidationError(detail="Project Identifier is taken")
if validated_data.get("logo_props", None) is None:
# Generate a random icon and color for the project icon
validated_data["logo_props"] = {
"in_use": "icon",
"icon": {
"name": random.choice(self.PROJECT_ICON_DEFAULT_ICONS),
"color": random.choice(self.PROJECT_ICON_DEFAULT_COLORS),
},
}
project = Project.objects.create(**validated_data, workspace_id=self.context["workspace_id"])
return project
+94 -423
View File
@@ -12,13 +12,7 @@ from django.db.models import (
OuterRef,
Q,
Sum,
FloatField,
Case,
When,
Value,
)
from django.db.models.functions import Cast, Concat
from django.db import models
# Third party imports
from rest_framework import status
@@ -47,7 +41,7 @@ from plane.db.models import (
ProjectMember,
UserFavorite,
)
from plane.utils.analytics_plot import burndown_plot
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -201,7 +195,9 @@ 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,
@@ -258,7 +254,9 @@ 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),
@@ -304,11 +302,17 @@ 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, context={"request": request})
serializer = CycleCreateSerializer(
data=request.data, context={"request": request}
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -351,7 +355,9 @@ 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,
)
@@ -499,7 +505,9 @@ 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(
@@ -512,14 +520,20 @@ 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, context={"request": request})
serializer = CycleUpdateSerializer(
cycle, data=request.data, partial=True, context={"request": request}
)
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -527,7 +541,9 @@ 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()
):
@@ -584,7 +600,11 @@ 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",
@@ -604,7 +624,9 @@ 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)
@@ -742,7 +764,9 @@ 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(
@@ -761,7 +785,9 @@ 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"},
@@ -792,7 +818,9 @@ 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)
@@ -855,7 +883,9 @@ 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()
@@ -892,7 +922,9 @@ 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(
@@ -922,10 +954,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
if not issues:
return Response(
{"error": "Work items are required", "code": "MISSING_WORK_ITEMS"}, 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(
@@ -937,9 +972,13 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
)
# 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))
@@ -990,7 +1029,9 @@ 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()),
@@ -1066,7 +1107,9 @@ 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(
@@ -1171,406 +1214,34 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
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)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
old_cycle = Cycle.objects.get(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"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"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.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(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(avatar=F("assignees__avatar"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"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"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.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(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
pk=cycle_id,
)
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,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
# transfer work items only when cycle is completed (passed the end data)
if old_cycle.end_date is not None and old_cycle.end_date > timezone.now():
return Response(
{"error": "The cycle where the issues are transferred is already completed"},
{"error": "The old cycle is not completed yet"},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
# Call the utility function to handle the transfer
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=self.request.user.id,
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
else:
return Response(
{"error": result.get("error")},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
+22 -18
View File
@@ -987,12 +987,12 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
serializer = LabelCreateUpdateSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
if (
str(request.data.get("external_id"))
and (label.external_id != str(request.data.get("external_id")))
request.data.get("external_id")
and request.data.get("external_source")
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"),
external_id=request.data.get("external_id"),
)
.exclude(id=pk)
@@ -1695,23 +1695,27 @@ class IssueActivityDetailAPIEndpoint(BaseAPIView):
Retrieve details of a specific activity.
Excludes comment, vote, reaction, and draft activities.
"""
issue_activities = (
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,
project__project_projectmember__is_active=True,
issue_activity = (
(
IssueActivity.objects.filter(issue_id=issue_id, workspace__slug=slug, project_id=project_id, id=pk)
.filter(
~Q(field__in=["comment", "vote", "reaction", "draft"]),
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
)
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
)
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))
.order_by(request.GET.get("order_by", "created_at"))
.first()
)
return self.paginate(
request=request,
queryset=(issue_activities),
on_results=lambda issue_activity: IssueActivitySerializer(
issue_activity, many=True, fields=self.fields, expand=self.expand
).data,
if not issue_activity:
return Response({"message": "Activity not found.", "code": "NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND)
return Response(
IssueActivitySerializer(issue_activity, fields=self.fields, expand=self.expand).data,
status=status.HTTP_200_OK,
)
+3 -1
View File
@@ -871,6 +871,8 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
module_id=module_id,
issue_id=issue_id,
)
module_name = module_issue.module.name if module_issue.module is not None else ""
module_issue.delete()
issue_activity.delay(
type="module.activity.deleted",
@@ -878,7 +880,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
current_instance=json.dumps({"module_name": module_name}),
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
+2
View File
@@ -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,
]
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from plane.app.views import ExportIssuesEndpoint
urlpatterns = [
path(
"workspaces/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
]
-7
View File
@@ -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/<str:slug>/export-issues/",
ExportIssuesEndpoint.as_view(),
name="export-issues",
),
## End Issues
## Issue Activity
path(
+108 -425
View File
@@ -51,6 +51,7 @@ from plane.db.models import (
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.host import base_host
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from .. import BaseAPIView, BaseViewSet
from plane.bgtasks.webhook_task import model_activity
from plane.utils.timezone_converter import convert_to_utc, user_timezone_converter
@@ -96,7 +97,9 @@ 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(
@@ -147,7 +150,8 @@ 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")),
@@ -166,7 +170,11 @@ 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())),
)
@@ -197,7 +205,9 @@ 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
@@ -264,10 +274,16 @@ 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 = (
@@ -307,7 +323,9 @@ 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(
@@ -323,13 +341,17 @@ 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(
@@ -337,21 +359,29 @@ 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(
@@ -451,7 +481,9 @@ 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
@@ -473,7 +505,11 @@ class CycleViewSet(BaseViewSet):
def destroy(self, request, slug, project_id, pk):
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
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",
@@ -524,7 +560,9 @@ 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,
@@ -597,409 +635,23 @@ 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()
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"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"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.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(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"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"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.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(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serilization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Pass the new_cycle queryset to burndown_plot
completion_chart = burndown_plot(
queryset=old_cycle,
# Transfer cycle issues and create progress snapshot
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=request.user.id,
)
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,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
# Handle error response
if result.get("error"):
return Response(
{"error": "The cycle where the issues are transferred is already completed"},
{"error": result["error"]},
status=status.HTTP_400_BAD_REQUEST,
)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(self.request.user.id),
issue_id=None,
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": "[]",
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response({"message": "Success"}, status=status.HTTP_200_OK)
@@ -1014,8 +666,12 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
)
cycle_properties.filters = request.data.get("filters", cycle_properties.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.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
)
@@ -1039,9 +695,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
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",
@@ -1087,7 +747,9 @@ 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:
@@ -1147,11 +809,22 @@ 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,
@@ -1169,7 +842,9 @@ 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",
@@ -1252,7 +927,9 @@ 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()),
@@ -1287,7 +964,9 @@ 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()),
@@ -1389,7 +1068,11 @@ 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",
+17 -8
View File
@@ -137,7 +137,11 @@ class PageViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=None,
page_id=serializer.data["id"],
)
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -168,11 +172,8 @@ class PageViewSet(BaseViewSet):
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=json.dumps(
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
page_id=page_id,
)
@@ -504,7 +505,11 @@ class PagesDescriptionViewSet(BaseViewSet):
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=page_id)
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page.description_html,
page_id=page_id,
)
# Update the page using serializer
updated_page = serializer.save()
@@ -550,7 +555,11 @@ class PageDuplicateEndpoint(BaseAPIView):
updated_by_id=page.updated_by_id,
)
page_transaction.delay({"description_html": page.description_html}, None, page.id)
page_transaction.delay(
new_description_html=page.description_html,
old_description_html=None,
page_id=page.id,
)
# Copy the s3 objects uploaded in the page
copy_s3_objects_of_description_and_assets.delay(
+158 -83
View File
@@ -43,22 +43,25 @@ class GlobalSearchEndpoint(BaseAPIView):
also show related workspace if found
"""
def filter_workspaces(self, query, slug, project_id, workspace_search):
def filter_workspaces(self, query, _slug, _project_id, _workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return (
Workspace.objects.filter(q, workspace_member__member=self.request.user)
.order_by("-created_at")
.distinct()
.values("name", "id", "slug")
)
def filter_projects(self, query, slug, project_id, workspace_search):
def filter_projects(self, query, slug, _project_id, _workspace_search):
fields = ["name", "identifier"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return (
Project.objects.filter(
q,
@@ -67,6 +70,7 @@ class GlobalSearchEndpoint(BaseAPIView):
archived_at__isnull=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "workspace__slug")
)
@@ -74,14 +78,15 @@ class GlobalSearchEndpoint(BaseAPIView):
def filter_issues(self, query, slug, project_id, workspace_search):
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
if query:
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = Issue.issue_objects.filter(
q,
@@ -106,8 +111,9 @@ class GlobalSearchEndpoint(BaseAPIView):
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = Cycle.objects.filter(
q,
@@ -120,13 +126,20 @@ 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.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_modules(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = Module.objects.filter(
q,
@@ -139,13 +152,20 @@ 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.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_pages(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
@@ -157,7 +177,9 @@ 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())),
)
)
@@ -174,19 +196,28 @@ 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.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
)
)
def filter_views(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
for field in fields:
q |= Q(**{f"{field}__icontains": query})
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
issue_views = IssueView.objects.filter(
q,
@@ -199,29 +230,57 @@ 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.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
def filter_intakes(self, query, slug, project_id, workspace_search):
fields = ["name", "sequence_id", "project__identifier"]
q = Q()
if query:
for field in fields:
if field == "sequence_id":
# Match whole integers only (exclude decimal numbers)
sequences = re.findall(r"\b\d+\b", query)
for sequence_id in sequences:
q |= Q(**{"sequence_id": sequence_id})
else:
q |= Q(**{f"{field}__icontains": query})
issues = Issue.objects.filter(
q,
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
workspace__slug=slug,
).filter(models.Q(issue_intake__status=0) | models.Q(issue_intake__status=-2))
if workspace_search == "false" and project_id:
issues = issues.filter(project_id=project_id)
return (
issues.order_by("-created_at")
.distinct()
.values(
"name",
"id",
"sequence_id",
"project__identifier",
"project_id",
"workspace__slug",
)[:100]
)
def get(self, request, slug):
query = request.query_params.get("search", False)
entities_param = request.query_params.get("entities")
workspace_search = request.query_params.get("workspace_search", "false")
project_id = request.query_params.get("project_id", False)
if not query:
return Response(
{
"results": {
"workspace": [],
"project": [],
"issue": [],
"cycle": [],
"module": [],
"issue_view": [],
"page": [],
}
},
status=status.HTTP_200_OK,
)
MODELS_MAPPER = {
"workspace": self.filter_workspaces,
"project": self.filter_projects,
@@ -230,13 +289,27 @@ class GlobalSearchEndpoint(BaseAPIView):
"module": self.filter_modules,
"issue_view": self.filter_views,
"page": self.filter_pages,
"intake": self.filter_intakes,
}
# Determine which entities to search
if entities_param:
requested_entities = [
e.strip() for e in entities_param.split(",") if e.strip()
]
requested_entities = [e for e in requested_entities if e in MODELS_MAPPER]
else:
requested_entities = list(MODELS_MAPPER.keys())
results = {}
for model in MODELS_MAPPER.keys():
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
for entity in requested_entities:
func = MODELS_MAPPER.get(entity)
if func:
results[entity] = func(
query or None, slug, project_id, workspace_search
)
return Response({"results": results}, status=status.HTTP_200_OK)
@@ -294,29 +367,15 @@ class SearchEndpoint(BaseAPIView):
.order_by("-created_at")
)
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id).values_list("created_by_id", flat=True).first()
)
users = (
users.filter(Q(role__gt=10) | Q(member_id=issue_created_by))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
else:
users = (
users.filter(Q(role__gt=10))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
users = (
users
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
response_data["user_mention"] = list(users[:count])
@@ -330,12 +389,15 @@ 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)
@@ -394,16 +456,20 @@ 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(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -521,7 +587,9 @@ 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)
@@ -535,12 +603,15 @@ 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)
@@ -597,16 +668,20 @@ 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(
Q(start_date__isnull=True) & Q(end_date__isnull=True),
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -38,9 +38,11 @@ AUTHENTICATION_ERROR_CODES = {
"GITHUB_NOT_CONFIGURED": 5110,
"GITHUB_USER_NOT_IN_ORG": 5122,
"GITLAB_NOT_CONFIGURED": 5111,
"GITEA_NOT_CONFIGURED": 5112,
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
"GITEA_OAUTH_PROVIDER_ERROR": 5123,
# Reset Password
"INVALID_PASSWORD_TOKEN": 5125,
"EXPIRED_PASSWORD_TOKEN": 5130,
@@ -48,6 +48,8 @@ class OauthAdapter(Adapter):
return "GITHUB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitlab":
return "GITLAB_OAUTH_PROVIDER_ERROR"
elif self.provider == "gitea":
return "GITEA_OAUTH_PROVIDER_ERROR"
else:
return "OAUTH_NOT_CONFIGURED"
@@ -0,0 +1,171 @@
import os
from datetime import datetime, timedelta
from urllib.parse import urlencode, urlparse
import pytz
import requests
# Module imports
from plane.authentication.adapter.oauth import OauthAdapter
from plane.license.utils.instance_value import get_configuration_value
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
class GiteaOAuthProvider(OauthAdapter):
provider = "gitea"
scope = "openid email profile"
def __init__(self, request, code=None, state=None, callback=None):
(GITEA_CLIENT_ID, GITEA_CLIENT_SECRET, GITEA_HOST) = get_configuration_value(
[
{
"key": "GITEA_CLIENT_ID",
"default": os.environ.get("GITEA_CLIENT_ID"),
},
{
"key": "GITEA_CLIENT_SECRET",
"default": os.environ.get("GITEA_CLIENT_SECRET"),
},
{
"key": "GITEA_HOST",
"default": os.environ.get("GITEA_HOST"),
},
]
)
if not (GITEA_CLIENT_ID and GITEA_CLIENT_SECRET and GITEA_HOST):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"],
error_message="GITEA_NOT_CONFIGURED",
)
# Enforce scheme and normalize trailing slash(es)
parsed = urlparse(GITEA_HOST)
if not parsed.scheme or parsed.scheme not in ("https", "http"):
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_NOT_CONFIGURED"],
error_message="GITEA_NOT_CONFIGURED", # avoid leaking details to query params
)
GITEA_HOST = GITEA_HOST.rstrip("/")
# Set URLs based on the host
self.token_url = f"{GITEA_HOST}/login/oauth/access_token"
self.userinfo_url = f"{GITEA_HOST}/api/v1/user"
client_id = GITEA_CLIENT_ID
client_secret = GITEA_CLIENT_SECRET
redirect_uri = f"{'https' if request.is_secure() else 'http'}://{request.get_host()}/auth/gitea/callback/"
url_params = {
"client_id": client_id,
"scope": self.scope,
"redirect_uri": redirect_uri,
"response_type": "code",
"state": state,
}
auth_url = f"{GITEA_HOST}/login/oauth/authorize?{urlencode(url_params)}"
super().__init__(
request,
self.provider,
client_id,
self.scope,
redirect_uri,
auth_url,
self.token_url,
self.userinfo_url,
client_secret,
code,
callback=callback,
)
def set_token_data(self):
data = {
"code": self.code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
"grant_type": "authorization_code",
}
headers = {"Accept": "application/json"}
token_response = self.get_user_token(data=data, headers=headers)
super().set_token_data(
{
"access_token": token_response.get("access_token"),
"refresh_token": token_response.get("refresh_token", None),
"access_token_expired_at": (
datetime.now(tz=pytz.utc) + timedelta(seconds=token_response.get("expires_in"))
if token_response.get("expires_in")
else None
),
"refresh_token_expired_at": (
datetime.fromtimestamp(
token_response.get("refresh_token_expired_at"), tz=pytz.utc
)
if token_response.get("refresh_token_expired_at")
else None
),
"id_token": token_response.get("id_token", ""),
}
)
def __get_email(self, headers):
try:
# Gitea may not provide email in user response, so fetch it separately
emails_url = f"{self.userinfo_url}/emails"
response = requests.get(emails_url, headers=headers)
if not response.ok:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR: Failed to fetch emails",
)
emails_response = response.json()
if not emails_response:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR: No emails found",
)
# Prefer primary+verified, then any verified, then primary, else first
email = next((e.get("email") for e in emails_response if e.get("primary") and e.get("verified")), None)
if not email:
email = next((e.get("email") for e in emails_response if e.get("verified")), None)
if not email:
email = next((e.get("email") for e in emails_response if e.get("primary")), None)
if not email and emails_response:
# If no primary email, use the first one
email = emails_response[0].get("email")
return email
except requests.RequestException:
raise AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR: Exception occurred while fetching emails",
)
def set_user_data(self):
user_info_response = self.get_user_response()
headers = {
"Authorization": f"Bearer {self.token_data.get('access_token')}",
"Accept": "application/json",
}
# Get email if not provided in user info
email = user_info_response.get("email")
if not email:
email = self.__get_email(headers=headers)
super().set_user_data(
{
"email": email,
"user": {
"provider_id": str(user_info_response.get("id")),
"email": email,
"avatar": user_info_response.get("avatar_url"),
"first_name": user_info_response.get("full_name") or user_info_response.get("login"),
"last_name": "", # Gitea doesn't provide separate first/last name
"is_password_autoset": True,
},
}
)
+17
View File
@@ -36,6 +36,10 @@ from .views import (
SignInAuthSpaceEndpoint,
SignUpAuthSpaceEndpoint,
SignOutAuthSpaceEndpoint,
GiteaCallbackEndpoint,
GiteaOauthInitiateEndpoint,
GiteaCallbackSpaceEndpoint,
GiteaOauthInitiateSpaceEndpoint,
)
urlpatterns = [
@@ -129,4 +133,17 @@ urlpatterns = [
),
path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"),
path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"),
## Gitea Oauth
path("gitea/", GiteaOauthInitiateEndpoint.as_view(), name="gitea-initiate"),
path("gitea/callback/", GiteaCallbackEndpoint.as_view(), name="gitea-callback"),
path(
"spaces/gitea/",
GiteaOauthInitiateSpaceEndpoint.as_view(),
name="space-gitea-initiate",
),
path(
"spaces/gitea/callback/",
GiteaCallbackSpaceEndpoint.as_view(),
name="space-gitea-callback",
),
]
@@ -5,6 +5,7 @@ from .app.check import EmailCheckEndpoint
from .app.email import SignInAuthEndpoint, SignUpAuthEndpoint
from .app.github import GitHubCallbackEndpoint, GitHubOauthInitiateEndpoint
from .app.gitlab import GitLabCallbackEndpoint, GitLabOauthInitiateEndpoint
from .app.gitea import GiteaCallbackEndpoint, GiteaOauthInitiateEndpoint
from .app.google import GoogleCallbackEndpoint, GoogleOauthInitiateEndpoint
from .app.magic import MagicGenerateEndpoint, MagicSignInEndpoint, MagicSignUpEndpoint
@@ -17,6 +18,8 @@ from .space.github import GitHubCallbackSpaceEndpoint, GitHubOauthInitiateSpaceE
from .space.gitlab import GitLabCallbackSpaceEndpoint, GitLabOauthInitiateSpaceEndpoint
from .space.gitea import GiteaCallbackSpaceEndpoint, GiteaOauthInitiateSpaceEndpoint
from .space.google import GoogleCallbackSpaceEndpoint, GoogleOauthInitiateSpaceEndpoint
from .space.magic import (
@@ -0,0 +1,109 @@
import uuid
from urllib.parse import urlencode, urljoin
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider
from plane.authentication.utils.login import user_login
from plane.authentication.utils.redirection_path import get_redirection_path
from plane.authentication.utils.user_auth_workflow import post_user_auth_workflow
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AuthenticationException,
AUTHENTICATION_ERROR_CODES,
)
from plane.utils.path_validator import validate_next_path
class GiteaOauthInitiateEndpoint(View):
def get(self, request):
# Get host and next path
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))
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
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)
)
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GiteaOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
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)
)
return HttpResponseRedirect(url)
class GiteaCallbackEndpoint(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", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_OAUTH_PROVIDER_ERROR",
)
params = exc.get_error_dict()
if next_path:
params["next_path"] = str(next_path)
url = urljoin(base_host, "?" + urlencode(params))
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_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))
return HttpResponseRedirect(url)
try:
provider = GiteaOAuthProvider(
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))
else:
path = get_redirection_path(user=user)
# redirect to referer path
url = urljoin(base_host, path)
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))
return HttpResponseRedirect(url)
@@ -0,0 +1,100 @@
# Python imports
import uuid
from urllib.parse import urlencode
# Django import
from django.http import HttpResponseRedirect
from django.views import View
# Module imports
from plane.authentication.provider.oauth.gitea import GiteaOAuthProvider
from plane.authentication.utils.login import user_login
from plane.license.models import Instance
from plane.authentication.utils.host import base_host
from plane.authentication.adapter.error import (
AUTHENTICATION_ERROR_CODES,
AuthenticationException,
)
from plane.utils.path_validator import validate_next_path
class GiteaOauthInitiateSpaceEndpoint(View):
def get(self, request):
# 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(validate_next_path(next_path))
# Check instance configuration
instance = Instance.objects.first()
if instance is None or not instance.is_setup_done:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["INSTANCE_NOT_CONFIGURED"],
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)}"
return HttpResponseRedirect(url)
try:
state = uuid.uuid4().hex
provider = GiteaOAuthProvider(request=request, state=state)
request.session["state"] = state
auth_url = provider.get_auth_url()
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)}"
return HttpResponseRedirect(url)
class GiteaCallbackSpaceEndpoint(View):
def get(self, request):
code = request.GET.get("code")
state = request.GET.get("state")
next_path = request.session.get("next_path")
if state != request.session.get("state", ""):
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_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)}"
return HttpResponseRedirect(url)
if not code:
exc = AuthenticationException(
error_code=AUTHENTICATION_ERROR_CODES["GITEA_OAUTH_PROVIDER_ERROR"],
error_message="GITEA_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)}"
return HttpResponseRedirect(url)
try:
provider = GiteaOAuthProvider(request=request, code=code)
user = provider.authenticate()
# Login the user and record his device info
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(validate_next_path(next_path)) if next_path else ''}"
)
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 = f"{base_host(request=request, is_space=True)}?{urlencode(params)}"
return HttpResponseRedirect(url)
+105 -43
View File
@@ -1,5 +1,5 @@
# Python imports
import json
import logging
# Django imports
from django.utils import timezone
@@ -7,72 +7,134 @@ from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# Module imports
from plane.db.models import Page, PageLog
# App imports
from celery import shared_task
from plane.db.models import Page, PageLog
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
def extract_components(value, tag):
COMPONENT_MAP = {
"mention-component": {
"attributes": ["id", "entity_identifier", "entity_name", "entity_type"],
"extract": lambda m: {
"entity_name": m.get("entity_name"),
"entity_type": None,
"entity_identifier": m.get("entity_identifier"),
},
},
"image-component": {
"attributes": ["id", "src"],
"extract": lambda m: {
"entity_name": "image",
"entity_type": None,
"entity_identifier": m.get("src"),
},
},
}
component_map = {
**COMPONENT_MAP,
}
def extract_all_components(description_html):
"""
Extracts all component types from the HTML value in a single pass.
Returns a dict mapping component_type -> list of extracted entities.
"""
try:
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
if not description_html:
return {component: [] for component in component_map.keys()}
for mention_tag in mention_tags:
mention = {
"id": mention_tag.get("id"),
"entity_identifier": mention_tag.get("entity_identifier"),
"entity_name": mention_tag.get("entity_name"),
}
mentions.append(mention)
soup = BeautifulSoup(description_html, "html.parser")
results = {}
for component, config in component_map.items():
attributes = config.get("attributes", ["id"])
component_tags = soup.find_all(component)
entities = []
for tag in component_tags:
entity = {attr: tag.get(attr) for attr in attributes}
entities.append(entity)
results[component] = entities
return results
return mentions
except Exception:
return []
return {component: [] for component in component_map.keys()}
def get_entity_details(component: str, mention: dict):
"""
Normalizes mention attributes into entity_name, entity_type, entity_identifier.
"""
config = component_map.get(component)
if not config:
return {"entity_name": None, "entity_type": None, "entity_identifier": None}
return config["extract"](mention)
@shared_task
def page_transaction(new_value, old_value, page_id):
def page_transaction(new_description_html, old_description_html, page_id):
"""
Tracks changes in page content (mentions, embeds, etc.)
and logs them in PageLog for audit and reference.
"""
try:
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
old_value = json.loads(old_value) if old_value else {}
has_existing_logs = PageLog.objects.filter(page_id=page_id).exists()
# Extract all components in a single pass (optimized)
old_components = extract_all_components(old_description_html)
new_components = extract_all_components(new_description_html)
new_transactions = []
deleted_transaction_ids = set()
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
for component in component_map.keys():
old_entities = old_components[component]
new_entities = new_components[component]
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
old_ids = {m.get("id") for m in old_entities if m.get("id")}
new_ids = {m.get("id") for m in new_entities if m.get("id")}
deleted_transaction_ids.update(old_ids - new_ids)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
created_at=timezone.now(),
updated_at=timezone.now(),
for mention in new_entities:
mention_id = mention.get("id")
if not mention_id or (mention_id in old_ids and has_existing_logs):
continue
details = get_entity_details(component, mention)
current_time = timezone.now()
new_transactions.append(
PageLog(
transaction=mention_id,
page_id=page_id,
entity_identifier=details["entity_identifier"],
entity_name=details["entity_name"],
entity_type=details["entity_type"],
workspace_id=page.workspace_id,
created_at=current_time,
updated_at=current_time,
)
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
# Bulk insert and cleanup
if new_transactions:
PageLog.objects.bulk_create(
new_transactions, batch_size=50, ignore_conflicts=True
)
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
except Page.DoesNotExist:
return
except Exception as e:
+4 -6
View File
@@ -86,7 +86,6 @@ def get_issue_prefetches():
]
def save_webhook_log(
webhook: Webhook,
request_method: str,
@@ -98,10 +97,9 @@ def save_webhook_log(
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),
@@ -123,7 +121,7 @@ def save_webhook_log(
logger.info("Webhook log saved successfully to mongo")
mongo_save_success = True
except Exception as e:
log_exception(e)
log_exception(e, warning=True)
logger.error(f"Failed to save webhook log: {e}")
mongo_save_success = False
@@ -134,7 +132,7 @@ def save_webhook_log(
WebhookLog.objects.create(**log_data)
logger.info("Webhook log saved successfully to database")
except Exception as e:
log_exception(e)
log_exception(e, warning=True)
logger.error(f"Failed to save webhook log: {e}")
@@ -244,7 +242,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s
msg.send()
logger.info("Email sent successfully.")
except Exception as e:
log_exception(e)
log_exception(e, warning=True)
logger.error(f"Failed to send email: {e}")
@@ -171,8 +171,12 @@ def fetch_and_encode_favicon(
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
try:
issue_link = IssueLink.objects.get(id=id)
except IssueLink.DoesNotExist:
logger.warning(f"IssueLink not found for the id {id} and the url {url}")
return
issue_link.metadata = meta_data
issue_link.save()
@@ -50,6 +50,7 @@ class InstanceEndpoint(BaseAPIView):
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
IS_GITLAB_ENABLED,
IS_GITEA_ENABLED,
EMAIL_HOST,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
@@ -86,6 +87,10 @@ class InstanceEndpoint(BaseAPIView):
"key": "IS_GITLAB_ENABLED",
"default": os.environ.get("IS_GITLAB_ENABLED", "0"),
},
{
"key": "IS_GITEA_ENABLED",
"default": os.environ.get("IS_GITEA_ENABLED", "0"),
},
{"key": "EMAIL_HOST", "default": os.environ.get("EMAIL_HOST", "")},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
@@ -134,6 +139,7 @@ class InstanceEndpoint(BaseAPIView):
data["is_google_enabled"] = IS_GOOGLE_ENABLED == "1"
data["is_github_enabled"] = IS_GITHUB_ENABLED == "1"
data["is_gitlab_enabled"] = IS_GITLAB_ENABLED == "1"
data["is_gitea_enabled"] = IS_GITEA_ENABLED == "1"
data["is_magic_login_enabled"] = ENABLE_MAGIC_LINK_LOGIN == "1"
data["is_email_password_enabled"] = ENABLE_EMAIL_PASSWORD == "1"
@@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand, CommandError
# Module imports
from plane.license.models import InstanceConfiguration
from plane.utils.instance_config_variables import instance_config_variables
class Command(BaseCommand):
@@ -21,175 +22,7 @@ class Command(BaseCommand):
if not os.environ.get(item):
raise CommandError(f"{item} env variable is required.")
config_keys = [
# Authentication Settings
{
"key": "ENABLE_SIGNUP",
"value": os.environ.get("ENABLE_SIGNUP", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "DISABLE_WORKSPACE_CREATION",
"value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
"category": "WORKSPACE_MANAGEMENT",
"is_encrypted": False,
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_ID",
"value": os.environ.get("GOOGLE_CLIENT_ID"),
"category": "GOOGLE",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_SECRET",
"value": os.environ.get("GOOGLE_CLIENT_SECRET"),
"category": "GOOGLE",
"is_encrypted": True,
},
{
"key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITHUB_CLIENT_SECRET",
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITHUB_ORGANIZATION_ID",
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_ID",
"value": os.environ.get("GITLAB_CLIENT_ID"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "ENABLE_SMTP",
"value": os.environ.get("ENABLE_SMTP", "0"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_SECRET",
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
"category": "GITLAB",
"is_encrypted": True,
},
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_USER",
"value": os.environ.get("EMAIL_HOST_USER", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_PASSWORD",
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"category": "SMTP",
"is_encrypted": True,
},
{
"key": "EMAIL_PORT",
"value": os.environ.get("EMAIL_PORT", "587"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_FROM",
"value": os.environ.get("EMAIL_FROM", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_TLS",
"value": os.environ.get("EMAIL_USE_TLS", "1"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_SSL",
"value": os.environ.get("EMAIL_USE_SSL", "0"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "LLM_API_KEY",
"value": os.environ.get("LLM_API_KEY"),
"category": "AI",
"is_encrypted": True,
},
{
"key": "LLM_PROVIDER",
"value": os.environ.get("LLM_PROVIDER", "openai"),
"category": "AI",
"is_encrypted": False,
},
{
"key": "LLM_MODEL",
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
"category": "AI",
"is_encrypted": False,
},
# Deprecated, use LLM_MODEL
{
"key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "UNSPLASH_ACCESS_KEY",
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
# intercom settings
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
for item in config_keys:
for item in instance_config_variables:
obj, created = InstanceConfiguration.objects.get_or_create(key=item.get("key"))
if created:
obj.category = item.get("category")
@@ -203,7 +36,7 @@ class Command(BaseCommand):
else:
self.stdout.write(self.style.WARNING(f"{obj.key} configuration already exists"))
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"]
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED", "IS_GITEA_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
@@ -282,6 +115,34 @@ class Command(BaseCommand):
is_encrypted=False,
)
self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable."))
if key == "IS_GITEA_ENABLED":
GITEA_HOST, GITEA_CLIENT_ID, GITEA_CLIENT_SECRET = get_configuration_value(
[
{
"key": "GITEA_HOST",
"default": os.environ.get("GITEA_HOST", ""),
},
{
"key": "GITEA_CLIENT_ID",
"default": os.environ.get("GITEA_CLIENT_ID", ""),
},
{
"key": "GITEA_CLIENT_SECRET",
"default": os.environ.get("GITEA_CLIENT_SECRET", ""),
},
]
)
if bool(GITEA_HOST) and bool(GITEA_CLIENT_ID) and bool(GITEA_CLIENT_SECRET):
value = "1"
else:
value = "0"
InstanceConfiguration.objects.create(
key="IS_GITEA_ENABLED",
value=value,
category="AUTHENTICATION",
is_encrypted=False,
)
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"))
+2 -2
View File
@@ -300,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
+6 -5
View File
@@ -4,7 +4,9 @@ import nh3
from plane.utils.exception_logger import log_exception
from bs4 import BeautifulSoup
from collections import defaultdict
import logging
logger = logging.getLogger("plane.api")
# Maximum allowed size for binary data (10MB)
MAX_SIZE = 10 * 1024 * 1024
@@ -54,7 +56,9 @@ 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
@@ -232,10 +236,7 @@ def validate_html_content(html_content: str):
summary = json.dumps(diff)
except Exception:
summary = str(diff)
log_exception(
f"HTML sanitization removals: {summary}",
warning=True,
)
logger.warning(f"HTML sanitization removals: {summary}")
return True, None, clean_html
except Exception as e:
log_exception(e)
@@ -0,0 +1,486 @@
# Python imports
import json
# Django imports
from django.db.models import (
Case,
Count,
F,
Q,
Sum,
FloatField,
Value,
When,
)
from django.db import models
from django.db.models.functions import Cast, Concat
from django.utils import timezone
# Module imports
from plane.db.models import (
Cycle,
CycleIssue,
Issue,
Project,
)
from plane.utils.analytics_plot import burndown_plot
from plane.bgtasks.issue_activities_task import issue_activity
from plane.utils.host import base_host
def transfer_cycle_issues(
slug,
project_id,
cycle_id,
new_cycle_id,
request,
user_id,
):
"""
Transfer incomplete issues from one cycle to another and create progress snapshot.
Args:
slug: Workspace slug
project_id: Project ID
cycle_id: Source cycle ID
new_cycle_id: Destination cycle ID
request: HTTP request object
user_id: User ID performing the transfer
Returns:
dict: Response data with success or error message
"""
# Get the new cycle
new_cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, pk=new_cycle_id
).first()
# Check if new cycle is already completed
if new_cycle.end_date is not None and new_cycle.end_date < timezone.now():
return {
"success": False,
"error": "The cycle where the issues are transferred is already completed",
}
# Get the old cycle with issue counts
old_cycle = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, pk=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle",
filter=Q(
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__deleted_at__isnull=True,
issue_cycle__issue__deleted_at__isnull=True,
),
)
)
.annotate(
completed_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
cancelled_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
started_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
unstarted_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
.annotate(
backlog_issues=Count(
"issue_cycle__issue__state__group",
filter=Q(
issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False,
issue_cycle__issue__deleted_at__isnull=True,
issue_cycle__deleted_at__isnull=True,
),
)
)
)
old_cycle = old_cycle.first()
if old_cycle is None:
return {
"success": False,
"error": "Source cycle not found",
}
# Check if project uses estimates
estimate_type = Project.objects.filter(
workspace__slug=slug,
pk=project_id,
estimate__isnull=False,
estimate__type="points",
).exists()
# Initialize estimate distribution variables
assignee_estimate_distribution = []
label_estimate_distribution = []
estimate_completion_chart = {}
if estimate_type:
assignee_estimate_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset",
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(
assignees__avatar_asset__isnull=True,
then="assignees__avatar",
),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# Assignee estimate distribution serialization
assignee_estimate_distribution = [
{
"display_name": item["display_name"],
"assignee_id": (
str(item["assignee_id"]) if item["assignee_id"] else None
),
"avatar_url": item.get("avatar_url"),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in assignee_estimate_data
]
label_distribution_data = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.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(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_estimates=Sum(
Cast("estimate_point__value", FloatField()),
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
estimate_completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="points",
cycle_id=cycle_id,
)
# Label estimate distribution serialization
label_estimate_distribution = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_estimates": item["total_estimates"],
"completed_estimates": item["completed_estimates"],
"pending_estimates": item["pending_estimates"],
}
for item in label_distribution_data
]
# Get the assignee distribution
assignee_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(display_name=F("assignees__display_name"))
.annotate(assignee_id=F("assignees__id"))
.annotate(
avatar_url=Case(
# If `avatar_asset` exists, use it to generate the asset URL
When(
assignees__avatar_asset__isnull=False,
then=Concat(
Value("/api/assets/v2/static/"),
"assignees__avatar_asset",
Value("/"),
),
),
# If `avatar_asset` is None, fall back to using `avatar` field directly
When(assignees__avatar_asset__isnull=True, then="assignees__avatar"),
default=Value(None),
output_field=models.CharField(),
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
total_issues=Count("id", filter=Q(archived_at__isnull=True, is_draft=False))
)
.annotate(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("display_name")
)
# Assignee distribution serialized
assignee_distribution_data = [
{
"display_name": item["display_name"],
"assignee_id": (str(item["assignee_id"]) if item["assignee_id"] else None),
"avatar_url": item.get("avatar_url"),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in assignee_distribution
]
# Get the label distribution
label_distribution = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id,
issue_cycle__deleted_at__isnull=True,
workspace__slug=slug,
project_id=project_id,
)
.annotate(label_name=F("labels__name"))
.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(
completed_issues=Count(
"id",
filter=Q(
completed_at__isnull=False,
archived_at__isnull=True,
is_draft=False,
),
)
)
.annotate(
pending_issues=Count(
"id",
filter=Q(
completed_at__isnull=True,
archived_at__isnull=True,
is_draft=False,
),
)
)
.order_by("label_name")
)
# Label distribution serialization
label_distribution_data = [
{
"label_name": item["label_name"],
"color": item["color"],
"label_id": (str(item["label_id"]) if item["label_id"] else None),
"total_issues": item["total_issues"],
"completed_issues": item["completed_issues"],
"pending_issues": item["pending_issues"],
}
for item in label_distribution
]
# Generate completion chart
completion_chart = burndown_plot(
queryset=old_cycle,
slug=slug,
project_id=project_id,
plot_type="issues",
cycle_id=cycle_id,
)
# Get the current cycle and save progress snapshot
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,
"completed_issues": old_cycle.completed_issues,
"cancelled_issues": old_cycle.cancelled_issues,
"started_issues": old_cycle.started_issues,
"unstarted_issues": old_cycle.unstarted_issues,
"backlog_issues": old_cycle.backlog_issues,
"distribution": {
"labels": label_distribution_data,
"assignees": assignee_distribution_data,
"completion_chart": completion_chart,
},
"estimate_distribution": (
{}
if not estimate_type
else {
"labels": label_estimate_distribution,
"assignees": assignee_estimate_distribution,
"completion_chart": estimate_completion_chart,
}
),
}
current_cycle.save(update_fields=["progress_snapshot"])
# Get issues to transfer (only incomplete issues)
cycle_issues = CycleIssue.objects.filter(
cycle_id=cycle_id,
project_id=project_id,
workspace__slug=slug,
issue__archived_at__isnull=True,
issue__is_draft=False,
issue__state__group__in=["backlog", "unstarted", "started"],
)
updated_cycles = []
update_cycle_issue_activity = []
for cycle_issue in cycle_issues:
cycle_issue.cycle_id = new_cycle_id
updated_cycles.append(cycle_issue)
update_cycle_issue_activity.append(
{
"old_cycle_id": str(cycle_id),
"new_cycle_id": str(new_cycle_id),
"issue_id": str(cycle_issue.issue_id),
}
)
# Bulk update cycle issues
cycle_issues = CycleIssue.objects.bulk_update(
updated_cycles, ["cycle_id"], batch_size=100
)
# Capture Issue Activity
issue_activity.delay(
type="cycle.activity.created",
requested_data=json.dumps({"cycles_list": []}),
actor_id=str(user_id),
issue_id=None,
project_id=str(project_id),
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": [],
}
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return {"success": True}
@@ -169,12 +169,16 @@ class IssueExportSchema(ExportSchema):
def prepare_cycle_start_date(self, i):
cycles_dict = self.context.get("cycles_dict") or {}
last_cycle = cycles_dict.get(i.id)
return last_cycle.cycle.start_date if last_cycle else None
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)
return last_cycle.cycle.end_date if last_cycle else None
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:
@@ -0,0 +1,4 @@
from .core import core_config_variables
from .extended import extended_config_variables
instance_config_variables = [*core_config_variables, *extended_config_variables]
@@ -0,0 +1,233 @@
# Python imports
import os
authentication_config_variables = [
{
"key": "ENABLE_SIGNUP",
"value": os.environ.get("ENABLE_SIGNUP", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_EMAIL_PASSWORD",
"value": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
{
"key": "ENABLE_MAGIC_LINK_LOGIN",
"value": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"),
"category": "AUTHENTICATION",
"is_encrypted": False,
},
]
workspace_management_config_variables = [
{
"key": "DISABLE_WORKSPACE_CREATION",
"value": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
"category": "WORKSPACE_MANAGEMENT",
"is_encrypted": False,
},
]
google_config_variables = [
{
"key": "GOOGLE_CLIENT_ID",
"value": os.environ.get("GOOGLE_CLIENT_ID"),
"category": "GOOGLE",
"is_encrypted": False,
},
{
"key": "GOOGLE_CLIENT_SECRET",
"value": os.environ.get("GOOGLE_CLIENT_SECRET"),
"category": "GOOGLE",
"is_encrypted": True,
},
]
github_config_variables = [
{
"key": "GITHUB_CLIENT_ID",
"value": os.environ.get("GITHUB_CLIENT_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
{
"key": "GITHUB_CLIENT_SECRET",
"value": os.environ.get("GITHUB_CLIENT_SECRET"),
"category": "GITHUB",
"is_encrypted": True,
},
{
"key": "GITHUB_ORGANIZATION_ID",
"value": os.environ.get("GITHUB_ORGANIZATION_ID"),
"category": "GITHUB",
"is_encrypted": False,
},
]
gitlab_config_variables = [
{
"key": "GITLAB_HOST",
"value": os.environ.get("GITLAB_HOST"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_ID",
"value": os.environ.get("GITLAB_CLIENT_ID"),
"category": "GITLAB",
"is_encrypted": False,
},
{
"key": "GITLAB_CLIENT_SECRET",
"value": os.environ.get("GITLAB_CLIENT_SECRET"),
"category": "GITLAB",
"is_encrypted": True,
},
]
gitea_config_variables = [
{
"key": "IS_GITEA_ENABLED",
"value": os.environ.get("IS_GITEA_ENABLED", "0"),
"category": "GITEA",
"is_encrypted": False,
},
{
"key": "GITEA_HOST",
"value": os.environ.get("GITEA_HOST"),
"category": "GITEA",
"is_encrypted": False,
},
{
"key": "GITEA_CLIENT_ID",
"value": os.environ.get("GITEA_CLIENT_ID"),
"category": "GITEA",
"is_encrypted": False,
},
{
"key": "GITEA_CLIENT_SECRET",
"value": os.environ.get("GITEA_CLIENT_SECRET"),
"category": "GITEA",
"is_encrypted": True,
},
]
smtp_config_variables = [
{
"key": "ENABLE_SMTP",
"value": os.environ.get("ENABLE_SMTP", "0"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST",
"value": os.environ.get("EMAIL_HOST", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_USER",
"value": os.environ.get("EMAIL_HOST_USER", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_HOST_PASSWORD",
"value": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"category": "SMTP",
"is_encrypted": True,
},
{
"key": "EMAIL_PORT",
"value": os.environ.get("EMAIL_PORT", "587"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_FROM",
"value": os.environ.get("EMAIL_FROM", ""),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_TLS",
"value": os.environ.get("EMAIL_USE_TLS", "1"),
"category": "SMTP",
"is_encrypted": False,
},
{
"key": "EMAIL_USE_SSL",
"value": os.environ.get("EMAIL_USE_SSL", "0"),
"category": "SMTP",
"is_encrypted": False,
},
]
llm_config_variables = [
{
"key": "LLM_API_KEY",
"value": os.environ.get("LLM_API_KEY"),
"category": "AI",
"is_encrypted": True,
},
{
"key": "LLM_PROVIDER",
"value": os.environ.get("LLM_PROVIDER", "openai"),
"category": "AI",
"is_encrypted": False,
},
{
"key": "LLM_MODEL",
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
"category": "AI",
"is_encrypted": False,
},
# Deprecated, use LLM_MODEL
{
"key": "GPT_ENGINE",
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
"category": "AI",
"is_encrypted": False,
},
]
unsplash_config_variables = [
{
"key": "UNSPLASH_ACCESS_KEY",
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
"category": "UNSPLASH",
"is_encrypted": True,
},
]
intercom_config_variables = [
{
"key": "IS_INTERCOM_ENABLED",
"value": os.environ.get("IS_INTERCOM_ENABLED", "1"),
"category": "INTERCOM",
"is_encrypted": False,
},
{
"key": "INTERCOM_APP_ID",
"value": os.environ.get("INTERCOM_APP_ID", ""),
"category": "INTERCOM",
"is_encrypted": False,
},
]
core_config_variables = [
*authentication_config_variables,
*workspace_management_config_variables,
*google_config_variables,
*github_config_variables,
*gitlab_config_variables,
*gitea_config_variables,
*smtp_config_variables,
*llm_config_variables,
*unsplash_config_variables,
*intercom_config_variables,
]
@@ -0,0 +1 @@
extended_config_variables = []
+6 -6
View File
@@ -1,6 +1,6 @@
{
"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": "./dist/start.js",
@@ -20,11 +20,11 @@
"author": "Plane Software Inc.",
"dependencies": {
"@dotenvx/dotenvx": "^1.49.0",
"@hocuspocus/extension-database": "3.2.5",
"@hocuspocus/extension-logger": "3.2.5",
"@hocuspocus/extension-redis": "3.2.5",
"@hocuspocus/server": "3.2.5",
"@hocuspocus/transformer": "3.2.5",
"@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:*",
+60 -17
View File
@@ -6,22 +6,17 @@ import {
} from "@plane/editor";
// logger
import { logger } from "@plane/logger";
// lib
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 normalizeToError = (error: unknown, fallbackMessage: string) => {
if (error instanceof Error) {
return error;
}
const message = typeof error === "string" && error.trim().length > 0 ? error : fallbackMessage;
return new Error(message);
};
const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWithContext) => {
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// fetch details
@@ -38,12 +33,22 @@ const fetchDocument = async ({ context, documentName: pageId }: FetchPayloadWith
// return binary data
return binaryData;
} catch (error) {
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
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 }: StorePayloadWithContext) => {
const storeDocument = async ({
context,
state: pageBinaryData,
documentName: pageId,
instance,
}: StorePayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
@@ -57,8 +62,46 @@ const storeDocument = async ({ context, state: pageBinaryData, documentName: pag
};
await service.updateDescriptionBinary(pageId, payload);
} catch (error) {
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
throw normalizeToError(error, `Failed to update document: ${pageId}`);
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;
}
};
@@ -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<ForceCloseCommandData>(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<void> => {
// 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`);
}
};
+114 -10
View File
@@ -1,30 +1,134 @@
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
import { OutgoingMessage } from "@hocuspocus/server";
// 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 Error("Redis client not initialized");
throw new AppError("Redis client not initialized");
}
return redisClient;
};
export class Redis extends HocuspocusRedis {
private adminHandlers = new Map<AdminCommand, AdminCommandHandler>();
private readonly ADMIN_CHANNEL = "hocuspocus:admin";
constructor() {
super({ redis: getRedisClient() });
}
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
async onConfigure(payload: onConfigurePayload) {
await super.onConfigure(payload);
// Subscribe to admin channel
await new Promise<void>((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<T extends AdminCommandData = AdminCommandData>(
command: AdminCommand,
handler: AdminCommandHandler<T>
) {
this.adminHandlers.set(command, handler as AdminCommandHandler);
logger.info(`[Redis] Registered admin command: ${command}`);
}
/**
* Publish admin command to global channel
*/
public async publishAdminCommand<T extends AdminCommandData>(data: T): Promise<number> {
// 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<void>((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<number> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
return this.pub.publish(
// we're accessing the private method of the hocuspocus redis extension
this["pubKey"](documentName),
// we're accessing the private method of the hocuspocus redis extension to encode the message
this["encodeMessage"](message.toUint8Array())
);
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;
}
}
+50
View File
@@ -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();
};
+14 -6
View File
@@ -2,6 +2,7 @@
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
@@ -35,8 +36,10 @@ export const onAuthenticate = async ({
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
// If token parsing fails, fallback to request headers
logger.error("AUTH: Token parsing failed, using request headers:", 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) {
@@ -45,7 +48,9 @@ export const onAuthenticate = async ({
}
if (!cookie || !userId) {
throw new Error("Credentials not provided");
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
@@ -67,7 +72,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
const userService = new UserService();
const user = await userService.currentUser(cookie);
if (user.id !== userId) {
throw new Error("Authentication unsuccessful!");
throw new AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
}
return {
@@ -77,7 +82,10 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
},
};
} catch (error) {
logger.error("AUTH: Token parsing failed, using request headers:", error);
throw Error("Authentication unsuccessful!");
const appError = new AppError(error, {
context: { operation: "handleAuthentication" },
});
logger.error("Authentication failed", appError);
throw new AppError("Authentication unsuccessful", { code: appError.code });
}
};
+73
View File
@@ -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<string, any>;
constructor(messageOrError: string | unknown, data?: Partial<Omit<AppError, "name" | "message">>) {
// 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";
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { DocumentCollaborativeEvents, type TDocumentEventsServer } from "@plane/
* @param param0
*/
export const onStateless = async ({ payload, document }: onStatelessPayload) => {
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
const response = DocumentCollaborativeEvents[payload as TDocumentEventsServer]?.client;
if (response) {
document.broadcastStateless(response);
}
+10 -7
View File
@@ -59,13 +59,20 @@ export class RedisManager {
return;
}
// Configuration optimized for BOTH regular operations AND pub/sub
// HocuspocusRedis uses .duplicate() which inherits these settings
this.redisClient = new Redis(redisUrl, {
lazyConnect: true,
lazyConnect: false, // Connect immediately for reliability (duplicates inherit this)
keepAlive: 30000,
connectTimeout: 10000,
commandTimeout: 5000,
// enableOfflineQueue: false,
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
@@ -94,10 +101,6 @@ export class RedisManager {
this.isConnected = false;
});
// Connect to Redis
await this.redisClient.connect();
// Test the connection
await this.redisClient.ping();
logger.info("REDIS_MANAGER: Redis connection test successful");
} catch (error) {
-1
View File
@@ -39,7 +39,6 @@ export class Server {
const manager = HocusPocusServerManager.getInstance();
this.hocuspocusServer = await manager.initialize();
logger.info("SERVER: HocusPocus setup completed");
this.setupRoutes(this.hocuspocusServer);
this.setupNotFoundHandler();
} catch (error) {
+2 -3
View File
@@ -1,6 +1,6 @@
import axios, { AxiosInstance } from "axios";
import { logger } from "@plane/logger";
import { env } from "@/env";
import { AppError } from "@/lib/errors";
export abstract class APIService {
protected baseURL: string;
@@ -21,8 +21,7 @@ export abstract class APIService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
logger.error("AXIOS_ERROR:", error);
return Promise.reject(error);
return Promise.reject(new AppError(error));
}
);
}
+29 -9
View File
@@ -1,5 +1,7 @@
import { logger } from "@plane/logger";
import { TPage } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
@@ -21,7 +23,11 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
throw error;
const appError = new AppError(error, {
context: { operation: "fetchDetails", pageId },
});
logger.error("Failed to fetch page details", appError);
throw appError;
});
}
@@ -35,7 +41,11 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
throw error;
const appError = new AppError(error, {
context: { operation: "fetchDescriptionBinary", pageId },
});
logger.error("Failed to fetch page description binary", appError);
throw appError;
});
}
@@ -50,7 +60,7 @@ export abstract class PageCoreService extends APIService {
// Early abort check
if (abortSignal?.aborted) {
throw new DOMException("Aborted", "AbortError");
throw new AppError(new DOMException("Aborted", "AbortError"));
}
// Create an abort listener that will reject the pending promise
@@ -58,7 +68,7 @@ export abstract class PageCoreService extends APIService {
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
reject(new DOMException("Aborted", "AbortError"));
reject(new AppError(new DOMException("Aborted", "AbortError")));
};
abortSignal.addEventListener("abort", abortListener);
}
@@ -66,16 +76,22 @@ export abstract class PageCoreService extends APIService {
try {
return await Promise.race([
this.patch(`${this.basePath}/pages/${pageId}`, data, {
this.patch(`${this.basePath}/pages/${pageId}/`, data, {
headers: this.getHeader(),
signal: abortSignal,
})
.then((response) => response?.data)
.catch((error) => {
if (error.name === "AbortError") {
throw new DOMException("Aborted", "AbortError");
const appError = new AppError(error, {
context: { operation: "updatePageProperties", pageId },
});
if (appError.code === "ABORT_ERROR") {
throw appError;
}
throw error;
logger.error("Failed to update page properties", appError);
throw appError;
}),
abortPromise,
]);
@@ -93,7 +109,11 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
throw error;
const appError = new AppError(error, {
context: { operation: "updateDescriptionBinary", pageId },
});
logger.error("Failed to update page description binary", appError);
throw appError;
});
}
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { AppError } from "@/lib/errors";
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
// services
import { ProjectPageService } from "./project-page.service";
@@ -11,5 +12,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus
});
}
throw new Error(`Invalid document type ${documentType} provided.`);
throw new AppError(`Invalid document type ${documentType} provided.`);
};
@@ -1,3 +1,4 @@
import { AppError } from "@/lib/errors";
import { PageService } from "./extended.service";
interface ProjectPageServiceParams {
@@ -13,9 +14,9 @@ export class ProjectPageService extends PageService {
constructor(params: ProjectPageServiceParams) {
super();
const { workspaceSlug, projectId } = params;
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
// validate cookie
if (!params.cookie) throw new Error("Cookie is required.");
if (!params.cookie) throw new AppError("Cookie is required.");
// set cookie
this.setHeader("Cookie", params.cookie);
// set base path
+7 -1
View File
@@ -1,6 +1,8 @@
// types
import { logger } from "@plane/logger";
import type { IUser } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "@/services/api.service";
export class UserService extends APIService {
@@ -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;
});
}
}
+33 -19
View File
@@ -1,8 +1,9 @@
// eslint-disable-next-line import/order
import { setupSentry } from "./instrument";
setupSentry();
// eslint-disable-next-line import/order
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { Server } from "./server";
let server: Server;
@@ -20,28 +21,41 @@ async function startServer() {
startServer();
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", async (err: Error) => {
logger.error(`UNHANDLED REJECTION!`, err);
// Handle process signals
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
try {
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// process.exit(1);
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);
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", async (err: Error) => {
logger.error(`UNCAUGHT EXCEPTION!`, err);
process.on("SIGINT", async () => {
logger.info("Received SIGINT signal. Killing node process...");
try {
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// process.exit(1);
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);
});
+143
View File
@@ -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<T extends AdminCommandData = AdminCommandData> = (data: T) => Promise<void> | 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, string> = {
[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.";
}
+38
View File
@@ -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);
}
};
+34
View File
@@ -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<boolean> => {
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;
}
};
+18 -80
View File
@@ -1,83 +1,21 @@
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 {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
getBinaryDataFromRichTextEditorHTMLString,
} from "@plane/editor";
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
// plane types
import { TDocumentPayload } from "@plane/types";
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
type TArgs = {
document_html: string;
variant: "rich" | "document";
};
export const convertHTMLDocumentToAllFormats = (args: TArgs): TDocumentPayload => {
const { document_html, variant } = args;
if (variant === "rich") {
const contentBinary = getBinaryDataFromRichTextEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(contentBinary);
return {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
}
if (variant === "document") {
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(document_html);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(contentBinary);
return {
description: contentJSON,
description_html: contentHTML,
description_binary: contentBinaryEncoded,
};
}
throw new Error(`Invalid variant provided: ${variant}`);
};
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);
export const generateTitleProsemirrorJson = (text: string) => {
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
};
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): Uint8Array => {
// convert HTML to JSON
const contentJSON = generateJSON(descriptionHTML ?? "<p></p>", DOCUMENT_EDITOR_EXTENSIONS);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(documentEditorSchema, contentJSON, "default");
// convert Y.Doc to Uint8Array format
return Y.encodeStateAsUpdate(transformedData);
};
@@ -24,6 +24,7 @@ import GithubLightLogo from "/public/logos/github-black.png";
import GithubDarkLogo from "/public/logos/github-dark.svg";
import GitlabLogo from "/public/logos/gitlab-logo.svg";
import GoogleLogo from "/public/logos/google-logo.svg";
import GiteaLogo from "/public/logos/gitea-logo.svg";
// local imports
import { TermsAndConditions } from "../terms-and-conditions";
import { AuthBanner } from "./auth-banner";
@@ -39,8 +40,7 @@ export const AuthRoot: FC = observer(() => {
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;
const error_code = searchParams.get("error_code") || undefined;
const nextPath = searchParams.get("next_path") || undefined;
const next_path = searchParams.get("next_path");
const next_path = searchParams.get("next_path") || undefined;
// states
const [authMode, setAuthMode] = useState<EAuthModes>(EAuthModes.SIGN_UP);
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
@@ -92,7 +92,12 @@ export const AuthRoot: FC = observer(() => {
const isMagicLoginEnabled = config?.is_magic_login_enabled || false;
const isEmailPasswordEnabled = config?.is_email_password_enabled || false;
const isOAuthEnabled =
(config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
(config &&
(config?.is_google_enabled ||
config?.is_github_enabled ||
config?.is_gitlab_enabled ||
config?.is_gitea_enabled)) ||
false;
// submit handler- email verification
const handleEmailVerification = async (data: IEmailCheckData) => {
@@ -189,6 +194,15 @@ export const AuthRoot: FC = observer(() => {
},
enabled: config?.is_gitlab_enabled,
},
{
id: "gitea",
text: `${content} with Gitea`,
icon: <Image src={GiteaLogo} height={18} width={18} alt="Gitea Logo" />,
onClick: () => {
window.location.assign(`${API_BASE_URL}/auth/gitea/${next_path ? `?next_path=${next_path}` : ``}`);
},
enabled: config?.is_gitea_enabled,
},
];
return (
@@ -205,7 +219,7 @@ export const AuthRoot: FC = observer(() => {
<AuthUniqueCodeForm
mode={authMode}
email={email}
nextPath={nextPath}
nextPath={next_path}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
@@ -219,7 +233,7 @@ export const AuthRoot: FC = observer(() => {
isPasswordAutoset={isPasswordAutoset}
isSMTPConfigured={isSMTPConfigured}
email={email}
nextPath={nextPath}
nextPath={next_path}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
@@ -1,7 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { CalendarCheck2 } from "lucide-react";
import { DueDatePropertyIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// helpers
@@ -33,7 +33,7 @@ export const IssueBlockDate = observer((props: Props) => {
"border-[0.5px] border-custom-border-300": shouldShowBorder,
})}
>
<CalendarCheck2 className="size-3 flex-shrink-0" />
<DueDatePropertyIcon className="size-3 flex-shrink-0" />
{formattedDate ? formattedDate : "No Date"}
</div>
</Tooltip>
@@ -1,7 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { Tags } from "lucide-react";
import { LabelPropertyIcon } from "@plane/propel/icons";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
// hooks
@@ -25,7 +25,7 @@ export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }:
<div
className={`flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs border-[0.5px] border-custom-border-300`}
>
<Tags className="h-3.5 w-3.5" strokeWidth={2} />
<LabelPropertyIcon className="h-3.5 w-3.5" strokeWidth={2} />
{shouldShowLabel && <span>No Labels</span>}
</div>
</Tooltip>
@@ -3,7 +3,7 @@
import { observer } from "mobx-react";
// icons
import type { LucideIcon } from "lucide-react";
import { Users } from "lucide-react";
import { MembersPropertyIcon } from "@plane/propel/icons";
// plane ui
import { Avatar, AvatarGroup } from "@plane/ui";
// plane utils
@@ -49,7 +49,11 @@ export const ButtonAvatars: React.FC<AvatarProps> = observer((props: AvatarProps
}
}
return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 mx-[4px] flex-shrink-0" />;
return Icon ? (
<Icon className="h-3 w-3 flex-shrink-0" />
) : (
<MembersPropertyIcon className="h-3 w-3 mx-[4px] flex-shrink-0" />
);
});
export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => {
@@ -1,13 +1,22 @@
import type { LucideProps } from "lucide-react";
import { List, Kanban } from "lucide-react";
import type { TIssueLayout } from "@plane/constants";
import { ListLayoutIcon, BoardLayoutIcon } from "@plane/propel/icons";
import type { ISvgIcons } from "@plane/propel/icons";
export const IssueLayoutIcon = ({
layout,
size,
...props
}: { layout: TIssueLayout; size?: number } & Omit<ISvgIcons, "width" | "height">) => {
const iconProps = {
...props,
...(size && { width: size, height: size }),
};
export const IssueLayoutIcon = ({ layout, ...props }: { layout: TIssueLayout } & LucideProps) => {
switch (layout) {
case "list":
return <List {...props} />;
return <ListLayoutIcon {...iconProps} />;
case "kanban":
return <Kanban {...props} />;
return <BoardLayoutIcon {...iconProps} />;
default:
return null;
}
@@ -2,10 +2,9 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { CalendarCheck2, Signal } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons";
import { StatePropertyIcon, StateGroupIcon, PriorityPropertyIcon, DueDatePropertyIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn, getIssuePriorityFilters } from "@plane/utils";
// components
@@ -66,7 +65,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = observer(({ issueDet
<div className={`space-y-2 ${mode === "full" ? "pt-3" : ""}`}>
<div className="flex items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<DoubleCircleIcon className="size-4 flex-shrink-0" />
<StatePropertyIcon className="size-4 flex-shrink-0" />
<span>State</span>
</div>
<div className="w-3/4 flex items-center gap-1.5 py-0.5 text-sm">
@@ -77,7 +76,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = observer(({ issueDet
<div className="flex items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<Signal className="size-4 flex-shrink-0" />
<PriorityPropertyIcon className="size-4 flex-shrink-0" />
<span>Priority</span>
</div>
<div className="w-3/4">
@@ -106,7 +105,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = observer(({ issueDet
<div className="flex items-center gap-3 h-8">
<div className="flex items-center gap-1 w-1/4 flex-shrink-0 text-sm text-custom-text-300">
<CalendarCheck2 className="size-4 flex-shrink-0" />
<DueDatePropertyIcon className="size-4 flex-shrink-0" />
<span>Due date</span>
</div>
<div>
@@ -116,7 +115,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = observer(({ issueDet
"text-red-500": shouldHighlightIssueDueDate(issueDetails.target_date, state?.group),
})}
>
<CalendarCheck2 className="size-3" />
<DueDatePropertyIcon className="size-3" />
{renderFormattedDate(issueDetails.target_date)}
</div>
) : (
-9
View File
@@ -1,5 +1,3 @@
import DOMPurify from "dompurify";
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
const fallbackCopyTextToClipboard = (text: string) => {
@@ -50,13 +48,6 @@ export const checkEmailValidity = (email: string): boolean => {
return isEmailValid;
};
export const isEmptyHtmlString = (htmlString: string, allowedHTMLTags: string[] = []) => {
// Remove HTML tags using regex
const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: allowedHTMLTags });
// Trim the string and check if it's empty
return cleanText.trim() === "";
};
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -31,7 +31,6 @@
"axios": "catalog:",
"clsx": "^2.0.0",
"date-fns": "^4.1.0",
"dompurify": "^3.0.11",
"dotenv": "^16.3.1",
"lodash-es": "catalog:",
"lowlight": "^2.9.0",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 640 640" width="32" height="32"><path d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8 17.2 8.3 27.1 13 27.1 13l-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12" style="fill:#fff"/><path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2c-5.5-2.6-11.1-5.3-16.6-7.9 0-36.4-.1-109.2-.1-109.2-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6M125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1m300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1" style="fill:#609926"/><path d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3 4.7-9.7 9.4-19.3 14.1-29-4.1-2-8.1-4-12.2-6.1-4.8 9.8-9.7 19.7-14.5 29.5-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8z" style="fill:#609926"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -6,20 +6,18 @@ import { useRouter } from "next/navigation";
// plane package imports
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
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";
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { getAnalyticsTabs } from "@/plane-web/components/analytics/tabs";
type Props = {
@@ -46,9 +44,6 @@ const AnalyticsPage = observer((props: Props) => {
const { currentWorkspace } = useWorkspace();
const { allowPermissions } = useUserPermissions();
// helper hooks
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
// permissions
const canPerformEmptyStateActions = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
@@ -96,22 +91,20 @@ const AnalyticsPage = observer((props: Props) => {
/>
</div>
) : (
<DetailedEmptyState
title={t("workspace_analytics.empty_state.general.title")}
description={t("workspace_analytics.empty_state.general.description")}
assetPath={resolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("workspace_analytics.empty_state.general.primary_button.text")}
title={t("workspace_analytics.empty_state.general.primary_button.comic.title")}
description={t("workspace_analytics.empty_state.general.primary_button.comic.description")}
onClick={() => {
<EmptyStateDetailed
assetKey="project"
title={t("workspace_projects.empty_state.no_projects.title")}
description={t("workspace_projects.empty_state.no_projects.description")}
actions={[
{
label: "Create a project",
onClick: () => {
toggleCreateProjectModal(true);
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON });
}}
disabled={!canPerformEmptyStateActions}
/>
}
},
disabled: !canPerformEmptyStateActions,
},
]}
/>
)}
</>
@@ -1,26 +1,33 @@
"use client";
import { CommandPalette } from "@/components/command-palette";
import { observer } from "mobx-react";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
// plane web components
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
import { ProjectAppSidebar } from "./_sidebar";
const WorkspaceLayoutContent = observer(({ children }: { children: React.ReactNode }) => (
<>
<ProjectsAppPowerKProvider />
<WorkspaceAuthWrapper>
<div className="relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<div id="full-screen-portal" className="inset-0 absolute w-full" />
<div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{children}
</main>
</div>
</div>
</WorkspaceAuthWrapper>
</>
));
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
<AuthenticationWrapper>
<CommandPalette />
<WorkspaceAuthWrapper>
<div className="relative flex flex-col h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<div id="full-screen-portal" className="inset-0 absolute w-full" />
<div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{children}
</main>
</div>
</div>
</WorkspaceAuthWrapper>
<WorkspaceLayoutContent>{children}</WorkspaceLayoutContent>
</AuthenticationWrapper>
);
}
@@ -1,9 +1,12 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import useSWR from "swr";
// ui
import { Banner } from "@plane/propel/banner";
import { Button } from "@plane/propel/button";
import { ArchiveIcon } from "@plane/propel/icons";
import { Loader } from "@plane/ui";
// components
import { PageHead } from "@/components/core/page-title";
@@ -16,6 +19,7 @@ import { useProject } from "@/hooks/store/use-project";
const ArchivedIssueDetailsPage = observer(() => {
// router
const { workspaceSlug, projectId, archivedIssueId } = useParams();
const router = useRouter();
// states
// hooks
const {
@@ -62,18 +66,35 @@ const ArchivedIssueDetailsPage = observer(() => {
</div>
</Loader>
) : (
<div className="flex h-full overflow-hidden">
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
{workspaceSlug && projectId && archivedIssueId && (
<IssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={archivedIssueId.toString()}
is_archived
/>
)}
<>
<Banner
variant="warning"
title="This work item has been archived. Visit the Archives section to restore it."
icon={<ArchiveIcon className="size-4" />}
action={
<Button
variant="neutral-primary"
size="sm"
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/`)}
>
Go to archives
</Button>
}
className="border-b border-custom-border-200"
/>
<div className="flex h-full overflow-hidden">
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
{workspaceSlug && projectId && archivedIssueId && (
<IssueDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={archivedIssueId.toString()}
is_archived
/>
)}
</div>
</div>
</div>
</>
)}
</>
);
@@ -1,12 +1,14 @@
"use client";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
import { ChevronDown } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CalendarLayoutIcon, BoardLayoutIcon, ListLayoutIcon } from "@plane/propel/icons";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
@@ -20,12 +22,12 @@ import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
const SUPPORTED_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 },
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: ListLayoutIcon },
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: BoardLayoutIcon },
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: CalendarLayoutIcon },
];
export const CycleIssuesMobileHeader = () => {
export const CycleIssuesMobileHeader = observer(() => {
// router
const { workspaceSlug, projectId, cycleId } = useParams();
// states
@@ -151,4 +153,4 @@ export const CycleIssuesMobileHeader = () => {
</div>
</>
);
};
});
@@ -1,9 +1,10 @@
"use client";
import type React from "react";
import { observer } from "mobx-react";
// ui
import { GanttChartSquare, LayoutGrid, List } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ISvgIcons } from "@plane/propel/icons";
import { TimelineLayoutIcon, GridLayoutIcon, ListLayoutIcon } from "@plane/propel/icons";
// plane package imports
import type { TCycleLayoutOptions } from "@plane/types";
import { CustomMenu } from "@plane/ui";
@@ -13,22 +14,22 @@ import { useProject } from "@/hooks/store/use-project";
const CYCLE_VIEW_LAYOUTS: {
key: TCycleLayoutOptions;
icon: LucideIcon;
icon: React.FC<ISvgIcons>;
title: string;
}[] = [
{
key: "list",
icon: List,
icon: ListLayoutIcon,
title: "List layout",
},
{
key: "board",
icon: LayoutGrid,
icon: GridLayoutIcon,
title: "Gallery layout",
},
{
key: "gantt",
icon: GanttChartSquare,
icon: TimelineLayoutIcon,
title: "Timeline layout",
},
];
@@ -45,7 +46,7 @@ export const CyclesListMobileHeader = observer(() => {
// placement="bottom-start"
customButton={
<span className="flex items-center gap-2">
<List className="h-4 w-4" />
<ListLayoutIcon className="h-4 w-4" />
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>
</span>
}
@@ -6,6 +6,7 @@ import { useParams } from "next/navigation";
// plane imports
import { EUserPermissionsLevel, CYCLE_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { TCycleFilters } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// components
@@ -15,7 +16,6 @@ import { PageHead } from "@/components/core/page-title";
import { CycleAppliedFiltersList } from "@/components/cycles/applied-filters";
import { CyclesView } from "@/components/cycles/cycles-view";
import { CycleCreateUpdateModal } from "@/components/cycles/modal";
import { ComicBoxButton } from "@/components/empty-state/comic-box-button";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
// hooks
@@ -96,22 +96,19 @@ const ProjectCyclesPage = observer(() => {
/>
{totalCycles === 0 ? (
<div className="h-full place-items-center">
<DetailedEmptyState
title={t("project_cycles.empty_state.general.title")}
description={t("project_cycles.empty_state.general.description")}
assetPath={resolvedPath}
customPrimaryButton={
<ComicBoxButton
label={t("project_cycles.empty_state.general.primary_button.text")}
title={t("project_cycles.empty_state.general.primary_button.comic.title")}
description={t("project_cycles.empty_state.general.primary_button.comic.description")}
data-ph-element={CYCLE_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON}
onClick={() => {
setCreateModal(true);
}}
disabled={!hasMemberLevelPermission}
/>
}
<EmptyStateDetailed
assetKey="cycle"
title={t("project_empty_state.cycles.title")}
description={t("project_empty_state.cycles.description")}
actions={[
{
label: t("project_empty_state.cycles.cta_primary"),
onClick: () => setCreateModal(true),
variant: "primary",
disabled: !hasMemberLevelPermission,
"data-ph-element": CYCLE_TRACKER_ELEMENTS.EMPTY_STATE_ADD_BUTTON,
},
]}
/>
</div>
) : (
@@ -4,10 +4,11 @@ import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
import { ChevronDown } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CalendarLayoutIcon, BoardLayoutIcon, ListLayoutIcon } from "@plane/propel/icons";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
@@ -21,9 +22,9 @@ import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
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 },
{ key: "list", i18n_title: "issue.layouts.list", icon: ListLayoutIcon },
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: BoardLayoutIcon },
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: CalendarLayoutIcon },
];
export const ModuleIssuesMobileHeader = observer(() => {
@@ -1,7 +1,7 @@
"use client";
import { CommandPalette } from "@/components/command-palette";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
import { SettingsHeader } from "@/components/settings/header";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
@@ -10,7 +10,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
return (
<AuthenticationWrapper>
<WorkspaceAuthWrapper>
<CommandPalette />
<ProjectsAppPowerKProvider />
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{/* Header */}
@@ -12,7 +12,7 @@ import { SettingsSidebar } from "@/components/settings/sidebar";
import { useUserPermissions } from "@/hooks/store/user";
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
const ICONS = {
export const WORKSPACE_SETTINGS_ICONS = {
general: Building,
members: Users,
export: ArrowUpToLine,
@@ -30,7 +30,7 @@ export const WorkspaceActionIcons = ({
className?: string;
}) => {
if (type === undefined) return null;
const Icon = ICONS[type as keyof typeof ICONS];
const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS];
if (!Icon) return null;
return <Icon size={size} className={className} strokeWidth={2} />;
};
@@ -8,9 +8,9 @@ import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel, WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
import { WebhookSettingsLoader } from "@/components/ui/loader/settings/web-hook";
@@ -20,7 +20,6 @@ import { captureClick } from "@/helpers/event-tracker.helper";
import { useWebhook } from "@/hooks/store/use-webhook";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const WebhooksListPage = observer(() => {
// states
@@ -35,7 +34,6 @@ const WebhooksListPage = observer(() => {
const { currentWorkspace } = useWorkspace();
// derived values
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/webhooks" });
useSWR(
workspaceSlug && canPerformWorkspaceAdminActions ? `WEBHOOKS_LIST_${workspaceSlug}` : null,
@@ -90,21 +88,23 @@ const WebhooksListPage = observer(() => {
) : (
<div className="flex h-full w-full flex-col">
<div className="h-full w-full flex items-center justify-center">
<DetailedEmptyState
className="!p-0"
title=""
description=""
assetPath={resolvedPath}
size="md"
primaryButton={{
text: t("workspace_settings.settings.webhooks.add_webhook"),
onClick: () => {
captureClick({
elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON,
});
setShowCreateWebhookModal(true);
<EmptyStateCompact
assetKey="webhook"
title={t("settings_empty_state.webhooks.title")}
description={t("settings_empty_state.webhooks.description")}
actions={[
{
label: t("settings_empty_state.webhooks.cta_primary"),
onClick: () => {
captureClick({
elementName: WORKSPACE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_WEBHOOK_BUTTON,
});
setShowCreateWebhookModal(true);
},
},
}}
]}
align="start"
rootClassName="py-20"
/>
</div>
</div>
@@ -7,18 +7,17 @@ import useSWR from "swr";
import { PROFILE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// component
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { APITokenService } from "@plane/services";
import { CreateApiTokenModal } from "@/components/api-token/modal/create-token-modal";
import { ApiTokenListItem } from "@/components/api-token/token-list-item";
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { SettingsHeading } from "@/components/settings/heading";
import { APITokenSettingsLoader } from "@/components/ui/loader/settings/api-token";
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
// store hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
const apiTokenService = new APITokenService();
@@ -30,8 +29,6 @@ const ApiTokensPage = observer(() => {
const { t } = useTranslation();
// store hooks
const { currentWorkspace } = useWorkspace();
// derived values
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" });
const { data: tokens } = useSWR(API_TOKENS_LIST, () => apiTokenService.list());
@@ -70,7 +67,7 @@ const ApiTokensPage = observer(() => {
</div>
</>
) : (
<div className="flex h-full w-full flex-col">
<div className="flex h-full w-full flex-col py-">
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
@@ -84,24 +81,26 @@ const ApiTokensPage = observer(() => {
},
}}
/>
<div className="h-full w-full flex items-center justify-center">
<DetailedEmptyState
title=""
description=""
assetPath={resolvedPath}
className="w-full !p-0 justify-center mx-auto"
size="md"
primaryButton={{
text: t("workspace_settings.settings.api_tokens.add_token"),
<EmptyStateCompact
assetKey="token"
assetClassName="size-20"
title={t("settings_empty_state.tokens.title")}
description={t("settings_empty_state.tokens.description")}
actions={[
{
label: t("settings_empty_state.tokens.cta_primary"),
onClick: () => {
captureClick({
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON,
});
setIsCreateTokenModalOpen(true);
},
}}
/>
</div>
},
]}
align="start"
rootClassName="py-20"
/>
</div>
)}
</section>
@@ -6,11 +6,11 @@ import { useParams } from "next/navigation";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { ProjectFeaturesList } from "@/components/project/settings/features-list";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { ProjectFeaturesList } from "@/plane-web/components/projects/settings/features-list";
const FeaturesSettingsPage = observer(() => {
const { workspaceSlug, projectId } = useParams();
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Metadata, Viewport } from "next";
import { PreloadResources } from "./layout.preload";
// styles
import "@/styles/command-pallette.css";
import "@/styles/power-k.css";
import "@/styles/emoji.css";
import "@plane/propel/styles/react-day-picker";
+2 -3
View File
@@ -1,9 +1,8 @@
"use client";
import type { ReactNode } from "react";
// components
import { CommandPalette } from "@/components/command-palette";
// wrappers
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
// layout
import { ProfileLayoutSidebar } from "./sidebar";
@@ -17,7 +16,7 @@ export default function ProfileSettingsLayout(props: Props) {
return (
<>
<CommandPalette />
<ProjectsAppPowerKProvider />
<AuthenticationWrapper>
<div className="relative flex h-full w-full overflow-hidden rounded-lg border border-custom-border-200">
<ProfileLayoutSidebar />
@@ -93,7 +93,7 @@ export const commandGroups: TCommandGroups = {
if (!!projectId && page?.project_ids?.includes(projectId)) redirectProjectId = projectId;
return redirectProjectId
? `/${page?.workspace__slug}/projects/${redirectProjectId}/pages/${page?.id}`
: `/${page?.workspace__slug}/pages/${page?.id}`;
: `/${page?.workspace__slug}/wiki/${page?.id}`;
},
title: "Pages",
},
@@ -1,3 +1,2 @@
export * from "./actions";
export * from "./modals";
export * from "./helpers";
@@ -1,3 +0,0 @@
export * from "./workspace-level";
export * from "./project-level";
export * from "./issue-level";
@@ -15,21 +15,23 @@ import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
export type TIssueLevelModalsProps = {
projectId: string | undefined;
issueId: string | undefined;
export type TWorkItemLevelModalsProps = {
workItemIdentifier: string | undefined;
};
export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) => {
const { projectId, issueId } = props;
export const WorkItemLevelModals: FC<TWorkItemLevelModalsProps> = observer((props) => {
const { workItemIdentifier } = props;
// router
const { workspaceSlug, cycleId, moduleId } = useParams();
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
const {
issue: { getIssueById },
issue: { getIssueById, getIssueIdByIdentifier },
} = useIssueDetail();
// derived values
const workItemId = workItemIdentifier ? getIssueIdByIdentifier(workItemIdentifier) : undefined;
const workItemDetails = workItemId ? getIssueById(workItemId) : undefined;
const { removeIssue: removeEpic } = useIssuesActions(EIssuesStoreType.EPIC);
const { removeIssue: removeWorkItem } = useIssuesActions(EIssuesStoreType.PROJECT);
@@ -44,13 +46,12 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
createWorkItemAllowedProjectIds,
} = useCommandPalette();
// derived values
const issueDetails = issueId ? getIssueById(issueId) : undefined;
const { fetchSubIssues: fetchSubWorkItems } = useIssueDetail();
const { fetchSubIssues: fetchEpicSubWorkItems } = useIssueDetail(EIssueServiceType.EPICS);
const handleDeleteIssue = async (workspaceSlug: string, projectId: string, issueId: string) => {
try {
const isEpic = issueDetails?.is_epic;
const isEpic = workItemDetails?.is_epic;
const deleteAction = isEpic ? removeEpic : removeWorkItem;
const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`;
@@ -62,10 +63,10 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
};
const handleCreateIssueSubmit = async (newIssue: TIssue) => {
if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== issueDetails?.id) return;
if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== workItemDetails?.id) return;
const fetchAction = issueDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems;
await fetchAction(workspaceSlug?.toString(), newIssue.project_id, issueDetails.id);
const fetchAction = workItemDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems;
await fetchAction(workspaceSlug?.toString(), newIssue.project_id, workItemDetails.id);
};
const getCreateIssueModalData = () => {
@@ -83,13 +84,15 @@ export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) =>
onSubmit={handleCreateIssueSubmit}
allowedProjectIds={createWorkItemAllowedProjectIds}
/>
{workspaceSlug && projectId && issueId && issueDetails && (
{workspaceSlug && workItemId && workItemDetails && workItemDetails.project_id && (
<DeleteIssueModal
handleClose={() => toggleDeleteIssueModal(false)}
isOpen={isDeleteIssueModalOpen}
data={issueDetails}
onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())}
isEpic={issueDetails?.is_epic}
data={workItemDetails}
onSubmit={() =>
handleDeleteIssue(workspaceSlug.toString(), workItemDetails.project_id!, workItemId?.toString())
}
isEpic={workItemDetails?.is_epic}
/>
)}
<BulkDeleteIssuesModal
@@ -0,0 +1,6 @@
// core
import type { TPowerKModalPageDetails } from "@/components/power-k/ui/modal/constants";
// local imports
import type { TPowerKPageTypeExtended } from "./types";
export const POWER_K_MODAL_PAGE_DETAILS_EXTENDED: Record<TPowerKPageTypeExtended, TPowerKModalPageDetails> = {};
@@ -0,0 +1,5 @@
import type { Params } from "next/dist/shared/lib/router/utils/route-matcher";
// local imports
import type { TPowerKContextTypeExtended } from "./types";
export const detectExtendedContextFromURL = (_params: Params): TPowerKContextTypeExtended | null => null;
@@ -0,0 +1,8 @@
// local imports
import type { TPowerKContextTypeExtended } from "../types";
type TArgs = {
activeContext: TPowerKContextTypeExtended | null;
};
export const useExtendedContextIndicator = (_args: TArgs): string | null => null;
@@ -0,0 +1 @@
export * from "./root";
@@ -0,0 +1,11 @@
// components
import type { TPowerKCommandConfig } from "@/components/power-k/core/types";
import type { ContextBasedActionsProps, TContextEntityMap } from "@/components/power-k/ui/pages/context-based";
// local imports
import type { TPowerKContextTypeExtended } from "../../types";
export const CONTEXT_ENTITY_MAP_EXTENDED: Record<TPowerKContextTypeExtended, TContextEntityMap> = {};
export const PowerKContextBasedActionsExtended: React.FC<ContextBasedActionsProps> = () => null;
export const usePowerKContextBasedExtendedActions = (): TPowerKCommandConfig[] => [];
@@ -0,0 +1,34 @@
"use client";
import { observer } from "mobx-react";
// plane types
import { StateGroupIcon } from "@plane/propel/icons";
import type { IState } from "@plane/types";
// components
import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item";
export type TPowerKProjectStatesMenuItemsProps = {
handleSelect: (stateId: string) => void;
projectId: string | undefined;
selectedStateId: string | undefined;
states: IState[];
workspaceSlug: string;
};
export const PowerKProjectStatesMenuItems: React.FC<TPowerKProjectStatesMenuItemsProps> = observer((props) => {
const { handleSelect, selectedStateId, states } = props;
return (
<>
{states.map((state) => (
<PowerKModalCommandItem
key={state.id}
iconNode={<StateGroupIcon stateGroup={state.group} color={state.color} className="shrink-0 size-3.5" />}
label={state.name}
isSelected={state.id === selectedStateId}
onSelect={() => handleSelect(state.id)}
/>
))}
</>
);
});
@@ -0,0 +1,36 @@
import { Command } from "cmdk";
import { Search } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// components
import type { TPowerKContext } from "@/components/power-k/core/types";
// plane web imports
import { PowerKModalCommandItem } from "@/components/power-k/ui/modal/command-item";
export type TPowerKModalNoSearchResultsCommandProps = {
context: TPowerKContext;
searchTerm: string;
updateSearchTerm: (value: string) => void;
};
export const PowerKModalNoSearchResultsCommand: React.FC<TPowerKModalNoSearchResultsCommandProps> = (props) => {
const { updateSearchTerm } = props;
// translation
const { t } = useTranslation();
return (
<Command.Group>
<PowerKModalCommandItem
icon={Search}
value="no-results"
label={
<p className="flex items-center gap-2">
{t("power_k.search_menu.no_results")}{" "}
<span className="shrink-0 text-sm text-custom-text-300">{t("power_k.search_menu.clear_search")}</span>
</p>
}
onSelect={() => updateSearchTerm("")}
/>
</Command.Group>
);
};
@@ -0,0 +1,10 @@
"use client";
// components
import type { TPowerKSearchResultGroupDetails } from "@/components/power-k/ui/modal/search-results-map";
// local imports
import type { TPowerKSearchResultsKeysExtended } from "../types";
type TSearchResultsGroupsMapExtended = Record<TPowerKSearchResultsKeysExtended, TPowerKSearchResultGroupDetails>;
export const SEARCH_RESULTS_GROUPS_MAP_EXTENDED: TSearchResultsGroupsMapExtended = {};
@@ -0,0 +1,5 @@
export type TPowerKContextTypeExtended = never;
export type TPowerKPageTypeExtended = never;
export type TPowerKSearchResultsKeysExtended = never;
@@ -1,19 +1,18 @@
import type { FC } from "react";
import {
CalendarCheck2,
CalendarClock,
CalendarDays,
LayersIcon,
Link2,
Paperclip,
Signal,
Tag,
Triangle,
Users,
} from "lucide-react";
import { CalendarDays, LayersIcon, Link2, Paperclip } from "lucide-react";
// types
import type { ISvgIcons } from "@plane/propel/icons";
import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons";
import {
CycleIcon,
StatePropertyIcon,
ModuleIcon,
MembersPropertyIcon,
DueDatePropertyIcon,
EstimatePropertyIcon,
LabelPropertyIcon,
PriorityPropertyIcon,
StartDatePropertyIcon,
} from "@plane/propel/icons";
import type { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types";
// components
import {
@@ -66,16 +65,16 @@ export const getScopeMemberIds = ({ isWorkspaceLevel, projectId }: TGetColumns):
export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined;
export const SpreadSheetPropertyIconMap: Record<string, FC<ISvgIcons>> = {
Users: Users,
MembersPropertyIcon: MembersPropertyIcon,
CalenderDays: CalendarDays,
CalenderCheck2: CalendarCheck2,
Triangle: Triangle,
Tag: Tag,
DueDatePropertyIcon: DueDatePropertyIcon,
EstimatePropertyIcon: EstimatePropertyIcon,
LabelPropertyIcon: LabelPropertyIcon,
ModuleIcon: ModuleIcon,
ContrastIcon: CycleIcon,
Signal: Signal,
CalendarClock: CalendarClock,
DoubleCircleIcon: DoubleCircleIcon,
PriorityPropertyIcon: PriorityPropertyIcon,
StartDatePropertyIcon: StartDatePropertyIcon,
StatePropertyIcon: StatePropertyIcon,
Link2: Link2,
Paperclip: Paperclip,
LayersIcon: LayersIcon,
@@ -0,0 +1 @@
export { ProjectFeaturesList } from "@/components/project/settings/features-list";
+3 -3
View File
@@ -1,5 +1,5 @@
import { CircleDot, CopyPlus, XCircle } from "lucide-react";
import { RelatedIcon } from "@plane/propel/icons";
import { CircleDot, XCircle } from "lucide-react";
import { RelatedIcon, DuplicatePropertyIcon } from "@plane/propel/icons";
import type { TRelationObject } from "@/components/issues/issue-detail-widgets/relations";
import type { TIssueRelationTypes } from "../../types";
@@ -17,7 +17,7 @@ export const ISSUE_RELATION_OPTIONS: Record<TIssueRelationTypes, TRelationObject
key: "duplicate",
i18n_label: "issue.relation.duplicate",
className: "bg-custom-background-80 text-custom-text-200",
icon: (size) => <CopyPlus size={size} className="text-custom-text-200" />,
icon: (size) => <DuplicatePropertyIcon width={size} height={size} className="text-custom-text-200" />,
placeholder: "None",
},
blocked_by: {

Some files were not shown because too many files have changed in this diff Show More