feat: New Pages with Enhanced Document Editor Packages made over Editor Core 📝 (#2784)

* fix: page transaction model

* fix: page transaction model

* feat: updated ui for page route

* chore: initailized `document-editor` package for plane

* fix: format persistence while pasting markdown in editor

* feat: Inititalized Document-Editor and Editor with Ref

* feat: added tooltip component and slash command for editor

* feat: added `document-editor` extensions

* feat: added custom search component for embedding labels

* feat: added top bar menu component

* feat: created document-editor exposed components

* feat: integrated `document-editor` in `pages` route

* chore: updated dependencies

* feat: merge conflict resolution

* chore: modified configuration for document editor

* feat: added content browser menu for document editor summary

* feat: added fixed menu and editor instances

* feat: added document edittor instances and summary table

* feat: implemented document-editor in PageDetail

* chore: css and export fixes

* fix: migration and optimisation

* fix: added `on_create` hook in the core editor

* feat: added conditional menu bar action in document-editor

* feat: added menu actions from single page view

* feat: added services for archiving, unarchiving and retriving archived pages

* feat: added services for page archives

* feat: implemented page archives in page list view

* feat: implemented page archives in document-editor

* feat: added editor marking hook

* chore: seperated editor header from the main content

* chore: seperated editor summary utilities from the main editor

* chore: refactored necessary components from the document editor

* chore: removed summary sidebar component from the main content editor

* chore: removed scrollSummaryDependency from Header and Sidebar

* feat: seperated page renderer as a seperate component

* chore: seperated page_renderer and sidebar as component from index

* feat: added locked property to IPage type

* feat: added lock/unlock services in page service

* chore: seperated DocumentDetails as exported interface from index

* feat: seperated document editor configs as seperate interfaces

* chore: seperated menu options from the editor header component

* fix: fixed page_lock performing lock/unlock operation on queryset instead of single instance

* fix: css positioning changes

* feat: added archive/lock alert labels

* feat: added boolean props in menu-actions/options

* feat: added lock/unlock & archive/unarchive services

* feat: added on update mutations for archived pages in page-view

* feat: added archive/lock on_update mutations in single page vieq

* feat: exported readonly editor for locked pages

* chore: seperated kanban menu props and saved over passing redundant data

* fix: readonly editor not generating markings on first render

* fix: cheveron overflowing from editor-header

* chore: removed unused utility actions

* fix: enabled sidebar view by default

* feat: removed locking on pages in archived state

* feat: added indentation in heading component

* fix: button classnames in vertical dropdowns

* feat: added `last_archived_at` and `last_edited_at` details in editor-header

* feat: changed types for archived updates and document last updates

* feat: updated editor and header props

* feat: updated queryset according to new page query format

* feat: added parameters in page view for shared / private pages

* feat: updated other-page-view to shared page view && same with private pages

* feat: added page-view as shared / private

* fix: replaced deleting to archiving for pages

* feat: handle restoring of page from archived section from list view

* feat: made previledge based option render for pages

* feat: removed layout view for page list view

* feat: linting changes

* fix: adding mobx changes to pages

* fix: removed uneccessary migrations

* fix: mobx store changes

* fix: adding date-fns pacakge

* fix: updating yarn lock

* fix: removing unneccessary method params

* chore: added access specifier to the create/update page modal

* fix: tab view layout changes

* chore: delete endpoint for page

* fix: page actions, including- archive, favorite, access control, delete

* chore: remove archive page modal

* fix: build errors

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
Henit Chobisa 2023-11-20 21:31:12 +05:30 committed by GitHub
parent b903126e5a
commit de581102e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
68 changed files with 2800 additions and 2173 deletions

View File

@ -136,7 +136,9 @@ class PageViewSet(BaseViewSet):
)
def lock(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
# only the owner can lock the page
if request.user.id != page.owned_by_id:
@ -149,7 +151,9 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, project_id=project_id
).first()
# only the owner can unlock the page
if request.user.id != page.owned_by_id:
@ -164,68 +168,10 @@ class PageViewSet(BaseViewSet):
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
page_view = request.GET.get("page_view", False)
if not page_view:
return Response(
{"error": "Page View parameter is required"},
status=status.HTTP_400_BAD_REQUEST,
)
# All Pages
if page_view == "all":
return Response(
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Recent pages
if page_view == "recent":
current_time = date.today()
day_before = current_time - timedelta(days=1)
todays_pages = queryset.filter(updated_at__date=date.today())
yesterdays_pages = queryset.filter(updated_at__date=day_before)
earlier_this_week = queryset.filter(
updated_at__date__range=(
(timezone.now() - timedelta(days=7)),
(timezone.now() - timedelta(days=2)),
)
)
return Response(
{
"today": PageSerializer(todays_pages, many=True).data,
"yesterday": PageSerializer(yesterdays_pages, many=True).data,
"earlier_this_week": PageSerializer(
earlier_this_week, many=True
).data,
},
status=status.HTTP_200_OK,
)
# Favorite Pages
if page_view == "favorite":
queryset = queryset.filter(is_favorite=True)
return Response(
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# My pages
if page_view == "created_by_me":
queryset = queryset.filter(owned_by=request.user)
return Response(
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
# Created by other Pages
if page_view == "created_by_other":
queryset = queryset.filter(~Q(owned_by=request.user), access=0)
return Response(
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
)
return Response(
{"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST
)
def archive(self, request, slug, project_id, page_id):
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
@ -251,25 +197,40 @@ class PageViewSet(BaseViewSet):
# if parent page is archived then the page will be un archived breaking the hierarchy
if page.parent_id and page.parent.archived_at:
page.parent = None
page.save(update_fields=['parent'])
page.save(update_fields=["parent"])
unarchive_archive_page_and_descendants(page_id, None)
return Response(status=status.HTTP_204_NO_CONTENT)
def archive_list(self, request, slug, project_id):
pages = (
Page.objects.filter(
pages = Page.objects.filter(
project_id=project_id,
workspace__slug=slug,
)
.filter(archived_at__isnull=False)
)
).filter(archived_at__isnull=False)
return Response(
PageSerializer(pages, many=True).data, status=status.HTTP_200_OK
)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
if page.archived_at is None:
return Response(
{"error": "The page should be archived before deleting"},
status=status.HTTP_400_BAD_REQUEST,
)
# remove parent from all the children
_ = Page.objects.filter(
parent_id=pk, project_id=project_id, workspace__slug=slug
).update(parent=None)
page.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class PageFavoriteViewSet(BaseViewSet):
permission_classes = [
@ -306,6 +267,7 @@ class PageFavoriteViewSet(BaseViewSet):
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class PageLogEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,

View File

@ -12,7 +12,7 @@ from celery import shared_task
from sentry_sdk import capture_exception
# Module imports
from plane.db.models import Issue, Project, State, Page
from plane.db.models import Issue, Project, State
from plane.bgtasks.issue_activites_task import issue_activity
@ -20,7 +20,6 @@ from plane.bgtasks.issue_activites_task import issue_activity
def archive_and_close_old_issues():
archive_old_issues()
close_old_issues()
delete_archived_pages()
def archive_old_issues():
@ -167,20 +166,3 @@ def close_old_issues():
print(e)
capture_exception(e)
return
def delete_archived_pages():
try:
pages_to_delete = Page.objects.filter(
archived_at__isnull=False,
archived_at__lte=(timezone.now() - timedelta(days=30)),
)
pages_to_delete._raw_delete(pages_to_delete.db)
return
except Exception as e:
if settings.DEBUG:
print(e)
capture_exception(e)
return

View File

@ -133,6 +133,7 @@ class Issue(ProjectBaseModel):
except ImportError:
pass
if self._state.adding:
# Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(project=self.project).aggregate(

View File

@ -18,6 +18,7 @@ interface CustomEditorProps {
value: string;
deleteFile: DeleteImage;
debouncedUpdatesEnabled?: boolean;
onStart?: (json: any, html: string) => void;
onChange?: (json: any, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
@ -34,6 +35,7 @@ export const useEditor = ({
editorProps = {},
value,
extensions = [],
onStart,
onChange,
setIsSubmitting,
forwardedRef,
@ -60,6 +62,9 @@ export const useEditor = ({
],
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onCreate: async ({ editor }) => {
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()))
},
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");

View File

@ -0,0 +1 @@
# Document Editor

View File

@ -0,0 +1,73 @@
{
"name": "@plane/document-editor",
"version": "0.0.1",
"description": "Package that powers Plane's Pages Editor",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist/**/*"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@plane/ui": "*",
"@plane/editor-core": "*",
"@popperjs/core": "^2.11.8",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-code-block-lowlight": "^2.1.11",
"@tiptap/extension-horizontal-rule": "^2.1.11",
"@tiptap/extension-list-item": "^2.1.11",
"@tiptap/extension-placeholder": "^2.1.11",
"@tiptap/suggestion": "^2.1.7",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"highlight.js": "^11.8.0",
"lowlight": "^3.0.0",
"lucide-react": "^0.244.0",
"react-markdown": "^8.0.7",
"react-popper": "^2.3.0",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"use-debounce": "^9.0.4"
},
"devDependencies": {
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"
},
"keywords": [
"editor",
"rich-text",
"markdown",
"nextjs",
"react"
]
}

View File

@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -0,0 +1,3 @@
export { DocumentEditor, DocumentEditorWithRef } from "./ui"
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly"
export { FixedMenu } from "./ui/menu/fixed-menu"

View File

@ -0,0 +1,19 @@
import { Icon } from "lucide-react"
interface IAlertLabelProps {
Icon: Icon,
backgroundColor: string,
textColor?: string,
label: string,
}
export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => {
return (
<div className={`text-xs flex items-center gap-1 ${backgroundColor} p-0.5 pl-3 pr-3 mr-1 rounded`}>
<Icon size={12} />
<span className={`normal-case ${textColor}`}>{label}</span>
</div>
)
}

View File

@ -0,0 +1,40 @@
import { HeadingComp, SubheadingComp } from "./heading-component";
import { IMarking } from "..";
import { Editor } from "@tiptap/react";
import { scrollSummary } from "../utils/editor-summary-utils";
interface ContentBrowserProps {
editor: Editor;
markings: IMarking[];
}
export const ContentBrowser = ({
editor,
markings,
}: ContentBrowserProps) => (
<div className="mt-4 flex w-[250px] flex-col h-full">
<h2 className="ml-4 border-b border-solid border-custom-border py-5 font-medium leading-[85.714%] tracking-tight max-md:ml-2.5">
Table of Contents
</h2>
<div className="mt-3 h-0.5 w-full self-stretch border-custom-border" />
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp
onClick={() => scrollSummary(editor, marking)}
heading={marking.text}
/>
) : (
<SubheadingComp
onClick={() => scrollSummary(editor, marking)}
subHeading={marking.text}
/>
)
)
) : (
<p className="ml-3 mr-3 flex h-full items-center px-5 text-center text-xs text-gray-500">
{"Headings will be displayed here for Navigation"}
</p>
)}
</div>
);

View File

@ -0,0 +1,79 @@
import { Editor } from "@tiptap/react"
import { Lock, ArchiveIcon, MenuSquare } from "lucide-react"
import { useRef, useState } from "react"
import { usePopper } from "react-popper"
import { IMarking, UploadImage } from ".."
import { FixedMenu } from "../menu"
import { DocumentDetails } from "../types/editor-types"
import { AlertLabel } from "./alert-label"
import { ContentBrowser } from "./content-browser"
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu"
interface IEditorHeader {
editor: Editor,
KanbanMenuOptions: IVerticalDropdownItemProps[],
sidePeakVisible: boolean,
setSidePeakVisible: (currentState: boolean) => void,
markings: IMarking[],
isLocked: boolean,
isArchived: boolean,
archivedAt?: Date,
readonly: boolean,
uploadFile?: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
documentDetails: DocumentDetails
}
export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => {
const summaryMenuRef = useRef(null);
const summaryButtonRef = useRef(null);
const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false);
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, {
placement: "bottom-start"
})
return (
<div className="border-custom-border self-stretch flex flex-col border-b border-solid max-md:max-w-full">
<div
className="self-center flex ml-0 w-full items-start justify-between gap-5 max-md:max-w-full max-md:flex-wrap max-md:justify-center">
<div className={"flex flex-row items-center"}>
<div
onMouseEnter={() => setSummaryPopoverVisible(true)}
onMouseLeave={() => setSummaryPopoverVisible(false)}
>
<button
ref={summaryButtonRef}
className={"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors"}
onClick={() => {
setSidePeakVisible(!sidePeakVisible)
setSummaryPopoverVisible(false)
}}
>
<MenuSquare
size={20}
/>
</button>
{summaryPopoverVisible &&
<div style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} className="z-10 h-[300px] w-[300px] ml-[40px] mt-[40px] shadow-xl rounded border-custom-border border-solid border-2 bg-custom-background-100 border-b pl-3 pr-3 pb-3 overflow-scroll">
<ContentBrowser editor={editor} markings={markings} />
</div>
}
</div>
{isLocked && <AlertLabel Icon={Lock} backgroundColor={"bg-red-200"} label={"Locked"} />}
{(isArchived && archivedAt) && <AlertLabel Icon={ArchiveIcon} backgroundColor={"bg-blue-200"} label={`Archived at ${new Date(archivedAt).toLocaleString()}`} />}
</div>
{(!readonly && uploadFile) && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
<div className="self-center flex items-start gap-3 my-auto max-md:justify-center"
>
{!isArchived && <p className="text-sm text-custom-text-300">{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}</p>}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,29 @@
export const HeadingComp = ({
heading,
onClick,
}: {
heading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<h3
onClick={onClick}
className="ml-4 mt-3 cursor-pointer text-sm font-bold font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
>
{heading}
</h3>
);
export const SubheadingComp = ({
subHeading,
onClick,
}: {
subHeading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<p
onClick={onClick}
className="ml-6 mt-2 text-xs cursor-pointer font-medium tracking-tight text-gray-400 hover:text-custom-primary"
>
{subHeading}
</p>
);

View File

@ -0,0 +1,33 @@
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"
import { Editor } from "@tiptap/react"
import { DocumentDetails } from "../types/editor-types"
interface IPageRenderer {
sidePeakVisible: boolean,
documentDetails: DocumentDetails ,
editor: Editor,
editorClassNames: string,
editorContentCustomClassNames?: string
}
export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => {
return (
<div className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${sidePeakVisible ? 'ml-[3%] ' : 'ml-0'}`}>
<div className="items-start mt-4 h-full flex flex-col w-fit max-md:max-w-full overflow-auto">
<div className="flex flex-col py-2 max-md:max-w-full">
<h1
className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full"
>{documentDetails.title}</h1>
</div>
<div className="border-custom-border border-b border-solid self-stretch w-full h-0.5 mt-3" />
<div className="flex flex-col max-md:max-w-full">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
</div>
</EditorContainer >
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,67 @@
import React, { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover, Transition } from "@headlessui/react";
import { Placement } from "@popperjs/core";
// ui
import { Button } from "@plane/ui";
// icons
import { ChevronUp, MenuIcon } from "lucide-react";
type Props = {
children: React.ReactNode;
title?: string;
placement?: Placement;
};
export const SummaryPopover: React.FC<Props> = (props) => {
const { children, title = "SummaryPopover", placement } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
return (
<Popover as="div">
{({ open }) => {
if (open) {
}
return (
<>
<Popover.Button as={React.Fragment}>
<Button
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
>
<MenuIcon size={20} />
</Button>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel>
<div
className="z-10 bg-custom-background-100 border border-custom-border-200 shadow-custom-shadow-rg rounded overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="w-[18.75rem] max-h-[37.5rem] flex flex-col overflow-hidden">{children}</div>
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
);
};

View File

@ -0,0 +1,18 @@
import { Editor } from "@tiptap/react"
import { IMarking } from ".."
import { ContentBrowser } from "./content-browser"
interface ISummarySideBarProps {
editor: Editor,
markings: IMarking[],
sidePeakVisible: boolean
}
export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => {
return (
<div className={`flex flex-col items-stretch w-[21%] max-md:w-full max-md:ml-0 border-custom-border border-r border-solid transition-all duration-200 ease-in-out transform ${sidePeakVisible ? 'translate-x-0' : '-translate-x-full'}`}>
<ContentBrowser editor={editor} markings={markings} />
</div>
)
}

View File

@ -0,0 +1,50 @@
import { Button, CustomMenu } from "@plane/ui"
import { ChevronUp, Icon, MoreVertical } from "lucide-react"
type TMenuItems = "archive_page" | "unarchive_page" | "lock_page" | "unlock_page" | "copy_markdown" | "close_page" | "copy_page_link" | "duplicate_page"
export interface IVerticalDropdownItemProps {
key: number,
type: TMenuItems,
Icon: Icon,
label: string,
action: () => Promise<void> | void
}
export interface IVerticalDropdownMenuProps {
items: IVerticalDropdownItemProps[],
}
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => {
return (
<CustomMenu.MenuItem>
<Button variant={"neutral-primary"} onClick={action} className="flex flex-row border-none items-center m-1 max-md:pr-5 cursor-pointer">
<Icon size={16} />
<div className="text-custom-text-300 ml-2 mr-2 leading-5 tracking-tight whitespace-nowrap self-start text-md">
{label}
</div>
</Button>
</CustomMenu.MenuItem>
)
}
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
return (
<CustomMenu maxHeight={"lg"} className={"h-4"} placement={"bottom-start"} optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "} customButton={
<MoreVertical size={18}/>
}>
{items.map((item, index) => (
<VerticalDropdownItem
key={index}
type={item.type}
Icon={item.Icon}
label={item.label}
action={item.action}
/>
))}
</CustomMenu>
)
}

View File

@ -0,0 +1,59 @@
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from 'lowlight'
import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
import SlashCommand from "./slash-command";
import { UploadImage } from "../";
const lowlight = createLowlight(common)
lowlight.register("ts", ts);
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) => [
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
const attributes = {};
const { tr } = state;
const start = range.from;
const end = range.to;
// @ts-ignore
tr.replaceWith(start - 1, end, this.type.create(attributes));
},
}),
];
},
}).configure({
HTMLAttributes: {
class: "mb-6 border-t border-custom-border-300",
},
}),
SlashCommand(uploadFile, setIsSubmitting),
CodeBlockLowlight.configure({
lowlight,
}),
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
return "Press '/' for commands...";
},
includeChildren: true,
}),
];

View File

@ -0,0 +1,343 @@
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import {
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Text,
TextQuote,
Code,
MinusSquare,
CheckSquare,
ImageIcon,
Table,
} from "lucide-react";
import { UploadImage } from "../";
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
interface CommandItemProps {
title: string;
description: string;
icon: ReactNode;
}
interface CommandProps {
editor: Editor;
range: Range;
}
const Command = Extension.create({
name: "slash-command",
addOptions() {
return {
suggestion: {
char: "/",
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
props.command({ editor, range });
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
allow({ editor }) {
return !editor.isActive("table");
},
...this.options.suggestion,
}),
];
},
});
const getSuggestionItems =
(
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingOne(editor, range);
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range)
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
},
},
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
insertTableCommand(editor, range);
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range)
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
toggleBlockquote(editor, range)
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const CommandList = ({
items,
command,
}: {
items: CommandItemProps[];
command: any;
editor: any;
range: any;
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[command, items]
);
useEffect(() => {
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
const onKeyDown = (e: KeyboardEvent) => {
if (navigationKeys.includes(e.key)) {
e.preventDefault();
if (e.key === "ArrowUp") {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
return true;
}
if (e.key === "ArrowDown") {
setSelectedIndex((selectedIndex + 1) % items.length);
return true;
}
if (e.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
};
}, [items, selectedIndex, setSelectedIndex, selectItem]);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
return items.length > 0 ? (
<div
id="slash-command"
ref={commandListContainer}
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
>
{items.map((item: CommandItemProps, index: number) => (
<button
className={cn(
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
)}
key={index}
onClick={() => selectItem(index)}
>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-custom-text-300">{item.description}</p>
</div>
</button>
))}
</div>
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
// @ts-ignore
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#editor-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
},
onExit: () => {
popup?.[0].destroy();
component?.destroy();
},
};
};
export const SlashCommand = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
Command.configure({
suggestion: {
items: getSuggestionItems(uploadFile, setIsSubmitting),
render: renderItems,
},
});
export default SlashCommand;

View File

@ -0,0 +1,33 @@
import { Editor } from "@tiptap/react";
import { useState } from "react";
import { IMarking } from "..";
export const useEditorMarkings = () => {
const [markings, setMarkings] = useState<IMarking[]>([])
const updateMarkings = (json: any) => {
const nodes = json.content as any[]
const tempMarkings: IMarking[] = []
let h1Sequence: number = 0
let h2Sequence: number = 0
if (nodes) {
nodes.forEach((node) => {
if (node.type === "heading" && (node.attrs.level === 1 || node.attrs.level === 2) && node.content) {
tempMarkings.push({
type: "heading",
level: node.attrs.level,
text: node.content[0].text,
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence
})
}
})
}
setMarkings(tempMarkings)
}
return {
updateMarkings,
markings,
}
}

View File

@ -0,0 +1,151 @@
"use client"
import React, { useState } from 'react';
import { cn, getEditorClassNames, useEditor } from '@plane/editor-core';
import { DocumentEditorExtensions } from './extensions';
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions';
import { EditorHeader } from './components/editor-header';
import { useEditorMarkings } from './hooks/use-editor-markings';
import { SummarySideBar } from './components/summary-side-bar';
import { DocumentDetails } from './types/editor-types';
import { PageRenderer } from './components/page-renderer';
import { getMenuOptions } from './utils/menu-options';
import { useRouter } from 'next/router';
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
interface IDocumentEditor {
documentDetails: DocumentDetails,
value: string;
uploadFile: UploadImage;
deleteFile: DeleteImage;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
duplicationConfig?: IDuplicationConfig,
pageLockConfig?: IPageLockConfig,
pageArchiveConfig?: IPageArchiveConfig
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
export interface IMarking {
type: "heading",
level: number,
text: string,
sequence: number
}
const DocumentEditor = ({
documentDetails,
onChange,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
uploadFile,
deleteFile,
customClassName,
forwardedRef,
duplicationConfig,
pageLockConfig,
pageArchiveConfig
}: IDocumentEditor) => {
// const [alert, setAlert] = useState<string>("")
const { markings, updateMarkings } = useEditorMarkings()
const [sidePeakVisible, setSidePeakVisible] = useState(true)
const router = useRouter()
const editor = useEditor({
onChange(json, html) {
updateMarkings(json)
onChange(json, html)
},
onStart(json) {
updateMarkings(json)
},
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
value,
uploadFile,
deleteFile,
forwardedRef,
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
});
if (!editor) {
return null
}
const KanbanMenuOptions = getMenuOptions(
{
editor: editor,
router: router,
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
}
)
const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName });
if (!editor) return null;
return (
<div className="flex flex-col">
<div className="top-0 sticky z-10 bg-custom-background-100">
<EditorHeader
readonly={false}
KanbanMenuOptions={KanbanMenuOptions}
editor={editor}
sidePeakVisible={sidePeakVisible}
setSidePeakVisible={setSidePeakVisible}
markings={markings}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
/>
</div>
<div className="self-center items-stretch w-full max-md:max-w-full h-full">
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 h-full", { "justify-center": !sidePeakVisible })}>
<SummarySideBar
editor={editor}
markings={markings}
sidePeakVisible={sidePeakVisible}
/>
<PageRenderer
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
editorClassNames={editorClassNames}
sidePeakVisible={sidePeakVisible}
documentDetails={documentDetails}
/>
{/* Page Element */}
</div>
</div>
</div>
);
}
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref} />
));
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
export { DocumentEditor, DocumentEditorWithRef }

View File

@ -0,0 +1,142 @@
import { Editor } from "@tiptap/react";
import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react";
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core";
import { UploadImage } from "..";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
}
type EditorBubbleMenuProps = {
editor: Editor;
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
}
export const FixedMenu = (props: EditorBubbleMenuProps) => {
const basicMarkItems: BubbleMenuItem[] = [
HeadingOneItem(props.editor),
HeadingTwoItem(props.editor),
HeadingThreeItem(props.editor),
BoldItem(props.editor),
ItalicItem(props.editor),
UnderLineItem(props.editor),
StrikeThroughItem(props.editor),
];
const listItems: BubbleMenuItem[] = [
BulletListItem(props.editor),
NumberedListItem(props.editor),
];
const userActionItems: BubbleMenuItem[] = [
QuoteItem(props.editor),
CodeItem(props.editor),
];
const complexItems: BubbleMenuItem[] = [
TableItem(props.editor),
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
];
// const handleAccessChange = (accessKey: string) => {
// props.commentAccessSpecifier?.onAccessChange(accessKey);
// };
return (
<div
className="flex w-fit rounded bg-custom-background-100"
>
<div className="flex">
{basicMarkItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
size={ item.icon === Heading1 || item.icon === Heading2 || item.icon === Heading3 ? 20 : 15}
className={cn({
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex">
{listItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex">
{userActionItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex">
{complexItems.map((item, index) => (
<button
key={index}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import React from "react";
type Props = {
iconName: string;
className?: string;
};
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
{iconName}
</span>
);

View File

@ -0,0 +1 @@
export { FixedMenu } from "./fixed-menu";

View File

@ -0,0 +1,121 @@
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"
import { useRouter } from "next/router";
import { useState, forwardRef, useEffect } from 'react'
import { EditorHeader } from "../components/editor-header";
import { PageRenderer } from "../components/page-renderer";
import { SummarySideBar } from "../components/summary-side-bar";
import { useEditorMarkings } from "../hooks/use-editor-markings";
import { DocumentDetails } from "../types/editor-types";
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "../types/menu-actions";
import { getMenuOptions } from "../utils/menu-options";
interface IDocumentReadOnlyEditor {
value: string,
noBorder: boolean,
borderOnFocus: boolean,
customClassName: string,
documentDetails: DocumentDetails,
pageLockConfig?: IPageLockConfig,
pageArchiveConfig?: IPageArchiveConfig,
pageDuplicationConfig?: IDuplicationConfig,
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
forwardedRef?: React.Ref<EditorHandle>
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
const DocumentReadOnlyEditor = ({
noBorder,
borderOnFocus,
customClassName,
value,
documentDetails,
forwardedRef,
pageDuplicationConfig,
pageLockConfig,
pageArchiveConfig,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter()
const [sidePeakVisible, setSidePeakVisible] = useState(true)
const { markings, updateMarkings } = useEditorMarkings()
const editor = useReadOnlyEditor({
value,
forwardedRef,
})
useEffect(() => {
if (editor) {
updateMarkings(editor.getJSON())
}
}, [editor?.getJSON()])
if (!editor) {
return null
}
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName
})
const KanbanMenuOptions = getMenuOptions({
editor: editor,
router: router,
pageArchiveConfig: pageArchiveConfig,
pageLockConfig: pageLockConfig,
duplicationConfig: pageDuplicationConfig,
})
return (
<div className="flex flex-col">
<div className="top-0 sticky z-10 bg-custom-background-100">
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly={true}
editor={editor}
sidePeakVisible={sidePeakVisible}
setSidePeakVisible={setSidePeakVisible}
KanbanMenuOptions={KanbanMenuOptions}
markings={markings}
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
</div>
<div className="self-center items-stretch w-full max-md:max-w-full overflow-y-hidden">
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 overflow-y-hidden", { "justify-center": !sidePeakVisible })}>
<SummarySideBar
editor={editor}
markings={markings}
sidePeakVisible={sidePeakVisible}
/>
<PageRenderer
editor={editor}
editorClassNames={editorClassNames}
sidePeakVisible={sidePeakVisible}
documentDetails={documentDetails}
/>
</div>
</div>
</div>
)
}
const DocumentReadOnlyEditorWithRef = forwardRef<
EditorHandle,
IDocumentReadOnlyEditor
>((props, ref) => <DocumentReadOnlyEditor {...props} forwardedRef={ref} />);
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }

View File

@ -0,0 +1,77 @@
import * as React from 'react';
// next-themes
import { useTheme } from "next-themes";
// tooltip2
import { Tooltip2 } from "@blueprintjs/popover2";
type Props = {
tooltipHeading?: string;
tooltipContent: string | React.ReactNode;
position?:
| "top"
| "right"
| "bottom"
| "left"
| "auto"
| "auto-end"
| "auto-start"
| "bottom-left"
| "bottom-right"
| "left-bottom"
| "left-top"
| "right-bottom"
| "right-top"
| "top-left"
| "top-right";
children: JSX.Element;
disabled?: boolean;
className?: string;
openDelay?: number;
closeDelay?: number;
};
export const Tooltip: React.FC<Props> = ({
tooltipHeading,
tooltipContent,
position = "top",
children,
disabled = false,
className = "",
openDelay = 200,
closeDelay,
}) => {
const { theme } = useTheme();
return (
<Tooltip2
disabled={disabled}
hoverOpenDelay={openDelay}
hoverCloseDelay={closeDelay}
content={
<div
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
theme === "custom"
? "bg-custom-background-100 text-custom-text-200"
: "bg-black text-gray-400"
} break-words overflow-hidden ${className}`}
>
{tooltipHeading && (
<h5
className={`font-medium ${
theme === "custom" ? "text-custom-text-100" : "text-white"
}`}
>
{tooltipHeading}
</h5>
)}
{tooltipContent}
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
}
/>
);
};

View File

@ -0,0 +1,8 @@
export interface DocumentDetails {
title: string;
created_by: string;
created_on: Date;
last_updated_by: string;
last_updated_at: Date;
}

View File

@ -0,0 +1,14 @@
export interface IDuplicationConfig {
action: () => Promise<void>
}
export interface IPageLockConfig {
is_locked: boolean,
action: () => Promise<void>
locked_by?: string,
}
export interface IPageArchiveConfig {
is_archived: boolean,
archived_at?: Date,
action: () => Promise<void>
}

View File

@ -0,0 +1,35 @@
import { Editor } from "@tiptap/react";
import { IMarking } from "..";
function findNthH1(editor: Editor, n: number, level: number): number {
let count = 0;
let pos = 0;
editor.state.doc.descendants((node, position) => {
if (node.type.name === 'heading' && node.attrs.level === level) {
count++;
if (count === n) {
pos = position;
return false;
}
}
});
return pos;
}
function scrollToNode(editor: Editor, pos: number): void {
const headingNode = editor.state.doc.nodeAt(pos);
if (headingNode) {
const headingDOM = editor.view.nodeDOM(pos);
if (headingDOM instanceof HTMLElement) {
headingDOM.scrollIntoView({ behavior: 'smooth' });
}
}
}
export function scrollSummary(editor: Editor, marking: IMarking) {
if (editor) {
const pos = findNthH1(editor, marking.sequence, marking.level)
scrollToNode(editor, pos)
}
}

View File

@ -0,0 +1,12 @@
import { Editor } from "@tiptap/core"
export const copyMarkdownToClipboard = (editor: Editor | null) => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
navigator.clipboard.writeText(markdownOutput)
}
export const CopyPageLink = () => {
if (window){
navigator.clipboard.writeText(window.location.toString())
}
}

View File

@ -0,0 +1,75 @@
import { Editor } from "@tiptap/react"
import { Archive, ArchiveIcon, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock, XCircle } from "lucide-react"
import { NextRouter } from "next/router"
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu"
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "../types/menu-actions"
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions"
export interface MenuOptionsProps{
editor: Editor,
router: NextRouter,
duplicationConfig?: IDuplicationConfig,
pageLockConfig?: IPageLockConfig ,
pageArchiveConfig?: IPageArchiveConfig,
}
export const getMenuOptions = ({ editor, router, duplicationConfig, pageLockConfig, pageArchiveConfig } : MenuOptionsProps) => {
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
{
key: 1,
type: "copy_markdown",
Icon: ClipboardIcon,
action: () => copyMarkdownToClipboard(editor),
label: "Copy Markdown"
},
{
key: 2,
type: "close_page",
Icon: XCircle,
action: () => router.back(),
label: "Close the page"
},
{
key: 3,
type: "copy_page_link",
Icon: Link,
action: () => CopyPageLink(),
label: "Copy Page Link"
},
]
// If duplicateConfig is given, page duplication will be allowed
if (duplicationConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
type: "duplicate_page",
Icon: Copy,
action: duplicationConfig.action,
label: "Make a copy"
})
}
// If Lock Configuration is given then, lock page option will be available in the kanban menu
if (pageLockConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
Icon: pageLockConfig.is_locked ? Unlock : Lock,
label: pageLockConfig.is_locked ? "Unlock Page" : "Lock Page",
action: pageLockConfig.action
})
}
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
if (pageArchiveConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
label: pageArchiveConfig.is_archived ? "Restore Page" : "Archive Page",
action: pageArchiveConfig.action,
})
}
return KanbanMenuOptions
}

