diff --git a/apiserver/plane/app/views/workspace/preference.py b/apiserver/plane/app/views/workspace/preference.py index 21c06321cb..4d6d945622 100644 --- a/apiserver/plane/app/views/workspace/preference.py +++ b/apiserver/plane/app/views/workspace/preference.py @@ -5,6 +5,11 @@ from plane.app.permissions import allow_permission, ROLE from plane.db.models import Workspace from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer +# Django imports + +from django.db.models import Count + + # Third party imports from rest_framework.response import Response from rest_framework import status @@ -28,20 +33,29 @@ class WorkspacePreferenceViewSet(BaseAPIView): keys = [key for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices] + sort_order_counter = 1 + for preference in keys: if preference not in get_preference.values_list("key", flat=True): create_preference_keys.append(preference) + sort_order = 1000 - sort_order_counter + preference = WorkspaceHomePreference.objects.bulk_create( [ WorkspaceHomePreference( - key=key, user=request.user, workspace=workspace + key=key, + user=request.user, + workspace=workspace, + sort_order=sort_order, ) for key in create_preference_keys ], batch_size=10, ignore_conflicts=True, ) + sort_order_counter += 1 + preference = WorkspaceHomePreference.objects.filter( user=request.user, workspace_id=workspace.id ) diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 7a9cd8b332..9ec3846b7c 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -39,3 +39,4 @@ export * from "./activity"; export * from "./epics"; export * from "./charts"; export * from "./home"; +export * from "./stickies"; diff --git a/packages/types/src/stickies.d copy.ts b/packages/types/src/stickies.d copy.ts new file mode 100644 index 0000000000..55f8b23c58 --- /dev/null +++ b/packages/types/src/stickies.d copy.ts @@ -0,0 +1,8 @@ +export type TSticky = { + id: string; + name?: string; + description_html?: string; + color?: string; + createdAt?: Date; + updatedAt?: Date; +}; diff --git a/packages/types/src/stickies.d.ts b/packages/types/src/stickies.d.ts new file mode 100644 index 0000000000..55f8b23c58 --- /dev/null +++ b/packages/types/src/stickies.d.ts @@ -0,0 +1,8 @@ +export type TSticky = { + id: string; + name?: string; + description_html?: string; + color?: string; + createdAt?: Date; + updatedAt?: Date; +}; diff --git a/packages/ui/src/icons/index.ts b/packages/ui/src/icons/index.ts index 01480c78d2..c09c26057d 100644 --- a/packages/ui/src/icons/index.ts +++ b/packages/ui/src/icons/index.ts @@ -47,3 +47,5 @@ export * from "./overview-icon"; export * from "./on-track-icon"; export * from "./off-track-icon"; export * from "./at-risk-icon"; +export * from "./multiple-sticky"; +export * from "./sticky-note-icon"; diff --git a/packages/ui/src/icons/multiple-sticky.tsx b/packages/ui/src/icons/multiple-sticky.tsx new file mode 100644 index 0000000000..9c33205e99 --- /dev/null +++ b/packages/ui/src/icons/multiple-sticky.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const RecentStickyIcon: React.FC = ({ className = "text-current", ...rest }) => ( + + + + +); diff --git a/packages/ui/src/icons/sticky-note-icon.tsx b/packages/ui/src/icons/sticky-note-icon.tsx new file mode 100644 index 0000000000..8719502899 --- /dev/null +++ b/packages/ui/src/icons/sticky-note-icon.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { ISvgIcons } from "./type"; + +export const StickyNoteIcon: React.FC = ({ width = "17", height = "17", className, color }) => ( + + + + + + +); diff --git a/web/ce/components/stickies/index.ts b/web/ce/components/stickies/index.ts deleted file mode 100644 index 97866ce19b..0000000000 --- a/web/ce/components/stickies/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./widget"; diff --git a/web/ce/components/stickies/widget.tsx b/web/ce/components/stickies/widget.tsx deleted file mode 100644 index 56df281e1e..0000000000 --- a/web/ce/components/stickies/widget.tsx +++ /dev/null @@ -1 +0,0 @@ -export const StickiesWidget = () => <>; diff --git a/web/core/components/editor/index.ts b/web/core/components/editor/index.ts index e7651069ed..674bbdf158 100644 --- a/web/core/components/editor/index.ts +++ b/web/core/components/editor/index.ts @@ -2,3 +2,4 @@ export * from "./embeds"; export * from "./lite-text-editor"; export * from "./pdf"; export * from "./rich-text-editor"; +export * from "./sticky-editor"; diff --git a/web/core/components/editor/sticky-editor/color-pallete.tsx b/web/core/components/editor/sticky-editor/color-pallete.tsx new file mode 100644 index 0000000000..3060650f7d --- /dev/null +++ b/web/core/components/editor/sticky-editor/color-pallete.tsx @@ -0,0 +1,36 @@ +import { TSticky } from "@plane/types"; + +export const STICKY_COLORS = [ + "#D4DEF7", // light periwinkle + "#B4E4FF", // light blue + "#FFF2B4", // light yellow + "#E3E3E3", // light gray + "#FFE2DD", // light pink + "#F5D1A5", // light orange + "#D1F7C4", // light green + "#E5D4FF", // light purple +]; + +type TProps = { + handleUpdate: (data: Partial) => Promise; +}; + +export const ColorPalette = (props: TProps) => { + const { handleUpdate } = props; + return ( +
+
Background colors
+
+ {STICKY_COLORS.map((color, index) => ( +
+
+ ); +}; diff --git a/web/core/components/editor/sticky-editor/editor.tsx b/web/core/components/editor/sticky-editor/editor.tsx new file mode 100644 index 0000000000..a56fc119b9 --- /dev/null +++ b/web/core/components/editor/sticky-editor/editor.tsx @@ -0,0 +1,109 @@ +import React, { useState } from "react"; +// plane constants +import { EIssueCommentAccessSpecifier } from "@plane/constants"; +// plane editor +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; +// components +import { TSticky } from "@plane/types"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { getEditorFileHandlers } from "@/helpers/editor.helper"; +// hooks +// plane web hooks +import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; +import { useFileSize } from "@/plane-web/hooks/use-file-size"; +import { Toolbar } from "./toolbar"; + +interface StickyEditorWrapperProps + extends Omit { + workspaceSlug: string; + workspaceId: string; + projectId?: string; + accessSpecifier?: EIssueCommentAccessSpecifier; + handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void; + showAccessSpecifier?: boolean; + showSubmitButton?: boolean; + isSubmitting?: boolean; + showToolbarInitially?: boolean; + showToolbar?: boolean; + uploadFile: (file: File) => Promise; + parentClassName?: string; + handleColorChange: (data: Partial) => Promise; + handleDelete: () => Promise; +} + +export const StickyEditor = React.forwardRef((props, ref) => { + const { + containerClassName, + workspaceSlug, + workspaceId, + projectId, + handleDelete, + handleColorChange, + showToolbarInitially = true, + showToolbar = true, + parentClassName = "", + placeholder = "Add comment...", + uploadFile, + ...rest + } = props; + // states + const [isFocused, setIsFocused] = useState(showToolbarInitially); + // editor flaggings + const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString()); + // file size + const { maxFileSize } = useFileSize(); + function isMutableRefObject(ref: React.ForwardedRef): ref is React.MutableRefObject { + return !!ref && typeof ref === "object" && "current" in ref; + } + // derived values + const editorRef = isMutableRefObject(ref) ? ref.current : null; + + return ( +
!showToolbarInitially && setIsFocused(true)} + onBlur={() => !showToolbarInitially && setIsFocused(false)} + > + <>, + }} + placeholder={placeholder} + containerClassName={cn(containerClassName, "relative")} + {...rest} + /> +
+ { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }} + handleDelete={handleDelete} + handleColorChange={handleColorChange} + editorRef={editorRef} + /> +
+
+ ); +}); + +StickyEditor.displayName = "StickyEditor"; diff --git a/web/core/components/editor/sticky-editor/index.ts b/web/core/components/editor/sticky-editor/index.ts new file mode 100644 index 0000000000..f73ee92ef6 --- /dev/null +++ b/web/core/components/editor/sticky-editor/index.ts @@ -0,0 +1,2 @@ +export * from "./editor"; +export * from "./toolbar"; diff --git a/web/core/components/editor/sticky-editor/toolbar.tsx b/web/core/components/editor/sticky-editor/toolbar.tsx new file mode 100644 index 0000000000..c1686b4469 --- /dev/null +++ b/web/core/components/editor/sticky-editor/toolbar.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import { Palette, Trash2 } from "lucide-react"; +// editor +import { EditorRefApi } from "@plane/editor"; +// ui +import { useOutsideClickDetector } from "@plane/hooks"; +import { TSticky } from "@plane/types"; +import { Tooltip } from "@plane/ui"; +// constants +import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { ColorPalette } from "./color-pallete"; + +type Props = { + executeCommand: (item: ToolbarMenuItem) => void; + editorRef: EditorRefApi | null; + handleColorChange: (data: Partial) => Promise; + handleDelete: () => void; +}; + +const toolbarItems = TOOLBAR_ITEMS.sticky; + +export const Toolbar: React.FC = (props) => { + const { executeCommand, editorRef, handleColorChange, handleDelete } = props; + + // State to manage active states of toolbar items + const [activeStates, setActiveStates] = useState>({}); + const [showColorPalette, setShowColorPalette] = useState(false); + const colorPaletteRef = React.useRef(null); + // Function to update active states + const updateActiveStates = useCallback(() => { + if (!editorRef) return; + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + newActiveStates[item.renderKey] = editorRef.isMenuItemActive({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }); + setActiveStates(newActiveStates); + }, [editorRef]); + + // useEffect to call updateActiveStates when isActive prop changes + useEffect(() => { + if (!editorRef) return; + const unsubscribe = editorRef.onStateChange(updateActiveStates); + updateActiveStates(); + return () => unsubscribe(); + }, [editorRef, updateActiveStates]); + + useOutsideClickDetector(colorPaletteRef, () => setShowColorPalette(false)); + + return ( +
+
+ {/* color palette */} + {showColorPalette && } + + Background color +

