[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
56 changed files with 1347 additions and 574 deletions

View File

@@ -39,9 +39,9 @@ class WorkspaceStickyViewSet(BaseViewSet):
)
def list(self, request, slug):
query = request.query_params.get("query", False)
stickies = self.get_queryset()
stickies = self.get_queryset().order_by("-sort_order")
if query:
stickies = stickies.filter(name__icontains=query)
stickies = stickies.filter(description_stripped__icontains=query)
return self.paginate(
request=request,
@@ -49,7 +49,7 @@ class WorkspaceStickyViewSet(BaseViewSet):
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
default_per_page=20,
)
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)

View File

@@ -5,6 +5,9 @@ from django.db import models
# Module imports
from .base import BaseModel
# Third party imports
from plane.utils.html_processor import strip_tags
class Sticky(BaseModel):
name = models.TextField(null=True, blank=True)
@@ -33,6 +36,12 @@ class Sticky(BaseModel):
ordering = ("-created_at",)
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:
# Get the maximum sequence value from the database
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(

View File

@@ -13,3 +13,4 @@ export * from "./state";
export * from "./swr";
export * from "./user";
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;
-webkit-appearance: none;
appearance: none;
background-color: rgb(var(--color-background-100));
background-color: transparent;
margin: 0;
cursor: pointer;
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 {
/* font sizes and line heights */
&.large-font {

View File

@@ -1,8 +1,16 @@
import { TLogoProps } from "./common";
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;
logo_props: TLogoProps | undefined;
name?: string;
description_html?: string;
color?: string;
createdAt?: Date;
updatedAt?: Date;
sort_order: number | undefined;
updated_at?: string | undefined;
updated_by?: string | undefined;
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;
containerClassName?: string;
fallback?: ReactNode;
customButton?: ReactNode;
}
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",
containerClassName,
fallback = null,
customButton,
} = props;
// states
@@ -131,16 +133,18 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
pointerEvents: isTransitioning ? "none" : "auto",
}}
>
<button
className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
buttonClassName
)}
onClick={handleToggle}
disabled={isTransitioning}
>
{showAll ? "Show less" : "Show all"}
</button>
{customButton || (
<button
className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
buttonClassName
)}
onClick={handleToggle}
disabled={isTransitioning}
>
{showAll ? "Show less" : "Show all"}
</button>
)}
</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,26 +82,28 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
<div
className={cn(
"transition-all duration-300 ease-out origin-top",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<Toolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
handleDelete={handleDelete}
handleColorChange={handleColorChange}
editorRef={editorRef}
/>
</div>
{showToolbar && (
<div
className={cn(
"transition-all duration-300 ease-out origin-top",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<Toolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
handleDelete={handleDelete}
handleColorChange={handleColorChange}
editorRef={editorRef}
/>
</div>
)}
</div>
);
});

View File

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

View File

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

View File

@@ -1,54 +1,96 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// types
// plane types
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
// components
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useHome } from "@/hooks/store/use-home";
// components
// plane web components
import { HomePageHeader } from "@/plane-web/components/home/header";
import { StickiesWidget } from "../stickies";
import { RecentActivityWidget } from "./widgets";
import { DashboardQuickLinks } from "./widgets/links";
import { ManageWidgetsModal } from "./widgets/manage";
const WIDGETS_LIST: {
[key in THomeWidgetKeys]: { component: React.FC<THomeWidgetProps> | null; fullWidth: boolean };
export const HOME_WIDGETS_LIST: {
[key in THomeWidgetKeys]: {
component: React.FC<THomeWidgetProps> | null;
fullWidth: boolean;
title: string;
};
} = {
quick_links: { component: DashboardQuickLinks, fullWidth: false },
recents: { component: RecentActivityWidget, fullWidth: false },
my_stickies: { component: StickiesWidget, fullWidth: false },
new_at_plane: { component: null, fullWidth: false },
quick_tutorial: { component: null, fullWidth: false },
quick_links: {
component: DashboardQuickLinks,
fullWidth: false,
title: "Quick links",
},
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(() => {
// router
const { workspaceSlug } = useParams();
// store hooks
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets } = useHome();
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled } = useHome();
if (!workspaceSlug) return null;
return (
<div className="relative flex flex-col gap-7">
<div className="h-full w-full relative flex flex-col gap-7">
<HomePageHeader />
<ManageWidgetsModal
workspaceSlug={workspaceSlug.toString()}
isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)}
/>
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => {
const WidgetComponent = WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null;
return (
<div key={key} className="py-4">
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
</div>
);
})}
</div>
{isAnyWidgetEnabled ? (
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => {
const WidgetComponent = HOME_WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null;
return (
<div key={key} className="py-4">
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
</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>
);
});

View File

@@ -59,12 +59,11 @@ export const WorkspaceHomeView = observer(() => {
<>
<IssuePeekOverview />
<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,
})}
>
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
<DashboardWidgets />
</ContentWrapper>
</>