View File

@ -0,0 +1,6 @@
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
module.exports = {
// prefix ui lib classes to avoid conflicting with the app
...sharedConfig,
};

View File

@ -0,0 +1,5 @@
{
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View File

@ -0,0 +1,11 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));

View File

@ -31,6 +31,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
"@plane/document-editor#build",
"@plane/ui#build"
]
},
@ -40,6 +41,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
"@plane/document-editor#build",
"@plane/ui#build"
]
},
@ -48,6 +50,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
"@plane/document-editor#build",
"@plane/ui#build"
]
},
@ -56,6 +59,7 @@
"dependsOn": [
"@plane/lite-text-editor#build",
"@plane/rich-text-editor#build",
"@plane/document-editor#build",
"@plane/ui#build"
]
},
@ -67,6 +71,12 @@
"cache": true,
"dependsOn": ["@plane/editor-core#build"]
},
"@plane/document-editor#build": {
"cache": true,
"dependsOn": [
"@plane/editor-core#build"
]
},
"test": {
"dependsOn": ["^build"],
"outputs": []

View File

@ -203,8 +203,6 @@ export const CommandPalette: FC = observer(() => {
<CreateUpdatePageModal
isOpen={isCreatePageModalOpen}
handleClose={() => toggleCreatePageModal(false)}
user={user}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
</>

View File

@ -250,7 +250,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
handleRemoveFromFavorites(e);
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
<Star className="h-4 w-4 text-orange-400 fill-orange-400" />
</button>
) : (
<button

View File

@ -313,7 +313,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
<Transition.Child
as={React.Fragment}

View File

@ -1,35 +1,33 @@
import React from "react";
import React, { FC } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
import { Dialog, Transition } from "@headlessui/react";
// services
import { PageService } from "services/page.service";
// hooks
import useToast from "hooks/use-toast";
// components
import { PageForm } from "./page-form";
// types
import { IUser, IPage } from "types";
// fetch-keys
import { ALL_PAGES_LIST, FAVORITE_PAGES_LIST, MY_PAGES_LIST, RECENT_PAGES_LIST } from "constants/fetch-keys";
import { IPage } from "types";
// store
import { useMobxStore } from "lib/mobx/store-provider";
// helpers
import { trackEvent } from "helpers/event-tracker.helper";
type Props = {
isOpen: boolean;
handleClose: () => void;
data?: IPage | null;
user: IUser | undefined;
workspaceSlug: string;
handleClose: () => void;
isOpen: boolean;
projectId: string;
};
// services
const pageService = new PageService();
export const CreateUpdatePageModal: React.FC<Props> = (props) => {
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
export const CreateUpdatePageModal: FC<Props> = (props) => {
const { isOpen, handleClose, data, projectId } = props;
// router
const router = useRouter();
const { workspaceSlug } = router.query;
// store
const {
page: { createPage, updatePage },
} = useMobxStore();
const { setToastAlert } = useToast();
@ -37,43 +35,22 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
handleClose();
};
const createPage = async (payload: IPage) => {
await pageService
.createPage(workspaceSlug as string, projectId as string, payload)
const createProjectPage = async (payload: IPage) => {
if (!workspaceSlug) return;
createPage(workspaceSlug.toString(), projectId, payload)
.then((res) => {
mutate(RECENT_PAGES_LIST(projectId as string));
mutate<IPage[]>(
MY_PAGES_LIST(projectId as string),
(prevData) => {
if (!prevData) return undefined;
return [res, ...(prevData as IPage[])];
},
false
);
mutate<IPage[]>(
ALL_PAGES_LIST(projectId as string),
(prevData) => {
if (!prevData) return undefined;
return [res, ...(prevData as IPage[])];
},
false
);
onClose();
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
onClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Page created successfully.",
});
trackEvent(
'PAGE_CREATE',
{
trackEvent("PAGE_CREATE", {
...res,
caase: "SUCCES"
}
)
case: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
@ -81,64 +58,27 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
title: "Error!",
message: "Page could not be created. Please try again.",
});
trackEvent(
'PAGE_CREATE',
{
case: "FAILED"
}
)
trackEvent("PAGE_CREATE", {
case: "FAILED",
});
});
};
const updatePage = async (payload: IPage) => {
await pageService
.patchPage(workspaceSlug as string, projectId as string, data?.id ?? "", payload)
const updateProjectPage = async (payload: IPage) => {
if (!data || !workspaceSlug) return;
return updatePage(workspaceSlug.toString(), projectId, data.id, payload)
.then((res) => {
mutate(RECENT_PAGES_LIST(projectId as string));
mutate<IPage[]>(
FAVORITE_PAGES_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === res.id) return { ...p, ...res };
return p;
}),
false
);
mutate<IPage[]>(
MY_PAGES_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === res.id) return { ...p, ...res };
return p;
}),
false
);
mutate<IPage[]>(
ALL_PAGES_LIST(projectId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === res.id) return { ...p, ...res };
return p;
}),
false
);
onClose();
setToastAlert({
type: "success",
title: "Success!",
message: "Page updated successfully.",
});
trackEvent(
'PAGE_UPDATE',
{
trackEvent("PAGE_UPDATE", {
...res,
case: "SUCCESS"
}
)
case: "SUCCESS",
});
})
.catch(() => {
setToastAlert({
@ -146,20 +86,17 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
title: "Error!",
message: "Page could not be updated. Please try again.",
});
trackEvent(
'PAGE_UPDATE',
{
case: "FAILED"
}
)
trackEvent("PAGE_UPDATE", {
case: "FAILED",
});
});
};
const handleFormSubmit = async (formData: IPage) => {
if (!workspaceSlug || !projectId) return;
if (!data) await createPage(formData);
else await updatePage(formData);
if (!data) await createProjectPage(formData);
else await updateProjectPage(formData);
};
return (
@ -178,7 +115,7 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
</Transition.Child>
<div className="fixed inset-0 z-20 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<div className="flex justify-center text-center p-4 sm:p-0 my-10 md:my-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
@ -188,13 +125,8 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
<PageForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
status={data ? true : false}
data={data}
/>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all px-4 sm:w-full sm:max-w-2xl">
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} />
</Dialog.Panel>
</Transition.Child>
</div>