+ } + > + +
+ +
+
+ {Object.keys(toolbarItems).map((key) => ( +
+ {toolbarItems[key].map((item) => { + const isItemActive = activeStates[item.renderKey]; + + return ( + + {item.name} + {item.shortcut && {item.shortcut.join(" + ")}} +

+ } + > + +
+ ); + })} +
+ ))} +
+
+
+ {/* delete action */} + + Delete +

+ } + > + +
+
+ ); +}; diff --git a/web/core/components/home/home-dashboard-widgets.tsx b/web/core/components/home/home-dashboard-widgets.tsx index 6accd1853f..a1186106fa 100644 --- a/web/core/components/home/home-dashboard-widgets.tsx +++ b/web/core/components/home/home-dashboard-widgets.tsx @@ -6,7 +6,7 @@ import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types"; import { useHome } from "@/hooks/store/use-home"; // components import { HomePageHeader } from "@/plane-web/components/home/header"; -import { StickiesWidget } from "@/plane-web/components/stickies"; +import { StickiesWidget } from "../stickies"; import { RecentActivityWidget } from "./widgets"; import { DashboardQuickLinks } from "./widgets/links"; import { ManageWidgetsModal } from "./widgets/manage"; @@ -37,19 +37,18 @@ export const DashboardWidgets = observer(() => { isModalOpen={showWidgetSettings} handleOnClose={() => toggleWidgetSettings(false)} /> - - {orderedWidgets.map((key) => { - const WidgetComponent = WIDGETS_LIST[key]?.component; - const isEnabled = widgetsMap[key]?.is_enabled; - if (!WidgetComponent || !isEnabled) return null; - if (WIDGETS_LIST[key]?.fullWidth) +
+ {orderedWidgets.map((key) => { + const WidgetComponent = WIDGETS_LIST[key]?.component; + const isEnabled = widgetsMap[key]?.is_enabled; + if (!WidgetComponent || !isEnabled) return null; return ( -
+
); - else return ; - })} + })} +
); }); diff --git a/web/core/components/home/root.tsx b/web/core/components/home/root.tsx index 44cd935661..2f6df1fe6f 100644 --- a/web/core/components/home/root.tsx +++ b/web/core/components/home/root.tsx @@ -3,15 +3,13 @@ import { useParams } from "next/navigation"; // components import useSWR from "swr"; import { ContentWrapper } from "@plane/ui"; -import { EmptyState } from "@/components/empty-state"; import { TourRoot } from "@/components/onboarding"; // constants -import { EmptyStateType } from "@/constants/empty-state"; import { PRODUCT_TOUR_COMPLETED } from "@/constants/event-tracker"; // helpers import { cn } from "@/helpers/common.helper"; // hooks -import { useCommandPalette, useUserProfile, useEventTracker, useProject, useUser } from "@/hooks/store"; +import { useUserProfile, useEventTracker, useUser } from "@/hooks/store"; import { useHome } from "@/hooks/store/use-home"; import useSize from "@/hooks/use-window-size"; import { IssuePeekOverview } from "../issues"; @@ -20,17 +18,11 @@ import { UserGreetingsView } from "./user-greetings"; export const WorkspaceHomeView = observer(() => { // store hooks - const { - // captureEvent, - setTrackElement, - } = useEventTracker(); - const { toggleCreateProjectModal } = useCommandPalette(); const { workspaceSlug } = useParams(); const { data: currentUser } = useUser(); const { data: currentUserProfile, updateTourCompleted } = useUserProfile(); const { captureEvent } = useEventTracker(); const { toggleWidgetSettings, fetchWidgets } = useHome(); - const { joinedProjectIds, loader } = useProject(); const [windowWidth] = useSize(); useSWR( @@ -64,34 +56,18 @@ export const WorkspaceHomeView = observer(() => { )} - {joinedProjectIds && ( - <> - {joinedProjectIds.length > 0 || loader ? ( - <> - - = 768, - })} - > - {currentUser && ( - toggleWidgetSettings(true)} /> - )} + <> + + = 768, + })} + > + {currentUser && toggleWidgetSettings(true)} />} - - - - ) : ( - { - setTrackElement("Dashboard empty state"); - toggleCreateProjectModal(true); - }} - /> - )} - - )} + + + ); }); diff --git a/web/core/components/home/widgets/empty-states/issues.tsx b/web/core/components/home/widgets/empty-states/issues.tsx new file mode 100644 index 0000000000..fc4548d67a --- /dev/null +++ b/web/core/components/home/widgets/empty-states/issues.tsx @@ -0,0 +1,21 @@ +import Image from "next/image"; +import { useTheme } from "next-themes"; +import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg"; +import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg"; + +export const IssuesEmptyState = () => { + // next-themes + const { resolvedTheme } = useTheme(); + + const image = resolvedTheme === "dark" ? UpcomingIssuesDark : UpcomingIssuesLight; + + // TODO: update empty state logic to use a general component + return ( +
+
+ Assigned issues +
+

