[WEB-3088] fix: home edits (#6357)

* fix: added delete sticky confirmation modal

* fix: prevented quick links reordering

* fix: quick links css

* fix: minor css

* fix: empty states

* Filter quick_tutorial and new_at_plane

* fix: stickies search backend change

* fix: stickies editor enhanced

* fix: sticky delete function

---------

Co-authored-by: gakshita <akshitagoyal1516@gmail.com>
This commit is contained in:
Sangeetha
2025-01-09 14:51:04 +05:30
committed by GitHub
parent 5d8f66ae22
commit d96ab2e7af
17 changed files with 303 additions and 173 deletions

View File

@@ -31,7 +31,11 @@ class WorkspacePreferenceViewSet(BaseAPIView):
create_preference_keys = []
keys = [key for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices]
keys = [
key
for key, _ in WorkspaceHomePreference.HomeWidgetKeys.choices
if key not in ["quick_tutorial", "new_at_plane"]
]
sort_order_counter = 1

View File

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

View File

@@ -0,0 +1,111 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { cn } from "@plane/utils";
interface IContentOverflowWrapper {
children: ReactNode;
maxHeight?: number;
gradientColor?: string;
buttonClassName?: string;
containerClassName?: string;
fallback?: ReactNode;
}
export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => {
const {
children,
maxHeight = 625,
buttonClassName = "text-sm font-medium text-custom-primary-100",
containerClassName,
fallback = null,
} = props;
// states
const [containerHeight, setContainerHeight] = useState(0);
const [showAll, setShowAll] = useState(false);
// refs
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!contentRef?.current) return;
const updateHeight = () => {
if (contentRef.current) {
const height = contentRef.current.getBoundingClientRect().height;
setContainerHeight(height);
}
};
// Initial height measurement
updateHeight();
// Create ResizeObserver for size changes
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(contentRef.current);
// Create MutationObserver for content changes
const mutationObserver = new MutationObserver((mutations) => {
const shouldUpdate = mutations.some(
(mutation) =>
mutation.type === "childList" ||
(mutation.type === "attributes" && (mutation.attributeName === "style" || mutation.attributeName === "class"))
);
if (shouldUpdate) {
updateHeight();
}
});
mutationObserver.observe(contentRef.current, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["style", "class"],
});
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [contentRef?.current]);
if (!children) return fallback;
return (
<div
className={cn(
"relative",
{
[`overflow-hidden`]: !showAll,
"overflow-visible": showAll,
},
containerClassName
)}
style={{ maxHeight: showAll ? "100%" : `${maxHeight}px` }}
>
<div ref={contentRef}>{children}</div>
{containerHeight > maxHeight && (
<div
className={cn(
"bottom-0 left-0 w-full",
`bg-gradient-to-t from-custom-background-100 to-transparent flex flex-col items-center justify-end`,
"text-center",
{
"absolute h-[100px]": !showAll,
"h-[30px]": showAll,
}
)}
>
<button
className={cn("gap-1 w-full text-custom-primary-100 text-sm font-medium", buttonClassName)}
onClick={() => setShowAll((prev) => !prev)}
>
{showAll ? "Show less" : "Show all"}
</button>
</div>
)}
</div>
);
});

View File

