Support async hooks in TreeExplorerNode (#888)

* Support async hooks in TreeExplorerNode

* rebase

* nit

* Fix component test failure

* Add edit vitest

* Add more tests

* Add component test
This commit is contained in:
Chenlei Hu
2024-09-19 20:10:43 +09:00
committed by GitHub
parent 609984d400
commit 810a63f808
8 changed files with 229 additions and 46 deletions

View File

@@ -60,7 +60,7 @@ watch(
const start = 0
const end = fileName.length
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(start, end)
inputElement.setSelectionRange?.(start, end)
})
}
},

View File

@@ -92,12 +92,15 @@ const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
: children.reduce((acc, child) => acc + child.totalLeaves, 0)
}
}
const onNodeContentClick = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode
) => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
}
if (node.handleClick) {
node.handleClick(node, e)
await node.handleClick(node, e)
}
emit('nodeClick', node, e)
}
@@ -118,8 +121,8 @@ const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
renameEditingNode.value = node
}
const deleteCommand = (node: RenderedTreeExplorerNode) => {
node.handleDelete?.(node)
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
await node.handleDelete?.(node)
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() =>
@@ -134,12 +137,15 @@ const menuItems = computed<MenuItem[]>(() =>
label: t('delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleDelete !== undefined
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
].map((menuItem) => ({
...menuItem,
command: wrapCommandWithErrorHandler(menuItem.command)
command: wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
}))
)
@@ -153,12 +159,18 @@ const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
const errorHandling = useErrorHandling()
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
return errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (...args: any[]) => Promise<any>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
}
defineExpose({

View File

@@ -46,6 +46,7 @@ import type {
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/hooks/errorHooks'
const props = defineProps<{
node: RenderedTreeExplorerNode
@@ -67,10 +68,14 @@ const renameEditingNode =
const isEditing = computed(
() => labelEditable.value && renameEditingNode.value?.key === props.node.key
)
const handleRename = (newName: string) => {
props.node.handleRename(props.node, newName)
renameEditingNode.value = null
}
const errorHandling = useErrorHandling()
const handleRename = errorHandling.wrapWithErrorHandlingAsync(
async (newName: string) => {
await props.node.handleRename(props.node, newName)
renameEditingNode.value = null
},
props.node.handleError
)
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)
const treeNodeElement = ref<HTMLElement | null>(null)
@@ -83,10 +88,10 @@ onMounted(() => {
if (props.node.droppable) {
dropTargetCleanup = dropTargetForElements({
element: treeNodeElement.value,
onDrop: (event) => {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
props.node.handleDrop?.(props.node, dndData)
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}

View File

@@ -3,7 +3,20 @@ import { mount } from '@vue/test-utils'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import EditableText from '@/components/common/EditableText.vue'
import Badge from 'primevue/badge'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { createTestingPinia } from '@pinia/testing'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { createI18n } from 'vue-i18n'
import { createApp } from 'vue'
import { useToastStore } from '@/stores/toastStore'
// Create a mock i18n instance
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {}
})
describe('TreeExplorerTreeNode', () => {
const mockNode = {
@@ -12,15 +25,28 @@ describe('TreeExplorerTreeNode', () => {
leaf: false,
totalLeaves: 3,
icon: 'pi pi-folder',
type: 'folder'
type: 'folder',
handleRename: () => {}
} as RenderedTreeExplorerNode
beforeAll(() => {
// Create a Vue app instance for PrimeVuePrimeVue
const app = createApp({})
app.use(PrimeVue)
vi.useFakeTimers()
})
afterAll(() => {
vi.useRealTimers()
})
it('renders correctly', () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
provide: { renameEditingNode: { value: null } }
provide: { renameEditingNode: { value: null } },
plugins: [createTestingPinia(), i18n]
}
})
@@ -32,4 +58,78 @@ describe('TreeExplorerTreeNode', () => {
)
expect(wrapper.findComponent(Badge).props()['value']).toBe(3)
})
it('makes node label editable when renamingEditingNode matches', async () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
expect(editableText.props('isEditing')).toBe(true)
})
it('triggers handleRename callback when editing is finished', async () => {
const handleRenameMock = vi.fn()
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
expect(handleRenameMock).toHaveBeenCalledOnce()
})
it('shows error toast when handleRename promise rejects', async () => {
const handleRenameMock = vi
.fn()
.mockRejectedValue(new Error('Rename failed'))
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const toastStore = useToastStore()
const addToastSpy = vi.spyOn(toastStore, 'add')
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
// Wait for the promise to reject and the toast to be added
vi.runAllTimers()
// Wait for any pending promises to resolve
await new Promise(process.nextTick)
expect(handleRenameMock).toHaveBeenCalledOnce()
expect(addToastSpy).toHaveBeenCalledWith({
severity: 'error',
summary: 'error',
detail: 'Rename failed',
life: 3000
})
})
})