No activity to display

+
+ ); +}; diff --git a/web/core/components/home/widgets/links/root.tsx b/web/core/components/home/widgets/links/root.tsx index b5d96678fc..f7b6f007cc 100644 --- a/web/core/components/home/widgets/links/root.tsx +++ b/web/core/components/home/widgets/links/root.tsx @@ -31,7 +31,7 @@ export const DashboardQuickLinks = observer((props: THomeWidgetProps) => { preloadedData={linkData} setLinkData={setLinkData} /> -
+
{/* rendering links */}
diff --git a/web/core/components/home/widgets/manage/index.tsx b/web/core/components/home/widgets/manage/index.tsx index 670212c6f5..9a2239e2ee 100644 --- a/web/core/components/home/widgets/manage/index.tsx +++ b/web/core/components/home/widgets/manage/index.tsx @@ -4,7 +4,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; // plane types // plane ui -import { Button, EModalWidth, ModalCore } from "@plane/ui"; +import { EModalWidth, ModalCore } from "@plane/ui"; import { WidgetList } from "./widget-list"; export type TProps = { @@ -22,14 +22,6 @@ export const ManageWidgetsModal: FC = observer((props) => {
Manage widgets
-
- - -
); diff --git a/web/core/components/home/widgets/recents/index.tsx b/web/core/components/home/widgets/recents/index.tsx index 4db8633961..8eaecca770 100644 --- a/web/core/components/home/widgets/recents/index.tsx +++ b/web/core/components/home/widgets/recents/index.tsx @@ -8,8 +8,10 @@ import { Briefcase, FileText } from "lucide-react"; import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types"; // components import { LayersIcon } from "@plane/ui"; +import { useProject } from "@/hooks/store"; import { WorkspaceService } from "@/plane-web/services"; import { EmptyWorkspace } from "../empty-states"; +import { IssuesEmptyState } from "../empty-states/issues"; import { EWidgetKeys, WidgetLoader } from "../loaders"; import { FiltersDropdown } from "./filters"; import { RecentIssue } from "./issue"; @@ -31,6 +33,7 @@ export const RecentActivityWidget: React.FC = observer((props) const [filter, setFilter] = useState(filters[0].name); // ref const ref = useRef(null); + const { joinedProjectIds, loader } = useProject(); const { data: recents, isLoading } = useSWR( workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null, @@ -61,19 +64,33 @@ export const RecentActivityWidget: React.FC = observer((props) } }; - if (!isLoading && recents?.length === 0) return ; + if (!loader && joinedProjectIds?.length === 0) return ; + if (!isLoading && recents?.length === 0) + return ( +
+
+
Recents
+ +
+
+ +
+
+ ); return ( -
+
-
Recents
+
Recents
- {isLoading && } - {!isLoading && - recents?.length > 0 && - recents.map((activity: TActivityEntityData) =>
{resolveRecent(activity)}
)} +
+ {isLoading && } + {!isLoading && + recents?.length > 0 && + recents.map((activity: TActivityEntityData) =>
{resolveRecent(activity)}
)} +
); }); diff --git a/web/core/components/home/widgets/recents/issue.tsx b/web/core/components/home/widgets/recents/issue.tsx index c5f76b0930..77e9044722 100644 --- a/web/core/components/home/widgets/recents/issue.tsx +++ b/web/core/components/home/widgets/recents/issue.tsx @@ -1,5 +1,5 @@ import { TActivityEntityData, TIssueEntityData } from "@plane/types"; -import { PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui"; +import { LayersIcon, PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui"; import { ListItem } from "@/components/core/list"; import { MemberDropdown } from "@/components/dropdowns"; import { calculateTimeAgo } from "@/helpers/date-time.helper"; @@ -27,13 +27,25 @@ export const RecentIssue = (props: BlockProps) => { title={""} prependTitleElement={
- + {issueDetails.type ? ( + + ) : ( +
+
+ +
+
+ {issueDetails?.project_identifier}-{issueDetails?.sequence_id} +
+
+ )}
{issueDetails?.name}
{calculateTimeAgo(activity.visited_at)}
diff --git a/web/core/components/home/widgets/recents/page.tsx b/web/core/components/home/widgets/recents/page.tsx index 91a350c9e8..d69940760f 100644 --- a/web/core/components/home/widgets/recents/page.tsx +++ b/web/core/components/home/widgets/recents/page.tsx @@ -28,17 +28,19 @@ export const RecentPage = (props: BlockProps) => { title={""} prependTitleElement={
-
- <> - {pageDetails?.logo_props?.in_use ? ( - - ) : ( - - )} - -
-
- {pageDetails?.project_identifier} +
+
+ <> + {pageDetails?.logo_props?.in_use ? ( + + ) : ( + + )} + +
+
+ {pageDetails?.project_identifier} +
{pageDetails?.name}
{calculateTimeAgo(activity.visited_at)}
diff --git a/web/core/components/home/widgets/recents/project.tsx b/web/core/components/home/widgets/recents/project.tsx index bfe2bd5cd1..92d5cd45e8 100644 --- a/web/core/components/home/widgets/recents/project.tsx +++ b/web/core/components/home/widgets/recents/project.tsx @@ -24,11 +24,13 @@ export const RecentProject = (props: BlockProps) => { title={""} prependTitleElement={
-
- -
-
- {projectDetails?.identifier} +
+
+ +
+
+ {projectDetails?.identifier} +
{projectDetails?.name}
{calculateTimeAgo(activity.visited_at)}
diff --git a/web/core/components/stickies/action-bar.tsx b/web/core/components/stickies/action-bar.tsx new file mode 100644 index 0000000000..6bbdd907ff --- /dev/null +++ b/web/core/components/stickies/action-bar.tsx @@ -0,0 +1,129 @@ +import { useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import useSWR from "swr"; +import { Plus, StickyNote as StickyIcon, X } from "lucide-react"; +import { useOutsideClickDetector } from "@plane/hooks"; +import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useCommandPalette } from "@/hooks/store"; +import { useSticky } from "@/hooks/use-stickies"; +import { AllStickiesModal } from "./modal"; +import { StickyNote } from "./sticky"; + +export const StickyActionBar = observer(() => { + const { workspaceSlug } = useParams(); + const [isExpanded, setIsExpanded] = useState(false); + const [newSticky, setNewSticky] = useState(false); + const [showRecentSticky, setShowRecentSticky] = useState(false); + const ref = useRef(null); + + // hooks + const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } = + useSticky(); + const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette(); + + useSWR( + workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null, + workspaceSlug ? () => fetchRecentSticky(workspaceSlug.toString()) : null + ); + + useOutsideClickDetector(ref, () => { + setNewSticky(false); + setShowRecentSticky(false); + setIsExpanded(false); + }); + + return ( +
+
+ + + + {recentStickyId && ( + + +
+
+ } + isMobile={false} + position="left" + disabled={showRecentSticky} + > + +
+ )} + + + +
+ + + +
+ {(newSticky || (showRecentSticky && recentStickyId)) && ( + (newSticky ? setNewSticky(false) : setShowRecentSticky(false))} + workspaceSlug={workspaceSlug.toString()} + stickyId={newSticky ? activeStickyId : recentStickyId || ""} + /> + )} +
+ + toggleAllStickiesModal(false)} /> +
+ ); +}); diff --git a/web/core/components/stickies/empty.tsx b/web/core/components/stickies/empty.tsx new file mode 100644 index 0000000000..4413ab570f --- /dev/null +++ b/web/core/components/stickies/empty.tsx @@ -0,0 +1,40 @@ +import { Plus, StickyNote as StickyIcon, X } from "lucide-react"; + +type TProps = { + handleCreate: () => void; + creatingSticky?: boolean; +}; +export const EmptyState = (props: TProps) => { + const { handleCreate, creatingSticky } = props; + return ( +
+
+
+ +
+
No stickies yet
+
+ All your stickies in this workspace will appear here. +
+ +
+
+ ); +}; diff --git a/web/core/components/stickies/index.ts b/web/core/components/stickies/index.ts new file mode 100644 index 0000000000..1376a85eb5 --- /dev/null +++ b/web/core/components/stickies/index.ts @@ -0,0 +1,2 @@ +export * from "./action-bar"; +export * from "./widget"; diff --git a/web/core/components/stickies/modal/index.tsx b/web/core/components/stickies/modal/index.tsx new file mode 100644 index 0000000000..8a91bd113c --- /dev/null +++ b/web/core/components/stickies/modal/index.tsx @@ -0,0 +1,15 @@ +import { EModalWidth, ModalCore } from "@plane/ui"; +import { Stickies } from "./stickies"; + +type TProps = { + isOpen: boolean; + handleClose: () => void; +}; +export const AllStickiesModal = (props: TProps) => { + const { isOpen, handleClose } = props; + return ( + + + + ); +}; diff --git a/web/core/components/stickies/modal/search.tsx b/web/core/components/stickies/modal/search.tsx new file mode 100644 index 0000000000..34c0a8f6d1 --- /dev/null +++ b/web/core/components/stickies/modal/search.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { FC, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Search, X } from "lucide-react"; +// plane hooks +import { useOutsideClickDetector } from "@plane/hooks"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { useSticky } from "@/hooks/use-stickies"; + +export const StickySearch: FC = observer(() => { + // hooks + const { searchQuery, updateSearchQuery } = useSticky(); + // refs + const inputRef = useRef(null); + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + + return ( +
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+
+ ); +}); diff --git a/web/core/components/stickies/modal/stickies.tsx b/web/core/components/stickies/modal/stickies.tsx new file mode 100644 index 0000000000..dcd8645a02 --- /dev/null +++ b/web/core/components/stickies/modal/stickies.tsx @@ -0,0 +1,68 @@ +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import { Plus, X } from "lucide-react"; +import { RecentStickyIcon } from "@plane/ui"; +import { useSticky } from "@/hooks/use-stickies"; +import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete"; +import { StickiesLayout } from "../stickies-layout"; +import { useStickyOperations } from "../sticky/use-operations"; +import { StickySearch } from "./search"; + +type TProps = { + handleClose?: () => void; +}; + +export const Stickies = observer((props: TProps) => { + const { handleClose } = props; + const { workspaceSlug } = useParams(); + const { creatingSticky, toggleShowNewSticky } = useSticky(); + const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() }); + + return ( +
+ {/* header */} +
+ {/* Title */} +
+ +