@@ -29,7 +29,7 @@ interface StickyEditorWrapperProps
uploadFile: (file: File) => Promise<string>;
parentClassName?: string;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => Promise<void>;
handleDelete: () => void;
}
export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperProps>((props, ref) => {

View File

@@ -1,21 +0,0 @@
import Image from "next/image";
import { useTheme } from "next-themes";
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
export const IssuesEmptyState = () => {
// next-themes
const { resolvedTheme } = useTheme();
const image = resolvedTheme === "dark" ? UpcomingIssuesDark : UpcomingIssuesLight;
// TODO: update empty state logic to use a general component
return (
<div className="text-center space-y-6 flex flex-col items-center justify-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Assigned issues" />
</div>
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">No activity to display</p>
</div>
);
};

View File

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

View File

@@ -0,0 +1,15 @@
import { History } from "lucide-react";
export const RecentsEmptyState = () => (
<div className="h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
<div className="m-auto">
<div
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
>
<History size={30} className="text-custom-text-400 -rotate-45" />
</div>
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No recent items yet</div>
<div className="text-custom-text-300 text-sm text-center mb-2">You dont have any recent items yet. </div>
</div>
</div>
);

View File

@@ -1,9 +1,10 @@
import { FC, useEffect, useState } from "react";
import { FC } from "react";
import { observer } from "mobx-react";
// computed
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
import { useHome } from "@/hooks/store/use-home";
import { LinksEmptyState } from "../empty-states/links";
import { EWidgetKeys, WidgetLoader } from "../loaders";
import { AddLink } from "./action";
import { ProjectLinkDetail } from "./link-detail";
import { TLinkOperations } from "./use-links";
@@ -17,9 +18,6 @@ export type TProjectLinkList = {
export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
// props
const { linkOperations, workspaceSlug } = props;
// states
const [columnCount, setColumnCount] = useState(4);
const [showAll, setShowAll] = useState(false);
// hooks
const {
quickLinks: { getLinksByWorkspaceId, toggleLinkModal },
@@ -27,51 +25,23 @@ export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
const links = getLinksByWorkspaceId(workspaceSlug);
useEffect(() => {
const updateColumnCount = () => {
if (window.matchMedia("(min-width: 1024px)").matches) {
setColumnCount(4); // lg screens
} else if (window.matchMedia("(min-width: 768px)").matches) {
setColumnCount(3); // md screens
} else if (window.matchMedia("(min-width: 640px)").matches) {
setColumnCount(2); // sm screens
} else {
setColumnCount(1); // mobile
}
};
// Initial check
updateColumnCount();
// Add event listener for window resize
window.addEventListener("resize", updateColumnCount);
// Cleanup
return () => window.removeEventListener("resize", updateColumnCount);
}, []);
if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
if (links.length === 0) return <LinksEmptyState handleCreate={() => toggleLinkModal(true)} />;
return (
<div>
<div className="flex gap-2 mb-2 flex-wrap justify-center ">
{links &&
links.length > 0 &&
(showAll ? links : links.slice(0, 2 * columnCount - 1)).map((linkId) => (
<ProjectLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} />
))}
{/* Add new link */}
<AddLink onClick={() => toggleLinkModal(true)} />
<ContentOverflowWrapper
maxHeight={150}
containerClassName="pb-2 box-border"
fallback={<></>}
buttonClassName="bg-custom-background-90/20"
>
<div>
<div className="flex gap-2 mb-2 flex-wrap">
{links &&
links.length > 0 &&
links.map((linkId) => <ProjectLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} />)}
</div>
</div>
{links.length > 2 * columnCount - 1 && (
<button
className="flex items-center justify-center gap-1 rounded-md px-2 py-1 text-sm font-medium text-custom-primary-100 mx-auto"
onClick={() => setShowAll((state) => !state)}
>
{showAll ? "Show less" : "Show more"}
</button>
)}
</div>
</ContentOverflowWrapper>
);
});

View File

