[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:
parent
d2c9b437f4
commit
fd7eedc343
56 changed files with 1347 additions and 574 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
1
packages/constants/src/stickies.ts
Normal file
1
packages/constants/src/stickies.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const STICKIES_PER_PAGE = 30;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
16
packages/types/src/stickies.d.ts
vendored
16
packages/types/src/stickies.d.ts
vendored
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
59
web/app/[workspaceSlug]/(projects)/stickies/header.tsx
Normal file
59
web/app/[workspaceSlug]/(projects)/stickies/header.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
13
web/app/[workspaceSlug]/(projects)/stickies/layout.tsx
Normal file
13
web/app/[workspaceSlug]/(projects)/stickies/layout.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
web/app/[workspaceSlug]/(projects)/stickies/page.tsx
Normal file
16
web/app/[workspaceSlug]/(projects)/stickies/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,16 +133,18 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
|
||||||
pointerEvents: isTransitioning ? "none" : "auto",
|
pointerEvents: isTransitioning ? "none" : "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
{customButton || (
|
||||||
className={cn(
|
<button
|
||||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
|
className={cn(
|
||||||
buttonClassName
|
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
|
||||||
)}
|
buttonClassName
|
||||||
onClick={handleToggle}
|
)}
|
||||||
disabled={isTransitioning}
|
onClick={handleToggle}
|
||||||
>
|
disabled={isTransitioning}
|
||||||
{showAll ? "Show less" : "Show all"}
|
>
|
||||||
</button>
|
{showAll ? "Show less" : "Show all"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
78
web/core/components/editor/sticky-editor/color-palette.tsx
Normal file
78
web/core/components/editor/sticky-editor/color-palette.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -82,26 +82,28 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||||
containerClassName={cn(containerClassName, "relative")}
|
containerClassName={cn(containerClassName, "relative")}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
<div
|
{showToolbar && (
|
||||||
className={cn(
|
<div
|
||||||
"transition-all duration-300 ease-out origin-top",
|
className={cn(
|
||||||
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
|
"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) => {
|
<Toolbar
|
||||||
// TODO: update this while toolbar homogenization
|
executeCommand={(item) => {
|
||||||
// @ts-expect-error type mismatch here
|
// TODO: update this while toolbar homogenization
|
||||||
editorRef?.executeMenuItemCommand({
|
// @ts-expect-error type mismatch here
|
||||||
itemKey: item.itemKey,
|
editorRef?.executeMenuItemCommand({
|
||||||
...item.extraProps,
|
itemKey: item.itemKey,
|
||||||
});
|
...item.extraProps,
|
||||||
}}
|
});
|
||||||
handleDelete={handleDelete}
|
}}
|
||||||
handleColorChange={handleColorChange}
|
handleDelete={handleDelete}
|
||||||
editorRef={editorRef}
|
handleColorChange={handleColorChange}
|
||||||
/>
|
editorRef={editorRef}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,96 @@
|
||||||
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)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
|
{isAnyWidgetEnabled ? (
|
||||||
{orderedWidgets.map((key) => {
|
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
|
||||||
const WidgetComponent = WIDGETS_LIST[key]?.component;
|
{orderedWidgets.map((key) => {
|
||||||
const isEnabled = widgetsMap[key]?.is_enabled;
|
const WidgetComponent = HOME_WIDGETS_LIST[key]?.component;
|
||||||
if (!WidgetComponent || !isEnabled) return null;
|
const isEnabled = widgetsMap[key]?.is_enabled;
|
||||||
return (
|
if (!WidgetComponent || !isEnabled) return null;
|
||||||
<div key={key} className="py-4">
|
return (
|
||||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
<div key={key} className="py-4">
|
||||||
</div>
|
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
};
|
||||||
|
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 don’t 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 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 don’t have any recent items yet. </div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) => (
|
||||||
|
|
|
||||||
13
web/core/components/home/widgets/empty-states/stickies.tsx
Normal file
13
web/core/components/home/widgets/empty-states/stickies.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./action-bar";
|
export * from "./action-bar";
|
||||||
export * from "./widget";
|
export * from "./widget";
|
||||||
|
export * from "./layout";
|
||||||
|
|
|
||||||
3
web/core/components/stickies/layout/index.ts
Normal file
3
web/core/components/stickies/layout/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from "./stickies-infinite";
|
||||||
|
export * from "./stickies-list";
|
||||||
|
export * from "./stickies-truncated";
|
||||||
62
web/core/components/stickies/layout/stickies-infinite.tsx
Normal file
62
web/core/components/stickies/layout/stickies-infinite.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
168
web/core/components/stickies/layout/stickies-list.tsx
Normal file
168
web/core/components/stickies/layout/stickies-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
44
web/core/components/stickies/layout/stickies-truncated.tsx
Normal file
44
web/core/components/stickies/layout/stickies-truncated.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
137
web/core/components/stickies/layout/sticky-dnd-wrapper.tsx
Normal file
137
web/core/components/stickies/layout/sticky-dnd-wrapper.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
45
web/core/components/stickies/layout/sticky.helpers.ts
Normal file
45
web/core/components/stickies/layout/sticky.helpers.ts
Normal 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;
|
||||||
|
};
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
||||||
|
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 (!workspaceSlug) throw new Error("Missing required fields");
|
||||||
if (!isValid(data)) return;
|
if (!isValid(payload)) return;
|
||||||
await createSticky(workspaceSlug, data);
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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="size-4 border-2 border-t-transparent border-custom-primary-100 rounded-full animate-spin"
|
||||||
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
|
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>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -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, Plane’s 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 project’s context. To make short work of any doc, invoke Galileo, Plane’s AI, with a shortcut or the click of a button.",
|
"Write a note, a doc, or a full knowledge base. Get Galileo, Plane’s 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 project’s context. To make short work of any doc, invoke Galileo, Plane’s 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;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
web/public/empty-state/dashboard/widgets-dark.webp
Normal file
BIN
web/public/empty-state/dashboard/widgets-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
web/public/empty-state/dashboard/widgets-light.webp
Normal file
BIN
web/public/empty-state/dashboard/widgets-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
web/public/empty-state/stickies/stickies-dark.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
web/public/empty-state/stickies/stickies-light.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
BIN
web/public/empty-state/stickies/stickies-search-dark.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-search-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
web/public/empty-state/stickies/stickies-search-light.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-search-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
|
|
@ -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 */
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue