From 260d9a053d82f59c7fad04270019f1e15733c926 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:49:30 +0530 Subject: [PATCH 001/169] [WEB-4802] fix: module sort order (#7674) * fix: module sort order * chore: removed secondary sort for progress * chore: sort refactor --- packages/utils/src/module.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/utils/src/module.ts b/packages/utils/src/module.ts index 37108b930..8d7eb4659 100644 --- a/packages/utils/src/module.ts +++ b/packages/utils/src/module.ts @@ -5,6 +5,15 @@ import { IModule, TModuleDisplayFilters, TModuleFilters, TModuleOrderByOptions } import { getDate } from "./datetime"; import { satisfiesDateFilter } from "./filter"; +const collator = new Intl.Collator("en-US", { numeric: true, sensitivity: "base" }); + +/** + * @description performs natural sorting of strings (handles numbers within strings correctly) + * @param {string} a - first string to compare + * @param {string} b - second string to compare + * @returns {number} - comparison result (-1, 0, or 1) + */ +const naturalSort = (a: string, b: string): number => collator.compare(a, b); /** * @description orders modules based on their status * @param {IModule[]} modules @@ -15,8 +24,8 @@ export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptio let orderedModules: IModule[] = []; if (modules.length === 0 || !orderByKey) return []; - if (orderByKey === "name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]); - if (orderByKey === "-name") orderedModules = sortBy(modules, [(m) => m.name.toLowerCase()]).reverse(); + if (orderByKey === "name") orderedModules = [...modules].sort((a, b) => naturalSort(a.name, b.name)); + if (orderByKey === "-name") orderedModules = [...modules].sort((a, b) => naturalSort(b.name, a.name)); if (["progress", "-progress"].includes(orderByKey)) orderedModules = sortBy(modules, [ (m) => { @@ -24,13 +33,9 @@ export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptio if (isNaN(progress)) progress = 0; return orderByKey === "progress" ? progress : -progress; }, - "name", ]); if (["issues_length", "-issues_length"].includes(orderByKey)) - orderedModules = sortBy(modules, [ - (m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues), - "name", - ]); + orderedModules = sortBy(modules, [(m) => (orderByKey === "issues_length" ? m.total_issues : !m.total_issues)]); if (orderByKey === "target_date") orderedModules = sortBy(modules, [(m) => m.target_date]); if (orderByKey === "-target_date") orderedModules = sortBy(modules, [(m) => !m.target_date]); if (orderByKey === "created_at") orderedModules = sortBy(modules, [(m) => m.created_at]); From 56cd0fc44502a99fae8a2248913051570c8f75d3 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:50:11 +0530 Subject: [PATCH 002/169] [WEB-4025] fix: external user comment and reaction (#7692) * chore: reactions types updated * fix: external user comments * fix: external user reactions * chore: added display name for actor * chore: merge conflicts * chore: updated the created_by and updated_by --------- Co-authored-by: NarayanBavisetti --- apps/api/plane/app/serializers/issue.py | 23 +++++++++++++-- .../ce/components/comments/comment-block.tsx | 28 +++++++++---------- .../issue-detail/reactions/issue-comment.tsx | 4 ++- .../issues/issue-detail/reactions/issue.tsx | 4 ++- .../issues/activity/issue_comment_reaction.ts | 1 + packages/types/src/issues/issue_reaction.ts | 1 + 6 files changed, 42 insertions(+), 19 deletions(-) diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 1eda37601..8a643ce4d 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -667,16 +667,33 @@ class IssueReactionSerializer(BaseSerializer): class IssueReactionLiteSerializer(DynamicBaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + class Meta: model = IssueReaction - fields = ["id", "actor", "issue", "reaction"] + fields = ["id", "actor", "issue", "reaction", "display_name"] class CommentReactionSerializer(BaseSerializer): + display_name = serializers.CharField(source="actor.display_name", read_only=True) + class Meta: model = CommentReaction - fields = "__all__" - read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at"] + fields = [ + "id", + "actor", + "comment", + "reaction", + "display_name", + "deleted_at", + "workspace", + "project", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = ["workspace", "project", "comment", "actor", "deleted_at", "created_by", "updated_by"] class IssueVoteSerializer(BaseSerializer): diff --git a/apps/web/ce/components/comments/comment-block.tsx b/apps/web/ce/components/comments/comment-block.tsx index 42c9dbfd1..6ed7fc094 100644 --- a/apps/web/ce/components/comments/comment-block.tsx +++ b/apps/web/ce/components/comments/comment-block.tsx @@ -2,9 +2,8 @@ import { FC, ReactNode, useRef } from "react"; import { observer } from "mobx-react"; // plane imports import { useTranslation } from "@plane/i18n"; -import { Tooltip } from "@plane/propel/tooltip"; -import { TIssueComment } from "@plane/types"; -import { Avatar } from "@plane/ui"; +import { EIssueCommentAccessSpecifier, TIssueComment } from "@plane/types"; +import { Avatar, Tooltip } from "@plane/ui"; import { calculateTimeAgo, cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; @@ -27,7 +26,13 @@ export const CommentBlock: FC = observer((props) => { // translation const { t } = useTranslation(); - if (!comment || !userDetails) return null; + const displayName = comment?.actor_detail?.is_bot + ? comment?.actor_detail?.first_name + ` ${t("bot")}` + : (userDetails?.display_name ?? comment?.actor_detail?.display_name); + + const avatarUrl = userDetails?.avatar_url ?? comment?.actor_detail?.avatar_url; + + if (!comment) return null; return (
= observer((props) => { "flex-shrink-0 relative w-7 h-6 rounded-full transition-border duration-1000 flex justify-center items-center z-[3] uppercase font-medium" )} > - +
-
- {comment?.actor_detail?.is_bot - ? comment?.actor_detail?.first_name + ` ${t("bot")}` - : comment?.actor_detail?.display_name || userDetails.display_name} +
+ + {`${displayName}${comment.access === EIssueCommentAccessSpecifier.EXTERNAL ? " (External User)" : ""}`} +
commented{" "} diff --git a/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx b/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx index 4305ef6ed..911b4fe8a 100644 --- a/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx +++ b/apps/web/core/components/issues/issue-detail/reactions/issue-comment.tsx @@ -85,7 +85,9 @@ export const IssueCommentReaction: FC = observer((props) const reactionUsers = (reactionIds?.[reaction] || []) .map((reactionId) => { const reactionDetails = getCommentReactionById(reactionId); - return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null; + return reactionDetails + ? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name + : null; }) .filter((displayName): displayName is string => !!displayName); const formattedUsers = formatTextList(reactionUsers); diff --git a/apps/web/core/components/issues/issue-detail/reactions/issue.tsx b/apps/web/core/components/issues/issue-detail/reactions/issue.tsx index 243b26aef..ff5ae3099 100644 --- a/apps/web/core/components/issues/issue-detail/reactions/issue.tsx +++ b/apps/web/core/components/issues/issue-detail/reactions/issue.tsx @@ -85,7 +85,9 @@ export const IssueReaction: FC = observer((props) => { const reactionUsers = (reactionIds?.[reaction] || []) .map((reactionId) => { const reactionDetails = getReactionById(reactionId); - return reactionDetails ? getUserDetails(reactionDetails.actor)?.display_name : null; + return reactionDetails + ? getUserDetails(reactionDetails?.actor)?.display_name || reactionDetails?.display_name + : null; }) .filter((displayName): displayName is string => !!displayName); diff --git a/packages/types/src/issues/activity/issue_comment_reaction.ts b/packages/types/src/issues/activity/issue_comment_reaction.ts index 892a3e906..590b18ca5 100644 --- a/packages/types/src/issues/activity/issue_comment_reaction.ts +++ b/packages/types/src/issues/activity/issue_comment_reaction.ts @@ -9,6 +9,7 @@ export type TIssueCommentReaction = { updated_at: Date; created_by: string; updated_by: string; + display_name: string; }; export type TIssueCommentReactionMap = { diff --git a/packages/types/src/issues/issue_reaction.ts b/packages/types/src/issues/issue_reaction.ts index 49c971f4a..bb0bf7a6a 100644 --- a/packages/types/src/issues/issue_reaction.ts +++ b/packages/types/src/issues/issue_reaction.ts @@ -5,6 +5,7 @@ export type TIssueReaction = { id: string; issue: string; reaction: string; + display_name: string; }; export interface IIssuePublicReaction { From 9ab3143a7343428b2e1da72d957a9b7b0bf2e5aa Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:50:39 +0530 Subject: [PATCH 003/169] [WEB-4816] chore: add label flow (#7716) * chore: remove create label modal * fix: label spinner * chore: add label flow improvements * chore: code refactor * chore: code refactor * chore: code refactor --- .../modals/create-modal/issue-properties.tsx | 1 - .../properties/label-dropdown.tsx | 2 +- .../components/default-properties.tsx | 8 +- .../components/issues/issue-modal/form.tsx | 16 -- .../core/components/issues/select/base.tsx | 75 ++++-- .../components/issues/select/dropdown.tsx | 19 +- .../components/labels/create-label-modal.tsx | 218 ------------------ apps/web/core/components/labels/index.ts | 1 - 8 files changed, 84 insertions(+), 256 deletions(-) delete mode 100644 apps/web/core/components/labels/create-label-modal.tsx diff --git a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx index 2a3609937..b06619909 100644 --- a/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx +++ b/apps/web/core/components/inbox/modals/create-modal/issue-properties.tsx @@ -89,7 +89,6 @@ export const InboxIssueProperties: FC = observer((props) {/* labels */}
{}} value={data?.label_ids || []} onChange={(labelIds) => handleData("label_ids", labelIds)} projectId={projectId} diff --git a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx index 20d137e9d..357423e04 100644 --- a/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx +++ b/apps/web/core/components/issues/issue-layouts/properties/label-dropdown.tsx @@ -277,7 +277,7 @@ export const LabelDropdown = (props: ILabelDropdownProps) => { )) ) : submitting ? ( - + ) : canCreateLabel ? (

{ diff --git a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx index 6b3c060d7..53abf7bd8 100644 --- a/apps/web/core/components/issues/issue-modal/components/default-properties.tsx +++ b/apps/web/core/components/issues/issue-modal/components/default-properties.tsx @@ -42,7 +42,6 @@ type TIssueDefaultPropertiesProps = { parentId: string | null; isDraft: boolean; handleFormChange: () => void; - setLabelModal: React.Dispatch>; setSelectedParentIssue: (issue: ISearchIssueResponse) => void; }; @@ -58,7 +57,6 @@ export const IssueDefaultProperties: React.FC = ob parentId, isDraft, handleFormChange, - setLabelModal, setSelectedParentIssue, } = props; // states @@ -74,7 +72,8 @@ export const IssueDefaultProperties: React.FC = ob const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile); - const canCreateLabel = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT); + const canCreateLabel = + projectId && allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId); const minDate = getDate(startDate); minDate?.setDate(minDate.getDate()); @@ -147,7 +146,6 @@ export const IssueDefaultProperties: React.FC = ob render={({ field: { value, onChange } }) => (

{ onChange(labelIds); @@ -155,7 +153,7 @@ export const IssueDefaultProperties: React.FC = ob }} projectId={projectId ?? undefined} tabIndex={getIndex("label_ids")} - createLabelEnabled={canCreateLabel} + createLabelEnabled={!!canCreateLabel} />
)} diff --git a/apps/web/core/components/issues/issue-modal/form.tsx b/apps/web/core/components/issues/issue-modal/form.tsx index b84ce03a0..c4fbb6d41 100644 --- a/apps/web/core/components/issues/issue-modal/form.tsx +++ b/apps/web/core/components/issues/issue-modal/form.tsx @@ -28,12 +28,10 @@ import { IssueProjectSelect, IssueTitleInput, } from "@/components/issues/issue-modal/components"; -import { CreateLabelModal } from "@/components/labels"; // helpers // hooks import { useIssueModal } from "@/hooks/context/use-issue-modal"; import { useIssueDetail } from "@/hooks/store/use-issue-detail"; -import { useLabel } from "@/hooks/store/use-label"; import { useProject } from "@/hooks/store/use-project"; import { useProjectState } from "@/hooks/store/use-project-state"; import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft"; @@ -97,7 +95,6 @@ export const IssueFormRoot: FC = observer((props) => { } = props; // states - const [labelModal, setLabelModal] = useState(false); const [gptAssistantModal, setGptAssistantModal] = useState(false); const [isMoving, setIsMoving] = useState(false); @@ -126,7 +123,6 @@ export const IssueFormRoot: FC = observer((props) => { } = useIssueModal(); const { isMobile } = usePlatformOS(); const { moveIssue } = useWorkspaceDraftIssues(); - const { createLabel } = useLabel(); const { issue: { getIssueById }, @@ -363,17 +359,6 @@ export const IssueFormRoot: FC = observer((props) => { return ( - {projectId && ( - setLabelModal(false)} - onSuccess={(response) => { - setValue<"label_ids">("label_ids", [...watch("label_ids"), response.id]); - handleFormChange(); - }} - /> - )}
= observer((props) => { parentId={watch("parent_id")} isDraft={isDraft} handleFormChange={handleFormChange} - setLabelModal={setLabelModal} setSelectedParentIssue={setSelectedParentIssue} />
diff --git a/apps/web/core/components/issues/select/base.tsx b/apps/web/core/components/issues/select/base.tsx index 8c48cf78e..07ee8d917 100644 --- a/apps/web/core/components/issues/select/base.tsx +++ b/apps/web/core/components/issues/select/base.tsx @@ -2,8 +2,9 @@ import React, { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; import { usePopper } from "react-popper"; -import { Check, Component, Plus, Search, Tag } from "lucide-react"; +import { Check, Component, Loader, Search, Tag } from "lucide-react"; import { Combobox } from "@headlessui/react"; +import { getRandomLabelColor } from "@plane/constants"; // plane imports import { useOutsideClickDetector } from "@plane/hooks"; import { useTranslation } from "@plane/i18n"; @@ -26,7 +27,7 @@ export type TWorkItemLabelSelectBaseProps = { onChange: (value: string[]) => void; onDropdownOpen?: () => void; placement?: Placement; - setIsOpen: React.Dispatch>; + createLabel?: (data: Partial) => Promise; tabIndex?: number; value: string[]; }; @@ -43,7 +44,7 @@ export const WorkItemLabelSelectBase: React.FC = onChange, onDropdownOpen, placement, - setIsOpen, + createLabel, tabIndex, value, } = props; @@ -55,6 +56,7 @@ export const WorkItemLabelSelectBase: React.FC = const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); // plane hooks const { t } = useTranslation(); // store hooks @@ -88,6 +90,26 @@ export const WorkItemLabelSelectBase: React.FC = onChange(val); }; + const searchInputKeyDown = async (e: React.KeyboardEvent) => { + const q = query.trim(); + if (q !== "" && e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setQuery(""); + return; + } + if ( + q !== "" && + e.key === "Enter" && + !e.nativeEvent.isComposing && + createLabelEnabled && + filteredOptions.length === 0 && + !submitting + ) { + e.preventDefault(); + await handleAddLabel(q); + } + }; const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); const handleOnClick = (e: React.MouseEvent) => { @@ -104,6 +126,23 @@ export const WorkItemLabelSelectBase: React.FC = } }, [isDropdownOpen, isMobile]); + const handleAddLabel = async (labelName: string) => { + if (!createLabel || submitting) return; + const name = labelName.trim(); + if (!name) return; + setSubmitting(true); + try { + const existing = labelsList.find((l) => l.name.toLowerCase() === name.toLowerCase()); + const idToAdd = existing ? existing.id : (await createLabel({ name, color: getRandomLabelColor() })).id; + onChange(Array.from(new Set([...value, idToAdd]))); + setQuery(""); + } catch (e) { + console.error("Failed to create label", e); + } finally { + setSubmitting(false); + } + }; + return ( = onChange={(event) => setQuery(event.target.value)} placeholder={t("search")} displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
@@ -242,22 +282,31 @@ export const WorkItemLabelSelectBase: React.FC =
); }) + ) : submitting ? ( + + ) : createLabelEnabled ? ( +

{ + if (!query.length) return; + handleAddLabel(query); + }} + className={`text-left text-custom-text-200 ${query.length ? "cursor-pointer" : "cursor-default"}`} + > + {/* TODO: translate here */} + {query.length ? ( + <> + + Add "{query}" to labels + + ) : ( + t("label.create.type") + )} +

) : (

{t("no_matching_results")}

) ) : (

{t("loading")}

)} - {createLabelEnabled && ( - - )}
diff --git a/apps/web/core/components/issues/select/dropdown.tsx b/apps/web/core/components/issues/select/dropdown.tsx index be66d709a..08d792b77 100644 --- a/apps/web/core/components/issues/select/dropdown.tsx +++ b/apps/web/core/components/issues/select/dropdown.tsx @@ -1,8 +1,11 @@ import React from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; +import { EUserPermissionsLevel } from "@plane/constants"; +import { EUserPermissions, IIssueLabel } from "@plane/types"; // hooks import { useLabel } from "@/hooks/store/use-label"; +import { useUserPermissions } from "@/hooks/store/user"; // local imports import { TWorkItemLabelSelectBaseProps, WorkItemLabelSelectBase } from "./base"; @@ -15,21 +18,35 @@ export const IssueLabelSelect: React.FC = observer((p // router const { workspaceSlug } = useParams(); // store hooks - const { getProjectLabelIds, getLabelById, fetchProjectLabels } = useLabel(); + const { allowPermissions } = useUserPermissions(); + const { getProjectLabelIds, getLabelById, fetchProjectLabels, createLabel } = useLabel(); // derived values const projectLabelIds = getProjectLabelIds(projectId); + const canCreateLabel = + projectId && + allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug?.toString(), projectId); + const onDropdownOpen = () => { if (projectLabelIds === undefined && workspaceSlug && projectId) fetchProjectLabels(workspaceSlug.toString(), projectId); }; + const handleCreateLabel = (data: Partial) => { + if (!workspaceSlug || !projectId) { + throw new Error("Workspace slug or project ID is missing"); + } + return createLabel(workspaceSlug.toString(), projectId, data); + }; + return ( ); }); diff --git a/apps/web/core/components/labels/create-label-modal.tsx b/apps/web/core/components/labels/create-label-modal.tsx deleted file mode 100644 index 413429c8a..000000000 --- a/apps/web/core/components/labels/create-label-modal.tsx +++ /dev/null @@ -1,218 +0,0 @@ -"use client"; - -import React, { useEffect } from "react"; -import { observer } from "mobx-react"; -import { TwitterPicker } from "react-color"; -import { Controller, useForm } from "react-hook-form"; -import { ChevronDown } from "lucide-react"; -import { Dialog, Popover, Transition } from "@headlessui/react"; -// plane imports -import { ETabIndices, LABEL_COLOR_OPTIONS, getRandomLabelColor } from "@plane/constants"; -// types -import type { IIssueLabel, IState } from "@plane/types"; -// ui -import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; -// helpers -import { getTabIndex } from "@plane/utils"; -// hooks - -import { usePlatformOS } from "@/hooks/use-platform-os"; - -// types -type Props = { - createLabel: (data: Partial) => Promise; - handleClose: () => void; - isOpen: boolean; - onSuccess?: (response: IIssueLabel) => void; -}; - -const defaultValues: Partial = { - name: "", - color: "rgb(var(--color-text-200))", -}; - -export const CreateLabelModal: React.FC = observer((props) => { - const { createLabel, handleClose, isOpen, onSuccess } = props; - // store hooks - const { isMobile } = usePlatformOS(); - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - watch, - control, - reset, - setValue, - setFocus, - } = useForm({ - defaultValues, - }); - - const { getIndex } = getTabIndex(ETabIndices.CREATE_LABEL, isMobile); - - /** - * For setting focus on name input - */ - useEffect(() => { - setFocus("name"); - }, [setFocus, isOpen]); - - useEffect(() => { - if (isOpen) setValue("color", getRandomLabelColor()); - }, [setValue, isOpen]); - - const onClose = () => { - handleClose(); - reset(defaultValues); - }; - - const onSubmit = async (formData: IIssueLabel) => { - await createLabel(formData) - .then((res) => { - onClose(); - if (onSuccess) onSuccess(res); - }) - .catch((error) => { - setToast({ - title: "Error!", - type: TOAST_TYPE.ERROR, - message: error?.detail ?? "Something went wrong. Please try again later.", - }); - reset(formData); - }); - }; - - return ( - - - -
- - -
-
- - - { - e.preventDefault(); - e.stopPropagation(); - handleSubmit(onSubmit)(e); - }} - > -
- - Create Label - -
- - {({ open, close }) => ( - <> - - {watch("color") && watch("color") !== "" && ( - - )} - - - - - ( - { - onChange(value.hex); - close(); - }} - /> - )} - /> - - - - )} - -
- ( - - )} - /> -
-
-
-
- - -
- -
-
-
-
-
-
- ); -}); diff --git a/apps/web/core/components/labels/index.ts b/apps/web/core/components/labels/index.ts index 9195f8649..7fe388345 100644 --- a/apps/web/core/components/labels/index.ts +++ b/apps/web/core/components/labels/index.ts @@ -1,4 +1,3 @@ -export * from "./create-label-modal"; export * from "./create-update-label-inline"; export * from "./delete-label-modal"; export * from "./project-setting-label-group"; From 498613284e3bb8028fafd937e07a7b0e667d2286 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:50:51 +0530 Subject: [PATCH 004/169] [WEB-4841] chore: calendar component migration UI to propel (#7730) * chore: move calendar components and dependencies * chore: update package configurations * chore: calendar import updated * chore: propel config updated * chore: propel calendar code refactor * chore: code refactor * fix: build error --- apps/web/app/(all)/layout.tsx | 2 +- .../core/filters/date-filter-modal.tsx | 3 +- .../core/components/dropdowns/date-range.tsx | 4 +- apps/web/core/components/dropdowns/date.tsx | 4 +- .../inbox/modals/snooze-issue-modal.tsx | 3 +- apps/web/package.json | 1 - packages/propel/.storybook/preview.ts | 1 + packages/propel/package.json | 3 + .../propel/src/calendar/calendar.stories.tsx | 32 ++++ packages/propel/src/calendar/index.ts | 2 + packages/propel/src/calendar/root.tsx | 38 +++++ .../propel/src}/styles/react-day-picker.css | 0 packages/propel/tsdown.config.ts | 1 + packages/ui/src/calendar.tsx | 85 ----------- packages/ui/src/index.ts | 1 - pnpm-lock.yaml | 140 +++++++++--------- 16 files changed, 156 insertions(+), 164 deletions(-) create mode 100644 packages/propel/src/calendar/calendar.stories.tsx create mode 100644 packages/propel/src/calendar/index.ts create mode 100644 packages/propel/src/calendar/root.tsx rename {apps/web => packages/propel/src}/styles/react-day-picker.css (100%) delete mode 100644 packages/ui/src/calendar.tsx diff --git a/apps/web/app/(all)/layout.tsx b/apps/web/app/(all)/layout.tsx index 32589c4bf..2fde65188 100644 --- a/apps/web/app/(all)/layout.tsx +++ b/apps/web/app/(all)/layout.tsx @@ -5,7 +5,7 @@ import { PreloadResources } from "./layout.preload"; // styles import "@/styles/command-pallette.css"; import "@/styles/emoji.css"; -import "@/styles/react-day-picker.css"; +import "@plane/propel/styles/react-day-picker"; export const metadata: Metadata = { robots: { diff --git a/apps/web/core/components/core/filters/date-filter-modal.tsx b/apps/web/core/components/core/filters/date-filter-modal.tsx index 874d1ea5d..dd7173959 100644 --- a/apps/web/core/components/core/filters/date-filter-modal.tsx +++ b/apps/web/core/components/core/filters/date-filter-modal.tsx @@ -5,7 +5,8 @@ import { Controller, useForm } from "react-hook-form"; import { X } from "lucide-react"; import { Dialog, Transition } from "@headlessui/react"; -import { Button, Calendar } from "@plane/ui"; +import { Calendar } from "@plane/propel/calendar"; +import { Button } from "@plane/ui"; import { renderFormattedPayloadDate, renderFormattedDate, getDate } from "@plane/utils"; import { DateFilterSelect } from "./date-filter-select"; diff --git a/apps/web/core/components/dropdowns/date-range.tsx b/apps/web/core/components/dropdowns/date-range.tsx index 09f0419cc..e0da93465 100644 --- a/apps/web/core/components/dropdowns/date-range.tsx +++ b/apps/web/core/components/dropdowns/date-range.tsx @@ -3,14 +3,14 @@ import React, { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; -import { DateRange, Matcher } from "react-day-picker"; import { usePopper } from "react-popper"; import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; // ui -import { ComboDropDown, Calendar } from "@plane/ui"; +import { Calendar, DateRange, Matcher } from "@plane/propel/calendar"; +import { ComboDropDown } from "@plane/ui"; import { cn, renderFormattedDate } from "@plane/utils"; // helpers // hooks diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index f01f1ea50..4a1af03e2 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -1,12 +1,12 @@ import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; -import { Matcher } from "react-day-picker"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; // ui -import { ComboDropDown, Calendar } from "@plane/ui"; +import { Calendar, Matcher } from "@plane/propel/calendar"; +import { ComboDropDown } from "@plane/ui"; import { cn, renderFormattedDate, getDate } from "@plane/utils"; // helpers // hooks diff --git a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx index 6d53a0234..3f9040e5c 100644 --- a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx +++ b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx @@ -4,7 +4,8 @@ import { FC, Fragment, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; // ui import { useTranslation } from "@plane/i18n"; -import { Button, Calendar } from "@plane/ui"; +import { Calendar } from "@plane/propel/calendar"; +import { Button } from "@plane/ui"; export type InboxIssueSnoozeModalProps = { isOpen: boolean; diff --git a/apps/web/package.json b/apps/web/package.json index 1c9954551..58c84cbe7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,7 +52,6 @@ "posthog-js": "^1.131.3", "react": "catalog:", "react-color": "^2.19.3", - "react-day-picker": "9.5.0", "react-dom": "catalog:", "react-dropzone": "^14.2.3", "react-hook-form": "7.51.5", diff --git a/packages/propel/.storybook/preview.ts b/packages/propel/.storybook/preview.ts index e2f23614e..b1402e0bd 100644 --- a/packages/propel/.storybook/preview.ts +++ b/packages/propel/.storybook/preview.ts @@ -1,5 +1,6 @@ import type { Preview } from "@storybook/react-vite"; import "@plane/tailwind-config/global.css"; +import "../src/styles/react-day-picker.css"; const parameters: Preview["parameters"] = { controls: { diff --git a/packages/propel/package.json b/packages/propel/package.json index a4ebe0495..bc0888176 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -18,6 +18,7 @@ "exports": { "./accordion": "./dist/accordion/index.js", "./avatar": "./dist/avatar/index.js", + "./calendar": "./dist/calendar/index.js", "./card": "./dist/card/index.js", "./charts/*": "./dist/charts/*/index.js", "./collapsible": "./dist/collapsible/index.js", @@ -31,6 +32,7 @@ "./scrollarea": "./dist/scrollarea/index.js", "./skeleton": "./dist/skeleton/index.js", "./styles/fonts": "./dist/styles/fonts/index.css", + "./styles/react-day-picker": "./dist/styles/react-day-picker.css", "./switch": "./dist/switch/index.js", "./table": "./dist/table/index.js", "./tabs": "./dist/tabs/index.js", @@ -50,6 +52,7 @@ "frimousse": "^0.3.0", "lucide-react": "catalog:", "react": "catalog:", + "react-day-picker": "9.5.0", "react-dom": "catalog:", "recharts": "^2.15.1", "tailwind-merge": "^3.3.1", diff --git a/packages/propel/src/calendar/calendar.stories.tsx b/packages/propel/src/calendar/calendar.stories.tsx new file mode 100644 index 000000000..221e86f36 --- /dev/null +++ b/packages/propel/src/calendar/calendar.stories.tsx @@ -0,0 +1,32 @@ +import { ComponentProps, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Calendar } from "./root"; + +type CalendarProps = ComponentProps; + +const meta: Meta = { + title: "Components/Calendar", + component: Calendar, + parameters: { + layout: "centered", + }, + args: { + showOutsideDays: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, + render: (args: CalendarProps) => { + const [date, setDate] = useState(new Date()); + + return ( +
+ +
+ ); + }, +}; diff --git a/packages/propel/src/calendar/index.ts b/packages/propel/src/calendar/index.ts new file mode 100644 index 000000000..33709fd2b --- /dev/null +++ b/packages/propel/src/calendar/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export type { Matcher, DateRange } from "react-day-picker"; diff --git a/packages/propel/src/calendar/root.tsx b/packages/propel/src/calendar/root.tsx new file mode 100644 index 000000000..4483ccc84 --- /dev/null +++ b/packages/propel/src/calendar/root.tsx @@ -0,0 +1,38 @@ +"use client"; + +import * as React from "react"; +import { ChevronLeft } from "lucide-react"; +import { DayPicker } from "react-day-picker"; + +import { cn } from "../utils"; + +export type CalendarProps = React.ComponentProps; + +export const Calendar = ({ className, showOutsideDays = true, ...props }: CalendarProps) => { + const currentYear = new Date().getFullYear(); + const thirtyYearsAgoFirstDay = new Date(currentYear - 30, 0, 1); + const thirtyYearsFromNowFirstDay = new Date(currentYear + 30, 11, 31); + + return ( + ( + + ), + }} + startMonth={thirtyYearsAgoFirstDay} + endMonth={thirtyYearsFromNowFirstDay} + {...props} + /> + ); +}; diff --git a/apps/web/styles/react-day-picker.css b/packages/propel/src/styles/react-day-picker.css similarity index 100% rename from apps/web/styles/react-day-picker.css rename to packages/propel/src/styles/react-day-picker.css diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index 0a06024b2..f559c0017 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ "src/accordion/index.ts", "src/avatar/index.ts", + "src/calendar/index.ts", "src/card/index.ts", "src/charts/*/index.ts", "src/collapsible/index.ts", diff --git a/packages/ui/src/calendar.tsx b/packages/ui/src/calendar.tsx deleted file mode 100644 index 4f89b3b3c..000000000 --- a/packages/ui/src/calendar.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client"; - -import { ChevronLeft } from "lucide-react"; -import * as React from "react"; -import { DayPicker } from "react-day-picker"; - -import { cn } from "./utils"; - -export type CalendarProps = React.ComponentProps; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export const Calendar = ({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) => { - const currentYear = new Date().getFullYear(); - const thirtyYearsAgoFirstDay = new Date(currentYear - 30, 0, 1); - const thirtyYearsFromNowFirstDay = new Date(currentYear + 30, 11, 31); - - return ( - .day-range-end)]:rounded-r-full [&:has(>.day-range-start)]:rounded-l-full first:[&:has([aria-selected])]:rounded-l-full last:[&:has([aria-selected])]:rounded-r-full" - // : "[&:has([aria-selected])]:rounded-full [&:has([aria-selected])]:bg-custom-primary-100 [&:has([aria-selected])]:text-white" - // ), - // // day_button: - // // "size-10 flex items-center justify-center overflow-hidden box-border m-0 border-2 border-transparent rounded-full", - // day: "size-10 p-0 font-normal aria-selected:opacity-100 rounded-full hover:bg-custom-primary-100/60", - // day_range_start: "day-range-start bg-custom-primary-100 text-white", - // day_range_end: "day-range-end bg-custom-primary-100 text-white", - // day_selected: "", - // day_today: - // "relative after:content-[''] after:absolute after:m-auto after:left-1/3 after:bottom-[6px] after:w-[6px] after:h-[6px] after:bg-custom-primary-100/50 after:rounded-full after:translate-x-1/2 after:translate-y-1/2", - // day_outside: "day-outside", - // day_disabled: "opacity-50 hover:!bg-transparent", - // day_range_middle: "text-black", - // day_hidden: "invisible", - // caption_dropdowns: "inline-flex bg-transparent", - // dropdown_root: "m-0 relative inline-flex items-center", - // dropdowns: "relative inline-flex items-center", - // dropdown: - // "appearance-none absolute z-[2] top-0 bottom-0 left-0 w-full m-0 p-0 opacity-0 border-none text-[1rem] cursor-pointer bg-transparent hover:bg-custom-background-80", - // months_dropdown: "capitalize", - // caption_label: - // "z-[1] inline-flex items-center gap-[0.25rem] m-0 py-0 px-[0.25rem] whitespace-nowrap border-2 border-transparent font-semibold bg-transparent rounded", - // ...classNames, - // }} - components={{ - Chevron: ({ className, ...props }) => ( - - ), - }} - startMonth={thirtyYearsAgoFirstDay} - endMonth={thirtyYearsFromNowFirstDay} - {...props} - /> - ); -}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 41f5c5e1b..a2b2f1f24 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,7 +2,6 @@ export * from "./avatar"; export * from "./badge"; export * from "./breadcrumbs"; export * from "./button"; -export * from "./calendar"; export * from "./card"; export * from "./collapsible"; export * from "./color-picker"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 447b63566..0525ab07b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -555,9 +555,6 @@ importers: react-color: specifier: ^2.19.3 version: 2.19.3(react@18.3.1) - react-day-picker: - specifier: 9.5.0 - version: 9.5.0(react@18.3.1) react-dom: specifier: 'catalog:' version: 18.3.1(react@18.3.1) @@ -1017,6 +1014,9 @@ importers: react: specifier: 'catalog:' version: 18.3.1 + react-day-picker: + specifier: 9.5.0 + version: 9.5.0(react@18.3.1) react-dom: specifier: 'catalog:' version: 18.3.1(react@18.3.1) @@ -2516,78 +2516,78 @@ packages: '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} - '@rolldown/binding-android-arm64@1.0.0-beta.35': - resolution: {integrity: sha512-zVTg0544Ib1ldJSWwjy8URWYHlLFJ98rLnj+2FIj5fRs4KqGKP4VgH/pVUbXNGxeLFjItie6NSK1Un7nJixneQ==} + '@rolldown/binding-android-arm64@1.0.0-beta.34': + resolution: {integrity: sha512-jf5GNe5jP3Sr1Tih0WKvg2bzvh5T/1TA0fn1u32xSH7ca/p5t+/QRr4VRFCV/na5vjwKEhwWrChsL2AWlY+eoA==} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-beta.35': - resolution: {integrity: sha512-WPy0qx22CABTKDldEExfpYHWHulRoPo+m/YpyxP+6ODUPTQexWl8Wp12fn1CVP0xi0rOBj7ugs6+kKMAJW56wQ==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.34': + resolution: {integrity: sha512-2F/TqH4QuJQ34tgWxqBjFL3XV1gMzeQgUO8YRtCPGBSP0GhxtoFzsp7KqmQEothsxztlv+KhhT9Dbg3HHwHViQ==} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-beta.35': - resolution: {integrity: sha512-3k1TabJafF/GgNubXMkfp93d5p30SfIMOmQ5gm1tFwO+baMxxVPwDs3FDvSl+feCWwXxBA+bzemgkaDlInmp1Q==} + '@rolldown/binding-darwin-x64@1.0.0-beta.34': + resolution: {integrity: sha512-E1QuFslgLWbHQ8Qli/AqUKdfg0pockQPwRxVbhNQ74SciZEZpzLaujkdmOLSccMlSXDfFCF8RPnMoRAzQ9JV8Q==} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-beta.35': - resolution: {integrity: sha512-GAiapN5YyIocnBVNEiOxMfWO9NqIeEKKWohj1sPLGc61P+9N1meXOOCiAPbLU+adXq0grtbYySid+Or7f2q+Mg==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.34': + resolution: {integrity: sha512-VS8VInNCwnkpI9WeQaWu3kVBq9ty6g7KrHdLxYMzeqz24+w9hg712TcWdqzdY6sn+24lUoMD9jTZrZ/qfVpk0g==} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.35': - resolution: {integrity: sha512-okPKKIE73qkUMvq7dxDyzD0VIysdV4AirHqjf8tGTjuNoddUAl3WAtMYbuZCEKJwUyI67UINKO1peFVlYEb+8w==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.34': + resolution: {integrity: sha512-4St4emjcnULnxJYb/5ZDrH/kK/j6PcUgc3eAqH5STmTrcF+I9m/X2xvSF2a2bWv1DOQhxBewThu0KkwGHdgu5w==} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.35': - resolution: {integrity: sha512-Nky8Q2cxyKVkEETntrvcmlzNir5khQbDfX3PflHPbZY7XVZalllRqw7+MW5vn+jTsk5BfKVeLsvrF4344IU55g==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.34': + resolution: {integrity: sha512-a737FTqhFUoWfnebS2SnQ2BS50p0JdukdkUBwy2J06j4hZ6Eej0zEB8vTfAqoCjn8BQKkXBy+3Sx0IRkgwz1gA==} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.35': - resolution: {integrity: sha512-8aHpWVSfZl3Dy2VNFG9ywmlCPAJx45g0z+qdOeqmYceY7PBAT4QGzii9ig1hPb1pY8K45TXH44UzQwr2fx352Q==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.34': + resolution: {integrity: sha512-NH+FeQWKyuw0k+PbXqpFWNfvD8RPvfJk766B/njdaWz4TmiEcSB0Nb6guNw1rBpM1FmltQYb3fFnTumtC6pRfA==} cpu: [arm64] os: [linux] - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.35': - resolution: {integrity: sha512-1r1Ac/vTcm1q4kRiX/NB6qtorF95PhjdCxKH3Z5pb+bWMDZnmcz18fzFlT/3C6Qpj/ZqUF+EUrG4QEDXtVXGgg==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.34': + resolution: {integrity: sha512-Q3RSCivp8pNadYK8ke3hLnQk08BkpZX9BmMjgwae2FWzdxhxxUiUzd9By7kneUL0vRQ4uRnhD9VkFQ+Haeqdvw==} cpu: [x64] os: [linux] - '@rolldown/binding-linux-x64-musl@1.0.0-beta.35': - resolution: {integrity: sha512-AFl1LnuhUBDfX2j+cE6DlVGROv4qG7GCPDhR1kJqi2+OuXGDkeEjqRvRQOFErhKz1ckkP/YakvN7JheLJ2PKHQ==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.34': + resolution: {integrity: sha512-wDd/HrNcVoBhWWBUW3evJHoo7GJE/RofssBy3Dsiip05YUBmokQVrYAyrboOY4dzs/lJ7HYeBtWQ9hj8wlyF0A==} cpu: [x64] os: [linux] - '@rolldown/binding-openharmony-arm64@1.0.0-beta.35': - resolution: {integrity: sha512-Tuwb8vPs+TVJlHhyLik+nwln/burvIgaPDgg6wjNZ23F1ttjZi0w0rQSZfAgsX4jaUbylwCETXQmTp3w6vcJMw==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.34': + resolution: {integrity: sha512-dH3FTEV6KTNWpYSgjSXZzeX7vLty9oBYn6R3laEdhwZftQwq030LKL+5wyQdlbX5pnbh4h127hpv3Hl1+sj8dg==} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-beta.35': - resolution: {integrity: sha512-rG0OozgqNUYcpu50MpICMlJflexRVtQfjlN9QYf6hoel46VvY0FbKGwBKoeUp2K5D4i8lV04DpEMfTZlzRjeiA==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.34': + resolution: {integrity: sha512-y5BUf+QtO0JsIDKA51FcGwvhJmv89BYjUl8AmN7jqD6k/eU55mH6RJYnxwCsODq5m7KSSTigVb6O7/GqB8wbPw==} engines: {node: '>=14.0.0'} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.35': - resolution: {integrity: sha512-WeOfAZrycFo9+ZqTDp3YDCAOLolymtKGwImrr9n+OW0lpwI2UKyKXbAwGXRhydAYbfrNmuqWyfyoAnLh3X9Hjg==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.34': + resolution: {integrity: sha512-ga5hFhdTwpaNxEiuxZHWnD3ed0GBAzbgzS5tRHpe0ObptxM1a9Xrq6TVfNQirBLwb5Y7T/FJmJi3pmdLy95ljg==} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.35': - resolution: {integrity: sha512-XkLT7ikKGiUDvLh7qtJHRukbyyP1BIrD1xb7A+w4PjIiOKeOH8NqZ+PBaO4plT7JJnLxx+j9g/3B7iylR1nTFQ==} + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.34': + resolution: {integrity: sha512-4/MBp9T9eRnZskxWr8EXD/xHvLhdjWaeX/qY9LPRG1JdCGV3DphkLTy5AWwIQ5jhAy2ZNJR5z2fYRlpWU0sIyQ==} cpu: [ia32] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.35': - resolution: {integrity: sha512-rftASFKVzjbcQHTCYHaBIDrnQFzbeV50tm4hVugG3tPjd435RHZC2pbeGV5IPdKEqyJSuurM/GfbV3kLQ3LY/A==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.34': + resolution: {integrity: sha512-7O5iUBX6HSBKlQU4WykpUoEmb0wQmonb6ziKFr3dJTHud2kzDnWMqk344T0qm3uGv9Ddq6Re/94pInxo1G2d4w==} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-beta.35': - resolution: {integrity: sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==} + '@rolldown/pluginutils@1.0.0-beta.34': + resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} '@rollup/pluginutils@5.2.0': resolution: {integrity: sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==} @@ -7085,8 +7085,8 @@ packages: vue-tsc: optional: true - rolldown@1.0.0-beta.35: - resolution: {integrity: sha512-gJATyqcsJe0Cs8RMFO8XgFjfTc0lK1jcSvirDQDSIfsJE+vt53QH/Ob+OBSJsXb98YtZXHfP/bHpELpPwCprow==} + rolldown@1.0.0-beta.34: + resolution: {integrity: sha512-Wwh7EwalMzzX3Yy3VN58VEajeR2Si8+HDNMf706jPLIqU7CxneRW+dQVfznf5O0TWTnJyu4npelwg2bzTXB1Nw==} hasBin: true rollup@4.50.0: @@ -9375,51 +9375,51 @@ snapshots: '@remirror/core-constants@3.0.0': {} - '@rolldown/binding-android-arm64@1.0.0-beta.35': + '@rolldown/binding-android-arm64@1.0.0-beta.34': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-beta.35': + '@rolldown/binding-darwin-arm64@1.0.0-beta.34': optional: true - '@rolldown/binding-darwin-x64@1.0.0-beta.35': + '@rolldown/binding-darwin-x64@1.0.0-beta.34': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-beta.35': + '@rolldown/binding-freebsd-x64@1.0.0-beta.34': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.35': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.34': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.35': + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.34': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-beta.35': + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.34': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-beta.35': + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.34': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-beta.35': + '@rolldown/binding-linux-x64-musl@1.0.0-beta.34': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-beta.35': + '@rolldown/binding-openharmony-arm64@1.0.0-beta.34': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-beta.35': + '@rolldown/binding-wasm32-wasi@1.0.0-beta.34': dependencies: '@napi-rs/wasm-runtime': 1.0.3 optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.35': + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.34': optional: true - '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.35': + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.34': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-beta.35': + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.34': optional: true - '@rolldown/pluginutils@1.0.0-beta.35': {} + '@rolldown/pluginutils@1.0.0-beta.34': {} '@rollup/pluginutils@5.2.0(rollup@4.50.0)': dependencies: @@ -14421,7 +14421,7 @@ snapshots: dependencies: glob: 7.2.3 - rolldown-plugin-dts@0.15.10(rolldown@1.0.0-beta.35)(typescript@5.8.3): + rolldown-plugin-dts@0.15.10(rolldown@1.0.0-beta.34)(typescript@5.8.3): dependencies: '@babel/generator': 7.28.3 '@babel/parser': 7.28.3 @@ -14431,34 +14431,34 @@ snapshots: debug: 4.4.1(supports-color@5.5.0) dts-resolver: 2.1.2 get-tsconfig: 4.10.1 - rolldown: 1.0.0-beta.35 + rolldown: 1.0.0-beta.34 optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: - oxc-resolver - supports-color - rolldown@1.0.0-beta.35: + rolldown@1.0.0-beta.34: dependencies: '@oxc-project/runtime': 0.82.3 '@oxc-project/types': 0.82.3 - '@rolldown/pluginutils': 1.0.0-beta.35 + '@rolldown/pluginutils': 1.0.0-beta.34 ansis: 4.1.0 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-beta.35 - '@rolldown/binding-darwin-arm64': 1.0.0-beta.35 - '@rolldown/binding-darwin-x64': 1.0.0-beta.35 - '@rolldown/binding-freebsd-x64': 1.0.0-beta.35 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.35 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.35 - '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.35 - '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.35 - '@rolldown/binding-linux-x64-musl': 1.0.0-beta.35 - '@rolldown/binding-openharmony-arm64': 1.0.0-beta.35 - '@rolldown/binding-wasm32-wasi': 1.0.0-beta.35 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.35 - '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.35 - '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.35 + '@rolldown/binding-android-arm64': 1.0.0-beta.34 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.34 + '@rolldown/binding-darwin-x64': 1.0.0-beta.34 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.34 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.34 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.34 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.34 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.34 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.34 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.34 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.34 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.34 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.34 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.34 rollup@4.50.0: dependencies: @@ -15142,8 +15142,8 @@ snapshots: diff: 8.0.2 empathic: 2.0.0 hookable: 5.5.3 - rolldown: 1.0.0-beta.35 - rolldown-plugin-dts: 0.15.10(rolldown@1.0.0-beta.35)(typescript@5.8.3) + rolldown: 1.0.0-beta.34 + rolldown-plugin-dts: 0.15.10(rolldown@1.0.0-beta.34)(typescript@5.8.3) semver: 7.7.2 tinyexec: 1.0.1 tinyglobby: 0.2.14 From 43b7a6ad0abf56cb5e42ad662279df1395c53e1f Mon Sep 17 00:00:00 2001 From: Jayash Tripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:51:13 +0530 Subject: [PATCH 005/169] [WEB-4855] refactor: chart tick improvements (#7732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚧 WIP: Introduced customTicks prop in BarChart for flexible tick rendering. * ✨ feat: added customTicks to axis charts for flexible tick rendering * 🔧 fix: update default bar fill color to black and ensure consistent color usage in BarChart * ✨ feat: add customTooltipContent prop to LineChart for enhanced tooltip flexibility * 🔧 fix: update bar fill color handling to support dynamic colors based on data and removed DEFAULT_BAR_FILL_COLOR * 🔧 fix: correct bar fill color handling in BarChart to ensure proper color assignment for tooltips * 🔧 fix: update customTicks prop types in TAxisChartProps to use unknown type for better type safety * 📝 chore: updated translations and cleaned up insight card * 🚨 fix: lint * 🔧 fix: remove unused translation key "no_of" from Russian translations --- .../components/analytics/insight-card.tsx | 23 ++---------- .../components/analytics/total-insights.tsx | 1 - .../i18n/src/locales/cs/translations.json | 4 +- .../i18n/src/locales/de/translations.json | 4 +- .../i18n/src/locales/en/translations.json | 4 +- .../i18n/src/locales/es/translations.json | 4 +- .../i18n/src/locales/fr/translations.json | 4 +- .../i18n/src/locales/id/translations.json | 4 +- .../i18n/src/locales/it/translations.json | 4 +- .../i18n/src/locales/ja/translations.json | 4 +- .../i18n/src/locales/ko/translations.json | 4 +- .../i18n/src/locales/pl/translations.json | 4 +- .../i18n/src/locales/pt-BR/translations.json | 4 +- .../i18n/src/locales/ro/translations.json | 4 +- .../i18n/src/locales/ru/translations.json | 7 ++-- .../i18n/src/locales/sk/translations.json | 4 +- .../i18n/src/locales/tr-TR/translations.json | 4 +- .../i18n/src/locales/ua/translations.json | 4 +- .../i18n/src/locales/vi-VN/translations.json | 4 +- .../i18n/src/locales/zh-CN/translations.json | 4 +- .../i18n/src/locales/zh-TW/translations.json | 4 +- .../propel/src/charts/area-chart/root.tsx | 11 +++++- packages/propel/src/charts/bar-chart/root.tsx | 14 +++++-- .../propel/src/charts/line-chart/root.tsx | 37 ++++++++++++------- .../propel/src/charts/scatter-chart/root.tsx | 11 +++++- packages/types/src/charts/index.ts | 32 ++++++++++------ 26 files changed, 115 insertions(+), 93 deletions(-) diff --git a/apps/web/core/components/analytics/insight-card.tsx b/apps/web/core/components/analytics/insight-card.tsx index 739de6645..abfbdf8b9 100644 --- a/apps/web/core/components/analytics/insight-card.tsx +++ b/apps/web/core/components/analytics/insight-card.tsx @@ -1,28 +1,17 @@ // plane package imports -import React, { useMemo } from "react"; +import React from "react"; import { IAnalyticsResponseFields } from "@plane/types"; import { Loader } from "@plane/ui"; -// components -import TrendPiece from "./trend-piece"; export type InsightCardProps = { data?: IAnalyticsResponseFields; label: string; isLoading?: boolean; - versus?: string | null; }; const InsightCard = (props: InsightCardProps) => { - const { data, label, isLoading, versus } = props; - const { count, filter_count } = data || {}; - const percentage = useMemo(() => { - if (count != null && filter_count != null) { - const result = ((count - filter_count) / count) * 100; - const isFiniteAndNotNaNOrZero = Number.isFinite(result) && !Number.isNaN(result) && result !== 0; - return isFiniteAndNotNaNOrZero ? result : null; - } - return null; - }, [count, filter_count]); + const { data, label, isLoading = false } = props; + const count = data?.count ?? 0; return (
@@ -30,12 +19,6 @@ const InsightCard = (props: InsightCardProps) => { {!isLoading ? (
{count}
- {/* {percentage && ( -
- - {versus &&
vs {versus}
} -
- )} */}
) : ( diff --git a/apps/web/core/components/analytics/total-insights.tsx b/apps/web/core/components/analytics/total-insights.tsx index 258ac11e1..0ac1f4f87 100644 --- a/apps/web/core/components/analytics/total-insights.tsx +++ b/apps/web/core/components/analytics/total-insights.tsx @@ -92,7 +92,6 @@ const TotalInsights: React.FC<{ isLoading={isLoading} data={totalInsightsData?.[item.key]} label={getInsightLabel(analyticsType, item, isEpic, t)} - versus={selectedDurationLabel} /> ))}
diff --git a/packages/i18n/src/locales/cs/translations.json b/packages/i18n/src/locales/cs/translations.json index 9881f5e6d..aa25abc5d 100644 --- a/packages/i18n/src/locales/cs/translations.json +++ b/packages/i18n/src/locales/cs/translations.json @@ -882,7 +882,8 @@ "in_progress": "Probíhá", "planned": "Plánováno", "paused": "Pozastaveno", - "no_of": "Počet {entity}" + "no_of": "Počet {entity}", + "resolved": "Vyřešeno" }, "chart": { "x_axis": "Osa X", @@ -2490,7 +2491,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane se nespustil. To může být způsobeno tím, že se jeden nebo více služeb Plane nepodařilo spustit.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logů, abyste si byli jisti." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/de/translations.json b/packages/i18n/src/locales/de/translations.json index bcce9268f..fc404e636 100644 --- a/packages/i18n/src/locales/de/translations.json +++ b/packages/i18n/src/locales/de/translations.json @@ -882,7 +882,8 @@ "in_progress": "In Bearbeitung", "planned": "Geplant", "paused": "Pausiert", - "no_of": "Anzahl {entity}" + "no_of": "Anzahl {entity}", + "resolved": "Gelöst" }, "chart": { "x_axis": "X-Achse", @@ -2489,7 +2490,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane ist nicht gestartet. Dies könnte daran liegen, dass einer oder mehrere Plane-Services nicht starten konnten.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wählen Sie View Logs aus setup.sh und Docker-Logs, um sicherzugehen." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/en/translations.json b/packages/i18n/src/locales/en/translations.json index 026fc90c1..39087d43a 100644 --- a/packages/i18n/src/locales/en/translations.json +++ b/packages/i18n/src/locales/en/translations.json @@ -725,7 +725,8 @@ "apply": "Apply", "applying": "Applying", "overview": "Overview", - "no_of": "No. of {entity}" + "no_of": "No. of {entity}", + "resolved": "Resolved" }, "chart": { "x_axis": "X-axis", @@ -2366,7 +2367,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane didn't start up. This could be because one or more Plane services failed to start.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choose View Logs from setup.sh and Docker logs to be sure." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/es/translations.json b/packages/i18n/src/locales/es/translations.json index 823d427b4..1621a1c8e 100644 --- a/packages/i18n/src/locales/es/translations.json +++ b/packages/i18n/src/locales/es/translations.json @@ -885,7 +885,8 @@ "in_progress": "En progreso", "planned": "Planificado", "paused": "Pausado", - "no_of": "N.º de {entity}" + "no_of": "N.º de {entity}", + "resolved": "Resuelto" }, "chart": { "x_axis": "Eje X", @@ -2492,7 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane no se inició. Esto podría deberse a que uno o más servicios de Plane fallaron al iniciar.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Selecciona View Logs desde setup.sh y los logs de Docker para estar seguro." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/fr/translations.json b/packages/i18n/src/locales/fr/translations.json index 5a24044cb..cf6fdd87a 100644 --- a/packages/i18n/src/locales/fr/translations.json +++ b/packages/i18n/src/locales/fr/translations.json @@ -883,7 +883,8 @@ "in_progress": "En cours", "planned": "Planifié", "paused": "En pause", - "no_of": "Nº de {entity}" + "no_of": "Nº de {entity}", + "resolved": "Résolu" }, "chart": { "x_axis": "Axe X", @@ -2490,7 +2491,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane n'a pas démarré. Cela pourrait être dû au fait qu'un ou plusieurs services Plane ont échoué à démarrer.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Choisissez View Logs depuis setup.sh et les logs Docker pour en être sûr." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/id/translations.json b/packages/i18n/src/locales/id/translations.json index caa973bd0..9fd407bde 100644 --- a/packages/i18n/src/locales/id/translations.json +++ b/packages/i18n/src/locales/id/translations.json @@ -882,7 +882,8 @@ "in_progress": "Sedang berlangsung", "planned": "Direncanakan", "paused": "Dijedaikan", - "no_of": "Jumlah {entity}" + "no_of": "Jumlah {entity}", + "resolved": "Terselesaikan" }, "chart": { "x_axis": "Sumbu-X", @@ -2485,7 +2486,6 @@ "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Pilih View Logs dari setup.sh dan log Docker untuk memastikan." }, "no_of": "Jumlah {entity}", - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/it/translations.json b/packages/i18n/src/locales/it/translations.json index 11b5f93c9..a29e0295d 100644 --- a/packages/i18n/src/locales/it/translations.json +++ b/packages/i18n/src/locales/it/translations.json @@ -881,7 +881,8 @@ "in_progress": "In corso", "planned": "Pianificato", "paused": "In pausa", - "no_of": "N. di {entity}" + "no_of": "N. di {entity}", + "resolved": "Risolto" }, "chart": { "x_axis": "Asse X", @@ -2489,7 +2490,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane non si è avviato. Questo potrebbe essere dovuto al fatto che uno o più servizi Plane non sono riusciti ad avviarsi.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Scegli View Logs da setup.sh e dai log Docker per essere sicuro." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ja/translations.json b/packages/i18n/src/locales/ja/translations.json index b7a43808a..bff522b37 100644 --- a/packages/i18n/src/locales/ja/translations.json +++ b/packages/i18n/src/locales/ja/translations.json @@ -883,7 +883,8 @@ "in_progress": "進行中", "planned": "計画済み", "paused": "一時停止", - "no_of": "{entity} の数" + "no_of": "{entity} の数", + "resolved": "解決済み" }, "chart": { "x_axis": "エックス アクシス", @@ -2490,7 +2491,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Planeが起動しませんでした。これは1つまたは複数のPlaneサービスの起動に失敗したことが原因である可能性があります。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "setup.shとDockerログからView Logsを選択して確認してください。" }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ko/translations.json b/packages/i18n/src/locales/ko/translations.json index bc23bc6e1..9a86dab61 100644 --- a/packages/i18n/src/locales/ko/translations.json +++ b/packages/i18n/src/locales/ko/translations.json @@ -884,7 +884,8 @@ "in_progress": "진행 중", "planned": "계획된", "paused": "일시 중지됨", - "no_of": "{entity} 수" + "no_of": "{entity} 수", + "resolved": "해결됨" }, "chart": { "x_axis": "X축", @@ -2492,7 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane이 시작되지 않았습니다. 이는 하나 이상의 Plane 서비스가 시작에 실패했기 때문일 수 있습니다.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "확실히 하려면 setup.sh와 Docker 로그에서 View Logs를 선택하세요." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/pl/translations.json b/packages/i18n/src/locales/pl/translations.json index 56dbf089b..a9f5190f6 100644 --- a/packages/i18n/src/locales/pl/translations.json +++ b/packages/i18n/src/locales/pl/translations.json @@ -884,7 +884,8 @@ "in_progress": "W trakcie", "planned": "Zaplanowane", "paused": "Wstrzymane", - "no_of": "Liczba {entity}" + "no_of": "Liczba {entity}", + "resolved": "Rozwiązane" }, "chart": { "x_axis": "Oś X", @@ -2491,7 +2492,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nie uruchomił się. Może to być spowodowane tym, że jedna lub więcej usług Plane nie mogła się uruchomić.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Wybierz View Logs z setup.sh i logów Docker, aby mieć pewność." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/pt-BR/translations.json b/packages/i18n/src/locales/pt-BR/translations.json index 831b3515e..f52a220fc 100644 --- a/packages/i18n/src/locales/pt-BR/translations.json +++ b/packages/i18n/src/locales/pt-BR/translations.json @@ -884,7 +884,8 @@ "in_progress": "Em andamento", "planned": "Planejado", "paused": "Pausado", - "no_of": "Nº de {entity}" + "no_of": "Nº de {entity}", + "resolved": "Resolvido" }, "chart": { "x_axis": "Eixo X", @@ -2486,7 +2487,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "O Plane não inicializou. Isso pode ser porque um ou mais serviços do Plane falharam ao iniciar.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Escolha View Logs do setup.sh e logs do Docker para ter certeza." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ro/translations.json b/packages/i18n/src/locales/ro/translations.json index 82cf21bfc..46c42f683 100644 --- a/packages/i18n/src/locales/ro/translations.json +++ b/packages/i18n/src/locales/ro/translations.json @@ -882,7 +882,8 @@ "in_progress": "În desfășurare", "planned": "Planificat", "paused": "Pauzat", - "no_of": "Nr. de {entity}" + "no_of": "Nr. de {entity}", + "resolved": "Rezolvat" }, "chart": { "x_axis": "axa-X", @@ -2484,7 +2485,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane nu a pornit. Aceasta ar putea fi din cauza că unul sau mai multe servicii Plane au eșuat să pornească.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Alegeți View Logs din setup.sh și logurile Docker pentru a fi siguri." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index 7d11338b7..c6c84ed8e 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -884,7 +884,8 @@ "in_progress": "В процессе", "planned": "Запланировано", "paused": "На паузе", - "no_of": "Количество {entity}" + "no_of": "Количество {entity}", + "resolved": "Решено" }, "chart": { "x_axis": "Ось X", @@ -2492,8 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустился. Это может быть из-за того, что один или несколько сервисов Plane не смогли запуститься.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Выберите View Logs из setup.sh и логов Docker, чтобы убедиться." }, - "no_of": "Количество {entity}", - "page_navigation_pane": { "tabs": { "outline": { @@ -2533,4 +2532,4 @@ "close_button": "Закрыть панель навигации", "outline_floating_button": "Открыть структуру" } -} +} \ No newline at end of file diff --git a/packages/i18n/src/locales/sk/translations.json b/packages/i18n/src/locales/sk/translations.json index f04165391..08da7e2dd 100644 --- a/packages/i18n/src/locales/sk/translations.json +++ b/packages/i18n/src/locales/sk/translations.json @@ -884,7 +884,8 @@ "in_progress": "Prebieha", "planned": "Plánované", "paused": "Pozastavené", - "no_of": "Počet {entity}" + "no_of": "Počet {entity}", + "resolved": "Vyriešené" }, "chart": { "x_axis": "Os X", @@ -2491,7 +2492,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane sa nespustil. Toto môže byť spôsobené tým, že sa jedna alebo viac služieb Plane nepodarilo spustiť.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Vyberte View Logs z setup.sh a Docker logov, aby ste si boli istí." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/tr-TR/translations.json b/packages/i18n/src/locales/tr-TR/translations.json index 8da90adc8..8088ff213 100644 --- a/packages/i18n/src/locales/tr-TR/translations.json +++ b/packages/i18n/src/locales/tr-TR/translations.json @@ -886,7 +886,8 @@ "in_progress": "Devam ediyor", "planned": "Planlandı", "paused": "Durduruldu", - "no_of": "{entity} sayısı" + "no_of": "{entity} sayısı", + "resolved": "Çözüldü" }, "chart": { "x_axis": "X ekseni", @@ -2471,7 +2472,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane başlatılamadı. Bu, bir veya daha fazla Plane servisinin başlatılamaması nedeniyle olabilir.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Emin olmak için setup.sh ve Docker loglarından View Logs'u seçin." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/ua/translations.json b/packages/i18n/src/locales/ua/translations.json index bd2158d96..6f9cccd4e 100644 --- a/packages/i18n/src/locales/ua/translations.json +++ b/packages/i18n/src/locales/ua/translations.json @@ -884,7 +884,8 @@ "in_progress": "В процесі", "planned": "Заплановано", "paused": "Призупинено", - "no_of": "Кількість {entity}" + "no_of": "Кількість {entity}", + "resolved": "Вирішено" }, "chart": { "x_axis": "Вісь X", @@ -2491,7 +2492,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane не запустився. Це може бути через те, що один або декілька сервісів Plane не змогли запуститися.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Виберіть View Logs з setup.sh та логів Docker, щоб переконатися." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/vi-VN/translations.json b/packages/i18n/src/locales/vi-VN/translations.json index b8610c2e0..12139dd6c 100644 --- a/packages/i18n/src/locales/vi-VN/translations.json +++ b/packages/i18n/src/locales/vi-VN/translations.json @@ -883,7 +883,8 @@ "in_progress": "Đang tiến hành", "planned": "Đã lên kế hoạch", "paused": "Tạm dừng", - "no_of": "Số lượng {entity}" + "no_of": "Số lượng {entity}", + "resolved": "Đã giải quyết" }, "chart": { "x_axis": "Trục X", @@ -2489,7 +2490,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane không khởi động được. Điều này có thể do một hoặc nhiều dịch vụ Plane không khởi động được.", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "Chọn View Logs từ setup.sh và log Docker để chắc chắn." }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/zh-CN/translations.json b/packages/i18n/src/locales/zh-CN/translations.json index 55ace9a66..03b35d9f6 100644 --- a/packages/i18n/src/locales/zh-CN/translations.json +++ b/packages/i18n/src/locales/zh-CN/translations.json @@ -883,7 +883,8 @@ "in_progress": "进行中", "planned": "已计划", "paused": "暂停", - "no_of": "{entity} 的数量" + "no_of": "{entity} 的数量", + "resolved": "已解决" }, "chart": { "x_axis": "X轴", @@ -2471,7 +2472,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能启动。这可能是因为一个或多个 Plane 服务启动失败。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "请选择“查看日志”来查看 setup.sh 和 Docker 日志,以确认问题。" }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/i18n/src/locales/zh-TW/translations.json b/packages/i18n/src/locales/zh-TW/translations.json index 6c6a85f20..0b7273b32 100644 --- a/packages/i18n/src/locales/zh-TW/translations.json +++ b/packages/i18n/src/locales/zh-TW/translations.json @@ -884,7 +884,8 @@ "planned": "已計劃", "paused": "暫停", "at_risk": "有風險", - "no_of": "{entity} 的數量" + "no_of": "{entity} 的數量", + "resolved": "已解決" }, "chart": { "x_axis": "X 軸", @@ -2492,7 +2493,6 @@ "plane_didnt_start_up_this_could_be_because_one_or_more_plane_services_failed_to_start": "Plane 未能啟動。這可能是因為一個或多個 Plane 服務啟動失敗。", "choose_view_logs_from_setup_sh_and_docker_logs_to_be_sure": "從 setup.sh 和 Docker 日誌中選擇 View Logs 來確認。" }, - "page_navigation_pane": { "tabs": { "outline": { diff --git a/packages/propel/src/charts/area-chart/root.tsx b/packages/propel/src/charts/area-chart/root.tsx index 11a1db2ef..db602fedb 100644 --- a/packages/propel/src/charts/area-chart/root.tsx +++ b/packages/propel/src/charts/area-chart/root.tsx @@ -23,6 +23,7 @@ export const AreaChart = React.memo((props: x: undefined, y: 10, }, + customTicks, showTooltip = true, comparisonLine, } = props; @@ -114,7 +115,10 @@ export const AreaChart = React.memo((props: } + tick={(props) => { + const TickComponent = customTicks?.x || CustomXAxisTick; + return ; + }} tickLine={false} axisLine={false} label={ @@ -140,7 +144,10 @@ export const AreaChart = React.memo((props: className: AXIS_LABEL_CLASSNAME, } } - tick={(props) => } + tick={(props) => { + const TickComponent = customTicks?.y || CustomYAxisTick; + return ; + }} tickCount={tickCount.y} allowDecimals={!!yAxis.allowDecimals} /> diff --git a/packages/propel/src/charts/bar-chart/root.tsx b/packages/propel/src/charts/bar-chart/root.tsx index 96efff32c..0d34b2783 100644 --- a/packages/propel/src/charts/bar-chart/root.tsx +++ b/packages/propel/src/charts/bar-chart/root.tsx @@ -35,6 +35,7 @@ export const BarChart = React.memo((props: T x: undefined, y: 10, }, + customTicks, showTooltip = true, customTooltipContent, } = props; @@ -52,7 +53,7 @@ export const BarChart = React.memo((props: T keys.push(bar.key); labels[bar.key] = bar.label; // For tooltip, we need a string color. If fill is a function, use a default color - colors[bar.key] = typeof bar.fill === "function" ? "#000000" : bar.fill; + colors[bar.key] = typeof bar.fill === "function" ? bar.fill({}) : bar.fill; } return { stackKeys: keys, stackLabels: labels, stackDotColors: colors }; @@ -65,6 +66,7 @@ export const BarChart = React.memo((props: T key={bar.key} dataKey={bar.key} stackId={bar.stackId} + fill={typeof bar.fill === "function" ? bar.fill({}) : bar.fill} opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1} shape={(shapeProps: any) => { const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"]; @@ -96,7 +98,10 @@ export const BarChart = React.memo((props: T } + tick={(props) => { + const TickComponent = customTicks?.x || CustomXAxisTick; + return ; + }} tickLine={false} axisLine={false} label={{ @@ -118,7 +123,10 @@ export const BarChart = React.memo((props: T dx: yAxis.dx ?? -16, className: AXIS_LABEL_CLASSNAME, }} - tick={(props) => } + tick={(props) => { + const TickComponent = customTicks?.y || CustomYAxisTick; + return ; + }} tickCount={tickCount.y} allowDecimals={!!yAxis.allowDecimals} /> diff --git a/packages/propel/src/charts/line-chart/root.tsx b/packages/propel/src/charts/line-chart/root.tsx index 28a02fc30..e59978692 100644 --- a/packages/propel/src/charts/line-chart/root.tsx +++ b/packages/propel/src/charts/line-chart/root.tsx @@ -32,8 +32,10 @@ export const LineChart = React.memo((props: x: undefined, y: 10, }, + customTicks, legend, showTooltip = true, + customTooltipContent, } = props; // states const [activeLine, setActiveLine] = useState(null); @@ -100,7 +102,10 @@ export const LineChart = React.memo((props: } + tick={(props) => { + const TickComponent = customTicks?.x || CustomXAxisTick; + return ; + }} tickLine={false} axisLine={false} label={ @@ -126,7 +131,10 @@ export const LineChart = React.memo((props: className: AXIS_LABEL_CLASSNAME, } } - tick={(props) => } + tick={(props) => { + const TickComponent = customTicks?.y || CustomYAxisTick; + return ; + }} tickCount={tickCount.y} allowDecimals={!!yAxis.allowDecimals} /> @@ -148,17 +156,20 @@ export const LineChart = React.memo((props: wrapperStyle={{ pointerEvents: "auto", }} - content={({ active, label, payload }) => ( - - )} + content={({ active, label, payload }) => { + if (customTooltipContent) return customTooltipContent({ active, label, payload }); + return ( + + ); + }} /> )} {renderLines} diff --git a/packages/propel/src/charts/scatter-chart/root.tsx b/packages/propel/src/charts/scatter-chart/root.tsx index 25d7b84b4..4ed4eecc1 100644 --- a/packages/propel/src/charts/scatter-chart/root.tsx +++ b/packages/propel/src/charts/scatter-chart/root.tsx @@ -28,6 +28,7 @@ export const ScatterChart = React.memo((prop xAxis, yAxis, className, + customTicks, tickCount = { x: undefined, y: 10, @@ -85,7 +86,10 @@ export const ScatterChart = React.memo((prop } + tick={(props) => { + const TickComponent = customTicks?.x || CustomXAxisTick; + return ; + }} tickLine={false} axisLine={false} label={ @@ -111,7 +115,10 @@ export const ScatterChart = React.memo((prop className: AXIS_LABEL_CLASSNAME, } } - tick={(props) => } + tick={(props) => { + const TickComponent = customTicks?.y || CustomYAxisTick; + return ; + }} tickCount={tickCount.y} allowDecimals={!!yAxis.allowDecimals} /> diff --git a/packages/types/src/charts/index.ts b/packages/types/src/charts/index.ts index fb49c7172..8ee4739b2 100644 --- a/packages/types/src/charts/index.ts +++ b/packages/types/src/charts/index.ts @@ -21,8 +21,17 @@ export type TChartData = { [key in K]: string | number; } & Record; -export type TChartProps = { +export type TBaseChartProps = { data: TChartData[]; + className?: string; + legend?: TChartLegend; + margin?: TChartMargin; + showTooltip?: boolean; + customTooltipContent?: (props: { active?: boolean; label: string; payload: any }) => React.ReactNode; +}; + +// Props specific to charts with X and Y axes +export type TAxisChartProps = TBaseChartProps & { xAxis: { key: keyof TChartData; label?: string; @@ -38,15 +47,14 @@ export type TChartProps = { offset?: number; dx?: number; }; - className?: string; - legend?: TChartLegend; - margin?: TChartMargin; tickCount?: { x?: number; y?: number; }; - showTooltip?: boolean; - customTooltipContent?: (props: { active?: boolean; label: string; payload: any }) => React.ReactNode; + customTicks?: { + x?: React.ComponentType; + y?: React.ComponentType; + }; }; // ============================================================ @@ -67,7 +75,7 @@ export type TBarItem = { shapeVariant?: TBarChartShapeVariant; }; -export type TBarChartProps = TChartProps & { +export type TBarChartProps = TAxisChartProps & { bars: TBarItem[]; barSize?: number; }; @@ -87,7 +95,7 @@ export type TLineItem = { style?: Record; }; -export type TLineChartProps = TChartProps & { +export type TLineChartProps = TAxisChartProps & { lines: TLineItem[]; }; @@ -102,7 +110,7 @@ export type TScatterPointItem = { stroke: string; }; -export type TScatterChartProps = TChartProps & { +export type TScatterChartProps = TAxisChartProps & { scatterPoints: TScatterPointItem[]; }; @@ -123,7 +131,7 @@ export type TAreaItem = { style?: Record; }; -export type TAreaChartProps = TChartProps & { +export type TAreaChartProps = TAxisChartProps & { areas: TAreaItem[]; comparisonLine?: { dashedLine: boolean; @@ -141,7 +149,7 @@ export type TCellItem = { }; export type TPieChartProps = Pick< - TChartProps, + TBaseChartProps, "className" | "data" | "showTooltip" | "legend" | "margin" > & { dataKey: T; @@ -223,7 +231,7 @@ export type TRadarItem = { }; export type TRadarChartProps = Pick< - TChartProps, + TBaseChartProps, "className" | "showTooltip" | "margin" | "data" | "legend" > & { dataKey: T; From 45688bdc729bc370d7b880f77555cae85b81764d Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:51:45 +0530 Subject: [PATCH 006/169] [WEB-4860] dev: propel animated counter component (#7740) * dev: animated counter added to propel * chore: animated counter story added * chore: propel config updated * chore: code refactor * chore: code refactor * fix: format error --- packages/propel/package.json | 1 + .../animated-counter.stories.tsx | 55 +++++++++++ .../src/animated-counter/animated-counter.tsx | 95 +++++++++++++++++++ packages/propel/src/animated-counter/index.ts | 2 + packages/propel/tsdown.config.ts | 1 + packages/tailwind-config/global.css | 54 +++++++++++ 6 files changed, 208 insertions(+) create mode 100644 packages/propel/src/animated-counter/animated-counter.stories.tsx create mode 100644 packages/propel/src/animated-counter/animated-counter.tsx create mode 100644 packages/propel/src/animated-counter/index.ts diff --git a/packages/propel/package.json b/packages/propel/package.json index bc0888176..f72f1fbc5 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -17,6 +17,7 @@ }, "exports": { "./accordion": "./dist/accordion/index.js", + "./animated-counter": "./dist/animated-counter/index.js", "./avatar": "./dist/avatar/index.js", "./calendar": "./dist/calendar/index.js", "./card": "./dist/card/index.js", diff --git a/packages/propel/src/animated-counter/animated-counter.stories.tsx b/packages/propel/src/animated-counter/animated-counter.stories.tsx new file mode 100644 index 000000000..fd93fe325 --- /dev/null +++ b/packages/propel/src/animated-counter/animated-counter.stories.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { AnimatedCounter } from "./animated-counter"; + +const meta: Meta = { + title: "AnimatedCounter", + component: AnimatedCounter, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + size: { + control: { type: "select" }, + options: ["sm", "md", "lg"], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const AnimatedCounterDemo = (args: React.ComponentProps) => { + const [count, setCount] = useState(args.count || 0); + + return ( +
+
+ +
+ +
+ +
+
+ ); +}; + +export const Default: Story = { + render: (args) => , + args: { + count: 5, + size: "md", + }, +}; diff --git a/packages/propel/src/animated-counter/animated-counter.tsx b/packages/propel/src/animated-counter/animated-counter.tsx new file mode 100644 index 000000000..09432358a --- /dev/null +++ b/packages/propel/src/animated-counter/animated-counter.tsx @@ -0,0 +1,95 @@ +import { useState, useEffect } from "react"; +import { cn } from "../utils"; + +export interface AnimatedCounterProps { + count: number; + className?: string; + size?: "sm" | "md" | "lg"; +} + +const sizeClasses = { + sm: "text-xs h-4 w-4", + md: "text-sm h-5 w-5", + lg: "text-base h-6 w-6", +}; + +export const AnimatedCounter: React.FC = ({ count, className, size = "md" }) => { + // states + const [displayCount, setDisplayCount] = useState(count); + const [prevCount, setPrevCount] = useState(count); + const [isAnimating, setIsAnimating] = useState(false); + const [direction, setDirection] = useState<"up" | "down" | null>(null); + const [animationKey, setAnimationKey] = useState(0); + + useEffect(() => { + if (count !== prevCount) { + setDirection(count > prevCount ? "up" : "down"); + setIsAnimating(true); + setAnimationKey((prev) => prev + 1); + + // Update the display count immediately, animation will show the transition + setDisplayCount(count); + + // End animation after CSS transition + const timer = setTimeout(() => { + setIsAnimating(false); + setDirection(null); + setPrevCount(count); + }, 250); + + return () => clearTimeout(timer); + } + }, [count, prevCount]); + + const sizeClass = sizeClasses[size]; + + return ( +
+ {/* Previous number sliding out */} + {isAnimating && ( + + {prevCount} + + )} + + {/* New number sliding in */} + + {displayCount} + +
+ ); +}; diff --git a/packages/propel/src/animated-counter/index.ts b/packages/propel/src/animated-counter/index.ts new file mode 100644 index 000000000..86b4c39b8 --- /dev/null +++ b/packages/propel/src/animated-counter/index.ts @@ -0,0 +1,2 @@ +export { AnimatedCounter } from "./animated-counter"; +export type { AnimatedCounterProps } from "./animated-counter"; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index f559c0017..ac439f1b6 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ entry: [ "src/accordion/index.ts", + "src/animated-counter/index.ts", "src/avatar/index.ts", "src/calendar/index.ts", "src/card/index.ts", diff --git a/packages/tailwind-config/global.css b/packages/tailwind-config/global.css index 1cf516922..4e3d5c525 100644 --- a/packages/tailwind-config/global.css +++ b/packages/tailwind-config/global.css @@ -694,3 +694,57 @@ div.web-view-spinner div.bar12 { .disable-autofill-style:-webkit-autofill:active { -webkit-background-clip: text; } + + +@keyframes slideInFromBottom { + 0% { + transform: translateY(100%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideInFromTop { + 0% { + transform: translateY(-100%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideOut { + 0% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(-100%); + opacity: 0; + } +} + +@keyframes slideOutDown { + 0% { + transform: translateY(0); + opacity: 1; + } + 100% { + transform: translateY(100%); + opacity: 0; + } +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} \ No newline at end of file From 5a63e6dad2f7d43617039505c728857ab570fd68 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:03:39 +0530 Subject: [PATCH 007/169] [WEB-4858] chore: error page ui revamp #7747 --- apps/web/app/error.tsx | 87 +++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 565c362d5..9853dc13e 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -1,14 +1,17 @@ "use client"; -import Link from "next/link"; +import Image from "next/image"; +import { useTheme } from "next-themes"; // plane imports import { API_BASE_URL } from "@plane/constants"; -import { Button, TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // hooks import { useAppRouter } from "@/hooks/use-app-router"; // layouts import DefaultLayout from "@/layouts/default-layout"; +// images +import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg"; +import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg"; // services import { AuthService } from "@/services/auth.service"; @@ -16,7 +19,10 @@ import { AuthService } from "@/services/auth.service"; const authService = new AuthService(); export default function CustomErrorComponent() { + // routers const router = useAppRouter(); + // hooks + const { resolvedTheme } = useTheme(); const handleSignOut = async () => { await authService @@ -31,39 +37,52 @@ export default function CustomErrorComponent() { .finally(() => router.push("/")); }; + // derived values + const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; + return ( -
-
-
-
-

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

-

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

-
-
- - Go to home - - -
+
+
+ ProjectSettingImg +
+
+
+

+ 🚧 Yikes! That doesn't look good. +

+ + That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more + details, please write to{" "} + + support@plane.so + {" "} + or on our{" "} + + Discord + + . + +
+ +
+ +
From 3b8bb1effc521e0041a803e99d6c3d467bc4f287 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:15:10 +0530 Subject: [PATCH 008/169] [WEB-4733] dev: propel toolbar component (#7742) * dev: toolbar component added to propel * dev: toolbar story added * chore: propel config updated * chore: code refactor --------- Co-authored-by: sriram veeraghanta --- packages/propel/package.json | 1 + packages/propel/src/toolbar/index.ts | 8 + .../propel/src/toolbar/toolbar.stories.tsx | 123 ++++++++++++++ packages/propel/src/toolbar/toolbar.tsx | 159 ++++++++++++++++++ packages/propel/tsdown.config.ts | 1 + 5 files changed, 292 insertions(+) create mode 100644 packages/propel/src/toolbar/index.ts create mode 100644 packages/propel/src/toolbar/toolbar.stories.tsx create mode 100644 packages/propel/src/toolbar/toolbar.tsx diff --git a/packages/propel/package.json b/packages/propel/package.json index f72f1fbc5..b08b2a12e 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -38,6 +38,7 @@ "./table": "./dist/table/index.js", "./tabs": "./dist/tabs/index.js", "./toast": "./dist/toast/index.js", + "./toolbar": "./dist/toolbar/index.js", "./tooltip": "./dist/tooltip/index.js", "./utils": "./dist/utils/index.js" }, diff --git a/packages/propel/src/toolbar/index.ts b/packages/propel/src/toolbar/index.ts new file mode 100644 index 000000000..da2a48965 --- /dev/null +++ b/packages/propel/src/toolbar/index.ts @@ -0,0 +1,8 @@ +export { Toolbar } from "./toolbar"; +export type { + ToolbarProps, + ToolbarGroupProps, + ToolbarItemProps, + ToolbarSeparatorProps, + ToolbarSubmitButtonProps, +} from "./toolbar"; diff --git a/packages/propel/src/toolbar/toolbar.stories.tsx b/packages/propel/src/toolbar/toolbar.stories.tsx new file mode 100644 index 000000000..f7e7dbbc7 --- /dev/null +++ b/packages/propel/src/toolbar/toolbar.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + Bold, + Italic, + Underline, + Strikethrough, + Code, + Link, + List, + ListOrdered, + Quote, + AlignLeft, + AlignCenter, + AlignRight, + Undo, + Redo, + Globe2, + Lock, +} from "lucide-react"; +import { Toolbar } from "./toolbar"; + +const meta: Meta = { + title: "Components/Toolbar", + component: Toolbar, + parameters: { + layout: "fullscreen", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ), +}; + +export const WithActiveStates: Story = { + render: () => ( +
+ + + + + + + + + + + + + + + + + +
+ ), +}; + +export const CommentToolbar: Story = { + render: () => ( +
+

Comment Toolbar with Access Control

+
+ + {/* Access Specifier */} +
+ + +
+ +
+
+ + + + + + + + + +
+ Comment +
+
+
+
+ ), +}; diff --git a/packages/propel/src/toolbar/toolbar.tsx b/packages/propel/src/toolbar/toolbar.tsx new file mode 100644 index 000000000..b1dda9e97 --- /dev/null +++ b/packages/propel/src/toolbar/toolbar.tsx @@ -0,0 +1,159 @@ +import * as React from "react"; +import { LucideIcon } from "lucide-react"; +import { Tooltip } from "../tooltip"; +import { cn } from "../utils"; + +export interface ToolbarProps extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; +} + +export interface ToolbarGroupProps extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; + isFirst?: boolean; +} + +export interface ToolbarItemProps extends React.ButtonHTMLAttributes { + icon: LucideIcon; + isActive?: boolean; + tooltip?: string; + shortcut?: string[]; + className?: string; + children?: React.ReactNode; +} + +export interface ToolbarSeparatorProps extends React.HTMLAttributes { + className?: string; +} + +export interface ToolbarSubmitButtonProps extends React.ButtonHTMLAttributes { + loading?: boolean; + variant?: "primary" | "secondary" | "outline" | "ghost" | "destructive"; + className?: string; + children: React.ReactNode; +} + +const ToolbarRoot = React.forwardRef(({ className, children, ...props }, ref) => ( +
+ {children} +
+)); + +const ToolbarGroup = React.forwardRef( + ({ className, children, isFirst = false, ...props }, ref) => ( +
+ {children} +
+ ) +); + +const ToolbarItem = React.forwardRef( + ({ icon: Icon, isActive = false, tooltip, shortcut, className, children, ...props }, ref) => { + const button = ( + + ); + + if (tooltip) { + return ( + + {tooltip} + {shortcut && {shortcut.join(" + ")}} +
+ } + > + {button} + + ); + } + + return button; + } +); + +const ToolbarSeparator = React.forwardRef(({ className, ...props }, ref) => ( +
+)); + +const buttonVariants = { + primary: "bg-custom-primary-100 text-white hover:bg-custom-primary-200 focus:bg-custom-primary-200", + secondary: + "bg-custom-background-100 text-custom-text-200 border border-custom-border-200 hover:bg-custom-background-90 focus:bg-custom-background-90", + outline: + "border border-custom-primary-100 text-custom-primary-100 bg-transparent hover:bg-custom-primary-100/10 focus:bg-custom-primary-100/20", + ghost: "text-custom-text-200 hover:bg-custom-background-90 focus:bg-custom-background-90", + destructive: "bg-red-500 text-white hover:bg-red-600 focus:bg-red-600", +}; + +const ToolbarSubmitButton = React.forwardRef( + ({ loading = false, variant = "primary", className, children, disabled, ...props }, ref) => ( +
+ +
+ ) +); + +ToolbarRoot.displayName = "ToolbarRoot"; +ToolbarGroup.displayName = "ToolbarGroup"; +ToolbarItem.displayName = "ToolbarItem"; +ToolbarSeparator.displayName = "ToolbarSeparator"; +ToolbarSubmitButton.displayName = "ToolbarSubmitButton"; + +// compound components +const Toolbar = Object.assign(ToolbarRoot, { + Group: ToolbarGroup, + Item: ToolbarItem, + Separator: ToolbarSeparator, + SubmitButton: ToolbarSubmitButton, +}); + +export { Toolbar }; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index ac439f1b6..7309c2cba 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -22,6 +22,7 @@ export default defineConfig({ "src/table/index.ts", "src/tabs/index.ts", "src/toast/index.ts", + "src/toolbar/index.ts", "src/tooltip/index.ts", "src/utils/index.ts", ], From 1c8ac3d247a18cecd8d1152cd21a3ee34e2b38a7 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:15:37 +0530 Subject: [PATCH 009/169] [WEB-4737] dev: propel pill component (#7743) * dev: pill component added to propel * dev: pill story added * chore: propel config updated * chore: code refactor --- packages/propel/package.json | 1 + packages/propel/src/pill/index.ts | 2 + packages/propel/src/pill/pill.stories.tsx | 143 ++++++++++++++++++++++ packages/propel/src/pill/pill.tsx | 72 +++++++++++ packages/propel/tsdown.config.ts | 1 + 5 files changed, 219 insertions(+) create mode 100644 packages/propel/src/pill/index.ts create mode 100644 packages/propel/src/pill/pill.stories.tsx create mode 100644 packages/propel/src/pill/pill.tsx diff --git a/packages/propel/package.json b/packages/propel/package.json index b08b2a12e..9de16d76e 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -29,6 +29,7 @@ "./emoji-icon-picker": "./dist/emoji-icon-picker/index.js", "./icons": "./dist/icons/index.js", "./menu": "./dist/menu/index.js", + "./pill": "./dist/pill/index.js", "./popover": "./dist/popover/index.js", "./scrollarea": "./dist/scrollarea/index.js", "./skeleton": "./dist/skeleton/index.js", diff --git a/packages/propel/src/pill/index.ts b/packages/propel/src/pill/index.ts new file mode 100644 index 000000000..9b9f4c84e --- /dev/null +++ b/packages/propel/src/pill/index.ts @@ -0,0 +1,2 @@ +export { Pill } from "./pill"; +export type { PillProps } from "./pill"; diff --git a/packages/propel/src/pill/pill.stories.tsx b/packages/propel/src/pill/pill.stories.tsx new file mode 100644 index 000000000..7072bdb72 --- /dev/null +++ b/packages/propel/src/pill/pill.stories.tsx @@ -0,0 +1,143 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Pill, EPillVariant, EPillSize } from "./pill"; + +const meta: Meta = { + title: "Components/Pill", + component: Pill, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "select", + options: Object.values(EPillVariant), + }, + size: { + control: "select", + options: Object.values(EPillSize), + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "Default", + }, +}; + +export const Primary: Story = { + args: { + variant: EPillVariant.PRIMARY, + children: "Primary", + }, +}; + +export const Success: Story = { + args: { + variant: EPillVariant.SUCCESS, + children: "Success", + }, +}; + +export const Warning: Story = { + args: { + variant: EPillVariant.WARNING, + children: "Warning", + }, +}; + +export const Error: Story = { + args: { + variant: EPillVariant.ERROR, + children: "Error", + }, +}; + +export const Info: Story = { + args: { + variant: EPillVariant.INFO, + children: "Info", + }, +}; + +export const Small: Story = { + args: { + size: EPillSize.SM, + children: "Small", + }, +}; + +export const Medium: Story = { + args: { + size: EPillSize.MD, + children: "Medium", + }, +}; + +export const Large: Story = { + args: { + size: EPillSize.LG, + children: "Large", + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+ Default + Primary + Success + Warning + Error + Info +
+
+ ), +}; + +export const AllSizes: Story = { + render: () => ( +
+
+ Small + Medium + Large +
+
+ ), +}; + +export const WithNumbers: Story = { + render: () => ( +
+
+ 3 + 12 + 99+ + ! +
+
+ ), +}; + +export const StatusExamples: Story = { + render: () => ( +
+
+

Task Status

+
+ Draft + In Progress + In Review + Completed + Blocked +
+
+
+ ), +}; diff --git a/packages/propel/src/pill/pill.tsx b/packages/propel/src/pill/pill.tsx new file mode 100644 index 000000000..dbbf60d8d --- /dev/null +++ b/packages/propel/src/pill/pill.tsx @@ -0,0 +1,72 @@ +import * as React from "react"; +import { cn } from "../utils"; + +export enum EPillVariant { + DEFAULT = "default", + PRIMARY = "primary", + SUCCESS = "success", + WARNING = "warning", + ERROR = "error", + INFO = "info", +} + +export enum EPillSize { + SM = "sm", + MD = "md", + LG = "lg", +} + +export type TPillVariant = + | EPillVariant.DEFAULT + | EPillVariant.PRIMARY + | EPillVariant.SUCCESS + | EPillVariant.WARNING + | EPillVariant.ERROR + | EPillVariant.INFO; +export type TPillSize = EPillSize.SM | EPillSize.MD | EPillSize.LG; + +export interface PillProps extends React.HTMLAttributes { + variant?: TPillVariant; + size?: TPillSize; + className?: string; + children: React.ReactNode; +} + +const pillVariants = { + [EPillVariant.DEFAULT]: "bg-custom-background-90 text-custom-text-200 border border-custom-border-200", + [EPillVariant.PRIMARY]: "bg-custom-primary-100/10 text-custom-primary-100 border border-custom-primary-100/20", + [EPillVariant.SUCCESS]: "bg-green-50 text-green-700 border border-green-200", + [EPillVariant.WARNING]: "bg-amber-50 text-amber-700 border border-amber-200", + [EPillVariant.ERROR]: "bg-red-50 text-red-700 border border-red-200", + [EPillVariant.INFO]: "bg-blue-50 text-blue-700 border border-blue-200", +}; + +const pillSizes = { + [EPillSize.SM]: "px-2 py-0.5 text-xs", + [EPillSize.MD]: "px-2.5 py-1 text-sm", + [EPillSize.LG]: "px-3 py-1.5 text-base", +}; + +const Pill = React.forwardRef( + ({ variant = EPillVariant.DEFAULT, size = EPillSize.MD, className, children, ...props }, ref) => ( + + {children} + + ) +); + +Pill.displayName = "Pill"; + +export { Pill }; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index 7309c2cba..71d201641 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "src/emoji-icon-picker/index.ts", "src/icons/index.ts", "src/menu/index.ts", + "src/pill/index.ts", "src/popover/index.ts", "src/scrollarea/index.ts", "src/skeleton/index.ts", From 7e0326475856e74b6d1890fc9fd7f14be9e699dc Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:15:55 +0530 Subject: [PATCH 010/169] [WEB-4730] dev: propel context menu component (#7745) * dev: context menu component added * dev: context menu story added * chore: propel config updated --- packages/propel/package.json | 1 + .../src/context-menu/context-menu.stories.tsx | 35 +++++ .../propel/src/context-menu/context-menu.tsx | 128 ++++++++++++++++++ packages/propel/src/context-menu/index.ts | 7 + packages/propel/tsdown.config.ts | 1 + 5 files changed, 172 insertions(+) create mode 100644 packages/propel/src/context-menu/context-menu.stories.tsx create mode 100644 packages/propel/src/context-menu/context-menu.tsx create mode 100644 packages/propel/src/context-menu/index.ts diff --git a/packages/propel/package.json b/packages/propel/package.json index 9de16d76e..739df2385 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -25,6 +25,7 @@ "./collapsible": "./dist/collapsible/index.js", "./combobox": "./dist/combobox/index.js", "./command": "./dist/command/index.js", + "./context-menu": "./dist/context-menu/index.js", "./dialog": "./dist/dialog/index.js", "./emoji-icon-picker": "./dist/emoji-icon-picker/index.js", "./icons": "./dist/icons/index.js", diff --git a/packages/propel/src/context-menu/context-menu.stories.tsx b/packages/propel/src/context-menu/context-menu.stories.tsx new file mode 100644 index 000000000..bc33a304d --- /dev/null +++ b/packages/propel/src/context-menu/context-menu.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ContextMenu } from "./context-menu"; + +const meta: Meta = { + title: "Components/ContextMenu", + component: ContextMenu, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => ( + + +
+ Right click here +
+
+ + + Back + Forward + Reload + + More Tools + + +
+ ), +}; diff --git a/packages/propel/src/context-menu/context-menu.tsx b/packages/propel/src/context-menu/context-menu.tsx new file mode 100644 index 000000000..60e05f1ba --- /dev/null +++ b/packages/propel/src/context-menu/context-menu.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import { ContextMenu as ContextMenuPrimitive } from "@base-ui-components/react/context-menu"; +import { cn } from "../utils"; + +export interface ContextMenuProps extends React.ComponentProps { + children: React.ReactNode; +} + +export interface ContextMenuTriggerProps extends React.ComponentProps { + children: React.ReactNode; + className?: string; +} + +export interface ContextMenuContentProps extends React.ComponentProps { + children: React.ReactNode; + className?: string; + side?: "top" | "right" | "bottom" | "left"; + sideOffset?: number; +} + +export interface ContextMenuItemProps extends React.ComponentProps { + children: React.ReactNode; + className?: string; + disabled?: boolean; +} + +const ContextMenuRoot = React.forwardRef, ContextMenuProps>( + ({ children, ...props }, _ref) => {children} +); + +const ContextMenuTrigger = React.forwardRef< + React.ElementRef, + ContextMenuTriggerProps +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +const ContextMenuPortal = ContextMenuPrimitive.Portal; + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + ContextMenuContentProps +>(({ className, children, side = "bottom", sideOffset = 4, ...props }, ref) => ( + + + {children} + + +)); + +const ContextMenuItem = React.forwardRef, ContextMenuItemProps>( + ({ className, disabled, children, ...props }, ref) => ( + + {children} + + ) +); + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, ...props }, ref) => ( + +)); + +const ContextMenuSubmenu = ContextMenuPrimitive.SubmenuRoot; + +const ContextMenuSubmenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, children, ...props }, ref) => ( + + {children} + +)); + +ContextMenuRoot.displayName = "ContextMenu"; +ContextMenuTrigger.displayName = "ContextMenuTrigger"; +ContextMenuContent.displayName = "ContextMenuContent"; +ContextMenuItem.displayName = "ContextMenuItem"; +ContextMenuSeparator.displayName = "ContextMenuSeparator"; +ContextMenuSubmenuTrigger.displayName = "ContextMenuSubmenuTrigger"; + +// compound components +const ContextMenu = Object.assign(ContextMenuRoot, { + Trigger: ContextMenuTrigger, + Portal: ContextMenuPortal, + Content: ContextMenuContent, + Item: ContextMenuItem, + Separator: ContextMenuSeparator, + Submenu: ContextMenuSubmenu, + SubmenuTrigger: ContextMenuSubmenuTrigger, +}); + +export { ContextMenu }; diff --git a/packages/propel/src/context-menu/index.ts b/packages/propel/src/context-menu/index.ts new file mode 100644 index 000000000..7a0cbc670 --- /dev/null +++ b/packages/propel/src/context-menu/index.ts @@ -0,0 +1,7 @@ +export { ContextMenu } from "./context-menu"; +export type { + ContextMenuProps, + ContextMenuTriggerProps, + ContextMenuContentProps, + ContextMenuItemProps, +} from "./context-menu"; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index 71d201641..b571adb31 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "src/collapsible/index.ts", "src/combobox/index.ts", "src/command/index.ts", + "src/context-menu/index.ts", "src/dialog/index.ts", "src/emoji-icon-picker/index.ts", "src/icons/index.ts", From b0db4fcf10edacb4d7ad2086d90dbf8876cdfec4 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:34:17 +0530 Subject: [PATCH 011/169] [WEB-4736] dev: propel button (#7746) * dev: button added to propel * dev: button story added * chore: propel config updated --------- Co-authored-by: sriram veeraghanta --- packages/propel/package.json | 1 + packages/propel/src/button/button.stories.tsx | 119 ++++++++++++++++++ packages/propel/src/button/button.tsx | 73 +++++++++++ packages/propel/src/button/index.ts | 2 + packages/propel/tsdown.config.ts | 1 + 5 files changed, 196 insertions(+) create mode 100644 packages/propel/src/button/button.stories.tsx create mode 100644 packages/propel/src/button/button.tsx create mode 100644 packages/propel/src/button/index.ts diff --git a/packages/propel/package.json b/packages/propel/package.json index 739df2385..8d0f939b4 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -19,6 +19,7 @@ "./accordion": "./dist/accordion/index.js", "./animated-counter": "./dist/animated-counter/index.js", "./avatar": "./dist/avatar/index.js", + "./button": "./dist/button/index.js", "./calendar": "./dist/calendar/index.js", "./card": "./dist/card/index.js", "./charts/*": "./dist/charts/*/index.js", diff --git a/packages/propel/src/button/button.stories.tsx b/packages/propel/src/button/button.stories.tsx new file mode 100644 index 000000000..4f0170992 --- /dev/null +++ b/packages/propel/src/button/button.stories.tsx @@ -0,0 +1,119 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Button, EButtonVariant, EButtonSize } from "./button"; + +const meta: Meta = { + title: "Components/Button", + component: Button, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "select", + options: Object.values(EButtonVariant), + }, + size: { + control: "select", + options: Object.values(EButtonSize), + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "Button", + }, +}; + +export const Primary: Story = { + args: { + variant: EButtonVariant.PRIMARY, + children: "Primary Button", + }, +}; + +export const Secondary: Story = { + args: { + variant: EButtonVariant.SECONDARY, + children: "Secondary Button", + }, +}; + +export const Outline: Story = { + args: { + variant: EButtonVariant.OUTLINE, + children: "Outline Button", + }, +}; + +export const Ghost: Story = { + args: { + variant: EButtonVariant.GHOST, + children: "Ghost Button", + }, +}; + +export const Destructive: Story = { + args: { + variant: EButtonVariant.DESTRUCTIVE, + children: "Destructive Button", + }, +}; + +export const Small: Story = { + args: { + size: EButtonSize.SM, + children: "Small Button", + }, +}; + +export const Medium: Story = { + args: { + size: EButtonSize.MD, + children: "Medium Button", + }, +}; + +export const Large: Story = { + args: { + size: EButtonSize.LG, + children: "Large Button", + }, +}; + +export const Disabled: Story = { + args: { + disabled: true, + children: "Disabled Button", + }, +}; + +export const AllVariants: Story = { + render: () => ( +
+
+ + + + + +
+
+ ), +}; + +export const AllSizes: Story = { + render: () => ( +
+
+ + + +
+
+ ), +}; diff --git a/packages/propel/src/button/button.tsx b/packages/propel/src/button/button.tsx new file mode 100644 index 000000000..995b5d3da --- /dev/null +++ b/packages/propel/src/button/button.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { cn } from "../utils"; + +export enum EButtonVariant { + PRIMARY = "primary", + SECONDARY = "secondary", + OUTLINE = "outline", + GHOST = "ghost", + DESTRUCTIVE = "destructive", +} + +export enum EButtonSize { + SM = "sm", + MD = "md", + LG = "lg", +} + +export type TButtonVariant = + | EButtonVariant.PRIMARY + | EButtonVariant.SECONDARY + | EButtonVariant.OUTLINE + | EButtonVariant.GHOST + | EButtonVariant.DESTRUCTIVE; +export type TButtonSize = EButtonSize.SM | EButtonSize.MD | EButtonSize.LG; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: TButtonVariant; + size?: TButtonSize; + className?: string; + children: React.ReactNode; +} + +const buttonVariants = { + [EButtonVariant.PRIMARY]: "bg-custom-primary-100 text-white hover:bg-custom-primary-200 focus:bg-custom-primary-200", + [EButtonVariant.SECONDARY]: + "bg-custom-background-100 text-custom-text-200 border border-custom-border-200 hover:bg-custom-background-90 focus:bg-custom-background-90", + [EButtonVariant.OUTLINE]: + "border border-custom-primary-100 text-custom-primary-100 bg-transparent hover:bg-custom-primary-100/10 focus:bg-custom-primary-100/20", + [EButtonVariant.GHOST]: "text-custom-text-200 hover:bg-custom-background-90 focus:bg-custom-background-90", + [EButtonVariant.DESTRUCTIVE]: "bg-red-500 text-white hover:bg-red-600 focus:bg-red-600", +}; + +const buttonSizes = { + [EButtonSize.SM]: "px-3 py-1.5 text-xs font-medium", + [EButtonSize.MD]: "px-4 py-2 text-sm font-medium", + [EButtonSize.LG]: "px-6 py-2.5 text-base font-medium", +}; + +const Button = React.forwardRef( + ({ variant = EButtonVariant.PRIMARY, size = EButtonSize.MD, className, children, ...props }, ref) => ( + + ) +); + +Button.displayName = "Button"; + +export { Button }; diff --git a/packages/propel/src/button/index.ts b/packages/propel/src/button/index.ts new file mode 100644 index 000000000..8827201b5 --- /dev/null +++ b/packages/propel/src/button/index.ts @@ -0,0 +1,2 @@ +export { Button } from "./button"; +export type { ButtonProps } from "./button"; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index b571adb31..e53e47d61 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ "src/accordion/index.ts", "src/animated-counter/index.ts", "src/avatar/index.ts", + "src/button/index.ts", "src/calendar/index.ts", "src/card/index.ts", "src/charts/*/index.ts", From 6d116beea3e0583e2e8455500701ba152104b4c8 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Wed, 10 Sep 2025 00:54:47 +0530 Subject: [PATCH 012/169] [WEB-4320] dev: propel emoji reaction component (#7741) * dev: animated counter added to propel * chore: animated counter story added * chore: propel config updated * chore: code refactor * dev: emoji reaction and renderer component added to propel * dev: emoji reaction story added * chore: propel config updated * chore: code refactor * fix: format error * chore: lint error resolved --------- Co-authored-by: sriram veeraghanta --- .../i18n/src/locales/ru/translations.json | 2 +- packages/propel/package.json | 2 + packages/propel/src/command/command.tsx | 4 +- .../emoji-reaction-picker.stories.tsx | 49 +++++ .../emoji-reaction/emoji-reaction-picker.tsx | 84 ++++++++ .../emoji-reaction/emoji-reaction.stories.tsx | 32 +++ .../src/emoji-reaction/emoji-reaction.tsx | 182 ++++++++++++++++++ packages/propel/src/emoji-reaction/index.ts | 10 + packages/propel/tsdown.config.ts | 2 + 9 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 packages/propel/src/emoji-reaction/emoji-reaction-picker.stories.tsx create mode 100644 packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx create mode 100644 packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx create mode 100644 packages/propel/src/emoji-reaction/emoji-reaction.tsx create mode 100644 packages/propel/src/emoji-reaction/index.ts diff --git a/packages/i18n/src/locales/ru/translations.json b/packages/i18n/src/locales/ru/translations.json index c6c84ed8e..ac1db0501 100644 --- a/packages/i18n/src/locales/ru/translations.json +++ b/packages/i18n/src/locales/ru/translations.json @@ -2532,4 +2532,4 @@ "close_button": "Закрыть панель навигации", "outline_floating_button": "Открыть структуру" } -} \ No newline at end of file +} diff --git a/packages/propel/package.json b/packages/propel/package.json index 8d0f939b4..6650905bb 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -29,6 +29,8 @@ "./context-menu": "./dist/context-menu/index.js", "./dialog": "./dist/dialog/index.js", "./emoji-icon-picker": "./dist/emoji-icon-picker/index.js", + "./emoji-reaction": "./dist/emoji-reaction/index.js", + "./emoji-reaction-picker": "./dist/emoji-reaction-picker/index.js", "./icons": "./dist/icons/index.js", "./menu": "./dist/menu/index.js", "./pill": "./dist/pill/index.js", diff --git a/packages/propel/src/command/command.tsx b/packages/propel/src/command/command.tsx index 25c977c3e..e462a7f2d 100644 --- a/packages/propel/src/command/command.tsx +++ b/packages/propel/src/command/command.tsx @@ -19,7 +19,7 @@ function CommandInput({ className, ...props }: React.ComponentProps) { +function CommandList({ ...props }: React.ComponentProps) { return ; } @@ -27,7 +27,7 @@ function CommandEmpty({ ...props }: React.ComponentProps; } -function CommandItem({ className, ...props }: React.ComponentProps) { +function CommandItem({ ...props }: React.ComponentProps) { return ; } diff --git a/packages/propel/src/emoji-reaction/emoji-reaction-picker.stories.tsx b/packages/propel/src/emoji-reaction/emoji-reaction-picker.stories.tsx new file mode 100644 index 000000000..558190fdf --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction-picker.stories.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { SmilePlus } from "lucide-react"; +import { stringToEmoji } from "../emoji-icon-picker"; +import { EmojiReactionPicker } from "./emoji-reaction-picker"; + +const meta: Meta = { + title: "EmojiReactionPicker", + component: EmojiReactionPicker, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const EmojiPickerDemo = (args: React.ComponentProps) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedEmoji, setSelectedEmoji] = useState(null); + + return ( +
+ { + setSelectedEmoji(emoji); + console.log("Selected emoji:", emoji); + }} + label={ + + {selectedEmoji ? stringToEmoji(selectedEmoji) : } + + } + /> +
+ ); +}; + +export const Default: Story = { + render: (args) => , + args: { + closeOnSelect: true, + searchPlaceholder: "Search emojis...", + }, +}; diff --git a/packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx b/packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx new file mode 100644 index 000000000..092a388ac --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction-picker.tsx @@ -0,0 +1,84 @@ +import React, { useMemo, useCallback } from "react"; +import { EmojiRoot } from "../emoji-icon-picker/emoji/emoji"; +import { emojiToString } from "../emoji-icon-picker/helper"; +import { Popover } from "../popover"; +import { cn } from "../utils/classname"; +import { convertPlacementToSideAndAlign, type TPlacement, type TSide, type TAlign } from "../utils/placement"; + +export interface EmojiReactionPickerProps { + isOpen: boolean; + handleToggle: (value: boolean) => void; + buttonClassName?: string; + closeOnSelect?: boolean; + disabled?: boolean; + dropdownClassName?: string; + label: React.ReactNode; + onChange: (emoji: string) => void; + placement?: TPlacement; + searchDisabled?: boolean; + searchPlaceholder?: string; + side?: TSide; + align?: TAlign; +} + +export const EmojiReactionPicker: React.FC = (props) => { + const { + isOpen, + handleToggle, + buttonClassName, + closeOnSelect = true, + disabled = false, + dropdownClassName, + label, + onChange, + placement = "bottom-start", + searchDisabled = false, + searchPlaceholder = "Search", + side = "bottom", + align = "start", + } = props; + + // side and align calculations + const { finalSide, finalAlign } = useMemo(() => { + if (placement) { + const converted = convertPlacementToSideAndAlign(placement); + return { finalSide: converted.side, finalAlign: converted.align }; + } + return { finalSide: side, finalAlign: align }; + }, [placement, side, align]); + + const handleEmojiChange = useCallback( + (value: string) => { + const emoji = emojiToString(value); + onChange(emoji); + if (closeOnSelect) handleToggle(false); + }, + [onChange, closeOnSelect, handleToggle] + ); + + return ( + + + {label} + + +
+ +
+
+
+ ); +}; diff --git a/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx b/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx new file mode 100644 index 000000000..55a4aa3b5 --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { EmojiReaction } from "./emoji-reaction"; + +const meta: Meta = { + title: "EmojiReaction", + component: EmojiReaction, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const Single: Story = { + args: { + emoji: "👍", + count: 5, + reacted: false, + users: ["User 1", "User 2", "User 3"], + }, +}; + +export const Reacted: Story = { + args: { + emoji: "❤️", + count: 12, + reacted: true, + users: ["User 1", "User 2", "User 3", "User 4", "User 5", "User 6"], + }, +}; diff --git a/packages/propel/src/emoji-reaction/emoji-reaction.tsx b/packages/propel/src/emoji-reaction/emoji-reaction.tsx new file mode 100644 index 000000000..cad773364 --- /dev/null +++ b/packages/propel/src/emoji-reaction/emoji-reaction.tsx @@ -0,0 +1,182 @@ +import * as React from "react"; +import { Plus } from "lucide-react"; +import { AnimatedCounter } from "../animated-counter"; +import { stringToEmoji } from "../emoji-icon-picker"; +import { Tooltip } from "../tooltip"; +import { cn } from "../utils"; + +export interface EmojiReactionType { + emoji: string; + count: number; + reacted?: boolean; + users?: string[]; +} + +export interface EmojiReactionProps extends React.ButtonHTMLAttributes { + emoji: string; + count: number; + reacted?: boolean; + users?: string[]; + onReactionClick?: (emoji: string) => void; + className?: string; + showCount?: boolean; + size?: "sm" | "md" | "lg"; +} + +export interface EmojiReactionGroupProps extends React.HTMLAttributes { + reactions: EmojiReactionType[]; + onReactionClick?: (emoji: string) => void; + onAddReaction?: () => void; + className?: string; + showAddButton?: boolean; + maxDisplayUsers?: number; + size?: "sm" | "md" | "lg"; +} + +export interface EmojiReactionButtonProps extends React.ButtonHTMLAttributes { + onAddReaction?: () => void; + className?: string; + size?: "sm" | "md" | "lg"; +} + +const sizeClasses = { + sm: { + button: "px-2 py-1 text-xs gap-1", + emoji: "text-sm", + count: "text-xs", + addButton: "h-6 w-6", + addIcon: "h-3 w-3", + }, + md: { + button: "px-2.5 py-1.5 text-sm gap-1.5", + emoji: "text-base", + count: "text-sm", + addButton: "h-7 w-7", + addIcon: "h-3.5 w-3.5", + }, + lg: { + button: "px-3 py-2 text-base gap-2", + emoji: "text-lg", + count: "text-base", + addButton: "h-8 w-8", + addIcon: "h-4 w-4", + }, +}; + +const EmojiReaction = React.forwardRef( + ( + { emoji, count, reacted = false, users = [], onReactionClick, className, showCount = true, size = "md", ...props }, + ref + ) => { + const sizeClass = sizeClasses[size]; + + const handleClick = () => { + onReactionClick?.(emoji); + }; + + const tooltipContent = React.useMemo(() => { + if (!users.length) return null; + + const displayUsers = users.slice(0, 5); + const remainingCount = users.length - displayUsers.length; + + return ( +
+
{stringToEmoji(emoji)}
+
+ {displayUsers.join(", ")} + {remainingCount > 0 && ` and ${remainingCount} more`} +
+
+ ); + }, [emoji, users]); + + const button = ( + + ); + + if (tooltipContent && users.length > 0) { + return {button}; + } + + return button; + } +); + +const EmojiReactionButton = React.forwardRef( + ({ onAddReaction, className, size = "md", ...props }, ref) => { + const sizeClass = sizeClasses[size]; + + return ( + + ); + } +); + +const EmojiReactionGroup = React.forwardRef( + ( + { + reactions, + onReactionClick, + onAddReaction, + className, + showAddButton = true, + maxDisplayUsers = 5, + size = "md", + ...props + }, + ref + ) => ( +
+ {reactions.map((reaction, index) => ( + + ))} + {showAddButton && } +
+ ) +); + +EmojiReaction.displayName = "EmojiReaction"; +EmojiReactionButton.displayName = "EmojiReactionButton"; +EmojiReactionGroup.displayName = "EmojiReactionGroup"; + +export { EmojiReaction, EmojiReactionButton, EmojiReactionGroup }; diff --git a/packages/propel/src/emoji-reaction/index.ts b/packages/propel/src/emoji-reaction/index.ts new file mode 100644 index 000000000..151af7bb2 --- /dev/null +++ b/packages/propel/src/emoji-reaction/index.ts @@ -0,0 +1,10 @@ +export { EmojiReaction, EmojiReactionGroup, EmojiReactionButton } from "./emoji-reaction"; +export type { + EmojiReactionProps, + EmojiReactionGroupProps, + EmojiReactionButtonProps, + EmojiReactionType, +} from "./emoji-reaction"; + +export { EmojiReactionPicker } from "./emoji-reaction-picker"; +export type { EmojiReactionPickerProps } from "./emoji-reaction-picker"; diff --git a/packages/propel/tsdown.config.ts b/packages/propel/tsdown.config.ts index e53e47d61..20f242fb5 100644 --- a/packages/propel/tsdown.config.ts +++ b/packages/propel/tsdown.config.ts @@ -15,6 +15,8 @@ export default defineConfig({ "src/context-menu/index.ts", "src/dialog/index.ts", "src/emoji-icon-picker/index.ts", + "src/emoji-reaction/index.ts", + "src/emoji-reaction-picker/index.ts", "src/icons/index.ts", "src/menu/index.ts", "src/pill/index.ts", From 30b175108be4c5d402b80f4080902c1c1f7c8973 Mon Sep 17 00:00:00 2001 From: sriramveeraghanta Date: Wed, 10 Sep 2025 13:38:30 +0530 Subject: [PATCH 013/169] chore (deps): django and vite version bump --- apps/api/requirements/base.txt | 2 +- package.json | 3 ++- pnpm-lock.yaml | 9 +++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/api/requirements/base.txt b/apps/api/requirements/base.txt index 8b0a720d4..28fede97f 100644 --- a/apps/api/requirements/base.txt +++ b/apps/api/requirements/base.txt @@ -1,7 +1,7 @@ # base requirements # django -Django==4.2.22 +Django==4.2.24 # rest framework djangorestframework==3.15.2 # postgres diff --git a/package.json b/package.json index 332f0dfb4..591eda56f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "prosemirror-view": "1.40.0", "@types/express": "4.17.23", "typescript": "catalog:", - "sharp": "catalog:" + "sharp": "catalog:", + "vite": "7.0.7" } }, "packageManager": "pnpm@10.12.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0525ab07b..a1d88bf7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,6 +64,7 @@ overrides: '@types/express': 4.17.23 typescript: 5.8.3 sharp: 0.33.5 + vite: 7.0.7 importers: @@ -2040,7 +2041,7 @@ packages: resolution: {integrity: sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==} peerDependencies: typescript: 5.8.3 - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: 7.0.7 peerDependenciesMeta: typescript: optional: true @@ -2823,7 +2824,7 @@ packages: resolution: {integrity: sha512-5Y7e5wnSzFxCGP63UNRRZVoxHe1znU4dYXazJBobAlEcUPBk7A0sH2716tA6bS4oz92oG9tgvn1g996hRrw4ow==} peerDependencies: storybook: ^9.1.2 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: 7.0.7 '@storybook/builder-webpack5@8.6.14': resolution: {integrity: sha512-YZYAqc6NBKoMTKZpjxnkMch6zDtMkBZdS/yaji1+wJX2QPFBwTbSh7SpeBxDp1S11gXSAJ4f1btUWeqSqo8nJA==} @@ -2931,7 +2932,7 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta storybook: ^9.1.2 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: 7.0.7 '@storybook/react-webpack5@8.6.14': resolution: {integrity: sha512-ka0q9tQBLruhO38sybP/MkZzejqAltce7HJTJ2KKbUYUlbvuG7m56tBX7DVC5JaImbsO3b8fqOrKH7gRt4KYrQ==} @@ -3773,7 +3774,7 @@ packages: resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: 7.0.7 peerDependenciesMeta: msw: optional: true From 20d773042bf4a2fa319dee2adcf9a6c4c1aec607 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Wed, 10 Sep 2025 14:42:46 +0530 Subject: [PATCH 014/169] Potential fix for code scanning alert no. 646: Server-side request forgery (#7758) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- apps/space/app/issues/[anchor]/layout.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/space/app/issues/[anchor]/layout.tsx b/apps/space/app/issues/[anchor]/layout.tsx index 91631d6c0..46f187ddc 100644 --- a/apps/space/app/issues/[anchor]/layout.tsx +++ b/apps/space/app/issues/[anchor]/layout.tsx @@ -13,6 +13,11 @@ export async function generateMetadata({ params }: Props) { const { anchor } = params; const DEFAULT_TITLE = "Plane"; const DEFAULT_DESCRIPTION = "Made with Plane, an AI-powered work management platform with publishing capabilities."; + // Validate anchor before using in request (only allow alphanumeric, -, _) + const ANCHOR_REGEX = /^[a-zA-Z0-9_-]+$/; + if (!ANCHOR_REGEX.test(anchor)) { + return { title: DEFAULT_TITLE, description: DEFAULT_DESCRIPTION }; + } try { const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/public/anchor/${anchor}/meta/`); const data = await response.json(); From ac835bf287a18e76a80fbf86270e5760445c0e80 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:10:23 +0530 Subject: [PATCH 015/169] [WEB-4874]fix: calendar picker build errors and styles override (#7762) * fix: calender picker build errors * fix: styles override in the picker implementation --- .../web/core/components/analytics/total-insights.tsx | 12 ++---------- .../components/core/filters/date-filter-modal.tsx | 12 ++++-------- apps/web/core/components/dropdowns/date-range.tsx | 4 ++-- apps/web/core/components/dropdowns/date.tsx | 4 ++-- .../components/inbox/modals/snooze-issue-modal.tsx | 4 ++-- 5 files changed, 12 insertions(+), 24 deletions(-) diff --git a/apps/web/core/components/analytics/total-insights.tsx b/apps/web/core/components/analytics/total-insights.tsx index 0ac1f4f87..a822e7560 100644 --- a/apps/web/core/components/analytics/total-insights.tsx +++ b/apps/web/core/components/analytics/total-insights.tsx @@ -19,7 +19,7 @@ const getInsightLabel = ( analyticsType: TAnalyticsTabsBase, item: IInsightField, isEpic: boolean | undefined, - t: (key: string, options?: any) => string + t: (key: string, params?: Record) => string ) => { if (analyticsType === "work-items") { return isEpic @@ -50,15 +50,7 @@ const TotalInsights: React.FC<{ const params = useParams(); const workspaceSlug = params.workspaceSlug.toString(); const { t } = useTranslation(); - const { - selectedDuration, - selectedProjects, - selectedDurationLabel, - selectedCycle, - selectedModule, - isPeekView, - isEpic, - } = useAnalytics(); + const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView, isEpic } = useAnalytics(); const { data: totalInsightsData, isLoading } = useSWR( `total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isEpic}`, () => diff --git a/apps/web/core/components/core/filters/date-filter-modal.tsx b/apps/web/core/components/core/filters/date-filter-modal.tsx index dd7173959..244aa69ea 100644 --- a/apps/web/core/components/core/filters/date-filter-modal.tsx +++ b/apps/web/core/components/core/filters/date-filter-modal.tsx @@ -95,13 +95,11 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o const date2Value = getDate(watch("date2")); return ( { + onSelect={(date: Date | undefined) => { if (!date) return; onChange(date); }} @@ -120,13 +118,11 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o const date1Value = getDate(watch("date1")); return ( { + onSelect={(date: Date | undefined) => { if (!date) return; onChange(date); }} diff --git a/apps/web/core/components/dropdowns/date-range.tsx b/apps/web/core/components/dropdowns/date-range.tsx index e0da93465..3cb07d970 100644 --- a/apps/web/core/components/dropdowns/date-range.tsx +++ b/apps/web/core/components/dropdowns/date-range.tsx @@ -271,10 +271,10 @@ export const DateRangeDropdown: React.FC = observer((props) => { {...attributes.popper} > { + onSelect={(val: DateRange | undefined) => { onSelect?.(val); }} mode="range" diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index 4a1af03e2..87d783d9a 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -178,11 +178,11 @@ export const DateDropdown: React.FC = observer((props) => { {...attributes.popper} > { + onSelect={(date: Date | undefined) => { dropdownOnChange(date ?? null); }} showOutsideDays diff --git a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx index 3f9040e5c..d137f6a03 100644 --- a/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx +++ b/apps/web/core/components/inbox/modals/snooze-issue-modal.tsx @@ -49,11 +49,11 @@ export const InboxIssueSnoozeModal: FC = (props) =>
{ + onSelect={(date: Date | undefined) => { if (!date) return; setDate(date); }} From 0f7bfdde91993a3236f67a7db03b6ed84cb48d89 Mon Sep 17 00:00:00 2001 From: Jayash Tripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Wed, 10 Sep 2025 17:44:52 +0530 Subject: [PATCH 016/169] [WEB-4877] fix: webapp crash because of bar chart (#7763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🔧 fix: dynamic bar color handling and refactored color retrieval logic. * ♻️ refactor: updated any to Record in getBarColor --- packages/propel/src/charts/bar-chart/root.tsx | 51 ++++++++++++++++--- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/packages/propel/src/charts/bar-chart/root.tsx b/packages/propel/src/charts/bar-chart/root.tsx index 0d34b2783..23a211432 100644 --- a/packages/propel/src/charts/bar-chart/root.tsx +++ b/packages/propel/src/charts/bar-chart/root.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ "use client"; -import React, { useMemo, useState } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { BarChart as CoreBarChart, Bar, @@ -21,6 +21,8 @@ import { CustomXAxisTick, CustomYAxisTick } from "../components/tick"; import { CustomTooltip } from "../components/tooltip"; import { barShapeVariants } from "./bar"; +const DEFAULT_BAR_FILL_COLOR = "#000000"; + export const BarChart = React.memo((props: TBarChartProps) => { const { data, @@ -44,21 +46,55 @@ export const BarChart = React.memo((props: T const [activeLegend, setActiveLegend] = useState(null); // derived values - const { stackKeys, stackLabels, stackDotColors } = useMemo(() => { + const { stackKeys, stackLabels } = useMemo(() => { const keys: string[] = []; const labels: Record = {}; - const colors: Record = {}; for (const bar of bars) { keys.push(bar.key); labels[bar.key] = bar.label; - // For tooltip, we need a string color. If fill is a function, use a default color - colors[bar.key] = typeof bar.fill === "function" ? bar.fill({}) : bar.fill; } - return { stackKeys: keys, stackLabels: labels, stackDotColors: colors }; + return { stackKeys: keys, stackLabels: labels }; }, [bars]); + // get bar color dynamically based on payload + const getBarColor = useCallback( + (payload: Record[], barKey: string) => { + const bar = bars.find((b) => b.key === barKey); + if (!bar) return DEFAULT_BAR_FILL_COLOR; + + if (typeof bar.fill === "function") { + const payloadItem = payload?.find((item) => item.dataKey === barKey); + if (payloadItem?.payload) { + try { + return bar.fill(payloadItem.payload); + } catch (error) { + console.error(error); + return DEFAULT_BAR_FILL_COLOR; + } + } else { + return DEFAULT_BAR_FILL_COLOR; // fallback color when no payload data + } + } else { + return bar.fill; + } + }, + [bars] + ); + + // get all bar colors + const getAllBarColors = useCallback( + (payload: any[]) => { + const colors: Record = {}; + for (const bar of bars) { + colors[bar.key] = getBarColor(payload, bar.key); + } + return colors; + }, + [bars, getBarColor] + ); + const renderBars = useMemo( () => bars.map((bar) => ( @@ -66,7 +102,6 @@ export const BarChart = React.memo((props: T key={bar.key} dataKey={bar.key} stackId={bar.stackId} - fill={typeof bar.fill === "function" ? bar.fill({}) : bar.fill} opacity={!!activeLegend && activeLegend !== bar.key ? 0.1 : 1} shape={(shapeProps: any) => { const shapeVariant = barShapeVariants[bar.shapeVariant ?? "bar"]; @@ -158,7 +193,7 @@ export const BarChart = React.memo((props: T activeKey={activeBar} itemKeys={stackKeys} itemLabels={stackLabels} - itemDotColors={stackDotColors} + itemDotColors={getAllBarColors(payload || [])} /> ); }} From 11cd8d11e4b331a404774eb78580e3a57275b9e9 Mon Sep 17 00:00:00 2001 From: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:15:39 +0530 Subject: [PATCH 017/169] [WIKI-632] chore: accept additional props for document collaborative editor (#7718) * chore: add collaborative document editor extended props * fix: additional rich text extension props * fix: formatting * chore: add types to the trailing node extension --------- Co-authored-by: Aaryan Khandelwal --- .../ce/extensions/rich-text-extensions.tsx | 2 +- .../editor/src/ce/types/editor-extended.ts | 2 + .../components/editors/rich-text/editor.tsx | 4 +- .../src/core/extensions/trailing-node.ts | 69 +++++++++++++++++++ packages/editor/src/core/types/editor.ts | 7 +- packages/editor/src/index.ts | 3 + 6 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 packages/editor/src/core/extensions/trailing-node.ts diff --git a/packages/editor/src/ce/extensions/rich-text-extensions.tsx b/packages/editor/src/ce/extensions/rich-text-extensions.tsx index 520dfa10e..7ade9e618 100644 --- a/packages/editor/src/ce/extensions/rich-text-extensions.tsx +++ b/packages/editor/src/ce/extensions/rich-text-extensions.tsx @@ -6,7 +6,7 @@ import { IEditorProps, TExtensions } from "@/types"; export type TRichTextEditorAdditionalExtensionsProps = Pick< IEditorProps, - "disabledExtensions" | "flaggedExtensions" | "fileHandler" + "disabledExtensions" | "flaggedExtensions" | "fileHandler" | "extendedEditorProps" >; /** diff --git a/packages/editor/src/ce/types/editor-extended.ts b/packages/editor/src/ce/types/editor-extended.ts index 98f9c7ece..0c3868112 100644 --- a/packages/editor/src/ce/types/editor-extended.ts +++ b/packages/editor/src/ce/types/editor-extended.ts @@ -2,6 +2,8 @@ export type IEditorExtensionOptions = unknown; export type IEditorPropsExtended = unknown; +export type ICollaborativeDocumentEditorPropsExtended = unknown; + export type TExtendedEditorCommands = never; export type TExtendedCommandExtraProps = unknown; diff --git a/packages/editor/src/core/components/editors/rich-text/editor.tsx b/packages/editor/src/core/components/editors/rich-text/editor.tsx index 02c0db55d..f110c789b 100644 --- a/packages/editor/src/core/components/editors/rich-text/editor.tsx +++ b/packages/editor/src/core/components/editors/rich-text/editor.tsx @@ -17,6 +17,7 @@ const RichTextEditor: React.FC = (props) => { extensions: externalExtensions = [], fileHandler, flaggedExtensions, + extendedEditorProps, } = props; const getExtensions = useCallback(() => { @@ -30,11 +31,12 @@ const RichTextEditor: React.FC = (props) => { disabledExtensions, fileHandler, flaggedExtensions, + extendedEditorProps, }), ]; return extensions; - }, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]); + }, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions, extendedEditorProps]); return ( diff --git a/packages/editor/src/core/extensions/trailing-node.ts b/packages/editor/src/core/extensions/trailing-node.ts new file mode 100644 index 000000000..27e3e85eb --- /dev/null +++ b/packages/editor/src/core/extensions/trailing-node.ts @@ -0,0 +1,69 @@ +import { Extension } from "@tiptap/core"; +import { NodeType, Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +// constants +import { CORE_EXTENSIONS } from "@/constants/extension"; + +function nodeEqualsType({ types, node }: { types: NodeType[]; node: ProseMirrorNode | null }) { + // TODO: check this logic, might be wrong + // @ts-expect-error - logic might be wrong + return (Array.isArray(types) && types.includes(node?.type)) || node?.type === types; +} + +export interface TrailingNodeOptions { + node: string; + notAfter: string[]; +} + +export const TrailingNode = Extension.create({ + name: "trailingNode", + + addOptions() { + return { + node: CORE_EXTENSIONS.PARAGRAPH, + notAfter: [CORE_EXTENSIONS.PARAGRAPH], + }; + }, + + addProseMirrorPlugins() { + const plugin = new PluginKey(this.name); + const disabledNodes = Object.entries(this.editor.schema.nodes) + .map(([, value]) => value) + .filter((node) => this.options.notAfter.includes(node.name)); + + return [ + new Plugin({ + key: plugin, + appendTransaction: (_, __, state) => { + const { doc, tr, schema } = state; + const shouldInsertNodeAtEnd = plugin.getState(state); + const endPosition = doc.content.size; + const type = schema.nodes[this.options.node]; + + if (!shouldInsertNodeAtEnd) { + return; + } + + // eslint-disable-next-line consistent-return + return tr.insert(endPosition, type.create()); + }, + state: { + init: (_, state) => { + const lastNode = state.tr.doc.lastChild; + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }); + }, + apply: (tr, value) => { + if (!tr.docChanged) { + return value; + } + + const lastNode = tr.doc.lastChild; + + return !nodeEqualsType({ node: lastNode, types: disabledNodes }); + }, + }, + }), + ]; + }, +}); diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 762d87d68..6c7254137 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -6,7 +6,11 @@ import type { NodeViewProps as TNodeViewProps } from "@tiptap/react"; // extension types import type { TTextAlign } from "@/extensions"; // plane editor imports -import type { IEditorPropsExtended, TExtendedEditorCommands } from "@/plane-editor/types/editor-extended"; +import type { + IEditorPropsExtended, + TExtendedEditorCommands, + ICollaborativeDocumentEditorPropsExtended, +} from "@/plane-editor/types/editor-extended"; // types import type { IMarking, @@ -176,6 +180,7 @@ export type ICollaborativeDocumentEditorProps = Omit & { diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 1872f35c7..3cf3b6fce 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -18,3 +18,6 @@ export { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; // types export * from "@/types"; + +// additional exports +export { TrailingNode } from "./core/extensions/trailing-node"; From ec541c255772a4c9c6dbd558a4c88b81736a9413 Mon Sep 17 00:00:00 2001 From: Sangeetha Date: Thu, 11 Sep 2025 14:16:36 +0530 Subject: [PATCH 018/169] [WEB-4854] chore: project admin accesss to workspace admins (#7749) * chore: project admin accesss to workspace admins * chore: frontend changes * chore: remove console.log * chore: refactor permission decorator * chore: role enum * chore: rearrange role_choices --- apps/api/plane/app/permissions/base.py | 22 +++++++- apps/api/plane/app/permissions/project.py | 35 ++++++++----- apps/api/plane/app/views/project/base.py | 52 ++++++++++++++----- apps/api/plane/db/models/project.py | 6 +++ .../core/store/user/base-permissions.store.ts | 6 ++- 5 files changed, 92 insertions(+), 29 deletions(-) diff --git a/apps/api/plane/app/permissions/base.py b/apps/api/plane/app/permissions/base.py index 7ba12a2e2..881088a3f 100644 --- a/apps/api/plane/app/permissions/base.py +++ b/apps/api/plane/app/permissions/base.py @@ -39,13 +39,31 @@ def allow_permission(allowed_roles, level="PROJECT", creator=False, model=None): ).exists(): return view_func(instance, request, *args, **kwargs) else: - if ProjectMember.objects.filter( + is_user_has_allowed_role = ProjectMember.objects.filter( member=request.user, workspace__slug=kwargs["slug"], project_id=kwargs["project_id"], role__in=allowed_role_values, is_active=True, - ).exists(): + ).exists() + + # Return if the user has the allowed role else if they are workspace admin and part of the project regardless of the role + if is_user_has_allowed_role: + return view_func(instance, request, *args, **kwargs) + elif ( + ProjectMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + project_id=kwargs["project_id"], + is_active=True, + ).exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=kwargs["slug"], + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ): return view_func(instance, request, *args, **kwargs) # Return permission denied if no conditions are met diff --git a/apps/api/plane/app/permissions/project.py b/apps/api/plane/app/permissions/project.py index 1596d90b3..e095ffed4 100644 --- a/apps/api/plane/app/permissions/project.py +++ b/apps/api/plane/app/permissions/project.py @@ -3,11 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission # Module import from plane.db.models import ProjectMember, WorkspaceMember - -# Permission Mappings -Admin = 20 -Member = 15 -Guest = 5 +from plane.db.models.project import ROLE class ProjectBasePermission(BasePermission): @@ -26,18 +22,31 @@ class ProjectBasePermission(BasePermission): return WorkspaceMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], is_active=True, ).exists() - ## Only Project Admins can update project attributes - return ProjectMember.objects.filter( + project_member_qs = ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role=Admin, project_id=view.project_id, is_active=True, - ).exists() + ) + + ## Only project admins or workspace admin who is part of the project can access + + if project_member_qs.filter(role=ROLE.ADMIN.value).exists(): + return True + else: + return ( + project_member_qs.exists() + and WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=view.workspace_slug, + role=ROLE.ADMIN.value, + is_active=True, + ).exists() + ) class ProjectMemberPermission(BasePermission): @@ -55,7 +64,7 @@ class ProjectMemberPermission(BasePermission): return WorkspaceMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], is_active=True, ).exists() @@ -63,7 +72,7 @@ class ProjectMemberPermission(BasePermission): return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], project_id=view.project_id, is_active=True, ).exists() @@ -97,7 +106,7 @@ class ProjectEntityPermission(BasePermission): return ProjectMember.objects.filter( workspace__slug=view.workspace_slug, member=request.user, - role__in=[Admin, Member], + role__in=[ROLE.ADMIN.value, ROLE.MEMBER.value], project_id=view.project_id, is_active=True, ).exists() diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index b4ee113c4..d4eeca2f7 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -5,13 +5,12 @@ from django.utils import timezone import json # Django imports -from django.db import IntegrityError from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response -from rest_framework import serializers, status +from rest_framework import status from rest_framework.permissions import AllowAny # Module imports @@ -106,7 +105,10 @@ class ProjectViewSet(BaseViewSet): fields = [field for field in request.GET.get("fields", "").split(",") if field] projects = self.get_queryset().order_by("sort_order", "name") if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.GUEST.value, ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, @@ -114,7 +116,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, ).exists(): projects = projects.filter( Q( @@ -189,7 +194,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=5 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.GUEST.value, ).exists(): projects = projects.filter( project_projectmember__member=self.request.user, @@ -197,7 +205,10 @@ class ProjectViewSet(BaseViewSet): ) if WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=15 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.MEMBER.value, ).exists(): projects = projects.filter( Q( @@ -250,7 +261,9 @@ class ProjectViewSet(BaseViewSet): # Add the user as Administrator to the project _ = ProjectMember.objects.create( - project_id=serializer.data["id"], member=request.user, role=20 + project_id=serializer.data["id"], + member=request.user, + role=ROLE.ADMIN.value, ) # Also create the issue property for the user _ = IssueUserProperty.objects.create( @@ -263,7 +276,7 @@ class ProjectViewSet(BaseViewSet): ProjectMember.objects.create( project_id=serializer.data["id"], member_id=serializer.data["project_lead"], - role=20, + role=ROLE.ADMIN.value, ) # Also create the issue property for the user IssueUserProperty.objects.create( @@ -341,13 +354,23 @@ class ProjectViewSet(BaseViewSet): def partial_update(self, request, slug, pk=None): # try: - if not ProjectMember.objects.filter( + is_workspace_admin = WorkspaceMember.objects.filter( + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, + ).exists() + + is_project_admin = ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, - role=20, + role=ROLE.ADMIN.value, is_active=True, - ).exists(): + ).exists() + + # Return error for if the user is neither workspace admin nor project admin + if not is_project_admin and not is_workspace_admin: return Response( {"error": "You don't have the required permissions."}, status=status.HTTP_403_FORBIDDEN, @@ -402,13 +425,16 @@ class ProjectViewSet(BaseViewSet): def destroy(self, request, slug, pk): if ( WorkspaceMember.objects.filter( - member=request.user, workspace__slug=slug, is_active=True, role=20 + member=request.user, + workspace__slug=slug, + is_active=True, + role=ROLE.ADMIN.value, ).exists() or ProjectMember.objects.filter( member=request.user, workspace__slug=slug, project_id=pk, - role=20, + role=ROLE.ADMIN.value, is_active=True, ).exists() ): diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index e58f60e80..af576be6e 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -18,6 +18,12 @@ from .base import BaseModel ROLE_CHOICES = ((20, "Admin"), (15, "Member"), (5, "Guest")) +class ROLE(Enum): + ADMIN = 20 + MEMBER = 15 + GUEST = 5 + + class ProjectNetwork(Enum): SECRET = 0 PUBLIC = 2 diff --git a/apps/web/core/store/user/base-permissions.store.ts b/apps/web/core/store/user/base-permissions.store.ts index b88d74930..4d6f13d26 100644 --- a/apps/web/core/store/user/base-permissions.store.ts +++ b/apps/web/core/store/user/base-permissions.store.ts @@ -118,7 +118,11 @@ export abstract class BaseUserPermissionStore implements IBaseUserPermissionStor */ protected getProjectRole = computedFn((workspaceSlug: string, projectId: string): EUserPermissions | undefined => { if (!workspaceSlug || !projectId) return undefined; - return this.workspaceProjectsPermissions?.[workspaceSlug]?.[projectId] || undefined; + const projectRole = this.workspaceProjectsPermissions?.[workspaceSlug]?.[projectId]; + if (!projectRole) return undefined; + const workspaceRole = this.workspaceUserInfo?.[workspaceSlug]?.role; + if (workspaceRole === EUserWorkspaceRoles.ADMIN) return EUserPermissions.ADMIN; + else return projectRole; }); /** From 8d354b3eb2cd8a13a28925fcd6ff2cff0ee7474a Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Thu, 11 Sep 2025 14:18:13 +0530 Subject: [PATCH 019/169] Potential fix for code scanning alert no. 636: URL redirection from remote source (#7760) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- apps/api/plane/utils/path_validator.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index ba81e9cab..aad28239f 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -3,18 +3,20 @@ from urllib.parse import urlparse def validate_next_path(next_path: str) -> str: - """Validates that next_path is a valid path and extracts only the path component.""" + """Validates that next_path is a safe relative path for redirection.""" + # Browsers interpret backslashes as forward slashes. Remove all backslashes. + next_path = next_path.replace("\\", "") parsed_url = urlparse(next_path) - # Ensure next_path is not an absolute URL + # Block absolute URLs or anything with scheme/netloc if parsed_url.scheme or parsed_url.netloc: next_path = parsed_url.path # Extract only the path component - # Ensure it starts with a forward slash (indicating a valid relative path) - if not next_path.startswith("/"): + # Must start with a forward slash and not be empty + if not next_path or not next_path.startswith("/"): return "" - # Ensure it does not contain dangerous path traversal sequences + # Prevent path traversal if ".." in next_path: return "" From 4fe2ef706b4cc268683287cc14aea723ee56d581 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:20:10 +0530 Subject: [PATCH 020/169] [WEB-4441]fix: members account type dropdown position #7759 --- packages/ui/src/dropdowns/custom-select.tsx | 66 +++++++++++---------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 0fe64dc35..23485b366 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -1,6 +1,7 @@ -import { Listbox } from "@headlessui/react"; +import { Combobox } from "@headlessui/react"; import { Check, ChevronDown } from "lucide-react"; import React, { useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; // plane helpers import { useOutsideClickDetector } from "@plane/hooks"; @@ -54,7 +55,7 @@ const CustomSelect = (props: ICustomSelectProps) => { }; return ( - { > <> {customButton ? ( - + - + ) : ( - + - + )} - {isOpen && ( - closeDropdown()} static> -
- {children} -
-
- )} -
+ {isOpen && + createPortal( + +
+
+ {children} +
+
+
, + document.body + )} + ); }; const Option = (props: ICustomSelectItemProps) => { const { children, value, className } = props; return ( - cn( @@ -147,7 +153,7 @@ const Option = (props: ICustomSelectItemProps) => { {selected && } )} - + ); }; From 4cfea87108c5397b942227afc454d868d2c7496c Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:21:30 +0530 Subject: [PATCH 021/169] [WEB-4857] fix: applied filters root update #7750 --- .../filters/applied-filters/roots/project-root.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx index e1b712983..010492274 100644 --- a/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx +++ b/apps/web/core/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx @@ -90,7 +90,7 @@ export const ProjectAppliedFiltersRoot: React.FC - {isEditingAllowed && ( + {isEditingAllowed && storeType === EIssuesStoreType.PROJECT && ( Date: Thu, 11 Sep 2025 14:22:46 +0530 Subject: [PATCH 022/169] [WEB-4858]chore: updated content for error page (#7766) * chore: updated content for error page * chore: updated btn url --- apps/web/app/error.tsx | 83 ++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index 9853dc13e..7e383ce2b 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -2,40 +2,36 @@ import Image from "next/image"; import { useTheme } from "next-themes"; -// plane imports -import { API_BASE_URL } from "@plane/constants"; -import { Button, TOAST_TYPE, setToast } from "@plane/ui"; -// hooks -import { useAppRouter } from "@/hooks/use-app-router"; // layouts +import { Button } from "@plane/ui"; +import { useAppRouter } from "@/hooks/use-app-router"; import DefaultLayout from "@/layouts/default-layout"; // images import maintenanceModeDarkModeImage from "@/public/instance/maintenance-mode-dark.svg"; import maintenanceModeLightModeImage from "@/public/instance/maintenance-mode-light.svg"; -// services -import { AuthService } from "@/services/auth.service"; -// services -const authService = new AuthService(); +const linkMap = [ + { + key: "mail_to", + label: "Contact Support", + value: "mailto:support@plane.so", + }, + { + key: "status", + label: "Status Page", + value: "https://status.plane.so/", + }, + { + key: "twitter_handle", + label: "@planepowers", + value: "https://x.com/planepowers", + }, +]; export default function CustomErrorComponent() { - // routers - const router = useAppRouter(); // hooks const { resolvedTheme } = useTheme(); - - const handleSignOut = async () => { - await authService - .signOut(API_BASE_URL) - .catch(() => - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to sign out. Please try again.", - }) - ) - .finally(() => router.push("/")); - }; + const router = useAppRouter(); // derived values const maintenanceModeImage = resolvedTheme === "dark" ? maintenanceModeDarkModeImage : maintenanceModeLightModeImage; @@ -55,34 +51,33 @@ export default function CustomErrorComponent() {

- 🚧 Yikes! That doesn't look good. + 🚧 Looks like something went wrong!

- That crashed Plane, pun intended. No worries, though. Our engineers have been notified. If you have more - details, please write to{" "} - - support@plane.so - {" "} - or on our{" "} - - Discord - - . + We track these errors automatically and working on getting things back up and running. If the problem + persists feel free to contact us. In the meantime, try refreshing.
-
+
+ {linkMap.map((link) => ( + + ))} +
+ +
-
From 85f23b450d8244eb9625ce734ed06152d51747fb Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:09:56 +0530 Subject: [PATCH 023/169] [WEB-4852] chore: views refactor (#7729) * chore: refactored view store and services * chore: removed unused import * chore: refactored update view component * fix: lint errors --- .../(projects)/workspace-views/header.tsx | 2 +- apps/web/ce/components/views/helper.tsx | 70 +++++- apps/web/ce/services/index.ts | 2 +- apps/web/ce/services/project/index.ts | 2 +- apps/web/ce/services/project/view.service.ts | 60 ------ apps/web/ce/services/workspace.service.ts | 24 --- apps/web/ce/store/global-view.store.ts | 1 + apps/web/ce/store/project-view.store.ts | 1 + .../core/components/views/quick-actions.tsx | 42 ++-- .../views/update-view-component.tsx | 44 ++-- .../workspace/views/quick-action.tsx | 43 ++-- apps/web/core/hooks/store/use-global-view.ts | 2 +- apps/web/core/hooks/store/use-project-view.ts | 2 +- .../web/core/local-db/utils/load-workspace.ts | 2 +- apps/web/core/services/view.service.ts | 5 +- apps/web/core/services/workspace.service.ts | 5 +- apps/web/core/store/global-view.store.ts | 114 ++-------- apps/web/core/store/project-view.store.ts | 203 +----------------- .../core/store/user/base-permissions.store.ts | 2 +- apps/web/ee/services/index.ts | 2 +- apps/web/ee/services/project/index.ts | 1 - 21 files changed, 156 insertions(+), 473 deletions(-) delete mode 100644 apps/web/ce/services/project/view.service.ts delete mode 100644 apps/web/ce/services/workspace.service.ts create mode 100644 apps/web/ce/store/global-view.store.ts create mode 100644 apps/web/ce/store/project-view.store.ts diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx index 1c50844c8..b4fa1bbd9 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/workspace-views/header.tsx @@ -191,7 +191,7 @@ export const GlobalIssuesHeader = observer(() => { - + {!isLocked ? ( <> <>; export const WorkspaceAdditionalLayouts = (props: TWorkspaceLayoutProps) => <>; + +export type TMenuItemsFactoryProps = { + isOwner: boolean; + isAdmin: boolean; + setDeleteViewModal: (open: boolean) => void; + setCreateUpdateViewModal: (open: boolean) => void; + handleOpenInNewTab: () => void; + handleCopyText: () => void; + isLocked: boolean; + workspaceSlug: string; + projectId?: string; + viewId: string; +}; + +export const useMenuItemsFactory = (props: TMenuItemsFactoryProps) => { + const { isOwner, isAdmin, setDeleteViewModal, setCreateUpdateViewModal, handleOpenInNewTab, handleCopyText } = props; + + const { t } = useTranslation(); + + const editMenuItem = () => ({ + key: "edit", + action: () => setCreateUpdateViewModal(true), + title: t("edit"), + icon: Pencil, + shouldRender: isOwner, + }); + + const openInNewTabMenuItem = () => ({ + key: "open-new-tab", + action: handleOpenInNewTab, + title: t("open_in_new_tab"), + icon: ExternalLink, + }); + + const copyLinkMenuItem = () => ({ + key: "copy-link", + action: handleCopyText, + title: t("copy_link"), + icon: Link, + }); + + const deleteMenuItem = () => ({ + key: "delete", + action: () => setDeleteViewModal(true), + title: t("delete"), + icon: Trash2, + shouldRender: isOwner || isAdmin, + }); + + return { + editMenuItem, + openInNewTabMenuItem, + copyLinkMenuItem, + deleteMenuItem, + }; +}; + +export const useViewMenuItems = (props: TMenuItemsFactoryProps): TContextMenuItem[] => { + const factory = useMenuItemsFactory(props); + + return [factory.editMenuItem(), factory.openInNewTabMenuItem(), factory.copyLinkMenuItem(), factory.deleteMenuItem()]; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const AdditionalHeaderItems = (view: IProjectView) => <>; diff --git a/apps/web/ce/services/index.ts b/apps/web/ce/services/index.ts index d0c059461..7e406b1b4 100644 --- a/apps/web/ce/services/index.ts +++ b/apps/web/ce/services/index.ts @@ -1,2 +1,2 @@ export * from "./project"; -export * from "./workspace.service"; +export * from "@/services/workspace.service"; diff --git a/apps/web/ce/services/project/index.ts b/apps/web/ce/services/project/index.ts index 15e12c5fd..8b75f6bf5 100644 --- a/apps/web/ce/services/project/index.ts +++ b/apps/web/ce/services/project/index.ts @@ -1,2 +1,2 @@ export * from "./estimate.service"; -export * from "./view.service"; +export * from "@/services/view.service"; diff --git a/apps/web/ce/services/project/view.service.ts b/apps/web/ce/services/project/view.service.ts deleted file mode 100644 index 5ab65a1b6..000000000 --- a/apps/web/ce/services/project/view.service.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { EViewAccess, TPublishViewSettings } from "@plane/types"; -import { ViewService as CoreViewService } from "@/services/view.service"; - -export class ViewService extends CoreViewService { - constructor() { - super(API_BASE_URL); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async updateViewAccess(workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async lockView(workspaceSlug: string, projectId: string, viewId: string) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async unLockView(workspaceSlug: string, projectId: string, viewId: string) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async getPublishDetails(workspaceSlug: string, projectId: string, viewId: string): Promise { - return Promise.resolve({}); - } - - async publishView( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - workspaceSlug: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - projectId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - viewId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - data: TPublishViewSettings - ): Promise { - return Promise.resolve(); - } - - async updatePublishedView( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - workspaceSlug: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - projectId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - viewId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - data: Partial - ): Promise { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async unPublishView(workspaceSlug: string, projectId: string, viewId: string): Promise { - return Promise.resolve(); - } -} diff --git a/apps/web/ce/services/workspace.service.ts b/apps/web/ce/services/workspace.service.ts deleted file mode 100644 index d1e175c81..000000000 --- a/apps/web/ce/services/workspace.service.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { API_BASE_URL } from "@plane/constants"; -import { EViewAccess } from "@plane/types"; -import { WorkspaceService as CoreWorkspaceService } from "@/services/workspace.service"; - -export class WorkspaceService extends CoreWorkspaceService { - constructor() { - super(API_BASE_URL); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async updateViewAccess(workspaceSlug: string, viewId: string, access: EViewAccess) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async lockView(workspaceSlug: string, viewId: string) { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async unLockView(workspaceSlug: string, viewId: string) { - return Promise.resolve(); - } -} diff --git a/apps/web/ce/store/global-view.store.ts b/apps/web/ce/store/global-view.store.ts new file mode 100644 index 000000000..f0d5cdfb4 --- /dev/null +++ b/apps/web/ce/store/global-view.store.ts @@ -0,0 +1 @@ +export * from "@/store/global-view.store"; diff --git a/apps/web/ce/store/project-view.store.ts b/apps/web/ce/store/project-view.store.ts new file mode 100644 index 000000000..41d7ba1ca --- /dev/null +++ b/apps/web/ce/store/project-view.store.ts @@ -0,0 +1 @@ +export * from "@/store/project-view.store"; diff --git a/apps/web/core/components/views/quick-actions.tsx b/apps/web/core/components/views/quick-actions.tsx index d46191a4f..9c5fbefcb 100644 --- a/apps/web/core/components/views/quick-actions.tsx +++ b/apps/web/core/components/views/quick-actions.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { ExternalLink, Link, Pencil, Trash2 } from "lucide-react"; // types import { EUserPermissions, EUserPermissionsLevel, PROJECT_VIEW_TRACKER_ELEMENTS } from "@plane/constants"; import { IProjectView } from "@plane/types"; @@ -13,6 +12,7 @@ import { copyUrlToClipboard, cn } from "@plane/utils"; import { captureClick } from "@/helpers/event-tracker.helper"; // hooks import { useUser, useUserPermissions } from "@/hooks/store/user"; +import { useViewMenuItems } from "@/plane-web/components/views/helper"; import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish"; // local imports import { DeleteProjectViewModal } from "./delete-view-modal"; @@ -54,34 +54,18 @@ export const ViewQuickActions: React.FC = observer((props) => { }); const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank"); - const MENU_ITEMS: TContextMenuItem[] = [ - { - key: "edit", - action: () => setCreateUpdateViewModal(true), - title: "Edit", - icon: Pencil, - shouldRender: isOwner, - }, - { - key: "open-new-tab", - action: handleOpenInNewTab, - title: "Open in new tab", - icon: ExternalLink, - }, - { - key: "copy-link", - action: handleCopyText, - title: "Copy link", - icon: Link, - }, - { - key: "delete", - action: () => setDeleteViewModal(true), - title: "Delete", - icon: Trash2, - shouldRender: isOwner || isAdmin, - }, - ]; + const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({ + isOwner, + isAdmin, + setDeleteViewModal, + setCreateUpdateViewModal, + handleOpenInNewTab, + handleCopyText, + isLocked: view.is_locked, + workspaceSlug, + projectId, + viewId: view.id, + }); if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu); diff --git a/apps/web/core/components/views/update-view-component.tsx b/apps/web/core/components/views/update-view-component.tsx index 9b199e0f5..54b6ae47d 100644 --- a/apps/web/core/components/views/update-view-component.tsx +++ b/apps/web/core/components/views/update-view-component.tsx @@ -1,6 +1,5 @@ import { SetStateAction, useEffect, useState } from "react"; import { Button } from "@plane/ui"; -import { LockedComponent } from "../icons/locked-component"; type Props = { isLocked: boolean; @@ -14,16 +13,8 @@ type Props = { }; export const UpdateViewComponent = (props: Props) => { - const { - isLocked, - areFiltersEqual, - isOwner, - isAuthorizedUser, - setIsModalOpen, - handleUpdateView, - lockedTooltipContent, - trackerElement, - } = props; + const { isLocked, areFiltersEqual, isOwner, isAuthorizedUser, setIsModalOpen, handleUpdateView, trackerElement } = + props; const [isUpdating, setIsUpdating] = useState(false); @@ -54,24 +45,19 @@ export const UpdateViewComponent = (props: Props) => { return (
- {isLocked ? ( - - ) : ( - !areFiltersEqual && - isAuthorizedUser && ( - <> - - {isOwner && <>{updateButton}} - - ) + {!isLocked && !areFiltersEqual && isAuthorizedUser && ( + <> + + {isOwner && <>{updateButton}} + )}
); diff --git a/apps/web/core/components/workspace/views/quick-action.tsx b/apps/web/core/components/workspace/views/quick-action.tsx index cbb67cb91..adb6b5e4b 100644 --- a/apps/web/core/components/workspace/views/quick-action.tsx +++ b/apps/web/core/components/workspace/views/quick-action.tsx @@ -2,10 +2,8 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel, GLOBAL_VIEW_TRACKER_ELEMENTS } from "@plane/constants"; -import { useTranslation } from "@plane/i18n"; import { IWorkspaceView } from "@plane/types"; import { CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui"; import { copyUrlToClipboard, cn } from "@plane/utils"; @@ -14,6 +12,7 @@ import { captureClick } from "@/helpers/event-tracker.helper"; // hooks import { useUser, useUserPermissions } from "@/hooks/store/user"; // local imports +import { useViewMenuItems } from "@/plane-web/components/views/helper"; import { DeleteGlobalViewModal } from "./delete-view-modal"; import { CreateUpdateWorkspaceViewModal } from "./modal"; @@ -30,7 +29,6 @@ export const WorkspaceViewQuickActions: React.FC = observer((props) => { // store hooks const { data } = useUser(); const { allowPermissions } = useUserPermissions(); - const { t } = useTranslation(); // auth const isOwner = view?.owned_by === data?.id; const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); @@ -46,34 +44,17 @@ export const WorkspaceViewQuickActions: React.FC = observer((props) => { }); const handleOpenInNewTab = () => window.open(`/${viewLink}`, "_blank"); - const MENU_ITEMS: TContextMenuItem[] = [ - { - key: "edit", - action: () => setUpdateViewModal(true), - title: t("edit"), - icon: Pencil, - shouldRender: isOwner, - }, - { - key: "open-new-tab", - action: handleOpenInNewTab, - title: t("open_in_new_tab"), - icon: ExternalLink, - }, - { - key: "copy-link", - action: handleCopyText, - title: t("copy_link"), - icon: LinkIcon, - }, - { - key: "delete", - action: () => setDeleteViewModal(true), - title: t("delete"), - icon: Trash2, - shouldRender: isOwner || isAdmin, - }, - ]; + const MENU_ITEMS: TContextMenuItem[] = useViewMenuItems({ + isOwner, + isAdmin, + setDeleteViewModal, + setCreateUpdateViewModal: setUpdateViewModal, + handleOpenInNewTab, + handleCopyText, + isLocked: view.is_locked, + workspaceSlug, + viewId: view.id, + }); return ( <> diff --git a/apps/web/core/hooks/store/use-global-view.ts b/apps/web/core/hooks/store/use-global-view.ts index 04e279cbb..92691be25 100644 --- a/apps/web/core/hooks/store/use-global-view.ts +++ b/apps/web/core/hooks/store/use-global-view.ts @@ -2,7 +2,7 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "@/lib/store-context"; // types -import type { IGlobalViewStore } from "@/store/global-view.store"; +import type { IGlobalViewStore } from "@/plane-web/store/global-view.store"; export const useGlobalView = (): IGlobalViewStore => { const context = useContext(StoreContext); diff --git a/apps/web/core/hooks/store/use-project-view.ts b/apps/web/core/hooks/store/use-project-view.ts index 493c81641..43e43d56d 100644 --- a/apps/web/core/hooks/store/use-project-view.ts +++ b/apps/web/core/hooks/store/use-project-view.ts @@ -2,7 +2,7 @@ import { useContext } from "react"; // mobx store import { StoreContext } from "@/lib/store-context"; // types -import type { IProjectViewStore } from "@/store/project-view.store"; +import type { IProjectViewStore } from "@/plane-web/store/project-view.store"; export const useProjectView = (): IProjectViewStore => { const context = useContext(StoreContext); diff --git a/apps/web/core/local-db/utils/load-workspace.ts b/apps/web/core/local-db/utils/load-workspace.ts index 509e093a9..92037d15d 100644 --- a/apps/web/core/local-db/utils/load-workspace.ts +++ b/apps/web/core/local-db/utils/load-workspace.ts @@ -99,7 +99,7 @@ export const getEstimatePoints = async (workspaceSlug: string) => { }; export const getMembers = async (workspaceSlug: string) => { - const workspaceService = new WorkspaceService(API_BASE_URL); + const workspaceService = new WorkspaceService(); const members = await workspaceService.fetchWorkspaceMembers(workspaceSlug); const objects = members.map((member: IWorkspaceMember) => member.member); return objects; diff --git a/apps/web/core/services/view.service.ts b/apps/web/core/services/view.service.ts index b05331775..57db5f82f 100644 --- a/apps/web/core/services/view.service.ts +++ b/apps/web/core/services/view.service.ts @@ -1,11 +1,12 @@ +import { API_BASE_URL } from "@plane/constants"; import { IProjectView } from "@plane/types"; import { APIService } from "@/services/api.service"; // types // helpers export class ViewService extends APIService { - constructor(baseUrl: string) { - super(baseUrl); + constructor() { + super(API_BASE_URL); } async createView(workspaceSlug: string, projectId: string, data: Partial): Promise { diff --git a/apps/web/core/services/workspace.service.ts b/apps/web/core/services/workspace.service.ts index 4bc042e1c..1c1dfeea6 100644 --- a/apps/web/core/services/workspace.service.ts +++ b/apps/web/core/services/workspace.service.ts @@ -1,3 +1,4 @@ +import { API_BASE_URL } from "@plane/constants"; import { IWorkspace, IWorkspaceMemberMe, @@ -23,8 +24,8 @@ import { import { APIService } from "@/services/api.service"; export class WorkspaceService extends APIService { - constructor(baseUrl: string) { - super(baseUrl); + constructor() { + super(API_BASE_URL); } async userWorkspaces(): Promise { diff --git a/apps/web/core/store/global-view.store.ts b/apps/web/core/store/global-view.store.ts index aa0503623..7ed35b307 100644 --- a/apps/web/core/store/global-view.store.ts +++ b/apps/web/core/store/global-view.store.ts @@ -5,7 +5,7 @@ import set from "lodash/set"; import { observable, action, makeObservable, runInAction, computed } from "mobx"; import { computedFn } from "mobx-utils"; import { EIssueFilterType } from "@plane/constants"; -import { EViewAccess, IIssueFilterOptions, IWorkspaceView } from "@plane/types"; +import { IIssueFilterOptions, IWorkspaceView } from "@plane/types"; // constants // services import { WorkspaceService } from "@/plane-web/services"; @@ -50,15 +50,18 @@ export class GlobalViewStore implements IGlobalViewStore { // actions fetchAllGlobalViews: action, fetchGlobalViewDetails: action, - createGlobalView: action, - updateGlobalView: action, deleteGlobalView: action, + updateGlobalView: action, + createGlobalView: action, }); // root store this.rootStore = _rootStore; // services this.workspaceService = new WorkspaceService(); + + this.createGlobalView = this.createGlobalView.bind(this); + this.updateGlobalView = this.updateGlobalView.bind(this); } /** @@ -130,18 +133,19 @@ export class GlobalViewStore implements IGlobalViewStore { * @param workspaceSlug * @param data */ - createGlobalView = async (workspaceSlug: string, data: Partial): Promise => { - const response = await this.workspaceService.createView(workspaceSlug, data); - runInAction(() => { - set(this.globalViewMap, response.id, response); - }); + async createGlobalView(workspaceSlug: string, data: Partial) { + try { + const response = await this.workspaceService.createView(workspaceSlug, data); + runInAction(() => { + set(this.globalViewMap, response.id, response); + }); - if (data.access === EViewAccess.PRIVATE) { - await this.updateViewAccess(workspaceSlug, response.id, EViewAccess.PRIVATE); + return response; + } catch (error) { + console.error(error); + throw error; } - - return response; - }; + } /** * @description update global view @@ -149,11 +153,11 @@ export class GlobalViewStore implements IGlobalViewStore { * @param viewId * @param data */ - updateGlobalView = async ( + async updateGlobalView( workspaceSlug: string, viewId: string, data: Partial - ): Promise => { + ): Promise { const currentViewData = this.getViewDetailsById(viewId) ? cloneDeep(this.getViewDetailsById(viewId)) : undefined; try { Object.keys(data).forEach((key) => { @@ -161,14 +165,7 @@ export class GlobalViewStore implements IGlobalViewStore { set(this.globalViewMap, [viewId, currentKey], data[currentKey]); }); - const promiseRequests = []; - promiseRequests.push(this.workspaceService.updateView(workspaceSlug, viewId, data)); - - if (data.access !== undefined && data.access !== currentViewData?.access) { - promiseRequests.push(this.updateViewAccess(workspaceSlug, viewId, data.access)); - } - - const [currentView] = await Promise.all(promiseRequests); + const currentView = await this.workspaceService.updateView(workspaceSlug, viewId, data); // applying the filters in the global view if (!isEqual(currentViewData?.filters || {}, currentView?.filters || {})) { @@ -205,7 +202,7 @@ export class GlobalViewStore implements IGlobalViewStore { if (currentViewData) set(this.globalViewMap, [viewId, currentKey], currentViewData[currentKey]); }); } - }; + } /** * @description delete global view @@ -218,73 +215,4 @@ export class GlobalViewStore implements IGlobalViewStore { delete this.globalViewMap[viewId]; }); }); - - /** Locks view - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - lockView = async (workspaceSlug: string, viewId: string) => { - try { - const currentView = this.getViewDetailsById(viewId); - if (currentView?.is_locked) return; - runInAction(() => { - set(this.globalViewMap, [viewId, "is_locked"], true); - }); - await this.workspaceService.lockView(workspaceSlug, viewId); - } catch (error) { - console.error("Failed to lock the view in view store", error); - runInAction(() => { - set(this.globalViewMap, [viewId, "is_locked"], false); - }); - } - }; - - /** - * unlocks View - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - unLockView = async (workspaceSlug: string, viewId: string) => { - try { - const currentView = this.getViewDetailsById(viewId); - if (!currentView?.is_locked) return; - runInAction(() => { - set(this.globalViewMap, [viewId, "is_locked"], false); - }); - await this.workspaceService.unLockView(workspaceSlug, viewId); - } catch (error) { - console.error("Failed to unlock view in view store", error); - runInAction(() => { - set(this.globalViewMap, [viewId, "is_locked"], true); - }); - } - }; - - /** - * Updates View access - * @param workspaceSlug - * @param projectId - * @param viewId - * @param access - * @returns - */ - updateViewAccess = async (workspaceSlug: string, viewId: string, access: EViewAccess) => { - const currentView = this.getViewDetailsById(viewId); - const currentAccess = currentView?.access; - try { - runInAction(() => { - set(this.globalViewMap, [viewId, "access"], access); - }); - await this.workspaceService.updateViewAccess(workspaceSlug, viewId, access); - } catch (error) { - console.error("Failed to update Access for view", error); - runInAction(() => { - set(this.globalViewMap, [viewId, "access"], currentAccess); - }); - } - }; } diff --git a/apps/web/core/store/project-view.store.ts b/apps/web/core/store/project-view.store.ts index 49c7a1122..732122471 100644 --- a/apps/web/core/store/project-view.store.ts +++ b/apps/web/core/store/project-view.store.ts @@ -2,7 +2,7 @@ import { set } from "lodash"; import { observable, action, makeObservable, runInAction, computed } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { EViewAccess, IProjectView, TPublishViewDetails, TPublishViewSettings, TViewFilters } from "@plane/types"; +import { IProjectView, TViewFilters } from "@plane/types"; // constants // helpers import { getValidatedViewFilters, getViewName, orderViews, shouldFilterView } from "@plane/utils"; @@ -41,25 +41,6 @@ export interface IProjectViewStore { // favorites actions addViewToFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise; removeViewFromFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise; - // publish - publishView: ( - workspaceSlug: string, - projectId: string, - viewId: string, - data: TPublishViewSettings - ) => Promise; - fetchPublishDetails: ( - workspaceSlug: string, - projectId: string, - viewId: string - ) => Promise; - updatePublishedView: ( - workspaceSlug: string, - projectId: string, - viewId: string, - data: Partial - ) => Promise; - unPublishView: (workspaceSlug: string, projectId: string, viewId: string) => Promise; } export class ProjectViewStore implements IProjectViewStore { @@ -101,6 +82,9 @@ export class ProjectViewStore implements IProjectViewStore { this.rootStore = _rootStore; // services this.viewService = new ViewService(); + + this.createView = this.createView.bind(this); + this.updateView = this.updateView.bind(this); } /** @@ -213,19 +197,15 @@ export class ProjectViewStore implements IProjectViewStore { * @param data * @returns Promise */ - createView = async (workspaceSlug: string, projectId: string, data: Partial): Promise => { + async createView(workspaceSlug: string, projectId: string, data: Partial): Promise { const response = await this.viewService.createView(workspaceSlug, projectId, getValidatedViewFilters(data)); runInAction(() => { set(this.viewMap, [response.id], response); }); - if (data.access === EViewAccess.PRIVATE) { - await this.updateViewAccess(workspaceSlug, projectId, response.id, EViewAccess.PRIVATE); - } - return response; - }; + } /** * Updates a view details of specific view and updates it in the store @@ -235,29 +215,22 @@ export class ProjectViewStore implements IProjectViewStore { * @param data * @returns Promise */ - updateView = async ( + async updateView( workspaceSlug: string, projectId: string, viewId: string, data: Partial - ): Promise => { + ): Promise { const currentView = this.getViewById(viewId); - const promiseRequests = []; - promiseRequests.push(this.viewService.patchView(workspaceSlug, projectId, viewId, data)); - runInAction(() => { set(this.viewMap, [viewId], { ...currentView, ...data }); }); - if (data.access !== undefined && data.access !== currentView.access) { - promiseRequests.push(this.updateViewAccess(workspaceSlug, projectId, viewId, data.access)); - } - - const [response] = await Promise.all(promiseRequests); + const response = await this.viewService.patchView(workspaceSlug, projectId, viewId, data); return response; - }; + } /** * Deletes a view and removes it from the viewMap object @@ -275,75 +248,6 @@ export class ProjectViewStore implements IProjectViewStore { }); }; - /** Locks view - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - lockView = async (workspaceSlug: string, projectId: string, viewId: string) => { - try { - const currentView = this.getViewById(viewId); - if (currentView?.is_locked) return; - runInAction(() => { - set(this.viewMap, [viewId, "is_locked"], true); - }); - await this.viewService.lockView(workspaceSlug, projectId, viewId); - } catch (error) { - console.error("Failed to lock the view in view store", error); - runInAction(() => { - set(this.viewMap, [viewId, "is_locked"], false); - }); - } - }; - - /** - * unlocks View - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - unLockView = async (workspaceSlug: string, projectId: string, viewId: string) => { - try { - const currentView = this.getViewById(viewId); - if (!currentView?.is_locked) return; - runInAction(() => { - set(this.viewMap, [viewId, "is_locked"], false); - }); - await this.viewService.unLockView(workspaceSlug, projectId, viewId); - } catch (error) { - console.error("Failed to unlock view in view store", error); - runInAction(() => { - set(this.viewMap, [viewId, "is_locked"], true); - }); - } - }; - - /** - * Updates View access - * @param workspaceSlug - * @param projectId - * @param viewId - * @param access - * @returns - */ - updateViewAccess = async (workspaceSlug: string, projectId: string, viewId: string, access: EViewAccess) => { - const currentView = this.getViewById(viewId); - const currentAccess = currentView?.access; - try { - runInAction(() => { - set(this.viewMap, [viewId, "access"], access); - }); - await this.viewService.updateViewAccess(workspaceSlug, projectId, viewId, access); - } catch (error) { - console.error("Failed to update Access for view", error); - runInAction(() => { - set(this.viewMap, [viewId, "access"], currentAccess); - }); - } - }; - /** * Adds a view to favorites * @param workspaceSlug @@ -394,91 +298,4 @@ export class ProjectViewStore implements IProjectViewStore { }); } }; - - /** - * Publishes View to the Public - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - publishView = async (workspaceSlug: string, projectId: string, viewId: string, data: TPublishViewSettings) => { - try { - const response = (await this.viewService.publishView( - workspaceSlug, - projectId, - viewId, - data - )) as TPublishViewDetails; - runInAction(() => { - set(this.viewMap, [viewId, "anchor"], response?.anchor); - }); - - return response; - } catch (error) { - console.error("Failed to publish view", error); - } - }; - - /** - * fetches Published Details - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - fetchPublishDetails = async (workspaceSlug: string, projectId: string, viewId: string) => { - try { - const response = (await this.viewService.getPublishDetails( - workspaceSlug, - projectId, - viewId - )) as TPublishViewDetails; - runInAction(() => { - set(this.viewMap, [viewId, "anchor"], response?.anchor); - }); - return response; - } catch (error) { - console.error("Failed to fetch published view details", error); - } - }; - - /** - * updates already published view - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - updatePublishedView = async ( - workspaceSlug: string, - projectId: string, - viewId: string, - data: Partial - ) => { - try { - return await this.viewService.updatePublishedView(workspaceSlug, projectId, viewId, data); - } catch (error) { - console.error("Failed to update published view details", error); - } - }; - - /** - * un publishes the view - * @param workspaceSlug - * @param projectId - * @param viewId - * @returns - */ - unPublishView = async (workspaceSlug: string, projectId: string, viewId: string) => { - try { - const response = await this.viewService.unPublishView(workspaceSlug, projectId, viewId); - runInAction(() => { - set(this.viewMap, [viewId, "anchor"], null); - }); - return response; - } catch (error) { - console.error("Failed to unPublish view", error); - } - }; } diff --git a/apps/web/core/store/user/base-permissions.store.ts b/apps/web/core/store/user/base-permissions.store.ts index 4d6f13d26..019c46219 100644 --- a/apps/web/core/store/user/base-permissions.store.ts +++ b/apps/web/core/store/user/base-permissions.store.ts @@ -18,7 +18,7 @@ import { TProjectMembership, } from "@plane/types"; // plane web imports -import { WorkspaceService } from "@/plane-web/services/workspace.service"; +import { WorkspaceService } from "@/plane-web/services"; import type { RootStore } from "@/plane-web/store/root.store"; // services import projectMemberService from "@/services/project/project-member.service"; diff --git a/apps/web/ee/services/index.ts b/apps/web/ee/services/index.ts index 6f175ecf7..7e406b1b4 100644 --- a/apps/web/ee/services/index.ts +++ b/apps/web/ee/services/index.ts @@ -1,2 +1,2 @@ export * from "./project"; -export * from "ce/services/workspace.service"; +export * from "@/services/workspace.service"; diff --git a/apps/web/ee/services/project/index.ts b/apps/web/ee/services/project/index.ts index b2cec5064..29b17e55d 100644 --- a/apps/web/ee/services/project/index.ts +++ b/apps/web/ee/services/project/index.ts @@ -1,2 +1 @@ export * from "./estimate.service"; -export * from "ce/services/project/view.service"; From 8ee665f49149105d68028541823af4a4f7a7be03 Mon Sep 17 00:00:00 2001 From: Sangeetha Date: Thu, 11 Sep 2025 17:52:51 +0530 Subject: [PATCH 024/169] [WEB-4875] fix: unsubscribed work items on workspace subscribed work item filter #7775 --- apps/api/plane/utils/issue_filters.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/plane/utils/issue_filters.py b/apps/api/plane/utils/issue_filters.py index 1c9619890..9136367b6 100644 --- a/apps/api/plane/utils/issue_filters.py +++ b/apps/api/plane/utils/issue_filters.py @@ -476,6 +476,8 @@ def filter_subscribed_issues(params, issue_filter, method, prefix=""): issue_filter[f"{prefix}issue_subscribers__subscriber_id__in"] = params.get( "subscriber" ) + issue_filter[f"{prefix}issue_subscribers__deleted_at__isnull"] = True + return issue_filter From 76a0b38dd14fd8b0ba0b361192978ed38c0c36ea Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Thu, 11 Sep 2025 18:56:04 +0530 Subject: [PATCH 025/169] [WEB-4890]fix: dropdown width #7778 --- .../core/components/integration/github/single-user-select.tsx | 1 - apps/web/core/components/integration/jira/give-details.tsx | 1 - apps/web/core/components/integration/jira/import-users.tsx | 1 - apps/web/core/components/onboarding/create-workspace.tsx | 1 - .../core/components/profile/preferences/language-timezone.tsx | 1 - .../core/components/project/send-project-invitation-modal.tsx | 1 - apps/web/core/components/project/settings/member-columns.tsx | 1 - .../sidebar/notification-card/options/snooze/modal.tsx | 1 - apps/web/core/components/workspace/create-workspace-form.tsx | 1 - apps/web/core/components/workspace/invite-modal/fields.tsx | 1 - apps/web/core/components/workspace/settings/member-columns.tsx | 1 - .../web/core/components/workspace/settings/workspace-details.tsx | 1 - 12 files changed, 12 deletions(-) diff --git a/apps/web/core/components/integration/github/single-user-select.tsx b/apps/web/core/components/integration/github/single-user-select.tsx index 114535518..d8332dfc1 100644 --- a/apps/web/core/components/integration/github/single-user-select.tsx +++ b/apps/web/core/components/integration/github/single-user-select.tsx @@ -92,7 +92,6 @@ export const SingleUserSelect: React.FC = ({ collaborator, index, users, newUsers[index].email = ""; setUsers(newUsers); }} - optionsClassName="w-full" noChevron > {importOptions.map((option) => ( diff --git a/apps/web/core/components/integration/jira/give-details.tsx b/apps/web/core/components/integration/jira/give-details.tsx index 5f7e699d8..293a5c871 100644 --- a/apps/web/core/components/integration/jira/give-details.tsx +++ b/apps/web/core/components/integration/jira/give-details.tsx @@ -181,7 +181,6 @@ export const JiraGetImportDetail: React.FC = observer(() => { )} } - optionsClassName="w-full" > {workspaceProjectIds && workspaceProjectIds.length > 0 ? ( workspaceProjectIds.map((projectId) => { diff --git a/apps/web/core/components/integration/jira/import-users.tsx b/apps/web/core/components/integration/jira/import-users.tsx index 2adaf3ba3..f14be78d6 100644 --- a/apps/web/core/components/integration/jira/import-users.tsx +++ b/apps/web/core/components/integration/jira/import-users.tsx @@ -96,7 +96,6 @@ export const JiraImportUsers: FC = () => { input value={value} onChange={onChange} - optionsClassName="w-full" label={{Boolean(value) ? value : ("Ignore" as any)}} > Invite by email diff --git a/apps/web/core/components/onboarding/create-workspace.tsx b/apps/web/core/components/onboarding/create-workspace.tsx index 3c1b8d167..b59e44767 100644 --- a/apps/web/core/components/onboarding/create-workspace.tsx +++ b/apps/web/core/components/onboarding/create-workspace.tsx @@ -268,7 +268,6 @@ export const CreateWorkspace: React.FC = observer((props) => { } buttonClassName="!border-[0.5px] !border-custom-border-300 !shadow-none !rounded-md" input - optionsClassName="w-full" > {ORGANIZATION_SIZE.map((item) => ( diff --git a/apps/web/core/components/profile/preferences/language-timezone.tsx b/apps/web/core/components/profile/preferences/language-timezone.tsx index 94d9c26f5..7ce1ce067 100644 --- a/apps/web/core/components/profile/preferences/language-timezone.tsx +++ b/apps/web/core/components/profile/preferences/language-timezone.tsx @@ -126,7 +126,6 @@ export const LanguageTimezone = observer(() => { onChange={handleLanguageChange} buttonClassName={"border-none"} className="rounded-md border !border-custom-border-200" - optionsClassName="w-full" input > {SUPPORTED_LANGUAGES.map((item) => ( diff --git a/apps/web/core/components/project/send-project-invitation-modal.tsx b/apps/web/core/components/project/send-project-invitation-modal.tsx index bd5334035..bb0b16819 100644 --- a/apps/web/core/components/project/send-project-invitation-modal.tsx +++ b/apps/web/core/components/project/send-project-invitation-modal.tsx @@ -293,7 +293,6 @@ export const SendProjectInvitationModal: React.FC = observer((props) => {
} input - optionsClassName="w-full" > {Object.entries( checkCurrentOptionWorkspaceRole(watch(`members.${index}.member_id`)) diff --git a/apps/web/core/components/project/settings/member-columns.tsx b/apps/web/core/components/project/settings/member-columns.tsx index 37f8a0cbe..1c31ae178 100644 --- a/apps/web/core/components/project/settings/member-columns.tsx +++ b/apps/web/core/components/project/settings/member-columns.tsx @@ -168,7 +168,6 @@ export const AccountTypeColumn: React.FC = observer((props) => } buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`} className="rounded-md p-0 w-32" - optionsClassName="w-full" input > {Object.entries(checkCurrentOptionWorkspaceRole(rowData.member.id)).map(([key, label]) => ( diff --git a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx index 389643157..24d7d09c6 100644 --- a/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx +++ b/apps/web/core/components/workspace-notifications/sidebar/notification-card/options/snooze/modal.tsx @@ -195,7 +195,6 @@ export const NotificationSnoozeModal: FC = (props) => )}
} - optionsClassName="w-full" input >
diff --git a/apps/web/core/components/workspace/create-workspace-form.tsx b/apps/web/core/components/workspace/create-workspace-form.tsx index b1e2fac5c..a36d8cc88 100644 --- a/apps/web/core/components/workspace/create-workspace-form.tsx +++ b/apps/web/core/components/workspace/create-workspace-form.tsx @@ -228,7 +228,6 @@ export const CreateWorkspaceForm: FC = observer((props) => { } buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" input - optionsClassName="w-full" > {ORGANIZATION_SIZE.map((item) => ( diff --git a/apps/web/core/components/workspace/invite-modal/fields.tsx b/apps/web/core/components/workspace/invite-modal/fields.tsx index e74e8104c..0d0337e7b 100644 --- a/apps/web/core/components/workspace/invite-modal/fields.tsx +++ b/apps/web/core/components/workspace/invite-modal/fields.tsx @@ -83,7 +83,6 @@ export const InvitationFields = observer((props: TInvitationFieldsProps) => { value={value} label={{ROLE[value]}} onChange={onChange} - optionsClassName="w-full" className="flex-grow w-24" input > diff --git a/apps/web/core/components/workspace/settings/member-columns.tsx b/apps/web/core/components/workspace/settings/member-columns.tsx index 60f36bff5..56275f249 100644 --- a/apps/web/core/components/workspace/settings/member-columns.tsx +++ b/apps/web/core/components/workspace/settings/member-columns.tsx @@ -146,7 +146,6 @@ export const AccountTypeColumn: React.FC = observer((props) => } buttonClassName={`!px-0 !justify-start hover:bg-custom-background-100 ${errors.role ? "border-red-500" : "border-none"}`} className="rounded-md p-0 w-32" - optionsClassName="w-full" input > {Object.keys(ROLE).map((item) => ( diff --git a/apps/web/core/components/workspace/settings/workspace-details.tsx b/apps/web/core/components/workspace/settings/workspace-details.tsx index a54dfca87..8073601b0 100644 --- a/apps/web/core/components/workspace/settings/workspace-details.tsx +++ b/apps/web/core/components/workspace/settings/workspace-details.tsx @@ -241,7 +241,6 @@ export const WorkspaceDetails: FC = observer(() => { ORGANIZATION_SIZE.find((c) => c === value) ?? t("workspace_settings.settings.general.errors.company_size.select_a_range") } - optionsClassName="w-full" buttonClassName="!border-[0.5px] !border-custom-border-200 !shadow-none" input disabled={!isAdmin} From b60f12a88e468d509fdc1c209fb11c2c3411689c Mon Sep 17 00:00:00 2001 From: Dheeraj Kumar Ketireddy Date: Thu, 11 Sep 2025 18:56:32 +0530 Subject: [PATCH 026/169] [WEB-4861] fix: update redirection path in MagicSignInEndpoint to home page (#7774) * fix: update redirection path in MagicSignInEndpoint to home page * Use / for clarity --- apps/api/plane/authentication/views/app/magic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py index 4b1bdb02e..61591439d 100644 --- a/apps/api/plane/authentication/views/app/magic.py +++ b/apps/api/plane/authentication/views/app/magic.py @@ -107,7 +107,8 @@ class MagicSignInEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_app=True) if user.is_password_autoset and profile.is_onboarded: - path = "accounts/set-password" + # Redirect to the home page + path = "/" else: # Get the redirection path path = ( From 9ffc30f7b108f7148360e491d251432f7cae2bfd Mon Sep 17 00:00:00 2001 From: Jayash Tripathy <76092296+JayashTripathy@users.noreply.github.com> Date: Fri, 12 Sep 2025 00:04:16 +0530 Subject: [PATCH 027/169] [WEB-4889] refactor: add fill in bar chart bar stroke (#7776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ♻️ refactor: add fill in barchart bar stroke * ♻️ refactor: added fill in the circle --- packages/propel/src/charts/bar-chart/bar.tsx | 14 ++++---------- packages/propel/src/charts/bar-chart/root.tsx | 3 ++- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/propel/src/charts/bar-chart/bar.tsx b/packages/propel/src/charts/bar-chart/bar.tsx index 558e73ad6..55ddd7934 100644 --- a/packages/propel/src/charts/bar-chart/bar.tsx +++ b/packages/propel/src/charts/bar-chart/bar.tsx @@ -23,7 +23,7 @@ interface TShapeProps { } interface TBarProps extends TShapeProps { - fill: string | ((payload: any) => string); + fill: string; stackKeys: string[]; textClassName?: string; showPercentage?: boolean; @@ -108,7 +108,7 @@ const CustomBar = React.memo((props: TBarProps) => { {showText && ( @@ -130,18 +130,12 @@ const CustomBarLollipop = React.memo((props: TBarProps) => { y1={y + height} x2={x + width / 2} y2={y} - stroke={typeof fill === "function" ? fill(payload) : fill} + stroke={fill} strokeWidth={DEFAULT_LOLLIPOP_LINE_WIDTH} strokeLinecap="round" strokeDasharray={dotted ? "4 4" : "0"} /> - + {showPercentage && ( )} diff --git a/packages/propel/src/charts/bar-chart/root.tsx b/packages/propel/src/charts/bar-chart/root.tsx index 23a211432..a0ea10d3c 100644 --- a/packages/propel/src/charts/bar-chart/root.tsx +++ b/packages/propel/src/charts/bar-chart/root.tsx @@ -111,9 +111,10 @@ export const BarChart = React.memo((props: T className="[&_path]:transition-opacity [&_path]:duration-200" onMouseEnter={() => setActiveBar(bar.key)} onMouseLeave={() => setActiveBar(null)} + fill={getBarColor(data, bar.key)} /> )), - [activeLegend, stackKeys, bars] + [activeLegend, stackKeys, bars, getBarColor, data] ); return ( From c3e7cfd16ba4506dc56db780bc05fc526f119481 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Fri, 12 Sep 2025 13:01:03 +0530 Subject: [PATCH 028/169] [WEB-4723] fix: disable project features on project create (#7625) * fix: disbale project features on project create * Implement migration 0105 to alter project cycle view fields to Boolean with default values * Add project view settings in workspace seed task * Add is_current_version_deprecated field to Instance model Index user_id field in Session model --------- Co-authored-by: pablohashescobar --- apps/api/plane/app/serializers/project.py | 1 - apps/api/plane/bgtasks/workspace_seed_task.py | 4 +++ .../0105_alter_project_cycle_view_and_more.py | 33 +++++++++++++++++++ apps/api/plane/db/models/project.py | 6 ++-- apps/api/plane/db/models/session.py | 2 +- ..._instance_is_current_version_deprecated.py | 18 ++++++++++ apps/api/plane/license/models/instance.py | 2 ++ 7 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py create mode 100644 apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py diff --git a/apps/api/plane/app/serializers/project.py b/apps/api/plane/app/serializers/project.py index 1d1ea927d..76f76d0e0 100644 --- a/apps/api/plane/app/serializers/project.py +++ b/apps/api/plane/app/serializers/project.py @@ -15,7 +15,6 @@ from plane.db.models import ( ) from plane.utils.content_validator import ( validate_html_content, - validate_binary_data, ) diff --git a/apps/api/plane/bgtasks/workspace_seed_task.py b/apps/api/plane/bgtasks/workspace_seed_task.py index c2fbfb065..6fae83e41 100644 --- a/apps/api/plane/bgtasks/workspace_seed_task.py +++ b/apps/api/plane/bgtasks/workspace_seed_task.py @@ -92,6 +92,10 @@ def create_project_and_member(workspace: Workspace) -> Dict[int, uuid.UUID]: name=workspace.name, # Use workspace name identifier=project_identifier, created_by_id=workspace.created_by_id, + # Enable all views in seed data + cycle_view=True, + module_view=True, + issue_views_view=True, ) # Create project members diff --git a/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py new file mode 100644 index 000000000..ef477fbc1 --- /dev/null +++ b/apps/api/plane/db/migrations/0105_alter_project_cycle_view_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.22 on 2025-09-10 09:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0104_cycleuserproperties_rich_filters_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="project", + name="cycle_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="issue_views_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="project", + name="module_view", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="session", + name="user_id", + field=models.CharField(db_index=True, max_length=50, null=True), + ), + ] diff --git a/apps/api/plane/db/models/project.py b/apps/api/plane/db/models/project.py index af576be6e..81a84f1ac 100644 --- a/apps/api/plane/db/models/project.py +++ b/apps/api/plane/db/models/project.py @@ -95,9 +95,9 @@ class Project(BaseModel): ) emoji = models.CharField(max_length=255, null=True, blank=True) icon_prop = models.JSONField(null=True) - module_view = models.BooleanField(default=True) - cycle_view = models.BooleanField(default=True) - issue_views_view = models.BooleanField(default=True) + module_view = models.BooleanField(default=False) + cycle_view = models.BooleanField(default=False) + issue_views_view = models.BooleanField(default=False) page_view = models.BooleanField(default=True) intake_view = models.BooleanField(default=False) is_time_tracking_enabled = models.BooleanField(default=False) diff --git a/apps/api/plane/db/models/session.py b/apps/api/plane/db/models/session.py index 3b35ebc70..e884498bf 100644 --- a/apps/api/plane/db/models/session.py +++ b/apps/api/plane/db/models/session.py @@ -13,7 +13,7 @@ VALID_KEY_CHARS = string.ascii_lowercase + string.digits class Session(AbstractBaseSession): device_info = models.JSONField(null=True, blank=True, default=None) session_key = models.CharField(max_length=128, primary_key=True) - user_id = models.CharField(null=True, max_length=50) + user_id = models.CharField(null=True, max_length=50, db_index=True) @classmethod def get_session_store_class(cls): diff --git a/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py new file mode 100644 index 000000000..f8c2c30bc --- /dev/null +++ b/apps/api/plane/license/migrations/0006_instance_is_current_version_deprecated.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.22 on 2025-09-11 08:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("license", "0005_rename_product_instance_edition_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="instance", + name="is_current_version_deprecated", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/api/plane/license/models/instance.py b/apps/api/plane/license/models/instance.py index 113b59ce4..0e596d8de 100644 --- a/apps/api/plane/license/models/instance.py +++ b/apps/api/plane/license/models/instance.py @@ -38,6 +38,8 @@ class Instance(BaseModel): is_signup_screen_visited = models.BooleanField(default=False) is_verified = models.BooleanField(default=False) is_test = models.BooleanField(default=False) + # field for validating if the current version is deprecated + is_current_version_deprecated = models.BooleanField(default=False) class Meta: verbose_name = "Instance" From 116c8118ab8942a39609d998972e05081cfc8a68 Mon Sep 17 00:00:00 2001 From: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Date: Mon, 15 Sep 2025 18:36:00 +0530 Subject: [PATCH 029/169] [WIKI-659] chore: added issue relation and page sort order (#7784) * chore: added issue relation and page sort order * feat: add ProjectWebhook model to manage webhooks associated with projects * chore: updated the migration file * chore: added migration * chore: reverted the page base code * chore: added a variable for sort order in pages --------- Co-authored-by: pablohashescobar --- .../db/migrations/0106_auto_20250912_0845.py | 152 ++++++++++++++++++ apps/api/plane/db/models/issue.py | 2 +- apps/api/plane/db/models/page.py | 3 +- apps/api/plane/db/models/webhook.py | 23 ++- apps/api/plane/utils/issue_relation_mapper.py | 6 +- 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 apps/api/plane/db/migrations/0106_auto_20250912_0845.py diff --git a/apps/api/plane/db/migrations/0106_auto_20250912_0845.py b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py new file mode 100644 index 000000000..8a0813fc1 --- /dev/null +++ b/apps/api/plane/db/migrations/0106_auto_20250912_0845.py @@ -0,0 +1,152 @@ +# Generated by Django 4.2.22 on 2025-09-12 08:45 +import uuid +import django +from django.conf import settings +from django.db import migrations, models + + +def set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + + batch_size = 3000 + sort_order = 100 + + # Get page IDs ordered by name using the historical model + # This should include all pages regardless of soft-delete status + page_ids = list(Page.objects.all().order_by("name").values_list("id", flat=True)) + + updated_pages = [] + for page_id in page_ids: + # Create page instance with minimal data + updated_pages.append(Page(id=page_id, sort_order=sort_order)) + sort_order += 100 + + # Bulk update when batch is full + if len(updated_pages) >= batch_size: + Page.objects.bulk_update( + updated_pages, ["sort_order"], batch_size=batch_size + ) + updated_pages = [] + + # Update remaining pages + if updated_pages: + Page.objects.bulk_update(updated_pages, ["sort_order"], batch_size=batch_size) + + +def reverse_set_page_sort_order(apps, schema_editor): + Page = apps.get_model("db", "Page") + Page.objects.update(sort_order=Page.DEFAULT_SORT_ORDER) + + +class Migration(migrations.Migration): + + dependencies = [ + ("db", "0105_alter_project_cycle_view_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectWebhook", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True, verbose_name="Created At"), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "deleted_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Deleted At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_%(class)s", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "webhook", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="project_webhooks", + to="db.webhook", + ), + ), + ( + "workspace", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_%(class)s", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "Project Webhook", + "verbose_name_plural": "Project Webhooks", + "db_table": "project_webhooks", + "ordering": ("-created_at",), + }, + ), + migrations.AddConstraint( + model_name="projectwebhook", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("project", "webhook"), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ), + ), + migrations.AlterUniqueTogether( + name="projectwebhook", + unique_together={("project", "webhook", "deleted_at")}, + ), + migrations.AlterField( + model_name="issuerelation", + name="relation_type", + field=models.CharField( + default="blocked_by", max_length=20, verbose_name="Issue Relation Type" + ), + ), + migrations.RunPython( + set_page_sort_order, reverse_code=reverse_set_page_sort_order + ), + ] diff --git a/apps/api/plane/db/models/issue.py b/apps/api/plane/db/models/issue.py index b8efd6ae7..2baf8ace1 100644 --- a/apps/api/plane/db/models/issue.py +++ b/apps/api/plane/db/models/issue.py @@ -284,6 +284,7 @@ class IssueRelationChoices(models.TextChoices): BLOCKED_BY = "blocked_by", "Blocked By" START_BEFORE = "start_before", "Start Before" FINISH_BEFORE = "finish_before", "Finish Before" + IMPLEMENTED_BY = "implemented_by", "Implemented By" class IssueRelation(ProjectBaseModel): @@ -295,7 +296,6 @@ class IssueRelation(ProjectBaseModel): ) relation_type = models.CharField( max_length=20, - choices=IssueRelationChoices.choices, verbose_name="Issue Relation Type", default=IssueRelationChoices.BLOCKED_BY, ) diff --git a/apps/api/plane/db/models/page.py b/apps/api/plane/db/models/page.py index 71fc49c45..4d465cd58 100644 --- a/apps/api/plane/db/models/page.py +++ b/apps/api/plane/db/models/page.py @@ -19,6 +19,7 @@ def get_view_props(): class Page(BaseModel): PRIVATE_ACCESS = 1 PUBLIC_ACCESS = 0 + DEFAULT_SORT_ORDER = 65535 ACCESS_CHOICES = ((PRIVATE_ACCESS, "Private"), (PUBLIC_ACCESS, "Public")) @@ -57,7 +58,7 @@ class Page(BaseModel): ) moved_to_page = models.UUIDField(null=True, blank=True) moved_to_project = models.UUIDField(null=True, blank=True) - sort_order = models.FloatField(default=65535) + sort_order = models.FloatField(default=DEFAULT_SORT_ORDER) external_id = models.CharField(max_length=255, null=True, blank=True) external_source = models.CharField(max_length=255, null=True, blank=True) diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py index b1428523b..189ccb279 100644 --- a/apps/api/plane/db/models/webhook.py +++ b/apps/api/plane/db/models/webhook.py @@ -7,7 +7,7 @@ from django.db import models from django.core.exceptions import ValidationError # Module imports -from plane.db.models import BaseModel +from plane.db.models import BaseModel, ProjectBaseModel def generate_token(): @@ -90,3 +90,24 @@ class WebhookLog(BaseModel): def __str__(self): return f"{self.event_type} {str(self.webhook)}" + + + +class ProjectWebhook(ProjectBaseModel): + webhook = models.ForeignKey( + "db.Webhook", on_delete=models.CASCADE, related_name="project_webhooks" + ) + + class Meta: + unique_together = ["project", "webhook", "deleted_at"] + constraints = [ + models.UniqueConstraint( + fields=["project", "webhook"], + condition=models.Q(deleted_at__isnull=True), + name="project_webhook_unique_project_webhook_when_deleted_at_null", + ) + ] + verbose_name = "Project Webhook" + verbose_name_plural = "Project Webhooks" + db_table = "project_webhooks" + ordering = ("-created_at",) \ No newline at end of file diff --git a/apps/api/plane/utils/issue_relation_mapper.py b/apps/api/plane/utils/issue_relation_mapper.py index f3188eb26..19d65c111 100644 --- a/apps/api/plane/utils/issue_relation_mapper.py +++ b/apps/api/plane/utils/issue_relation_mapper.py @@ -6,12 +6,14 @@ def get_inverse_relation(relation_type): "blocking": "blocked_by", "start_before": "start_after", "finish_before": "finish_after", + "implemented_by": "implements", + "implements": "implemented_by", } return relation_mapping.get(relation_type, relation_type) def get_actual_relation(relation_type): - # This function is used to get the actual relation type which is store in database + # This function is used to get the actual relation type which is stored in database actual_relation = { "start_after": "start_before", "finish_after": "finish_before", @@ -19,6 +21,8 @@ def get_actual_relation(relation_type): "blocked_by": "blocked_by", "start_before": "start_before", "finish_before": "finish_before", + "implemented_by": "implemented_by", + "implements": "implemented_by", } return actual_relation.get(relation_type, relation_type) From 345dfce25d09fe143c865f42cf8b953ad7a3e340 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 16 Sep 2025 00:01:06 +0530 Subject: [PATCH 030/169] [WEB-4900]: validated authentication redirection paths (#7798) * refactor: replace validate_next_path with get_safe_redirect_url for safer URL redirection across authentication views * refactor: use get_safe_redirect_url for improved URL redirection in SignInAuthSpaceEndpoint and SignUpAuthSpaceEndpoint * fix: redirect paths --------- Co-authored-by: sriram veeraghanta --- .../plane/authentication/views/app/email.py | 105 +++++++++--------- .../plane/authentication/views/app/github.py | 57 ++++++---- .../plane/authentication/views/app/gitlab.py | 55 +++++---- .../plane/authentication/views/app/google.py | 55 +++++---- .../plane/authentication/views/app/magic.py | 67 ++++++----- .../plane/authentication/views/space/email.py | 97 ++++++++++------ .../authentication/views/space/github.py | 52 +++++---- .../authentication/views/space/gitlab.py | 51 +++++---- .../authentication/views/space/google.py | 51 +++++---- .../plane/authentication/views/space/magic.py | 64 ++++++----- .../authentication/views/space/signout.py | 12 +- apps/api/plane/license/api/views/admin.py | 12 +- apps/api/plane/utils/path_validator.py | 75 +++++++++++++ 13 files changed, 477 insertions(+), 276 deletions(-) diff --git a/apps/api/plane/authentication/views/app/email.py b/apps/api/plane/authentication/views/app/email.py index 0ac51265e..417e7b40e 100644 --- a/apps/api/plane/authentication/views/app/email.py +++ b/apps/api/plane/authentication/views/app/email.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -19,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignInAuthEndpoint(View): @@ -34,11 +31,11 @@ class SignInAuthEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) # Base URL join - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -58,10 +55,10 @@ class SignInAuthEndpoint(View): ) params = exc.get_error_dict() # Next path - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -76,10 +73,10 @@ class SignInAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -92,10 +89,10 @@ class SignInAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -112,19 +109,23 @@ class SignInAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + # Get the safe redirect URL + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -141,10 +142,10 @@ class SignUpAuthEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -161,10 +162,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) # Validate the email @@ -179,10 +180,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -197,10 +198,10 @@ class SignUpAuthEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -217,17 +218,21 @@ class SignUpAuthEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py index 18cbe7b6c..425f12549 100644 --- a/apps/api/plane/authentication/views/app/github.py +++ b/apps/api/plane/authentication/views/app/github.py @@ -1,5 +1,5 @@ +# Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -16,8 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path - +from plane.utils.path_validator import get_safe_redirect_url class GitHubOauthInitiateEndpoint(View): def get(self, request): @@ -35,10 +34,10 @@ class GitHubOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) try: @@ -49,10 +48,10 @@ class GitHubOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -70,9 +69,11 @@ class GitHubCallbackEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -81,9 +82,11 @@ class GitHubCallbackEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -93,17 +96,23 @@ class GitHubCallbackEndpoint(View): user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_app=True) - # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin(base_host, path) + + # Get the safe redirect URL + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py index d6479e954..e22911d32 100644 --- a/apps/api/plane/authentication/views/app/gitlab.py +++ b/apps/api/plane/authentication/views/app/gitlab.py @@ -1,5 +1,5 @@ +# Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -16,7 +16,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitLabOauthInitiateEndpoint(View): @@ -25,7 +25,7 @@ class GitLabOauthInitiateEndpoint(View): request.session["host"] = base_host(request=request, is_app=True) next_path = request.GET.get("next_path") if next_path: - request.session["next_path"] = str(validate_next_path(next_path)) + request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -35,10 +35,10 @@ class GitLabOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) try: @@ -49,10 +49,10 @@ class GitLabOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -70,9 +70,11 @@ class GitLabCallbackEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -81,9 +83,11 @@ class GitLabCallbackEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -94,16 +98,23 @@ class GitLabCallbackEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_app=True) # Get the redirection path + if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host, path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py index 66b6f7662..aa65fa7fb 100644 --- a/apps/api/plane/authentication/views/app/google.py +++ b/apps/api/plane/authentication/views/app/google.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode, urljoin # Django import from django.http import HttpResponseRedirect @@ -18,7 +17,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GoogleOauthInitiateEndpoint(View): @@ -36,10 +35,10 @@ class GoogleOauthInitiateEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -51,10 +50,10 @@ class GoogleOauthInitiateEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params ) return HttpResponseRedirect(url) @@ -72,9 +71,11 @@ class GoogleCallbackEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( @@ -82,9 +83,11 @@ class GoogleCallbackEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: provider = GoogleOAuthProvider( @@ -94,15 +97,21 @@ class GoogleCallbackEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_app=True) # Get the redirection path - path = get_redirection_path(user=user) - # redirect to referer path - url = urljoin( - base_host, str(validate_next_path(next_path)) if next_path else path + if next_path: + path = next_path + else: + path = get_redirection_path(user=user) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={} ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin(base_host, "?" + urlencode(params)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py index 61591439d..9be3693e5 100644 --- a/apps/api/plane/authentication/views/app/magic.py +++ b/apps/api/plane/authentication/views/app/magic.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode, urljoin - # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect @@ -26,7 +23,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, ) from plane.authentication.rate_limit import AuthenticationThrottle -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class MagicGenerateEndpoint(APIView): @@ -72,10 +69,10 @@ class MagicSignInEndpoint(View): error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -88,10 +85,10 @@ class MagicSignInEndpoint(View): error_message="USER_DOES_NOT_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -117,15 +114,19 @@ class MagicSignInEndpoint(View): else str(get_redirection_path(user=user)) ) # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "sign-in?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -145,10 +146,10 @@ class MagicSignUpEndpoint(View): error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) # Existing user @@ -160,9 +161,11 @@ class MagicSignUpEndpoint(View): ) params = exc.get_error_dict() if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + params["next_path"] = str(next_path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) @@ -178,18 +181,22 @@ class MagicSignUpEndpoint(View): user_login(request=request, user=user, is_app=True) # Get the redirection path if next_path: - path = str(validate_next_path(next_path)) + path = next_path else: path = get_redirection_path(user=user) # redirect to referer path - url = urljoin(base_host(request=request, is_app=True), path) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=path, + params={}, + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = urljoin( - base_host(request=request, is_app=True), "?" + urlencode(params) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_app=True), + next_path=next_path, + params=params, ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index 6fa2d4517..cd0954db8 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode - # Django imports from django.core.exceptions import ValidationError from django.core.validators import validate_email @@ -17,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignInAuthSpaceEndpoint(View): @@ -32,9 +29,11 @@ class SignInAuthSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # set the referer as session to redirect after login @@ -51,9 +50,11 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Validate email @@ -67,9 +68,11 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Existing User @@ -82,9 +85,11 @@ class SignInAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -95,13 +100,19 @@ class SignInAuthSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to next path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -117,9 +128,11 @@ class SignUpAuthSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) email = request.POST.get("email", False) @@ -135,9 +148,11 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Validate the email email = email.strip().lower() @@ -151,9 +166,11 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Existing User @@ -166,9 +183,11 @@ class SignUpAuthSpaceEndpoint(View): payload={"email": str(email)}, ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -179,11 +198,17 @@ class SignUpAuthSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params={} + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py index fec71cb48..e3b64e8a0 100644 --- a/apps/api/plane/authentication/views/space/github.py +++ b/apps/api/plane/authentication/views/space/github.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect @@ -15,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitHubOauthInitiateSpaceEndpoint(View): @@ -23,9 +22,6 @@ class GitHubOauthInitiateSpaceEndpoint(View): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) - # Check instance configuration instance = Instance.objects.first() if instance is None or not instance.is_setup_done: @@ -34,9 +30,11 @@ class GitHubOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -47,9 +45,11 @@ class GitHubOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -66,9 +66,11 @@ class GitHubCallbackSpaceEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -77,9 +79,11 @@ class GitHubCallbackSpaceEndpoint(View): error_message="GITHUB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -89,11 +93,17 @@ class GitHubCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py index 4bdcf9514..a63466005 100644 --- a/apps/api/plane/authentication/views/space/gitlab.py +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect @@ -15,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GitLabOauthInitiateSpaceEndpoint(View): @@ -23,8 +22,6 @@ class GitLabOauthInitiateSpaceEndpoint(View): # Get host and next path request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -34,9 +31,11 @@ class GitLabOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -47,9 +46,11 @@ class GitLabOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -66,9 +67,11 @@ class GitLabCallbackSpaceEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: @@ -77,9 +80,11 @@ class GitLabCallbackSpaceEndpoint(View): error_message="GITLAB_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -89,11 +94,17 @@ class GitLabCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py index 03ad97793..7b9728762 100644 --- a/apps/api/plane/authentication/views/space/google.py +++ b/apps/api/plane/authentication/views/space/google.py @@ -1,6 +1,5 @@ # Python imports import uuid -from urllib.parse import urlencode # Django import from django.http import HttpResponseRedirect @@ -15,15 +14,13 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class GoogleOauthInitiateSpaceEndpoint(View): def get(self, request): request.session["host"] = base_host(request=request, is_space=True) next_path = request.GET.get("next_path") - if next_path: - request.session["next_path"] = str(next_path) # Check instance configuration instance = Instance.objects.first() @@ -33,9 +30,11 @@ class GoogleOauthInitiateSpaceEndpoint(View): error_message="INSTANCE_NOT_CONFIGURED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -46,9 +45,11 @@ class GoogleOauthInitiateSpaceEndpoint(View): return HttpResponseRedirect(auth_url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) @@ -65,9 +66,11 @@ class GoogleCallbackSpaceEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) if not code: exc = AuthenticationException( @@ -75,9 +78,11 @@ class GoogleCallbackSpaceEndpoint(View): error_message="GOOGLE_OAUTH_PROVIDER_ERROR", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: provider = GoogleOAuthProvider(request=request, code=code) @@ -85,11 +90,17 @@ class GoogleCallbackSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index d230af7ed..052a2118a 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -1,6 +1,3 @@ -# Python imports -from urllib.parse import urlencode - # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect @@ -23,7 +20,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class MagicGenerateSpaceEndpoint(APIView): @@ -66,9 +63,11 @@ class MagicSignInSpaceEndpoint(View): error_message="MAGIC_SIGN_IN_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) existing_user = User.objects.filter(email=email).first() @@ -79,9 +78,11 @@ class MagicSignInSpaceEndpoint(View): error_message="USER_DOES_NOT_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Active User @@ -93,15 +94,19 @@ class MagicSignInSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - path = str(next_path) if next_path else "" - url = f"{base_host(request=request, is_space=True)}{path}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(next_path) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + base_url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path + ) + url = urljoin(base_url, "?" + urlencode(params)) return HttpResponseRedirect(url) @@ -120,9 +125,11 @@ class MagicSignUpSpaceEndpoint(View): error_message="MAGIC_SIGN_UP_EMAIL_CODE_REQUIRED", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) # Existing User existing_user = User.objects.filter(email=email).first() @@ -133,9 +140,11 @@ class MagicSignUpSpaceEndpoint(View): error_message="USER_ALREADY_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) try: @@ -146,12 +155,17 @@ class MagicSignUpSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = f"{base_host(request=request, is_space=True)}{str(next_path) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path + ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() - if next_path: - params["next_path"] = str(validate_next_path(next_path)) - url = f"{base_host(request=request, is_space=True)}?{urlencode(params)}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/signout.py b/apps/api/plane/authentication/views/space/signout.py index 11e617436..613f705ad 100644 --- a/apps/api/plane/authentication/views/space/signout.py +++ b/apps/api/plane/authentication/views/space/signout.py @@ -7,7 +7,7 @@ from django.utils import timezone # Module imports from plane.authentication.utils.host import base_host, user_ip from plane.db.models import User -from plane.utils.path_validator import validate_next_path +from plane.utils.path_validator import get_safe_redirect_url class SignOutAuthSpaceEndpoint(View): @@ -22,8 +22,14 @@ class SignOutAuthSpaceEndpoint(View): user.save() # Log the user out logout(request) - url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path + ) return HttpResponseRedirect(url) except Exception: - url = f"{base_host(request=request, is_space=True)}{str(validate_next_path(next_path)) if next_path else ''}" + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path + ) return HttpResponseRedirect(url) diff --git a/apps/api/plane/license/api/views/admin.py b/apps/api/plane/license/api/views/admin.py index e1e386082..3a9563e3b 100644 --- a/apps/api/plane/license/api/views/admin.py +++ b/apps/api/plane/license/api/views/admin.py @@ -34,6 +34,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, ) from plane.utils.ip_address import get_client_ip +from plane.utils.path_validator import get_safe_redirect_url class InstanceAdminEndpoint(BaseAPIView): @@ -392,7 +393,14 @@ class InstanceAdminSignOutEndpoint(View): user.save() # Log the user out logout(request) - url = urljoin(base_host(request=request, is_admin=True)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_admin=True), + next_path="" + ) return HttpResponseRedirect(url) except Exception: - return HttpResponseRedirect(base_host(request=request, is_admin=True)) + url = get_safe_redirect_url( + base_url=base_host(request=request, is_admin=True), + next_path="" + ) + return HttpResponseRedirect(url) diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index aad28239f..ebac7ca0b 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -2,9 +2,55 @@ from urllib.parse import urlparse +def _contains_suspicious_patterns(path: str) -> bool: + """ + Check for suspicious patterns that might indicate malicious intent. + + Args: + path (str): The path to check + + Returns: + bool: True if suspicious patterns found, False otherwise + """ + suspicious_patterns = [ + r'javascript:', # JavaScript injection + r'data:', # Data URLs + r'vbscript:', # VBScript injection + r'file:', # File protocol + r'ftp:', # FTP protocol + r'%2e%2e', # URL encoded path traversal + r'%2f%2f', # URL encoded double slash + r'%5c%5c', # URL encoded backslashes + r' Date: Tue, 16 Sep 2025 00:14:18 +0530 Subject: [PATCH 031/169] chore(deps): axios version upgrade to 1.12.0 --- pnpm-lock.yaml | 20 ++++++++++---------- pnpm-workspace.yaml | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1d88bf7b..9585a2da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ catalogs: specifier: 18.3.1 version: 18.3.1 axios: - specifier: 1.11.0 - version: 1.11.0 + specifier: 1.12.0 + version: 1.12.0 lodash: specifier: 4.17.21 version: 4.17.21 @@ -111,7 +111,7 @@ importers: version: 10.4.14(postcss@8.5.6) axios: specifier: 'catalog:' - version: 1.11.0 + version: 1.12.0 lodash: specifier: 'catalog:' version: 4.17.21 @@ -208,7 +208,7 @@ importers: version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1) axios: specifier: 'catalog:' - version: 1.11.0 + version: 1.12.0 compression: specifier: 1.8.1 version: 1.8.1 @@ -344,7 +344,7 @@ importers: version: 2.11.8 axios: specifier: 'catalog:' - version: 1.11.0 + version: 1.12.0 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -501,7 +501,7 @@ importers: version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) axios: specifier: 'catalog:' - version: 1.11.0 + version: 1.12.0 clsx: specifier: ^2.0.0 version: 2.1.1 @@ -1078,7 +1078,7 @@ importers: version: link:../types axios: specifier: 'catalog:' - version: 1.11.0 + version: 1.12.0 devDependencies: '@plane/eslint-config': specifier: workspace:* @@ -4068,8 +4068,8 @@ packages: resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} engines: {node: '>=4'} - axios@1.11.0: - resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + axios@1.12.0: + resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -11058,7 +11058,7 @@ snapshots: axe-core@4.10.3: {} - axios@1.11.0: + axios@1.12.0: dependencies: follow-redirects: 1.15.11 form-data: 4.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9db883508..168601be6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ packages: - "!apps/proxy" catalog: - axios: 1.11.0 + axios: 1.12.0 mobx: 6.12.0 mobx-react: 9.1.1 mobx-utils: 6.0.8 From bd2272a7dae9c39f1874ccf1ae1b29c787e35911 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Tue, 16 Sep 2025 00:55:35 +0530 Subject: [PATCH 032/169] chore (deps): pnpm lockfile overrides update --- pnpm-lock.yaml | 868 +++++++++++++++++++++++-------------------------- 1 file changed, 411 insertions(+), 457 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9585a2da8..e128b79ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,16 +184,16 @@ importers: dependencies: '@hocuspocus/extension-database': specifier: ^2.15.0 - version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@hocuspocus/extension-logger': specifier: ^2.15.0 - version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@hocuspocus/extension-redis': specifier: ^2.15.0 - version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@hocuspocus/server': specifier: ^2.15.0 - version: 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@plane/editor': specifier: workspace:* version: link:../../packages/editor @@ -229,7 +229,7 @@ importers: version: 7.2.0 ioredis: specifier: ^5.4.1 - version: 5.6.1 + version: 5.7.0 lodash: specifier: 'catalog:' version: 4.17.21 @@ -247,7 +247,7 @@ importers: version: 10.0.0 y-prosemirror: specifier: ^1.2.15 - version: 1.3.6(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) y-protocols: specifier: ^1.0.6 version: 1.0.6(yjs@13.6.27) @@ -284,13 +284,13 @@ importers: version: 9.0.8 concurrently: specifier: ^9.0.1 - version: 9.1.2 + version: 9.2.0 nodemon: specifier: ^3.1.7 version: 3.1.10 ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@20.19.11)(typescript@5.8.3) + version: 10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@20.19.11)(typescript@5.8.3) tsdown: specifier: 'catalog:' version: 0.14.2(typescript@5.8.3) @@ -308,13 +308,13 @@ importers: version: 11.14.0(@types/react@18.3.11)(react@18.3.1) '@emotion/styled': specifier: ^11.11.0 - version: 11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) + version: 11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) '@headlessui/react': specifier: ^1.7.13 version: 1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@mui/material': specifier: ^5.14.1 - version: 5.17.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@plane/constants': specifier: workspace:* version: link:../../packages/constants @@ -519,13 +519,13 @@ importers: version: 16.6.1 emoji-picker-react: specifier: ^4.5.16 - version: 4.12.2(react@18.3.1) + version: 4.13.2(react@18.3.1) export-to-csv: specifier: ^1.4.0 version: 1.4.0 isomorphic-dompurify: specifier: ^2.12.0 - version: 2.25.0 + version: 2.26.0 lodash: specifier: 'catalog:' version: 4.17.21 @@ -549,7 +549,7 @@ importers: version: 0.2.1(next@14.2.32(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) posthog-js: specifier: ^1.131.3 - version: 1.255.1 + version: 1.260.1 react: specifier: 'catalog:' version: 18.3.1 @@ -647,7 +647,7 @@ importers: version: link:../typescript-config '@types/node': specifier: ^22.5.4 - version: 22.17.2 + version: 22.18.0 '@types/react': specifier: 'catalog:' version: 18.3.11 @@ -689,7 +689,7 @@ importers: dependencies: '@floating-ui/dom': specifier: ^1.7.1 - version: 1.7.2 + version: 1.7.4 '@floating-ui/react': specifier: ^0.26.4 version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -725,7 +725,7 @@ importers: version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1) '@tiptap/extension-collaboration': specifier: ^2.22.3 - version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(y-prosemirror@1.3.6(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) + version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)) '@tiptap/extension-emoji': specifier: ^2.22.3 version: 2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(@tiptap/suggestion@2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1))(emojibase@16.0.0) @@ -815,7 +815,7 @@ importers: version: 9.0.12(yjs@13.6.27) y-prosemirror: specifier: ^1.2.15 - version: 1.3.6(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + version: 1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) y-protocols: specifier: ^1.0.6 version: 1.0.6(yjs@13.6.27) @@ -855,7 +855,7 @@ importers: devDependencies: '@typescript-eslint/eslint-plugin': specifier: ^8.6.0 - version: 8.38.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + version: 8.40.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.6.0 version: 8.40.0(eslint@8.57.1)(typescript@5.8.3) @@ -864,7 +864,7 @@ importers: version: 8.57.1 eslint-config-next: specifier: ^14.1.0 - version: 14.2.31(eslint@8.57.1)(typescript@5.8.3) + version: 14.2.32(eslint@8.57.1)(typescript@5.8.3) eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@8.57.1) @@ -876,7 +876,7 @@ importers: version: 2.31.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-react: specifier: ^7.33.2 - version: 7.37.5(eslint@8.57.1) + version: 7.37.3(eslint@8.57.1) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@8.57.1) @@ -898,7 +898,7 @@ importers: version: link:../typescript-config '@types/node': specifier: ^22.5.4 - version: 22.17.2 + version: 22.18.0 '@types/react': specifier: 'catalog:' version: 18.3.11 @@ -941,7 +941,7 @@ importers: version: 4.17.20 '@types/node': specifier: ^22.5.4 - version: 22.17.2 + version: 22.18.0 '@types/react': specifier: 'catalog:' version: 18.3.11 @@ -1042,13 +1042,13 @@ importers: version: link:../typescript-config '@storybook/addon-designs': specifier: 10.0.2 - version: 10.0.2(@storybook/addon-docs@9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + version: 10.0.2(@storybook/addon-docs@9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) '@storybook/addon-docs': specifier: 9.1.2 - version: 9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + version: 9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) '@storybook/react-vite': specifier: 9.1.2 - version: 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.0)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + version: 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.0)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) '@types/react': specifier: 'catalog:' version: 18.3.11 @@ -1057,10 +1057,10 @@ importers: version: 18.3.1 eslint-plugin-storybook: specifier: 9.1.2 - version: 9.1.2(eslint@8.57.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) + version: 9.1.2(eslint@8.57.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) storybook: specifier: 9.1.2 - version: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + version: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) tsdown: specifier: 'catalog:' version: 0.14.2(typescript@5.8.3) @@ -1110,7 +1110,7 @@ importers: version: link:../typescript-config '@types/node': specifier: ^22.5.4 - version: 22.17.2 + version: 22.18.0 typescript: specifier: 5.8.3 version: 5.8.3 @@ -1119,22 +1119,22 @@ importers: devDependencies: '@tailwindcss/container-queries': specifier: ^0.1.1 - version: 0.1.1(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3))) + version: 0.1.1(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3))) '@tailwindcss/typography': specifier: ^0.5.9 - version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3))) + version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3))) autoprefixer: specifier: ^10.4.14 - version: 10.4.21(postcss@8.5.6) + version: 10.4.20(postcss@8.5.6) postcss: specifier: ^8.4.38 version: 8.5.6 tailwindcss: specifier: ^3.4.17 - version: 3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)) + version: 3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)) tailwindcss-animate: specifier: ^1.0.6 - version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3))) + version: 1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3))) packages/types: dependencies: @@ -1209,7 +1209,7 @@ importers: version: 2.1.1 emoji-picker-react: specifier: ^4.5.16 - version: 4.12.2(react@18.3.1) + version: 4.13.2(react@18.3.1) lodash: specifier: 'catalog:' version: 4.17.21 @@ -1267,10 +1267,10 @@ importers: version: 8.6.14(storybook@8.6.14(prettier@3.6.2)) '@storybook/addon-styling-webpack': specifier: ^1.0.0 - version: 1.0.1(storybook@8.6.14(prettier@3.6.2))(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + version: 1.0.1(storybook@8.6.14(prettier@3.6.2))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) '@storybook/addon-webpack5-compiler-swc': specifier: ^1.0.2 - version: 1.0.6(@swc/helpers@0.5.17)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + version: 1.0.6(@swc/helpers@0.5.17)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) '@storybook/blocks': specifier: ^8.1.1 version: 8.6.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2)) @@ -1279,7 +1279,7 @@ importers: version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) '@storybook/react-webpack5': specifier: ^8.1.1 - version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) + version: 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) '@storybook/test': specifier: ^8.1.1 version: 8.6.14(storybook@8.6.14(prettier@3.6.2)) @@ -1300,7 +1300,7 @@ importers: version: 18.3.1 autoprefixer: specifier: ^10.4.19 - version: 10.4.21(postcss@8.5.6) + version: 10.4.20(postcss@8.5.6) postcss-cli: specifier: ^11.0.0 version: 11.0.1(jiti@2.5.1)(postcss@8.5.6) @@ -1333,7 +1333,7 @@ importers: version: 4.1.0 isomorphic-dompurify: specifier: ^2.16.0 - version: 2.25.0 + version: 2.26.0 lodash: specifier: 'catalog:' version: 4.17.21 @@ -1364,7 +1364,7 @@ importers: version: 4.17.20 '@types/node': specifier: ^22.5.4 - version: 22.17.2 + version: 22.18.0 '@types/react': specifier: 'catalog:' version: 18.3.11 @@ -1584,14 +1584,14 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} - '@emnapi/core@1.4.5': - resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + '@emnapi/core@1.5.0': + resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} - '@emnapi/runtime@1.4.5': - resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} + '@emnapi/runtime@1.5.0': + resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/wasi-threads@1.0.4': - resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -1623,8 +1623,8 @@ packages: '@emotion/sheet@1.4.0': resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} - '@emotion/styled@11.14.0': - resolution: {integrity: sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==} + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} peerDependencies: '@emotion/react': ^11.0.0-rc.0 '@types/react': '*' @@ -1797,8 +1797,14 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.7.0': - resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -1826,14 +1832,17 @@ packages: '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - '@floating-ui/dom@1.7.2': - resolution: {integrity: sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==} + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} - '@floating-ui/dom@1.7.3': - resolution: {integrity: sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==} + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' - '@floating-ui/react-dom@2.1.5': - resolution: {integrity: sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==} + '@floating-ui/react-dom@2.1.6': + resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -1847,6 +1856,9 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + '@formatjs/ecma402-abstract@2.3.4': resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==} @@ -1872,16 +1884,16 @@ packages: '@hocuspocus/common@2.15.3': resolution: {integrity: sha512-Rzh1HF0a2o/tf90A3w2XNdXd9Ym3aQzMDfD3lAUONCX9B9QOdqdyiORrj6M25QEaJrEIbXFy8LtAFcL0wRdWzA==} - '@hocuspocus/extension-database@2.15.2': - resolution: {integrity: sha512-BkYDfKA99udx7AEkqWReBS61kvGMC9SqoPJs3v8xNgpaj2GGyMJQlUdQRMhPyZTn2osV+pqhk8Hn7xUJCW1RJg==} + '@hocuspocus/extension-database@2.15.3': + resolution: {integrity: sha512-+PVlPwfdVyOase68WEBb105ZwglhCLVeGpyNa3uwnmH+Ers7OKGLma2SDMKn3Vcb5oHzTzEGx7jNt+32KTEKXA==} peerDependencies: yjs: ^13.6.8 - '@hocuspocus/extension-logger@2.15.2': - resolution: {integrity: sha512-nqSnSFI+xO7dBTsgzSANKvx09ptq8J4Doz3AdLgxfaweYC85qFao7mAx1ZCtWoVHseVwBYua6S3dTwQq5IsWEg==} + '@hocuspocus/extension-logger@2.15.3': + resolution: {integrity: sha512-NufsjXldlVX1c2B98Hyg8Vq9GblPmnIvw9QGdsBK6SNEgWzDwfVrjljrigMAVGuQ4pbBVz8TaY5DcsmOCi5jqA==} - '@hocuspocus/extension-redis@2.15.2': - resolution: {integrity: sha512-2BNBLnDEQq2v3uQSidBSdUOIbHhH383SGxn+hmy6tDQfrt2hLE4MwilgDdcCO0FKOCYaJrV7HyKu6/WyzLbAOg==} + '@hocuspocus/extension-redis@2.15.3': + resolution: {integrity: sha512-gKeiiuQcAoRYb+QK9vyIczRrjNy8NW6ky+oyVv7raMcaizfFxeWP3TaAHPyC2pjGKfXsqN2m3YM0GbBGZfMiCg==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 @@ -1892,8 +1904,8 @@ packages: y-protocols: ^1.0.6 yjs: ^13.6.8 - '@hocuspocus/server@2.15.2': - resolution: {integrity: sha512-+fLRVswg+bkgfHqJ+wFgywivw3H08WMOtVvJF7dJzWT2ZR/Sc3nDMFh2KqMF6Ygh4z6mt23xr7SKIm3eP1zoLA==} + '@hocuspocus/server@2.15.3': + resolution: {integrity: sha512-Ju4ty4/7JtmvivcP7gKReOLf8KrFwN7Yx/5VhXYh4TRULy4kSo2fsDVUaluPp0neZa6PbVhizJuzlOim73IEbQ==} peerDependencies: y-protocols: ^1.0.6 yjs: ^13.6.8 @@ -2056,11 +2068,11 @@ packages: '@jridgewell/source-map@0.3.11': resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - '@jridgewell/sourcemap-codec@1.5.4': - resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.29': - resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==} + '@jridgewell/trace-mapping@0.3.30': + resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -2089,8 +2101,8 @@ packages: '@mui/core-downloads-tracker@5.18.0': resolution: {integrity: sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA==} - '@mui/material@5.17.1': - resolution: {integrity: sha512-2B33kQf+GmPnrvXXweWAx+crbiUEsxCdCN979QDYnlH9ox4pd+0/IBriWLV+l6ORoBF60w39cWjFnJYGFdzXcw==} + '@mui/material@5.18.0': + resolution: {integrity: sha512-bbH/HaJZpFtXGvWg3TsBWG4eyt3gah3E7nCNU8GLyRjVoWcA91Vm/T+sjHfUcwgJSw9iLtucfHBoq+qW/T30aA==} engines: {node: '>=12.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -2172,8 +2184,8 @@ packages: '@next/env@14.2.32': resolution: {integrity: sha512-n9mQdigI6iZ/DF6pCTwMKeWgF2e8lg7qgt5M7HXMLtyhZYMnf/u905M18sSpPmHL9MKp9JHo56C6jrD2EvWxng==} - '@next/eslint-plugin-next@14.2.31': - resolution: {integrity: sha512-ouaB+l8Cr/uzGxoGHUvd01OnfFTM8qM81Crw1AG0xoWDRN0DKLXyTWVe0FdAOHVBpGuXB87aufdRmrwzZDArIw==} + '@next/eslint-plugin-next@14.2.32': + resolution: {integrity: sha512-tyZMX8g4cWg/uPW4NxiJK13t62Pab47SKGJGVZJa6YtFwtfrXovH4j1n9tdpRdXW03PGQBugYEVGM7OhWfytdA==} '@next/swc-darwin-arm64@14.2.32': resolution: {integrity: sha512-osHXveM70zC+ilfuFa/2W6a1XQxJTvEhzEycnjUaVE8kpUS09lDpiDDX2YLdyFCzoUbvbo5r0X1Kp4MllIOShw==} @@ -2707,8 +2719,8 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@rushstack/eslint-patch@1.12.0': - resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} + '@rushstack/eslint-patch@1.10.4': + resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==} '@storybook/addon-actions@8.6.14': resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==} @@ -2983,68 +2995,68 @@ packages: peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - '@swc/core-darwin-arm64@1.13.3': - resolution: {integrity: sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw==} + '@swc/core-darwin-arm64@1.13.5': + resolution: {integrity: sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.13.3': - resolution: {integrity: sha512-p0X6yhxmNUOMZrbeZ3ZNsPige8lSlSe1llllXvpCLkKKxN/k5vZt1sULoq6Nj4eQ7KeHQVm81/+AwKZyf/e0TA==} + '@swc/core-darwin-x64@1.13.5': + resolution: {integrity: sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.13.3': - resolution: {integrity: sha512-OmDoiexL2fVWvQTCtoh0xHMyEkZweQAlh4dRyvl8ugqIPEVARSYtaj55TBMUJIP44mSUOJ5tytjzhn2KFxFcBA==} + '@swc/core-linux-arm-gnueabihf@1.13.5': + resolution: {integrity: sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.13.3': - resolution: {integrity: sha512-STfKku3QfnuUj6k3g9ld4vwhtgCGYIFQmsGPPgT9MK/dI3Lwnpe5Gs5t1inoUIoGNP8sIOLlBB4HV4MmBjQuhw==} + '@swc/core-linux-arm64-gnu@1.13.5': + resolution: {integrity: sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.13.3': - resolution: {integrity: sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==} + '@swc/core-linux-arm64-musl@1.13.5': + resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.13.3': - resolution: {integrity: sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==} + '@swc/core-linux-x64-gnu@1.13.5': + resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.13.3': - resolution: {integrity: sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==} + '@swc/core-linux-x64-musl@1.13.5': + resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.13.3': - resolution: {integrity: sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==} + '@swc/core-win32-arm64-msvc@1.13.5': + resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.13.3': - resolution: {integrity: sha512-nvehQVEOdI1BleJpuUgPLrclJ0TzbEMc+MarXDmmiRFwEUGqj+pnfkTSb7RZyS1puU74IXdK/YhTirHurtbI9w==} + '@swc/core-win32-ia32-msvc@1.13.5': + resolution: {integrity: sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.13.3': - resolution: {integrity: sha512-A+JSKGkRbPLVV2Kwx8TaDAV0yXIXm/gc8m98hSkVDGlPBBmydgzNdWy3X7HTUBM7IDk7YlWE7w2+RUGjdgpTmg==} + '@swc/core-win32-x64-msvc@1.13.5': + resolution: {integrity: sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.13.3': - resolution: {integrity: sha512-ZaDETVWnm6FE0fc+c2UE8MHYVS3Fe91o5vkmGfgwGXFbxYvAjKSqxM/j4cRc9T7VZNSJjriXq58XkfCp3Y6f+w==} + '@swc/core@1.13.5': + resolution: {integrity: sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -3102,8 +3114,8 @@ packages: resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@testing-library/jest-dom@6.6.3': - resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + '@testing-library/jest-dom@6.7.0': + resolution: {integrity: sha512-RI2e97YZ7MRa+vxP4UUnMuMFL2buSsf0ollxUbTgrbPLKhMn8KVTx7raS6DYjC7v1NDVrioOvaShxsguLNISCA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} '@testing-library/user-event@14.5.2': @@ -3488,8 +3500,8 @@ packages: '@types/node@20.19.11': resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==} - '@types/node@22.17.2': - resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==} + '@types/node@22.18.0': + resolution: {integrity: sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==} '@types/nprogress@0.2.3': resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==} @@ -3577,11 +3589,11 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@typescript-eslint/eslint-plugin@8.38.0': - resolution: {integrity: sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==} + '@typescript-eslint/eslint-plugin@8.40.0': + resolution: {integrity: sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.38.0 + '@typescript-eslint/parser': ^8.40.0 eslint: ^8.57.0 || ^9.0.0 typescript: 5.8.3 @@ -3592,82 +3604,52 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: 5.8.3 - '@typescript-eslint/project-service@8.38.0': - resolution: {integrity: sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: 5.8.3 - '@typescript-eslint/project-service@8.40.0': resolution: {integrity: sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: 5.8.3 - '@typescript-eslint/scope-manager@8.38.0': - resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.40.0': resolution: {integrity: sha512-y9ObStCcdCiZKzwqsE8CcpyuVMwRouJbbSrNuThDpv16dFAj429IkM6LNb1dZ2m7hK5fHyzNcErZf7CEeKXR4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.38.0': - resolution: {integrity: sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: 5.8.3 - '@typescript-eslint/tsconfig-utils@8.40.0': resolution: {integrity: sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.38.0': - resolution: {integrity: sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==} + '@typescript-eslint/type-utils@8.40.0': + resolution: {integrity: sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: 5.8.3 - '@typescript-eslint/types@8.38.0': - resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.40.0': resolution: {integrity: sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.38.0': - resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: 5.8.3 - '@typescript-eslint/typescript-estree@8.40.0': resolution: {integrity: sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: 5.8.3 - '@typescript-eslint/utils@8.38.0': - resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} + '@typescript-eslint/utils@8.40.0': + resolution: {integrity: sha512-Cgzi2MXSZyAUOY+BFwGs17s7ad/7L+gKt6Y8rAVVWS+7o6wrjeFN4nVfTpbE25MNcxyJ+iYUXflbs2xR9h4UBg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: 5.8.3 - '@typescript-eslint/visitor-keys@8.38.0': - resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.40.0': resolution: {integrity: sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@ungap/structured-clone@1.2.1': + resolution: {integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==} '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3924,8 +3906,8 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.1.0: - resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + ansi-regex@6.2.0: + resolution: {integrity: sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==} engines: {node: '>=12'} ansi-styles@3.2.1: @@ -4053,8 +4035,8 @@ packages: peerDependencies: postcss: ^8.1.0 - autoprefixer@10.4.21: - resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + autoprefixer@10.4.20: + resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -4142,8 +4124,8 @@ packages: browserify-zlib@0.2.0: resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} - browserslist@4.25.2: - resolution: {integrity: sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==} + browserslist@4.25.4: + resolution: {integrity: sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -4191,6 +4173,9 @@ packages: caniuse-lite@1.0.30001735: resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==} + caniuse-lite@1.0.30001741: + resolution: {integrity: sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==} + capital-case@1.0.4: resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} @@ -4346,8 +4331,8 @@ packages: compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - concurrently@9.1.2: - resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} + concurrently@9.2.0: + resolution: {integrity: sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==} engines: {node: '>=18'} hasBin: true @@ -4729,14 +4714,14 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.204: - resolution: {integrity: sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA==} + electron-to-chromium@1.5.218: + resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==} element-resize-detector@1.2.4: resolution: {integrity: sha512-Fl5Ftk6WwXE0wqCgNoseKWndjzZlDCwuPTcoVZfCP9R3EHQF8qUtr3YUPNETegRBOKqQKPW3n4kiIWngGi8tKg==} - emoji-picker-react@4.12.2: - resolution: {integrity: sha512-6PDYZGlhidt+Kc0ay890IU4HLNfIR7/OxPvcNxw+nJ4HQhMKd8pnGnPn4n2vqC/arRFCNWQhgJP8rpsYKsz0GQ==} + emoji-picker-react@4.13.2: + resolution: {integrity: sha512-azaJQLTshEOZVhksgU136izJWJyZ4Clx6xQ6Vctzk1gOdPPAUbTa/JYDwZJ8rh97QxnjpyeftXl99eRlYr3vNA==} engines: {node: '>=10'} peerDependencies: react: '>=16' @@ -4862,8 +4847,8 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - eslint-config-next@14.2.31: - resolution: {integrity: sha512-sT32j4678je7SWstBM6l0kE2L+LSgAARDAxw8iloNhI4/8xwkdDesbrGCPaGWzQv+dD6f6adhB+eRSThpGkBdg==} + eslint-config-next@14.2.32: + resolution: {integrity: sha512-mP/NmYtDBsKlKIOBnH+CW+pYeyR3wBhE+26DAqQ0/aRtEBeTEjgY2wAFUugUELkTLmrX6PpuMSSTpOhz7j9kdQ==} peerDependencies: eslint: ^7.23.0 || ^8.0.0 typescript: 5.8.3 @@ -4947,8 +4932,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + eslint-plugin-react@7.37.3: + resolution: {integrity: sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==} engines: {node: '>=4'} peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 @@ -5102,8 +5087,9 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - fdir@6.4.6: - resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} peerDependencies: picomatch: ^3 || ^4 peerDependenciesMeta: @@ -5511,8 +5497,8 @@ packages: resolution: {integrity: sha512-P9F4Eo6zicYsIJbEy/mPJmSxKY0rVcmiy5H8oXPxPDotQRCvCBjBuI5QWoQQanVE9jdeocnum5iqYAHl4pHdLA==} engines: {node: '>=6'} - ioredis@5.6.1: - resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} + ioredis@5.7.0: + resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} engines: {node: '>=12.22.0'} ipaddr.js@1.9.1: @@ -5680,8 +5666,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isomorphic-dompurify@2.25.0: - resolution: {integrity: sha512-bcpJzu9DOjN21qaCVpcoCwUX1ytpvA6EFqCK5RNtPg5+F0Jz9PX50jl6jbEicBNeO87eDDfC7XtPs4zjDClZJg==} + isomorphic-dompurify@2.26.0: + resolution: {integrity: sha512-nZmoK4wKdzPs5USq4JHBiimjdKSVAOm2T1KyDoadtMPNXYHxiENd19ou4iU/V4juFM6LVgYQnpxCYmxqNP4Obw==} engines: {node: '>=18'} isomorphic.js@0.2.5: @@ -5905,8 +5891,8 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.19: + resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} make-dir@3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} @@ -6451,8 +6437,8 @@ packages: pino-std-serializers@7.0.0: resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} - pino@9.7.0: - resolution: {integrity: sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==} + pino@9.9.0: + resolution: {integrity: sha512-zxsRIQG9HzG+jEljmvmZupOMDUQ0Jpj0yAgE28jQvvrdYTlEaiGwelJpdndMl/MBuRr70heIj83QyqJUWaU8mQ==} hasBin: true pirates@4.0.7: @@ -6580,8 +6566,8 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - posthog-js@1.255.1: - resolution: {integrity: sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==} + posthog-js@1.260.1: + resolution: {integrity: sha512-DD8ZSRpdScacMqtqUIvMFme8lmOWkOvExG8VvjONE7Cm3xpRH5xXpfrwMJE4bayTGWKMx4ij6SfphK6dm/o2ug==} peerDependencies: '@rrweb/types': 2.0.0-alpha.17 rrweb-snapshot: 2.0.0-alpha.17 @@ -6838,8 +6824,8 @@ packages: resolution: {integrity: sha512-hlSJDQ2synMPKFZOsKo9Hi8WWZTC7POR8EmWvTSjow+VDgKzkmjQvFm2fk0tmRw+f0vTOIYKlarR0iL4996pdg==} engines: {node: '>=16.14.0'} - react-docgen@8.0.0: - resolution: {integrity: sha512-kmob/FOTwep7DUWf9KjuenKX0vyvChr3oTdvvPt09V60Iz75FJp+T/0ZeHMbAfJj2WaVWqAPP5Hmm3PYzSPPKg==} + react-docgen@8.0.1: + resolution: {integrity: sha512-kQKsqPLplY3Hx4jGnM3jpQcG3FQDt7ySz32uTHt3C9HAe45kNXG+3o16Eqn3Fw1GtMfHoN3b4J/z2e6cZJCmqQ==} engines: {node: ^20.9.0 || >=22} react-dom@18.3.1: @@ -7464,8 +7450,8 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - tapable@2.2.2: - resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} + tapable@2.2.3: + resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} terser-webpack-plugin@5.3.14: @@ -7892,8 +7878,8 @@ packages: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} - vite@7.0.0: - resolution: {integrity: sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==} + vite@7.0.7: + resolution: {integrity: sha512-hc6LujN/EkJHmxeiDJMs0qBontZ1cdBvvoCbWhVjzUFTU329VRyOC46gHNSA8NcOC5yzCeXpwI40tieI3DEZqg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -8081,8 +8067,8 @@ packages: peerDependencies: yjs: ^13.0.0 - y-prosemirror@1.3.6: - resolution: {integrity: sha512-vtS2rv8+ll/TBQRqwUiqflgSuN/DhfvUQX0r5O3o5i0pO6K4pSNgFtVkOKtNWPBVkS6l9BDQjbtnDNftZnxq7Q==} + y-prosemirror@1.3.7: + resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} peerDependencies: prosemirror-model: ^1.7.1 @@ -8156,7 +8142,7 @@ snapshots: '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.30 '@asamuzakjp/css-color@3.2.0': dependencies: @@ -8215,14 +8201,14 @@ snapshots: '@babel/parser': 7.28.3 '@babel/types': 7.28.2 '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.30 jsesc: 3.1.0 '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.28.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.2 + browserslist: 4.25.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -8290,7 +8276,7 @@ snapshots: dependencies: '@babel/runtime': 7.26.10 '@base-ui-components/utils': 0.1.0(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/react-dom': 2.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@floating-ui/utils': 0.2.10 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -8415,18 +8401,18 @@ snapshots: '@date-fns/tz@1.4.1': {} - '@emnapi/core@1.4.5': + '@emnapi/core@1.5.0': dependencies: - '@emnapi/wasi-threads': 1.0.4 + '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.4.5': + '@emnapi/runtime@1.5.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.0.4': + '@emnapi/wasi-threads@1.1.0': dependencies: tslib: 2.8.1 optional: true @@ -8489,7 +8475,7 @@ snapshots: '@emotion/sheet@1.4.0': {} - '@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1)': + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.10 '@emotion/babel-plugin': 11.13.5 @@ -8589,7 +8575,12 @@ snapshots: '@esbuild/win32-x64@0.25.0': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@8.57.1)': + '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': dependencies: eslint: 8.57.1 eslint-visitor-keys: 3.4.3 @@ -8626,32 +8617,35 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.2': + '@floating-ui/dom@1.7.4': dependencies: '@floating-ui/core': 1.7.3 '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.7.3': + '@floating-ui/react-dom@2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/core': 1.7.3 - '@floating-ui/utils': 0.2.10 + '@floating-ui/dom': 1.7.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) - '@floating-ui/react-dom@2.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/dom': 1.7.3 + '@floating-ui/dom': 1.7.4 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) '@floating-ui/react@0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@floating-ui/react-dom': 2.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/utils': 0.2.10 + '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.8 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) tabbable: 6.2.0 '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.8': {} + '@formatjs/ecma402-abstract@2.3.4': dependencies: '@formatjs/fast-memoize': 2.2.7 @@ -8689,27 +8683,27 @@ snapshots: dependencies: lib0: 0.2.114 - '@hocuspocus/extension-database@2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/extension-database@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': dependencies: - '@hocuspocus/server': 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@hocuspocus/server': 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) yjs: 13.6.27 transitivePeerDependencies: - bufferutil - utf-8-validate - y-protocols - '@hocuspocus/extension-logger@2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/extension-logger@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': dependencies: - '@hocuspocus/server': 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@hocuspocus/server': 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) transitivePeerDependencies: - bufferutil - utf-8-validate - y-protocols - yjs - '@hocuspocus/extension-redis@2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/extension-redis@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': dependencies: - '@hocuspocus/server': 2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + '@hocuspocus/server': 2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) ioredis: 4.30.0 kleur: 4.1.5 lodash.debounce: 4.0.8 @@ -8734,7 +8728,7 @@ snapshots: - bufferutil - utf-8-validate - '@hocuspocus/server@2.15.2(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': + '@hocuspocus/server@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27)': dependencies: '@hocuspocus/common': 2.15.3 async-lock: 1.4.1 @@ -8837,7 +8831,7 @@ snapshots: '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.4.5 + '@emnapi/runtime': 1.5.0 optional: true '@img/sharp-win32-ia32@0.33.5': @@ -8859,38 +8853,38 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.8.3)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.1(typescript@5.8.3)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: glob: 10.4.5 - magic-string: 0.30.17 + magic-string: 0.30.19 react-docgen-typescript: 2.4.0(typescript@5.8.3) - vite: 7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) optionalDependencies: typescript: 5.8.3 '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.30 '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/source-map@0.3.11': dependencies: '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.30 - '@jridgewell/sourcemap-codec@1.5.4': {} + '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.29': + '@jridgewell/trace-mapping@0.3.30': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 '@juggle/resize-observer@3.4.0': {} @@ -8912,11 +8906,11 @@ snapshots: '@mui/core-downloads-tracker@5.18.0': {} - '@mui/material@5.17.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@mui/material@5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.10 '@mui/core-downloads-tracker': 5.18.0 - '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) + '@mui/system': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) '@mui/types': 7.2.24(@types/react@18.3.11) '@mui/utils': 5.17.1(@types/react@18.3.11)(react@18.3.1) '@popperjs/core': 2.11.8 @@ -8930,7 +8924,7 @@ snapshots: react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) optionalDependencies: '@emotion/react': 11.14.0(@types/react@18.3.11)(react@18.3.1) - '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) '@types/react': 18.3.11 '@mui/private-theming@5.17.1(@types/react@18.3.11)(react@18.3.1)': @@ -8942,7 +8936,7 @@ snapshots: optionalDependencies: '@types/react': 18.3.11 - '@mui/styled-engine@5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(react@18.3.1)': + '@mui/styled-engine@5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.26.10 '@emotion/cache': 11.14.0 @@ -8952,13 +8946,13 @@ snapshots: react: 18.3.1 optionalDependencies: '@emotion/react': 11.14.0(@types/react@18.3.11)(react@18.3.1) - '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) - '@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1)': + '@mui/system@5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1)': dependencies: '@babel/runtime': 7.26.10 '@mui/private-theming': 5.17.1(@types/react@18.3.11)(react@18.3.1) - '@mui/styled-engine': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(react@18.3.1) + '@mui/styled-engine': 5.18.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(react@18.3.1) '@mui/types': 7.2.24(@types/react@18.3.11) '@mui/utils': 5.17.1(@types/react@18.3.11)(react@18.3.1) clsx: 2.1.1 @@ -8967,7 +8961,7 @@ snapshots: react: 18.3.1 optionalDependencies: '@emotion/react': 11.14.0(@types/react@18.3.11)(react@18.3.1) - '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1) '@types/react': 18.3.11 '@mui/types@7.2.24(@types/react@18.3.11)': @@ -8988,21 +8982,21 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.5.0 + '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.0 optional: true '@napi-rs/wasm-runtime@1.0.3': dependencies: - '@emnapi/core': 1.4.5 - '@emnapi/runtime': 1.4.5 + '@emnapi/core': 1.5.0 + '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.0 optional: true '@next/env@14.2.32': {} - '@next/eslint-plugin-next@14.2.31': + '@next/eslint-plugin-next@14.2.32': dependencies: glob: 10.3.10 @@ -9495,7 +9489,7 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@rushstack/eslint-patch@1.12.0': {} + '@rushstack/eslint-patch@1.10.4': {} '@storybook/addon-actions@8.6.14(storybook@8.6.14(prettier@3.6.2))': dependencies: @@ -9520,12 +9514,12 @@ snapshots: storybook: 8.6.14(prettier@3.6.2) ts-dedent: 2.2.0 - '@storybook/addon-designs@10.0.2(@storybook/addon-docs@9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/addon-designs@10.0.2(@storybook/addon-docs@9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: '@figspec/react': 1.0.4(react@18.3.1) - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) optionalDependencies: - '@storybook/addon-docs': 9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/addon-docs': 9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -9542,15 +9536,15 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@storybook/addon-docs@9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/addon-docs@9.1.2(@types/react@18.3.11)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: '@mdx-js/react': 3.1.0(@types/react@18.3.11)(react@18.3.1) - '@storybook/csf-plugin': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/csf-plugin': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) '@storybook/icons': 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@storybook/react-dom-shim': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/react-dom-shim': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -9609,10 +9603,10 @@ snapshots: storybook: 8.6.14(prettier@3.6.2) ts-dedent: 2.2.0 - '@storybook/addon-styling-webpack@1.0.1(storybook@8.6.14(prettier@3.6.2))(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0))': + '@storybook/addon-styling-webpack@1.0.1(storybook@8.6.14(prettier@3.6.2))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0))': dependencies: '@storybook/node-logger': 8.6.14(storybook@8.6.14(prettier@3.6.2)) - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) transitivePeerDependencies: - storybook @@ -9625,10 +9619,10 @@ snapshots: memoizerific: 1.11.3 storybook: 8.6.14(prettier@3.6.2) - '@storybook/addon-webpack5-compiler-swc@1.0.6(@swc/helpers@0.5.17)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0))': + '@storybook/addon-webpack5-compiler-swc@1.0.6(@swc/helpers@0.5.17)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0))': dependencies: - '@swc/core': 1.13.3(@swc/helpers@0.5.17) - swc-loader: 0.2.6(@swc/core@1.13.3(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + '@swc/core': 1.13.5(@swc/helpers@0.5.17) + swc-loader: 0.2.6(@swc/core@1.13.5(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) transitivePeerDependencies: - '@swc/helpers' - webpack @@ -9642,14 +9636,14 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - '@storybook/builder-vite@9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': + '@storybook/builder-vite@9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: - '@storybook/csf-plugin': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + '@storybook/csf-plugin': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) ts-dedent: 2.2.0 - vite: 7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) - '@storybook/builder-webpack5@8.6.14(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': + '@storybook/builder-webpack5@8.6.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': dependencies: '@storybook/core-webpack': 8.6.14(storybook@8.6.14(prettier@3.6.2)) '@types/semver': 7.7.0 @@ -9657,23 +9651,23 @@ snapshots: case-sensitive-paths-webpack-plugin: 2.4.0 cjs-module-lexer: 1.4.3 constants-browserify: 1.0.0 - css-loader: 6.11.0(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + css-loader: 6.11.0(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) es-module-lexer: 1.7.0 - fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) - html-webpack-plugin: 5.6.4(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) - magic-string: 0.30.17 + fork-ts-checker-webpack-plugin: 8.0.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) + html-webpack-plugin: 5.6.4(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) + magic-string: 0.30.19 path-browserify: 1.0.1 process: 0.11.10 semver: 7.7.2 storybook: 8.6.14(prettier@3.6.2) - style-loader: 3.3.4(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) - terser-webpack-plugin: 5.3.14(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + style-loader: 3.3.4(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) + terser-webpack-plugin: 5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) ts-dedent: 2.2.0 url: 0.11.4 util: 0.12.5 util-deprecate: 1.0.2 - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) - webpack-dev-middleware: 6.1.3(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack-dev-middleware: 6.1.3(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) webpack-hot-middleware: 2.26.1 webpack-virtual-modules: 0.6.2 optionalDependencies: @@ -9720,9 +9714,9 @@ snapshots: storybook: 8.6.14(prettier@3.6.2) unplugin: 1.16.1 - '@storybook/csf-plugin@9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/csf-plugin@9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) unplugin: 1.16.1 '@storybook/global@5.0.0': {} @@ -9746,14 +9740,14 @@ snapshots: dependencies: storybook: 8.6.14(prettier@3.6.2) - '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': + '@storybook/preset-react-webpack@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': dependencies: '@storybook/core-webpack': 8.6.14(storybook@8.6.14(prettier@3.6.2)) '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) - '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + '@storybook/react-docgen-typescript-plugin': 1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) '@types/semver': 7.7.0 find-up: 5.0.0 - magic-string: 0.30.17 + magic-string: 0.30.19 react: 18.3.1 react-docgen: 7.1.1 react-dom: 18.3.1(react@18.3.1) @@ -9761,7 +9755,7 @@ snapshots: semver: 7.7.2 storybook: 8.6.14(prettier@3.6.2) tsconfig-paths: 4.2.0 - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) optionalDependencies: typescript: 5.8.3 transitivePeerDependencies: @@ -9776,7 +9770,7 @@ snapshots: dependencies: storybook: 8.6.14(prettier@3.6.2) - '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0))': + '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0))': dependencies: debug: 4.4.1(supports-color@5.5.0) endent: 2.1.0 @@ -9786,7 +9780,7 @@ snapshots: react-docgen-typescript: 2.4.0(typescript@5.8.3) tslib: 2.8.1 typescript: 5.8.3 - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) transitivePeerDependencies: - supports-color @@ -9796,36 +9790,36 @@ snapshots: react-dom: 18.3.1(react@18.3.1) storybook: 8.6.14(prettier@3.6.2) - '@storybook/react-dom-shim@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': + '@storybook/react-dom-shim@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))': dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) - '@storybook/react-vite@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.0)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': + '@storybook/react-vite@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(rollup@4.50.0)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.8.3)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.1(typescript@5.8.3)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) '@rollup/pluginutils': 5.2.0(rollup@4.50.0) - '@storybook/builder-vite': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) - '@storybook/react': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) + '@storybook/builder-vite': 9.1.2(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + '@storybook/react': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3) find-up: 7.0.0 - magic-string: 0.30.17 + magic-string: 0.30.19 react: 18.3.1 - react-docgen: 8.0.0 + react-docgen: 8.0.1 react-dom: 18.3.1(react@18.3.1) resolve: 1.22.10 - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) tsconfig-paths: 4.2.0 - vite: 7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) transitivePeerDependencies: - rollup - supports-color - typescript - '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': + '@storybook/react-webpack5@8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3)': dependencies: - '@storybook/builder-webpack5': 8.6.14(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) - '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) + '@storybook/builder-webpack5': 8.6.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) + '@storybook/preset-react-webpack': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) '@storybook/react': 8.6.14(@storybook/test@8.6.14(storybook@8.6.14(prettier@3.6.2)))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.14(prettier@3.6.2))(typescript@5.8.3) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -9856,13 +9850,13 @@ snapshots: '@storybook/test': 8.6.14(storybook@8.6.14(prettier@3.6.2)) typescript: 5.8.3 - '@storybook/react@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)': + '@storybook/react@9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) + '@storybook/react-dom-shim': 9.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) optionalDependencies: typescript: 5.8.3 @@ -9881,51 +9875,51 @@ snapshots: dependencies: storybook: 8.6.14(prettier@3.6.2) - '@swc/core-darwin-arm64@1.13.3': + '@swc/core-darwin-arm64@1.13.5': optional: true - '@swc/core-darwin-x64@1.13.3': + '@swc/core-darwin-x64@1.13.5': optional: true - '@swc/core-linux-arm-gnueabihf@1.13.3': + '@swc/core-linux-arm-gnueabihf@1.13.5': optional: true - '@swc/core-linux-arm64-gnu@1.13.3': + '@swc/core-linux-arm64-gnu@1.13.5': optional: true - '@swc/core-linux-arm64-musl@1.13.3': + '@swc/core-linux-arm64-musl@1.13.5': optional: true - '@swc/core-linux-x64-gnu@1.13.3': + '@swc/core-linux-x64-gnu@1.13.5': optional: true - '@swc/core-linux-x64-musl@1.13.3': + '@swc/core-linux-x64-musl@1.13.5': optional: true - '@swc/core-win32-arm64-msvc@1.13.3': + '@swc/core-win32-arm64-msvc@1.13.5': optional: true - '@swc/core-win32-ia32-msvc@1.13.3': + '@swc/core-win32-ia32-msvc@1.13.5': optional: true - '@swc/core-win32-x64-msvc@1.13.3': + '@swc/core-win32-x64-msvc@1.13.5': optional: true - '@swc/core@1.13.3(@swc/helpers@0.5.17)': + '@swc/core@1.13.5(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.24 optionalDependencies: - '@swc/core-darwin-arm64': 1.13.3 - '@swc/core-darwin-x64': 1.13.3 - '@swc/core-linux-arm-gnueabihf': 1.13.3 - '@swc/core-linux-arm64-gnu': 1.13.3 - '@swc/core-linux-arm64-musl': 1.13.3 - '@swc/core-linux-x64-gnu': 1.13.3 - '@swc/core-linux-x64-musl': 1.13.3 - '@swc/core-win32-arm64-msvc': 1.13.3 - '@swc/core-win32-ia32-msvc': 1.13.3 - '@swc/core-win32-x64-msvc': 1.13.3 + '@swc/core-darwin-arm64': 1.13.5 + '@swc/core-darwin-x64': 1.13.5 + '@swc/core-linux-arm-gnueabihf': 1.13.5 + '@swc/core-linux-arm64-gnu': 1.13.5 + '@swc/core-linux-arm64-musl': 1.13.5 + '@swc/core-linux-x64-gnu': 1.13.5 + '@swc/core-linux-x64-musl': 1.13.5 + '@swc/core-win32-arm64-msvc': 1.13.5 + '@swc/core-win32-ia32-msvc': 1.13.5 + '@swc/core-win32-x64-msvc': 1.13.5 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -9943,17 +9937,17 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)))': + '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)))': dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)) - '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)))': + '@tailwindcss/typography@0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)))': dependencies: lodash.castarray: 4.4.0 lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)) '@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -9992,14 +9986,13 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 - '@testing-library/jest-dom@6.6.3': + '@testing-library/jest-dom@6.7.0': dependencies: '@adobe/css-tools': 4.4.4 aria-query: 5.3.2 - chalk: 3.0.0 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 - lodash: 4.17.21 + picocolors: 1.1.1 redent: 3.0.0 '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': @@ -10046,11 +10039,11 @@ snapshots: dependencies: '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) - '@tiptap/extension-collaboration@2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(y-prosemirror@1.3.6(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': + '@tiptap/extension-collaboration@2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))(@tiptap/pm@2.26.1)(y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))': dependencies: '@tiptap/core': 2.26.1(@tiptap/pm@2.26.1) '@tiptap/pm': 2.26.1 - y-prosemirror: 1.3.6(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) + y-prosemirror: 1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27) '@tiptap/extension-document@2.26.1(@tiptap/core@2.26.1(@tiptap/pm@2.26.1))': dependencies: @@ -10416,7 +10409,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.17.2': + '@types/node@22.18.0': dependencies: undici-types: 6.21.0 @@ -10504,14 +10497,14 @@ snapshots: dependencies: '@types/node': 20.19.11 - '@typescript-eslint/eslint-plugin@8.38.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.40.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 '@typescript-eslint/parser': 8.40.0(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.38.0 - '@typescript-eslint/type-utils': 8.38.0(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@8.57.1)(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.38.0 + '@typescript-eslint/scope-manager': 8.40.0 + '@typescript-eslint/type-utils': 8.40.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.40.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.40.0 eslint: 8.57.1 graphemer: 1.4.0 ignore: 7.0.5 @@ -10533,15 +10526,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.38.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.40.0(typescript@5.8.3) - '@typescript-eslint/types': 8.40.0 - debug: 4.4.1(supports-color@5.5.0) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.40.0(typescript@5.8.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.40.0(typescript@5.8.3) @@ -10551,29 +10535,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.38.0': - dependencies: - '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/visitor-keys': 8.38.0 - '@typescript-eslint/scope-manager@8.40.0': dependencies: '@typescript-eslint/types': 8.40.0 '@typescript-eslint/visitor-keys': 8.40.0 - '@typescript-eslint/tsconfig-utils@8.38.0(typescript@5.8.3)': - dependencies: - typescript: 5.8.3 - '@typescript-eslint/tsconfig-utils@8.40.0(typescript@5.8.3)': dependencies: typescript: 5.8.3 - '@typescript-eslint/type-utils@8.38.0(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.40.0(eslint@8.57.1)(typescript@5.8.3)': dependencies: - '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) - '@typescript-eslint/utils': 8.38.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/types': 8.40.0 + '@typescript-eslint/typescript-estree': 8.40.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.40.0(eslint@8.57.1)(typescript@5.8.3) debug: 4.4.1(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.1.0(typescript@5.8.3) @@ -10581,26 +10556,8 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.38.0': {} - '@typescript-eslint/types@8.40.0': {} - '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/project-service': 8.38.0(typescript@5.8.3) - '@typescript-eslint/tsconfig-utils': 8.38.0(typescript@5.8.3) - '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/visitor-keys': 8.38.0 - debug: 4.4.1(supports-color@5.5.0) - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.8.3) - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.40.0(typescript@5.8.3)': dependencies: '@typescript-eslint/project-service': 8.40.0(typescript@5.8.3) @@ -10617,28 +10574,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.38.0(eslint@8.57.1)(typescript@5.8.3)': + '@typescript-eslint/utils@8.40.0(eslint@8.57.1)(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) - '@typescript-eslint/scope-manager': 8.38.0 - '@typescript-eslint/types': 8.38.0 - '@typescript-eslint/typescript-estree': 8.38.0(typescript@5.8.3) + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.40.0 + '@typescript-eslint/types': 8.40.0 + '@typescript-eslint/typescript-estree': 8.40.0(typescript@5.8.3) eslint: 8.57.1 typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.38.0': - dependencies: - '@typescript-eslint/types': 8.38.0 - eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.40.0': dependencies: '@typescript-eslint/types': 8.40.0 eslint-visitor-keys: 4.2.1 - '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.2.1': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -10714,13 +10666,13 @@ snapshots: chai: 5.3.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 - magic-string: 0.30.17 + magic-string: 0.30.19 optionalDependencies: - vite: 7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) + vite: 7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1) '@vitest/pretty-format@2.0.5': dependencies: @@ -10899,7 +10851,7 @@ snapshots: ansi-regex@5.0.1: {} - ansi-regex@6.1.0: {} + ansi-regex@6.2.0: {} ansi-styles@3.2.1: dependencies: @@ -11034,7 +10986,7 @@ snapshots: autoprefixer@10.4.14(postcss@8.5.6): dependencies: - browserslist: 4.25.2 + browserslist: 4.25.4 caniuse-lite: 1.0.30001735 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -11042,9 +10994,9 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 - autoprefixer@10.4.21(postcss@8.5.6): + autoprefixer@10.4.20(postcss@8.5.6): dependencies: - browserslist: 4.25.2 + browserslist: 4.25.4 caniuse-lite: 1.0.30001735 fraction.js: 4.3.7 normalize-range: 0.1.2 @@ -11141,12 +11093,12 @@ snapshots: dependencies: pako: 1.0.11 - browserslist@4.25.2: + browserslist@4.25.4: dependencies: - caniuse-lite: 1.0.30001735 - electron-to-chromium: 1.5.204 + caniuse-lite: 1.0.30001741 + electron-to-chromium: 1.5.218 node-releases: 2.0.19 - update-browserslist-db: 1.1.3(browserslist@4.25.2) + update-browserslist-db: 1.1.3(browserslist@4.25.4) buffer-from@1.1.2: {} @@ -11185,16 +11137,18 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.8.1 + tslib: 2.5.3 camelcase-css@2.0.1: {} caniuse-lite@1.0.30001735: {} + caniuse-lite@1.0.30001741: {} + capital-case@1.0.4: dependencies: no-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 upper-case-first: 2.0.2 case-sensitive-paths-webpack-plugin@2.4.0: {} @@ -11236,7 +11190,7 @@ snapshots: path-case: 3.0.4 sentence-case: 3.0.4 snake-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 character-entities@2.0.2: {} @@ -11364,7 +11318,7 @@ snapshots: compute-scroll-into-view@3.1.1: {} - concurrently@9.1.2: + concurrently@9.2.0: dependencies: chalk: 4.1.2 lodash: 4.17.21 @@ -11377,7 +11331,7 @@ snapshots: constant-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 upper-case: 2.0.2 constants-browserify@1.0.0: {} @@ -11434,7 +11388,7 @@ snapshots: crypto-js@4.2.0: {} - css-loader@6.11.0(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)): + css-loader@6.11.0(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)): dependencies: icss-utils: 5.1.0(postcss@8.5.6) postcss: 8.5.6 @@ -11445,7 +11399,7 @@ snapshots: postcss-value-parser: 4.2.0 semver: 7.7.2 optionalDependencies: - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) css-select@4.3.0: dependencies: @@ -11705,7 +11659,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 dotenv@16.0.3: {} @@ -11723,13 +11677,13 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.204: {} + electron-to-chromium@1.5.218: {} element-resize-detector@1.2.4: dependencies: batch-processor: 1.0.0 - emoji-picker-react@4.12.2(react@18.3.1): + emoji-picker-react@4.13.2(react@18.3.1): dependencies: flairup: 1.0.0 react: 18.3.1 @@ -11767,7 +11721,7 @@ snapshots: enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 - tapable: 2.2.2 + tapable: 2.2.3 entities@2.2.0: {} @@ -11927,18 +11881,18 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@14.2.31(eslint@8.57.1)(typescript@5.8.3): + eslint-config-next@14.2.32(eslint@8.57.1)(typescript@5.8.3): dependencies: - '@next/eslint-plugin-next': 14.2.31 - '@rushstack/eslint-patch': 1.12.0 - '@typescript-eslint/eslint-plugin': 8.38.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + '@next/eslint-plugin-next': 14.2.32 + '@rushstack/eslint-patch': 1.10.4 + '@typescript-eslint/eslint-plugin': 8.40.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/parser': 8.40.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@8.57.1) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.40.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) - eslint-plugin-react: 7.37.5(eslint@8.57.1) + eslint-plugin-react: 7.37.3(eslint@8.57.1) eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.57.1) optionalDependencies: typescript: 5.8.3 @@ -12046,7 +12000,7 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-plugin-react@7.37.5(eslint@8.57.1): + eslint-plugin-react@7.37.3(eslint@8.57.1): dependencies: array-includes: 3.1.9 array.prototype.findlast: 1.2.5 @@ -12068,11 +12022,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-storybook@9.1.2(eslint@8.57.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3): + eslint-plugin-storybook@9.1.2(eslint@8.57.1)(storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)))(typescript@5.8.3): dependencies: - '@typescript-eslint/utils': 8.38.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.40.0(eslint@8.57.1)(typescript@5.8.3) eslint: 8.57.1 - storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + storybook: 9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) transitivePeerDependencies: - supports-color - typescript @@ -12098,14 +12052,14 @@ snapshots: eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@8.57.1) + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) '@eslint-community/regexpp': 4.12.1 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.1 '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 + '@ungap/structured-clone': 1.2.1 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 @@ -12265,7 +12219,7 @@ snapshots: dependencies: format: 0.2.2 - fdir@6.4.6(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -12364,7 +12318,7 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - fork-ts-checker-webpack-plugin@8.0.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)): + fork-ts-checker-webpack-plugin@8.0.0(typescript@5.8.3)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)): dependencies: '@babel/code-frame': 7.27.1 chalk: 4.1.2 @@ -12377,9 +12331,9 @@ snapshots: node-abort-controller: 3.1.1 schema-utils: 3.3.0 semver: 7.7.2 - tapable: 2.2.2 + tapable: 2.2.3 typescript: 5.8.3 - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) form-data@4.0.4: dependencies: @@ -12555,7 +12509,7 @@ snapshots: header-case@2.0.4: dependencies: capital-case: 1.0.4 - tslib: 2.8.1 + tslib: 2.5.3 helmet@7.2.0: {} @@ -12593,15 +12547,15 @@ snapshots: relateurl: 0.2.7 terser: 5.43.1 - html-webpack-plugin@5.6.4(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)): + html-webpack-plugin@5.6.4(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)): dependencies: '@types/html-minifier-terser': 6.1.0 html-minifier-terser: 6.1.0 lodash: 4.17.21 pretty-error: 4.0.0 - tapable: 2.2.2 + tapable: 2.2.3 optionalDependencies: - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) htmlparser2@6.1.0: dependencies: @@ -12707,7 +12661,7 @@ snapshots: transitivePeerDependencies: - supports-color - ioredis@5.6.1: + ioredis@5.7.0: dependencies: '@ioredis/commands': 1.3.0 cluster-key-slot: 1.1.2 @@ -12873,7 +12827,7 @@ snapshots: isexe@2.0.0: {} - isomorphic-dompurify@2.25.0: + isomorphic-dompurify@2.26.0: dependencies: dompurify: 3.2.6 jsdom: 26.1.0 @@ -13095,7 +13049,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.8.1 + tslib: 2.5.3 lowlight@2.9.0: dependencies: @@ -13121,9 +13075,9 @@ snapshots: lz-string@1.5.0: {} - magic-string@0.30.17: + magic-string@0.30.19: dependencies: - '@jridgewell/sourcemap-codec': 1.5.4 + '@jridgewell/sourcemap-codec': 1.5.5 make-dir@3.1.0: dependencies: @@ -13467,7 +13421,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.8.1 + tslib: 2.5.3 node-abort-controller@3.1.1: {} @@ -13650,7 +13604,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 parent-module@1.0.1: dependencies: @@ -13674,14 +13628,14 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 path-browserify@1.0.1: {} path-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 path-exists@4.0.0: {} @@ -13721,7 +13675,7 @@ snapshots: pino-http@10.5.0: dependencies: get-caller-file: 2.0.5 - pino: 9.7.0 + pino: 9.9.0 pino-std-serializers: 7.0.0 process-warning: 5.0.0 @@ -13744,7 +13698,7 @@ snapshots: pino-std-serializers@7.0.0: {} - pino@9.7.0: + pino@9.9.0: dependencies: atomic-sleep: 1.0.0 fast-redact: 3.5.0 @@ -13802,13 +13756,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)): + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.1 optionalDependencies: postcss: 8.5.6 - ts-node: 10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3) + ts-node: 10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3) postcss-load-config@5.1.0(jiti@2.5.1)(postcss@8.5.6): dependencies: @@ -13879,7 +13833,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - posthog-js@1.255.1: + posthog-js@1.260.1: dependencies: core-js: 3.45.0 fflate: 0.4.8 @@ -14126,7 +14080,7 @@ snapshots: transitivePeerDependencies: - supports-color - react-docgen@8.0.0: + react-docgen@8.0.1: dependencies: '@babel/core': 7.28.3 '@babel/traverse': 7.28.3 @@ -14588,7 +14542,7 @@ snapshots: sentence-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 upper-case-first: 2.0.2 serialize-javascript@6.0.2: @@ -14709,7 +14663,7 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.8.1 + tslib: 2.5.3 sonic-boom@2.8.0: dependencies: @@ -14762,13 +14716,13 @@ snapshots: - supports-color - utf-8-validate - storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): + storybook@9.1.2(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)): dependencies: '@storybook/global': 5.0.0 - '@testing-library/jest-dom': 6.6.3 + '@testing-library/jest-dom': 6.7.0 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0) '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1)) '@vitest/spy': 3.2.4 better-opn: 3.0.2 esbuild: 0.25.0 @@ -14860,7 +14814,7 @@ snapshots: strip-ansi@7.1.0: dependencies: - ansi-regex: 6.1.0 + ansi-regex: 6.2.0 strip-bom@3.0.0: {} @@ -14874,9 +14828,9 @@ snapshots: strip-json-comments@3.1.1: {} - style-loader@3.3.4(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)): + style-loader@3.3.4(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)): dependencies: - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) style-to-object@0.4.4: dependencies: @@ -14915,11 +14869,11 @@ snapshots: svg-arc-to-cubic-bezier@3.2.0: {} - swc-loader@0.2.6(@swc/core@1.13.3(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)): + swc-loader@0.2.6(@swc/core@1.13.5(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)): dependencies: - '@swc/core': 1.13.3(@swc/helpers@0.5.17) + '@swc/core': 1.13.5(@swc/helpers@0.5.17) '@swc/counter': 0.1.3 - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) swr@2.2.4(react@18.3.1): dependencies: @@ -14935,11 +14889,11 @@ snapshots: tailwind-merge@3.3.1: {} - tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3))): + tailwindcss-animate@1.0.7(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3))): dependencies: - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)) - tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)): + tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -14958,7 +14912,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.0.1(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3)) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3)) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -14966,18 +14920,18 @@ snapshots: transitivePeerDependencies: - ts-node - tapable@2.2.2: {} + tapable@2.2.3: {} - terser-webpack-plugin@5.3.14(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)): + terser-webpack-plugin@5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)): dependencies: - '@jridgewell/trace-mapping': 0.3.29 + '@jridgewell/trace-mapping': 0.3.30 jest-worker: 27.5.1 schema-utils: 4.3.2 serialize-javascript: 6.0.2 terser: 5.43.1 - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) optionalDependencies: - '@swc/core': 1.13.3(@swc/helpers@0.5.17) + '@swc/core': 1.13.5(@swc/helpers@0.5.17) esbuild: 0.25.0 terser@5.43.1: @@ -15015,7 +14969,7 @@ snapshots: tinyglobby@0.2.14: dependencies: - fdir: 6.4.6(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 tinyrainbow@1.2.0: {} @@ -15080,7 +15034,7 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@20.19.11)(typescript@5.8.3): + ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@20.19.11)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -15098,16 +15052,16 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.13.3(@swc/helpers@0.5.17) + '@swc/core': 1.13.5(@swc/helpers@0.5.17) - ts-node@10.9.2(@swc/core@1.13.3(@swc/helpers@0.5.17))(@types/node@22.17.2)(typescript@5.8.3): + ts-node@10.9.2(@swc/core@1.13.5(@swc/helpers@0.5.17))(@types/node@22.18.0)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.17.2 + '@types/node': 22.18.0 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -15118,7 +15072,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.13.3(@swc/helpers@0.5.17) + '@swc/core': 1.13.5(@swc/helpers@0.5.17) optional: true tsconfig-paths@3.15.0: @@ -15339,19 +15293,19 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.1.3(browserslist@4.25.2): + update-browserslist-db@1.1.3(browserslist@4.25.4): dependencies: - browserslist: 4.25.2 + browserslist: 4.25.4 escalade: 3.2.0 picocolors: 1.1.1 upper-case-first@2.0.2: dependencies: - tslib: 2.8.1 + tslib: 2.5.3 upper-case@2.0.2: dependencies: - tslib: 2.8.1 + tslib: 2.5.3 uri-js@4.4.1: dependencies: @@ -15452,16 +15406,16 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 - vite@7.0.0(@types/node@22.17.2)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1): + vite@7.0.7(@types/node@22.18.0)(jiti@2.5.1)(terser@5.43.1)(yaml@2.8.1): dependencies: esbuild: 0.25.0 - fdir: 6.4.6(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.50.0 tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 22.17.2 + '@types/node': 22.18.0 fsevents: 2.3.3 jiti: 2.5.1 terser: 5.43.1 @@ -15488,7 +15442,7 @@ snapshots: webidl-conversions@7.0.0: {} - webpack-dev-middleware@6.1.3(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)): + webpack-dev-middleware@6.1.3(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)): dependencies: colorette: 2.0.20 memfs: 3.5.3 @@ -15496,7 +15450,7 @@ snapshots: range-parser: 1.2.1 schema-utils: 4.3.2 optionalDependencies: - webpack: 5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0) + webpack: 5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0) webpack-hot-middleware@2.26.1: dependencies: @@ -15508,7 +15462,7 @@ snapshots: webpack-virtual-modules@0.6.2: {} - webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0): + webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -15518,7 +15472,7 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.15.0 acorn-import-phases: 1.0.4(acorn@8.15.0) - browserslist: 4.25.2 + browserslist: 4.25.4 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.3 es-module-lexer: 1.7.0 @@ -15531,8 +15485,8 @@ snapshots: mime-types: 2.1.35 neo-async: 2.6.2 schema-utils: 4.3.2 - tapable: 2.2.2 - terser-webpack-plugin: 5.3.14(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)(webpack@5.101.3(@swc/core@1.13.3(@swc/helpers@0.5.17))(esbuild@0.25.0)) + tapable: 2.2.3 + terser-webpack-plugin: 5.3.14(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)(webpack@5.101.3(@swc/core@1.13.5(@swc/helpers@0.5.17))(esbuild@0.25.0)) watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: @@ -15650,7 +15604,7 @@ snapshots: lib0: 0.2.114 yjs: 13.6.27 - y-prosemirror@1.3.6(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): + y-prosemirror@1.3.7(prosemirror-model@1.25.3)(prosemirror-state@1.4.3)(prosemirror-view@1.40.0)(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27): dependencies: lib0: 0.2.114 prosemirror-model: 1.25.3 From 1f7eef5f81a7ff60d9d6fe4841a67b2f3449c5a2 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Tue, 16 Sep 2025 01:24:48 +0530 Subject: [PATCH 033/169] chore: django import error --- .../plane/authentication/views/space/magic.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index 052a2118a..81ef6f77f 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -1,3 +1,5 @@ +from urllib.parse import urljoin, urlencode + # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect @@ -66,7 +68,7 @@ class MagicSignInSpaceEndpoint(View): url = get_safe_redirect_url( base_url=base_host(request=request, is_space=True), next_path=next_path, - params=params + params=params, ) return HttpResponseRedirect(url) @@ -81,7 +83,7 @@ class MagicSignInSpaceEndpoint(View): url = get_safe_redirect_url( base_url=base_host(request=request, is_space=True), next_path=next_path, - params=params + params=params, ) return HttpResponseRedirect(url) @@ -95,16 +97,14 @@ class MagicSignInSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # redirect to referer path url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path + base_url=base_host(request=request, is_space=True), next_path=next_path ) return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() base_url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path + base_url=base_host(request=request, is_space=True), next_path=next_path ) url = urljoin(base_url, "?" + urlencode(params)) return HttpResponseRedirect(url) @@ -128,7 +128,7 @@ class MagicSignUpSpaceEndpoint(View): url = get_safe_redirect_url( base_url=base_host(request=request, is_space=True), next_path=next_path, - params=params + params=params, ) return HttpResponseRedirect(url) # Existing User @@ -143,7 +143,7 @@ class MagicSignUpSpaceEndpoint(View): url = get_safe_redirect_url( base_url=base_host(request=request, is_space=True), next_path=next_path, - params=params + params=params, ) return HttpResponseRedirect(url) @@ -156,8 +156,7 @@ class MagicSignUpSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # redirect to referer path url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path + base_url=base_host(request=request, is_space=True), next_path=next_path ) return HttpResponseRedirect(url) @@ -166,6 +165,6 @@ class MagicSignUpSpaceEndpoint(View): url = get_safe_redirect_url( base_url=base_host(request=request, is_space=True), next_path=next_path, - params=params + params=params, ) return HttpResponseRedirect(url) From 56d3a9e049ed95c6b377561a4586a3a2d496a7cb Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:57:20 +0530 Subject: [PATCH 034/169] [WEB-4900] refactor: remove base_host retrieval from authentication views (#7804) * refactor: remove base_host retrieval from authentication views * Removed unnecessary base_host retrieval from GitHub, GitLab, and Google callback endpoints. * Updated MagicSignUpEndpoint to use get_safe_redirect_url for URL construction. * Refactored MagicSignInSpaceEndpoint to streamline URL redirection logic. * refactor: streamline URL redirection in MagicSignInSpaceEndpoint * Removed redundant base_url retrieval from the exception handling in MagicSignInSpaceEndpoint. * Enhanced the clarity of URL construction by directly using get_safe_redirect_url. --- apps/api/plane/authentication/views/app/github.py | 1 - apps/api/plane/authentication/views/app/gitlab.py | 1 - apps/api/plane/authentication/views/app/google.py | 1 - apps/api/plane/authentication/views/app/magic.py | 2 -- apps/api/plane/authentication/views/space/magic.py | 9 ++++----- 5 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/api/plane/authentication/views/app/github.py b/apps/api/plane/authentication/views/app/github.py index 425f12549..35c4d2121 100644 --- a/apps/api/plane/authentication/views/app/github.py +++ b/apps/api/plane/authentication/views/app/github.py @@ -60,7 +60,6 @@ class GitHubCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): diff --git a/apps/api/plane/authentication/views/app/gitlab.py b/apps/api/plane/authentication/views/app/gitlab.py index e22911d32..b2e5da80f 100644 --- a/apps/api/plane/authentication/views/app/gitlab.py +++ b/apps/api/plane/authentication/views/app/gitlab.py @@ -61,7 +61,6 @@ class GitLabCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): diff --git a/apps/api/plane/authentication/views/app/google.py b/apps/api/plane/authentication/views/app/google.py index aa65fa7fb..cfa409ae5 100644 --- a/apps/api/plane/authentication/views/app/google.py +++ b/apps/api/plane/authentication/views/app/google.py @@ -62,7 +62,6 @@ class GoogleCallbackEndpoint(View): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") - base_host = request.session.get("host") next_path = request.session.get("next_path") if state != request.session.get("state", ""): diff --git a/apps/api/plane/authentication/views/app/magic.py b/apps/api/plane/authentication/views/app/magic.py index 9be3693e5..694fca6cb 100644 --- a/apps/api/plane/authentication/views/app/magic.py +++ b/apps/api/plane/authentication/views/app/magic.py @@ -160,8 +160,6 @@ class MagicSignUpEndpoint(View): error_message="USER_ALREADY_EXIST", ) params = exc.get_error_dict() - if next_path: - params["next_path"] = str(next_path) url = get_safe_redirect_url( base_url=base_host(request=request, is_app=True), next_path=next_path, diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index 81ef6f77f..0a5f2b42c 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -1,5 +1,3 @@ -from urllib.parse import urljoin, urlencode - # Django imports from django.core.validators import validate_email from django.http import HttpResponseRedirect @@ -103,10 +101,11 @@ class MagicSignInSpaceEndpoint(View): except AuthenticationException as e: params = e.get_error_dict() - base_url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), next_path=next_path + url = get_safe_redirect_url( + base_url=base_host(request=request, is_space=True), + next_path=next_path, + params=params, ) - url = urljoin(base_url, "?" + urlencode(params)) return HttpResponseRedirect(url) From bf45635a7b741fa71b312e51d15dd535fdbfeb0b Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:35:31 +0530 Subject: [PATCH 035/169] [WEB-4898] fix: extended sidebar toggle #7797 --- apps/web/core/components/command-palette/command-palette.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/core/components/command-palette/command-palette.tsx b/apps/web/core/components/command-palette/command-palette.tsx index 85115a3d7..7b326b91f 100644 --- a/apps/web/core/components/command-palette/command-palette.tsx +++ b/apps/web/core/components/command-palette/command-palette.tsx @@ -38,7 +38,7 @@ export const CommandPalette: FC = observer(() => { const { workspaceSlug, projectId: paramsProjectId, workItem } = useParams(); // store hooks const { fetchIssueWithIdentifier } = useIssueDetail(); - const { toggleSidebar } = useAppTheme(); + const { toggleSidebar, toggleExtendedSidebar } = useAppTheme(); const { platform } = usePlatformOS(); const { data: currentUser, canPerformAnyCreateAction } = useUser(); const { toggleCommandPaletteModal, isShortcutModalOpen, toggleShortcutModal, isAnyModalOpen } = useCommandPalette(); @@ -197,6 +197,7 @@ export const CommandPalette: FC = observer(() => { } else if (keyPressed === "b") { e.preventDefault(); toggleSidebar(); + toggleExtendedSidebar(false); } } else if (!isAnyModalOpen) { captureClick({ elementName: COMMAND_PALETTE_TRACKER_ELEMENTS.COMMAND_PALETTE_SHORTCUT_KEY }); @@ -242,6 +243,7 @@ export const CommandPalette: FC = observer(() => { toggleCommandPaletteModal, toggleShortcutModal, toggleSidebar, + toggleExtendedSidebar, workspaceSlug, ] ); From 4d17637edf07b3db586f08e3a0a551e451d28a72 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:44:26 +0530 Subject: [PATCH 036/169] [WEB-4943] refactor: streamline URL construction in authentication views (#7806) * refactor: streamline URL construction in authentication views * Updated MagicSignInSpaceEndpoint and MagicSignUpSpaceEndpoint to directly construct redirect URLs using formatted strings instead of the get_safe_redirect_url function. * Enhanced get_safe_redirect_url to use quote for safer URL encoding of parameters. * refactor: enhance URL validation and redirection in authentication views * Added validate_next_path function to improve the safety of redirect URLs in MagicSignInSpaceEndpoint and MagicSignUpSpaceEndpoint. * Updated URL construction to ensure proper handling of next_path and base_url. * Streamlined the get_safe_redirect_url function for better parameter encoding. * refactor: unify URL redirection logic across authentication views * Introduced validate_next_path function to enhance URL safety in SignInAuthSpaceEndpoint, SignUpAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, and GoogleCallbackSpaceEndpoint. * Updated URL construction to directly format the redirect URL, improving clarity and consistency across multiple authentication views. --- apps/api/plane/authentication/views/space/email.py | 9 +++------ apps/api/plane/authentication/views/space/github.py | 9 +++------ apps/api/plane/authentication/views/space/gitlab.py | 9 +++------ apps/api/plane/authentication/views/space/google.py | 9 +++------ apps/api/plane/authentication/views/space/magic.py | 12 +++++------- apps/api/plane/utils/path_validator.py | 12 ++++++------ 6 files changed, 23 insertions(+), 37 deletions(-) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index cd0954db8..d247f6e98 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -14,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import get_safe_redirect_url +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path class SignInAuthSpaceEndpoint(View): @@ -198,11 +198,8 @@ class SignUpAuthSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path, - params={} - ) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py index e3b64e8a0..dd148b8c1 100644 --- a/apps/api/plane/authentication/views/space/github.py +++ b/apps/api/plane/authentication/views/space/github.py @@ -14,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import get_safe_redirect_url +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path class GitHubOauthInitiateSpaceEndpoint(View): @@ -93,11 +93,8 @@ class GitHubCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path, - params=params - ) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py index a63466005..77a10a914 100644 --- a/apps/api/plane/authentication/views/space/gitlab.py +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -14,7 +14,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import get_safe_redirect_url +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path class GitLabOauthInitiateSpaceEndpoint(View): @@ -94,11 +94,8 @@ class GitLabCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # Process workspace and project invitations # redirect to referer path - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path, - params=params - ) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py index 7b9728762..d8fef9da4 100644 --- a/apps/api/plane/authentication/views/space/google.py +++ b/apps/api/plane/authentication/views/space/google.py @@ -14,7 +14,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import get_safe_redirect_url +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path class GoogleOauthInitiateSpaceEndpoint(View): @@ -90,11 +90,8 @@ class GoogleCallbackSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path, - params=params - ) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" return HttpResponseRedirect(url) except AuthenticationException as e: params = e.get_error_dict() diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index 0a5f2b42c..f50274a4a 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -20,7 +20,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import get_safe_redirect_url +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path class MagicGenerateSpaceEndpoint(APIView): @@ -94,9 +94,8 @@ class MagicSignInSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), next_path=next_path - ) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" return HttpResponseRedirect(url) except AuthenticationException as e: @@ -154,9 +153,8 @@ class MagicSignUpSpaceEndpoint(View): # Login the user and record his device info user_login(request=request, user=user, is_space=True) # redirect to referer path - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), next_path=next_path - ) + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" return HttpResponseRedirect(url) except AuthenticationException as e: diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index ebac7ca0b..e5bf7aeb2 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -1,7 +1,6 @@ # Python imports from urllib.parse import urlparse - def _contains_suspicious_patterns(path: str) -> bool: """ Check for suspicious patterns that might indicate malicious intent. @@ -84,15 +83,16 @@ def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) Returns: str: The safe redirect URL """ - from urllib.parse import urlencode + from urllib.parse import urlencode, quote # Validate the next path validated_path = validate_next_path(next_path) # Add the next path to the parameters - if validated_path: - params["next_path"] = validated_path + base_url = base_url.rstrip('/') + if params: + encoded_params = urlencode(params) + return f"{base_url}/?next_path={validated_path}&{encoded_params}" - # Return the safe redirect URL - return f"{base_url.rstrip('/')}?{urlencode(params)}" + return f"{base_url}/?next_path={validated_path}" \ No newline at end of file From 00e070b5094e279692516f1aca7caf0e93440fa6 Mon Sep 17 00:00:00 2001 From: sriramveeraghanta Date: Tue, 16 Sep 2025 20:46:44 +0530 Subject: [PATCH 037/169] fix: codeql triggers --- .github/workflows/codeql.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2bcbf557f..e3aba5cf1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,11 +3,9 @@ name: "CodeQL" on: workflow_dispatch: push: - branches: ["preview", "master"] + branches: ["preview", "canary", "master"] pull_request: - branches: ["develop", "preview", "master"] - schedule: - - cron: "53 19 * * 5" + branches: ["preview", "canary", "master"] jobs: analyze: From d521eab22f4f0d3c20fc8e933173067d8ba325db Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Tue, 16 Sep 2025 21:15:08 +0530 Subject: [PATCH 038/169] [WEB-4885] feat: new filters architecture and UI components (#7802) * feat: add rich filters types * feat: add rich filters constants * feat: add rich filters utils * feat: add rich filters store in shared state package * feat: add rich filters UI components * fix: make setLoading optional in loadOptions function for improved flexibility * chore: minor improvements to rich filters * fix: formatting --- .../core/components/dropdowns/date-range.tsx | 65 ++- apps/web/core/components/dropdowns/date.tsx | 6 +- .../rich-filters/add-filters-button.tsx | 106 ++++ .../components/rich-filters/filter-item.tsx | 161 ++++++ .../filter-value-input/date/range.tsx | 59 +++ .../filter-value-input/date/single.tsx | 46 ++ .../rich-filters/filter-value-input/root.tsx | 90 ++++ .../filter-value-input/select/multi.tsx | 54 ++ .../select/selected-options-display.tsx | 61 +++ .../filter-value-input/select/shared.tsx | 54 ++ .../filter-value-input/select/single.tsx | 58 +++ .../components/rich-filters/filters-row.tsx | 185 +++++++ .../core/components/rich-filters/shared.ts | 1 + apps/web/package.json | 1 + packages/constants/src/index.ts | 45 +- packages/constants/src/rich-filters/index.ts | 2 + .../src/rich-filters/operator-labels/core.ts | 24 + .../rich-filters/operator-labels/extended.ts | 21 + .../src/rich-filters/operator-labels/index.ts | 36 ++ packages/constants/src/rich-filters/option.ts | 63 +++ packages/shared-state/package.json | 8 + packages/shared-state/src/index.ts | 2 + packages/shared-state/src/store/index.ts | 1 + .../src/store/rich-filters/adapter.ts | 31 ++ .../src/store/rich-filters/config-manager.ts | 173 +++++++ .../src/store/rich-filters/config.ts | 212 ++++++++ .../src/store/rich-filters/filter-helpers.ts | 172 ++++++ .../src/store/rich-filters/filter.ts | 490 ++++++++++++++++++ .../src/store/rich-filters/index.ts | 2 + packages/shared-state/src/utils/index.ts | 1 + .../src/utils/rich-filter.helper.ts | 47 ++ packages/types/src/index.ts | 76 +-- packages/types/src/rich-filters/adapter.ts | 23 + packages/types/src/rich-filters/builder.ts | 29 ++ .../src/rich-filters/config/filter-config.ts | 18 + .../types/src/rich-filters/config/index.ts | 1 + .../types/src/rich-filters/derived/core.ts | 77 +++ .../src/rich-filters/derived/extended.ts | 19 + .../types/src/rich-filters/derived/index.ts | 43 ++ .../types/src/rich-filters/derived/shared.ts | 9 + packages/types/src/rich-filters/expression.ts | 110 ++++ .../src/rich-filters/field-types/core.ts | 79 +++ .../src/rich-filters/field-types/extended.ts | 13 + .../src/rich-filters/field-types/index.ts | 27 + .../src/rich-filters/field-types/shared.ts | 37 ++ packages/types/src/rich-filters/index.ts | 8 + .../src/rich-filters/operator-configs/core.ts | 26 + .../rich-filters/operator-configs/extended.ts | 13 + .../rich-filters/operator-configs/index.ts | 56 ++ .../types/src/rich-filters/operators/core.ts | 38 ++ .../src/rich-filters/operators/extended.ts | 33 ++ .../types/src/rich-filters/operators/index.ts | 59 +++ packages/types/src/utils.ts | 2 + .../ui/src/dropdowns/custom-search-select.tsx | 1 + packages/ui/src/dropdowns/helper.tsx | 2 +- packages/utils/src/datetime.ts | 21 +- packages/utils/src/index.ts | 3 +- .../rich-filters/factories/configs/core.ts | 156 ++++++ .../rich-filters/factories/configs/index.ts | 2 + .../rich-filters/factories/configs/shared.ts | 76 +++ .../utils/src/rich-filters/factories/index.ts | 3 + .../src/rich-filters/factories/nodes/core.ts | 39 ++ packages/utils/src/rich-filters/index.ts | 6 + .../src/rich-filters/operations/comparison.ts | 170 ++++++ .../src/rich-filters/operations/index.ts | 4 + .../operations/manipulation/core.ts | 124 +++++ .../operations/transformation/core.ts | 178 +++++++ .../operations/transformation/shared.ts | 18 + .../rich-filters/operations/traversal/core.ts | 210 ++++++++ .../operations/traversal/shared.ts | 23 + .../utils/src/rich-filters/operators/core.ts | 42 ++ .../utils/src/rich-filters/operators/index.ts | 2 + .../src/rich-filters/operators/shared.ts | 24 + packages/utils/src/rich-filters/types/core.ts | 68 +++ .../utils/src/rich-filters/types/index.ts | 2 + .../utils/src/rich-filters/types/shared.ts | 35 ++ .../utils/src/rich-filters/validators/core.ts | 52 ++ .../src/rich-filters/validators/index.ts | 2 + .../src/rich-filters/validators/shared.ts | 22 + .../utils/src/rich-filters/values/core.ts | 24 + .../utils/src/rich-filters/values/index.ts | 1 + pnpm-lock.yaml | 78 ++- pnpm-workspace.yaml | 1 + 83 files changed, 4345 insertions(+), 117 deletions(-) create mode 100644 apps/web/core/components/rich-filters/add-filters-button.tsx create mode 100644 apps/web/core/components/rich-filters/filter-item.tsx create mode 100644 apps/web/core/components/rich-filters/filter-value-input/date/range.tsx create mode 100644 apps/web/core/components/rich-filters/filter-value-input/date/single.tsx create mode 100644 apps/web/core/components/rich-filters/filter-value-input/root.tsx create mode 100644 apps/web/core/components/rich-filters/filter-value-input/select/multi.tsx create mode 100644 apps/web/core/components/rich-filters/filter-value-input/select/selected-options-display.tsx create mode 100644 apps/web/core/components/rich-filters/filter-value-input/select/shared.tsx create mode 100644 apps/web/core/components/rich-filters/filter-value-input/select/single.tsx create mode 100644 apps/web/core/components/rich-filters/filters-row.tsx create mode 100644 apps/web/core/components/rich-filters/shared.ts create mode 100644 packages/constants/src/rich-filters/index.ts create mode 100644 packages/constants/src/rich-filters/operator-labels/core.ts create mode 100644 packages/constants/src/rich-filters/operator-labels/extended.ts create mode 100644 packages/constants/src/rich-filters/operator-labels/index.ts create mode 100644 packages/constants/src/rich-filters/option.ts create mode 100644 packages/shared-state/src/store/index.ts create mode 100644 packages/shared-state/src/store/rich-filters/adapter.ts create mode 100644 packages/shared-state/src/store/rich-filters/config-manager.ts create mode 100644 packages/shared-state/src/store/rich-filters/config.ts create mode 100644 packages/shared-state/src/store/rich-filters/filter-helpers.ts create mode 100644 packages/shared-state/src/store/rich-filters/filter.ts create mode 100644 packages/shared-state/src/store/rich-filters/index.ts create mode 100644 packages/shared-state/src/utils/index.ts create mode 100644 packages/shared-state/src/utils/rich-filter.helper.ts create mode 100644 packages/types/src/rich-filters/adapter.ts create mode 100644 packages/types/src/rich-filters/builder.ts create mode 100644 packages/types/src/rich-filters/config/filter-config.ts create mode 100644 packages/types/src/rich-filters/config/index.ts create mode 100644 packages/types/src/rich-filters/derived/core.ts create mode 100644 packages/types/src/rich-filters/derived/extended.ts create mode 100644 packages/types/src/rich-filters/derived/index.ts create mode 100644 packages/types/src/rich-filters/derived/shared.ts create mode 100644 packages/types/src/rich-filters/expression.ts create mode 100644 packages/types/src/rich-filters/field-types/core.ts create mode 100644 packages/types/src/rich-filters/field-types/extended.ts create mode 100644 packages/types/src/rich-filters/field-types/index.ts create mode 100644 packages/types/src/rich-filters/field-types/shared.ts create mode 100644 packages/types/src/rich-filters/index.ts create mode 100644 packages/types/src/rich-filters/operator-configs/core.ts create mode 100644 packages/types/src/rich-filters/operator-configs/extended.ts create mode 100644 packages/types/src/rich-filters/operator-configs/index.ts create mode 100644 packages/types/src/rich-filters/operators/core.ts create mode 100644 packages/types/src/rich-filters/operators/extended.ts create mode 100644 packages/types/src/rich-filters/operators/index.ts create mode 100644 packages/utils/src/rich-filters/factories/configs/core.ts create mode 100644 packages/utils/src/rich-filters/factories/configs/index.ts create mode 100644 packages/utils/src/rich-filters/factories/configs/shared.ts create mode 100644 packages/utils/src/rich-filters/factories/index.ts create mode 100644 packages/utils/src/rich-filters/factories/nodes/core.ts create mode 100644 packages/utils/src/rich-filters/index.ts create mode 100644 packages/utils/src/rich-filters/operations/comparison.ts create mode 100644 packages/utils/src/rich-filters/operations/index.ts create mode 100644 packages/utils/src/rich-filters/operations/manipulation/core.ts create mode 100644 packages/utils/src/rich-filters/operations/transformation/core.ts create mode 100644 packages/utils/src/rich-filters/operations/transformation/shared.ts create mode 100644 packages/utils/src/rich-filters/operations/traversal/core.ts create mode 100644 packages/utils/src/rich-filters/operations/traversal/shared.ts create mode 100644 packages/utils/src/rich-filters/operators/core.ts create mode 100644 packages/utils/src/rich-filters/operators/index.ts create mode 100644 packages/utils/src/rich-filters/operators/shared.ts create mode 100644 packages/utils/src/rich-filters/types/core.ts create mode 100644 packages/utils/src/rich-filters/types/index.ts create mode 100644 packages/utils/src/rich-filters/types/shared.ts create mode 100644 packages/utils/src/rich-filters/validators/core.ts create mode 100644 packages/utils/src/rich-filters/validators/index.ts create mode 100644 packages/utils/src/rich-filters/validators/shared.ts create mode 100644 packages/utils/src/rich-filters/values/core.ts create mode 100644 packages/utils/src/rich-filters/values/index.ts diff --git a/apps/web/core/components/dropdowns/date-range.tsx b/apps/web/core/components/dropdowns/date-range.tsx index 3cb07d970..a34abcc98 100644 --- a/apps/web/core/components/dropdowns/date-range.tsx +++ b/apps/web/core/components/dropdowns/date-range.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { observer } from "mobx-react"; +import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; @@ -59,6 +60,8 @@ type Props = { renderPlaceholder?: boolean; customTooltipContent?: React.ReactNode; customTooltipHeading?: string; + defaultOpen?: boolean; + renderInPortal?: boolean; }; export const DateRangeDropdown: React.FC = observer((props) => { @@ -93,9 +96,11 @@ export const DateRangeDropdown: React.FC = observer((props) => { renderPlaceholder = true, customTooltipContent, customTooltipHeading, + defaultOpen = false, + renderInPortal = false, } = props; // states - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); const [dateRange, setDateRange] = useState(value); // hooks const { data } = useUserProfile(); @@ -193,7 +198,9 @@ export const DateRangeDropdown: React.FC = observer((props) => { renderPlaceholder && ( <> {placeholder.from} - + {placeholder.from && placeholder.to && ( + + )} {placeholder.to} ) @@ -247,6 +254,34 @@ export const DateRangeDropdown: React.FC = observer((props) => { ); + const comboOptions = ( + +
+ { + onSelect?.(val); + }} + mode="range" + disabled={disabledDays} + showOutsideDays + fixedWeeks + weekStartsOn={startOfWeek} + initialFocus + /> +
+
+ ); + + const Options = renderInPortal ? createPortal(comboOptions, document.body) : comboOptions; + return ( = observer((props) => { disabled={disabled} renderByDefault={renderByDefault} > - {isOpen && ( - -
- { - onSelect?.(val); - }} - mode="range" - disabled={disabledDays} - showOutsideDays - fixedWeeks - weekStartsOn={startOfWeek} - initialFocus - /> -
-
- )} + {isOpen && Options}
); }); diff --git a/apps/web/core/components/dropdowns/date.tsx b/apps/web/core/components/dropdowns/date.tsx index 87d783d9a..b24713e84 100644 --- a/apps/web/core/components/dropdowns/date.tsx +++ b/apps/web/core/components/dropdowns/date.tsx @@ -1,3 +1,5 @@ +"use client"; + import React, { useRef, useState } from "react"; import { observer } from "mobx-react"; import { createPortal } from "react-dom"; @@ -21,6 +23,7 @@ import { TDropdownProps } from "./types"; type Props = TDropdownProps & { clearIconClassName?: string; + defaultOpen?: boolean; optionsClassName?: string; icon?: React.ReactNode; isClearable?: boolean; @@ -41,6 +44,7 @@ export const DateDropdown: React.FC = observer((props) => { buttonVariant, className = "", clearIconClassName = "", + defaultOpen = false, optionsClassName = "", closeOnSelect = true, disabled = false, @@ -60,7 +64,7 @@ export const DateDropdown: React.FC = observer((props) => { renderByDefault = true, } = props; // states - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(defaultOpen); // refs const dropdownRef = useRef(null); // hooks diff --git a/apps/web/core/components/rich-filters/add-filters-button.tsx b/apps/web/core/components/rich-filters/add-filters-button.tsx new file mode 100644 index 000000000..512d16fee --- /dev/null +++ b/apps/web/core/components/rich-filters/add-filters-button.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { ListFilter } from "lucide-react"; +// plane imports +import { IFilterInstance } from "@plane/shared-state"; +import { LOGICAL_OPERATOR, TExternalFilter, TFilterProperty } from "@plane/types"; +import { CustomSearchSelect, getButtonStyling, TButtonVariant } from "@plane/ui"; +import { cn, getOperatorForPayload } from "@plane/utils"; + +export type TAddFilterButtonProps

= { + buttonConfig?: { + label?: string; + variant?: TButtonVariant; + className?: string; + defaultOpen?: boolean; + iconConfig?: { + shouldShowIcon: boolean; + iconComponent?: React.ReactNode; + }; + isDisabled?: boolean; + }; + filter: IFilterInstance; + onFilterSelect?: (id: string) => void; +}; + +export const AddFilterButton = observer( +

(props: TAddFilterButtonProps) => { + const { filter, buttonConfig, onFilterSelect } = props; + const { + label = "Filters", + variant = "link-neutral", + className, + defaultOpen = false, + iconConfig = { shouldShowIcon: true }, + isDisabled = false, + } = buttonConfig || {}; + + // Transform available filter configs to CustomSearchSelect options format + const filterOptions = filter.configManager.allAvailableConfigs.map((config) => ({ + value: config.id, + content: ( +

+ {config.icon && ( + + )} + {config.label} +
+ ), + query: config.label.toLowerCase(), + })); + + // If all filters are applied, show disabled options + const allFiltersApplied = filterOptions.length === 0; + const displayOptions = allFiltersApplied + ? [ + { + value: "all_filters_applied", + content:
All filters applied
, + query: "all filters applied", + disabled: true, + }, + ] + : filterOptions; + + const handleFilterSelect = (property: P) => { + const config = filter.configManager.getConfigByProperty(property); + if (config && config.firstOperator) { + const { operator, isNegation } = getOperatorForPayload(config.firstOperator); + filter.addCondition( + LOGICAL_OPERATOR.AND, + { + property: config.id, + operator, + value: undefined, + }, + isNegation + ); + onFilterSelect?.(property); + } + }; + + if (isDisabled) return null; + return ( +
+ + {iconConfig.shouldShowIcon && + (iconConfig.iconComponent || )} + {label} +
+ } + /> +
+ ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-item.tsx b/apps/web/core/components/rich-filters/filter-item.tsx new file mode 100644 index 000000000..9576c1c33 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-item.tsx @@ -0,0 +1,161 @@ +import React, { useRef, useEffect } from "react"; +import { observer } from "mobx-react"; +import { X } from "lucide-react"; +// plane imports +import { IFilterInstance } from "@plane/shared-state"; +import { + SingleOrArray, + TExternalFilter, + TFilterProperty, + TFilterValue, + TFilterConditionNodeForDisplay, + TAllAvailableOperatorsForDisplay, +} from "@plane/types"; +import { CustomSearchSelect } from "@plane/ui"; +import { cn, hasValidValue, getOperatorForPayload } from "@plane/utils"; +// local imports +import { FilterValueInput } from "./filter-value-input/root"; +import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "./shared"; + +interface FilterItemProps

{ + condition: TFilterConditionNodeForDisplay; + filter: IFilterInstance; + isDisabled?: boolean; + showTransition?: boolean; +} + +export const FilterItem = observer( +

(props: FilterItemProps) => { + const { condition, filter, isDisabled = false, showTransition = true } = props; + // refs + const itemRef = useRef(null); + // derived values + const filterConfig = condition?.property ? filter.configManager.getConfigByProperty(condition.property) : undefined; + const operatorOptions = filterConfig + ?.getAllDisplayOperatorOptionsByValue(condition.value as TFilterValue) + .map((option) => ({ + value: option.value, + content: option.label, + query: option.label.toLowerCase(), + })); + const selectedOperatorFieldConfig = filterConfig?.getOperatorConfig(condition.operator); + const selectedOperatorOption = filterConfig?.getDisplayOperatorByValue( + condition.operator, + condition.value as TFilterValue + ); + // Disable operator selection when filter is disabled or only one operator option is available and selected + const isOperatorSelectionDisabled = + isDisabled || + (condition.operator && operatorOptions?.length === 1 && operatorOptions[0]?.value === condition.operator); + + // effects + useEffect(() => { + if (!showTransition) return; + + const element = itemRef.current; + if (!element) return; + + if (hasValidValue(condition.value)) return; + + const applyInitialStyles = () => { + element.style.opacity = "0"; + element.style.transform = "scale(0.95)"; + }; + + const applyFinalStyles = () => { + // Force a reflow to ensure the initial state is applied + void element.offsetWidth; + element.style.opacity = "1"; + element.style.transform = "scale(1)"; + }; + + applyInitialStyles(); + applyFinalStyles(); + + return () => { + applyInitialStyles(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleOperatorChange = (operator: TAllAvailableOperatorsForDisplay) => { + if (operator) { + const { operator: positiveOperator, isNegation } = getOperatorForPayload(operator); + filter.updateConditionOperator(condition.id, positiveOperator, isNegation); + } + }; + + const handleValueChange = (values: SingleOrArray) => { + filter.updateConditionValue(condition.id, values); + }; + + const handleRemoveFilter = () => { + filter.removeCondition(condition.id); + }; + + if (!filterConfig || !filterConfig.isEnabled) return null; + return ( +

+ {/* Property section */} +
+ {filterConfig.icon && ( +
+ +
+ )} + {filterConfig.label} +
+ + {/* Operator section */} + + {filterConfig.getLabelForOperator(selectedOperatorOption)} +
+ } + /> + + {/* Value section */} + {selectedOperatorFieldConfig && ( + + )} + + {/* Remove button */} + {!isDisabled && ( + + )} +
+ ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/date/range.tsx b/apps/web/core/components/rich-filters/filter-value-input/date/range.tsx new file mode 100644 index 000000000..df60d4d5e --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/date/range.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import { TDateRangeFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types"; +import { cn, isValidDate, renderFormattedPayloadDate, toFilterArray } from "@plane/utils"; +// components +import { DateRangeDropdown } from "@/components/dropdowns/date-range"; +// local imports +import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared"; + +type TDateRangeFilterValueInputProps

= { + config: TDateRangeFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (value: string[]) => void; +}; + +export const DateRangeFilterValueInput = observer( +

(props: TDateRangeFilterValueInputProps

) => { + const { config, condition, isDisabled, onChange } = props; + // derived values + const [fromRaw, toRaw] = toFilterArray(condition.value) ?? []; + const from = isValidDate(fromRaw) ? new Date(fromRaw) : undefined; + const to = isValidDate(toRaw) ? new Date(toRaw) : undefined; + const isIncomplete = !from || !to; + + // Handler for date range selection + const handleSelect = (range: { from?: Date; to?: Date } | undefined) => { + const formattedFrom = range?.from ? renderFormattedPayloadDate(range.from) : undefined; + const formattedTo = range?.to ? renderFormattedPayloadDate(range.to) : undefined; + if (formattedFrom && formattedTo) { + onChange([formattedFrom, formattedTo]); + } else { + onChange([]); + } + }; + + return ( + + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/date/single.tsx b/apps/web/core/components/rich-filters/filter-value-input/date/single.tsx new file mode 100644 index 000000000..c03256c03 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/date/single.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import { TDateFilterFieldConfig, TFilterConditionNodeForDisplay, TFilterProperty } from "@plane/types"; +import { cn, renderFormattedPayloadDate } from "@plane/utils"; +import { DateDropdown } from "@/components/dropdowns/date"; +import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared"; + +type TSingleDateFilterValueInputProps

= { + config: TDateFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (value: string | null | undefined) => void; +}; + +export const SingleDateFilterValueInput = observer( +

(props: TSingleDateFilterValueInputProps

) => { + const { config, condition, isDisabled, onChange } = props; + // derived values + const conditionValue = typeof condition.value === "string" ? condition.value : null; + + return ( + { + const formattedDate = value ? renderFormattedPayloadDate(value) : null; + onChange(formattedDate); + }} + buttonClassName={cn("rounded-none", { + [COMMON_FILTER_ITEM_BORDER_CLASSNAME]: !isDisabled, + "text-custom-text-400": !conditionValue, + "hover:bg-custom-background-100": isDisabled, + })} + minDate={config.min} + maxDate={config.max} + icon={null} + placeholder="--" + buttonVariant="transparent-with-text" + isClearable={false} + closeOnSelect + defaultOpen={!conditionValue} + disabled={isDisabled} + /> + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/root.tsx b/apps/web/core/components/rich-filters/filter-value-input/root.tsx new file mode 100644 index 000000000..ce5777e95 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/root.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { observer } from "mobx-react"; +// plane imports +import { + FILTER_FIELD_TYPE, + TFilterConditionNode, + TFilterValue, + TFilterProperty, + SingleOrArray, + TSingleSelectFilterFieldConfig, + TMultiSelectFilterFieldConfig, + TDateFilterFieldConfig, + TDateRangeFilterFieldConfig, + TSupportedFilterFieldConfigs, + TFilterConditionNodeForDisplay, +} from "@plane/types"; +// local imports +import { DateRangeFilterValueInput } from "./date/range"; +import { SingleDateFilterValueInput } from "./date/single"; +import { MultiSelectFilterValueInput } from "./select/multi"; +import { SingleSelectFilterValueInput } from "./select/single"; + +type TFilterValueInputProps

= { + condition: TFilterConditionNodeForDisplay; + filterFieldConfig: TSupportedFilterFieldConfigs; + isDisabled?: boolean; + onChange: (values: SingleOrArray) => void; +}; + +// TODO: Prevent type assertion +export const FilterValueInput = observer( +

(props: TFilterValueInputProps) => { + const { condition, filterFieldConfig, isDisabled = false, onChange } = props; + + // Single select input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.SINGLE_SELECT) { + return ( + + config={filterFieldConfig as TSingleSelectFilterFieldConfig} + condition={condition as TFilterConditionNodeForDisplay} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Multi select input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT) { + return ( + + config={filterFieldConfig as TMultiSelectFilterFieldConfig} + condition={condition as TFilterConditionNode} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Date filter input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE) { + return ( + + config={filterFieldConfig as TDateFilterFieldConfig} + condition={condition as TFilterConditionNodeForDisplay} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Date range filter input + if (filterFieldConfig?.type === FILTER_FIELD_TYPE.DATE_RANGE) { + return ( + + config={filterFieldConfig as TDateRangeFilterFieldConfig} + condition={condition as TFilterConditionNodeForDisplay} + isDisabled={isDisabled} + onChange={(value) => onChange(value as SingleOrArray)} + /> + ); + } + + // Fallback + return ( +

+ Filter type not supported +
+ ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/multi.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/multi.tsx new file mode 100644 index 000000000..1302cd50b --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/multi.tsx @@ -0,0 +1,54 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { + SingleOrArray, + IFilterOption, + TFilterProperty, + TMultiSelectFilterFieldConfig, + TFilterConditionNodeForDisplay, +} from "@plane/types"; +import { CustomSearchSelect } from "@plane/ui"; +import { toFilterArray, getFilterValueLength } from "@plane/utils"; +// local imports +import { SelectedOptionsDisplay } from "./selected-options-display"; +import { getCommonCustomSearchSelectProps, getFormattedOptions, loadOptions } from "./shared"; + +type TMultiSelectFilterValueInputProps

= { + config: TMultiSelectFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (values: SingleOrArray) => void; +}; + +export const MultiSelectFilterValueInput = observer( +

(props: TMultiSelectFilterValueInputProps

) => { + const { config, condition, isDisabled, onChange } = props; + // states + const [options, setOptions] = useState[]>([]); + const [loading, setLoading] = useState(false); + // derived values + const formattedOptions = useMemo(() => getFormattedOptions(options), [options]); + + useEffect(() => { + loadOptions({ config, setOptions, setLoading }); + }, [config]); + + const handleSelectChange = (values: string[]) => { + onChange(values); + }; + + return ( + selectedValue={condition.value} options={options} />} + defaultOpen={getFilterValueLength(condition.value) === 0} + /> + ); + } +); diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/selected-options-display.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/selected-options-display.tsx new file mode 100644 index 000000000..a2c35893a --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/selected-options-display.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { Transition } from "@headlessui/react"; +// plane imports +import { SingleOrArray, IFilterOption, TFilterValue } from "@plane/types"; +import { cn, toFilterArray } from "@plane/utils"; + +type TSelectedOptionsDisplayProps = { + selectedValue: SingleOrArray; + options: IFilterOption[]; + displayCount?: number; + emptyValue?: string; + fallbackText?: string; +}; + +export const SelectedOptionsDisplay = (props: TSelectedOptionsDisplayProps) => { + const { selectedValue, options, displayCount = 2, emptyValue = "--", fallbackText } = props; + // derived values + const selectedArray = toFilterArray(selectedValue); + const remainingCount = selectedArray.length - displayCount; + const selectedOptions = selectedArray + .map((value) => options.find((opt) => opt.value === value)) + .filter(Boolean) as IFilterOption[]; + + // When no value is selected, display the empty value + if (selectedArray.length === 0) { + return {emptyValue}; + } + + // When no options are found but we have a fallback text + if (options.length === 0) { + return {fallbackText ?? `${selectedArray.length} option(s) selected`}; + } + + return ( +

+ {selectedOptions.slice(0, displayCount).map((option, index) => ( + +
+ {option?.icon && {option.icon}} + {option?.label} +
+ {index < Math.min(displayCount, selectedOptions.length) - 1 && ( + , + )} +
+ ))} + {remainingCount > 0 && ( + + +{remainingCount} more + + )} +
+ ); +}; diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/shared.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/shared.tsx new file mode 100644 index 000000000..23fae6196 --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/shared.tsx @@ -0,0 +1,54 @@ +// plane imports +import { TSupportedFilterFieldConfigs, IFilterOption, TFilterValue } from "@plane/types"; +import { cn } from "@plane/utils"; +// local imports +import { COMMON_FILTER_ITEM_BORDER_CLASSNAME } from "../../shared"; + +type TLoadOptionsProps = { + config: TSupportedFilterFieldConfigs; + setOptions: (options: IFilterOption[]) => void; + setLoading?: (loading: boolean) => void; +}; + +export const loadOptions = async (props: TLoadOptionsProps) => { + const { config, setOptions, setLoading } = props; + + // if the config has a getOptions function, load the options + if ("getOptions" in config && typeof config.getOptions === "function") { + setLoading?.(true); + try { + const result = await config.getOptions(); + setOptions(result); + } catch (error) { + console.error("Failed to load options:", error); + } finally { + setLoading?.(false); + } + } +}; + +export const getFormattedOptions = (options: IFilterOption[]) => + options.map((option) => ({ + value: option.value, + content: ( +
+ {option.icon && ( + {option.icon} + )} + {option.label} +
+ ), + query: option.label.toString().toLowerCase(), + disabled: option.disabled, + tooltip: option.description, + })); + +export const getCommonCustomSearchSelectProps = (isDisabled?: boolean) => ({ + customButtonClassName: cn( + "h-full w-full px-2 text-sm font-normal transition-all duration-300 ease-in-out", + !isDisabled && COMMON_FILTER_ITEM_BORDER_CLASSNAME, + isDisabled && "hover:bg-custom-background-100" + ), + optionsClassName: "w-56", + maxHeight: "md" as const, +}); diff --git a/apps/web/core/components/rich-filters/filter-value-input/select/single.tsx b/apps/web/core/components/rich-filters/filter-value-input/select/single.tsx new file mode 100644 index 000000000..ab475e8ba --- /dev/null +++ b/apps/web/core/components/rich-filters/filter-value-input/select/single.tsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { + IFilterOption, + TFilterProperty, + TSingleSelectFilterFieldConfig, + TFilterConditionNodeForDisplay, +} from "@plane/types"; +import { CustomSearchSelect } from "@plane/ui"; +// local imports +import { SelectedOptionsDisplay } from "./selected-options-display"; +import { getCommonCustomSearchSelectProps, getFormattedOptions, loadOptions } from "./shared"; + +type TSingleSelectFilterValueInputProps

= { + config: TSingleSelectFilterFieldConfig; + condition: TFilterConditionNodeForDisplay; + isDisabled?: boolean; + onChange: (value: string | null) => void; +}; + +export const SingleSelectFilterValueInput = observer( +

(props: TSingleSelectFilterValueInputProps

) => { + const { config, condition, onChange, isDisabled } = props; + // states + const [options, setOptions] = useState[]>([]); + const [loading, setLoading] = useState(false); + // derived values + const formattedOptions = useMemo(() => getFormattedOptions(options), [options]); + + useEffect(() => { + loadOptions({ config, setOptions, setLoading }); + }, [config]); + + const handleSelectChange = (value: string) => { + if (value === condition.value) { + onChange(null); + } else { + onChange(value); + } + }; + + return ( + selectedValue={condition.value} options={options} displayCount={1} /> + } + defaultOpen={!condition.value} + /> + ); + } +); diff --git a/apps/web/core/components/rich-filters/filters-row.tsx b/apps/web/core/components/rich-filters/filters-row.tsx new file mode 100644 index 000000000..973cefe38 --- /dev/null +++ b/apps/web/core/components/rich-filters/filters-row.tsx @@ -0,0 +1,185 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { observer } from "mobx-react"; +import { Transition } from "@headlessui/react"; +// plane imports +import { IFilterInstance } from "@plane/shared-state"; +import { TExternalFilter, TFilterProperty } from "@plane/types"; +import { Button, EHeaderVariant, Header } from "@plane/ui"; +// local imports +import { AddFilterButton, TAddFilterButtonProps } from "./add-filters-button"; +import { FilterItem } from "./filter-item"; + +export type TFiltersRowProps = { + buttonConfig?: TAddFilterButtonProps["buttonConfig"]; + disabledAllOperations?: boolean; + filter: IFilterInstance; + variant?: "default" | "header"; + visible?: boolean; + maxVisibleConditions?: number; + trackerElements?: { + clearFilter?: string; + saveView?: string; + updateView?: string; + }; +}; + +export const FiltersRow = observer( + (props: TFiltersRowProps) => { + const { + buttonConfig, + disabledAllOperations = false, + filter, + variant = "header", + visible = true, + maxVisibleConditions = 3, + trackerElements, + } = props; + // states + const [showAllConditions, setShowAllConditions] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + // derived values + const visibleConditions = useMemo(() => { + if (variant === "default" || !maxVisibleConditions || showAllConditions) { + return filter.allConditionsForDisplay; + } + return filter.allConditionsForDisplay.slice(0, maxVisibleConditions); + }, [filter.allConditionsForDisplay, maxVisibleConditions, showAllConditions, variant]); + const hiddenConditionsCount = useMemo(() => { + if (variant === "default" || !maxVisibleConditions || showAllConditions) { + return 0; + } + return Math.max(0, filter.allConditionsForDisplay.length - maxVisibleConditions); + }, [filter.allConditionsForDisplay.length, maxVisibleConditions, showAllConditions, variant]); + + const handleUpdate = useCallback(async () => { + setIsUpdating(true); + await filter.updateView(); + setTimeout(() => setIsUpdating(false), 240); // To avoid flickering + }, [filter]); + + if (!visible) return null; + + const leftContent = ( + <> + { + if (variant === "header") { + setShowAllConditions(true); + } + }} + /> + {visibleConditions.map((condition) => ( + + ))} + {variant === "header" && hiddenConditionsCount > 0 && ( + + )} + {variant === "header" && + showAllConditions && + maxVisibleConditions && + filter.allConditionsForDisplay.length > maxVisibleConditions && ( + + )} + + ); + + const rightContent = !disabledAllOperations && ( + <> + + + + + + + + + + + ); + + if (variant === "default") { + return ( +

+ {leftContent} + {rightContent} +
+ ); + } + + return ( +
+
+
{leftContent}
+
{rightContent}
+
+
+ ); + } +); + +const COMMON_VISIBILITY_BUTTON_CLASSNAME = "py-0.5 px-2 text-custom-text-300 hover:text-custom-text-100 rounded-full"; +const COMMON_OPERATION_BUTTON_CLASSNAME = "py-1"; + +type TElementTransitionProps = { + children: React.ReactNode; + show: boolean; +}; + +const ElementTransition = observer((props: TElementTransitionProps) => ( + + {props.children} + +)); diff --git a/apps/web/core/components/rich-filters/shared.ts b/apps/web/core/components/rich-filters/shared.ts new file mode 100644 index 000000000..7361c81f2 --- /dev/null +++ b/apps/web/core/components/rich-filters/shared.ts @@ -0,0 +1 @@ +export const COMMON_FILTER_ITEM_BORDER_CLASSNAME = "border-r border-custom-border-200"; diff --git a/apps/web/package.json b/apps/web/package.json index 58c84cbe7..4a7b96814 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@plane/i18n": "workspace:*", "@plane/propel": "workspace:*", "@plane/services": "workspace:*", + "@plane/shared-state": "workspace:*", "@plane/types": "workspace:*", "@plane/ui": "workspace:*", "@plane/utils": "workspace:*", diff --git a/packages/constants/src/index.ts b/packages/constants/src/index.ts index 045538f3a..7be7ac63e 100644 --- a/packages/constants/src/index.ts +++ b/packages/constants/src/index.ts @@ -1,38 +1,39 @@ export * from "./ai"; +export * from "./analytics"; export * from "./auth"; export * from "./chart"; +export * from "./cycle"; +export * from "./dashboard"; +export * from "./emoji"; export * from "./endpoints"; +export * from "./estimates"; +export * from "./event-tracker"; export * from "./file"; export * from "./filter"; export * from "./graph"; +export * from "./icon"; export * from "./instance"; +export * from "./intake"; export * from "./issue"; +export * from "./label"; export * from "./metadata"; +export * from "./module"; export * from "./notification"; +export * from "./page"; +export * from "./payment"; +export * from "./profile"; +export * from "./project"; +export * from "./rich-filters"; +export * from "./settings"; +export * from "./sidebar"; +export * from "./spreadsheet"; export * from "./state"; +export * from "./stickies"; +export * from "./subscription"; export * from "./swr"; export * from "./tab-indices"; -export * from "./user"; -export * from "./payment"; -export * from "./workspace"; -export * from "./stickies"; -export * from "./cycle"; -export * from "./module"; -export * from "./project"; -export * from "./views"; export * from "./themes"; -export * from "./intake"; -export * from "./profile"; +export * from "./user"; +export * from "./views"; export * from "./workspace-drafts"; -export * from "./label"; -export * from "./event-tracker"; -export * from "./spreadsheet"; -export * from "./dashboard"; -export * from "./page"; -export * from "./emoji"; -export * from "./subscription"; -export * from "./settings"; -export * from "./icon"; -export * from "./estimates"; -export * from "./analytics"; -export * from "./sidebar"; +export * from "./workspace"; diff --git a/packages/constants/src/rich-filters/index.ts b/packages/constants/src/rich-filters/index.ts new file mode 100644 index 000000000..cf6b76514 --- /dev/null +++ b/packages/constants/src/rich-filters/index.ts @@ -0,0 +1,2 @@ +export * from "./operator-labels"; +export * from "./option"; diff --git a/packages/constants/src/rich-filters/operator-labels/core.ts b/packages/constants/src/rich-filters/operator-labels/core.ts new file mode 100644 index 000000000..39f902ae3 --- /dev/null +++ b/packages/constants/src/rich-filters/operator-labels/core.ts @@ -0,0 +1,24 @@ +import { + CORE_EQUALITY_OPERATOR, + CORE_COLLECTION_OPERATOR, + CORE_COMPARISON_OPERATOR, + TCoreSupportedOperators, + TCoreSupportedDateFilterOperators, +} from "@plane/types"; + +/** + * Core operator labels + */ +export const CORE_OPERATOR_LABELS_MAP: Record = { + [CORE_EQUALITY_OPERATOR.EXACT]: "is", + [CORE_COLLECTION_OPERATOR.IN]: "is any of", + [CORE_COMPARISON_OPERATOR.RANGE]: "between", +} as const; + +/** + * Core date-specific operator labels + */ +export const CORE_DATE_OPERATOR_LABELS_MAP: Record = { + [CORE_EQUALITY_OPERATOR.EXACT]: "is", + [CORE_COMPARISON_OPERATOR.RANGE]: "between", +} as const; diff --git a/packages/constants/src/rich-filters/operator-labels/extended.ts b/packages/constants/src/rich-filters/operator-labels/extended.ts new file mode 100644 index 000000000..349baeb81 --- /dev/null +++ b/packages/constants/src/rich-filters/operator-labels/extended.ts @@ -0,0 +1,21 @@ +import { TExtendedSupportedOperators } from "@plane/types"; + +/** + * Extended operator labels + */ +export const EXTENDED_OPERATOR_LABELS_MAP: Record = {} as const; + +/** + * Extended date-specific operator labels + */ +export const EXTENDED_DATE_OPERATOR_LABELS_MAP: Record = {} as const; + +/** + * Negated operator labels for all operators + */ +export const NEGATED_OPERATOR_LABELS_MAP: Record = {} as const; + +/** + * Negated date operator labels for all date operators + */ +export const NEGATED_DATE_OPERATOR_LABELS_MAP: Record = {} as const; diff --git a/packages/constants/src/rich-filters/operator-labels/index.ts b/packages/constants/src/rich-filters/operator-labels/index.ts new file mode 100644 index 000000000..8098b17e0 --- /dev/null +++ b/packages/constants/src/rich-filters/operator-labels/index.ts @@ -0,0 +1,36 @@ +import { TAllAvailableOperatorsForDisplay, TAllAvailableDateFilterOperatorsForDisplay } from "@plane/types"; +import { CORE_OPERATOR_LABELS_MAP, CORE_DATE_OPERATOR_LABELS_MAP } from "./core"; +import { + EXTENDED_OPERATOR_LABELS_MAP, + EXTENDED_DATE_OPERATOR_LABELS_MAP, + NEGATED_OPERATOR_LABELS_MAP, + NEGATED_DATE_OPERATOR_LABELS_MAP, +} from "./extended"; + +/** + * Empty operator label for unselected state + */ +export const EMPTY_OPERATOR_LABEL = "--"; + +/** + * Complete operator labels mapping - combines core, extended, and negated labels + */ +export const OPERATOR_LABELS_MAP: Record = { + ...CORE_OPERATOR_LABELS_MAP, + ...EXTENDED_OPERATOR_LABELS_MAP, + ...NEGATED_OPERATOR_LABELS_MAP, +} as const; + +/** + * Complete date operator labels mapping - combines core, extended, and negated labels + */ +export const DATE_OPERATOR_LABELS_MAP: Record = { + ...CORE_DATE_OPERATOR_LABELS_MAP, + ...EXTENDED_DATE_OPERATOR_LABELS_MAP, + ...NEGATED_DATE_OPERATOR_LABELS_MAP, +} as const; + +// -------- RE-EXPORTS -------- + +export * from "./core"; +export * from "./extended"; diff --git a/packages/constants/src/rich-filters/option.ts b/packages/constants/src/rich-filters/option.ts new file mode 100644 index 000000000..123585787 --- /dev/null +++ b/packages/constants/src/rich-filters/option.ts @@ -0,0 +1,63 @@ +import { TExternalFilter } from "@plane/types"; + +/** + * Filter config options. + */ +export type TConfigOptions = Record; + +/** + * Default filter config options. + */ +export const DEFAULT_FILTER_CONFIG_OPTIONS: TConfigOptions = {}; + +/** + * Clear filter config. + */ +export type TClearFilterOptions = { + label?: string; + onFilterClear: () => void | Promise; + isDisabled?: boolean; +}; + +/** + * Save view config. + */ +export type TSaveViewOptions = { + label?: string; + onViewSave: (expression: E) => void | Promise; + isDisabled?: boolean; +}; + +/** + * Update view config. + */ +export type TUpdateViewOptions = { + label?: string; + hasAdditionalChanges?: boolean; + onViewUpdate: (expression: E) => void | Promise; + isDisabled?: boolean; +}; + +/** + * Filter expression options. + */ +export type TExpressionOptions = { + clearFilterOptions?: TClearFilterOptions; + saveViewOptions?: TSaveViewOptions; + updateViewOptions?: TUpdateViewOptions; +}; + +/** + * Default filter expression options. + */ +export const DEFAULT_FILTER_EXPRESSION_OPTIONS: TExpressionOptions = {}; + +/** + * Filter options. + * - expression: Filter expression options. + * - config: Filter config options. + */ +export type TFilterOptions = { + expression: Partial>; + config: Partial; +}; diff --git a/packages/shared-state/package.json b/packages/shared-state/package.json index 496f2272a..02d5632f7 100644 --- a/packages/shared-state/package.json +++ b/packages/shared-state/package.json @@ -15,13 +15,21 @@ "clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist" }, "dependencies": { + "@plane/constants": "workspace:*", + "@plane/types": "workspace:*", + "@plane/utils": "workspace:*", + "lodash": "catalog:", "mobx": "catalog:", + "mobx-utils": "catalog:", + "uuid": "catalog:", "zod": "^3.22.2" }, "devDependencies": { "@plane/eslint-config": "workspace:*", "@plane/typescript-config": "workspace:*", "@types/node": "^22.5.4", + "@types/lodash": "catalog:", + "@types/uuid": "catalog:", "typescript": "catalog:" } } diff --git a/packages/shared-state/src/index.ts b/packages/shared-state/src/index.ts index e69de29bb..faaf31f83 100644 --- a/packages/shared-state/src/index.ts +++ b/packages/shared-state/src/index.ts @@ -0,0 +1,2 @@ +export * from "./store"; +export * from "./utils"; diff --git a/packages/shared-state/src/store/index.ts b/packages/shared-state/src/store/index.ts new file mode 100644 index 000000000..253180f0e --- /dev/null +++ b/packages/shared-state/src/store/index.ts @@ -0,0 +1 @@ +export * from "./rich-filters"; diff --git a/packages/shared-state/src/store/rich-filters/adapter.ts b/packages/shared-state/src/store/rich-filters/adapter.ts new file mode 100644 index 000000000..1dc74a67e --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/adapter.ts @@ -0,0 +1,31 @@ +// plane imports +import { IFilterAdapter, TExternalFilter, TFilterExpression, TFilterProperty } from "@plane/types"; + +/** + * Abstract base class for converting between external filter formats and internal filter expressions. + * Provides common utilities for creating and manipulating filter nodes. + * + * @template K - Property key type that extends TFilterProperty + * @template E - External filter type that extends TExternalFilter + */ +export abstract class FilterAdapter + implements IFilterAdapter +{ + /** + * Converts an external filter format to internal filter expression. + * Must be implemented by concrete adapter classes. + * + * @param externalFilter - The external filter to convert + * @returns The internal filter expression or null if conversion fails + */ + abstract toInternal(externalFilter: E): TFilterExpression | null; + + /** + * Converts an internal filter expression to external filter format. + * Must be implemented by concrete adapter classes. + * + * @param internalFilter - The internal filter expression to convert + * @returns The external filter format + */ + abstract toExternal(internalFilter: TFilterExpression | null): E; +} diff --git a/packages/shared-state/src/store/rich-filters/config-manager.ts b/packages/shared-state/src/store/rich-filters/config-manager.ts new file mode 100644 index 000000000..88b493723 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/config-manager.ts @@ -0,0 +1,173 @@ +import { action, computed, makeObservable, observable } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane imports +import { DEFAULT_FILTER_CONFIG_OPTIONS, TConfigOptions } from "@plane/constants"; +import { TExternalFilter, TFilterConfig, TFilterProperty, TFilterValue } from "@plane/types"; +// local imports +import { FilterConfig, IFilterConfig } from "./config"; +import { IFilterInstance } from "./filter"; + +/** + * Interface for managing filter configurations. + * Provides methods to register, update, and retrieve filter configurations. + * - filterConfigs: Map storing filter configurations by their ID + * - configOptions: Configuration options controlling filter behavior + * - allConfigs: All registered filter configurations + * - allAvailableConfigs: All available filter configurations based on current state + * - getConfigByProperty: Retrieves a filter configuration by its ID + * - register: Registers a single filter configuration + * - registerAll: Registers multiple filter configurations + * - updateConfigByProperty: Updates an existing filter configuration by ID + * @template P - The filter property type extending TFilterProperty + */ +export interface IFilterConfigManager

{ + // observables + filterConfigs: Map>; // filter property -> config + configOptions: TConfigOptions; + // computed + allAvailableConfigs: IFilterConfig[]; + // computed functions + getConfigByProperty: (property: P) => IFilterConfig | undefined; + // helpers + register: >(config: C) => void; + registerAll: (configs: TFilterConfig[]) => void; + updateConfigByProperty: (property: P, configUpdates: Partial>) => void; +} + +/** + * Parameters for initializing the FilterConfigManager. + * - options: Optional configuration options to override defaults + */ +export type TConfigManagerParams = { + options?: Partial; +}; + +/** + * Manages filter configurations for a filter instance. + * Handles registration, updates, and retrieval of filter configurations. + * Provides computed properties for available configurations based on current filter state. + * + * @template P - The filter property type extending TFilterProperty + * @template V - The filter value type extending TFilterValue + * @template E - The external filter type extending TExternalFilter + */ +export class FilterConfigManager

+ implements IFilterConfigManager

+{ + // observables + filterConfigs: IFilterConfigManager

["filterConfigs"]; + configOptions: IFilterConfigManager

["configOptions"]; + // parent filter instance + _filterInstance: IFilterInstance; + + /** + * Creates a new FilterConfigManager instance. + * + * @param filterInstance - The parent filter instance this manager belongs to + * @param params - Configuration parameters for the manager + */ + constructor(filterInstance: IFilterInstance, params: TConfigManagerParams) { + this.filterConfigs = new Map>(); + this.configOptions = this._initializeConfigOptions(params.options); + // parent filter instance + this._filterInstance = filterInstance; + + makeObservable(this, { + filterConfigs: observable, + configOptions: observable, + // computed + allAvailableConfigs: computed, + // helpers + register: action, + registerAll: action, + updateConfigByProperty: action, + }); + } + + // ------------ computed ------------ + + /** + * Returns all available filterConfigs. + * If allowSameFilters is true, all enabled configs are returned. + * Otherwise, only configs that are not already applied to the filter instance are returned. + * @returns All available filterConfigs. + */ + get allAvailableConfigs(): IFilterConfigManager

["allAvailableConfigs"] { + const appliedProperties = new Set(this._filterInstance.allConditions.map((condition) => condition.property)); + // Return all enabled configs that either allow multiple filters or are not currently applied + return this._allEnabledConfigs.filter((config) => config.allowMultipleFilters || !appliedProperties.has(config.id)); + } + + // ------------ computed functions ------------ + + /** + * Returns a config by filter property. + * @param property - The property to get the config for. + * @returns The config for the property, or undefined if not found. + */ + getConfigByProperty: IFilterConfigManager

["getConfigByProperty"] = computedFn( + (property) => this.filterConfigs.get(property) as IFilterConfig + ); + + // ------------ helpers ------------ + + /** + * Register a config. + * If a config with the same property already exists, it will be updated with the new values. + * Otherwise, a new config will be created. + * @param configUpdates - The config updates to register. + */ + register: IFilterConfigManager

["register"] = action((configUpdates) => { + if (this.filterConfigs.has(configUpdates.id)) { + // Update existing config if it has differences + const existingConfig = this.filterConfigs.get(configUpdates.id)!; + existingConfig.mutate(configUpdates); + } else { + // Create new config if it doesn't exist + this.filterConfigs.set(configUpdates.id, new FilterConfig(configUpdates)); + } + }); + + /** + * Register all configs. + * @param configs - The configs to register. + */ + registerAll: IFilterConfigManager

["registerAll"] = action((configs) => { + configs.forEach((config) => this.register(config)); + }); + + /** + * Updates a config by filter property. + * @param property - The property of the config to update. + * @param configUpdates - The updates to apply to the config. + */ + updateConfigByProperty: IFilterConfigManager

["updateConfigByProperty"] = action((property, configUpdates) => { + const prevConfig = this.filterConfigs.get(property); + prevConfig?.mutate(configUpdates); + }); + + // ------------ private computed ------------ + + private get _allConfigs(): IFilterConfig[] { + return Array.from(this.filterConfigs.values()); + } + + /** + * Returns all enabled filterConfigs. + * @returns All enabled filterConfigs. + */ + private get _allEnabledConfigs(): IFilterConfig[] { + return this._allConfigs.filter((config) => config.isEnabled); + } + + // ------------ private helpers ------------ + + /** + * Initializes the config options. + * @param options - The options to initialize the config options with. + * @returns The initialized config options. + */ + private _initializeConfigOptions(options?: Partial): TConfigOptions { + return DEFAULT_FILTER_CONFIG_OPTIONS ? { ...DEFAULT_FILTER_CONFIG_OPTIONS, ...options } : options || {}; + } +} diff --git a/packages/shared-state/src/store/rich-filters/config.ts b/packages/shared-state/src/store/rich-filters/config.ts new file mode 100644 index 000000000..a590631d4 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/config.ts @@ -0,0 +1,212 @@ +import set from "lodash/set"; +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +// plane imports +import { EMPTY_OPERATOR_LABEL } from "@plane/constants"; +import { + FILTER_FIELD_TYPE, + TSupportedOperators, + TFilterConfig, + TFilterProperty, + TFilterValue, + TOperatorSpecificConfigs, + TAllAvailableOperatorsForDisplay, +} from "@plane/types"; +import { + getOperatorLabel, + isDateFilterType, + getDateOperatorLabel, + isDateFilterOperator, + getOperatorForPayload, +} from "@plane/utils"; + +type TOperatorOptionForDisplay = { + value: TAllAvailableOperatorsForDisplay; + label: string; +}; + +export interface IFilterConfig

+ extends TFilterConfig { + // computed + allSupportedOperators: TSupportedOperators[]; + allSupportedOperatorConfigs: TOperatorSpecificConfigs[keyof TOperatorSpecificConfigs][]; + firstOperator: TSupportedOperators | undefined; + // computed functions + getOperatorConfig: ( + operator: TAllAvailableOperatorsForDisplay + ) => TOperatorSpecificConfigs[keyof TOperatorSpecificConfigs] | undefined; + getLabelForOperator: (operator: TAllAvailableOperatorsForDisplay | undefined) => string; + getDisplayOperatorByValue: ( + operator: T, + value: V + ) => T; + getAllDisplayOperatorOptionsByValue: (value: V) => TOperatorOptionForDisplay[]; + // actions + mutate: (updates: Partial>) => void; +} + +export class FilterConfig

+ implements IFilterConfig +{ + // observables + id: IFilterConfig["id"]; + label: IFilterConfig["label"]; + icon?: IFilterConfig["icon"]; + isEnabled: IFilterConfig["isEnabled"]; + supportedOperatorConfigsMap: IFilterConfig["supportedOperatorConfigsMap"]; + allowMultipleFilters: IFilterConfig["allowMultipleFilters"]; + + /** + * Creates a new FilterConfig instance. + * @param params - The parameters for the filter config. + */ + constructor(params: TFilterConfig) { + this.id = params.id; + this.label = params.label; + this.icon = params.icon; + this.isEnabled = params.isEnabled; + this.supportedOperatorConfigsMap = params.supportedOperatorConfigsMap; + this.allowMultipleFilters = params.allowMultipleFilters; + + makeObservable(this, { + id: observable, + label: observable, + icon: observable, + isEnabled: observable, + supportedOperatorConfigsMap: observable, + allowMultipleFilters: observable, + // computed + allSupportedOperators: computed, + allSupportedOperatorConfigs: computed, + firstOperator: computed, + // actions + mutate: action, + }); + } + + // ------------ computed ------------ + + /** + * Returns all supported operators. + * @returns All supported operators. + */ + get allSupportedOperators(): IFilterConfig["allSupportedOperators"] { + return Array.from(this.supportedOperatorConfigsMap.keys()); + } + + /** + * Returns all supported operator configs. + * @returns All supported operator configs. + */ + get allSupportedOperatorConfigs(): IFilterConfig["allSupportedOperatorConfigs"] { + return Array.from(this.supportedOperatorConfigsMap.values()); + } + + /** + * Returns the first operator. + * @returns The first operator. + */ + get firstOperator(): IFilterConfig["firstOperator"] { + return this.allSupportedOperators[0]; + } + + // ------------ computed functions ------------ + + /** + * Returns the operator config. + * @param operator - The operator. + * @returns The operator config. + */ + getOperatorConfig: IFilterConfig["getOperatorConfig"] = computedFn((operator) => + this.supportedOperatorConfigsMap.get(getOperatorForPayload(operator).operator) + ); + + /** + * Returns the label for an operator. + * @param operator - The operator. + * @returns The label for the operator. + */ + getLabelForOperator: IFilterConfig["getLabelForOperator"] = computedFn((operator) => { + if (!operator) return EMPTY_OPERATOR_LABEL; + + const operatorConfig = this.getOperatorConfig(operator); + + if (operatorConfig?.operatorLabel) { + return operatorConfig.operatorLabel; + } + + if (operatorConfig?.type && isDateFilterType(operatorConfig.type) && isDateFilterOperator(operator)) { + return getDateOperatorLabel(operator); + } + + return getOperatorLabel(operator); + }); + + /** + * Returns the operator for a value. + * @param value - The value. + * @returns The operator for the value. + */ + getDisplayOperatorByValue: IFilterConfig["getDisplayOperatorByValue"] = computedFn((operator, value) => { + const operatorConfig = this.getOperatorConfig(operator); + if (operatorConfig?.type === FILTER_FIELD_TYPE.MULTI_SELECT && (Array.isArray(value) ? value.length : 0) <= 1) { + return operatorConfig.singleValueOperator as typeof operator; + } + return operator; + }); + + /** + * Returns all supported operator options for display in the filter UI. + * This method filters out operators that are already applied (unless multiple filters are allowed) + * and includes both positive and negative variants when supported. + * + * @param value - The current filter value used to determine the appropriate operator variant + * @returns Array of operator options with their display labels and values + */ + getAllDisplayOperatorOptionsByValue: IFilterConfig["getAllDisplayOperatorOptionsByValue"] = computedFn( + (value) => { + const operatorOptions: TOperatorOptionForDisplay[] = []; + + // Process each supported operator to build display options + for (const operator of this.allSupportedOperators) { + const displayOperator = this.getDisplayOperatorByValue(operator, value); + const displayOperatorLabel = this.getLabelForOperator(displayOperator); + operatorOptions.push({ + value: operator, + label: displayOperatorLabel, + }); + + const additionalOperatorOption = this._getAdditionalOperatorOptions(operator, value); + if (additionalOperatorOption) { + operatorOptions.push(additionalOperatorOption); + } + } + + return operatorOptions; + } + ); + + // ------------ actions ------------ + + /** + * Mutates the config. + * @param updates - The updates to apply to the config. + */ + mutate: IFilterConfig["mutate"] = action((updates) => { + runInAction(() => { + for (const key in updates) { + if (updates.hasOwnProperty(key)) { + const configKey = key as keyof TFilterConfig; + set(this, configKey, updates[configKey]); + } + } + }); + }); + + // ------------ private helpers ------------ + + private _getAdditionalOperatorOptions = ( + _operator: TSupportedOperators, + _value: V + ): TOperatorOptionForDisplay | undefined => undefined; +} diff --git a/packages/shared-state/src/store/rich-filters/filter-helpers.ts b/packages/shared-state/src/store/rich-filters/filter-helpers.ts new file mode 100644 index 000000000..68ca76fc7 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/filter-helpers.ts @@ -0,0 +1,172 @@ +import cloneDeep from "lodash/cloneDeep"; +import { toJS } from "mobx"; +// plane imports +import { DEFAULT_FILTER_EXPRESSION_OPTIONS, TExpressionOptions } from "@plane/constants"; +import { + IFilterAdapter, + LOGICAL_OPERATOR, + TSupportedOperators, + TFilterExpression, + TFilterValue, + TFilterProperty, + TExternalFilter, + TLogicalOperator, + TFilterConditionPayload, +} from "@plane/types"; +import { addAndCondition, createConditionNode, updateNodeInExpression } from "@plane/utils"; + +/** + * Interface for filter instance helper utilities. + * Provides comprehensive methods for filter expression manipulation, node operations, + * operator utilities, and expression restructuring. + * @template P - The filter property type extending TFilterProperty + * @template E - The external filter type extending TExternalFilter + */ +export interface IFilterInstanceHelper

{ + // initialization + initializeExpression: (initialExpression?: E) => TFilterExpression

| null; + initializeExpressionOptions: (expressionOptions?: Partial>) => TExpressionOptions; + // condition operations + addConditionToExpression: ( + expression: TFilterExpression

| null, + groupOperator: TLogicalOperator, + condition: TFilterConditionPayload, + isNegation: boolean + ) => TFilterExpression

| null; + // group operations + restructureExpressionForOperatorChange: ( + expression: TFilterExpression

| null, + conditionId: string, + newOperator: TSupportedOperators, + isNegation: boolean, + shouldResetValue: boolean + ) => TFilterExpression

| null; +} + +/** + * Comprehensive helper class for filter instance operations. + * Provides utilities for filter expression manipulation, node operations, + * operator transformations, and expression restructuring. + * + * @template K - The filter property type extending TFilterProperty + * @template E - The external filter type extending TExternalFilter + */ +export class FilterInstanceHelper

+ implements IFilterInstanceHelper +{ + private adapter: IFilterAdapter; + + /** + * Creates a new FilterInstanceHelper instance. + * + * @param adapter - The filter adapter for converting between internal and external formats + */ + constructor(adapter: IFilterAdapter) { + this.adapter = adapter; + } + + // ------------ initialization ------------ + + /** + * Initializes the filter expression from external format. + * @param initialExpression - The initial expression to initialize the filter with + * @returns The initialized filter expression or null if no initial expression provided + */ + initializeExpression: IFilterInstanceHelper["initializeExpression"] = (initialExpression) => { + if (!initialExpression) return null; + return this.adapter.toInternal(toJS(cloneDeep(initialExpression))); + }; + + /** + * Initializes the filter expression options with defaults. + * @param expressionOptions - Optional expression options to override defaults + * @returns The initialized filter expression options + */ + initializeExpressionOptions: IFilterInstanceHelper["initializeExpressionOptions"] = (expressionOptions) => ({ + ...DEFAULT_FILTER_EXPRESSION_OPTIONS, + ...expressionOptions, + }); + + // ------------ condition operations ------------ + + /** + * Adds a condition to the filter expression based on the logical operator. + * @param expression - The current filter expression + * @param groupOperator - The logical operator to use for the condition + * @param condition - The condition to add + * @param isNegation - Whether the condition should be negated + * @returns The updated filter expression + */ + addConditionToExpression: IFilterInstanceHelper["addConditionToExpression"] = ( + expression, + groupOperator, + condition, + isNegation + ) => this._addConditionByOperator(expression, groupOperator, this._getConditionPayloadToAdd(condition, isNegation)); + + // ------------ group operations ------------ + + /** + * Restructures the expression when a condition's operator changes between positive and negative. + * @param expression - The filter expression to operate on + * @param conditionId - The ID of the condition being updated + * @param newOperator - The new operator for the condition + * @param isNegation - Whether the operator is negation + * @param shouldResetValue - Whether to reset the condition value + * @returns The restructured expression + */ + restructureExpressionForOperatorChange: IFilterInstanceHelper["restructureExpressionForOperatorChange"] = ( + expression, + conditionId, + newOperator, + _isNegation, + shouldResetValue + ) => { + if (!expression) return null; + + const payload = shouldResetValue ? { operator: newOperator, value: undefined } : { operator: newOperator }; + + // Update the condition with the new operator + updateNodeInExpression(expression, conditionId, payload); + + return expression; + }; + + // ------------ private helpers ------------ + + /** + * Gets the condition payload to add to the expression. + * @param conditionNode - The condition node to add + * @param isNegation - Whether the condition should be negated + * @returns The condition payload to add + */ + private _getConditionPayloadToAdd = ( + condition: TFilterConditionPayload, + _isNegation: boolean + ): TFilterExpression

=> { + const conditionNode = createConditionNode(condition); + + return conditionNode; + }; + + /** + * Handles the logical operator switch for adding conditions. + * @param expression - The current expression + * @param groupOperator - The logical operator + * @param conditionToAdd - The condition to add + * @returns The updated expression + */ + private _addConditionByOperator( + expression: TFilterExpression

| null, + groupOperator: TLogicalOperator, + conditionToAdd: TFilterExpression

+ ): TFilterExpression

| null { + switch (groupOperator) { + case LOGICAL_OPERATOR.AND: + return addAndCondition(expression, conditionToAdd); + default: + console.warn(`Unsupported logical operator: ${groupOperator}`); + return expression; + } + } +} diff --git a/packages/shared-state/src/store/rich-filters/filter.ts b/packages/shared-state/src/store/rich-filters/filter.ts new file mode 100644 index 000000000..e840845f8 --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/filter.ts @@ -0,0 +1,490 @@ +import cloneDeep from "lodash/cloneDeep"; +import { action, computed, makeObservable, observable, toJS } from "mobx"; +import { computedFn } from "mobx-utils"; +import { v4 as uuidv4 } from "uuid"; +// plane imports +import { + TClearFilterOptions, + TExpressionOptions, + TFilterOptions, + TSaveViewOptions, + TUpdateViewOptions, +} from "@plane/constants"; +import { + FILTER_NODE_TYPE, + IFilterAdapter, + SingleOrArray, + TAllAvailableOperatorsForDisplay, + TExternalFilter, + TFilterConditionNode, + TFilterConditionNodeForDisplay, + TFilterConditionPayload, + TFilterExpression, + TFilterProperty, + TFilterValue, + TLogicalOperator, + TSupportedOperators, +} from "@plane/types"; +// local imports +import { + deepCompareFilterExpressions, + extractConditions, + extractConditionsWithDisplayOperators, + findConditionsByPropertyAndOperator, + findNodeById, + hasValidValue, + removeNodeFromExpression, + sanitizeAndStabilizeExpression, + shouldNotifyChangeForExpression, + updateNodeInExpression, +} from "@plane/utils"; +import { FilterConfigManager, IFilterConfigManager } from "./config-manager"; +import { FilterInstanceHelper, IFilterInstanceHelper } from "./filter-helpers"; + +/** + * Interface for a filter instance. + * Provides methods to manage the filter expression and notify changes. + * - id: The id of the filter instance + * - expression: The filter expression + * - adapter: The filter adapter + * - configManager: The filter config manager + * - onExpressionChange: The callback to notify when the expression changes + * - hasActiveFilters: Whether the filter instance has any active filters + * - allConditions: All conditions in the filter expression + * - allConditionsForDisplay: All conditions in the filter expression + * - addCondition: Adds a condition to the filter expression + * - updateConditionOperator: Updates the operator of a condition in the filter expression + * - updateConditionValue: Updates the value of a condition in the filter expression + * - removeCondition: Removes a condition from the filter expression + * - clearFilters: Clears the filter expression + * @template P - The filter property type extending TFilterProperty + * @template E - The external filter type extending TExternalFilter + */ +export interface IFilterInstance

{ + // observables + id: string; + initialFilterExpression: TFilterExpression

| null; + expression: TFilterExpression

| null; + adapter: IFilterAdapter; + configManager: IFilterConfigManager

; + onExpressionChange?: (expression: E) => void; + // computed + hasActiveFilters: boolean; + hasChanges: boolean; + allConditions: TFilterConditionNode[]; + allConditionsForDisplay: TFilterConditionNodeForDisplay[]; + // computed option helpers + clearFilterOptions: TClearFilterOptions | undefined; + saveViewOptions: TSaveViewOptions | undefined; + updateViewOptions: TUpdateViewOptions | undefined; + // computed permissions + canClearFilters: boolean; + canSaveView: boolean; + canUpdateView: boolean; + // filter expression actions + resetExpression: (externalExpression: E, shouldResetInitialExpression?: boolean) => void; + // filter condition + findConditionsByPropertyAndOperator: ( + property: P, + operator: TAllAvailableOperatorsForDisplay + ) => TFilterConditionNodeForDisplay[]; + findFirstConditionByPropertyAndOperator: ( + property: P, + operator: TAllAvailableOperatorsForDisplay + ) => TFilterConditionNodeForDisplay | undefined; + addCondition: ( + groupOperator: TLogicalOperator, + condition: TFilterConditionPayload, + isNegation: boolean + ) => void; + updateConditionOperator: (conditionId: string, operator: TSupportedOperators, isNegation: boolean) => void; + updateConditionValue: (conditionId: string, value: SingleOrArray) => void; + removeCondition: (conditionId: string) => void; + // config actions + clearFilters: () => Promise; + saveView: () => Promise; + updateView: () => Promise; + // expression options actions + updateExpressionOptions: (newOptions: Partial>) => void; +} + +export type TFilterParams

= { + adapter: IFilterAdapter; + options?: Partial>; + initialExpression?: E; + onExpressionChange?: (expression: E) => void; +}; + +export class FilterInstance

implements IFilterInstance { + // observables + id: string; + initialFilterExpression: TFilterExpression

| null; + expression: TFilterExpression

| null; + expressionOptions: TExpressionOptions; + adapter: IFilterAdapter; + configManager: IFilterConfigManager

; + onExpressionChange?: (expression: E) => void; + + // helper instance + private helper: IFilterInstanceHelper; + + constructor(params: TFilterParams) { + this.id = uuidv4(); + this.adapter = params.adapter; + this.helper = new FilterInstanceHelper(this.adapter); + this.configManager = new FilterConfigManager(this, { + options: params.options?.config, + }); + // initialize expression + const initialExpression = this.helper.initializeExpression(params.initialExpression); + this.initialFilterExpression = cloneDeep(initialExpression); + this.expression = cloneDeep(initialExpression); + this.expressionOptions = this.helper.initializeExpressionOptions(params.options?.expression); + this.onExpressionChange = params.onExpressionChange; + + makeObservable(this, { + // observables + id: observable, + initialFilterExpression: observable, + expression: observable, + expressionOptions: observable, + adapter: observable, + configManager: observable, + // computed + hasActiveFilters: computed, + hasChanges: computed, + allConditions: computed, + allConditionsForDisplay: computed, + // computed option helpers + clearFilterOptions: computed, + saveViewOptions: computed, + updateViewOptions: computed, + // computed permissions + canClearFilters: computed, + canSaveView: computed, + canUpdateView: computed, + // actions + resetExpression: action, + findConditionsByPropertyAndOperator: action, + findFirstConditionByPropertyAndOperator: action, + addCondition: action, + updateConditionOperator: action, + updateConditionValue: action, + removeCondition: action, + clearFilters: action, + saveView: action, + updateView: action, + updateExpressionOptions: action, + }); + } + + // ------------ computed ------------ + + /** + * Checks if the filter instance has any active filters. + * @returns True if the filter instance has any active filters, false otherwise. + */ + get hasActiveFilters(): IFilterInstance["hasActiveFilters"] { + // if the expression is null, return false + if (!this.expression) return false; + // if there are no conditions, return false + if (this.allConditionsForDisplay.length === 0) return false; + // if there are conditions, return true if any of them have a valid value + return this.allConditionsForDisplay.some((condition) => hasValidValue(condition.value)); + } + + /** + * Checks if the filter instance has any changes with respect to the initial expression. + * @returns True if the filter instance has any changes, false otherwise. + */ + get hasChanges(): IFilterInstance["hasChanges"] { + return !deepCompareFilterExpressions(this.initialFilterExpression, this.expression); + } + + /** + * Returns all conditions from the filter expression. + * @returns An array of filter conditions. + */ + get allConditions(): IFilterInstance["allConditions"] { + if (!this.expression) return []; + return extractConditions(this.expression); + } + + /** + * Returns all conditions in the filter expression for display purposes. + * @returns An array of filter conditions for display purposes. + */ + get allConditionsForDisplay(): IFilterInstance["allConditionsForDisplay"] { + if (!this.expression) return []; + return extractConditionsWithDisplayOperators(this.expression); + } + + // ------------ computed option helpers ------------ + + /** + * Returns the clear filter options. + * @returns The clear filter options. + */ + get clearFilterOptions(): IFilterInstance["clearFilterOptions"] { + return this.expressionOptions.clearFilterOptions; + } + + /** + * Returns the save view options. + * @returns The save view options. + */ + get saveViewOptions(): IFilterInstance["saveViewOptions"] { + return this.expressionOptions.saveViewOptions; + } + + /** + * Returns the update view options. + * @returns The update view options. + */ + get updateViewOptions(): IFilterInstance["updateViewOptions"] { + return this.expressionOptions.updateViewOptions; + } + + // ------------ computed permissions ------------ + + /** + * Checks if the filter expression can be cleared. + * @returns True if the filter expression can be cleared, false otherwise. + */ + get canClearFilters(): IFilterInstance["canClearFilters"] { + if (!this.expression) return false; + if (this.allConditionsForDisplay.length === 0) return false; + return this.clearFilterOptions ? !this.clearFilterOptions.isDisabled : true; + } + + /** + * Checks if the filter expression can be saved as a view. + * @returns True if the filter instance can be saved, false otherwise. + */ + get canSaveView(): IFilterInstance["canSaveView"] { + return this.hasActiveFilters && !!this.saveViewOptions && !this.saveViewOptions.isDisabled; + } + + /** + * Checks if the filter expression can be updated as a view. + * @returns True if the filter expression can be updated, false otherwise. + */ + get canUpdateView(): IFilterInstance["canUpdateView"] { + return ( + !!this.updateViewOptions && + (this.hasChanges || !!this.updateViewOptions.hasAdditionalChanges) && + !this.updateViewOptions.isDisabled + ); + } + + // ------------ actions ------------ + + /** + * Resets the filter expression to the initial expression. + * @param externalExpression - The external expression to reset to. + */ + resetExpression: IFilterInstance["resetExpression"] = action( + (externalExpression, shouldResetInitialExpression = true) => { + this.expression = this.helper.initializeExpression(externalExpression); + if (shouldResetInitialExpression) { + this._resetInitialFilterExpression(); + } + this._notifyExpressionChange(); + } + ); + + /** + * Finds all conditions by property and operator. + * @param property - The property to find the conditions by. + * @param operator - The operator to find the conditions by. + * @returns All the conditions that match the property and operator. + */ + findConditionsByPropertyAndOperator: IFilterInstance["findConditionsByPropertyAndOperator"] = action( + (property, operator) => { + if (!this.expression) return []; + return findConditionsByPropertyAndOperator(this.expression, property, operator); + } + ); + + /** + * Finds the first condition by property and operator. + * @param property - The property to find the condition by. + * @param operator - The operator to find the condition by. + * @returns The first condition that matches the property and operator. + */ + findFirstConditionByPropertyAndOperator: IFilterInstance["findFirstConditionByPropertyAndOperator"] = action( + (property, operator) => { + if (!this.expression) return undefined; + const conditions = findConditionsByPropertyAndOperator(this.expression, property, operator); + return conditions[0]; + } + ); + + /** + * Adds a condition to the filter expression. + * @param groupOperator - The logical operator to use for the condition. + * @param condition - The condition to add. + * @param isNegation - Whether the condition should be negated. + */ + addCondition: IFilterInstance["addCondition"] = action((groupOperator, condition, isNegation = false) => { + const conditionValue = condition.value; + + this.expression = this.helper.addConditionToExpression(this.expression, groupOperator, condition, isNegation); + + if (hasValidValue(conditionValue)) { + this._notifyExpressionChange(); + } + }); + + /** + * Updates the operator of a condition in the filter expression. + * @param conditionId - The id of the condition to update. + * @param operator - The new operator for the condition. + */ + updateConditionOperator: IFilterInstance["updateConditionOperator"] = action( + (conditionId: string, operator: TSupportedOperators, isNegation: boolean) => { + if (!this.expression) return; + const conditionBeforeUpdate = cloneDeep(findNodeById(this.expression, conditionId)); + if (!conditionBeforeUpdate || conditionBeforeUpdate.type !== FILTER_NODE_TYPE.CONDITION) return; + + // Get the operator configs for the current and new operators + const currentOperatorConfig = this.configManager + .getConfigByProperty(conditionBeforeUpdate.property) + ?.getOperatorConfig(conditionBeforeUpdate.operator); + const newOperatorConfig = this.configManager + .getConfigByProperty(conditionBeforeUpdate.property) + ?.getOperatorConfig(operator); + // Reset the value if the operator config types are different + const shouldResetConditionValue = currentOperatorConfig?.type !== newOperatorConfig?.type; + + // Use restructuring logic for operator changes + const updatedExpression = this.helper.restructureExpressionForOperatorChange( + this.expression, + conditionId, + operator, + isNegation, + shouldResetConditionValue + ); + + if (updatedExpression) { + this.expression = updatedExpression; + } + + if (hasValidValue(conditionBeforeUpdate.value)) { + this._notifyExpressionChange(); + } + } + ); + + /** + * Updates the value of a condition in the filter expression with automatic optimization. + * @param conditionId - The id of the condition to update. + * @param value - The new value for the condition. + */ + updateConditionValue: IFilterInstance["updateConditionValue"] = action( + (conditionId: string, value: SingleOrArray) => { + // If the expression is not valid, return + if (!this.expression) return; + + // If the value is not valid, remove the condition + if (!hasValidValue(value)) { + this.removeCondition(conditionId); + return; + } + + // Update the condition value + updateNodeInExpression(this.expression, conditionId, { + value, + }); + + // Notify the change + this._notifyExpressionChange(); + } + ); + + /** + * Removes a condition from the filter expression. + * @param conditionId - The id of the condition to remove. + */ + removeCondition: IFilterInstance["removeCondition"] = action((conditionId) => { + if (!this.expression) return; + const { expression, shouldNotify } = removeNodeFromExpression(this.expression, conditionId); + this.expression = expression; + if (shouldNotify) { + this._notifyExpressionChange(); + } + }); + + /** + * Clears the filter expression. + */ + clearFilters: IFilterInstance["clearFilters"] = action(async () => { + if (this.canClearFilters) { + const shouldNotify = shouldNotifyChangeForExpression(this.expression); + this.expression = null; + await this.clearFilterOptions?.onFilterClear(); + if (shouldNotify) { + this._notifyExpressionChange(); + } + } else { + console.warn("Cannot clear filters: invalid expression or missing options."); + } + }); + + /** + * Saves the filter expression. + */ + saveView: IFilterInstance["saveView"] = action(async () => { + if (this.canSaveView && this.saveViewOptions) { + await this.saveViewOptions.onViewSave(this._getExternalExpression()); + } else { + console.warn("Cannot save view: invalid expression or missing options."); + } + }); + + /** + * Updates the filter expression. + */ + updateView: IFilterInstance["updateView"] = action(async () => { + if (this.canUpdateView && this.updateViewOptions) { + await this.updateViewOptions.onViewUpdate(this._getExternalExpression()); + this._resetInitialFilterExpression(); + } else { + console.warn("Cannot update view: invalid expression or missing options."); + } + }); + + /** + * Updates the expression options for the filter instance. + * This allows dynamic updates to options like isDisabled properties. + */ + updateExpressionOptions: IFilterInstance["updateExpressionOptions"] = action((newOptions) => { + this.expressionOptions = { + ...this.expressionOptions, + ...newOptions, + }; + }); + + // ------------ private helpers ------------ + /** + * Resets the initial filter expression to the current expression. + */ + private _resetInitialFilterExpression(): void { + this.initialFilterExpression = cloneDeep(this.expression); + } + + /** + * Returns the external filter representation of the filter instance. + * @returns The external filter representation of the filter instance. + */ + private _getExternalExpression = computedFn(() => + this.adapter.toExternal(sanitizeAndStabilizeExpression(toJS(this.expression))) + ); + + /** + * Notifies the parent component of the expression change. + */ + private _notifyExpressionChange(): void { + this.onExpressionChange?.(this._getExternalExpression()); + } +} diff --git a/packages/shared-state/src/store/rich-filters/index.ts b/packages/shared-state/src/store/rich-filters/index.ts new file mode 100644 index 000000000..eb3564ddc --- /dev/null +++ b/packages/shared-state/src/store/rich-filters/index.ts @@ -0,0 +1,2 @@ +export * from "./adapter"; +export * from "./filter"; diff --git a/packages/shared-state/src/utils/index.ts b/packages/shared-state/src/utils/index.ts new file mode 100644 index 000000000..42270deb7 --- /dev/null +++ b/packages/shared-state/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./rich-filter.helper"; diff --git a/packages/shared-state/src/utils/rich-filter.helper.ts b/packages/shared-state/src/utils/rich-filter.helper.ts new file mode 100644 index 000000000..3d1533fcc --- /dev/null +++ b/packages/shared-state/src/utils/rich-filter.helper.ts @@ -0,0 +1,47 @@ +// plane imports +import { + LOGICAL_OPERATOR, + TBuildFilterExpressionParams, + TExternalFilter, + TFilterProperty, + TFilterValue, +} from "@plane/types"; +import { getOperatorForPayload } from "@plane/utils"; +// local imports +import { FilterInstance } from "../store/rich-filters/filter"; + +/** + * Builds a temporary filter expression from conditions. + * @param params.conditions - The conditions for building the filter expression. + * @param params.adapter - The adapter for building the filter expression. + * @returns The temporary filter expression. + */ +export const buildTempFilterExpressionFromConditions = < + P extends TFilterProperty, + V extends TFilterValue, + E extends TExternalFilter, +>( + params: TBuildFilterExpressionParams +): E | undefined => { + const { conditions, adapter } = params; + let tempExpression: E | undefined = undefined; + const tempFilterInstance = new FilterInstance({ + adapter, + onExpressionChange: (expression) => { + tempExpression = expression; + }, + }); + for (const condition of conditions) { + const { operator, isNegation } = getOperatorForPayload(condition.operator); + tempFilterInstance.addCondition( + LOGICAL_OPERATOR.AND, + { + property: condition.property, + operator, + value: condition.value, + }, + isNegation + ); + } + return tempExpression; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2a63141b1..ea6ee4080 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,49 +1,49 @@ -export * from "./users"; -export * from "./workspace"; -export * from "./cycle"; -export * from "./dashboard"; -export * from "./de-dupe"; -export * from "./description_version"; -export * from "./enums"; -export * from "./project"; -export * from "./state"; -export * from "./issues"; -export * from "./module"; -export * from "./views"; -export * from "./integration"; -export * from "./page"; +export * from "./activity"; export * from "./ai"; -export * from "./estimate"; -export * from "./importer"; -export * from "./inbox"; export * from "./analytics"; export * from "./api_token"; export * from "./auth"; export * from "./calendar"; -export * from "./instance"; -export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable -export * from "./reaction"; -export * from "./view-props"; -export * from "./waitlist"; -export * from "./webhook"; -export * from "./workspace-views"; +export * from "./charts"; +export * from "./command-palette"; export * from "./common"; +export * from "./cycle"; +export * from "./dashboard"; +export * from "./de-dupe"; +export * from "./description_version"; export * from "./editor"; -export * from "./pragmatic"; -export * from "./publish"; -export * from "./search"; -export * from "./workspace-notifications"; +export * from "./enums"; +export * from "./epics"; +export * from "./estimate"; export * from "./favorite"; export * from "./file"; -export * from "./workspace-draft-issues/base"; -export * from "./command-palette"; -export * from "./timezone"; -export * from "./activity"; -export * from "./epics"; -export * from "./charts"; export * from "./home"; -export * from "./stickies"; -export * from "./utils"; -export * from "./payment"; +export * from "./importer"; +export * from "./inbox"; +export * from "./instance"; +export * from "./integration"; +export * from "./issues"; +export * from "./issues/base"; // TODO: Remove this after development and the refactor/mobx-store-issue branch is stable export * from "./layout"; -export * from "./analytics"; +export * from "./module"; +export * from "./page"; +export * from "./payment"; +export * from "./pragmatic"; +export * from "./project"; +export * from "./publish"; +export * from "./reaction"; +export * from "./rich-filters"; +export * from "./search"; +export * from "./state"; +export * from "./stickies"; +export * from "./timezone"; +export * from "./users"; +export * from "./utils"; +export * from "./view-props"; +export * from "./views"; +export * from "./waitlist"; +export * from "./webhook"; +export * from "./workspace"; +export * from "./workspace-draft-issues/base"; +export * from "./workspace-notifications"; +export * from "./workspace-views"; diff --git a/packages/types/src/rich-filters/adapter.ts b/packages/types/src/rich-filters/adapter.ts new file mode 100644 index 000000000..8641ec33f --- /dev/null +++ b/packages/types/src/rich-filters/adapter.ts @@ -0,0 +1,23 @@ +// local imports +import { TFilterExpression, TFilterProperty } from "./expression"; + +/** + * External filter format + */ +export type TExternalFilter = Record | undefined | null; + +/** + * Adapter for converting between internal filter trees and external formats. + * @template P - Filter property type (e.g., 'state_id', 'priority', 'assignee') + * @template E - External filter format type (e.g., work item filters, automation filters) + */ +export interface IFilterAdapter

{ + /** + * Converts external format to internal filter tree. + */ + toInternal(externalFilter: E): TFilterExpression

| null; + /** + * Converts internal filter tree to external format. + */ + toExternal(internalFilter: TFilterExpression

| null): E; +} diff --git a/packages/types/src/rich-filters/builder.ts b/packages/types/src/rich-filters/builder.ts new file mode 100644 index 000000000..1dc0847fa --- /dev/null +++ b/packages/types/src/rich-filters/builder.ts @@ -0,0 +1,29 @@ +import { SingleOrArray } from "../utils"; +import { IFilterAdapter, TExternalFilter } from "./adapter"; +import { TFilterProperty, TFilterValue } from "./expression"; +import { TAllAvailableOperatorsForDisplay } from "./operators"; + +/** + * Condition payload for building filter expressions. + * @template P - Property key type + * @template V - Value type + */ +export type TFilterConditionForBuild

= { + property: P; + operator: TAllAvailableOperatorsForDisplay; + value: SingleOrArray; +}; + +/** + * Parameters for building filter expressions from multiple conditions. + * @template P - Property key type + * @template V - Value type + */ +export type TBuildFilterExpressionParams< + P extends TFilterProperty, + V extends TFilterValue, + E extends TExternalFilter, +> = { + conditions: TFilterConditionForBuild[]; + adapter: IFilterAdapter; +}; diff --git a/packages/types/src/rich-filters/config/filter-config.ts b/packages/types/src/rich-filters/config/filter-config.ts new file mode 100644 index 000000000..9ab7aeed2 --- /dev/null +++ b/packages/types/src/rich-filters/config/filter-config.ts @@ -0,0 +1,18 @@ +import { TFilterProperty, TFilterValue } from "../expression"; +import { TOperatorConfigMap } from "../operator-configs"; + +/** + * Main filter configuration type for different properties. + * This is the primary configuration type used throughout the application. + * + * @template P - Property key type (e.g., 'state_id', 'priority', 'assignee') + * @template V - Value type for the filter + */ +export type TFilterConfig

= { + id: P; + label: string; + icon?: React.FC>; + isEnabled: boolean; + allowMultipleFilters?: boolean; + supportedOperatorConfigsMap: TOperatorConfigMap; +}; diff --git a/packages/types/src/rich-filters/config/index.ts b/packages/types/src/rich-filters/config/index.ts new file mode 100644 index 000000000..cd3f61fa5 --- /dev/null +++ b/packages/types/src/rich-filters/config/index.ts @@ -0,0 +1 @@ +export * from "./filter-config"; diff --git a/packages/types/src/rich-filters/derived/core.ts b/packages/types/src/rich-filters/derived/core.ts new file mode 100644 index 000000000..23c960c90 --- /dev/null +++ b/packages/types/src/rich-filters/derived/core.ts @@ -0,0 +1,77 @@ +import { TFilterValue } from "../expression"; +import { + TDateFilterFieldConfig, + TDateRangeFilterFieldConfig, + TSingleSelectFilterFieldConfig, + TMultiSelectFilterFieldConfig, +} from "../field-types"; +import { TCoreOperatorSpecificConfigs } from "../operator-configs"; +import { TFilterOperatorHelper } from "./shared"; + +// -------- DATE FILTER OPERATORS -------- + +/** + * Union type representing all core operators that support single date filter types. + */ +export type TCoreSupportedSingleDateFilterOperators = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TDateFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support range date filter types. + */ +export type TCoreSupportedRangeDateFilterOperators = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TDateRangeFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support date filter types. + */ +export type TCoreSupportedDateFilterOperators = + | TCoreSupportedSingleDateFilterOperators + | TCoreSupportedRangeDateFilterOperators; + +export type TCoreAllAvailableDateFilterOperatorsForDisplay = + TCoreSupportedDateFilterOperators; + +// -------- SELECT FILTER OPERATORS -------- + +/** + * Union type representing all core operators that support single select filter types. + */ +export type TCoreSupportedSingleSelectFilterOperators = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TSingleSelectFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support multi select filter types. + */ +export type TCoreSupportedMultiSelectFilterOperators = { + [K in keyof TCoreOperatorSpecificConfigs]: TFilterOperatorHelper< + TCoreOperatorSpecificConfigs, + K, + TMultiSelectFilterFieldConfig + >; +}[keyof TCoreOperatorSpecificConfigs]; + +/** + * Union type representing all core operators that support any select filter types. + */ +export type TCoreSupportedSelectFilterOperators = + | TCoreSupportedSingleSelectFilterOperators + | TCoreSupportedMultiSelectFilterOperators; + +export type TCoreAllAvailableSelectFilterOperatorsForDisplay = + TCoreSupportedSelectFilterOperators; diff --git a/packages/types/src/rich-filters/derived/extended.ts b/packages/types/src/rich-filters/derived/extended.ts new file mode 100644 index 000000000..118e5b87b --- /dev/null +++ b/packages/types/src/rich-filters/derived/extended.ts @@ -0,0 +1,19 @@ +import { TFilterValue } from "../expression"; + +// -------- DATE FILTER OPERATORS -------- + +/** + * Union type representing all extended operators that support date filter types. + */ +export type TExtendedSupportedDateFilterOperators<_V extends TFilterValue = TFilterValue> = never; + +export type TExtendedAllAvailableDateFilterOperatorsForDisplay<_V extends TFilterValue = TFilterValue> = never; + +// -------- SELECT FILTER OPERATORS -------- + +/** + * Union type representing all extended operators that support select filter types. + */ +export type TExtendedSupportedSelectFilterOperators<_V extends TFilterValue = TFilterValue> = never; + +export type TExtendedAllAvailableSelectFilterOperatorsForDisplay<_V extends TFilterValue = TFilterValue> = never; diff --git a/packages/types/src/rich-filters/derived/index.ts b/packages/types/src/rich-filters/derived/index.ts new file mode 100644 index 000000000..cc31c946c --- /dev/null +++ b/packages/types/src/rich-filters/derived/index.ts @@ -0,0 +1,43 @@ +import { TFilterValue } from "../expression"; +import { + TCoreAllAvailableDateFilterOperatorsForDisplay, + TCoreAllAvailableSelectFilterOperatorsForDisplay, + TCoreSupportedDateFilterOperators, + TCoreSupportedSelectFilterOperators, +} from "./core"; +import { + TExtendedAllAvailableDateFilterOperatorsForDisplay, + TExtendedAllAvailableSelectFilterOperatorsForDisplay, + TExtendedSupportedDateFilterOperators, + TExtendedSupportedSelectFilterOperators, +} from "./extended"; + +// -------- COMPOSED SUPPORT TYPES -------- + +/** + * All supported date filter operators. + */ +export type TSupportedDateFilterOperators = + | TCoreSupportedDateFilterOperators + | TExtendedSupportedDateFilterOperators; + +export type TAllAvailableDateFilterOperatorsForDisplay = + | TCoreAllAvailableDateFilterOperatorsForDisplay + | TExtendedAllAvailableDateFilterOperatorsForDisplay; + +/** + * All supported select filter operators. + */ +export type TSupportedSelectFilterOperators = + | TCoreSupportedSelectFilterOperators + | TExtendedSupportedSelectFilterOperators; + +export type TAllAvailableSelectFilterOperatorsForDisplay = + | TCoreAllAvailableSelectFilterOperatorsForDisplay + | TExtendedAllAvailableSelectFilterOperatorsForDisplay; + +// -------- RE-EXPORTS -------- + +export * from "./shared"; +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/rich-filters/derived/shared.ts b/packages/types/src/rich-filters/derived/shared.ts new file mode 100644 index 000000000..cce5ebaf0 --- /dev/null +++ b/packages/types/src/rich-filters/derived/shared.ts @@ -0,0 +1,9 @@ +/** + * Generic utility type to check if a configuration type supports specific filter types. + * Returns the operator key if any member of the union includes the target filter types, never otherwise. + */ +export type TFilterOperatorHelper< + TOperatorConfigs, + K extends keyof TOperatorConfigs, + TTargetFilter, +> = TTargetFilter extends TOperatorConfigs[K] ? K : TOperatorConfigs[K] extends TTargetFilter ? K : never; diff --git a/packages/types/src/rich-filters/expression.ts b/packages/types/src/rich-filters/expression.ts new file mode 100644 index 000000000..10d115e84 --- /dev/null +++ b/packages/types/src/rich-filters/expression.ts @@ -0,0 +1,110 @@ +// local imports +import { SingleOrArray } from "../utils"; +import { TSupportedOperators, LOGICAL_OPERATOR, TAllAvailableOperatorsForDisplay } from "./operators"; + +/** + * Filter node types for building hierarchical filter trees. + * - CONDITION: Single filter for one field (e.g., "state is backlog") + * - GROUP: Logical container combining multiple filters with AND/OR or single filter/group with NOT + */ +export const FILTER_NODE_TYPE = { + CONDITION: "condition", + GROUP: "group", +} as const; +export type TFilterNodeType = (typeof FILTER_NODE_TYPE)[keyof typeof FILTER_NODE_TYPE]; + +/** + * Field property key that can be filtered (e.g., "state", "assignee", "created_at"). + */ +export type TFilterProperty = string; + +/** + * Allowed filter values - primitives plus null/undefined for empty states. + */ +export type TFilterValue = string | number | Date | boolean | null | undefined; + +/** + * Base properties shared by all filter nodes. + * - id: Unique identifier for the node + * - type: Node type (condition or group) + */ +type TBaseFilterNode = { + id: string; + type: TFilterNodeType; +}; + +/** + * Leaf node representing a single filter condition (e.g., "state is backlog"). + * - type: Node type (condition) + * - property: Field being filtered + * - operator: Comparison operator (is, is not, between, not between, etc.) + * - value: Filter value(s) - array for operators that support multiple values + * @template P - Property key type + * @template V - Value type + */ +export type TFilterConditionNode

= TBaseFilterNode & { + type: typeof FILTER_NODE_TYPE.CONDITION; + property: P; + operator: TSupportedOperators; + value: SingleOrArray; +}; + +/** + * Filter condition node for display purposes. + */ +export type TFilterConditionNodeForDisplay

= Omit< + TFilterConditionNode, + "operator" +> & { + operator: TAllAvailableOperatorsForDisplay; +}; + +/** + * Container node that combines multiple conditions with AND logical operator. + * - type: Node type (group) + * - logicalOperator: AND operator for combining child filters + * - children: Child conditions and/or nested groups (minimum 2 for meaningful operations) + * @template P - Property key type + */ +export type TFilterAndGroupNode

= TBaseFilterNode & { + type: typeof FILTER_NODE_TYPE.GROUP; + logicalOperator: typeof LOGICAL_OPERATOR.AND; + children: TFilterExpression

[]; +}; + +/** + * Union type for all group node types - AND, OR, and NOT groups. + * @template P - Property key type + */ +export type TFilterGroupNode

= TFilterAndGroupNode

; + +/** + * Union type for any filter node - either a single condition or a group container. + * @template P - Property key type + * @template V - Value type + */ +export type TFilterExpression

= + | TFilterConditionNode + | TFilterGroupNode

; + +/** + * Payload for creating/updating condition nodes - excludes base node properties. + * @template P - Property key type + * @template V - Value type + */ +export type TFilterConditionPayload

= Omit< + TFilterConditionNode, + keyof TBaseFilterNode +>; + +/** + * Payload for creating/updating AND group nodes - excludes base node properties. + * @template P - Property key type + */ +export type TFilterAndGroupPayload

= Omit, keyof TBaseFilterNode>; + +/** + * Union payload type for creating/updating any group node - excludes base node properties. + * @template P - Property key type + */ +export type TFilterGroupPayload

= TFilterAndGroupPayload

; diff --git a/packages/types/src/rich-filters/field-types/core.ts b/packages/types/src/rich-filters/field-types/core.ts new file mode 100644 index 000000000..504979f21 --- /dev/null +++ b/packages/types/src/rich-filters/field-types/core.ts @@ -0,0 +1,79 @@ +import { TFilterValue } from "../expression"; +import { TSupportedOperators } from "../operators"; +import { TBaseFilterFieldConfig, IFilterOption } from "./shared"; + +/** + * Core filter types + */ +export const CORE_FILTER_FIELD_TYPE = { + DATE: "date", + DATE_RANGE: "date_range", + SINGLE_SELECT: "single_select", + MULTI_SELECT: "multi_select", +} as const; + +// -------- DATE FILTER CONFIGURATIONS -------- + +type TBaseDateFilterFieldConfig = TBaseFilterFieldConfig & { + min?: Date; + max?: Date; +}; + +/** + * Date filter configuration - for temporal filtering. + * - defaultValue: Initial date/time value + * - min: Minimum allowed date + * - max: Maximum allowed date + */ +export type TDateFilterFieldConfig = TBaseDateFilterFieldConfig & { + type: typeof CORE_FILTER_FIELD_TYPE.DATE; + defaultValue?: V; +}; + +/** + * Date range filter configuration - for temporal filtering. + * - defaultValue: Initial date/time range values + * - min: Minimum allowed date + * - max: Maximum allowed date + */ +export type TDateRangeFilterFieldConfig = TBaseDateFilterFieldConfig & { + type: typeof CORE_FILTER_FIELD_TYPE.DATE_RANGE; + defaultValue?: V[]; +}; + +// -------- SELECT FILTER CONFIGURATIONS -------- + +/** + * Single-select filter configuration - dropdown with one selectable option. + * - defaultValue: Initial selected value + * - getOptions: Options as static array or async function + */ +export type TSingleSelectFilterFieldConfig = TBaseFilterFieldConfig & { + type: typeof CORE_FILTER_FIELD_TYPE.SINGLE_SELECT; + defaultValue?: V; + getOptions: IFilterOption[] | (() => IFilterOption[] | Promise[]>); +}; + +/** + * Multi-select filter configuration - allows selecting multiple options. + * - defaultValue: Initial selected values array + * - getOptions: Options as static array or async function + * - singleValueOperator: Operator to show when single value is selected + */ +export type TMultiSelectFilterFieldConfig = TBaseFilterFieldConfig & { + type: typeof CORE_FILTER_FIELD_TYPE.MULTI_SELECT; + defaultValue?: V[]; + getOptions: IFilterOption[] | (() => IFilterOption[] | Promise[]>); + singleValueOperator: TSupportedOperators; +}; + +// -------- UNION TYPES -------- + +/** + * All core filter configurations + */ +export type TCoreFilterFieldConfigs = + | TDateFilterFieldConfig + | TDateRangeFilterFieldConfig + | TSingleSelectFilterFieldConfig + | TMultiSelectFilterFieldConfig; diff --git a/packages/types/src/rich-filters/field-types/extended.ts b/packages/types/src/rich-filters/field-types/extended.ts new file mode 100644 index 000000000..80922fbd0 --- /dev/null +++ b/packages/types/src/rich-filters/field-types/extended.ts @@ -0,0 +1,13 @@ +import { TFilterValue } from "../expression"; + +/** + * Extended filter types + */ +export const EXTENDED_FILTER_FIELD_TYPE = {} as const; + +// -------- UNION TYPES -------- + +/** + * All extended filter configurations + */ +export type TExtendedFilterFieldConfigs<_V extends TFilterValue = TFilterValue> = never; diff --git a/packages/types/src/rich-filters/field-types/index.ts b/packages/types/src/rich-filters/field-types/index.ts new file mode 100644 index 000000000..946c38cda --- /dev/null +++ b/packages/types/src/rich-filters/field-types/index.ts @@ -0,0 +1,27 @@ +import { TFilterValue } from "../expression"; +import { CORE_FILTER_FIELD_TYPE, TCoreFilterFieldConfigs } from "./core"; +import { EXTENDED_FILTER_FIELD_TYPE, TExtendedFilterFieldConfigs } from "./extended"; + +// -------- COMPOSED FILTER TYPES -------- + +export const FILTER_FIELD_TYPE = { + ...CORE_FILTER_FIELD_TYPE, + ...EXTENDED_FILTER_FIELD_TYPE, +} as const; + +export type TFilterFieldType = (typeof FILTER_FIELD_TYPE)[keyof typeof FILTER_FIELD_TYPE]; + +// -------- COMPOSED CONFIGURATIONS -------- + +/** + * All supported filter configurations. + */ +export type TSupportedFilterFieldConfigs = + | TCoreFilterFieldConfigs + | TExtendedFilterFieldConfigs; + +// -------- RE-EXPORTS -------- + +export * from "./shared"; +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/rich-filters/field-types/shared.ts b/packages/types/src/rich-filters/field-types/shared.ts new file mode 100644 index 000000000..0163e9743 --- /dev/null +++ b/packages/types/src/rich-filters/field-types/shared.ts @@ -0,0 +1,37 @@ +import { TFilterValue } from "../expression"; + +/** + * Negative operator configuration for operators. + * - allowNegative: Whether the operator supports negation + * - negOperatorLabel: Label to use when the operator is negated + */ +export type TNegativeOperatorConfig = { allowNegative: true; negOperatorLabel?: string } | { allowNegative?: false }; + +/** + * Base filter configuration shared by all filter types. + * - operatorLabel: Label to use for the operator + * - negativeOperatorConfig: Configuration for negative operators + */ +export type TBaseFilterFieldConfig = { + operatorLabel?: string; +} & TNegativeOperatorConfig; + +/** + * Individual option for select/multi-select filters. + * - id: Unique identifier for the option + * - label: Display text shown to users + * - value: Actual value used in filtering + * - icon: Optional icon component + * - iconClassName: CSS class for icon styling + * - disabled: Whether option can be selected + * - description: Additional context to be displayed in the filter dropdown + */ +export interface IFilterOption { + id: string; + label: string; + value: V; + icon?: React.ReactNode; + iconClassName?: string; + disabled?: boolean; + description?: string; +} diff --git a/packages/types/src/rich-filters/index.ts b/packages/types/src/rich-filters/index.ts new file mode 100644 index 000000000..e242d63e1 --- /dev/null +++ b/packages/types/src/rich-filters/index.ts @@ -0,0 +1,8 @@ +export * from "./adapter"; +export * from "./builder"; +export * from "./config"; +export * from "./derived"; +export * from "./expression"; +export * from "./operator-configs"; +export * from "./operators"; +export * from "./field-types"; diff --git a/packages/types/src/rich-filters/operator-configs/core.ts b/packages/types/src/rich-filters/operator-configs/core.ts new file mode 100644 index 000000000..994361159 --- /dev/null +++ b/packages/types/src/rich-filters/operator-configs/core.ts @@ -0,0 +1,26 @@ +import { TFilterValue } from "../expression"; +import { + TDateFilterFieldConfig, + TDateRangeFilterFieldConfig, + TSingleSelectFilterFieldConfig, + TMultiSelectFilterFieldConfig, +} from "../field-types"; +import { CORE_COLLECTION_OPERATOR, CORE_COMPARISON_OPERATOR, CORE_EQUALITY_OPERATOR } from "../operators"; + +// ----------------------------- EXACT Operator ----------------------------- +export type TCoreExactOperatorConfigs = + | TSingleSelectFilterFieldConfig + | TDateFilterFieldConfig; + +// ----------------------------- IN Operator ----------------------------- +export type TCoreInOperatorConfigs = TMultiSelectFilterFieldConfig; + +// ----------------------------- RANGE Operator ----------------------------- +export type TCoreRangeOperatorConfigs = TDateRangeFilterFieldConfig; + +// ----------------------------- Core Operator Specific Configs ----------------------------- +export type TCoreOperatorSpecificConfigs = { + [CORE_EQUALITY_OPERATOR.EXACT]: TCoreExactOperatorConfigs; + [CORE_COLLECTION_OPERATOR.IN]: TCoreInOperatorConfigs; + [CORE_COMPARISON_OPERATOR.RANGE]: TCoreRangeOperatorConfigs; +}; diff --git a/packages/types/src/rich-filters/operator-configs/extended.ts b/packages/types/src/rich-filters/operator-configs/extended.ts new file mode 100644 index 000000000..19f2870c1 --- /dev/null +++ b/packages/types/src/rich-filters/operator-configs/extended.ts @@ -0,0 +1,13 @@ +import { TFilterValue } from "../expression"; + +// ----------------------------- EXACT Operator ----------------------------- +export type TExtendedExactOperatorConfigs<_V extends TFilterValue> = never; + +// ----------------------------- IN Operator ----------------------------- +export type TExtendedInOperatorConfigs<_V extends TFilterValue> = never; + +// ----------------------------- RANGE Operator ----------------------------- +export type TExtendedRangeOperatorConfigs<_V extends TFilterValue> = never; + +// ----------------------------- Extended Operator Specific Configs ----------------------------- +export type TExtendedOperatorSpecificConfigs<_V extends TFilterValue> = unknown; diff --git a/packages/types/src/rich-filters/operator-configs/index.ts b/packages/types/src/rich-filters/operator-configs/index.ts new file mode 100644 index 000000000..4b863c306 --- /dev/null +++ b/packages/types/src/rich-filters/operator-configs/index.ts @@ -0,0 +1,56 @@ +import { TFilterValue } from "../expression"; +import { EQUALITY_OPERATOR, COLLECTION_OPERATOR, COMPARISON_OPERATOR } from "../operators"; +import { TCoreExactOperatorConfigs, TCoreInOperatorConfigs, TCoreRangeOperatorConfigs } from "./core"; +import { + TExtendedExactOperatorConfigs, + TExtendedInOperatorConfigs, + TExtendedOperatorSpecificConfigs, + TExtendedRangeOperatorConfigs, +} from "./extended"; + +// ----------------------------- Composed Operator Configs ----------------------------- + +/** + * EXACT operator - combines core and extended configurations + */ +export type TExactOperatorConfigs = + | TCoreExactOperatorConfigs + | TExtendedExactOperatorConfigs; + +/** + * IN operator - combines core and extended configurations + */ +export type TInOperatorConfigs = TCoreInOperatorConfigs | TExtendedInOperatorConfigs; + +/** + * RANGE operator - combines core and extended configurations + */ +export type TRangeOperatorConfigs = + | TCoreRangeOperatorConfigs + | TExtendedRangeOperatorConfigs; + +// ----------------------------- Final Operator Specific Configs ----------------------------- + +/** + * Type-safe mapping of specific operators to their supported filter type configurations. + * Each operator maps to its composed (core + extended) configurations. + */ +export type TOperatorSpecificConfigs = { + [EQUALITY_OPERATOR.EXACT]: TExactOperatorConfigs; + [COLLECTION_OPERATOR.IN]: TInOperatorConfigs; + [COMPARISON_OPERATOR.RANGE]: TRangeOperatorConfigs; +} & TExtendedOperatorSpecificConfigs; + +/** + * Operator filter configuration mapping - for different operators. + * Provides type-safe mapping of operators to their specific supported configurations. + */ +export type TOperatorConfigMap = Map< + keyof TOperatorSpecificConfigs, + TOperatorSpecificConfigs[keyof TOperatorSpecificConfigs] +>; + +// -------- RE-EXPORTS -------- + +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/rich-filters/operators/core.ts b/packages/types/src/rich-filters/operators/core.ts new file mode 100644 index 000000000..91c9adc28 --- /dev/null +++ b/packages/types/src/rich-filters/operators/core.ts @@ -0,0 +1,38 @@ +/** + * Core logical operators + */ +export const CORE_LOGICAL_OPERATOR = { + AND: "and", +} as const; + +/** + * Core equality operators + */ +export const CORE_EQUALITY_OPERATOR = { + EXACT: "exact", +} as const; + +/** + * Core collection operators + */ +export const CORE_COLLECTION_OPERATOR = { + IN: "in", +} as const; + +/** + * Core comparison operators + */ +export const CORE_COMPARISON_OPERATOR = { + RANGE: "range", +} as const; + +// -------- TYPE EXPORTS -------- + +type TCoreEqualityOperator = (typeof CORE_EQUALITY_OPERATOR)[keyof typeof CORE_EQUALITY_OPERATOR]; +type TCoreCollectionOperator = (typeof CORE_COLLECTION_OPERATOR)[keyof typeof CORE_COLLECTION_OPERATOR]; +type TCoreComparisonOperator = (typeof CORE_COMPARISON_OPERATOR)[keyof typeof CORE_COMPARISON_OPERATOR]; + +/** + * All core operators that can be used in filter conditions + */ +export type TCoreSupportedOperators = TCoreEqualityOperator | TCoreCollectionOperator | TCoreComparisonOperator; diff --git a/packages/types/src/rich-filters/operators/extended.ts b/packages/types/src/rich-filters/operators/extended.ts new file mode 100644 index 000000000..56870326c --- /dev/null +++ b/packages/types/src/rich-filters/operators/extended.ts @@ -0,0 +1,33 @@ +/** + * Extended logical operators + */ +export const EXTENDED_LOGICAL_OPERATOR = {} as const; + +/** + * Extended equality operators + */ +export const EXTENDED_EQUALITY_OPERATOR = {} as const; + +/** + * Extended collection operators + */ +export const EXTENDED_COLLECTION_OPERATOR = {} as const; + +/** + * Extended comparison operators + */ +export const EXTENDED_COMPARISON_OPERATOR = {} as const; + +// -------- TYPE EXPORTS -------- + +type TExtendedEqualityOperator = (typeof EXTENDED_EQUALITY_OPERATOR)[keyof typeof EXTENDED_EQUALITY_OPERATOR]; +type TExtendedCollectionOperator = (typeof EXTENDED_COLLECTION_OPERATOR)[keyof typeof EXTENDED_COLLECTION_OPERATOR]; +type TExtendedComparisonOperator = (typeof EXTENDED_COMPARISON_OPERATOR)[keyof typeof EXTENDED_COMPARISON_OPERATOR]; + +/** + * All extended operators that can be used in filter conditions + */ +export type TExtendedSupportedOperators = + | TExtendedEqualityOperator + | TExtendedCollectionOperator + | TExtendedComparisonOperator; diff --git a/packages/types/src/rich-filters/operators/index.ts b/packages/types/src/rich-filters/operators/index.ts new file mode 100644 index 000000000..458eff497 --- /dev/null +++ b/packages/types/src/rich-filters/operators/index.ts @@ -0,0 +1,59 @@ +import { + CORE_LOGICAL_OPERATOR, + CORE_EQUALITY_OPERATOR, + CORE_COLLECTION_OPERATOR, + CORE_COMPARISON_OPERATOR, + TCoreSupportedOperators, +} from "./core"; +import { + EXTENDED_LOGICAL_OPERATOR, + EXTENDED_EQUALITY_OPERATOR, + EXTENDED_COLLECTION_OPERATOR, + EXTENDED_COMPARISON_OPERATOR, + TExtendedSupportedOperators, +} from "./extended"; + +// -------- COMPOSED OPERATORS -------- + +export const LOGICAL_OPERATOR = { + ...CORE_LOGICAL_OPERATOR, + ...EXTENDED_LOGICAL_OPERATOR, +} as const; + +export const EQUALITY_OPERATOR = { + ...CORE_EQUALITY_OPERATOR, + ...EXTENDED_EQUALITY_OPERATOR, +} as const; + +export const COLLECTION_OPERATOR = { + ...CORE_COLLECTION_OPERATOR, + ...EXTENDED_COLLECTION_OPERATOR, +} as const; + +export const COMPARISON_OPERATOR = { + ...CORE_COMPARISON_OPERATOR, + ...EXTENDED_COMPARISON_OPERATOR, +} as const; + +// -------- COMPOSED TYPES -------- + +export type TLogicalOperator = (typeof LOGICAL_OPERATOR)[keyof typeof LOGICAL_OPERATOR]; +export type TEqualityOperator = (typeof EQUALITY_OPERATOR)[keyof typeof EQUALITY_OPERATOR]; +export type TCollectionOperator = (typeof COLLECTION_OPERATOR)[keyof typeof COLLECTION_OPERATOR]; +export type TComparisonOperator = (typeof COMPARISON_OPERATOR)[keyof typeof COMPARISON_OPERATOR]; + +/** + * Union type representing all operators that can be used in a filter condition. + * Combines core and extended operators. + */ +export type TSupportedOperators = TCoreSupportedOperators | TExtendedSupportedOperators; + +/** + * All operators available for use in rich filters UI, including negated versions. + */ +export type TAllAvailableOperatorsForDisplay = TSupportedOperators; + +// -------- RE-EXPORTS -------- + +export * from "./core"; +export * from "./extended"; diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts index d7f7067b1..81cc8a581 100644 --- a/packages/types/src/utils.ts +++ b/packages/types/src/utils.ts @@ -5,3 +5,5 @@ export type PartialDeep = { export type CompleteOrEmpty = T | Record; export type MakeOptional = Omit & Partial>; + +export type SingleOrArray = T extends null | undefined ? T : T | T[]; diff --git a/packages/ui/src/dropdowns/custom-search-select.tsx b/packages/ui/src/dropdowns/custom-search-select.tsx index 712f2120f..8fdd0f664 100644 --- a/packages/ui/src/dropdowns/custom-search-select.tsx +++ b/packages/ui/src/dropdowns/custom-search-select.tsx @@ -162,6 +162,7 @@ export const CustomSearchSelect = (props: ICustomSearchSelectProps) => { "max-h-48": maxHeight === "md", "max-h-36": maxHeight === "rg", "max-h-28": maxHeight === "sm", + "max-h-full": maxHeight === "full", })} > {filteredOptions ? ( diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 230b517f5..8a3fc0e3b 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -24,7 +24,7 @@ export interface IDropdownProps { disabled?: boolean; input?: boolean; label?: string | React.ReactNode; - maxHeight?: "sm" | "rg" | "md" | "lg"; + maxHeight?: "sm" | "rg" | "md" | "lg" | "full"; noChevron?: boolean; chevronClassName?: string; onOpen?: () => void; diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index fe306a617..685273dba 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -535,18 +535,25 @@ export const formatDateRange = ( // Duration Helpers /** * @returns {string} formatted duration in human readable format - * @description Converts seconds to human readable duration format (e.g., "1 hr 20 min 5 sec") + * @description Converts seconds to human readable duration format (e.g., "1 hr 20 min 5 sec" or "122.30 ms") * @param {number} seconds - The duration in seconds * @example formatDuration(3665) // "1 hr 1 min 5 sec" * @example formatDuration(125) // "2 min 5 sec" * @example formatDuration(45) // "45 sec" + * @example formatDuration(0.1223094) // "122.31 ms" */ export const formatDuration = (seconds: number | undefined | null): string => { // Return "N/A" if seconds is not a valid number - if (!isNumber(seconds) || seconds === null || seconds === undefined || seconds < 0) { + if (seconds == null || typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) { return "N/A"; } + // If less than 1 second, show in ms (2 decimal places) + if (seconds > 0 && seconds < 1) { + const ms = seconds * 1000; + return `${ms.toFixed(2)} ms`; + } + // Round to nearest second const totalSeconds = Math.round(seconds); @@ -559,7 +566,7 @@ export const formatDuration = (seconds: number | undefined | null): string => { const parts: string[] = []; if (hours > 0) { - parts.push(`${hours} hr${hours !== 1 ? "" : ""}`); // Always use "hr" for consistency + parts.push(`${hours} hr`); } if (minutes > 0) { @@ -572,3 +579,11 @@ export const formatDuration = (seconds: number | undefined | null): string => { return parts.join(" "); }; + +/** + * Checks if a date is valid + * @param date The date to check + * @returns Whether the date is valid or not + */ +export const isValidDate = (date: unknown): date is string | Date => + (typeof date === "string" || typeof date === "object") && date !== null && !isNaN(Date.parse(date as string)); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 760f82333..d411a69d2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -19,8 +19,9 @@ export * from "./module"; export * from "./notification"; export * from "./page"; export * from "./permission"; -export * from "./project"; export * from "./project-views"; +export * from "./project"; +export * from "./rich-filters"; export * from "./router"; export * from "./string"; export * from "./subscription"; diff --git a/packages/utils/src/rich-filters/factories/configs/core.ts b/packages/utils/src/rich-filters/factories/configs/core.ts new file mode 100644 index 000000000..a3450d2c0 --- /dev/null +++ b/packages/utils/src/rich-filters/factories/configs/core.ts @@ -0,0 +1,156 @@ +// plane imports +import { + FILTER_FIELD_TYPE, + TFilterValue, + TFilterProperty, + TFilterConfig, + TSupportedOperators, + TBaseFilterFieldConfig, +} from "@plane/types"; +// local imports +import { + createFilterFieldConfig, + DEFAULT_DATE_FILTER_TYPE_CONFIG, + DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG, + DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG, + DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG, + IFilterIconConfig, +} from "./shared"; + +/** + * Helper to create a type-safe filter config + * @param config - The filter config to create + * @returns The created filter config + */ +export const createFilterConfig =

( + config: TFilterConfig +): TFilterConfig => config; + +// ------------ Selection filters ------------ + +/** + * Options transformation interface for selection filters + */ +export interface TOptionTransforms { + items: TItem[]; + getId: (item: TItem) => string; + getLabel: (item: TItem) => string; + getValue: (item: TItem) => TValue; + getIconData?: (item: TItem) => TIconData; +} + +/** + * Single-select filter configuration + */ +export type TSingleSelectConfig = TBaseFilterFieldConfig & { + defaultValue?: TValue; +}; + +/** + * Helper to get the single select config + * @param transforms - How to transform items into options + * @param config - Single-select specific configuration + * @param iconConfig - Icon configuration for options + * @returns The single select config + */ +export const getSingleSelectConfig = < + TItem, + TValue extends TFilterValue = string, + TIconData extends string | number | boolean | object | undefined = undefined, +>( + transforms: TOptionTransforms, + config?: TSingleSelectConfig, + iconConfig?: IFilterIconConfig +) => + createFilterFieldConfig({ + type: FILTER_FIELD_TYPE.SINGLE_SELECT, + ...DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG, + ...config, + getOptions: () => + transforms.items.map((item) => ({ + id: transforms.getId(item), + label: transforms.getLabel(item), + value: transforms.getValue(item), + icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData), + })), + }); + +/** + * Multi-select filter configuration + */ +export type TMultiSelectConfig = TBaseFilterFieldConfig & { + defaultValue?: TValue[]; + singleValueOperator: TSupportedOperators; +}; + +/** + * Helper to get the multi select config + * @param transforms - How to transform items into options + * @param config - Multi-select specific configuration + * @param iconConfig - Icon configuration for options + * @returns The multi select config + */ +export const getMultiSelectConfig = < + TItem, + TValue extends TFilterValue = string, + TIconData extends string | number | boolean | object | undefined = undefined, +>( + transforms: TOptionTransforms, + config: TMultiSelectConfig, + iconConfig?: IFilterIconConfig +) => + createFilterFieldConfig({ + type: FILTER_FIELD_TYPE.MULTI_SELECT, + ...DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG, + ...config, + operatorLabel: config?.operatorLabel, + getOptions: () => + transforms.items.map((item) => ({ + id: transforms.getId(item), + label: transforms.getLabel(item), + value: transforms.getValue(item), + icon: iconConfig?.getOptionIcon?.(transforms.getIconData?.(item) as TIconData), + })), + }); + +// ------------ Date filters ------------ + +/** + * Date filter configuration + */ +export type TDateConfig = TBaseFilterFieldConfig & { + min?: Date; + max?: Date; +}; + +/** + * Date range filter configuration + */ +export type TDateRangeConfig = TBaseFilterFieldConfig & { + min?: Date; + max?: Date; +}; + +/** + * Helper to get the date picker config + * @param config - Date-specific configuration + * @returns The date picker config + */ +export const getDatePickerConfig = (config?: TDateConfig) => + createFilterFieldConfig({ + type: FILTER_FIELD_TYPE.DATE, + ...DEFAULT_DATE_FILTER_TYPE_CONFIG, + ...config, + }); + +/** + * Helper to get the date range picker config + * @param config - Date range-specific configuration + * @returns The date range picker config + */ +export const getDateRangePickerConfig = (config?: TDateRangeConfig) => + createFilterFieldConfig({ + type: FILTER_FIELD_TYPE.DATE_RANGE, + ...DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG, + ...config, + }); diff --git a/packages/utils/src/rich-filters/factories/configs/index.ts b/packages/utils/src/rich-filters/factories/configs/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/factories/configs/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/factories/configs/shared.ts b/packages/utils/src/rich-filters/factories/configs/shared.ts new file mode 100644 index 000000000..8647ea5d6 --- /dev/null +++ b/packages/utils/src/rich-filters/factories/configs/shared.ts @@ -0,0 +1,76 @@ +import { + FILTER_FIELD_TYPE, + TBaseFilterFieldConfig, + TDateFilterFieldConfig, + TDateRangeFilterFieldConfig, + TFilterConfig, + TFilterProperty, + TFilterFieldType, + TFilterValue, + TMultiSelectFilterFieldConfig, + TSingleSelectFilterFieldConfig, + TSupportedFilterFieldConfigs, +} from "@plane/types"; + +/** + * Factory function signature for creating filter configurations. + */ +export type TCreateFilterConfig

= (params: T) => TFilterConfig

; + +/** + * Helper to create a type-safe filter field config + * @param config - The filter field config to create + * @returns The created filter field config + */ +export const createFilterFieldConfig = ( + config: T extends typeof FILTER_FIELD_TYPE.SINGLE_SELECT + ? TSingleSelectFilterFieldConfig + : T extends typeof FILTER_FIELD_TYPE.MULTI_SELECT + ? TMultiSelectFilterFieldConfig + : T extends typeof FILTER_FIELD_TYPE.DATE + ? TDateFilterFieldConfig + : T extends typeof FILTER_FIELD_TYPE.DATE_RANGE + ? TDateRangeFilterFieldConfig + : never +): TSupportedFilterFieldConfigs => config as TSupportedFilterFieldConfigs; + +/** + * Base parameters for filter type config factory functions. + * - operator: The operator to use for the filter. + */ +export type TCreateFilterConfigParams = TBaseFilterFieldConfig & { + isEnabled: boolean; +}; + +/** + * Icon configuration for filters and their options. + * - filterIcon: Optional icon for the filter + * - getOptionIcon: Function to get icon for specific option values + */ +export interface IFilterIconConfig { + filterIcon?: React.FC>; + getOptionIcon?: (value: T) => React.ReactNode; +} + +/** + * Date filter config params + */ +export type TCreateDateFilterParams = TCreateFilterConfigParams & IFilterIconConfig; + +// ------------ Default filter type configs ------------ + +export const DEFAULT_SINGLE_SELECT_FILTER_TYPE_CONFIG = { + allowNegative: false, +}; + +export const DEFAULT_MULTI_SELECT_FILTER_TYPE_CONFIG = { + allowNegative: false, +}; + +export const DEFAULT_DATE_FILTER_TYPE_CONFIG = { + allowNegative: false, +}; + +export const DEFAULT_DATE_RANGE_FILTER_TYPE_CONFIG = { + allowNegative: false, +}; diff --git a/packages/utils/src/rich-filters/factories/index.ts b/packages/utils/src/rich-filters/factories/index.ts new file mode 100644 index 000000000..9518a7e35 --- /dev/null +++ b/packages/utils/src/rich-filters/factories/index.ts @@ -0,0 +1,3 @@ +export * from "./configs/core"; +export * from "./configs/shared"; +export * from "./nodes/core"; diff --git a/packages/utils/src/rich-filters/factories/nodes/core.ts b/packages/utils/src/rich-filters/factories/nodes/core.ts new file mode 100644 index 000000000..962ddddcd --- /dev/null +++ b/packages/utils/src/rich-filters/factories/nodes/core.ts @@ -0,0 +1,39 @@ +import { v4 as uuidv4 } from "uuid"; +// plane imports +import { + FILTER_NODE_TYPE, + LOGICAL_OPERATOR, + TFilterAndGroupNode, + TFilterConditionNode, + TFilterConditionPayload, + TFilterExpression, + TFilterProperty, + TFilterValue, +} from "@plane/types"; + +/** + * Creates a condition node with a unique ID. + * @param condition - The condition to create + * @returns The created condition node + */ +export const createConditionNode =

( + condition: TFilterConditionPayload +): TFilterConditionNode => ({ + id: uuidv4(), + type: FILTER_NODE_TYPE.CONDITION, + ...condition, +}); + +/** + * Creates an AND group node with a unique ID. + * @param nodes - The nodes to add to the group + * @returns The created AND group node + */ +export const createAndGroupNode =

( + nodes: TFilterExpression

[] +): TFilterAndGroupNode

=> ({ + id: uuidv4(), + type: FILTER_NODE_TYPE.GROUP, + logicalOperator: LOGICAL_OPERATOR.AND, + children: nodes, +}); diff --git a/packages/utils/src/rich-filters/index.ts b/packages/utils/src/rich-filters/index.ts new file mode 100644 index 000000000..ac68890e2 --- /dev/null +++ b/packages/utils/src/rich-filters/index.ts @@ -0,0 +1,6 @@ +export * from "./factories"; +export * from "./operations"; +export * from "./operators"; +export * from "./types"; +export * from "./validators"; +export * from "./values"; diff --git a/packages/utils/src/rich-filters/operations/comparison.ts b/packages/utils/src/rich-filters/operations/comparison.ts new file mode 100644 index 000000000..63d5ecb2e --- /dev/null +++ b/packages/utils/src/rich-filters/operations/comparison.ts @@ -0,0 +1,170 @@ +import compact from "lodash/compact"; +import isEqual from "lodash/isEqual"; +import sortBy from "lodash/sortBy"; +// plane imports +import { + FILTER_NODE_TYPE, + TFilterConditionNode, + TFilterExpression, + TFilterGroupNode, + TFilterProperty, + TFilterValue, +} from "@plane/types"; +// local imports +import { isConditionNode, isGroupNode } from "../types/core"; +import { processGroupNode } from "../types/shared"; +import { hasValidValue } from "../validators/core"; +import { transformExpressionTree } from "./transformation/core"; + +/** + * Creates a comparable representation of a condition for deep comparison. + * This uses property, operator, and value instead of ID for comparison. + * IDs are completely excluded to avoid UUID comparison issues. + * @param condition - The condition to create a comparable representation for + * @returns A comparable object without ID + */ +const createConditionComparable =

(condition: TFilterConditionNode) => ({ + // Explicitly exclude: id (random UUID should not be compared) + type: condition.type, + property: condition.property, + operator: condition.operator, + value: Array.isArray(condition.value) ? condition.value : [condition.value], +}); + +/** + * Helper function to create comparable children for AND/OR groups. + * This eliminates code duplication between AND and OR group processing. + */ +const createComparableChildren =

( + children: TFilterExpression

[], + baseComparable: Record +): Record => { + const childrenComparable = compact(children.map((child) => createExpressionComparable(child))); + + // Sort children by a consistent key for comparison to ensure order doesn't affect equality + const sortedChildren = sortBy(childrenComparable, (child) => { + if (child?.type === FILTER_NODE_TYPE.CONDITION) { + return `condition_${child.property}_${child.operator}_${JSON.stringify(child.value)}`; + } + // For nested groups, sort by logical operator and recursive structure + if (child?.type === FILTER_NODE_TYPE.GROUP) { + const childrenCount = child.child ? 1 : Array.isArray(child.children) ? child.children.length : 0; + return `group_${child.logicalOperator}_${childrenCount}_${JSON.stringify(child)}`; + } + return "unknown"; + }); + + return { + ...baseComparable, + children: sortedChildren, + }; +}; + +/** + * Creates a comparable representation of a group for deep comparison. + * This recursively creates comparable representations for all children. + * IDs are completely excluded to avoid UUID comparison issues. + * Uses processGroupNode for consistent group type handling. + * @param group - The group to create a comparable representation for + * @returns A comparable object without ID + */ +export const createGroupComparable =

( + group: TFilterGroupNode

+): Record => { + const baseComparable = { + // Explicitly exclude: id (random UUID should not be compared) + type: group.type, + logicalOperator: group.logicalOperator, + }; + + return processGroupNode(group, { + onAndGroup: (andGroup) => createComparableChildren(andGroup.children, baseComparable), + }); +}; + +/** + * Creates a comparable representation of any filter expression. + * Recursively handles deep nesting of groups within groups. + * Completely excludes IDs from comparison to avoid UUID issues. + * @param expression - The expression to create a comparable representation for + * @returns A comparable object without IDs or null if the expression is empty + */ +export const createExpressionComparable =

( + expression: TFilterExpression

| null +): Record | null => { + if (!expression) return null; + + // Handle condition nodes - exclude ID completely + if (isConditionNode(expression)) { + return createConditionComparable(expression); + } + + // Handle group nodes - exclude ID completely and support deep nesting + if (isGroupNode(expression)) { + return createGroupComparable(expression); + } + + // Should never reach here with proper typing, but return null for safety + return null; +}; + +/** + * Normalizes a filter expression by removing empty conditions and groups. + * This helps compare expressions by focusing only on meaningful content. + * Uses the transformExpressionTree utility for consistent tree processing. + * @param expression - The filter expression to normalize + * @returns The normalized expression or null if the entire expression is empty + */ +export const normalizeFilterExpression =

( + expression: TFilterExpression

| null +): TFilterExpression

| null => { + const result = transformExpressionTree

(expression, (node: TFilterExpression

) => { + // Only transform condition nodes - check if they have valid values + if (isConditionNode(node)) { + return { + expression: hasValidValue(node.value) ? node : null, + shouldNotify: false, + }; + } + // For group nodes, let the generic transformer handle the recursion + return { expression: node, shouldNotify: false }; + }); + + return result.expression; +}; + +/** + * Performs a deep comparison of two filter expressions based on their meaningful content. + * This comparison completely ignores IDs (UUIDs) and focuses on property, operator, value, and tree structure. + * Empty conditions and groups are normalized before comparison. + * Supports deep nesting of groups within groups recursively. + * @param expression1 - The first expression to compare + * @param expression2 - The second expression to compare + * @returns True if the expressions are meaningfully equal, false otherwise + */ +export const deepCompareFilterExpressions =

( + expression1: TFilterExpression

| null, + expression2: TFilterExpression

| null +): boolean => { + // Normalize both expressions to remove empty conditions and groups + const normalized1 = normalizeFilterExpression(expression1); + const normalized2 = normalizeFilterExpression(expression2); + + // If both are null after normalization, they're equal + if (!normalized1 && !normalized2) { + return true; + } + + // If one is null and the other isn't, they're different + if (!normalized1 || !normalized2) { + return false; + } + + // Create comparable representations (IDs completely excluded) + const comparable1 = createExpressionComparable(normalized1); + const comparable2 = createExpressionComparable(normalized2); + + // Deep compare using lodash isEqual for reliable object comparison + // This handles deep nesting recursively and ignores UUID differences + return isEqual(comparable1, comparable2); +}; diff --git a/packages/utils/src/rich-filters/operations/index.ts b/packages/utils/src/rich-filters/operations/index.ts new file mode 100644 index 000000000..d362a4d5e --- /dev/null +++ b/packages/utils/src/rich-filters/operations/index.ts @@ -0,0 +1,4 @@ +export * from "./comparison"; +export * from "./manipulation/core"; +export * from "./transformation/core"; +export * from "./traversal/core"; diff --git a/packages/utils/src/rich-filters/operations/manipulation/core.ts b/packages/utils/src/rich-filters/operations/manipulation/core.ts new file mode 100644 index 000000000..22f6d662c --- /dev/null +++ b/packages/utils/src/rich-filters/operations/manipulation/core.ts @@ -0,0 +1,124 @@ +// plane imports +import { + TFilterConditionPayload, + TFilterExpression, + TFilterGroupNode, + TFilterProperty, + TFilterValue, +} from "@plane/types"; +// local imports +import { createAndGroupNode } from "../../factories/nodes/core"; +import { getGroupChildren } from "../../types"; +import { isAndGroupNode, isConditionNode, isGroupNode } from "../../types/core"; +import { shouldUnwrapGroup } from "../../validators/shared"; +import { transformExpressionTree } from "../transformation/core"; + +/** + * Adds an AND condition to the filter expression. + * @param expression - The current filter expression + * @param condition - The condition to add + * @returns The updated filter expression + */ +export const addAndCondition =

( + expression: TFilterExpression

| null, + condition: TFilterExpression

+): TFilterExpression

=> { + // if no expression, set the new condition + if (!expression) { + return condition; + } + // if the expression is a condition, convert it to an AND group + if (isConditionNode(expression)) { + return createAndGroupNode([expression, condition]); + } + // if the expression is a group, and the group is an AND group, add the new condition to the group + if (isGroupNode(expression) && isAndGroupNode(expression)) { + expression.children.push(condition); + return expression; + } + // if the expression is a group, but not an AND group, create a new AND group and add the new condition to it + if (isGroupNode(expression) && !isAndGroupNode(expression)) { + return createAndGroupNode([expression, condition]); + } + // Throw error for unexpected expression type + console.error("Invalid expression type", expression); + return expression; +}; + +/** + * Replaces a node in the expression tree with another node. + * Uses transformExpressionTree for consistent tree processing and better maintainability. + * @param expression - The expression tree to search in + * @param targetId - The ID of the node to replace + * @param replacement - The node to replace with + * @returns The updated expression tree + */ +export const replaceNodeInExpression =

( + expression: TFilterExpression

, + targetId: string, + replacement: TFilterExpression

+): TFilterExpression

=> { + const result = transformExpressionTree(expression, (node: TFilterExpression

) => { + // If this is the node we want to replace, return the replacement + if (node.id === targetId) { + return { + expression: replacement, + shouldNotify: false, + }; + } + // For all other nodes, let the generic transformer handle the recursion + return { expression: node, shouldNotify: false }; + }); + + // Since we're doing a replacement, the result should never be null + return result.expression || expression; +}; + +/** + * Updates a node in the filter expression. + * Uses recursive tree traversal with proper type handling. + * @param expression - The filter expression to update + * @param targetId - The id of the node to update + * @param updates - The updates to apply to the node + */ +export const updateNodeInExpression =

( + expression: TFilterExpression

, + targetId: string, + updates: Partial> +) => { + // Helper function to recursively update nodes + const updateNode = (node: TFilterExpression

): void => { + if (node.id === targetId) { + if (!isConditionNode(node)) { + console.warn("updateNodeInExpression: targetId matched a group; ignoring updates"); + return; + } + Object.assign(node, updates); + return; + } + + if (isGroupNode(node)) { + const children = getGroupChildren(node); + children.forEach((child) => updateNode(child)); + } + }; + + updateNode(expression); +}; + +/** + * Unwraps a group if it meets the unwrapping criteria, otherwise returns the group. + * @param group - The group node to potentially unwrap + * @param preserveNotGroups - Whether to preserve NOT groups even with single children + * @returns The unwrapped child or the original group + */ +export const unwrapGroupIfNeeded =

( + group: TFilterGroupNode

, + preserveNotGroups = true +) => { + if (shouldUnwrapGroup(group, preserveNotGroups)) { + const children = getGroupChildren(group); + return children[0]; + } + return group; +}; diff --git a/packages/utils/src/rich-filters/operations/transformation/core.ts b/packages/utils/src/rich-filters/operations/transformation/core.ts new file mode 100644 index 000000000..d35168cf6 --- /dev/null +++ b/packages/utils/src/rich-filters/operations/transformation/core.ts @@ -0,0 +1,178 @@ +// plane imports +import { TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types"; +// local imports +import { isConditionNode, isGroupNode } from "../../types/core"; +import { getGroupChildren } from "../../types/shared"; +import { hasValidValue } from "../../validators/core"; +import { unwrapGroupIfNeeded } from "../manipulation/core"; +import { transformGroup } from "./shared"; + +/** + * Generic tree transformation result type + */ +export type TTreeTransformResult

= { + expression: TFilterExpression

| null; + shouldNotify?: boolean; +}; + +/** + * Transform function type for tree processing + */ +export type TTreeTransformFn

= (expression: TFilterExpression

) => TTreeTransformResult

; + +/** + * Generic recursive tree transformer that handles common tree manipulation logic. + * This function provides a reusable way to transform expression trees while maintaining + * tree integrity, handling group restructuring, and applying stabilization. + * + * @param expression - The expression to transform + * @param transformFn - Function that defines the transformation logic for each node + * @returns The transformation result with expression and metadata + */ +/** + * Helper function to create a consistent transformation result for group nodes. + * Centralizes the logic for wrapping group expressions and tracking notifications. + */ +const createGroupTransformResult =

( + groupExpression: TFilterGroupNode

| null, + shouldNotify: boolean +): TTreeTransformResult

=> ({ + expression: groupExpression ? unwrapGroupIfNeeded(groupExpression, true) : null, + shouldNotify, +}); + +/** + * Transforms groups with children by processing all children. + * Handles child collection, null filtering, and empty group removal. + */ +export const transformGroupWithChildren =

( + group: TFilterGroupNode

, + transformFn: TTreeTransformFn

+): TTreeTransformResult

=> { + const children = getGroupChildren(group); + const transformedChildren: TFilterExpression

[] = []; + let shouldNotify = false; + + // Transform all children and collect non-null results + for (const child of children) { + const childResult = transformExpressionTree(child, transformFn); + + if (childResult.shouldNotify) { + shouldNotify = true; + } + + if (childResult.expression !== null) { + transformedChildren.push(childResult.expression); + } + } + + // If no children remain, remove the entire group + if (transformedChildren.length === 0) { + return { expression: null, shouldNotify }; + } + + // Create updated group with transformed children - type-safe without casting + const updatedGroup: TFilterGroupNode

= { + ...group, + children: transformedChildren, + } as TFilterGroupNode

; + + return createGroupTransformResult(updatedGroup, shouldNotify); +}; + +/** + * Generic recursive tree transformer that handles common tree manipulation logic. + * This function provides a reusable way to transform expression trees while maintaining + * tree integrity, handling group restructuring, and applying stabilization. + * + * @param expression - The expression to transform + * @param transformFn - Function that defines the transformation logic for each node + * @returns The transformation result with expression and metadata + */ +export const transformExpressionTree =

( + expression: TFilterExpression

| null, + transformFn: TTreeTransformFn

+): TTreeTransformResult

=> { + // Handle null expressions early + if (!expression) { + return { expression: null, shouldNotify: false }; + } + + // Apply the transformation function to the current node + const transformResult = transformFn(expression); + + // If the transform function handled this node completely, return its result + if (transformResult.expression === null || transformResult.expression !== expression) { + return transformResult; + } + + // Handle condition nodes (no children to transform) + if (isConditionNode(expression)) { + return { expression, shouldNotify: false }; + } + + // Handle group nodes by delegating to the extended transformGroup function + if (isGroupNode(expression)) { + return transformGroup(expression, transformFn); + } + + throw new Error("Unknown expression type in transformExpressionTree"); +}; + +/** + * Removes a node from the filter expression. + * @param expression - The filter expression to remove the node from + * @param targetId - The id of the node to remove + * @returns An object containing the updated filter expression and whether to notify about the change + */ +export const removeNodeFromExpression =

( + expression: TFilterExpression

, + targetId: string +): { expression: TFilterExpression

| null; shouldNotify: boolean } => { + const result = transformExpressionTree(expression, (node) => { + // If this node matches the target ID, remove it + if (node.id === targetId) { + const shouldNotify = isConditionNode(node) ? hasValidValue(node.value) : true; + return { + expression: null, + shouldNotify, + }; + } + // For all other nodes, let the generic transformer handle the recursion + return { expression: node, shouldNotify: false }; + }); + + return { + expression: result.expression, + shouldNotify: result.shouldNotify || false, + }; +}; + +/** + * Sanitizes and stabilizes a filter expression by removing invalid conditions and unnecessary groups. + * This function performs deep sanitization of the entire expression tree: + * 1. Removes condition nodes that don't have valid values + * 2. Removes empty groups (groups with no children after sanitization) + * 3. Unwraps single-child groups that don't need to be wrapped + * 4. Preserves tree integrity and logical operators + * + * @param expression - The filter expression to sanitize + * @returns The sanitized expression or null if no valid conditions remain + */ +export const sanitizeAndStabilizeExpression =

( + expression: TFilterExpression

| null +): TFilterExpression

| null => { + const result = transformExpressionTree(expression, (node) => { + // Only transform condition nodes - check if they have valid values + if (isConditionNode(node)) { + return { + expression: hasValidValue(node.value) ? node : null, + shouldNotify: false, + }; + } + // For group nodes, let the generic transformer handle the recursion + return { expression: node, shouldNotify: false }; + }); + + return result.expression; +}; diff --git a/packages/utils/src/rich-filters/operations/transformation/shared.ts b/packages/utils/src/rich-filters/operations/transformation/shared.ts new file mode 100644 index 000000000..28da3990c --- /dev/null +++ b/packages/utils/src/rich-filters/operations/transformation/shared.ts @@ -0,0 +1,18 @@ +import { TFilterGroupNode, TFilterProperty } from "@plane/types"; +import { processGroupNode } from "../../types/shared"; +import { transformGroupWithChildren, TTreeTransformFn, TTreeTransformResult } from "./core"; + +/** + * Transforms groups by processing children. + * Handles AND/OR groups with children and NOT groups with single child. + * @param group - The group to transform + * @param transformFn - The transformation function + * @returns The transformation result + */ +export const transformGroup =

( + group: TFilterGroupNode

, + transformFn: TTreeTransformFn

+): TTreeTransformResult

=> + processGroupNode(group, { + onAndGroup: (andGroup) => transformGroupWithChildren(andGroup, transformFn), + }); diff --git a/packages/utils/src/rich-filters/operations/traversal/core.ts b/packages/utils/src/rich-filters/operations/traversal/core.ts new file mode 100644 index 000000000..72b42100d --- /dev/null +++ b/packages/utils/src/rich-filters/operations/traversal/core.ts @@ -0,0 +1,210 @@ +// plane imports +import { + TAllAvailableOperatorsForDisplay, + TFilterConditionNode, + TFilterConditionNodeForDisplay, + TFilterExpression, + TFilterGroupNode, + TFilterProperty, + TFilterValue, +} from "@plane/types"; +// local imports +import { isConditionNode, isGroupNode } from "../../types/core"; +import { getGroupChildren } from "../../types/shared"; +import { getDisplayOperator } from "./shared"; + +/** + * Generic tree visitor function type + */ +export type TreeVisitorFn

= ( + expression: TFilterExpression

, + parent?: TFilterGroupNode

, + depth?: number +) => T | null; + +/** + * Tree traversal modes + */ +export enum TreeTraversalMode { + /** Visit all nodes depth-first */ + ALL = "ALL", + /** Visit only condition nodes */ + CONDITIONS = "CONDITIONS", + /** Visit only group nodes */ + GROUPS = "GROUPS", +} + +/** + * Generic tree traversal utility that visits nodes based on the specified mode. + * This eliminates code duplication in tree walking functions. + * + * @param expression - The expression to traverse + * @param visitor - Function to call for each visited node + * @param mode - Traversal mode to determine which nodes to visit + * @param parent - Parent node (used internally for recursion) + * @param depth - Current depth (used internally for recursion) + * @returns Array of results from the visitor function (nulls are filtered out) + */ +export const traverseExpressionTree =

( + expression: TFilterExpression

| null, + visitor: TreeVisitorFn, + mode: TreeTraversalMode = TreeTraversalMode.ALL, + parent?: TFilterGroupNode

, + depth: number = 0 +): T[] => { + if (!expression) return []; + + const results: T[] = []; + + // Determine if we should visit this node based on the mode + const shouldVisit = + mode === TreeTraversalMode.ALL || + (mode === TreeTraversalMode.CONDITIONS && isConditionNode(expression)) || + (mode === TreeTraversalMode.GROUPS && isGroupNode(expression)); + + if (shouldVisit) { + const result = visitor(expression, parent, depth); + if (result !== null) { + results.push(result); + } + } + + // Recursively traverse children for group nodes + if (isGroupNode(expression)) { + const children = getGroupChildren(expression); + for (const child of children) { + const childResults = traverseExpressionTree(child, visitor, mode, expression, depth + 1); + results.push(...childResults); + } + } + + return results; +}; + +/** + * Finds a node by its ID in the filter expression tree. + * Uses the generic tree traversal utility for better maintainability. + * @param expression - The filter expression to search in + * @param targetId - The ID of the node to find + * @returns The found node or null if not found + */ +export const findNodeById =

( + expression: TFilterExpression

, + targetId: string +): TFilterExpression

| null => { + const results = traverseExpressionTree( + expression, + (node) => (node.id === targetId ? node : null), + TreeTraversalMode.ALL + ); + + // Return the first match (there should only be one with unique IDs) + return results.length > 0 ? results[0] : null; +}; + +/** + * Finds the parent chain of a given node ID in the filter expression tree. + * @param expression - The filter expression to search in + * @param targetId - The ID of the node whose parent chain to find + * @param currentPath - Current path of parent nodes (used internally for recursion) + * @returns Array of parent nodes from immediate parent to root, or null if not found + */ +export const findParentChain =

( + expression: TFilterExpression

, + targetId: string, + currentPath: TFilterGroupNode

[] = [] +): TFilterGroupNode

[] | null => { + // if the expression is a group, search in the children + if (isGroupNode(expression)) { + const children = getGroupChildren(expression); + + // check if any direct child has the target ID + for (const child of children) { + if (child.id === targetId) { + return [expression, ...currentPath]; + } + } + + // recursively search in child groups + for (const child of children) { + if (isGroupNode(child)) { + const chain = findParentChain(child, targetId, [expression, ...currentPath]); + if (chain) return chain; + } + } + } + + return null; +}; + +/** + * Finds the immediate parent node of a given node ID. + * @param expression - The filter expression to find parent in + * @param targetId - The ID of the node whose parent to find + * @returns The immediate parent node or null if not found or if the target is the root + */ +export const findImmediateParent =

( + expression: TFilterExpression

, + targetId: string +): TFilterGroupNode

| null => { + // if the expression is null, return null + if (!expression) return null; + + // find the parent chain + const parentChain = findParentChain(expression, targetId); + + // return the immediate parent if it exists + return parentChain && parentChain.length > 0 ? parentChain[0] : null; +}; + +/** + * Extracts all conditions from a filter expression. + * Uses the generic tree traversal utility for better maintainability and consistency. + * @param expression - The filter expression to extract conditions from + * @returns An array of filter conditions + */ +export const extractConditions =

( + expression: TFilterExpression

+): TFilterConditionNode[] => + traverseExpressionTree( + expression, + (node) => (isConditionNode(node) ? node : null), + TreeTraversalMode.CONDITIONS + ) as TFilterConditionNode[]; + +/** + * Extracts all conditions from a filter expression, including their display operators. + * @param expression - The filter expression to extract conditions from + * @returns An array of filter conditions with their display operators + */ +export const extractConditionsWithDisplayOperators =

( + expression: TFilterExpression

+): TFilterConditionNodeForDisplay[] => { + // First extract all raw conditions + const rawConditions = extractConditions(expression); + + // Transform operators using the extended helper + return rawConditions.map((condition) => { + const displayOperator = getDisplayOperator(condition.operator, expression, condition.id); + return { + ...condition, + operator: displayOperator, + }; + }); +}; + +/** + * Finds all conditions by property and operator. + * @param expression - The filter expression to search in + * @param property - The property to find the conditions by + * @param operator - The operator to find the conditions by + * @returns An array of conditions that match the property and operator + */ +export const findConditionsByPropertyAndOperator =

( + expression: TFilterExpression

, + property: P, + operator: TAllAvailableOperatorsForDisplay +): TFilterConditionNodeForDisplay[] => { + const conditions = extractConditionsWithDisplayOperators(expression); + return conditions.filter((condition) => condition.property === property && condition.operator === operator); +}; diff --git a/packages/utils/src/rich-filters/operations/traversal/shared.ts b/packages/utils/src/rich-filters/operations/traversal/shared.ts new file mode 100644 index 000000000..b3fcc3e99 --- /dev/null +++ b/packages/utils/src/rich-filters/operations/traversal/shared.ts @@ -0,0 +1,23 @@ +// plane imports +import { + TAllAvailableOperatorsForDisplay, + TFilterExpression, + TFilterProperty, + TSupportedOperators, +} from "@plane/types"; + +/** + * Helper function to get the display operator for a condition. + * This checks for NOT group context and applies negation if needed. + * @param operator - The original operator + * @param expression - The filter expression + * @param conditionId - The ID of the condition + * @returns The display operator (possibly negated) + */ +export const getDisplayOperator =

( + operator: TSupportedOperators, + _expression: TFilterExpression

, + _conditionId: string +): TAllAvailableOperatorsForDisplay => + // Otherwise, return the operator as-is + operator; diff --git a/packages/utils/src/rich-filters/operators/core.ts b/packages/utils/src/rich-filters/operators/core.ts new file mode 100644 index 000000000..8d7384b98 --- /dev/null +++ b/packages/utils/src/rich-filters/operators/core.ts @@ -0,0 +1,42 @@ +import get from "lodash/get"; +// plane imports +import { DATE_OPERATOR_LABELS_MAP, EMPTY_OPERATOR_LABEL, OPERATOR_LABELS_MAP } from "@plane/constants"; +import { + TAllAvailableOperatorsForDisplay, + TFilterValue, + TAllAvailableDateFilterOperatorsForDisplay, +} from "@plane/types"; + +// -------- OPERATOR LABEL UTILITIES -------- + +/** + * Get the label for a filter operator + * @param operator - The operator to get the label for + * @returns The label for the operator + */ +export const getOperatorLabel = (operator: TAllAvailableOperatorsForDisplay | undefined): string => { + if (!operator) return EMPTY_OPERATOR_LABEL; + return get(OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL); +}; + +/** + * Get the label for a date filter operator + * @param operator - The operator to get the label for + * @returns The label for the operator + */ +export const getDateOperatorLabel = (operator: TAllAvailableDateFilterOperatorsForDisplay | undefined): string => { + if (!operator) return EMPTY_OPERATOR_LABEL; + return get(DATE_OPERATOR_LABELS_MAP, operator, EMPTY_OPERATOR_LABEL); +}; + +// -------- OPERATOR TYPE GUARDS -------- + +/** + * Type guard to check if an operator supports date filter types. + * @param operator - The operator to check + * @returns True if the operator supports date filters + */ +export const isDateFilterOperator = ( + operator: TAllAvailableOperatorsForDisplay +): operator is TAllAvailableDateFilterOperatorsForDisplay => + Object.keys(DATE_OPERATOR_LABELS_MAP).includes(operator); diff --git a/packages/utils/src/rich-filters/operators/index.ts b/packages/utils/src/rich-filters/operators/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/operators/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/operators/shared.ts b/packages/utils/src/rich-filters/operators/shared.ts new file mode 100644 index 000000000..6923ddfcf --- /dev/null +++ b/packages/utils/src/rich-filters/operators/shared.ts @@ -0,0 +1,24 @@ +import { TAllAvailableOperatorsForDisplay, TSupportedOperators } from "@plane/types"; + +/** + * Result type for operator conversion + */ +export type TOperatorForPayload = { + operator: TSupportedOperators; + isNegation: boolean; +}; + +/** + * Converts a display operator to the format needed for supported by filter expression condition. + * @param displayOperator - The operator from the UI + * @returns Object with supported operator and negation flag + */ +export const getOperatorForPayload = (displayOperator: TAllAvailableOperatorsForDisplay): TOperatorForPayload => { + const isNegation = false; + const operator = displayOperator; + + return { + operator, + isNegation, + }; +}; diff --git a/packages/utils/src/rich-filters/types/core.ts b/packages/utils/src/rich-filters/types/core.ts new file mode 100644 index 000000000..696fe3796 --- /dev/null +++ b/packages/utils/src/rich-filters/types/core.ts @@ -0,0 +1,68 @@ +import { + FILTER_FIELD_TYPE, + FILTER_NODE_TYPE, + LOGICAL_OPERATOR, + TFilterAndGroupNode, + TFilterConditionNode, + TFilterExpression, + TFilterFieldType, + TFilterGroupNode, + TFilterProperty, + TFilterValue, +} from "@plane/types"; + +/** + * Type guard to check if a node is a condition node. + * @param node - The node to check + * @returns True if the node is a condition node + */ +export const isConditionNode =

( + node: TFilterExpression

+): node is TFilterConditionNode => node.type === FILTER_NODE_TYPE.CONDITION; + +/** + * Type guard to check if a node is a group node. + * @param node - The node to check + * @returns True if the node is a group node + */ +export const isGroupNode =

(node: TFilterExpression

): node is TFilterGroupNode

=> + node.type === FILTER_NODE_TYPE.GROUP; + +/** + * Type guard to check if a group node is an AND group. + * @param group - The group node to check + * @returns True if the group is an AND group + */ +export const isAndGroupNode =

( + group: TFilterGroupNode

+): group is TFilterAndGroupNode

=> group.logicalOperator === LOGICAL_OPERATOR.AND; + +/** + * Type guard to check if a group node has children property + * @param group - The group node to check + * @returns True if the group has children property + */ +export const hasChildrenProperty =

( + group: TFilterGroupNode

+): group is TFilterAndGroupNode

=> { + const groupWithChildren = group as { children?: unknown }; + return "children" in group && Array.isArray(groupWithChildren.children); +}; + +/** + * Safely gets the children array from an AND group node. + * @param group - The AND group node + * @returns The children array + */ +export const getAndGroupChildren =

(group: TFilterAndGroupNode

): TFilterExpression

[] => + group.children; + +/** + * Type guard to check if a filter type is a date filter type. + * @param type - The filter type to check + * @returns True if the filter type is a date filter type + */ +export const isDateFilterType = ( + type: TFilterFieldType +): type is typeof FILTER_FIELD_TYPE.DATE | typeof FILTER_FIELD_TYPE.DATE_RANGE => + type === FILTER_FIELD_TYPE.DATE || type === FILTER_FIELD_TYPE.DATE_RANGE; diff --git a/packages/utils/src/rich-filters/types/index.ts b/packages/utils/src/rich-filters/types/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/types/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/types/shared.ts b/packages/utils/src/rich-filters/types/shared.ts new file mode 100644 index 000000000..a116fe8de --- /dev/null +++ b/packages/utils/src/rich-filters/types/shared.ts @@ -0,0 +1,35 @@ +// plane imports +import { TFilterAndGroupNode, TFilterExpression, TFilterGroupNode, TFilterProperty } from "@plane/types"; +// local imports +import { getAndGroupChildren, isAndGroupNode } from "./core"; + +type TProcessGroupNodeHandlers

= { + onAndGroup: (group: TFilterAndGroupNode

) => T; +}; + +/** + * Generic helper to process group nodes with type-safe handlers. + * @param group - The group node to process + * @param handlers - Object with handlers for each group type + * @returns Result of the appropriate handler + */ +export const processGroupNode =

( + group: TFilterGroupNode

, + handlers: TProcessGroupNodeHandlers +): T => { + if (isAndGroupNode(group)) { + return handlers.onAndGroup(group); + } + throw new Error(`Invalid group node: unknown logical operator ${group}`); +}; + +/** + * Gets the children of a group node, handling AND/OR groups (children array) and NOT groups (single child). + * Uses processGroupNode for consistent group type handling. + * @param group - The group node to get children from + * @returns Array of child expressions + */ +export const getGroupChildren =

(group: TFilterGroupNode

): TFilterExpression

[] => + processGroupNode(group, { + onAndGroup: (andGroup) => getAndGroupChildren(andGroup), + }); diff --git a/packages/utils/src/rich-filters/validators/core.ts b/packages/utils/src/rich-filters/validators/core.ts new file mode 100644 index 000000000..9a9268bd8 --- /dev/null +++ b/packages/utils/src/rich-filters/validators/core.ts @@ -0,0 +1,52 @@ +// plane imports +import { SingleOrArray, TFilterExpression, TFilterProperty, TFilterValue } from "@plane/types"; +// local imports +import { getGroupChildren } from "../types"; +import { isConditionNode, isGroupNode } from "../types/core"; + +/** + * Determines whether to notify about a change based on the filter value. + * @param value - The filter value to check + * @returns True if we should notify, false otherwise + */ +export const hasValidValue = (value: SingleOrArray): boolean => { + if (value === null || value === undefined) { + return false; + } + + // If it's an array, check if it's empty or contains only null/undefined values + if (Array.isArray(value)) { + if (value.length === 0) { + return false; + } + return value.some((v) => v !== null && v !== undefined); + } + + return true; +}; + +/** + * Determines whether to notify about a change based on the entire filter expression. + * @param expression - The filter expression to check + * @returns True if we should notify, false otherwise + */ +export const shouldNotifyChangeForExpression =

( + expression: TFilterExpression

| null +): boolean => { + if (!expression) { + return false; + } + + // If it's a condition, check its value + if (isConditionNode(expression)) { + return hasValidValue(expression.value); + } + + // If it's a group, check if any of its children have meaningful values + if (isGroupNode(expression)) { + const children = getGroupChildren(expression); + return children.some((child) => shouldNotifyChangeForExpression(child)); + } + + return false; +}; diff --git a/packages/utils/src/rich-filters/validators/index.ts b/packages/utils/src/rich-filters/validators/index.ts new file mode 100644 index 000000000..102ec949b --- /dev/null +++ b/packages/utils/src/rich-filters/validators/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./shared"; diff --git a/packages/utils/src/rich-filters/validators/shared.ts b/packages/utils/src/rich-filters/validators/shared.ts new file mode 100644 index 000000000..1df8e69ae --- /dev/null +++ b/packages/utils/src/rich-filters/validators/shared.ts @@ -0,0 +1,22 @@ +// plane imports +import { TFilterGroupNode, TFilterProperty } from "@plane/types"; +// local imports +import { getGroupChildren } from "../types/shared"; + +/** + * Determines if a group should be unwrapped based on the number of children and group type. + * @param group - The group node to check + * @param preserveNotGroups - Whether to preserve NOT groups even with single children + * @returns True if the group should be unwrapped, false otherwise + */ +export const shouldUnwrapGroup =

(group: TFilterGroupNode

, _preserveNotGroups = true) => { + const children = getGroupChildren(group); + + // Never unwrap groups with multiple children + if (children.length !== 1) { + return false; + } + + // Unwrap AND/OR groups with single children, and NOT groups if preserveNotGroups is false + return true; +}; diff --git a/packages/utils/src/rich-filters/values/core.ts b/packages/utils/src/rich-filters/values/core.ts new file mode 100644 index 000000000..a2a6b111f --- /dev/null +++ b/packages/utils/src/rich-filters/values/core.ts @@ -0,0 +1,24 @@ +import type { SingleOrArray, TFilterValue } from "@plane/types"; + +/** + * Converts any value to a non-null array for UI components that expect arrays + * Returns empty array for null/undefined values + */ +export const toFilterArray = (value: SingleOrArray): NonNullable[] => { + if (value === null || value === undefined) { + return []; + } + + return Array.isArray(value) ? (value as NonNullable[]) : ([value] as NonNullable[]); +}; + +/** + * Gets the length of a filter value + */ +export const getFilterValueLength = (value: SingleOrArray): number => { + if (value === null || value === undefined) { + return 0; + } + + return Array.isArray(value) ? value.length : 1; +}; diff --git a/packages/utils/src/rich-filters/values/index.ts b/packages/utils/src/rich-filters/values/index.ts new file mode 100644 index 000000000..8d119dee8 --- /dev/null +++ b/packages/utils/src/rich-filters/values/index.ts @@ -0,0 +1 @@ +export * from "./core"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e128b79ed..f782fccba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: '@types/react-dom': specifier: 18.3.1 version: 18.3.1 + '@types/uuid': + specifier: 9.0.8 + version: 9.0.8 axios: specifier: 1.12.0 version: 1.12.0 @@ -481,6 +484,9 @@ importers: '@plane/services': specifier: workspace:* version: link:../../packages/services + '@plane/shared-state': + specifier: workspace:* + version: link:../../packages/shared-state '@plane/types': specifier: workspace:* version: link:../../packages/types @@ -1095,9 +1101,27 @@ importers: packages/shared-state: dependencies: + '@plane/constants': + specifier: workspace:* + version: link:../constants + '@plane/types': + specifier: workspace:* + version: link:../types + '@plane/utils': + specifier: workspace:* + version: link:../utils + lodash: + specifier: 'catalog:' + version: 4.17.21 mobx: specifier: 'catalog:' version: 6.12.0 + mobx-utils: + specifier: 'catalog:' + version: 6.0.8(mobx@6.12.0) + uuid: + specifier: 'catalog:' + version: 10.0.0 zod: specifier: ^3.22.2 version: 3.25.76 @@ -1108,9 +1132,15 @@ importers: '@plane/typescript-config': specifier: workspace:* version: link:../typescript-config + '@types/lodash': + specifier: 'catalog:' + version: 4.17.20 '@types/node': specifier: ^22.5.4 version: 22.18.0 + '@types/uuid': + specifier: 'catalog:' + version: 9.0.8 typescript: specifier: 5.8.3 version: 5.8.3 @@ -1584,14 +1614,14 @@ packages: '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} - '@emnapi/core@1.5.0': - resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==} + '@emnapi/core@1.4.5': + resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.0.4': + resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} '@emotion/babel-plugin@11.13.5': resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} @@ -8401,9 +8431,9 @@ snapshots: '@date-fns/tz@1.4.1': {} - '@emnapi/core@1.5.0': + '@emnapi/core@1.4.5': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.0.4 tslib: 2.8.1 optional: true @@ -8412,7 +8442,7 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.0.4': dependencies: tslib: 2.8.1 optional: true @@ -8982,14 +9012,14 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.5.0 + '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.0 optional: true '@napi-rs/wasm-runtime@1.0.3': dependencies: - '@emnapi/core': 1.5.0 + '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.5.0 '@tybys/wasm-util': 0.10.0 optional: true @@ -11137,7 +11167,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.5.3 + tslib: 2.8.1 camelcase-css@2.0.1: {} @@ -11148,7 +11178,7 @@ snapshots: capital-case@1.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 upper-case-first: 2.0.2 case-sensitive-paths-webpack-plugin@2.4.0: {} @@ -11190,7 +11220,7 @@ snapshots: path-case: 3.0.4 sentence-case: 3.0.4 snake-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 character-entities@2.0.2: {} @@ -11331,7 +11361,7 @@ snapshots: constant-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 upper-case: 2.0.2 constants-browserify@1.0.0: {} @@ -11659,7 +11689,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 dotenv@16.0.3: {} @@ -12509,7 +12539,7 @@ snapshots: header-case@2.0.4: dependencies: capital-case: 1.0.4 - tslib: 2.5.3 + tslib: 2.8.1 helmet@7.2.0: {} @@ -13049,7 +13079,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.5.3 + tslib: 2.8.1 lowlight@2.9.0: dependencies: @@ -13421,7 +13451,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.5.3 + tslib: 2.8.1 node-abort-controller@3.1.1: {} @@ -13604,7 +13634,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 parent-module@1.0.1: dependencies: @@ -13628,14 +13658,14 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 path-browserify@1.0.1: {} path-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 path-exists@4.0.0: {} @@ -14542,7 +14572,7 @@ snapshots: sentence-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 upper-case-first: 2.0.2 serialize-javascript@6.0.2: @@ -14663,7 +14693,7 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.5.3 + tslib: 2.8.1 sonic-boom@2.8.0: dependencies: @@ -15301,11 +15331,11 @@ snapshots: upper-case-first@2.0.2: dependencies: - tslib: 2.5.3 + tslib: 2.8.1 upper-case@2.0.2: dependencies: - tslib: 2.5.3 + tslib: 2.8.1 uri-js@4.4.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 168601be6..bda86ac05 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,6 +19,7 @@ catalog: react-dom: 18.3.1 "@types/react": 18.3.11 "@types/react-dom": 18.3.1 + "@types/uuid": 9.0.8 typescript: 5.8.3 tsdown: 0.14.2 uuid: 10.0.0 From 6d3d9e6df7b070a0281cdf56ef78d7059d56921a Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 16 Sep 2025 21:37:08 +0530 Subject: [PATCH 039/169] [WEB-4943]: add url has allowed host or scheme for validating valid redirections (#7809) * feat: enhance path validation and URL safety in path_validator.py * Added get_allowed_hosts function to retrieve allowed hosts from settings. * Updated get_safe_redirect_url to validate URLs against allowed hosts. * Improved URL construction logic for safer redirection handling. * feat: enhance URL validation in authentication views * Added url_has_allowed_host_and_scheme checks in SignUpAuthSpaceEndpoint and MagicSignInSpaceEndpoint for safer redirection. * Updated redirect logic to fallback to base host if the constructed URL is not allowed. * Improved overall URL safety and handling in authentication flows. * fix: improve host extraction in get_allowed_hosts function * Updated get_allowed_hosts to extract only the host from ADMIN_BASE_URL and SPACE_BASE_URL settings for better URL validation. * Enhanced overall safety and clarity in allowed hosts retrieval. --- .../plane/authentication/views/space/email.py | 6 +++- .../plane/authentication/views/space/magic.py | 13 ++++++-- apps/api/plane/utils/path_validator.py | 31 +++++++++++++++++-- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index d247f6e98..2fb1c2c5e 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.credentials.email import EmailProvider @@ -200,7 +201,10 @@ class SignUpAuthSpaceEndpoint(View): # redirect to referer path next_path = validate_next_path(next_path=next_path) url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" - return HttpResponseRedirect(url) + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() url = get_safe_redirect_url( diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index f50274a4a..85e3a185c 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -2,6 +2,7 @@ from django.core.validators import validate_email from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Third party imports from rest_framework import status @@ -20,7 +21,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import get_safe_redirect_url, validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class MagicGenerateSpaceEndpoint(APIView): @@ -96,7 +97,10 @@ class MagicSignInSpaceEndpoint(View): # redirect to referer path next_path = validate_next_path(next_path=next_path) url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" - return HttpResponseRedirect(url) + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() @@ -155,7 +159,10 @@ class MagicSignUpSpaceEndpoint(View): # redirect to referer path next_path = validate_next_path(next_path=next_path) url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" - return HttpResponseRedirect(url) + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index e5bf7aeb2..a89c8b969 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -1,6 +1,11 @@ +# Django imports +from django.utils.http import url_has_allowed_host_and_scheme +from django.conf import settings + # Python imports from urllib.parse import urlparse + def _contains_suspicious_patterns(path: str) -> bool: """ Check for suspicious patterns that might indicate malicious intent. @@ -38,6 +43,21 @@ def _contains_suspicious_patterns(path: str) -> bool: return False +def get_allowed_hosts() -> list[str]: + """Get the allowed hosts from the settings.""" + base_origin = settings.WEB_URL or settings.APP_BASE_URL + allowed_hosts = [base_origin] + if settings.ADMIN_BASE_URL: + # Get only the host + host = urlparse(settings.ADMIN_BASE_URL).netloc + allowed_hosts.append(host) + if settings.SPACE_BASE_URL: + # Get only the host + host = urlparse(settings.SPACE_BASE_URL).netloc + allowed_hosts.append(host) + return allowed_hosts + + def validate_next_path(next_path: str) -> str: """Validates that next_path is a safe relative path for redirection.""" # Browsers interpret backslashes as forward slashes. Remove all backslashes. @@ -92,7 +112,14 @@ def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) base_url = base_url.rstrip('/') if params: encoded_params = urlencode(params) - return f"{base_url}/?next_path={validated_path}&{encoded_params}" + url = f"{base_url}/?next_path={validated_path}&{encoded_params}" + else: + url = f"{base_url}/?next_path={validated_path}" - return f"{base_url}/?next_path={validated_path}" + # Check if the URL is allowed + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return url + + # Return the base URL if the URL is not allowed + return base_url \ No newline at end of file From 3d061897232797913cd7871f8c6f6be3d14e2dd6 Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Wed, 17 Sep 2025 16:13:32 +0530 Subject: [PATCH 040/169] [WEB-4943] refactor: enhance URL validation and redirection logic in authentication views (#7815) * refactor: enhance URL validation and redirection logic in authentication views * Updated authentication views (SignInAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, GoogleCallbackSpaceEndpoint, and MagicSignInSpaceEndpoint) to include url_has_allowed_host_and_scheme checks for safer redirection. * Improved URL construction by ensuring proper formatting and fallback to base host when necessary. * Added get_allowed_hosts function to path_validator.py for better host validation. * refactor: improve comments and clean up code in path_validator.py * Updated comments for clarity in the get_safe_redirect_url function. * Removed unnecessary blank line to enhance --- .../plane/authentication/views/space/email.py | 5 ++-- .../authentication/views/space/github.py | 10 +++++-- .../authentication/views/space/gitlab.py | 10 +++++-- .../authentication/views/space/google.py | 10 +++++-- .../plane/authentication/views/space/magic.py | 4 +-- apps/api/plane/utils/path_validator.py | 30 +++++++++++++++---- 6 files changed, 49 insertions(+), 20 deletions(-) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index 2fb1c2c5e..b25e2d015 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -15,8 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import get_safe_redirect_url, validate_next_path - +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class SignInAuthSpaceEndpoint(View): def post(self, request): @@ -200,7 +199,7 @@ class SignUpAuthSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # redirect to referer path next_path = validate_next_path(next_path=next_path) - url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): return HttpResponseRedirect(url) else: diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py index dd148b8c1..acc956cf3 100644 --- a/apps/api/plane/authentication/views/space/github.py +++ b/apps/api/plane/authentication/views/space/github.py @@ -4,6 +4,7 @@ import uuid # Django import from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.oauth.github import GitHubOAuthProvider @@ -14,7 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import get_safe_redirect_url, validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class GitHubOauthInitiateSpaceEndpoint(View): @@ -94,8 +95,11 @@ class GitHubCallbackSpaceEndpoint(View): # Process workspace and project invitations # redirect to referer path next_path = validate_next_path(next_path=next_path) - url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" - return HttpResponseRedirect(url) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() url = get_safe_redirect_url( diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py index 77a10a914..493cd823e 100644 --- a/apps/api/plane/authentication/views/space/gitlab.py +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -4,6 +4,7 @@ import uuid # Django import from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.oauth.gitlab import GitLabOAuthProvider @@ -14,7 +15,7 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import get_safe_redirect_url, validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, get_allowed_hosts, validate_next_path class GitLabOauthInitiateSpaceEndpoint(View): @@ -95,8 +96,11 @@ class GitLabCallbackSpaceEndpoint(View): # Process workspace and project invitations # redirect to referer path next_path = validate_next_path(next_path=next_path) - url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" - return HttpResponseRedirect(url) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() url = get_safe_redirect_url( diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py index d8fef9da4..c7c833771 100644 --- a/apps/api/plane/authentication/views/space/google.py +++ b/apps/api/plane/authentication/views/space/google.py @@ -4,6 +4,7 @@ import uuid # Django import from django.http import HttpResponseRedirect from django.views import View +from django.utils.http import url_has_allowed_host_and_scheme # Module imports from plane.authentication.provider.oauth.google import GoogleOAuthProvider @@ -14,7 +15,7 @@ from plane.authentication.adapter.error import ( AuthenticationException, AUTHENTICATION_ERROR_CODES, ) -from plane.utils.path_validator import get_safe_redirect_url, validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts class GoogleOauthInitiateSpaceEndpoint(View): @@ -91,8 +92,11 @@ class GoogleCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # redirect to referer path next_path = validate_next_path(next_path=next_path) - url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" - return HttpResponseRedirect(url) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() url = get_safe_redirect_url( diff --git a/apps/api/plane/authentication/views/space/magic.py b/apps/api/plane/authentication/views/space/magic.py index 85e3a185c..5052d40c0 100644 --- a/apps/api/plane/authentication/views/space/magic.py +++ b/apps/api/plane/authentication/views/space/magic.py @@ -96,7 +96,7 @@ class MagicSignInSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # redirect to referer path next_path = validate_next_path(next_path=next_path) - url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): return HttpResponseRedirect(url) else: @@ -158,7 +158,7 @@ class MagicSignUpSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # redirect to referer path next_path = validate_next_path(next_path=next_path) - url = f"{base_host(request=request, is_space=True).rstrip("/")}{next_path}" + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): return HttpResponseRedirect(url) else: diff --git a/apps/api/plane/utils/path_validator.py b/apps/api/plane/utils/path_validator.py index a89c8b969..ccb67d868 100644 --- a/apps/api/plane/utils/path_validator.py +++ b/apps/api/plane/utils/path_validator.py @@ -46,7 +46,11 @@ def _contains_suspicious_patterns(path: str) -> bool: def get_allowed_hosts() -> list[str]: """Get the allowed hosts from the settings.""" base_origin = settings.WEB_URL or settings.APP_BASE_URL - allowed_hosts = [base_origin] + + allowed_hosts = [] + if base_origin: + host = urlparse(base_origin).netloc + allowed_hosts.append(host) if settings.ADMIN_BASE_URL: # Get only the host host = urlparse(settings.ADMIN_BASE_URL).netloc @@ -107,19 +111,33 @@ def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) # Validate the next path validated_path = validate_next_path(next_path) - + # Add the next path to the parameters base_url = base_url.rstrip('/') + + # Prepare the query parameters + query_parts = [] + encoded_params = "" + + # Add the next path to the parameters + if validated_path: + query_parts.append(f"next_path={validated_path}") + + # Add additional parameters if params: encoded_params = urlencode(params) - url = f"{base_url}/?next_path={validated_path}&{encoded_params}" + query_parts.append(encoded_params) + + # Construct the url query string + if query_parts: + query_string = "&".join(query_parts) + url = f"{base_url}/?{query_string}" else: - url = f"{base_url}/?next_path={validated_path}" + url = base_url # Check if the URL is allowed if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): return url # Return the base URL if the URL is not allowed - return base_url - \ No newline at end of file + return base_url + (f"?{encoded_params}" if encoded_params else "") \ No newline at end of file From 877c117c371f92192c6121d0ad1833fa67e9a3f8 Mon Sep 17 00:00:00 2001 From: Vamsi Krishna <46787868+vamsikrishnamathala@users.noreply.github.com> Date: Wed, 17 Sep 2025 18:52:35 +0530 Subject: [PATCH 041/169] [WEB-4943]fix: next path url redirection (#7817) * fix: next path url redirection * fix: enhance URL redirection safety in authentication views Updated SignInAuthSpaceEndpoint, GitHubCallbackSpaceEndpoint, GitLabCallbackSpaceEndpoint, and GoogleCallbackSpaceEndpoint to include checks for allowed hosts and schemes before redirecting. This improves the security of URL redirection by ensuring only valid URLs are used. * chore: updated uitl to handle double / --------- Co-authored-by: pablohashescobar Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com> --- .../plane/authentication/views/space/email.py | 15 ++-- .../authentication/views/space/github.py | 1 + .../authentication/views/space/gitlab.py | 4 +- .../authentication/views/space/google.py | 1 + apps/space/app/page.tsx | 25 +++++- packages/utils/src/url.ts | 79 +++++++++++++++---- 6 files changed, 99 insertions(+), 26 deletions(-) diff --git a/apps/api/plane/authentication/views/space/email.py b/apps/api/plane/authentication/views/space/email.py index b25e2d015..afba06ddc 100644 --- a/apps/api/plane/authentication/views/space/email.py +++ b/apps/api/plane/authentication/views/space/email.py @@ -17,6 +17,7 @@ from plane.authentication.adapter.error import ( ) from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + class SignInAuthSpaceEndpoint(View): def post(self, request): next_path = request.POST.get("next_path") @@ -99,13 +100,13 @@ class SignInAuthSpaceEndpoint(View): user = provider.authenticate() # Login the user and record his device info user_login(request=request, user=user, is_space=True) - # redirect to next path - url = get_safe_redirect_url( - base_url=base_host(request=request, is_space=True), - next_path=next_path, - params={} - ) - return HttpResponseRedirect(url) + # redirect to referer path + next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" + if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): + return HttpResponseRedirect(url) + else: + return HttpResponseRedirect(base_host(request=request, is_space=True)) except AuthenticationException as e: params = e.get_error_dict() url = get_safe_redirect_url( diff --git a/apps/api/plane/authentication/views/space/github.py b/apps/api/plane/authentication/views/space/github.py index acc956cf3..1a7d51d66 100644 --- a/apps/api/plane/authentication/views/space/github.py +++ b/apps/api/plane/authentication/views/space/github.py @@ -95,6 +95,7 @@ class GitHubCallbackSpaceEndpoint(View): # Process workspace and project invitations # redirect to referer path next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/gitlab.py b/apps/api/plane/authentication/views/space/gitlab.py index 493cd823e..26ed17f32 100644 --- a/apps/api/plane/authentication/views/space/gitlab.py +++ b/apps/api/plane/authentication/views/space/gitlab.py @@ -15,7 +15,8 @@ from plane.authentication.adapter.error import ( AUTHENTICATION_ERROR_CODES, AuthenticationException, ) -from plane.utils.path_validator import get_safe_redirect_url, get_allowed_hosts, validate_next_path +from plane.utils.path_validator import get_safe_redirect_url, validate_next_path, get_allowed_hosts + class GitLabOauthInitiateSpaceEndpoint(View): @@ -96,6 +97,7 @@ class GitLabCallbackSpaceEndpoint(View): # Process workspace and project invitations # redirect to referer path next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): return HttpResponseRedirect(url) diff --git a/apps/api/plane/authentication/views/space/google.py b/apps/api/plane/authentication/views/space/google.py index c7c833771..617216d64 100644 --- a/apps/api/plane/authentication/views/space/google.py +++ b/apps/api/plane/authentication/views/space/google.py @@ -92,6 +92,7 @@ class GoogleCallbackSpaceEndpoint(View): user_login(request=request, user=user, is_space=True) # redirect to referer path next_path = validate_next_path(next_path=next_path) + url = f"{base_host(request=request, is_space=True).rstrip('/')}{next_path}" if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()): return HttpResponseRedirect(url) diff --git a/apps/space/app/page.tsx b/apps/space/app/page.tsx index a75275e0d..f544bcb10 100644 --- a/apps/space/app/page.tsx +++ b/apps/space/app/page.tsx @@ -1,6 +1,9 @@ "use client"; - +import { useEffect } from "react"; import { observer } from "mobx-react"; +import { useSearchParams, useRouter } from "next/navigation"; +// plane imports +import { isValidNextPath } from "@plane/utils"; // components import { UserLoggedIn } from "@/components/account/user-logged-in"; import { LogoSpinner } from "@/components/common/logo-spinner"; @@ -10,6 +13,15 @@ import { useUser } from "@/hooks/store/use-user"; const HomePage = observer(() => { const { data: currentUser, isAuthenticated, isInitializing } = useUser(); + const searchParams = useSearchParams(); + const router = useRouter(); + const nextPath = searchParams.get("next_path"); + + useEffect(() => { + if (currentUser && isAuthenticated && nextPath && isValidNextPath(nextPath)) { + router.replace(nextPath); + } + }, [currentUser, isAuthenticated, nextPath, router]); if (isInitializing) return ( @@ -18,7 +30,16 @@ const HomePage = observer(() => {

); - if (currentUser && isAuthenticated) return ; + if (currentUser && isAuthenticated) { + if (nextPath && isValidNextPath(nextPath)) { + return ( +
+ +
+ ); + } + return ; + } return ; }); diff --git a/packages/utils/src/url.ts b/packages/utils/src/url.ts index 638839bb0..738d935d5 100644 --- a/packages/utils/src/url.ts +++ b/packages/utils/src/url.ts @@ -114,22 +114,6 @@ export function extractHostname(url: string): string { * - Removes hash fragments (everything after '#') * - Removes port numbers (everything after ':') * 3. Validates the TLD against a list of known TLDs - * - * @example - * // Valid cases (returns the TLD) - * extractTLD('example.com') // returns 'com' - * extractTLD('sub.example.com') // returns 'com' - * extractTLD('example.com/path') // returns 'com' - * extractTLD('example.com:8080') // returns 'com' - * extractTLD('example.com?query=1') // returns 'com' - * extractTLD('example.com#hash') // returns 'com' - * - * // Invalid cases (returns empty string) - * extractTLD('') // returns '' - * extractTLD('.example.com') // returns '' - * extractTLD('example.com.') // returns '' - * extractTLD('example.invalid') // returns '' - * extractTLD('localhost') // returns '' */ export function extractTLD(urlString: string): string { @@ -257,3 +241,66 @@ export function extractURLComponents(url: URL | string): IURLComponents | undefi return undefined; } } + +/** + * Validates that a next_path parameter is safe for redirection. + * Only allows relative paths starting with "/" to prevent open redirect vulnerabilities. + * + * @param url - The next_path URL to validate + * @returns True if the URL is a safe relative path, false otherwise + * + * @example + * isValidNextPath("/dashboard") // true + * isValidNextPath("/workspace/123") // true + * isValidNextPath("https://malicious.com") // false + * isValidNextPath("//malicious.com") // false (protocol-relative) + * isValidNextPath("javascript:alert(1)") // false + * isValidNextPath("") // false + * isValidNextPath("dashboard") // false (must start with /) + * isValidNextPath("\\malicious") // false (backslash) + * isValidNextPath(" /dashboard ") // true (trimmed) + */ +export function isValidNextPath(url: string): boolean { + if (!url || typeof url !== "string") return false; + + // Trim leading/trailing whitespace + const trimmedUrl = url.trim(); + + if (!trimmedUrl) return false; + + // Only allow relative paths starting with / + if (!trimmedUrl.startsWith("/")) return false; + + // Block protocol-relative URLs (//example.com) - open redirect vulnerability + if (trimmedUrl.startsWith("//")) return false; + + // Block backslashes which can be used for path traversal or Windows-style paths + if (trimmedUrl.includes("\\")) return false; + + try { + // Use URL constructor with a dummy base to normalize and validate the path + const normalizedUrl = new URL(trimmedUrl, "http://localhost"); + + // Ensure the path is still relative (no host change from our dummy base) + if (normalizedUrl.hostname !== "localhost" || normalizedUrl.protocol !== "http:") { + return false; + } + + // Use the normalized pathname for additional security checks + const pathname = normalizedUrl.pathname; + + // Additional security checks for malicious patterns in the normalized path + const maliciousPatterns = [ + /javascript:/i, + /data:/i, + /vbscript:/i, + /