فهرست منبع

feat: nested views

* chore: remove folder code merge page and folder into navitem component

* chore: test fix

* fix: nav item expand fix

* fix: unfold page and active page

* fix: nav item click area fix

* chore: remove old components

* chore: remove old code

* chore: cell controller reorganize

* chore: nav item optimizations

* fix: add async queue to fix data problem

* chore: change semantics of new folder button

* chore: move row methods to database controller

---------

Co-authored-by: qinluhe <[email protected]>
Askarbek Zadauly 1 سال پیش
والد
کامیت
eee32110f4
38فایلهای تغییر یافته به همراه776 افزوده شده و 961 حذف شده
  1. 5 3
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/EditRow.tsx
  2. 29 23
      frontend/appflowy_tauri/src/appflowy_app/components/_shared/database-hooks/useDatabase.ts
  3. 2 4
      frontend/appflowy_tauri/src/appflowy_app/components/board/BoardCard.tsx
  4. 4 5
      frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/Breadcrumbs.tsx
  5. 0 204
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts
  6. 0 118
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.tsx
  7. 226 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.hooks.ts
  8. 126 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.tsx
  9. 0 27
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.hooks.ts
  10. 13 73
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx
  11. 0 20
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewFolderButton.hooks.ts
  12. 45 0
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.hooks.ts
  13. 5 5
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.tsx
  14. 0 68
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PageItem.hooks.ts
  15. 0 83
      frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PageItem.tsx
  16. 4 18
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Screen.tsx
  17. 56 39
      frontend/appflowy_tauri/src/appflowy_app/components/layout/Workspace.hooks.ts
  18. 11 4
      frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts
  19. 9 4
      frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts
  20. 3 0
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx
  21. 61 0
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx
  22. 2 4
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx
  23. 1 1
      frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx
  24. 17 17
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_controller.ts
  25. 13 0
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts
  26. 10 2
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts
  27. 7 8
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts
  28. 0 32
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_bd_svc.ts
  29. 0 105
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/app_bd_svc.ts
  30. 0 34
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/app_observer.ts
  31. 20 3
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_bd_svc.ts
  32. 23 12
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_observer.ts
  33. 25 14
      frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_bd_svc.ts
  34. 0 26
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/folders/notifications/parser.ts
  35. 11 3
      frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts
  36. 0 2
      frontend/appflowy_tauri/src/appflowy_app/stores/store.ts
  37. 46 0
      frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts
  38. 2 0
      frontend/appflowy_tauri/src/services/backend/notifications/observer.ts

+ 5 - 3
frontend/appflowy_tauri/src/appflowy_app/components/_shared/EditRow/EditRow.tsx

@@ -201,7 +201,7 @@ export const EditRow = ({
           }}
           className={`relative flex h-[90%] w-[70%] flex-col gap-8 rounded-xl bg-white `}
         >
-          <div onClick={() => onCloseClick()} className={'absolute top-1 right-1'}>
+          <div onClick={() => onCloseClick()} className={'absolute right-1 top-1'}>
             <button className={'block h-8 w-8 rounded-lg text-shade-2 hover:bg-main-secondary'}>
               <CloseSvg></CloseSvg>
             </button>
