mirror of
https://github.com/makeplane/plane.git
synced 2026-01-19 21:00:18 -06:00
[WEB-3048] feat: added-stickies (#6339)
* feat: added-stickies * fix: recents empty state fixed * fix: added border * Change sort_order field * fix: remvoved btn * fix: sticky toolbar * fix: build * fix: sticky search * fix: minor css fix * fix: issue identifier css handled * fix: issue type default icon * fix: added tooltip for color palette and delete --------- Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
@@ -5,6 +5,11 @@ from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Workspace
|
||||
from plane.app.serializers.workspace import WorkspaceHomePreferenceSerializer
|
||||
|
||||
# Django imports
|
||||
|
||||
from django.db.models import Count
|
||||
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
@@ -28,20 +33,29 @@ class WorkspacePreferenceViewSet(BaseAPIView):
|
||||
|
||||
keys = [key for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices]
|
||||
|
||||
sort_order_counter = 1
|
||||
|
||||
for preference in keys:
|
||||
if preference not in get_preference.values_list("key", flat=True):
|
||||
create_preference_keys.append(preference)
|
||||
|
||||
sort_order = 1000 - sort_order_counter
|
||||
|
||||
preference = WorkspaceHomePreference.objects.bulk_create(
|
||||
[
|
||||
WorkspaceHomePreference(
|
||||
key=key, user=request.user, workspace=workspace
|
||||
key=key,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
for key in create_preference_keys
|
||||
],
|
||||
batch_size=10,
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
sort_order_counter += 1
|
||||
|
||||
preference = WorkspaceHomePreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
|
||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
@@ -39,3 +39,4 @@ export * from "./activity";
|
||||
export * from "./epics";
|
||||
export * from "./charts";
|
||||
export * from "./home";
|
||||
export * from "./stickies";
|
||||
|
||||
8
packages/types/src/stickies.d copy.ts
Normal file
8
packages/types/src/stickies.d copy.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type TSticky = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description_html?: string;
|
||||
color?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
8
packages/types/src/stickies.d.ts
vendored
Normal file
8
packages/types/src/stickies.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export type TSticky = {
|
||||
id: string;
|
||||
name?: string;
|
||||
description_html?: string;
|
||||
color?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
@@ -47,3 +47,5 @@ export * from "./overview-icon";
|
||||
export * from "./on-track-icon";
|
||||
export * from "./off-track-icon";
|
||||
export * from "./at-risk-icon";
|
||||
export * from "./multiple-sticky";
|
||||
export * from "./sticky-note-icon";
|
||||
|
||||
28
packages/ui/src/icons/multiple-sticky.tsx
Normal file
28
packages/ui/src/icons/multiple-sticky.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const RecentStickyIcon: React.FC<ISvgIcons> = ({ className = "text-current", ...rest }) => (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.23602 12.7638C2.60067 13.1285 3.09525 13.3333 3.61095 13.3333H9.72206C9.94307 13.3333 10.155 13.2455 10.3113 13.0892L13.0891 10.3115C13.2454 10.1552 13.3332 9.94323 13.3332 9.72221V3.6111C13.3332 3.0954 13.1283 2.60083 12.7637 2.23617C12.399 1.87152 11.9044 1.66666 11.3887 1.66666H3.61095C3.09525 1.66666 2.60067 1.87152 2.23602 2.23617C1.87136 2.60083 1.6665 3.0954 1.6665 3.6111V11.3889C1.6665 11.9046 1.87136 12.3992 2.23602 12.7638ZM11.0435 9.99999L9.99984 11.0437V10.2778C9.99984 10.2041 10.0291 10.1334 10.0812 10.0813C10.1333 10.0293 10.2039 9.99999 10.2776 9.99999H11.0435ZM8.33317 11.6667V10.2778C8.33317 9.76207 8.53803 9.26749 8.90269 8.90284C9.26734 8.53819 9.76192 8.33332 10.2776 8.33332H11.6665V3.6111C11.6665 3.53743 11.6372 3.46678 11.5851 3.41468C11.5331 3.36259 11.4624 3.33332 11.3887 3.33332H3.61095C3.53728 3.33332 3.46662 3.36259 3.41453 3.41468C3.36244 3.46678 3.33317 3.53743 3.33317 3.6111V11.3889C3.33317 11.4626 3.36244 11.5332 3.41453 11.5853C3.46662 11.6374 3.53728 11.6667 3.61095 11.6667H8.33317Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M6.36088 16.832C6.65667 17.2545 7.10816 17.5421 7.61603 17.6317L13.6343 18.6928C13.852 18.7312 14.0759 18.6816 14.257 18.5548L17.4749 16.3016C17.656 16.1748 17.7792 15.9813 17.8176 15.7636L18.8788 9.74538C18.9683 9.23752 18.8525 8.71488 18.5567 8.29244C18.2609 7.87001 17.8094 7.58238 17.3015 7.49283L15.1445 7.11249C14.6913 7.03257 14.2591 7.33521 14.1792 7.78846C14.0992 8.2417 14.4019 8.67392 14.8551 8.75384L17.0121 9.13417C17.0847 9.14697 17.1492 9.18806 17.1914 9.2484C17.2337 9.30875 17.2502 9.38341 17.2374 9.45597L16.4174 14.1064L15.0497 13.8653C14.5418 13.7757 14.0192 13.8916 13.5967 14.1874C13.1743 14.4832 12.8867 14.9347 12.7971 15.4425L12.5559 16.8103L7.90544 15.9903C7.83289 15.9775 7.76839 15.9364 7.72613 15.8761C7.68388 15.8157 7.66733 15.7411 7.68012 15.6685L7.77252 15.1445C7.85244 14.6913 7.5498 14.259 7.09655 14.1791C6.64331 14.0992 6.21109 14.4018 6.13117 14.8551L6.03877 15.3791C5.94922 15.887 6.06509 16.4096 6.36088 16.832ZM14.3054 16.4862L14.4384 15.7319C14.4512 15.6594 14.4923 15.5949 14.5527 15.5526C14.613 15.5104 14.6877 15.4938 14.7602 15.5066L15.5145 15.6396L14.3054 16.4862Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
36
packages/ui/src/icons/sticky-note-icon.tsx
Normal file
36
packages/ui/src/icons/sticky-note-icon.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { ISvgIcons } from "./type";
|
||||
|
||||
export const StickyNoteIcon: React.FC<ISvgIcons> = ({ width = "17", height = "17", className, color }) => (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
viewBox="0 0 17 17"
|
||||
fill={"currentColor"}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ color }}
|
||||
>
|
||||
<path
|
||||
d="M11.9167 16.0833H2.75008C2.30805 16.0833 1.88413 15.9077 1.57157 15.5951C1.25901 15.2826 1.08341 14.8587 1.08341 14.4166V2.74996C1.08341 2.30793 1.25901 1.88401 1.57157 1.57145C1.88413 1.25889 2.30805 1.08329 2.75008 1.08329H14.4167C14.8588 1.08329 15.2827 1.25889 15.5953 1.57145C15.9078 1.88401 16.0834 2.30793 16.0834 2.74996V11.9166L11.9167 16.0833Z"
|
||||
style={{ opacity: 0.5 }}
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.0834 16.0833V12.75C11.0834 12.3079 11.259 11.884 11.5716 11.5714C11.8841 11.2589 12.3081 11.0833 12.7501 11.0833H16.0834"
|
||||
style={{ opacity: 0.5 }}
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M1.27694 15.8898C1.66764 16.2805 2.19755 16.5 2.75008 16.5H11.9167C12.0273 16.5 12.1332 16.4561 12.2114 16.3779L16.378 12.2113C16.4562 12.1331 16.5001 12.0271 16.5001 11.9166V2.74996C16.5001 2.19742 16.2806 1.66752 15.8899 1.27682C15.4992 0.88612 14.9693 0.666626 14.4167 0.666626H2.75008C2.19755 0.666626 1.66764 0.88612 1.27694 1.27682C0.886241 1.66752 0.666748 2.19742 0.666748 2.74996V14.4166C0.666748 14.9692 0.886241 15.4991 1.27694 15.8898ZM15.6667 11.5V11.744L11.7442 15.6666H11.5001V12.75C11.5001 12.4184 11.6318 12.1005 11.8662 11.8661C12.1006 11.6317 12.4186 11.5 12.7501 11.5H15.6667ZM10.6667 15.6666V12.75C10.6667 12.1974 10.8862 11.6675 11.2769 11.2768C11.6676 10.8861 12.1975 10.6666 12.7501 10.6666H15.6667V2.74996C15.6667 2.41844 15.5351 2.1005 15.3006 1.86608C15.0662 1.63166 14.7483 1.49996 14.4167 1.49996H2.75008C2.41856 1.49996 2.10062 1.63166 1.8662 1.86608C1.63178 2.1005 1.50008 2.41844 1.50008 2.74996V14.4166C1.50008 14.7481 1.63178 15.0661 1.8662 15.3005C2.10062 15.5349 2.41856 15.6666 2.75008 15.6666H10.6667Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M11.0816 12.7499C11.0491 13.2706 11.0816 16.0833 11.0816 16.0833H11.9149L16.0816 11.9166V11.0833H12.7483C11.5001 11.0833 11.1141 12.2293 11.0816 12.7499Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./widget";
|
||||
@@ -1 +0,0 @@
|
||||
export const StickiesWidget = () => <></>;
|
||||
@@ -2,3 +2,4 @@ export * from "./embeds";
|
||||
export * from "./lite-text-editor";
|
||||
export * from "./pdf";
|
||||
export * from "./rich-text-editor";
|
||||
export * from "./sticky-editor";
|
||||
|
||||
36
web/core/components/editor/sticky-editor/color-pallete.tsx
Normal file
36
web/core/components/editor/sticky-editor/color-pallete.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { TSticky } from "@plane/types";
|
||||
|
||||
export const STICKY_COLORS = [
|
||||
"#D4DEF7", // light periwinkle
|
||||
"#B4E4FF", // light blue
|
||||
"#FFF2B4", // light yellow
|
||||
"#E3E3E3", // light gray
|
||||
"#FFE2DD", // light pink
|
||||
"#F5D1A5", // light orange
|
||||
"#D1F7C4", // light green
|
||||
"#E5D4FF", // light purple
|
||||
];
|
||||
|
||||
type TProps = {
|
||||
handleUpdate: (data: Partial<TSticky>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ColorPalette = (props: TProps) => {
|
||||
const { handleUpdate } = props;
|
||||
return (
|
||||
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
|
||||
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STICKY_COLORS.map((color, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={() => handleUpdate({ color })}
|
||||
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
web/core/components/editor/sticky-editor/editor.tsx
Normal file
109
web/core/components/editor/sticky-editor/editor.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useState } from "react";
|
||||
// plane constants
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane editor
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
||||
// components
|
||||
import { TSticky } from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
import { Toolbar } from "./toolbar";
|
||||
|
||||
interface StickyEditorWrapperProps
|
||||
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
projectId?: string;
|
||||
accessSpecifier?: EIssueCommentAccessSpecifier;
|
||||
handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;
|
||||
showAccessSpecifier?: boolean;
|
||||
showSubmitButton?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
showToolbarInitially?: boolean;
|
||||
showToolbar?: boolean;
|
||||
uploadFile: (file: File) => Promise<string>;
|
||||
parentClassName?: string;
|
||||
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
|
||||
handleDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperProps>((props, ref) => {
|
||||
const {
|
||||
containerClassName,
|
||||
workspaceSlug,
|
||||
workspaceId,
|
||||
projectId,
|
||||
handleDelete,
|
||||
handleColorChange,
|
||||
showToolbarInitially = true,
|
||||
showToolbar = true,
|
||||
parentClassName = "",
|
||||
placeholder = "Add comment...",
|
||||
uploadFile,
|
||||
...rest
|
||||
} = props;
|
||||
// states
|
||||
const [isFocused, setIsFocused] = useState(showToolbarInitially);
|
||||
// editor flaggings
|
||||
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
||||
return !!ref && typeof ref === "object" && "current" in ref;
|
||||
}
|
||||
// derived values
|
||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative border border-custom-border-200 rounded p-3", parentClassName)}
|
||||
onFocus={() => !showToolbarInitially && setIsFocused(true)}
|
||||
onBlur={() => !showToolbarInitially && setIsFocused(false)}
|
||||
>
|
||||
<LiteTextEditorWithRef
|
||||
ref={ref}
|
||||
disabledExtensions={[...disabledExtensions, "enter-key"]}
|
||||
fileHandler={getEditorFileHandlers({
|
||||
maxFileSize,
|
||||
projectId,
|
||||
uploadFile,
|
||||
workspaceId,
|
||||
workspaceSlug,
|
||||
})}
|
||||
mentionHandler={{
|
||||
renderComponent: () => <></>,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
containerClassName={cn(containerClassName, "relative")}
|
||||
{...rest}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-out origin-top",
|
||||
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
|
||||
)}
|
||||
>
|
||||
<Toolbar
|
||||
executeCommand={(item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
editorRef?.executeMenuItemCommand({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
}}
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleColorChange}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StickyEditor.displayName = "StickyEditor";
|
||||
2
web/core/components/editor/sticky-editor/index.ts
Normal file
2
web/core/components/editor/sticky-editor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./editor";
|
||||
export * from "./toolbar";
|
||||
131
web/core/components/editor/sticky-editor/toolbar.tsx
Normal file
131
web/core/components/editor/sticky-editor/toolbar.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Palette, Trash2 } from "lucide-react";
|
||||
// editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// ui
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { TSticky } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { ColorPalette } from "./color-pallete";
|
||||
|
||||
type Props = {
|
||||
executeCommand: (item: ToolbarMenuItem) => void;
|
||||
editorRef: EditorRefApi | null;
|
||||
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
|
||||
handleDelete: () => void;
|
||||
};
|
||||
|
||||
const toolbarItems = TOOLBAR_ITEMS.sticky;
|
||||
|
||||
export const Toolbar: React.FC<Props> = (props) => {
|
||||
const { executeCommand, editorRef, handleColorChange, handleDelete } = props;
|
||||
|
||||
// State to manage active states of toolbar items
|
||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||
const [showColorPalette, setShowColorPalette] = useState(false);
|
||||
const colorPaletteRef = React.useRef<HTMLDivElement>(null);
|
||||
// Function to update active states
|
||||
const updateActiveStates = useCallback(() => {
|
||||
if (!editorRef) return;
|
||||
const newActiveStates: Record<string, boolean> = {};
|
||||
Object.values(toolbarItems)
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
newActiveStates[item.renderKey] = editorRef.isMenuItemActive({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
});
|
||||
setActiveStates(newActiveStates);
|
||||
}, [editorRef]);
|
||||
|
||||
// useEffect to call updateActiveStates when isActive prop changes
|
||||
useEffect(() => {
|
||||
if (!editorRef) return;
|
||||
const unsubscribe = editorRef.onStateChange(updateActiveStates);
|
||||
updateActiveStates();
|
||||
return () => unsubscribe();
|
||||
}, [editorRef, updateActiveStates]);
|
||||
|
||||
useOutsideClickDetector(colorPaletteRef, () => setShowColorPalette(false));
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-between mt-2 h-full">
|
||||
<div className="flex my-auto gap-4" ref={colorPaletteRef}>
|
||||
{/* color palette */}
|
||||
{showColorPalette && <ColorPalette handleUpdate={handleColorChange} />}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<p className="flex flex-col gap-1 text-center text-xs">
|
||||
<span className="font-medium">Background color</span>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button onClick={() => setShowColorPalette(!showColorPalette)} className="flex text-custom-text-300">
|
||||
<Palette className="size-4 my-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex w-fit items-stretch justify-between gap-4 rounded p-1 my-auto">
|
||||
<div className="flex items-stretch my-auto gap-4">
|
||||
{Object.keys(toolbarItems).map((key) => (
|
||||
<div key={key} className={cn("flex items-stretch gap-4", {})}>
|
||||
{toolbarItems[key].map((item) => {
|
||||
const isItemActive = activeStates[item.renderKey];
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.renderKey}
|
||||
tooltipContent={
|
||||
<p className="flex flex-col gap-1 text-center text-xs">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
{item.shortcut && <kbd className="text-custom-text-400">{item.shortcut.join(" + ")}</kbd>}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => executeCommand(item)}
|
||||
className={cn(
|
||||
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-300",
|
||||
{}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"font-extrabold": isItemActive,
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* delete action */}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<p className="flex flex-col gap-1 text-center text-xs">
|
||||
<span className="font-medium">Delete</span>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button onClick={handleDelete} className="my-auto text-custom-text-300">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// components
|
||||
import { HomePageHeader } from "@/plane-web/components/home/header";
|
||||
import { StickiesWidget } from "@/plane-web/components/stickies";
|
||||
import { StickiesWidget } from "../stickies";
|
||||
import { RecentActivityWidget } from "./widgets";
|
||||
import { DashboardQuickLinks } from "./widgets/links";
|
||||
import { ManageWidgetsModal } from "./widgets/manage";
|
||||
@@ -37,19 +37,18 @@ export const DashboardWidgets = observer(() => {
|
||||
isModalOpen={showWidgetSettings}
|
||||
handleOnClose={() => toggleWidgetSettings(false)}
|
||||
/>
|
||||
|
||||
{orderedWidgets.map((key) => {
|
||||
const WidgetComponent = WIDGETS_LIST[key]?.component;
|
||||
const isEnabled = widgetsMap[key]?.is_enabled;
|
||||
if (!WidgetComponent || !isEnabled) return null;
|
||||
if (WIDGETS_LIST[key]?.fullWidth)
|
||||
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
|
||||
{orderedWidgets.map((key) => {
|
||||
const WidgetComponent = WIDGETS_LIST[key]?.component;
|
||||
const isEnabled = widgetsMap[key]?.is_enabled;
|
||||
if (!WidgetComponent || !isEnabled) return null;
|
||||
return (
|
||||
<div key={key} className="lg:col-span-2">
|
||||
<div key={key} className="py-4">
|
||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
);
|
||||
else return <WidgetComponent key={key} workspaceSlug={workspaceSlug.toString()} />;
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,15 +3,13 @@ import { useParams } from "next/navigation";
|
||||
// components
|
||||
import useSWR from "swr";
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { TourRoot } from "@/components/onboarding";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { PRODUCT_TOUR_COMPLETED } from "@/constants/event-tracker";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useUserProfile, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
import { useUserProfile, useEventTracker, useUser } from "@/hooks/store";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
import { IssuePeekOverview } from "../issues";
|
||||
@@ -20,17 +18,11 @@ import { UserGreetingsView } from "./user-greetings";
|
||||
|
||||
export const WorkspaceHomeView = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
// captureEvent,
|
||||
setTrackElement,
|
||||
} = useEventTracker();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { data: currentUser } = useUser();
|
||||
const { data: currentUserProfile, updateTourCompleted } = useUserProfile();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { toggleWidgetSettings, fetchWidgets } = useHome();
|
||||
const { joinedProjectIds, loader } = useProject();
|
||||
const [windowWidth] = useSize();
|
||||
|
||||
useSWR(
|
||||
@@ -64,34 +56,18 @@ export const WorkspaceHomeView = observer(() => {
|
||||
<TourRoot onComplete={handleTourCompleted} />
|
||||
</div>
|
||||
)}
|
||||
{joinedProjectIds && (
|
||||
<>
|
||||
{joinedProjectIds.length > 0 || loader ? (
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
<ContentWrapper
|
||||
className={cn("gap-7 bg-custom-background-90/20", {
|
||||
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
|
||||
})}
|
||||
>
|
||||
{currentUser && (
|
||||
<UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />
|
||||
)}
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
<ContentWrapper
|
||||
className={cn("gap-7 bg-custom-background-90/20", {
|
||||
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
|
||||
})}
|
||||
>
|
||||
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
|
||||
|
||||
<DashboardWidgets />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
type={EmptyStateType.WORKSPACE_DASHBOARD}
|
||||
primaryButtonOnClick={() => {
|
||||
setTrackElement("Dashboard empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DashboardWidgets />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
21
web/core/components/home/widgets/empty-states/issues.tsx
Normal file
21
web/core/components/home/widgets/empty-states/issues.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
|
||||
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
|
||||
|
||||
export const IssuesEmptyState = () => {
|
||||
// next-themes
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const image = resolvedTheme === "dark" ? UpcomingIssuesDark : UpcomingIssuesLight;
|
||||
|
||||
// TODO: update empty state logic to use a general component
|
||||
return (
|
||||
<div className="text-center space-y-6 flex flex-col items-center justify-center">
|
||||
<div className="h-24 w-24">
|
||||
<Image src={image} className="w-full h-full" alt="Assigned issues" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">No activity to display</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -31,7 +31,7 @@ export const DashboardQuickLinks = observer((props: THomeWidgetProps) => {
|
||||
preloadedData={linkData}
|
||||
setLinkData={setLinkData}
|
||||
/>
|
||||
<div className="flex mx-auto flex-wrap border-b border-custom-border-100 pb-4 w-full justify-center">
|
||||
<div className="flex mx-auto flex-wrap pb-4 w-full justify-center">
|
||||
{/* rendering links */}
|
||||
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
// plane ui
|
||||
import { Button, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { WidgetList } from "./widget-list";
|
||||
|
||||
export type TProps = {
|
||||
@@ -22,14 +22,6 @@ export const ManageWidgetsModal: FC<TProps> = observer((props) => {
|
||||
<div className="p-4">
|
||||
<div className="font-medium text-xl">Manage widgets</div>
|
||||
<WidgetList workspaceSlug={workspaceSlug} />
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="md" onClick={handleOnClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="md" type="submit">
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Briefcase, FileText } from "lucide-react";
|
||||
import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
// components
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import { EmptyWorkspace } from "../empty-states";
|
||||
import { IssuesEmptyState } from "../empty-states/issues";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { FiltersDropdown } from "./filters";
|
||||
import { RecentIssue } from "./issue";
|
||||
@@ -31,6 +33,7 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(filters[0].name);
|
||||
// ref
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { joinedProjectIds, loader } = useProject();
|
||||
|
||||
const { data: recents, isLoading } = useSWR(
|
||||
workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
|
||||
@@ -61,19 +64,33 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoading && recents?.length === 0) return <EmptyWorkspace />;
|
||||
if (!loader && joinedProjectIds?.length === 0) return <EmptyWorkspace />;
|
||||
if (!isLoading && recents?.length === 0)
|
||||
return (
|
||||
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-base font-semibold text-custom-text-350">Recents</div>
|
||||
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
|
||||
</div>
|
||||
<div className="min-h-[400px] flex flex-col items-center justify-center">
|
||||
<IssuesEmptyState />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
|
||||
<div ref={ref} className=" max-h-[500px] min-h-[400px] overflow-y-scroll">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-base font-semibold text-custom-text-350 hover:underline">Recents</div>
|
||||
<div className="text-base font-semibold text-custom-text-350">Recents</div>
|
||||
|
||||
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
|
||||
</div>
|
||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isLoading &&
|
||||
recents?.length > 0 &&
|
||||
recents.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
<div className="min-h-[400px] flex flex-col">
|
||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isLoading &&
|
||||
recents?.length > 0 &&
|
||||
recents.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TActivityEntityData, TIssueEntityData } from "@plane/types";
|
||||
import { PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui";
|
||||
import { LayersIcon, PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MemberDropdown } from "@/components/dropdowns";
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
@@ -27,13 +27,25 @@ export const RecentIssue = (props: BlockProps) => {
|
||||
title={""}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<IssueIdentifier
|
||||
issueTypeId={issueDetails?.type}
|
||||
projectId={issueDetails?.project_id || ""}
|
||||
projectIdentifier={issueDetails?.project_identifier || ""}
|
||||
issueSequenceId={issueDetails?.sequence_id || ""}
|
||||
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
|
||||
/>
|
||||
{issueDetails.type ? (
|
||||
<IssueIdentifier
|
||||
size="lg"
|
||||
issueTypeId={issueDetails?.type}
|
||||
projectId={issueDetails?.project_id || ""}
|
||||
projectIdentifier={issueDetails?.project_identifier || ""}
|
||||
issueSequenceId={issueDetails?.sequence_id || ""}
|
||||
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
|
||||
<LayersIcon className="w-4 h-4 text-custom-text-350" />
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{issueDetails?.project_identifier}-{issueDetails?.sequence_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{issueDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
</div>
|
||||
|
||||
@@ -28,17 +28,19 @@ export const RecentPage = (props: BlockProps) => {
|
||||
title={""}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
|
||||
<>
|
||||
{pageDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-custom-text-300" />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{pageDetails?.project_identifier}
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-2 bg-custom-background-80 w-[25.5px] h-[25.5px]">
|
||||
<>
|
||||
{pageDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-custom-text-350" />
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{pageDetails?.project_identifier}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{pageDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
|
||||
@@ -24,11 +24,13 @@ export const RecentProject = (props: BlockProps) => {
|
||||
title={""}
|
||||
prependTitleElement={
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{projectDetails?.identifier}
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</div>
|
||||
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
|
||||
{projectDetails?.identifier}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{projectDetails?.name}</div>
|
||||
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
|
||||
|
||||
129
web/core/components/stickies/action-bar.tsx
Normal file
129
web/core/components/stickies/action-bar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useCommandPalette } from "@/hooks/store";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { AllStickiesModal } from "./modal";
|
||||
import { StickyNote } from "./sticky";
|
||||
|
||||
export const StickyActionBar = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [newSticky, setNewSticky] = useState(false);
|
||||
const [showRecentSticky, setShowRecentSticky] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
// hooks
|
||||
const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } =
|
||||
useSticky();
|
||||
const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchRecentSticky(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
useOutsideClickDetector(ref, () => {
|
||||
setNewSticky(false);
|
||||
setShowRecentSticky(false);
|
||||
setIsExpanded(false);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="sticky-action-bar__item flex flex-col bg-custom-background-90 rounded-full p-[2px] border-2 border-custom-primary-100/10 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col gap-2 transition-all duration-300 ease-in-out origin-bottom ${isExpanded ? "scale-y-100 opacity-100 mb-2 " : "scale-y-0 opacity-0 h-0"}`}
|
||||
>
|
||||
<Tooltip tooltipContent="All stickies" isMobile={false} position="left">
|
||||
<button
|
||||
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
|
||||
onClick={() => toggleAllStickiesModal(true)}
|
||||
>
|
||||
<RecentStickyIcon className="size-5 rotate-90 text-custom-text-350" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{recentStickyId && (
|
||||
<Tooltip
|
||||
className="scale-75 -mr-30 translate-x-10"
|
||||
tooltipContent={
|
||||
<div className="-m-2 max-h-[150px]">
|
||||
<StickyNote
|
||||
className={"w-[290px]"}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
stickyId={newSticky ? activeStickyId : recentStickyId || ""}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-full"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${stickies[recentStickyId]?.color}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
isMobile={false}
|
||||
position="left"
|
||||
disabled={showRecentSticky}
|
||||
>
|
||||
<button
|
||||
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
|
||||
onClick={() => setShowRecentSticky(true)}
|
||||
style={{ color: stickies[recentStickyId]?.color }}
|
||||
>
|
||||
<StickyNoteIcon className={cn("size-5 rotate-90")} color={stickies[recentStickyId]?.color} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip tooltipContent="Add sticky" isMobile={false} position="left">
|
||||
<button
|
||||
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
|
||||
onClick={() => {
|
||||
updateActiveStickyId("");
|
||||
toggleShowNewSticky(true);
|
||||
setNewSticky(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-5 rotate-90 text-custom-text-350" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100 transition-transform duration-300 ${isExpanded ? "rotate-180" : ""}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<X className="size-5 text-custom-text-350" />
|
||||
) : (
|
||||
<StickyIcon className="size-5 rotate-90 text-custom-text-350" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-16 right-0 z-[20]",
|
||||
"transform transition-all duration-300 ease-in-out",
|
||||
newSticky || showRecentSticky ? "translate-y-[0%] min-h-[300px]" : "translate-y-[100%] h-0"
|
||||
)}
|
||||
>
|
||||
{(newSticky || (showRecentSticky && recentStickyId)) && (
|
||||
<StickyNote
|
||||
className={"w-[290px]"}
|
||||
onClose={() => (newSticky ? setNewSticky(false) : setShowRecentSticky(false))}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
stickyId={newSticky ? activeStickyId : recentStickyId || ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AllStickiesModal isOpen={allStickiesModal} handleClose={() => toggleAllStickiesModal(false)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
40
web/core/components/stickies/empty.tsx
Normal file
40
web/core/components/stickies/empty.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
|
||||
|
||||
type TProps = {
|
||||
handleCreate: () => void;
|
||||
creatingSticky?: boolean;
|
||||
};
|
||||
export const EmptyState = (props: TProps) => {
|
||||
const { handleCreate, creatingSticky } = props;
|
||||
return (
|
||||
<div className="flex justify-center h-[500px]">
|
||||
<div className="m-auto">
|
||||
<div
|
||||
className={`mb-4 rounded-full mx-auto last:rounded-full w-[98px] h-[98px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
|
||||
>
|
||||
<StickyIcon className="size-[60px] rotate-90 text-custom-text-350/20" />
|
||||
</div>
|
||||
<div className="text-custom-text-100 font-medium text-lg text-center">No stickies yet</div>
|
||||
<div className="text-custom-text-300 text-sm text-center my-2">
|
||||
All your stickies in this workspace will appear here.
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="mx-auto flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
disabled={creatingSticky}
|
||||
>
|
||||
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
|
||||
{creatingSticky && (
|
||||
<div className="flex items-center justify-center ml-2">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
2
web/core/components/stickies/index.ts
Normal file
2
web/core/components/stickies/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./action-bar";
|
||||
export * from "./widget";
|
||||
15
web/core/components/stickies/modal/index.tsx
Normal file
15
web/core/components/stickies/modal/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { Stickies } from "./stickies";
|
||||
|
||||
type TProps = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
export const AllStickiesModal = (props: TProps) => {
|
||||
const { isOpen, handleClose } = props;
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VXL}>
|
||||
<Stickies handleClose={handleClose} />
|
||||
</ModalCore>
|
||||
);
|
||||
};
|
||||
76
web/core/components/stickies/modal/search.tsx
Normal file
76
web/core/components/stickies/modal/search.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
// plane hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
|
||||
export const StickySearch: FC = observer(() => {
|
||||
// hooks
|
||||
const { searchQuery, updateSearchQuery } = useSticky();
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
// outside click detector hook
|
||||
useOutsideClickDetector(inputRef, () => {
|
||||
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
|
||||
});
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
|
||||
else setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center mr-2">
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-1 p-1 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className=" size-4 " />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
|
||||
{
|
||||
"w-30 md:w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
|
||||
placeholder="Search by title"
|
||||
value={searchQuery}
|
||||
onChange={(e) => updateSearchQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
updateSearchQuery("");
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
68
web/core/components/stickies/modal/stickies.tsx
Normal file
68
web/core/components/stickies/modal/stickies.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import { RecentStickyIcon } from "@plane/ui";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
|
||||
import { StickiesLayout } from "../stickies-layout";
|
||||
import { useStickyOperations } from "../sticky/use-operations";
|
||||
import { StickySearch } from "./search";
|
||||
|
||||
type TProps = {
|
||||
handleClose?: () => void;
|
||||
};
|
||||
|
||||
export const Stickies = observer((props: TProps) => {
|
||||
const { handleClose } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
const { creatingSticky, toggleShowNewSticky } = useSticky();
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-0">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Title */}
|
||||
<div className="text-custom-text-100 flex gap-2">
|
||||
<RecentStickyIcon className="size-5 rotate-90" />
|
||||
<p className="text-lg font-medium">My Stickies</p>
|
||||
</div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
<StickySearch />
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create({ color: STICKY_COLORS[0] });
|
||||
}}
|
||||
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
disabled={creatingSticky}
|
||||
>
|
||||
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
|
||||
{creatingSticky && (
|
||||
<div className="flex items-center justify-center ml-2">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{handleClose && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 grid place-items-center text-custom-text-300 hover:text-custom-text-100 hover:bg-custom-background-80 rounded p-1 transition-colors my-auto"
|
||||
>
|
||||
<X className="text-custom-text-400 size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* content */}
|
||||
<div className="mb-4 max-h-[625px] overflow-scroll">
|
||||
<StickiesLayout />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
209
web/core/components/stickies/stickies-layout.tsx
Normal file
209
web/core/components/stickies/stickies-layout.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import Masonry from "react-masonry-component";
|
||||
import useSWR from "swr";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS } from "../editor/sticky-editor/color-pallete";
|
||||
import { EmptyState } from "./empty";
|
||||
import { StickyNote } from "./sticky";
|
||||
import { useStickyOperations } from "./sticky/use-operations";
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
type TProps = {
|
||||
columnCount: number;
|
||||
};
|
||||
|
||||
export const StickyAll = observer((props: TProps) => {
|
||||
const { columnCount } = props;
|
||||
// refs
|
||||
const masonryRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
const [showAllStickies, setShowAllStickies] = useState(false);
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
|
||||
const {
|
||||
fetchingWorkspaceStickies,
|
||||
toggleShowNewSticky,
|
||||
getWorkspaceStickies,
|
||||
fetchWorkspaceStickies,
|
||||
currentPage,
|
||||
totalPages,
|
||||
incrementPage,
|
||||
creatingSticky,
|
||||
} = useSticky();
|
||||
|
||||
const workspaceStickies = getWorkspaceStickies(workspaceSlug?.toString());
|
||||
const itemWidth = `${100 / columnCount}%`;
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}_${PER_PAGE}:${currentPage}:0` : null,
|
||||
workspaceSlug
|
||||
? () => fetchWorkspaceStickies(workspaceSlug.toString(), `${PER_PAGE}:${currentPage}:0`, PER_PAGE)
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetchingWorkspaceStickies && workspaceStickies.length === 0) {
|
||||
toggleShowNewSticky(true);
|
||||
}
|
||||
}, [fetchingWorkspaceStickies, workspaceStickies, toggleShowNewSticky]);
|
||||
|
||||
// Update this useEffect to correctly track height
|
||||
useEffect(() => {
|
||||
if (!masonryRef?.current) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
if (masonryRef.current) {
|
||||
const height = masonryRef.current.getBoundingClientRect().height;
|
||||
setContainerHeight(parseInt(height.toString()));
|
||||
}
|
||||
};
|
||||
|
||||
// Initial height measurement
|
||||
updateHeight();
|
||||
|
||||
// Create ResizeObserver
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateHeight();
|
||||
});
|
||||
|
||||
resizeObserver.observe(masonryRef.current);
|
||||
|
||||
// Also update height when Masonry content changes
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
updateHeight();
|
||||
});
|
||||
|
||||
mutationObserver.observe(masonryRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [masonryRef?.current]);
|
||||
|
||||
useIntersectionObserver(containerRef, fetchingWorkspaceStickies ? null : intersectionElement, incrementPage, "20%");
|
||||
|
||||
if (fetchingWorkspaceStickies && workspaceStickies.length === 0) {
|
||||
return (
|
||||
<div className="min-h-[500px] overflow-scroll pb-2">
|
||||
<Loader>
|
||||
<Loader.Item height="300px" width="255px" />
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStickiesToRender = () => {
|
||||
let stickies: (string | undefined)[] = workspaceStickies;
|
||||
if (currentPage + 1 < totalPages && stickies.length >= PER_PAGE) {
|
||||
stickies = [...stickies, undefined];
|
||||
}
|
||||
return stickies;
|
||||
};
|
||||
|
||||
const stickyIds = getStickiesToRender();
|
||||
|
||||
const childElements = stickyIds.map((stickyId, index) => (
|
||||
<div key={stickyId} className={cn("flex min-h-[300px] box-border p-2")} style={{ width: itemWidth }}>
|
||||
{index === stickyIds.length - 1 && currentPage + 1 < totalPages ? (
|
||||
<div ref={setIntersectionElement} className="flex w-full rounded min-h-[300px]">
|
||||
<Loader className="w-full h-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
) : (
|
||||
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug.toString()} stickyId={stickyId} />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
if (!fetchingWorkspaceStickies && workspaceStickies.length === 0)
|
||||
return (
|
||||
<EmptyState
|
||||
creatingSticky={creatingSticky}
|
||||
handleCreate={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create({ color: STICKY_COLORS[0] });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn("relative max-h-[625px] overflow-hidden pb-2 box-border", {
|
||||
"max-h-full overflow-scroll": showAllStickies,
|
||||
})}
|
||||
>
|
||||
<div className="h-full w-full" ref={masonryRef}>
|
||||
{/* @ts-expect-error type mismatch here */}
|
||||
<Masonry elementType="div">{childElements}</Masonry>
|
||||
</div>
|
||||
{containerHeight > 632.9 && (
|
||||
<div className="absolute bottom-0 left-0 bg-gradient-to-t from-custom-background-100 to-transparent w-full h-[100px] text-center text-sm font-medium text-custom-primary-100">
|
||||
<button
|
||||
className="flex flex-col items-center justify-end gap-1 h-full m-auto w-full"
|
||||
onClick={() => setShowAllStickies((state) => !state)}
|
||||
>
|
||||
{showAllStickies ? "Show less" : "Show all"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const StickiesLayout = () => {
|
||||
// states
|
||||
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref?.current) return;
|
||||
|
||||
setContainerWidth(ref?.current.offsetWidth);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(ref?.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const getColumnCount = (width: number | null): number => {
|
||||
if (width === null) return 4;
|
||||
|
||||
if (width < 640) return 2; // sm
|
||||
if (width < 768) return 3; // md
|
||||
if (width < 1024) return 4; // lg
|
||||
if (width < 1280) return 5; // xl
|
||||
return 6; // 2xl and above
|
||||
};
|
||||
|
||||
const columnCount = getColumnCount(containerWidth);
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<StickyAll columnCount={columnCount} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
web/core/components/stickies/sticky/index.ts
Normal file
1
web/core/components/stickies/sticky/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
109
web/core/components/stickies/sticky/inputs.tsx
Normal file
109
web/core/components/stickies/sticky/inputs.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { DebouncedFunc } from "lodash";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
import { TSticky } from "@plane/types";
|
||||
import { TextArea } from "@plane/ui";
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
import { StickyEditor } from "../../editor";
|
||||
|
||||
type TProps = {
|
||||
stickyData: TSticky | undefined;
|
||||
workspaceSlug: string;
|
||||
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
|
||||
stickyId: string | undefined;
|
||||
handleChange: (data: Partial<TSticky>) => Promise<void>;
|
||||
handleDelete: () => Promise<void>;
|
||||
};
|
||||
export const StickyInput = (props: TProps) => {
|
||||
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props;
|
||||
//refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// form info
|
||||
const { handleSubmit, reset, control } = useForm<TSticky>({
|
||||
defaultValues: {
|
||||
description_html: stickyData?.description_html,
|
||||
name: stickyData?.name,
|
||||
},
|
||||
});
|
||||
|
||||
// computed values
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
|
||||
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
if (!stickyId) return;
|
||||
reset({
|
||||
id: stickyId,
|
||||
description_html: stickyData?.description_html === "" ? "<p></p>" : stickyData?.description_html,
|
||||
name: stickyData?.name,
|
||||
});
|
||||
}, [stickyData, reset]);
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
async (formdata: Partial<TSticky>) => {
|
||||
if (formdata.name !== undefined) {
|
||||
await handleUpdate({
|
||||
description_html: formdata.description_html ?? "<p></p>",
|
||||
name: formdata.name,
|
||||
});
|
||||
} else {
|
||||
await handleUpdate({
|
||||
description_html: formdata.description_html ?? "<p></p>",
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleUpdate, workspaceSlug]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
{/* name */}
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<TextArea
|
||||
value={value}
|
||||
id="name"
|
||||
name="name"
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
handleSubmit(handleFormSubmit)();
|
||||
}}
|
||||
placeholder="Title"
|
||||
className="text-lg font-medium text-[#455068] mb-2 w-full p-0 border-none min-h-[22px]"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* description */}
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { onChange } }) => (
|
||||
<StickyEditor
|
||||
id={`description-${stickyId}`}
|
||||
initialValue={stickyData?.description_html ?? ""}
|
||||
value={null}
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceId={workspaceId}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
handleSubmit(handleFormSubmit)();
|
||||
}}
|
||||
placeholder={"Click to type here"}
|
||||
containerClassName={"px-0 text-base min-h-[200px] w-full text-[#455068]"}
|
||||
uploadFile={async () => ""}
|
||||
showToolbar={false}
|
||||
parentClassName={"border-none p-0"}
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleChange}
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
web/core/components/stickies/sticky/root.tsx
Normal file
72
web/core/components/stickies/sticky/root.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useCallback } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { Minimize2 } from "lucide-react";
|
||||
import { TSticky } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
|
||||
import { StickyInput } from "./inputs";
|
||||
import { useStickyOperations } from "./use-operations";
|
||||
|
||||
type TProps = {
|
||||
onClose?: () => void;
|
||||
workspaceSlug: string;
|
||||
className?: string;
|
||||
stickyId: string | undefined;
|
||||
};
|
||||
export const StickyNote = observer((props: TProps) => {
|
||||
const { onClose, workspaceSlug, className = "", stickyId } = props;
|
||||
// hooks
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug });
|
||||
const { stickies } = useSticky();
|
||||
// derived values
|
||||
const stickyData: TSticky | undefined = stickyId ? stickies[stickyId] : undefined;
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (payload: Partial<TSticky>) => {
|
||||
stickyId
|
||||
? await stickyOperations.update(stickyId, payload)
|
||||
: await stickyOperations.create({
|
||||
color: payload.color || STICKY_COLORS[0],
|
||||
...payload,
|
||||
});
|
||||
},
|
||||
[stickyId, stickyOperations]
|
||||
);
|
||||
|
||||
const debouncedFormSave = useCallback(
|
||||
debounce(async (payload: Partial<TSticky>) => {
|
||||
await handleChange(payload);
|
||||
}, 500),
|
||||
[stickyOperations, stickyData, handleChange]
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!stickyId) return;
|
||||
onClose?.();
|
||||
stickyOperations.remove(stickyId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)}
|
||||
style={{ backgroundColor: stickyData?.color || STICKY_COLORS[0] }}
|
||||
>
|
||||
{onClose && (
|
||||
<button className="flex w-full" onClick={onClose}>
|
||||
<Minimize2 className="size-4 m-auto mr-0" />
|
||||
</button>
|
||||
)}
|
||||
{/* inputs */}
|
||||
<StickyInput
|
||||
stickyData={stickyData}
|
||||
workspaceSlug={workspaceSlug}
|
||||
handleUpdate={debouncedFormSave}
|
||||
stickyId={stickyId}
|
||||
handleDelete={handleDelete}
|
||||
handleChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
92
web/core/components/stickies/sticky/use-operations.tsx
Normal file
92
web/core/components/stickies/sticky/use-operations.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from "react";
|
||||
import { TSticky } from "@plane/types";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
|
||||
export type TOperations = {
|
||||
create: (data: Partial<TSticky>) => Promise<void>;
|
||||
update: (stickyId: string, data: Partial<TSticky>) => Promise<void>;
|
||||
remove: (stickyId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type TProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const useStickyOperations = (props: TProps) => {
|
||||
const { workspaceSlug } = props;
|
||||
const { createSticky, updateSticky, deleteSticky } = useSticky();
|
||||
|
||||
const isValid = (data: Partial<TSticky>) => {
|
||||
if (data.name && data.name.length > 100) {
|
||||
setToast({
|
||||
message: "The sticky name cannot be longer than 100 characters",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not updated",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const stickyOperations: TOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TSticky>) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
if (!isValid(data)) return;
|
||||
await createSticky(workspaceSlug, data);
|
||||
setToast({
|
||||
message: "The sticky has been successfully created",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Sticky created",
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? "The sticky could not be created",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not created",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async (stickyId: string, data: Partial<TSticky>) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
if (!isValid(data)) return;
|
||||
await updateSticky(workspaceSlug, stickyId, data);
|
||||
} catch (error) {
|
||||
setToast({
|
||||
message: "The sticky could not be updated",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not updated",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (stickyId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
await deleteSticky(workspaceSlug, stickyId);
|
||||
setToast({
|
||||
message: "The sticky has been successfully removed",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Sticky removed",
|
||||
});
|
||||
} catch (error) {
|
||||
setToast({
|
||||
message: "The sticky could not be removed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not removed",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug]
|
||||
);
|
||||
|
||||
return {
|
||||
stickyOperations,
|
||||
};
|
||||
};
|
||||
46
web/core/components/stickies/widget.tsx
Normal file
46
web/core/components/stickies/widget.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS } from "../editor/sticky-editor/color-pallete";
|
||||
import { StickySearch } from "./modal/search";
|
||||
import { StickiesLayout } from "./stickies-layout";
|
||||
import { useStickyOperations } from "./sticky/use-operations";
|
||||
|
||||
export const StickiesWidget = () => {
|
||||
const { workspaceSlug } = useParams();
|
||||
const { creatingSticky, toggleShowNewSticky } = useSticky();
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">My Stickies </div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
<StickySearch />
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create({ color: STICKY_COLORS[0] });
|
||||
}}
|
||||
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
disabled={creatingSticky}
|
||||
>
|
||||
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
|
||||
{creatingSticky && (
|
||||
<div className="flex items-center justify-center ml-2">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-2">
|
||||
<StickiesLayout />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { convertRemToPixel } from "@/helpers/common.helper";
|
||||
|
||||
type TEditorTypes = "lite" | "document";
|
||||
type TEditorTypes = "lite" | "document" | "sticky";
|
||||
|
||||
// Utility type to enforce the necessary extra props or make extraProps optional
|
||||
type ExtraPropsForCommand<T extends TEditorCommands> = T extends keyof TCommandExtraProps
|
||||
@@ -184,6 +184,10 @@ export const TOOLBAR_ITEMS: {
|
||||
userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")),
|
||||
complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")),
|
||||
},
|
||||
sticky: {
|
||||
basic: BASIC_MARK_ITEMS.filter((item) => ["Bold", "Italic"].includes(item.name)),
|
||||
list: LIST_ITEMS.filter((item) => ["To-do list"].includes(item.name)),
|
||||
},
|
||||
};
|
||||
|
||||
export const EDITOR_FONT_STYLES: {
|
||||
|
||||
11
web/core/hooks/use-stickies.tsx
Normal file
11
web/core/hooks/use-stickies.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
// context
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
import { IStickyStore } from "@/store/sticky/sticky.store";
|
||||
// plane web stores
|
||||
|
||||
export const useSticky = (): IStickyStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useSticky must be used within StoreProvider");
|
||||
return context.stickyStore;
|
||||
};
|
||||
60
web/core/services/sticky.service.ts
Normal file
60
web/core/services/sticky.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// helpers
|
||||
import { TSticky } from "@plane/types";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class StickyService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async createSticky(workspaceSlug: string, payload: Partial<TSticky>) {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/stickies/`, payload)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getStickies(
|
||||
workspaceSlug: string,
|
||||
cursor?: string,
|
||||
per_page?: number
|
||||
): Promise<{ results: TSticky[]; total_pages: number }> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/stickies/`, {
|
||||
params: {
|
||||
cursor: cursor || `5:0:0`,
|
||||
per_page: per_page || 5,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getSticky(workspaceSlug: string, id: string) {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/stickies/${id}`)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateSticky(workspaceSlug: string, id: string, data: Partial<TSticky>) {
|
||||
return await this.patch(`/api/workspaces/${workspaceSlug}/stickies/${id}/`, data)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSticky(workspaceSlug: string, id: string) {
|
||||
return await this.delete(`/api/workspaces/${workspaceSlug}/stickies/${id}`)
|
||||
.then((res) => res?.data)
|
||||
.catch((err) => {
|
||||
throw err?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface IBaseCommandPaletteStore {
|
||||
isDeleteIssueModalOpen: boolean;
|
||||
isBulkDeleteIssueModalOpen: boolean;
|
||||
createIssueStoreType: TCreateModalStoreTypes;
|
||||
allStickiesModal: boolean;
|
||||
// toggle actions
|
||||
toggleCommandPaletteModal: (value?: boolean) => void;
|
||||
toggleShortcutModal: (value?: boolean) => void;
|
||||
@@ -34,6 +35,7 @@ export interface IBaseCommandPaletteStore {
|
||||
toggleCreateModuleModal: (value?: boolean) => void;
|
||||
toggleDeleteIssueModal: (value?: boolean) => void;
|
||||
toggleBulkDeleteIssueModal: (value?: boolean) => void;
|
||||
toggleAllStickiesModal: (value?: boolean) => void;
|
||||
}
|
||||
|
||||
export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStore {
|
||||
@@ -49,6 +51,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
isBulkDeleteIssueModalOpen: boolean = false;
|
||||
createPageModal: TCreatePageModal = DEFAULT_CREATE_PAGE_MODAL_DATA;
|
||||
createIssueStoreType: TCreateModalStoreTypes = EIssuesStoreType.PROJECT;
|
||||
allStickiesModal: boolean = false;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
@@ -64,6 +67,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
isBulkDeleteIssueModalOpen: observable.ref,
|
||||
createPageModal: observable,
|
||||
createIssueStoreType: observable,
|
||||
allStickiesModal: observable,
|
||||
// projectPages: computed,
|
||||
// toggle actions
|
||||
toggleCommandPaletteModal: action,
|
||||
@@ -76,6 +80,7 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
toggleCreateModuleModal: action,
|
||||
toggleDeleteIssueModal: action,
|
||||
toggleBulkDeleteIssueModal: action,
|
||||
toggleAllStickiesModal: action,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,14 +91,15 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
protected getCoreModalsState(): boolean {
|
||||
return Boolean(
|
||||
this.isCreateIssueModalOpen ||
|
||||
this.isCreateCycleModalOpen ||
|
||||
this.isCreateProjectModalOpen ||
|
||||
this.isCreateModuleModalOpen ||
|
||||
this.isCreateViewModalOpen ||
|
||||
this.isShortcutModalOpen ||
|
||||
this.isBulkDeleteIssueModalOpen ||
|
||||
this.isDeleteIssueModalOpen ||
|
||||
this.createPageModal.isOpen
|
||||
this.isCreateCycleModalOpen ||
|
||||
this.isCreateProjectModalOpen ||
|
||||
this.isCreateModuleModalOpen ||
|
||||
this.isCreateViewModalOpen ||
|
||||
this.isShortcutModalOpen ||
|
||||
this.isBulkDeleteIssueModalOpen ||
|
||||
this.isDeleteIssueModalOpen ||
|
||||
this.createPageModal.isOpen ||
|
||||
this.allStickiesModal
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,4 +241,17 @@ export abstract class BaseCommandPaletteStore implements IBaseCommandPaletteStor
|
||||
this.isBulkDeleteIssueModalOpen = !this.isBulkDeleteIssueModalOpen;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles the all stickies modal
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
toggleAllStickiesModal = (value?: boolean) => {
|
||||
if (value) {
|
||||
this.allStickiesModal = value;
|
||||
} else {
|
||||
this.allStickiesModal = !this.allStickiesModal;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { IProjectPageStore, ProjectPageStore } from "./pages/project-page.store"
|
||||
import { IProjectRootStore, ProjectRootStore } from "./project";
|
||||
import { IProjectViewStore, ProjectViewStore } from "./project-view.store";
|
||||
import { RouterStore, IRouterStore } from "./router.store";
|
||||
import { IStickyStore, StickyStore } from "./sticky/sticky.store";
|
||||
import { ThemeStore, IThemeStore } from "./theme.store";
|
||||
import { ITransientStore, TransientStore } from "./transient.store";
|
||||
import { IUserStore, UserStore } from "./user";
|
||||
@@ -59,6 +60,7 @@ export class CoreRootStore {
|
||||
workspaceNotification: IWorkspaceNotificationStore;
|
||||
favorite: IFavoriteStore;
|
||||
transient: ITransientStore;
|
||||
stickyStore: IStickyStore;
|
||||
|
||||
constructor() {
|
||||
this.router = new RouterStore();
|
||||
@@ -87,6 +89,7 @@ export class CoreRootStore {
|
||||
this.workspaceNotification = new WorkspaceNotificationStore(this);
|
||||
this.favorite = new FavoriteStore(this);
|
||||
this.transient = new TransientStore();
|
||||
this.stickyStore = new StickyStore();
|
||||
}
|
||||
|
||||
resetOnSignOut() {
|
||||
@@ -118,5 +121,6 @@ export class CoreRootStore {
|
||||
this.workspaceNotification = new WorkspaceNotificationStore(this);
|
||||
this.favorite = new FavoriteStore(this);
|
||||
this.transient = new TransientStore();
|
||||
this.stickyStore = new StickyStore();
|
||||
}
|
||||
}
|
||||
|
||||
170
web/core/store/sticky/sticky.store.ts
Normal file
170
web/core/store/sticky/sticky.store.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { TSticky } from "@plane/types";
|
||||
import { StickyService } from "@/services/sticky.service";
|
||||
export interface IStickyStore {
|
||||
creatingSticky: boolean;
|
||||
fetchingWorkspaceStickies: boolean;
|
||||
workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds
|
||||
stickies: Record<string, TSticky>; // stickyId -> sticky
|
||||
searchQuery: string;
|
||||
activeStickyId: string | undefined;
|
||||
recentStickyId: string | undefined;
|
||||
showAddNewSticky: boolean;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
|
||||
// computed
|
||||
getWorkspaceStickies: (workspaceSlug: string) => string[];
|
||||
|
||||
// actions
|
||||
toggleShowNewSticky: (value: boolean) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
fetchWorkspaceStickies: (workspaceSlug: string, cursor?: string, per_page?: number) => void;
|
||||
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => void;
|
||||
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => void;
|
||||
deleteSticky: (workspaceSlug: string, id: string) => void;
|
||||
updateActiveStickyId: (id: string | undefined) => void;
|
||||
fetchRecentSticky: (workspaceSlug: string) => void;
|
||||
incrementPage: () => void;
|
||||
}
|
||||
|
||||
export class StickyStore implements IStickyStore {
|
||||
creatingSticky = false;
|
||||
fetchingWorkspaceStickies = true;
|
||||
workspaceStickies: Record<string, string[]> = {};
|
||||
stickies: Record<string, TSticky> = {};
|
||||
recentStickyId: string | undefined = undefined;
|
||||
searchQuery = "";
|
||||
activeStickyId: string | undefined = undefined;
|
||||
showAddNewSticky = false;
|
||||
currentPage = 0;
|
||||
totalPages = 0;
|
||||
|
||||
// services
|
||||
stickyService;
|
||||
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
creatingSticky: observable,
|
||||
fetchingWorkspaceStickies: observable,
|
||||
activeStickyId: observable,
|
||||
showAddNewSticky: observable,
|
||||
recentStickyId: observable,
|
||||
workspaceStickies: observable,
|
||||
stickies: observable,
|
||||
searchQuery: observable,
|
||||
currentPage: observable,
|
||||
totalPages: observable,
|
||||
// actions
|
||||
updateSearchQuery: action,
|
||||
updateSticky: action,
|
||||
deleteSticky: action,
|
||||
incrementPage: action,
|
||||
});
|
||||
this.stickyService = new StickyService();
|
||||
}
|
||||
|
||||
getWorkspaceStickies = computedFn((workspaceSlug: string) => {
|
||||
let filteredStickies = (this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]);
|
||||
if (this.searchQuery) {
|
||||
filteredStickies = filteredStickies.filter(
|
||||
(sticky) => sticky.name && sticky.name.toLowerCase().includes(this.searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
return filteredStickies.map((sticky) => sticky.id);
|
||||
});
|
||||
|
||||
toggleShowNewSticky = (value: boolean) => {
|
||||
this.showAddNewSticky = value;
|
||||
};
|
||||
|
||||
updateSearchQuery = (query: string) => {
|
||||
this.searchQuery = query;
|
||||
};
|
||||
|
||||
updateActiveStickyId = (id: string | undefined) => {
|
||||
this.activeStickyId = id;
|
||||
};
|
||||
|
||||
incrementPage = () => {
|
||||
this.currentPage += 1;
|
||||
};
|
||||
|
||||
fetchRecentSticky = async (workspaceSlug: string) => {
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", 1);
|
||||
runInAction(() => {
|
||||
this.recentStickyId = response.results[0]?.id;
|
||||
this.stickies[response.results[0]?.id] = response.results[0];
|
||||
});
|
||||
};
|
||||
fetchWorkspaceStickies = async (workspaceSlug: string, cursor?: string, per_page?: number) => {
|
||||
try {
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, cursor, per_page);
|
||||
|
||||
runInAction(() => {
|
||||
response.results.forEach((sticky) => {
|
||||
if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) {
|
||||
this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id];
|
||||
}
|
||||
this.stickies[sticky.id] = sticky;
|
||||
});
|
||||
this.totalPages = response.total_pages;
|
||||
this.fetchingWorkspaceStickies = false;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.fetchingWorkspaceStickies = false;
|
||||
}
|
||||
};
|
||||
|
||||
createSticky = async (workspaceSlug: string, sticky: Partial<TSticky>) => {
|
||||
if (!this.showAddNewSticky) return;
|
||||
this.showAddNewSticky = false;
|
||||
this.creatingSticky = true;
|
||||
const workspaceStickies = this.workspaceStickies[workspaceSlug] || [];
|
||||
const response = await this.stickyService.createSticky(workspaceSlug, sticky);
|
||||
runInAction(() => {
|
||||
this.stickies[response.id] = response;
|
||||
this.workspaceStickies[workspaceSlug] = [response.id, ...workspaceStickies];
|
||||
this.activeStickyId = response.id;
|
||||
this.recentStickyId = response.id;
|
||||
this.creatingSticky = false;
|
||||
});
|
||||
};
|
||||
|
||||
updateSticky = async (workspaceSlug: string, id: string, updates: Partial<TSticky>) => {
|
||||
const sticky = this.stickies[id];
|
||||
if (!sticky) return;
|
||||
try {
|
||||
this.stickies[id] = {
|
||||
...sticky,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.recentStickyId = id;
|
||||
await this.stickyService.updateSticky(workspaceSlug, id, updates);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
this.stickies[id] = sticky;
|
||||
}
|
||||
};
|
||||
|
||||
deleteSticky = async (workspaceSlug: string, id: string) => {
|
||||
const sticky = this.stickies[id];
|
||||
if (!sticky) return;
|
||||
try {
|
||||
this.workspaceStickies[workspaceSlug] = this.workspaceStickies[workspaceSlug].filter(
|
||||
(stickyId) => stickyId !== id
|
||||
);
|
||||
if (this.activeStickyId === id) this.activeStickyId = undefined;
|
||||
delete this.stickies[id];
|
||||
this.recentStickyId = this.workspaceStickies[workspaceSlug][0];
|
||||
await this.stickyService.deleteSticky(workspaceSlug, id);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
this.stickies[id] = sticky;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "ce/components/stickies";
|
||||
Reference in New Issue
Block a user