View File

@ -1,13 +1,9 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import { mutate } from "swr";
// headless ui
import { observer } from "mobx-react-lite";
import { Dialog, Transition } from "@headlessui/react";
// services
import { PageService } from "services/page.service";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useToast from "hooks/use-toast";
// ui
@ -15,56 +11,40 @@ import { Button } from "@plane/ui";
// icons
import { AlertTriangle } from "lucide-react";
// types
import type { IUser, IPage } from "types";
// fetch-keys
import { ALL_PAGES_LIST, FAVORITE_PAGES_LIST, MY_PAGES_LIST, RECENT_PAGES_LIST } from "constants/fetch-keys";
import type { IPage } from "types";
type TConfirmPageDeletionProps = {
isOpen: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
data?: IPage | null;
user: IUser | undefined;
isOpen: boolean;
onClose: () => void;
};
// services
const pageService = new PageService();
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
const { data, isOpen, onClose } = props;
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, setIsOpen, data }) => {
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const {
page: { deletePage },
} = useMobxStore();
const { setToastAlert } = useToast();
const handleClose = () => {
setIsOpen(false);
setIsDeleteLoading(false);
setIsDeleting(false);
onClose();
};
const handleDeletion = async () => {
setIsDeleteLoading(true);
const handleDelete = async () => {
if (!data || !workspaceSlug || !projectId) return;
await pageService
.deletePage(workspaceSlug as string, data.project, data.id)
setIsDeleting(true);
await deletePage(workspaceSlug.toString(), data.project, data.id)
.then(() => {
mutate(RECENT_PAGES_LIST(projectId as string));
mutate<IPage[]>(
MY_PAGES_LIST(projectId as string),
(prevData) => (prevData ?? []).filter((page) => page.id !== data?.id),
false
);
mutate<IPage[]>(
ALL_PAGES_LIST(projectId as string),
(prevData) => (prevData ?? []).filter((page) => page.id !== data?.id),
false
);
mutate<IPage[]>(
FAVORITE_PAGES_LIST(projectId as string),
(prevData) => (prevData ?? []).filter((page) => page.id !== data?.id),
false
);
handleClose();
setToastAlert({
type: "success",
@ -80,7 +60,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
});
})
.finally(() => {
setIsDeleteLoading(false);
setIsDeleting(false);
});
};
@ -122,9 +102,9 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-custom-text-200">
Are you sure you want to delete Page-{" "}
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? All of the
data related to the page will be permanently removed. This action cannot be undone.
Are you sure you want to delete page-{" "}
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? The Page
will be deleted permanently. This action cannot be undone.
</p>
</div>
</div>
@ -134,8 +114,8 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
{isDeleteLoading ? "Deleting..." : "Delete"}
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDelete} loading={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
</Dialog.Panel>
@ -145,4 +125,4 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
</Dialog>
</Transition.Root>
);
};
});

View File

@ -1,10 +1,6 @@
export * from "./pages-list";
export * from "./create-block";
export * from "./create-update-block-inline";
export * from "./create-update-page-modal";
export * from "./delete-page-modal";
export * from "./page-form";
export * from "./pages-view";
export * from "./single-page-block";
export * from "./single-page-detailed-item";
export * from "./single-page-list-item";
export * from "./create-block";

View File

