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 {