[WEB-3096] feat: stickies page (#6380)

* feat: added independent stickies page

* chore: randomized sticky color

* chore: search in stickies

* feat: dnd

* fix: quick links

* fix: stickies abrupt rendering

* fix: handled edge cases for dnd

* fix: empty states

* fix: build and lint

* fix: handled new sticky when last sticky is emoty

* fix: new sticky condition

* refactor: stickies empty states, store

* chore: update stickies empty states

* fix: random sticky color

* fix: header

* refactor: better error handling

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Akshita Goyal 2025-01-16 19:57:51 +05:30 committed by GitHub
parent d2c9b437f4
commit fd7eedc343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1347 additions and 574 deletions

View file

@ -39,9 +39,9 @@ class WorkspaceStickyViewSet(BaseViewSet):
) )
def list(self, request, slug): def list(self, request, slug):
query = request.query_params.get("query", False) query = request.query_params.get("query", False)
stickies = self.get_queryset() stickies = self.get_queryset().order_by("-sort_order")
if query: if query:
stickies = stickies.filter(name__icontains=query) stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate( return self.paginate(
request=request, request=request,

View file

@ -5,6 +5,9 @@ from django.db import models
# Module imports # Module imports
from .base import BaseModel from .base import BaseModel
# Third party imports
from plane.utils.html_processor import strip_tags
class Sticky(BaseModel): class Sticky(BaseModel):
name = models.TextField(null=True, blank=True) name = models.TextField(null=True, blank=True)
@ -33,6 +36,12 @@ class Sticky(BaseModel):
ordering = ("-created_at",) ordering = ("-created_at",)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
if self._state.adding: if self._state.adding:
# Get the maximum sequence value from the database # Get the maximum sequence value from the database
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate( last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(

View file

@ -13,3 +13,4 @@ export * from "./state";
export * from "./swr"; export * from "./swr";
export * from "./user"; export * from "./user";
export * from "./workspace"; export * from "./workspace";
export * from "./stickies";

View file

@ -0,0 +1 @@
export const STICKIES_PER_PAGE = 30;

View file

@ -145,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative; position: relative;
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background-color: rgb(var(--color-background-100)); background-color: transparent;
margin: 0; margin: 0;
cursor: pointer; cursor: pointer;
width: 0.8rem; width: 0.8rem;

View file

@ -1,41 +1,3 @@
:root {
/* text colors */
--editor-colors-gray-text: #5c5e63;
--editor-colors-peach-text: #ff5b59;
--editor-colors-pink-text: #f65385;
--editor-colors-orange-text: #fd9038;
--editor-colors-green-text: #0fc27b;
--editor-colors-light-blue-text: #17bee9;
--editor-colors-dark-blue-text: #266df0;
--editor-colors-purple-text: #9162f9;
/* end text colors */
}
/* text background colors */
[data-theme="light"],
[data-theme="light-contrast"] {
--editor-colors-gray-background: #d6d6d8;
--editor-colors-peach-background: #ffd5d7;
--editor-colors-pink-background: #fdd4e3;
--editor-colors-orange-background: #ffe3cd;
--editor-colors-green-background: #c3f0de;
--editor-colors-light-blue-background: #c5eff9;
--editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd;
}
[data-theme="dark"],
[data-theme="dark-contrast"] {
--editor-colors-gray-background: #404144;
--editor-colors-peach-background: #593032;
--editor-colors-pink-background: #562e3d;
--editor-colors-orange-background: #583e2a;
--editor-colors-green-background: #1d4a3b;
--editor-colors-light-blue-background: #1f495c;
--editor-colors-dark-blue-background: #223558;
--editor-colors-purple-background: #3d325a;
}
/* end text background colors */
.editor-container { .editor-container {
/* font sizes and line heights */ /* font sizes and line heights */
&.large-font { &.large-font {

View file

@ -1,8 +1,16 @@
import { TLogoProps } from "./common";
export type TSticky = { export type TSticky = {
created_at?: string | undefined;
created_by?: string | undefined;
background_color?: string | null | undefined;
description?: object | undefined;
description_html?: string | undefined;
id: string; id: string;
logo_props: TLogoProps | undefined;
name?: string; name?: string;
description_html?: string; sort_order: number | undefined;
color?: string; updated_at?: string | undefined;
createdAt?: Date; updated_by?: string | undefined;
updatedAt?: Date; workspace: string | undefined;
}; };

View file

@ -0,0 +1,59 @@
"use client";
import { observer } from "mobx-react";
// ui
import { useParams } from "next/navigation";
import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// hooks
import { StickySearch } from "@/components/stickies/modal/search";
import { useStickyOperations } from "@/components/stickies/sticky/use-operations";
// plane-web
import { useSticky } from "@/hooks/use-stickies";
export const WorkspaceStickyHeader = observer(() => {
const { workspaceSlug } = useParams();
// hooks
const { creatingSticky, toggleShowNewSticky } = useSticky();
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return (
<>
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={`Stickies`}
icon={<RecentStickyIcon className="size-5 rotate-90 text-custom-text-200" />}
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<StickySearch />
<Button
variant="primary"
size="sm"
className="items-center gap-1"
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create();
}}
loading={creatingSticky}
>
Add sticky
</Button>
</Header.RightItem>
</Header>
</>
);
});

View file

@ -0,0 +1,13 @@
"use client";
import { AppHeader, ContentWrapper } from "@/components/core";
import { WorkspaceStickyHeader } from "./header";
export default function WorkspaceStickiesLayout({ children }: { children: React.ReactNode }) {
return (
<>
<AppHeader header={<WorkspaceStickyHeader />} />
<ContentWrapper>{children}</ContentWrapper>
</>
);
}

View file

@ -0,0 +1,16 @@
"use client";
// components
import { PageHead } from "@/components/core";
import { StickiesInfinite } from "@/components/stickies";
export default function WorkspaceStickiesPage() {
return (
<>
<PageHead title="Your stickies" />
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
<StickiesInfinite />
</div>
</>
);
}

View file

@ -9,6 +9,7 @@ interface IContentOverflowWrapper {
buttonClassName?: string; buttonClassName?: string;
containerClassName?: string; containerClassName?: string;
fallback?: ReactNode; fallback?: ReactNode;
customButton?: ReactNode;
} }
export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => { export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => {
@ -18,6 +19,7 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
buttonClassName = "text-sm font-medium text-custom-primary-100", buttonClassName = "text-sm font-medium text-custom-primary-100",
containerClassName, containerClassName,
fallback = null, fallback = null,
customButton,
} = props; } = props;
// states // states
@ -131,6 +133,7 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
pointerEvents: isTransitioning ? "none" : "auto", pointerEvents: isTransitioning ? "none" : "auto",
}} }}
> >
{customButton || (
<button <button
className={cn( className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300", "gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
@ -141,6 +144,7 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
> >
{showAll ? "Show less" : "Show all"} {showAll ? "Show less" : "Show all"}
</button> </button>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -0,0 +1,78 @@
import { TSticky } from "@plane/types";
export const STICKY_COLORS_LIST: {
key: string;
label: string;
backgroundColor: string;
}[] = [
{
key: "gray",
label: "Gray",
backgroundColor: "var(--editor-colors-gray-background)",
},
{
key: "peach",
label: "Peach",
backgroundColor: "var(--editor-colors-peach-background)",
},
{
key: "pink",
label: "Pink",
backgroundColor: "var(--editor-colors-pink-background)",
},
{
key: "orange",
label: "Orange",
backgroundColor: "var(--editor-colors-orange-background)",
},
{
key: "green",
label: "Green",
backgroundColor: "var(--editor-colors-green-background)",
},
{
key: "light-blue",
label: "Light blue",
backgroundColor: "var(--editor-colors-light-blue-background)",
},
{
key: "dark-blue",
label: "Dark blue",
backgroundColor: "var(--editor-colors-dark-blue-background)",
},
{
key: "purple",
label: "Purple",
backgroundColor: "var(--editor-colors-purple-background)",
},
];
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_LIST.map((color) => (
<button
key={color.key}
type="button"
onClick={() => {
handleUpdate({
background_color: color.key,
});
}}
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.backgroundColor,
}}
/>
))}
</div>
</div>
);
};

View file

@ -1,36 +0,0 @@
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

@ -82,6 +82,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
containerClassName={cn(containerClassName, "relative")} containerClassName={cn(containerClassName, "relative")}
{...rest} {...rest}
/> />
{showToolbar && (
<div <div
className={cn( className={cn(
"transition-all duration-300 ease-out origin-top", "transition-all duration-300 ease-out origin-top",
@ -102,6 +103,7 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
editorRef={editorRef} editorRef={editorRef}
/> />
</div> </div>
)}
</div> </div>
); );
}); });

View file