@ -1,23 +1,24 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button, Input } from "@plane/ui";
import { Button, Input, Tooltip } from "@plane/ui";
// types
import { IPage } from "types";
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
type Props = {
handleFormSubmit: (values: IPage) => Promise<void>;
handleClose: () => void;
status: boolean;
data?: IPage | null;
};
const defaultValues = {
name: "",
description: "",
access: 0,
};
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, data }) => {
const {
formState: { errors, isSubmitting },
handleSubmit,
@ -44,8 +45,8 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
return (
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
<div className="space-y-5">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{status ? "Update" : "Create"} Page</h3>
<div className="space-y-4">
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{data ? "Update" : "Create"} Page</h3>
<div className="space-y-3">
<div>
<Controller
@ -61,26 +62,57 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
render={({ field: { value, onChange, ref } }) => (
<Input
id="name"
name="name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.name)}
placeholder="Title"
className="resize-none text-xl w-full"
className="resize-none text-lg w-full"
/>
)}
/>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<div className="mt-5 flex items-center justify-between gap-2">
<Controller
control={control}
name="access"
render={({ field: { value, onChange } }) => (
<div className="flex items-center gap-2">
<div className="flex-shrink-0 flex items-stretch gap-0.5 border-[0.5px] border-custom-border-200 rounded p-1">
{PAGE_ACCESS_SPECIFIERS.map((access) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<button
type="button"
onClick={() => onChange(access.key)}
className={`aspect-square grid place-items-center p-1 rounded-sm hover:bg-custom-background-90 ${
value === access.key ? "bg-custom-background-90" : ""
}`}
>
<access.icon
className={`w-3.5 h-3.5 ${
value === access.key ? "text-custom-text-100" : "text-custom-text-400"
}`}
strokeWidth={2}
/>
</button>
</Tooltip>
))}
</div>
<h6 className="text-xs font-medium">
{PAGE_ACCESS_SPECIFIERS.find((access) => access.key === value)?.label}
</h6>
</div>
)}
/>
<div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
{status
{data
? isSubmitting
? "Updating Page..."
: "Update Page"
@ -89,6 +121,7 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
: "Create Page"}
</Button>
</div>
</div>
</form>
);
};

View File

@ -1,29 +1,26 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { PageService } from "services/page.service";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { PagesView } from "components/pages";
// types
import { TPagesListProps } from "./types";
import { PagesListView } from "components/pages/pages-list";
// fetch-keys
import { ALL_PAGES_LIST } from "constants/fetch-keys";
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Loader } from "@plane/ui";
// services
const pageService = new PageService();
export const AllPagesList: FC = observer(() => {
// store
const {
page: { projectPages },
} = useMobxStore();
export const AllPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? ALL_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "all")
: null
if (!projectPages)
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
return <PagesView pages={pages} viewType={viewType} />;
};
return <PagesListView pages={projectPages} />;
});

View File

@ -0,0 +1,25 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { PagesListView } from "components/pages/pages-list";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Loader } from "@plane/ui";
export const ArchivedPagesList: FC = observer(() => {
const {
page: { archivedProjectPages },
} = useMobxStore();
if (!archivedProjectPages)
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
return <PagesListView pages={archivedProjectPages} />;
});

View File

@ -1,29 +1,25 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { PageService } from "services/page.service";
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { PagesView } from "components/pages";
// types
import { TPagesListProps } from "./types";
// fetch-keys
import { FAVORITE_PAGES_LIST } from "constants/fetch-keys";
import { PagesListView } from "components/pages/pages-list";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Loader } from "@plane/ui";
// services
const pageService = new PageService();
export const FavoritePagesList: FC = observer(() => {
const {
page: { favoriteProjectPages },
} = useMobxStore();
export const FavoritePagesList: React.FC<TPagesListProps> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? FAVORITE_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "favorite")
: null
if (!favoriteProjectPages)
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
return <PagesView pages={pages} viewType={viewType} />;
};
return <PagesListView pages={favoriteProjectPages} />;
});

View File

@ -1,6 +1,8 @@
export * from "./all-pages-list";
export * from "./archived-pages-list";
export * from "./favorite-pages-list";
export * from "./my-pages-list";
export * from "./other-pages-list";
export * from "./private-page-list";
export * from "./shared-pages-list";
export * from "./recent-pages-list";
export * from "./types";
export * from "./list-view";

View File

