[WEB-3048] feat: added-stickies (#6339)

* feat: added-stickies

* fix: recents empty state fixed

* fix: added border

* Change sort_order field

* fix: remvoved btn

* fix: sticky toolbar

* fix: build

* fix: sticky search

* fix: minor css fix

* fix: issue identifier css handled

* fix: issue type default icon

* fix: added tooltip for color palette and delete

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
Akshita Goyal 2025-01-07 20:30:42 +05:30 committed by GitHub
parent 24cc69fd7b
commit cb045abfe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1621 additions and 100 deletions

View file

@ -2,3 +2,4 @@ export * from "./embeds";
export * from "./lite-text-editor";
export * from "./pdf";
export * from "./rich-text-editor";
export * from "./sticky-editor";

View file

@ -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<TSticky>) => Promise<void>;
};
export const ColorPalette = (props: TProps) => {
const { handleUpdate } = props;
return (
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
<div className="flex flex-wrap gap-2">
{STICKY_COLORS.map((color, index) => (
<button
key={index}
type="button"
onClick={() => handleUpdate({ color })}
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
);
};

View file

@ -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<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
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<string>;
parentClassName?: string;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => Promise<void>;
}
export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperProps>((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<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
return !!ref && typeof ref === "object" && "current" in ref;
}
// derived values
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
return (
<div
className={cn("relative border border-custom-border-200 rounded p-3", parentClassName)}
onFocus={() => !showToolbarInitially && setIsFocused(true)}
onBlur={() => !showToolbarInitially && setIsFocused(false)}
>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...disabledExtensions, "enter-key"]}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,
workspaceSlug,
})}
mentionHandler={{
renderComponent: () => <></>,
}}
placeholder={placeholder}
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
<div
className={cn(
"transition-all duration-300 ease-out origin-top",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<Toolbar
executeCommand={(item) => {
// 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}
/>
</div>
</div>
);
});
StickyEditor.displayName = "StickyEditor";

View file

@ -0,0 +1,2 @@
export * from "./editor";
export * from "./toolbar";

View file

@ -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<TSticky>) => Promise<void>;
handleDelete: () => void;
};
const toolbarItems = TOOLBAR_ITEMS.sticky;
export const Toolbar: React.FC<Props> = (props) => {
const { executeCommand, editorRef, handleColorChange, handleDelete } = props;
// State to manage active states of toolbar items
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
const [showColorPalette, setShowColorPalette] = useState(false);
const colorPaletteRef = React.useRef<HTMLDivElement>(null);
// Function to update active states
const updateActiveStates = useCallback(() => {
if (!editorRef) return;
const newActiveStates: Record<string, boolean> = {};
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 (
<div className="flex w-full justify-between mt-2 h-full">
<div className="flex my-auto gap-4" ref={colorPaletteRef}>
{/* color palette */}
{showColorPalette && <ColorPalette handleUpdate={handleColorChange} />}
<Tooltip
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">Background color</span>
</p>
}
>
<button onClick={() => setShowColorPalette(!showColorPalette)} className="flex text-custom-text-300">
<Palette className="size-4 my-auto" />
</button>
</Tooltip>
<div className="flex w-fit items-stretch justify-between gap-4 rounded p-1 my-auto">
<div className="flex items-stretch my-auto gap-4">
{Object.keys(toolbarItems).map((key) => (
<div key={key} className={cn("flex items-stretch gap-4", {})}>
{toolbarItems[key].map((item) => {
const isItemActive = activeStates[item.renderKey];
return (
<Tooltip
key={item.renderKey}
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">{item.name}</span>
{item.shortcut && <kbd className="text-custom-text-400">{item.shortcut.join(" + ")}</kbd>}
</p>
}
>
<button
type="button"
onClick={() => executeCommand(item)}
className={cn(
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-300",
{}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"font-extrabold": isItemActive,
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
);
})}
</div>
))}
</div>
</div>
</div>
{/* delete action */}
<Tooltip
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">Delete</span>
</p>
}
>
<button onClick={handleDelete} className="my-auto text-custom-text-300">
<Trash2 className="size-4" />
</button>
</Tooltip>
</div>
);
};

View file

@ -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)
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => {
const WidgetComponent = WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null;
return (
<div key={key} className="lg:col-span-2">
<div key={key} className="py-4">
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
</div>
);
else return <WidgetComponent key={key} workspaceSlug={workspaceSlug.toString()} />;
})}
})}
</div>
</div>
);
});