@ -12,7 +12,7 @@ import { Tooltip } from "@plane/ui";
import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor"; import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { ColorPalette } from "./color-pallete"; import { ColorPalette } from "./color-palette";
type Props = { type Props = {
executeCommand: (item: ToolbarMenuItem) => void; executeCommand: (item: ToolbarMenuItem) => void;

View file

@ -8,7 +8,7 @@ import Link from "next/link";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
// hooks // hooks
// components // components
import { Button, TButtonVariant } from "@plane/ui"; import { Button, TButtonSizes, TButtonVariant } from "@plane/ui";
// constant // constant
import { EMPTY_STATE_DETAILS, EmptyStateType } from "@/constants/empty-state"; import { EMPTY_STATE_DETAILS, EmptyStateType } from "@/constants/empty-state";
// helpers // helpers
@ -18,10 +18,14 @@ import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
import { ComicBoxButton } from "./comic-box-button"; import { ComicBoxButton } from "./comic-box-button";
export type EmptyStateProps = { export type EmptyStateProps = {
size?: TButtonSizes;
type: EmptyStateType; type: EmptyStateType;
size?: "sm" | "md" | "lg";
layout?: "screen-detailed" | "screen-simple"; layout?: "screen-detailed" | "screen-simple";
additionalPath?: string; additionalPath?: string;
primaryButtonConfig?: {
size?: TButtonSizes;
variant?: TButtonVariant;
};
primaryButtonOnClick?: () => void; primaryButtonOnClick?: () => void;
primaryButtonLink?: string; primaryButtonLink?: string;
secondaryButtonOnClick?: () => void; secondaryButtonOnClick?: () => void;
@ -29,10 +33,14 @@ export type EmptyStateProps = {
export const EmptyState: React.FC<EmptyStateProps> = observer((props) => { export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
const { const {
type,
size = "lg", size = "lg",
type,
layout = "screen-detailed", layout = "screen-detailed",
additionalPath = "", additionalPath = "",
primaryButtonConfig = {
size: "lg",
variant: "primary",
},
primaryButtonOnClick, primaryButtonOnClick,
primaryButtonLink, primaryButtonLink,
secondaryButtonOnClick, secondaryButtonOnClick,
@ -67,8 +75,8 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
if (!primaryButton) return null; if (!primaryButton) return null;
const commonProps = { const commonProps = {
size: size, size: primaryButtonConfig.size,
variant: "primary" as TButtonVariant, variant: primaryButtonConfig.variant,
prependIcon: primaryButton.icon, prependIcon: primaryButton.icon,
onClick: primaryButtonOnClick ? primaryButtonOnClick : undefined, onClick: primaryButtonOnClick ? primaryButtonOnClick : undefined,
disabled: !isEditingAllowed, disabled: !isEditingAllowed,
@ -145,12 +153,10 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
)} )}
{anyButton && ( {anyButton && (
<>
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full"> <div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
{renderPrimaryButton()} {renderPrimaryButton()}
{renderSecondaryButton()} {renderSecondaryButton()}
</div> </div>
</>
)} )}
</div> </div>
</div> </div>
@ -175,6 +181,12 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
) : ( ) : (
<h3 className="text-sm font-medium text-custom-text-400 whitespace-pre-line">{title}</h3> <h3 className="text-sm font-medium text-custom-text-400 whitespace-pre-line">{title}</h3>
)} )}
{anyButton && (
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
{renderPrimaryButton()}
{renderSecondaryButton()}
</div>
)}
</div> </div>
)} )}
</> </>

View file

@ -1,45 +1,74 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// types // plane types
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types"; import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
// components
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks // hooks
import { useHome } from "@/hooks/store/use-home"; import { useHome } from "@/hooks/store/use-home";
// components // plane web components
import { HomePageHeader } from "@/plane-web/components/home/header"; import { HomePageHeader } from "@/plane-web/components/home/header";
import { StickiesWidget } from "../stickies"; import { StickiesWidget } from "../stickies";
import { RecentActivityWidget } from "./widgets"; import { RecentActivityWidget } from "./widgets";
import { DashboardQuickLinks } from "./widgets/links"; import { DashboardQuickLinks } from "./widgets/links";
import { ManageWidgetsModal } from "./widgets/manage"; import { ManageWidgetsModal } from "./widgets/manage";
const WIDGETS_LIST: { export const HOME_WIDGETS_LIST: {
[key in THomeWidgetKeys]: { component: React.FC<THomeWidgetProps> | null; fullWidth: boolean }; [key in THomeWidgetKeys]: {
component: React.FC<THomeWidgetProps> | null;
fullWidth: boolean;
title: string;
};
} = { } = {
quick_links: { component: DashboardQuickLinks, fullWidth: false }, quick_links: {
recents: { component: RecentActivityWidget, fullWidth: false }, component: DashboardQuickLinks,
my_stickies: { component: StickiesWidget, fullWidth: false }, fullWidth: false,
new_at_plane: { component: null, fullWidth: false }, title: "Quick links",
quick_tutorial: { component: null, fullWidth: false }, },
recents: {
component: RecentActivityWidget,
fullWidth: false,
title: "Recents",
},
my_stickies: {
component: StickiesWidget,
fullWidth: false,
title: "Your stickies",
},
new_at_plane: {
component: null,
fullWidth: false,
title: "New at Plane",
},
quick_tutorial: {
component: null,
fullWidth: false,
title: "Quick tutorial",
},
}; };
export const DashboardWidgets = observer(() => { export const DashboardWidgets = observer(() => {
// router // router
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// store hooks // store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets } = useHome(); const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled } = useHome();
if (!workspaceSlug) return null; if (!workspaceSlug) return null;
return ( return (
<div className="relative flex flex-col gap-7"> <div className="h-full w-full relative flex flex-col gap-7">
<HomePageHeader /> <HomePageHeader />
<ManageWidgetsModal <ManageWidgetsModal
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
isModalOpen={showWidgetSettings} isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)} handleOnClose={() => toggleWidgetSettings(false)}
/> />
{isAnyWidgetEnabled ? (
<div className="flex flex-col divide-y-[1px] divide-custom-border-100"> <div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => { {orderedWidgets.map((key) => {
const WidgetComponent = WIDGETS_LIST[key]?.component; const WidgetComponent = HOME_WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled; const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null; if (!WidgetComponent || !isEnabled) return null;
return ( return (
@ -49,6 +78,19 @@ export const DashboardWidgets = observer(() => {
); );
})} })}
</div> </div>
) : (
<div className="h-full w-full grid place-items-center">
<EmptyState
type={EmptyStateType.HOME_WIDGETS}
layout="screen-simple"
primaryButtonOnClick={() => toggleWidgetSettings(true)}
primaryButtonConfig={{
size: "sm",
variant: "neutral-primary",
}}
/>
</div>
)}
</div> </div>
); );
}); });

View file

@ -59,12 +59,11 @@ export const WorkspaceHomeView = observer(() => {
<> <>
<IssuePeekOverview /> <IssuePeekOverview />
<ContentWrapper <ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", { className={cn("gap-6 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768, "vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})} })}
> >
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />} {currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
<DashboardWidgets /> <DashboardWidgets />
</ContentWrapper> </ContentWrapper>
</> </>

View file

@ -1,9 +1,11 @@
import { FC } from "react"; import { FC } from "react";
// hooks
import { Shapes } from "lucide-react"; import { Shapes } from "lucide-react";
// plane types
import { IUser } from "@plane/types"; import { IUser } from "@plane/types";
// plane ui
import { Button } from "@plane/ui";
// hooks
import { useCurrentTime } from "@/hooks/use-current-time"; import { useCurrentTime } from "@/hooks/use-current-time";
// types
export interface IUserGreetingsView { export interface IUserGreetingsView {
user: IUser; user: IUser;
@ -51,13 +53,10 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
</div> </div>
</h6> </h6>
</div> </div>
<button <Button variant="neutral-primary" size="sm" onClick={handleWidgetModal} className="my-auto mb-0">
onClick={handleWidgetModal}
className="flex items-center gap-2 font-medium text-custom-text-300 justify-center border border-custom-border-200 rounded p-2 my-auto mb-0"
>
<Shapes size={16} /> <Shapes size={16} />
<div className="text-xs font-medium">Manage widgets</div> <div className="text-xs font-medium">Manage widgets</div>
</button> </Button>
</div> </div>
); );
}; };

View file

@ -1,27 +1,12 @@
import { Link2, Plus } from "lucide-react"; import { Link2 } from "lucide-react";
import { Button } from "@plane/ui";
type TProps = { export const LinksEmptyState = () => (
handleCreate: () => void; <div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
}; <div className="m-auto flex gap-2">
export const LinksEmptyState = (props: TProps) => { <Link2 size={30} className="text-custom-text-400/40 -rotate-45" />
const { handleCreate } = props; <div className="text-custom-text-400 text-sm text-center my-auto">
return ( Add any links you need for quick access to your work.
<div className="min-h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
<div className="m-auto">
<div
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
>
<Link2 size={30} className="text-custom-text-400 -rotate-45" />
</div> </div>
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No quick links yet</div>
<div className="text-custom-text-300 text-sm text-center mb-2">
Add any links you need for quick access to your work.{" "}
</div>
<Button variant="accent-primary" size="sm" onClick={handleCreate} className="mx-auto">
<Plus className="size-4 my-auto" /> <span>Add quick link</span>
</Button>
</div> </div>
</div> </div>
); );
};

View file