@@ -209,7 +209,7 @@ export const EditRow = ({
 
           <div className={'flex h-full'}>
             <div className={'flex h-full flex-1 flex-col border-r border-shade-6 pb-4 pt-6'}>
-              <div className={'pl-12 pb-4'}>
+              <div className={'pb-4 pl-12'}>
                 <button className={'flex items-center gap-2 p-4'}>
                   <i className={'h-5 w-5'}>
                     <ImageSvg></ImageSvg>
@@ -229,7 +229,9 @@ export const EditRow = ({
                       }`}
                     >
                       {cells
-                        .filter((cell) => databaseStore.fields[cell.cellIdentifier.fieldId].visible)
+                        .filter((cell) => {
+                          return databaseStore.fields[cell.cellIdentifier.fieldId]?.visible;
+                        })
                         .map((cell, cellIndex) => (
                           <EditCellWrapper
                             index={cellIndex}

+ 29 - 23
frontend/appflowy_tauri/src/appflowy_app/components/_shared/database-hooks/useDatabase.ts

@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { DatabaseController } from '$app/stores/effects/database/database_controller';
 import { databaseActions, DatabaseFieldMap, IDatabaseColumn } from '$app/stores/reducers/database/slice';
 import { useAppDispatch } from '$app/stores/store';
@@ -8,6 +8,7 @@ import { RowInfo } from '$app/stores/effects/database/row/row_cache';
 import { ViewLayoutPB } from '@/services/backend';
 import { DatabaseGroupController } from '$app/stores/effects/database/group/group_controller';
 import { OnDragEndResponder } from 'react-beautiful-dnd';
+import { AsyncQueue } from '$app/utils/async_queue';
 
 export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
   const dispatch = useAppDispatch();
@@ -24,25 +25,30 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
     return () => void c.dispose();
   }, [viewId]);
 
-  const loadFields = async (fieldInfos: readonly FieldInfo[]) => {
-    const fields: DatabaseFieldMap = {};
-    const columns: IDatabaseColumn[] = [];
-
-    for (const fieldInfo of fieldInfos) {
-      const fieldPB = fieldInfo.field;
-      columns.push({
-        fieldId: fieldPB.id,
-        sort: 'none',
-        visible: fieldPB.visibility,
-      });
-
-      const field = await loadField(viewId, fieldInfo, dispatch);
-      fields[field.fieldId] = field;
-    }
+  const loadFields = useCallback(
+    async (fieldInfos: readonly FieldInfo[]) => {
+      const fields: DatabaseFieldMap = {};
+      const columns: IDatabaseColumn[] = [];
+      for (const fieldInfo of fieldInfos) {
+        const fieldPB = fieldInfo.field;
+        columns.push({
+          fieldId: fieldPB.id,
+          sort: 'none',
+          visible: fieldPB.visibility,
+        });
+
+        const field = await loadField(viewId, fieldInfo, dispatch);
+        fields[field.fieldId] = field;
+      }
+      dispatch(databaseActions.updateFields({ fields }));
+      dispatch(databaseActions.updateColumns({ columns }));
+    },
+    [viewId, dispatch]
+  );
 
-    dispatch(databaseActions.updateFields({ fields }));
-    dispatch(databaseActions.updateColumns({ columns }));
-  };
+  const queue = useMemo(() => {
+    return new AsyncQueue<readonly FieldInfo[]>(loadFields);
+  }, [loadFields]);
 
   useEffect(() => {
     void (async () => {
@@ -53,7 +59,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
           setRows([...rowInfos]);
         },
         onFieldsChanged: (fieldInfos) => {
-          void loadFields(fieldInfos);
+          queue.enqueue(fieldInfos);
         },
       });
 
@@ -76,7 +82,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
     return () => {
       void controller?.dispose();
     };
-  }, [controller]);
+  }, [controller, queue]);
 
   const onNewRowClick = async (index: number) => {
     if (!groups) return;
@@ -95,7 +101,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
 
     if (source.droppableId === destination?.droppableId) {
       // move inside the block (group)
-      await controller.exchangeRow(
+      await controller.exchangeGroupRow(
         group.rows[source.index].id,
         destination.droppableId,
         group.rows[destination.index].id
@@ -103,7 +109,7 @@ export const useDatabase = (viewId: string, type?: ViewLayoutPB) => {
     } else {
       // move to different block (group)
       if (!destination?.droppableId) return;
-      await controller.moveRow(group.rows[source.index].id, destination.droppableId);
+      await controller.moveGroupRow(group.rows[source.index].id, destination.droppableId);
     }
   };
 

+ 2 - 4
frontend/appflowy_tauri/src/appflowy_app/components/board/BoardCard.tsx

@@ -7,7 +7,6 @@ import { Draggable } from 'react-beautiful-dnd';
 import { MouseEventHandler, useState } from 'react';
 import { PopupWindow } from '$app/components/_shared/PopupWindow';
 import { TrashSvg } from '$app/components/_shared/svg/TrashSvg';
-import { RowBackendService } from '$app/stores/effects/database/row/row_bd_svc';
 import { useTranslation } from 'react-i18next';
 import { useAppSelector } from '$app/stores/store';
 
@@ -52,8 +51,7 @@ export const BoardCard = ({
 
   const onDeleteRowClick = async () => {
     setShowCardPopup(false);
-    const svc = new RowBackendService(viewId);
-    await svc.deleteRow(rowInfo.row.id);
+    await controller.deleteRow(rowInfo.row.id);
   };
 
   return (
@@ -73,7 +71,7 @@ export const BoardCard = ({
             <div className={'flex flex-col gap-3'}>
               {cells
                 .filter(
-                  (cell) => cell.fieldId !== groupByFieldId && databaseStore.fields[cell.cellIdentifier.fieldId].visible
+                  (cell) => cell.fieldId !== groupByFieldId && databaseStore.fields[cell.cellIdentifier.fieldId]?.visible
                 )
                 .map((cell, cellIndex) => (
                   <BoardCell

+ 4 - 5
frontend/appflowy_tauri/src/appflowy_app/components/layout/HeaderPanel/Breadcrumbs.tsx

@@ -1,6 +1,6 @@
 import { ShowMenuSvg } from '../../_shared/svg/ShowMenuSvg';
 import { useEffect, useState } from 'react';
-import { useAppSelector } from '../../../stores/store';
+import { useAppSelector } from '$app/stores/store';
 import { useLocation } from 'react-router-dom';
 
 export const Breadcrumbs = ({ menuHidden, onShowMenuClick }: { menuHidden: boolean; onShowMenuClick: () => void }) => {
@@ -9,7 +9,6 @@ export const Breadcrumbs = ({ menuHidden, onShowMenuClick }: { menuHidden: boole
   const [activePageId, setActivePageId] = useState<string>('');
   const currentLocation = useLocation();
   const pagesStore = useAppSelector((state) => state.pages);
-  const foldersStore = useAppSelector((state) => state.folders);
 
   useEffect(() => {
     const { pathname } = currentLocation;
@@ -20,10 +19,10 @@ export const Breadcrumbs = ({ menuHidden, onShowMenuClick }: { menuHidden: boole
 
   useEffect(() => {
     const page = pagesStore.find((p) => p.id === activePageId);
-    const folder = foldersStore.find((f) => f.id === page?.folderId);
-    setFolderName(folder?.title ?? '');
+    // const folder = foldersStore.find((f) => f.id === page?.parentPageId);
+    // setFolderName(folder?.title ?? '');
     setPageName(page?.title ?? '');
-  }, [pagesStore, foldersStore, activePageId]);
+  }, [pagesStore, activePageId]);
 
   return (
     <div className={'flex items-center'}>

+ 0 - 204
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.hooks.ts

@@ -1,204 +0,0 @@
-import { foldersActions, IFolder } from '$app_reducers/folders/slice';
-import { useEffect, useState } from 'react';
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { IPage, pagesActions } from '$app_reducers/pages/slice';
-import { ViewLayoutPB } from '@/services/backend';
-import { AppBackendService } from '$app/stores/effects/folder/app/app_bd_svc';
-import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
-
-import { AppObserver } from '$app/stores/effects/folder/app/app_observer';
-import { useNavigate } from 'react-router-dom';
-import { INITIAL_FOLDER_HEIGHT, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
-
-export const useFolderEvents = (folder: IFolder, pages: IPage[]) => {
-  const appDispatch = useAppDispatch();
-  const workspace = useAppSelector((state) => state.workspace);
-
-  const navigate = useNavigate();
-
-  // Actions
-  const [showPages, setShowPages] = useState(false);
-  const [showFolderOptions, setShowFolderOptions] = useState(false);
-  const [showNewPageOptions, setShowNewPageOptions] = useState(false);
-  const [showRenamePopup, setShowRenamePopup] = useState(false);
-
-  // UI configurations
-  const [folderHeight, setFolderHeight] = useState(`${INITIAL_FOLDER_HEIGHT}px`);
-
-  // Observers
-  const appObserver = new AppObserver(folder.id);
-
-  // Backend services
-  const appBackendService = new AppBackendService(folder.id);
-
-  useEffect(() => {
-    void appObserver.subscribe({
-      onViewsChanged: async () => {
-        const result = await appBackendService.getAllViews();
-        if (!result.ok) return;
-        const views = result.val;
-        const updatedPages: IPage[] = views.map((view) => ({
-          id: view.id,
-          folderId: view.parent_view_id,
-          pageType: view.layout,
-          title: view.name,
-        }));
-        appDispatch(pagesActions.didReceivePages({ pages: updatedPages, folderId: folder.id }));
-      },
-    });
-    return () => {
-      // Unsubscribe when the component is unmounted.
-      void appObserver.unsubscribe();
-    };
-  }, []);
-
-  useEffect(() => {
-    if (showPages) {
-      setFolderHeight(`${INITIAL_FOLDER_HEIGHT + pages.length * PAGE_ITEM_HEIGHT}px`);
-    }
-  }, [pages]);
-
-  const onFolderNameClick = () => {
-    if (showPages) {
-      setFolderHeight(`${INITIAL_FOLDER_HEIGHT}px`);
-    } else {
-      setFolderHeight(`${INITIAL_FOLDER_HEIGHT + pages.length * PAGE_ITEM_HEIGHT}px`);
-    }
-    setShowPages(!showPages);
-  };
-
-  const onFolderOptionsClick = () => {
-    setShowFolderOptions(!showFolderOptions);
-  };
-
-  const onNewPageClick = () => {
-    setShowNewPageOptions(!showNewPageOptions);
-  };
-
-  const startFolderRename = () => {
-    closePopup();
-    setShowRenamePopup(true);
-  };
-
-  const changeFolderTitle = async (newTitle: string) => {
-    await appBackendService.update({ name: newTitle });
-    appDispatch(foldersActions.renameFolder({ id: folder.id, newTitle }));
-  };
-
-  const closeRenamePopup = () => {
-    setShowRenamePopup(false);
-  };
-
-  const deleteFolder = async () => {
-    closePopup();
-    await appBackendService.delete();
-    appDispatch(foldersActions.deleteFolder({ id: folder.id }));
-  };
-
-  const duplicateFolder = async () => {
-    closePopup();
-    const workspaceBackendService = new WorkspaceBackendService(workspace.id ?? '');
-    const newApp = await workspaceBackendService.createApp({
-      name: folder.title,
-    });
-    appDispatch(foldersActions.addFolder({ id: newApp.id, title: folder.title }));
-  };
-
-  const closePopup = () => {
-    setShowFolderOptions(false);
-    setShowNewPageOptions(false);
-  };
-
-  const onAddNewDocumentPage = async () => {
-    closePopup();
-    const newView = await appBackendService.createView({
-      name: 'New Document 1',
-      layoutType: ViewLayoutPB.Document,
-    });
-    try {
-      appDispatch(
-        pagesActions.addPage({
-          folderId: folder.id,
-          pageType: ViewLayoutPB.Document,
-          title: newView.name,
-          id: newView.id,
-        })
-      );
-
-      setShowPages(true);
-
-      navigate(`/page/document/${newView.id}`);
-    } catch (e) {
-      console.error(e);
-    }
-  };
-
-  const onAddNewBoardPage = async () => {
-    closePopup();
-    const newView = await appBackendService.createView({
-      name: 'New Board 1',
-      layoutType: ViewLayoutPB.Board,
-    });
-
-    setShowPages(true);
-
-    appDispatch(
-      pagesActions.addPage({
-        folderId: folder.id,
-        pageType: ViewLayoutPB.Board,
-        title: newView.name,
-        id: newView.id,
-      })
-    );
-
-    navigate(`/page/board/${newView.id}`);
-  };
-
-  const onAddNewGridPage = async () => {
-    closePopup();
-    const newView = await appBackendService.createView({
-      name: 'New Grid 1',
-      layoutType: ViewLayoutPB.Grid,
-    });
-
-    setShowPages(true);
-
-    appDispatch(
-      pagesActions.addPage({
-        folderId: folder.id,
-        pageType: ViewLayoutPB.Grid,
-        title: newView.name,
-        id: newView.id,
-      })
-    );
-
-    navigate(`/page/grid/${newView.id}`);
-  };
-
-  useEffect(() => {
-    appDispatch(foldersActions.setShowPages({ id: folder.id, showPages: showPages }));
-  }, [showPages]);
-
-  return {
-    showPages,
-    onFolderNameClick,
-    showFolderOptions,
-    onFolderOptionsClick,
-    showNewPageOptions,
-    onNewPageClick,
-
-    showRenamePopup,
-    startFolderRename,
-    changeFolderTitle,
-    closeRenamePopup,
-    deleteFolder,
-    duplicateFolder,
-
-    onAddNewDocumentPage,
-    onAddNewBoardPage,
-    onAddNewGridPage,
-
-    closePopup,
-    folderHeight,
-  };
-};

+ 0 - 118
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/FolderItem.tsx

@@ -1,118 +0,0 @@
-import { Details2Svg } from '../../_shared/svg/Details2Svg';
-import AddSvg from '../../_shared/svg/AddSvg';
-import { NavItemOptionsPopup } from './NavItemOptionsPopup';
-import { NewPagePopup } from './NewPagePopup';
-import { IFolder } from '$app_reducers/folders/slice';
-import { useFolderEvents } from './FolderItem.hooks';
-import { IPage } from '$app_reducers/pages/slice';
-import { PageItem } from './PageItem';
-import { Button } from '../../_shared/Button';
-import { RenamePopup } from './RenamePopup';
-import { useEffect, useRef, useState } from 'react';
-import { DropDownShowSvg } from '../../_shared/svg/DropDownShowSvg';
-import { ANIMATION_DURATION } from '../../_shared/constants';
-
-export const FolderItem = ({
-  folder,
-  pages,
-  onPageClick,
-}: {
-  folder: IFolder;
-  pages: IPage[];
-  onPageClick: (page: IPage) => void;
-}) => {
-  const {
-    showPages,
-    onFolderNameClick,
-    showFolderOptions,
-    onFolderOptionsClick,
-    showNewPageOptions,
-    onNewPageClick,
-
-    showRenamePopup,
-    startFolderRename,
-    changeFolderTitle,
-    closeRenamePopup,
-    deleteFolder,
-    duplicateFolder,
-
-    onAddNewDocumentPage,
-    onAddNewBoardPage,
-    onAddNewGridPage,
-
-    closePopup,
-    folderHeight,
-  } = useFolderEvents(folder, pages);
-
-  const [popupY, setPopupY] = useState(0);
-
-  const el = useRef<HTMLDivElement>(null);
-
-  useEffect(() => {
-    if (el.current) {
-      const { top } = el.current.getBoundingClientRect();
-      setPopupY(top);
-    }
-  }, [showFolderOptions, showNewPageOptions, showRenamePopup]);
-
-  return (
-    <div ref={el}>
-      <div
-        className={`my-2 overflow-hidden transition-all`}
-        style={{ height: folderHeight, transitionDuration: `${ANIMATION_DURATION}ms` }}
-      >
-        <div
-          onClick={() => onFolderNameClick()}
-          className={'flex cursor-pointer items-center justify-between rounded-lg px-4 py-2 hover:bg-surface-2'}
-        >
-          <button className={'flex min-w-0 flex-1 items-center'}>
-            <i className={`mr-2 h-5 w-5 transition-transform duration-500 ${showPages && 'rotate-180'}`}>
-              {pages.length > 0 && <DropDownShowSvg></DropDownShowSvg>}
-            </i>
-            <span className={'min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap text-left'}>
-              {folder.title}
-            </span>
-          </button>
-          <div className={'flex items-center'}>
-            <Button size={'box-small-transparent'} onClick={() => onFolderOptionsClick()}>
-              <Details2Svg></Details2Svg>
-            </Button>
-            <Button size={'box-small-transparent'} onClick={() => onNewPageClick()}>
-              <AddSvg></AddSvg>
-            </Button>
-          </div>
-        </div>
-
-        {pages.map((page, index) => (
-          <PageItem key={index} page={page} onPageClick={() => onPageClick(page)}></PageItem>
-        ))}
-      </div>
-      {showFolderOptions && (
-        <NavItemOptionsPopup
-          onRenameClick={() => startFolderRename()}
-          onDeleteClick={() => deleteFolder()}
-          onDuplicateClick={() => duplicateFolder()}
-          onClose={() => closePopup()}
-          top={popupY - 124 + 40}
-        ></NavItemOptionsPopup>
-      )}
-      {showNewPageOptions && (
-        <NewPagePopup
-          onDocumentClick={() => onAddNewDocumentPage()}
-          onBoardClick={() => onAddNewBoardPage()}
-          onGridClick={() => onAddNewGridPage()}
-          onClose={() => closePopup()}
-          top={popupY - 124 + 40}
-        ></NewPagePopup>
-      )}
-      {showRenamePopup && (
-        <RenamePopup
-          value={folder.title}
-          onChange={(newTitle) => changeFolderTitle(newTitle)}
-          onClose={closeRenamePopup}
-          top={popupY - 124 + 40}
-        ></RenamePopup>
-      )}
-    </div>
-  );
-};

+ 226 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.hooks.ts

@@ -0,0 +1,226 @@
+import { useEffect, useState } from 'react';
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { IPage, pagesActions } from '$app_reducers/pages/slice';
+import { ViewLayoutPB } from '@/services/backend';
+import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
+
+import { useLocation, useNavigate } from 'react-router-dom';
+import { INITIAL_FOLDER_HEIGHT, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
+
+import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
+import { ViewObserver } from '$app/stores/effects/folder/view/view_observer';
+
+export const useNavItem = (page: IPage) => {
+  const appDispatch = useAppDispatch();
+  const workspace = useAppSelector((state) => state.workspace);
+  const currentLocation = useLocation();
+  const [activePageId, setActivePageId] = useState<string>('');
+  const pages = useAppSelector((state) => state.pages);
+
+  const navigate = useNavigate();
+
+  // Actions
+  const [showPageOptions, setShowPageOptions] = useState(false);
+  const [showNewPageOptions, setShowNewPageOptions] = useState(false);
+  const [showRenamePopup, setShowRenamePopup] = useState(false);
+
+  // UI configurations
+  const [folderHeight, setFolderHeight] = useState(`${INITIAL_FOLDER_HEIGHT}px`);
+
+  // backend
+  const service = new ViewBackendService(page.id);
+  const observer = new ViewObserver(page.id);
+
+  const loadInsidePages = async () => {
+    const result = await service.getChildViews();
+    if (!result.ok) return;
+    const views = result.val;
+    const updatedPages: IPage[] = views.map<IPage>((view) => ({
+      parentPageId: page.id,
+      id: view.id,
+      pageType: view.layout,
+      title: view.name,
+      showPagesInside: false,
+    }));
+    appDispatch(pagesActions.addInsidePages({ currentPageId: page.id, insidePages: updatedPages }));
+  };
+
+  useEffect(() => {
+    void loadInsidePages();
+    void observer.subscribe({
+      onChildViewsChanged: () => {
+        void loadInsidePages();
+      },
+    });
+    return () => {
+      // Unsubscribe when the component is unmounted.
+      void observer.unsubscribe();
+    };
+  }, []);
+
+  useEffect(() => {
+    const { pathname } = currentLocation;
+    const parts = pathname.split('/');
+    const pageId = parts[parts.length - 1];
+    setActivePageId(pageId);
+  }, [currentLocation]);
+
+  useEffect(() => {
+    if (page.showPagesInside) {
+      setFolderHeight(`${PAGE_ITEM_HEIGHT + getChildCount(page) * PAGE_ITEM_HEIGHT}px`);
+    } else {
+      setFolderHeight(`${PAGE_ITEM_HEIGHT}px`);
+    }
+  }, [page, pages]);
+
+  // recursively get all unfolded child pages
+  const getChildCount: (startPage: IPage) => number = (startPage: IPage) => {
+    let count = 0;
+    count = pages.filter((p) => p.parentPageId === startPage.id).length;
+    pages
+      .filter((p) => p.parentPageId === startPage.id)
+      .forEach((p) => {
+        if (p.showPagesInside) {
+          count += getChildCount(p);
+        }
+      });
+    return count;
+  };
+
+  const onUnfoldClick = () => {
+    appDispatch(pagesActions.toggleShowPages({ id: page.id }));
+  };
+
+  const onPageOptionsClick = () => {
+    setShowPageOptions(!showPageOptions);
+  };
+
+  const startPageRename = () => {
+    setShowRenamePopup(true);
+    closePopup();
+  };
+
+  const onNewPageClick = () => {
+    setShowNewPageOptions(!showNewPageOptions);
+  };
+
+  const changePageTitle = async (newTitle: string) => {
+    await service.update({ name: newTitle });
+    appDispatch(pagesActions.renamePage({ id: page.id, newTitle }));
+  };
+
+  const closeRenamePopup = () => {
+    setShowRenamePopup(false);
+  };
+
+  const deletePage = async () => {
+    closePopup();
+    await service.delete();
+    appDispatch(pagesActions.deletePage({ id: page.id }));
+  };
+
+  const duplicatePage = async () => {
+    closePopup();
+    await service.duplicate();
+  };
+
+  const closePopup = () => {
+    setShowPageOptions(false);
+    setShowNewPageOptions(false);
+  };
+
+  const onPageClick = (eventPage: IPage) => {
+    const pageTypeRoute = (() => {
+      switch (eventPage.pageType) {
+        case ViewLayoutPB.Document:
+          return 'document';
+        case ViewLayoutPB.Grid:
+          return 'grid';
+        case ViewLayoutPB.Board:
+          return 'board';
+        default:
+          return 'document';
+      }
+    })();
+
+    navigate(`/page/${pageTypeRoute}/${eventPage.id}`);
+  };
+
+  const onAddNewPage = async (pageType: ViewLayoutPB) => {
+    closePopup();
+    if (!workspace?.id) return;
+
+    let newPageName = '';
+    let pageTypeRoute = '';
+
+    switch (pageType) {
+      case ViewLayoutPB.Document:
+        newPageName = 'Document Page 1';
+        pageTypeRoute = 'document';
+        break;
+      case ViewLayoutPB.Grid:
+        newPageName = 'Grid Page 1';
+        pageTypeRoute = 'grid';
+        break;
+      case ViewLayoutPB.Board:
+        newPageName = 'Board Page 1';
+        pageTypeRoute = 'board';
+        break;
+      default:
+        newPageName = 'Document Page 1';
+        pageTypeRoute = 'document';
+        break;
+    }
+
+    const workspaceService = new WorkspaceBackendService(workspace.id);
+    const newViewResult = await workspaceService.createView({
+      name: newPageName,
+      layoutType: pageType,
+      parentViewId: page.id,
+    });
+
+    if (newViewResult.ok) {
+      const newView = newViewResult.val;
+      if (!page.showPagesInside) {
+        appDispatch(pagesActions.toggleShowPages({ id: page.id }));
+      }
+
+      appDispatch(
+        pagesActions.addPage({
+          parentPageId: page.id,
+          pageType,
+          title: newView.name,
+          id: newView.id,
+          showPagesInside: false,
+        })
+      );
+
+      navigate(`/page/${pageTypeRoute}/${newView.id}`);
+    }
+  };
+
+  return {
+    onUnfoldClick,
+    onNewPageClick,
+    onPageOptionsClick,
+    startPageRename,
+
+    changePageTitle,
+    closeRenamePopup,
+    closePopup,
+
+    showNewPageOptions,
+    showPageOptions,
+    showRenamePopup,
+
+    deletePage,
+    duplicatePage,
+
+    onPageClick,
+
+    onAddNewPage,
+
+    folderHeight,
+    activePageId,
+  };
+};

+ 126 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavItem.tsx

@@ -0,0 +1,126 @@
+import { Details2Svg } from '../../_shared/svg/Details2Svg';
+import AddSvg from '../../_shared/svg/AddSvg';
+import { NavItemOptionsPopup } from './NavItemOptionsPopup';
+import { NewPagePopup } from './NewPagePopup';
+import { IPage } from '$app_reducers/pages/slice';
+import { Button } from '../../_shared/Button';
+import { RenamePopup } from './RenamePopup';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { DropDownShowSvg } from '../../_shared/svg/DropDownShowSvg';
+import { ANIMATION_DURATION, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
+import { useNavItem } from '$app/components/layout/NavigationPanel/NavItem.hooks';
+import { useAppSelector } from '$app/stores/store';
+import { ViewLayoutPB } from '@/services/backend';
+
+export const NavItem = ({ page }: { page: IPage }) => {
+  const pages = useAppSelector((state) => state.pages);
+  const {
+    onUnfoldClick,
+    onNewPageClick,
+    onPageOptionsClick,
+    startPageRename,
+
+    changePageTitle,
+    closeRenamePopup,
+    closePopup,
+
+    showNewPageOptions,
+    showPageOptions,
+    showRenamePopup,
+
+    deletePage,
+    duplicatePage,
+
+    onAddNewPage,
+
+    folderHeight,
+    activePageId,
+
+    onPageClick,
+  } = useNavItem(page);
+
+  const [popupY, setPopupY] = useState(0);
+
+  const el = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (el.current) {
+      const { top } = el.current.getBoundingClientRect();
+      setPopupY(top);
+    }
+  }, [showPageOptions, showNewPageOptions, showRenamePopup]);
+
+  return (
+    <div ref={el}>
+      <div
+        className={`overflow-hidden transition-all`}
+        style={{ height: folderHeight, transitionDuration: `${ANIMATION_DURATION}ms` }}
+      >
+        <div
+          style={{ height: PAGE_ITEM_HEIGHT }}
+          className={`flex cursor-pointer items-center justify-between rounded-lg px-4 hover:bg-surface-2 ${
+            activePageId === page.id ? 'bg-surface-2' : ''
+          }`}
+        >
+          <div className={'flex h-full min-w-0 flex-1 items-center'}>
+            <button
+              onClick={() => onUnfoldClick()}
+              className={`mr-2 h-5 w-5 transition-transform duration-200 ${page.showPagesInside && 'rotate-180'}`}
+            >
+              <DropDownShowSvg></DropDownShowSvg>
+            </button>
+            <div
+              onClick={() => onPageClick(page)}
+              className={
+                'flex h-full min-w-0 flex-1 items-center overflow-hidden overflow-ellipsis whitespace-nowrap text-left'
+              }
+            >
+              {page.title}
+            </div>
+          </div>
+          <div className={'flex items-center'}>
+            <Button size={'box-small-transparent'} onClick={() => onPageOptionsClick()}>
+              <Details2Svg></Details2Svg>
+            </Button>
+            <Button size={'box-small-transparent'} onClick={() => onNewPageClick()}>
+              <AddSvg></AddSvg>
+            </Button>
+          </div>
+        </div>
+        <div className={'pl-4'}>
+          {useMemo(() => pages.filter((insidePage) => insidePage.parentPageId === page.id), [pages, page]).map(
+            (insidePage, insideIndex) => (
+              <NavItem key={insideIndex} page={insidePage}></NavItem>
+            )
+          )}
+        </div>
+      </div>
+      {showPageOptions && (
+        <NavItemOptionsPopup
+          onRenameClick={() => startPageRename()}
+          onDeleteClick={() => deletePage()}
+          onDuplicateClick={() => duplicatePage()}
+          onClose={() => closePopup()}
+          top={popupY - 124 + 40}
+        ></NavItemOptionsPopup>
+      )}
+      {showNewPageOptions && (
+        <NewPagePopup
+          onDocumentClick={() => onAddNewPage(ViewLayoutPB.Document)}
+          onBoardClick={() => onAddNewPage(ViewLayoutPB.Board)}
+          onGridClick={() => onAddNewPage(ViewLayoutPB.Grid)}
+          onClose={() => closePopup()}
+          top={popupY - 124 + 40}
+        ></NewPagePopup>
+      )}
+      {showRenamePopup && (
+        <RenamePopup
+          value={page.title}
+          onChange={(newTitle) => changePageTitle(newTitle)}
+          onClose={closeRenamePopup}
+          top={popupY - 124 + 40}
+        ></RenamePopup>
+      )}
+    </div>
+  );
+};

+ 0 - 27
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.hooks.ts

@@ -1,17 +1,10 @@
 import { useAppSelector } from '$app/stores/store';
-import { useNavigate } from 'react-router-dom';
-import { IPage } from '$app_reducers/pages/slice';
-import { ViewLayoutPB } from '@/services/backend';
 import { useState } from 'react';
 
 export const useNavigationPanelHooks = function () {
-  const folders = useAppSelector((state) => state.folders);
-  const pages = useAppSelector((state) => state.pages);
   const width = useAppSelector((state) => state.navigationWidth);
   const [menuHidden, setMenuHidden] = useState(false);
 
-  const navigate = useNavigate();
-
   const onHideMenuClick = () => {
     setMenuHidden(true);
   };
@@ -20,28 +13,8 @@ export const useNavigationPanelHooks = function () {
     setMenuHidden(false);
   };
 
-  const onPageClick = (page: IPage) => {
-    const pageTypeRoute = (() => {
-      switch (page.pageType) {
-        case ViewLayoutPB.Document:
-          return 'document';
-        case ViewLayoutPB.Grid:
-          return 'grid';
-        case ViewLayoutPB.Board:
-          return 'board';
-        default:
-          return 'document';
-      }
-    })();
-
-    navigate(`/page/${pageTypeRoute}/${page.id}`);
-  };
-
   return {
     width,
-    folders,
-    pages,
-    onPageClick,
     menuHidden,
     onHideMenuClick,
     onShowMenuClick,

+ 13 - 73
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NavigationPanel.tsx

@@ -1,40 +1,27 @@
 import { WorkspaceUser } from '../WorkspaceUser';
 import { AppLogo } from '../AppLogo';
-import { FolderItem } from './FolderItem';
 import { TrashButton } from './TrashButton';
-import { NewFolderButton } from './NewFolderButton';
+import { NewViewButton } from './NewViewButton';
 import { NavigationResizer } from './NavigationResizer';
-import { IFolder } from '$app_reducers/folders/slice';
 import { IPage } from '$app_reducers/pages/slice';
 import { useLocation, useNavigate } from 'react-router-dom';
 import React, { useEffect, useRef, useState } from 'react';
 import { useAppSelector } from '$app/stores/store';
-import {
-  ANIMATION_DURATION,
-  FOLDER_MARGIN,
-  INITIAL_FOLDER_HEIGHT,
-  NAV_PANEL_MINIMUM_WIDTH,
-  PAGE_ITEM_HEIGHT,
-} from '../../_shared/constants';
+import { NavItem } from '$app/components/layout/NavigationPanel/NavItem';
+import { ANIMATION_DURATION, NAV_PANEL_MINIMUM_WIDTH, PAGE_ITEM_HEIGHT } from '../../_shared/constants';
 
 export const NavigationPanel = ({
   onHideMenuClick,
   menuHidden,
   width,
-  folders,
-  pages,
-  onPageClick,
 }: {
   onHideMenuClick: () => void;
   menuHidden: boolean;
   width: number;
-  folders: IFolder[];
-  pages: IPage[];
-  onPageClick: (page: IPage) => void;
 }) => {
   const el = useRef<HTMLDivElement>(null);
-  const foldersStore = useAppSelector((state) => state.folders);
-  const pagesStore = useAppSelector((state) => state.pages);
+  const pages = useAppSelector((state) => state.pages);
+  const workspace = useAppSelector((state) => state.workspace);
   const [activePageId, setActivePageId] = useState<string>('');
   const currentLocation = useLocation();
   const [maxHeight, setMaxHeight] = useState(0);
@@ -47,44 +34,8 @@ export const NavigationPanel = ({
   }, [currentLocation]);
 
   useEffect(() => {
-    setTimeout(() => {
-      if (!el.current) return;
-      if (!activePageId?.length) return;
-      const activePage = pagesStore.find((page) => page.id === activePageId);
-      if (!activePage) return;
-
-      const folderIndex = foldersStore.findIndex((folder) => folder.id === activePage.folderId);
-      if (folderIndex === -1) return;
-
-      let height = 0;
-      for (let i = 0; i < folderIndex; i++) {
-        height += INITIAL_FOLDER_HEIGHT + FOLDER_MARGIN;
-        if (foldersStore[i].showPages) {
-          height += pagesStore.filter((p) => p.folderId === foldersStore[i].id).length * PAGE_ITEM_HEIGHT;
-        }
-      }
-
-      height += INITIAL_FOLDER_HEIGHT + FOLDER_MARGIN / 2;
-
-      const pageIndex = pagesStore
-        .filter((p) => p.folderId === foldersStore[folderIndex].id)
-        .findIndex((p) => p.id === activePageId);
-      for (let i = 0; i <= pageIndex; i++) {
-        height += PAGE_ITEM_HEIGHT;
-      }
-
-      const elHeight = el.current.getBoundingClientRect().height;
-      const scrollTop = el.current.scrollTop;
-
-      if (scrollTop + elHeight < height || scrollTop > height) {
-        el.current.scrollTo({ top: height - elHeight, behavior: 'smooth' });
-      }
-    }, ANIMATION_DURATION);
-  }, [activePageId]);
-
-  useEffect(() => {
-    setMaxHeight(foldersStore.length * (INITIAL_FOLDER_HEIGHT + FOLDER_MARGIN) + pagesStore.length * PAGE_ITEM_HEIGHT);
-  }, [foldersStore, pagesStore]);
+    setMaxHeight(pages.length * PAGE_ITEM_HEIGHT);
+  }, [pages]);
 
   const scrollDown = () => {
     setTimeout(() => {
@@ -113,7 +64,7 @@ export const NavigationPanel = ({
               }}
               ref={el}
             >
-              <WorkspaceApps folders={folders} pages={pages} onPageClick={onPageClick} />
+              <WorkspaceApps pages={pages.filter((p) => p.parentPageId === workspace.id)} />
             </div>
           </div>
         </div>
@@ -130,8 +81,8 @@ export const NavigationPanel = ({
             <TrashButton></TrashButton>
           </div>
 
-          {/*New Folder Button*/}
-          <NewFolderButton scrollDown={scrollDown}></NewFolderButton>
+          {/*New Root View Button*/}
+          <NewViewButton scrollDown={scrollDown}></NewViewButton>
         </div>
       </div>
       <NavigationResizer minWidth={NAV_PANEL_MINIMUM_WIDTH}></NavigationResizer>
@@ -139,21 +90,10 @@ export const NavigationPanel = ({
   );
 };
 
-type AppsContext = {
-  folders: IFolder[];
-  pages: IPage[];
-  onPageClick: (page: IPage) => void;
-};
-
-const WorkspaceApps: React.FC<AppsContext> = ({ folders, pages, onPageClick }) => (
+const WorkspaceApps: React.FC<{ pages: IPage[] }> = ({ pages }) => (
   <>
-    {folders.map((folder, index) => (
-      <FolderItem
-        key={index}
-        folder={folder}
-        pages={pages.filter((page) => page.folderId === folder.id)}
-        onPageClick={onPageClick}
-      ></FolderItem>
+    {pages.map((page, index) => (
+      <NavItem key={index} page={page}></NavItem>
     ))}
   </>
 );

+ 0 - 20
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewFolderButton.hooks.ts

@@ -1,20 +0,0 @@
-import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { foldersActions } from '$app_reducers/folders/slice';
-import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
-
-export const useNewFolder = () => {
-  const appDispatch = useAppDispatch();
-  const workspace = useAppSelector((state) => state.workspace);
-  const workspaceBackendService = new WorkspaceBackendService(workspace.id ?? '');
-
-  const onNewFolder = async () => {
-    const newApp = await workspaceBackendService.createApp({
-      name: 'New Folder 1',
-    });
-    appDispatch(foldersActions.addFolder({ id: newApp.id, title: newApp.name }));
-  };
-
-  return {
-    onNewFolder,
-  };
-};

+ 45 - 0
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.hooks.ts

@@ -0,0 +1,45 @@
+import { useAppDispatch, useAppSelector } from '$app/stores/store';
+import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
+import { ViewLayoutPB } from '@/services/backend';
+import { pagesActions } from '$app_reducers/pages/slice';
+import { useNavigate } from 'react-router-dom';
+
+export const useNewRootView = () => {
+  const appDispatch = useAppDispatch();
+  const workspace = useAppSelector((state) => state.workspace);
+  const navigate = useNavigate();
+
+  const onNewRootView = async () => {
+    if (!workspace.id) return;
+    const workspaceBackendService = new WorkspaceBackendService(workspace.id);
+
+    // in future should show options for new page type
+    const defaultType = ViewLayoutPB.Document;
+    const defaultName = 'Document Page 1';
+    const defaultRoute = 'document';
+
+    const result = await workspaceBackendService.createView({
+      parentViewId: workspace.id,
+      layoutType: defaultType,
+      name: defaultName,
+    });
+
+    if (result.ok) {
+      const newView = result.val;
+      appDispatch(
+        pagesActions.addPage({
+          parentPageId: workspace.id,
+          id: newView.id,
+          title: newView.name,
+          showPagesInside: false,
+          pageType: defaultType,
+        })
+      );
+      navigate(`/page/${defaultRoute}/${newView.id}`);
+    }
+  };
+
+  return {
+    onNewRootView,
+  };
+};

+ 5 - 5
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewFolderButton.tsx → frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/NewViewButton.tsx

@@ -1,13 +1,13 @@
 import AddSvg from '../../_shared/svg/AddSvg';
-import { useNewFolder } from './NewFolderButton.hooks';
+import { useNewRootView } from './NewViewButton.hooks';
 
-export const NewFolderButton = ({ scrollDown }: { scrollDown: () => void }) => {
-  const { onNewFolder } = useNewFolder();
+export const NewViewButton = ({ scrollDown }: { scrollDown: () => void }) => {
+  const { onNewRootView } = useNewRootView();
 
   return (
     <button
       onClick={() => {
-        void onNewFolder();
+        void onNewRootView();
         scrollDown();
       }}
       className={'flex h-[50px] w-full items-center px-6 hover:bg-surface-2'}
@@ -17,7 +17,7 @@ export const NewFolderButton = ({ scrollDown }: { scrollDown: () => void }) => {
           <AddSvg></AddSvg>
         </div>
       </div>
-      <span>New Folder</span>
+      <span>New View</span>
     </button>
   );
 };

+ 0 - 68
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PageItem.hooks.ts

@@ -1,68 +0,0 @@
-import { IPage, pagesActions } from '$app_reducers/pages/slice';
-import { useAppDispatch } from '$app/stores/store';
-import { useEffect, useState } from 'react';
-import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
-import { useLocation } from 'react-router-dom';
-import { ViewPB } from '@/services/backend';
-
-export const usePageEvents = (page: IPage) => {
-  const appDispatch = useAppDispatch();
-  const [showPageOptions, setShowPageOptions] = useState(false);
-  const [showRenamePopup, setShowRenamePopup] = useState(false);
-  const [activePageId, setActivePageId] = useState<string>('');
-  const currentLocation = useLocation();
-  const viewBackendService: ViewBackendService = new ViewBackendService(page.id);
-
-  useEffect(() => {
-    const { pathname } = currentLocation;
-    const parts = pathname.split('/');
-    const pageId = parts[parts.length - 1];
-    setActivePageId(pageId);
-  }, [currentLocation]);
-
-  const onPageOptionsClick = () => {
-    setShowPageOptions(!showPageOptions);
-  };
-
-  const startPageRename = () => {
-    setShowRenamePopup(true);
-    closePopup();
-  };
-
-  const changePageTitle = async (newTitle: string) => {
-    await viewBackendService.update({ name: newTitle });
-    appDispatch(pagesActions.renamePage({ id: page.id, newTitle }));
-  };
-
-  const deletePage = async () => {
-    closePopup();
-    await viewBackendService.delete();
-    appDispatch(pagesActions.deletePage({ id: page.id }));
-  };
-
-  const duplicatePage = async () => {
-    closePopup();
-    await viewBackendService.duplicate(ViewPB.fromObject(page));
-  };
-
-  const closePopup = () => {
-    setShowPageOptions(false);
-  };
-
-  const closeRenamePopup = () => {
-    setShowRenamePopup(false);
-  };
-
-  return {
-    showPageOptions,
-    onPageOptionsClick,
-    showRenamePopup,
-    startPageRename,
-    changePageTitle,
-    deletePage,
-    duplicatePage,
-    closePopup,
-    closeRenamePopup,
-    activePageId,
-  };
-};

+ 0 - 83
frontend/appflowy_tauri/src/appflowy_app/components/layout/NavigationPanel/PageItem.tsx

@@ -1,83 +0,0 @@
-import { DocumentSvg } from '../../_shared/svg/DocumentSvg';
-import { BoardSvg } from '../../_shared/svg/BoardSvg';
-import { GridSvg } from '../../_shared/svg/GridSvg';
-import { Details2Svg } from '../../_shared/svg/Details2Svg';
-import { NavItemOptionsPopup } from './NavItemOptionsPopup';
-import { IPage } from '$app_reducers/pages/slice';
-import { Button } from '../../_shared/Button';
-import { usePageEvents } from './PageItem.hooks';
-import { RenamePopup } from './RenamePopup';
-import { ViewLayoutPB } from '@/services/backend';
-import { useEffect, useRef, useState } from 'react';
-import { PAGE_ITEM_HEIGHT } from '../../_shared/constants';
-
-export const PageItem = ({ page, onPageClick }: { page: IPage; onPageClick: () => void }) => {
-  const {
-    showPageOptions,
-    onPageOptionsClick,
-    showRenamePopup,
-    startPageRename,
-    changePageTitle,
-    deletePage,
-    duplicatePage,
-    closePopup,
-    closeRenamePopup,
-    activePageId,
-  } = usePageEvents(page);
-
-  const el = useRef<HTMLDivElement>(null);
-
-  const [popupY, setPopupY] = useState(0);
-
-  useEffect(() => {
-    if (el.current) {
-      const { top } = el.current.getBoundingClientRect();
-      setPopupY(top);
-    }
-  }, [showPageOptions, showRenamePopup]);
-
-  return (
-    <div ref={el}>
-      <div
-        onClick={() => onPageClick()}
-        className={`flex cursor-pointer items-center justify-between rounded-lg pl-8 pr-4 hover:bg-surface-2 ${
-          activePageId === page.id ? 'bg-surface-2' : ''
-        }`}
-        style={{ height: PAGE_ITEM_HEIGHT }}
-      >
-        <button className={'flex min-w-0 flex-1 items-center'}>
-          <i className={'ml-1 mr-1 h-[16px] w-[16px]'}>
-            {page.pageType === ViewLayoutPB.Document && <DocumentSvg></DocumentSvg>}
-            {page.pageType === ViewLayoutPB.Board && <BoardSvg></BoardSvg>}
-            {page.pageType === ViewLayoutPB.Grid && <GridSvg></GridSvg>}
-          </i>
-          <span className={'ml-2 min-w-0 flex-1 overflow-hidden overflow-ellipsis whitespace-nowrap text-left'}>
-            {page.title}
-          </span>
-        </button>
-        <div className={'flex items-center'}>
-          <Button size={'box-small-transparent'} onClick={() => onPageOptionsClick()}>
-            <Details2Svg></Details2Svg>
-          </Button>
-        </div>
-      </div>
-      {showPageOptions && (
-        <NavItemOptionsPopup
-          onRenameClick={() => startPageRename()}
-          onDeleteClick={() => deletePage()}
-          onDuplicateClick={() => duplicatePage()}
-          onClose={() => closePopup()}
-          top={popupY - 124 + 40}
-        ></NavItemOptionsPopup>
-      )}
-      {showRenamePopup && (
-        <RenamePopup
-          value={page.title}
-          onChange={(newTitle) => changePageTitle(newTitle)}
-          onClose={closeRenamePopup}
-          top={popupY - 124 + 40}
-        ></RenamePopup>
-      )}
-    </div>
-  );
-};

+ 4 - 18
frontend/appflowy_tauri/src/appflowy_app/components/layout/Screen.tsx

@@ -1,31 +1,17 @@
-import React, { ReactNode, useEffect } from 'react';
+import React, { ReactNode } from 'react';
 import { NavigationPanel } from './NavigationPanel/NavigationPanel';
 import { MainPanel } from './MainPanel';
 import { useNavigationPanelHooks } from './NavigationPanel/NavigationPanel.hooks';
 import { useWorkspace } from './Workspace.hooks';
-import { useAppSelector } from '$app/stores/store';
 
 export const Screen = ({ children }: { children: ReactNode }) => {
-  const currentUser = useAppSelector((state) => state.currentUser);
-  const { loadWorkspaceItems } = useWorkspace();
-  useEffect(() => {
-    void (async () => {
-      await loadWorkspaceItems();
-    })();
-  }, [currentUser.isAuthenticated]);
+  useWorkspace();
 
-  const { width, folders, pages, onPageClick, onHideMenuClick, onShowMenuClick, menuHidden } = useNavigationPanelHooks();
+  const { width, onHideMenuClick, onShowMenuClick, menuHidden } = useNavigationPanelHooks();
 
   return (
     <div className='flex h-screen w-screen bg-white text-black'>
-      <NavigationPanel
-        onHideMenuClick={onHideMenuClick}
-        width={width}
-        folders={folders}
-        pages={pages}
-        onPageClick={onPageClick}
-        menuHidden={menuHidden}
-      ></NavigationPanel>
+      <NavigationPanel onHideMenuClick={onHideMenuClick} width={width} menuHidden={menuHidden}></NavigationPanel>
 
       <MainPanel left={width} menuHidden={menuHidden} onShowMenuClick={onShowMenuClick}>
         {children}

+ 56 - 39
frontend/appflowy_tauri/src/appflowy_app/components/layout/Workspace.hooks.ts

@@ -1,52 +1,69 @@
 import { foldersActions } from '$app_reducers/folders/slice';
 import { useAppDispatch, useAppSelector } from '$app/stores/store';
-import { pagesActions } from '$app_reducers/pages/slice';
+import { IPage, pagesActions } from '$app_reducers/pages/slice';
 import { workspaceActions } from '$app_reducers/workspace/slice';
 import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
-import { AppBackendService } from '$app/stores/effects/folder/app/app_bd_svc';
-import { Log } from '$app/utils/log';
+import { useEffect, useState } from 'react';
+import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
 
 export const useWorkspace = () => {
   const currentUser = useAppSelector((state) => state.currentUser);
-
   const appDispatch = useAppDispatch();
 
-  const userBackendService: UserBackendService = new UserBackendService(currentUser.id ?? 0);
-
-  const loadWorkspaceItems = async () => {
-    try {
-      const workspaceSettingPB = await userBackendService.getCurrentWorkspace();
-      const workspace = workspaceSettingPB.workspace;
-      appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
-      appDispatch(foldersActions.clearFolders());
-      appDispatch(pagesActions.clearPages());
-
-      const apps = workspace.views;
-      for (const app of apps) {
-        appDispatch(foldersActions.addFolder({ id: app.id, title: app.name }));
-        const service = new AppBackendService(app.id);
-        const result = await service.getAllViews();
-        if (result.ok) {
-          for (const view of result.val) {
-            appDispatch(
-              pagesActions.addPage({ folderId: app.id, id: view.id, pageType: view.layout, title: view.name })
-            );
-          }
-        } else {
-          Log.error('Failed to get views, folderId: ' + app.id);
-        }
-      }
-    } catch (e1) {
-      // create workspace for first start
-      const workspace = await userBackendService.createWorkspace({ name: 'New Workspace', desc: '' });
-      appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
+  const [userService, setUserService] = useState<UserBackendService | null>(null);
+  const [workspaceService, setWorkspaceService] = useState<WorkspaceBackendService | null>(null);
+  const [isReady, setIsReady] = useState(false);
 
-      appDispatch(foldersActions.clearFolders());
-      appDispatch(pagesActions.clearPages());
+  useEffect(() => {
+    if (currentUser.id) {
+      setUserService(new UserBackendService(currentUser.id));
     }
-  };
+  }, [currentUser]);
+
+  useEffect(() => {
+    if (!userService) return;
+
+    void (async () => {
+      try {
+        const workspaceSettingPB = await userService.getCurrentWorkspace();
+        const workspace = workspaceSettingPB.workspace;
+        appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
+        appDispatch(foldersActions.clearFolders());
+        appDispatch(pagesActions.clearPages());
+
+        setWorkspaceService(new WorkspaceBackendService(workspace.id));
+      } catch (e1) {
+        // create workspace for first start
+        const workspace = await userService.createWorkspace({ name: 'New Workspace', desc: '' });
+        appDispatch(workspaceActions.updateWorkspace({ id: workspace.id, name: workspace.name }));
+
+        appDispatch(foldersActions.clearFolders());
+        appDispatch(pagesActions.clearPages());
+      }
+    })();
+  }, [userService]);
+
+  useEffect(() => {
+    if (!workspaceService) return;
+    void (async () => {
+      const rootViews = await workspaceService.getAllViews();
+      if (rootViews.ok) {
+        appDispatch(
+          pagesActions.addInsidePages({
+            currentPageId: workspaceService.workspaceId,
+            insidePages: rootViews.val.map<IPage>((v) => ({
+              id: v.id,
+              title: v.name,
+              pageType: v.layout,
+              showPagesInside: false,
+              parentPageId: workspaceService.workspaceId,
+            })),
+          })
+        );
+        setIsReady(true);
+      }
+    })();
+  }, [workspaceService]);
 
-  return {
-    loadWorkspaceItems,
-  };
+  return {};
 };

+ 11 - 4
frontend/appflowy_tauri/src/appflowy_app/components/tests/DatabaseTestHelper.ts

@@ -1,12 +1,12 @@
 import {
   FieldType,
+  FlowyError,
   SingleSelectTypeOptionPB,
   ViewLayoutPB,
   ViewPB,
   WorkspaceSettingPB,
 } from '../../../services/backend';
 import { FolderEventGetCurrentWorkspace } from '../../../services/backend/events/flowy-folder2';
-import { AppBackendService } from '../../stores/effects/folder/app/app_bd_svc';
 import { DatabaseController } from '../../stores/effects/database/database_controller';
 import { RowInfo } from '../../stores/effects/database/row/row_cache';
 import { RowController } from '../../stores/effects/database/row/row_controller';
@@ -19,7 +19,7 @@ import {
   TextCellController,
   URLCellController,
 } from '../../stores/effects/database/cell/controller_builder';
-import { None, Option, Some } from 'ts-results';
+import { None, Ok, Option, Result, Some } from 'ts-results';
 import { TypeOptionBackendService } from '../../stores/effects/database/field/type_option/type_option_bd_svc';
 import { DatabaseBackendService } from '../../stores/effects/database/database_bd_svc';
 import { FieldInfo } from '../../stores/effects/database/field/field_controller';
@@ -27,13 +27,20 @@ import { TypeOptionController } from '../../stores/effects/database/field/type_o
 import { makeSingleSelectTypeOptionContext } from '../../stores/effects/database/field/type_option/type_option_context';
 import { SelectOptionBackendService } from '../../stores/effects/database/cell/select_option_bd_svc';
 import { Log } from '$app/utils/log';
+import { ViewBackendService } from '$app/stores/effects/folder/view/view_bd_svc';
+import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
 
 // Create a database view for specific layout type
 // Do not use it production code. Just for testing
 export async function createTestDatabaseView(layout: ViewLayoutPB): Promise<ViewPB> {
   const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
-  const appService = new AppBackendService(workspaceSetting.workspace.id);
-  return await appService.createView({ name: 'New Grid', layoutType: layout });
+  const wsSvc = new WorkspaceBackendService(workspaceSetting.workspace.id);
+  const viewRes = await wsSvc.createView({ name: 'New Grid', layoutType: layout });
+  if (viewRes.ok) {
+    return viewRes.val;
+  } else {
+    throw Error(viewRes.val.msg);
+  }
 }
 
 export async function openTestDatabase(viewId: string): Promise<DatabaseController> {

+ 9 - 4
frontend/appflowy_tauri/src/appflowy_app/components/tests/DocumentTestHelper.ts

@@ -1,10 +1,15 @@
 import { ViewLayoutPB, WorkspaceSettingPB } from '@/services/backend';
 import { FolderEventGetCurrentWorkspace } from '@/services/backend/events/flowy-folder2';
-import { AppBackendService } from '$app/stores/effects/folder/app/app_bd_svc';
+import {WorkspaceBackendService} from "$app/stores/effects/folder/workspace/workspace_bd_svc";
 
 export async function createTestDocument() {
   const workspaceSetting: WorkspaceSettingPB = await FolderEventGetCurrentWorkspace().then((result) => result.unwrap());
-  const app = workspaceSetting.workspace.views[0];
-  const appService = new AppBackendService(app.id);
-  return await appService.createView({ name: 'New Document', layoutType: ViewLayoutPB.Document });
+  const appService = new WorkspaceBackendService(workspaceSetting.workspace.id);
+  const result = await appService.createView({ name: 'New Document', layoutType: ViewLayoutPB.Document });
+  if (result.ok) {
+    return result.val;
+  }
+  else {
+    throw Error(result.val.msg);
+  }
 }

+ 3 - 0
frontend/appflowy_tauri/src/appflowy_app/components/tests/TestAPI.tsx

@@ -29,6 +29,7 @@ import {
   TestMoveKanbanBoardRow,
 } from './TestGroup';
 import { TestCreateDocument } from './TestDocument';
+import { TestCreateViews } from '$app/components/tests/TestFolder';
 
 export const TestAPI = () => {
   return (
@@ -62,6 +63,8 @@ export const TestAPI = () => {
         <TestMoveKanbanBoardColumn></TestMoveKanbanBoardColumn>
         <TestCreateKanbanBoardColumn></TestCreateKanbanBoardColumn>
         <TestCreateDocument></TestCreateDocument>
+        {/*Folders*/}
+        <TestCreateViews></TestCreateViews>
       </ul>
     </React.Fragment>
   );

+ 61 - 0
frontend/appflowy_tauri/src/appflowy_app/components/tests/TestFolder.tsx

@@ -0,0 +1,61 @@
+import React from 'react';
+import { UserBackendService } from '$app/stores/effects/user/user_bd_svc';
+import { useAppSelector } from '$app/stores/store';
+import { WorkspaceBackendService } from '$app/stores/effects/folder/workspace/workspace_bd_svc';
+import { ViewLayoutPB, ViewPB } from '@/services/backend';
+
+const testCreateFolder = async (userId?: number) => {
+  if (!userId) {
+    console.log('user is not logged in');
+    return;
+  }
+  console.log('test create views');
+  const userBackendService: UserBackendService = new UserBackendService(userId);
+  const workspaces = await userBackendService.getWorkspaces();
+  if (workspaces.ok) {
+    console.log('workspaces: ', workspaces.val.toObject());
+  }
+  const currentWorkspace = await userBackendService.getCurrentWorkspace();
+
+  const workspaceService = new WorkspaceBackendService(currentWorkspace.workspace.id);
+  const rootViews: ViewPB[] = [];
+  for (let i = 1; i <= 3; i++) {
+    const result = await workspaceService.createView({
+      name: `test board ${i}`,
+      desc: 'test description',
+      layoutType: ViewLayoutPB.Board,
+    });
+    if (result.ok) {
+      rootViews.push(result.val);
+    }
+  }
+  for (let i = 1; i <= 3; i++) {
+    const result = await workspaceService.createView({
+      name: `test board 1 ${i}`,
+      desc: 'test description',
+      layoutType: ViewLayoutPB.Board,
+      parentViewId: rootViews[0].id,
+    });
+  }
+
+  const allApps = await workspaceService.getAllViews();
+  console.log(allApps);
+};
+
+export const TestCreateViews = () => {
+  const currentUser = useAppSelector((state) => state.currentUser);
+
+  return TestButton('Test create views', testCreateFolder, currentUser.id);
+};
+
+const TestButton = (title: string, onClick: (userId?: number) => void, userId?: number) => {
+  return (
+    <React.Fragment>
+      <div>
+        <button className='rounded-md bg-pink-200 p-4' type='button' onClick={() => onClick(userId)}>
+          {title}
+        </button>
+      </div>
+    </React.Fragment>
+  );
+};

+ 2 - 4
frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGrid.tsx

@@ -30,7 +30,6 @@ import {
 import { SelectOptionCellBackendService } from '$app/stores/effects/database/cell/select_option_bd_svc';
 import { TypeOptionController } from '$app/stores/effects/database/field/type_option/type_option_controller';
 import { None, Some } from 'ts-results';
-import { RowBackendService } from '$app/stores/effects/database/row/row_bd_svc';
 import {
   makeDateTypeOptionContext,
   makeNumberTypeOptionContext,
@@ -250,7 +249,7 @@ async function testCreateRow() {
   await databaseController.open().then((result) => result.unwrap());
   await assertNumberOfRows(view.id, 3);
 
-  // Create a row from a DatabaseController or create using the RowBackendService
+  // Create a row from a DatabaseController
   await databaseController.createRow();
   await assertNumberOfRows(view.id, 4);
   await databaseController.dispose();
@@ -262,8 +261,7 @@ async function testDeleteRow() {
   await databaseController.open().then((result) => result.unwrap());
 
   const rows = databaseController.databaseViewCache.rowInfos;
-  const svc = new RowBackendService(view.id);
-  await svc.deleteRow(rows[0].row.id);
+  await databaseController.deleteRow(rows[0].row.id);
   await assertNumberOfRows(view.id, 2);
 
   // Wait the databaseViewCache get the change notification and

+ 1 - 1
frontend/appflowy_tauri/src/appflowy_app/components/tests/TestGroup.tsx

@@ -101,7 +101,7 @@ async function moveKanbanBoardRow() {
   });
 
   const row = firstGroup.rowAtIndex(0).unwrap();
-  await databaseController.moveRow(row.id, secondGroup.groupId);
+  await databaseController.moveGroupRow(row.id, secondGroup.groupId);
 
   assert(firstGroup.rows.length === 2);
   await assertNumberOfRowsInGroup(view.id, firstGroup.groupId, 2);

+ 17 - 17
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/cell/cell_controller.ts

@@ -3,9 +3,9 @@ import { CellCache, CellCacheKey } from './cell_cache';
 import { CellDataLoader } from './data_parser';
 import { CellDataPersistence } from './data_persistence';
 import { FieldBackendService, TypeOptionParser } from '../field/field_bd_svc';
-import { ChangeNotifier } from '../../../../utils/change_notifier';
+import { ChangeNotifier } from '$app/utils/change_notifier';
 import { CellObserver } from './cell_observer';
-import { Log } from '../../../../utils/log';
+import { Log } from '$app/utils/log';
 import { Err, None, Ok, Option, Some } from 'ts-results';
 import { DatabaseFieldObserver } from '../field/field_observer';
 
@@ -48,14 +48,14 @@ export class CellController<T, D> {
 
     /// 2.Listen on the field event and load the cell data if needed.
     void this.fieldNotifier.subscribe({
-      onFieldChanged: () => {
-        this.subscribeCallbacks?.onFieldChanged?.();
+      onFieldChanged: async () => {
         /// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed
         /// For example:
         ///   ¥12 -> $12
         if (this.cellDataLoader.reloadOnFieldChanged) {
-          void this._loadCellData();
+          await this._loadCellData();
         }
+        this.subscribeCallbacks?.onFieldChanged?.();
       },
     });
   }
@@ -97,24 +97,24 @@ export class CellController<T, D> {
     return cellData;
   };
 
-  private _loadCellData = () => {
-    return this.cellDataLoader.loadData().then((result) => {
-      if (result.ok) {
-        const cellData = result.val;
-        if (cellData.some) {
-          this.cellCache.insert(this.cacheKey, cellData.val);
-          this.cellDataNotifier.cellData = cellData;
-        }
-      } else {
-        this.cellCache.remove(this.cacheKey);
-        this.cellDataNotifier.cellData = None;
+  private _loadCellData = async () => {
+    const result = await this.cellDataLoader.loadData();
+    if (result.ok) {
+      const cellData = result.val;
+      if (cellData.some) {
+        this.cellCache.insert(this.cacheKey, cellData.val);
+        this.cellDataNotifier.cellData = cellData;
       }
-    });
+    } else {
+      this.cellCache.remove(this.cacheKey);
+      this.cellDataNotifier.cellData = None;
+    }
   };
 
   dispose = async () => {
     await this.cellObserver.unsubscribe();
     await this.fieldNotifier.unsubscribe();
+    this.cellDataNotifier.unsubscribe();
   };
 }
 

+ 13 - 0
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_bd_svc.ts

@@ -1,5 +1,7 @@
 import {
   DatabaseEventCreateRow,
+  DatabaseEventDeleteRow,
+  DatabaseEventDuplicateRow,
   DatabaseEventGetDatabase,
   DatabaseEventGetDatabaseSetting,
   DatabaseEventGetFields,
@@ -14,6 +16,7 @@ import {
   MoveGroupPayloadPB,
   MoveGroupRowPayloadPB,
   MoveRowPayloadPB,
+  RowIdPB,
 } from '@/services/backend/events/flowy-database2';
 import {
   GetFieldPayloadPB,
@@ -64,6 +67,16 @@ export class DatabaseBackendService {
     return DatabaseEventCreateRow(payload);
   };
 
+  duplicateRow = async (rowId: string) => {
+    const payload = RowIdPB.fromObject({ view_id: this.viewId, row_id: rowId });
+    return DatabaseEventDuplicateRow(payload);
+  };
+
+  deleteRow = async (rowId: string) => {
+    const payload = RowIdPB.fromObject({ view_id: this.viewId, row_id: rowId });
+    return DatabaseEventDeleteRow(payload);
+  };
+
   /// Move the row from one group to another group
   /// [toRowId] is used to locate the moving row location.
   moveGroupRow = (fromRowId: string, toGroupId: string, toRowId?: string) => {

+ 10 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/database_controller.ts

@@ -89,11 +89,19 @@ export class DatabaseController {
     return this.backendService.createRow();
   };
 
-  moveRow = (rowId: string, groupId: string) => {
+  duplicateRow = async (rowId: string) => {
+    return this.backendService.duplicateRow(rowId);
+  };
+
+  deleteRow = async (rowId: string) => {
+    return this.backendService.deleteRow(rowId);
+  };
+
+  moveGroupRow = (rowId: string, groupId: string) => {
     return this.backendService.moveGroupRow(rowId, groupId);
   };
 
-  exchangeRow = async (fromRowId: string, toGroupId: string, toRowId?: string) => {
+  exchangeGroupRow = async (fromRowId: string, toGroupId: string, toRowId?: string) => {
     await this.backendService.moveGroupRow(fromRowId, toGroupId, toRowId);
     await this.loadGroup();
   };

+ 7 - 8
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/field/field_controller.ts

@@ -1,8 +1,8 @@
-import { Log } from "$app/utils/log";
-import { DatabaseBackendService } from "../database_bd_svc";
-import { DatabaseFieldChangesetObserver } from "./field_observer";
-import { FieldIdPB, FieldPB, IndexFieldPB } from "@/services/backend";
-import { ChangeNotifier } from "$app/utils/change_notifier";
+import { Log } from '$app/utils/log';
+import { DatabaseBackendService } from '../database_bd_svc';
+import { DatabaseFieldChangesetObserver } from './field_observer';
+import { FieldIdPB, FieldPB, IndexFieldPB } from '@/services/backend';
+import { ChangeNotifier } from '$app/utils/change_notifier';
 
 export class FieldController {
   private backendService: DatabaseBackendService;
@@ -53,7 +53,7 @@ export class FieldController {
         } else {
           Log.error(result.val);
         }
-      }
+      },
     });
   };
 
@@ -122,6 +122,5 @@ class NumOfFieldsNotifier extends ChangeNotifier<FieldInfo[]> {
 }
 
 export class FieldInfo {
-  constructor(public readonly field: FieldPB) {
-  }
+  constructor(public readonly field: FieldPB) {}
 }

+ 0 - 32
frontend/appflowy_tauri/src/appflowy_app/stores/effects/database/row/row_bd_svc.ts

@@ -1,32 +0,0 @@
-import { CreateRowPayloadPB, RowIdPB } from '@/services/backend';
-import {
-  DatabaseEventCreateRow,
-  DatabaseEventDeleteRow,
-  DatabaseEventDuplicateRow,
-  DatabaseEventGetRow,
-} from '@/services/backend/events/flowy-database2';
-
-export class RowBackendService {
-  constructor(public readonly viewId: string) {}
-
-  // Create a row below the row with rowId
-  createRow = (rowId: string) => {
-    const payload = CreateRowPayloadPB.fromObject({ view_id: this.viewId, start_row_id: rowId });
-    return DatabaseEventCreateRow(payload);
-  };
-
-  deleteRow = (rowId: string) => {
-    const payload = RowIdPB.fromObject({ view_id: this.viewId, row_id: rowId });
-    return DatabaseEventDeleteRow(payload);
-  };
-
-  duplicateRow = (rowId: string) => {
-    const payload = RowIdPB.fromObject({ view_id: this.viewId, row_id: rowId });
-    return DatabaseEventDuplicateRow(payload);
-  };
-
-  getRow = (rowId: string) => {
-    const payload = RowIdPB.fromObject({ view_id: this.viewId, row_id: rowId });
-    return DatabaseEventGetRow(payload);
-  };
-}

+ 0 - 105
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/app_bd_svc.ts

@@ -1,105 +0,0 @@
-import {
-  FolderEventCreateView,
-  FolderEventDeleteView,
-  FolderEventMoveView,
-  FolderEventReadView,
-  FolderEventUpdateView,
-  ViewLayoutPB,
-} from '@/services/backend/events/flowy-folder2';
-import {
-  CreateViewPayloadPB,
-  RepeatedViewIdPB,
-  ViewPB,
-  MoveViewPayloadPB,
-  FlowyError,
-  ViewIdPB,
-  UpdateViewPayloadPB,
-} from '@/services/backend';
-import { None, Result, Some } from 'ts-results';
-
-export class AppBackendService {
-  constructor(public readonly appId: string) {}
-
-  getApp = () => {
-    const payload = ViewIdPB.fromObject({ value: this.appId });
-    return FolderEventReadView(payload);
-  };
-
-  createView = async (params: {
-    name: string;
-    desc?: string;
-    layoutType: ViewLayoutPB;
-    /// The initial data should be the JSON of the document
-    /// For example: {"document":{"type":"editor","children":[]}}
-    initialData?: string;
-  }) => {
-    const encoder = new TextEncoder();
-    const payload = CreateViewPayloadPB.fromObject({
-      parent_view_id: this.appId,
-      name: params.name,
-      desc: params.desc || '',
-      layout: params.layoutType,
-      initial_data: encoder.encode(params.initialData || ''),
-    });
-
-    const result = await FolderEventCreateView(payload);
-
-    if (result.ok) {
-      return result.val;
-    } else {
-      throw new Error(result.val.msg);
-    }
-  };
-
-  getAllViews = (): Promise<Result<ViewPB[], FlowyError>> => {
-    const payload = ViewIdPB.fromObject({ value: this.appId });
-    return FolderEventReadView(payload).then((result) => {
-      return result.map((app) => app.child_views);
-    });
-  };
-
-  getView = async (viewId: string) => {
-    const result = await this.getAllViews();
-    if (result.ok) {
-      const target = result.val.find((view) => view.id === viewId);
-      if (target !== undefined) {
-        return Some(target);
-      } else {
-        return None;
-      }
-    } else {
-      return None;
-    }
-  };
-
-  update = async (params: { name: string }) => {
-    const payload = UpdateViewPayloadPB.fromObject({ view_id: this.appId, name: params.name });
-    const result = await FolderEventUpdateView(payload);
-    if (!result.ok) {
-      throw new Error(result.val.msg);
-    }
-  };
-
-  delete = async () => {
-    const payload = RepeatedViewIdPB.fromObject({ items: [this.appId] });
-    const result = await FolderEventDeleteView(payload);
-    if (!result.ok) {
-      throw new Error(result.val.msg);
-    }
-  };
-
-  deleteView = (viewId: string) => {
-    const payload = RepeatedViewIdPB.fromObject({ items: [viewId] });
-    return FolderEventDeleteView(payload);
-  };
-
-  moveView = (params: { view_id: string; fromIndex: number; toIndex: number }) => {
-    const payload = MoveViewPayloadPB.fromObject({
-      view_id: params.view_id,
-      from: params.fromIndex,
-      to: params.toIndex,
-    });
-
-    return FolderEventMoveView(payload);
-  };
-}

+ 0 - 34
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/app/app_observer.ts

@@ -1,34 +0,0 @@
-import { FolderNotification } from '@/services/backend';
-import { ChangeNotifier } from '$app/utils/change_notifier';
-import { FolderNotificationObserver } from '../notifications/observer';
-
-export class AppObserver {
-  _viewsNotifier = new ChangeNotifier<void>();
-  _listener?: FolderNotificationObserver;
-
-  constructor(public readonly appId: string) {}
-
-  subscribe = async (callbacks: { onViewsChanged: () => void }) => {
-    this._viewsNotifier?.observer?.subscribe(callbacks.onViewsChanged);
-    this._listener = new FolderNotificationObserver({
-      viewId: this.appId,
-      parserHandler: (notification, result) => {
-        switch (notification) {
-          case FolderNotification.DidUpdateChildViews:
-            if (result.ok) {
-              this._viewsNotifier?.notify();
-            }
-            break;
-          default:
-            break;
-        }
-      },
-    });
-    await this._listener.start();
-  };
-
-  unsubscribe = async () => {
-    this._viewsNotifier.unsubscribe();
-    await this._listener?.stop();
-  };
-}

+ 20 - 3
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_bd_svc.ts

@@ -1,13 +1,25 @@
-import { UpdateViewPayloadPB, RepeatedViewIdPB, ViewPB } from '@/services/backend';
+import { UpdateViewPayloadPB, RepeatedViewIdPB, ViewPB, FlowyError, ViewIdPB } from '@/services/backend';
 import {
   FolderEventDeleteView,
   FolderEventDuplicateView,
+  FolderEventReadView,
   FolderEventUpdateView,
 } from '@/services/backend/events/flowy-folder2';
+import { Ok, Result } from 'ts-results';
 
 export class ViewBackendService {
   constructor(public readonly viewId: string) {}
 
+  getChildViews = async (): Promise<Result<ViewPB[], FlowyError>> => {
+    const payload = ViewIdPB.fromObject({ value: this.viewId });
+    const result = await FolderEventReadView(payload);
+    if (result.ok) {
+      return Ok(result.val.child_views);
+    } else {
+      return result;
+    }
+  };
+
   update = (params: { name?: string; desc?: string }) => {
     const payload = UpdateViewPayloadPB.fromObject({ view_id: this.viewId });
 
@@ -26,7 +38,12 @@ export class ViewBackendService {
     return FolderEventDeleteView(payload);
   };
 
-  duplicate = (view: ViewPB) => {
-    return FolderEventDuplicateView(view);
+  duplicate = async () => {
+    const view = await FolderEventReadView(ViewIdPB.fromObject({ value: this.viewId }));
+    if (view.ok) {
+      return FolderEventDuplicateView(view.val);
+    } else {
+      return view;
+    }
   };
 }

+ 23 - 12
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/view/view_observer.ts

@@ -1,7 +1,7 @@
-import { Ok, Result } from "ts-results";
-import { DeletedViewPB, FolderNotification, ViewPB, FlowyError } from "@/services/backend";
-import { ChangeNotifier } from "$app/utils/change_notifier";
-import { FolderNotificationObserver } from "../notifications/observer";
+import { Ok, Result } from 'ts-results';
+import { DeletedViewPB, FolderNotification, ViewPB, FlowyError } from '@/services/backend';
+import { ChangeNotifier } from '$app/utils/change_notifier';
+import { FolderNotificationObserver } from '../notifications/observer';
 
 type DeleteViewNotifyValue = Result<ViewPB, FlowyError>;
 type UpdateViewNotifyValue = Result<ViewPB, FlowyError>;
@@ -12,17 +12,18 @@ export class ViewObserver {
   private _deleteViewNotifier = new ChangeNotifier<DeleteViewNotifyValue>();
   private _updateViewNotifier = new ChangeNotifier<UpdateViewNotifyValue>();
   private _restoreViewNotifier = new ChangeNotifier<RestoreViewNotifyValue>();
-  private _moveToTashNotifier = new ChangeNotifier<MoveToTrashViewNotifyValue>();
+  private _moveToTrashNotifier = new ChangeNotifier<MoveToTrashViewNotifyValue>();
+  private _childViewsNotifier = new ChangeNotifier<void>();
   private _listener?: FolderNotificationObserver;
 
-  constructor(public readonly viewId: string) {
-  }
+  constructor(public readonly viewId: string) {}
 
   subscribe = async (callbacks: {
     onViewUpdate?: (value: UpdateViewNotifyValue) => void;
     onViewDelete?: (value: DeleteViewNotifyValue) => void;
     onViewRestored?: (value: RestoreViewNotifyValue) => void;
     onViewMoveToTrash?: (value: MoveToTrashViewNotifyValue) => void;
+    onChildViewsChanged?: () => void;
   }) => {
     if (callbacks.onViewDelete !== undefined) {
       this._deleteViewNotifier.observer?.subscribe(callbacks.onViewDelete);
@@ -37,7 +38,11 @@ export class ViewObserver {
     }
 
     if (callbacks.onViewMoveToTrash !== undefined) {
-      this._moveToTashNotifier.observer?.subscribe(callbacks.onViewMoveToTrash);
+      this._moveToTrashNotifier.observer?.subscribe(callbacks.onViewMoveToTrash);
+    }
+
+    if (callbacks.onChildViewsChanged !== undefined) {
+      this._childViewsNotifier.observer?.subscribe(callbacks.onChildViewsChanged);
     }
 
     this._listener = new FolderNotificationObserver({
@@ -67,15 +72,20 @@ export class ViewObserver {
             break;
           case FolderNotification.DidMoveViewToTrash:
             if (result.ok) {
-              this._moveToTashNotifier.notify(Ok(DeletedViewPB.deserializeBinary(result.val)));
+              this._moveToTrashNotifier.notify(Ok(DeletedViewPB.deserializeBinary(result.val)));
             } else {
-              this._moveToTashNotifier.notify(result);
+              this._moveToTrashNotifier.notify(result);
+            }
+            break;
+          case FolderNotification.DidUpdateChildViews:
+            if (result.ok) {
+              this._childViewsNotifier?.notify();
             }
             break;
           default:
             break;
         }
-      }
+      },
     });
     await this._listener.start();
   };
@@ -84,7 +94,8 @@ export class ViewObserver {
     this._deleteViewNotifier.unsubscribe();
     this._updateViewNotifier.unsubscribe();
     this._restoreViewNotifier.unsubscribe();
-    this._moveToTashNotifier.unsubscribe();
+    this._moveToTrashNotifier.unsubscribe();
+    this._childViewsNotifier.unsubscribe();
     await this._listener?.stop();
   };
 }

+ 25 - 14
frontend/appflowy_tauri/src/appflowy_app/stores/effects/folder/workspace/workspace_bd_svc.ts

@@ -1,9 +1,10 @@
-import { Err, Ok } from 'ts-results';
+import { Err, Ok, Result } from 'ts-results';
 import {
   FolderEventCreateView,
   FolderEventMoveView,
   FolderEventReadWorkspaceViews,
   FolderEventReadAllWorkspaces,
+  ViewPB,
 } from '@/services/backend/events/flowy-folder2';
 import { CreateViewPayloadPB, FlowyError, MoveViewPayloadPB, ViewLayoutPB, WorkspaceIdPB } from '@/services/backend';
 import assert from 'assert';
@@ -11,20 +12,25 @@ import assert from 'assert';
 export class WorkspaceBackendService {
   constructor(public readonly workspaceId: string) {}
 
-  createApp = async (params: { name: string; desc?: string }) => {
+  createView = async (params: {
+    name: string;
+    desc?: string;
+    layoutType: ViewLayoutPB;
+    parentViewId?: string;
+    /// The initial data should be the JSON of the document
+    /// For example: {"document":{"type":"editor","children":[]}}
+    initialData?: string;
+  }) => {
+    const encoder = new TextEncoder();
     const payload = CreateViewPayloadPB.fromObject({
-      parent_view_id: this.workspaceId,
+      parent_view_id: params.parentViewId ?? this.workspaceId,
       name: params.name,
       desc: params.desc || '',
-      layout: ViewLayoutPB.Document,
+      layout: params.layoutType,
+      initial_data: encoder.encode(params.initialData || ''),
     });
 
-    const result = await FolderEventCreateView(payload);
-    if (result.ok) {
-      return result.val;
-    } else {
-      throw new Error(result.val.msg);
-    }
+    return FolderEventCreateView(payload);
   };
 
   getWorkspace = () => {
@@ -44,14 +50,19 @@ export class WorkspaceBackendService {
     });
   };
 
-  getApps = () => {
+  getAllViews: () => Promise<Result<ViewPB[], FlowyError>> = async () => {
     const payload = WorkspaceIdPB.fromObject({ value: this.workspaceId });
-    return FolderEventReadWorkspaceViews(payload).then((result) => result.map((val) => val.items));
+    const result = await FolderEventReadWorkspaceViews(payload);
+    if (result.ok) {
+      return Ok(result.val.items);
+    } else {
+      return result;
+    }
   };
 
-  moveApp = (params: { appId: string; fromIndex: number; toIndex: number }) => {
+  moveView = (params: { viewId: string; fromIndex: number; toIndex: number }) => {
     const payload = MoveViewPayloadPB.fromObject({
-      view_id: params.appId,
+      view_id: params.viewId,
       from: params.fromIndex,
       to: params.toIndex,
     });

+ 0 - 26
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/folders/notifications/parser.ts

@@ -1,26 +0,0 @@
-import { FlowyError, FolderNotification } from '@/services/backend';
-import { NotificationParser, OnNotificationError } from '@/services/backend/notifications';
-import { Result } from 'ts-results';
-
-declare type FolderNotificationCallback = (ty: FolderNotification, payload: Result<Uint8Array, FlowyError>) => void;
-
-export class FolderNotificationParser extends NotificationParser<FolderNotification> {
-  constructor(params: { id?: string; callback: FolderNotificationCallback; onError?: OnNotificationError }) {
-    super(
-      params.callback,
-      (ty) => {
-        const notification = FolderNotification[ty];
-        if (isFolderNotification(notification)) {
-          return FolderNotification[notification];
-        } else {
-          return FolderNotification.Unknown;
-        }
-      },
-      params.id
-    );
-  }
-}
-
-const isFolderNotification = (notification: string): notification is keyof typeof FolderNotification => {
-  return Object.values(FolderNotification).indexOf(notification) !== -1;
-};

+ 11 - 3
frontend/appflowy_tauri/src/appflowy_app/stores/reducers/pages/slice.ts

@@ -5,7 +5,8 @@ export interface IPage {
   id: string;
   title: string;
   pageType: ViewLayoutPB;
-  folderId: string;
+  parentPageId: string;
+  showPagesInside: boolean;
 }
 
 const initialState: IPage[] = [];
@@ -14,12 +15,19 @@ export const pagesSlice = createSlice({
   name: 'pages',
   initialState: initialState,
   reducers: {
-    didReceivePages(state, action: PayloadAction<{ pages: IPage[]; folderId: string }>) {
-      return state.filter((page) => page.folderId !== action.payload.folderId).concat(action.payload.pages);
+    addInsidePages(state, action: PayloadAction<{ insidePages: IPage[]; currentPageId: string }>) {
+      return state
+        .filter((page) => page.parentPageId !== action.payload.currentPageId)
+        .concat(action.payload.insidePages);
     },
     addPage(state, action: PayloadAction<IPage>) {
       state.push(action.payload);
     },
+    toggleShowPages(state, action: PayloadAction<{ id: string }>) {
+      return state.map<IPage>((page: IPage) =>
+        page.id === action.payload.id ? { ...page, showPagesInside: !page.showPagesInside } : page
+      );
+    },
     renamePage(state, action: PayloadAction<{ id: string; newTitle: string }>) {
       return state.map<IPage>((page: IPage) =>
         page.id === action.payload.id ? { ...page, title: action.payload.newTitle } : page

+ 0 - 2
frontend/appflowy_tauri/src/appflowy_app/stores/store.ts

@@ -7,7 +7,6 @@ import {
   ListenerEffectAPI,
   addListener,
 } from '@reduxjs/toolkit';
-import { foldersSlice } from './reducers/folders/slice';
 import { pagesSlice } from './reducers/pages/slice';
 import { navigationWidthSlice } from './reducers/navigation-width/slice';
 import { currentUserSlice } from './reducers/current-user/slice';
@@ -25,7 +24,6 @@ const listenerMiddlewareInstance = createListenerMiddleware({
 
 const store = configureStore({
   reducer: {
-    [foldersSlice.name]: foldersSlice.reducer,
     [pagesSlice.name]: pagesSlice.reducer,
     [activePageIdSlice.name]: activePageIdSlice.reducer,
     [navigationWidthSlice.name]: navigationWidthSlice.reducer,

+ 46 - 0
frontend/appflowy_tauri/src/appflowy_app/utils/async_queue.ts

@@ -0,0 +1,46 @@
+import { Log } from '$app/utils/log';
+
+export class AsyncQueue<T> {
+  private queue: T[] = [];
+  private isProcessing = false;
+  private executeFunction: (item: T) => Promise<void>;
+
+  constructor(executeFunction: (item: T) => Promise<void>) {
+    this.executeFunction = executeFunction;
+  }
+
+  enqueue(item: T): void {
+    this.queue.push(item);
+    this.processQueue();
+  }
+
+  private processQueue(): void {
+    if (this.isProcessing || this.queue.length === 0) {
+      return;
+    }
+
+    const item = this.queue.shift();
+    this.isProcessing = true;
+
+    const executeFn = async (item: T) => {
+      try {
+        await this.processItem(item);
+      } catch (error) {
+        Log.error('queue processing error:', error);
+      } finally {
+        this.isProcessing = false;
+        this.processQueue();
+      }
+    };
+
+    executeFn(item!);
+  }
+
+  private async processItem(item: T): Promise<void> {
+    try {
+      await this.executeFunction(item);
+    } catch (error) {
+      Log.error('queue processing error:', error);
+    }
+  }
+}

+ 2 - 0
frontend/appflowy_tauri/src/services/backend/notifications/observer.ts

@@ -25,6 +25,8 @@ export abstract class AFNotificationObserver<T> {
 
   async stop() {
     if (this._listener !== undefined) {
+      // call the unlisten function before setting it to undefined
+      this._listener();
       this._listener = undefined;
     }
     this.parser = null;