@ -0,0 +1,303 @@
import { FC, useState } from "react";
import Link from "next/link";
import { observer } from "mobx-react-lite";
// icons
import {
AlertCircle,
Archive,
ArchiveRestoreIcon,
FileText,
Globe2,
LinkIcon,
Lock,
Pencil,
Star,
Trash2,
} from "lucide-react";
// hooks
import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
// helpers
import { copyUrlToClipboard } from "helpers/string.helper";
import { renderShortDate, render24HourFormatTime, renderLongDateFormat } from "helpers/date-time.helper";
// ui
import { CustomMenu, Tooltip } from "@plane/ui";
// components
import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
// types
import { IPage } from "types";
export interface IPagesListItem {
workspaceSlug: string;
projectId: string;
page: IPage;
}
export const PagesListItem: FC<IPagesListItem> = observer((props) => {
const { workspaceSlug, projectId, page } = props;
// states
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
const [deletePageModal, setDeletePageModal] = useState(false);
// store
const {
page: { archivePage, removeFromFavorites, addToFavorites, makePublic, makePrivate, restorePage },
user: { currentProjectRole },
projectMember: { projectMembers },
} = useMobxStore();
// hooks
const { setToastAlert } = useToast();
const handleCopyUrl = (e: any) => {
e.preventDefault();
e.stopPropagation();
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
};
const handleAddToFavorites = (e: any) => {
e.preventDefault();
e.stopPropagation();
addToFavorites(workspaceSlug, projectId, page.id)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the page to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the page to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (e: any) => {
e.preventDefault();
e.stopPropagation();
removeFromFavorites(workspaceSlug, projectId, page.id)
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the page from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the page from favorites. Please try again.",
});
});
};
const handleMakePublic = (e: any) => {
e.preventDefault();
e.stopPropagation();
makePublic(workspaceSlug, projectId, page.id);
};
const handleMakePrivate = (e: any) => {
e.preventDefault();
e.stopPropagation();
makePrivate(workspaceSlug, projectId, page.id);
};
const handleArchivePage = (e: any) => {
e.preventDefault();
e.stopPropagation();
archivePage(workspaceSlug, projectId, page.id);
};
const handleRestorePage = (e: any) => {
e.preventDefault();
e.stopPropagation();
restorePage(workspaceSlug, projectId, page.id);
};
const handleDeletePage = (e: any) => {
e.preventDefault();
e.stopPropagation();
setDeletePageModal(true);
};
const handleEditPage = (e: any) => {
e.preventDefault();
e.stopPropagation();
setCreateUpdatePageModal(true);
};
const userCanEdit = currentProjectRole === 15 || currentProjectRole === 20;
return (
<>
<CreateUpdatePageModal
isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)}
data={page}
projectId={projectId}
/>
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} data={page} />
<li>
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
<a>
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
<div className="flex items-center justify-between">
<div className="flex overflow-hidden items-center gap-2">
<FileText className="h-4 w-4 shrink-0" />
<p className="mr-2 truncate text-sm text-custom-text-100">{page.name}</p>
{page.label_details.length > 0 &&
page.label_details.map((label) => (
<div
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
style={{
backgroundColor: `${label?.color}20`,
}}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color,
}}
/>
{label.name}
</div>
))}
</div>
<div className="flex items-center gap-2.5">
{page.archived_at ? (
<Tooltip
tooltipContent={`Archived at ${render24HourFormatTime(page.archived_at)} on ${renderShortDate(
page.archived_at
)}`}
>
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.archived_at)}</p>
</Tooltip>
) : (
<Tooltip
tooltipContent={`Last updated at ${render24HourFormatTime(page.updated_at)} on ${renderShortDate(
page.updated_at
)}`}
>
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
</Tooltip>
)}
{!page.archived_at && userCanEdit && (
<Tooltip tooltipContent={`${page.is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
{page.is_favorite ? (
<button type="button" onClick={handleRemoveFromFavorites}>
<Star className="h-3.5 w-3.5 text-orange-400 fill-orange-400" />
</button>
) : (
<button type="button" onClick={handleAddToFavorites}>
<Star className="h-3.5 w-3.5" />
</button>
)}
</Tooltip>
)}
{!page.archived_at && userCanEdit && (
<Tooltip
tooltipContent={`${
page.access
? "This page is only visible to you"
: "This page can be viewed by anyone in the project"
}`}
>
{page.access ? (
<button type="button" onClick={handleMakePublic}>
<Lock className="h-3.5 w-3.5" />
</button>
) : (
<button type="button" onClick={handleMakePrivate}>
<Globe2 className="h-3.5 w-3.5" />
</button>
)}
</Tooltip>
)}
<Tooltip
position="top-right"
tooltipContent={`Created by ${
projectMembers?.find((projectMember) => projectMember.member.id === page.created_by)?.member
.display_name ?? ""
} on ${renderLongDateFormat(`${page.created_at}`)}`}
>
<AlertCircle className="h-3.5 w-3.5" />
</Tooltip>
{page.archived_at ? (
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
{userCanEdit && (
<>
<CustomMenu.MenuItem onClick={handleRestorePage}>
<div className="flex items-center gap-2">
<ArchiveRestoreIcon className="h-3 w-3" />
<span>Restore page</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleDeletePage}>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
<span>Delete page</span>
</div>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyUrl}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy page link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
) : (
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
{userCanEdit && (
<>
<CustomMenu.MenuItem onClick={handleEditPage}>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
<span>Edit page</span>
</div>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleArchivePage}>
<div className="flex items-center gap-2">
<Archive className="h-3 w-3" />
<span>Archive page</span>
</div>
</CustomMenu.MenuItem>
</>
)}
<CustomMenu.MenuItem onClick={handleCopyUrl}>
<div className="flex items-center gap-2">
<LinkIcon className="h-3 w-3" />
<span>Copy page link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
)}
</div>
</div>
</div>
</a>
</Link>
</li>
</>
);
});

View File

@ -0,0 +1,65 @@
import { FC } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import { Plus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { EmptyState } from "components/common";
import { PagesListItem } from "./list-item";
// ui
import { Loader } from "@plane/ui";
// images
import emptyPage from "public/empty-state/page.svg";
// types
import { IPage } from "types";
type IPagesListView = {
pages: IPage[];
};
export const PagesListView: FC<IPagesListView> = observer(({ pages }) => {
// store
const { commandPalette: commandPaletteStore } = useMobxStore();
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
return (
<>
{pages && workspaceSlug && projectId ? (
<div className="space-y-4 h-full overflow-y-auto">
{pages.length > 0 ? (
<ul role="list" className="divide-y divide-custom-border-200">
{pages.map((page) => (
<PagesListItem
key={page.id}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
page={page}
/>
))}
</ul>
) : (
<EmptyState
title="Have your thoughts in place"
description="You can think of Pages as an AI-powered notepad."
image={emptyPage}
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "New Page",
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
}}
/>
)}
</div>
) : (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
)}
</>
);
});

View File

@ -1,29 +0,0 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { PageService } from "services/page.service";
// components
import { PagesView } from "components/pages";
// types
import { TPagesListProps } from "./types";
// fetch-keys
import { MY_PAGES_LIST } from "constants/fetch-keys";
// services
const pageService = new PageService();
export const MyPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? MY_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_me")
: null
);
return <PagesView pages={pages} viewType={viewType} />;
};

View File

@ -1,29 +0,0 @@
import { useRouter } from "next/router";
import useSWR from "swr";
// services
import { PageService } from "services/page.service";
// components
import { PagesView } from "components/pages";
// types
import { TPagesListProps } from "./types";
// fetch-keys
import { OTHER_PAGES_LIST } from "constants/fetch-keys";
// services
const pageService = new PageService();
export const OtherPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? OTHER_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_other")
: null
);
return <PagesView pages={pages} viewType={viewType} />;
};

View File

@ -0,0 +1,25 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { PagesListView } from "components/pages/pages-list";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Loader } from "@plane/ui";
export const PrivatePagesList: FC = observer(() => {
const {
page: { privateProjectPages },
} = useMobxStore();
if (!privateProjectPages)
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
return <PagesListView pages={privateProjectPages} />;
});

View File

@ -1,14 +1,10 @@
import React from "react";
import { useRouter } from "next/router";
import React, { FC } from "react";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
import { Plus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { PageService } from "services/page.service";
// components
import { PagesView } from "components/pages";
import { PagesListView } from "components/pages/pages-list";
import { EmptyState } from "components/common";
// ui
import { Loader } from "@plane/ui";
@ -16,47 +12,42 @@ import { Loader } from "@plane/ui";
import emptyPage from "public/empty-state/page.svg";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
// types
import { TPagesListProps } from "./types";
import { RecentPagesResponse } from "types";
// fetch-keys
import { RECENT_PAGES_LIST } from "constants/fetch-keys";
// services
const pageService = new PageService();
export const RecentPagesList: FC = observer(() => {
// store
const {
commandPalette: commandPaletteStore,
page: { recentProjectPages },
} = useMobxStore();
export const RecentPagesList: React.FC<TPagesListProps> = observer((props) => {
const { viewType } = props;
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value) => value.length === 0);
const { commandPalette: commandPaletteStore } = useMobxStore();
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { data: pages } = useSWR(
workspaceSlug && projectId ? RECENT_PAGES_LIST(projectId as string) : null,
workspaceSlug && projectId ? () => pageService.getRecentPages(workspaceSlug as string, projectId as string) : null
if (!recentProjectPages) {
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
const isEmpty = pages && Object.keys(pages).every((key) => pages[key].length === 0);
}
return (
<>
{pages ? (
Object.keys(pages).length > 0 && !isEmpty ? (
Object.keys(pages).map((key) => {
if (pages[key].length === 0) return null;
{Object.keys(recentProjectPages).length > 0 && !isEmpty ? (
<>
{Object.keys(recentProjectPages).map((key) => {
if (recentProjectPages[key].length === 0) return null;
return (
<div key={key} className="h-full overflow-hidden pb-9">
<h2 className="text-xl font-semibold capitalize mb-2">
{replaceUnderscoreIfSnakeCase(key)}
</h2>
<PagesView pages={pages[key as keyof RecentPagesResponse]} viewType={viewType} />
<h2 className="text-xl font-semibold capitalize mb-2">{replaceUnderscoreIfSnakeCase(key)}</h2>
<PagesListView pages={recentProjectPages[key]} />
</div>
);
})
})}
</>
) : (
<>
<EmptyState
title="Have your thoughts in place"
description="You can think of Pages as an AI-powered notepad."
@ -67,13 +58,7 @@ export const RecentPagesList: React.FC<TPagesListProps> = observer((props) => {
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
}}
/>
)
) : (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
</>
)}
</>
);

View File

@ -0,0 +1,25 @@
import { FC } from "react";
import { observer } from "mobx-react-lite";
// components
import { PagesListView } from "components/pages/pages-list";
// hooks
import { useMobxStore } from "lib/mobx/store-provider";
// ui
import { Loader } from "@plane/ui";
export const SharedPagesList: FC = observer(() => {
const {
page: { sharedProjectPages },
} = useMobxStore();
if (!sharedProjectPages)
return (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
);
return <PagesListView pages={sharedProjectPages} />;
});

View File

@ -1,293 +0,0 @@
import { useState } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR, { mutate } from "swr";
import { Plus } from "lucide-react";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// services
import { PageService } from "services/page.service";
import { ProjectMemberService } from "services/project";
// hooks
import useToast from "hooks/use-toast";
// components
import { CreateUpdatePageModal, DeletePageModal, SinglePageDetailedItem, SinglePageListItem } from "components/pages";
import { EmptyState } from "components/common";
// ui
import { Loader } from "@plane/ui";
// images
import emptyPage from "public/empty-state/page.svg";
// types
import { IPage, TPageViewProps } from "types";
import {
ALL_PAGES_LIST,
FAVORITE_PAGES_LIST,
MY_PAGES_LIST,
PROJECT_MEMBERS,
RECENT_PAGES_LIST,
} from "constants/fetch-keys";
type Props = {
pages: IPage[] | undefined;
viewType: TPageViewProps;
};
// services
const pageService = new PageService();
const projectMemberService = new ProjectMemberService();
export const PagesView: React.FC<Props> = observer(({ pages, viewType }) => {
// states
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
const [selectedPageToUpdate, setSelectedPageToUpdate] = useState<IPage | null>(null);
const [deletePageModal, setDeletePageModal] = useState(false);
const [selectedPageToDelete, setSelectedPageToDelete] = useState<IPage | null>(null);
const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore();
const user = userStore.currentUser ?? undefined;
// router
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { setToastAlert } = useToast();
const { data: people } = useSWR(
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectMemberService.fetchProjectMembers(workspaceSlug.toString(), projectId.toString())
: null
);
const handleEditPage = (page: IPage) => {
setSelectedPageToUpdate(page);
setCreateUpdatePageModal(true);
};
const handleDeletePage = (page: IPage) => {
setSelectedPageToDelete(page);
setDeletePageModal(true);
};
const handleAddToFavorites = (page: IPage) => {
if (!workspaceSlug || !projectId) return;
mutate<IPage[]>(
ALL_PAGES_LIST(projectId.toString()),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === page.id) p.is_favorite = true;
return p;
}),
false
);
mutate<IPage[]>(
MY_PAGES_LIST(projectId.toString()),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === page.id) p.is_favorite = true;
return p;
}),
false
);
mutate<IPage[]>(FAVORITE_PAGES_LIST(projectId.toString()), (prevData) => [page, ...(prevData ?? [])], false);
pageService
.addPageToFavorites(workspaceSlug.toString(), projectId.toString(), {
page: page.id,
})
.then(() => {
mutate(RECENT_PAGES_LIST(projectId.toString()));
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully added the page to favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't add the page to favorites. Please try again.",
});
});
};
const handleRemoveFromFavorites = (page: IPage) => {
if (!workspaceSlug || !projectId) return;
mutate<IPage[]>(
ALL_PAGES_LIST(projectId.toString()),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === page.id) p.is_favorite = false;
return p;
}),
false
);
mutate<IPage[]>(
MY_PAGES_LIST(projectId.toString()),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === page.id) p.is_favorite = false;
return p;
}),
false
);
mutate<IPage[]>(
FAVORITE_PAGES_LIST(projectId.toString()),
(prevData) => (prevData ?? []).filter((p) => p.id !== page.id),
false
);
pageService
.removePageFromFavorites(workspaceSlug.toString(), projectId.toString(), page.id)
.then(() => {
mutate(RECENT_PAGES_LIST(projectId.toString()));
setToastAlert({
type: "success",
title: "Success!",
message: "Successfully removed the page from favorites.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Couldn't remove the page from favorites. Please try again.",
});
});
};
const partialUpdatePage = (page: IPage, formData: Partial<IPage>) => {
if (!workspaceSlug || !projectId || !user) return;
mutate<IPage[]>(
ALL_PAGES_LIST(projectId.toString()),
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
false
);
mutate<IPage[]>(
MY_PAGES_LIST(projectId.toString()),
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
false
);
mutate<IPage[]>(
FAVORITE_PAGES_LIST(projectId.toString()),
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
false
);
pageService.patchPage(workspaceSlug.toString(), projectId.toString(), page.id, formData).then(() => {
mutate(RECENT_PAGES_LIST(projectId.toString()));
});
};
return (
<>
{workspaceSlug && projectId && (
<>
<CreateUpdatePageModal
isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)}
data={selectedPageToUpdate}
user={user}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
<DeletePageModal
isOpen={deletePageModal}
setIsOpen={setDeletePageModal}
data={selectedPageToDelete}
user={user}
/>
</>
)}
{pages ? (
<div className="space-y-4 h-full overflow-y-auto">
{pages.length > 0 ? (
viewType === "list" ? (
<ul role="list" className="divide-y divide-custom-border-200">
{pages.map((page) => (
<SinglePageListItem
key={page.id}
page={page}
people={people}
handleEditPage={() => handleEditPage(page)}
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/>
))}
</ul>
) : viewType === "detailed" ? (
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100">
{pages.map((page) => (
<SinglePageDetailedItem
key={page.id}
page={page}
people={people}
handleEditPage={() => handleEditPage(page)}
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/>
))}
</div>
) : (
<div className="rounded-[10px] border border-custom-border-200">
{pages.map((page) => (
<SinglePageDetailedItem
key={page.id}
page={page}
people={people}
handleEditPage={() => handleEditPage(page)}
handleDeletePage={() => handleDeletePage(page)}
handleAddToFavorites={() => handleAddToFavorites(page)}
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
partialUpdatePage={partialUpdatePage}
/>
))}
</div>
)
) : (
<EmptyState
title="Have your thoughts in place"
description="You can think of Pages as an AI-powered notepad."
image={emptyPage}
primaryButton={{
icon: <Plus className="h-4 w-4" />,
text: "New Page",
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
}}
/>
)}
</div>
) : viewType === "list" ? (
<Loader className="space-y-4">
<Loader.Item height="40px" />
<Loader.Item height="40px" />
<Loader.Item height="40px" />
</Loader>
) : viewType === "detailed" ? (
<Loader className="space-y-4">
<Loader.Item height="150px" />
<Loader.Item height="150px" />
</Loader>
) : (
<Loader className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Loader.Item height="150px" />
<Loader.Item height="150px" />
<Loader.Item height="150px" />
</Loader>
)}
</>
);
});

View File

@ -1,432 +0,0 @@
import React, { useEffect, useState, useRef } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { mutate } from "swr";
import { useForm } from "react-hook-form";
import { Draggable } from "@hello-pangea/dnd";
// services
import { PageService } from "services/page.service";
import { IssueService } from "services/issue/issue.service";
import { AIService } from "services/ai.service";
import { FileService } from "services/file.service";
// hooks
import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import { GptAssistantModal } from "components/core";
import { CreateUpdateBlockInline } from "components/pages";
import { RichTextEditor } from "@plane/rich-text-editor";
// ui
import { CustomMenu, LayersIcon, TextArea } from "@plane/ui";
// icons
import { RefreshCw, LinkIcon, Zap, Check, MoreVertical, Pencil, Sparkle, Trash2 } from "lucide-react";
// helpers
import { copyTextToClipboard } from "helpers/string.helper";
// types
import { IUser, IIssue, IPageBlock, IProject } from "types";
// fetch-keys
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = {
block: IPageBlock;
projectDetails: IProject | undefined;
showBlockDetails: boolean;
index: number;
user: IUser | undefined;
};
const aiService = new AIService();
const pageService = new PageService();
const issueService = new IssueService();
const fileService = new FileService();
export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, showBlockDetails, index, user }) => {
const [isSyncing, setIsSyncing] = useState(false);
const [createBlockForm, setCreateBlockForm] = useState(false);
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
const [gptAssistantModal, setGptAssistantModal] = useState(false);
const [isMenuActive, setIsMenuActive] = useState(false);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
const { setToastAlert } = useToast();
const { handleSubmit, watch, reset, setValue } = useForm<IPageBlock>({
defaultValues: {
name: "",
description: {},
description_html: "<p></p>",
},
});
const editorSuggestion = useEditorSuggestions();
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
if (!workspaceSlug || !projectId || !pageId) return;
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
if (block.issue && block.sync) setIsSyncing(true);
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) =>
prevData?.map((p) => {
if (p.id === block.id) return { ...p, ...formData };
return p;
}),
false
);
await pageService
.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, {
name: formData.name,
description: formData.description,
description_html: formData.description_html,
})
.then((res) => {
mutate(PAGE_BLOCKS_LIST(pageId as string));
if (block.issue && block.sync)
issueService
.patchIssue(workspaceSlug as string, projectId as string, block.issue, {
name: res.name,
description: res.description,
description_html: res.description_html,
})
.finally(() => setIsSyncing(false));
});
};
const pushBlockIntoIssues = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
await pageService
.convertPageBlockToIssue(workspaceSlug as string, projectId as string, pageId as string, block.id)
.then((res: IIssue) => {
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === block.id) return { ...p, issue: res.id, issue_detail: res };
return p;
}),
false
);
setToastAlert({
type: "success",
title: "Success!",
message: "Page block converted to issue successfully.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page block could not be converted to issue. Please try again.",
});
});
};
const deletePageBlock = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) => (prevData ?? []).filter((p) => p.id !== block.id),
false
);
await pageService
.deletePageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id)
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Page could not be deleted. Please try again.",
});
});
};
const handleAutoGenerateDescription = async () => {
if (!workspaceSlug || !projectId) return;
setIAmFeelingLucky(true);
aiService
.createGptTask(workspaceSlug as string, projectId as string, {
prompt: block.name,
task: "Generate a proper description for this issue.",
})
.then((res) => {
if (res.response === "")
setToastAlert({
type: "error",
title: "Error!",
message:
"Block title isn't informative enough to generate the description. Please try with a different title.",
});
else handleAiAssistance(res.response_html);
})
.catch((err) => {
if (err.status === 429)
setToastAlert({
type: "error",
title: "Error!",
message: "You have reached the maximum number of requests of 50 requests per month per user.",
});
else
setToastAlert({
type: "error",
title: "Error!",
message: "Some error occurred. Please try again.",
});
})
.finally(() => setIAmFeelingLucky(false));
};
const handleAiAssistance = async (response: string) => {
if (!workspaceSlug || !projectId) return;
setValue("description", {});
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
handleSubmit(updatePageBlock)()
.then(() => {
setToastAlert({
type: "success",
title: "Success!",
message: "Block description updated successfully.",
});
})
.catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Block description could not be updated. Please try again.",
});
});
};
const handleBlockSync = () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
(prevData) =>
(prevData ?? []).map((p) => {
if (p.id === block.id) return { ...p, sync: !block.sync };
return p;
}),
false
);
pageService.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, {
sync: !block.sync,
});
};
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
});
};
useEffect(() => {
if (!block) return;
reset({ ...block });
}, [reset, block]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
return (
<Draggable draggableId={block.id} index={index} isDragDisabled={createBlockForm}>
{(provided, snapshot) => (
<>
{createBlockForm ? (
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
<CreateUpdateBlockInline
handleAiAssistance={handleAiAssistance}
handleClose={() => setCreateBlockForm(false)}
data={block}
setIsSyncing={setIsSyncing}
focus="name"
user={user}
/>
</div>
) : (
<div
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${
snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
}`}
ref={provided.innerRef}
{...provided.draggableProps}
>
<button
type="button"
className="absolute top-4 -left-0 hidden rounded p-0.5 group-hover:!flex"
{...provided.dragHandleProps}
>
<MoreVertical className="h-4" />
<MoreVertical className="-ml-5 h-4" />
</button>
<div
ref={actionSectionRef}
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${
isMenuActive ? "!flex" : ""
}`}
>
{block.issue && block.sync && (
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs">
{isSyncing ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
{isSyncing ? "Syncing..." : "Synced"}
</div>
)}
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
iAmFeelingLucky ? "cursor-wait" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
{iAmFeelingLucky ? (
"Generating response..."
) : (
<>
<Sparkle className="h-4 w-4" />I{"'"}m feeling lucky
</>
)}
</button>
<button
type="button"
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setGptAssistantModal((prevData) => !prevData)}
>
<Sparkle className="h-4 w-4" />
AI
</button>
<button
type="button"
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
onClick={() => setCreateBlockForm(true)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<CustomMenu
customButton={
<div
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded px-2.5 py-1 text-left text-xs duration-300 hover:bg-custom-background-90"
onClick={() => setIsMenuActive(!isMenuActive)}
>
<Zap className="h-3.5 w-3.5" />
</div>
}
>
{block.issue ? (
<>
<CustomMenu.MenuItem onClick={handleBlockSync}>
<span className="flex items-center gap-1">
<RefreshCw className="h-4 w-4" />
<span>Turn sync {block.sync ? "off" : "on"}</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center gap-1">
<LinkIcon className="h-4 w-4" />
Copy issue link
</span>
</CustomMenu.MenuItem>
</>
) : (
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
<span className="flex items-center gap-1">
<LayersIcon className="h-4 w-4" />
Push into issues
</span>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={deletePageBlock}>
<span className="flex items-center gap-1">
<Trash2 className="h-4 w-4" />
Delete block
</span>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
<div className={`flex items-start gap-2 px-3 ${snapshot.isDragging ? "" : "py-4"}`}>
<div
className="w-full cursor-pointer overflow-hidden break-words px-4"
onClick={() => setCreateBlockForm(true)}
>
<div className="flex items-center">
{block.issue && (
<div className="mr-1.5 flex">
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
<a className="flex h-6 flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs">
<LayersIcon height="16" width="16" />
{projectDetails?.identifier}-{block.issue_detail?.sequence_id}
</a>
</Link>
</div>
)}
<TextArea
id="blockName"
name="blockName"
value={block.name}
placeholder="Title"
className="min-h-[20px] block w-full resize-none overflow-hidden border-none bg-transparent text-sm text-custom-text-100 !p-0"
/>
</div>
{showBlockDetails
? block.description_html.length > 7 && (
<RichTextEditor
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
value={block.description_html}
customClassName="text-sm min-h-[150px]"
noBorder
borderOnFocus={false}
mentionSuggestions={editorSuggestion.mentionSuggestions}
mentionHighlights={editorSuggestion.mentionHighlights}
/>
)
: block.description_stripped.length > 0 && (
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
{block.description_stripped}
</p>
)}
</div>
</div>
<GptAssistantModal
block={block}
isOpen={gptAssistantModal}
handleClose={() => setGptAssistantModal(false)}
inset="top-8 left-0"
content={block.description_stripped}
htmlContent={block.description_html}
onResponse={handleAiAssistance}
projectId={projectId as string}
/>
</div>
)}
</>
)}
</Draggable>
);
};

View File

@ -1,202 +0,0 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Tooltip } from "@plane/ui";
// icons
import { AlertCircle, LinkIcon, Lock, Pencil, Star, Trash2, Unlock } from "lucide-react";
// helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { render24HourFormatTime, renderShortDate, renderLongDateFormat } from "helpers/date-time.helper";
// types
import { IPage, IProjectMember } from "types";
type TSingleStatProps = {
page: IPage;
people: IProjectMember[] | undefined;
handleEditPage: () => void;
handleDeletePage: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
};
export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
page,
people,
handleEditPage,
handleDeletePage,
handleAddToFavorites,
handleRemoveFromFavorites,
partialUpdatePage,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
};
return (
<div className="relative">
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
<a className="block p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center overflow-hidden gap-2">
<p className="mr-2 truncate text-sm">{page.name}</p>
{page.label_details.length > 0 &&
page.label_details.map((label) => (
<div
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
style={{
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000"}20`,
}}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
))}
</div>
<div className="flex items-center gap-2">
<Tooltip
tooltipContent={`Last updated at ${
render24HourFormatTime(page.updated_at) +
` ${new Date(page.updated_at).getHours() < 12 ? "am" : "pm"}`
} on ${renderShortDate(page.updated_at)}`}
>
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
</Tooltip>
{page.is_favorite ? (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveFromFavorites();
}}
className="z-10 grid place-items-center"
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToFavorites();
}}
className="z-10 grid place-items-center"
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
{page.created_by === user?.id && (
<Tooltip
tooltipContent={`${
page.access
? "This page is only visible to you."
: "This page can be viewed by anyone in the project."
}`}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
partialUpdatePage(page, { access: page.access ? 0 : 1 });
}}
>
{page.access ? (
<Lock className="h-4 w-4" color="rgb(var(--color-text-200))" />
) : (
<Unlock className="h-4 w-4" color="rgb(var(--color-text-200))" />
)}
</button>
</Tooltip>
)}
<Tooltip
position="top-right"
tooltipContent={`Created by ${
people?.find((person) => person.member.id === page.created_by)?.member.display_name ?? ""
} on ${renderLongDateFormat(`${page.created_at}`)}`}
>
<span>
<AlertCircle className="h-4 w-4 text-custom-text-200" />
</span>
</Tooltip>
<CustomMenu verticalEllipsis>
<CustomMenu.MenuItem
onClick={(e: any) => {
e.preventDefault();
e.stopPropagation();
handleEditPage();
}}
>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3.5 w-3.5" />
<span>Edit Page</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e: any) => {
e.preventDefault();
e.stopPropagation();
handleDeletePage();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3.5 w-3.5" />
<span>Delete Page</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy Page link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
{page.blocks.length > 0 && (
<div className="relative mt-2 space-y-2 text-sm text-custom-text-200">
{page.blocks.slice(0, 3).map((block) => (
<h4 className="truncate">{block.name}</h4>
))}
</div>
)}
</a>
</Link>
</div>
);
};

