diff --git a/src/controller/explorer/editorTree.tsx b/src/controller/explorer/editorTree.tsx index 0e4cf6c4c..21b678985 100644 --- a/src/controller/explorer/editorTree.tsx +++ b/src/controller/explorer/editorTree.tsx @@ -20,18 +20,22 @@ import { IOpenEditProps, } from 'mo/workbench/sidebar/explore/editorTree'; import { connect } from 'mo/react'; -import { IMenuItemProps, ITabProps } from 'mo/components'; +import { IActionBarItemProps, IMenuItemProps, ITabProps } from 'mo/components'; export interface IEditorTreeController { - readonly onClose: (tabId: string, groupId: number) => void; - readonly onSelect: (tabId: string, groupId: number) => void; - readonly onCloseGroup: (groupId: number) => void; - readonly onSaveGroup: (groupId: number) => void; + readonly onClose?: (tabId: string, groupId: number) => void; + readonly onSelect?: (tabId: string, groupId: number) => void; + readonly onCloseGroup?: (groupId: number) => void; + readonly onSaveGroup?: (groupId: number) => void; + readonly onToolbarClick?: ( + toolbar: IActionBarItemProps, + groupId: number + ) => void; /** * Trigger by context menu click event * When click the context menu from group header, it doesn't have file info */ - readonly onContextMenu: ( + readonly onContextMenu?: ( menu: IMenuItemProps, groupId: number, file?: ITabProps @@ -74,6 +78,7 @@ export class EditorTreeController onCloseGroup={this.onCloseGroup} onSaveGroup={this.onSaveGroup} onContextMenu={this.onContextMenu} + onToolbarClick={this.onToolbarClick} /> ), }); @@ -122,6 +127,10 @@ export class EditorTreeController public onSaveGroup = (groupId: number) => { this.emit(EditorTreeEvent.onSaveAll, groupId); }; + + public onToolbarClick = (toolbar: IActionBarItemProps, groupId: number) => { + this.emit(EditorTreeEvent.onToolbarClick, toolbar, groupId); + }; } // Register singleton diff --git a/src/model/workbench/explorer/editorTree.ts b/src/model/workbench/explorer/editorTree.ts index 05fea23e2..c2143d0a9 100644 --- a/src/model/workbench/explorer/editorTree.ts +++ b/src/model/workbench/explorer/editorTree.ts @@ -14,6 +14,7 @@ export enum EditorTreeEvent { onCloseAll = 'editorTree.closeAll', onSaveAll = 'editorTree.saveAll', onSplitEditorLayout = 'editorTree.splitEditorLayout', + onToolbarClick = 'editorTree.toolbarClick', onContextMenu = 'editorTree.contextMenuClick', } diff --git a/src/services/workbench/__tests__/editorTreeService.test.ts b/src/services/workbench/__tests__/editorTreeService.test.ts index 1a23dad22..119c7fb5b 100644 --- a/src/services/workbench/__tests__/editorTreeService.test.ts +++ b/src/services/workbench/__tests__/editorTreeService.test.ts @@ -71,4 +71,11 @@ describe('Test StatusBarService', () => { editorTreeService.emit(EditorTreeEvent.onContextMenu); }); }); + + test('Should support to trigger toolbar click', () => { + expectFnCalled((testFn) => { + editorTreeService.onToolbarClick(testFn); + editorTreeService.emit(EditorTreeEvent.onToolbarClick); + }); + }); }); diff --git a/src/services/workbench/explorer/editorTreeService.ts b/src/services/workbench/explorer/editorTreeService.ts index 9393192c9..2c2baeb50 100644 --- a/src/services/workbench/explorer/editorTreeService.ts +++ b/src/services/workbench/explorer/editorTreeService.ts @@ -1,4 +1,4 @@ -import { IMenuItemProps, ITabProps } from 'mo/components'; +import { IActionBarItemProps, IMenuItemProps, ITabProps } from 'mo/components'; import { IEditor, IEditorTab } from 'mo/model'; import { EditorTreeEvent } from 'mo/model/workbench/explorer/editorTree'; import { Component } from 'mo/react'; @@ -40,6 +40,14 @@ export interface IEditorTreeService extends Component { * @param callback */ onSaveAll(callback: (groupId?: number) => void): void; + /** + * Callback for the click event from toolbar buttons, except for saving button and closing button, + * if you want to subscribe to the click events for these two buttons, please use the methods of `onSaveAll` and `onCloseAll` + * @param callback + */ + onToolbarClick( + callback: (toolbar: IActionBarItemProps, groupId?: number) => void + ): void; /** * Callback for adjust editor layout * @param callback @@ -97,6 +105,12 @@ export class EditorTreeService this.subscribe(EditorTreeEvent.onSaveAll, callback); } + public onToolbarClick( + callback: (toolbar: IActionBarItemProps, groupId?: number) => void + ) { + this.subscribe(EditorTreeEvent.onToolbarClick, callback); + } + public onLayout(callback: () => void) { this.subscribe(EditorTreeEvent.onSplitEditorLayout, callback); } diff --git a/src/workbench/sidebar/__tests__/__snapshots__/editorTree.test.tsx.snap b/src/workbench/sidebar/__tests__/__snapshots__/editorTree.test.tsx.snap new file mode 100644 index 000000000..23d2ead91 --- /dev/null +++ b/src/workbench/sidebar/__tests__/__snapshots__/editorTree.test.tsx.snap @@ -0,0 +1,163 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The EditorTree Component Match Snapshot 1`] = ` +
+
+ + + tab1 + + + tab + +
+
+ + + tab2 + + + tab + +
+
+`; + +exports[`The EditorTree Component With multiple groups Match Snapshot 1`] = ` +
+
+ 第 1 组 +
+
+ + + tab1 + + + tab + +
+
+ + + tab2 + + + tab + +
+
+ 第 2 组 +
+
+ + + tab1 + + + tab + +
+
+ + + tab2 + + + tab + +
+
+`; diff --git a/src/workbench/sidebar/__tests__/__snapshots__/siderbar.test.tsx.snap b/src/workbench/sidebar/__tests__/__snapshots__/siderbar.test.tsx.snap new file mode 100644 index 000000000..a774d4c92 --- /dev/null +++ b/src/workbench/sidebar/__tests__/__snapshots__/siderbar.test.tsx.snap @@ -0,0 +1,63 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The Content Component Match Snapshot 1`] = ` +
+ test +
+`; + +exports[`The Header Component Match Snapshot 1`] = ` +
+
+

+ test +

+
+
+
+`; + +exports[`The SideBar Component Match Snapshot 1`] = ` +
+
+
+ here is content +
+
+
+
+ here is another content +
+
+
+`; diff --git a/src/workbench/sidebar/__tests__/editorTree.test.tsx b/src/workbench/sidebar/__tests__/editorTree.test.tsx new file mode 100644 index 000000000..9c5c09c1f --- /dev/null +++ b/src/workbench/sidebar/__tests__/editorTree.test.tsx @@ -0,0 +1,378 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { cleanup, fireEvent, render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { expectFnCalled } from '@test/utils'; + +import { EditorTree, IOpenEditProps } from '../explore/editorTree'; +import { + editorTreeActiveItemClassName, + editorTreeGroupClassName, +} from '../explore/base'; +import { + EXPLORER_TOGGLE_CLOSE_GROUP_EDITORS, + EXPLORER_TOGGLE_SAVE_GROUP, +} from 'mo/model'; + +const PaneEditorTree = (props: Omit) => { + return ; +}; + +const mockTab1 = { + id: 'tab1', + name: 'tab1', + data: { + path: 'tab', + }, +}; + +const mockTab2 = { + id: 'tab2', + name: 'tab2', + data: { + path: 'tab', + }, +}; +const TAB1_PATH = `${mockTab1.data.path}/${mockTab1.name}`; +const TAB2_PATH = `${mockTab2.data.path}/${mockTab2.name}`; + +const mockGroups = [ + { + id: 1, + tab: { + id: 'tab1', + name: 'tab1', + }, + data: [mockTab1, mockTab2], + }, +]; + +// mock Toolbar component +jest.mock('mo/components/toolbar', () => { + const originalModule = jest.requireActual('mo/components/toolbar'); + return { + ...originalModule, + Toolbar: ({ onClick, data = [] }) => ( +
+ {data.map((item, index) => ( + onClick(e, item)}> + toolbar-{index} + + ))} +
+ ), + }; +}); + +// to mock useRef +jest.mock('react', () => { + const originReact = jest.requireActual('react'); + return { + ...originReact, + useRef: jest.fn(() => ({ + current: null, + })), + }; +}); + +describe('The EditorTree Component', () => { + afterEach(cleanup); + + test('Match Snapshot', () => { + const component = renderer.create( + + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + test('Should get null without groups', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + test('Should not render title without path in data', () => { + const { container } = render( + + ); + expect(container.querySelector("*[title='tab/tab1']")).toBeNull(); + }); + + test('Should support to active the pane', () => { + const { getByTitle } = render( + + ); + + const tab1 = getByTitle('tab/tab1'); + expect(tab1.classList).toContain(editorTreeActiveItemClassName); + }); + + test('Should support to close tab', () => { + expectFnCalled((testFn) => { + const { getByTitle } = render( + + ); + + const tab1 = getByTitle(TAB1_PATH); + fireEvent.click(tab1.querySelector('span.codicon-close')!); + + expect(testFn.mock.calls[0][0]).toBe(mockTab1.name); + expect(testFn.mock.calls[0][1]).toBe(1); + }); + }); + + test('Should support to contextMenu event', () => { + const contextMenu = { + id: 'test', + name: 'menu', + }; + expectFnCalled((contextMenuFn) => { + const { getByTitle, getByRole } = render( + + ); + + const tab1 = getByTitle(TAB1_PATH); + fireEvent.contextMenu(tab1); + + const menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + fireEvent.click(menu.firstElementChild!); + + expect(contextMenuFn.mock.calls[0][0]).toEqual( + expect.objectContaining(contextMenu) + ); + expect(contextMenuFn.mock.calls[0][1]).toEqual(1); + expect(contextMenuFn.mock.calls[0][2]).toEqual(mockTab1); + }); + }); + + test('Should support to select tab', () => { + expectFnCalled((selectFn) => { + const { getByTitle } = render( + + ); + + const tab1 = getByTitle(TAB1_PATH); + fireEvent.click(tab1); + // same current tab won't triiger onSelect event + expect(selectFn).not.toBeCalled(); + + const tab2 = getByTitle(TAB2_PATH); + fireEvent.click(tab2); + }); + }); + + test('Should not to scroll into view when scollerHeight is invalid', () => { + const mockScroll = jest.fn(); + (React.useRef as jest.Mock).mockImplementation(() => ({ + current: { scrollHeight: 100, scrollTo: mockScroll }, + })); + + const current = mockGroups[0]; + + render(); + expect(mockScroll).not.toBeCalled(); + }); + + test('Should scroll into view when add an active tab', () => { + const mockScroll = jest.fn(); + (React.useRef as jest.Mock).mockImplementation(() => ({ + current: { scrollHeight: 200, scrollTo: mockScroll }, + })); + + const current = mockGroups[0]; + + render(); + expect(mockScroll).toBeCalled(); + }); +}); + +const multipleGroups = [ + { + id: 1, + tab: { + id: 'tab1', + name: 'tab1', + }, + data: [mockTab1, mockTab2], + }, + { + id: 2, + tab: { + id: 'tab1', + name: 'tab1', + }, + data: [mockTab1, mockTab2], + }, +]; + +describe('The EditorTree Component With multiple groups', () => { + afterEach(cleanup); + + test('Match Snapshot', () => { + const component = renderer.create( + + ); + expect(component.toJSON()).toMatchSnapshot(); + }); + + test('Should focus the first child in group when click group title', () => { + expectFnCalled((selectFn) => { + const { container } = render( + + ); + + const groups = container.querySelectorAll( + `.${editorTreeGroupClassName}` + ); + + expect(groups).toHaveLength(2); + + const group1 = groups[0]; + fireEvent.click(group1); + + expect( + (group1.nextElementSibling! as HTMLDivElement).focus + ).toBeTruthy(); + + expect(selectFn.mock.calls[0][0]).toBe(mockTab1.name); + expect(selectFn.mock.calls[0][1]).toBe(multipleGroups[0].id); + }); + }); + + test('Should support trigger contextMenu event in group title', () => { + const contextMenu = { + id: 'test', + name: 'menu', + }; + expectFnCalled((contextMenuFn) => { + const { container, getByRole } = render( + + ); + + const group1 = container.querySelector( + `.${editorTreeGroupClassName}` + ); + + fireEvent.contextMenu(group1!); + + const menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + fireEvent.click(menu.firstElementChild!); + + expect(contextMenuFn.mock.calls[0][0]).toEqual( + expect.objectContaining(contextMenu) + ); + expect(contextMenuFn.mock.calls[0][1]).toEqual( + multipleGroups[0].id + ); + expect(contextMenuFn.mock.calls[0][2]).toBeUndefined(); + }); + }); + + test('Should have a toolbar in group title', () => { + const closeMockFn = jest.fn(); + const saveMockFn = jest.fn(); + const toolbarMockFn = jest.fn(); + + const { getAllByTestId } = render( + + ); + + const toolbars = getAllByTestId('toolbar'); + + expect(toolbars).toHaveLength(multipleGroups.length); + + const index = 0; + let triggerBtn = toolbars[index].firstElementChild!; + + fireEvent.click(triggerBtn); + + // first button is for saving + expect(saveMockFn).toBeCalled(); + expect(saveMockFn.mock.calls[0][0]).toBe(multipleGroups[index].id); + + triggerBtn = triggerBtn.nextElementSibling!; + fireEvent.click(triggerBtn); + + // next button is for closing + expect(closeMockFn).toBeCalled(); + expect(closeMockFn.mock.calls[0][0]).toBe(multipleGroups[index].id); + + triggerBtn = triggerBtn.nextElementSibling!; + fireEvent.click(triggerBtn); + + // last button is extra button + expect(toolbarMockFn).toBeCalled(); + expect(toolbarMockFn.mock.calls[0][0]).toEqual({ + id: 'test', + title: 'testing', + }); + expect(toolbarMockFn.mock.calls[0][1]).toBe(multipleGroups[index].id); + }); +}); diff --git a/src/workbench/sidebar/__tests__/siderbar.test.tsx b/src/workbench/sidebar/__tests__/siderbar.test.tsx new file mode 100644 index 000000000..be828d86b --- /dev/null +++ b/src/workbench/sidebar/__tests__/siderbar.test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; + +import { Sidebar, Content, Header } from '../sidebar'; + +const testPane = { + id: 'test', + title: 'test', + render() { + return
here is content
; + }, +}; + +const anotherPane = { + id: 'another', + title: 'another', + render() { + return
here is another content
; + }, +}; + +describe('The SideBar Component', () => { + test('Match Snapshot', () => { + const component = renderer.create( + + ); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); + +describe('The Header Component', () => { + test('Match Snapshot', () => { + const component = renderer.create( +
+ ); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); + +describe('The Content Component', () => { + test('Match Snapshot', () => { + const component = renderer.create(test); + expect(component.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/src/workbench/sidebar/explore/editorTree.tsx b/src/workbench/sidebar/explore/editorTree.tsx index adbec78c6..495021b9e 100644 --- a/src/workbench/sidebar/explore/editorTree.tsx +++ b/src/workbench/sidebar/explore/editorTree.tsx @@ -73,6 +73,7 @@ const EditorTree = (props: IOpenEditProps) => { onContextMenu, onCloseGroup, onClose, + onToolbarClick, } = props; const wrapper = useRef(null); @@ -123,6 +124,7 @@ const EditorTree = (props: IOpenEditProps) => { e.preventDefault(); contextView.show(getEventPosition(e), () => ( handleOnMenuClick(item!, group, file)} data={contextMenu} /> @@ -137,6 +139,7 @@ const EditorTree = (props: IOpenEditProps) => { const groupHeaderContext = headerContextMenu || contextMenu; contextView.show(getEventPosition(e), () => ( handleOnMenuClick(item!, group)} data={groupHeaderContext} /> @@ -168,6 +171,7 @@ const EditorTree = (props: IOpenEditProps) => { break; default: // default behavior + onToolbarClick?.(item, group.id!); break; } }; @@ -216,7 +220,10 @@ const EditorTree = (props: IOpenEditProps) => { file.id === current?.tab?.id; return (
{ className={ editorTreeFileIconClassName } - type={file.data.icon || ''} + type={file.data?.icon || ''} /> { editorTreeFilePathClassName } > - {file.data.path} + {file.data?.path}
);