View File

@@ -1,9 +1,11 @@
import { FC } from "react";
// hooks
import { Shapes } from "lucide-react";
// plane types
import { IUser } from "@plane/types";
// plane ui
import { Button } from "@plane/ui";
// hooks
import { useCurrentTime } from "@/hooks/use-current-time";
// types
export interface IUserGreetingsView {
user: IUser;
@@ -51,13 +53,10 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
</div>
</h6>
</div>
<button
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"
>
<Button variant="neutral-primary" size="sm" onClick={handleWidgetModal} className="my-auto mb-0">
<Shapes size={16} />
<div className="text-xs font-medium">Manage widgets</div>
</button>
</Button>
</div>
);
};

View File

@@ -1,27 +1,12 @@
import { Link2, Plus } from "lucide-react";
import { Button } from "@plane/ui";
import { Link2 } from "lucide-react";
type TProps = {
handleCreate: () => void;
};
export const LinksEmptyState = (props: TProps) => {
const { handleCreate } = props;
return (
<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" />
export const LinksEmptyState = () => (
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
<div className="m-auto flex gap-2">
<Link2 size={30} className="text-custom-text-400/40 -rotate-45" />
<div className="text-custom-text-400 text-sm text-center my-auto">
Add any links you need for quick access to your work.
</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>
);
};

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 = () => (
<div className="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`}
>
<History size={30} className="text-custom-text-400 -rotate-45" />
export const RecentsEmptyState = ({ type }: { type: string }) => {
const getDisplayContent = () => {
switch (type) {
case "project":
return {
icon: <Briefcase size={30} className="text-custom-text-400/40" />,
text: "Your recent projects will appear here once you visit one.",
};
case "page":
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 className="text-custom-text-100 font-medium text-base text-center mb-1">No recent items yet</div>
<div className="text-custom-text-300 text-sm text-center mb-2">You dont have any recent items yet. </div>
</div>
</div>
);
);
};

View File

@@ -2,17 +2,22 @@ import React from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { Briefcase, Hotel, Users } from "lucide-react";
// helpers
import { getFileURL } from "@/helpers/file.helper";
// hooks
import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store";
// plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
export const EmptyWorkspace = () => {
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { allowPermissions } = useUserPermissions();
const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const { data: currentUser } = useUser();
// derived values
const canCreateProject = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
@@ -83,6 +88,7 @@ export const EmptyWorkspace = () => {
},
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{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
// ui
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";
// helpers
import { cn } from "@plane/utils";
import { cn, copyTextToClipboard } from "@plane/utils";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper";
import { useHome } from "@/hooks/store/use-home";
import { TLinkOperations } from "./use-links";
@@ -37,7 +36,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
};
const handleCopyText = () =>
copyUrlToClipboard(viewLink).then(() => {
copyTextToClipboard(viewLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Link Copied!",
@@ -74,7 +73,10 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
];
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">
<Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" />
</div>

View File

@@ -20,14 +20,14 @@ export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
const { linkOperations, workspaceSlug } = props;
// hooks
const {
quickLinks: { getLinksByWorkspaceId, toggleLinkModal },
quickLinks: { getLinksByWorkspaceId },
} = useHome();
const links = getLinksByWorkspaceId(workspaceSlug);
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 (
<div className="relative">

View File

@@ -32,7 +32,6 @@ export const useLinks = (workspaceSlug: string) => {
create: async (data: Partial<TProjectLink>) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
console.log("data", data, workspaceSlug);
await createLink(workspaceSlug, data);
setToast({
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 { 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";
// plane helpers
import { useParams } from "next/navigation";
import { createRoot } from "react-dom/client";
// ui
// plane types
import { InstructionType, TWidgetEntityData } from "@plane/types";
// components
// plane ui
import { DropIndicator, ToggleSwitch } from "@plane/ui";
// helpers
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useHome } from "@/hooks/store/use-home";
import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
@@ -46,6 +46,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
const { widgetsMap } = useHome();
// derived values
const widget = widgetsMap[widgetId] as TWidgetEntityData;
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
// drag and drop
useEffect(() => {
@@ -119,7 +120,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
<div
ref={elementRef}
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,
}
@@ -127,7 +128,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
>
<div className="flex items-center">
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
<div>{widget.key.replaceAll("_", " ")}</div>
<div>{widgetTitle}</div>
</div>
<ToggleSwitch
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} />
</div>
<div className="flex flex-col items-center justify-center">
<RecentsEmptyState />
<RecentsEmptyState type={filter} />
</div>
</div>
);

View File

@@ -3,25 +3,37 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks";
// plane ui
import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useCommandPalette } from "@/hooks/store";
import { useSticky } from "@/hooks/use-stickies";
// components
import { STICKY_COLORS_LIST } from "../editor/sticky-editor/color-palette";
import { AllStickiesModal } from "./modal";
import { StickyNote } from "./sticky";
export const StickyActionBar = observer(() => {
const { workspaceSlug } = useParams();
// states
const [isExpanded, setIsExpanded] = useState(false);
const [newSticky, setNewSticky] = useState(false);
const [showRecentSticky, setShowRecentSticky] = useState(false);
// navigation
const { workspaceSlug } = useParams();
// refs
const ref = useRef(null);
// hooks
// store hooks
const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } =
useSticky();
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(
workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null,
@@ -63,7 +75,7 @@ export const StickyActionBar = observer(() => {
<div
className="absolute top-0 right-0 h-full w-full"
style={{
background: `linear-gradient(to top, ${stickies[recentStickyId]?.color}, transparent)`,
background: `linear-gradient(to top, ${recentStickyBackgroundColor}, transparent)`,
}}
/>
</div>
@@ -75,9 +87,9 @@ export const StickyActionBar = observer(() => {
<button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => setShowRecentSticky(true)}
style={{ color: stickies[recentStickyId]?.color }}
style={{ color: recentStickyBackgroundColor }}
>
<StickyNoteIcon className={cn("size-5 rotate-90")} color={stickies[recentStickyId]?.color} />
<StickyNoteIcon className={cn("size-5 rotate-90")} color={recentStickyBackgroundColor} />
</button>
</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 "./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";
import { FC, useRef, useState } from "react";
import { FC, useCallback, useRef, useState } from "react";
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search, X } from "lucide-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks";
@@ -10,25 +12,41 @@ import { cn } from "@/helpers/common.helper";
import { useSticky } from "@/hooks/use-stickies";
export const StickySearch: FC = observer(() => {
// router
const { workspaceSlug } = useParams();
// hooks
const { searchQuery, updateSearchQuery } = useSticky();
const { searchQuery, updateSearchQuery, fetchWorkspaceStickies } = useSticky();
// refs
const inputRef = useRef<HTMLInputElement>(null);
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else setIsSearchOpen(false);
if (searchQuery && searchQuery.trim() !== "") {
updateSearchQuery("");
fetchStickies();
} else setIsSearchOpen(false);
}
};
const fetchStickies = async () => {
await fetchWorkspaceStickies(workspaceSlug.toString());
};
const debouncedSearch = useCallback(
debounce(async () => {
await fetchStickies();
}, 500),
[fetchWorkspaceStickies]
);
return (
<div className="flex items-center mr-2">
<div className="flex items-center mr-2 my-auto">
{!isSearchOpen && (
<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"
placeholder="Search by title"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onChange={(e) => {
updateSearchQuery(e.target.value);
debouncedSearch();
}}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
@@ -65,6 +86,7 @@ export const StickySearch: FC = observer(() => {
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
fetchStickies();
}}
>
<X className="h-3 w-3" />

View File

@@ -3,8 +3,7 @@ import { useParams } from "next/navigation";
import { Plus, X } from "lucide-react";
import { RecentStickyIcon } from "@plane/ui";
import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
import { StickiesLayout } from "../stickies-layout";
import { StickiesTruncated } from "../layout/stickies-truncated";
import { useStickyOperations } from "../sticky/use-operations";
import { StickySearch } from "./search";
@@ -19,13 +18,13 @@ export const Stickies = observer((props: TProps) => {
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return (
<div className="p-6 pb-0">
<div className="p-6 pb-0 min-h-[620px]">
{/* header */}
<div className="flex items-center justify-between mb-6">
{/* Title */}
<div className="text-custom-text-100 flex gap-2">
<RecentStickyIcon className="size-5 rotate-90" />
<p className="text-lg font-medium">My Stickies</p>
<p className="text-lg font-medium">Your stickies</p>
</div>
{/* actions */}
<div className="flex gap-2">
@@ -33,7 +32,7 @@ export const Stickies = observer((props: TProps) => {
<button
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] });
stickyOperations.create();
}}
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky}
@@ -61,7 +60,7 @@ export const Stickies = observer((props: TProps) => {
</div>
{/* content */}
<div className="mb-4 max-h-[625px] overflow-scroll">
<StickiesLayout />
<StickiesTruncated />
</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 { DebouncedFunc } from "lodash";
import { Controller, useForm } from "react-hook-form";
// plane editor
import { EditorRefApi } from "@plane/editor";
// plane types
import { TSticky } from "@plane/types";
import { TextArea } from "@plane/ui";
// hooks
import { useWorkspace } from "@/hooks/store";
// components
import { StickyEditor } from "../../editor";
type TProps = {
@@ -12,73 +15,45 @@ type TProps = {
workspaceSlug: string;
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
stickyId: string | undefined;
showToolbar?: boolean;
handleChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void;
};
export const StickyInput = (props: TProps) => {
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props;
//refs
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange, showToolbar } = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? "";
// form info
const { handleSubmit, reset, control } = useForm<TSticky>({
defaultValues: {
description_html: stickyData?.description_html,
name: stickyData?.name,
},
});
// computed values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
// handle description update
const handleFormSubmit = useCallback(
async (formdata: Partial<TSticky>) => {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
});
},
[handleUpdate]
);
// reset form values
useEffect(() => {
if (!stickyId) return;
reset({
id: stickyId,
description_html: stickyData?.description_html === "" ? "<p></p>" : stickyData?.description_html,
name: stickyData?.name,
description_html: stickyData?.description_html?.trim() === "" ? "<p></p>" : stickyData?.description_html,
});
}, [stickyData, reset]);
const handleFormSubmit = useCallback(
async (formdata: Partial<TSticky>) => {
if (formdata.name !== undefined) {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
name: formdata.name,
});
} else {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
});
}
},
[handleUpdate, workspaceSlug]
);
}, [stickyData, stickyId, reset]);
return (
<div className="flex-1">
{/* name */}
<Controller
name="name"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
value={value}
id="name"
name="name"
onChange={(e) => {
onChange(e.target.value);
handleSubmit(handleFormSubmit)();
}}
placeholder="Title"
className="text-lg font-medium text-[#455068] mb-2 w-full p-0 border-none min-h-[22px]"
/>
)}
/>
{/* description */}
<Controller
name="description_html"
control={control}
@@ -89,14 +64,14 @@ export const StickyInput = (props: TProps) => {
value={null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
onChange={(_description: object, description_html: string) => {
onChange={(_description, description_html) => {
onChange(description_html);
handleSubmit(handleFormSubmit)();
}}
placeholder={"Click to type here"}
containerClassName={"px-0 text-base min-h-[200px] w-full text-[#455068]"}
placeholder="Click to type here"
containerClassName="px-0 text-base min-h-[250px] w-full"
uploadFile={async () => ""}
showToolbar={false}
showToolbar={showToolbar}
parentClassName={"border-none p-0"}
handleDelete={handleDelete}
handleColorChange={handleChange}

View File

@@ -1,13 +1,19 @@
import { useCallback, useState } from "react";
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import { Minimize2 } from "lucide-react";
// plane types
import { TSticky } from "@plane/types";
// plane utils
import { cn } from "@plane/utils";
// hooks
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 { StickyInput } from "./inputs";
import { StickyItemDragHandle } from "./sticky-item-drag-handle";
import { useStickyOperations } from "./use-operations";
type TProps = {
@@ -15,25 +21,34 @@ type TProps = {
workspaceSlug: string;
className?: string;
stickyId: string | undefined;
showToolbar?: boolean;
};
export const StickyNote = observer((props: TProps) => {
const { onClose, workspaceSlug, className = "", stickyId } = props;
//state
const { onClose, workspaceSlug, className = "", stickyId, showToolbar } = props;
// navigation
const pathName = usePathname();
// states
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// hooks
const { stickyOperations } = useStickyOperations({ workspaceSlug });
// store hooks
const { stickies } = useSticky();
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug });
// 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(
async (payload: Partial<TSticky>) => {
stickyId
? await stickyOperations.update(stickyId, payload)
: await stickyOperations.create({
color: payload.color || STICKY_COLORS[0],
...payload,
});
if (stickyId) {
await stickyOperations.update(stickyId, payload);
} else {
await stickyOperations.create({
...payload,
});
}
},
[stickyId, stickyOperations]
);
@@ -60,10 +75,13 @@ export const StickyNote = observer((props: TProps) => {
/>
<div
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 && (
<button className="flex w-full" onClick={onClose}>
<button type="button" className="flex w-full" onClick={onClose}>
<Minimize2 className="size-4 m-auto mr-0" />
</button>
)}
@@ -73,11 +91,9 @@ export const StickyNote = observer((props: TProps) => {
workspaceSlug={workspaceSlug}
handleUpdate={debouncedFormSave}
stickyId={stickyId}
handleDelete={() => {
if (!stickyId) return;
setIsDeleteModalOpen(true);
}}
handleDelete={() => setIsDeleteModalOpen(true)}
handleChange={handleChange}
showToolbar={showToolbar}
/>
</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 { TSticky } from "@plane/types";
// plane types
import { InstructionType, TSticky } from "@plane/types";
// 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";
export type TOperations = {
create: (data: Partial<TSticky>) => Promise<void>;
create: (data?: Partial<TSticky>) => Promise<void>;
update: (stickyId: string, data: Partial<TSticky>) => Promise<void>;
remove: (stickyId: string) => Promise<void>;
updatePosition: (
workspaceSlug: string,
sourceId: string,
droppedId: string,
instruction: InstructionType
) => Promise<void>;
};
type TProps = {
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) => {
const { workspaceSlug } = props;
const { createSticky, updateSticky, deleteSticky } = useSticky();
// store hooks
const { stickies, getWorkspaceStickyIds, createSticky, updateSticky, deleteSticky, updateStickyPosition } =
useSticky();
const isValid = (data: Partial<TSticky>) => {
if (data.name && data.name.length > 100) {
setToast({
message: "The sticky name cannot be longer than 100 characters",
type: TOAST_TYPE.ERROR,
title: "Sticky not updated",
message: "The sticky name cannot be longer than 100 characters.",
});
return false;
}
@@ -31,23 +51,40 @@ export const useStickyOperations = (props: TProps) => {
const stickyOperations: TOperations = useMemo(
() => ({
create: async (data: Partial<TSticky>) => {
create: async (data?: Partial<TSticky>) => {
try {
const payload: Partial<TSticky> = {
background_color: getRandomStickyColor(),
...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(data)) return;
await createSticky(workspaceSlug, data);
if (!isValid(payload)) return;
await createSticky(workspaceSlug, payload);
setToast({
message: "The sticky has been successfully created",
type: TOAST_TYPE.SUCCESS,
title: "Sticky created",
message: "The sticky has been successfully created.",
});
} catch (error: any) {
console.error("Error in creating sticky:", error);
setToast({
message: error?.data?.error ?? "The sticky could not be created",
type: TOAST_TYPE.ERROR,
title: "Sticky not created",
message: error?.data?.error ?? "The sticky could not be created.",
});
throw error;
}
},
update: async (stickyId: string, data: Partial<TSticky>) => {
@@ -56,12 +93,12 @@ export const useStickyOperations = (props: TProps) => {
if (!isValid(data)) return;
await updateSticky(workspaceSlug, stickyId, data);
} catch (error) {
console.error("Error in updating sticky:", error);
setToast({
message: "The sticky could not be updated",
type: TOAST_TYPE.ERROR,
title: "Sticky not updated",
message: "The sticky could not be updated.",
});
throw error;
}
},
remove: async (stickyId: string) => {
@@ -69,21 +106,39 @@ export const useStickyOperations = (props: TProps) => {
if (!workspaceSlug) throw new Error("Missing required fields");
await deleteSticky(workspaceSlug, stickyId);
setToast({
message: "The sticky has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Sticky removed",
message: "The sticky has been removed successfully.",
});
} catch (error) {
console.error("Error in removing sticky:", error);
setToast({
message: "The sticky could not be removed",
type: TOAST_TYPE.ERROR,
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 {

View File

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

View File

@@ -1,4 +1,5 @@
import { EUserPermissions } from "ee/constants/user-permissions";
import { Plus, Shapes } from "lucide-react";
export interface EmptyStateDetails {
key: EmptyStateType;
@@ -122,9 +123,14 @@ export enum EmptyStateType {
TEAM_EMPTY_FILTER = "team-empty-filter",
TEAM_VIEW = "team-view",
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
[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.",
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;
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;

View File

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

View File

@@ -1,45 +1,50 @@
import { orderBy, set } from "lodash";
import { observable, action, makeObservable, runInAction } from "mobx";
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";
export interface IStickyStore {
creatingSticky: boolean;
fetchingWorkspaceStickies: boolean;
loader: TLoader;
workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds
stickies: Record<string, TSticky>; // stickyId -> sticky
searchQuery: string;
activeStickyId: string | undefined;
recentStickyId: string | undefined;
showAddNewSticky: boolean;
currentPage: number;
totalPages: number;
paginationInfo: TPaginationInfo | undefined;
// computed
getWorkspaceStickies: (workspaceSlug: string) => string[];
getWorkspaceStickyIds: (workspaceSlug: string) => string[];
// actions
toggleShowNewSticky: (value: boolean) => void;
updateSearchQuery: (query: string) => void;
fetchWorkspaceStickies: (workspaceSlug: string, cursor?: string, per_page?: number) => void;
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => void;
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => void;
deleteSticky: (workspaceSlug: string, id: string) => void;
fetchWorkspaceStickies: (workspaceSlug: string) => void;
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => Promise<void>;
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => Promise<void>;
deleteSticky: (workspaceSlug: string, id: string) => Promise<void>;
updateActiveStickyId: (id: string | undefined) => void;
fetchRecentSticky: (workspaceSlug: string) => void;
incrementPage: () => void;
fetchRecentSticky: (workspaceSlug: string) => Promise<void>;
fetchNextWorkspaceStickies: (workspaceSlug: string) => Promise<void>;
updateStickyPosition: (
workspaceSlug: string,
stickyId: string,
destinationId: string,
edge: InstructionType
) => Promise<void>;
}
export class StickyStore implements IStickyStore {
loader: TLoader = "init-loader";
creatingSticky = false;
fetchingWorkspaceStickies = true;
workspaceStickies: Record<string, string[]> = {};
stickies: Record<string, TSticky> = {};
recentStickyId: string | undefined = undefined;
searchQuery = "";
activeStickyId: string | undefined = undefined;
showAddNewSticky = false;
currentPage = 0;
totalPages = 0;
paginationInfo: TPaginationInfo | undefined = undefined;
// services
stickyService;
@@ -48,33 +53,35 @@ export class StickyStore implements IStickyStore {
makeObservable(this, {
// observables
creatingSticky: observable,
fetchingWorkspaceStickies: observable,
loader: observable,
activeStickyId: observable,
showAddNewSticky: observable,
recentStickyId: observable,
workspaceStickies: observable,
stickies: observable,
searchQuery: observable,
currentPage: observable,
totalPages: observable,
// actions
updateSearchQuery: action,
updateSticky: action,
deleteSticky: action,
incrementPage: action,
fetchNextWorkspaceStickies: action,
fetchWorkspaceStickies: action,
createSticky: action,
updateActiveStickyId: action,
toggleShowNewSticky: action,
fetchRecentSticky: action,
updateStickyPosition: action,
});
this.stickyService = new StickyService();
}
getWorkspaceStickies = computedFn((workspaceSlug: string) => {
let filteredStickies = (this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]);
if (this.searchQuery) {
filteredStickies = filteredStickies.filter(
(sticky) => sticky.name && sticky.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
}
return filteredStickies.map((sticky) => sticky.id);
});
getWorkspaceStickyIds = computedFn((workspaceSlug: string) =>
orderBy(
(this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]),
["sort_order"],
["desc"]
).map((sticky) => sticky.id)
);
toggleShowNewSticky = (value: boolean) => {
this.showAddNewSticky = value;
@@ -88,34 +95,77 @@ export class StickyStore implements IStickyStore {
this.activeStickyId = id;
};
incrementPage = () => {
this.currentPage += 1;
};
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(() => {
this.recentStickyId = response.results[0]?.id;
this.stickies[response.results[0]?.id] = response.results[0];
});
};
fetchWorkspaceStickies = async (workspaceSlug: string, cursor?: string, per_page?: number) => {
fetchNextWorkspaceStickies = async (workspaceSlug: string) => {
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(() => {
response.results.forEach((sticky) => {
const { results, ...paginationInfo } = response;
// Add new stickies to store
results.forEach((sticky) => {
if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) {
this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id];
}
this.stickies[sticky.id] = sticky;
});
this.totalPages = response.total_pages;
this.fetchingWorkspaceStickies = false;
// Update pagination info directly from backend
set(this, "paginationInfo", paginationInfo);
set(this, "loader", "loaded");
});
} catch (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];
if (!sticky) return;
try {
this.stickies[id] = {
...sticky,
...updates,
updatedAt: new Date(),
};
runInAction(() => {
Object.keys(updates).forEach((key) => {
const currentStickyKey = key as keyof TSticky;
set(this.stickies[id], key, updates[currentStickyKey] || undefined);
});
});
this.recentStickyId = id;
await this.stickyService.updateSticky(workspaceSlug, id, updates);
} catch (e) {
console.log(e);
} catch (error) {
console.error("Error in updating sticky:", error);
this.stickies[id] = sticky;
throw new Error();
}
};
@@ -167,4 +219,53 @@ export class StickyStore implements IStickyStore {
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>;
widgets: THomeWidgetKeys[];
// computed
isAnyWidgetEnabled: boolean;
orderedWidgets: THomeWidgetKeys[];
//stores
quickLinks: IWorkspaceLinkStore;
@@ -38,6 +39,7 @@ export class HomeStore implements IHomeStore {
widgetsMap: observable,
widgets: observable,
// computed
isAnyWidgetEnabled: computed,
orderedWidgets: computed,
// actions
toggleWidgetSettings: action,
@@ -52,6 +54,10 @@ export class HomeStore implements IHomeStore {
this.quickLinks = new WorkspaceLinkStore();
}
get isAnyWidgetEnabled() {
return Object.values(this.widgetsMap).some((widget) => widget.is_enabled);
}
get orderedWidgets() {
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-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 */
}
* {