View File

@ -1,197 +0,0 @@
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
// hooks
import useUser from "hooks/use-user";
import useToast from "hooks/use-toast";
// ui
import { CustomMenu, Tooltip } from "@plane/ui";
// icons
import { AlertCircle, FileText, LinkIcon, Lock, Pencil, Star, Trash2, Unlock } from "lucide-react";
// helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { renderLongDateFormat, renderShortDate, render24HourFormatTime } from "helpers/date-time.helper";
// types
import { IPage, IProjectMember } from "types";
type TSingleStatProps = {
page: IPage;
people: IProjectMember[] | undefined;
handleEditPage: () => void;
handleDeletePage: () => void;
handleAddToFavorites: () => void;
handleRemoveFromFavorites: () => void;
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
};
export const SinglePageListItem: React.FC<TSingleStatProps> = ({
page,
people,
handleEditPage,
handleDeletePage,
handleAddToFavorites,
handleRemoveFromFavorites,
partialUpdatePage,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { user } = useUser();
const { setToastAlert } = useToast();
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
};
return (
<li>
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
<a>
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
<div className="flex items-center justify-between">
<div className="flex overflow-hidden items-center gap-2">
<FileText className="h-4 w-4 shrink-0" />
<p className="mr-2 truncate text-sm text-custom-text-100">{page.name}</p>
{page.label_details.length > 0 &&
page.label_details.map((label) => (
<div
key={label.id}
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
style={{
backgroundColor: `${label?.color}20`,
}}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color,
}}
/>
{label.name}
</div>
))}
</div>
<div className="ml-2 flex flex-shrink-0">
<div className="flex items-center gap-2">
<Tooltip
tooltipContent={`Last updated at ${render24HourFormatTime(page.updated_at)} on ${renderShortDate(
page.updated_at
)}`}
>
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
</Tooltip>
{page.is_favorite ? (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemoveFromFavorites();
}}
>
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddToFavorites();
}}
>
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
</button>
)}
{page.created_by === user?.id && (
<Tooltip
tooltipContent={`${
page.access
? "This page is only visible to you."
: "This page can be viewed by anyone in the project."
}`}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
partialUpdatePage(page, { access: page.access ? 0 : 1 });
}}
>
{page.access ? (
<Lock className="h-4 w-4" color="rgb(var(--color-text-200))" />
) : (
<Unlock className="h-4 w-4" color="rgb(var(--color-text-200))" />
)}
</button>
</Tooltip>
)}
<Tooltip
position="top-right"
tooltipContent={`Created by ${
people?.find((person) => person.member.id === page.created_by)?.member.display_name ?? ""
} on ${renderLongDateFormat(`${page.created_at}`)}`}
>
<span>
<AlertCircle className="h-4 w-4 text-custom-text-200" />
</span>
</Tooltip>
<CustomMenu width="auto" verticalEllipsis>
<CustomMenu.MenuItem
onClick={(e: any) => {
e.preventDefault();
e.stopPropagation();
handleEditPage();
}}
>
<span className="flex items-center justify-start gap-2">
<Pencil className="h-3.5 w-3.5" />
<span>Edit Page</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e: any) => {
e.preventDefault();
e.stopPropagation();
handleDeletePage();
}}
>
<span className="flex items-center justify-start gap-2">
<Trash2 className="h-3.5 w-3.5" />
<span>Delete Page</span>
</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleCopyText();
}}
>
<div className="flex items-center justify-start gap-2">
<LinkIcon className="h-4 w-4" />
<span>Copy Page link</span>
</div>
</CustomMenu.MenuItem>
</CustomMenu>
</div>
</div>
</div>
</div>
</a>
</Link>
</li>
);
};

View File