@ -1,15 +1,38 @@
import { History } from "lucide-react"; import { Briefcase, FileText, History } from "lucide-react";
import { LayersIcon } from "@plane/ui";
export const RecentsEmptyState = () => ( export const RecentsEmptyState = ({ type }: { type: string }) => {
<div className="h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded"> const getDisplayContent = () => {
<div className="m-auto"> switch (type) {
<div case "project":
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`} return {
> icon: <Briefcase size={30} className="text-custom-text-400/40" />,
<History size={30} className="text-custom-text-400 -rotate-45" /> text: "Your recent projects will appear here once you visit one.",
</div> };
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No recent items yet</div> case "page":
<div className="text-custom-text-300 text-sm text-center mb-2">You dont have any recent items yet. </div> return {
icon: <FileText size={30} className="text-custom-text-400/40" />,
text: "Your recent pages will appear here once you visit one.",
};
case "issue":
return {
icon: <LayersIcon className="text-custom-text-400/40 w-[30px] h-[30px]" />,
text: "Your recent issues will appear here once you visit one.",
};
default:
return {
icon: <History size={30} className="text-custom-text-400/40" />,
text: "You dont have any recent items yet.",
};
}
};
const { icon, text } = getDisplayContent();
return (
<div className="min-h-[120px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
<div className="m-auto flex gap-2">
{icon} <div className="text-custom-text-400 text-sm text-center my-auto">{text}</div>
</div> </div>
</div> </div>
); );
};

View file

@ -2,17 +2,22 @@ import React from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Briefcase, Hotel, Users } from "lucide-react"; import { Briefcase, Hotel, Users } from "lucide-react";
// helpers
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store"; import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
export const EmptyWorkspace = () => { export const EmptyWorkspace = () => {
// navigation
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// store hooks
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { toggleCreateProjectModal } = useCommandPalette(); const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
// derived values
const canCreateProject = allowPermissions( const canCreateProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER], [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE EUserPermissionsLevel.WORKSPACE
@ -83,6 +88,7 @@ export const EmptyWorkspace = () => {
}, },
}, },
]; ];
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{EMPTY_STATE_DATA.map((item) => ( {EMPTY_STATE_DATA.map((item) => (

View file

@ -0,0 +1,13 @@
// plane ui
import { RecentStickyIcon } from "@plane/ui";
export const StickiesEmptyState = () => (
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
<div className="m-auto flex gap-2">
<RecentStickyIcon className="h-[30px] w-[30px] text-custom-text-400/40" />
<div className="text-custom-text-400 text-sm text-center my-auto">
No stickies yet. Add one to start making quick notes.
</div>
</div>
</div>
);

View file

@ -4,12 +4,11 @@ import { FC } from "react";
// hooks // hooks
// ui // ui
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link, Link2 } from "lucide-react"; import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link2, Link } from "lucide-react";
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui"; import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
// helpers // helpers
import { cn } from "@plane/utils"; import { cn, copyTextToClipboard } from "@plane/utils";
import { calculateTimeAgo } from "@/helpers/date-time.helper"; import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useHome } from "@/hooks/store/use-home"; import { useHome } from "@/hooks/store/use-home";
import { TLinkOperations } from "./use-links"; import { TLinkOperations } from "./use-links";
@ -37,7 +36,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
}; };
const handleCopyText = () => const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => { copyTextToClipboard(viewLink).then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Link Copied!", title: "Link Copied!",
@ -74,7 +73,10 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
]; ];
return ( return (
<div className="group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md"> <div
onClick={handleOpenInNewTab}
className="cursor-pointer group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md"
>
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto"> <div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
<Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" /> <Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" />
</div> </div>

View file

@ -20,14 +20,14 @@ export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
const { linkOperations, workspaceSlug } = props; const { linkOperations, workspaceSlug } = props;
// hooks // hooks
const { const {
quickLinks: { getLinksByWorkspaceId, toggleLinkModal }, quickLinks: { getLinksByWorkspaceId },
} = useHome(); } = useHome();
const links = getLinksByWorkspaceId(workspaceSlug); const links = getLinksByWorkspaceId(workspaceSlug);
if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />; if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
if (links.length === 0) return <LinksEmptyState handleCreate={() => toggleLinkModal(true)} />; if (links.length === 0) return <LinksEmptyState />;
return ( return (
<div className="relative"> <div className="relative">

View file

@ -32,7 +32,6 @@ export const useLinks = (workspaceSlug: string) => {
create: async (data: Partial<TProjectLink>) => { create: async (data: Partial<TProjectLink>) => {
try { try {
if (!workspaceSlug) throw new Error("Missing required fields"); if (!workspaceSlug) throw new Error("Missing required fields");
console.log("data", data, workspaceSlug);
await createLink(workspaceSlug, data); await createLink(workspaceSlug, data);
setToast({ setToast({
message: "The link has been successfully created", message: "The link has been successfully created",

View file

@ -11,18 +11,18 @@ import {
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview"; import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane helpers
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
// ui // plane types
import { InstructionType, TWidgetEntityData } from "@plane/types"; import { InstructionType, TWidgetEntityData } from "@plane/types";
// components // plane ui
import { DropIndicator, ToggleSwitch } from "@plane/ui"; import { DropIndicator, ToggleSwitch } from "@plane/ui";
// helpers // plane utils
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// hooks
import { useHome } from "@/hooks/store/use-home"; import { useHome } from "@/hooks/store/use-home";
import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
import { WidgetItemDragHandle } from "./widget-item-drag-handle"; import { WidgetItemDragHandle } from "./widget-item-drag-handle";
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers"; import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
@ -46,6 +46,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
const { widgetsMap } = useHome(); const { widgetsMap } = useHome();
// derived values // derived values
const widget = widgetsMap[widgetId] as TWidgetEntityData; const widget = widgetsMap[widgetId] as TWidgetEntityData;
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
// drag and drop // drag and drop
useEffect(() => { useEffect(() => {
@ -119,7 +120,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
<div <div
ref={elementRef} ref={elementRef}
className={cn( className={cn(
"px-2 relative flex items-center py-2 font-medium text-sm capitalize group/widget-item rounded hover:bg-custom-background-80 justify-between", "px-2 relative flex items-center py-2 font-medium text-sm group/widget-item rounded hover:bg-custom-background-80 justify-between",
{ {
"cursor-grabbing bg-custom-background-80": isDragging, "cursor-grabbing bg-custom-background-80": isDragging,
} }
@ -127,7 +128,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
> >
<div className="flex items-center"> <div className="flex items-center">
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} /> <WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
<div>{widget.key.replaceAll("_", " ")}</div> <div>{widgetTitle}</div>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={widget.is_enabled} value={widget.is_enabled}

View file

@ -74,7 +74,7 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} /> <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
</div> </div>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<RecentsEmptyState /> <RecentsEmptyState type={filter} />
</div> </div>
</div> </div>
); );

View file

@ -3,25 +3,37 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
import { Plus, StickyNote as StickyIcon, X } from "lucide-react"; import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
// plane ui
import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui"; import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// hooks
import { useCommandPalette } from "@/hooks/store"; import { useCommandPalette } from "@/hooks/store";
import { useSticky } from "@/hooks/use-stickies"; import { useSticky } from "@/hooks/use-stickies";
// components
import { STICKY_COLORS_LIST } from "../editor/sticky-editor/color-palette";
import { AllStickiesModal } from "./modal"; import { AllStickiesModal } from "./modal";
import { StickyNote } from "./sticky"; import { StickyNote } from "./sticky";
export const StickyActionBar = observer(() => { export const StickyActionBar = observer(() => {
const { workspaceSlug } = useParams(); // states
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [newSticky, setNewSticky] = useState(false); const [newSticky, setNewSticky] = useState(false);
const [showRecentSticky, setShowRecentSticky] = useState(false); const [showRecentSticky, setShowRecentSticky] = useState(false);
// navigation
const { workspaceSlug } = useParams();
// refs
const ref = useRef(null); const ref = useRef(null);
// store hooks
// hooks
const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } = const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } =
useSticky(); useSticky();
const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette(); const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette();
// derived values
const recentStickyBackgroundColor = recentStickyId
? STICKY_COLORS_LIST.find((c) => c.key === stickies[recentStickyId].background_color)?.backgroundColor
: STICKY_COLORS_LIST[0].backgroundColor;
useSWR( useSWR(
workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null, workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null,
@ -63,7 +75,7 @@ export const StickyActionBar = observer(() => {
<div <div
className="absolute top-0 right-0 h-full w-full" className="absolute top-0 right-0 h-full w-full"
style={{ style={{
background: `linear-gradient(to top, ${stickies[recentStickyId]?.color}, transparent)`, background: `linear-gradient(to top, ${recentStickyBackgroundColor}, transparent)`,
}} }}
/> />
</div> </div>
@ -75,9 +87,9 @@ export const StickyActionBar = observer(() => {
<button <button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100" className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => setShowRecentSticky(true)} onClick={() => setShowRecentSticky(true)}
style={{ color: stickies[recentStickyId]?.color }} style={{ color: recentStickyBackgroundColor }}
> >
<StickyNoteIcon className={cn("size-5 rotate-90")} color={stickies[recentStickyId]?.color} /> <StickyNoteIcon className={cn("size-5 rotate-90")} color={recentStickyBackgroundColor} />
</button> </button>
</Tooltip> </Tooltip>
)} )}

View file

@ -1,37 +0,0 @@
import { Plus, StickyNote as StickyIcon } from "lucide-react";
import { Button } from "@plane/ui";
type TProps = {
handleCreate: () => void;
creatingSticky?: boolean;
};
export const EmptyState = (props: TProps) => {
const { handleCreate, creatingSticky } = props;
return (
<div className="flex justify-center h-[500px] rounded border-[1.5px] border-custom-border-100 mx-2">
<div className="m-auto">
<div
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
>
<StickyIcon className="size-[30px] rotate-90 text-custom-text-350/20" />
</div>
<div className="text-custom-text-100 font-medium text-lg text-center mb-1">No stickies yet</div>
<div className="text-custom-text-300 text-sm text-center mb-2">
All your stickies in this workspace will appear here.
</div>
<Button size="sm" variant="accent-primary" className="mx-auto" onClick={handleCreate} 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

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

View file

@ -0,0 +1,3 @@
export * from "./stickies-infinite";
export * from "./stickies-list";
export * from "./stickies-truncated";

View file

@ -0,0 +1,62 @@
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { STICKIES_PER_PAGE } from "@plane/constants";
import { ContentWrapper, Loader } from "@plane/ui";
import { cn } from "@plane/utils";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useSticky } from "@/hooks/use-stickies";
import { StickiesLayout } from "./stickies-list";
export const StickiesInfinite = observer(() => {
const { workspaceSlug } = useParams();
// hooks
const { fetchWorkspaceStickies, fetchNextWorkspaceStickies, getWorkspaceStickyIds, loader, paginationInfo } =
useSticky();
//state
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
// ref
const containerRef = useRef<HTMLDivElement>(null);
useSWR(
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const handleLoadMore = () => {
if (loader === "pagination") return;
fetchNextWorkspaceStickies(workspaceSlug?.toString());
};
const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined;
const shouldObserve = hasNextPage && loader !== "pagination";
const workspaceStickies = getWorkspaceStickyIds(workspaceSlug?.toString());
useIntersectionObserver(containerRef, shouldObserve ? elementRef : null, handleLoadMore);
return (
<ContentWrapper ref={containerRef} className="space-y-4">
<StickiesLayout
workspaceSlug={workspaceSlug.toString()}
intersectionElement={
hasNextPage &&
workspaceStickies?.length >= STICKIES_PER_PAGE && (
<div
className={cn("flex min-h-[300px] box-border p-2 w-full")}
ref={setElementRef}
id="intersection-element"
>
<div className="flex w-full rounded min-h-[300px]">
<Loader className="w-full h-full">
<Loader.Item height="100%" width="100%" />
</Loader>
</div>
</div>
)
}
/>
</ContentWrapper>
);
});

View file

@ -0,0 +1,168 @@
import { useEffect, useRef, useState } from "react";
import type {
DropTargetRecord,
DragLocationHistory,
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import Masonry from "react-masonry-component";
// plane ui
import { Loader } from "@plane/ui";
// components
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useSticky } from "@/hooks/use-stickies";
import { useStickyOperations } from "../sticky/use-operations";
import { StickyDNDWrapper } from "./sticky-dnd-wrapper";
import { getInstructionFromPayload } from "./sticky.helpers";
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
type TStickiesLayout = {
workspaceSlug: string;
intersectionElement?: React.ReactNode | null;
};
type TProps = TStickiesLayout & {
columnCount: number;
};
export const StickiesList = observer((props: TProps) => {
const { workspaceSlug, intersectionElement, columnCount } = props;
// navigation
const pathname = usePathname();
// store hooks
const { getWorkspaceStickyIds, toggleShowNewSticky, searchQuery, loader } = useSticky();
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
// derived values
const workspaceStickyIds = getWorkspaceStickyIds(workspaceSlug?.toString());
const itemWidth = `${100 / columnCount}%`;
const totalRows = Math.ceil(workspaceStickyIds.length / columnCount);
const isStickiesPage = pathname?.includes("stickies");
// Function to determine if an item is in first or last row
const getRowPositions = (index: number) => {
const currentRow = Math.floor(index / columnCount);
return {
isInFirstRow: currentRow === 0,
isInLastRow: currentRow === totalRows - 1 || index >= workspaceStickyIds.length - columnCount,
};
};
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
const dropTargets = location?.current?.dropTargets ?? [];
if (!dropTargets || dropTargets.length <= 0) return;
const dropTarget = dropTargets[0];
if (!dropTarget?.data?.id || !source.data?.id) return;
const instruction = getInstructionFromPayload(dropTarget, source, location);
const droppedId = dropTarget.data.id;
const sourceId = source.data.id;
try {
if (!instruction || !droppedId || !sourceId) return;
stickyOperations.updatePosition(workspaceSlug, sourceId as string, droppedId as string, instruction);
} catch (error) {
console.error("Error reordering sticky:", error);
}
};
if (loader === "init-loader") {
return (
<div className="min-h-[500px] overflow-scroll pb-2">
<Loader>
<Loader.Item height="300px" width="255px" />
</Loader>
</div>
);
}
if (loader === "loaded" && workspaceStickyIds.length === 0) {
return (
<div className="size-full grid place-items-center">
{isStickiesPage ? (
<EmptyState
type={searchQuery ? EmptyStateType.STICKIES_SEARCH : EmptyStateType.STICKIES}
layout={searchQuery ? "screen-simple" : "screen-detailed"}
primaryButtonOnClick={() => {
toggleShowNewSticky(true);
stickyOperations.create();
}}
primaryButtonConfig={{
size: "sm",
}}
/>
) : (
<StickiesEmptyState />
)}
</div>
);
}
return (
<div className="transition-opacity duration-300 ease-in-out">
{/* @ts-expect-error type mismatch here */}
<Masonry elementType="div">
{workspaceStickyIds.map((stickyId, index) => {
const { isInFirstRow, isInLastRow } = getRowPositions(index);
return (
<StickyDNDWrapper
key={stickyId}
stickyId={stickyId}
workspaceSlug={workspaceSlug.toString()}
itemWidth={itemWidth}
handleDrop={handleDrop}
isLastChild={index === workspaceStickyIds.length - 1}
isInFirstRow={isInFirstRow}
isInLastRow={isInLastRow}
/>
);
})}
{intersectionElement && <div style={{ width: itemWidth }}>{intersectionElement}</div>}
</Masonry>
</div>
);
});
export const StickiesLayout = (props: TStickiesLayout) => {
// 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} className="size-full min-h-[500px]">
<StickiesList {...props} columnCount={columnCount} />
</div>
);
};

View file

@ -0,0 +1,44 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useSticky } from "@/hooks/use-stickies";
// components
import { ContentOverflowWrapper } from "../../core/content-overflow-HOC";
import { StickiesLayout } from "./stickies-list";
export const StickiesTruncated = observer(() => {
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { fetchWorkspaceStickies } = useSticky();
useSWR(
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
return (
<ContentOverflowWrapper
maxHeight={620}
containerClassName="pb-2 box-border"
fallback={null}
customButton={
<Link
href={`/${workspaceSlug}/stickies`}
className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300 bg-custom-background-90/20"
)}
>
Show all
</Link>
}
>
<StickiesLayout workspaceSlug={workspaceSlug?.toString()} />
</ContentOverflowWrapper>
);
});

View file

@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import type {
DropTargetRecord,
DragLocationHistory,
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
import {
draggable,
dropTargetForElements,
ElementDragPayload,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { createRoot } from "react-dom/client";
import { InstructionType } from "@plane/types";
import { DropIndicator } from "@plane/ui";
import { cn } from "@plane/utils";
import { StickyNote } from "../sticky";
import { getInstructionFromPayload } from "./sticky.helpers";
// Draggable Sticky Wrapper Component
export const StickyDNDWrapper = observer(
({
stickyId,
workspaceSlug,
itemWidth,
isLastChild,
isInFirstRow,
isInLastRow,
handleDrop,
}: {
stickyId: string;
workspaceSlug: string;
itemWidth: string;
isLastChild: boolean;
isInFirstRow: boolean;
isInLastRow: boolean;
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
}) => {
const pathName = usePathname();
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const initialData = { id: stickyId, type: "sticky" };
if (pathName.includes("stickies"))
return combine(
draggable({
element,
dragHandle: element,
getInitialData: () => initialData,
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="scale-50">
<div className="-m-2 max-h-[150px]">
<StickyNote
className={"w-[290px]"}
workspaceSlug={workspaceSlug.toString()}
stickyId={stickyId}
showToolbar={false}
/>
</div>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data?.type === "sticky",
getData: ({ input, element }) => {
const blockedStates: InstructionType[] = ["make-child"];
if (!isLastChild) {
blockedStates.push("reorder-below");
}
return attachInstruction(initialData, {
input,
element,
currentLevel: 1,
indentPerLevel: 0,
mode: isLastChild ? "last-in-group" : "standard",
block: blockedStates,
});
},
onDrag: ({ self, source, location }) => {
const instruction = getInstructionFromPayload(self, source, location);
setInstruction(instruction);
},
onDragLeave: () => {
setInstruction(undefined);
},
onDrop: ({ self, source, location }) => {
setInstruction(undefined);
handleDrop(self, source, location);
},
})
);
}, [stickyId, isDragging]);
return (
<div className="relative" style={{ width: itemWidth }}>
{!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />}
<div
ref={elementRef}
className={cn("flex min-h-[300px] box-border p-2", {
"opacity-50": isDragging,
})}
>
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug} stickyId={stickyId} />
</div>
{!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />}
</div>
);
}
);

View file

@ -0,0 +1,45 @@
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
export type TargetData = {
id: string;
parentId: string | null;
isGroup: boolean;
isChild: boolean;
};
/**
* extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
* @param dropTarget dropTarget for which the instruction is required
* @param source the dragging sticky data that is being dragged on the dropTarget
* @param location location includes the data of all the dropTargets the source is being dragged on
* @returns Instruction for dropTarget
*/
export const getInstructionFromPayload = (
dropTarget: TDropTarget,
source: TDropTarget,
location: IPragmaticPayloadLocation
): InstructionType | undefined => {
const dropTargetData = dropTarget?.data as TargetData;
const sourceData = source?.data as TargetData;
const allDropTargets = location?.current?.dropTargets;
// if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
// and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
if (!dropTargetData || !sourceData) return undefined;
let instruction = extractInstruction(dropTargetData)?.type;
// If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
if (instruction === "instruction-blocked") {
instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
}
// if source that is being dragged is a group. A group cannon be a child of any other sticky,
// hence if current instruction is to be a child of dropTarget then reorder-above instead
if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
return instruction;
};

View file

@ -1,7 +1,9 @@
"use client"; "use client";
import { FC, useRef, useState } from "react"; import { FC, useCallback, useRef, useState } from "react";
import { debounce } from "lodash";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
// plane hooks // plane hooks
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
@ -10,25 +12,41 @@ import { cn } from "@/helpers/common.helper";
import { useSticky } from "@/hooks/use-stickies"; import { useSticky } from "@/hooks/use-stickies";
export const StickySearch: FC = observer(() => { export const StickySearch: FC = observer(() => {
// router
const { workspaceSlug } = useParams();
// hooks // hooks
const { searchQuery, updateSearchQuery } = useSticky(); const { searchQuery, updateSearchQuery, fetchWorkspaceStickies } = useSticky();
// refs // refs
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// states // states
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
// outside click detector hook // outside click detector hook
useOutsideClickDetector(inputRef, () => { useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
}); });
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") { if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); if (searchQuery && searchQuery.trim() !== "") {
else setIsSearchOpen(false); updateSearchQuery("");
fetchStickies();
} else setIsSearchOpen(false);
} }
}; };
const fetchStickies = async () => {
await fetchWorkspaceStickies(workspaceSlug.toString());
};
const debouncedSearch = useCallback(
debounce(async () => {
await fetchStickies();
}, 500),
[fetchWorkspaceStickies]
);
return ( return (
<div className="flex items-center mr-2"> <div className="flex items-center mr-2 my-auto">
{!isSearchOpen && ( {!isSearchOpen && (
<button <button
type="button" type="button"
@ -55,7 +73,10 @@ export const StickySearch: FC = observer(() => {
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none" 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" placeholder="Search by title"
value={searchQuery} value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)} onChange={(e) => {
updateSearchQuery(e.target.value);
debouncedSearch();
}}
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
/> />
{isSearchOpen && ( {isSearchOpen && (
@ -65,6 +86,7 @@ export const StickySearch: FC = observer(() => {
onClick={() => { onClick={() => {
updateSearchQuery(""); updateSearchQuery("");
setIsSearchOpen(false); setIsSearchOpen(false);
fetchStickies();
}} }}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />

View file

@ -3,8 +3,7 @@ import { useParams } from "next/navigation";
import { Plus, X } from "lucide-react"; import { Plus, X } from "lucide-react";
import { RecentStickyIcon } from "@plane/ui"; import { RecentStickyIcon } from "@plane/ui";
import { useSticky } from "@/hooks/use-stickies"; import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete"; import { StickiesTruncated } from "../layout/stickies-truncated";
import { StickiesLayout } from "../stickies-layout";
import { useStickyOperations } from "../sticky/use-operations"; import { useStickyOperations } from "../sticky/use-operations";
import { StickySearch } from "./search"; import { StickySearch } from "./search";
@ -19,13 +18,13 @@ export const Stickies = observer((props: TProps) => {
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() }); const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return ( return (
<div className="p-6 pb-0"> <div className="p-6 pb-0 min-h-[620px]">
{/* header */} {/* header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
{/* Title */} {/* Title */}
<div className="text-custom-text-100 flex gap-2"> <div className="text-custom-text-100 flex gap-2">
<RecentStickyIcon className="size-5 rotate-90" /> <RecentStickyIcon className="size-5 rotate-90" />
<p className="text-lg font-medium">My Stickies</p> <p className="text-lg font-medium">Your stickies</p>
</div> </div>
{/* actions */} {/* actions */}
<div className="flex gap-2"> <div className="flex gap-2">
@ -33,7 +32,7 @@ export const Stickies = observer((props: TProps) => {
<button <button
onClick={() => { onClick={() => {
toggleShowNewSticky(true); toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] }); stickyOperations.create();
}} }}
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto" className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky} disabled={creatingSticky}
@ -61,7 +60,7 @@ export const Stickies = observer((props: TProps) => {
</div> </div>
{/* content */} {/* content */}
<div className="mb-4 max-h-[625px] overflow-scroll"> <div className="mb-4 max-h-[625px] overflow-scroll">
<StickiesLayout /> <StickiesTruncated />
</div> </div>
</div> </div>
); );

View file

@ -1,160 +0,0 @@
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 { ContentOverflowWrapper } from "../core/content-overflow-HOC";
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 [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]);
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}>
<ContentOverflowWrapper
maxHeight={650}
containerClassName="pb-2 box-border"
fallback={<></>}
buttonClassName="bg-custom-background-90/20"
>
{/* @ts-expect-error type mismatch here */}
<Masonry elementType="div">{childElements}</Masonry>
</ContentOverflowWrapper>
</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

@ -1,10 +1,13 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { DebouncedFunc } from "lodash"; import { DebouncedFunc } from "lodash";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
// plane editor
import { EditorRefApi } from "@plane/editor"; import { EditorRefApi } from "@plane/editor";
// plane types
import { TSticky } from "@plane/types"; import { TSticky } from "@plane/types";
import { TextArea } from "@plane/ui"; // hooks
import { useWorkspace } from "@/hooks/store"; import { useWorkspace } from "@/hooks/store";
// components
import { StickyEditor } from "../../editor"; import { StickyEditor } from "../../editor";
type TProps = { type TProps = {
@ -12,73 +15,45 @@ type TProps = {
workspaceSlug: string; workspaceSlug: string;
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>; handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
stickyId: string | undefined; stickyId: string | undefined;
showToolbar?: boolean;
handleChange: (data: Partial<TSticky>) => Promise<void>; handleChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void; handleDelete: () => void;
}; };
export const StickyInput = (props: TProps) => { export const StickyInput = (props: TProps) => {
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props; const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange, showToolbar } = props;
//refs // refs
const editorRef = useRef<EditorRefApi>(null); const editorRef = useRef<EditorRefApi>(null);
// store hooks // store hooks
const { getWorkspaceBySlug } = useWorkspace(); const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? "";
// form info // form info
const { handleSubmit, reset, control } = useForm<TSticky>({ const { handleSubmit, reset, control } = useForm<TSticky>({
defaultValues: { defaultValues: {
description_html: stickyData?.description_html, description_html: stickyData?.description_html,
name: stickyData?.name,
}, },
}); });
// handle description update
// computed values const handleFormSubmit = useCallback(
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string; async (formdata: Partial<TSticky>) => {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
});
},
[handleUpdate]
);
// reset form values // reset form values
useEffect(() => { useEffect(() => {
if (!stickyId) return; if (!stickyId) return;
reset({ reset({
id: stickyId, id: stickyId,
description_html: stickyData?.description_html === "" ? "<p></p>" : stickyData?.description_html, description_html: stickyData?.description_html?.trim() === "" ? "<p></p>" : stickyData?.description_html,
name: stickyData?.name,
}); });
}, [stickyData, reset]); }, [stickyData, stickyId, 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 ( return (
<div className="flex-1"> <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 <Controller
name="description_html" name="description_html"
control={control} control={control}
@ -89,14 +64,14 @@ export const StickyInput = (props: TProps) => {
value={null} value={null}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
workspaceId={workspaceId} workspaceId={workspaceId}
onChange={(_description: object, description_html: string) => { onChange={(_description, description_html) => {
onChange(description_html); onChange(description_html);
handleSubmit(handleFormSubmit)(); handleSubmit(handleFormSubmit)();
}} }}
placeholder={"Click to type here"} placeholder="Click to type here"
containerClassName={"px-0 text-base min-h-[200px] w-full text-[#455068]"} containerClassName="px-0 text-base min-h-[250px] w-full"
uploadFile={async () => ""} uploadFile={async () => ""}
showToolbar={false} showToolbar={showToolbar}
parentClassName={"border-none p-0"} parentClassName={"border-none p-0"}
handleDelete={handleDelete} handleDelete={handleDelete}
handleColorChange={handleChange} handleColorChange={handleChange}

View file

@ -1,13 +1,19 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Minimize2 } from "lucide-react"; import { Minimize2 } from "lucide-react";
// plane types
import { TSticky } from "@plane/types"; import { TSticky } from "@plane/types";
// plane utils
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
// hooks
import { useSticky } from "@/hooks/use-stickies"; import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete"; // components
import { STICKY_COLORS_LIST } from "../../editor/sticky-editor/color-palette";
import { StickyDeleteModal } from "../delete-modal"; import { StickyDeleteModal } from "../delete-modal";
import { StickyInput } from "./inputs"; import { StickyInput } from "./inputs";
import { StickyItemDragHandle } from "./sticky-item-drag-handle";
import { useStickyOperations } from "./use-operations"; import { useStickyOperations } from "./use-operations";
type TProps = { type TProps = {
@ -15,25 +21,34 @@ type TProps = {
workspaceSlug: string; workspaceSlug: string;
className?: string; className?: string;
stickyId: string | undefined; stickyId: string | undefined;
showToolbar?: boolean;
}; };
export const StickyNote = observer((props: TProps) => { export const StickyNote = observer((props: TProps) => {
const { onClose, workspaceSlug, className = "", stickyId } = props; const { onClose, workspaceSlug, className = "", stickyId, showToolbar } = props;
//state // navigation
const pathName = usePathname();
// states
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// hooks // store hooks
const { stickyOperations } = useStickyOperations({ workspaceSlug });
const { stickies } = useSticky(); const { stickies } = useSticky();
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug });
// derived values // derived values
const stickyData: TSticky | undefined = stickyId ? stickies[stickyId] : undefined; const stickyData = stickyId ? stickies[stickyId] : undefined;
const isStickiesPage = pathName?.includes("stickies");
const backgroundColor =
STICKY_COLORS_LIST.find((c) => c.key === stickyData?.background_color)?.backgroundColor ||
STICKY_COLORS_LIST[0].backgroundColor;
const handleChange = useCallback( const handleChange = useCallback(
async (payload: Partial<TSticky>) => { async (payload: Partial<TSticky>) => {
stickyId if (stickyId) {
? await stickyOperations.update(stickyId, payload) await stickyOperations.update(stickyId, payload);
: await stickyOperations.create({ } else {
color: payload.color || STICKY_COLORS[0], await stickyOperations.create({
...payload, ...payload,
}); });
}
}, },
[stickyId, stickyOperations] [stickyId, stickyOperations]
); );
@ -60,10 +75,13 @@ export const StickyNote = observer((props: TProps) => {
/> />
<div <div
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)} className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)}
style={{ backgroundColor: stickyData?.color || STICKY_COLORS[0] }} style={{
backgroundColor,
}}
> >
{isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "}
{onClose && ( {onClose && (
<button className="flex w-full" onClick={onClose}> <button type="button" className="flex w-full" onClick={onClose}>
<Minimize2 className="size-4 m-auto mr-0" /> <Minimize2 className="size-4 m-auto mr-0" />
</button> </button>
)} )}
@ -73,11 +91,9 @@ export const StickyNote = observer((props: TProps) => {
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
handleUpdate={debouncedFormSave} handleUpdate={debouncedFormSave}
stickyId={stickyId} stickyId={stickyId}
handleDelete={() => { handleDelete={() => setIsDeleteModalOpen(true)}
if (!stickyId) return;
setIsDeleteModalOpen(true);
}}
handleChange={handleChange} handleChange={handleChange}
showToolbar={showToolbar}
/> />
</div> </div>
</> </>

View file

@ -0,0 +1,28 @@
"use client";
import React, { FC } from "react";
import { observer } from "mobx-react";
// ui
import { DragHandle } from "@plane/ui";
// helper
import { cn } from "@/helpers/common.helper";
type Props = {
isDragging: boolean;
};
export const StickyItemDragHandle: FC<Props> = observer((props) => {
const { isDragging } = props;
return (
<div
className={cn(
"hidden group-hover/sticky:flex absolute top-3 left-1/2 -translate-x-1/2 items-center justify-center rounded text-custom-sidebar-text-400 cursor-grab mr-2 rotate-90",
{
"cursor-grabbing": isDragging,
}
)}
>
<DragHandle className="bg-transparent" />
</div>
);
});

View file

@ -1,28 +1,48 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { TSticky } from "@plane/types"; // plane types
import { InstructionType, TSticky } from "@plane/types";
// plane ui
import { setToast, TOAST_TYPE } from "@plane/ui"; import { setToast, TOAST_TYPE } from "@plane/ui";
// plane utils
import { isCommentEmpty } from "@plane/utils";
// components
import { STICKY_COLORS_LIST } from "@/components/editor/sticky-editor/color-palette";
// hooks
import { useSticky } from "@/hooks/use-stickies"; import { useSticky } from "@/hooks/use-stickies";
export type TOperations = { export type TOperations = {
create: (data: Partial<TSticky>) => Promise<void>; create: (data?: Partial<TSticky>) => Promise<void>;
update: (stickyId: string, data: Partial<TSticky>) => Promise<void>; update: (stickyId: string, data: Partial<TSticky>) => Promise<void>;
remove: (stickyId: string) => Promise<void>; remove: (stickyId: string) => Promise<void>;
updatePosition: (
workspaceSlug: string,
sourceId: string,
droppedId: string,
instruction: InstructionType
) => Promise<void>;
}; };
type TProps = { type TProps = {
workspaceSlug: string; workspaceSlug: string;
}; };
export const getRandomStickyColor = (): string => {
const randomIndex = Math.floor(Math.random() * STICKY_COLORS_LIST.length);
return STICKY_COLORS_LIST[randomIndex].key;
};
export const useStickyOperations = (props: TProps) => { export const useStickyOperations = (props: TProps) => {
const { workspaceSlug } = props; const { workspaceSlug } = props;
const { createSticky, updateSticky, deleteSticky } = useSticky(); // store hooks
const { stickies, getWorkspaceStickyIds, createSticky, updateSticky, deleteSticky, updateStickyPosition } =
useSticky();
const isValid = (data: Partial<TSticky>) => { const isValid = (data: Partial<TSticky>) => {
if (data.name && data.name.length > 100) { if (data.name && data.name.length > 100) {
setToast({ setToast({
message: "The sticky name cannot be longer than 100 characters",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Sticky not updated", title: "Sticky not updated",
message: "The sticky name cannot be longer than 100 characters.",
}); });
return false; return false;
} }
@ -31,23 +51,40 @@ export const useStickyOperations = (props: TProps) => {
const stickyOperations: TOperations = useMemo( const stickyOperations: TOperations = useMemo(
() => ({ () => ({
create: async (data: Partial<TSticky>) => { create: async (data?: Partial<TSticky>) => {
try { try {
if (!workspaceSlug) throw new Error("Missing required fields"); const payload: Partial<TSticky> = {
if (!isValid(data)) return; background_color: getRandomStickyColor(),
await createSticky(workspaceSlug, data); ...data,
};
const workspaceStickIds = getWorkspaceStickyIds(workspaceSlug);
// check if latest sticky is empty
if (workspaceStickIds && workspaceStickIds.length >= 0) {
const latestSticky = stickies[workspaceStickIds[0]];
if (latestSticky && (!latestSticky.description_html || isCommentEmpty(latestSticky.description_html))) {
setToast({
message: "There already exists a sticky with no description",
type: TOAST_TYPE.WARNING,
title: "Sticky already created",
});
return;
}
}
if (!workspaceSlug) throw new Error("Missing required fields");
if (!isValid(payload)) return;
await createSticky(workspaceSlug, payload);
setToast({ setToast({
message: "The sticky has been successfully created",
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Sticky created", title: "Sticky created",
message: "The sticky has been successfully created.",
}); });
} catch (error: any) { } catch (error: any) {
console.error("Error in creating sticky:", error);
setToast({ setToast({
message: error?.data?.error ?? "The sticky could not be created",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Sticky not created", title: "Sticky not created",
message: error?.data?.error ?? "The sticky could not be created.",
}); });
throw error;
} }
}, },
update: async (stickyId: string, data: Partial<TSticky>) => { update: async (stickyId: string, data: Partial<TSticky>) => {
@ -56,12 +93,12 @@ export const useStickyOperations = (props: TProps) => {
if (!isValid(data)) return; if (!isValid(data)) return;
await updateSticky(workspaceSlug, stickyId, data); await updateSticky(workspaceSlug, stickyId, data);
} catch (error) { } catch (error) {
console.error("Error in updating sticky:", error);
setToast({ setToast({
message: "The sticky could not be updated",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Sticky not updated", title: "Sticky not updated",
message: "The sticky could not be updated.",
}); });
throw error;
} }
}, },
remove: async (stickyId: string) => { remove: async (stickyId: string) => {
@ -69,21 +106,39 @@ export const useStickyOperations = (props: TProps) => {
if (!workspaceSlug) throw new Error("Missing required fields"); if (!workspaceSlug) throw new Error("Missing required fields");
await deleteSticky(workspaceSlug, stickyId); await deleteSticky(workspaceSlug, stickyId);
setToast({ setToast({
message: "The sticky has been successfully removed",
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Sticky removed", title: "Sticky removed",
message: "The sticky has been removed successfully.",
}); });
} catch (error) { } catch (error) {
console.error("Error in removing sticky:", error);
setToast({ setToast({
message: "The sticky could not be removed",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Sticky not removed", title: "Sticky not removed",
message: "The sticky could not be removed.",
});
}
},
updatePosition: async (
workspaceSlug: string,
sourceId: string,
droppedId: string,
instruction: InstructionType
) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
await updateStickyPosition(workspaceSlug, sourceId, droppedId, instruction);
} catch (error) {
console.error("Error in updating sticky position:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: "Sticky not updated",
message: "The sticky could not be updated.",
}); });
throw error;
} }
}, },
}), }),
[workspaceSlug] [createSticky, deleteSticky, getWorkspaceStickyIds, stickies, updateSticky, updateStickyPosition, workspaceSlug]
); );
return { return {

View file

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

View file

@ -1,4 +1,5 @@
import { EUserPermissions } from "ee/constants/user-permissions"; import { EUserPermissions } from "ee/constants/user-permissions";
import { Plus, Shapes } from "lucide-react";
export interface EmptyStateDetails { export interface EmptyStateDetails {
key: EmptyStateType; key: EmptyStateType;
@ -122,9 +123,14 @@ export enum EmptyStateType {
TEAM_EMPTY_FILTER = "team-empty-filter", TEAM_EMPTY_FILTER = "team-empty-filter",
TEAM_VIEW = "team-view", TEAM_VIEW = "team-view",
TEAM_PAGE = "team-page", TEAM_PAGE = "team-page",
// stickies
STICKIES = "stickies",
STICKIES_SEARCH = "stickies-search",
// home widgets
HOME_WIDGETS = "home-widgets",
} }
const emptyStateDetails = { const emptyStateDetails: Record<EmptyStateType, EmptyStateDetails> = {
// workspace // workspace
[EmptyStateType.WORKSPACE_DASHBOARD]: { [EmptyStateType.WORKSPACE_DASHBOARD]: {
key: EmptyStateType.WORKSPACE_DASHBOARD, key: EmptyStateType.WORKSPACE_DASHBOARD,
@ -912,6 +918,43 @@ const emptyStateDetails = {
"Write a note, a doc, or a full knowledge base. Get Galileo, Planes AI assistant, to help you get started. Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your projects context. To make short work of any doc, invoke Galileo, Planes AI, with a shortcut or the click of a button.", "Write a note, a doc, or a full knowledge base. Get Galileo, Planes AI assistant, to help you get started. Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your projects context. To make short work of any doc, invoke Galileo, Planes AI, with a shortcut or the click of a button.",
path: "/empty-state/onboarding/pages", path: "/empty-state/onboarding/pages",
}, },
[EmptyStateType.STICKIES]: {
key: EmptyStateType.STICKIES,
title: "Stickies are quick notes and to-dos you take down on the fly.",
description:
"Capture your thoughts and ideas effortlessly by creating stickies that you can access anytime and from anywhere.",
path: "/empty-state/stickies/stickies",
primaryButton: {
icon: <Plus className="size-4" />,
text: "Add sticky",
},
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
accessType: "workspace",
},
[EmptyStateType.STICKIES_SEARCH]: {
key: EmptyStateType.STICKIES_SEARCH,
title: "That doesn't match any of your stickies.",
description: "Try a different term or let us know\nif you are sure your search is right. ",
path: "/empty-state/stickies/stickies-search",
primaryButton: {
icon: <Plus className="size-4" />,
text: "Add sticky",
},
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
accessType: "workspace",
},
[EmptyStateType.HOME_WIDGETS]: {
key: EmptyStateType.HOME_WIDGETS,
title: "It's Quiet Without Widgets, Turn Them On",
description: "It looks like all your widgets are turned off. Enable them\nnow to enhance your experience!",
path: "/empty-state/dashboard/widgets",
primaryButton: {
icon: <Shapes className="size-4" />,
text: "Manage widgets",
},
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
accessType: "workspace",
},
} as const; } as const;
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails; export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;

View file

@ -1,4 +1,5 @@
// helpers // helpers
import { STICKIES_PER_PAGE } from "@plane/constants";
import { TSticky } from "@plane/types"; import { TSticky } from "@plane/types";
import { API_BASE_URL } from "@/helpers/common.helper"; import { API_BASE_URL } from "@/helpers/common.helper";
// services // services
@ -19,13 +20,14 @@ export class StickyService extends APIService {
async getStickies( async getStickies(
workspaceSlug: string, workspaceSlug: string,
cursor?: string, cursor: string,
per_page?: number query?: string
): Promise<{ results: TSticky[]; total_pages: number }> { ): Promise<{ results: TSticky[]; total_pages: number }> {
return this.get(`/api/workspaces/${workspaceSlug}/stickies/`, { return this.get(`/api/workspaces/${workspaceSlug}/stickies/`, {
params: { params: {
cursor: cursor || `5:0:0`, cursor,
per_page: per_page || 5, per_page: STICKIES_PER_PAGE,
query,
}, },
}) })
.then((res) => res?.data) .then((res) => res?.data)

View file

@ -1,45 +1,50 @@
import { orderBy, set } from "lodash";
import { observable, action, makeObservable, runInAction } from "mobx"; import { observable, action, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
import { TSticky } from "@plane/types"; import { STICKIES_PER_PAGE } from "@plane/constants";
import { InstructionType, TLoader, TPaginationInfo, TSticky } from "@plane/types";
import { StickyService } from "@/services/sticky.service"; import { StickyService } from "@/services/sticky.service";
export interface IStickyStore { export interface IStickyStore {
creatingSticky: boolean; creatingSticky: boolean;
fetchingWorkspaceStickies: boolean; loader: TLoader;
workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds
stickies: Record<string, TSticky>; // stickyId -> sticky stickies: Record<string, TSticky>; // stickyId -> sticky
searchQuery: string; searchQuery: string;
activeStickyId: string | undefined; activeStickyId: string | undefined;
recentStickyId: string | undefined; recentStickyId: string | undefined;
showAddNewSticky: boolean; showAddNewSticky: boolean;
currentPage: number; paginationInfo: TPaginationInfo | undefined;
totalPages: number;
// computed // computed
getWorkspaceStickies: (workspaceSlug: string) => string[]; getWorkspaceStickyIds: (workspaceSlug: string) => string[];
// actions // actions
toggleShowNewSticky: (value: boolean) => void; toggleShowNewSticky: (value: boolean) => void;
updateSearchQuery: (query: string) => void; updateSearchQuery: (query: string) => void;
fetchWorkspaceStickies: (workspaceSlug: string, cursor?: string, per_page?: number) => void; fetchWorkspaceStickies: (workspaceSlug: string) => void;
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => void; createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => Promise<void>;
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => void; updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => Promise<void>;
deleteSticky: (workspaceSlug: string, id: string) => void; deleteSticky: (workspaceSlug: string, id: string) => Promise<void>;
updateActiveStickyId: (id: string | undefined) => void; updateActiveStickyId: (id: string | undefined) => void;
fetchRecentSticky: (workspaceSlug: string) => void; fetchRecentSticky: (workspaceSlug: string) => Promise<void>;
incrementPage: () => void; fetchNextWorkspaceStickies: (workspaceSlug: string) => Promise<void>;
updateStickyPosition: (
workspaceSlug: string,
stickyId: string,
destinationId: string,
edge: InstructionType
) => Promise<void>;
} }
export class StickyStore implements IStickyStore { export class StickyStore implements IStickyStore {
loader: TLoader = "init-loader";
creatingSticky = false; creatingSticky = false;
fetchingWorkspaceStickies = true;
workspaceStickies: Record<string, string[]> = {}; workspaceStickies: Record<string, string[]> = {};
stickies: Record<string, TSticky> = {}; stickies: Record<string, TSticky> = {};
recentStickyId: string | undefined = undefined; recentStickyId: string | undefined = undefined;
searchQuery = ""; searchQuery = "";
activeStickyId: string | undefined = undefined; activeStickyId: string | undefined = undefined;
showAddNewSticky = false; showAddNewSticky = false;
currentPage = 0; paginationInfo: TPaginationInfo | undefined = undefined;
totalPages = 0;
// services // services
stickyService; stickyService;
@ -48,33 +53,35 @@ export class StickyStore implements IStickyStore {
makeObservable(this, { makeObservable(this, {
// observables // observables
creatingSticky: observable, creatingSticky: observable,
fetchingWorkspaceStickies: observable, loader: observable,
activeStickyId: observable, activeStickyId: observable,
showAddNewSticky: observable, showAddNewSticky: observable,
recentStickyId: observable, recentStickyId: observable,
workspaceStickies: observable, workspaceStickies: observable,
stickies: observable, stickies: observable,
searchQuery: observable, searchQuery: observable,
currentPage: observable,
totalPages: observable,
// actions // actions
updateSearchQuery: action, updateSearchQuery: action,
updateSticky: action, updateSticky: action,
deleteSticky: action, deleteSticky: action,
incrementPage: action, fetchNextWorkspaceStickies: action,
fetchWorkspaceStickies: action,
createSticky: action,
updateActiveStickyId: action,
toggleShowNewSticky: action,
fetchRecentSticky: action,
updateStickyPosition: action,
}); });
this.stickyService = new StickyService(); this.stickyService = new StickyService();
} }
getWorkspaceStickies = computedFn((workspaceSlug: string) => { getWorkspaceStickyIds = computedFn((workspaceSlug: string) =>
let filteredStickies = (this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]); orderBy(
if (this.searchQuery) { (this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]),
filteredStickies = filteredStickies.filter( ["sort_order"],
(sticky) => sticky.name && sticky.name.toLowerCase().includes(this.searchQuery.toLowerCase()) ["desc"]
).map((sticky) => sticky.id)
); );
}
return filteredStickies.map((sticky) => sticky.id);
});
toggleShowNewSticky = (value: boolean) => { toggleShowNewSticky = (value: boolean) => {
this.showAddNewSticky = value; this.showAddNewSticky = value;
@ -88,34 +95,77 @@ export class StickyStore implements IStickyStore {
this.activeStickyId = id; this.activeStickyId = id;
}; };
incrementPage = () => {
this.currentPage += 1;
};
fetchRecentSticky = async (workspaceSlug: string) => { fetchRecentSticky = async (workspaceSlug: string) => {
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", 1); const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0");
runInAction(() => { runInAction(() => {
this.recentStickyId = response.results[0]?.id; this.recentStickyId = response.results[0]?.id;
this.stickies[response.results[0]?.id] = response.results[0]; this.stickies[response.results[0]?.id] = response.results[0];
}); });
}; };
fetchWorkspaceStickies = async (workspaceSlug: string, cursor?: string, per_page?: number) => { fetchNextWorkspaceStickies = async (workspaceSlug: string) => {
try { try {
const response = await this.stickyService.getStickies(workspaceSlug, cursor, per_page); if (!this.paginationInfo?.next_cursor || !this.paginationInfo.next_page_results || this.loader === "pagination") {
return;
}
this.loader = "pagination";
const response = await this.stickyService.getStickies(
workspaceSlug,
this.paginationInfo.next_cursor,
this.searchQuery
);
runInAction(() => { runInAction(() => {
response.results.forEach((sticky) => { const { results, ...paginationInfo } = response;
// Add new stickies to store
results.forEach((sticky) => {
if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) { if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) {
this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id]; this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id];
} }
this.stickies[sticky.id] = sticky; this.stickies[sticky.id] = sticky;
}); });
this.totalPages = response.total_pages;
this.fetchingWorkspaceStickies = false; // Update pagination info directly from backend
set(this, "paginationInfo", paginationInfo);
set(this, "loader", "loaded");
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
this.fetchingWorkspaceStickies = false; runInAction(() => {
this.loader = "loaded";
});
}
};
fetchWorkspaceStickies = async (workspaceSlug: string) => {
try {
if (this.workspaceStickies[workspaceSlug]) {
this.loader = "mutation";
} else {
this.loader = "init-loader";
}
const response = await this.stickyService.getStickies(
workspaceSlug,
`${STICKIES_PER_PAGE}:0:0`,
this.searchQuery
);
runInAction(() => {
const { results, ...paginationInfo } = response;
results.forEach((sticky) => {
this.stickies[sticky.id] = sticky;
});
this.workspaceStickies[workspaceSlug] = results.map((sticky) => sticky.id);
set(this, "paginationInfo", paginationInfo);
this.loader = "loaded";
});
} catch (e) {
console.error(e);
runInAction(() => {
this.loader = "loaded";
});
} }
}; };
@ -138,16 +188,18 @@ export class StickyStore implements IStickyStore {
const sticky = this.stickies[id]; const sticky = this.stickies[id];
if (!sticky) return; if (!sticky) return;
try { try {
this.stickies[id] = { runInAction(() => {
...sticky, Object.keys(updates).forEach((key) => {
...updates, const currentStickyKey = key as keyof TSticky;
updatedAt: new Date(), set(this.stickies[id], key, updates[currentStickyKey] || undefined);
}; });
});
this.recentStickyId = id; this.recentStickyId = id;
await this.stickyService.updateSticky(workspaceSlug, id, updates); await this.stickyService.updateSticky(workspaceSlug, id, updates);
} catch (e) { } catch (error) {
console.log(e); console.error("Error in updating sticky:", error);
this.stickies[id] = sticky; this.stickies[id] = sticky;
throw new Error();
} }
}; };
@ -167,4 +219,53 @@ export class StickyStore implements IStickyStore {
this.stickies[id] = sticky; this.stickies[id] = sticky;
} }
}; };
updateStickyPosition = async (
workspaceSlug: string,
stickyId: string,
destinationId: string,
edge: InstructionType
) => {
const previousSortOrder = this.stickies[stickyId].sort_order;
try {
let resultSequence = 10000;
const workspaceStickies = this.workspaceStickies[workspaceSlug] || [];
const stickies = workspaceStickies.map((id) => this.stickies[id]);
const sortedStickies = orderBy(stickies, "sort_order", "desc").map((sticky) => sticky.id);
const destinationSequence = this.stickies[destinationId]?.sort_order || undefined;
if (destinationSequence) {
const destinationIndex = sortedStickies.findIndex((id) => id === destinationId);
if (edge === "reorder-above") {
const prevSequence = this.stickies[sortedStickies[destinationIndex - 1]]?.sort_order || undefined;
if (prevSequence) {
resultSequence = (destinationSequence + prevSequence) / 2;
} else {
resultSequence = destinationSequence + resultSequence;
}
} else {
// reorder-below
resultSequence = destinationSequence - resultSequence;
}
}
runInAction(() => {
this.stickies[stickyId] = {
...this.stickies[stickyId],
sort_order: resultSequence,
};
});
await this.stickyService.updateSticky(workspaceSlug, stickyId, {
sort_order: resultSequence,
});
} catch (error) {
console.error("Failed to move sticky");
runInAction(() => {
this.stickies[stickyId].sort_order = previousSortOrder;
});
throw error;
}
};
} }

View file

@ -11,6 +11,7 @@ export interface IHomeStore {
widgetsMap: Record<string, TWidgetEntityData>; widgetsMap: Record<string, TWidgetEntityData>;
widgets: THomeWidgetKeys[]; widgets: THomeWidgetKeys[];
// computed // computed
isAnyWidgetEnabled: boolean;
orderedWidgets: THomeWidgetKeys[]; orderedWidgets: THomeWidgetKeys[];
//stores //stores
quickLinks: IWorkspaceLinkStore; quickLinks: IWorkspaceLinkStore;
@ -38,6 +39,7 @@ export class HomeStore implements IHomeStore {
widgetsMap: observable, widgetsMap: observable,
widgets: observable, widgets: observable,
// computed // computed
isAnyWidgetEnabled: computed,
orderedWidgets: computed, orderedWidgets: computed,
// actions // actions
toggleWidgetSettings: action, toggleWidgetSettings: action,
@ -52,6 +54,10 @@ export class HomeStore implements IHomeStore {
this.quickLinks = new WorkspaceLinkStore(); this.quickLinks = new WorkspaceLinkStore();
} }
get isAnyWidgetEnabled() {
return Object.values(this.widgetsMap).some((widget) => widget.is_enabled);
}
get orderedWidgets() { get orderedWidgets() {
return orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key); return orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key);
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View file

@ -379,6 +379,53 @@
--color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */ --color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */
--color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */ --color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */
} }
/* stickies and editor colors */
:root {
/* text colors */
--editor-colors-gray-text: #5c5e63;
--editor-colors-peach-text: #ff5b59;
--editor-colors-pink-text: #f65385;
--editor-colors-orange-text: #fd9038;
--editor-colors-green-text: #0fc27b;
--editor-colors-light-blue-text: #17bee9;
--editor-colors-dark-blue-text: #266df0;
--editor-colors-purple-text: #9162f9;
/* end text colors */
/* background colors */
--editor-colors-gray-background: #d6d6d8;
--editor-colors-peach-background: #ffd5d7;
--editor-colors-pink-background: #fdd4e3;
--editor-colors-orange-background: #ffe3cd;
--editor-colors-green-background: #c3f0de;
--editor-colors-light-blue-background: #c5eff9;
--editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd;
/* end background colors */
}
/* background colors */
[data-theme*="light"] {
--editor-colors-gray-background: #d6d6d8;
--editor-colors-peach-background: #ffd5d7;
--editor-colors-pink-background: #fdd4e3;
--editor-colors-orange-background: #ffe3cd;
--editor-colors-green-background: #c3f0de;
--editor-colors-light-blue-background: #c5eff9;
--editor-colors-dark-blue-background: #c9dafb;
--editor-colors-purple-background: #e3d8fd;
}
[data-theme*="dark"] {
--editor-colors-gray-background: #404144;
--editor-colors-peach-background: #593032;
--editor-colors-pink-background: #562e3d;
--editor-colors-orange-background: #583e2a;
--editor-colors-green-background: #1d4a3b;
--editor-colors-light-blue-background: #1f495c;
--editor-colors-dark-blue-background: #223558;
--editor-colors-purple-background: #3d325a;
}
/* end background colors */
} }
* { * {