From 4460529b37a7aaf314e3f3bbaa075592f6d7e814 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 23 May 2025 13:53:16 +0530 Subject: [PATCH 01/23] [WEB-4154] fix: dropdown container classname (#7085) * fix: dropdown container classname * improvement: update string utils for joinWithConjunction * improvement: add more string utils --- packages/ui/src/dropdown/dropdown.d.ts | 2 +- packages/ui/src/dropdown/multi-select.tsx | 22 ++++----- packages/ui/src/dropdown/single-select.tsx | 22 ++++----- packages/utils/src/string.ts | 57 ++++++++++------------ 4 files changed, 48 insertions(+), 55 deletions(-) diff --git a/packages/ui/src/dropdown/dropdown.d.ts b/packages/ui/src/dropdown/dropdown.d.ts index dd441d0a86..8d1159e7cd 100644 --- a/packages/ui/src/dropdown/dropdown.d.ts +++ b/packages/ui/src/dropdown/dropdown.d.ts @@ -4,7 +4,7 @@ export interface IDropdown { // root props onOpen?: () => void; onClose?: () => void; - containerClassName?: (isOpen: boolean) => string; + containerClassName?: string | ((isOpen: boolean) => string); tabIndex?: number; placement?: Placement; disabled?: boolean; diff --git a/packages/ui/src/dropdown/multi-select.tsx b/packages/ui/src/dropdown/multi-select.tsx index 25f22c6be4..400e2c7284 100644 --- a/packages/ui/src/dropdown/multi-select.tsx +++ b/packages/ui/src/dropdown/multi-select.tsx @@ -1,19 +1,14 @@ -import React, { FC, useMemo, useRef, useState } from "react"; -import sortBy from "lodash/sortBy"; -// headless ui import { Combobox } from "@headlessui/react"; -// popper-js +import sortBy from "lodash/sortBy"; +import React, { FC, useMemo, useRef, useState } from "react"; import { usePopper } from "react-popper"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// components +// local imports +import { cn } from "../../helpers"; +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; import { DropdownButton } from "./common"; import { DropdownOptions } from "./common/options"; -// hooks -import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; -// helper -import { cn } from "../../helpers"; -// types import { IMultiSelectDropdown } from "./dropdown"; export const MultiSelectDropdown: FC = (props) => { @@ -118,7 +113,10 @@ export const MultiSelectDropdown: FC = (props) => { ref={dropdownRef} value={value} onChange={onChange} - className={cn("h-full", containerClassName)} + className={cn( + "h-full", + typeof containerClassName === "function" ? containerClassName(isOpen) : containerClassName + )} tabIndex={tabIndex} multiple onKeyDown={handleKeyDown} diff --git a/packages/ui/src/dropdown/single-select.tsx b/packages/ui/src/dropdown/single-select.tsx index bcdff40c14..9614feb516 100644 --- a/packages/ui/src/dropdown/single-select.tsx +++ b/packages/ui/src/dropdown/single-select.tsx @@ -1,19 +1,14 @@ -import React, { FC, useMemo, useRef, useState } from "react"; -import sortBy from "lodash/sortBy"; -// headless ui import { Combobox } from "@headlessui/react"; -// popper-js +import sortBy from "lodash/sortBy"; +import React, { FC, useMemo, useRef, useState } from "react"; import { usePopper } from "react-popper"; -// plane helpers +// plane imports import { useOutsideClickDetector } from "@plane/hooks"; -// components +// local imports +import { cn } from "../../helpers"; +import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; import { DropdownButton } from "./common"; import { DropdownOptions } from "./common/options"; -// hooks -import { useDropdownKeyPressed } from "../hooks/use-dropdown-key-pressed"; -// helper -import { cn } from "../../helpers"; -// types import { ISingleSelectDropdown } from "./dropdown"; export const Dropdown: FC = (props) => { @@ -118,7 +113,10 @@ export const Dropdown: FC = (props) => { ref={dropdownRef} value={value} onChange={onChange} - className={cn("h-full", containerClassName)} + className={cn( + "h-full", + typeof containerClassName === "function" ? containerClassName(isOpen) : containerClassName + )} tabIndex={tabIndex} onKeyDown={handleKeyDown} disabled={disabled} diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 19840df4d1..d663c49c9b 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -86,36 +86,6 @@ export const copyUrlToClipboard = async (path: string) => { await copyTextToClipboard(url.toString()); }; -/** - * @description Generates a deterministic HSL color based on input string - * @param {string} string - Input string to generate color from - * @returns {string} HSL color string - * @example - * generateRandomColor("hello") // returns consistent HSL color for "hello" - * generateRandomColor("") // returns "rgb(var(--color-primary-100))" - */ -export const generateRandomColor = (string: string): string => { - if (!string) return "rgb(var(--color-primary-100))"; - - string = `${string}`; - - const uniqueId = string.length.toString() + string; - const combinedString = uniqueId + string; - - const hash = Array.from(combinedString).reduce((acc, char) => { - const charCode = char.charCodeAt(0); - return (acc << 5) - acc + charCode; - }, 0); - - const hue = hash % 360; - const saturation = 70; - const lightness = 60; - - const randomColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`; - - return randomColor; -}; - /** * @description Gets first character of first word or first characters of first two words * @param {string} str - Input string @@ -275,6 +245,33 @@ export const checkURLValidity = (url: string): boolean => { return urlPattern.test(url); }; +/** + * Combines array elements with a separator and adds a conjunction before the last element + * @param array Array of strings to combine + * @param separator Separator to use between elements (default: ", ") + * @param conjunction Conjunction to use before last element (default: "and") + * @returns Combined string with conjunction before the last element + */ +export const joinWithConjunction = (array: string[], separator: string = ", ", conjunction: string = "and"): string => { + if (!array || array.length === 0) return ""; + if (array.length === 1) return array[0]; + if (array.length === 2) return `${array[0]} ${conjunction} ${array[1]}`; + + const lastElement = array[array.length - 1]; + const elementsExceptLast = array.slice(0, -1); + + return `${elementsExceptLast.join(separator)}${separator}${conjunction} ${lastElement}`; +}; + +/** + * @description Ensures a URL has a protocol + * @param {string} url + * @returns {string} + * @example + * ensureUrlHasProtocol("example.com") => "http://example.com" + */ +export const ensureUrlHasProtocol = (url: string): string => (url.startsWith("http") ? url : `http://${url}`); + // Browser-only clipboard functions // let copyTextToClipboard: (text: string) => Promise; From b376e5300a5c2a9c4ef2d0ef067815334b05e7f7 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Fri, 23 May 2025 15:04:50 +0530 Subject: [PATCH 02/23] [WEB-3155]fix: email notification comments overflow #7110 --- apiserver/templates/emails/notifications/issue-updates.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html index c1a48752fd..e17f0e9e68 100644 --- a/apiserver/templates/emails/notifications/issue-updates.html +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -209,7 +209,7 @@ {% for actor_comment in comment.actor_comments.new_value %} -
+

{{ actor_comment|safe }}

From 5226b17f90e25db01f387a41fa0c067e4af75c93 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Fri, 23 May 2025 15:05:37 +0530 Subject: [PATCH 03/23] [WEB-4159] feat: add 'restricted_entity' translation key across multiple languages #7106 --- packages/i18n/src/locales/cs/translations.json | 3 ++- packages/i18n/src/locales/de/translations.json | 3 ++- packages/i18n/src/locales/en/translations.json | 3 ++- packages/i18n/src/locales/es/translations.json | 3 ++- packages/i18n/src/locales/fr/translations.json | 3 ++- packages/i18n/src/locales/id/translations.json | 3 ++- packages/i18n/src/locales/it/translations.json | 3 ++- packages/i18n/src/locales/ja/translations.json | 3 ++- packages/i18n/src/locales/ko/translations.json | 3 ++- packages/i18n/src/locales/pl/translations.json | 3 ++- packages/i18n/src/locales/pt-BR/translations.json | 3 ++- packages/i18n/src/locales/ro/translations.json | 3 ++- packages/i18n/src/locales/ru/translations.json | 3 ++- packages/i18n/src/locales/sk/translations.json | 3 ++- packages/i18n/src/locales/tr-TR/translations.json | 3 ++- packages/i18n/src/locales/ua/translations.json | 5 +++-- packages/i18n/src/locales/vi-VN/translations.json | 3 ++- packages/i18n/src/locales/zh-CN/translations.json | 3 ++- packages/i18n/src/locales/zh-TW/translations.json | 3 ++- 19 files changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 4aa64f40df..f41ae90216 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -746,7 +746,8 @@ "message": "Něco se pokazilo. Zkuste to prosím znovu." }, "required": "Toto pole je povinné", - "entity_required": "{entity} je povinná" + "entity_required": "{entity} je povinná", + "restricted_entity": "{entity} je omezen" }, "update_link": "Aktualizovat odkaz", "attach": "Připojit", diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 1fa8eaa0e5..857a08a8a7 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -746,7 +746,8 @@ "message": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut." }, "required": "Dieses Feld ist erforderlich", - "entity_required": "{entity} ist erforderlich" + "entity_required": "{entity} ist erforderlich", + "restricted_entity": "{entity} ist eingeschränkt" }, "update_link": "Link aktualisieren", "attach": "Anhängen", diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index ef16944ef2..e6202deb6e 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -580,7 +580,8 @@ "message": "Something went wrong. Please try again." }, "required": "This field is required", - "entity_required": "{entity} is required" + "entity_required": "{entity} is required", + "restricted_entity": "{entity} is restricted" }, "update_link": "Update link", "attach": "Attach", diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 966e3178dd..c8f258385c 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -750,7 +750,8 @@ "message": "Algo salió mal. Por favor, inténtalo de nuevo." }, "required": "Este campo es obligatorio", - "entity_required": "{entity} es obligatorio" + "entity_required": "{entity} es obligatorio", + "restricted_entity": "{entity} está restringido" }, "update_link": "Actualizar enlace", "attach": "Adjuntar", diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 5188b33349..a92d64daa9 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -748,7 +748,8 @@ "message": "Quelque chose s'est mal passé. Veuillez réessayer." }, "required": "Ce champ est obligatoire", - "entity_required": "{entity} est requis" + "entity_required": "{entity} est requis", + "restricted_entity": "{entity} est restreint" }, "update_link": "Mettre à jour le lien", "attach": "Joindre", diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index 3a6c92873a..f1fb2a827b 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -746,7 +746,8 @@ "message": "Sesuatu telah salah. Silakan coba lagi." }, "required": "Bidang ini diperlukan", - "entity_required": "{entity} diperlukan" + "entity_required": "{entity} diperlukan", + "restricted_entity": "{entity} dibatasi" }, "update_link": "Perbarui tautan", "attach": "Lampirkan", diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index ff58fee313..1311c625ed 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -743,7 +743,8 @@ "message": "Qualcosa è andato storto. Per favore, riprova." }, "required": "Questo campo è obbligatorio", - "entity_required": "{entity} è obbligatorio" + "entity_required": "{entity} è obbligatorio", + "restricted_entity": "{entity} è limitato" }, "update_link": "Aggiorna link", "attach": "Allega", diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index 9656f04391..70bcd1c8fc 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -748,7 +748,8 @@ "message": "問題が発生しました。もう一度お試しください。" }, "required": "この項目は必須です", - "entity_required": "{entity}は必須です" + "entity_required": "{entity}は必須です", + "restricted_entity": "{entity} は制限されています" }, "update_link": "リンクを更新", "attach": "添付", diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index eb9b97bf4c..eee2ffbf05 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -748,7 +748,8 @@ "message": "문제가 발생했습니다. 다시 시도해주세요." }, "required": "이 필드는 필수입니다", - "entity_required": "{entity}가 필요합니다" + "entity_required": "{entity}가 필요합니다", + "restricted_entity": "{entity}은(는) 제한되어 있습니다" }, "update_link": "링크 업데이트", "attach": "첨부", diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 28290e3d01..7ff792a26f 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -748,7 +748,8 @@ "message": "Coś poszło nie tak. Spróbuj ponownie." }, "required": "To pole jest wymagane", - "entity_required": "{entity} jest wymagane" + "entity_required": "{entity} jest wymagane", + "restricted_entity": "{entity} jest ograniczony" }, "update_link": "Zaktualizuj link", "attach": "Dołącz", diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index 6b31fcbf4a..a5eb08a861 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -748,7 +748,8 @@ "message": "Algo deu errado. Por favor, tente novamente." }, "required": "Este campo é obrigatório", - "entity_required": "{entity} é obrigatório" + "entity_required": "{entity} é obrigatório", + "restricted_entity": "{entity} está restrito" }, "update_link": "Atualizar link", "attach": "Anexar", diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index 704ee840f5..a88ef06093 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -746,7 +746,8 @@ "message": "Ceva a funcționat greșit. Te rugăm să încerci din nou." }, "required": "Acest câmp este obligatoriu", - "entity_required": "{entity} este obligatoriu" + "entity_required": "{entity} este obligatoriu", + "restricted_entity": "{entity} este restricționat" }, "update_link": "Actualizează link-ul", "attach": "Atașează", diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index f1a9659e35..4159cccd3d 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -748,7 +748,8 @@ "message": "Что-то пошло не так. Попробуйте позже." }, "required": "Это поле обязательно", - "entity_required": "{entity} обязательно" + "entity_required": "{entity} обязательно", + "restricted_entity": "{entity} ограничен" }, "update_link": "обновить ссылку", "attach": "Прикрепить", diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index 0aa8f4f84b..f7093e59cb 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -748,7 +748,8 @@ "message": "Niečo sa pokazilo. Skúste to prosím znova." }, "required": "Toto pole je povinné", - "entity_required": "{entity} je povinná" + "entity_required": "{entity} je povinná", + "restricted_entity": "{entity} je obmedzený" }, "update_link": "Aktualizovať odkaz", "attach": "Pripojiť", diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index 7d4cde25dd..de2cb9d7f2 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -748,7 +748,8 @@ "message": "Bir hata oluştu. Lütfen tekrar deneyin." }, "required": "Bu alan gereklidir", - "entity_required": "{entity} gereklidir" + "entity_required": "{entity} gereklidir", + "restricted_entity": "{entity} kısıtlanmıştır" }, "update_link": "Bağlantıyı güncelle", "attach": "Ekle", diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index 841dbf8031..9be16b11d9 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -747,8 +747,9 @@ "title": "Помилка!", "message": "Щось пішло не так. Будь ласка, спробуйте ще раз." }, - "required": "Це поле є обов’язковим", - "entity_required": "{entity} є обов’язковим" + "required": "Це поле є обов'язковим", + "entity_required": "{entity} є обов'язковим", + "restricted_entity": "{entity} обмежено" }, "update_link": "Оновити посилання", "attach": "Прикріпити", diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index de2c722ebd..756eea3d97 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -748,7 +748,8 @@ "message": "Đã xảy ra lỗi. Vui lòng thử lại." }, "required": "Trường này là bắt buộc", - "entity_required": "{entity} là bắt buộc" + "entity_required": "{entity} là bắt buộc", + "restricted_entity": "{entity} bị hạn chế" }, "update_link": "Cập nhật liên kết", "attach": "Đính kèm", diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index d3e3e59986..8dbdb55233 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -748,7 +748,8 @@ "message": "发生错误。请重试。" }, "required": "此字段为必填项", - "entity_required": "{entity}为必填项" + "entity_required": "{entity}为必填项", + "restricted_entity": "{entity}已被限制" }, "update_link": "更新链接", "attach": "附加", diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index ed49e1fe3d..53888c516a 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -748,7 +748,8 @@ "message": "發生錯誤。請再試一次。" }, "required": "此欄位為必填", - "entity_required": "{entity} 為必填" + "entity_required": "{entity} 為必填", + "restricted_entity": "{entity}已被限制" }, "update_link": "更新連結", "attach": "附加", From 9812129ad3cb4ac972794786e1c9f3147d1929da Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Fri, 23 May 2025 15:05:57 +0530 Subject: [PATCH 04/23] [WEB-4133] chore: optimised the analytics endpoints (#7105) * chore: optimised the analytics endpoints * chore: segregated peek view endpoints * chore: added analytics values validation * chore: added project validation * chore: reverted the changes --------- Co-authored-by: JayashTripathy --- apiserver/plane/app/urls/analytic.py | 18 + apiserver/plane/app/views/__init__.py | 6 + apiserver/plane/app/views/analytic/advance.py | 338 +++----------- .../app/views/analytic/project_analytics.py | 421 ++++++++++++++++++ .../overview/project-insights.tsx | 5 +- .../analytics-v2/total-insights.tsx | 18 +- .../work-items/created-vs-resolved.tsx | 5 +- .../work-items/priority-chart.tsx | 20 +- .../work-items/workitems-insight-table.tsx | 18 +- web/core/services/analytics-v2.service.ts | 57 ++- 10 files changed, 599 insertions(+), 307 deletions(-) create mode 100644 apiserver/plane/app/views/analytic/project_analytics.py diff --git a/apiserver/plane/app/urls/analytic.py b/apiserver/plane/app/urls/analytic.py index 0eebd3108b..3e4172771b 100644 --- a/apiserver/plane/app/urls/analytic.py +++ b/apiserver/plane/app/urls/analytic.py @@ -11,6 +11,9 @@ from plane.app.views import ( AdvanceAnalyticsChartEndpoint, DefaultAnalyticsEndpoint, ProjectStatsEndpoint, + ProjectAdvanceAnalyticsEndpoint, + ProjectAdvanceAnalyticsStatsEndpoint, + ProjectAdvanceAnalyticsChartEndpoint, ) @@ -67,4 +70,19 @@ urlpatterns = [ AdvanceAnalyticsChartEndpoint.as_view(), name="advance-analytics-chart", ), + path( + "workspaces//projects//advance-analytics/", + ProjectAdvanceAnalyticsEndpoint.as_view(), + name="project-advance-analytics", + ), + path( + "workspaces//projects//advance-analytics-stats/", + ProjectAdvanceAnalyticsStatsEndpoint.as_view(), + name="project-advance-analytics-stats", + ), + path( + "workspaces//projects//advance-analytics-charts/", + ProjectAdvanceAnalyticsChartEndpoint.as_view(), + name="project-advance-analytics-chart", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 2034c55487..98dcab84fd 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -205,6 +205,12 @@ from .analytic.advance import ( AdvanceAnalyticsChartEndpoint, ) +from .analytic.project_analytics import ( + ProjectAdvanceAnalyticsEndpoint, + ProjectAdvanceAnalyticsStatsEndpoint, + ProjectAdvanceAnalyticsChartEndpoint, +) + from .notification.base import ( NotificationViewSet, UnreadNotificationEndpoint, diff --git a/apiserver/plane/app/views/analytic/advance.py b/apiserver/plane/app/views/analytic/advance.py index c55f5566b1..a79ab98c58 100644 --- a/apiserver/plane/app/views/analytic/advance.py +++ b/apiserver/plane/app/views/analytic/advance.py @@ -5,7 +5,6 @@ from django.db.models import QuerySet, Q, Count from django.http import HttpRequest from django.db.models.functions import TruncMonth from django.utils import timezone -from datetime import timedelta from plane.app.views.base import BaseAPIView from plane.app.permissions import ROLE, allow_permission from plane.db.models import ( @@ -17,12 +16,7 @@ from plane.db.models import ( IssueView, ProjectPage, Workspace, - CycleIssue, - ModuleIssue, ) -from django.db import models -from django.db.models import F, Case, When, Value -from django.db.models.functions import Concat from plane.utils.build_chart import build_analytics_chart from plane.utils.date_utils import ( get_analytics_filters, @@ -118,25 +112,8 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): ), } - def get_work_items_stats( - self, cycle_id=None, module_id=None - ) -> Dict[str, Dict[str, int]]: - """ - Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided. - """ - base_queryset = None - if cycle_id is not None: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) - base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) - elif module_id is not None: - module_issues = ModuleIssue.objects.filter( - **self.filters["base_filters"], module_id=module_id - ).values_list("issue_id", flat=True) - base_queryset = Issue.issue_objects.filter(id__in=module_issues) - else: - base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) return { "total_work_items": self.get_filtered_counts(base_queryset), @@ -165,11 +142,8 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): status=status.HTTP_200_OK, ) elif tab == "work-items": - # Optionally accept cycle_id or module_id as query params - cycle_id = request.GET.get("cycle_id", None) - module_id = request.GET.get("module_id", None) return Response( - self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id), + self.get_work_items_stats(), status=status.HTTP_200_OK, ) return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST) @@ -188,7 +162,21 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView): ) return ( - base_queryset.values("project_id", "project__name") + base_queryset.values("project_id", "project__name").annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + def get_work_items_stats(self) -> Dict[str, Dict[str, int]]: + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + return ( + base_queryset + .values("project_id", "project__name") .annotate( cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), completed_work_items=Count("id", filter=Q(state__group="completed")), @@ -199,100 +187,14 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView): .order_by("project_id") ) - def get_work_items_stats( - self, cycle_id=None, module_id=None, peek_view=False - ) -> Dict[str, Dict[str, int]]: - base_queryset = None - if cycle_id is not None: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) - base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) - elif module_id is not None: - module_issues = ModuleIssue.objects.filter( - **self.filters["base_filters"], module_id=module_id - ).values_list("issue_id", flat=True) - base_queryset = Issue.issue_objects.filter(id__in=module_issues) - elif peek_view: - base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) - else: - base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) - return ( - base_queryset.values("project_id", "project__name") - .annotate( - cancelled_work_items=Count( - "id", filter=Q(state__group="cancelled") - ), - completed_work_items=Count( - "id", filter=Q(state__group="completed") - ), - backlog_work_items=Count("id", filter=Q(state__group="backlog")), - un_started_work_items=Count( - "id", filter=Q(state__group="unstarted") - ), - started_work_items=Count("id", filter=Q(state__group="started")), - ) - .order_by("project_id") - ) - - return ( - base_queryset.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( - cancelled_work_items=Count( - "id", filter=Q(state__group="cancelled"), distinct=True - ), - completed_work_items=Count( - "id", filter=Q(state__group="completed"), distinct=True - ), - backlog_work_items=Count( - "id", filter=Q(state__group="backlog"), distinct=True - ), - un_started_work_items=Count( - "id", filter=Q(state__group="unstarted"), distinct=True - ), - started_work_items=Count( - "id", filter=Q(state__group="started"), distinct=True - ), - ) - .order_by("display_name") - ) - @allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request: HttpRequest, slug: str) -> Response: self.initialize_workspace(slug, type="chart") type = request.GET.get("type", "work-items") if type == "work-items": - # Optionally accept cycle_id or module_id as query params - cycle_id = request.GET.get("cycle_id", None) - module_id = request.GET.get("module_id", None) - peek_view = request.GET.get("peek_view", False) return Response( - self.get_work_items_stats( - cycle_id=cycle_id, module_id=module_id, peek_view=peek_view - ), + self.get_work_items_stats(), status=status.HTTP_200_OK, ) @@ -352,9 +254,7 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): for key, value in data.items() ] - def work_item_completion_chart( - self, cycle_id=None, module_id=None, peek_view=False - ) -> Dict[str, Any]: + def work_item_completion_chart(self) -> Dict[str, Any]: # Get the base queryset queryset = ( Issue.issue_objects.filter(**self.filters["base_filters"]) @@ -364,143 +264,62 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): ) ) - if cycle_id is not None and peek_view: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) - cycle = Cycle.objects.filter(id=cycle_id).first() - if cycle and cycle.start_date: - start_date = cycle.start_date.date() - end_date = cycle.end_date.date() - else: - return {"data": [], "schema": {}} - queryset = cycle_issues - elif module_id is not None and peek_view: - module_issues = ModuleIssue.objects.filter( - **self.filters["base_filters"], module_id=module_id - ).values_list("issue_id", flat=True) - module = Module.objects.filter(id=module_id).first() - if module and module.start_date: - start_date = module.start_date - end_date = module.target_date - else: - return {"data": [], "schema": {}} - queryset = module_issues - elif peek_view: - project_ids_str = self.request.GET.get("project_ids") - if project_ids_str: - project_id_list = [ - pid.strip() for pid in project_ids_str.split(",") if pid.strip() - ] - else: - project_id_list = [] - return {"data": [], "schema": {}} - project_id = project_id_list[0] - project = Project.objects.filter(id=project_id).first() - if project.created_at: - start_date = project.created_at.date().replace(day=1) - else: - return {"data": [], "schema": {}} - else: - workspace = Workspace.objects.get(slug=self._workspace_slug) - start_date = workspace.created_at.date().replace(day=1) + workspace = Workspace.objects.get(slug=self._workspace_slug) + start_date = workspace.created_at.date().replace(day=1) - if cycle_id or module_id: - # Get daily stats with optimized query - daily_stats = ( - queryset.values("created_at__date") - .annotate( - created_count=Count("id"), - completed_count=Count( - "id", filter=Q(issue__state__group="completed") - ), - ) - .order_by("created_at__date") + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date ) - # Create a dictionary of existing stats with summed counts - stats_dict = { - stat["created_at__date"].strftime("%Y-%m-%d"): { - "created_count": stat["created_count"], - "completed_count": stat["completed_count"], - } - for stat in daily_stats - } - - # Generate data for all days in the range - data = [] - current_date = start_date - while current_date <= end_date: - date_str = current_date.strftime("%Y-%m-%d") - stats = stats_dict.get( - date_str, {"created_count": 0, "completed_count": 0} - ) - data.append( - { - "key": date_str, - "name": date_str, - "count": stats["created_count"] + stats["completed_count"], - "completed_issues": stats["completed_count"], - "created_issues": stats["created_count"], - } - ) - current_date += timedelta(days=1) - else: - # Apply date range filter if available - if self.filters["chart_period_range"]: - start_date, end_date = self.filters["chart_period_range"] - queryset = queryset.filter( - created_at__date__gte=start_date, created_at__date__lte=end_date - ) - - # Annotate by month and count - monthly_stats = ( - queryset.annotate(month=TruncMonth("created_at")) - .values("month") - .annotate( - created_count=Count("id"), - completed_count=Count("id", filter=Q(state__group="completed")), - ) - .order_by("month") + # Annotate by month and count + monthly_stats = ( + queryset.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(state__group="completed")), ) + .order_by("month") + ) - # Create dictionary of month -> counts - stats_dict = { - stat["month"].strftime("%Y-%m-%d"): { - "created_count": stat["created_count"], - "completed_count": stat["completed_count"], - } - for stat in monthly_stats + # Create dictionary of month -> counts + stats_dict = { + stat["month"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], } + for stat in monthly_stats + } - # Generate monthly data (ensure months with 0 count are included) - data = [] - # include the current date at the end - end_date = timezone.now().date() - last_month = end_date.replace(day=1) - current_month = start_date + # Generate monthly data (ensure months with 0 count are included) + data = [] + # include the current date at the end + end_date = timezone.now().date() + last_month = end_date.replace(day=1) + current_month = start_date - while current_month <= last_month: - date_str = current_month.strftime("%Y-%m-%d") - stats = stats_dict.get( - date_str, {"created_count": 0, "completed_count": 0} + while current_month <= last_month: + date_str = current_month.strftime("%Y-%m-%d") + stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0}) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + # Move to next month + if current_month.month == 12: + current_month = current_month.replace( + year=current_month.year + 1, month=1 ) - data.append( - { - "key": date_str, - "name": date_str, - "count": stats["created_count"], - "completed_issues": stats["completed_count"], - "created_issues": stats["created_count"], - } - ) - # Move to next month - if current_month.month == 12: - current_month = current_month.replace( - year=current_month.year + 1, month=1 - ) - else: - current_month = current_month.replace(month=current_month.month + 1) + else: + current_month = current_month.replace(month=current_month.month + 1) schema = { "completed_issues": "completed_issues", @@ -515,8 +334,6 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): type = request.GET.get("type", "projects") group_by = request.GET.get("group_by", None) x_axis = request.GET.get("x_axis", "PRIORITY") - cycle_id = request.GET.get("cycle_id", None) - module_id = request.GET.get("module_id", None) if type == "projects": return Response(self.project_chart(), status=status.HTTP_200_OK) @@ -530,19 +347,6 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): ) ) - # Apply cycle/module filters if present - if cycle_id is not None: - cycle_issues = CycleIssue.objects.filter( - **self.filters["base_filters"], cycle_id=cycle_id - ).values_list("issue_id", flat=True) - queryset = queryset.filter(id__in=cycle_issues) - - elif module_id is not None: - module_issues = ModuleIssue.objects.filter( - **self.filters["base_filters"], module_id=module_id - ).values_list("issue_id", flat=True) - queryset = queryset.filter(id__in=module_issues) - # Apply date range filter if available if self.filters["chart_period_range"]: start_date, end_date = self.filters["chart_period_range"] @@ -556,14 +360,8 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView): ) elif type == "work-items": - # Optionally accept cycle_id or module_id as query params - cycle_id = request.GET.get("cycle_id", None) - module_id = request.GET.get("module_id", None) - peek_view = request.GET.get("peek_view", False) return Response( - self.work_item_completion_chart( - cycle_id=cycle_id, module_id=module_id, peek_view=peek_view - ), + self.work_item_completion_chart(), status=status.HTTP_200_OK, ) diff --git a/apiserver/plane/app/views/analytic/project_analytics.py b/apiserver/plane/app/views/analytic/project_analytics.py new file mode 100644 index 0000000000..655f8e9898 --- /dev/null +++ b/apiserver/plane/app/views/analytic/project_analytics.py @@ -0,0 +1,421 @@ +from rest_framework.response import Response +from rest_framework import status +from typing import Dict, Any +from django.db.models import QuerySet, Q, Count +from django.http import HttpRequest +from django.db.models.functions import TruncMonth +from django.utils import timezone +from datetime import timedelta +from plane.app.views.base import BaseAPIView +from plane.app.permissions import ROLE, allow_permission +from plane.db.models import ( + Project, + Issue, + Cycle, + Module, + CycleIssue, + ModuleIssue, +) +from django.db import models +from django.db.models import F, Case, When, Value +from django.db.models.functions import Concat +from plane.utils.build_chart import build_analytics_chart +from plane.utils.date_utils import ( + get_analytics_filters, +) + + +class ProjectAdvanceAnalyticsBaseView(BaseAPIView): + def initialize_workspace(self, slug: str, type: str) -> None: + self._workspace_slug = slug + self.filters = get_analytics_filters( + slug=slug, + type=type, + user=self.request.user, + date_filter=self.request.GET.get("date_filter", None), + project_ids=self.request.GET.get("project_ids", None), + ) + + +class ProjectAdvanceAnalyticsEndpoint(ProjectAdvanceAnalyticsBaseView): + def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]: + def get_filtered_count() -> int: + if self.filters["analytics_date_range"]: + return queryset.filter( + created_at__gte=self.filters["analytics_date_range"]["current"][ + "gte" + ], + created_at__lte=self.filters["analytics_date_range"]["current"][ + "lte" + ], + ).count() + return queryset.count() + + return { + "count": get_filtered_count(), + } + + def get_work_items_stats( + self, project_id, cycle_id=None, module_id=None + ) -> Dict[str, Dict[str, int]]: + """ + Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided. + """ + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + else: + base_queryset = Issue.issue_objects.filter( + **self.filters["base_filters"], project_id=project_id + ) + + return { + "total_work_items": self.get_filtered_counts(base_queryset), + "started_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="started") + ), + "backlog_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="backlog") + ), + "un_started_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="unstarted") + ), + "completed_work_items": self.get_filtered_counts( + base_queryset.filter(state__group="completed") + ), + } + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="analytics") + + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + return Response( + self.get_work_items_stats( + cycle_id=cycle_id, module_id=module_id, project_id=project_id + ), + status=status.HTTP_200_OK, + ) + + +class ProjectAdvanceAnalyticsStatsEndpoint(ProjectAdvanceAnalyticsBaseView): + def get_project_issues_stats(self) -> QuerySet: + # Get the base queryset with workspace and project filters + base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"]) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + base_queryset = base_queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + return ( + base_queryset.values("project_id", "project__name") + .annotate( + cancelled_work_items=Count("id", filter=Q(state__group="cancelled")), + completed_work_items=Count("id", filter=Q(state__group="completed")), + backlog_work_items=Count("id", filter=Q(state__group="backlog")), + un_started_work_items=Count("id", filter=Q(state__group="unstarted")), + started_work_items=Count("id", filter=Q(state__group="started")), + ) + .order_by("project_id") + ) + + def get_work_items_stats( + self, project_id, cycle_id=None, module_id=None + ) -> Dict[str, Dict[str, int]]: + base_queryset = None + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=cycle_issues) + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + base_queryset = Issue.issue_objects.filter(id__in=module_issues) + else: + base_queryset = Issue.issue_objects.filter( + **self.filters["base_filters"], project_id=project_id + ) + return ( + base_queryset.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( + cancelled_work_items=Count( + "id", filter=Q(state__group="cancelled"), distinct=True + ), + completed_work_items=Count( + "id", filter=Q(state__group="completed"), distinct=True + ), + backlog_work_items=Count( + "id", filter=Q(state__group="backlog"), distinct=True + ), + un_started_work_items=Count( + "id", filter=Q(state__group="unstarted"), distinct=True + ), + started_work_items=Count( + "id", filter=Q(state__group="started"), distinct=True + ), + ) + .order_by("display_name") + ) + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "work-items") + + if type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + return Response( + self.get_work_items_stats( + project_id=project_id, cycle_id=cycle_id, module_id=module_id + ), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) + + +class ProjectAdvanceAnalyticsChartEndpoint(ProjectAdvanceAnalyticsBaseView): + def work_item_completion_chart( + self, project_id, cycle_id=None, module_id=None + ) -> Dict[str, Any]: + # Get the base queryset + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .filter(project_id=project_id) + .select_related("workspace", "state", "parent") + .prefetch_related( + "assignees", "labels", "issue_module__module", "issue_cycle__cycle" + ) + ) + + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + cycle = Cycle.objects.filter(id=cycle_id).first() + if cycle and cycle.start_date: + start_date = cycle.start_date.date() + end_date = cycle.end_date.date() + else: + return {"data": [], "schema": {}} + queryset = cycle_issues + + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + module = Module.objects.filter(id=module_id).first() + if module and module.start_date: + start_date = module.start_date + end_date = module.target_date + else: + return {"data": [], "schema": {}} + queryset = module_issues + + else: + project = Project.objects.filter(id=project_id).first() + if project.created_at: + start_date = project.created_at.date().replace(day=1) + else: + return {"data": [], "schema": {}} + + if cycle_id or module_id: + # Get daily stats with optimized query + daily_stats = ( + queryset.values("created_at__date") + .annotate( + created_count=Count("id"), + completed_count=Count( + "id", filter=Q(issue__state__group="completed") + ), + ) + .order_by("created_at__date") + ) + + # Create a dictionary of existing stats with summed counts + stats_dict = { + stat["created_at__date"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in daily_stats + } + + # Generate data for all days in the range + data = [] + current_date = start_date + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + stats = stats_dict.get( + date_str, {"created_count": 0, "completed_count": 0} + ) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"] + stats["completed_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + current_date += timedelta(days=1) + else: + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + # Annotate by month and count + monthly_stats = ( + queryset.annotate(month=TruncMonth("created_at")) + .values("month") + .annotate( + created_count=Count("id"), + completed_count=Count("id", filter=Q(state__group="completed")), + ) + .order_by("month") + ) + + # Create dictionary of month -> counts + stats_dict = { + stat["month"].strftime("%Y-%m-%d"): { + "created_count": stat["created_count"], + "completed_count": stat["completed_count"], + } + for stat in monthly_stats + } + + # Generate monthly data (ensure months with 0 count are included) + data = [] + # include the current date at the end + end_date = timezone.now().date() + last_month = end_date.replace(day=1) + current_month = start_date + + while current_month <= last_month: + date_str = current_month.strftime("%Y-%m-%d") + stats = stats_dict.get( + date_str, {"created_count": 0, "completed_count": 0} + ) + data.append( + { + "key": date_str, + "name": date_str, + "count": stats["created_count"], + "completed_issues": stats["completed_count"], + "created_issues": stats["created_count"], + } + ) + # Move to next month + if current_month.month == 12: + current_month = current_month.replace( + year=current_month.year + 1, month=1 + ) + else: + current_month = current_month.replace(month=current_month.month + 1) + + schema = { + "completed_issues": "completed_issues", + "created_issues": "created_issues", + } + + return {"data": data, "schema": schema} + + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) + def get(self, request: HttpRequest, slug: str, project_id: str) -> Response: + self.initialize_workspace(slug, type="chart") + type = request.GET.get("type", "projects") + group_by = request.GET.get("group_by", None) + x_axis = request.GET.get("x_axis", "PRIORITY") + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + + if type == "custom-work-items": + queryset = ( + Issue.issue_objects.filter(**self.filters["base_filters"]) + .filter(project_id=project_id) + .select_related("workspace", "state", "parent") + .prefetch_related( + "assignees", "labels", "issue_module__module", "issue_cycle__cycle" + ) + ) + + # Apply cycle/module filters if present + if cycle_id is not None: + cycle_issues = CycleIssue.objects.filter( + **self.filters["base_filters"], cycle_id=cycle_id + ).values_list("issue_id", flat=True) + queryset = queryset.filter(id__in=cycle_issues) + + elif module_id is not None: + module_issues = ModuleIssue.objects.filter( + **self.filters["base_filters"], module_id=module_id + ).values_list("issue_id", flat=True) + queryset = queryset.filter(id__in=module_issues) + + # Apply date range filter if available + if self.filters["chart_period_range"]: + start_date, end_date = self.filters["chart_period_range"] + queryset = queryset.filter( + created_at__date__gte=start_date, created_at__date__lte=end_date + ) + + return Response( + build_analytics_chart(queryset, x_axis, group_by), + status=status.HTTP_200_OK, + ) + + elif type == "work-items": + # Optionally accept cycle_id or module_id as query params + cycle_id = request.GET.get("cycle_id", None) + module_id = request.GET.get("module_id", None) + + return Response( + self.work_item_completion_chart( + project_id=project_id, cycle_id=cycle_id, module_id=module_id + ), + status=status.HTTP_200_OK, + ) + + return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/web/core/components/analytics-v2/overview/project-insights.tsx b/web/core/components/analytics-v2/overview/project-insights.tsx index 83054a885f..9c8f829a14 100644 --- a/web/core/components/analytics-v2/overview/project-insights.tsx +++ b/web/core/components/analytics-v2/overview/project-insights.tsx @@ -39,8 +39,9 @@ const ProjectInsights = observer(() => { ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), ...(selectedCycle ? { cycle_id: selectedCycle } : {}), ...(selectedModule ? { module_id: selectedModule } : {}), - ...(isPeekView ? { peek_view: true } : {}), - }) + }, + isPeekView + ) ); return ( diff --git a/web/core/components/analytics-v2/total-insights.tsx b/web/core/components/analytics-v2/total-insights.tsx index 8a3ffe8b75..5fc8b2239e 100644 --- a/web/core/components/analytics-v2/total-insights.tsx +++ b/web/core/components/analytics-v2/total-insights.tsx @@ -26,13 +26,17 @@ const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?: const { data: totalInsightsData, isLoading } = useSWR( `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, () => - analyticsV2Service.getAdvanceAnalytics(workspaceSlug, analyticsType, { - // date_filter: selectedDuration, - ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), - ...(selectedCycle ? { cycle_id: selectedCycle } : {}), - ...(selectedModule ? { module_id: selectedModule } : {}), - ...(isPeekView ? { peek_view: true } : {}), - }) + analyticsV2Service.getAdvanceAnalytics( + workspaceSlug, + analyticsType, + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + }, + isPeekView + ) ); return (
{ ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), ...(selectedCycle ? { cycle_id: selectedCycle } : {}), ...(selectedModule ? { module_id: selectedModule } : {}), - ...(isPeekView ? { peek_view: true } : {}), - }) + }, + isPeekView + ) ); const parsedData: TChartData[] = useMemo(() => { if (!createdVsResolvedData?.data) return []; diff --git a/web/core/components/analytics-v2/work-items/priority-chart.tsx b/web/core/components/analytics-v2/work-items/priority-chart.tsx index 664824851e..c5b31bb168 100644 --- a/web/core/components/analytics-v2/work-items/priority-chart.tsx +++ b/web/core/components/analytics-v2/work-items/priority-chart.tsx @@ -57,14 +57,18 @@ const PriorityChart = observer((props: Props) => { `customized-insights-chart-${workspaceSlug}-${selectedDuration}- ${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}`, () => - analyticsV2Service.getAdvanceAnalyticsCharts(workspaceSlug, "custom-work-items", { - // date_filter: selectedDuration, - ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), - ...(selectedCycle ? { cycle_id: selectedCycle } : {}), - ...(selectedModule ? { module_id: selectedModule } : {}), - ...(isPeekView ? { peek_view: true } : {}), - ...props, - }) + analyticsV2Service.getAdvanceAnalyticsCharts( + workspaceSlug, + "custom-work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + ...props, + }, + isPeekView + ) ); const parsedData = useMemo( () => diff --git a/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx index e428244925..1fd8343296 100644 --- a/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx +++ b/web/core/components/analytics-v2/work-items/workitems-insight-table.tsx @@ -31,13 +31,17 @@ const WorkItemsInsightTable = observer(() => { const { data: workItemsData, isLoading } = useSWR( `insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`, () => - analyticsV2Service.getAdvanceAnalyticsStats(workspaceSlug, "work-items", { - // date_filter: selectedDuration, - ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), - ...(selectedCycle ? { cycle_id: selectedCycle } : {}), - ...(selectedModule ? { module_id: selectedModule } : {}), - ...(isPeekView ? { peek_view: true } : {}), - }) + analyticsV2Service.getAdvanceAnalyticsStats( + workspaceSlug, + "work-items", + { + // date_filter: selectedDuration, + ...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}), + ...(selectedCycle ? { cycle_id: selectedCycle } : {}), + ...(selectedModule ? { module_id: selectedModule } : {}), + }, + isPeekView + ) ); // derived values const columnsLabels = useMemo( diff --git a/web/core/services/analytics-v2.service.ts b/web/core/services/analytics-v2.service.ts index 87257cbc6b..05e1b78b72 100644 --- a/web/core/services/analytics-v2.service.ts +++ b/web/core/services/analytics-v2.service.ts @@ -10,14 +10,18 @@ export class AnalyticsV2Service extends APIService { async getAdvanceAnalytics( workspaceSlug: string, tab: TAnalyticsTabsV2Base, - params?: Record + params?: Record, + isPeekView?: boolean ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics/`, { - params: { - tab, - ...params, - }, - }) + return this.get( + this.processUrl("advance-analytics", workspaceSlug, tab, params, isPeekView), + { + params: { + tab, + ...params, + }, + } + ) .then((res) => res?.data) .catch((err) => { throw err?.response?.data; @@ -27,9 +31,17 @@ export class AnalyticsV2Service extends APIService { async getAdvanceAnalyticsStats( workspaceSlug: string, tab: Exclude, - params?: Record + params?: Record, + isPeekView?: boolean ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-stats/`, { + const processedUrl = this.processUrl>( + "advance-analytics-stats", + workspaceSlug, + tab, + params, + isPeekView + ); + return this.get(processedUrl, { params: { type: tab, ...params, @@ -44,9 +56,17 @@ export class AnalyticsV2Service extends APIService { async getAdvanceAnalyticsCharts( workspaceSlug: string, tab: TAnalyticsGraphsV2Base, - params?: Record + params?: Record, + isPeekView?: boolean ): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/advance-analytics-charts/`, { + const processedUrl = this.processUrl( + "advance-analytics-charts", + workspaceSlug, + tab, + params, + isPeekView + ); + return this.get(processedUrl, { params: { type: tab, ...params, @@ -57,4 +77,19 @@ export class AnalyticsV2Service extends APIService { throw err?.response?.data; }); } + + processUrl( + endpoint: string, + workspaceSlug: string, + tab: T, + params?: Record, + isPeekView?: boolean + ) { + let processedUrl = `/api/workspaces/${workspaceSlug}`; + if (isPeekView && tab === "work-items") { + const projectId = params?.project_ids.split(",")[0]; + processedUrl += `/projects/${projectId}`; + } + return `${processedUrl}/${endpoint}`; + } } From 6216ad77f443793ec08bb80eb287b83ba28f1546 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Fri, 23 May 2025 15:06:58 +0530 Subject: [PATCH 05/23] [WEB-4146] fix: AI environment variables configuration in GodMode (#7104) * [WEB-4146] fix: artificial intelligence environment variables configuration * chore: update llm configuration keys --- admin/app/ai/form.tsx | 16 ++++++++-------- apiserver/plane/license/api/views/instance.py | 8 ++++---- packages/types/src/instance/ai.d.ts | 2 +- packages/types/src/instance/base.d.ts | 2 +- .../components/description-editor.tsx | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/admin/app/ai/form.tsx b/admin/app/ai/form.tsx index 4258a99fbb..47ab9480ea 100644 --- a/admin/app/ai/form.tsx +++ b/admin/app/ai/form.tsx @@ -26,16 +26,16 @@ export const InstanceAIForm: FC = (props) => { formState: { errors, isSubmitting }, } = useForm({ defaultValues: { - OPENAI_API_KEY: config["OPENAI_API_KEY"], - GPT_ENGINE: config["GPT_ENGINE"], + LLM_API_KEY: config["LLM_API_KEY"], + LLM_MODEL: config["LLM_MODEL"], }, }); const aiFormFields: TControllerInputFormField[] = [ { - key: "GPT_ENGINE", + key: "LLM_MODEL", type: "text", - label: "GPT_ENGINE", + label: "LLM Model", description: ( <> Choose an OpenAI engine.{" "} @@ -49,12 +49,12 @@ export const InstanceAIForm: FC = (props) => { ), - placeholder: "gpt-3.5-turbo", - error: Boolean(errors.GPT_ENGINE), + placeholder: "gpt-4o-mini", + error: Boolean(errors.LLM_MODEL), required: false, }, { - key: "OPENAI_API_KEY", + key: "LLM_API_KEY", type: "password", label: "API key", description: ( @@ -71,7 +71,7 @@ export const InstanceAIForm: FC = (props) => { ), placeholder: "sk-asddassdfasdefqsdfasd23das3dasdcasd", - error: Boolean(errors.OPENAI_API_KEY), + error: Boolean(errors.LLM_API_KEY), required: false, }, ]; diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 0e2b64fc9b..c598acfef9 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -57,7 +57,7 @@ class InstanceEndpoint(BaseAPIView): POSTHOG_API_KEY, POSTHOG_HOST, UNSPLASH_ACCESS_KEY, - OPENAI_API_KEY, + LLM_API_KEY, IS_INTERCOM_ENABLED, INTERCOM_APP_ID, ) = get_configuration_value( @@ -112,8 +112,8 @@ class InstanceEndpoint(BaseAPIView): "default": os.environ.get("UNSPLASH_ACCESS_KEY", ""), }, { - "key": "OPENAI_API_KEY", - "default": os.environ.get("OPENAI_API_KEY", ""), + "key": "LLM_API_KEY", + "default": os.environ.get("LLM_API_KEY", ""), }, # Intercom settings { @@ -151,7 +151,7 @@ class InstanceEndpoint(BaseAPIView): data["has_unsplash_configured"] = bool(UNSPLASH_ACCESS_KEY) # Open AI settings - data["has_openai_configured"] = bool(OPENAI_API_KEY) + data["has_llm_configured"] = bool(LLM_API_KEY) # File size settings data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) diff --git a/packages/types/src/instance/ai.d.ts b/packages/types/src/instance/ai.d.ts index 0ac34557a8..5bfd1a6ba8 100644 --- a/packages/types/src/instance/ai.d.ts +++ b/packages/types/src/instance/ai.d.ts @@ -1 +1 @@ -export type TInstanceAIConfigurationKeys = "OPENAI_API_KEY" | "GPT_ENGINE"; +export type TInstanceAIConfigurationKeys = "LLM_API_KEY" | "LLM_MODEL"; diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index dc5ee5fc7f..79b1e642f2 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -49,7 +49,7 @@ export interface IInstanceConfig { posthog_api_key: string | undefined; posthog_host: string | undefined; has_unsplash_configured: boolean; - has_openai_configured: boolean; + has_llm_configured: boolean; file_size_limit: number | undefined; is_smtp_configured: boolean; app_base_url: string | undefined; diff --git a/web/core/components/issues/issue-modal/components/description-editor.tsx b/web/core/components/issues/issue-modal/components/description-editor.tsx index b19fc62dbb..9cae8d8407 100644 --- a/web/core/components/issues/issue-modal/components/description-editor.tsx +++ b/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -225,7 +225,7 @@ export const IssueDescriptionEditor: React.FC = ob )} />
- {issueName && issueName.trim() !== "" && config?.has_openai_configured && ( + {issueName && issueName.trim() !== "" && config?.has_llm_configured && ( )} - {config?.has_openai_configured && projectId && ( + {config?.has_llm_configured && projectId && ( { From 731c4e8fcdc950e3ac98138037e093333edc43b4 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 23 May 2025 15:29:37 +0530 Subject: [PATCH 06/23] [WEB-4161] fix: eslint config for library config file #7103 --- packages/eslint-config/library.js | 4 +- packages/eslint-config/package.json | 1 + yarn.lock | 200 +++++++++++++++++++++++----- 3 files changed, 171 insertions(+), 34 deletions(-) diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js index 790364230f..b868b35a44 100644 --- a/packages/eslint-config/library.js +++ b/packages/eslint-config/library.js @@ -5,7 +5,7 @@ const project = resolve(process.cwd(), "tsconfig.json"); /** @type {import("eslint").Linter.Config} */ module.exports = { extends: ["prettier", "plugin:@typescript-eslint/recommended"], - plugins: ["react", "@typescript-eslint", "import"], + plugins: ["react", "react-hooks", "@typescript-eslint", "import"], globals: { React: true, JSX: true, @@ -38,7 +38,7 @@ module.exports = { "react/self-closing-comp": ["error", { component: true, html: true }], "react/jsx-boolean-value": "error", "react/jsx-no-duplicate-props": "error", - // "react-hooks/exhaustive-deps": "warn", + "react-hooks/exhaustive-deps": "warn", "@typescript-eslint/no-unused-expressions": "warn", "@typescript-eslint/no-unused-vars": ["warn"], "@typescript-eslint/no-explicit-any": "warn", diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index 9ec3536840..4e2ef3b57a 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -17,6 +17,7 @@ "eslint-config-turbo": "^1.12.4", "eslint-plugin-import": "^2.29.1", "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^5.2.0", "typescript": "5.3.3" } } diff --git a/yarn.lock b/yarn.lock index 6976451fa2..ef27eff52e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -257,7 +257,7 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helpers@7.26.10", "@babel/helpers@^7.26.7": +"@babel/helpers@^7.26.7": version "7.26.10" resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz#6baea3cd62ec2d0c1068778d63cb1314f6637384" integrity sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g== @@ -852,7 +852,7 @@ "@babel/plugin-transform-modules-commonjs" "^7.25.9" "@babel/plugin-transform-typescript" "^7.25.9" -"@babel/runtime@7.26.10", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.26.10" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2" integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw== @@ -1124,126 +1124,251 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== +"@esbuild/aix-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" + integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== + "@esbuild/aix-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz#499600c5e1757a524990d5d92601f0ac3ce87f64" integrity sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ== +"@esbuild/android-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" + integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== + "@esbuild/android-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz#b9b8231561a1dfb94eb31f4ee056b92a985c324f" integrity sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g== +"@esbuild/android-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" + integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== + "@esbuild/android-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.0.tgz#ca6e7888942505f13e88ac9f5f7d2a72f9facd2b" integrity sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g== +"@esbuild/android-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" + integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== + "@esbuild/android-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.0.tgz#e765ea753bac442dfc9cb53652ce8bd39d33e163" integrity sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg== +"@esbuild/darwin-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" + integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== + "@esbuild/darwin-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz#fa394164b0d89d4fdc3a8a21989af70ef579fa2c" integrity sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw== +"@esbuild/darwin-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" + integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== + "@esbuild/darwin-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz#91979d98d30ba6e7d69b22c617cc82bdad60e47a" integrity sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg== +"@esbuild/freebsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" + integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== + "@esbuild/freebsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz#b97e97073310736b430a07b099d837084b85e9ce" integrity sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w== +"@esbuild/freebsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" + integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== + "@esbuild/freebsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz#f3b694d0da61d9910ec7deff794d444cfbf3b6e7" integrity sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A== +"@esbuild/linux-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" + integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== + "@esbuild/linux-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz#f921f699f162f332036d5657cad9036f7a993f73" integrity sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg== +"@esbuild/linux-arm@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" + integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== + "@esbuild/linux-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz#cc49305b3c6da317c900688995a4050e6cc91ca3" integrity sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg== +"@esbuild/linux-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" + integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== + "@esbuild/linux-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz#3e0736fcfab16cff042dec806247e2c76e109e19" integrity sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg== +"@esbuild/linux-loong64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" + integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== + "@esbuild/linux-loong64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz#ea2bf730883cddb9dfb85124232b5a875b8020c7" integrity sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw== +"@esbuild/linux-mips64el@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" + integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== + "@esbuild/linux-mips64el@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz#4cababb14eede09248980a2d2d8b966464294ff1" integrity sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ== +"@esbuild/linux-ppc64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" + integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== + "@esbuild/linux-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz#8860a4609914c065373a77242e985179658e1951" integrity sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw== +"@esbuild/linux-riscv64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" + integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== + "@esbuild/linux-riscv64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz#baf26e20bb2d38cfb86ee282dff840c04f4ed987" integrity sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA== +"@esbuild/linux-s390x@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" + integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== + "@esbuild/linux-s390x@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz#8323afc0d6cb1b6dc6e9fd21efd9e1542c3640a4" integrity sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA== +"@esbuild/linux-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" + integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== + "@esbuild/linux-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz#08fcf60cb400ed2382e9f8e0f5590bac8810469a" integrity sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw== +"@esbuild/netbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" + integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== + "@esbuild/netbsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz#935c6c74e20f7224918fbe2e6c6fe865b6c6ea5b" integrity sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw== +"@esbuild/netbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" + integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== + "@esbuild/netbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz#414677cef66d16c5a4d210751eb2881bb9c1b62b" integrity sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA== +"@esbuild/openbsd-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" + integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== + "@esbuild/openbsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz#8fd55a4d08d25cdc572844f13c88d678c84d13f7" integrity sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw== +"@esbuild/openbsd-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" + integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== + "@esbuild/openbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz#0c48ddb1494bbc2d6bcbaa1429a7f465fa1dedde" integrity sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg== +"@esbuild/sunos-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" + integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== + "@esbuild/sunos-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz#86ff9075d77962b60dd26203d7352f92684c8c92" integrity sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg== +"@esbuild/win32-arm64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" + integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== + "@esbuild/win32-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz#849c62327c3229467f5b5cd681bf50588442e96c" integrity sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw== +"@esbuild/win32-ia32@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" + integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== + "@esbuild/win32-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz#f62eb480cd7cca088cb65bb46a6db25b725dc079" integrity sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA== +"@esbuild/win32-x64@0.24.2": + version "0.24.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" + integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== + "@esbuild/win32-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz#c8e119a30a7c8d60b9d2e22d2073722dde3b710b" @@ -5930,7 +6055,38 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.25.0: +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": + version "0.24.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" + integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.24.2" + "@esbuild/android-arm" "0.24.2" + "@esbuild/android-arm64" "0.24.2" + "@esbuild/android-x64" "0.24.2" + "@esbuild/darwin-arm64" "0.24.2" + "@esbuild/darwin-x64" "0.24.2" + "@esbuild/freebsd-arm64" "0.24.2" + "@esbuild/freebsd-x64" "0.24.2" + "@esbuild/linux-arm" "0.24.2" + "@esbuild/linux-arm64" "0.24.2" + "@esbuild/linux-ia32" "0.24.2" + "@esbuild/linux-loong64" "0.24.2" + "@esbuild/linux-mips64el" "0.24.2" + "@esbuild/linux-ppc64" "0.24.2" + "@esbuild/linux-riscv64" "0.24.2" + "@esbuild/linux-s390x" "0.24.2" + "@esbuild/linux-x64" "0.24.2" + "@esbuild/netbsd-arm64" "0.24.2" + "@esbuild/netbsd-x64" "0.24.2" + "@esbuild/openbsd-arm64" "0.24.2" + "@esbuild/openbsd-x64" "0.24.2" + "@esbuild/sunos-x64" "0.24.2" + "@esbuild/win32-arm64" "0.24.2" + "@esbuild/win32-ia32" "0.24.2" + "@esbuild/win32-x64" "0.24.2" + +esbuild@^0.25.0: version "0.25.0" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92" integrity sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw== @@ -6085,6 +6241,11 @@ eslint-plugin-jsx-a11y@^6.7.1: resolved "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz#4d55c50e186f1a2b0636433d2b0b2f592ddbccfd" integrity sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw== +eslint-plugin-react-hooks@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz#1be0080901e6ac31ce7971beed3d3ec0a423d9e3" + integrity sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg== + eslint-plugin-react@^7.33.2: version "7.37.4" resolved "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.4.tgz#1b6c80b6175b6ae4b26055ae4d55d04c414c7181" @@ -8396,7 +8557,7 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@3.3.8, nanoid@^3.3.6, nanoid@^3.3.8: +nanoid@^3.3.6, nanoid@^3.3.8: version "3.3.8" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== @@ -10556,16 +10717,7 @@ streamx@^2.15.0, streamx@^2.21.0: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10658,14 +10810,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11883,16 +12028,7 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 643390e723cd9426d87ecb360380944ad50a16a3 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Fri, 23 May 2025 15:30:42 +0530 Subject: [PATCH 07/23] [WEB-4145] chore: added validation for project deletion #7101 --- apiserver/plane/app/views/project/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 31cbd83305..8e4ea52464 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -445,7 +445,7 @@ class ProjectViewSet(BaseViewSet): is_active=True, ).exists() ): - project = Project.objects.get(pk=pk) + project = Project.objects.get(pk=pk, workspace__slug=slug) project.delete() webhook_activity.delay( event="project", From 037bb88b53e2333ecbe0acf9a17f6d10a5b05bb4 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Fri, 23 May 2025 15:31:40 +0530 Subject: [PATCH 08/23] [WEB-4144] fix: api logger to handle content decode errors #7099 --- apiserver/plane/middleware/logger.py | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/apiserver/plane/middleware/logger.py b/apiserver/plane/middleware/logger.py index 166de17c2d..7481c39924 100644 --- a/apiserver/plane/middleware/logger.py +++ b/apiserver/plane/middleware/logger.py @@ -83,6 +83,32 @@ class APITokenLogMiddleware: self.process_request(request, response, request_body) return response + def _safe_decode_body(self, content): + """ + Safely decodes request/response body content, handling binary data. + Returns None if content is None, or a string representation of the content. + """ + # If the content is None, return None + if content is None: + return None + + # If the content is an empty bytes object, return None + if content == b"": + return None + + # Check if content is binary by looking for common binary file signatures + if ( + content.startswith(b"\x89PNG") + or content.startswith(b"\xff\xd8\xff") + or content.startswith(b"%PDF") + ): + return "[Binary Content]" + + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return "[Could not decode content]" + def process_request(self, request, response, request_body): api_key_header = "X-Api-Key" api_key = request.headers.get(api_key_header) @@ -95,9 +121,13 @@ class APITokenLogMiddleware: method=request.method, query_params=request.META.get("QUERY_STRING", ""), headers=str(request.headers), - body=(request_body.decode("utf-8") if request_body else None), + body=( + self._safe_decode_body(request_body) if request_body else None + ), response_body=( - response.content.decode("utf-8") if response.content else None + self._safe_decode_body(response.content) + if response.content + else None ), response_code=response.status_code, ip_address=get_client_ip(request=request), From cd200169b6b9d66890084a30ddff3f0ebc29990d Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 23 May 2025 15:32:41 +0530 Subject: [PATCH 09/23] [WEB-4107] chore: redirect user to the newly created project view after creation #7098 --- web/core/components/views/modal.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/core/components/views/modal.tsx b/web/core/components/views/modal.tsx index 0ec329ac10..8e708eb2bd 100644 --- a/web/core/components/views/modal.tsx +++ b/web/core/components/views/modal.tsx @@ -10,6 +10,7 @@ import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@p import { ProjectViewForm } from "@/components/views"; // hooks import { useProjectView } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; import useKeypress from "@/hooks/use-keypress"; type Props = { @@ -23,6 +24,8 @@ type Props = { export const CreateUpdateProjectViewModal: FC = observer((props) => { const { data, isOpen, onClose, preLoadedData, workspaceSlug, projectId } = props; + // router + const router = useAppRouter(); // store hooks const { createView, updateView } = useProjectView(); @@ -32,8 +35,9 @@ export const CreateUpdateProjectViewModal: FC = observer((props) => { const handleCreateView = async (payload: IProjectView) => { await createView(workspaceSlug, projectId, payload) - .then(() => { + .then((res) => { handleClose(); + router.push(`/${workspaceSlug}/projects/${projectId}/views/${res.id}`); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", From 6eb0b5ddb0513d529723977b46963802dbc4a1c8 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 23 May 2025 15:33:56 +0530 Subject: [PATCH 10/23] [WEB-4137] chore: restrict SVG file selection (#7095) * chore: update accepted file mime types * chore: update accepted file mime types --- packages/constants/src/file.ts | 13 +++++++++++++ web/core/components/core/image-picker-popover.tsx | 6 ++---- .../core/modals/user-image-upload-modal.tsx | 6 ++---- .../core/modals/workspace-image-upload-modal.tsx | 6 ++---- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/constants/src/file.ts b/packages/constants/src/file.ts index 3fac821faa..9de3b0356b 100644 --- a/packages/constants/src/file.ts +++ b/packages/constants/src/file.ts @@ -1 +1,14 @@ export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +export const ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE = { + "image/jpeg": [], + "image/jpg": [], + "image/png": [], + "image/webp": [], +}; +export const ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE = { + "image/jpeg": [], + "image/jpg": [], + "image/png": [], + "image/webp": [], +}; diff --git a/web/core/components/core/image-picker-popover.tsx b/web/core/components/core/image-picker-popover.tsx index e54805d0f2..bade076d9b 100644 --- a/web/core/components/core/image-picker-popover.tsx +++ b/web/core/components/core/image-picker-popover.tsx @@ -9,7 +9,7 @@ import { Control, Controller } from "react-hook-form"; import useSWR from "swr"; import { Tab, Popover } from "@headlessui/react"; // plane imports -import { MAX_FILE_SIZE } from "@plane/constants"; +import { ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; import { useOutsideClickDetector } from "@plane/hooks"; // plane types import { EFileAssetType } from "@plane/types/src/enums"; @@ -88,9 +88,7 @@ export const ImagePickerPopover: React.FC = observer((props) => { const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, - accept: { - "image/*": [".png", ".jpg", ".jpeg", ".webp"], - }, + accept: ACCEPTED_COVER_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, maxSize: MAX_FILE_SIZE, }); diff --git a/web/core/components/core/modals/user-image-upload-modal.tsx b/web/core/components/core/modals/user-image-upload-modal.tsx index 286f364151..c768ff2652 100644 --- a/web/core/components/core/modals/user-image-upload-modal.tsx +++ b/web/core/components/core/modals/user-image-upload-modal.tsx @@ -6,7 +6,7 @@ import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; // plane imports -import { MAX_FILE_SIZE } from "@plane/constants"; +import { ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; import { EFileAssetType } from "@plane/types/src/enums"; import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // helpers @@ -35,9 +35,7 @@ export const UserImageUploadModal: React.FC = observer((props) => { const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, - accept: { - "image/*": [".png", ".jpg", ".jpeg", ".webp"], - }, + accept: ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, maxSize: MAX_FILE_SIZE, multiple: false, }); diff --git a/web/core/components/core/modals/workspace-image-upload-modal.tsx b/web/core/components/core/modals/workspace-image-upload-modal.tsx index df0248c843..6b4bf36099 100644 --- a/web/core/components/core/modals/workspace-image-upload-modal.tsx +++ b/web/core/components/core/modals/workspace-image-upload-modal.tsx @@ -6,7 +6,7 @@ import { useDropzone } from "react-dropzone"; import { UserCircle2 } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; // plane imports -import { MAX_FILE_SIZE } from "@plane/constants"; +import { ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, MAX_FILE_SIZE } from "@plane/constants"; import { EFileAssetType } from "@plane/types/src/enums"; import { Button } from "@plane/ui"; // helpers @@ -43,9 +43,7 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({ onDrop, - accept: { - "image/*": [".png", ".jpg", ".jpeg", ".webp"], - }, + accept: ACCEPTED_AVATAR_IMAGE_MIME_TYPES_FOR_REACT_DROPZONE, maxSize: MAX_FILE_SIZE, multiple: false, }); From 5223bd01e82acd00581323abee872e053ea32f39 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Fri, 23 May 2025 15:35:47 +0530 Subject: [PATCH 11/23] [WEB-4153] chore: extend custom font family in tailwind config (#7093) * chore: remove unwanted font family * chore: add font family to extend object --- packages/tailwind-config/tailwind.config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js index 700831d123..5beff4bf83 100644 --- a/packages/tailwind-config/tailwind.config.js +++ b/packages/tailwind-config/tailwind.config.js @@ -461,9 +461,9 @@ module.exports = { "onboarding-gradient-200": "var( --gradient-onboarding-200)", "onboarding-gradient-300": "var( --gradient-onboarding-300)", }, - }, - fontFamily: { - custom: ["Inter", "sans-serif"], + fontFamily: { + custom: ["Inter", "sans-serif"], + }, }, }, plugins: [ From a3b9152a9bb741504b3ee456b0f995c0c5aaf90d Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Fri, 23 May 2025 15:36:47 +0530 Subject: [PATCH 12/23] [WEB-4123]feat: language support for sub-work item empty states #7092 --- .../i18n/src/locales/cs/translations.json | 12 +++++++ .../i18n/src/locales/de/translations.json | 12 +++++++ .../i18n/src/locales/en/translations.json | 14 +++++++- .../i18n/src/locales/es/translations.json | 12 +++++++ .../i18n/src/locales/fr/translations.json | 12 +++++++ .../i18n/src/locales/id/translations.json | 12 +++++++ .../i18n/src/locales/it/translations.json | 12 +++++++ .../i18n/src/locales/ja/translations.json | 12 +++++++ .../i18n/src/locales/ko/translations.json | 12 +++++++ .../i18n/src/locales/pl/translations.json | 12 +++++++ .../i18n/src/locales/pt-BR/translations.json | 12 +++++++ .../i18n/src/locales/ro/translations.json | 12 +++++++ .../i18n/src/locales/ru/translations.json | 12 +++++++ .../i18n/src/locales/sk/translations.json | 12 +++++++ .../i18n/src/locales/tr-TR/translations.json | 12 +++++++ .../i18n/src/locales/ua/translations.json | 12 +++++++ .../i18n/src/locales/vi-VN/translations.json | 12 +++++++ .../i18n/src/locales/zh-CN/translations.json | 12 +++++++ .../i18n/src/locales/zh-TW/translations.json | 12 +++++++ .../sub-issues/issues-list/root.tsx | 33 ++++++++++--------- 20 files changed, 247 insertions(+), 16 deletions(-) diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index f41ae90216..28109cd9d2 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -1108,6 +1108,18 @@ "remove": { "success": "Podřízená pracovní položka úspěšně odebrána", "error": "Chyba při odebírání podřízené položky" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nemáte podřízené pracovní položky, které odpovídají použitým filtrům.", + "description": "Chcete-li zobrazit všechny podřízené pracovní položky, odstraňte všechny použité filtry.", + "action": "Odstranit filtry" + }, + "list_filters": { + "title": "Nemáte pracovní položky, které odpovídají použitým filtrům.", + "description": "Chcete-li zobrazit všechny pracovní položky, odstraňte všechny použité filtry.", + "action": "Odstranit filtry" + } } }, "view": { diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index 857a08a8a7..1cc3707287 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -1108,6 +1108,18 @@ "remove": { "success": "Untergeordnetes Arbeitselement erfolgreich entfernt", "error": "Fehler beim Entfernen des untergeordneten Elements" + }, + "empty_state": { + "sub_list_filters": { + "title": "Sie haben keine untergeordneten Arbeitselemente, die den von Ihnen angewendeten Filtern entsprechen.", + "description": "Um alle untergeordneten Arbeitselemente anzuzeigen, entfernen Sie alle angewendeten Filter.", + "action": "Filter entfernen" + }, + "list_filters": { + "title": "Sie haben keine Arbeitselemente, die den von Ihnen angewendeten Filtern entsprechen.", + "description": "Um alle Arbeitselemente anzuzeigen, entfernen Sie alle angewendeten Filter.", + "action": "Filter entfernen" + } } }, "view": { diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index e6202deb6e..c959108e0a 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -944,6 +944,18 @@ "remove": { "success": "Sub-work item removed successfully", "error": "Error removing sub-work item" + }, + "empty_state": { + "sub_list_filters": { + "title": "You don't have sub-work items that match the filters you've applied.", + "description": "To see all sub-work items, clear all applied filters.", + "action": "Clear filters" + }, + "list_filters": { + "title": "You don't have work items that match the filters you've applied.", + "description": "To see all work items, clear all applied filters.", + "action": "Clear filters" + } } }, "view": { @@ -2284,4 +2296,4 @@ "previously_edited_by": "Previously edited by", "edited_by": "Edited by" } -} \ No newline at end of file +} diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index c8f258385c..94f1819a48 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -1111,6 +1111,18 @@ "remove": { "success": "Sub-elemento eliminado correctamente", "error": "Error al eliminar el sub-elemento" + }, + "empty_state": { + "sub_list_filters": { + "title": "No tienes sub-elementos de trabajo que coincidan con los filtros que has aplicado.", + "description": "Para ver todos los sub-elementos de trabajo, elimina todos los filtros aplicados.", + "action": "Eliminar filtros" + }, + "list_filters": { + "title": "No tienes elementos de trabajo que coincidan con los filtros que has aplicado.", + "description": "Para ver todos los elementos de trabajo, elimina todos los filtros aplicados.", + "action": "Eliminar filtros" + } } }, "view": { diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index a92d64daa9..4f356f1491 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -1109,6 +1109,18 @@ "remove": { "success": "Sous-élément de travail supprimé avec succès", "error": "Erreur lors de la suppression du sous-élément de travail" + }, + "empty_state": { + "sub_list_filters": { + "title": "Vous n'avez pas de sous-éléments de travail qui correspondent aux filtres que vous avez appliqués.", + "description": "Pour voir tous les sous-éléments de travail, effacer tous les filtres appliqués.", + "action": "Effacer les filtres" + }, + "list_filters": { + "title": "Vous n'avez pas d'éléments de travail qui correspondent aux filtres que vous avez appliqués.", + "description": "Pour voir tous les éléments de travail, effacer tous les filtres appliqués.", + "action": "Effacer les filtres" + } } }, "view": { diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index f1fb2a827b..20b683c65f 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -1108,6 +1108,18 @@ "remove": { "success": "Sub-item kerja berhasil dihapus", "error": "Kesalahan saat menghapus sub-item kerja" + }, + "empty_state": { + "sub_list_filters": { + "title": "Anda tidak memiliki sub-item kerja yang cocok dengan filter yang Anda terapkan.", + "description": "Untuk melihat semua sub-item kerja, hapus semua filter yang diterapkan.", + "action": "Hapus filter" + }, + "list_filters": { + "title": "Anda tidak memiliki item kerja yang cocok dengan filter yang Anda terapkan.", + "description": "Untuk melihat semua item kerja, hapus semua filter yang diterapkan.", + "action": "Hapus filter" + } } }, "view": { diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index 1311c625ed..5534d885c1 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -1107,6 +1107,18 @@ "remove": { "success": "Sotto-elemento di lavoro rimosso con successo", "error": "Errore nella rimozione del sotto-elemento di lavoro" + }, + "empty_state": { + "sub_list_filters": { + "title": "Non hai sotto-elementi di lavoro che corrispondono ai filtri che hai applicato.", + "description": "Per vedere tutti i sotto-elementi di lavoro, cancella tutti i filtri applicati.", + "action": "Cancella filtri" + }, + "list_filters": { + "title": "Non hai elementi di lavoro che corrispondono ai filtri che hai applicato.", + "description": "Per vedere tutti gli elementi di lavoro, cancella tutti i filtri applicati.", + "action": "Cancella filtri" + } } }, "view": { diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index 70bcd1c8fc..a6f36a65b5 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -1109,6 +1109,18 @@ "remove": { "success": "サブ作業項目を削除しました", "error": "サブ作業項目の削除中にエラーが発生しました" + }, + "empty_state": { + "sub_list_filters": { + "title": "適用されたフィルターに一致するサブ作業項目がありません。", + "description": "すべてのサブ作業項目を表示するには、すべての適用されたフィルターをクリアしてください。", + "action": "フィルターをクリア" + }, + "list_filters": { + "title": "適用されたフィルターに一致する作業項目がありません。", + "description": "すべての作業項目を表示するには、すべての適用されたフィルターをクリアしてください。", + "action": "フィルターをクリア" + } } }, "view": { diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index eee2ffbf05..2858d729cb 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -1110,6 +1110,18 @@ "remove": { "success": "하위 작업 항목이 성공적으로 제거되었습니다", "error": "하위 작업 항목 제거 중 오류 발생" + }, + "empty_state": { + "sub_list_filters": { + "title": "적용된 필터에 일치하는 하위 작업 항목이 없습니다.", + "description": "모든 하위 작업 항목을 보려면 모든 적용된 필터를 지우세요.", + "action": "필터 지우기" + }, + "list_filters": { + "title": "적용된 필터에 일치하는 작업 항목이 없습니다.", + "description": "모든 작업 항목을 보려면 모든 적용된 필터를 지우세요.", + "action": "필터 지우기" + } } }, "view": { diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 7ff792a26f..d11005833f 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -1110,6 +1110,18 @@ "remove": { "success": "Podrzędny element pracy usunięto pomyślnie", "error": "Błąd podczas usuwania elementu podrzędnego" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nie masz elementów podrzędnych, które pasują do filtrów, które zastosowałeś.", + "description": "Aby zobaczyć wszystkie elementy podrzędne, wyczyść wszystkie zastosowane filtry.", + "action": "Wyczyść filtry" + }, + "list_filters": { + "title": "Nie masz elementów pracy, które pasują do filtrów, które zastosowałeś.", + "description": "Aby zobaczyć wszystkie elementy pracy, wyczyść wszystkie zastosowane filtry.", + "action": "Wyczyść filtry" + } } }, "view": { diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index a5eb08a861..de630da974 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -1110,6 +1110,18 @@ "remove": { "success": "Sub-item de trabalho removido com sucesso", "error": "Erro ao remover sub-item de trabalho" + }, + "empty_state": { + "sub_list_filters": { + "title": "Você não tem sub-itens de trabalho que correspondem aos filtros que você aplicou.", + "description": "Para ver todos os sub-itens de trabalho, limpe todos os filtros aplicados.", + "action": "Limpar filtros" + }, + "list_filters": { + "title": "Você não tem itens de trabalho que correspondem aos filtros que você aplicou.", + "description": "Para ver todos os itens de trabalho, limpe todos os filtros aplicados.", + "action": "Limpar filtros" + } } }, "view": { diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index a88ef06093..f60a4881b5 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -1108,6 +1108,18 @@ "remove": { "success": "Sub-activitatea a fost eliminată cu succes", "error": "Eroare la eliminarea sub-activității" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nu ai sub-elemente de lucru care corespund filtrelor pe care le-ai aplicat.", + "description": "Pentru a vedea toate sub-elementele de lucru, șterge toate filtrele aplicate.", + "action": "Șterge filtrele" + }, + "list_filters": { + "title": "Nu ai elemente de lucru care corespund filtrelor pe care le-ai aplicat.", + "description": "Pentru a vedea toate elementele de lucru, șterge toate filtrele aplicate.", + "action": "Șterge filtrele" + } } }, "view": { diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 4159cccd3d..564716529d 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -1110,6 +1110,18 @@ "remove": { "success": "Подэлемент успешно удален", "error": "Ошибка удаления подэлемента" + }, + "empty_state": { + "sub_list_filters": { + "title": "У вас нет подэлементов, которые соответствуют примененным фильтрам.", + "description": "Чтобы увидеть все подэлементы, очистите все примененные фильтры.", + "action": "Очистить фильтры" + }, + "list_filters": { + "title": "У вас нет рабочих элементов, которые соответствуют примененным фильтрам.", + "description": "Чтобы увидеть все рабочие элементы, очистите все примененные фильтры.", + "action": "Очистить фильтры" + } } }, "view": { diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index f7093e59cb..60f2c21ca8 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -1110,6 +1110,18 @@ "remove": { "success": "Podriadená pracovná položka bola úspešne odstránená", "error": "Chyba pri odstraňovaní podriadenej položky" + }, + "empty_state": { + "sub_list_filters": { + "title": "Nemáte podriadené pracovné položky, ktoré zodpovedajú použitým filtrom.", + "description": "Pre zobrazenie všetkých podriadených pracovných položiek vymažte všetky použité filtre.", + "action": "Vymazať filtre" + }, + "list_filters": { + "title": "Nemáte pracovné položky, ktoré zodpovedajú použitým filtrom.", + "description": "Pre zobrazenie všetkých pracovných položiek vymažte všetky použité filtre.", + "action": "Vymazať filtre" + } } }, "view": { diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index de2cb9d7f2..cec11a9921 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -1111,6 +1111,18 @@ "remove": { "success": "Alt iş öğesi başarıyla kaldırıldı", "error": "Alt iş öğesi kaldırılırken hata oluştu" + }, + "empty_state": { + "sub_list_filters": { + "title": "Alt iş öğelerinizin filtreleriyle eşleşmiyor.", + "description": "Tüm alt iş öğelerini görmek için tüm uygulanan filtreleri temizleyin.", + "action": "Filtreleri temizle" + }, + "list_filters": { + "title": "İş öğelerinizin filtreleriyle eşleşmiyor.", + "description": "Tüm iş öğelerini görmek için tüm uygulanan filtreleri temizleyin.", + "action": "Filtreleri temizle" + } } }, "view": { diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index 9be16b11d9..2a82df68f5 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -1110,6 +1110,18 @@ "remove": { "success": "Похідну робочу одиницю успішно вилучено", "error": "Помилка під час вилучення похідної одиниці" + }, + "empty_state": { + "sub_list_filters": { + "title": "Ви не маєте похідних робочих одиниць, які відповідають застосованим фільтрам.", + "description": "Щоб побачити всі похідні робочі одиниці, очистіть всі застосовані фільтри.", + "action": "Очистити фільтри" + }, + "list_filters": { + "title": "Ви не маєте робочих одиниць, які відповідають застосованим фільтрам.", + "description": "Щоб побачити всі робочі одиниці, очистіть всі застосовані фільтри.", + "action": "Очистити фільтри" + } } }, "view": { diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index 756eea3d97..418d96ac43 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -1109,6 +1109,18 @@ "remove": { "success": "Đã xóa mục công việc con thành công", "error": "Đã xảy ra lỗi khi xóa mục công việc con" + }, + "empty_state": { + "sub_list_filters": { + "title": "Bạn không có mục công việc con nào phù hợp với các bộ lọc mà bạn đã áp dụng.", + "description": "Để xem tất cả các mục công việc con, hãy xóa tất cả các bộ lọc đã áp dụng.", + "action": "Xóa bộ lọc" + }, + "list_filters": { + "title": "Bạn không có mục công việc nào phù hợp với các bộ lọc mà bạn đã áp dụng.", + "description": "Để xem tất cả các mục công việc, hãy xóa tất cả các bộ lọc đã áp dụng.", + "action": "Xóa bộ lọc" + } } }, "view": { diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 8dbdb55233..8f8ca2d26f 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -1109,6 +1109,18 @@ "remove": { "success": "子工作项移除成功", "error": "移除子工作项时出错" + }, + "empty_state": { + "sub_list_filters": { + "title": "您没有符合您应用的过滤器的子工作项。", + "description": "要查看所有子工作项,请清除所有应用的过滤器。", + "action": "清除过滤器" + }, + "list_filters": { + "title": "您没有符合您应用的过滤器的工作项。", + "description": "要查看所有工作项,请清除所有应用的过滤器。", + "action": "清除过滤器" + } } }, "view": { diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index 53888c516a..472ba631c1 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -1110,6 +1110,18 @@ "remove": { "success": "子工作事項移除成功", "error": "移除子工作事項時發生錯誤" + }, + "empty_state": { + "sub_list_filters": { + "title": "您沒有符合您應用過的過濾器的子工作事項。", + "description": "要查看所有子工作事項,請清除所有應用過的過濾器。", + "action": "清除過濾器" + }, + "list_filters": { + "title": "您沒有符合您應用過的過濾器的工作事項。", + "description": "要查看所有工作事項,請清除所有應用過的過濾器。", + "action": "清除過濾器" + } } }, "view": { diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx index 6b101829e7..c99f990864 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/root.tsx @@ -3,6 +3,7 @@ import { observer } from "mobx-react"; // plane imports import { ListFilter } from "lucide-react"; import { EIssueServiceType, EIssuesStoreType } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types"; // hooks import { Button, Loader } from "@plane/ui"; @@ -41,10 +42,12 @@ export const SubIssuesListRoot: React.FC = observer((props) => { storeType = EIssuesStoreType.PROJECT, spacingLeft = 0, } = props; + const { t } = useTranslation(); // store hooks const { subIssues: { - subIssuesByIssueId, loader, + subIssuesByIssueId, + loader, filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters }, }, } = useIssueDetail(issueServiceType); @@ -77,15 +80,15 @@ export const SubIssuesListRoot: React.FC = observer((props) => { const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES; - if (loader === "init-loader") { - return ( - - {Array.from({ length: 5 }).map((_, index) => ( - - ))} - - ); - } + if (loader === "init-loader") { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + ))} + + ); + } return (
@@ -93,19 +96,19 @@ export const SubIssuesListRoot: React.FC = observer((props) => { } customClassName={storeType !== EIssuesStoreType.EPIC ? "border-none" : ""} actionElement={ } /> From f8ca1e46b15684e281aa36e86b39c58e30b8d24b Mon Sep 17 00:00:00 2001 From: Aaron Heckmann Date: Fri, 23 May 2025 03:42:04 -0700 Subject: [PATCH 13/23] [WEB-4098] feat: noindex/nofollow (#7088) * feat: noindex/nofollow - On login: nofollow - On app pages: noindex, nofollow https://app.plane.so/plane/browse/WEB-4098/ - https://nextjs.org/docs/app/api-reference/file-conventions/layout - https://nextjs.org/docs/app/building-your-application/routing/route-groups#creating-multiple-root-layouts - https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload * chore: address PR feedback --- .../(projects)/active-cycles/header.tsx | 0 .../(projects)/active-cycles/layout.tsx | 0 .../(projects)/active-cycles/page.tsx | 0 .../(projects)/analytics/header.tsx | 0 .../(projects)/analytics/layout.tsx | 0 .../(projects)/analytics/page.tsx | 0 .../(projects)/browse/[workItem]/header.tsx | 0 .../(projects)/browse/[workItem]/layout.tsx | 0 .../(projects)/browse/[workItem]/page.tsx | 0 .../(projects)/drafts/header.tsx | 0 .../(projects)/drafts/layout.tsx | 0 .../(projects)/drafts/page.tsx | 0 .../(projects)/extended-project-sidebar.tsx | 0 .../(projects)/extended-sidebar.tsx | 0 .../[workspaceSlug]/(projects)/header.tsx | 0 .../[workspaceSlug]/(projects)/layout.tsx | 0 .../(projects)/notifications/layout.tsx | 0 .../(projects)/notifications/page.tsx | 0 .../[workspaceSlug]/(projects)/page.tsx | 0 .../profile/[userId]/[profileViewId]/page.tsx | 0 .../profile/[userId]/activity/page.tsx | 0 .../(projects)/profile/[userId]/header.tsx | 0 .../(projects)/profile/[userId]/layout.tsx | 0 .../profile/[userId]/mobile-header.tsx | 0 .../(projects)/profile/[userId]/navbar.tsx | 0 .../(projects)/profile/[userId]/page.tsx | 0 .../[projectId]/archives/cycles/layout.tsx | 0 .../[projectId]/archives/cycles/page.tsx | 0 .../(detail)/[projectId]/archives/header.tsx | 0 .../(detail)/[archivedIssueId]/page.tsx | 0 .../archives/issues/(detail)/header.tsx | 0 .../archives/issues/(detail)/layout.tsx | 0 .../archives/issues/(list)/layout.tsx | 0 .../archives/issues/(list)/page.tsx | 0 .../[projectId]/archives/modules/layout.tsx | 0 .../[projectId]/archives/modules/page.tsx | 0 .../cycles/(detail)/[cycleId]/page.tsx | 0 .../[projectId]/cycles/(detail)/header.tsx | 0 .../[projectId]/cycles/(detail)/layout.tsx | 0 .../cycles/(detail)/mobile-header.tsx | 0 .../[projectId]/cycles/(list)/header.tsx | 0 .../[projectId]/cycles/(list)/layout.tsx | 0 .../cycles/(list)/mobile-header.tsx | 0 .../[projectId]/cycles/(list)/page.tsx | 0 .../[projectId]/draft-issues/header.tsx | 0 .../[projectId]/draft-issues/layout.tsx | 0 .../[projectId]/draft-issues/page.tsx | 0 .../(detail)/[projectId]/intake/layout.tsx | 0 .../(detail)/[projectId]/intake/page.tsx | 0 .../issues/(detail)/[issueId]/page.tsx | 0 .../[projectId]/issues/(list)/header.tsx | 0 .../[projectId]/issues/(list)/layout.tsx | 0 .../issues/(list)/mobile-header.tsx | 0 .../[projectId]/issues/(list)/page.tsx | 0 .../modules/(detail)/[moduleId]/page.tsx | 0 .../[projectId]/modules/(detail)/header.tsx | 0 .../[projectId]/modules/(detail)/layout.tsx | 0 .../modules/(detail)/mobile-header.tsx | 0 .../[projectId]/modules/(list)/header.tsx | 0 .../[projectId]/modules/(list)/layout.tsx | 0 .../modules/(list)/mobile-header.tsx | 0 .../[projectId]/modules/(list)/page.tsx | 0 .../pages/(detail)/[pageId]/page.tsx | 0 .../[projectId]/pages/(detail)/header.tsx | 0 .../[projectId]/pages/(detail)/layout.tsx | 0 .../[projectId]/pages/(list)/header.tsx | 0 .../[projectId]/pages/(list)/layout.tsx | 0 .../[projectId]/pages/(list)/page.tsx | 0 .../(with-sidebar)/automations/page.tsx | 0 .../(with-sidebar)/estimates/page.tsx | 0 .../settings/(with-sidebar)/features/page.tsx | 0 .../settings/(with-sidebar)/labels/page.tsx | 0 .../settings/(with-sidebar)/layout.tsx | 0 .../settings/(with-sidebar)/members/page.tsx | 0 .../settings/(with-sidebar)/page.tsx | 0 .../settings/(with-sidebar)/sidebar.tsx | 0 .../settings/(with-sidebar)/states/page.tsx | 0 .../(detail)/[projectId]/settings/header.tsx | 0 .../views/(detail)/[viewId]/header.tsx | 0 .../views/(detail)/[viewId]/page.tsx | 0 .../[projectId]/views/(detail)/layout.tsx | 0 .../[projectId]/views/(list)/header.tsx | 0 .../[projectId]/views/(list)/layout.tsx | 0 .../views/(list)/mobile-header.tsx | 0 .../[projectId]/views/(list)/page.tsx | 0 .../projects/(detail)/archives/layout.tsx | 0 .../projects/(detail)/archives/page.tsx | 0 .../(projects)/projects/(detail)/layout.tsx | 0 .../(projects)/projects/(list)/layout.tsx | 0 .../(projects)/projects/(list)/page.tsx | 0 .../(with-sidebar)/api-tokens/page.tsx | 0 .../settings/(with-sidebar)/billing/page.tsx | 0 .../settings/(with-sidebar)/exports/page.tsx | 0 .../settings/(with-sidebar)/imports/page.tsx | 0 .../(with-sidebar)/integrations/page.tsx | 0 .../settings/(with-sidebar)/layout.tsx | 0 .../settings/(with-sidebar)/members/page.tsx | 0 .../(with-sidebar)/mobile-header-tabs.tsx | 0 .../settings/(with-sidebar)/page.tsx | 0 .../settings/(with-sidebar)/sidebar.tsx | 0 .../webhooks/[webhookId]/page.tsx | 0 .../settings/(with-sidebar)/webhooks/page.tsx | 0 .../(projects)/settings/header.tsx | 0 .../[workspaceSlug]/(projects)/sidebar.tsx | 0 .../(projects)/stickies/header.tsx | 0 .../(projects)/stickies/layout.tsx | 0 .../(projects)/stickies/page.tsx | 0 .../workspace-views/[globalViewId]/page.tsx | 0 .../(projects)/workspace-views/header.tsx | 0 .../(projects)/workspace-views/layout.tsx | 0 .../(projects)/workspace-views/page.tsx | 0 .../accounts/forgot-password/layout.tsx | 0 .../accounts/forgot-password/page.tsx | 0 .../accounts/reset-password/layout.tsx | 0 .../accounts/reset-password/page.tsx | 0 .../accounts/set-password/layout.tsx | 0 .../accounts/set-password/page.tsx | 0 .../{ => (all)}/create-workspace/layout.tsx | 0 web/app/{ => (all)}/create-workspace/page.tsx | 0 .../installations/[provider]/layout.tsx | 0 .../installations/[provider]/page.tsx | 0 web/app/{ => (all)}/invitations/layout.tsx | 0 web/app/{ => (all)}/invitations/page.tsx | 0 web/app/(all)/layout.preload.tsx | 28 +++++++++++++++++ web/app/(all)/layout.tsx | 31 +++++++++++++++++++ web/app/{ => (all)}/onboarding/layout.tsx | 0 web/app/{ => (all)}/onboarding/page.tsx | 0 web/app/{ => (all)}/profile/activity/page.tsx | 0 .../{ => (all)}/profile/appearance/page.tsx | 0 web/app/{ => (all)}/profile/layout.tsx | 0 .../profile/notifications/page.tsx | 0 web/app/{ => (all)}/profile/page.tsx | 0 web/app/{ => (all)}/profile/security/page.tsx | 0 web/app/{ => (all)}/profile/sidebar.tsx | 0 web/app/{ => (all)}/sign-up/layout.tsx | 4 +++ web/app/{ => (all)}/sign-up/page.tsx | 0 .../workspace-invitations/layout.tsx | 0 .../workspace-invitations/page.tsx | 0 web/app/(home)/layout.tsx | 21 +++++++++++++ web/app/{ => (home)}/page.tsx | 0 web/app/layout.tsx | 20 +++--------- web/app/not-found.tsx | 4 +++ 142 files changed, 92 insertions(+), 16 deletions(-) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/active-cycles/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/active-cycles/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/active-cycles/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/analytics/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/analytics/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/analytics/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/drafts/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/drafts/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/drafts/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/extended-project-sidebar.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/extended-sidebar.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/notifications/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/notifications/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/profile/[userId]/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/profile/[userId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(list)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/projects/(list)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/settings/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/sidebar.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/stickies/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/stickies/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/stickies/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/workspace-views/header.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/workspace-views/layout.tsx (100%) rename web/app/{ => (all)}/[workspaceSlug]/(projects)/workspace-views/page.tsx (100%) rename web/app/{ => (all)}/accounts/forgot-password/layout.tsx (100%) rename web/app/{ => (all)}/accounts/forgot-password/page.tsx (100%) rename web/app/{ => (all)}/accounts/reset-password/layout.tsx (100%) rename web/app/{ => (all)}/accounts/reset-password/page.tsx (100%) rename web/app/{ => (all)}/accounts/set-password/layout.tsx (100%) rename web/app/{ => (all)}/accounts/set-password/page.tsx (100%) rename web/app/{ => (all)}/create-workspace/layout.tsx (100%) rename web/app/{ => (all)}/create-workspace/page.tsx (100%) rename web/app/{ => (all)}/installations/[provider]/layout.tsx (100%) rename web/app/{ => (all)}/installations/[provider]/page.tsx (100%) rename web/app/{ => (all)}/invitations/layout.tsx (100%) rename web/app/{ => (all)}/invitations/page.tsx (100%) create mode 100644 web/app/(all)/layout.preload.tsx create mode 100644 web/app/(all)/layout.tsx rename web/app/{ => (all)}/onboarding/layout.tsx (100%) rename web/app/{ => (all)}/onboarding/page.tsx (100%) rename web/app/{ => (all)}/profile/activity/page.tsx (100%) rename web/app/{ => (all)}/profile/appearance/page.tsx (100%) rename web/app/{ => (all)}/profile/layout.tsx (100%) rename web/app/{ => (all)}/profile/notifications/page.tsx (100%) rename web/app/{ => (all)}/profile/page.tsx (100%) rename web/app/{ => (all)}/profile/security/page.tsx (100%) rename web/app/{ => (all)}/profile/sidebar.tsx (100%) rename web/app/{ => (all)}/sign-up/layout.tsx (79%) rename web/app/{ => (all)}/sign-up/page.tsx (100%) rename web/app/{ => (all)}/workspace-invitations/layout.tsx (100%) rename web/app/{ => (all)}/workspace-invitations/page.tsx (100%) create mode 100644 web/app/(home)/layout.tsx rename web/app/{ => (home)}/page.tsx (100%) diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/active-cycles/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/active-cycles/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/active-cycles/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/active-cycles/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/active-cycles/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/active-cycles/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/analytics/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/analytics/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/analytics/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/analytics/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/analytics/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/analytics/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/analytics/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/analytics/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/analytics/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/analytics/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/analytics/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/drafts/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/drafts/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/drafts/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/drafts/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/drafts/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/drafts/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/extended-project-sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/extended-project-sidebar.tsx diff --git a/web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/extended-sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx diff --git a/web/app/[workspaceSlug]/(projects)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/notifications/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/notifications/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/notifications/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/notifications/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/notifications/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/[profileViewId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/activity/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/navbar.tsx diff --git a/web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/profile/[userId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/profile/[userId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/cycles/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/[archivedIssueId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/issues/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/modules/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/[cycleId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(detail)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/cycles/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/draft-issues/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/intake/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(detail)/[issueId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/issues/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/[moduleId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(detail)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/modules/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/automations/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/estimates/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/features/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/labels/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/members/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/sidebar.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/(with-sidebar)/states/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/settings/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/[viewId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/mobile-header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/views/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/archives/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(detail)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(list)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/projects/(list)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/projects/(list)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/api-tokens/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/billing/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/exports/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/imports/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/integrations/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/members/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/mobile-header-tabs.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/sidebar.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/[webhookId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/(with-sidebar)/webhooks/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/settings/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/settings/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/settings/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/sidebar.tsx b/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/sidebar.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx diff --git a/web/app/[workspaceSlug]/(projects)/stickies/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/stickies/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/stickies/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/stickies/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/stickies/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/stickies/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/stickies/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/stickies/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/stickies/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/[globalViewId]/page.tsx diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/workspace-views/header.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/layout.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/workspace-views/layout.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/layout.tsx diff --git a/web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx b/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx similarity index 100% rename from web/app/[workspaceSlug]/(projects)/workspace-views/page.tsx rename to web/app/(all)/[workspaceSlug]/(projects)/workspace-views/page.tsx diff --git a/web/app/accounts/forgot-password/layout.tsx b/web/app/(all)/accounts/forgot-password/layout.tsx similarity index 100% rename from web/app/accounts/forgot-password/layout.tsx rename to web/app/(all)/accounts/forgot-password/layout.tsx diff --git a/web/app/accounts/forgot-password/page.tsx b/web/app/(all)/accounts/forgot-password/page.tsx similarity index 100% rename from web/app/accounts/forgot-password/page.tsx rename to web/app/(all)/accounts/forgot-password/page.tsx diff --git a/web/app/accounts/reset-password/layout.tsx b/web/app/(all)/accounts/reset-password/layout.tsx similarity index 100% rename from web/app/accounts/reset-password/layout.tsx rename to web/app/(all)/accounts/reset-password/layout.tsx diff --git a/web/app/accounts/reset-password/page.tsx b/web/app/(all)/accounts/reset-password/page.tsx similarity index 100% rename from web/app/accounts/reset-password/page.tsx rename to web/app/(all)/accounts/reset-password/page.tsx diff --git a/web/app/accounts/set-password/layout.tsx b/web/app/(all)/accounts/set-password/layout.tsx similarity index 100% rename from web/app/accounts/set-password/layout.tsx rename to web/app/(all)/accounts/set-password/layout.tsx diff --git a/web/app/accounts/set-password/page.tsx b/web/app/(all)/accounts/set-password/page.tsx similarity index 100% rename from web/app/accounts/set-password/page.tsx rename to web/app/(all)/accounts/set-password/page.tsx diff --git a/web/app/create-workspace/layout.tsx b/web/app/(all)/create-workspace/layout.tsx similarity index 100% rename from web/app/create-workspace/layout.tsx rename to web/app/(all)/create-workspace/layout.tsx diff --git a/web/app/create-workspace/page.tsx b/web/app/(all)/create-workspace/page.tsx similarity index 100% rename from web/app/create-workspace/page.tsx rename to web/app/(all)/create-workspace/page.tsx diff --git a/web/app/installations/[provider]/layout.tsx b/web/app/(all)/installations/[provider]/layout.tsx similarity index 100% rename from web/app/installations/[provider]/layout.tsx rename to web/app/(all)/installations/[provider]/layout.tsx diff --git a/web/app/installations/[provider]/page.tsx b/web/app/(all)/installations/[provider]/page.tsx similarity index 100% rename from web/app/installations/[provider]/page.tsx rename to web/app/(all)/installations/[provider]/page.tsx diff --git a/web/app/invitations/layout.tsx b/web/app/(all)/invitations/layout.tsx similarity index 100% rename from web/app/invitations/layout.tsx rename to web/app/(all)/invitations/layout.tsx diff --git a/web/app/invitations/page.tsx b/web/app/(all)/invitations/page.tsx similarity index 100% rename from web/app/invitations/page.tsx rename to web/app/(all)/invitations/page.tsx diff --git a/web/app/(all)/layout.preload.tsx b/web/app/(all)/layout.preload.tsx new file mode 100644 index 0000000000..18ca3b4b38 --- /dev/null +++ b/web/app/(all)/layout.preload.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import ReactDOM from "react-dom"; + +// https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload +export const usePreloadResources = () => { + useEffect(() => { + const preloadItem = (url: string) => { + ReactDOM.preload(url, { as: "fetch", crossOrigin: "use-credentials" }); + }; + + const urls = [ + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/instances/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/profile/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/settings/`, + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users/me/workspaces/?v=${Date.now()}`, + ]; + + urls.forEach(url => preloadItem(url)); + }, []); +}; + +export const PreloadResources = () => { + usePreloadResources(); + return null; +}; diff --git a/web/app/(all)/layout.tsx b/web/app/(all)/layout.tsx new file mode 100644 index 0000000000..0238526488 --- /dev/null +++ b/web/app/(all)/layout.tsx @@ -0,0 +1,31 @@ +import { Metadata, Viewport } from "next"; + +import { PreloadResources } from "./layout.preload"; + +// styles +import "@/styles/command-pallette.css"; +import "@/styles/emoji.css"; +import "@/styles/react-day-picker.css"; + +export const metadata: Metadata = { + robots: { + index: false, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function AppLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/web/app/onboarding/layout.tsx b/web/app/(all)/onboarding/layout.tsx similarity index 100% rename from web/app/onboarding/layout.tsx rename to web/app/(all)/onboarding/layout.tsx diff --git a/web/app/onboarding/page.tsx b/web/app/(all)/onboarding/page.tsx similarity index 100% rename from web/app/onboarding/page.tsx rename to web/app/(all)/onboarding/page.tsx diff --git a/web/app/profile/activity/page.tsx b/web/app/(all)/profile/activity/page.tsx similarity index 100% rename from web/app/profile/activity/page.tsx rename to web/app/(all)/profile/activity/page.tsx diff --git a/web/app/profile/appearance/page.tsx b/web/app/(all)/profile/appearance/page.tsx similarity index 100% rename from web/app/profile/appearance/page.tsx rename to web/app/(all)/profile/appearance/page.tsx diff --git a/web/app/profile/layout.tsx b/web/app/(all)/profile/layout.tsx similarity index 100% rename from web/app/profile/layout.tsx rename to web/app/(all)/profile/layout.tsx diff --git a/web/app/profile/notifications/page.tsx b/web/app/(all)/profile/notifications/page.tsx similarity index 100% rename from web/app/profile/notifications/page.tsx rename to web/app/(all)/profile/notifications/page.tsx diff --git a/web/app/profile/page.tsx b/web/app/(all)/profile/page.tsx similarity index 100% rename from web/app/profile/page.tsx rename to web/app/(all)/profile/page.tsx diff --git a/web/app/profile/security/page.tsx b/web/app/(all)/profile/security/page.tsx similarity index 100% rename from web/app/profile/security/page.tsx rename to web/app/(all)/profile/security/page.tsx diff --git a/web/app/profile/sidebar.tsx b/web/app/(all)/profile/sidebar.tsx similarity index 100% rename from web/app/profile/sidebar.tsx rename to web/app/(all)/profile/sidebar.tsx diff --git a/web/app/sign-up/layout.tsx b/web/app/(all)/sign-up/layout.tsx similarity index 79% rename from web/app/sign-up/layout.tsx rename to web/app/(all)/sign-up/layout.tsx index f7f405c279..3ae0977219 100644 --- a/web/app/sign-up/layout.tsx +++ b/web/app/(all)/sign-up/layout.tsx @@ -2,6 +2,10 @@ import { Metadata } from "next"; export const metadata: Metadata = { title: "Sign up - Plane", + robots: { + index: true, + follow: false, + } }; export default function SignUpLayout({ children }: { children: React.ReactNode }) { diff --git a/web/app/sign-up/page.tsx b/web/app/(all)/sign-up/page.tsx similarity index 100% rename from web/app/sign-up/page.tsx rename to web/app/(all)/sign-up/page.tsx diff --git a/web/app/workspace-invitations/layout.tsx b/web/app/(all)/workspace-invitations/layout.tsx similarity index 100% rename from web/app/workspace-invitations/layout.tsx rename to web/app/(all)/workspace-invitations/layout.tsx diff --git a/web/app/workspace-invitations/page.tsx b/web/app/(all)/workspace-invitations/page.tsx similarity index 100% rename from web/app/workspace-invitations/page.tsx rename to web/app/(all)/workspace-invitations/page.tsx diff --git a/web/app/(home)/layout.tsx b/web/app/(home)/layout.tsx new file mode 100644 index 0000000000..56380fb66b --- /dev/null +++ b/web/app/(home)/layout.tsx @@ -0,0 +1,21 @@ +import { Metadata, Viewport } from "next"; + +export const metadata: Metadata = { + robots: { + index: true, + follow: false, + }, +}; + +export const viewport: Viewport = { + minimumScale: 1, + initialScale: 1, + width: "device-width", + viewportFit: "cover", +}; + +export default function HomeLayout({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} diff --git a/web/app/page.tsx b/web/app/(home)/page.tsx similarity index 100% rename from web/app/page.tsx rename to web/app/(home)/page.tsx diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 6024753df8..a36c75c49b 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,15 +1,14 @@ import { Metadata, Viewport } from "next"; import Script from "next/script"; + // styles import "@/styles/globals.css"; -import "@/styles/command-pallette.css"; -import "@/styles/emoji.css"; -import "@/styles/react-day-picker.css"; -// meta data info import { SITE_DESCRIPTION, SITE_NAME } from "@plane/constants"; + // helpers -import { API_BASE_URL, cn } from "@/helpers/common.helper"; +import { cn } from "@/helpers/common.helper"; + // local import { AppProvider } from "./provider"; @@ -60,17 +59,6 @@ export default function RootLayout({ children }: { children: React.ReactNode }) - {/* preloading */} - - - - -
diff --git a/web/app/not-found.tsx b/web/app/not-found.tsx index ecc01b5008..1f1ec0e2c0 100644 --- a/web/app/not-found.tsx +++ b/web/app/not-found.tsx @@ -11,6 +11,10 @@ import Image404 from "@/public/404.svg"; export const metadata: Metadata = { title: "404 - Page Not Found", + robots: { + index: false, + follow: false, + }, }; const PageNotFound = () => ( From 5c9bdb1cea351e92ad0e1c98bbc3da2cf1ff4ee5 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Fri, 23 May 2025 16:13:09 +0530 Subject: [PATCH 14/23] [WEB-4133] fix: analytics release bugs (#7086) * fix: header text of insight table search * fix: made the active project list scrollable * chore: added xAxis label to table header * chore: removed the intake issues * fix: made the headerText necessary --------- Co-authored-by: NarayanBavisetti Co-authored-by: sriram veeraghanta --- apiserver/plane/app/views/analytic/advance.py | 40 +++++++++---------- .../analytics-v2/insight-table/root.tsx | 5 ++- .../analytics-v2/overview/active-projects.tsx | 2 +- .../work-items/priority-chart.tsx | 24 +++++------ .../work-items/workitems-insight-table.tsx | 1 + 5 files changed, 36 insertions(+), 36 deletions(-) diff --git a/apiserver/plane/app/views/analytic/advance.py b/apiserver/plane/app/views/analytic/advance.py index a79ab98c58..8a2aea90b7 100644 --- a/apiserver/plane/app/views/analytic/advance.py +++ b/apiserver/plane/app/views/analytic/advance.py @@ -16,6 +16,9 @@ from plane.db.models import ( IssueView, ProjectPage, Workspace, + CycleIssue, + ModuleIssue, + ProjectMember, ) from plane.utils.build_chart import build_analytics_chart from plane.utils.date_utils import ( @@ -69,32 +72,27 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): } def get_overview_data(self) -> Dict[str, Dict[str, int]]: + members_query = WorkspaceMember.objects.filter( + workspace__slug=self._workspace_slug, is_active=True + ) + + if self.request.GET.get("project_ids", None): + project_ids = self.request.GET.get("project_ids", None) + project_ids = [str(project_id) for project_id in project_ids.split(",")] + members_query = ProjectMember.objects.filter( + project_id__in=project_ids, is_active=True + ) + return { - "total_users": self.get_filtered_counts( - WorkspaceMember.objects.filter( - workspace__slug=self._workspace_slug, is_active=True - ) - ), + "total_users": self.get_filtered_counts(members_query), "total_admins": self.get_filtered_counts( - WorkspaceMember.objects.filter( - workspace__slug=self._workspace_slug, - role=ROLE.ADMIN.value, - is_active=True, - ) + members_query.filter(role=ROLE.ADMIN.value) ), "total_members": self.get_filtered_counts( - WorkspaceMember.objects.filter( - workspace__slug=self._workspace_slug, - role=ROLE.MEMBER.value, - is_active=True, - ) + members_query.filter(role=ROLE.MEMBER.value) ), "total_guests": self.get_filtered_counts( - WorkspaceMember.objects.filter( - workspace__slug=self._workspace_slug, - role=ROLE.GUEST.value, - is_active=True, - ) + members_query.filter(role=ROLE.GUEST.value) ), "total_projects": self.get_filtered_counts( Project.objects.filter(**self.filters["project_filters"]) @@ -107,7 +105,7 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView): ), "total_intake": self.get_filtered_counts( Issue.objects.filter(**self.filters["base_filters"]).filter( - issue_intake__isnull=False + issue_intake__status__in=["-2", "0"] ) ), } diff --git a/web/core/components/analytics-v2/insight-table/root.tsx b/web/core/components/analytics-v2/insight-table/root.tsx index 8e6e8422ef..1ee90c726a 100644 --- a/web/core/components/analytics-v2/insight-table/root.tsx +++ b/web/core/components/analytics-v2/insight-table/root.tsx @@ -13,12 +13,13 @@ interface InsightTableProps> isLoading?: boolean; columns: ColumnDef[]; columnsLabels?: Record; + headerText: string; } export const InsightTable = >( props: InsightTableProps ): React.ReactElement => { - const { data, isLoading, columns, columnsLabels } = props; + const { data, isLoading, columns, columnsLabels, headerText } = props; const params = useParams(); const { t } = useTranslation(); const workspaceSlug = params.workspaceSlug.toString(); @@ -55,7 +56,7 @@ export const InsightTable = ) => (
@@ -80,33 +83,7 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => disabled={!disabled} buttonVariant="border-without-text" buttonClassName="border" - /> -
- - - -
- - issue.project_id && - updateSubIssue( - workspaceSlug, - issue.project_id, - parentIssueId, - issueId, - { - start_date: val ? renderFormattedPayloadDate(val) : null, - }, - { ...issue } - ) - } - maxDate={maxDate} - placeholder={t("common.order_by.start_date")} - icon={} - buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} - optionsClassName="z-30" - disabled={!disabled} + showTooltip />
From 7cb5a9120a62471e7815e035516e56995610847a Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Mon, 26 May 2025 14:28:56 +0530 Subject: [PATCH 17/23] [WEB-4173]fix: fixed layout overflow issue #7119 --- web/app/(all)/layout.tsx | 4 ++-- web/app/(home)/layout.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/(all)/layout.tsx b/web/app/(all)/layout.tsx index 0238526488..32589c4bf0 100644 --- a/web/app/(all)/layout.tsx +++ b/web/app/(all)/layout.tsx @@ -23,9 +23,9 @@ export const viewport: Viewport = { export default function AppLayout({ children }: { children: React.ReactNode }) { return ( -
+ <> {children} -
+ ); } diff --git a/web/app/(home)/layout.tsx b/web/app/(home)/layout.tsx index 56380fb66b..0ed40f86b2 100644 --- a/web/app/(home)/layout.tsx +++ b/web/app/(home)/layout.tsx @@ -16,6 +16,6 @@ export const viewport: Viewport = { export default function HomeLayout({ children }: { children: React.ReactNode }) { return ( -
{children}
+ <>{children} ); } From 193ae9bfc842278acfba9fdccb8c7f62af5c96f5 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Mon, 26 May 2025 14:58:26 +0530 Subject: [PATCH 18/23] fix: yarn lock file --- yarn.lock | 195 +++++++++--------------------------------------------- 1 file changed, 32 insertions(+), 163 deletions(-) diff --git a/yarn.lock b/yarn.lock index ef27eff52e..22d8754dc6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -257,7 +257,7 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helpers@^7.26.7": +"@babel/helpers@7.26.10", "@babel/helpers@^7.26.7": version "7.26.10" resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz#6baea3cd62ec2d0c1068778d63cb1314f6637384" integrity sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g== @@ -852,7 +852,7 @@ "@babel/plugin-transform-modules-commonjs" "^7.25.9" "@babel/plugin-transform-typescript" "^7.25.9" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": +"@babel/runtime@7.26.10", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.26.10" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz#a07b4d8fa27af131a633d7b3524db803eb4764c2" integrity sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw== @@ -1124,251 +1124,126 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz#38848d3e25afe842a7943643cbcd387cc6e13461" - integrity sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA== - "@esbuild/aix-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz#499600c5e1757a524990d5d92601f0ac3ce87f64" integrity sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ== -"@esbuild/android-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz#f592957ae8b5643129fa889c79e69cd8669bb894" - integrity sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg== - "@esbuild/android-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz#b9b8231561a1dfb94eb31f4ee056b92a985c324f" integrity sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g== -"@esbuild/android-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz#72d8a2063aa630308af486a7e5cbcd1e134335b3" - integrity sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q== - "@esbuild/android-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.0.tgz#ca6e7888942505f13e88ac9f5f7d2a72f9facd2b" integrity sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g== -"@esbuild/android-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz#9a7713504d5f04792f33be9c197a882b2d88febb" - integrity sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw== - "@esbuild/android-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.0.tgz#e765ea753bac442dfc9cb53652ce8bd39d33e163" integrity sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg== -"@esbuild/darwin-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz#02ae04ad8ebffd6e2ea096181b3366816b2b5936" - integrity sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA== - "@esbuild/darwin-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz#fa394164b0d89d4fdc3a8a21989af70ef579fa2c" integrity sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw== -"@esbuild/darwin-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz#9ec312bc29c60e1b6cecadc82bd504d8adaa19e9" - integrity sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA== - "@esbuild/darwin-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz#91979d98d30ba6e7d69b22c617cc82bdad60e47a" integrity sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg== -"@esbuild/freebsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz#5e82f44cb4906d6aebf24497d6a068cfc152fa00" - integrity sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg== - "@esbuild/freebsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz#b97e97073310736b430a07b099d837084b85e9ce" integrity sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w== -"@esbuild/freebsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz#3fb1ce92f276168b75074b4e51aa0d8141ecce7f" - integrity sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q== - "@esbuild/freebsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz#f3b694d0da61d9910ec7deff794d444cfbf3b6e7" integrity sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A== -"@esbuild/linux-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz#856b632d79eb80aec0864381efd29de8fd0b1f43" - integrity sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg== - "@esbuild/linux-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz#f921f699f162f332036d5657cad9036f7a993f73" integrity sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg== -"@esbuild/linux-arm@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz#c846b4694dc5a75d1444f52257ccc5659021b736" - integrity sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA== - "@esbuild/linux-arm@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz#cc49305b3c6da317c900688995a4050e6cc91ca3" integrity sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg== -"@esbuild/linux-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz#f8a16615a78826ccbb6566fab9a9606cfd4a37d5" - integrity sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw== - "@esbuild/linux-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz#3e0736fcfab16cff042dec806247e2c76e109e19" integrity sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg== -"@esbuild/linux-loong64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz#1c451538c765bf14913512c76ed8a351e18b09fc" - integrity sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ== - "@esbuild/linux-loong64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz#ea2bf730883cddb9dfb85124232b5a875b8020c7" integrity sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw== -"@esbuild/linux-mips64el@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz#0846edeefbc3d8d50645c51869cc64401d9239cb" - integrity sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw== - "@esbuild/linux-mips64el@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz#4cababb14eede09248980a2d2d8b966464294ff1" integrity sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ== -"@esbuild/linux-ppc64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz#8e3fc54505671d193337a36dfd4c1a23b8a41412" - integrity sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw== - "@esbuild/linux-ppc64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz#8860a4609914c065373a77242e985179658e1951" integrity sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw== -"@esbuild/linux-riscv64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz#6a1e92096d5e68f7bb10a0d64bb5b6d1daf9a694" - integrity sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q== - "@esbuild/linux-riscv64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz#baf26e20bb2d38cfb86ee282dff840c04f4ed987" integrity sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA== -"@esbuild/linux-s390x@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz#ab18e56e66f7a3c49cb97d337cd0a6fea28a8577" - integrity sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw== - "@esbuild/linux-s390x@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz#8323afc0d6cb1b6dc6e9fd21efd9e1542c3640a4" integrity sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA== -"@esbuild/linux-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz#8140c9b40da634d380b0b29c837a0b4267aff38f" - integrity sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q== - "@esbuild/linux-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz#08fcf60cb400ed2382e9f8e0f5590bac8810469a" integrity sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw== -"@esbuild/netbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz#65f19161432bafb3981f5f20a7ff45abb2e708e6" - integrity sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw== - "@esbuild/netbsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz#935c6c74e20f7224918fbe2e6c6fe865b6c6ea5b" integrity sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw== -"@esbuild/netbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz#7a3a97d77abfd11765a72f1c6f9b18f5396bcc40" - integrity sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw== - "@esbuild/netbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz#414677cef66d16c5a4d210751eb2881bb9c1b62b" integrity sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA== -"@esbuild/openbsd-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz#58b00238dd8f123bfff68d3acc53a6ee369af89f" - integrity sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A== - "@esbuild/openbsd-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz#8fd55a4d08d25cdc572844f13c88d678c84d13f7" integrity sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw== -"@esbuild/openbsd-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz#0ac843fda0feb85a93e288842936c21a00a8a205" - integrity sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA== - "@esbuild/openbsd-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz#0c48ddb1494bbc2d6bcbaa1429a7f465fa1dedde" integrity sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg== -"@esbuild/sunos-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz#8b7aa895e07828d36c422a4404cc2ecf27fb15c6" - integrity sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig== - "@esbuild/sunos-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz#86ff9075d77962b60dd26203d7352f92684c8c92" integrity sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg== -"@esbuild/win32-arm64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz#c023afb647cabf0c3ed13f0eddfc4f1d61c66a85" - integrity sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ== - "@esbuild/win32-arm64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz#849c62327c3229467f5b5cd681bf50588442e96c" integrity sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw== -"@esbuild/win32-ia32@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz#96c356132d2dda990098c8b8b951209c3cd743c2" - integrity sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA== - "@esbuild/win32-ia32@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz#f62eb480cd7cca088cb65bb46a6db25b725dc079" integrity sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA== -"@esbuild/win32-x64@0.24.2": - version "0.24.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz#34aa0b52d0fbb1a654b596acfa595f0c7b77a77b" - integrity sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg== - "@esbuild/win32-x64@0.25.0": version "0.25.0" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz#c8e119a30a7c8d60b9d2e22d2073722dde3b710b" @@ -6055,38 +5930,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0": - version "0.24.2" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.2.tgz#b5b55bee7de017bff5fb8a4e3e44f2ebe2c3567d" - integrity sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA== - optionalDependencies: - "@esbuild/aix-ppc64" "0.24.2" - "@esbuild/android-arm" "0.24.2" - "@esbuild/android-arm64" "0.24.2" - "@esbuild/android-x64" "0.24.2" - "@esbuild/darwin-arm64" "0.24.2" - "@esbuild/darwin-x64" "0.24.2" - "@esbuild/freebsd-arm64" "0.24.2" - "@esbuild/freebsd-x64" "0.24.2" - "@esbuild/linux-arm" "0.24.2" - "@esbuild/linux-arm64" "0.24.2" - "@esbuild/linux-ia32" "0.24.2" - "@esbuild/linux-loong64" "0.24.2" - "@esbuild/linux-mips64el" "0.24.2" - "@esbuild/linux-ppc64" "0.24.2" - "@esbuild/linux-riscv64" "0.24.2" - "@esbuild/linux-s390x" "0.24.2" - "@esbuild/linux-x64" "0.24.2" - "@esbuild/netbsd-arm64" "0.24.2" - "@esbuild/netbsd-x64" "0.24.2" - "@esbuild/openbsd-arm64" "0.24.2" - "@esbuild/openbsd-x64" "0.24.2" - "@esbuild/sunos-x64" "0.24.2" - "@esbuild/win32-arm64" "0.24.2" - "@esbuild/win32-ia32" "0.24.2" - "@esbuild/win32-x64" "0.24.2" - -esbuild@^0.25.0: +esbuild@0.25.0, "esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0", esbuild@^0.25.0: version "0.25.0" resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz#0de1787a77206c5a79eeb634a623d39b5006ce92" integrity sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw== @@ -8557,7 +8401,7 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.6, nanoid@^3.3.8: +nanoid@3.3.8, nanoid@^3.3.6, nanoid@^3.3.8: version "3.3.8" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== @@ -10717,7 +10561,16 @@ streamx@^2.15.0, streamx@^2.21.0: optionalDependencies: bare-events "^2.2.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -10810,7 +10663,14 @@ string_decoder@^1.1.1, string_decoder@^1.3.0: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -12028,7 +11888,16 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 0eafbb698a317e453422241bfc69a27788807479 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Mon, 26 May 2025 15:22:16 +0530 Subject: [PATCH 19/23] [WEB-3494] fix: size of created at value #7112 --- web/core/components/issues/peek-overview/properties.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/core/components/issues/peek-overview/properties.tsx b/web/core/components/issues/peek-overview/properties.tsx index 8fd7fd58f3..93516297f9 100644 --- a/web/core/components/issues/peek-overview/properties.tsx +++ b/web/core/components/issues/peek-overview/properties.tsx @@ -135,7 +135,7 @@ export const PeekOverviewProperties: FC = observer((pro showTooltip userIds={createdByDetails?.display_name.includes("-intake") ? null : createdByDetails?.id} /> - + {createdByDetails?.display_name.includes("-intake") ? "Plane" : createdByDetails?.display_name}
From 5a208cb1b9360d727951dbd0e99634fec9021069 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Mon, 26 May 2025 15:23:39 +0530 Subject: [PATCH 20/23] [WEB-2403] fix: alignment of project states in collapsed view #7114 --- web/core/components/issues/issue-layouts/kanban/default.tsx | 2 +- .../issues/issue-layouts/kanban/headers/group-by-card.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/core/components/issues/issue-layouts/kanban/default.tsx b/web/core/components/issues/issue-layouts/kanban/default.tsx index d136154049..32cddc5a2d 100644 --- a/web/core/components/issues/issue-layouts/kanban/default.tsx +++ b/web/core/components/issues/issue-layouts/kanban/default.tsx @@ -162,7 +162,7 @@ export const KanBan: React.FC = observer((props) => { } `} > {sub_group_by === null && ( -
+
= observer((props) => { verticalAlignPosition ? `w-[44px] flex-col items-center` : `w-full flex-row items-center` }`} > -
+
{icon ? icon : }
From 4e485d6402d71ee263ddc507b7029d0b6b73e106 Mon Sep 17 00:00:00 2001 From: JayashTripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Mon, 26 May 2025 15:24:13 +0530 Subject: [PATCH 21/23] [WEB-4160] fix: close the context menu after select #7113 --- web/core/components/workspace/sidebar/projects-list-item.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/core/components/workspace/sidebar/projects-list-item.tsx b/web/core/components/workspace/sidebar/projects-list-item.tsx index 75b10aa4c7..715d02cb1c 100644 --- a/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -311,6 +311,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { customButtonClassName="grid place-items-center" placement="bottom-start" useCaptureForOutsideClick + closeOnSelect > {/* TODO: Removed is_favorite logic due to the optimization in projects API */} {/* {isAuthorized && ( From 78cc32765bfb9ef4b5d7a6901d26332e2cd072eb Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Mon, 26 May 2025 15:26:26 +0530 Subject: [PATCH 22/23] [WEB-3707] pytest based test suite for apiserver (#7010) * pytest bases tests for apiserver * Trimmed spaces * Updated .gitignore for pytest local files --- .gitignore | 2 + apiserver/.coveragerc | 25 + apiserver/plane/authentication/urls.py | 30 +- apiserver/plane/tests/README.md | 143 ++++++ apiserver/plane/tests/TESTING_GUIDE.md | 151 ++++++ apiserver/plane/tests/__init__.py | 2 +- apiserver/plane/tests/api/base.py | 34 -- apiserver/plane/tests/api/test_asset.py | 1 - .../plane/tests/api/test_auth_extended.py | 1 - .../plane/tests/api/test_authentication.py | 183 ------- apiserver/plane/tests/api/test_cycle.py | 1 - apiserver/plane/tests/api/test_issue.py | 1 - apiserver/plane/tests/api/test_oauth.py | 1 - apiserver/plane/tests/api/test_people.py | 1 - apiserver/plane/tests/api/test_project.py | 1 - apiserver/plane/tests/api/test_shortcut.py | 1 - apiserver/plane/tests/api/test_state.py | 1 - apiserver/plane/tests/api/test_view.py | 1 - apiserver/plane/tests/api/test_workspace.py | 44 -- apiserver/plane/tests/conftest.py | 78 +++ apiserver/plane/tests/conftest_external.py | 117 +++++ .../plane/tests/{api => contract}/__init__.py | 0 .../plane/tests/contract/api/__init__.py | 0 .../plane/tests/contract/app/__init__.py | 1 + .../tests/contract/app/test_authentication.py | 459 ++++++++++++++++++ .../tests/contract/app/test_workspace_app.py | 79 +++ apiserver/plane/tests/factories.py | 82 ++++ apiserver/plane/tests/smoke/__init__.py | 0 .../plane/tests/smoke/test_auth_smoke.py | 100 ++++ apiserver/plane/tests/unit/__init__.py | 0 apiserver/plane/tests/unit/models/__init__.py | 0 .../tests/unit/models/test_workspace_model.py | 50 ++ .../plane/tests/unit/serializers/__init__.py | 0 .../tests/unit/serializers/test_workspace.py | 71 +++ apiserver/plane/tests/unit/utils/__init__.py | 0 apiserver/plane/tests/unit/utils/test_uuid.py | 49 ++ apiserver/pytest.ini | 17 + apiserver/requirements/test.txt | 14 +- apiserver/run_tests.py | 91 ++++ apiserver/run_tests.sh | 4 + 40 files changed, 1546 insertions(+), 290 deletions(-) create mode 100644 apiserver/.coveragerc create mode 100644 apiserver/plane/tests/README.md create mode 100644 apiserver/plane/tests/TESTING_GUIDE.md delete mode 100644 apiserver/plane/tests/api/base.py delete mode 100644 apiserver/plane/tests/api/test_asset.py delete mode 100644 apiserver/plane/tests/api/test_auth_extended.py delete mode 100644 apiserver/plane/tests/api/test_authentication.py delete mode 100644 apiserver/plane/tests/api/test_cycle.py delete mode 100644 apiserver/plane/tests/api/test_issue.py delete mode 100644 apiserver/plane/tests/api/test_oauth.py delete mode 100644 apiserver/plane/tests/api/test_people.py delete mode 100644 apiserver/plane/tests/api/test_project.py delete mode 100644 apiserver/plane/tests/api/test_shortcut.py delete mode 100644 apiserver/plane/tests/api/test_state.py delete mode 100644 apiserver/plane/tests/api/test_view.py delete mode 100644 apiserver/plane/tests/api/test_workspace.py create mode 100644 apiserver/plane/tests/conftest.py create mode 100644 apiserver/plane/tests/conftest_external.py rename apiserver/plane/tests/{api => contract}/__init__.py (100%) create mode 100644 apiserver/plane/tests/contract/api/__init__.py create mode 100644 apiserver/plane/tests/contract/app/__init__.py create mode 100644 apiserver/plane/tests/contract/app/test_authentication.py create mode 100644 apiserver/plane/tests/contract/app/test_workspace_app.py create mode 100644 apiserver/plane/tests/factories.py create mode 100644 apiserver/plane/tests/smoke/__init__.py create mode 100644 apiserver/plane/tests/smoke/test_auth_smoke.py create mode 100644 apiserver/plane/tests/unit/__init__.py create mode 100644 apiserver/plane/tests/unit/models/__init__.py create mode 100644 apiserver/plane/tests/unit/models/test_workspace_model.py create mode 100644 apiserver/plane/tests/unit/serializers/__init__.py create mode 100644 apiserver/plane/tests/unit/serializers/test_workspace.py create mode 100644 apiserver/plane/tests/unit/utils/__init__.py create mode 100644 apiserver/plane/tests/unit/utils/test_uuid.py create mode 100644 apiserver/pytest.ini create mode 100755 apiserver/run_tests.py create mode 100755 apiserver/run_tests.sh diff --git a/.gitignore b/.gitignore index 0c89564230..a6a407ba9e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ mediafiles .env .DS_Store logs/ +htmlcov/ +.coverage node_modules/ assets/dist/ diff --git a/apiserver/.coveragerc b/apiserver/.coveragerc new file mode 100644 index 0000000000..bd829d1412 --- /dev/null +++ b/apiserver/.coveragerc @@ -0,0 +1,25 @@ +[run] +source = plane +omit = + */tests/* + */migrations/* + */settings/* + */wsgi.py + */asgi.py + */urls.py + manage.py + */admin.py + */apps.py + +[report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + raise NotImplementedError + if __name__ == .__main__. + pass + raise ImportError + +[html] +directory = htmlcov \ No newline at end of file diff --git a/apiserver/plane/authentication/urls.py b/apiserver/plane/authentication/urls.py index d474fe4dfa..d8b5799de1 100644 --- a/apiserver/plane/authentication/urls.py +++ b/apiserver/plane/authentication/urls.py @@ -42,11 +42,11 @@ urlpatterns = [ # credentials path("sign-in/", SignInAuthEndpoint.as_view(), name="sign-in"), path("sign-up/", SignUpAuthEndpoint.as_view(), name="sign-up"), - path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="sign-in"), - path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="sign-in"), + path("spaces/sign-in/", SignInAuthSpaceEndpoint.as_view(), name="space-sign-in"), + path("spaces/sign-up/", SignUpAuthSpaceEndpoint.as_view(), name="space-sign-up"), # signout path("sign-out/", SignOutAuthEndpoint.as_view(), name="sign-out"), - path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="sign-out"), + path("spaces/sign-out/", SignOutAuthSpaceEndpoint.as_view(), name="space-sign-out"), # csrf token path("get-csrf-token/", CSRFTokenEndpoint.as_view(), name="get_csrf_token"), # Magic sign in @@ -56,17 +56,17 @@ urlpatterns = [ path( "spaces/magic-generate/", MagicGenerateSpaceEndpoint.as_view(), - name="magic-generate", + name="space-magic-generate", ), path( "spaces/magic-sign-in/", MagicSignInSpaceEndpoint.as_view(), - name="magic-sign-in", + name="space-magic-sign-in", ), path( "spaces/magic-sign-up/", MagicSignUpSpaceEndpoint.as_view(), - name="magic-sign-up", + name="space-magic-sign-up", ), ## Google Oauth path("google/", GoogleOauthInitiateEndpoint.as_view(), name="google-initiate"), @@ -74,12 +74,12 @@ urlpatterns = [ path( "spaces/google/", GoogleOauthInitiateSpaceEndpoint.as_view(), - name="google-initiate", + name="space-google-initiate", ), path( - "google/callback/", + "spaces/google/callback/", GoogleCallbackSpaceEndpoint.as_view(), - name="google-callback", + name="space-google-callback", ), ## Github Oauth path("github/", GitHubOauthInitiateEndpoint.as_view(), name="github-initiate"), @@ -87,12 +87,12 @@ urlpatterns = [ path( "spaces/github/", GitHubOauthInitiateSpaceEndpoint.as_view(), - name="github-initiate", + name="space-github-initiate", ), path( "spaces/github/callback/", GitHubCallbackSpaceEndpoint.as_view(), - name="github-callback", + name="space-github-callback", ), ## Gitlab Oauth path("gitlab/", GitLabOauthInitiateEndpoint.as_view(), name="gitlab-initiate"), @@ -100,12 +100,12 @@ urlpatterns = [ path( "spaces/gitlab/", GitLabOauthInitiateSpaceEndpoint.as_view(), - name="gitlab-initiate", + name="space-gitlab-initiate", ), path( "spaces/gitlab/callback/", GitLabCallbackSpaceEndpoint.as_view(), - name="gitlab-callback", + name="space-gitlab-callback", ), # Email Check path("email-check/", EmailCheckEndpoint.as_view(), name="email-check"), @@ -120,12 +120,12 @@ urlpatterns = [ path( "spaces/forgot-password/", ForgotPasswordSpaceEndpoint.as_view(), - name="forgot-password", + name="space-forgot-password", ), path( "spaces/reset-password///", ResetPasswordSpaceEndpoint.as_view(), - name="forgot-password", + name="space-forgot-password", ), path("change-password/", ChangePasswordEndpoint.as_view(), name="forgot-password"), path("set-password/", SetUserPasswordEndpoint.as_view(), name="set-password"), diff --git a/apiserver/plane/tests/README.md b/apiserver/plane/tests/README.md new file mode 100644 index 0000000000..df9aba6da1 --- /dev/null +++ b/apiserver/plane/tests/README.md @@ -0,0 +1,143 @@ +# Plane Tests + +This directory contains tests for the Plane application. The tests are organized using pytest. + +## Test Structure + +Tests are organized into the following categories: + +- **Unit tests**: Test individual functions or classes in isolation. +- **Contract tests**: Test interactions between components and verify API contracts are fulfilled. + - **API tests**: Test the external API endpoints (under `/api/v1/`). + - **App tests**: Test the web application API endpoints (under `/api/`). +- **Smoke tests**: Basic tests to verify that the application runs correctly. + +## API vs App Endpoints + +Plane has two types of API endpoints: + +1. **External API** (`plane.api`): + - Available at `/api/v1/` endpoint + - Uses API key authentication (X-Api-Key header) + - Designed for external API contracts and third-party access + - Tests use the `api_key_client` fixture for authentication + - Test files are in `contract/api/` + +2. **Web App API** (`plane.app`): + - Available at `/api/` endpoint + - Uses session-based authentication (CSRF disabled) + - Designed for the web application frontend + - Tests use the `session_client` fixture for authentication + - Test files are in `contract/app/` + +## Running Tests + +To run all tests: + +```bash +python -m pytest +``` + +To run specific test categories: + +```bash +# Run unit tests +python -m pytest plane/tests/unit/ + +# Run API contract tests +python -m pytest plane/tests/contract/api/ + +# Run App contract tests +python -m pytest plane/tests/contract/app/ + +# Run smoke tests +python -m pytest plane/tests/smoke/ +``` + +For convenience, we also provide a helper script: + +```bash +# Run all tests +./run_tests.py + +# Run only unit tests +./run_tests.py -u + +# Run contract tests with coverage report +./run_tests.py -c -o + +# Run tests in parallel +./run_tests.py -p +``` + +## Fixtures + +The following fixtures are available for testing: + +- `api_client`: Unauthenticated API client +- `create_user`: Creates a test user +- `api_token`: API token for the test user +- `api_key_client`: API client with API key authentication (for external API tests) +- `session_client`: API client with session authentication (for app API tests) +- `plane_server`: Live Django test server for HTTP-based smoke tests + +## Writing Tests + +When writing tests, follow these guidelines: + +1. Place tests in the appropriate directory based on their type. +2. Use the correct client fixture based on the API being tested: + - For external API (`/api/v1/`), use `api_key_client` + - For web app API (`/api/`), use `session_client` + - For smoke tests with real HTTP, use `plane_server` +3. Use the correct URL namespace when reverse-resolving URLs: + - For external API, use `reverse("api:endpoint_name")` + - For web app API, use `reverse("endpoint_name")` +4. Add the `@pytest.mark.django_db` decorator to tests that interact with the database. +5. Add the appropriate markers (`@pytest.mark.contract`, etc.) to categorize tests. + +## Test Fixtures + +Common fixtures are defined in: + +- `conftest.py`: General fixtures for authentication, database access, etc. +- `conftest_external.py`: Fixtures for external services (Redis, Elasticsearch, Celery, MongoDB) +- `factories.py`: Test factories for easy model instance creation + +## Best Practices + +When writing tests, follow these guidelines: + +1. **Use pytest's assert syntax** instead of Django's `self.assert*` methods. +2. **Add markers to categorize tests**: + ```python + @pytest.mark.unit + @pytest.mark.contract + @pytest.mark.smoke + ``` +3. **Use fixtures instead of setUp/tearDown methods** for cleaner, more reusable test code. +4. **Mock external dependencies** with the provided fixtures to avoid external service dependencies. +5. **Write focused tests** that verify one specific behavior or edge case. +6. **Keep test files small and organized** by logical components or endpoints. +7. **Target 90% code coverage** for models, serializers, and business logic. + +## External Dependencies + +Tests for components that interact with external services should: + +1. Use the `mock_redis`, `mock_elasticsearch`, `mock_mongodb`, and `mock_celery` fixtures for unit and most contract tests. +2. For more comprehensive contract tests, use Docker-based test containers (optional). + +## Coverage Reports + +Generate a coverage report with: + +```bash +python -m pytest --cov=plane --cov-report=term --cov-report=html +``` + +This creates an HTML report in the `htmlcov/` directory. + +## Migration from Old Tests + +Some tests are still in the old format in the `api/` directory. These need to be migrated to the new contract test structure in the appropriate directories. \ No newline at end of file diff --git a/apiserver/plane/tests/TESTING_GUIDE.md b/apiserver/plane/tests/TESTING_GUIDE.md new file mode 100644 index 0000000000..98f4a1dba7 --- /dev/null +++ b/apiserver/plane/tests/TESTING_GUIDE.md @@ -0,0 +1,151 @@ +# Testing Guide for Plane + +This guide explains how to write tests for Plane using our pytest-based testing strategy. + +## Test Categories + +We divide tests into three categories: + +1. **Unit Tests**: Testing individual components in isolation. +2. **Contract Tests**: Testing API endpoints and verifying contracts between components. +3. **Smoke Tests**: Basic end-to-end tests for critical flows. + +## Writing Unit Tests + +Unit tests should be placed in the appropriate directory under `tests/unit/` depending on what you're testing: + +- `tests/unit/models/` - For model tests +- `tests/unit/serializers/` - For serializer tests +- `tests/unit/utils/` - For utility function tests + +### Example Unit Test: + +```python +import pytest +from plane.api.serializers import MySerializer + +@pytest.mark.unit +class TestMySerializer: + def test_serializer_valid_data(self): + # Create input data + data = {"field1": "value1", "field2": 42} + + # Initialize the serializer + serializer = MySerializer(data=data) + + # Validate + assert serializer.is_valid() + + # Check validated data + assert serializer.validated_data["field1"] == "value1" + assert serializer.validated_data["field2"] == 42 +``` + +## Writing Contract Tests + +Contract tests should be placed in `tests/contract/api/` or `tests/contract/app/` directories and should test the API endpoints. + +### Example Contract Test: + +```python +import pytest +from django.urls import reverse +from rest_framework import status + +@pytest.mark.contract +class TestMyEndpoint: + @pytest.mark.django_db + def test_my_endpoint_get(self, auth_client): + # Get the URL + url = reverse("my-endpoint") + + # Make request + response = auth_client.get(url) + + # Check response + assert response.status_code == status.HTTP_200_OK + assert "data" in response.data +``` + +## Writing Smoke Tests + +Smoke tests should be placed in `tests/smoke/` directory and use the `plane_server` fixture to test against a real HTTP server. + +### Example Smoke Test: + +```python +import pytest +import requests + +@pytest.mark.smoke +class TestCriticalFlow: + @pytest.mark.django_db + def test_login_flow(self, plane_server, create_user, user_data): + # Get login URL + url = f"{plane_server.url}/api/auth/signin/" + + # Test login + response = requests.post( + url, + json={ + "email": user_data["email"], + "password": user_data["password"] + } + ) + + # Verify + assert response.status_code == 200 + data = response.json() + assert "access_token" in data +``` + +## Useful Fixtures + +Our test setup provides several useful fixtures: + +1. `api_client`: An unauthenticated DRF APIClient +2. `api_key_client`: API client with API key authentication (for external API tests) +3. `session_client`: API client with session authentication (for web app API tests) +4. `create_user`: Creates and returns a test user +5. `mock_redis`: Mocks Redis interactions +6. `mock_elasticsearch`: Mocks Elasticsearch interactions +7. `mock_celery`: Mocks Celery task execution + +## Using Factory Boy + +For more complex test data setup, use the provided factories: + +```python +from plane.tests.factories import UserFactory, WorkspaceFactory + +# Create a user +user = UserFactory() + +# Create a workspace with a specific owner +workspace = WorkspaceFactory(owner=user) + +# Create multiple objects +users = UserFactory.create_batch(5) +``` + +## Running Tests + +Use pytest to run tests: + +```bash +# Run all tests +python -m pytest + +# Run only unit tests with coverage +python -m pytest -m unit --cov=plane +``` + +## Best Practices + +1. **Keep tests small and focused** - Each test should verify one specific behavior. +2. **Use markers** - Always add appropriate markers (`@pytest.mark.unit`, etc.). +3. **Mock external dependencies** - Use the provided mock fixtures. +4. **Use factories** - For complex data setup, use factories. +5. **Don't test the framework** - Focus on testing your business logic, not Django/DRF itself. +6. **Write readable assertions** - Use plain `assert` statements with clear messaging. +7. **Focus on coverage** - Aim for ≥90% code coverage for critical components. \ No newline at end of file diff --git a/apiserver/plane/tests/__init__.py b/apiserver/plane/tests/__init__.py index 0a0e47b0b0..73d90cd21b 100644 --- a/apiserver/plane/tests/__init__.py +++ b/apiserver/plane/tests/__init__.py @@ -1 +1 @@ -from .api import * +# Test package initialization diff --git a/apiserver/plane/tests/api/base.py b/apiserver/plane/tests/api/base.py deleted file mode 100644 index e3209a2818..0000000000 --- a/apiserver/plane/tests/api/base.py +++ /dev/null @@ -1,34 +0,0 @@ -# Third party imports -from rest_framework.test import APITestCase, APIClient - -# Module imports -from plane.db.models import User -from plane.app.views.authentication import get_tokens_for_user - - -class BaseAPITest(APITestCase): - def setUp(self): - self.client = APIClient(HTTP_USER_AGENT="plane/test", REMOTE_ADDR="10.10.10.10") - - -class AuthenticatedAPITest(BaseAPITest): - def setUp(self): - super().setUp() - - ## Create Dummy User - self.email = "user@plane.so" - user = User.objects.create(email=self.email) - user.set_password("user@123") - user.save() - - # Set user - self.user = user - - # Set Up User ID - self.user_id = user.id - - access_token, _ = get_tokens_for_user(user) - self.access_token = access_token - - # Set Up Authentication Token - self.client.credentials(HTTP_AUTHORIZATION="Bearer " + access_token) diff --git a/apiserver/plane/tests/api/test_asset.py b/apiserver/plane/tests/api/test_asset.py deleted file mode 100644 index b15d32e40e..0000000000 --- a/apiserver/plane/tests/api/test_asset.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for File Asset Uploads diff --git a/apiserver/plane/tests/api/test_auth_extended.py b/apiserver/plane/tests/api/test_auth_extended.py deleted file mode 100644 index af6450ef43..0000000000 --- a/apiserver/plane/tests/api/test_auth_extended.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for ChangePassword and other Endpoints diff --git a/apiserver/plane/tests/api/test_authentication.py b/apiserver/plane/tests/api/test_authentication.py deleted file mode 100644 index 5d7beabdfd..0000000000 --- a/apiserver/plane/tests/api/test_authentication.py +++ /dev/null @@ -1,183 +0,0 @@ -# Python import -import json - -# Django imports -from django.urls import reverse - -# Third Party imports -from rest_framework import status -from .base import BaseAPITest - -# Module imports -from plane.db.models import User -from plane.settings.redis import redis_instance - - -class SignInEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("sign-in") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_email_validity(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "useremail.com", "password": "user@123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Please provide a valid email address."} - ) - - def test_password_validity(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "user@plane.so", "password": "user123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.data, - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - ) - - def test_user_exists(self): - url = reverse("sign-in") - response = self.client.post( - url, {"email": "user@email.so", "password": "user123"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.data, - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - ) - - def test_user_login(self): - url = reverse("sign-in") - - response = self.client.post( - url, {"email": "user@plane.so", "password": "user@123"}, format="json" - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get("user").get("email"), "user@plane.so") - - -class MagicLinkGenerateEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("magic-generate") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - def test_email_validity(self): - url = reverse("magic-generate") - response = self.client.post(url, {"email": "useremail.com"}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Please provide a valid email address."} - ) - - def test_magic_generate(self): - url = reverse("magic-generate") - - ri = redis_instance() - ri.delete("magic_user@plane.so") - - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - self.assertEqual(response.status_code, status.HTTP_200_OK) - - def test_max_generate_attempt(self): - url = reverse("magic-generate") - - ri = redis_instance() - ri.delete("magic_user@plane.so") - - for _ in range(4): - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - - response = self.client.post(url, {"email": "user@plane.so"}, format="json") - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Max attempts exhausted. Please try again later."} - ) - - -class MagicSignInEndpointTests(BaseAPITest): - def setUp(self): - super().setUp() - user = User.objects.create(email="user@plane.so") - user.set_password("user@123") - user.save() - - def test_without_data(self): - url = reverse("magic-sign-in") - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data, {"error": "User token and key are required"}) - - def test_expired_invalid_magic_link(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - - url = reverse("magic-sign-in") - response = self.client.post( - url, - {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "The magic code/link has expired please try again"} - ) - - def test_invalid_magic_code(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - ## Create Token - url = reverse("magic-generate") - self.client.post(url, {"email": "user@plane.so"}, format="json") - - url = reverse("magic-sign-in") - response = self.client.post( - url, - {"key": "magic_user@plane.so", "token": "xxxx-xxxxx-xxxx"}, - format="json", - ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual( - response.data, {"error": "Your login code was incorrect. Please try again."} - ) - - def test_magic_code_sign_in(self): - ri = redis_instance() - ri.delete("magic_user@plane.so") - ## Create Token - url = reverse("magic-generate") - self.client.post(url, {"email": "user@plane.so"}, format="json") - - # Get the token - user_data = json.loads(ri.get("magic_user@plane.so")) - token = user_data["token"] - - url = reverse("magic-sign-in") - response = self.client.post( - url, {"key": "magic_user@plane.so", "token": token}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data.get("user").get("email"), "user@plane.so") diff --git a/apiserver/plane/tests/api/test_cycle.py b/apiserver/plane/tests/api/test_cycle.py deleted file mode 100644 index 72b580c99b..0000000000 --- a/apiserver/plane/tests/api/test_cycle.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for Cycle Endpoints diff --git a/apiserver/plane/tests/api/test_issue.py b/apiserver/plane/tests/api/test_issue.py deleted file mode 100644 index a45ff36b1d..0000000000 --- a/apiserver/plane/tests/api/test_issue.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for Issue Endpoints diff --git a/apiserver/plane/tests/api/test_oauth.py b/apiserver/plane/tests/api/test_oauth.py deleted file mode 100644 index 1e7dac0ef3..0000000000 --- a/apiserver/plane/tests/api/test_oauth.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Tests for OAuth Authentication Endpoint diff --git a/apiserver/plane/tests/api/test_people.py b/apiserver/plane/tests/api/test_people.py deleted file mode 100644 index 624281a2ff..0000000000 --- a/apiserver/plane/tests/api/test_people.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for people Endpoint diff --git a/apiserver/plane/tests/api/test_project.py b/apiserver/plane/tests/api/test_project.py deleted file mode 100644 index 9a7c50f194..0000000000 --- a/apiserver/plane/tests/api/test_project.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Tests for project endpoints diff --git a/apiserver/plane/tests/api/test_shortcut.py b/apiserver/plane/tests/api/test_shortcut.py deleted file mode 100644 index 5103b50594..0000000000 --- a/apiserver/plane/tests/api/test_shortcut.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write Test for shortcuts diff --git a/apiserver/plane/tests/api/test_state.py b/apiserver/plane/tests/api/test_state.py deleted file mode 100644 index a336d955af..0000000000 --- a/apiserver/plane/tests/api/test_state.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Wrote test for state endpoints diff --git a/apiserver/plane/tests/api/test_view.py b/apiserver/plane/tests/api/test_view.py deleted file mode 100644 index c8864f28ad..0000000000 --- a/apiserver/plane/tests/api/test_view.py +++ /dev/null @@ -1 +0,0 @@ -# TODO: Write test for view endpoints diff --git a/apiserver/plane/tests/api/test_workspace.py b/apiserver/plane/tests/api/test_workspace.py deleted file mode 100644 index d63eab2e09..0000000000 --- a/apiserver/plane/tests/api/test_workspace.py +++ /dev/null @@ -1,44 +0,0 @@ -# Django imports -from django.urls import reverse - -# Third party import -from rest_framework import status - -# Module imports -from .base import AuthenticatedAPITest -from plane.db.models import Workspace, WorkspaceMember - - -class WorkSpaceCreateReadUpdateDelete(AuthenticatedAPITest): - def setUp(self): - super().setUp() - - def test_create_workspace(self): - url = reverse("workspace") - - # Test with empty data - response = self.client.post(url, {}, format="json") - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - # Test with valid data - response = self.client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) - self.assertEqual(Workspace.objects.count(), 1) - # Check if the member is created - self.assertEqual(WorkspaceMember.objects.count(), 1) - - # Check other values - workspace = Workspace.objects.get(pk=response.data["id"]) - workspace_member = WorkspaceMember.objects.get( - workspace=workspace, member_id=self.user_id - ) - self.assertEqual(workspace.owner_id, self.user_id) - self.assertEqual(workspace_member.role, 20) - - # Create a already existing workspace - response = self.client.post( - url, {"name": "Plane", "slug": "pla-ne"}, format="json" - ) - self.assertEqual(response.status_code, status.HTTP_409_CONFLICT) diff --git a/apiserver/plane/tests/conftest.py b/apiserver/plane/tests/conftest.py new file mode 100644 index 0000000000..ce0d3be2b4 --- /dev/null +++ b/apiserver/plane/tests/conftest.py @@ -0,0 +1,78 @@ +import pytest +from django.conf import settings +from rest_framework.test import APIClient +from pytest_django.fixtures import django_db_setup +from unittest.mock import patch, MagicMock + +from plane.db.models import User +from plane.db.models.api import APIToken + + +@pytest.fixture(scope="session") +def django_db_setup(django_db_setup): + """Set up the Django database for the test session""" + pass + + +@pytest.fixture +def api_client(): + """Return an unauthenticated API client""" + return APIClient() + + +@pytest.fixture +def user_data(): + """Return standard user data for tests""" + return { + "email": "test@plane.so", + "password": "test-password", + "first_name": "Test", + "last_name": "User" + } + + +@pytest.fixture +def create_user(db, user_data): + """Create and return a user instance""" + user = User.objects.create( + email=user_data["email"], + first_name=user_data["first_name"], + last_name=user_data["last_name"] + ) + user.set_password(user_data["password"]) + user.save() + return user + + +@pytest.fixture +def api_token(db, create_user): + """Create and return an API token for testing the external API""" + token = APIToken.objects.create( + user=create_user, + label="Test API Token", + token="test-api-token-12345", + ) + return token + + +@pytest.fixture +def api_key_client(api_client, api_token): + """Return an API key authenticated client for external API testing""" + api_client.credentials(HTTP_X_API_KEY=api_token.token) + return api_client + + +@pytest.fixture +def session_client(api_client, create_user): + """Return a session authenticated API client for app API testing, which is what plane.app uses""" + api_client.force_authenticate(user=create_user) + return api_client + + +@pytest.fixture +def plane_server(live_server): + """ + Renamed version of live_server fixture to avoid name clashes. + Returns a live Django server for testing HTTP requests. + """ + return live_server \ No newline at end of file diff --git a/apiserver/plane/tests/conftest_external.py b/apiserver/plane/tests/conftest_external.py new file mode 100644 index 0000000000..d2d6a2df51 --- /dev/null +++ b/apiserver/plane/tests/conftest_external.py @@ -0,0 +1,117 @@ +import pytest +from unittest.mock import MagicMock, patch +from django.conf import settings + + +@pytest.fixture +def mock_redis(): + """ + Mock Redis for testing without actual Redis connection. + + This fixture patches the redis_instance function to return a MagicMock + that behaves like a Redis client. + """ + mock_redis_client = MagicMock() + + # Configure the mock to handle common Redis operations + mock_redis_client.get.return_value = None + mock_redis_client.set.return_value = True + mock_redis_client.delete.return_value = True + mock_redis_client.exists.return_value = 0 + mock_redis_client.ttl.return_value = -1 + + # Start the patch + with patch('plane.settings.redis.redis_instance', return_value=mock_redis_client): + yield mock_redis_client + + +@pytest.fixture +def mock_elasticsearch(): + """ + Mock Elasticsearch for testing without actual ES connection. + + This fixture patches Elasticsearch to return a MagicMock + that behaves like an Elasticsearch client. + """ + mock_es_client = MagicMock() + + # Configure the mock to handle common ES operations + mock_es_client.indices.exists.return_value = True + mock_es_client.indices.create.return_value = {"acknowledged": True} + mock_es_client.search.return_value = {"hits": {"total": {"value": 0}, "hits": []}} + mock_es_client.index.return_value = {"_id": "test_id", "result": "created"} + mock_es_client.update.return_value = {"_id": "test_id", "result": "updated"} + mock_es_client.delete.return_value = {"_id": "test_id", "result": "deleted"} + + # Start the patch + with patch('elasticsearch.Elasticsearch', return_value=mock_es_client): + yield mock_es_client + + +@pytest.fixture +def mock_mongodb(): + """ + Mock MongoDB for testing without actual MongoDB connection. + + This fixture patches PyMongo to return a MagicMock that behaves like a MongoDB client. + """ + # Create mock MongoDB clients and collections + mock_mongo_client = MagicMock() + mock_mongo_db = MagicMock() + mock_mongo_collection = MagicMock() + + # Set up the chain: client -> database -> collection + mock_mongo_client.__getitem__.return_value = mock_mongo_db + mock_mongo_client.get_database.return_value = mock_mongo_db + mock_mongo_db.__getitem__.return_value = mock_mongo_collection + + # Configure common MongoDB collection operations + mock_mongo_collection.find_one.return_value = None + mock_mongo_collection.find.return_value = MagicMock( + __iter__=lambda x: iter([]), + count=lambda: 0 + ) + mock_mongo_collection.insert_one.return_value = MagicMock( + inserted_id="mock_id_123", + acknowledged=True + ) + mock_mongo_collection.insert_many.return_value = MagicMock( + inserted_ids=["mock_id_123", "mock_id_456"], + acknowledged=True + ) + mock_mongo_collection.update_one.return_value = MagicMock( + modified_count=1, + matched_count=1, + acknowledged=True + ) + mock_mongo_collection.update_many.return_value = MagicMock( + modified_count=2, + matched_count=2, + acknowledged=True + ) + mock_mongo_collection.delete_one.return_value = MagicMock( + deleted_count=1, + acknowledged=True + ) + mock_mongo_collection.delete_many.return_value = MagicMock( + deleted_count=2, + acknowledged=True + ) + mock_mongo_collection.count_documents.return_value = 0 + + # Start the patch + with patch('pymongo.MongoClient', return_value=mock_mongo_client): + yield mock_mongo_client + + +@pytest.fixture +def mock_celery(): + """ + Mock Celery for testing without actual task execution. + + This fixture patches Celery's task.delay() to prevent actual task execution. + """ + # Start the patch + with patch('celery.app.task.Task.delay') as mock_delay: + mock_delay.return_value = MagicMock(id="mock-task-id") + yield mock_delay \ No newline at end of file diff --git a/apiserver/plane/tests/api/__init__.py b/apiserver/plane/tests/contract/__init__.py similarity index 100% rename from apiserver/plane/tests/api/__init__.py rename to apiserver/plane/tests/contract/__init__.py diff --git a/apiserver/plane/tests/contract/api/__init__.py b/apiserver/plane/tests/contract/api/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/tests/contract/app/__init__.py b/apiserver/plane/tests/contract/app/__init__.py new file mode 100644 index 0000000000..0519ecba6e --- /dev/null +++ b/apiserver/plane/tests/contract/app/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apiserver/plane/tests/contract/app/test_authentication.py b/apiserver/plane/tests/contract/app/test_authentication.py new file mode 100644 index 0000000000..0dc5487104 --- /dev/null +++ b/apiserver/plane/tests/contract/app/test_authentication.py @@ -0,0 +1,459 @@ +import json +import uuid +import pytest +from django.urls import reverse +from django.utils import timezone +from rest_framework import status +from django.test import Client +from django.core.exceptions import ValidationError +from unittest.mock import patch, MagicMock + +from plane.db.models import User +from plane.settings.redis import redis_instance +from plane.license.models import Instance + + +@pytest.fixture +def setup_instance(db): + """Create and configure an instance for authentication tests""" + instance_id = uuid.uuid4() if not Instance.objects.exists() else Instance.objects.first().id + + # Create or update instance with all required fields + instance, _ = Instance.objects.update_or_create( + id=instance_id, + defaults={ + "instance_name": "Test Instance", + "instance_id": str(uuid.uuid4()), + "current_version": "1.0.0", + "domain": "http://localhost:8000", + "last_checked_at": timezone.now(), + "is_setup_done": True, + } + ) + return instance + + +@pytest.fixture +def django_client(): + """Return a Django test client with User-Agent header for handling redirects""" + client = Client(HTTP_USER_AGENT="Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1") + return client + + +@pytest.mark.contract +class TestMagicLinkGenerate: + """Test magic link generation functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic link tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, api_client, setup_user, setup_instance): + """Test magic link generation with empty data""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the empty email was rejected + assert True + + @pytest.mark.django_db + def test_email_validity(self, api_client, setup_user, setup_instance): + """Test magic link generation with invalid email format""" + url = reverse("magic-generate") + try: + response = api_client.post(url, {"email": "useremail.com"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + except ValidationError: + # If a ValidationError is raised directly, that's also acceptable + # as it indicates the invalid email was rejected + assert True + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_generate(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test successful magic link generation""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_200_OK + assert "key" in response.data # Check for key in response + + # Verify the mock was called with the expected arguments + mock_magic_link.assert_called_once() + args = mock_magic_link.call_args[0] + assert args[0] == "user@plane.so" # First arg should be the email + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_max_generate_attempt(self, mock_magic_link, api_client, setup_user, setup_instance): + """Test exceeding maximum magic link generation attempts""" + url = reverse("magic-generate") + + ri = redis_instance() + ri.delete("magic_user@plane.so") + + for _ in range(4): + api_client.post(url, {"email": "user@plane.so"}, format="json") + + response = api_client.post(url, {"email": "user@plane.so"}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "error_code" in response.data # Check for error code in response + + +@pytest.mark.contract +class TestSignInEndpoint: + """Test sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for authentication tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test sign-in with empty data""" + url = reverse("sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "REQUIRED_EMAIL_PASSWORD_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_email_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with invalid email format""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "useremail.com", "password": "user@123"}, follow=True + ) + + # Check redirect contains error code + assert "INVALID_EMAIL_SIGN_IN" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_exists(self, django_client, setup_user, setup_instance): + """Test sign-in with non-existent user""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "user@email.so", "password": "user123"}, follow=True + ) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_password_validity(self, django_client, setup_user, setup_instance): + """Test sign-in with incorrect password""" + url = reverse("sign-in") + response = django_client.post( + url, {"email": "user@plane.so", "password": "user123"}, follow=True + ) + + + # Check for the specific authentication error in the URL + redirect_urls = [url for url, _ in response.redirect_chain] + redirect_contents = ' '.join(redirect_urls) + + # The actual error code for invalid password is AUTHENTICATION_FAILED_SIGN_IN + assert "AUTHENTICATION_FAILED_SIGN_IN" in redirect_contents + + @pytest.mark.django_db + def test_user_login(self, django_client, setup_user, setup_instance): + """Test successful sign-in""" + url = reverse("sign-in") + + # First make the request without following redirects + response = django_client.post( + url, {"email": "user@plane.so", "password": "user@123"}, follow=False + ) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Now follow just the first redirect to avoid 404s + response = django_client.get(response.url, follow=False) + + # The user should be authenticated regardless of the final page + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + def test_next_path_redirection(self, django_client, setup_user, setup_instance): + """Test sign-in with next_path parameter""" + url = reverse("sign-in") + next_path = "workspaces" + + # First make the request without following redirects + response = django_client.post( + url, + {"email": "user@plane.so", "password": "user@123", "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect (302) without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + # Instead, just verify that we're authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignIn: + """Test magic link sign-in functionality""" + + @pytest.fixture + def setup_user(self, db): + """Create a test user for magic sign-in tests""" + user = User.objects.create(email="user@plane.so") + user.set_password("user@123") + user.save() + return user + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with empty data""" + url = reverse("magic-sign-in") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_user, setup_instance): + """Test magic link sign-in with expired/invalid link""" + ri = redis_instance() + ri.delete("magic_user@plane.so") + + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "user@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=False + ) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_IN (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_IN (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_IN" in response.url or "INVALID_MAGIC_CODE_SIGN_IN" in response.url + + @pytest.mark.django_db + def test_user_does_not_exist(self, django_client, setup_instance): + """Test magic sign-in with non-existent user""" + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "nonexistent@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=True + ) + + # Check redirect contains error code + assert "USER_DOES_NOT_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_in(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test successful magic link sign-in process""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + response = django_client.post( + url, + {"email": "user@plane.so", "code": token}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_in_with_next_path(self, mock_magic_link, django_client, api_client, setup_user, setup_instance): + """Test magic sign-in with next_path parameter""" + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": "user@plane.so"}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get("magic_user@plane.so")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-in") + next_path = "workspaces" + response = django_client.post( + url, + {"email": "user@plane.so", "code": token, "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check that the redirect URL contains the next_path + assert next_path in response.url + + # The user should now be authenticated + assert "_auth_user_id" in django_client.session + + +@pytest.mark.contract +class TestMagicSignUp: + """Test magic link sign-up functionality""" + + @pytest.mark.django_db + def test_without_data(self, django_client, setup_instance): + """Test magic link sign-up with empty data""" + url = reverse("magic-sign-up") + response = django_client.post(url, {}, follow=True) + + # Check redirect contains error code + assert "MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_user_already_exists(self, django_client, db, setup_instance): + """Test magic sign-up with existing user""" + # Create a user that already exists + User.objects.create(email="existing@plane.so") + + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": "existing@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=True + ) + + # Check redirect contains error code + assert "USER_ALREADY_EXIST" in response.redirect_chain[-1][0] + + @pytest.mark.django_db + def test_expired_invalid_magic_link(self, django_client, setup_instance): + """Test magic link sign-up with expired/invalid link""" + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": "new@plane.so", "code": "xxxx-xxxxx-xxxx"}, + follow=False + ) + + # Check that we get a redirect + assert response.status_code == 302 + + # The actual error code is EXPIRED_MAGIC_CODE_SIGN_UP (when key doesn't exist) + # or INVALID_MAGIC_CODE_SIGN_UP (when key exists but code doesn't match) + assert "EXPIRED_MAGIC_CODE_SIGN_UP" in response.url or "INVALID_MAGIC_CODE_SIGN_UP" in response.url + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_code_sign_up(self, mock_magic_link, django_client, api_client, setup_instance): + """Test successful magic link sign-up process""" + email = "newuser@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + response = django_client.post( + url, + {"email": email, "code": token}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session + + @pytest.mark.django_db + @patch("plane.bgtasks.magic_link_code_task.magic_link.delay") + def test_magic_sign_up_with_next_path(self, mock_magic_link, django_client, api_client, setup_instance): + """Test magic sign-up with next_path parameter""" + email = "newuser2@plane.so" + + # First generate a magic link token + gen_url = reverse("magic-generate") + response = api_client.post(gen_url, {"email": email}, format="json") + + # Check that the token generation was successful + assert response.status_code == status.HTTP_200_OK + + # Since we're mocking the magic_link task, we need to manually get the token from Redis + ri = redis_instance() + user_data = json.loads(ri.get(f"magic_{email}")) + token = user_data["token"] + + # Use Django client to test the redirect flow without following redirects + url = reverse("magic-sign-up") + next_path = "onboarding" + response = django_client.post( + url, + {"email": email, "code": token, "next_path": next_path}, + follow=False + ) + + # Check that the initial response is a redirect without error code + assert response.status_code == 302 + assert "error_code" not in response.url + + # In a real browser, the next_path would be used to build the absolute URL + # Since we're just testing the authentication logic, we won't check for the exact URL structure + + # Check if user was created + assert User.objects.filter(email=email).exists() + + # Check if user is authenticated + assert "_auth_user_id" in django_client.session \ No newline at end of file diff --git a/apiserver/plane/tests/contract/app/test_workspace_app.py b/apiserver/plane/tests/contract/app/test_workspace_app.py new file mode 100644 index 0000000000..71ad1d4124 --- /dev/null +++ b/apiserver/plane/tests/contract/app/test_workspace_app.py @@ -0,0 +1,79 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from unittest.mock import patch + +from plane.db.models import Workspace, WorkspaceMember + + +@pytest.mark.contract +class TestWorkspaceAPI: + """Test workspace CRUD operations""" + + @pytest.mark.django_db + def test_create_workspace_empty_data(self, session_client): + """Test creating a workspace with empty data""" + url = reverse("workspace") + + # Test with empty data + response = session_client.post(url, {}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + @patch("plane.bgtasks.workspace_seed_task.workspace_seed.delay") + def test_create_workspace_valid_data(self, mock_workspace_seed, session_client, create_user): + """Test creating a workspace with valid data""" + url = reverse("workspace") + user = create_user # Use the create_user fixture directly as it returns a user object + + # Test with valid data - include all required fields + workspace_data = { + "name": "Plane", + "slug": "pla-ne-test", + "company_name": "Plane Inc." + } + + # Make the request + response = session_client.post(url, workspace_data, format="json") + + # Check response status + assert response.status_code == status.HTTP_201_CREATED + + # Verify workspace was created + assert Workspace.objects.count() == 1 + + # Check if the member is created + assert WorkspaceMember.objects.count() == 1 + + # Check other values + workspace = Workspace.objects.get(slug=workspace_data["slug"]) + workspace_member = WorkspaceMember.objects.filter( + workspace=workspace, member=user + ).first() + assert workspace.owner == user + assert workspace_member.role == 20 + + # Verify the workspace_seed task was called + mock_workspace_seed.assert_called_once_with(response.data["id"]) + + @pytest.mark.django_db + @patch('plane.bgtasks.workspace_seed_task.workspace_seed.delay') + def test_create_duplicate_workspace(self, mock_workspace_seed, session_client): + """Test creating a duplicate workspace""" + url = reverse("workspace") + + # Create first workspace + session_client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + + # Try to create a workspace with the same slug + response = session_client.post( + url, {"name": "Plane", "slug": "pla-ne"}, format="json" + ) + + # The API returns 400 BAD REQUEST for duplicate slugs, not 409 CONFLICT + assert response.status_code == status.HTTP_400_BAD_REQUEST + + # Optionally check the error message to confirm it's related to the duplicate slug + assert "slug" in response.data \ No newline at end of file diff --git a/apiserver/plane/tests/factories.py b/apiserver/plane/tests/factories.py new file mode 100644 index 0000000000..8d95773ded --- /dev/null +++ b/apiserver/plane/tests/factories.py @@ -0,0 +1,82 @@ +import factory +from uuid import uuid4 +from django.utils import timezone + +from plane.db.models import ( + User, + Workspace, + WorkspaceMember, + Project, + ProjectMember +) + + +class UserFactory(factory.django.DjangoModelFactory): + """Factory for creating User instances""" + class Meta: + model = User + django_get_or_create = ('email',) + + id = factory.LazyFunction(uuid4) + email = factory.Sequence(lambda n: f'user{n}@plane.so') + password = factory.PostGenerationMethodCall('set_password', 'password') + first_name = factory.Sequence(lambda n: f'First{n}') + last_name = factory.Sequence(lambda n: f'Last{n}') + is_active = True + is_superuser = False + is_staff = False + + +class WorkspaceFactory(factory.django.DjangoModelFactory): + """Factory for creating Workspace instances""" + class Meta: + model = Workspace + django_get_or_create = ('slug',) + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f'Workspace {n}') + slug = factory.Sequence(lambda n: f'workspace-{n}') + owner = factory.SubFactory(UserFactory) + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class WorkspaceMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating WorkspaceMember instances""" + class Meta: + model = WorkspaceMember + + id = factory.LazyFunction(uuid4) + workspace = factory.SubFactory(WorkspaceFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectFactory(factory.django.DjangoModelFactory): + """Factory for creating Project instances""" + class Meta: + model = Project + django_get_or_create = ('name', 'workspace') + + id = factory.LazyFunction(uuid4) + name = factory.Sequence(lambda n: f'Project {n}') + workspace = factory.SubFactory(WorkspaceFactory) + created_by = factory.SelfAttribute('workspace.owner') + updated_by = factory.SelfAttribute('workspace.owner') + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) + + +class ProjectMemberFactory(factory.django.DjangoModelFactory): + """Factory for creating ProjectMember instances""" + class Meta: + model = ProjectMember + + id = factory.LazyFunction(uuid4) + project = factory.SubFactory(ProjectFactory) + member = factory.SubFactory(UserFactory) + role = 20 # Admin role by default + created_at = factory.LazyFunction(timezone.now) + updated_at = factory.LazyFunction(timezone.now) \ No newline at end of file diff --git a/apiserver/plane/tests/smoke/__init__.py b/apiserver/plane/tests/smoke/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/tests/smoke/test_auth_smoke.py b/apiserver/plane/tests/smoke/test_auth_smoke.py new file mode 100644 index 0000000000..4d6de6c35c --- /dev/null +++ b/apiserver/plane/tests/smoke/test_auth_smoke.py @@ -0,0 +1,100 @@ +import pytest +import requests +from django.urls import reverse + + +@pytest.mark.smoke +class TestAuthSmoke: + """Smoke tests for authentication endpoints""" + + @pytest.mark.django_db + def test_login_endpoint_available(self, plane_server, create_user, user_data): + """Test that the login endpoint is available and responds correctly""" + # Get the sign-in URL + relative_url = reverse("sign-in") + url = f"{plane_server.url}{relative_url}" + + # 1. Test bad login - test with wrong password + response = requests.post( + url, + data={ + "email": user_data["email"], + "password": "wrong-password" + } + ) + + # For bad credentials, any of these status codes would be valid + # The test shouldn't be brittle to minor implementation changes + assert response.status_code != 500, "Authentication should not cause server errors" + assert response.status_code != 404, "Authentication endpoint should exist" + + if response.status_code == 200: + # If API returns 200 for failures, check the response body for error indication + if hasattr(response, 'json'): + try: + data = response.json() + # JSON response might indicate error in its structure + assert "error" in data or "error_code" in data or "detail" in data or response.url.endswith("sign-in"), \ + "Error response should contain error details" + except ValueError: + # It's ok if response isn't JSON format + pass + elif response.status_code in [302, 303]: + # If it's a redirect, it should redirect to a login page or error page + redirect_url = response.headers.get('Location', '') + assert "error" in redirect_url or "sign-in" in redirect_url, \ + "Failed login should redirect to login page or error page" + + # 2. Test good login with correct credentials + response = requests.post( + url, + data={ + "email": user_data["email"], + "password": user_data["password"] + }, + allow_redirects=False # Don't follow redirects + ) + + # Successful auth should not be a client error or server error + assert response.status_code not in range(400, 600), \ + f"Authentication with valid credentials failed with status {response.status_code}" + + # Specific validation based on response type + if response.status_code in [302, 303]: + # Redirect-based auth: check that redirect URL doesn't contain error + redirect_url = response.headers.get('Location', '') + assert "error" not in redirect_url and "error_code" not in redirect_url, \ + "Successful login redirect should not contain error parameters" + + elif response.status_code == 200: + # API token-based auth: check for tokens or user session + if hasattr(response, 'json'): + try: + data = response.json() + # If it's a token response + if "access_token" in data: + assert "refresh_token" in data, "JWT auth should return both access and refresh tokens" + # If it's a user session response + elif "user" in data: + assert "is_authenticated" in data and data["is_authenticated"], \ + "User session response should indicate authentication" + # Otherwise it should at least indicate success + else: + assert not any(error_key in data for error_key in ["error", "error_code", "detail"]), \ + "Success response should not contain error keys" + except ValueError: + # Non-JSON is acceptable if it's a redirect or HTML response + pass + + +@pytest.mark.smoke +class TestHealthCheckSmoke: + """Smoke test for health check endpoint""" + + def test_healthcheck_endpoint(self, plane_server): + """Test that the health check endpoint is available and responds correctly""" + # Make a request to the health check endpoint + response = requests.get(f"{plane_server.url}/") + + # Should be OK + assert response.status_code == 200, "Health check endpoint should return 200 OK" \ No newline at end of file diff --git a/apiserver/plane/tests/unit/__init__.py b/apiserver/plane/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/tests/unit/models/__init__.py b/apiserver/plane/tests/unit/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/tests/unit/models/test_workspace_model.py b/apiserver/plane/tests/unit/models/test_workspace_model.py new file mode 100644 index 0000000000..40380fa0f4 --- /dev/null +++ b/apiserver/plane/tests/unit/models/test_workspace_model.py @@ -0,0 +1,50 @@ +import pytest +from uuid import uuid4 + +from plane.db.models import Workspace, WorkspaceMember, User + + +@pytest.mark.unit +class TestWorkspaceModel: + """Test the Workspace model""" + + @pytest.mark.django_db + def test_workspace_creation(self, create_user): + """Test creating a workspace""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=create_user + ) + + # Verify it was created + assert workspace.id is not None + assert workspace.name == "Test Workspace" + assert workspace.slug == "test-workspace" + assert workspace.owner == create_user + + @pytest.mark.django_db + def test_workspace_member_creation(self, create_user): + """Test creating a workspace member""" + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=create_user + ) + + # Create a workspace member + workspace_member = WorkspaceMember.objects.create( + workspace=workspace, + member=create_user, + role=20 # Admin role + ) + + # Verify it was created + assert workspace_member.id is not None + assert workspace_member.workspace == workspace + assert workspace_member.member == create_user + assert workspace_member.role == 20 \ No newline at end of file diff --git a/apiserver/plane/tests/unit/serializers/__init__.py b/apiserver/plane/tests/unit/serializers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/tests/unit/serializers/test_workspace.py b/apiserver/plane/tests/unit/serializers/test_workspace.py new file mode 100644 index 0000000000..19767a7c61 --- /dev/null +++ b/apiserver/plane/tests/unit/serializers/test_workspace.py @@ -0,0 +1,71 @@ +import pytest +from uuid import uuid4 + +from plane.api.serializers import WorkspaceLiteSerializer +from plane.db.models import Workspace, User + + +@pytest.mark.unit +class TestWorkspaceLiteSerializer: + """Test the WorkspaceLiteSerializer""" + + def test_workspace_lite_serializer_fields(self, db): + """Test that the serializer includes the correct fields""" + # Create a user to be the owner + owner = User.objects.create( + email="test@example.com", + first_name="Test", + last_name="User" + ) + + # Create a workspace with explicit ID to test serialization + workspace_id = uuid4() + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=workspace_id, + owner=owner + ) + + # Serialize the workspace + serialized_data = WorkspaceLiteSerializer(workspace).data + + # Check fields are present and correct + assert "name" in serialized_data + assert "slug" in serialized_data + assert "id" in serialized_data + + assert serialized_data["name"] == "Test Workspace" + assert serialized_data["slug"] == "test-workspace" + assert str(serialized_data["id"]) == str(workspace_id) + + def test_workspace_lite_serializer_read_only(self, db): + """Test that the serializer fields are read-only""" + # Create a user to be the owner + owner = User.objects.create( + email="test2@example.com", + first_name="Test", + last_name="User" + ) + + # Create a workspace + workspace = Workspace.objects.create( + name="Test Workspace", + slug="test-workspace", + id=uuid4(), + owner=owner + ) + + # Try to update via serializer + serializer = WorkspaceLiteSerializer( + workspace, + data={"name": "Updated Name", "slug": "updated-slug"} + ) + + # Serializer should be valid (since read-only fields are ignored) + assert serializer.is_valid() + + # Save should not update the read-only fields + updated_workspace = serializer.save() + assert updated_workspace.name == "Test Workspace" + assert updated_workspace.slug == "test-workspace" \ No newline at end of file diff --git a/apiserver/plane/tests/unit/utils/__init__.py b/apiserver/plane/tests/unit/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apiserver/plane/tests/unit/utils/test_uuid.py b/apiserver/plane/tests/unit/utils/test_uuid.py new file mode 100644 index 0000000000..81403c5bef --- /dev/null +++ b/apiserver/plane/tests/unit/utils/test_uuid.py @@ -0,0 +1,49 @@ +import uuid +import pytest +from plane.utils.uuid import is_valid_uuid, convert_uuid_to_integer + + +@pytest.mark.unit +class TestUUIDUtils: + """Test the UUID utilities""" + + def test_is_valid_uuid_with_valid_uuid(self): + """Test is_valid_uuid with a valid UUID""" + # Generate a valid UUID + valid_uuid = str(uuid.uuid4()) + assert is_valid_uuid(valid_uuid) is True + + def test_is_valid_uuid_with_invalid_uuid(self): + """Test is_valid_uuid with invalid UUID strings""" + # Test with different invalid formats + assert is_valid_uuid("not-a-uuid") is False + assert is_valid_uuid("123456789") is False + assert is_valid_uuid("") is False + assert is_valid_uuid("00000000-0000-0000-0000-000000000000") is False # This is a valid UUID but version 1 + + def test_convert_uuid_to_integer(self): + """Test convert_uuid_to_integer function""" + # Create a known UUID + test_uuid = uuid.UUID("f47ac10b-58cc-4372-a567-0e02b2c3d479") + + # Convert to integer + result = convert_uuid_to_integer(test_uuid) + + # Check that the result is an integer + assert isinstance(result, int) + + # Ensure consistent results with the same input + assert convert_uuid_to_integer(test_uuid) == result + + # Different UUIDs should produce different integers + different_uuid = uuid.UUID("550e8400-e29b-41d4-a716-446655440000") + assert convert_uuid_to_integer(different_uuid) != result + + def test_convert_uuid_to_integer_string_input(self): + """Test convert_uuid_to_integer handles string UUID""" + # Test with a UUID string + test_uuid_str = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + test_uuid = uuid.UUID(test_uuid_str) + + # Should get the same result whether passing UUID or string + assert convert_uuid_to_integer(test_uuid) == convert_uuid_to_integer(test_uuid_str) \ No newline at end of file diff --git a/apiserver/pytest.ini b/apiserver/pytest.ini new file mode 100644 index 0000000000..e2f1944567 --- /dev/null +++ b/apiserver/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +DJANGO_SETTINGS_MODULE = plane.settings.test +python_files = test_*.py +python_classes = Test* +python_functions = test_* + +markers = + unit: Unit tests for models, serializers, and utility functions + contract: Contract tests for API endpoints + smoke: Smoke tests for critical functionality + slow: Tests that are slow and might be skipped in some contexts + +addopts = + --strict-markers + --reuse-db + --nomigrations + -vs \ No newline at end of file diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt index 1ffc82d006..85978128b9 100644 --- a/apiserver/requirements/test.txt +++ b/apiserver/requirements/test.txt @@ -1,4 +1,12 @@ -r base.txt -# test checker -pytest==7.1.2 -coverage==6.5.0 \ No newline at end of file +# test framework +pytest==7.4.0 +pytest-django==4.5.2 +pytest-cov==4.1.0 +pytest-xdist==3.3.1 +pytest-mock==3.11.1 +factory-boy==3.3.0 +freezegun==1.2.2 +coverage==7.2.7 +httpx==0.24.1 +requests==2.31.0 \ No newline at end of file diff --git a/apiserver/run_tests.py b/apiserver/run_tests.py new file mode 100755 index 0000000000..f4f0951b19 --- /dev/null +++ b/apiserver/run_tests.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +import argparse +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description="Run Plane tests") + parser.add_argument( + "-u", "--unit", + action="store_true", + help="Run unit tests only" + ) + parser.add_argument( + "-c", "--contract", + action="store_true", + help="Run contract tests only" + ) + parser.add_argument( + "-s", "--smoke", + action="store_true", + help="Run smoke tests only" + ) + parser.add_argument( + "-o", "--coverage", + action="store_true", + help="Generate coverage report" + ) + parser.add_argument( + "-p", "--parallel", + action="store_true", + help="Run tests in parallel" + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Verbose output" + ) + args = parser.parse_args() + + # Build command + cmd = ["python", "-m", "pytest"] + markers = [] + + # Add test markers + if args.unit: + markers.append("unit") + if args.contract: + markers.append("contract") + if args.smoke: + markers.append("smoke") + + # Add markers filter + if markers: + cmd.extend(["-m", " or ".join(markers)]) + + # Add coverage + if args.coverage: + cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"]) + + # Add parallel + if args.parallel: + cmd.extend(["-n", "auto"]) + + # Add verbose + if args.verbose: + cmd.append("-v") + + # Add common flags + cmd.extend(["--reuse-db", "--nomigrations"]) + + # Print command + print(f"Running: {' '.join(cmd)}") + + # Execute command + result = subprocess.run(cmd) + + # Check coverage thresholds if coverage is enabled + if args.coverage: + print("Checking coverage thresholds...") + coverage_cmd = ["python", "-m", "coverage", "report", "--fail-under=90"] + coverage_result = subprocess.run(coverage_cmd) + if coverage_result.returncode != 0: + print("Coverage below threshold (90%)") + sys.exit(coverage_result.returncode) + + sys.exit(result.returncode) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/apiserver/run_tests.sh b/apiserver/run_tests.sh new file mode 100755 index 0000000000..7e22479b57 --- /dev/null +++ b/apiserver/run_tests.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# This is a simple wrapper script that calls the main test runner in the tests directory +exec tests/run_tests.sh "$@" \ No newline at end of file From 04c7c53e0926910aa2dae2b68348794a12ba742f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 19:45:15 +0530 Subject: [PATCH 23/23] chore(deps): bump requests (#7120) Bumps the pip group with 1 update in the /apiserver/requirements directory: [requests](https://github.com/psf/requests). Updates `requests` from 2.31.0 to 2.32.2 - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.32.2) --- updated-dependencies: - dependency-name: requests dependency-version: 2.32.2 dependency-type: direct:production dependency-group: pip ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- apiserver/requirements/test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apiserver/requirements/test.txt b/apiserver/requirements/test.txt index 85978128b9..9536ab1e27 100644 --- a/apiserver/requirements/test.txt +++ b/apiserver/requirements/test.txt @@ -9,4 +9,4 @@ factory-boy==3.3.0 freezegun==1.2.2 coverage==7.2.7 httpx==0.24.1 -requests==2.31.0 \ No newline at end of file +requests==2.32.2 \ No newline at end of file