My Stickies

+
+ {/* actions */} +
+ + + {handleClose && ( + + )} +
+
+ {/* content */} +
+ +
+
+ ); +}); diff --git a/web/core/components/stickies/stickies-layout.tsx b/web/core/components/stickies/stickies-layout.tsx new file mode 100644 index 0000000000..5ceecc4431 --- /dev/null +++ b/web/core/components/stickies/stickies-layout.tsx @@ -0,0 +1,209 @@ +import React, { useState, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +import Masonry from "react-masonry-component"; +import useSWR from "swr"; +import { Loader } from "@plane/ui"; +import { cn } from "@plane/utils"; +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +import { useSticky } from "@/hooks/use-stickies"; +import { STICKY_COLORS } from "../editor/sticky-editor/color-pallete"; +import { EmptyState } from "./empty"; +import { StickyNote } from "./sticky"; +import { useStickyOperations } from "./sticky/use-operations"; + +const PER_PAGE = 10; + +type TProps = { + columnCount: number; +}; + +export const StickyAll = observer((props: TProps) => { + const { columnCount } = props; + // refs + const masonryRef = useRef(null); + const containerRef = useRef(null); + // states + const [containerHeight, setContainerHeight] = useState(0); + const [showAllStickies, setShowAllStickies] = useState(false); + const [intersectionElement, setIntersectionElement] = useState(null); + // router + const { workspaceSlug } = useParams(); + // hooks + const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() }); + + const { + fetchingWorkspaceStickies, + toggleShowNewSticky, + getWorkspaceStickies, + fetchWorkspaceStickies, + currentPage, + totalPages, + incrementPage, + creatingSticky, + } = useSticky(); + + const workspaceStickies = getWorkspaceStickies(workspaceSlug?.toString()); + const itemWidth = `${100 / columnCount}%`; + + useSWR( + workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}_${PER_PAGE}:${currentPage}:0` : null, + workspaceSlug + ? () => fetchWorkspaceStickies(workspaceSlug.toString(), `${PER_PAGE}:${currentPage}:0`, PER_PAGE) + : null + ); + + useEffect(() => { + if (!fetchingWorkspaceStickies && workspaceStickies.length === 0) { + toggleShowNewSticky(true); + } + }, [fetchingWorkspaceStickies, workspaceStickies, toggleShowNewSticky]); + + // Update this useEffect to correctly track height + useEffect(() => { + if (!masonryRef?.current) return; + + const updateHeight = () => { + if (masonryRef.current) { + const height = masonryRef.current.getBoundingClientRect().height; + setContainerHeight(parseInt(height.toString())); + } + }; + + // Initial height measurement + updateHeight(); + + // Create ResizeObserver + const resizeObserver = new ResizeObserver(() => { + updateHeight(); + }); + + resizeObserver.observe(masonryRef.current); + + // Also update height when Masonry content changes + const mutationObserver = new MutationObserver(() => { + updateHeight(); + }); + + mutationObserver.observe(masonryRef.current, { + childList: true, + subtree: true, + attributes: true, + }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [masonryRef?.current]); + + useIntersectionObserver(containerRef, fetchingWorkspaceStickies ? null : intersectionElement, incrementPage, "20%"); + + if (fetchingWorkspaceStickies && workspaceStickies.length === 0) { + return ( +
+ + + +
+ ); + } + + const getStickiesToRender = () => { + let stickies: (string | undefined)[] = workspaceStickies; + if (currentPage + 1 < totalPages && stickies.length >= PER_PAGE) { + stickies = [...stickies, undefined]; + } + return stickies; + }; + + const stickyIds = getStickiesToRender(); + + const childElements = stickyIds.map((stickyId, index) => ( +
+ {index === stickyIds.length - 1 && currentPage + 1 < totalPages ? ( +
+ + + +
+ ) : ( + + )} +
+ )); + + if (!fetchingWorkspaceStickies && workspaceStickies.length === 0) + return ( + { + toggleShowNewSticky(true); + stickyOperations.create({ color: STICKY_COLORS[0] }); + }} + /> + ); + + return ( +
+
+ {/* @ts-expect-error type mismatch here */} + {childElements} +
+ {containerHeight > 632.9 && ( +
+ +
+ )} +
+ ); +}); + +export const StickiesLayout = () => { + // states + const [containerWidth, setContainerWidth] = useState(null); + // refs + const ref = useRef(null); + + useEffect(() => { + if (!ref?.current) return; + + setContainerWidth(ref?.current.offsetWidth); + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + resizeObserver.observe(ref?.current); + return () => resizeObserver.disconnect(); + }, []); + + const getColumnCount = (width: number | null): number => { + if (width === null) return 4; + + if (width < 640) return 2; // sm + if (width < 768) return 3; // md + if (width < 1024) return 4; // lg + if (width < 1280) return 5; // xl + return 6; // 2xl and above + }; + + const columnCount = getColumnCount(containerWidth); + return ( +
+ +
+ ); +}; diff --git a/web/core/components/stickies/sticky/index.ts b/web/core/components/stickies/sticky/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/core/components/stickies/sticky/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/stickies/sticky/inputs.tsx b/web/core/components/stickies/sticky/inputs.tsx new file mode 100644 index 0000000000..3dd97c9c06 --- /dev/null +++ b/web/core/components/stickies/sticky/inputs.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useRef } from "react"; +import { DebouncedFunc } from "lodash"; +import { Controller, useForm } from "react-hook-form"; +import { EditorRefApi } from "@plane/editor"; +import { TSticky } from "@plane/types"; +import { TextArea } from "@plane/ui"; +import { useWorkspace } from "@/hooks/store"; +import { StickyEditor } from "../../editor"; + +type TProps = { + stickyData: TSticky | undefined; + workspaceSlug: string; + handleUpdate: DebouncedFunc<(payload: Partial) => Promise>; + stickyId: string | undefined; + handleChange: (data: Partial) => Promise; + handleDelete: () => Promise; +}; +export const StickyInput = (props: TProps) => { + const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props; + //refs + const editorRef = useRef(null); + // store hooks + const { getWorkspaceBySlug } = useWorkspace(); + // form info + const { handleSubmit, reset, control } = useForm({ + defaultValues: { + description_html: stickyData?.description_html, + name: stickyData?.name, + }, + }); + + // computed values + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string; + + // reset form values + useEffect(() => { + if (!stickyId) return; + reset({ + id: stickyId, + description_html: stickyData?.description_html === "" ? "

" : stickyData?.description_html, + name: stickyData?.name, + }); + }, [stickyData, reset]); + + const handleFormSubmit = useCallback( + async (formdata: Partial) => { + if (formdata.name !== undefined) { + await handleUpdate({ + description_html: formdata.description_html ?? "

", + name: formdata.name, + }); + } else { + await handleUpdate({ + description_html: formdata.description_html ?? "

", + }); + } + }, + [handleUpdate, workspaceSlug] + ); + + return ( +
+ {/* name */} + ( +