@ -195,7 +195,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
{project.is_favorite && (
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
<span className="flex items-center justify-start gap-2">
<Star className="h-3.5 w-3.5 stroke-[1.5] text-orange-400" fill="#f6ad55" />
<Star className="h-3.5 w-3.5 stroke-[1.5] text-orange-400 fill-orange-400" />
<span>Remove from favorites</span>
</span>
</CustomMenu.MenuItem>

View File

@ -10,7 +10,6 @@ import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "components
// ui
import { CustomMenu, PhotoFilterIcon } from "@plane/ui";
// helpers
import { truncateText } from "helpers/string.helper";
import { calculateTotalFilters } from "helpers/filter.helper";
// types
import { IProjectView } from "types";
@ -85,7 +84,7 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
}}
className="grid place-items-center"
>
<StarIcon className="text-orange-400" fill="#f6ad55" size={14} strokeWidth={2} />
<StarIcon className="h-3.5 w-3.5 text-orange-400 fill-orange-400" strokeWidth={2} />
</button>
) : (
<button

View File

@ -228,13 +228,14 @@ export const SLACK_CHANNEL_INFO = (workspaceSlug: string, projectId: string) =>
// Pages
export const RECENT_PAGES_LIST = (projectId: string) => `RECENT_PAGES_LIST_${projectId.toUpperCase()}`;
export const ALL_PAGES_LIST = (projectId: string) => `ALL_PAGES_LIST_${projectId.toUpperCase()}`;
export const ARCHIVED_PAGES_LIST = (projectId: string) => `ARCHIVED_PAGES_LIST_${projectId.toUpperCase}`;
export const FAVORITE_PAGES_LIST = (projectId: string) => `FAVORITE_PAGES_LIST_${projectId.toUpperCase()}`;
export const MY_PAGES_LIST = (projectId: string) => `MY_PAGES_LIST_${projectId.toUpperCase()}`;
export const OTHER_PAGES_LIST = (projectId: string) => `OTHER_PAGES_LIST_${projectId.toUpperCase()}`;
export const PRIVATE_PAGES_LIST = (projectId: string) => `PRIVATE_PAGES_LIST_${projectId.toUpperCase()}`;
export const SHARED_PAGES_LIST = (projectId: string) => `SHARED_PAGES_LIST_${projectId.toUpperCase()}`;
export const PAGE_DETAILS = (pageId: string) => `PAGE_DETAILS_${pageId.toUpperCase()}`;
export const PAGE_BLOCKS_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${pageId.toUpperCase()}`;
export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${pageId.toUpperCase()}`;
export const MY_PAGES_LIST = (pageId: string) => `MY_PAGE_LIST_${pageId}`;
// estimates
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;

View File

@ -1,4 +1,4 @@
import { LayoutGrid, List } from "lucide-react";
import { Globe2, LayoutGrid, List, Lock } from "lucide-react";
export const PAGE_VIEW_LAYOUTS = [
{
@ -27,11 +27,28 @@ export const PAGE_TABS_LIST: { key: string; title: string }[] = [
title: "Favorites",
},
{
key: "created-by-me",
title: "Created by me",
key: "private",
title: "Private",
},
{
key: "created-by-others",
title: "Created by others",
key: "shared",
title: "Shared",
},
{
key: "archived-pages",
title: "Archived",
},
];
export const PAGE_ACCESS_SPECIFIERS: { key: number; label: string; icon: any }[] = [
{
key: 0,
label: "Public",
icon: Globe2,
},
{
key: 1,
label: "Private",
icon: Lock,
},
];

View File

@ -31,6 +31,7 @@
"@types/react-datepicker": "^4.8.0",
"axios": "^1.1.3",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"dotenv": "^16.0.3",
"js-cookie": "^3.0.1",
"lodash.debounce": "^4.0.8",

View File

@ -2,76 +2,49 @@ import React, { useEffect, useRef, useState, ReactElement } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import { Controller, useForm } from "react-hook-form";
import { Popover, Transition } from "@headlessui/react";
import { TwitterPicker } from "react-color";
import { DragDropContext, DropResult } from "@hello-pangea/dnd";
// services
import { ProjectService, ProjectMemberService } from "services/project";
import { PageService } from "services/page.service";
import { IssueLabelService } from "services/issue";
import { useDebouncedCallback } from "use-debounce";
// hooks
import useToast from "hooks/use-toast";
import useUser from "hooks/use-user";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CreateUpdateBlockInline, SinglePageBlock } from "components/pages";
import { CreateLabelModal } from "components/labels";
import { CreateBlock } from "components/pages/create-block";
import { PageDetailsHeader } from "components/headers/page-details";
// ui
import { EmptyState } from "components/common";
import { CustomSearchSelect, TextArea, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
// images
// ui
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
import { Loader } from "@plane/ui";
// assets
import emptyPage from "public/empty-state/page.svg";
// icons
import { ArrowLeft, Lock, LinkIcon, Palette, Plus, Star, Unlock, X, ChevronDown } from "lucide-react";
// helpers
import { render24HourFormatTime, renderShortDate } from "helpers/date-time.helper";
import { copyTextToClipboard } from "helpers/string.helper";
import { orderArrayBy } from "helpers/array.helper";
import { renderDateFormat } from "helpers/date-time.helper";
// types
import { NextPageWithLayout } from "types/app";
import { IIssueLabel, IPage, IPageBlock, IProjectMember } from "types";
import { IPage } from "types";
// fetch-keys
import {
PAGE_BLOCKS_LIST,
PAGE_DETAILS,
PROJECT_DETAILS,
PROJECT_ISSUE_LABELS,
USER_PROJECT_VIEW,
} from "constants/fetch-keys";
import { PAGE_DETAILS } from "constants/fetch-keys";
import { FileService } from "services/file.service";
// services
const projectService = new ProjectService();
const projectMemberService = new ProjectMemberService();
const fileService = new FileService();
const pageService = new PageService();
const issueLabelService = new IssueLabelService();
const PageDetailsPage: NextPageWithLayout = () => {
const [createBlockForm, setCreateBlockForm] = useState(false);
const [labelModal, setLabelModal] = useState(false);
const [showBlock, setShowBlock] = useState(false);
const editorRef = useRef<any>(null);
const scrollToRef = useRef<HTMLDivElement>(null);
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const router = useRouter();
const { workspaceSlug, projectId, pageId } = router.query;
const { setToastAlert } = useToast();
const { user } = useUser();
const { handleSubmit, reset, watch, setValue, control } = useForm<IPage>({
const { handleSubmit, reset, getValues, control } = useForm<IPage>({
defaultValues: { name: "" },
});
const { data: projectDetails } = useSWR(
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null
);
// =================== Fetching Page Details ======================
const { data: pageDetails, error } = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
workspaceSlug && projectId
@ -79,27 +52,6 @@ const PageDetailsPage: NextPageWithLayout = () => {
: null
);
const { data: pageBlocks } = useSWR(
workspaceSlug && projectId && pageId ? PAGE_BLOCKS_LIST(pageId as string) : null,
workspaceSlug && projectId
? () => pageService.listPageBlocks(workspaceSlug as string, projectId as string, pageId as string)
: null
);
const { data: labels } = useSWR<IIssueLabel[]>(
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
workspaceSlug && projectId
? () => issueLabelService.getProjectIssueLabels(workspaceSlug as string, projectId as string)
: null
);
const { data: memberDetails } = useSWR(
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
workspaceSlug && projectId
? () => projectMemberService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
: null
);
const updatePage = async (formData: IPage) => {
if (!workspaceSlug || !projectId || !pageId) return;
@ -117,159 +69,96 @@ const PageDetailsPage: NextPageWithLayout = () => {
});
};
const partialUpdatePage = async (formData: Partial<IPage>) => {
if (!workspaceSlug || !projectId || !pageId) return;
const createPage = async (payload: Partial<IPage>) => {
await pageService.createPage(workspaceSlug as string, projectId as string, payload);
};
// ================ Page Menu Actions ==================
const duplicate_page = async () => {
const currentPageValues = getValues();
const formData: Partial<IPage> = {
name: "Copy of " + currentPageValues.name,
description_html: currentPageValues.description_html,
};
await createPage(formData);
};
const archivePage = async () => {
try {
await pageService.archivePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...(prevData as IPage),
...formData,
}),
false
);
await pageService.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData).then(() => {
mutate(PAGE_DETAILS(pageId as string));
});
};
const handleAddToFavorites = () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...(prevData as IPage),
is_favorite: true,
}),
false
).then(() => {
setToastAlert({
type: "success",
title: "Success",
message: "Added to favorites",
});
});
pageService.addPageToFavorites(workspaceSlug as string, projectId as string, {
page: pageId as string,
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug || !projectId || !pageId) return;
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...(prevData as IPage),
is_favorite: false,
}),
false
).then(() => {
setToastAlert({
type: "success",
title: "Success",
message: "Removed from favorites",
});
});
pageService.removePageFromFavorites(workspaceSlug as string, projectId as string, pageId as string);
};
const handleOnDragEnd = (result: DropResult) => {
if (!result.destination || !workspaceSlug || !projectId || !pageId || !pageBlocks) return;
const { source, destination } = result;
let newSortOrder = pageBlocks.find((p) => p.id === result.draggableId)?.sort_order ?? 65535;
if (destination.index === 0) newSortOrder = pageBlocks[0].sort_order - 10000;
else if (destination.index === pageBlocks.length - 1)
newSortOrder = pageBlocks[pageBlocks.length - 1].sort_order + 10000;
else {
if (destination.index > source.index)
newSortOrder = (pageBlocks[destination.index].sort_order + pageBlocks[destination.index + 1].sort_order) / 2;
else if (destination.index < source.index)
newSortOrder = (pageBlocks[destination.index - 1].sort_order + pageBlocks[destination.index].sort_order) / 2;
}
const newBlocksList = pageBlocks.map((p) => ({
...p,
sort_order: p.id === result.draggableId ? newSortOrder : p.sort_order,
}));
mutate<IPageBlock[]>(
PAGE_BLOCKS_LIST(pageId as string),
orderArrayBy(newBlocksList, "sort_order", "ascending"),
false
);
pageService.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, result.draggableId, {
sort_order: newSortOrder,
});
};
const handleCopyText = () => {
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(() => {
setToastAlert({
type: "success",
title: "Link Copied!",
message: "Page link copied to clipboard.",
});
});
};
const handleShowBlockToggle = async () => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<IProjectMember> = {
preferences: {
pages: {
block_display: !showBlock,
},
},
};
mutate<IProjectMember>(
(workspaceSlug as string) && (projectId as string) ? USER_PROJECT_VIEW(projectId as string) : null,
(prevData) => {
if (!prevData) return prevData;
return {
...prevData,
...payload,
};
if (prevData && prevData.is_locked) {
prevData.archived_at = renderDateFormat(new Date());
return prevData;
}
},
false
true
);
await projectService.setProjectView(workspaceSlug as string, projectId as string, payload).catch(() => {
setToastAlert({
type: "error",
title: "Error!",
message: "Something went wrong. Please try again.",
});
});
} catch (e) {
console.log(e);
}
};
const options = labels?.map((label) => ({
value: label.id,
query: label.name,
content: (
<div className="flex items-center gap-2">
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
</div>
),
}));
const unArchivePage = async () => {
try {
await pageService.restorePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.archived_at = null;
return prevData;
}
},
true
);
});
} catch (e) {
console.log(e);
}
};
// ========================= Page Lock ==========================
const lockPage = async () => {
try {
await pageService.lockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.is_locked = true;
}
return prevData;
},
true
);
});
} catch (e) {
console.log(e);
}
};
const unlockPage = async () => {
try {
await pageService.unlockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.is_locked = false;
return prevData;
}
},
true
);
});
} catch (e) {
console.log(e);
}
};
useEffect(() => {
if (!pageDetails) return;
@ -279,10 +168,9 @@ const PageDetailsPage: NextPageWithLayout = () => {
});
}, [reset, pageDetails]);
useEffect(() => {
if (!memberDetails) return;
setShowBlock(memberDetails.preferences.pages.block_display);
}, [memberDetails]);
const debouncedFormSave = useDebouncedCallback(async () => {
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
}, 1500);
return (
<>
@ -297,314 +185,80 @@ const PageDetailsPage: NextPageWithLayout = () => {
}}
/>
) : pageDetails ? (
<div className="flex h-full flex-col justify-between space-y-4 overflow-hidden p-4">
<div className="h-full w-full overflow-y-auto">
<div className="flex items-start justify-between gap-2">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center gap-2">
<button
type="button"
className="flex items-center gap-2 text-sm text-custom-text-200"
onClick={() => router.back()}
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex h-full flex-col justify-between pl-5 pr-5">
<div className="h-full w-full">
{pageDetails.is_locked || pageDetails.archived_at ? (
<DocumentReadOnlyEditorWithRef
ref={editorRef}
value={pageDetails.description_html}
customClassName={"tracking-tight self-center w-full max-w-full px-0"}
borderOnFocus={false}
noBorder={true}
documentDetails={{
title: pageDetails.name,
created_by: pageDetails.created_by,
created_on: pageDetails.created_at,
last_updated_at: pageDetails.updated_at,
last_updated_by: pageDetails.updated_by,
}}
pageLockConfig={
!pageDetails.archived_at && user && pageDetails.owned_by === user.id
? { action: unlockPage, is_locked: pageDetails.is_locked }
: undefined
}
pageArchiveConfig={
user && pageDetails.owned_by === user.id
? {
action: pageDetails.archived_at ? unArchivePage : archivePage,
is_archived: pageDetails.archived_at ? true : false,
archived_at: pageDetails.archived_at ? new Date(pageDetails.archived_at) : undefined,
}
: undefined
}
/>
) : (
<Controller
name="name"
name="description_html"
control={control}
render={() => (
<TextArea
id="name"
name="name"
value={watch("name")}
placeholder="Page Title"
onBlur={handleSubmit(updatePage)}
onChange={(e) => setValue("name", e.target.value)}
required
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent !px-3 !py-2 text-xl font-semibold outline-none ring-0"
role="textbox"
/>
)}
/>
</div>
<div className="flex w-full flex-wrap gap-1">
{pageDetails.labels.length > 0 && (
<>
{pageDetails.labels.map((labelId) => {
const label = labels?.find((label) => label.id === labelId);
if (!label) return;
return (
<div
key={label.id}
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs hover:border-red-500 hover:bg-red-50"
onClick={() => {
const updatedLabels = pageDetails.labels.filter((l) => l !== labelId);
partialUpdatePage({ labels: updatedLabels });
render={({ field: { value, onChange } }) => (
<DocumentEditorWithRef
documentDetails={{
title: pageDetails.name,
created_by: pageDetails.created_by,
created_on: pageDetails.created_at,
last_updated_at: pageDetails.updated_at,
last_updated_by: pageDetails.updated_by,
}}
style={{
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000"}20`,
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}
value={!value || value === "" ? "<p></p>" : value}
customClassName="tracking-tight self-center w-full max-w-full px-0"
onChange={(_description_json: Object, description_html: string) => {
onChange(description_html);
setIsSubmitting("submitting");
debouncedFormSave();
}}
>
<span
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
}}
/>
{label.name}
<X className="h-2.5 w-2.5 group-hover:text-red-500" />
</div>
);
})}
</>
)}
<CustomSearchSelect
customButton={
<div className="flex items-center gap-1 rounded-sm bg-custom-background-80 p-1.5 text-xs">
<Plus className="h-3.5 w-3.5" />
{pageDetails.labels.length <= 0 && <span>Add Label</span>}
</div>
duplicationConfig={{ action: duplicate_page }}
pageArchiveConfig={
user && pageDetails.owned_by === user.id
? {
is_archived: pageDetails.archived_at ? true : false,
action: pageDetails.archived_at ? unArchivePage : archivePage,
}
value={pageDetails.labels}
footerOption={
<button
type="button"
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-custom-background-80"
onClick={() => {
setLabelModal(true);
}}
>
<span className="flex items-center justify-start gap-1 text-custom-text-200">
<Plus className="h-4 w-4" aria-hidden="true" />
<span>Create New Label</span>
</span>
</button>
: undefined
}
pageLockConfig={
user && pageDetails.owned_by === user.id ? { is_locked: false, action: lockPage } : undefined
}
onChange={(val: string[]) => partialUpdatePage({ labels: val })}
options={options}
multiple
noChevron
/>
</div>
</div>
<div className="flex items-center">
<div className="flex items-center gap-6 text-custom-text-200">
<Tooltip
tooltipContent={`Last updated at ${render24HourFormatTime(
pageDetails.updated_at
)} on ${renderShortDate(pageDetails.updated_at)}`}
>
<p className="text-sm">{render24HourFormatTime(pageDetails.updated_at)}</p>
</Tooltip>
<Popover className="relative">
{({ open }) => (
<>
<Popover.Button
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-2 py-1 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
open
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
: "text-custom-sidebar-text-200"
}`}
>
Display
<ChevronDown className="h-3 w-3" aria-hidden="true" />
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
<div className="relative divide-y-2 divide-custom-border-200">
<div className="flex items-center justify-between">
<span className="text-sm text-custom-text-200">Show full block content</span>
<ToggleSwitch
value={showBlock}
onChange={(value) => {
setShowBlock(value);
handleShowBlockToggle();
}}
/>
</div>
</div>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
<button className="flex items-center gap-2" onClick={handleCopyText}>
<LinkIcon className="h-4 w-4" />
</button>
<div className="flex-shrink-0">
<Popover className="relative grid place-items-center">
{({ open }) => (
<>
<Popover.Button
type="button"
className={`group inline-flex items-center outline-none ${
open ? "text-custom-text-100" : "text-custom-text-200"
}`}
>
{watch("color") && watch("color") !== "" ? (
<span
className="h-4 w-4 rounded"
style={{
backgroundColor: watch("color") ?? "black",
}}
/>
) : (
<Palette height={16} width={16} />
)}
</Popover.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-full right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
<TwitterPicker
color={pageDetails.color}
styles={{
default: {
card: {
backgroundColor: `rgba(var(--color-background-80))`,
},
triangle: {
position: "absolute",
borderColor:
"transparent transparent rgba(var(--color-background-80)) transparent",
},
input: {
border: "none",
height: "1.85rem",
fontSize: "0.875rem",
paddingLeft: "0.25rem",
color: `rgba(var(--color-text-200))`,
boxShadow: "none",
backgroundColor: `rgba(var(--color-background-90))`,
borderLeft: `1px solid rgba(var(--color-background-80))`,
},
hash: {
color: `rgba(var(--color-text-200))`,
boxShadow: "none",
backgroundColor: `rgba(var(--color-background-90))`,
},
},
}}
onChange={(val) => partialUpdatePage({ color: val.hex })}
/>
</Popover.Panel>
</Transition>
</>
)}
</Popover>
</div>
{pageDetails.created_by === user?.id && (
<Tooltip
tooltipContent={`${
pageDetails.access
? "This page is only visible to you."
: "This page can be viewed by anyone in the project."
}`}
>
{pageDetails.access ? (
<button onClick={() => partialUpdatePage({ access: 0 })} className="z-10">
<Lock className="h-4 w-4" />
</button>
) : (
<button onClick={() => partialUpdatePage({ access: 1 })} type="button" className="z-10">
<Unlock className="h-4 w-4" />
</button>
)}
</Tooltip>
)}
{pageDetails.is_favorite ? (
<button onClick={handleRemoveFromFavorites} className="z-10">
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
</button>
) : (
<button onClick={handleAddToFavorites} type="button" className="z-10">
<Star className="h-4 w-4" />
</button>
)}
</div>
</div>
</div>
<div className="mt-5 h-full w-full">
{pageBlocks ? (
<>
<DragDropContext onDragEnd={handleOnDragEnd}>
{pageBlocks.length !== 0 && (
<StrictModeDroppable droppableId="blocks-list">
{(provided) => (
<div
className="flex w-full flex-col gap-2"
ref={provided.innerRef}
{...provided.droppableProps}
>
<>
{pageBlocks.map((block, index) => (
<SinglePageBlock
key={block.id}
block={block}
projectDetails={projectDetails}
showBlockDetails={showBlock}
index={index}
user={user}
/>
))}
{provided.placeholder}
</>
</div>
)}
</StrictModeDroppable>
)}
</DragDropContext>
{createBlockForm && (
<div className="mt-4" ref={scrollToRef}>
<CreateUpdateBlockInline handleClose={() => setCreateBlockForm(false)} focus="name" user={user} />
</div>
)}
{labelModal && typeof projectId === "string" && (
<CreateLabelModal
isOpen={labelModal}
handleClose={() => setLabelModal(false)}
projectId={projectId}
onSuccess={(response) => {
partialUpdatePage({
labels: [...(pageDetails.labels ?? []), response.id],
});
}}
/>
)}
</>
) : (
<Loader>
<Loader.Item height="150px" />
<Loader.Item height="150px" />
</Loader>
/>
)}
</div>
</div>
<div>
<CreateBlock user={user} />
</div>
</div>
) : (
<Loader className="p-8">
<Loader.Item height="200px" />

View File

@ -2,48 +2,65 @@ import { useState, Fragment, ReactElement } from "react";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
import { Tab } from "@headlessui/react";
import useSWR from "swr";
import { observer } from "mobx-react-lite";
// hooks
import useLocalStorage from "hooks/use-local-storage";
import useUserAuth from "hooks/use-user-auth";
import { useMobxStore } from "lib/mobx/store-provider";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
import { RecentPagesList, CreateUpdatePageModal, TPagesListProps } from "components/pages";
import { RecentPagesList, CreateUpdatePageModal } from "components/pages";
import { PagesHeader } from "components/headers";
// ui
import { Tooltip } from "@plane/ui";
// types
import { TPageViewProps } from "types";
import { NextPageWithLayout } from "types/app";
// constants
import { PAGE_TABS_LIST, PAGE_VIEW_LAYOUTS } from "constants/page";
import { PAGE_TABS_LIST } from "constants/page";
const AllPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.AllPagesList), {
const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), {
ssr: false,
});
const FavoritePagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.FavoritePagesList), {
const FavoritePagesList = dynamic<any>(() => import("components/pages").then((a) => a.FavoritePagesList), {
ssr: false,
});
const MyPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.MyPagesList), {
const PrivatePagesList = dynamic<any>(() => import("components/pages").then((a) => a.PrivatePagesList), {
ssr: false,
});
const OtherPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.OtherPagesList), {
const ArchivedPagesList = dynamic<any>(() => import("components/pages").then((a) => a.ArchivedPagesList), {
ssr: false,
});
const ProjectPagesPage: NextPageWithLayout = () => {
const SharedPagesList = dynamic<any>(() => import("components/pages").then((a) => a.SharedPagesList), {
ssr: false,
});
const ProjectPagesPage: NextPageWithLayout = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
// states
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
const [viewType, setViewType] = useState<TPageViewProps>("list");
const { user } = useUserAuth();
// store
const {
page: { fetchPages, fetchArchivedPages },
} = useMobxStore();
// hooks
const {} = useUserAuth();
// local storage
const { storedValue: pageTab, setValue: setPageTab } = useLocalStorage("pageTab", "Recent");
// fetching pages from API
useSWR(
workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null,
workspaceSlug && projectId ? () => fetchPages(workspaceSlug.toString(), projectId.toString()) : null
);
// fetching archived pages from API
useSWR(
workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null,
workspaceSlug && projectId ? () => fetchArchivedPages(workspaceSlug.toString(), projectId.toString()) : null
);
const currentTabValue = (tab: string | null) => {
switch (tab) {
@ -53,11 +70,12 @@ const ProjectPagesPage: NextPageWithLayout = () => {
return 1;
case "Favorites":
return 2;
case "Created by me":
case "Private":
return 3;
case "Created by others":
case "Shared":
return 4;
case "Archived":
return 5;
default:
return 0;
}
@ -69,34 +87,12 @@ const ProjectPagesPage: NextPageWithLayout = () => {
<CreateUpdatePageModal
isOpen={createUpdatePageModal}
handleClose={() => setCreateUpdatePageModal(false)}
user={user}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
)}
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
<div className="flex gap-4 justify-between">
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
<div className="flex items-center gap-1 p-1 rounded bg-custom-background-80">
{PAGE_VIEW_LAYOUTS.map((layout) => (
<Tooltip key={layout.key} tooltipContent={layout.title}>
<button
type="button"
className={`w-7 h-[22px] rounded grid place-items-center transition-all hover:bg-custom-background-100 overflow-hidden group ${
viewType == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
}`}
onClick={() => setViewType(layout.key as TPageViewProps)}
>
<layout.icon
strokeWidth={2}
className={`h-3.5 w-3.5 ${
viewType == layout.key ? "text-custom-text-100" : "text-custom-text-200"
}`}
/>
</button>
</Tooltip>
))}
</div>
</div>
<Tab.Group
as={Fragment}
@ -110,12 +106,13 @@ const ProjectPagesPage: NextPageWithLayout = () => {
case 2:
return setPageTab("Favorites");
case 3:
return setPageTab("Created by me");
return setPageTab("Private");
case 4:
return setPageTab("Created by others");
return setPageTab("Shared");
case 5:
return setPageTab("Archived");
default:
return setPageTab("Recent");
return setPageTab("All");
}
}}
>
@ -139,26 +136,29 @@ const ProjectPagesPage: NextPageWithLayout = () => {
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel as="div" className="h-full overflow-y-auto space-y-5">
<RecentPagesList viewType={viewType} />
<RecentPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<AllPagesList viewType={viewType} />
<AllPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<FavoritePagesList viewType={viewType} />
<FavoritePagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<MyPagesList viewType={viewType} />
<PrivatePagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<OtherPagesList viewType={viewType} />
<SharedPagesList />
</Tab.Panel>
<Tab.Panel as="div" className="h-full overflow-hidden">
<ArchivedPagesList />
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
</>
);
};
});
ProjectPagesPage.getLayout = function getLayout(page: ReactElement) {
return (

View File

@ -33,14 +33,8 @@ export class PageService extends APIService {
});
}
async addPageToFavorites(
workspaceSlug: string,
projectId: string,
data: {
page: string;
}
): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`, data)
async addPageToFavorites(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`, { page: pageId })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
@ -58,7 +52,7 @@ export class PageService extends APIService {
async getPagesWithParams(
workspaceSlug: string,
projectId: string,
pageType: "all" | "favorite" | "created_by_me" | "created_by_other"
pageType: "all" | "favorite" | "private" | "shared"
): Promise<IPage[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, {
params: {
@ -168,4 +162,45 @@ export class PageService extends APIService {
throw error?.response?.data;
});
}
// =============== Archiving & Unarchiving Pages =================
async archivePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/archive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async restorePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unarchive/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getArchivedPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-pages/`)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
// ==================== Pages Locking Services ==========================
async lockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/lock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async unlockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unlock/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}

View File

@ -1,42 +1,51 @@
import { observable, action, computed, makeObservable, runInAction } from "mobx";
// types
import { RootStore } from "./root";
import { IPage } from "types";
import isYesterday from "date-fns/isYesterday";
import isToday from "date-fns/isToday";
import isThisWeek from "date-fns/isThisWeek";
// services
import { ProjectService } from "services/project";
import { PageService } from "services/page.service";
// helpers
import { renderDateFormat } from "helpers/date-time.helper";
// types
import { RootStore } from "./root";
import { IPage, IRecentPages } from "types";
export interface IPageStore {
loader: boolean;
error: any | null;
pageId: string | null;
pages: {
[project_id: string]: IPage[];
};
page_details: {
[page_id: string]: IPage;
archivedPages: {
[project_id: string]: IPage[];
};
//computed
projectPages: IPage[];
projectPages: IPage[] | undefined;
recentProjectPages: IRecentPages | undefined;
favoriteProjectPages: IPage[] | undefined;
privateProjectPages: IPage[] | undefined;
sharedProjectPages: IPage[] | undefined;
archivedProjectPages: IPage[] | undefined;
// actions
setPageId: (pageId: string) => void;
fetchPages: (workspaceSlug: string, projectSlug: string) => void;
fetchPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<IPage>;
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<IPage>;
fetchArchivedPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
}
class PageStore implements IPageStore {
export class PageStore implements IPageStore {
loader: boolean = false;
error: any | null = null;
pageId: string | null = null;
pages: {
[project_id: string]: IPage[];
} = {};
page_details: {
[page_id: string]: IPage;
} = {};
pages: { [project_id: string]: IPage[] } = {};
archivedPages: { [project_id: string]: IPage[] } = {};
// root store
rootStore;
// service
@ -48,15 +57,27 @@ class PageStore implements IPageStore {
// observable
loader: observable,
error: observable,
pageId: observable.ref,
pages: observable.ref,
archivedPages: observable.ref,
// computed
projectPages: computed,
recentProjectPages: computed,
favoriteProjectPages: computed,
privateProjectPages: computed,
sharedProjectPages: computed,
archivedProjectPages: computed,
// action
setPageId: action,
fetchPages: action,
createPage: action,
updatePage: action,
deletePage: action,
addToFavorites: action,
removeFromFavorites: action,
makePublic: action,
makePrivate: action,
archivePage: action,
restorePage: action,
fetchArchivedPages: action,
});
this.rootStore = _rootStore;
@ -65,35 +86,252 @@ class PageStore implements IPageStore {
}
get projectPages() {
if (!this.rootStore.project.projectId) return [];
if (!this.rootStore.project.projectId) return;
return this.pages?.[this.rootStore.project.projectId] || [];
}
setPageId = (pageId: string) => {
this.pageId = pageId;
get recentProjectPages() {
if (!this.rootStore.project.projectId) return;
const data: IRecentPages = { today: [], yesterday: [], this_week: [] };
data["today"] = this.pages[this.rootStore.project.projectId]?.filter((p) => isToday(new Date(p.created_at))) || [];
data["yesterday"] =
this.pages[this.rootStore.project.projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || [];
data["this_week"] =
this.pages[this.rootStore.project.projectId]?.filter((p) => isThisWeek(new Date(p.created_at))) || [];
return data;
}
get favoriteProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.is_favorite);
}
get privateProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 1);
}
get sharedProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 0);
}
get archivedProjectPages() {
if (!this.rootStore.project.projectId) return;
return this.archivedPages[this.rootStore.project.projectId];
}
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: this.pages[projectId].map((page) => {
if (page.id === pageId) {
return { ...page, is_favorite: true };
}
return page;
}),
};
});
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
} catch (error) {
throw error;
}
};
fetchPages = async (workspaceSlug: string, projectSlug: string) => {
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: this.pages[projectId].map((page) => {
if (page.id === pageId) {
return { ...page, is_favorite: false };
}
return page;
}),
};
});
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
} catch (error) {
throw error;
}
};
fetchPages = async (workspaceSlug: string, projectId: string) => {
try {
this.loader = true;
this.error = null;
const pagesResponse = await this.pageService.getPagesWithParams(workspaceSlug, projectSlug, "all");
const response = await this.pageService.getPagesWithParams(workspaceSlug, projectId, "all");
runInAction(() => {
this.pages = {
...this.pages,
[projectSlug]: pagesResponse,
[projectId]: response,
};
this.loader = false;
this.error = null;
});
return response;
} catch (error) {
console.error("Failed to fetch project pages in project store", error);
this.loader = false;
this.error = error;
throw error;
}
};
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
try {
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: [...this.pages[projectId], response],
};
});
return response;
} catch (error) {
throw error;
}
};
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => {
try {
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: this.pages[projectId]?.map((page) => {
if (page.id === pageId) {
return { ...page, ...data };
}
return page;
}),
};
});
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, data);
return response;
} catch (error) {
throw error;
}
};
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.archivedPages = {
...this.archivedPages,
[projectId]: this.archivedPages[projectId]?.filter((page) => page.id !== pageId),
};
});
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
return response;
} catch (error) {
throw error;
}
};
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: this.pages[projectId]?.map((page) => ({
...page,
access: page.id === pageId ? 0 : page.access,
})),
};
});
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
return response;
} catch (error) {
throw error;
}
};
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: this.pages[projectId]?.map((page) => ({
...page,
access: page.id === pageId ? 1 : page.access,
})),
};
});
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
return response;
} catch (error) {
throw error;
}
};
fetchArchivedPages = async (workspaceSlug: string, projectId: string) => {
try {
const response = await this.pageService.getArchivedPages(workspaceSlug, projectId);
runInAction(() => {
this.archivedPages = {
...this.archivedPages,
[projectId]: response,
};
});
return response;
} catch (error) {
throw error;
}
};
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
const archivedPage = this.pages[projectId]?.find((page) => page.id === pageId);
if (archivedPage) {
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: [...this.pages[projectId]?.filter((page) => page.id != pageId)],
};
this.archivedPages = {
...this.archivedPages,
[projectId]: [
...this.archivedPages[projectId],
{ ...archivedPage, archived_at: renderDateFormat(new Date()) },
],
};
});
}
await this.pageService.archivePage(workspaceSlug, projectId, pageId);
} catch (error) {
throw error;
}
};
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
try {
const restoredPage = this.archivedPages[projectId]?.find((page) => page.id === pageId);
if (restoredPage) {
runInAction(() => {
this.pages = {
...this.pages,
[projectId]: [...this.pages[projectId], { ...restoredPage, archived_at: null }],
};
this.archivedPages = {
...this.archivedPages,
[projectId]: [...this.archivedPages[projectId]?.filter((page) => page.id != pageId)],
};
});
}
await this.pageService.restorePage(workspaceSlug, projectId, pageId);
} catch (error) {
throw error;
}
};
}
export default PageStore;