View file

@ -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(() => {
<TourRoot onComplete={handleTourCompleted} />
</div>
)}
{joinedProjectIds && (
<>
{joinedProjectIds.length > 0 || loader ? (
<>
<IssuePeekOverview />
<ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})}
>
{currentUser && (
<UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />
)}
<>
<IssuePeekOverview />
<ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})}
>
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
<DashboardWidgets />
</ContentWrapper>
</>
) : (
<EmptyState
type={EmptyStateType.WORKSPACE_DASHBOARD}
primaryButtonOnClick={() => {
setTrackElement("Dashboard empty state");
toggleCreateProjectModal(true);
}}
/>
)}
</>
)}
<DashboardWidgets />
</ContentWrapper>
</>
</>
);
});

View file

@ -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 (
<div className="text-center space-y-6 flex flex-col items-center justify-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Assigned issues" />
</div>
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">No activity to display</p>
</div>
);
};

View file

@ -31,7 +31,7 @@ export const DashboardQuickLinks = observer((props: THomeWidgetProps) => {
preloadedData={linkData}
setLinkData={setLinkData}
/>
<div className="flex mx-auto flex-wrap border-b border-custom-border-100 pb-4 w-full justify-center">
<div className="flex mx-auto flex-wrap pb-4 w-full justify-center">
{/* rendering links */}
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
</div>

View file

@ -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<TProps> = observer((props) => {
<div className="p-4">
<div className="font-medium text-xl">Manage widgets</div>
<WidgetList workspaceSlug={workspaceSlug} />
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="md" onClick={handleOnClose}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit">
Save changes
</Button>
</div>
</div>
</ModalCore>
);

View file

@ -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<THomeWidgetProps> = observer((props)
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(filters[0].name);
// ref
const ref = useRef<HTMLDivElement>(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<THomeWidgetProps> = observer((props)
}
};
if (!isLoading && recents?.length === 0) return <EmptyWorkspace />;
if (!loader && joinedProjectIds?.length === 0) return <EmptyWorkspace />;
if (!isLoading && recents?.length === 0)
return (
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
<div className="flex items-center justify-between mb-2">
<div className="text-base font-semibold text-custom-text-350">Recents</div>
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
</div>
<div className="min-h-[400px] flex flex-col items-center justify-center">
<IssuesEmptyState />
</div>
</div>
);
return (
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
<div ref={ref} className=" max-h-[500px] min-h-[400px] overflow-y-scroll">
<div className="flex items-center justify-between mb-2">
<div className="text-base font-semibold text-custom-text-350 hover:underline">Recents</div>
<div className="text-base font-semibold text-custom-text-350">Recents</div>
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
</div>
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading &&
recents?.length > 0 &&
recents.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
<div className="min-h-[400px] flex flex-col">
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading &&
recents?.length > 0 &&
recents.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
</div>
</div>
);
});

View file

@ -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={
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
<IssueIdentifier
issueTypeId={issueDetails?.type}
projectId={issueDetails?.project_id || ""}
projectIdentifier={issueDetails?.project_identifier || ""}
issueSequenceId={issueDetails?.sequence_id || ""}
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
/>
{issueDetails.type ? (
<IssueIdentifier
size="lg"
issueTypeId={issueDetails?.type}
projectId={issueDetails?.project_id || ""}
projectIdentifier={issueDetails?.project_identifier || ""}
issueSequenceId={issueDetails?.sequence_id || ""}
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
/>
) : (
<div className="flex gap-2 items-center justify-center">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
<LayersIcon className="w-4 h-4 text-custom-text-350" />
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{issueDetails?.project_identifier}-{issueDetails?.sequence_id}
</div>
</div>
)}
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{issueDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
</div>

View file

@ -28,17 +28,19 @@ export const RecentPage = (props: BlockProps) => {
title={""}
prependTitleElement={
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
<>
{pageDetails?.logo_props?.in_use ? (
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
) : (
<FileText className="h-4 w-4 text-custom-text-300" />
)}
</>
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{pageDetails?.project_identifier}
<div className="flex gap-2 items-center justify-center">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-2 bg-custom-background-80 w-[25.5px] h-[25.5px]">
<>
{pageDetails?.logo_props?.in_use ? (
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
) : (
<FileText className="h-4 w-4 text-custom-text-350" />
)}
</>
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{pageDetails?.project_identifier}
</div>
</div>
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{pageDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>

View file

@ -24,11 +24,13 @@ export const RecentProject = (props: BlockProps) => {
title={""}
prependTitleElement={
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
<Logo logo={projectDetails?.logo_props} size={16} />
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{projectDetails?.identifier}
<div className="flex gap-2 items-center justify-center">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
<Logo logo={projectDetails?.logo_props} size={16} />
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{projectDetails?.identifier}
</div>
</div>
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{projectDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>

View file

@ -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 (
<div
ref={ref}
className="sticky-action-bar__item flex flex-col bg-custom-background-90 rounded-full p-[2px] border-2 border-custom-primary-100/10 overflow-hidden"
>
<div
className={`flex flex-col gap-2 transition-all duration-300 ease-in-out origin-bottom ${isExpanded ? "scale-y-100 opacity-100 mb-2 " : "scale-y-0 opacity-0 h-0"}`}
>
<Tooltip tooltipContent="All stickies" isMobile={false} position="left">
<button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => toggleAllStickiesModal(true)}
>
<RecentStickyIcon className="size-5 rotate-90 text-custom-text-350" />
</button>
</Tooltip>
{recentStickyId && (
<Tooltip
className="scale-75 -mr-30 translate-x-10"
tooltipContent={
<div className="-m-2 max-h-[150px]">
<StickyNote
className={"w-[290px]"}
workspaceSlug={workspaceSlug.toString()}
stickyId={newSticky ? activeStickyId : recentStickyId || ""}
/>
<div
className="absolute top-0 right-0 h-full w-full"
style={{
background: `linear-gradient(to top, ${stickies[recentStickyId]?.color}, transparent)`,
}}
/>
</div>
}
isMobile={false}
position="left"
disabled={showRecentSticky}
>
<button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => setShowRecentSticky(true)}
style={{ color: stickies[recentStickyId]?.color }}
>
<StickyNoteIcon className={cn("size-5 rotate-90")} color={stickies[recentStickyId]?.color} />
</button>
</Tooltip>
)}
<Tooltip tooltipContent="Add sticky" isMobile={false} position="left">
<button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => {
updateActiveStickyId("");
toggleShowNewSticky(true);
setNewSticky(true);
}}
>
<Plus className="size-5 rotate-90 text-custom-text-350" />
</button>
</Tooltip>
</div>
<button
className={`btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100 transition-transform duration-300 ${isExpanded ? "rotate-180" : ""}`}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<X className="size-5 text-custom-text-350" />
) : (
<StickyIcon className="size-5 rotate-90 text-custom-text-350" />
)}
</button>
<div
className={cn(
"absolute bottom-16 right-0 z-[20]",
"transform transition-all duration-300 ease-in-out",
newSticky || showRecentSticky ? "translate-y-[0%] min-h-[300px]" : "translate-y-[100%] h-0"
)}
>
{(newSticky || (showRecentSticky && recentStickyId)) && (
<StickyNote
className={"w-[290px]"}
onClose={() => (newSticky ? setNewSticky(false) : setShowRecentSticky(false))}
workspaceSlug={workspaceSlug.toString()}
stickyId={newSticky ? activeStickyId : recentStickyId || ""}
/>
)}
</div>
<AllStickiesModal isOpen={allStickiesModal} handleClose={() => toggleAllStickiesModal(false)} />
</div>
);
});

View file

@ -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 (
<div className="flex justify-center h-[500px]">
<div className="m-auto">
<div
className={`mb-4 rounded-full mx-auto last:rounded-full w-[98px] h-[98px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
>
<StickyIcon className="size-[60px] rotate-90 text-custom-text-350/20" />
</div>
<div className="text-custom-text-100 font-medium text-lg text-center">No stickies yet</div>
<div className="text-custom-text-300 text-sm text-center my-2">
All your stickies in this workspace will appear here.
</div>
<button
onClick={handleCreate}
className="mx-auto flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky}
>
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
{creatingSticky && (
<div className="flex items-center justify-center ml-2">
<div
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
role="status"
aria-label="loading"
/>
</div>
)}
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,2 @@
export * from "./action-bar";
export * from "./widget";

View file

@ -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 (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VXL}>
<Stickies handleClose={handleClose} />
</ModalCore>
);
};

View file

@ -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<HTMLInputElement>(null);
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
}
};
return (
<div className="flex items-center mr-2">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-1 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className=" size-4 " />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-30 md:w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search by title"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
});

View file

@ -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 (
<div className="p-6 pb-0">
{/* header */}
<div className="flex items-center justify-between mb-6">
{/* Title */}
<div className="text-custom-text-100 flex gap-2">
<RecentStickyIcon className="size-5 rotate-90" />
<p className="text-lg font-medium">My Stickies</p>
</div>
{/* actions */}
<div className="flex gap-2">
<StickySearch />
<button
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] });
}}
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky}
>
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
{creatingSticky && (
<div className="flex items-center justify-center ml-2">
<div
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
role="status"
aria-label="loading"
/>
</div>
)}
</button>
{handleClose && (
<button
onClick={handleClose}
className="flex-shrink-0 grid place-items-center text-custom-text-300 hover:text-custom-text-100 hover:bg-custom-background-80 rounded p-1 transition-colors my-auto"
>
<X className="text-custom-text-400 size-4" />
</button>
)}
</div>
</div>
{/* content */}
<div className="mb-4 max-h-[625px] overflow-scroll">
<StickiesLayout />
</div>
</div>
);
});

View file

@ -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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// states
const [containerHeight, setContainerHeight] = useState(0);
const [showAllStickies, setShowAllStickies] = useState(false);
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(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 (
<div className="min-h-[500px] overflow-scroll pb-2">
<Loader>
<Loader.Item height="300px" width="255px" />
</Loader>
</div>
);
}
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) => (
<div key={stickyId} className={cn("flex min-h-[300px] box-border p-2")} style={{ width: itemWidth }}>
{index === stickyIds.length - 1 && currentPage + 1 < totalPages ? (
<div ref={setIntersectionElement} className="flex w-full rounded min-h-[300px]">
<Loader className="w-full h-full">
<Loader.Item height="100%" width="100%" />
</Loader>
</div>
) : (
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug.toString()} stickyId={stickyId} />
)}
</div>
));
if (!fetchingWorkspaceStickies && workspaceStickies.length === 0)
return (
<EmptyState
creatingSticky={creatingSticky}
handleCreate={() => {
toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] });
}}
/>
);
return (
<div
ref={containerRef}
className={cn("relative max-h-[625px] overflow-hidden pb-2 box-border", {
"max-h-full overflow-scroll": showAllStickies,
})}
>
<div className="h-full w-full" ref={masonryRef}>
{/* @ts-expect-error type mismatch here */}
<Masonry elementType="div">{childElements}</Masonry>
</div>
{containerHeight > 632.9 && (
<div className="absolute bottom-0 left-0 bg-gradient-to-t from-custom-background-100 to-transparent w-full h-[100px] text-center text-sm font-medium text-custom-primary-100">
<button
className="flex flex-col items-center justify-end gap-1 h-full m-auto w-full"
onClick={() => setShowAllStickies((state) => !state)}
>
{showAllStickies ? "Show less" : "Show all"}
</button>
</div>
)}
</div>
);
});
export const StickiesLayout = () => {
// states
const [containerWidth, setContainerWidth] = useState<number | null>(null);
// refs
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref}>
<StickyAll columnCount={columnCount} />
</div>
);
};

View file

@ -0,0 +1 @@
export * from "./root";

View file

@ -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<TSticky>) => Promise<void>>;
stickyId: string | undefined;
handleChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => Promise<void>;
};
export const StickyInput = (props: TProps) => {
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props;
//refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// form info
const { handleSubmit, reset, control } = useForm<TSticky>({
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 === "" ? "<p></p>" : stickyData?.description_html,
name: stickyData?.name,
});
}, [stickyData, reset]);
const handleFormSubmit = useCallback(
async (formdata: Partial<TSticky>) => {
if (formdata.name !== undefined) {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
name: formdata.name,
});
} else {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
});
}
},
[handleUpdate, workspaceSlug]
);
return (
<div className="flex-1">
{/* name */}
<Controller
name="name"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
value={value}
id="name"
name="name"
onChange={(e) => {
onChange(e.target.value);
handleSubmit(handleFormSubmit)();
}}
placeholder="Title"
className="text-lg font-medium text-[#455068] mb-2 w-full p-0 border-none min-h-[22px]"
/>
)}
/>
{/* description */}
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<StickyEditor
id={`description-${stickyId}`}
initialValue={stickyData?.description_html ?? ""}
value={null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
onChange={(_description: object, description_html: string) => {
onChange(description_html);
handleSubmit(handleFormSubmit)();
}}
placeholder={"Click to type here"}
containerClassName={"px-0 text-base min-h-[200px] w-full text-[#455068]"}
uploadFile={async () => ""}
showToolbar={false}
parentClassName={"border-none p-0"}
handleDelete={handleDelete}
handleColorChange={handleChange}
ref={editorRef}
/>
)}
/>
</div>
);
};

View file

@ -0,0 +1,72 @@
import { useCallback } from "react";
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { Minimize2 } from "lucide-react";
import { TSticky } from "@plane/types";
import { cn } from "@plane/utils";
import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
import { StickyInput } from "./inputs";
import { useStickyOperations } from "./use-operations";
type TProps = {
onClose?: () => void;
workspaceSlug: string;
className?: string;
stickyId: string | undefined;
};
export const StickyNote = observer((props: TProps) => {
const { onClose, workspaceSlug, className = "", stickyId } = props;
// hooks
const { stickyOperations } = useStickyOperations({ workspaceSlug });
const { stickies } = useSticky();
// derived values
const stickyData: TSticky | undefined = stickyId ? stickies[stickyId] : undefined;
const handleChange = useCallback(
async (payload: Partial<TSticky>) => {
stickyId
? await stickyOperations.update(stickyId, payload)
: await stickyOperations.create({
color: payload.color || STICKY_COLORS[0],
...payload,
});
},
[stickyId, stickyOperations]
);
const debouncedFormSave = useCallback(
debounce(async (payload: Partial<TSticky>) => {
await handleChange(payload);
}, 500),
[stickyOperations, stickyData, handleChange]
);
const handleDelete = async () => {
if (!stickyId) return;
onClose?.();
stickyOperations.remove(stickyId);
};
return (
<div
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)}
style={{ backgroundColor: stickyData?.color || STICKY_COLORS[0] }}
>
{onClose && (
<button className="flex w-full" onClick={onClose}>
<Minimize2 className="size-4 m-auto mr-0" />
</button>
)}
{/* inputs */}
<StickyInput
stickyData={stickyData}
workspaceSlug={workspaceSlug}
handleUpdate={debouncedFormSave}
stickyId={stickyId}
handleDelete={handleDelete}
handleChange={handleChange}
/>
</div>
);
});

View file

@ -0,0 +1,92 @@
import { useMemo } from "react";
import { TSticky } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { useSticky } from "@/hooks/use-stickies";
export type TOperations = {
create: (data: Partial<TSticky>) => Promise<void>;
update: (stickyId: string, data: Partial<TSticky>) => Promise<void>;
remove: (stickyId: string) => Promise<void>;
};
type TProps = {
workspaceSlug: string;
};
export const useStickyOperations = (props: TProps) => {
const { workspaceSlug } = props;
const { createSticky, updateSticky, deleteSticky } = useSticky();
const isValid = (data: Partial<TSticky>) => {
if (data.name && data.name.length > 100) {
setToast({
message: "The sticky name cannot be longer than 100 characters",
type: TOAST_TYPE.ERROR,
title: "Sticky not updated",
});
return false;
}
return true;
};
const stickyOperations: TOperations = useMemo(
() => ({
create: async (data: Partial<TSticky>) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
if (!isValid(data)) return;
await createSticky(workspaceSlug, data);
setToast({
message: "The sticky has been successfully created",
type: TOAST_TYPE.SUCCESS,
title: "Sticky created",
});
} catch (error: any) {
setToast({
message: error?.data?.error ?? "The sticky could not be created",
type: TOAST_TYPE.ERROR,
title: "Sticky not created",
});
throw error;
}
},
update: async (stickyId: string, data: Partial<TSticky>) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
if (!isValid(data)) return;
await updateSticky(workspaceSlug, stickyId, data);
} catch (error) {
setToast({
message: "The sticky could not be updated",
type: TOAST_TYPE.ERROR,
title: "Sticky not updated",
});
throw error;
}
},
remove: async (stickyId: string) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
await deleteSticky(workspaceSlug, stickyId);
setToast({
message: "The sticky has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Sticky removed",
});
} catch (error) {
setToast({
message: "The sticky could not be removed",
type: TOAST_TYPE.ERROR,
title: "Sticky not removed",
});
throw error;
}
},
}),
[workspaceSlug]
);
return {
stickyOperations,
};
};

View file

@ -0,0 +1,46 @@
import { useParams } from "next/navigation";
import { Plus } from "lucide-react";
import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../editor/sticky-editor/color-pallete";
import { StickySearch } from "./modal/search";
import { StickiesLayout } from "./stickies-layout";
import { useStickyOperations } from "./sticky/use-operations";
export const StickiesWidget = () => {
const { workspaceSlug } = useParams();
const { creatingSticky, toggleShowNewSticky } = useSticky();
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="text-base font-semibold text-custom-text-350">My Stickies </div>
{/* actions */}
<div className="flex gap-2">
<StickySearch />
<button
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] });
}}
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky}
>
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
{creatingSticky && (
<div className="flex items-center justify-center ml-2">
<div
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
role="status"
aria-label="loading"
/>
</div>
)}
</button>
</div>
</div>
<div className="-mx-2">
<StickiesLayout />
</div>
</div>
);
};

View file

@ -30,7 +30,7 @@ import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
// helpers
import { convertRemToPixel } from "@/helpers/common.helper";
type TEditorTypes = "lite" | "document";
type TEditorTypes = "lite" | "document" | "sticky";
// Utility type to enforce the necessary extra props or make extraProps optional
type ExtraPropsForCommand<T extends TEditorCommands> = T extends keyof TCommandExtraProps
@ -184,6 +184,10 @@ export const TOOLBAR_ITEMS: {
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")),
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")),
},
sticky: {
basic: BASIC_MARK_ITEMS.filter((item) => ["Bold", "Italic"].includes(item.name)),
list: LIST_ITEMS.filter((item) => ["To-do list"].includes(item.name)),
},
};
export const EDITOR_FONT_STYLES: {

View file

@ -0,0 +1,11 @@
import { useContext } from "react";
// context
import { StoreContext } from "@/lib/store-context";
import { IStickyStore } from "@/store/sticky/sticky.store";
// plane web stores
export const useSticky = (): IStickyStore => {
const context = useContext(StoreContext);
if (context === undefined) throw new Error("useSticky must be used within StoreProvider");
return context.stickyStore;
};

View file

@ -0,0 +1,60 @@
// helpers
import { TSticky } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class StickyService extends APIService {
constructor() {
super(API_BASE_URL);
}
async createSticky(workspaceSlug: string, payload: Partial<TSticky>) {
return this.post(`/api/workspaces/${workspaceSlug}/stickies/`, payload)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async getStickies(
workspaceSlug: string,
cursor?: string,
per_page?: number
): Promise<{ results: TSticky[]; total_pages: number }> {
return this.get(`/api/workspaces/${workspaceSlug}/stickies/`, {
params: {
cursor: cursor || `5:0:0`,
per_page: per_page || 5,
},
})
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async getSticky(workspaceSlug: string, id: string) {
return this.get(`/api/workspaces/${workspaceSlug}/stickies/${id}`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async updateSticky(workspaceSlug: string, id: string, data: Partial<TSticky>) {
return await this.patch(`/api/workspaces/${workspaceSlug}/stickies/${id}/`, data)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async deleteSticky(workspaceSlug: string, id: string) {
return await this.delete(`/api/workspaces/${workspaceSlug}/stickies/${id}`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
}

View file

@ -23,6 +23,7 @@ export interface IBaseCommandPaletteStore {
isDeleteIssueModalOpen: boolean;
isBulkDeleteIssueModalOpen: boolean;
createIssueStoreType: TCreateModalStoreTypes;
allStickiesModal: boolean;
// toggle actions
toggleCommandPaletteModal: (value?: boolean) => void;
toggleShortcutModal: (value?: boolean) => void;
@ -34,6 +35,7 @@ export interface IBaseCommandPaletteStore {
toggleCreateModuleModal: (value?: boolean) => void;
toggleDeleteIssueModal: (value?: boolean) => void;
toggleBulkDeleteIssueModal: (value?: boolean) => void;
toggleAllStickiesModal: (value?: boolean) => void;
}
export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStore {
@ -49,6 +51,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
isBulkDeleteIssueModalOpen: boolean = false;
createPageModal: TCreatePageModal = DEFAULT_CREATE_PAGE_MODAL_DATA;
createIssueStoreType: TCreateModalStoreTypes = EIssuesStoreType.PROJECT;
allStickiesModal: boolean = false;
constructor() {
makeObservable(this, {
@ -64,6 +67,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
isBulkDeleteIssueModalOpen: observable.ref,
createPageModal: observable,
createIssueStoreType: observable,
allStickiesModal: observable,
// projectPages: computed,
// toggle actions
toggleCommandPaletteModal: action,
@ -76,6 +80,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
toggleCreateModuleModal: action,
toggleDeleteIssueModal: action,
toggleBulkDeleteIssueModal: action,
toggleAllStickiesModal: action,
});
}
@ -86,14 +91,15 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
protected getCoreModalsState(): boolean {
return Boolean(
this.isCreateIssueModalOpen ||
this.isCreateCycleModalOpen ||
this.isCreateProjectModalOpen ||
this.isCreateModuleModalOpen ||
this.isCreateViewModalOpen ||
this.isShortcutModalOpen ||
this.isBulkDeleteIssueModalOpen ||
this.isDeleteIssueModalOpen ||
this.createPageModal.isOpen
this.isCreateCycleModalOpen ||
this.isCreateProjectModalOpen ||
this.isCreateModuleModalOpen ||
this.isCreateViewModalOpen ||
this.isShortcutModalOpen ||
this.isBulkDeleteIssueModalOpen ||
this.isDeleteIssueModalOpen ||
this.createPageModal.isOpen ||
this.allStickiesModal
);
}
@ -235,4 +241,17 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
this.isBulkDeleteIssueModalOpen = !this.isBulkDeleteIssueModalOpen;
}
};
/**
* Toggles the all stickies modal
* @param value
* @returns
*/
toggleAllStickiesModal = (value?: boolean) => {
if (value) {
this.allStickiesModal = value;
} else {
this.allStickiesModal = !this.allStickiesModal;
}
};
}

View file

@ -25,6 +25,7 @@ import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store"
import { IProjectRootStore, ProjectRootStore } from "./project";
import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
import { RouterStore, IRouterStore } from "./router.store";
import { IStickyStore, StickyStore } from "./sticky/sticky.store";
import { ThemeStore, IThemeStore } from "./theme.store";
import { ITransientStore, TransientStore } from "./transient.store";
import { IUserStore, UserStore } from "./user";
@ -59,6 +60,7 @@ export class CoreRootStore {
workspaceNotification: IWorkspaceNotificationStore;
favorite: IFavoriteStore;
transient: ITransientStore;
stickyStore: IStickyStore;
constructor() {
this.router = new RouterStore();
@ -87,6 +89,7 @@ export class CoreRootStore {
this.workspaceNotification = new WorkspaceNotificationStore(this);
this.favorite = new FavoriteStore(this);
this.transient = new TransientStore();
this.stickyStore = new StickyStore();
}
resetOnSignOut() {
@ -118,5 +121,6 @@ export class CoreRootStore {
this.workspaceNotification = new WorkspaceNotificationStore(this);
this.favorite = new FavoriteStore(this);
this.transient = new TransientStore();
this.stickyStore = new StickyStore();
}
}

View file

@ -0,0 +1,170 @@
import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils";
import { TSticky } from "@plane/types";
import { StickyService } from "@/services/sticky.service";
export interface IStickyStore {
creatingSticky: boolean;
fetchingWorkspaceStickies: boolean;
workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds
stickies: Record<string, TSticky>; // stickyId -> sticky
searchQuery: string;
activeStickyId: string | undefined;
recentStickyId: string | undefined;
showAddNewSticky: boolean;
currentPage: number;
totalPages: number;
// computed
getWorkspaceStickies: (workspaceSlug: string) => string[];
// actions
toggleShowNewSticky: (value: boolean) => void;
updateSearchQuery: (query: string) => void;
fetchWorkspaceStickies: (workspaceSlug: string, cursor?: string, per_page?: number) => void;
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => void;
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => void;
deleteSticky: (workspaceSlug: string, id: string) => void;
updateActiveStickyId: (id: string | undefined) => void;
fetchRecentSticky: (workspaceSlug: string) => void;
incrementPage: () => void;
}
export class StickyStore implements IStickyStore {
creatingSticky = false;
fetchingWorkspaceStickies = true;
workspaceStickies: Record<string, string[]> = {};
stickies: Record<string, TSticky> = {};
recentStickyId: string | undefined = undefined;
searchQuery = "";
activeStickyId: string | undefined = undefined;
showAddNewSticky = false;
currentPage = 0;
totalPages = 0;
// services
stickyService;
constructor() {
makeObservable(this, {
// observables
creatingSticky: observable,
fetchingWorkspaceStickies: observable,
activeStickyId: observable,
showAddNewSticky: observable,
recentStickyId: observable,
workspaceStickies: observable,
stickies: observable,
searchQuery: observable,
currentPage: observable,
totalPages: observable,
// actions
updateSearchQuery: action,
updateSticky: action,
deleteSticky: action,
incrementPage: action,
});
this.stickyService = new StickyService();
}
getWorkspaceStickies = computedFn((workspaceSlug: string) => {
let filteredStickies = (this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]);
if (this.searchQuery) {
filteredStickies = filteredStickies.filter(
(sticky) => sticky.name && sticky.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
return filteredStickies.map((sticky) => sticky.id);
});
toggleShowNewSticky = (value: boolean) => {
this.showAddNewSticky = value;
};
updateSearchQuery = (query: string) => {
this.searchQuery = query;
};
updateActiveStickyId = (id: string | undefined) => {
this.activeStickyId = id;
};
incrementPage = () => {
this.currentPage += 1;
};
fetchRecentSticky = async (workspaceSlug: string) => {
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", 1);
runInAction(() => {
this.recentStickyId = response.results[0]?.id;
this.stickies[response.results[0]?.id] = response.results[0];
});
};
fetchWorkspaceStickies = async (workspaceSlug: string, cursor?: string, per_page?: number) => {
try {
const response = await this.stickyService.getStickies(workspaceSlug, cursor, per_page);
runInAction(() => {
response.results.forEach((sticky) => {
if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) {
this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id];
}
this.stickies[sticky.id] = sticky;
});
this.totalPages = response.total_pages;
this.fetchingWorkspaceStickies = false;
});
} catch (e) {
console.error(e);
this.fetchingWorkspaceStickies = false;
}
};
createSticky = async (workspaceSlug: string, sticky: Partial<TSticky>) => {
if (!this.showAddNewSticky) return;
this.showAddNewSticky = false;
this.creatingSticky = true;
const workspaceStickies = this.workspaceStickies[workspaceSlug] || [];
const response = await this.stickyService.createSticky(workspaceSlug, sticky);
runInAction(() => {
this.stickies[response.id] = response;
this.workspaceStickies[workspaceSlug] = [response.id, ...workspaceStickies];
this.activeStickyId = response.id;
this.recentStickyId = response.id;
this.creatingSticky = false;
});
};
updateSticky = async (workspaceSlug: string, id: string, updates: Partial<TSticky>) => {
const sticky = this.stickies[id];
if (!sticky) return;
try {
this.stickies[id] = {
...sticky,
...updates,
updatedAt: new Date(),
};
this.recentStickyId = id;
await this.stickyService.updateSticky(workspaceSlug, id, updates);
} catch (e) {
console.log(e);
this.stickies[id] = sticky;
}
};
deleteSticky = async (workspaceSlug: string, id: string) => {
const sticky = this.stickies[id];
if (!sticky) return;
try {
this.workspaceStickies[workspaceSlug] = this.workspaceStickies[workspaceSlug].filter(
(stickyId) => stickyId !== id
);
if (this.activeStickyId === id) this.activeStickyId = undefined;
delete this.stickies[id];
this.recentStickyId = this.workspaceStickies[workspaceSlug][0];
await this.stickyService.deleteSticky(workspaceSlug, id);
} catch (e) {
console.log(e);
this.stickies[id] = sticky;
}
};
}