@@ -1,5 +1,6 @@
import { observer } from "mobx-react";
import useSWR from "swr";
import { Plus } from "lucide-react";
import { THomeWidgetProps } from "@plane/types";
import { useHome } from "@/hooks/store/use-home";
import { LinkCreateUpdateModal } from "./create-update-link-modal";
@@ -31,9 +32,22 @@ export const DashboardQuickLinks = observer((props: THomeWidgetProps) => {
preloadedData={linkData}
setLinkData={setLinkData}
/>
<div className="flex mx-auto flex-wrap pb-4 w-full justify-center">
{/* rendering links */}
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
<div className="mb-2">
<div className="flex items-center justify-between mb-4">
<div className="text-base font-semibold text-custom-text-350">Quick links</div>
<button
onClick={() => {
toggleLinkModal(true);
}}
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
>
<Plus className="size-4 my-auto" /> <span>Add quick link</span>
</button>
</div>
<div className="flex flex-wrap w-full">
{/* rendering links */}
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
</div>
</div>
</>
);

View File

@@ -5,7 +5,7 @@ import range from "lodash/range";
import { Loader } from "@plane/ui";
export const QuickLinksWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl gap-2 flex flex-wrap justify-center">
<Loader className="bg-custom-background-100 rounded-xl gap-2 flex flex-wrap">
{range(4).map((index) => (
<Loader.Item key={index} height="56px" width="230px" />
))}

View File

@@ -6,7 +6,7 @@ import { Loader } from "@plane/ui";
export const RecentActivityWidgetLoader = () => (
<Loader className="bg-custom-background-100 rounded-xl px-2 space-y-6">
{range(7).map((index) => (
{range(5).map((index) => (
<div key={index} className="flex items-start gap-3.5">
<div className="flex-shrink-0">
<Loader.Item height="32px" width="32px" />

View File

@@ -11,7 +11,7 @@ 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 { RecentsEmptyState } from "../empty-states/recents";
import { EWidgetKeys, WidgetLoader } from "../loaders";
import { FiltersDropdown } from "./filters";
import { RecentIssue } from "./issue";
@@ -68,24 +68,24 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
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="flex items-center justify-between mb-4">
<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 className="flex flex-col items-center justify-center">
<RecentsEmptyState />
</div>
</div>
);
return (
<div ref={ref} className=" max-h-[500px] min-h-[400px] overflow-y-scroll">
<div ref={ref} className=" max-h-[500px] min-h-[250px] 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">
<div className="min-h-[250px] flex flex-col">
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading &&
recents?.length > 0 &&

View File

@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { AlertModalCore, TOAST_TYPE, setToast } from "@plane/ui";
interface IStickyDelete {
isOpen: boolean;
handleSubmit: () => void;
handleClose: () => void;
}
export const StickyDeleteModal: React.FC<IStickyDelete> = observer((props) => {
const { isOpen, handleClose, handleSubmit } = props;
// states
const [loader, setLoader] = useState(false);
const formSubmit = async () => {
try {
setLoader(true);
await handleSubmit();
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Warning!",
message: "Something went wrong please try again later.",
});
} finally {
setLoader(false);
}
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={formSubmit}
isSubmitting={loader}
isOpen={isOpen}
title="Delete sticky"
content={<>Are you sure you want to delete the sticky? </>}
/>
);
});

View File

@@ -1,4 +1,5 @@
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
import { Plus, StickyNote as StickyIcon } from "lucide-react";
import { Button } from "@plane/ui";
type TProps = {
handleCreate: () => void;
@@ -7,22 +8,18 @@ type TProps = {
export const EmptyState = (props: TProps) => {
const { handleCreate, creatingSticky } = props;
return (
<div className="flex justify-center h-[500px]">
<div className="flex justify-center h-[500px] rounded border-[1.5px] border-custom-border-100 mx-2">
<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`}
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-[60px] rotate-90 text-custom-text-350/20" />
<StickyIcon className="size-[30px] 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">
<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
onClick={handleCreate}
className="mx-auto flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky}
>
<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">
@@ -33,7 +30,7 @@ export const EmptyState = (props: TProps) => {
/>
</div>
)}
</button>
</Button>
</div>
</div>
);

View File

@@ -7,6 +7,7 @@ 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";
@@ -24,8 +25,6 @@ export const StickyAll = observer((props: TProps) => {
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();
@@ -59,44 +58,6 @@ export const StickyAll = observer((props: TProps) => {
}
}, [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) {
@@ -145,26 +106,16 @@ export const StickyAll = observer((props: TProps) => {
);
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}>
<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>
</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>
)}
</ContentOverflowWrapper>
</div>
);
});

View File

@@ -13,7 +13,7 @@ type TProps = {
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
stickyId: string | undefined;
handleChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => Promise<void>;
handleDelete: () => void;
};
export const StickyInput = (props: TProps) => {
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props;

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { Minimize2 } from "lucide-react";
@@ -6,6 +6,7 @@ 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 { StickyDeleteModal } from "../delete-modal";
import { StickyInput } from "./inputs";
import { useStickyOperations } from "./use-operations";
@@ -17,6 +18,8 @@ type TProps = {
};
export const StickyNote = observer((props: TProps) => {
const { onClose, workspaceSlug, className = "", stickyId } = props;
//state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// hooks
const { stickyOperations } = useStickyOperations({ workspaceSlug });
const { stickies } = useSticky();
@@ -49,24 +52,34 @@ export const StickyNote = observer((props: TProps) => {
};
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}
<>
<StickyDeleteModal
isOpen={isDeleteModalOpen}
handleSubmit={handleDelete}
handleClose={() => setIsDeleteModalOpen(false)}
/>
</div>
<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={() => {
if (!stickyId) return;
setIsDeleteModalOpen(true);
}}
handleChange={handleChange}
/>
</div>
</>
);
});