View File

@ -113,6 +113,8 @@ import {
import { IWebhookStore, WebhookStore } from "./webhook.store";
import { IMentionsStore, MentionsStore } from "store/editor";
// pages
import { PageStore, IPageStore } from "store/page.store";
enableStaticRendering(typeof window === "undefined");
@ -186,6 +188,8 @@ export class RootStore {
mentionsStore: IMentionsStore;
page: IPageStore;
constructor() {
this.instance = new InstanceStore(this);
@ -254,5 +258,7 @@ export class RootStore {
this.webhook = new WebhookStore(this);
this.mentionsStore = new MentionsStore(this);
this.page = new PageStore(this);
}
}

View File

@ -3,6 +3,7 @@ import { IIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "types";
export interface IPage {
access: number;
archived_at: string | null;
blocks: IPageBlock[];
color: string;
created_at: Date;
@ -12,6 +13,7 @@ export interface IPage {
description_stripped: string | null;
id: string;
is_favorite: boolean;
is_locked: boolean;
label_details: IIssueLabel[];
labels: string[];
name: string;
@ -24,6 +26,13 @@ export interface IPage {
workspace_detail: IWorkspaceLite;
}
export interface IRecentPages {
today: IPage[];
yesterday: IPage[];
this_week: IPage[];
[key: string]: IPage[];
}
export interface RecentPagesResponse {
[key: string]: IPage[];
}

View File

@ -2376,7 +2376,7 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.12.tgz#7c905a577ce30ef2cb335870a23f9d24fd26f6aa"
integrity sha512-vtD8vWtNlmAZX8LYqt2yU9w3mU9rPCiHmbp4hDXJs2kBnI0Ju/qAyXFx6iJ3C3XyuMnMbJdDI9ee0spAvFz7cQ==
"@tiptap/extension-code-block-lowlight@^2.1.12":
"@tiptap/extension-code-block-lowlight@^2.1.11", "@tiptap/extension-code-block-lowlight@^2.1.12":
version "2.1.12"
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.1.12.tgz#ccbca5d0d92bee373dc8e2e2ae6c27f62f66437c"
integrity sha512-dtIbpI9QrWa9TzNO4v5q/zf7+d83wpy5i9PEccdJAVtRZ0yOI8JIZAWzG5ex3zAoCA0CnQFdsPSVykYSDdxtDA==