diff --git a/src/app/src/App.vue b/src/app/src/App.vue index d286d614..9be2ef52 100644 --- a/src/app/src/App.vue +++ b/src/app/src/App.vue @@ -23,8 +23,8 @@ function detectActiveDocuments() { }) } -function onContentSelect(id: string) { - documentTree.selectItemById(id) +async function onContentSelect(id: string) { + await documentTree.selectItemById(id) ui.openPanel(StudioFeature.Content) } @@ -70,7 +70,7 @@ host.on.mounted(() => { size="lg" variant="outline" label="Edit This Page" - class="shadow-lg" + class="shadow-lg bg-white hover:bg-gray-100" @click="onContentSelect(activeDocuments[0].id)" /> diff --git a/src/app/src/components/modal/ModalConfirmAction.vue b/src/app/src/components/modal/ModalConfirmAction.vue index e265df51..3d57d11b 100644 --- a/src/app/src/components/modal/ModalConfirmAction.vue +++ b/src/app/src/components/modal/ModalConfirmAction.vue @@ -27,22 +27,27 @@ const emit = defineEmits<{ close: [] }>() const titleMap = { [StudioItemActionId.RevertItem]: `Reverting ${name.value}`, + [StudioItemActionId.DeleteItem]: `Deleting ${name.value}`, } as Record const descriptionMap = { [StudioItemActionId.RevertItem]: `Are you sure you want to revert ${name.value} back to its original version?`, + [StudioItemActionId.DeleteItem]: `Are you sure you want to delete ${name.value}?`, } as Record const successLabelMap = { [StudioItemActionId.RevertItem]: 'Revert changes', + [StudioItemActionId.DeleteItem]: 'Delete', } as Record const successMessageMap = { - [StudioItemActionId.RevertItem]: 'Changes reverted successfully!', + [StudioItemActionId.RevertItem]: 'Revert successful!', + [StudioItemActionId.DeleteItem]: 'Deletion successful!', } as Record const errorMessageMap = { [StudioItemActionId.RevertItem]: 'Something went wrong while reverting your file.', + [StudioItemActionId.DeleteItem]: 'Something went wrong while deleting your file.', } as Record const handleConfirm = async () => { diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index 47ae1c11..8ecdb21a 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -7,6 +7,7 @@ import type { useDraftDocuments } from './useDraftDocuments' import { useModal } from './useModal' import type { useTree } from './useTree' import type { useDraftMedias } from './useDraftMedias' +import { findDescendantsFileItemsFromId } from '../utils/tree' export const useContext = createSharedComposable(( host: StudioHost, @@ -58,7 +59,7 @@ export const useContext = createSharedComposable(( [StudioItemActionId.CreateDocument]: async ({ fsPath, routePath, content }: CreateFileParams) => { const document = await host.document.create(fsPath, routePath, content) const draftItem = await draft.value.create(document) - tree.selectItemById(draftItem.id) + await tree.selectItemById(draftItem.id) }, [StudioItemActionId.UploadMedia]: async ({ directory, files }: UploadMediaParams) => { for (const file of files) { @@ -66,7 +67,6 @@ export const useContext = createSharedComposable(( } }, [StudioItemActionId.RevertItem]: async (id: string) => { - console.log('revert item', id) modal.openConfirmActionModal(id, StudioItemActionId.RevertItem, async () => { await draft.value.revert(id) }) @@ -75,7 +75,11 @@ export const useContext = createSharedComposable(( alert(`rename file ${path} ${file.name}`) }, [StudioItemActionId.DeleteItem]: async (id: string) => { - alert(`delete file ${id}`) + modal.openConfirmActionModal(id, StudioItemActionId.DeleteItem, async () => { + const ids: string[] = findDescendantsFileItemsFromId(tree.root.value, id).map(item => item.id) + await draft.value.remove(ids) + await tree.selectParentById(id) + }) }, [StudioItemActionId.DuplicateItem]: async (id: string) => { alert(`duplicate file ${id}`) diff --git a/src/app/src/composables/useDraftDocuments.ts b/src/app/src/composables/useDraftDocuments.ts index a525b61a..e768c1a4 100644 --- a/src/app/src/composables/useDraftDocuments.ts +++ b/src/app/src/composables/useDraftDocuments.ts @@ -88,56 +88,62 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, git: return existingItem } - async function remove(id: string) { - const item = await storage.getItem(id) as DraftItem - const fsPath = host.document.getFileSystemPath(id) - - if (item) { - if (item.status === DraftStatus.Deleted) return + async function remove(ids: string[]) { + for (const id of ids) { + const existingDraftItem = list.value.find(item => item.id === id) + const fsPath = host.document.getFileSystemPath(id) + const originalDbItem = await host.document.get(id) await storage.removeItem(id) await host.document.delete(id) - if (item.original) { - const deleteDraft: DraftItem = { + let deleteDraftItem: DraftItem | null = null + if (existingDraftItem) { + if (existingDraftItem.status === DraftStatus.Deleted) return + + if (existingDraftItem.status === DraftStatus.Created) { + list.value = list.value.filter(item => item.id !== id) + } + else { + deleteDraftItem = { + id, + fsPath: existingDraftItem.fsPath, + status: DraftStatus.Deleted, + original: existingDraftItem.original, + githubFile: existingDraftItem.githubFile, + } + + list.value = list.value.map(item => item.id === id ? deleteDraftItem! : item) + } + } + else { + // TODO: check if gh file has been updated + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + + deleteDraftItem = { id, - fsPath: item.fsPath, + fsPath, status: DraftStatus.Deleted, - original: item.original, - githubFile: item.githubFile, + original: originalDbItem, + githubFile, } - await storage.setItem(id, deleteDraft) - await host.document.upsert(id, item.original!) + list.value.push(deleteDraftItem) } - } - else { - // Fetch github file before creating draft to detect non deployed changes - const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile - const original = await host.document.get(id) - - const deleteItem: DraftItem = { - id, - fsPath, - status: DraftStatus.Deleted, - original, - githubFile, + + if (deleteDraftItem) { + await storage.setItem(id, deleteDraftItem) } - await storage.setItem(id, deleteItem) + host.app.requestRerender() - await host.document.delete(id) + await hooks.callHook('studio:draft:document:updated') } - - list.value = list.value.filter(item => item.id !== id) - host.app.requestRerender() } async function revert(id: string) { const draftItems = findDescendantsFromId(list.value, id) - console.log('draftItems', draftItems) - for (const draftItem of draftItems) { const existingItem = list.value.find(item => item.id === draftItem.id) if (!existingItem) { @@ -206,6 +212,11 @@ export const useDraftDocuments = createSharedComposable((host: StudioHost, git: } function select(draftItem: DraftItem | null) { + // TODO: Handle editor with deleted file + if (draftItem?.status === DraftStatus.Deleted) { + return + } + current.value = draftItem } diff --git a/src/app/src/composables/useDraftMedias.ts b/src/app/src/composables/useDraftMedias.ts index 411123be..da1b8090 100644 --- a/src/app/src/composables/useDraftMedias.ts +++ b/src/app/src/composables/useDraftMedias.ts @@ -82,36 +82,38 @@ export const useDraftMedias = createSharedComposable((host: StudioHost, git: Ret return existingItem } - async function remove(id: string) { - const item = await storage.getItem(id) as DraftItem - const fsPath = host.media.getFileSystemPath(id) + async function remove(ids: string[]) { + for (const id of ids) { + const item = await storage.getItem(id) as DraftItem + const fsPath = host.media.getFileSystemPath(id) - if (item) { - if (item.status === DraftStatus.Deleted) return + if (item) { + if (item.status === DraftStatus.Deleted) return - await storage.removeItem(id) - await host.media.delete(id) - } - else { + await storage.removeItem(id) + await host.media.delete(id) + } + else { // Fetch github file before creating draft to detect non deployed changes - const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile - const original = await host.media.get(id) + const githubFile = await git.fetchFile(fsPath, { cached: true }) as GithubFile + const original = await host.media.get(id) + + const deleteItem: DraftItem = { + id, + fsPath, + status: DraftStatus.Deleted, + original, + githubFile, + } - const deleteItem: DraftItem = { - id, - fsPath, - status: DraftStatus.Deleted, - original, - githubFile, - } + await storage.setItem(id, deleteItem) - await storage.setItem(id, deleteItem) + await host.media.delete(id) + } - await host.media.delete(id) + list.value = list.value.filter(item => item.id !== id) + host.app.requestRerender() } - - list.value = list.value.filter(item => item.id !== id) - host.app.requestRerender() } async function revert(id: string) { diff --git a/src/app/src/composables/useStudio.ts b/src/app/src/composables/useStudio.ts index 163d90f1..02c363b4 100644 --- a/src/app/src/composables/useStudio.ts +++ b/src/app/src/composables/useStudio.ts @@ -37,9 +37,9 @@ export const useStudio = createSharedComposable(() => { host.app.requestRerender() isReady.value = true - host.on.routeChange((to: RouteLocationNormalized, _from: RouteLocationNormalized) => { + host.on.routeChange(async (to: RouteLocationNormalized, _from: RouteLocationNormalized) => { if (ui.isPanelOpen.value && ui.config.value.syncEditorAndRoute) { - documentTree.selectByRoute(to) + await documentTree.selectByRoute(to) } // setTimeout(() => { // host.document.detectActives() diff --git a/src/app/src/composables/useTree.ts b/src/app/src/composables/useTree.ts index d0cfbfd7..4fa366ed 100644 --- a/src/app/src/composables/useTree.ts +++ b/src/app/src/composables/useTree.ts @@ -2,7 +2,7 @@ import { StudioFeature, type StudioHost, type TreeItem } from '../types' import { ref, computed } from 'vue' import type { useDraftDocuments } from './useDraftDocuments' import type { useDraftMedias } from './useDraftMedias' -import { buildTree, findItemFromId, findItemFromRoute, ROOT_ITEM } from '../utils/tree' +import { buildTree, findItemFromId, findItemFromRoute, ROOT_ITEM, findParentFromId } from '../utils/tree' import type { RouteLocationNormalized } from 'vue-router' import { useHooks } from './useHooks' @@ -56,7 +56,7 @@ export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType if (!item || item.id === currentItem.value.id) return - select(item) + await select(item) } async function selectItemById(id: string) { @@ -64,7 +64,14 @@ export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType if (!treeItem || treeItem.id === currentItem.value.id) return - select(treeItem) + await select(treeItem) + } + + async function selectParentById(id: string) { + const parent = findParentFromId(tree.value, id) + if (parent) { + await select(parent) + } } async function handleDraftUpdate() { @@ -102,5 +109,6 @@ export const useTree = (type: StudioFeature, host: StudioHost, draft: ReturnType select, selectByRoute, selectItemById, + selectParentById, } } diff --git a/src/app/src/utils/context.ts b/src/app/src/utils/context.ts index 2b0c0b3b..60ea4199 100644 --- a/src/app/src/utils/context.ts +++ b/src/app/src/utils/context.ts @@ -36,18 +36,18 @@ export const STUDIO_ITEM_ACTION_DEFINITIONS: StudioAction[] = [ icon: 'i-lucide-pencil', tooltip: 'Rename file', }, - { - id: StudioItemActionId.DeleteItem, - label: 'Delete', - icon: 'i-lucide-trash', - tooltip: 'Delete file', - }, { id: StudioItemActionId.DuplicateItem, label: 'Duplicate', icon: 'i-lucide-copy', tooltip: 'Duplicate file', }, + { + id: StudioItemActionId.DeleteItem, + label: 'Delete', + icon: 'i-lucide-trash', + tooltip: 'Delete file', + }, ] as const export function computeActionItems(itemActions: StudioAction[], item?: TreeItem | null): StudioAction[] { @@ -93,6 +93,7 @@ export function computeActionItems(itemActions: StudioAction[], item?: TreeItem export function computeActionParams(action: StudioItemActionId, { item }: { item: TreeItem }): ActionHandlerParams[typeof action] { switch (action) { case StudioItemActionId.RevertItem: + case StudioItemActionId.DeleteItem: return item.id default: return {} diff --git a/src/app/src/utils/draft.ts b/src/app/src/utils/draft.ts index 9ba66610..cd88e5a3 100644 --- a/src/app/src/utils/draft.ts +++ b/src/app/src/utils/draft.ts @@ -13,7 +13,7 @@ export const COLOR_STATUS_MAP: { [key in DraftStatus]?: string } = { export const COLOR_UI_STATUS_MAP: { [key in DraftStatus]?: string } = { [DraftStatus.Created]: 'success', [DraftStatus.Updated]: 'warning', - [DraftStatus.Deleted]: 'danger', + [DraftStatus.Deleted]: 'error', [DraftStatus.Renamed]: 'info', [DraftStatus.Opened]: 'neutral', } as const diff --git a/src/app/src/utils/tree.ts b/src/app/src/utils/tree.ts index 8a9b3a40..8b706faa 100644 --- a/src/app/src/utils/tree.ts +++ b/src/app/src/utils/tree.ts @@ -21,7 +21,28 @@ TreeItem[] { const tree: TreeItem[] = [] const directoryMap = new Map() - for (const dbItem of dbItems) { + const deletedDraftItems = draftList?.filter(draft => draft.status === DraftStatus.Deleted) || [] + + function addDeletedDraftItemsInDbItems(dbItems: ((BaseItem) & { fsPath: string })[], deletedItems: DraftItem[]) { + dbItems = [...dbItems] + for (const deletedItem of deletedItems) { + const virtualDbItems: BaseItem & { fsPath: string } = { + id: deletedItem.id, + extension: deletedItem.id.split('.').pop()!, + stem: '', + fsPath: deletedItem.fsPath, + path: deletedItem.original?.path, + } + + dbItems.push(virtualDbItems) + } + + return dbItems + } + + const virtualDbItems = addDeletedDraftItemsInDbItems(dbItems, deletedDraftItems) + + for (const dbItem of virtualDbItems) { const itemHasPathField = 'path' in dbItem && dbItem.path const fsPathSegments = dbItem.fsPath.split('/') const directorySegments = fsPathSegments.slice(0, -1) @@ -192,14 +213,43 @@ export function findItemFromRoute(tree: TreeItem[], route: RouteLocationNormaliz return null } -export function findDescendantsFromId(tree: TreeItem[], id: string): TreeItem[] { +export function findDescendantsFileItemsFromId(tree: TreeItem[], id: string): TreeItem[] { const descendants: TreeItem[] = [] - for (const item of tree) { - if (item.id === id) { - descendants.push(item) + + function traverse(items: TreeItem[]) { + for (const item of items) { + // Check if this item matches the id or is a descendant of it + if (item.id === id || item.id.startsWith(id + '/')) { + if (item.type === 'file') { + descendants.push(item) + } + + // If this item has children, add all of them as descendants + if (item.children) { + getAllDescendants(item.children, descendants) + } + } + else if (item.children) { + // Continue searching in children + traverse(item.children) + } } } + function getAllDescendants(items: TreeItem[], result: TreeItem[]) { + for (const item of items) { + if (item.type === 'file') { + result.push(item) + } + + if (item.children) { + getAllDescendants(item.children, result) + } + } + } + + traverse(tree) + return descendants } @@ -208,10 +258,19 @@ function calculateDirectoryStatuses(items: TreeItem[]) { if (item.type === 'directory' && item.children) { calculateDirectoryStatuses(item.children) - for (const child of item.children) { - if (child.status && child.status !== DraftStatus.Opened) { + const childrenWithStatus = item.children.filter(child => child.status && child.status !== DraftStatus.Opened) + + if (childrenWithStatus.length > 0) { + // Check if ALL children with status are deleted + const allDeleted = childrenWithStatus.every(child => child.status === DraftStatus.Deleted) + + if (allDeleted && childrenWithStatus.length === item.children.length) { + // If all children are deleted, mark directory as deleted + item.status = DraftStatus.Deleted + } + else { + // Otherwise, mark as updated item.status = DraftStatus.Updated - break } } } diff --git a/src/app/test/mocks/database.ts b/src/app/test/mocks/database.ts index dc9e9184..2f36fcb7 100644 --- a/src/app/test/mocks/database.ts +++ b/src/app/test/mocks/database.ts @@ -59,3 +59,44 @@ export const dbItemsList: (DatabaseItem & { fsPath: string })[] = [ __hash__: 'EXAMPLE_HASH_FOR_INSTALLATION', }, ] + +export const nestedDbItemsList: (DatabaseItem & { fsPath: string })[] = [ + { + id: 'docs/1.essentials/2.configuration.md', + title: 'Configuration', + body: { type: 'minimark', value: [] }, + description: 'Learn how to configure Docus.', + extension: 'md', + layout: null, + links: null, + meta: {}, + navigation: {}, + path: '/essentials/configuration', + fsPath: '1.essentials/2.configuration.md', + seo: { + title: 'Configuration', + description: 'Learn how to configure Docus.', + }, + stem: '1.essentials/2.configuration', + __hash__: 'EXAMPLE_HASH_FOR_CONFIGURATION', + }, + { + id: 'docs/1.essentials/1.nested/2.advanced.md', + title: 'Advanced', + body: { type: 'minimark', value: [] }, + description: 'Learn how to configure Docus.', + extension: 'md', + layout: null, + links: null, + meta: {}, + navigation: {}, + path: '/essentials/nested/advanced', + fsPath: '1.essentials/1.nested/2.advanced.md', + seo: { + title: 'Advanced', + description: 'Learn how to use advanced features in Docus.', + }, + stem: '1.essentials/1.nested/2.advanced', + __hash__: 'EXAMPLE_HASH_FOR_ADVANCED', + }, +] diff --git a/src/app/test/mocks/tree.ts b/src/app/test/mocks/tree.ts index 8d17f2a6..89a0833c 100644 --- a/src/app/test/mocks/tree.ts +++ b/src/app/test/mocks/tree.ts @@ -4,40 +4,40 @@ export const tree: TreeItem[] = [ { id: 'landing/index.md', name: 'home', - fsPath: '/index.md', + fsPath: 'index.md', type: 'file', routePath: '/', }, { id: 'docs/1.getting-started', name: 'getting-started', - fsPath: '/getting-started', + fsPath: 'getting-started', type: 'directory', children: [ { id: 'docs/1.getting-started/2.introduction.md', name: 'introduction', - fsPath: '/1.getting-started/2.introduction.md', + fsPath: '1.getting-started/2.introduction.md', type: 'file', routePath: '/getting-started/introduction', }, { id: 'docs/1.getting-started/3.installation.md', name: 'installation', - fsPath: '/1.getting-started/3.installation.md', + fsPath: '1.getting-started/3.installation.md', type: 'file', routePath: '/getting-started/installation', }, { id: 'docs/1.getting-started/1.advanced', name: 'advanced', - fsPath: '/1.getting-started/1.advanced', + fsPath: '1.getting-started/1.advanced', type: 'directory', children: [ { id: 'docs/1.getting-started/1.advanced/1.studio.md', name: 'studio', - fsPath: '/1.getting-started/1.advanced/1.studio.md', + fsPath: '1.getting-started/1.advanced/1.studio.md', type: 'file', routePath: '/getting-started/installation/advanced/studio', }, diff --git a/src/app/test/utils/tree.test.ts b/src/app/test/utils/tree.test.ts index d88ba642..439c0312 100644 --- a/src/app/test/utils/tree.test.ts +++ b/src/app/test/utils/tree.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect } from 'vitest' -import { buildTree, findParentFromId, findItemFromRoute, findItemFromId } from '../../src/utils/tree' +import { buildTree, findParentFromId, findItemFromRoute, findItemFromId, findDescendantsFileItemsFromId } from '../../src/utils/tree' import { tree } from '../mocks/tree' import type { TreeItem } from '../../src/types/tree' -import { dbItemsList } from '../mocks/database' +import { dbItemsList, nestedDbItemsList } from '../mocks/database' import type { DraftItem } from '../../src/types/draft' import { DraftStatus } from '../../src/types/draft' import type { RouteLocationNormalized } from 'vue-router' +import type { DatabaseItem } from '../../src/types/database' -describe('buildTree', () => { +describe('buildTree with one level of depth', () => { // Result based on dbItemsList mock const result: TreeItem[] = [ { @@ -42,12 +43,12 @@ describe('buildTree', () => { }, ] - it('should build a tree from a list of database items with empty draft', () => { + it('Db items list without draft', () => { const tree = buildTree(dbItemsList, null) expect(tree).toStrictEqual(result) }) - it('should build a tree from a list of database items and set file status for root file based on draft', () => { + it('Db items list with draft', () => { const draftList: DraftItem[] = [{ id: dbItemsList[0].id, fsPath: 'index.md', @@ -63,7 +64,112 @@ describe('buildTree', () => { ...result.slice(1)]) }) - it('should build a tree from a list of database items and set status for nested file and parent directory based on draft', () => { + it('Db items list with DELETED non existing file in existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.getting-started/2.deleted.md', + fsPath: '1.getting-started/2.deleted.md', + status: DraftStatus.Deleted, + original: { + path: '/getting-started/deleted', + } as DatabaseItem, + }] + + const tree = buildTree(dbItemsList, draftList) + + expect(tree).toStrictEqual([ + { ...result[0] }, + { + ...result[1], + status: DraftStatus.Updated, + children: [ + ...result[1].children!, + { + id: 'docs/1.getting-started/2.deleted.md', + name: 'deleted', + fsPath: '1.getting-started/2.deleted.md', + type: 'file', + routePath: '/getting-started/deleted', + status: DraftStatus.Deleted, + }, + ], + }, + ]) + }) + + it('Db items list with DELETED non existing file in non existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.deleted-directory/2.deleted-file.md', + fsPath: '1.deleted-directory/2.deleted-file.md', + status: DraftStatus.Deleted, + original: { + path: '/deleted-directory/deleted-file', + } as DatabaseItem, + }] + + const tree = buildTree(dbItemsList, draftList) + + expect(tree).toStrictEqual([ + ...result, + { + id: 'docs/1.deleted-directory', + name: 'deleted-directory', + fsPath: '1.deleted-directory', + routePath: '/deleted-directory', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.deleted-directory/2.deleted-file.md', + name: 'deleted-file', + fsPath: '1.deleted-directory/2.deleted-file.md', + routePath: '/deleted-directory/deleted-file', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ]) + }) + + it('Db items list with all DELETED existing files in existing directory in draft (directory status is set to DELETED)', () => { + const draftList: DraftItem[] = [{ + id: dbItemsList[1].id, + fsPath: '1.getting-started/2.introduction.md', + status: DraftStatus.Deleted, + original: { + path: '/getting-started/introduction', + } as DatabaseItem, + }, { + id: dbItemsList[2].id, + fsPath: '1.getting-started/3.installation.md', + status: DraftStatus.Deleted, + original: { + path: '/getting-started/installation', + } as DatabaseItem, + }] + + const tree = buildTree(dbItemsList, draftList) + + expect(tree).toStrictEqual([ + result[0], + { + ...result[1], + status: DraftStatus.Deleted, + children: [ + { + ...result[1].children![0], + status: DraftStatus.Deleted, + }, + { + ...result[1].children![1], + status: DraftStatus.Deleted, + }, + ], + }, + ]) + }) + + it('Db items list with UPDATED file in existing directory in draft (directory status is set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -89,7 +195,7 @@ describe('buildTree', () => { expect(tree).toStrictEqual(expectedTree) }) - it('should build a tree from a list of database items and set status for nested files. Directory status is always UPDATED if at least one child is other then OPENED', () => { + it('Db items list with UPDATED and OPENED files in exsiting directory in draft (directory status is set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -118,7 +224,7 @@ describe('buildTree', () => { expect(tree).toStrictEqual(expectedTree) }) - it('should build a tree from a list of database items and set OPENED status for nestedfile. Parent directory status is not set if all children are OPENED', () => { + it('Db items list with OPENED files in existing directory in draft (directory status is not set)', () => { const draftList: DraftItem[] = [{ id: dbItemsList[1].id, fsPath: '1.getting-started/2.introduction.md', @@ -148,6 +254,317 @@ describe('buildTree', () => { }) }) +describe('buildTree with two levels of depth', () => { + const result: TreeItem[] = [ + { + id: 'docs/1.essentials', + name: 'essentials', + fsPath: '1.essentials', + routePath: '/essentials', + type: 'directory', + children: [ + { + id: 'docs/1.essentials/2.configuration.md', + name: 'configuration', + fsPath: '1.essentials/2.configuration.md', + type: 'file', + routePath: '/essentials/configuration', + }, + { + id: 'docs/1.essentials/1.nested', + name: 'nested', + fsPath: '1.essentials/1.nested', + routePath: '/essentials/nested', + type: 'directory', + children: [ + { + id: 'docs/1.essentials/1.nested/2.advanced.md', + name: 'advanced', + fsPath: '1.essentials/1.nested/2.advanced.md', + type: 'file', + routePath: '/essentials/nested/advanced', + }, + ], + }, + ], + }, + ] + + it('Db items list without draft', () => { + const tree = buildTree(nestedDbItemsList, null) + expect(tree).toStrictEqual(result) + }) + + it('Db items list with one level of depth draft', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[0].id, + fsPath: '1.essentials/2.configuration.md', + status: DraftStatus.Updated, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + { ...result[0].children![0], status: DraftStatus.Updated }, + result[0].children![1], + ], + }]) + }) + + it('Db items list with nested levels of depth draft', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[1].id, + fsPath: '1.essentials/1.nested/2.advanced.md', + status: DraftStatus.Updated, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + { + ...result[0].children![1], + status: DraftStatus.Updated, + children: [ + { + ...result[0].children![1].children![0], + status: DraftStatus.Updated, + }, + ], + }, + ], + }]) + }) + + it('Db items list with DELETED existing file in first level directory in draft', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[0].id, + fsPath: '1.essentials/2.configuration.md', + status: DraftStatus.Deleted, + original: { + path: '/essentials/configuration', + } as DatabaseItem, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + { + ...result[0].children![0], + status: DraftStatus.Deleted, + }, + result[0].children![1], + ], + }]) + }) + + it ('Db items list with DELETED existing file in nested existing directory in draft (directory status is set to DELETED)', () => { + const draftList: DraftItem[] = [{ + id: nestedDbItemsList[1].id, + fsPath: '1.essentials/1.nested/2.advanced.md', + status: DraftStatus.Deleted, + original: { + path: '/essentials/nested/advanced', + } as DatabaseItem, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + { + ...result[0].children![1], + status: DraftStatus.Deleted, + children: [ + { + ...result[0].children![1].children![0], + status: DraftStatus.Deleted, + }, + ], + }, + ], + }]) + }) + + it ('Db items list with DELETED non exisitng file in first level existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.deleted.md', + fsPath: '1.essentials/1.deleted.md', + status: DraftStatus.Deleted, + original: { + path: '/essentials/deleted', + } as DatabaseItem, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + result[0].children![1], + { + id: 'docs/1.essentials/1.deleted.md', + name: 'deleted', + fsPath: '1.essentials/1.deleted.md', + routePath: '/essentials/deleted', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }]) + }) + + it ('Db items list with DELETED non existing file in nested existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.nested/1.deleted.md', + fsPath: '1.essentials/1.nested/1.deleted.md', + status: DraftStatus.Deleted, + original: { + path: '/essentials/nested/deleted', + } as DatabaseItem, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + { + ...result[0].children![1], + status: DraftStatus.Updated, + children: [ + result[0].children![1].children![0], + { + id: 'docs/1.essentials/1.nested/1.deleted.md', + name: 'deleted', + fsPath: '1.essentials/1.nested/1.deleted.md', + routePath: '/essentials/nested/deleted', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ], + }]) + }) + + it ('Db items list with DELETED existing file in nested non existing directory in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.deleted/1.deleted.md', + fsPath: '1.essentials/1.deleted/1.deleted.md', + status: DraftStatus.Deleted, + original: { + path: '/essentials/deleted/deleted', + } as DatabaseItem, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + result[0].children![1], + { + id: 'docs/1.essentials/1.deleted', + name: 'deleted', + fsPath: '1.essentials/1.deleted', + routePath: '/essentials/deleted', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deleted/1.deleted.md', + name: 'deleted', + fsPath: '1.essentials/1.deleted/1.deleted.md', + routePath: '/essentials/deleted/deleted', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ], + }]) + }) + + it ('Db items list with DELETED file in multi-nested non existing directory chain in draft', () => { + const draftList: DraftItem[] = [{ + id: 'docs/1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + fsPath: '1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + status: DraftStatus.Deleted, + original: { + path: '/essentials/deep/deeper/deepest/file', + } as DatabaseItem, + }] + + const tree = buildTree(nestedDbItemsList, draftList) + + expect(tree).toStrictEqual([{ + ...result[0], + status: DraftStatus.Updated, + children: [ + result[0].children![0], + result[0].children![1], + { + id: 'docs/1.essentials/1.deep', + name: 'deep', + fsPath: '1.essentials/1.deep', + routePath: '/essentials/deep', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deep/2.deeper', + name: 'deeper', + fsPath: '1.essentials/1.deep/2.deeper', + routePath: '/essentials/deep/deeper', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deep/2.deeper/3.deepest', + name: 'deepest', + fsPath: '1.essentials/1.deep/2.deeper/3.deepest', + routePath: '/essentials/deep/deeper/deepest', + type: 'directory', + status: DraftStatus.Deleted, + children: [ + { + id: 'docs/1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + name: 'file', + fsPath: '1.essentials/1.deep/2.deeper/3.deepest/1.file.md', + routePath: '/essentials/deep/deeper/deepest/file', + type: 'file', + status: DraftStatus.Deleted, + }, + ], + }, + ], + }, + ], + }, + ], + }]) + }) +}) + describe('findParentFromId', () => { it('should find direct parent of a child', () => { const parent = findParentFromId(tree, 'docs/1.getting-started/2.introduction.md') @@ -279,3 +696,48 @@ describe('findItemFromId', () => { expect(item).toBeNull() }) }) + +describe('findDescendantsFileItemsFromId', () => { + it('returns exact match for a root level file', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'landing/index.md') + expect(descendants).toHaveLength(1) + expect(descendants[0].id).toBe('landing/index.md') + }) + + it('returns empty array for non-existent id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'non-existent/file.md') + expect(descendants).toHaveLength(0) + }) + + it('returns all descendants files for directory id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started') + + expect(descendants).toHaveLength(3) + + expect(descendants.some(item => item.id === 'docs/1.getting-started/2.introduction.md')).toBe(true) + expect(descendants.some(item => item.id === 'docs/1.getting-started/3.installation.md')).toBe(true) + expect(descendants.some(item => item.id === 'docs/1.getting-started/1.advanced/1.studio.md')).toBe(true) + }) + + it('returns all descendants files for nested directory id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started/1.advanced') + + expect(descendants).toHaveLength(1) + + expect(descendants.some(item => item.id === 'docs/1.getting-started/1.advanced/1.studio.md')).toBe(true) + }) + + it('returns only the file itself when searching for a specific file', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started/2.introduction.md') + + expect(descendants).toHaveLength(1) + expect(descendants[0].id).toBe('docs/1.getting-started/2.introduction.md') + }) + + it('returns deeply nested file when searching by specific file id', () => { + const descendants = findDescendantsFileItemsFromId(tree, 'docs/1.getting-started/1.advanced/1.studio.md') + + expect(descendants).toHaveLength(1) + expect(descendants[0].id).toBe('docs/1.getting-started/1.advanced/1.studio.md') + }) +})