Compare commits

..

1 Commits

500 changed files with 5575 additions and 18028 deletions
-3
View File
@@ -102,6 +102,3 @@ dev-editor
storybook-static
CLAUDE.md
AGENTS.md
temp/
@@ -1,210 +0,0 @@
"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>
</>
);
};
@@ -1,10 +0,0 @@
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}</>;
}
@@ -1,104 +0,0 @@
"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 === "1" ? "active" : "disabled"}.`,
message: () => `GitHub authentication is now ${value ? "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 === "1" ? "active" : "disabled"}.`,
message: () => `GitLab authentication is now ${value ? "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 === "1" ? "active" : "disabled"}.`,
message: () => `Google authentication is now ${value ? "active" : "disabled"}.`,
},
error: {
title: "Error",
@@ -12,7 +12,6 @@ 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";
@@ -20,7 +19,6 @@ 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";
@@ -82,13 +80,6 @@ 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",
@@ -1,58 +0,0 @@
"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.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "1.1.0",
"version": "1.0.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -54
View File
@@ -1,5 +1,4 @@
# Third party imports
import random
from rest_framework import serializers
# Module imports
@@ -25,47 +24,6 @@ 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 = [
@@ -86,10 +44,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 = [
@@ -99,7 +57,6 @@ class ProjectCreateSerializer(BaseSerializer):
"updated_at",
"created_by",
"updated_by",
"logo_props",
]
def validate(self, data):
@@ -129,16 +86,6 @@ 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
+423 -94
View File
@@ -12,7 +12,13 @@ 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
@@ -41,7 +47,7 @@ from plane.db.models import (
ProjectMember,
UserFavorite,
)
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.analytics_plot import burndown_plot
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -195,9 +201,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=timezone.now(), end_date__gte=timezone.now()
)
queryset = queryset.filter(start_date__lte=timezone.now(), end_date__gte=timezone.now())
data = CycleSerializer(
queryset,
many=True,
@@ -254,9 +258,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
# Incomplete Cycles
if cycle_view == "incomplete":
queryset = queryset.filter(
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True)
)
queryset = queryset.filter(Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True))
return self.paginate(
request=request,
queryset=(queryset),
@@ -302,17 +304,11 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
Create a new development cycle with specified name, description, and date range.
Supports external ID tracking for integration purposes.
"""
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
):
serializer = CycleCreateSerializer(
data=request.data, context={"request": request}
)
serializer = CycleCreateSerializer(data=request.data, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -355,9 +351,7 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{
"error": "Both start date and end date are either required or are to be null"
},
{"error": "Both start date and end date are either required or are to be null"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -505,9 +499,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
"""
cycle = Cycle.objects.get(workspace__slug=slug, project_id=project_id, pk=pk)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
if cycle.archived_at:
return Response(
@@ -520,20 +512,14 @@ class CycleDetailAPIEndpoint(BaseAPIView):
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
else:
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
},
{"error": "The Cycle has already been completed so it cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleUpdateSerializer(
cycle, data=request.data, partial=True, context={"request": request}
)
serializer = CycleUpdateSerializer(cycle, data=request.data, partial=True, context={"request": request})
if serializer.is_valid():
if (
request.data.get("external_id")
@@ -541,9 +527,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
and Cycle.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get(
"external_source", cycle.external_source
),
external_source=request.data.get("external_source", cycle.external_source),
external_id=request.data.get("external_id"),
).exists()
):
@@ -600,11 +584,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
status=status.HTTP_403_FORBIDDEN,
)
cycle_issues = list(
CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list(
"issue", flat=True
)
)
cycle_issues = list(CycleIssue.objects.filter(cycle_id=self.kwargs.get("pk")).values_list("issue", flat=True))
issue_activity.delay(
type="cycle.activity.deleted",
@@ -624,9 +604,7 @@ class CycleDetailAPIEndpoint(BaseAPIView):
# Delete the cycle
cycle.delete()
# Delete the user favorite cycle
UserFavorite.objects.filter(
entity_type="cycle", entity_identifier=pk, project_id=project_id
).delete()
UserFavorite.objects.filter(entity_type="cycle", entity_identifier=pk, project_id=project_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -764,9 +742,7 @@ 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(
@@ -785,9 +761,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Move a completed cycle to archived status for historical tracking.
Only cycles that have ended can be archived.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
if cycle.end_date >= timezone.now():
return Response(
{"error": "Only completed cycles can be archived"},
@@ -818,9 +792,7 @@ class CycleArchiveUnarchiveAPIEndpoint(BaseAPIView):
Restore an archived cycle to active status, making it available for regular use.
The cycle will reappear in active cycle lists.
"""
cycle = Cycle.objects.get(
pk=cycle_id, project_id=project_id, workspace__slug=slug
)
cycle = Cycle.objects.get(pk=cycle_id, project_id=project_id, workspace__slug=slug)
cycle.archived_at = None
cycle.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -883,9 +855,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
# List
order_by = request.GET.get("order_by", "created_at")
issues = (
Issue.issue_objects.filter(
issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True
)
Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id, issue_cycle__deleted_at__isnull=True)
.annotate(
sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id"))
.order_by()
@@ -922,9 +892,7 @@ 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(
@@ -954,13 +922,10 @@ 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(
@@ -972,13 +937,9 @@ 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))
@@ -1029,9 +990,7 @@ class CycleIssueListCreateAPIEndpoint(BaseAPIView):
current_instance=json.dumps(
{
"updated_cycle_issues": update_cycle_issue_activity,
"created_cycle_issues": serializers.serialize(
"json", created_records
),
"created_cycle_issues": serializers.serialize("json", created_records),
}
),
epoch=int(timezone.now().timestamp()),
@@ -1107,9 +1066,7 @@ class CycleIssueDetailAPIEndpoint(BaseAPIView):
cycle_id=cycle_id,
issue_id=issue_id,
)
serializer = CycleIssueSerializer(
cycle_issue, fields=self.fields, expand=self.expand
)
serializer = CycleIssueSerializer(cycle_issue, fields=self.fields, expand=self.expand)
return Response(serializer.data, status=status.HTTP_200_OK)
@cycle_docs(
@@ -1214,34 +1171,406 @@ class TransferCycleIssueAPIEndpoint(BaseAPIView):
{"error": "New Cycle Id is required"},
status=status.HTTP_400_BAD_REQUEST,
)
old_cycle = Cycle.objects.get(
workspace__slug=slug,
project_id=project_id,
pk=cycle_id,
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,
),
)
)
)
# 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 old cycle is not completed yet"},
status=status.HTTP_400_BAD_REQUEST,
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=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")
)
# Call the utility function to handle the transfer
result = transfer_cycle_issues(
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,
new_cycle_id=new_cycle_id,
request=request,
user_id=self.request.user.id,
)
# Handle the result
if result.get("success"):
return Response({"message": "Success"}, status=status.HTTP_200_OK)
else:
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():
return Response(
{"error": result.get("error")},
{"error": "The cycle where the issues are transferred is already completed"},
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)
+18 -22
View File
@@ -987,12 +987,12 @@ class LabelDetailAPIEndpoint(LabelListCreateAPIEndpoint):
serializer = LabelCreateUpdateSerializer(label, data=request.data, partial=True)
if serializer.is_valid():
if (
request.data.get("external_id")
and request.data.get("external_source")
str(request.data.get("external_id"))
and (label.external_id != str(request.data.get("external_id")))
and Label.objects.filter(
project_id=project_id,
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_source=request.data.get("external_source", label.external_source),
external_id=request.data.get("external_id"),
)
.exclude(id=pk)
@@ -1695,27 +1695,23 @@ class IssueActivityDetailAPIEndpoint(BaseAPIView):
Retrieve details of a specific activity.
Excludes comment, vote, reaction, and draft activities.
"""
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")
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,
)
.order_by(request.GET.get("order_by", "created_at"))
.first()
)
.filter(project__archived_at__isnull=True)
.select_related("actor", "workspace", "issue", "project")
).order_by(request.GET.get("order_by", "created_at"))
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,
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,
)
+1 -3
View File
@@ -871,8 +871,6 @@ 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",
@@ -880,7 +878,7 @@ class ModuleIssueDetailAPIEndpoint(BaseAPIView):
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps({"module_name": module_name}),
current_instance=None,
epoch=int(timezone.now().timestamp()),
)
return Response(status=status.HTTP_204_NO_CONTENT)
-2
View File
@@ -17,7 +17,6 @@ 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,
@@ -39,5 +38,4 @@ urlpatterns = [
*api_urls,
*webhook_urls,
*timezone_urls,
*exporter_urls,
]
-12
View File
@@ -1,12 +0,0 @@
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,6 +7,7 @@ from plane.app.views import (
IssueLinkViewSet,
IssueAttachmentEndpoint,
CommentReactionViewSet,
ExportIssuesEndpoint,
IssueActivityEndpoint,
IssueArchiveViewSet,
IssueCommentViewSet,
@@ -140,6 +141,12 @@ 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(
+428 -111
View File
@@ -51,7 +51,6 @@ 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
@@ -97,9 +96,7 @@ class CycleViewSet(BaseViewSet):
.prefetch_related(
Prefetch(
"issue_cycle__issue__assignees",
queryset=User.objects.only(
"avatar_asset", "first_name", "id"
).distinct(),
queryset=User.objects.only("avatar_asset", "first_name", "id").distinct(),
)
)
.prefetch_related(
@@ -150,8 +147,7 @@ class CycleViewSet(BaseViewSet):
.annotate(
status=Case(
When(
Q(start_date__lte=current_time_in_utc)
& Q(end_date__gte=current_time_in_utc),
Q(start_date__lte=current_time_in_utc) & Q(end_date__gte=current_time_in_utc),
then=Value("CURRENT"),
),
When(start_date__gt=current_time_in_utc, then=Value("UPCOMING")),
@@ -170,11 +166,7 @@ class CycleViewSet(BaseViewSet):
"issue_cycle__issue__assignees__id",
distinct=True,
filter=~Q(issue_cycle__issue__assignees__id__isnull=True)
& (
Q(
issue_cycle__issue__issue_assignee__deleted_at__isnull=True
)
),
& (Q(issue_cycle__issue__issue_assignee__deleted_at__isnull=True)),
),
Value([], output_field=ArrayField(UUIDField())),
)
@@ -205,9 +197,7 @@ class CycleViewSet(BaseViewSet):
# Current Cycle
if cycle_view == "current":
queryset = queryset.filter(
start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc
)
queryset = queryset.filter(start_date__lte=current_time_in_utc, end_date__gte=current_time_in_utc)
data = queryset.values(
# necessary fields
@@ -274,16 +264,10 @@ class CycleViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
if (
request.data.get("start_date", None) is None
and request.data.get("end_date", None) is None
) or (
request.data.get("start_date", None) is not None
and request.data.get("end_date", None) is not None
if (request.data.get("start_date", None) is None and request.data.get("end_date", None) is None) or (
request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None
):
serializer = CycleWriteSerializer(
data=request.data, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(data=request.data, context={"project_id": project_id})
if serializer.is_valid():
serializer.save(project_id=project_id, owned_by=request.user)
cycle = (
@@ -323,9 +307,7 @@ class CycleViewSet(BaseViewSet):
project_timezone = project.timezone
datetime_fields = ["start_date", "end_date"]
cycle = user_timezone_converter(
cycle, datetime_fields, project_timezone
)
cycle = user_timezone_converter(cycle, datetime_fields, project_timezone)
# Send the model activity
model_activity.delay(
@@ -341,17 +323,13 @@ class CycleViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
else:
return Response(
{
"error": "Both start date and end date are either required or are to be null"
},
{"error": "Both start date and end date are either required or are to be null"},
status=status.HTTP_400_BAD_REQUEST,
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
queryset = self.get_queryset().filter(
workspace__slug=slug, project_id=project_id, pk=pk
)
queryset = self.get_queryset().filter(workspace__slug=slug, project_id=project_id, pk=pk)
cycle = queryset.first()
if cycle.archived_at:
return Response(
@@ -359,29 +337,21 @@ class CycleViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
current_instance = json.dumps(
CycleSerializer(cycle).data, cls=DjangoJSONEncoder
)
current_instance = json.dumps(CycleSerializer(cycle).data, cls=DjangoJSONEncoder)
request_data = request.data
if cycle.end_date is not None and cycle.end_date < timezone.now():
if "sort_order" in request_data:
# Can only change sort order for a completed cycle``
request_data = {
"sort_order": request_data.get("sort_order", cycle.sort_order)
}
request_data = {"sort_order": request_data.get("sort_order", cycle.sort_order)}
else:
return Response(
{
"error": "The Cycle has already been completed so it cannot be edited"
},
{"error": "The Cycle has already been completed so it cannot be edited"},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = CycleWriteSerializer(
cycle, data=request.data, partial=True, context={"project_id": project_id}
)
serializer = CycleWriteSerializer(cycle, data=request.data, partial=True, context={"project_id": project_id})
if serializer.is_valid():
serializer.save()
cycle = queryset.values(
@@ -481,9 +451,7 @@ class CycleViewSet(BaseViewSet):
)
if data is None:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
queryset = queryset.first()
# Fetch the project timezone
@@ -505,11 +473,7 @@ 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",
@@ -560,9 +524,7 @@ class CycleDateCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
start_date = convert_to_utc(
date=str(start_date), project_id=project_id, is_start_date=True
)
start_date = convert_to_utc(date=str(start_date), project_id=project_id, is_start_date=True)
end_date = convert_to_utc(
date=str(end_date),
project_id=project_id,
@@ -635,23 +597,409 @@ class TransferCycleIssueEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST,
)
# Transfer cycle issues and create progress snapshot
result = transfer_cycle_issues(
slug=slug,
project_id=project_id,
cycle_id=cycle_id,
new_cycle_id=new_cycle_id,
request=request,
user_id=request.user.id,
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")
)
# Handle error response
if result.get("error"):
# 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,
)
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():
return Response(
{"error": result["error"]},
{"error": "The cycle where the issues are transferred is already completed"},
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)
@@ -666,12 +1014,8 @@ 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
)
@@ -695,13 +1039,9 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
class CycleProgressEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
).first()
cycle = Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id).first()
if not cycle:
return Response(
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
)
return Response({"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND)
aggregate_estimates = (
Issue.issue_objects.filter(
estimate_point__estimate__type="points",
@@ -747,9 +1087,7 @@ class CycleProgressEndpoint(BaseAPIView):
output_field=FloatField(),
)
),
total_estimate_points=Sum(
"value_as_float", default=Value(0), output_field=FloatField()
),
total_estimate_points=Sum("value_as_float", default=Value(0), output_field=FloatField()),
)
)
if cycle.progress_snapshot:
@@ -809,22 +1147,11 @@ class CycleProgressEndpoint(BaseAPIView):
return Response(
{
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"]
or 0,
"unstarted_estimate_points": aggregate_estimates[
"unstarted_estimate_point"
]
or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"]
or 0,
"cancelled_estimate_points": aggregate_estimates[
"cancelled_estimate_point"
]
or 0,
"completed_estimate_points": aggregate_estimates[
"completed_estimate_points"
]
or 0,
"backlog_estimate_points": aggregate_estimates["backlog_estimate_point"] or 0,
"unstarted_estimate_points": aggregate_estimates["unstarted_estimate_point"] or 0,
"started_estimate_points": aggregate_estimates["started_estimate_point"] or 0,
"cancelled_estimate_points": aggregate_estimates["cancelled_estimate_point"] or 0,
"completed_estimate_points": aggregate_estimates["completed_estimate_points"] or 0,
"total_estimate_points": aggregate_estimates["total_estimate_points"],
"backlog_issues": backlog_issues,
"total_issues": total_issues,
@@ -842,9 +1169,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
def get(self, request, slug, project_id, cycle_id):
analytic_type = request.GET.get("type", "issues")
cycle = (
Cycle.objects.filter(
workspace__slug=slug, project_id=project_id, id=cycle_id
)
Cycle.objects.filter(workspace__slug=slug, project_id=project_id, id=cycle_id)
.annotate(
total_issues=Count(
"issue_cycle__issue__id",
@@ -927,9 +1252,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
)
)
.values("display_name", "assignee_id", "avatar_url")
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -964,9 +1287,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_estimates=Sum(Cast("estimate_point__value", FloatField()))
)
.annotate(total_estimates=Sum(Cast("estimate_point__value", FloatField())))
.annotate(
completed_estimates=Sum(
Cast("estimate_point__value", FloatField()),
@@ -1068,11 +1389,7 @@ class CycleAnalyticsEndpoint(BaseAPIView):
.annotate(color=F("labels__color"))
.annotate(label_id=F("labels__id"))
.values("label_name", "color", "label_id")
.annotate(
total_issues=Count(
"label_id", filter=Q(archived_at__isnull=True, is_draft=False)
)
)
.annotate(total_issues=Count("label_id", filter=Q(archived_at__isnull=True, is_draft=False)))
.annotate(
completed_issues=Count(
"label_id",
+8 -17
View File
@@ -137,11 +137,7 @@ class PageViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=None,
page_id=serializer.data["id"],
)
page_transaction.delay(request.data, None, serializer.data["id"])
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@@ -172,8 +168,11 @@ class PageViewSet(BaseViewSet):
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page_description,
new_value=request.data,
old_value=json.dumps(
{"description_html": page_description},
cls=DjangoJSONEncoder,
),
page_id=page_id,
)
@@ -505,11 +504,7 @@ class PagesDescriptionViewSet(BaseViewSet):
if serializer.is_valid():
# Capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_description_html=request.data.get("description_html", "<p></p>"),
old_description_html=page.description_html,
page_id=page_id,
)
page_transaction.delay(new_value=request.data, old_value=existing_instance, page_id=page_id)
# Update the page using serializer
updated_page = serializer.save()
@@ -555,11 +550,7 @@ class PageDuplicateEndpoint(BaseAPIView):
updated_by_id=page.updated_by_id,
)
page_transaction.delay(
new_description_html=page.description_html,
old_description_html=None,
page_id=page.id,
)
page_transaction.delay({"description_html": page.description_html}, None, page.id)
# Copy the s3 objects uploaded in the page
copy_s3_objects_of_description_and_assets.delay(
+83 -158
View File
@@ -43,25 +43,22 @@ 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()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": 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()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
return (
Project.objects.filter(
q,
@@ -70,7 +67,6 @@ class GlobalSearchEndpoint(BaseAPIView):
archived_at__isnull=True,
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values("name", "id", "identifier", "workspace__slug")
)
@@ -78,15 +74,14 @@ class GlobalSearchEndpoint(BaseAPIView):
def filter_issues(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})
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,
@@ -111,9 +106,8 @@ class GlobalSearchEndpoint(BaseAPIView):
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
cycles = Cycle.objects.filter(
q,
@@ -126,20 +120,13 @@ class GlobalSearchEndpoint(BaseAPIView):
if workspace_search == "false" and project_id:
cycles = cycles.filter(project_id=project_id)
return (
cycles.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
return cycles.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
def filter_modules(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
modules = Module.objects.filter(
q,
@@ -152,20 +139,13 @@ class GlobalSearchEndpoint(BaseAPIView):
if workspace_search == "false" and project_id:
modules = modules.filter(project_id=project_id)
return (
modules.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_id", "project__identifier", "workspace__slug"
)
)
return modules.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
def filter_pages(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
pages = (
Page.objects.filter(
@@ -177,9 +157,7 @@ class GlobalSearchEndpoint(BaseAPIView):
)
.annotate(
project_ids=Coalesce(
ArrayAgg(
"projects__id", distinct=True, filter=~Q(projects__id=True)
),
ArrayAgg("projects__id", distinct=True, filter=~Q(projects__id=True)),
Value([], output_field=ArrayField(UUIDField())),
)
)
@@ -196,28 +174,19 @@ 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.order_by("-created_at")
.distinct()
.values(
"name", "id", "project_ids", "project_identifiers", "workspace__slug"
)
)
return pages.distinct().values("name", "id", "project_ids", "project_identifiers", "workspace__slug")
def filter_views(self, query, slug, project_id, workspace_search):
fields = ["name"]
q = Q()
if query:
for field in fields:
q |= Q(**{f"{field}__icontains": query})
for field in fields:
q |= Q(**{f"{field}__icontains": query})
issue_views = IssueView.objects.filter(
q,
@@ -230,57 +199,29 @@ class GlobalSearchEndpoint(BaseAPIView):
if workspace_search == "false" and project_id:
issue_views = issue_views.filter(project_id=project_id)
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]
)
return issue_views.distinct().values("name", "id", "project_id", "project__identifier", "workspace__slug")
def get(self, request, slug):
query = request.query_params.get("search", False)
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,
@@ -289,27 +230,13 @@ 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 entity in requested_entities:
func = MODELS_MAPPER.get(entity)
if func:
results[entity] = func(
query or None, slug, project_id, workspace_search
)
for model in MODELS_MAPPER.keys():
func = MODELS_MAPPER.get(model, None)
results[model] = func(query, slug, project_id, workspace_search)
return Response({"results": results}, status=status.HTTP_200_OK)
@@ -367,15 +294,29 @@ class SearchEndpoint(BaseAPIView):
.order_by("-created_at")
)
users = (
users
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
if issue_id:
issue_created_by = (
Issue.objects.filter(id=issue_id).values_list("created_by_id", flat=True).first()
)
users = (
users.filter(Q(role__gt=10) | Q(member_id=issue_created_by))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
else:
users = (
users.filter(Q(role__gt=10))
.distinct()
.values(
"member__avatar_url",
"member__display_name",
"member__id",
)
)
)
response_data["user_mention"] = list(users[:count])
@@ -389,15 +330,12 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
Q(project_projectmember__member=self.request.user) | Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
)
response_data["project"] = list(projects)
@@ -456,20 +394,16 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -587,9 +521,7 @@ class SearchEndpoint(BaseAPIView):
)
)
.order_by("-created_at")
.values(
"member__avatar_url", "member__display_name", "member__id"
)[:count]
.values("member__avatar_url", "member__display_name", "member__id")[:count]
)
response_data["user_mention"] = list(users)
@@ -603,15 +535,12 @@ class SearchEndpoint(BaseAPIView):
projects = (
Project.objects.filter(
q,
Q(project_projectmember__member=self.request.user)
| Q(network=2),
Q(project_projectmember__member=self.request.user) | Q(network=2),
workspace__slug=slug,
)
.order_by("-created_at")
.distinct()
.values(
"name", "id", "identifier", "logo_props", "workspace__slug"
)[:count]
.values("name", "id", "identifier", "logo_props", "workspace__slug")[:count]
)
response_data["project"] = list(projects)
@@ -668,20 +597,16 @@ class SearchEndpoint(BaseAPIView):
.annotate(
status=Case(
When(
Q(start_date__lte=timezone.now())
& Q(end_date__gte=timezone.now()),
Q(start_date__lte=timezone.now()) & Q(end_date__gte=timezone.now()),
then=Value("CURRENT"),
),
When(
start_date__gt=timezone.now(),
then=Value("UPCOMING"),
),
When(end_date__lt=timezone.now(), then=Value("COMPLETED")),
When(
end_date__lt=timezone.now(), then=Value("COMPLETED")
),
When(
Q(start_date__isnull=True)
& Q(end_date__isnull=True),
Q(start_date__isnull=True) & Q(end_date__isnull=True),
then=Value("DRAFT"),
),
default=Value("DRAFT"),
@@ -38,11 +38,9 @@ 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,8 +48,6 @@ 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"
@@ -1,171 +0,0 @@
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,10 +36,6 @@ from .views import (
SignInAuthSpaceEndpoint,
SignUpAuthSpaceEndpoint,
SignOutAuthSpaceEndpoint,
GiteaCallbackEndpoint,
GiteaOauthInitiateEndpoint,
GiteaCallbackSpaceEndpoint,
GiteaOauthInitiateSpaceEndpoint,
)
urlpatterns = [
@@ -133,17 +129,4 @@ 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,7 +5,6 @@ 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
@@ -18,8 +17,6 @@ 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 (
@@ -1,109 +0,0 @@
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)
@@ -1,100 +0,0 @@
# 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)
@@ -1154,7 +1154,10 @@ def create_comment_reaction_activity(
.values_list("id", "comment__id")
.first()
)
comment = IssueComment.objects.get(pk=comment_id, project_id=project_id)
comment = IssueComment.objects.filter(pk=comment_id, project_id=project_id).first()
if comment is None:
return
if comment is not None and comment_reaction_id is not None and comment_id is not None:
issue_activities.append(
IssueActivity(
+43 -105
View File
@@ -1,5 +1,5 @@
# Python imports
import logging
import json
# Django imports
from django.utils import timezone
@@ -7,134 +7,72 @@ from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# App imports
from celery import shared_task
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
from plane.utils.exception_logger import log_exception
logger = logging.getLogger("plane.worker")
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.
"""
def extract_components(value, tag):
try:
if not description_html:
return {component: [] for component in component_map.keys()}
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
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
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)
return mentions
except Exception:
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)
return []
@shared_task
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.
"""
def page_transaction(new_value, old_value, page_id):
try:
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
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)
old_value = json.loads(old_value) if old_value else {}
new_transactions = []
deleted_transaction_ids = set()
for component in component_map.keys():
old_entities = old_components[component]
new_entities = new_components[component]
# 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)
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_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)
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,
)
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(),
)
# Bulk insert and cleanup
if new_transactions:
PageLog.objects.bulk_create(
new_transactions, batch_size=50, ignore_conflicts=True
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
)
if deleted_transaction_ids:
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
# Delete the removed transactions
PageLog.objects.filter(transaction__in=deleted_transaction_ids).delete()
except Page.DoesNotExist:
return
except Exception as e:
+6 -4
View File
@@ -86,6 +86,7 @@ def get_issue_prefetches():
]
def save_webhook_log(
webhook: Webhook,
request_method: str,
@@ -97,9 +98,10 @@ 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),
@@ -121,7 +123,7 @@ def save_webhook_log(
logger.info("Webhook log saved successfully to mongo")
mongo_save_success = True
except Exception as e:
log_exception(e, warning=True)
log_exception(e)
logger.error(f"Failed to save webhook log: {e}")
mongo_save_success = False
@@ -132,7 +134,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, warning=True)
log_exception(e)
logger.error(f"Failed to save webhook log: {e}")
@@ -242,7 +244,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, warning=True)
log_exception(e)
logger.error(f"Failed to send email: {e}")
@@ -171,12 +171,8 @@ 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)
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 = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
@@ -50,7 +50,6 @@ class InstanceEndpoint(BaseAPIView):
IS_GITHUB_ENABLED,
GITHUB_APP_NAME,
IS_GITLAB_ENABLED,
IS_GITEA_ENABLED,
EMAIL_HOST,
ENABLE_MAGIC_LINK_LOGIN,
ENABLE_EMAIL_PASSWORD,
@@ -87,10 +86,6 @@ 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",
@@ -139,7 +134,6 @@ 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,7 +6,6 @@ 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):
@@ -22,7 +21,175 @@ class Command(BaseCommand):
if not os.environ.get(item):
raise CommandError(f"{item} env variable is required.")
for item in instance_config_variables:
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:
obj, created = InstanceConfiguration.objects.get_or_create(key=item.get("key"))
if created:
obj.category = item.get("category")
@@ -36,7 +203,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", "IS_GITEA_ENABLED"]
keys = ["IS_GOOGLE_ENABLED", "IS_GITHUB_ENABLED", "IS_GITLAB_ENABLED"]
if not InstanceConfiguration.objects.filter(key__in=keys).exists():
for key in keys:
if key == "IS_GOOGLE_ENABLED":
@@ -115,34 +282,6 @@ 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 = int(os.environ.get("SESSION_COOKIE_AGE", 604800))
SESSION_COOKIE_AGE = 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 = int(os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600))
ADMIN_SESSION_COOKIE_AGE = os.environ.get("ADMIN_SESSION_COOKIE_AGE", 3600)
# CSRF cookies
CSRF_COOKIE_SECURE = secure_origins
+5 -6
View File
@@ -4,9 +4,7 @@ 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
@@ -56,9 +54,7 @@ def validate_binary_data(data):
# Check for suspicious text patterns (HTML/JS)
try:
decoded_text = binary_data.decode("utf-8", errors="ignore")[:200]
if any(
pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS
):
if any(pattern in decoded_text.lower() for pattern in SUSPICIOUS_BINARY_PATTERNS):
return False, "Binary data contains suspicious content patterns"
except Exception:
pass # Binary data might not be decodable as text, which is fine
@@ -236,7 +232,10 @@ def validate_html_content(html_content: str):
summary = json.dumps(diff)
except Exception:
summary = str(diff)
logger.warning(f"HTML sanitization removals: {summary}")
log_exception(
f"HTML sanitization removals: {summary}",
warning=True,
)
return True, None, clean_html
except Exception as e:
log_exception(e)
@@ -1,486 +0,0 @@
# 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}
@@ -173,6 +173,7 @@ class IssueExportSchema(ExportSchema):
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)
@@ -1,4 +0,0 @@
from .core import core_config_variables
from .extended import extended_config_variables
instance_config_variables = [*core_config_variables, *extended_config_variables]
@@ -1,233 +0,0 @@
# 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,
]
@@ -1 +0,0 @@
extended_config_variables = []
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "1.1.0",
"version": "1.0.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": "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",
"@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",
"@plane/decorators": "workspace:*",
"@plane/editor": "workspace:*",
"@plane/logger": "workspace:*",
+17 -60
View File
@@ -6,17 +6,22 @@ import {
} from "@plane/editor";
// logger
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
// services
// lib
import { getPageService } from "@/services/page/handler";
// type
import type { FetchPayloadWithContext, StorePayloadWithContext } from "@/types";
import { ForceCloseReason, CloseCode } from "@/types/admin-commands";
import { broadcastError } from "@/utils/broadcast-error";
// force close utility
import { forceCloseDocumentAcrossServers } from "./force-close-handler";
const fetchDocument = async ({ context, documentName: pageId, instance }: FetchPayloadWithContext) => {
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) => {
try {
const service = getPageService(context.documentType, context);
// fetch details
@@ -33,22 +38,12 @@ const fetchDocument = async ({ context, documentName: pageId, instance }: FetchP
// return binary data
return binaryData;
} catch (error) {
const appError = new AppError(error, { context: { pageId } });
logger.error("Error in fetching document", appError);
// Broadcast error to frontend for user document types
await broadcastError(instance, pageId, "Unable to load the page. Please try refreshing.", "fetch", context);
throw appError;
logger.error("DATABASE_EXTENSION: Error in fetching document", error);
throw normalizeToError(error, `Failed to fetch document: ${pageId}`);
}
};
const storeDocument = async ({
context,
state: pageBinaryData,
documentName: pageId,
instance,
}: StorePayloadWithContext) => {
const storeDocument = async ({ context, state: pageBinaryData, documentName: pageId }: StorePayloadWithContext) => {
try {
const service = getPageService(context.documentType, context);
// convert binary data to all formats
@@ -62,46 +57,8 @@ const storeDocument = async ({
};
await service.updateDescriptionBinary(pageId, payload);
} catch (error) {
const appError = new AppError(error, { context: { pageId } });
logger.error("Error in updating document:", appError);
// Check error types
const isContentTooLarge = appError.statusCode === 413;
// Determine if we should disconnect and unload
const shouldDisconnect = isContentTooLarge;
// Determine error message and code
let errorMessage: string;
let errorCode: "content_too_large" | "page_locked" | "page_archived" | undefined;
if (isContentTooLarge) {
errorMessage = "Document is too large to save. Please reduce the content size.";
errorCode = "content_too_large";
} else {
errorMessage = "Unable to save the page. Please try again.";
}
// Broadcast error to frontend for user document types
await broadcastError(instance, pageId, errorMessage, "store", context, errorCode, shouldDisconnect);
// If we should disconnect, close connections and unload document
if (shouldDisconnect) {
// Map error code to ForceCloseReason with proper types
const reason =
errorCode === "content_too_large" ? ForceCloseReason.DOCUMENT_TOO_LARGE : ForceCloseReason.CRITICAL_ERROR;
const closeCode = errorCode === "content_too_large" ? CloseCode.DOCUMENT_TOO_LARGE : CloseCode.FORCE_CLOSE;
// force close connections and unload document
await forceCloseDocumentAcrossServers(instance, pageId, reason, closeCode);
// Don't throw after force close - document is already unloaded
// Throwing would cause hocuspocus's finally block to access the null document
return;
}
throw appError;
logger.error("DATABASE_EXTENSION: Error in updating document:", error);
throw normalizeToError(error, `Failed to update document: ${pageId}`);
}
};
@@ -1,203 +0,0 @@
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`);
}
};
+10 -114
View File
@@ -1,134 +1,30 @@
import { Redis as HocuspocusRedis } from "@hocuspocus/extension-redis";
import { OutgoingMessage, type onConfigurePayload } from "@hocuspocus/server";
import { logger } from "@plane/logger";
import { AppError } from "@/lib/errors";
import { OutgoingMessage } from "@hocuspocus/server";
// redis
import { redisManager } from "@/redis";
import { AdminCommand } from "@/types/admin-commands";
import type { AdminCommandData, AdminCommandHandler } from "@/types/admin-commands";
const getRedisClient = () => {
const redisClient = redisManager.getClient();
if (!redisClient) {
throw new AppError("Redis client not initialized");
throw new Error("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() });
}
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> {
public broadcastToDocument(documentName: string, payload: any): Promise<number> {
const stringPayload = typeof payload === "string" ? payload : JSON.stringify(payload);
const message = new OutgoingMessage(documentName).writeBroadcastStateless(stringPayload);
const emptyPrefix = Buffer.concat([Buffer.from([0])]);
const channel = this["pubKey"](documentName);
const encodedMessage = Buffer.concat([emptyPrefix, Buffer.from(message.toUint8Array())]);
const result = await this.pub.publishBuffer(channel, encodedMessage);
logger.info(`REDIS_EXTENSION: Published to ${documentName}, ${result} subscribers`);
return result;
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())
);
}
}
-50
View File
@@ -1,50 +0,0 @@
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();
};
+6 -14
View File
@@ -2,7 +2,6 @@
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
@@ -36,10 +35,8 @@ export const onAuthenticate = async ({
userId = parsedToken.id;
cookie = parsedToken.cookie;
} catch (error) {
const appError = new AppError(error, {
context: { operation: "onAuthenticate" },
});
logger.error("Token parsing failed, using request headers", appError);
// If token parsing fails, fallback to request headers
logger.error("AUTH: Token parsing failed, using request headers:", error);
} finally {
// If cookie is still not found, fallback to request headers
if (!cookie) {
@@ -48,9 +45,7 @@ export const onAuthenticate = async ({
}
if (!cookie || !userId) {
const appError = new AppError("Credentials not provided", { code: "AUTH_MISSING_CREDENTIALS" });
logger.error("Credentials not provided", appError);
throw appError;
throw new Error("Credentials not provided");
}
// set cookie in context, so it can be used throughout the ws connection
@@ -72,7 +67,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 AppError("Authentication unsuccessful: User ID mismatch", { code: "AUTH_USER_MISMATCH" });
throw new Error("Authentication unsuccessful!");
}
return {
@@ -82,10 +77,7 @@ export const handleAuthentication = async ({ cookie, userId }: { cookie: string;
},
};
} catch (error) {
const appError = new AppError(error, {
context: { operation: "handleAuthentication" },
});
logger.error("Authentication failed", appError);
throw new AppError("Authentication unsuccessful", { code: appError.code });
logger.error("AUTH: Token parsing failed, using request headers:", error);
throw Error("Authentication unsuccessful!");
}
};
-73
View File
@@ -1,73 +0,0 @@
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);
}
+7 -10
View File
@@ -59,20 +59,13 @@ 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: false, // Connect immediately for reliability (duplicates inherit this)
lazyConnect: true,
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
@@ -101,6 +94,10 @@ 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,6 +39,7 @@ 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) {
+3 -2
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,7 +21,8 @@ export abstract class APIService {
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
return Promise.reject(new AppError(error));
logger.error("AXIOS_ERROR:", error);
return Promise.reject(error);
}
);
}
+9 -29
View File
@@ -1,7 +1,5 @@
import { logger } from "@plane/logger";
import { TPage } from "@plane/types";
// services
import { AppError } from "@/lib/errors";
import { APIService } from "../api.service";
export type TPageDescriptionPayload = {
@@ -23,11 +21,7 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "fetchDetails", pageId },
});
logger.error("Failed to fetch page details", appError);
throw appError;
throw error;
});
}
@@ -41,11 +35,7 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "fetchDescriptionBinary", pageId },
});
logger.error("Failed to fetch page description binary", appError);
throw appError;
throw error;
});
}
@@ -60,7 +50,7 @@ export abstract class PageCoreService extends APIService {
// Early abort check
if (abortSignal?.aborted) {
throw new AppError(new DOMException("Aborted", "AbortError"));
throw new DOMException("Aborted", "AbortError");
}
// Create an abort listener that will reject the pending promise
@@ -68,7 +58,7 @@ export abstract class PageCoreService extends APIService {
const abortPromise = new Promise((_, reject) => {
if (abortSignal) {
abortListener = () => {
reject(new AppError(new DOMException("Aborted", "AbortError")));
reject(new DOMException("Aborted", "AbortError"));
};
abortSignal.addEventListener("abort", abortListener);
}
@@ -76,22 +66,16 @@ 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) => {
const appError = new AppError(error, {
context: { operation: "updatePageProperties", pageId },
});
if (appError.code === "ABORT_ERROR") {
throw appError;
if (error.name === "AbortError") {
throw new DOMException("Aborted", "AbortError");
}
logger.error("Failed to update page properties", appError);
throw appError;
throw error;
}),
abortPromise,
]);
@@ -109,11 +93,7 @@ export abstract class PageCoreService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "updateDescriptionBinary", pageId },
});
logger.error("Failed to update page description binary", appError);
throw appError;
throw error;
});
}
}
+1 -2
View File
@@ -1,4 +1,3 @@
import { AppError } from "@/lib/errors";
import type { HocusPocusServerContext, TDocumentTypes } from "@/types";
// services
import { ProjectPageService } from "./project-page.service";
@@ -12,5 +11,5 @@ export const getPageService = (documentType: TDocumentTypes, context: HocusPocus
});
}
throw new AppError(`Invalid document type ${documentType} provided.`);
throw new Error(`Invalid document type ${documentType} provided.`);
};
@@ -1,4 +1,3 @@
import { AppError } from "@/lib/errors";
import { PageService } from "./extended.service";
interface ProjectPageServiceParams {
@@ -14,9 +13,9 @@ export class ProjectPageService extends PageService {
constructor(params: ProjectPageServiceParams) {
super();
const { workspaceSlug, projectId } = params;
if (!workspaceSlug || !projectId) throw new AppError("Missing required fields.");
if (!workspaceSlug || !projectId) throw new Error("Missing required fields.");
// validate cookie
if (!params.cookie) throw new AppError("Cookie is required.");
if (!params.cookie) throw new Error("Cookie is required.");
// set cookie
this.setHeader("Cookie", params.cookie);
// set base path
+1 -7
View File
@@ -1,8 +1,6 @@
// 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 {
@@ -24,11 +22,7 @@ export class UserService extends APIService {
})
.then((response) => response?.data)
.catch((error) => {
const appError = new AppError(error, {
context: { operation: "currentUser" },
});
logger.error("Failed to fetch current user", appError);
throw appError;
throw error;
});
}
}
+19 -33
View File
@@ -1,9 +1,8 @@
// 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;
@@ -21,41 +20,28 @@ async function startServer() {
startServer();
// Handle process signals
process.on("SIGTERM", async () => {
logger.info("Received SIGTERM signal. Initiating graceful shutdown...");
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", async (err: Error) => {
logger.error(`UNHANDLED REJECTION!`, err);
try {
if (server) {
await server.destroy();
}
logger.info("Server shut down gracefully");
} catch (error) {
logger.error("Error during graceful shutdown:", error);
process.exit(1);
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// process.exit(1);
}
process.exit(0);
});
process.on("SIGINT", async () => {
logger.info("Received SIGINT signal. Killing node process...");
// Graceful shutdown on uncaught exception
process.on("uncaughtException", async (err: Error) => {
logger.error(`UNCAUGHT EXCEPTION!`, err);
try {
if (server) {
await server.destroy();
}
logger.info("Server shut down gracefully");
} catch (error) {
logger.error("Error during graceful shutdown:", error);
process.exit(1);
// if (server) {
// await server.destroy();
// }
} finally {
// logger.info("Exiting process...");
// 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
@@ -1,143 +0,0 @@
/**
* 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
@@ -1,38 +0,0 @@
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
@@ -1,34 +0,0 @@
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;
}
};
+80 -18
View File
@@ -1,21 +1,83 @@
export const generateTitleProsemirrorJson = (text: string) => {
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);
return {
type: "doc",
content: [
{
type: "heading",
attrs: { level: 1 },
...(text
? {
content: [
{
type: "text",
text,
},
],
}
: {}),
},
],
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
};
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,7 +24,6 @@ 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";
@@ -40,7 +39,8 @@ export const AuthRoot: FC = observer(() => {
const searchParams = useSearchParams();
const emailParam = searchParams.get("email") || undefined;
const error_code = searchParams.get("error_code") || undefined;
const next_path = searchParams.get("next_path") || undefined;
const nextPath = searchParams.get("next_path") || undefined;
const next_path = searchParams.get("next_path");
// states
const [authMode, setAuthMode] = useState<EAuthModes>(EAuthModes.SIGN_UP);
const [authStep, setAuthStep] = useState<EAuthSteps>(EAuthSteps.EMAIL);
@@ -92,12 +92,7 @@ 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 ||
config?.is_gitea_enabled)) ||
false;
(config && (config?.is_google_enabled || config?.is_github_enabled || config?.is_gitlab_enabled)) || false;
// submit handler- email verification
const handleEmailVerification = async (data: IEmailCheckData) => {
@@ -194,15 +189,6 @@ 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 (
@@ -219,7 +205,7 @@ export const AuthRoot: FC = observer(() => {
<AuthUniqueCodeForm
mode={authMode}
email={email}
nextPath={next_path}
nextPath={nextPath}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
@@ -233,7 +219,7 @@ export const AuthRoot: FC = observer(() => {
isPasswordAutoset={isPasswordAutoset}
isSMTPConfigured={isSMTPConfigured}
email={email}
nextPath={next_path}
nextPath={nextPath}
handleEmailClear={() => {
setEmail("");
setAuthStep(EAuthSteps.EMAIL);
@@ -1,7 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { DueDatePropertyIcon } from "@plane/propel/icons";
import { CalendarCheck2 } from "lucide-react";
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,
})}
>
<DueDatePropertyIcon className="size-3 flex-shrink-0" />
<CalendarCheck2 className="size-3 flex-shrink-0" />
{formattedDate ? formattedDate : "No Date"}
</div>
</Tooltip>
@@ -1,7 +1,7 @@
"use client";
import { observer } from "mobx-react";
import { LabelPropertyIcon } from "@plane/propel/icons";
import { Tags } from "lucide-react";
// 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`}
>
<LabelPropertyIcon className="h-3.5 w-3.5" strokeWidth={2} />
<Tags 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 { MembersPropertyIcon } from "@plane/propel/icons";
import { Users } from "lucide-react";
// plane ui
import { Avatar, AvatarGroup } from "@plane/ui";
// plane utils
@@ -49,11 +49,7 @@ export const ButtonAvatars: React.FC<AvatarProps> = observer((props: AvatarProps
}
}
return Icon ? (
<Icon className="h-3 w-3 flex-shrink-0" />
) : (
<MembersPropertyIcon className="h-3 w-3 mx-[4px] flex-shrink-0" />
);
return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 mx-[4px] flex-shrink-0" />;
});
export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => {
@@ -1,22 +1,13 @@
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 <ListLayoutIcon {...iconProps} />;
return <List {...props} />;
case "kanban":
return <BoardLayoutIcon {...iconProps} />;
return <Kanban {...props} />;
default:
return null;
}
@@ -2,9 +2,10 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { CalendarCheck2, Signal } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { StatePropertyIcon, StateGroupIcon, PriorityPropertyIcon, DueDatePropertyIcon } from "@plane/propel/icons";
import { DoubleCircleIcon, StateGroupIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { cn, getIssuePriorityFilters } from "@plane/utils";
// components
@@ -65,7 +66,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">
<StatePropertyIcon className="size-4 flex-shrink-0" />
<DoubleCircleIcon 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">
@@ -76,7 +77,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">
<PriorityPropertyIcon className="size-4 flex-shrink-0" />
<Signal className="size-4 flex-shrink-0" />
<span>Priority</span>
</div>
<div className="w-3/4">
@@ -105,7 +106,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">
<DueDatePropertyIcon className="size-4 flex-shrink-0" />
<CalendarCheck2 className="size-4 flex-shrink-0" />
<span>Due date</span>
</div>
<div>
@@ -115,7 +116,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = observer(({ issueDet
"text-red-500": shouldHighlightIssueDueDate(issueDetails.target_date, state?.group),
})}
>
<DueDatePropertyIcon className="size-3" />
<CalendarCheck2 className="size-3" />
{renderFormattedDate(issueDetails.target_date)}
</div>
) : (
+9
View File
@@ -1,3 +1,5 @@
import DOMPurify from "dompurify";
export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2");
const fallbackCopyTextToClipboard = (text: string) => {
@@ -48,6 +50,13 @@ 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);
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "space",
"version": "1.1.0",
"version": "1.0.0",
"private": true,
"license": "AGPL-3.0",
"scripts": {
@@ -31,6 +31,7 @@
"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
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

@@ -6,18 +6,20 @@ 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 = {
@@ -44,6 +46,9 @@ 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],
@@ -91,20 +96,22 @@ const AnalyticsPage = observer((props: Props) => {
/>
</div>
) : (
<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: () => {
<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={() => {
toggleCreateProjectModal(true);
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON });
},
disabled: !canPerformEmptyStateActions,
},
]}
}}
disabled={!canPerformEmptyStateActions}
/>
}
/>
)}
</>
@@ -1,33 +1,26 @@
"use client";
import { observer } from "mobx-react";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
import { CommandPalette } from "@/components/command-palette";
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>
<WorkspaceLayoutContent>{children}</WorkspaceLayoutContent>
<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>
</AuthenticationWrapper>
);
}
@@ -1,12 +1,9 @@
"use client";
import { observer } from "mobx-react";
import { useParams, useRouter } from "next/navigation";
import { useParams } 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";
@@ -19,7 +16,6 @@ import { useProject } from "@/hooks/store/use-project";
const ArchivedIssueDetailsPage = observer(() => {
// router
const { workspaceSlug, projectId, archivedIssueId } = useParams();
const router = useRouter();
// states
// hooks
const {
@@ -66,35 +62,18 @@ const ArchivedIssueDetailsPage = observer(() => {
</div>
</Loader>
) : (
<>
<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 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>
)}
</>
);
@@ -1,14 +1,12 @@
"use client";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { ChevronDown } from "lucide-react";
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { 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";
@@ -22,12 +20,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: ListLayoutIcon },
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: BoardLayoutIcon },
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: CalendarLayoutIcon },
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: List },
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban },
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar },
];
export const CycleIssuesMobileHeader = observer(() => {
export const CycleIssuesMobileHeader = () => {
// router
const { workspaceSlug, projectId, cycleId } = useParams();
// states
@@ -153,4 +151,4 @@ export const CycleIssuesMobileHeader = observer(() => {
</div>
</>
);
});
};
@@ -1,10 +1,9 @@
"use client";
import type React from "react";
import { observer } from "mobx-react";
// ui
import type { ISvgIcons } from "@plane/propel/icons";
import { TimelineLayoutIcon, GridLayoutIcon, ListLayoutIcon } from "@plane/propel/icons";
import { GanttChartSquare, LayoutGrid, List } from "lucide-react";
import type { LucideIcon } from "lucide-react";
// plane package imports
import type { TCycleLayoutOptions } from "@plane/types";
import { CustomMenu } from "@plane/ui";
@@ -14,22 +13,22 @@ import { useProject } from "@/hooks/store/use-project";
const CYCLE_VIEW_LAYOUTS: {
key: TCycleLayoutOptions;
icon: React.FC<ISvgIcons>;
icon: LucideIcon;
title: string;
}[] = [
{
key: "list",
icon: ListLayoutIcon,
icon: List,
title: "List layout",
},
{
key: "board",
icon: GridLayoutIcon,
icon: LayoutGrid,
title: "Gallery layout",
},
{
key: "gantt",
icon: TimelineLayoutIcon,
icon: GanttChartSquare,
title: "Timeline layout",
},
];
@@ -46,7 +45,7 @@ export const CyclesListMobileHeader = observer(() => {
// placement="bottom-start"
customButton={
<span className="flex items-center gap-2">
<ListLayoutIcon className="h-4 w-4" />
<List className="h-4 w-4" />
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>
</span>
}
@@ -6,7 +6,6 @@ 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
@@ -16,6 +15,7 @@ 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,19 +96,22 @@ const ProjectCyclesPage = observer(() => {
/>
{totalCycles === 0 ? (
<div className="h-full place-items-center">
<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,
},
]}
<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}
/>
}
/>
</div>
) : (
@@ -4,11 +4,10 @@ import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { ChevronDown } from "lucide-react";
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { 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";
@@ -22,9 +21,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: ListLayoutIcon },
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: BoardLayoutIcon },
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: CalendarLayoutIcon },
{ key: "list", i18n_title: "issue.layouts.list", icon: List },
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban },
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar },
];
export const ModuleIssuesMobileHeader = observer(() => {
@@ -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>
<ProjectsAppPowerKProvider />
<CommandPalette />
<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";
export const WORKSPACE_SETTINGS_ICONS = {
const ICONS = {
general: Building,
members: Users,
export: ArrowUpToLine,
@@ -30,7 +30,7 @@ export const WorkspaceActionIcons = ({
className?: string;
}) => {
if (type === undefined) return null;
const Icon = WORKSPACE_SETTINGS_ICONS[type as keyof typeof WORKSPACE_SETTINGS_ICONS];
const Icon = ICONS[type as keyof typeof 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,6 +20,7 @@ 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
@@ -34,6 +35,7 @@ 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,
@@ -88,23 +90,21 @@ const WebhooksListPage = observer(() => {
) : (
<div className="flex h-full w-full flex-col">
<div className="h-full w-full flex items-center justify-center">
<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);
},
<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);
},
]}
align="start"
rootClassName="py-20"
}}
/>
</div>
</div>
@@ -7,17 +7,18 @@ 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();
@@ -29,6 +30,8 @@ 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());
@@ -67,7 +70,7 @@ const ApiTokensPage = observer(() => {
</div>
</>
) : (
<div className="flex h-full w-full flex-col py-">
<div className="flex h-full w-full flex-col">
<SettingsHeading
title={t("account_settings.api_tokens.heading")}
description={t("account_settings.api_tokens.description")}
@@ -81,26 +84,24 @@ const ApiTokensPage = observer(() => {
},
}}
/>
<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"),
<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"),
onClick: () => {
captureClick({
elementName: PROFILE_SETTINGS_TRACKER_ELEMENTS.EMPTY_STATE_ADD_PAT_BUTTON,
});
setIsCreateTokenModalOpen(true);
},
},
]}
align="start"
rootClassName="py-20"
/>
}}
/>
</div>
</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/power-k.css";
import "@/styles/command-pallette.css";
import "@/styles/emoji.css";
import "@plane/propel/styles/react-day-picker";
+3 -2
View File
@@ -1,8 +1,9 @@
"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";
@@ -16,7 +17,7 @@ export default function ProfileSettingsLayout(props: Props) {
return (
<>
<ProjectsAppPowerKProvider />
<CommandPalette />
<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}/wiki/${page?.id}`;
: `/${page?.workspace__slug}/pages/${page?.id}`;
},
title: "Pages",
},
@@ -1,2 +1,3 @@
export * from "./actions";
export * from "./modals";
export * from "./helpers";
@@ -0,0 +1,3 @@
export * from "./workspace-level";
export * from "./project-level";
export * from "./issue-level";
@@ -15,23 +15,21 @@ import { useUser } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
export type TWorkItemLevelModalsProps = {
workItemIdentifier: string | undefined;
export type TIssueLevelModalsProps = {
projectId: string | undefined;
issueId: string | undefined;
};
export const WorkItemLevelModals: FC<TWorkItemLevelModalsProps> = observer((props) => {
const { workItemIdentifier } = props;
export const IssueLevelModals: FC<TIssueLevelModalsProps> = observer((props) => {
const { projectId, issueId } = props;
// router
const { workspaceSlug, cycleId, moduleId } = useParams();
const router = useAppRouter();
// store hooks
const { data: currentUser } = useUser();
const {
issue: { getIssueById, getIssueIdByIdentifier },
issue: { getIssueById },
} = 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);
@@ -46,12 +44,13 @@ export const WorkItemLevelModals: FC<TWorkItemLevelModalsProps> = observer((prop
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 = workItemDetails?.is_epic;
const isEpic = issueDetails?.is_epic;
const deleteAction = isEpic ? removeEpic : removeWorkItem;
const redirectPath = `/${workspaceSlug}/projects/${projectId}/${isEpic ? "epics" : "issues"}`;
@@ -63,10 +62,10 @@ export const WorkItemLevelModals: FC<TWorkItemLevelModalsProps> = observer((prop
};
const handleCreateIssueSubmit = async (newIssue: TIssue) => {
if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== workItemDetails?.id) return;
if (!workspaceSlug || !newIssue.project_id || !newIssue.id || newIssue.parent_id !== issueDetails?.id) return;
const fetchAction = workItemDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems;
await fetchAction(workspaceSlug?.toString(), newIssue.project_id, workItemDetails.id);
const fetchAction = issueDetails?.is_epic ? fetchEpicSubWorkItems : fetchSubWorkItems;
await fetchAction(workspaceSlug?.toString(), newIssue.project_id, issueDetails.id);
};
const getCreateIssueModalData = () => {
@@ -84,15 +83,13 @@ export const WorkItemLevelModals: FC<TWorkItemLevelModalsProps> = observer((prop
onSubmit={handleCreateIssueSubmit}
allowedProjectIds={createWorkItemAllowedProjectIds}
/>
{workspaceSlug && workItemId && workItemDetails && workItemDetails.project_id && (
{workspaceSlug && projectId && issueId && issueDetails && (
<DeleteIssueModal
handleClose={() => toggleDeleteIssueModal(false)}
isOpen={isDeleteIssueModalOpen}
data={workItemDetails}
onSubmit={() =>
handleDeleteIssue(workspaceSlug.toString(), workItemDetails.project_id!, workItemId?.toString())
}
isEpic={workItemDetails?.is_epic}
data={issueDetails}
onSubmit={() => handleDeleteIssue(workspaceSlug.toString(), projectId?.toString(), issueId?.toString())}
isEpic={issueDetails?.is_epic}
/>
)}
<BulkDeleteIssuesModal
@@ -1,6 +0,0 @@
// 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> = {};
@@ -1,5 +0,0 @@
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;
@@ -1,8 +0,0 @@
// local imports
import type { TPowerKContextTypeExtended } from "../types";
type TArgs = {
activeContext: TPowerKContextTypeExtended | null;
};
export const useExtendedContextIndicator = (_args: TArgs): string | null => null;
@@ -1 +0,0 @@
export * from "./root";
@@ -1,11 +0,0 @@
// 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[] => [];
@@ -1,34 +0,0 @@
"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)}
/>
))}
</>
);
});
@@ -1,36 +0,0 @@
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>
);
};
@@ -1,10 +0,0 @@
"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 = {};
@@ -1,5 +0,0 @@
export type TPowerKContextTypeExtended = never;
export type TPowerKPageTypeExtended = never;
export type TPowerKSearchResultsKeysExtended = never;
@@ -1,18 +1,19 @@
import type { FC } from "react";
import { CalendarDays, LayersIcon, Link2, Paperclip } from "lucide-react";
import {
CalendarCheck2,
CalendarClock,
CalendarDays,
LayersIcon,
Link2,
Paperclip,
Signal,
Tag,
Triangle,
Users,
} from "lucide-react";
// types
import type { ISvgIcons } from "@plane/propel/icons";
import {
CycleIcon,
StatePropertyIcon,
ModuleIcon,
MembersPropertyIcon,
DueDatePropertyIcon,
EstimatePropertyIcon,
LabelPropertyIcon,
PriorityPropertyIcon,
StartDatePropertyIcon,
} from "@plane/propel/icons";
import { CycleIcon, DoubleCircleIcon, ModuleIcon } from "@plane/propel/icons";
import type { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types";
// components
import {
@@ -65,16 +66,16 @@ export const getScopeMemberIds = ({ isWorkspaceLevel, projectId }: TGetColumns):
export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined;
export const SpreadSheetPropertyIconMap: Record<string, FC<ISvgIcons>> = {
MembersPropertyIcon: MembersPropertyIcon,
Users: Users,
CalenderDays: CalendarDays,
DueDatePropertyIcon: DueDatePropertyIcon,
EstimatePropertyIcon: EstimatePropertyIcon,
LabelPropertyIcon: LabelPropertyIcon,
CalenderCheck2: CalendarCheck2,
Triangle: Triangle,
Tag: Tag,
ModuleIcon: ModuleIcon,
ContrastIcon: CycleIcon,
PriorityPropertyIcon: PriorityPropertyIcon,
StartDatePropertyIcon: StartDatePropertyIcon,
StatePropertyIcon: StatePropertyIcon,
Signal: Signal,
CalendarClock: CalendarClock,
DoubleCircleIcon: DoubleCircleIcon,
Link2: Link2,
Paperclip: Paperclip,
LayersIcon: LayersIcon,
@@ -1 +0,0 @@
export { ProjectFeaturesList } from "@/components/project/settings/features-list";

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