From ae8d15391c6888f81a0ce07142eff1df3dc963ae Mon Sep 17 00:00:00 2001 From: Komediruzecki Date: Sun, 6 Jun 2021 10:55:43 +0200 Subject: [PATCH] Implement drag and drop based order in sidebar for local space Add array move lib Implemented orderedIds and DnD handling Add logic for moving note or doc into note target Add basic Database updates and API for orderedIds changes Implement ordered IDs initialization in storages Implement ordered IDs initializatio only in FSNote Remove excess function from createStore Updated handling of pouch DB in createStore Add create note update for parent folder ordered IDs Fix direct subfolders rather than all for create subfolders function Add implementation of trash/untrash note workspace orderedIds Update rename folder to update ordered IDs as well Implement createNote update of ordered IDs (workspace) Implement createFolder update of ordered IDs (workspace and parent folders) Implement folder reordering Implement moving of folders across different folder Refactor folder IDs to use real IDs Initialize real IDs (only FSNoteDB) Update rename folder so that it handles realIDs updates (previous and new parent folders ordered IDs update) Refactor code to use root folder instead of workspace ordered Ids Refactor and update for loading of storages Some improvements on loading multiple storages and some fails Add folder realID changing instead of reusing (also previous ordered IDs should be persisted on folder rename) Add handling of trash/untrash regarding ordered Ids Some ordered IDs bugfixes Remove console logs (most of them) Fix bug with parallel execution and parent folder creation on rename folder upsert folder calls Remove note id set from local space Fix bug with hiding items in sidebar (invalid key provided on callback) Assess some todos --- package-lock.json | 17 + package.json | 1 + src/components/App.tsx | 17 +- src/components/organisms/FolderDetail.tsx | 7 +- src/components/organisms/SidebarContainer.tsx | 24 +- src/lib/db/FSNoteDb.ts | 185 +++++++-- src/lib/db/PouchNoteDb.spec.ts | 9 +- src/lib/db/PouchNoteDb.ts | 17 +- src/lib/db/createStore.ts | 372 +++++++++++++----- src/lib/db/patterns.ts | 25 +- src/lib/db/store.spec.ts | 8 +- src/lib/db/types.ts | 11 +- src/lib/db/utils.ts | 32 ++ src/lib/v2/hooks/local/useLocalDB.ts | 38 +- src/lib/v2/hooks/local/useLocalDnd.ts | 288 ++++++++++---- src/lib/v2/mappers/local/sidebarTree.tsx | 95 ++--- src/lib/v2/stores/sidebarCollapse/store.tsx | 2 +- 17 files changed, 867 insertions(+), 281 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe0a54578f..1e13761d14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@stripe/react-stripe-js": "^1.2.0", "@stripe/stripe-js": "^1.11.0", "@types/react-color": "^3.0.4", + "array-move": "^2.2.1", "aws-amplify": "^4.0.3", "chart.js": "^2.9.4", "classcat": "^4.0.2", @@ -21256,6 +21257,17 @@ "node": ">= 0.4" } }, + "node_modules/array-move": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-move/-/array-move-2.2.2.tgz", + "integrity": "sha512-lKc6C+nsOSA1o7eHSP/HshlGDYUI7QKyaus5kPDm2zEEPQID9xlspnraLR8l+rDlqg9mGo8ziE7F8TEnF6D3Tw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -67485,6 +67497,11 @@ "is-string": "^1.0.5" } }, + "array-move": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/array-move/-/array-move-2.2.2.tgz", + "integrity": "sha512-lKc6C+nsOSA1o7eHSP/HshlGDYUI7QKyaus5kPDm2zEEPQID9xlspnraLR8l+rDlqg9mGo8ziE7F8TEnF6D3Tw==" + }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", diff --git a/package.json b/package.json index 14e2a42101..5ddffd1540 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "@stripe/react-stripe-js": "^1.2.0", "@stripe/stripe-js": "^1.11.0", "@types/react-color": "^3.0.4", + "array-move": "^2.2.1", "aws-amplify": "^4.0.3", "chart.js": "^2.9.4", "classcat": "^4.0.2", diff --git a/src/components/App.tsx b/src/components/App.tsx index 71c193a537..32c5485dfe 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -54,6 +54,7 @@ import CloudIntroModal from './organisms/CloudIntroModal' import AppNavigator from './organisms/AppNavigator' import Toast from '../shared/components/organisms/Toast' import styled from '../shared/lib/styled' +import { useToast } from '../shared/lib/stores/toast' const LoadingText = styled.div` margin: 30px; @@ -69,7 +70,7 @@ const AppContainer = styled.div` ` const App = () => { - const { initialize, storageMap } = useDb() + const { initialize, storageMap, getUninitializedStorageData } = useDb() const { push, pathname } = useRouter() const [initialized, setInitialized] = useState(false) const { setGeneralStatus, generalStatus } = useGeneralStatus() @@ -82,6 +83,7 @@ const App = () => { const routeParams = useRouteParams() const { navigate: navigateToStorage } = useStorageRouter() const { messageBox } = useDialog() + const { pushMessage } = useToast() useEffectOnce(() => { const fetchDesktopGlobalDataOfCloud = async () => { @@ -208,8 +210,21 @@ const App = () => { } } setInitialized(true) + + // notify on failed initializations + const uninitializedStorageData = await getUninitializedStorageData() + if (uninitializedStorageData.length > 0) { + pushMessage({ + title: 'Error', + description: `Failed to initialize some storages, please check console for more info.`, + }) + } }) .catch((error) => { + pushMessage({ + title: 'Error', + description: `Failed to initialize some storages, please check console for more info.`, + }) console.error(error) }) }) diff --git a/src/components/organisms/FolderDetail.tsx b/src/components/organisms/FolderDetail.tsx index f713a6976e..0d77b26259 100644 --- a/src/components/organisms/FolderDetail.tsx +++ b/src/components/organisms/FolderDetail.tsx @@ -18,6 +18,7 @@ import { selectStyle, } from '../../shared/lib/styled/styleFunctions' import styled from '../../shared/lib/styled' +import { NOTE_ID_PREFIX } from '../../lib/db/consts' interface FolderDetailProps { storage: NoteStorage @@ -46,7 +47,11 @@ const FolderDetail = ({ storage, folderPathname }: FolderDetailProps) => { return [] } - return [...folder.noteIdSet] + return [ + ...(folder.orderedIds || []).filter((orderId) => + orderId.startsWith(NOTE_ID_PREFIX) + ), + ] .reduce((notes, noteId) => { const note = storage.noteMap[noteId] if (note != null && !note.trashed) { diff --git a/src/components/organisms/SidebarContainer.tsx b/src/components/organisms/SidebarContainer.tsx index 37d4ab8f4b..a02d84511d 100644 --- a/src/components/organisms/SidebarContainer.tsx +++ b/src/components/organisms/SidebarContainer.tsx @@ -30,13 +30,11 @@ import { useTranslation } from 'react-i18next' import { useSearchModal } from '../../lib/searchModal' import styled from '../../shared/lib/styled' import cc from 'classcat' +import { SidebarTreeSortingOrders } from '../../shared/lib/sidebar' import { useGeneralStatus } from '../../lib/generalStatus' import { AppUser } from '../../shared/lib/mappers/users' import { useLocalUI } from '../../lib/v2/hooks/local/useLocalUI' -import { - mapTree, - SidebarTreeSortingOrders, -} from '../../lib/v2/mappers/local/sidebarTree' +import { mapTree } from '../../lib/v2/mappers/local/sidebarTree' import { useLocalDB } from '../../lib/v2/hooks/local/useLocalDB' import { useLocalDnd } from '../../lib/v2/hooks/local/useLocalDnd' import { CollapsableType } from '../../lib/v2/stores/sidebarCollapse' @@ -293,14 +291,22 @@ const SidebarContainer = ({ ) const getFoldEvents = useCallback( - (type: CollapsableType, key: string) => { + (type: CollapsableType, key: string, reversed?: boolean) => { + if (reversed) { + return { + fold: () => unfoldItem(type, key), + unfold: () => foldItem(type, key), + toggle: () => toggleItem(type, key), + } + } + return { fold: () => foldItem(type, key), unfold: () => unfoldItem(type, key), toggle: () => toggleItem(type, key), } }, - [foldItem, unfoldItem, toggleItem] + [toggleItem, unfoldItem, foldItem] ) const tree = useMemo(() => { @@ -356,11 +362,13 @@ const SidebarContainer = ({ const sidebarHeaderControls: SidebarControls = useMemo(() => { return { 'View Options': (tree || []).map((category) => { + const key = (category.label || '').toLocaleLowerCase() + const hideKey = `hide-${key}` return { type: 'check', label: category.label, - checked: !sideBarOpenedLinksIdsSet.has(`hide-${category.label}`), - onClick: () => toggleItem('links', `hide-${category.label}`), + checked: !sideBarOpenedLinksIdsSet.has(hideKey), + onClick: () => toggleItem('links', hideKey), } }), Sorting: Object.values(SidebarTreeSortingOrders).map((sort) => { diff --git a/src/lib/db/FSNoteDb.ts b/src/lib/db/FSNoteDb.ts index 224f8cfb36..abeaefeaac 100644 --- a/src/lib/db/FSNoteDb.ts +++ b/src/lib/db/FSNoteDb.ts @@ -28,6 +28,8 @@ import { values, isSubPathname, keys, + getFolderPathname, + generateFolderId, } from './utils' import { escapeRegExp, generateId, getHexatrigesimalString } from '../string' import { @@ -38,6 +40,7 @@ import { writeFile, unlinkFile, } from '../electronOnly' +import { removeDuplicates } from '../../shared/lib/utils/array' interface StorageJSONData { folderMap: ObjectMap @@ -85,6 +88,18 @@ class FSNoteDb implements NoteDb { ), ...[...missingTagNameSet].map((tagName) => this.upsertTag(tagName)), ]) + + const allFolders = await this.getAllFolders() + let anyFolderDocUpdated = false + allFolders.forEach((folderDoc) => { + if (folderDoc._realId === undefined) { + folderDoc._realId = generateFolderId() + anyFolderDocUpdated = true + } + }) + if (anyFolderDocUpdated) { + await this.saveBoostNoteJSON() + } } async getFolder(pathname: string): Promise { @@ -106,14 +121,16 @@ class FSNoteDb implements NoteDb { async upsertFolder( pathname: string, - props?: Partial + props?: Partial, + oldRealId?: string, + skipParentFolderCreation?: boolean ): Promise { if (!isFolderPathnameValid(pathname)) { throw createUnprocessableEntityError( `pathname is invalid, got \`${pathname}\`` ) } - if (pathname !== '/') { + if (pathname !== '/' && skipParentFolderCreation === false) { await this.doesParentFolderExistOrCreate(pathname) } @@ -122,16 +139,22 @@ class FSNoteDb implements NoteDb { return folder } const now = getNow() + const newRealId = + folder != null && folder._realId != null ? folder._realId : oldRealId const newFolderDoc = { ...(folder || { _id: getFolderId(pathname), - createdAt: now, + createdAt: now, // todo: [komediruzecki-10/07/2021] FIXME: should be updated at when renaming folder! data: {}, }), + _realId: newRealId != null ? newRealId : generateFolderId(), ...props, + orderedIds: removeDuplicates([ + ...(props != null ? props.orderedIds || [] : []), + ...(folder != null ? folder.orderedIds || [] : []), + ]), updatedAt: now, } - this.data!.folderMap[pathname] = newFolderDoc await this.saveBoostNoteJSON() @@ -156,6 +179,19 @@ class FSNoteDb implements NoteDb { foldersToDelete.forEach((folderPathname) => { delete newFolderMap[folderPathname] }) + + // update parent folder ordered IDs + const parentFolder = await this.getFolder(getParentFolderPathname(pathname)) + const folderToDelete = await this.getFolder(pathname) + if (parentFolder != null && folderToDelete != null) { + const newParentOrderedIds = (parentFolder.orderedIds || []).filter( + (orderId) => orderId != folderToDelete._realId + ) + newFolderMap[getParentFolderPathname(pathname)] = { + ...parentFolder, + orderedIds: newParentOrderedIds, + } + } this.data!.folderMap = newFolderMap await this.saveBoostNoteJSON() } @@ -273,7 +309,9 @@ class FSNoteDb implements NoteDb { _rev: generateId(), } - await this.upsertFolder(noteDoc.folderPathname) + await this.upsertFolder(noteDoc.folderPathname, { + orderedIds: [noteDoc._id], + }) await Promise.all(noteDoc.tags.map((tagName) => this.upsertTag(tagName))) await writeFile( @@ -291,8 +329,11 @@ class FSNoteDb implements NoteDb { // TODO: If note doesn't exist, throw not found error if (noteProps.folderPathname) { - await this.upsertFolder(noteProps.folderPathname) + await this.upsertFolder(noteProps.folderPathname, { + orderedIds: [noteDoc._id], + }) } + if (noteProps.tags) { await Promise.all( noteProps.tags.map((tagName) => this.upsertTag(tagName)) @@ -487,6 +528,30 @@ class FSNoteDb implements NoteDb { await this.saveBoostNoteJSON() } + async updateFolderOrderedIds( + folderId: string, + orderedIds: string[] + ): Promise { + const folderPathname = getFolderPathname(folderId) + + const newFolderMap = this.data!.folderMap + const folder = newFolderMap[folderPathname] + if (folder == null) { + throw createUnprocessableEntityError( + `Folder resource not found, cannot update order ${folderId}` + ) + } + newFolderMap[folderPathname] = { + ...folder, + orderedIds: orderedIds, + } + + this.data!.folderMap = newFolderMap + await this.saveBoostNoteJSON() + + return newFolderMap[folderPathname] + } + async renameFolder(pathname: string, newPathname: string) { if (!isFolderPathnameValid(pathname)) { throw createUnprocessableEntityError( @@ -526,16 +591,80 @@ class FSNoteDb implements NoteDb { newPathname ) } - await Promise.all( - allFoldersToRename.map(async (folderPathname) => { - const newFolderPathname = replacePathname(folderPathname) - updatedFolderMap.set(newFolderPathname, { - ...(await this.upsertFolder(newFolderPathname)), - pathname: newFolderPathname, - noteIdSet: new Set(), - }) + + // we want this sequentially to avoid any parent folder creation even though flag is added to explicitly not + // create parent folders and they wont be created since creation order is from parent towards children + for (const folderPathname of allFoldersToRename) { + const newFolderPathname = replacePathname(folderPathname) + const oldFolderDoc = await this.getFolder(folderPathname) + const oldRealId = oldFolderDoc != null ? oldFolderDoc._realId : undefined + const newFolder = await this.upsertFolder( + newFolderPathname, + { + orderedIds: + oldFolderDoc != null ? oldFolderDoc.orderedIds : undefined, + }, + oldRealId, + true + ) + updatedFolderMap.set(newFolderPathname, { + ...newFolder, + pathname: newFolderPathname, }) - ) + } + + const folderLocationIsChanged = + getParentFolderPathname(pathname) !== getParentFolderPathname(newPathname) + if (folderLocationIsChanged) { + const previousParentFolder = await this.getFolder( + getParentFolderPathname(pathname) + ) + const newParentFolder = await this.getFolder( + getParentFolderPathname(newPathname) + ) + if (previousParentFolder != null && newParentFolder != null) { + const newPreviousParentOrderedIds = removeDuplicates( + (previousParentFolder.orderedIds || []).filter( + (orderId) => folder._realId != orderId + ) + ) + + const previousParentFolderPathname = getParentFolderPathname(pathname) + + // new parent folder update + const newParentOrderedIds = removeDuplicates([ + ...(newParentFolder.orderedIds || []), + folder._realId, + ]) + const newParentFolderPathname = getParentFolderPathname(newPathname) + + // save ordered IDs to database for those folders + const newPreviousFolderDoc = await this.updateFolderOrderedIds( + previousParentFolder._id, + newPreviousParentOrderedIds + ) + const newParentFolderDoc = await this.updateFolderOrderedIds( + newParentFolder._id, + newParentOrderedIds + ) + if (newPreviousFolderDoc != null && newParentFolderDoc != null) { + updatedFolderMap.set(previousParentFolderPathname, { + ...newPreviousFolderDoc, + pathname: previousParentFolderPathname, + orderedIds: newPreviousParentOrderedIds, + }) + updatedFolderMap.set(newParentFolderPathname, { + ...newParentFolderDoc, + pathname: newParentFolderPathname, + orderedIds: newParentOrderedIds, + }) + } + } else { + console.warn( + 'Cannot update folder IDs - please use other views rather than Drag and Drop one' + ) + } + } const allNotes = await this.loadAllNotes() for (const note of allNotes) { @@ -551,15 +680,6 @@ class FSNoteDb implements NoteDb { ...note, folderPathname: newFolderPathname, } - - const folderToUpdate = updatedFolderMap.get(newFolderPathname) - if (folderToUpdate != null && folderToUpdate.noteIdSet != null) { - folderToUpdate.noteIdSet.add(updatedNote._id) - } else { - console.warn( - `Folder not updated correctly ${folder}, for note: ${note}, on pathname: ${pathname}` - ) - } updatedNotes.push(updatedNote) } @@ -573,12 +693,21 @@ class FSNoteDb implements NoteDb { { ...this.data!.folderMap } ) updatedFolderMap.forEach((updatedFolderDoc) => { - const { _id, createdAt, updatedAt, data } = updatedFolderDoc + const { + _realId, + _id, + createdAt, + updatedAt, + data, + orderedIds, + } = updatedFolderDoc newFolderMap[updatedFolderDoc.pathname] = { + _realId, _id, createdAt, updatedAt, data, + orderedIds, } }) @@ -602,11 +731,13 @@ class FSNoteDb implements NoteDb { getAllFolderUnderPathname(pathname: string) { const allFolderPathnames = keys(this.data!.folderMap) - const pathnameRegexp = new RegExp(`^${escapeRegExp(pathname)}/`, 'g') + const pathnameRegexp = new RegExp( + `^${escapeRegExp(pathname + (pathname == '/' ? '' : '/'))}` + ) + const subFolderPathnames = allFolderPathnames.filter((pathname) => { return pathnameRegexp.test(pathname) }) - return [pathname, ...subFolderPathnames] } diff --git a/src/lib/db/PouchNoteDb.spec.ts b/src/lib/db/PouchNoteDb.spec.ts index 9602cf098b..8f28706405 100644 --- a/src/lib/db/PouchNoteDb.spec.ts +++ b/src/lib/db/PouchNoteDb.spec.ts @@ -1,6 +1,12 @@ import PouchNoteDb from './PouchNoteDb' import PouchDB from './PouchDB' -import { getFolderId, getTagId, generateNoteId, getNow } from './utils' +import { + getFolderId, + getTagId, + generateNoteId, + getNow, + generateFolderId, +} from './utils' import { sortNotesByKeys } from '../sort' import { NoteDoc, FolderDoc, ExceptRev } from './types' @@ -27,6 +33,7 @@ describe('PouchNoteDb', () => { const now = new Date().toISOString() await noteDb.pouchDb.put({ _id: getFolderId('/test'), + _realId: generateFolderId(), createdAt: now, updatedAt: now, data: {}, diff --git a/src/lib/db/PouchNoteDb.ts b/src/lib/db/PouchNoteDb.ts index 955d8b35f0..16f916525b 100644 --- a/src/lib/db/PouchNoteDb.ts +++ b/src/lib/db/PouchNoteDb.ts @@ -30,6 +30,7 @@ import { getTagName, values, isSubPathname, + generateFolderId, } from './utils' import { FOLDER_ID_PREFIX, ATTACHMENTS_ID } from './consts' import NoteDb from './NoteDb' @@ -103,6 +104,7 @@ export default class PouchNoteDb implements NoteDb { const now = getNow() const folderDocProps = { ...(folder || { + _realId: generateFolderId(), _id: getFolderId(pathname), createdAt: now, data: {}, @@ -113,6 +115,7 @@ export default class PouchNoteDb implements NoteDb { const { rev } = await this.pouchDb.put(folderDocProps) return { + _realId: folderDocProps._realId, _id: folderDocProps._id, createdAt: folderDocProps.createdAt, updatedAt: folderDocProps.updatedAt, @@ -121,6 +124,16 @@ export default class PouchNoteDb implements NoteDb { } } + async updateFolderOrderedIds( + folderId: string, + orderedIds: string[] + ): Promise { + console.warn( + 'Ordered IDs not supported in PouchDB' + folderId + ' ' + orderedIds + ) + return undefined + } + async renameFolder( pathname: string, newPathname: string @@ -178,10 +191,12 @@ export default class PouchNoteDb implements NoteDb { }) ) ) + // todo: [komediruzecki-14. 07. 2021.] See if we need to handle POUCH DB noteId set and other things + // or just remove completely and don't allow any actio in pouch DB storage updatedFolders.push({ ...newFolder, pathname: destinationPathname, - noteIdSet: new Set(rewrittenNotes.map((note) => note._id)), + // noteIdSet: new Set(rewrittenNotes.map((note) => note._id)), }) updatedNotes.push(...rewrittenNotes) } diff --git a/src/lib/db/createStore.ts b/src/lib/db/createStore.ts index 22814ccce2..469fc3d73f 100644 --- a/src/lib/db/createStore.ts +++ b/src/lib/db/createStore.ts @@ -1,39 +1,43 @@ import { - NoteStorage, - PouchNoteStorageData, - ObjectMap, + Attachment, + FolderDoc, + LiteStorageStorageItem, NoteDoc, NoteDocEditibleProps, NoteDocImportableProps, + NoteStorage, + NoteStorageData, + ObjectMap, PopulatedFolderDoc, PopulatedTagDoc, - Attachment, + PouchNoteStorageData, TagDoc, - NoteStorageData, TagDocEditibleProps, - FolderDoc, } from './types' -import { useState, useCallback } from 'react' +import { useCallback, useState } from 'react' import ow from 'ow' -import { schema, isValid } from '../predicates' +import { isValid, schema } from '../predicates' import PouchNoteDb from './PouchNoteDb' import { + entries, + getAllParentFolderPathnames, getFolderPathname, getParentFolderPathname, - getAllParentFolderPathnames, - entries, + isDirectSubPathname, + mapStorageToLiteStorageData, + values, } from './utils' import { generateId } from '../string' import PouchDB from './PouchDB' import { LiteStorage } from 'ltstrg' -import { produce, enableMapSet } from 'immer' +import { enableMapSet, produce } from 'immer' import { RouterStore } from '../router' -import { values } from './utils' import { storageDataListKey } from '../localStorageKeys' import { TAG_ID_PREFIX } from './consts' import { difference } from 'ramda' import { useRefState } from '../hooks' import FSNoteDb from './FSNoteDb' +import { removeDuplicates } from '../../shared/lib/utils/array' enableMapSet() @@ -45,6 +49,7 @@ export interface DbStore { name: string, props?: { type: 'fs'; location: string } ) => Promise + getUninitializedStorageData: () => Promise removeStorage: (id: string) => Promise renameStorage: (id: string, name: string) => void createFolder: ( @@ -56,6 +61,11 @@ export interface DbStore { pathname: string, newName: string ) => Promise + updateFolderOrderedIds: ( + storageId: string, + folderId: string, + newOrderedIds: string[] + ) => Promise removeFolder: (storageId: string, pathname: string) => Promise createNote( storageId: string, @@ -104,6 +114,9 @@ export function createDbStoreCreator( const router = routerHook() const currentPathnameWithoutNoteId = pathnameWithoutNoteIdGetter() const [initialized, setInitialized] = useState(false) + const [uninitializedStoragesData, setUninitializedStoragesData] = useState< + LiteStorageStorageItem[] + >([]) const [storageMap, storageMapRef, setStorageMap] = useRefState< ObjectMap >({}) @@ -130,14 +143,16 @@ export function createDbStoreCreator( const folder: PopulatedFolderDoc = storage.folderMap[noteDoc.folderPathname] == null ? ({ + // todo: [komediruzecki-11/07/2021] FIXME: Should be upsert folder? Probably never executed code ...(await storage.db.getFolder(noteDoc.folderPathname)!), pathname: noteDoc.folderPathname, - noteIdSet: new Set([noteDoc._id]), + orderedIds: [noteDoc._id], } as PopulatedFolderDoc) : { ...storage.folderMap[noteDoc.folderPathname]!, - noteIdSet: new Set([ - ...storage.folderMap[noteDoc.folderPathname]!.noteIdSet, + orderedIds: removeDuplicates([ + ...(storage.folderMap[noteDoc.folderPathname]!.orderedIds || + []), noteDoc._id, ]), } @@ -172,7 +187,6 @@ export function createDbStoreCreator( draft[storageId]!.folderMap[aPathname] = { ...folder, pathname: aPathname, - noteIdSet: new Set(), } }) draft[storageId]!.folderMap[noteDoc.folderPathname] = folder @@ -199,7 +213,7 @@ export function createDbStoreCreator( ...props, }) - let newStorageMap: ObjectMap + let newStorageMap: ObjectMap = {} setStorageMap((prevStorageMap) => { newStorageMap = produce(prevStorageMap, (draft) => { draft[id] = storage @@ -208,29 +222,48 @@ export function createDbStoreCreator( return newStorageMap }) - saveStorageDataList(liteStorage, newStorageMap!) + const localStorageStorageDataList: LiteStorageStorageItem[] = [ + ...values(newStorageMap).map(mapStorageToLiteStorageData), + ...uninitializedStoragesData, + ] + saveStorageDataList(liteStorage, localStorageStorageDataList) return storage }, - [setStorageMap] + [setStorageMap, uninitializedStoragesData] ) const initialize = useCallback(async () => { const storageDataList = getStorageDataListOrFix(liteStorage) + const storagesFailedAtInit: LiteStorageStorageItem[] = [] const prepared = await Promise.all( - storageDataList.map((storage) => prepareStorage(storage)) + storageDataList.map((storage) => + prepareStorage(storage).catch((err) => { + console.warn(`Skipping loading storage: '${storage.name}'!`) + console.warn('[ERROR]', err) + storagesFailedAtInit.push(mapStorageToLiteStorageData(storage)) + return null + }) + ) ) + + setUninitializedStoragesData(storagesFailedAtInit) const storageMap = prepared.reduce((map, storage) => { - map[storage.id] = storage + if (storage != null) { + map[storage.id] = storage + } return map }, {} as ObjectMap) - saveStorageDataList(liteStorage, storageMap) + const localStorageStorageDataList: LiteStorageStorageItem[] = [ + ...values(storageMap).map(mapStorageToLiteStorageData), + ...storagesFailedAtInit, + ] + saveStorageDataList(liteStorage, localStorageStorageDataList) setStorageMap(storageMap) setInitialized(true) - return storageMap - }, [setStorageMap]) + }, [setStorageMap, setUninitializedStoragesData]) const removeStorage = useCallback( async (id: string) => { @@ -243,7 +276,7 @@ export function createDbStoreCreator( await storage.db.pouchDb.destroy() } - let newStorageMap: ObjectMap + let newStorageMap: ObjectMap = {} setStorageMap((prevStorageMap) => { newStorageMap = produce(prevStorageMap, (draft) => { delete draft[id] @@ -252,11 +285,15 @@ export function createDbStoreCreator( return newStorageMap }) - saveStorageDataList(liteStorage, newStorageMap!) + const localStorageStorageDataList: LiteStorageStorageItem[] = [ + ...values(newStorageMap).map(mapStorageToLiteStorageData), + ...uninitializedStoragesData, + ] + saveStorageDataList(liteStorage, localStorageStorageDataList) }, // FIXME: The callback regenerates every storageMap change. // We should move the method to NoteStorage so the method instantiate only once. - [setStorageMap, storageMap] + [setStorageMap, storageMap, uninitializedStoragesData] ) const renameStorage = useCallback( @@ -273,9 +310,14 @@ export function createDbStoreCreator( }) return newStorageMap }) - saveStorageDataList(liteStorage, newStorageMap) + + const localStorageStorageDataList: LiteStorageStorageItem[] = [ + ...values(newStorageMap).map(mapStorageToLiteStorageData), + ...uninitializedStoragesData, + ] + saveStorageDataList(liteStorage, localStorageStorageDataList) }, - [setStorageMap, storageMap] + [setStorageMap, storageMap, uninitializedStoragesData] ) const createFolder = useCallback( @@ -295,17 +337,44 @@ export function createDbStoreCreator( getAllParentFolderPathnames(pathname) ) const createdFolders = [folder, ...parentFolders].reverse() + const createdFoldersWithOrderedIds: FolderDoc[] = [] + + for (const aFolder of createdFolders) { + const aPathname = getFolderPathname(aFolder._id) + if (storage.folderMap[aPathname] != null) { + continue + } + const parentFolder = + storage.folderMap[getParentFolderPathname(aPathname)] + if (parentFolder == null) { + continue + } + const previousOrderedIds = parentFolder.orderedIds || [] + const newOrderedIds = removeDuplicates([ + ...previousOrderedIds, + aFolder._realId, + ]) + const updatedFolder = await storage.db.upsertFolder( + parentFolder.pathname, + { + orderedIds: newOrderedIds, + } + ) + if (updatedFolder != null) { + createdFoldersWithOrderedIds.push(updatedFolder) + } + } + + // add last one which isn't updated - no ordered IDs to update + createdFoldersWithOrderedIds.push(folder) setStorageMap( produce((draft: ObjectMap) => { - createdFolders.forEach((aFolder) => { + createdFoldersWithOrderedIds.forEach((aFolder) => { const aPathname = getFolderPathname(aFolder._id) - if (storage.folderMap[aPathname] == null) { - draft[storageId]!.folderMap[aPathname] = { - ...aFolder, - pathname: aPathname, - noteIdSet: new Set(), - } + draft[storageId]!.folderMap[aPathname] = { + ...aFolder, + pathname: aPathname, } }) }) @@ -344,6 +413,38 @@ export function createDbStoreCreator( [storageMap, setStorageMap] ) + const updateFolderOrderedIds = useCallback( + async (workspaceId: string, resourceId: string, orderedIds: string[]) => { + const storage = storageMap[workspaceId] + if (storage == null) { + return + } + const folderPathname = getFolderPathname(resourceId) + const populatedFolderDoc = storage.folderMap[folderPathname] + if (populatedFolderDoc == null) { + throw new Error('Missing folder to update: ' + resourceId) + } + const folderDoc = await storage.db.updateFolderOrderedIds( + resourceId, + orderedIds + ) + + if (folderDoc != null) { + setStorageMap( + produce((draft: ObjectMap) => { + draft[storage.id]!.folderMap[populatedFolderDoc.pathname] = { + pathname: populatedFolderDoc.pathname, + ...folderDoc, + } + }) + ) + } + + return folderDoc + }, + [storageMap, setStorageMap] + ) + const removeFolder = useCallback( async (storageId: string, pathname: string) => { const storage = storageMap[storageId] @@ -454,16 +555,23 @@ export function createDbStoreCreator( const previousFolder = storage.folderMap[previousNoteDoc.folderPathname] if (previousFolder != null) { - const newNoteIdSetForPreviousFolder = new Set( - previousFolder.noteIdSet + const previousOrderedIds = previousFolder.orderedIds || [] + const newOrderedIdsForParent = removeDuplicates( + previousOrderedIds.filter((orderId) => noteDoc._id != orderId) + ) + const updatedPreviousFolder = await storage.db.updateFolderOrderedIds( + previousFolder._id, + newOrderedIdsForParent ) - newNoteIdSetForPreviousFolder.delete(noteId) folderListToRefresh.push({ ...previousFolder, - noteIdSet: newNoteIdSetForPreviousFolder, + ...updatedPreviousFolder, }) } } + + // todo: [komediruzecki-14/07/2021] this parent folders update should not happen - but if someone calls update note with + // folders not existing, we should update its ordered IDs as well - not done currently (none should call it like this) const parentFolderPathnamesToCheck = [ ...getAllParentFolderPathnames(noteDoc.folderPathname), ].filter((aPathname) => storage.folderMap[aPathname] == null) @@ -482,17 +590,20 @@ export function createDbStoreCreator( const folder: PopulatedFolderDoc = storage.folderMap[noteDoc.folderPathname] == null ? ({ + // todo: [komediruzecki-11/07/2021] Should be upsert folder? Not executed? ...(await storage.db.getFolder(noteDoc.folderPathname)!), pathname: noteDoc.folderPathname, - noteIdSet: new Set([noteDoc._id]), + orderedIds: [noteDoc._id], } as PopulatedFolderDoc) : { ...storage.folderMap[noteDoc.folderPathname]!, - noteIdSet: new Set([ - ...storage.folderMap[noteDoc.folderPathname]!.noteIdSet, + orderedIds: removeDuplicates([ + ...(storage.folderMap[noteDoc.folderPathname]!.orderedIds || + []), noteDoc._id, ]), } + folderListToRefresh.push(folder) const removedTags: ObjectMap = difference( @@ -599,13 +710,8 @@ export function createDbStoreCreator( let modifiedFolderInOriginalStorage = originalStorage.folderMap[originalNote.folderPathname] if (modifiedFolderInOriginalStorage != null) { - const newNoteIdSet = new Set( - modifiedFolderInOriginalStorage.noteIdSet - ) - newNoteIdSet.delete(originalNote._id) modifiedFolderInOriginalStorage = { ...modifiedFolderInOriginalStorage, - noteIdSet: newNoteIdSet, } } @@ -618,13 +724,8 @@ export function createDbStoreCreator( pathname: targetFolderPathname, } : targetStorage.folderMap[targetFolderPathname]! - const newNoteIdSetForTargetFolder = new Set([ - ...targetFolder.noteIdSet, - newNote._id, - ]) modifiedFoldersInTargetStorage.push({ ...targetFolder, - noteIdSet: newNoteIdSetForTargetFolder, }) const parentFolderPathnamesToCheck = [ @@ -702,14 +803,21 @@ export function createDbStoreCreator( } let folder: PopulatedFolderDoc | undefined - if (storage.folderMap[noteDoc.folderPathname] != null) { - const newFolderNoteIdSet = new Set( - storage.folderMap[noteDoc.folderPathname]!.noteIdSet + const parentFolder = storage.folderMap[noteDoc.folderPathname] + if (parentFolder != null) { + const previousOrderedIds = parentFolder.orderedIds || [] + const newOrderedIdsForParent = removeDuplicates( + previousOrderedIds.filter((orderId) => noteDoc._id != orderId) + ) + // update folder ordered IDs in database + const updatedFolder = await storage.db.updateFolderOrderedIds( + parentFolder._id, + newOrderedIdsForParent ) - newFolderNoteIdSet.delete(noteDoc._id) folder = { - ...storage.folderMap[noteDoc.folderPathname]!, - noteIdSet: newFolderNoteIdSet, + ...(updatedFolder || storage.folderMap[noteDoc.folderPathname]!), + pathname: noteDoc.folderPathname, + orderedIds: newOrderedIdsForParent, } } @@ -761,28 +869,61 @@ export function createDbStoreCreator( const folder: PopulatedFolderDoc = storage.folderMap[noteDoc.folderPathname] == null ? ({ - ...(await storage.db.getFolder(noteDoc.folderPathname)!), + ...(await storage.db.upsertFolder(noteDoc.folderPathname)), pathname: noteDoc.folderPathname, - noteIdSet: new Set([noteDoc._id]), + orderedIds: [noteDoc._id], } as PopulatedFolderDoc) : { ...storage.folderMap[noteDoc.folderPathname]!, - noteIdSet: new Set([ - ...storage.folderMap[noteDoc.folderPathname]!.noteIdSet, + orderedIds: removeDuplicates([ + ...(storage.folderMap[noteDoc.folderPathname]!.orderedIds || + []), noteDoc._id, ]), } const parentFolderPathnames = getAllParentFolderPathnames( noteDoc.folderPathname ) - const missingFolders: PopulatedFolderDoc[] = [] + const foldersToUpdate: PopulatedFolderDoc[] = [] + const foldersToUpdateParentOrderedIds: PopulatedFolderDoc[] = [folder] for (const parentFolderPathname of parentFolderPathnames) { if (storage.folderMap[parentFolderPathname] == null) { - missingFolders.push({ - ...(await storage.db.getFolder(parentFolderPathname)), + const missingFolder = await storage.db.upsertFolder( + parentFolderPathname + ) + + const missingFolderPopulatedDoc = { + ...missingFolder, pathname: parentFolderPathname, noteIdSet: new Set(), - } as PopulatedFolderDoc) + } as PopulatedFolderDoc + foldersToUpdate.push(missingFolderPopulatedDoc) + foldersToUpdateParentOrderedIds.push(missingFolderPopulatedDoc) + } + } + + for (const folder of foldersToUpdateParentOrderedIds) { + const parentFolder = await storage.db.getFolder( + getParentFolderPathname(folder.pathname) + ) + if (parentFolder == null) { + continue + } + const previousOrderedIds = parentFolder.orderedIds || [] + const newOrderedIds = removeDuplicates([ + ...previousOrderedIds, + folder._realId, + ]) + + const updatedParentFolder = await storage.db.updateFolderOrderedIds( + parentFolder._id, + newOrderedIds + ) + if (updatedParentFolder != null) { + foldersToUpdate.push({ + ...updatedParentFolder, + pathname: getFolderPathname(parentFolder._id), + }) } } @@ -812,15 +953,16 @@ export function createDbStoreCreator( produce((draft: ObjectMap) => { draft[storageId]!.noteMap[noteDoc._id] = noteDoc draft[storageId]!.folderMap[noteDoc.folderPathname] = folder - missingFolders.forEach((missingFolder) => { + foldersToUpdate.forEach((folderToUpdate) => { draft[storageId]!.folderMap[ - missingFolder.pathname - ] = missingFolder + folderToUpdate.pathname + ] = folderToUpdate }) draft[storageId]!.tagMap = { ...storage.tagMap, ...modifiedTags, } + if (noteDoc.data.bookmarked) { const bookmarkedItemIdSet = new Set(storage.bookmarkedItemIds) bookmarkedItemIdSet.add(noteDoc._id) @@ -834,6 +976,7 @@ export function createDbStoreCreator( [storageMap, setStorageMap] ) + // unused function - but should be fixed for ordered IDs if ever used! const purgeNote = useCallback( async (storageId: string, noteId: string) => { const storage = storageMap[storageId] @@ -854,12 +997,8 @@ export function createDbStoreCreator( delete noteMap[noteId] draft[storageId]!.noteMap = noteMap - const folder = storage.folderMap[note.folderPathname] - if (folder != null) { - const newFolderNoteIdSet = new Set(folder.noteIdSet) - newFolderNoteIdSet.delete(note._id) - folder.noteIdSet = newFolderNoteIdSet - } + // todo: [komediruzecki-14/07/2021] On note purge remove ordered ID from parent folder + // here note ID set was updated - do we need ordered IDs update note.tags.forEach((tagName) => { const tag = storage.tagMap[tagName] @@ -1107,6 +1246,10 @@ export function createDbStoreCreator( [setStorageMap, storageMapRef] ) + const getUninitializedStorageData = useCallback(async () => { + return uninitializedStoragesData + }, [uninitializedStoragesData]) + return { initialized, storageMap, @@ -1116,6 +1259,7 @@ export function createDbStoreCreator( renameStorage, createFolder, renameFolder, + updateFolderOrderedIds, removeFolder, createNote, updateNote, @@ -1130,6 +1274,7 @@ export function createDbStoreCreator( removeAttachment, bookmarkNote, unbookmarkNote, + getUninitializedStorageData, } } } @@ -1175,29 +1320,9 @@ function getStorageDataListOrFix(liteStorage: LiteStorage): NoteStorageData[] { function saveStorageDataList( liteStorage: LiteStorage, - storageMap: ObjectMap + storageData: LiteStorageStorageItem[] ) { - liteStorage.setItem( - storageDataListKey, - JSON.stringify( - values(storageMap).map((storage) => { - const { id, name } = storage - if (storage.type === 'fs') { - return { - id, - name, - type: 'fs', - location: storage.location, - } - } - return { - id, - name, - cloudStorage: storage.cloudStorage, - } - }) - ) - ) + liteStorage.setItem(storageDataListKey, JSON.stringify(storageData)) } async function prepareStorage( @@ -1216,17 +1341,22 @@ async function prepareStorage( ) await db.init() + const foldersToUpdateOrderedIds: string[] = [] + const { noteMap, folderMap, tagMap } = await db.getAllDocsMap() const attachmentMap = await db.getAttachmentMap() const populatedFolderMap = entries(folderMap).reduce< ObjectMap >((map, [pathname, folderDoc]) => { + if (folderDoc.orderedIds == null && storageData.type == 'fs') { + folderDoc.orderedIds = [] + foldersToUpdateOrderedIds.push(pathname) + } + map[pathname] = { ...folderDoc, pathname, - noteIdSet: new Set(), } - return map }, {}) const populatedTagMap = entries(tagMap).reduce>( @@ -1242,11 +1372,16 @@ async function prepareStorage( ) const bookmarkedIdSet = new Set() + const folderNoteIds = new Map>() for (const noteDoc of values(noteMap)) { if (noteDoc.trashed) { continue } - populatedFolderMap[noteDoc.folderPathname]!.noteIdSet.add(noteDoc._id) + if (!folderNoteIds.has(noteDoc.folderPathname)) { + folderNoteIds.set(noteDoc.folderPathname, new Set([noteDoc._id])) + } else { + folderNoteIds.get(noteDoc.folderPathname)!.add(noteDoc._id) + } noteDoc.tags.forEach((tag) => { populatedTagMap[tag]!.noteIdSet.add(noteDoc._id) }) @@ -1257,6 +1392,41 @@ async function prepareStorage( const bookmarkedItemIds = [...bookmarkedIdSet] if (storageData.type === 'fs') { + // update folder ordered Ids if needed + foldersToUpdateOrderedIds.forEach((parentFolderPathname) => { + const subFoldersPathnames: string[] = db.getAllFolderUnderPathname( + parentFolderPathname + ) as string[] + + subFoldersPathnames + .slice(1) + .filter((subFolderPathname) => + isDirectSubPathname(parentFolderPathname, subFolderPathname) + ) + .forEach((subFolderPathname: string) => { + const subFolderDoc = populatedFolderMap[subFolderPathname] + if (subFolderDoc != null) { + populatedFolderMap[parentFolderPathname]!.orderedIds!.push( + subFolderDoc._realId + ) + } + }) + const noteIDs = folderNoteIds.get(parentFolderPathname) + if (noteIDs != null) { + populatedFolderMap[parentFolderPathname]?.orderedIds!.push(...noteIDs) + } + + // remove any duplicates even though there should not be any! + populatedFolderMap[parentFolderPathname]!.orderedIds! = removeDuplicates( + populatedFolderMap[parentFolderPathname]!.orderedIds! + ) + }) + foldersToUpdateOrderedIds.forEach((folderPathname) => { + const folderDoc = populatedFolderMap[folderPathname] + if (folderDoc != null && folderDoc.orderedIds != null) { + db.updateFolderOrderedIds(folderDoc._id, folderDoc.orderedIds) + } + }) return { type: 'fs', id, diff --git a/src/lib/db/patterns.ts b/src/lib/db/patterns.ts index aff072b3a7..cb9d09210b 100644 --- a/src/lib/db/patterns.ts +++ b/src/lib/db/patterns.ts @@ -1,5 +1,28 @@ import { NavResource } from '../v2/interfaces/resources' +import { getFolderPathname, getParentFolderPathname } from './utils' +import { DraggedTo, SidebarDragState } from '../../shared/lib/dnd' export function getResourceId(source: NavResource) { - return source.result._id + if (source.type == 'folder') { + return source.result._realId + } else { + return source.result._id + } +} + +export function getResourceParentPathname( + source: NavResource, + targetedPosition?: SidebarDragState +) { + if (source.type == 'doc') { + return source.result.folderPathname + } else { + if (targetedPosition == DraggedTo.insideFolder) { + return getFolderPathname(source.result._id) + } else if (targetedPosition == DraggedTo.beforeItem) { + return getParentFolderPathname(getFolderPathname(source.result._id)) + } else { + return getParentFolderPathname(getFolderPathname(source.result._id)) + } + } } diff --git a/src/lib/db/store.spec.ts b/src/lib/db/store.spec.ts index b54891a64c..a2ef8c6114 100644 --- a/src/lib/db/store.spec.ts +++ b/src/lib/db/store.spec.ts @@ -458,7 +458,7 @@ describe('DbStore', () => { expect( result.current.storageMap[storage!.id]!.folderMap[ noteDoc!.folderPathname - ]!.noteIdSet.has(noteDoc!._id) + ]!.orderedIds!.indexOf(noteDoc!._id) == -1 ).toEqual(false) }) }) @@ -535,8 +535,8 @@ describe('DbStore', () => { expect( result.current.storageMap[storage!.id]!.folderMap[ noteDoc!.folderPathname - ]!.noteIdSet.has(noteDoc!._id) - ).toEqual(false) + ]!.orderedIds!.indexOf(noteDoc!._id) + ).toEqual(-1) }) }) @@ -589,7 +589,7 @@ describe('DbStore', () => { expect( result.current.storageMap[storage!.id]!.folderMap[ noteDoc!.folderPathname - ]!.noteIdSet.has(noteDoc!._id) + ]!.orderedIds!.indexOf(noteDoc!._id) != -1 ).toEqual(true) }) }) diff --git a/src/lib/db/types.ts b/src/lib/db/types.ts index 6a18430df4..d1aeb1710a 100644 --- a/src/lib/db/types.ts +++ b/src/lib/db/types.ts @@ -59,12 +59,14 @@ export type NoteDoc = { export type FolderDoc = { _id: string // folder:${FOLDER_PATHNAME} + _realId: string // identification for reordering (unique across any property change) createdAt: string updatedAt: string _rev?: string } & FolderDocEditibleProps export type FolderDocEditibleProps = { + orderedIds?: string[] data: JsonObject } @@ -116,7 +118,6 @@ export type FSNoteStorage = FSNoteStorageData & export type NoteStorage = PouchNoteStorage | FSNoteStorage export type PopulatedFolderDoc = FolderDoc & { pathname: string - noteIdSet: NoteIdSet } export type PopulatedTagDoc = TagDoc & { @@ -131,3 +132,11 @@ export interface AllPopulatedDocsMap { attachmentMap: ObjectMap bookmarkedItemIds: string[] } + +export interface LiteStorageStorageItem { + id?: string + name?: string + type?: 'fs' + location?: string + cloudStorage?: CloudNoteStorageData +} diff --git a/src/lib/db/utils.ts b/src/lib/db/utils.ts index 724f323206..3f2923a4a8 100644 --- a/src/lib/db/utils.ts +++ b/src/lib/db/utils.ts @@ -8,6 +8,8 @@ import { PouchNoteStorageData, NoteStorage, PopulatedTagDoc, + NoteStorageData, + LiteStorageStorageItem, } from './types' import { generateId, escapeRegExp } from '../string' @@ -31,6 +33,10 @@ export function generateNoteId(): string { return `${NOTE_ID_PREFIX}${generateId()}` } +export function generateFolderId(): string { + return `${FOLDER_ID_PREFIX}${generateId()}` +} + export function excludeNoteIdPrefix(noteId: string): string { return noteId.replace(new RegExp(`^${NOTE_ID_PREFIX}`), '') } @@ -39,6 +45,13 @@ export function excludeFileProtocol(src: string) { return src.replace('file://', '') } +export function prependFolderIdPrefix(folderPathname: string): string { + if (new RegExp(`^${FOLDER_ID_PREFIX}`).test(folderPathname)) { + return folderPathname + } + return `${FOLDER_ID_PREFIX}${folderPathname}` +} + export function getWorkspaceHref(storage: NoteStorage, query?: any): string { return `/app/storages/${storage.id}?${query}` } @@ -242,3 +255,22 @@ export function isCloudStorageData( export function normalizeTagColor(tag: PopulatedTagDoc): string { return typeof tag.data.color == 'string' ? tag.data.color : '' } + +export function mapStorageToLiteStorageData( + storage: NoteStorage | NoteStorageData +): LiteStorageStorageItem { + const { id, name } = storage + if (storage.type === 'fs') { + return { + id, + name, + type: 'fs', + location: storage.location, + } + } + return { + id, + name, + cloudStorage: storage.cloudStorage, + } +} diff --git a/src/lib/v2/hooks/local/useLocalDB.ts b/src/lib/v2/hooks/local/useLocalDB.ts index d6be55878c..a3e10583a0 100644 --- a/src/lib/v2/hooks/local/useLocalDB.ts +++ b/src/lib/v2/hooks/local/useLocalDB.ts @@ -26,6 +26,7 @@ export function useLocalDB() { unbookmarkNote, updateNote, renameFolder, + updateFolderOrderedIds, storageMap: workspaceMap, } = useDb() const { push } = useRouter() @@ -145,7 +146,7 @@ export function useLocalDB() { await send(workspace.id, 'delete', { api: () => removeStorage(workspace.id), cb: () => { - // maybe push message to notify successfully update + // maybe push message to notify successful update }, }) }, @@ -157,7 +158,7 @@ export function useLocalDB() { await send(target.workspaceId, 'delete', { api: () => removeFolder(target.workspaceId, target.pathname), cb: () => { - // maybe push message to notify successfully update + // maybe push message to notify successful update }, }) }, @@ -169,36 +170,45 @@ export function useLocalDB() { return send(target.workspaceId, 'delete', { api: () => deleteNote(target.workspaceId, target.docId), cb: () => { - // maybe push message to notify successfully update + // maybe push message to notify successful update }, }) }, [send, deleteNote] ) - const updateFolderApi = useCallback( + const renameFolderApi = useCallback( async (target: FolderDoc, body: UpdateFolderRequestBody) => { await send(target._id, 'update', { api: () => - // generic update not available, rename instead renameFolder(body.workspaceId, body.oldPathname, body.newPathname), cb: () => { - // maybe push message to notify successfully update + // maybe push message to notify successful update }, }) }, [send, renameFolder] ) + const updateFolderOrderedIdsApi = useCallback( + async (resourceId, body: UpdateFolderOrderedIdsRequestBody) => { + await send(resourceId, 'update', { + api: () => + updateFolderOrderedIds(body.workspaceId, resourceId, body.orderedIds), + cb: () => { + // maybe push message to notify successful update + }, + }) + }, + [send, updateFolderOrderedIds] + ) + const updateDocApi = useCallback( async (docId: string, body: UpdateDocRequestBody) => { await send(docId, 'update', { api: () => updateNote(body.workspaceId, docId, body.docProps), cb: () => { - // if (pageDoc != null && doc.id === pageDoc.id) { - // setPartialPageData({ pageDoc: doc }) - // setCurrentPath(doc.folderPathname) - // } + // maybe push message to notify successful update }, }) }, @@ -217,7 +227,8 @@ export function useLocalDB() { deleteFolderApi, deleteDocApi, updateDocApi, - updateFolder: updateFolderApi, + renameFolderApi, + updateFolderOrderedIdsApi, workspaceMap, } } @@ -244,6 +255,11 @@ export interface UpdateFolderRequestBody { newPathname: string } +export interface UpdateFolderOrderedIdsRequestBody { + workspaceId: string + orderedIds: string[] +} + export interface UpdateDocRequestBody { workspaceId: string docProps: Partial diff --git a/src/lib/v2/hooks/local/useLocalDnd.ts b/src/lib/v2/hooks/local/useLocalDnd.ts index 3df42bdc6b..915d48c9a3 100644 --- a/src/lib/v2/hooks/local/useLocalDnd.ts +++ b/src/lib/v2/hooks/local/useLocalDnd.ts @@ -1,63 +1,184 @@ import { useCallback, useRef } from 'react' import { NavResource } from '../../interfaces/resources' import { useToast } from '../../../../shared/lib/stores/toast' -import { FolderDoc } from '../../../db/types' -import { - UpdateFolderRequestBody, - UpdateDocRequestBody, - useLocalDB, -} from './useLocalDB' +import { NoteStorage } from '../../../db/types' +import { useLocalDB } from './useLocalDB' import { getFolderName, getFolderPathname } from '../../../db/utils' -import { DraggedTo, SidebarDragState } from '../../../../shared/lib/dnd' -import { getResourceId } from '../../../db/patterns' +import { SidebarDragState } from '../../../../shared/lib/dnd' +import { getResourceId, getResourceParentPathname } from '../../../db/patterns' import { join } from 'path' +import arrayMove from 'array-move' export function useLocalDnd() { const draggedResource = useRef() - const { pushApiErrorMessage, pushMessage } = useToast() - const { updateDocApi, updateFolder } = useLocalDB() + const { pushApiErrorMessage } = useToast() + const { + updateDocApi, + renameFolderApi, + updateFolderOrderedIdsApi, + } = useLocalDB() - const dropInWorkspace = useCallback( + const moveInSameFolder = useCallback( async ( workspaceId: string, - updateFolder: (folder: FolderDoc, body: UpdateFolderRequestBody) => void, - updateDoc: (docId: string, body: UpdateDocRequestBody) => void + sourceIndex: number, + targetedIndex: number, + orderedIds: string[], + resourceId: string ) => { - if (draggedResource.current == null) { - return - } + const rawOrderedIds = [...orderedIds] + arrayMove.mutate(rawOrderedIds, sourceIndex, targetedIndex) + await updateFolderOrderedIdsApi(resourceId, { + workspaceId: workspaceId, + orderedIds: rawOrderedIds, + }) + }, + [updateFolderOrderedIdsApi] + ) - if (draggedResource.current.result._id === workspaceId) { - pushMessage({ - title: 'Oops', - description: 'Resource is already present in this space', - }) - return + const isMoveInsideSameFolder = useCallback( + ({ + workspace, + targetedResource, + draggedResource, + targetedPosition, + }: { + workspace: NoteStorage + targetedResource: NavResource + draggedResource: NavResource + targetedPosition: SidebarDragState + }) => { + const targetParentFolder = + workspace.folderMap[ + getResourceParentPathname(targetedResource, targetedPosition) + ] + const sourceParentFolder = + workspace.folderMap[ + getResourceParentPathname(draggedResource, targetedPosition) + ] + const originalPath = + sourceParentFolder != null ? sourceParentFolder.pathname : '/' + const targetedPath = + targetParentFolder != null ? targetParentFolder.pathname : '/' + return targetedPath === originalPath + }, + [] + ) + + const updateFolderOrDocOrder = useCallback( + async ({ + workspace, + targetedResource, + draggedResource, + targetedPosition, + }: { + workspace: NoteStorage + targetedResource: NavResource + draggedResource: NavResource + targetedPosition: SidebarDragState + }) => { + const originalResourceId = getResourceId(draggedResource) + const targetParentFolder = + workspace.folderMap[ + getResourceParentPathname(targetedResource, targetedPosition) + ] + // note on note (reorder them) + const targetedPath = + targetParentFolder != null ? targetParentFolder.pathname : '/' + const isInterFolderMove = isMoveInsideSameFolder({ + workspace, + targetedResource, + draggedResource, + targetedPosition, + }) + const orderedIds: string[] | undefined = + targetParentFolder != null + ? targetParentFolder.orderedIds || [] + : undefined + if (orderedIds == null) { + console.warn( + 'Error during drag and drop, ordered IDs not initialized', + orderedIds + ) + throw new Error('The drag and drop transfer data is incorrect') } - if (draggedResource.current.type === 'folder') { - const folder = draggedResource.current.result - updateFolder(folder, { - workspaceId: workspaceId, - oldPathname: getFolderPathname(folder._id), - newPathname: '/' + getFolderName(folder), - }) - } else if (draggedResource.current.type === 'doc') { - const doc = draggedResource.current.result - updateDoc(doc._id, { - workspaceId: workspaceId, + let sourceOriginalIndex = -1 + let targetOriginalIndex = -1 + let targetedPositionIndex = -1 + if (isInterFolderMove) { + // check indexes + sourceOriginalIndex = orderedIds.indexOf(originalResourceId) + targetOriginalIndex = orderedIds.indexOf( + getResourceId(targetedResource) + ) + } else { + // outer folder move - for now just add it to destination folder (DB updates ordered IDs + await updateDocApi(originalResourceId, { + workspaceId: workspace.id, docProps: { - folderPathname: '/', + folderPathname: targetedPath, }, }) + return + } + + if (sourceOriginalIndex === -1 || targetOriginalIndex === -1) { + // do nothing... + console.warn( + '[ERROR] - Doing nothing on move, source and targets were invalid', + sourceOriginalIndex, + targetOriginalIndex + ) + return + } + + const isMoveAfter = sourceOriginalIndex < targetOriginalIndex + + switch (targetedPosition) { + case 0: + throw new Error('The drag and drop transfer data is incorrect') + case 1: + // move after + targetedPositionIndex = isMoveAfter + ? targetOriginalIndex + : targetOriginalIndex + 1 + break + case -1: + // move before + targetedPositionIndex = isMoveAfter + ? targetOriginalIndex - 1 + : targetOriginalIndex + break + } + + /* move onto itself, do nothing */ + if (isInterFolderMove && targetedPositionIndex === sourceOriginalIndex) { + // do nothing + console.log( + '[ERROR] - target and source indexes are the same, ignoring Drag And Drop update' + ) + return + } else { + const updateResourceId = + targetParentFolder != null ? targetParentFolder._id : workspace.id + if (isInterFolderMove) { + await moveInSameFolder( + workspace.id, + sourceOriginalIndex, + targetedPositionIndex, + orderedIds, + updateResourceId + ) + } } }, - [pushMessage] + [isMoveInsideSameFolder, moveInSameFolder, updateDocApi] ) const dropInDocOrFolder = useCallback( async ( - workspaceId: string, + workspace: NoteStorage, targetedResource: NavResource, targetedPosition: SidebarDragState ) => { @@ -75,41 +196,65 @@ export function useLocalDnd() { try { const originalResourceId = getResourceId(draggedResource.current) if (draggedResource.current.type == 'doc') { - if (targetedResource.type == 'folder') { - // move doc to target item (folder) at position (before, in, after) - if (targetedPosition == DraggedTo.insideFolder) { - await updateDocApi(originalResourceId, { - workspaceId: workspaceId, - docProps: { - folderPathname: getFolderPathname( - targetedResource.result._id - ), - }, - }) - } + const isInterFolderMove = isMoveInsideSameFolder({ + workspace, + targetedResource, + draggedResource: draggedResource.current, + targetedPosition, + }) + if (isInterFolderMove) { + await updateFolderOrDocOrder({ + workspace, + targetedResource, + draggedResource: draggedResource.current, + targetedPosition, + }) + } else { + const targetFolderPathname = getResourceParentPathname( + targetedResource, + targetedPosition + ) + + await updateDocApi(originalResourceId, { + workspaceId: workspace.id, + docProps: { + folderPathname: targetFolderPathname, + }, + }) } } else { // move folder - if (targetedResource.type == 'folder') { + const isInterFolderMove = isMoveInsideSameFolder({ + workspace, + targetedResource, + draggedResource: draggedResource.current, + targetedPosition, + }) + if (isInterFolderMove) { + await updateFolderOrDocOrder({ + workspace, + targetedResource, + draggedResource: draggedResource.current, + targetedPosition, + }) + } else { // move folder inside target folder - if (targetedPosition == DraggedTo.insideFolder) { - const folderResource = draggedResource.current?.result - const folderOriginalPathname = getFolderPathname( - folderResource._id - ) - const targetFolderPathname = getFolderPathname( - targetedResource.result._id - ) - const newFolderPathname = join( - targetFolderPathname, - getFolderName(folderResource) - ) - await updateFolder(draggedResource.current?.result, { - workspaceId: workspaceId, - oldPathname: folderOriginalPathname, - newPathname: newFolderPathname, - }) - } + const folderResource = draggedResource.current?.result + const folderOriginalPathname = getFolderPathname(folderResource._id) + const targetFolderPathname = getResourceParentPathname( + targetedResource, + targetedPosition + ) + const newFolderPathname = join( + targetFolderPathname, + getFolderName(folderResource) + ) + // database updates ordered IDs on folder renames + await renameFolderApi(draggedResource.current?.result, { + workspaceId: workspace.id, + oldPathname: folderOriginalPathname, + newPathname: newFolderPathname, + }) } } } catch (error) { @@ -117,12 +262,17 @@ export function useLocalDnd() { pushApiErrorMessage(error) } }, - [pushApiErrorMessage, updateDocApi, updateFolder] + [ + isMoveInsideSameFolder, + updateFolderOrDocOrder, + updateDocApi, + renameFolderApi, + pushApiErrorMessage, + ] ) return { draggedResource, - dropInWorkspace, dropInDocOrFolder, } } diff --git a/src/lib/v2/mappers/local/sidebarTree.tsx b/src/lib/v2/mappers/local/sidebarTree.tsx index fc7868ee48..563f19068c 100644 --- a/src/lib/v2/mappers/local/sidebarTree.tsx +++ b/src/lib/v2/mappers/local/sidebarTree.tsx @@ -11,9 +11,6 @@ import { mdiFolderPlusOutline, mdiPaperclip, mdiPencil, - mdiSortAlphabeticalAscending, - mdiSortAlphabeticalDescending, - mdiSortClockAscending, mdiStar, mdiStarOutline, mdiTag, @@ -84,7 +81,9 @@ type LocalTreeItem = { function getWorkspaceChildrenOrdered( sortingOrder: SidebarTreeSortingOrder, - workspaceRows: LocalTreeItem[] + workspaceRows: LocalTreeItem[], + map: Map, + orderedIds?: string[] ): LocalTreeItem[] { switch (sortingOrder) { case 'a-z': @@ -95,8 +94,17 @@ function getWorkspaceChildrenOrdered( return sortByAttributeDesc('lastUpdated', workspaceRows) case 'drag': default: - return workspaceRows - // todo: [komediruzecki-09/06/2021] Implement drag and drop (ordered Ids) + if (orderedIds == null) { + return workspaceRows + } + const orderedWorkspaceRows: LocalTreeItem[] = [] + orderedIds.forEach((orderId) => { + const workspaceChildItem = map.get(orderId) + if (workspaceChildItem != null) { + orderedWorkspaceRows.push(workspaceChildItem) + } + }) + return orderedWorkspaceRows } } @@ -116,7 +124,7 @@ function getFolderChildrenOrderedIds( folders.forEach((folder) => { const folderPathname = getFolderPathname(folder._id) if (isDirectSubPathname(parentFolderPathname, folderPathname)) { - children.push(folder._id) + children.push(folder._realId) } }) @@ -159,7 +167,11 @@ export function mapTree( sideBarOpenedLinksIdsSet: Set, sideBarOpenedFolderIdsSet: Set, toggleItem: (type: CollapsableType, id: string) => void, - getFoldEvents: (type: CollapsableType, key: string) => FoldingProps, + getFoldEvents: ( + type: CollapsableType, + key: string, + reversed?: boolean + ) => FoldingProps, push: (url: string) => void, toggleNoteBookmark: ( workspaceId: string, @@ -178,8 +190,8 @@ export function mapTree( createFolder: (body: CreateFolderRequestBody) => Promise, createNote: (body: CreateNoteRequestBody) => Promise, draggedResource: React.MutableRefObject, - dropInFolderOrDoc: ( - workspaceId: string, + dropInDocOrFolder: ( + workspace: NoteStorage, targetedResource: NavResource, targetedPosition: SidebarDragState ) => void, @@ -199,8 +211,8 @@ export function mapTree( )}/${currentRouterPath}` const items = new Map() const [notes, folders] = [values(docMap), values(folderMap)] - folders.forEach((folder) => { + const folderRealId = folder._realId const folderId = folder._id const folderPathname = getFolderPathname(folderId) if (folderPathname == '/') { @@ -212,20 +224,20 @@ export function mapTree( const parentFolderDoc = folderMap[parentFolderPathname] const parentFolderId = parentFolderDoc != null && parentFolderPathname != '/' - ? parentFolderDoc._id + ? parentFolderDoc._realId : workspace.id - items.set(folderId, { - id: folderId, + items.set(folderRealId, { + id: folderRealId, lastUpdated: folder.updatedAt, label: folderName, - folded: !sideBarOpenedFolderIdsSet.has(folderId), - folding: getFoldEvents('folders', folderId), + folded: !sideBarOpenedFolderIdsSet.has(folderRealId), + folding: getFoldEvents('folders', folderRealId), href, active: href === currentPathWithWorkspace, navigateTo: () => push(href), onDrop: (position: SidebarDragState) => - dropInFolderOrDoc( - workspace.id, + dropInDocOrFolder( + workspace, { type: 'folder', result: folder }, position ), @@ -304,7 +316,10 @@ export function mapTree( }, ], parentId: parentFolderId, - children: getFolderChildrenOrderedIds(folder, notes, folders), + children: + folder.orderedIds != null + ? folder.orderedIds + : getFolderChildrenOrderedIds(folder, notes, folders), }) }) @@ -320,7 +335,7 @@ export function mapTree( parentFolderDoc != null ? parentFolderDoc.pathname == '/' ? workspace.id - : parentFolderDoc._id + : parentFolderDoc._realId : workspace.id items.set(noteId, { id: noteId, @@ -335,7 +350,7 @@ export function mapTree( dropAround: sortingOrder === 'drag', navigateTo: () => push(href), onDrop: (position: SidebarDragState) => - dropInFolderOrDoc(workspace.id, { type: 'doc', result: doc }, position), + dropInDocOrFolder(workspace, { type: 'doc', result: doc }, position), onDragStart: () => { draggedResource.current = { type: 'doc', result: doc } }, @@ -386,9 +401,12 @@ export function mapTree( const workspaceRows = arrayItems.filter( (item) => item.parentId == workspace.id ) + const rootFolder = workspace.folderMap['/'] const orderedWorkspaceRows = getWorkspaceChildrenOrdered( sortingOrder, - workspaceRows + workspaceRows, + items, + rootFolder != null ? rootFolder.orderedIds : undefined ) const navTree = orderedWorkspaceRows.reduce((acc, val) => { acc.push({ @@ -512,7 +530,7 @@ export function mapTree( const foldKey = `fold-${key}` const hideKey = `hide-${key}` category.folded = sideBarOpenedLinksIdsSet.has(foldKey) - category.folding = getFoldEvents('links', foldKey) + category.folding = getFoldEvents('links', foldKey, true) category.hidden = sideBarOpenedLinksIdsSet.has(hideKey) category.toggleHidden = () => toggleItem('links', hideKey) }) @@ -557,38 +575,7 @@ function buildChildrenNavRows( case 'last-updated': return sortByAttributeDesc('lastUpdated', rows) case 'drag': - // todo: [komediruzecki-05/06/2021] Implement dragged based order (orderedIds) default: return rows } } - -export const SidebarTreeSortingOrders = { - lastUpdated: { - value: 'last-updated', - label: 'Last updated', - icon: mdiSortClockAscending, - }, - aZ: { - value: 'a-z', - label: 'Title A-Z', - icon: mdiSortAlphabeticalAscending, - }, - zA: { - value: 'z-a', - label: 'Title Z-A', - icon: mdiSortAlphabeticalDescending, - }, - // todo: [komediruzecki-05/06/2021] Enable once implemented (or use shared one) - // dragDrop: { - // value: 'drag', - // label: 'Drag and drop', - // icon: mdiMouseMoveDown, - // }, -} as { - [title: string]: { - value: SidebarTreeSortingOrder - label: string - icon: string - } -} diff --git a/src/lib/v2/stores/sidebarCollapse/store.tsx b/src/lib/v2/stores/sidebarCollapse/store.tsx index a4b168307e..3db0cfded8 100644 --- a/src/lib/v2/stores/sidebarCollapse/store.tsx +++ b/src/lib/v2/stores/sidebarCollapse/store.tsx @@ -13,7 +13,7 @@ import { useActiveStorageId } from '../../../routeParams' const initialContent: CollapsableContent = { folders: [], workspaces: [], - links: [`hide-labels`, 'hide-status'], + links: [], } function useSidebarCollapseStore(): SidebarCollapseContext {