diff --git a/.eslintrc.yaml b/.eslintrc.yaml index a064777b06..7b9a76d9bd 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -6,7 +6,19 @@ extends: env: node: true es6: true -ignorePatterns: ["**/scripts", "**/__mocks__", "**/lib", "wdio.conf.ts", "**/features/", "samples/__integration__/"] +ignorePatterns: + [ + "**/scripts", + "**/__mocks__", + "**/lib", + "webpack.config.js", + "**/*wdio.conf.ts", + "**/features/", + "samples/__integration__/", + "**/out", + "**/results", + "**/src/webviews", + ] overrides: - files: "**/__tests__/**" rules: diff --git a/packages/zowe-explorer-api/.eslintignore b/packages/zowe-explorer-api/.eslintignore deleted file mode 100644 index 0997e433bc..0000000000 --- a/packages/zowe-explorer-api/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -__mocks__/** -node_modules/** -lib/** -*.js diff --git a/packages/zowe-explorer-api/CHANGELOG.md b/packages/zowe-explorer-api/CHANGELOG.md index 35ffca4865..3ab87e1878 100644 --- a/packages/zowe-explorer-api/CHANGELOG.md +++ b/packages/zowe-explorer-api/CHANGELOG.md @@ -99,6 +99,8 @@ All notable changes to the "zowe-explorer-api" extension will be documented in t - `reopen` - `saveSearch` +- Implemented support for building, exposing and displaying table views within Zowe Explorer. Tables can be customized and exposed using the helper facilities (`TableBuilder` and `TableMediator`) for an extender's specific use case. For more information on how to configure and show tables, please refer to the [wiki article on Table Views](https://github.com/zowe/zowe-explorer-vscode/wiki/Table-Views). [#2258](https://github.com/zowe/zowe-explorer-vscode/issues/2258) +- **Breaking:** Consolidated WebView API options into a single object (`WebViewOpts` type), both for developer convenience and to support future options. - Enhanced the `ZoweVsCodeExtension.loginWithBaseProfile` and `ZoweVsCodeExtension.logoutWithBaseProfile` methods to store SSO token in parent profile when nested profiles are in use. [#2264](https://github.com/zowe/zowe-explorer-vscode/issues/2264) - **Next Breaking:** Changed return type of `ZoweVsCodeExtension.logoutWithBaseProfile` method from `void` to `boolean` to indicate whether logout was successful. diff --git a/packages/zowe-explorer-api/__mocks__/vscode.ts b/packages/zowe-explorer-api/__mocks__/vscode.ts index 7a86f4bb85..f1a55ae1f1 100644 --- a/packages/zowe-explorer-api/__mocks__/vscode.ts +++ b/packages/zowe-explorer-api/__mocks__/vscode.ts @@ -266,6 +266,181 @@ export interface TabGroups { close(tabGroup: TabGroup | readonly TabGroup[], preserveFocus?: boolean): Thenable; } +/** + * Content settings for a webview panel. + */ +export interface WebviewPanelOptions { + /** + * Controls if the find widget is enabled in the panel. + * + * Defaults to `false`. + */ + readonly enableFindWidget?: boolean; + + /** + * Controls if the webview panel's content (iframe) is kept around even when the panel + * is no longer visible. + * + * Normally the webview panel's html context is created when the panel becomes visible + * and destroyed when it is hidden. Extensions that have complex state + * or UI can set the `retainContextWhenHidden` to make the editor keep the webview + * context around, even when the webview moves to a background tab. When a webview using + * `retainContextWhenHidden` becomes hidden, its scripts and other dynamic content are suspended. + * When the panel becomes visible again, the context is automatically restored + * in the exact same state it was in originally. You cannot send messages to a + * hidden webview, even with `retainContextWhenHidden` enabled. + * + * `retainContextWhenHidden` has a high memory overhead and should only be used if + * your panel's context cannot be quickly saved and restored. + */ + readonly retainContextWhenHidden?: boolean; +} + +/** + * A panel that contains a webview. + */ +interface WebviewPanel { + /** + * Identifies the type of the webview panel, such as `'markdown.preview'`. + */ + readonly viewType: string; + + /** + * Title of the panel shown in UI. + */ + title: string; + + /** + * Icon for the panel shown in UI. + */ + iconPath?: + | Uri + | { + /** + * The icon path for the light theme. + */ + readonly light: Uri; + /** + * The icon path for the dark theme. + */ + readonly dark: Uri; + }; + + /** + * {@linkcode Webview} belonging to the panel. + */ + readonly webview: any; + + /** + * Content settings for the webview panel. + */ + readonly options: WebviewPanelOptions; + + /** + * Editor position of the panel. This property is only set if the webview is in + * one of the editor view columns. + */ + readonly viewColumn: ViewColumn | undefined; + + /** + * Whether the panel is active (focused by the user). + */ + readonly active: boolean; + + /** + * Whether the panel is visible. + */ + readonly visible: boolean; + + /** + * Fired when the panel's view state changes. + */ + readonly onDidChangeViewState: Event; + + /** + * Fired when the panel is disposed. + * + * This may be because the user closed the panel or because `.dispose()` was + * called on it. + * + * Trying to use the panel after it has been disposed throws an exception. + */ + readonly onDidDispose: Event; + + /** + * Show the webview panel in a given column. + * + * A webview panel may only show in a single column at a time. If it is already showing, this + * method moves it to a new column. + * + * @param viewColumn View column to show the panel in. Shows in the current `viewColumn` if undefined. + * @param preserveFocus When `true`, the webview will not take focus. + */ + reveal(viewColumn?: ViewColumn, preserveFocus?: boolean): void; + + /** + * Dispose of the webview panel. + * + * This closes the panel if it showing and disposes of the resources owned by the webview. + * Webview panels are also disposed when the user closes the webview panel. Both cases + * fire the `onDispose` event. + */ + dispose(): any; +} + +/** + * Content settings for a webview. + */ +export interface WebviewOptions { + /** + * Controls whether scripts are enabled in the webview content or not. + * + * Defaults to false (scripts-disabled). + */ + readonly enableScripts?: boolean; + + /** + * Controls whether forms are enabled in the webview content or not. + * + * Defaults to true if {@link WebviewOptions.enableScripts scripts are enabled}. Otherwise defaults to false. + * Explicitly setting this property to either true or false overrides the default. + */ + readonly enableForms?: boolean; + + /** + * Controls whether command uris are enabled in webview content or not. + * + * Defaults to `false` (command uris are disabled). + * + * If you pass in an array, only the commands in the array are allowed. + */ + readonly enableCommandUris?: boolean | readonly string[]; + + /** + * Root paths from which the webview can load local (filesystem) resources using uris from `asWebviewUri` + * + * Default to the root folders of the current workspace plus the extension's install directory. + * + * Pass in an empty array to disallow access to any local resources. + */ + readonly localResourceRoots?: readonly Uri[]; + + /** + * Mappings of localhost ports used inside the webview. + * + * Port mapping allow webviews to transparently define how localhost ports are resolved. This can be used + * to allow using a static localhost port inside the webview that is resolved to random port that a service is + * running on. + * + * If a webview accesses localhost content, we recommend that you specify port mappings even if + * the `webviewPort` and `extensionHostPort` ports are the same. + * + * *Note* that port mappings only work for `http` or `https` urls. Websocket urls (e.g. `ws://localhost:3000`) + * cannot be mapped to another port. + */ + readonly portMapping?: readonly any[]; +} + export namespace window { /** * Represents the grid widget within the main editor area @@ -309,6 +484,15 @@ export namespace window { return undefined; } + export function createWebviewPanel( + viewType: string, + title: string, + showOptions: ViewColumn | { preserveFocus: boolean; viewColumn: ViewColumn }, + options?: WebviewPanelOptions & WebviewOptions + ): WebviewPanel { + return undefined as any; + } + export function showQuickPick( _items: readonly T[] | Thenable, _options?: QuickPickOptions & { canPickMany: true }, diff --git a/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/TableView.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/TableView.unit.test.ts new file mode 100644 index 0000000000..70a9bb0c6f --- /dev/null +++ b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/TableView.unit.test.ts @@ -0,0 +1,664 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { join } from "path"; +import { Table, TableBuilder, WebView } from "../../../../src"; +import { env, EventEmitter, Uri, window } from "vscode"; +import * as crypto from "crypto"; +import { diff } from "deep-object-diff"; + +function createGlobalMocks() { + const mockPanel = { + dispose: jest.fn(), + onDidDispose: jest.fn(), + webview: { asWebviewUri: (uri) => uri.toString(), onDidReceiveMessage: jest.fn(), postMessage: jest.fn() }, + }; + // Mock `vscode.window.createWebviewPanel` to return a usable panel object + const createWebviewPanelMock = jest.spyOn(window, "createWebviewPanel").mockReturnValueOnce(mockPanel as any); + + return { + createWebviewPanelMock, + context: { + extensionPath: "/a/b/c/zowe-explorer", + extension: { + id: "Zowe.vscode-extension-for-zowe", + }, + }, + updateWebviewMock: jest.spyOn((Table.View as any).prototype, "updateWebview"), + }; +} + +// Table.View unit tests +describe("Table.View", () => { + describe("constructor", () => { + it("handles a missing title in the data object", () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, {} as any); + expect((view as any).title).toBe("Table view"); + }); + }); + + describe("getUris", () => { + it("returns the URIs from the WebView base class", () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, false, { title: "Table" } as any); + const buildPath = join(globalMocks.context.extensionPath, "src", "webviews"); + const scriptPath = join(buildPath, "dist", "table-view", "table-view.js"); + expect(view.getUris()).toStrictEqual({ + disk: { + build: Uri.parse(buildPath), + script: Uri.parse(scriptPath), + css: undefined, + }, + resource: { + build: buildPath, + script: scriptPath, + css: undefined, + }, + }); + }); + }); + + describe("getHtml", () => { + it("returns the HTML content generated by the WebView base class", () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, false, { title: "Table" } as any); + expect(view.getHtml()).toStrictEqual(view.panel.webview.html); + }); + }); + + describe("updateWebview", () => { + it("calls postMessage on the panel and sends the data to the webview", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, false, { title: "Table" } as any); + + // case 1: Post message was not successful; updateWebview returns false + const postMessageMock = jest.spyOn(view.panel.webview, "postMessage").mockResolvedValueOnce(false); + await expect((view as any).updateWebview()).resolves.toBe(false); + expect(postMessageMock).toHaveBeenCalledWith({ + command: "ondatachanged", + data: { title: "Table" }, + }); + + // case 2: Post message was successful; updateWebview returns true and event is fired + const emitterFireMock = jest.spyOn(EventEmitter.prototype, "fire"); + postMessageMock.mockResolvedValueOnce(true); + await expect((view as any).updateWebview()).resolves.toBe(true); + expect(postMessageMock).toHaveBeenCalledWith({ + command: "ondatachanged", + data: { title: "Table" }, + }); + expect(emitterFireMock).toHaveBeenCalledWith({ title: "Table" }); + postMessageMock.mockRestore(); + emitterFireMock.mockClear(); + + // case 2: Post message was successful; updateWebview was previously called + // result: Uses lastUpdated cache, returns true and fires the event + postMessageMock.mockResolvedValueOnce(true); + const mockNewRow = { a: 3, b: 2, c: 1 }; + (view as any).data.rows = [mockNewRow]; + await expect((view as any).updateWebview()).resolves.toBe(true); + expect(postMessageMock).toHaveBeenCalledWith({ + command: "ondatachanged", + data: { title: "Table", rows: [mockNewRow] }, + }); + expect(emitterFireMock).toHaveBeenCalledWith(diff((view as any).lastUpdated, (view as any).data)); + postMessageMock.mockRestore(); + }); + }); + + describe("getId", () => { + it("returns a valid ID for the table view", () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, false, { title: "Table" } as any); + const randomUuidMock = jest.spyOn(crypto, "randomUUID").mockReturnValueOnce("foo-bar-baz-qux-quux"); + expect(view.getId()).toBe("Table-foo##Zowe.vscode-extension-for-zowe"); + expect(randomUuidMock).toHaveBeenCalled(); + }); + }); + + describe("setTitle", () => { + it("returns false if it was unable to send the new title", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Stable Table of Cables" } as any); + globalMocks.updateWebviewMock.mockResolvedValueOnce(false); + await expect(view.setTitle("Unstable Table of Cables")).resolves.toBe(false); + }); + + it("returns true if it successfully sent the new title", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Stable Table of Cables" } as any); + globalMocks.updateWebviewMock.mockResolvedValueOnce(true); + await expect(view.setTitle("Unstable Table of Cables")).resolves.toBe(true); + expect((view as any).data.title).toBe("Unstable Table of Cables"); + }); + }); + + describe("setOptions", () => { + it("returns false if it was unable to send the new options", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Table" } as any); + globalMocks.updateWebviewMock.mockResolvedValueOnce(false); + await expect( + view.setOptions({ + debug: true, + pagination: false, + }) + ).resolves.toBe(false); + }); + + it("returns true if it successfully sent the new options", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Stable Table of Cables" } as any); + globalMocks.updateWebviewMock.mockResolvedValueOnce(true); + + // case 1: No options were previously defined + await expect( + view.setOptions({ + debug: true, + pagination: false, + }) + ).resolves.toBe(true); + expect((view as any).data.options.debug).toBe(true); + expect((view as any).data.options.pagination).toBe(false); + + globalMocks.updateWebviewMock.mockResolvedValueOnce(true); + // case 2: Options were previously specified + await expect( + view.setOptions({ + paginationPageSize: 0, + }) + ).resolves.toBe(true); + expect((view as any).data.options).toStrictEqual({ + debug: true, + pagination: false, + paginationPageSize: 0, + }); + }); + }); + + describe("setColumns", () => { + it("returns false if it was unable to send the new columns", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Table" } as any); + globalMocks.updateWebviewMock.mockResolvedValueOnce(false); + await expect(view.setColumns([{ field: "apple" }, { field: "banana" }, { field: "orange" }])).resolves.toBe(false); + }); + + it("returns true if it successfully sent the new options", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Stable Table of Cables" } as any); + globalMocks.updateWebviewMock.mockResolvedValueOnce(true); + const cols = [ + { field: "apple", valueFormatter: (data: { value: Table.ContentTypes }) => `${data.value.toString()} apples` }, + { field: "banana", comparator: (valueA, valueB, nodeA, nodeB, isDescending) => -1, colSpan: (params) => 2 }, + { field: "orange", rowSpan: (params) => 2 }, + ]; + await expect(view.setColumns(cols)).resolves.toBe(true); + expect((view as any).data.columns).toStrictEqual( + cols.map((col) => ({ + ...col, + colSpan: col.colSpan?.toString(), + comparator: col.comparator?.toString(), + rowSpan: col.rowSpan?.toString(), + valueFormatter: col.valueFormatter?.toString(), + })) + ); + }); + }); + + describe("onMessageReceived", () => { + it("does nothing if no command is provided", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Table w/ changing display" } as any); + const onTableDisplayChangedFireMock = jest.spyOn((view as any).onTableDisplayChangedEmitter, "fire"); + globalMocks.updateWebviewMock.mockClear(); + const tableData = { rows: [{ a: 1, b: 1, c: 1 }] }; + await view.onMessageReceived({ + data: tableData, + }); + expect(onTableDisplayChangedFireMock).not.toHaveBeenCalledWith(tableData); + expect(globalMocks.updateWebviewMock).not.toHaveBeenCalled(); + }); + + it("fires the onTableDisplayChanged event when handling the 'ondisplaychanged' command", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Table w/ changing display" } as any); + const onTableDisplayChangedFireMock = jest.spyOn((view as any).onTableDisplayChangedEmitter, "fire"); + const tableData = { rows: [{ a: 1, b: 1, c: 1 }] }; + await view.onMessageReceived({ + command: "ondisplaychanged", + data: tableData, + }); + expect(onTableDisplayChangedFireMock).toHaveBeenCalledWith(tableData); + }); + + it("fires the onTableDataEdited event when handling the 'ontableedited' command", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, false, { title: "Table w/ editable columns" } as any); + await view.setColumns([{ field: "a", editable: true }, { field: "b" }, { field: "c" }]); + const onTableDataEditedFireMock = jest.spyOn((view as any).onTableDataEditedEmitter, "fire"); + const tableData = { rows: [{ a: 1, b: 1, c: 1 }] }; + const editData = { + value: 2, + oldValue: tableData.rows[0].a, + field: "a", + rowIndex: 1, + }; + await view.onMessageReceived({ + command: "ontableedited", + data: editData, + }); + expect(onTableDataEditedFireMock).toHaveBeenCalledWith(editData); + }); + + it("calls updateWebview when handling the 'ready' command", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Table w/ changing display" } as any); + globalMocks.updateWebviewMock.mockImplementation(); + await view.onMessageReceived({ + command: "ready", + }); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + globalMocks.updateWebviewMock.mockRestore(); + }); + + it("calls vscode.env.clipboard.writeText when handling the 'copy' command", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Table w/ copy" } as any); + const writeTextMock = jest.spyOn(env.clipboard, "writeText").mockImplementation(); + const mockWebviewMsg = { + command: "copy", + data: { row: { a: 1, b: 1, c: 1 } }, + }; + await view.onMessageReceived(mockWebviewMsg); + expect(writeTextMock).toHaveBeenCalledWith(JSON.stringify(mockWebviewMsg.data.row)); + writeTextMock.mockRestore(); + }); + + it("calls vscode.env.clipboard.writeText when handling the 'copy-cell' command", async () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, { title: "Table w/ copy-cell" } as any); + const writeTextMock = jest.spyOn(env.clipboard, "writeText").mockImplementation(); + const mockWebviewMsg = { + command: "copy-cell", + data: { cell: 1, row: { a: 1, b: 1, c: 1 } }, + }; + await view.onMessageReceived(mockWebviewMsg); + expect(writeTextMock).toHaveBeenCalledWith(mockWebviewMsg.data.cell); + writeTextMock.mockRestore(); + }); + + it("does nothing for a command that doesn't exist as a context option or row action", async () => { + const globalMocks = createGlobalMocks(); + const data = { + title: "Some table", + rows: [{ a: 1, b: 1, c: 1 }], + columns: [], + contextOpts: { + all: [], + }, + actions: { + all: [], + }, + }; + const view = new Table.View(globalMocks.context as any, false, data); + const writeTextMock = jest.spyOn(env.clipboard, "writeText"); + const mockWebviewMsg = { + command: "nonexistent-action", + data: { row: data.rows[0] }, + }; + await view.onMessageReceived(mockWebviewMsg); + expect(writeTextMock).not.toHaveBeenCalled(); + expect(globalMocks.updateWebviewMock).not.toHaveBeenCalled(); + }); + + it("runs the callback for an action that exists", async () => { + const globalMocks = createGlobalMocks(); + const allCallbackMock = jest.fn(); + const zeroCallbackMock = jest.fn(); + const multiCallbackMock = jest.fn(); + const data = { + title: "Some table", + rows: [{ a: 1, b: 1, c: 1 }], + columns: [], + contextOpts: { + all: [], + }, + actions: { + all: [ + { + title: "Some action", + command: "some-action", + callback: { + typ: "cell", + fn: (_view: Table.View, _cell: Table.ContentTypes) => { + allCallbackMock(); + }, + }, + } as Table.Action, + { + title: "Multi action", + command: "multi-action", + callback: { + typ: "multi-row", + fn: (_view: Table.View, row: Record) => { + multiCallbackMock(); + }, + }, + } as Table.Action, + ], + 1: [ + { + title: "Zero action", + command: "zero-action", + callback: { + typ: "single-row", + fn: (_view: Table.View, row: Table.RowInfo) => { + zeroCallbackMock(); + }, + }, + } as Table.Action, + ], + }, + }; + const view = new Table.View(globalMocks.context as any, false, data); + const writeTextMock = jest.spyOn(env.clipboard, "writeText"); + + // case 1: A cell action that exists for all rows + const mockWebviewMsg = { + command: "some-action", + data: { cell: data.rows[0].a, row: data.rows[0] }, + rowIndex: 1, + }; + await view.onMessageReceived(mockWebviewMsg); + expect(writeTextMock).not.toHaveBeenCalled(); + expect(globalMocks.updateWebviewMock).not.toHaveBeenCalled(); + expect(allCallbackMock).toHaveBeenCalled(); + + // case 2: A single-row action that exists for one row + const mockNextWebviewMsg = { + command: "zero-action", + data: { cell: data.rows[0].a, row: data.rows[0] }, + rowIndex: 1, + }; + await view.onMessageReceived(mockNextWebviewMsg); + expect(writeTextMock).not.toHaveBeenCalled(); + expect(globalMocks.updateWebviewMock).not.toHaveBeenCalled(); + expect(zeroCallbackMock).toHaveBeenCalled(); + + // case 2: A multi-row action that exists for all rows + const mockFinalWebviewMsg = { + command: "multi-action", + data: { cell: data.rows[0].a, rows: data.rows }, + rowIndex: 2, + }; + await view.onMessageReceived(mockFinalWebviewMsg); + expect(writeTextMock).not.toHaveBeenCalled(); + expect(globalMocks.updateWebviewMock).not.toHaveBeenCalled(); + expect(multiCallbackMock).toHaveBeenCalled(); + }); + }); + + describe("setContent", () => { + it("sets the rows on the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const mockRow = { red: 255, green: 0, blue: 255 }; + const data = { title: "Table w/ content", rows: [] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + await view.setContent([mockRow]); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.rows[0]).toStrictEqual(mockRow); + globalMocks.updateWebviewMock.mockRestore(); + }); + }); + + describe("addColumns", () => { + it("sets the columns on the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const mockCols = [ + { field: "name", sort: "desc", colSpan: (params) => 2, rowSpan: (params) => 2 }, + { + field: "address", + sort: "asc", + comparator: (valueA, valueB, nodeA, nodeB, isDescending) => 1, + valueFormatter: (data: { value }) => `Located at ${data.value.toString()}`, + }, + ] as Table.ColumnOpts[]; + const data = { title: "Table w/ cols", columns: [], rows: [] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + await view.addColumns(...mockCols); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.columns).toStrictEqual( + mockCols.map((col) => ({ + ...col, + colSpan: col.colSpan?.toString(), + comparator: col.comparator?.toString(), + rowSpan: col.rowSpan?.toString(), + valueFormatter: col.valueFormatter?.toString(), + })) + ); + globalMocks.updateWebviewMock.mockRestore(); + }); + }); + + describe("addContent", () => { + it("adds the rows to the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const mockRow = { blue: true, yellow: false, violet: true }; + const data = { title: "Table w/ no initial rows", rows: [] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + await view.addContent(mockRow); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.rows[0]).toStrictEqual(mockRow); + globalMocks.updateWebviewMock.mockRestore(); + }); + }); + + describe("addContextOption", () => { + it("adds the context option with conditional to the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const data = { title: "Table w/ no initial rows", contextOpts: { all: [] }, rows: [] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + + // case 1: Adding a context menu option with conditional to all rows + const contextOpt = { + title: "Add to cart", + command: "add-to-cart", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + condition: (_data) => true, + } as Table.ContextMenuOpts; + await view.addContextOption("all", contextOpt); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.contextOpts["all"]).toStrictEqual([{ ...contextOpt, condition: contextOpt.condition?.toString() }]); + + // case 2: Adding a context menu option with conditional to one row + const singleRowContextOpt = { + title: "Save for later", + command: "save-for-later", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + condition: (_data) => true, + } as Table.ContextMenuOpts; + await view.addContextOption(1, singleRowContextOpt); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.contextOpts[1]).toStrictEqual([ + { ...singleRowContextOpt, condition: singleRowContextOpt.condition?.toString() }, + ]); + globalMocks.updateWebviewMock.mockRestore(); + }); + + it("adds the context option without conditional to the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const data = { title: "Table w/ no initial rows", contextOpts: { all: [] }, rows: [] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + + // case 1: Adding a context menu option without conditional to all rows + const contextOpt = { + title: "Remove from cart", + command: "rm-from-cart", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + } as Table.ContextMenuOpts; + await view.addContextOption("all", contextOpt); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.contextOpts["all"]).toStrictEqual([{ ...contextOpt, condition: contextOpt.condition?.toString() }]); + + // case 2: Adding a context menu option without conditional to one row + const singleRowContextOpt = { + title: "Add to wishlist", + command: "add-to-wishlist", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + } as Table.ContextMenuOpts; + await view.addContextOption(1, singleRowContextOpt); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.contextOpts[1]).toStrictEqual([ + { ...singleRowContextOpt, condition: singleRowContextOpt.condition?.toString() }, + ]); + globalMocks.updateWebviewMock.mockRestore(); + }); + }); + + describe("addAction", () => { + it("adds the action with conditional to the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const data = { title: "Table w/ no initial rows", actions: { all: [] }, rows: [] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + + // case 1: Adding an action to all rows + const action = { + title: "Add to wishlist", + command: "add-to-wishlist", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + condition: (_data) => true, + } as Table.ContextMenuOpts; + await view.addAction("all", action); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.actions["all"]).toStrictEqual([{ ...action, condition: action.condition?.toString() }]); + + // case 2: Adding an action to one row + const singleRowAction = { + title: "Learn more", + command: "learn-more", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + condition: (_data) => true, + } as Table.ContextMenuOpts; + await view.addAction(2, singleRowAction); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.actions[2]).toStrictEqual([{ ...singleRowAction, condition: singleRowAction.condition?.toString() }]); + globalMocks.updateWebviewMock.mockRestore(); + }); + + it("adds the action without conditional to the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const data = { title: "Table w/ no initial rows", actions: { all: [] }, rows: [] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + + // case 1: Adding an action without conditional to all rows + const action = { + title: "Remove from wishlist", + command: "rm-from-wishlist", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + } as Table.ContextMenuOpts; + await view.addAction("all", action); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.actions["all"]).toStrictEqual([{ ...action, condition: action.condition?.toString() }]); + + // case 2: Adding an action without conditional to one row + const singleRowAction = { + title: "Learn less", + command: "learn-less", + callback: { + typ: "single-row", + fn: (_data) => {}, + }, + } as Table.ContextMenuOpts; + await view.addAction(2, singleRowAction); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + expect((view as any).data.actions[2]).toStrictEqual([{ ...singleRowAction, condition: singleRowAction.condition?.toString() }]); + globalMocks.updateWebviewMock.mockRestore(); + }); + }); + + describe("getContent", () => { + it("returns the content provided to the table view", () => { + const globalMocks = createGlobalMocks(); + const view = new Table.View(globalMocks.context as any, false, { rows: [{ d: true, e: false, f: true }], title: "Table" } as any); + expect(view.getContent()).toStrictEqual([{ d: true, e: false, f: true }]); + }); + }); + + describe("updateRow", () => { + it("updates the rows on the internal data structure and calls updateWebview", async () => { + const globalMocks = createGlobalMocks(); + const mockRow = { a: 2, b: 2, c: 2 }; + + // case 1: Update the contents of a single row with new contents + const data = { title: "Table w/ content", rows: [{ a: 1, b: 2, c: 3 }] }; + const view = new Table.View(globalMocks.context as any, data as any); + globalMocks.updateWebviewMock.mockImplementation(); + await view.updateRow(0, mockRow); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + globalMocks.updateWebviewMock.mockClear(); + + // case 2: Remove a row from the table + await view.updateRow(0, null); + expect((view as any).data.rows.length).toBe(0); + expect(globalMocks.updateWebviewMock).toHaveBeenCalled(); + globalMocks.updateWebviewMock.mockRestore(); + }); + }); +}); + +// Table.Instance unit tests +describe("Table.Instance", () => { + describe("dispose", () => { + it("disposes of the table view using the function in the base class", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any) + .addRows([ + { a: 1, b: 2, c: 3, d: false, e: 5 }, + { a: 3, b: 2, c: 1, d: true, e: 6 }, + ]) + .addColumns([{ field: "a" }, { field: "b" }, { field: "c" }, { field: "d" }, { field: "e" }]); + const instance = builder.build(); + const disposeMock = jest.spyOn((WebView as any).prototype, "dispose"); + instance.dispose(); + expect(disposeMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/TableViewProvider.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/TableViewProvider.unit.test.ts new file mode 100644 index 0000000000..4bc7d06b79 --- /dev/null +++ b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/TableViewProvider.unit.test.ts @@ -0,0 +1,109 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { EventEmitter, ExtensionContext, WebviewView } from "vscode"; +import { TableBuilder, TableViewProvider } from "../../../../src/vscode/ui"; + +describe("TableViewProvider", () => { + const fakeExtContext = { + extensionPath: "/a/b/c/zowe-explorer", + extension: { + id: "Zowe.vscode-extension-for-zowe", + }, + } as ExtensionContext; + + describe("getInstance", () => { + it("returns a singleton instance for the TableViewProvider", () => { + expect(TableViewProvider.getInstance()).toBeInstanceOf(TableViewProvider); + }); + }); + + describe("setTableView", () => { + it("sets the table to the given table view", () => { + // case 1: table did not previously exist + const builder = new TableBuilder(fakeExtContext); + + const tableOne = builder + .isView() + .addColumns([ + { field: "apple", headerName: "Apple" }, + { field: "orange", headerName: "Orange" }, + { field: "apple", headerName: "Banana" }, + ]) + .addRows([ + { apple: 0, banana: 1, orange: 2 }, + { apple: 3, banana: 4, orange: 5 }, + { apple: 6, banana: 7, orange: 8 }, + { apple: 9, banana: 10, orange: 11 }, + ]) + .build(); + TableViewProvider.getInstance().setTableView(tableOne); + expect((TableViewProvider.getInstance() as any).tableView).toBe(tableOne); + + const disposeSpy = jest.spyOn(tableOne, "dispose"); + + // case 2: table previously existed, dispose called on old table + const tableTwo = builder.options({ pagination: false }).build(); + TableViewProvider.getInstance().setTableView(tableTwo); + expect((TableViewProvider.getInstance() as any).tableView).toBe(tableTwo); + expect(disposeSpy).toHaveBeenCalled(); + }); + }); + + describe("getTableView", () => { + beforeEach(() => { + TableViewProvider.getInstance().setTableView(null); + }); + + it("returns null if no table view has been provided", () => { + expect(TableViewProvider.getInstance().getTableView()).toBe(null); + }); + + it("returns a valid table view if one has been provided", () => { + expect(TableViewProvider.getInstance().getTableView()).toBe(null); + const table = new TableBuilder(fakeExtContext) + .isView() + .addColumns([ + { field: "a", headerName: "A" }, + { field: "b", headerName: "B" }, + { field: "c", headerName: "C" }, + ]) + .addRows([ + { a: 0, b: 1, c: 2 }, + { a: 3, b: 4, c: 5 }, + ]) + .build(); + TableViewProvider.getInstance().setTableView(table); + expect(TableViewProvider.getInstance().getTableView()).toBe(table); + }); + }); + + describe("resolveWebviewView", () => { + it("correctly resolves the view and calls resolveForView on the table", async () => { + const table = new TableBuilder(fakeExtContext).isView().build(); + TableViewProvider.getInstance().setTableView(table); + const resolveForViewSpy = jest.spyOn(table, "resolveForView"); + const fakeView = { + onDidDispose: jest.fn(), + viewType: "zowe.panel", + title: "SomeWebviewView", + webview: { asWebviewUri: jest.fn(), onDidReceiveMessage: jest.fn(), options: {} }, + } as unknown as WebviewView; + const fakeEventEmitter = new EventEmitter(); + await TableViewProvider.getInstance().resolveWebviewView( + fakeView, + { state: undefined }, + { isCancellationRequested: false, onCancellationRequested: fakeEventEmitter.event } + ); + expect(resolveForViewSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/WebView.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/WebView.unit.test.ts index ab7e4f225b..2df27e7d48 100644 --- a/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/WebView.unit.test.ts +++ b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/WebView.unit.test.ts @@ -44,12 +44,9 @@ describe("WebView unit tests", () => { const createWebviewPanelSpy = jest.spyOn(vscode.window, "createWebviewPanel"); const renderSpy = jest.spyOn(Mustache, "render"); - const testView = new WebView( - "Test Webview Title", - "example-folder", - { extensionPath: "test/path" } as vscode.ExtensionContext, - async (_message: any) => {} - ); + const testView = new WebView("Test Webview Title", "example-folder", { extensionPath: "test/path" } as vscode.ExtensionContext, { + onDidReceiveMessage: async (_message: any) => {}, + }); expect(createWebviewPanelSpy).toHaveBeenCalled(); expect(renderSpy).toHaveBeenCalled(); (testView as any).dispose(); @@ -57,12 +54,9 @@ describe("WebView unit tests", () => { }); it("returns HTML content from WebView", () => { - const testView = new WebView( - "Test Webview Title", - "example-folder", - { extensionPath: "test/path" } as vscode.ExtensionContext, - async (_message: any) => {} - ); + const testView = new WebView("Test Webview Title", "example-folder", { extensionPath: "test/path" } as vscode.ExtensionContext, { + onDidReceiveMessage: async (_message: any) => {}, + }); expect(testView.htmlContent).toBe(testView.panel.webview.html); }); }); diff --git a/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/utils/TableBuilder.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/utils/TableBuilder.unit.test.ts new file mode 100644 index 0000000000..14d8768236 --- /dev/null +++ b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/utils/TableBuilder.unit.test.ts @@ -0,0 +1,366 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { window } from "vscode"; +import { Table, TableBuilder, TableMediator } from "../../../../../src"; + +// TableBuilder unit tests + +function createGlobalMocks() { + const mockPanel = { + onDidDispose: (_fn) => {}, + webview: { asWebviewUri: (uri) => uri.toString(), onDidReceiveMessage: (_fn) => {} }, + }; + // Mock `vscode.window.createWebviewPanel` to return a usable panel object + const createWebviewPanelMock = jest.spyOn(window, "createWebviewPanel").mockReturnValueOnce(mockPanel as any); + + return { + createWebviewPanelMock, + context: { + extensionPath: "/a/b/c/zowe-explorer", + extension: { + id: "Zowe.vscode-extension-for-zowe", + }, + }, + }; +} + +describe("TableBuilder", () => { + describe("constructor", () => { + it("stores the extension context within the builder", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).context).toBe(globalMocks.context); + }); + }); + + describe("options", () => { + it("adds the given options to the table data, returning the same instance", () => { + const globalMocks = createGlobalMocks(); + let builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).data?.options).toBe(undefined); + builder = builder.options({ + pagination: false, + }); + expect((builder as any).data.options).toHaveProperty("pagination"); + }); + + it("keeps the existing options on the table data if called multiple times", () => { + const globalMocks = createGlobalMocks(); + let builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).data?.options).toBe(undefined); + builder = builder.options({ + suppressAutoSize: true, + }); + expect((builder as any).data.options).toHaveProperty("suppressAutoSize"); + builder = builder.options({ + paginationPageSize: 50, + }); + expect((builder as any).data.options).toStrictEqual({ + suppressAutoSize: true, + paginationPageSize: 50, + }); + }); + }); + + describe("title", () => { + it("sets the given title on the table data, returning the same instance", () => { + const globalMocks = createGlobalMocks(); + let builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).data.title).toBe(""); + const title = "An incredulously long title for a table such that nobody should have to bear witness to such a tragedy"; + builder = builder.title(title); + expect((builder as any).data.title).toBe(title); + }); + }); + + describe("rows", () => { + it("sets the given rows for the table, returning the same instance", () => { + const globalMocks = createGlobalMocks(); + let builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).data.rows).toStrictEqual([]); + const newRows = [ + { a: 1, b: 2, c: 3, d: false }, + { a: 3, b: 2, c: 1, d: true }, + ]; + builder = builder.rows(...newRows); + expect((builder as any).data.rows).toStrictEqual(newRows); + }); + }); + + describe("addRows", () => { + it("adds the given rows to the table, returning the same instance", () => { + const globalMocks = createGlobalMocks(); + let builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).data.rows).toStrictEqual([]); + const newRows = [ + { a: 1, b: 2, c: 3, d: false }, + { a: 3, b: 2, c: 1, d: true }, + ]; + builder = builder.rows(...newRows); + newRows.push({ a: 2, b: 1, c: 3, d: false }); + builder = builder.addRows([newRows[newRows.length - 1]]); + expect((builder as any).data.rows).toStrictEqual(newRows); + }); + }); + + describe("columns", () => { + it("sets the given columns for the table, returning the same instance", () => { + const globalMocks = createGlobalMocks(); + let builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).data.columns).toStrictEqual([]); + const newCols: Table.ColumnOpts[] = [{ field: "cat" }, { field: "doge", filter: true }, { field: "parrot", sort: "asc" }]; + builder = builder.columns(...newCols); + expect(JSON.parse(JSON.stringify((builder as any).data.columns))).toStrictEqual(JSON.parse(JSON.stringify(newCols))); + }); + }); + + describe("addColumns", () => { + it("adds the given columns to the table, returning the same instance", () => { + const globalMocks = createGlobalMocks(); + let builder = new TableBuilder(globalMocks.context as any); + expect((builder as any).data.columns).toStrictEqual([]); + const newCols: Table.ColumnOpts[] = [{ field: "cat" }, { field: "doge", filter: true }, { field: "parrot", sort: "asc" }]; + builder = builder.columns(...newCols); + newCols.push({ field: "parakeet", sort: "desc" }); + builder = builder.addColumns([newCols[newCols.length - 1]]); + expect(JSON.parse(JSON.stringify((builder as any).data.columns))).toStrictEqual(JSON.parse(JSON.stringify(newCols))); + }); + }); + + describe("convertColumnOpts", () => { + it("converts an array of ColumnOpts to an array of Column", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + const newCols: Table.ColumnOpts[] = [ + { field: "cat", valueFormatter: (data: { value: Table.ContentTypes }) => `val: ${data.value.toString()}` }, + { field: "doge", filter: true, comparator: (valueA, valueB, nodeA, nodeB, isDescending) => -1, colSpan: (params) => 2 }, + { field: "parrot", sort: "asc", rowSpan: (params) => 2 }, + ]; + expect((builder as any).convertColumnOpts(newCols)).toStrictEqual( + newCols.map((col) => ({ + ...col, + comparator: col.comparator?.toString(), + colSpan: col.colSpan?.toString(), + rowSpan: col.rowSpan?.toString(), + valueFormatter: col.valueFormatter?.toString(), + })) + ); + }); + }); + + describe("contextOptions", () => { + it("adds the given context options and returns the same instance", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + const ctxOpts = { + all: [ + { + title: "Add to queue", + command: "add-to-queue", + callback: { + typ: "cell", + fn: (_cell: Table.ContentTypes) => {}, + }, + condition: (_data) => true, + }, + ], + } as Record; + + const addCtxOptSpy = jest.spyOn(builder, "addContextOption"); + const builderCtxOpts = builder.contextOptions(ctxOpts); + expect(builderCtxOpts).toBeInstanceOf(TableBuilder); + expect(addCtxOptSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("addContextOption", () => { + it("adds the given context option with conditional and returns the same instance", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + (builder as any).data.contextOpts = { all: [] }; + const ctxOpt = { + title: "Delete", + command: "delete", + callback: { + typ: "row", + fn: (_row: Table.RowData) => {}, + }, + condition: (_data) => true, + } as Table.ContextMenuOpts; + + // case 0: adding context option w/ conditional to "all" rows, index previously existed + const builderCtxOpts = builder.addContextOption("all", ctxOpt); + expect(builderCtxOpts).toBeInstanceOf(TableBuilder); + expect((builderCtxOpts as any).data.contextOpts).toStrictEqual({ + all: [{ ...ctxOpt, condition: ctxOpt.condition?.toString() }], + }); + + // case 1: adding context option w/ conditional to a specific row, index did not already exist + const finalBuilder = builderCtxOpts.addContextOption(0, ctxOpt); + expect((finalBuilder as any).data.contextOpts).toStrictEqual({ + 0: [{ ...ctxOpt, condition: ctxOpt.condition?.toString() }], + all: [{ ...ctxOpt, condition: ctxOpt.condition?.toString() }], + }); + }); + + it("adds the given context option without conditional and returns the same instance", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + (builder as any).data.contextOpts = { all: [] }; + const ctxOptNoCond = { + title: "Add", + command: "add", + callback: { + typ: "row", + fn: (_row: Table.RowData) => {}, + }, + } as Table.ContextMenuOpts; + + // case 0: adding context option w/ condition to "all" rows, index previously existed + const builderCtxOpts = builder.addContextOption("all", ctxOptNoCond); + expect(builderCtxOpts).toBeInstanceOf(TableBuilder); + expect((builderCtxOpts as any).data.contextOpts).toStrictEqual({ + all: [{ ...ctxOptNoCond, condition: ctxOptNoCond.condition?.toString() }], + }); + + // case 1: adding context option w/ condition to a specific row, index did not already exist + const finalBuilder = builderCtxOpts.addContextOption(0, ctxOptNoCond); + expect((finalBuilder as any).data.contextOpts).toStrictEqual({ + 0: [{ ...ctxOptNoCond, condition: ctxOptNoCond.condition?.toString() }], + all: [{ ...ctxOptNoCond, condition: ctxOptNoCond.condition?.toString() }], + }); + }); + }); + + describe("addRowAction", () => { + it("adds the given row action to all rows and returns the same instance", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + const rowAction = { + title: "Move", + command: "move", + callback: { + typ: "cell", + fn: (_cell: Table.ContentTypes) => {}, + }, + condition: (_data) => true, + } as Table.ActionOpts; + const builderAction = builder.addRowAction("all", rowAction); + expect(builderAction).toBeInstanceOf(TableBuilder); + expect((builderAction as any).data.actions).toStrictEqual({ + all: [{ ...rowAction, condition: rowAction.condition?.toString() }], + }); + const finalBuilder = builderAction.addRowAction(0, rowAction); + expect((finalBuilder as any).data.actions).toStrictEqual({ + 0: [{ ...rowAction, condition: rowAction.condition?.toString() }], + all: [{ ...rowAction, condition: rowAction.condition?.toString() }], + }); + }); + }); + + describe("rowActions", () => { + it("calls rowAction for each action and returns the same instance", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + const rowActions = { + 0: [ + { + title: "Move", + command: "move", + callback: { + typ: "cell", + fn: (_cell: Table.ContentTypes) => {}, + }, + condition: (_data) => true, + }, + ] as Table.ActionOpts[], + all: [ + { + title: "Recall", + command: "recall", + callback: { + typ: "cell", + fn: (_cell: Table.ContentTypes) => {}, + }, + condition: (_data) => true, + }, + ] as Table.ActionOpts[], + }; + const rowActionSpy = jest.spyOn(builder, "addRowAction"); + const builderAction = builder.rowActions(rowActions); + expect(rowActionSpy).toHaveBeenCalledTimes(2); + expect(builderAction).toBeInstanceOf(TableBuilder); + }); + }); + + describe("build", () => { + it("builds the table view and constructs column definitions if needed", () => { + const globalMocks = createGlobalMocks(); + const newRows = [ + { a: 1, b: 2, c: 3, d: false, e: 5 }, + { a: 3, b: 2, c: 1, d: true, e: 6 }, + ]; + const builder = new TableBuilder(globalMocks.context as any).addRows(newRows); + const instance = builder.build(); + expect((instance as any).data.columns).toStrictEqual([{ field: "a" }, { field: "b" }, { field: "c" }, { field: "d" }, { field: "e" }]); + }); + + it("builds the table view", () => { + const globalMocks = createGlobalMocks(); + const builder = new TableBuilder(globalMocks.context as any); + const instance = builder.build(); + expect((instance as any).data.columns).toHaveLength(0); + }); + }); + + describe("buildAndShare", () => { + it("builds the table view and adds it to the table mediator", () => { + const globalMocks = createGlobalMocks(); + const newRows = [ + { a: 1, b: 2, c: 3, d: false, e: 5 }, + { a: 3, b: 2, c: 1, d: true, e: 6 }, + ]; + const addTableSpy = jest.spyOn(TableMediator.prototype, "addTable"); + const builder = new TableBuilder(globalMocks.context as any) + .addRows(newRows) + .addColumns([{ field: "a" }, { field: "b" }, { field: "c" }, { field: "d" }, { field: "e" }]); + const instance = builder.buildAndShare(); + expect(addTableSpy).toHaveBeenCalledWith(instance); + }); + }); + + describe("reset", () => { + it("resets all table data on the builder instance", () => { + const globalMocks = createGlobalMocks(); + const newRows = [ + { a: 1, b: 2, c: 3, d: false }, + { a: 3, b: 2, c: 1, d: true }, + ]; + const builder = new TableBuilder(globalMocks.context as any) + .rows(...newRows) + .title("A table") + .options({ pagination: false }); + builder.reset(); + expect((builder as any).data).toStrictEqual({ + actions: { + all: [], + }, + contextOpts: { + all: [], + }, + columns: [], + rows: [], + title: "", + }); + }); + }); +}); diff --git a/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/utils/TableMediator.unit.test.ts b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/utils/TableMediator.unit.test.ts new file mode 100644 index 0000000000..bbca697f6f --- /dev/null +++ b/packages/zowe-explorer-api/__tests__/__unit__/vscode/ui/utils/TableMediator.unit.test.ts @@ -0,0 +1,86 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { TableBuilder, TableMediator } from "../../../../../src/"; +import * as vscode from "vscode"; + +// TableMediator unit tests + +// Global mocks for building a table view and using it within test cases. +function createGlobalMocks() { + const mockPanel = { + onDidDispose: (_fn) => {}, + webview: { asWebviewUri: (uri) => uri.toString(), onDidReceiveMessage: (_fn) => {} }, + }; + // Mock `vscode.window.createWebviewPanel` to return a usable panel object + const createWebviewPanelMock = jest.spyOn(vscode.window, "createWebviewPanel").mockReturnValueOnce(mockPanel as any); + + const extensionContext = { + extensionPath: "/a/b/c/zowe-explorer", + extension: { id: "Zowe.vscode-extension-for-zowe" }, + }; + + // Example table for use with the mediator + const table = new TableBuilder(extensionContext as any).title("SomeTable").build(); + + return { + createWebviewPanelMock, + extensionContext, + mockPanel, + table, + }; +} + +describe("TableMediator", () => { + describe("getInstance", () => { + it("returns an instance of TableMediator", () => { + expect(TableMediator.getInstance()).toBeInstanceOf(TableMediator); + }); + }); + + describe("addTable", () => { + it("adds the given table object to its internal map", () => { + const globalMocks = createGlobalMocks(); + TableMediator.getInstance().addTable(globalMocks.table); + expect((TableMediator.getInstance() as any).tables.get(globalMocks.table.getId())).toBe(globalMocks.table); + (TableMediator.getInstance() as any).tables = new Map(); + }); + }); + + describe("getTable", () => { + it("retrieves the table by ID using its internal map", () => { + const globalMocks = createGlobalMocks(); + const tableId = globalMocks.table.getId(); + TableMediator.getInstance().addTable(globalMocks.table); + expect(TableMediator.getInstance().getTable(tableId)).toBe(globalMocks.table); + (TableMediator.getInstance() as any).tables = new Map(); + }); + }); + + describe("removeTable", () => { + it("removes a table view from its internal map", () => { + const globalMocks = createGlobalMocks(); + const tableId = globalMocks.table.getId(); + TableMediator.getInstance().addTable(globalMocks.table); + expect(TableMediator.getInstance().removeTable(globalMocks.table)).toBe(true); + expect((TableMediator.getInstance() as any).tables.get(globalMocks.table.getId())).toBe(undefined); + expect(TableMediator.getInstance().getTable(tableId)).toBe(undefined); + }); + + it("returns false if the table instance does not exist in the map", () => { + const globalMocks = createGlobalMocks(); + globalMocks.createWebviewPanelMock.mockReturnValueOnce(globalMocks.mockPanel as any); + const table2 = new TableBuilder(globalMocks.extensionContext as any).build(); + TableMediator.getInstance().addTable(globalMocks.table); + expect(TableMediator.getInstance().removeTable(table2)).toBe(false); + }); + }); +}); diff --git a/packages/zowe-explorer-api/package.json b/packages/zowe-explorer-api/package.json index 9fc5242d19..fd8d8e96ea 100644 --- a/packages/zowe-explorer-api/package.json +++ b/packages/zowe-explorer-api/package.json @@ -37,6 +37,7 @@ "@zowe/zos-tso-for-zowe-sdk": "8.0.0-next.202407232256", "@zowe/zos-uss-for-zowe-sdk": "8.0.0-next.202407232256", "@zowe/zosmf-for-zowe-sdk": "8.0.0-next.202407232256", + "deep-object-diff": "^1.1.9", "mustache": "^4.2.0", "semver": "^7.6.0" }, diff --git a/packages/zowe-explorer-api/src/Types.ts b/packages/zowe-explorer-api/src/Types.ts index 70879f1e80..b0fbe36cd2 100644 --- a/packages/zowe-explorer-api/src/Types.ts +++ b/packages/zowe-explorer-api/src/Types.ts @@ -36,6 +36,7 @@ export namespace Types { export type WebviewUris = { build: Uri; script: Uri; + css?: Uri; }; export type FileAttributes = { diff --git a/packages/zowe-explorer-api/src/vscode/ui/TableView.ts b/packages/zowe-explorer-api/src/vscode/ui/TableView.ts new file mode 100644 index 0000000000..597c1963fa --- /dev/null +++ b/packages/zowe-explorer-api/src/vscode/ui/TableView.ts @@ -0,0 +1,570 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { UriPair, WebView } from "./WebView"; +import { Event, EventEmitter, ExtensionContext, env } from "vscode"; +import { randomUUID } from "crypto"; +import { diff } from "deep-object-diff"; +import { TableMediator } from "./utils/TableMediator"; + +export namespace Table { + /* The types of supported content for the table and how they are represented in callback functions. */ + export type ContentTypes = string | number | boolean | string[]; + export type RowData = Record; + export type ColData = RowData; + + export type RowInfo = { + index?: number; + row: RowData; + }; + + /* Defines the supported callbacks and related types. */ + export type CallbackTypes = "single-row" | "multi-row" | "column" | "cell"; + export type SingleRowCallback = { + /** The type of callback */ + typ: "single-row"; + /** The callback function itself - called from within the webview container. */ + fn: (view: Table.View, row: RowInfo) => void | PromiseLike; + }; + export type MultiRowCallback = { + /** The type of callback */ + typ: "multi-row"; + /** The callback function itself - called from within the webview container. */ + fn: (view: Table.View, rows: Record) => void | PromiseLike; + }; + export type CellCallback = { + /** The type of callback */ + typ: "cell"; + /** The callback function itself - called from within the webview container. */ + fn: (view: Table.View, cell: ContentTypes) => void | PromiseLike; + }; + export type ColumnCallback = { + /** The type of callback */ + typ: "column"; + /** The callback function itself - called from within the webview container. */ + fn: (view: Table.View, col: ColData) => void | PromiseLike; + }; + + export type Callback = SingleRowCallback | MultiRowCallback | CellCallback; + + /** Conditional callback function - whether an action or option should be rendered. */ + export type Conditional = (data: RowData | ContentTypes) => boolean; + + // Defines the supported actions and related types. + export type ActionKind = "primary" | "secondary" | "icon"; + export type Action = { + title: string; + command: string; + type?: ActionKind; + /** Stringified function will be called from within the webview container. */ + condition?: string; + callback: Callback; + }; + export type ContextMenuOption = Omit & { dataType?: CallbackTypes }; + + // Helper types to allow passing function properties to builder/view functions. + export type ActionOpts = Omit & { condition?: Conditional }; + export type ContextMenuOpts = Omit & { condition?: Conditional }; + + // -- Misc types -- + /** Value formatter callback. Expects the exact display value to be returned. */ + export type ValueFormatter = (data: { value: ContentTypes }) => string; + export type Positions = "left" | "right"; + + /** The column type definition. All available properties are offered for AG Grid columns. */ + export type Column = { + field: string; + type?: string | string[]; + cellDataType?: boolean | string; + valueFormatter?: string; + checkboxSelection?: boolean; + icons?: { [key: string]: string }; + suppressNavigable?: boolean; + context?: any; + + // Locking and edit variables + hide?: boolean; + lockVisible?: boolean; + lockPosition?: boolean | Positions; + suppressMovable?: boolean; + editable?: boolean; + singleClickEdit?: boolean; + + filter?: boolean; + floatingFilter?: boolean; + + // Headers + + // "field" variable will be used as header name if not provided + headerName?: string; + headerTooltip?: string; + headerClass?: string | string[]; + wrapHeaderText?: boolean; + autoHeaderHeight?: boolean; + headerCheckboxSelection?: boolean; + + // Pinning + pinned?: boolean | Positions | null; + initialPinned?: boolean | Positions; + lockPinned?: boolean; + + // Row dragging + rowDrag?: boolean; + dndSource?: boolean; + + // Sorting + sortable?: boolean; + sort?: "asc" | "desc"; + initialSort?: "asc" | "desc"; + sortIndex?: number | null; + initialSortIndex?: number; + sortingOrder?: ("asc" | "desc")[]; + comparator?: string; + unSortIcon?: boolean; + + // Column/row spanning + colSpan?: string; + rowSpan?: string; + + // Sizing + width?: number; + initialWidth?: number; + minWidth?: number; + maxWidth?: number; + flex?: number; + initialFlex?: number; + resizable?: boolean; + suppressSizeToFit?: boolean; + suppressAutoSize?: boolean; + }; + export type ColumnOpts = Omit & { + comparator?: (valueA: any, valueB: any, nodeA: any, nodeB: any, isDescending: boolean) => number; + colSpan?: (params: any) => number; + rowSpan?: (params: any) => number; + valueFormatter?: ValueFormatter; + }; + + export interface SizeColumnsToFitGridStrategy { + type: "fitGridWidth"; + // Default minimum width for every column (does not override the column minimum width). + defaultMinWidth?: number; + // Default maximum width for every column (does not override the column maximum width). + defaultMaxWidth?: number; + // Provide to limit specific column widths when sizing. + columnLimits?: SizeColumnsToFitGridColumnLimits[]; + } + + export interface SizeColumnsToFitGridColumnLimits { + colId: string; + // Minimum width for this column (does not override the column minimum width) + minWidth?: number; + // Maximum width for this column (does not override the column maximum width) + maxWidth?: number; + } + + export interface SizeColumnsToFitProvidedWidthStrategy { + type: "fitProvidedWidth"; + width: number; + } + + export interface SizeColumnsToContentStrategy { + type: "fitCellContents"; + // If true, the header won't be included when calculating the column widths. + skipHeader?: boolean; + // If not provided will auto-size all columns. Otherwise will size the specified columns. + colIds?: string[]; + } + + // AG Grid: Optional properties + export type GridProperties = { + /** Allow reordering and pinning columns by dragging columns from the Columns Tool Panel to the grid */ + allowDragFromColumnsToolPanel?: boolean; + /** Number of pixels to add a column width after the auto-sizing calculation */ + autoSizePadding?: number; + /** + * Auto-size the columns when the grid is loaded. Can size to fit the grid width, fit a provided width or fit the cell contents. + * Read once during initialization. + */ + autoSizeStrategy?: SizeColumnsToFitGridStrategy | SizeColumnsToFitProvidedWidthStrategy | SizeColumnsToContentStrategy; + /** Set to 'shift' to have shift-resize as the default resize operation */ + colResizeDefault?: "shift"; + /** Changes the display type of the column menu. 'new' displays the main list of menu items; 'legacy' displays a tabbed menu */ + columnMenu?: "legacy" | "new"; + /** Set this to `true` to enable debugging information from the grid */ + debug?: boolean; + /** The height in pixels for the rows containing floating filters. */ + floatingFiltersHeight?: number; + /** The height in pixels for the rows containing header column groups. */ + groupHeaderHeight?: number; + /** The height in pixels for the row contianing the column label header. Default provided by the AG Grid theme. */ + headerHeight?: number; + /** Show/hide the "Loading" overlay */ + loading?: boolean; + /** Map of key:value pairs for localizing grid text. Read once during initialization. */ + localeText?: { [key: string]: string }; + /** Keeps the order of columns maintained after new Column Definitions are updated. */ + maintainColumnOrder?: boolean; + /** Whether the table should be split into pages. */ + pagination?: boolean; + /** + * Set to `true` so that the number of rows to load per page is automatically adjusted by the grid. + * If `false`, `paginationPageSize` is used. + */ + paginationAutoPageSize?: boolean; + /** How many rows to load per page */ + paginationPageSize?: number; + /** + * Set to an array of values to show the page size selector with custom list of possible page sizes. + * Set to `true` to show the page size selector with the default page sizes `[20, 50, 100]`. + * Set to `false` to hide the page size selector. + */ + paginationPageSizeSelector?: number[] | boolean; + /** If defined, rows are filtered using this text as a Quick Filter. */ + quickFilterText?: string; + /** Enable selection of rows in table */ + rowSelection?: "single" | "multiple"; + /** Set to `true` to skip the `headerName` when `autoSize` is called by default. Read once during initialization. */ + skipHeaderOnAutoSize?: boolean; + /** + * Suppresses auto-sizing columns for columns - when enabled, double-clicking a column's header's edge will not auto-size. + * Read once during initialization. + */ + suppressAutoSize?: boolean; + suppressColumnMoveAnimation?: boolean; + /** If `true`, when you dreag a column out of the grid, the column is not hidden */ + suppressDragLeaveHidesColumns?: boolean; + /** If `true`, then dots in field names are not treated as deep references, allowing you to use dots in your field name if preferred. */ + suppressFieldDotNotation?: boolean; + /** + * When `true`, the column menu button will always be shown. + * When `false`, the column menu button will only show when the mouse is over the column header. + * If `columnMenu = 'legacy'`, this will default to `false` instead of `true`. + */ + suppressMenuHide?: boolean; + /** Set to `true` to suppress column moving (fixed position for columns) */ + suppressMovableColumns?: boolean; + }; + + export type ViewOpts = { + /** Actions to apply to the given row or column index */ + actions: Record; + /** Column definitions for the top of the table */ + columns: Column[]; + /** Context menu options for rows in the table */ + contextOpts: Record; + /** The row data for the table. Each row contains a set of variables corresponding to the data for each column in that row. */ + rows: RowData[]; + /** The display title for the table */ + title?: string; + /** AG Grid-specific properties */ + options?: GridProperties; + }; + + export type EditEvent = { + rowIndex: number; + field: string; + value: ContentTypes; + oldValue?: ContentTypes; + }; + + /** + * A class that acts as a controller between the extension and the table view. Based off of the {@link WebView} class. + * + * @remarks + * ## Usage + * + * To easily configure a table before creating a table view, + * use the `TableBuilder` class to prepare table data and build an instance. + */ + export class View extends WebView { + private lastUpdated: ViewOpts; + private data: ViewOpts = { + actions: { + all: [], + }, + contextOpts: { + all: [], + }, + rows: [], + columns: [], + title: "", + }; + private onTableDataReceivedEmitter: EventEmitter> = new EventEmitter(); + private onTableDisplayChangedEmitter: EventEmitter = new EventEmitter(); + private onTableDataEditedEmitter: EventEmitter = new EventEmitter(); + public onTableDisplayChanged: Event = this.onTableDisplayChangedEmitter.event; + public onTableDataReceived: Event> = this.onTableDataReceivedEmitter.event; + public onTableDataEdited: Event = this.onTableDataEditedEmitter.event; + + private uuid: string; + + public getUris(): UriPair { + return this.uris; + } + + public getHtml(): string { + return this.htmlContent; + } + + public constructor(context: ExtensionContext, isView?: boolean, data?: ViewOpts) { + super(data?.title ?? "Table view", "table-view", context, { + onDidReceiveMessage: (message) => this.onMessageReceived(message), + isView, + unsafeEval: true, + }); + if (data) { + this.data = data; + } + } + + /** + * (Receiver) message handler for the table view. + * Used to dispatch client-side updates of the table to subscribers when the table's display has changed. + * + * @param message The message received from the webview + */ + public async onMessageReceived(message: any): Promise { + if (!("command" in message)) { + return; + } + switch (message.command) { + // "ontableedited" command: The table's contents were updated by the user from within the webview. + // Fires for editable columns only. + case "ontableedited": + this.onTableDataEditedEmitter.fire(message.data); + return; + // "ondisplaychanged" command: The table's layout was updated by the user from within the webview. + case "ondisplaychanged": + this.onTableDisplayChangedEmitter.fire(message.data); + return; + // "ready" command: The table view has attached its message listener and is ready to receive data. + case "ready": + await this.updateWebview(); + return; + // "copy" command: Copy the data for the row that was right-clicked. + case "copy": + await env.clipboard.writeText(JSON.stringify(message.data.row)); + return; + case "copy-cell": + await env.clipboard.writeText(message.data.cell); + return; + default: + break; + } + + const row: number = message.rowIndex ?? 0; + const matchingActionable = [ + ...(this.data.actions[row] ?? []), + ...this.data.actions.all, + ...(this.data.contextOpts[row] ?? []), + ...this.data.contextOpts.all, + ].find((action) => action.command === message.command); + if (matchingActionable != null) { + switch (matchingActionable.callback.typ) { + case "single-row": + await matchingActionable.callback.fn(this, { index: message.data.rowIndex, row: message.data.row }); + break; + case "multi-row": + await matchingActionable.callback.fn(this, message.data.rows); + break; + case "cell": + await matchingActionable.callback.fn(this, message.data.cell); + break; + // TODO: Support column callbacks? (if there's enough interest) + default: + break; + } + } + } + + /** + * (Sender) message handler for the table view. + * Used to send data and table layout changes to the table view to be re-rendered. + * + * @returns Whether the webview received the update that was sent + */ + private async updateWebview(): Promise { + const result = await (this.panel ?? this.view).webview.postMessage({ + command: "ondatachanged", + data: this.data, + }); + + if (result) { + this.onTableDataReceivedEmitter.fire(this.lastUpdated ? diff(this.lastUpdated, this.data) : this.data); + this.lastUpdated = this.data; + } + return result; + } + + /** + * Access the unique ID for the table view instance. + * + * @returns The unique ID for this table view + */ + public getId(): string { + this.uuid ??= randomUUID(); + return `${this.data.title}-${this.uuid.substring(0, this.uuid.indexOf("-"))}##${this.context.extension.id}`; + } + + /** + * Add one or more actions to the given row. + * + * @param index The row index where the action should be displayed + * @param actions The actions to add to the given row + * + * @returns Whether the webview successfully received the new action(s) + */ + public addAction(index: number | "all", ...actions: ActionOpts[]): Promise { + if (this.data.actions[index]) { + const existingActions = this.data.actions[index]; + this.data.actions[index] = [...existingActions, ...actions.map((action) => ({ ...action, condition: action.condition?.toString() }))]; + } else { + this.data.actions[index] = actions.map((action) => ({ ...action, condition: action.condition?.toString() })); + } + return this.updateWebview(); + } + + /** + * Add one or more context menu options to the given row. + * + * @param id The row index or column ID where the action should be displayed + * @param actions The actions to add to the given row + * @returns Whether the webview successfully received the new context menu option(s) + */ + public addContextOption(id: number | "all", ...options: ContextMenuOpts[]): Promise { + if (this.data.contextOpts[id]) { + const existingOpts = this.data.contextOpts[id]; + this.data.contextOpts[id] = [...existingOpts, ...options.map((option) => ({ ...option, condition: option.condition?.toString() }))]; + } else { + this.data.contextOpts[id] = options.map((option) => ({ ...option, condition: option.condition?.toString() })); + } + return this.updateWebview(); + } + + /** + * Get rows of content from the table view. + * @param rows The rows of data in the table + * @returns Whether the webview successfully received the new content + */ + public getContent(): RowData[] { + return this.data.rows; + } + + /** + * Add rows of content to the table view. + * @param rows The rows of data to add to the table + * @returns Whether the webview successfully received the new content + */ + public async addContent(...rows: RowData[]): Promise { + this.data.rows.push(...rows); + return this.updateWebview(); + } + + /** + * Update an existing row in the table view. + * @param index The AG GRID row index to update within the table + * @param row The new row content. If `null`, the given row index will be deleted from the list of rows. + * @returns Whether the webview successfully updated the new row + */ + public async updateRow(index: number, row: RowData | null): Promise { + if (row == null) { + this.data.rows.splice(index, 1); + } else { + this.data.rows[index] = row; + } + return this.updateWebview(); + } + + /** + * Adds headers to the end of the existing header list in the table view. + * + * @param headers The headers to add to the existing header list + * @returns Whether the webview successfully received the list of headers + */ + public async addColumns(...columns: ColumnOpts[]): Promise { + this.data.columns.push( + ...columns.map((col) => ({ + ...col, + comparator: col.comparator?.toString(), + colSpan: col.colSpan?.toString(), + rowSpan: col.rowSpan?.toString(), + valueFormatter: col.valueFormatter?.toString(), + })) + ); + return this.updateWebview(); + } + + /** + * Sets the content for the table; replaces any pre-existing content. + * + * @param rows The rows of data to apply to the table + * @returns Whether the webview successfully received the new content + */ + public async setContent(rows: RowData[]): Promise { + this.data.rows = rows; + return this.updateWebview(); + } + + /** + * Sets the headers for the table. + * + * @param headers The new headers to use for the table + * @returns Whether the webview successfully received the new headers + */ + public async setColumns(columns: ColumnOpts[]): Promise { + this.data.columns = columns.map((col) => ({ + ...col, + comparator: col.comparator?.toString(), + colSpan: col.colSpan?.toString(), + rowSpan: col.rowSpan?.toString(), + valueFormatter: col.valueFormatter?.toString(), + })); + return this.updateWebview(); + } + + /** + * Sets the options for the table. + * + * @param opts The optional grid properties for the table + * @returns Whether the webview successfully received the new options + */ + public setOptions(opts: GridProperties): Promise { + this.data = { ...this.data, options: this.data.options ? { ...this.data.options, ...opts } : opts }; + return this.updateWebview(); + } + + /** + * Sets the display title for the table view. + * + * @param title The new title for the table + * @returns Whether the webview successfully received the new title + */ + public async setTitle(title: string): Promise { + this.data.title = title; + return this.updateWebview(); + } + } + + export class Instance extends View { + public constructor(context: ExtensionContext, isView: boolean, data: Table.ViewOpts) { + super(context, isView, data); + } + + /** + * Closes the table view and marks it as disposed. + * Removes the table instance from the mediator if it exists. + */ + public dispose(): void { + TableMediator.getInstance().removeTable(this); + super.dispose(); + } + } +} diff --git a/packages/zowe-explorer-api/src/vscode/ui/TableViewProvider.ts b/packages/zowe-explorer-api/src/vscode/ui/TableViewProvider.ts new file mode 100644 index 0000000000..5e94f833fd --- /dev/null +++ b/packages/zowe-explorer-api/src/vscode/ui/TableViewProvider.ts @@ -0,0 +1,105 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { CancellationToken, WebviewView, WebviewViewProvider, WebviewViewResolveContext } from "vscode"; +import { Table } from "./TableView"; + +/** + * View provider class for rendering table views in the "Zowe Resources" panel. + * Registered during initialization of Zowe Explorer. + * + * @remarks + * ## Usage + * + * ### Setting the current table view + * + * Use the {@link setTableView} function to set a table view for the "Zowe Resources" panel. + * _Note_ that setting another table view on the view provider will dispose of the last table instance that was provided. + * + * ### Getting the current table view + * + * Use the {@link getTableView} function to get the current table view in the "Zowe Resources" panel. + * + * ### resolveWebviewView + * + * VS Code uses this function to resolve the instance of the table view to render. **Please do not use this function directly** - + * use {@link setTableView} instead to provide the table view. + * + * Calling the function directly will interfere with the intended behavior of + * the "Zowe Resources" panel and is therefore not supported. + */ +export class TableViewProvider implements WebviewViewProvider { + private view?: WebviewView; + private tableView: Table.Instance = null; + + private static instance: TableViewProvider; + + private constructor() {} + + /** + * Retrieve the singleton instance of the TableViewProvider. + * @returns the TableViewProvider instance used by Zowe Explorer + */ + public static getInstance(): TableViewProvider { + if (!this.instance) { + this.instance = new TableViewProvider(); + } + + return this.instance; + } + + /** + * Provide a table view to display in the "Zowe Resources" view. + * @param tableView The table view to prepare for rendering + */ + public setTableView(tableView: Table.Instance | null): void { + if (this.tableView != null) { + this.tableView.dispose(); + } + this.tableView = tableView; + + if (tableView == null) { + if (this.view != null) { + this.view.webview.html = ""; + } + return; + } + + if (this.view) { + this.tableView.resolveForView(this.view); + } + } + + /** + * Retrieve the current table view for the "Zowe Resources" panel, if one exists. + */ + public getTableView(): Table.View { + return this.tableView; + } + + /** + * VS Code internal function used to resolve the webview view from the view provider. + * @param webviewView The webview view object for the panel + * @param context Additional context for the webview view + * @param token (unused) cancellation token for the webview view resolution process + */ + public resolveWebviewView( + webviewView: WebviewView, + context: WebviewViewResolveContext, + token: CancellationToken + ): void | Thenable { + this.view = webviewView; + + if (this.tableView != null) { + this.tableView.resolveForView(this.view); + } + } +} diff --git a/packages/zowe-explorer-api/src/vscode/ui/WebView.ts b/packages/zowe-explorer-api/src/vscode/ui/WebView.ts index 847060c3ad..6d461d1820 100644 --- a/packages/zowe-explorer-api/src/vscode/ui/WebView.ts +++ b/packages/zowe-explorer-api/src/vscode/ui/WebView.ts @@ -9,30 +9,50 @@ * */ +import * as fs from "fs"; import Mustache = require("mustache"); import HTMLTemplate from "./utils/HTMLTemplate"; import { Types } from "../../Types"; -import { Disposable, ExtensionContext, Uri, ViewColumn, WebviewPanel, window } from "vscode"; +import { Disposable, ExtensionContext, Uri, ViewColumn, WebviewPanel, WebviewView, window } from "vscode"; import { join as joinPath } from "path"; import { randomUUID } from "crypto"; +export type WebViewOpts = { + /** Callback function that is called when the extension has received a message from the webview. */ + onDidReceiveMessage?: (message: object) => void | Promise; + /** Retains context of the webview even after it is hidden. */ + retainContext?: boolean; + /** Whether the webview should be prepared for a WebviewViewProvider. */ + isView?: boolean; + /** Allow evaluation of functions within the webview script code. */ + unsafeEval?: boolean; +}; + +export type UriPair = { + /** The paths for the webview on-disk. */ + disk?: Types.WebviewUris; + /** The paths for the webview resources, before transformation by the `asWebviewUri` function. */ + resource?: Types.WebviewUris; +}; + export class WebView { - private disposables: Disposable[]; + protected disposables: Disposable[]; // The webview HTML content to render after filling the HTML template. - private webviewContent: string; + protected webviewContent: string; public panel: WebviewPanel; + public view: WebviewView; // Resource identifiers for the on-disk content and vscode-webview resource. - private uris: { - disk?: Types.WebviewUris; - resource?: Types.WebviewUris; - } = {}; + protected uris: UriPair = {}; // Unique identifier private nonce: string; + protected title: string; + + protected context: ExtensionContext; - private title: string; + private webviewOpts: WebViewOpts; /** * Constructs a webview for use with bundled assets. @@ -43,55 +63,88 @@ export class WebView { * @param context The VSCode extension context * @param onDidReceiveMessage Event callback: called when messages are received from the webview */ - public constructor( - title: string, - webviewName: string, - context: ExtensionContext, - onDidReceiveMessage?: (message: object) => void | Promise, - retainContext?: boolean - ) { + public constructor(title: string, webviewName: string, context: ExtensionContext, opts?: WebViewOpts) { + this.context = context; this.disposables = []; // Generate random nonce for loading the bundled script this.nonce = randomUUID(); this.title = title; + this.webviewOpts = opts; + + const cssPath = joinPath(context.extensionPath, "src", "webviews", "dist", "style", "style.css"); + const cssExists = fs.existsSync(cssPath); + // Build URIs for the webview directory and get the paths as VScode resources this.uris.disk = { build: Uri.file(joinPath(context.extensionPath, "src", "webviews")), script: Uri.file(joinPath(context.extensionPath, "src", "webviews", "dist", webviewName, `${webviewName}.js`)), + css: cssExists ? Uri.file(cssPath) : undefined, }; - this.panel = window.createWebviewPanel("ZEAPIWebview", this.title, ViewColumn.Beside, { + if (!(opts?.isView ?? false)) { + this.panel = window.createWebviewPanel("ZEAPIWebview", this.title, ViewColumn.Beside, { + enableScripts: true, + localResourceRoots: [this.uris.disk.build], + retainContextWhenHidden: opts?.retainContext ?? false, + }); + + // Associate URI resources with webview + this.uris.resource = { + build: this.panel.webview.asWebviewUri(this.uris.disk.build), + script: this.panel.webview.asWebviewUri(this.uris.disk.script), + css: this.uris.disk.css ? this.panel.webview.asWebviewUri(this.uris.disk.css) : undefined, + }; + + const builtHtml = Mustache.render(HTMLTemplate, { + unsafeEval: this.webviewOpts?.unsafeEval, + uris: this.uris, + nonce: this.nonce, + title: this.title, + }); + this.webviewContent = builtHtml; + if (opts?.onDidReceiveMessage) { + this.panel.webview.onDidReceiveMessage(async (message) => opts.onDidReceiveMessage(message)); + } + this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + this.panel.webview.html = this.webviewContent; + } + } + + public resolveForView(webviewView: WebviewView): void { + webviewView.webview.options = { enableScripts: true, localResourceRoots: [this.uris.disk.build], - retainContextWhenHidden: retainContext ?? false, - }); + }; // Associate URI resources with webview this.uris.resource = { - build: this.panel.webview.asWebviewUri(this.uris.disk.build), - script: this.panel.webview.asWebviewUri(this.uris.disk.script), + build: webviewView.webview.asWebviewUri(this.uris.disk.build), + script: webviewView.webview.asWebviewUri(this.uris.disk.script), + css: this.uris.disk.css ? webviewView.webview.asWebviewUri(this.uris.disk.css) : undefined, }; const builtHtml = Mustache.render(HTMLTemplate, { + unsafeEval: this.webviewOpts?.unsafeEval, uris: this.uris, nonce: this.nonce, title: this.title, }); this.webviewContent = builtHtml; - if (onDidReceiveMessage) { - this.panel.webview.onDidReceiveMessage(async (message) => onDidReceiveMessage(message)); + if (this.webviewOpts?.onDidReceiveMessage) { + webviewView.webview.onDidReceiveMessage(async (message) => this.webviewOpts.onDidReceiveMessage(message)); } - this.panel.onDidDispose(() => this.dispose(), null, this.disposables); - this.panel.webview.html = this.webviewContent; + webviewView.onDidDispose(() => this.dispose(), null, this.disposables); + webviewView.webview.html = this.webviewContent; + this.view = webviewView; } /** * Disposes of the webview instance */ - private dispose(): void { - this.panel.dispose(); + protected dispose(): void { + this.panel?.dispose(); for (const disp of this.disposables) { disp.dispose(); diff --git a/packages/zowe-explorer-api/src/vscode/ui/index.ts b/packages/zowe-explorer-api/src/vscode/ui/index.ts index 739e319f3b..5d275adf11 100644 --- a/packages/zowe-explorer-api/src/vscode/ui/index.ts +++ b/packages/zowe-explorer-api/src/vscode/ui/index.ts @@ -9,4 +9,8 @@ * */ +export * from "./utils/TableBuilder"; +export * from "./utils/TableMediator"; +export * from "./TableView"; +export * from "./TableViewProvider"; export * from "./WebView"; diff --git a/packages/zowe-explorer-api/src/vscode/ui/utils/HTMLTemplate.ts b/packages/zowe-explorer-api/src/vscode/ui/utils/HTMLTemplate.ts index b3095d5f09..964734ab84 100644 --- a/packages/zowe-explorer-api/src/vscode/ui/utils/HTMLTemplate.ts +++ b/packages/zowe-explorer-api/src/vscode/ui/utils/HTMLTemplate.ts @@ -21,7 +21,7 @@ const HTMLTemplate: string = ` diff --git a/packages/zowe-explorer-api/src/vscode/ui/utils/TableBuilder.ts b/packages/zowe-explorer-api/src/vscode/ui/utils/TableBuilder.ts new file mode 100644 index 0000000000..6a56f74fec --- /dev/null +++ b/packages/zowe-explorer-api/src/vscode/ui/utils/TableBuilder.ts @@ -0,0 +1,219 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { ExtensionContext } from "vscode"; +import { Table } from "../TableView"; +import { TableMediator } from "./TableMediator"; + +/** + * A builder class for quickly instantiating {@link Table.View} instances. + * Especially useful for building multiple tables with similar layouts or data. + * + * @remarks + * + * ## Building a table + * + * Developers can build a table using the helper methods provided by the builder. + * Once a table is ready to be built, use the {@link TableBuilder.build} function to create + * a new {@link Table.View} instance with the given configuration. + * + * ## Sharing tables + * + * Share a table during the build process by using the {@link TableBuilder.buildAndShare} function. + * This function will create the {@link Table.View} instance while also adding it to the mediator + * in Zowe Explorer's extender API. Extenders who would like to contribute to shared tables can do + * so by accessing the table by its unique ID. + */ +export class TableBuilder { + private context: ExtensionContext; + private data: Table.ViewOpts; + private forWebviewView = false; + + public constructor(context: ExtensionContext) { + this.reset(); + this.context = context; + } + + /** + * Prepares the table to be rendered within a WebviewViewProvider (such as the "Zowe Resources" panel) + * @returns The instance of the TableBuilder, with the private `forWebviewView` flag set to `true` + */ + public isView(): this { + this.forWebviewView = true; + return this; + } + + /** + * Set optional properties for the table view. + * @param opts The options for the table + * @returns The same {@link TableBuilder} instance with the options added + */ + public options(opts: Table.GridProperties): this { + this.data = { ...this.data, options: this.data.options ? { ...this.data.options, ...opts } : opts }; + return this; + } + + /** + * Set the title for the next table. + * @param name The name of the table + * @returns The same {@link TableBuilder} instance with the title added + */ + public title(name: string): this { + this.data.title = name; + return this; + } + + /** + * Set the rows for the next table. + * @param rows The rows of content to use for the table + * @returns The same {@link TableBuilder} instance with the rows added + */ + public rows(...rows: Table.RowData[]): this { + this.data.rows = rows; + return this; + } + + /** + * Adds rows to the table. Does not replace existing rows. + * @param rows The rows of content to add to the table + * @returns The same {@link TableBuilder} instance with the new rows added + */ + public addRows(rows: Table.RowData[]): this { + this.data.rows = [...this.data.rows, ...rows]; + return this; + } + + /** + * Set the columns for the next table. + * @param columns The columns to use for the table + * @returns The same {@link TableBuilder} instance with the columns added + */ + public columns(...columns: Table.ColumnOpts[]): this { + this.data.columns = this.convertColumnOpts(columns); + return this; + } + + private convertColumnOpts(columns: Table.ColumnOpts[]): Table.Column[] { + return columns.map((col) => ({ + ...col, + comparator: col.comparator?.toString(), + colSpan: col.colSpan?.toString(), + rowSpan: col.rowSpan?.toString(), + valueFormatter: col.valueFormatter?.toString(), + })); + } + + /** + * Adds columns to the table. Does not replace existing columns. + * @param columns The column definitions to add to the table + * @returns The same {@link TableBuilder} instance with the new column definitions added + */ + public addColumns(columns: Table.ColumnOpts[]): this { + this.data.columns = [...this.data.columns, ...this.convertColumnOpts(columns)]; + return this; + } + + /** + * Add context options for the next table. + * @param actions the record of indices to {@link Table.Action} arrays to use for the table + * @returns The same {@link TableBuilder} instance with the row actions added + */ + public contextOptions(opts: Record): this { + for (const [key, optsForKey] of Object.entries(opts)) { + for (const opt of optsForKey) { + this.addContextOption(key as number | "all", opt); + } + } + return this; + } + + /** + * Add a context menu option to the table. + * @param index The row index to add an option to (or "all" for all rows) + * @returns The same {@link TableBuilder} instance with the context menu option added + */ + public addContextOption(index: number | "all", option: Table.ContextMenuOpts): this { + if (this.data.contextOpts[index]) { + const opts = this.data.contextOpts[index]; + this.data.contextOpts[index] = [...opts, { ...option, condition: option.condition?.toString() }]; + } else { + this.data.contextOpts[index] = [{ ...option, condition: option.condition?.toString() }]; + } + return this; + } + + /** + * Add row actions for the next table. + * @param actions the record of indices to {@link Table.Action} arrays to use for the table + * @returns The same {@link TableBuilder} instance with the row actions added + */ + public rowActions(actions: Record): this { + for (const key of Object.keys(actions)) { + this.addRowAction(key as number | "all", actions[key]); + } + return this; + } + + /** + * Add a row action to the next table. + * @param index The column index to add an action to + * @returns The same {@link TableBuilder} instance with the row action added + */ + public addRowAction(index: number | "all", action: Table.ActionOpts): this { + if (this.data.actions[index]) { + const actionList = this.data.actions[index]; + this.data.actions[index] = [...actionList, { ...action, condition: action.condition?.toString() }]; + } else { + this.data.actions[index] = [{ ...action, condition: action.condition?.toString() }]; + } + return this; + } + + /** + * Builds the table with the given data. + * @returns A new {@link Table.Instance} with the given data/options + */ + public build(): Table.Instance { + // Construct column definitions if rows were provided, but no columns are specified at time of build + if (this.data.columns.length === 0 && this.data.rows.length > 0) { + this.data.columns = Object.keys(this.data.rows[0]).map((k) => ({ field: k })); + } + + return new Table.Instance(this.context, this.forWebviewView, this.data); + } + + /** + * Builds the table with the given data and shares it with the TableMediator singleton. + * @returns A new, **shared** {@link Table.Instance} with the given data/options + */ + public buildAndShare(): Table.Instance { + const table = this.build(); + TableMediator.getInstance().addTable(table); + return table; + } + + /** + * Resets all data configured in the builder from previously-created table views. + */ + public reset(): void { + this.data = { + actions: { + all: [], + }, + contextOpts: { + all: [], + }, + columns: [], + rows: [], + title: "", + }; + } +} diff --git a/packages/zowe-explorer-api/src/vscode/ui/utils/TableMediator.ts b/packages/zowe-explorer-api/src/vscode/ui/utils/TableMediator.ts new file mode 100644 index 0000000000..ea7728fe34 --- /dev/null +++ b/packages/zowe-explorer-api/src/vscode/ui/utils/TableMediator.ts @@ -0,0 +1,98 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import type { Table } from "../TableView"; + +/** + * Mediator class for managing and accessing shared tables in Zowe Explorer. + * Developers can expose their tables to allow external extenders to contribute to them. + * This class serves to satisfy three main use cases: + * + * @remarks + * + * ## Adding a table + * + * Tables are added to the mediator based on their unique ID using {@link TableMediator.addTable}. The ID is in the following format: + * + * `-<8digit-Unique-Id>##` + * + * This avoids indirect naming conflicts by making the table identifier specific to the contributing extension. + * The table ID can be accessed directly from a {@link Table.View} instance using the {@link Table.View.getId} function. + * + * ## Accessing a table + * + * Tables are only accessible by their unique IDs using {@link TableMediator.getTable}. Extenders must communicate a table ID + * to another extender to facilitate table changes between extensions. This facilitates explicit access requests + * by ensuring that two or more extenders are coordinating changes to the same table. + * + * The map containing the tables is not publicly exposed and is defined as a + * [private class property](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties) + * to avoid exposing any tables to extenders who do not have explicit access. + * + * ## Removing a table + * + * Tables can only be removed by the extender that has contributed them using {@link TableMediator.deleteTable}. + * This establishes a read-only relationship between the mediator and extenders that have not contributed the table they are trying to access. + * + * **Note** that this does not prevent an extender with access to the ID from disposing the table, once they've received access to the instance. + */ + +export class TableMediator { + private static instance: TableMediator; + private tables: Map = new Map(); + + private constructor() {} + + /** + * Access the singleton instance. + * + * @returns the global {@link TableMediator} instance + */ + public static getInstance(): TableMediator { + if (!this.instance) { + this.instance = new TableMediator(); + } + + return this.instance; + } + + /** + * Adds a table to the mediator to enable sharing between extensions. + * + * @param table The {@link Table.View} instance to add to the mediator + */ + public addTable(table: Table.View): void { + this.tables.set(table.getId(), table); + } + + /** + * Accesses a table in the mediator based on its unique ID. + * + * @param id The unique identifier for the desired table + * + * @returns + * * {@link Table.View} instance if the table exists + * * `undefined` if the instance was deleted or does not exist + */ + public getTable(id: string): Table.View | undefined { + return this.tables.get(id); + } + + /** + * Removes a table from the mediator. + * + * @param id The unique ID of the table to delete + * @returns `true` if the table was deleted; `false` otherwise + */ + public removeTable(instance: Table.Instance): boolean { + return this.tables.delete(instance.getId()); + } +} diff --git a/packages/zowe-explorer-ftp-extension/.eslintignore b/packages/zowe-explorer-ftp-extension/.eslintignore deleted file mode 100644 index 246b4930e4..0000000000 --- a/packages/zowe-explorer-ftp-extension/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules/** -out/** -__mocks__/** -__tests__/** -*.js \ No newline at end of file diff --git a/packages/zowe-explorer/.eslintignore b/packages/zowe-explorer/.eslintignore deleted file mode 100644 index f899a8314f..0000000000 --- a/packages/zowe-explorer/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/**/* -results/**/* -out/**/* -*.config.js -**/.wdio-vscode-service/ -**/*.wdio.conf.ts \ No newline at end of file diff --git a/packages/zowe-explorer/CHANGELOG.md b/packages/zowe-explorer/CHANGELOG.md index 35cbb7eb17..bf260a6de7 100644 --- a/packages/zowe-explorer/CHANGELOG.md +++ b/packages/zowe-explorer/CHANGELOG.md @@ -44,6 +44,7 @@ All notable changes to the "vscode-extension-for-zowe" extension will be documen - Implemented the `onVaultUpdate` VSCode events to notify extenders when credentials are updated on the OS vault by other applications. [#2994](https://github.com/zowe/zowe-explorer-vscode/pull/2994) - Changed default base profile naming scheme in newly generated configuration files to prevent name and property conflicts between Global and Project profiles [#2682](https://github.com/zowe/zowe-explorer-vscode/issues/2682) - Implemented the `onCredMgrUpdate` VSCode events to notify extenders when the local PC's credential manager has been updated by other applications. [#2994](https://github.com/zowe/zowe-explorer-vscode/pull/2994) +- Implemented support for building, exposing and displaying table views within Zowe Explorer. Tables can be customized and exposed using the helper facilities (`TableBuilder` and `TableMediator`) for an extender's specific use case. For more information on how to configure and show tables, please refer to the [wiki article on Table Views](https://github.com/zowe/zowe-explorer-vscode/wiki/Table-Views). [#2258](https://github.com/zowe/zowe-explorer-vscode/issues/2258) - Added support for logging in to multiple API ML instances per team config file. [#2264](https://github.com/zowe/zowe-explorer-vscode/issues/2264) ### Bug fixes diff --git a/packages/zowe-explorer/__tests__/__mocks__/mockCreators/jobs.ts b/packages/zowe-explorer/__tests__/__mocks__/mockCreators/jobs.ts index 461372ad2f..97d29c3d92 100644 --- a/packages/zowe-explorer/__tests__/__mocks__/mockCreators/jobs.ts +++ b/packages/zowe-explorer/__tests__/__mocks__/mockCreators/jobs.ts @@ -145,7 +145,6 @@ export function createJobNode(session: any, profile: imperative.IProfileLoaded) label: "sampleJob", collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, parentNode: session.getSessionNode(), - session, profile, job: createIJobObject(), }); diff --git a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts index b7028bcc7b..2d3aa4dbfd 100644 --- a/packages/zowe-explorer/__tests__/__mocks__/vscode.ts +++ b/packages/zowe-explorer/__tests__/__mocks__/vscode.ts @@ -389,7 +389,176 @@ export interface FileDecorationProvider { provideFileDecoration(uri: Uri, token: CancellationToken): ProviderResult; } +/** + * Additional information the webview view being resolved. + * + * @param T Type of the webview's state. + */ +interface WebviewViewResolveContext { + /** + * Persisted state from the webview content. + * + * To save resources, the editor normally deallocates webview documents (the iframe content) that are not visible. + * For example, when the user collapse a view or switches to another top level activity in the sidebar, the + * `WebviewView` itself is kept alive but the webview's underlying document is deallocated. It is recreated when + * the view becomes visible again. + * + * You can prevent this behavior by setting `retainContextWhenHidden` in the `WebviewOptions`. However this + * increases resource usage and should be avoided wherever possible. Instead, you can use persisted state to + * save off a webview's state so that it can be quickly recreated as needed. + * + * To save off a persisted state, inside the webview call `acquireVsCodeApi().setState()` with + * any json serializable object. To restore the state again, call `getState()`. For example: + * + * ```js + * // Within the webview + * const vscode = acquireVsCodeApi(); + * + * // Get existing state + * const oldState = vscode.getState() || { value: 0 }; + * + * // Update state + * setState({ value: oldState.value + 1 }) + * ``` + * + * The editor ensures that the persisted state is saved correctly when a webview is hidden and across + * editor restarts. + */ + readonly state: T | undefined; +} + +/** + * A webview based view. + */ +export interface WebviewView { + /** + * Identifies the type of the webview view, such as `'hexEditor.dataView'`. + */ + readonly viewType: string; + + /** + * The underlying webview for the view. + */ + readonly webview: any; + + /** + * View title displayed in the UI. + * + * The view title is initially taken from the extension `package.json` contribution. + */ + title?: string; + + /** + * Human-readable string which is rendered less prominently in the title. + */ + description?: string; + + /** + * The badge to display for this webview view. + * To remove the badge, set to undefined. + */ + badge?: any; + + /** + * Event fired when the view is disposed. + * + * Views are disposed when they are explicitly hidden by a user (this happens when a user + * right clicks in a view and unchecks the webview view). + * + * Trying to use the view after it has been disposed throws an exception. + */ + readonly onDidDispose: Event; + + /** + * Tracks if the webview is currently visible. + * + * Views are visible when they are on the screen and expanded. + */ + readonly visible: boolean; + + /** + * Event fired when the visibility of the view changes. + * + * Actions that trigger a visibility change: + * + * - The view is collapsed or expanded. + * - The user switches to a different view group in the sidebar or panel. + * + * Note that hiding a view using the context menu instead disposes of the view and fires `onDidDispose`. + */ + readonly onDidChangeVisibility: Event; + + /** + * Reveal the view in the UI. + * + * If the view is collapsed, this will expand it. + * + * @param preserveFocus When `true` the view will not take focus. + */ + show(preserveFocus?: boolean): void; +} + +/** + * Provider for creating `WebviewView` elements. + */ +export interface WebviewViewProvider { + /** + * Resolves a webview view. + * + * `resolveWebviewView` is called when a view first becomes visible. This may happen when the view is + * first loaded or when the user hides and then shows a view again. + * + * @param webviewView Webview view to restore. The provider should take ownership of this view. The + * provider must set the webview's `.html` and hook up all webview events it is interested in. + * @param context Additional metadata about the view being resolved. + * @param token Cancellation token indicating that the view being provided is no longer needed. + * + * @returns Optional thenable indicating that the view has been fully resolved. + */ + resolveWebviewView(webviewView: WebviewView, context: WebviewViewResolveContext, token: CancellationToken): Thenable | void; +} + export namespace window { + /** + * Register a new provider for webview views. + * + * @param viewId Unique id of the view. This should match the `id` from the + * `views` contribution in the package.json. + * @param provider Provider for the webview views. + * + * @returns Disposable that unregisters the provider. + */ + export function registerWebviewViewProvider( + viewId: string, + provider: WebviewViewProvider, + options?: { + /** + * Content settings for the webview created for this view. + */ + readonly webviewOptions?: { + /** + * Controls if the webview element itself (iframe) is kept around even when the view + * is no longer visible. + * + * Normally the webview's html context is created when the view becomes visible + * and destroyed when it is hidden. Extensions that have complex state + * or UI can set the `retainContextWhenHidden` to make the editor keep the webview + * context around, even when the webview moves to a background tab. When a webview using + * `retainContextWhenHidden` becomes hidden, its scripts and other dynamic content are suspended. + * When the view becomes visible again, the context is automatically restored + * in the exact same state it was in originally. You cannot send messages to a + * hidden webview, even with `retainContextWhenHidden` enabled. + * + * `retainContextWhenHidden` has a high memory overhead and should only be used if + * your view's context cannot be quickly saved and restored. + */ + readonly retainContextWhenHidden?: boolean; + }; + } + ): Disposable { + return new Disposable(); + } + export const visibleTextEditors = []; /** * Options for creating a {@link TreeView} diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/job/JobActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/job/JobActions.unit.test.ts index 5a10debfd4..e13f60b095 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/job/JobActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/job/JobActions.unit.test.ts @@ -111,7 +111,7 @@ function createGlobalMocks() { get: activeTextEditorDocument, configurable: true, }); - Object.defineProperty(Profiles, "getInstance", { value: jest.fn().mockResolvedValue(newMocks.mockProfileInstance), configurable: true }); + Object.defineProperty(Profiles, "getInstance", { value: jest.fn().mockReturnValue(newMocks.mockProfileInstance), configurable: true }); const executeCommand = jest.fn(); Object.defineProperty(vscode.commands, "executeCommand", { value: executeCommand, configurable: true }); Object.defineProperty(ZoweLogger, "error", { value: jest.fn(), configurable: true }); @@ -1130,9 +1130,20 @@ describe("cancelJob", () => { createGlobalMocks(); const session = createISession(); const profile = createIProfile(); - const jobSessionNode = createJobSessionNode(session, profile); - const jobNode = createJobNode(jobSessionNode, profile); - const jobsProvider = createJobsTree(session, jobNode.job, profile, createTreeView()); + + const createBlockMocks = () => { + const jobSessionNode = createJobSessionNode(session, profile); + const jobNode = createJobNode(jobSessionNode, profile); + + return { + session: createISession(), + profile, + jobSessionNode, + jobNode, + jobsProvider: createJobsTree(session, jobNode.job, profile, createTreeView()), + }; + }; + const jesCancelJobMock = jest.fn(); const mockJesApi = (mockFn?: jest.Mock): void => { @@ -1155,17 +1166,20 @@ describe("cancelJob", () => { }); it("returns early if no nodes are specified", async () => { + const { jobsProvider } = createBlockMocks(); await JobActions.cancelJobs(jobsProvider, []); expect(Gui.showMessage).not.toHaveBeenCalled(); }); it("returns early if all nodes in selection have been cancelled", async () => { + const { jobNode, jobsProvider } = createBlockMocks(); jobNode.job.retcode = "CANCELED"; await JobActions.cancelJobs(jobsProvider, [jobNode]); expect(Gui.showMessage).toHaveBeenCalledWith("The selected jobs were already cancelled."); }); it("shows a warning message if one or more jobs failed to cancel", async () => { + const { jobNode, jobsProvider } = createBlockMocks(); jobNode.job.retcode = "ACTIVE"; jesCancelJobMock.mockResolvedValueOnce(false); await JobActions.cancelJobs(jobsProvider, [jobNode]); @@ -1175,6 +1189,7 @@ describe("cancelJob", () => { }); it("shows a warning message if one or more APIs do not support cancelJob", async () => { + const { jobNode, jobsProvider } = createBlockMocks(); // Make cancelJob undefined mockJesApi(); jobNode.job.retcode = "ACTIVE"; @@ -1188,6 +1203,7 @@ describe("cancelJob", () => { }); it("shows matching error messages for one or more failed jobs", async () => { + const { jobNode, jobsProvider } = createBlockMocks(); jobNode.job.retcode = "ACTIVE"; jesCancelJobMock.mockRejectedValueOnce(new Error("Failed to cancel job... something went wrong.")); await JobActions.cancelJobs(jobsProvider, [jobNode]); @@ -1200,18 +1216,18 @@ describe("cancelJob", () => { }); it("shows a message confirming the jobs were cancelled", async () => { + const { jobNode, jobsProvider, jobSessionNode } = createBlockMocks(); jobNode.job.retcode = "ACTIVE"; jesCancelJobMock.mockResolvedValueOnce(true); - const setImmediateSpy = jest.spyOn(global, "setImmediate"); + const getChildrenMock = jest.spyOn(jobSessionNode, "getChildren"); await JobActions.cancelJobs(jobsProvider, [jobNode]); - + expect(getChildrenMock).toHaveBeenCalled(); // Check that refreshElement was called through setImmediate - expect(setImmediateSpy).toHaveBeenCalled(); - expect(Gui.showMessage).toHaveBeenCalledWith("Cancelled selected jobs successfully."); }); it("does not work for job session nodes", async () => { + const { jobsProvider, jobSessionNode } = createBlockMocks(); await JobActions.cancelJobs(jobsProvider, [jobSessionNode]); expect(jesCancelJobMock).not.toHaveBeenCalled(); }); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts index af9143996a..82fcf186cc 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSActions.unit.test.ts @@ -39,7 +39,7 @@ import { USSFileStructure } from "../../../../src/trees/uss/USSFileStructure"; import { AuthUtils } from "../../../../src/utils/AuthUtils"; import { IZoweTree } from "../../../../../zowe-explorer-api/src/tree/IZoweTree"; import { IZoweUSSTreeNode } from "../../../../../zowe-explorer-api/src/tree"; -import { USSAtributeView } from "../../../../src/trees/uss/USSAttributeView"; +import { USSAttributeView } from "../../../../src/trees/uss/USSAttributeView"; import { mocked } from "../../../__mocks__/mockUtils"; import { USSTree } from "../../../../src/trees/uss/USSTree"; @@ -828,7 +828,7 @@ describe("USS Action Unit Tests - function editAttributes", () => { {} as IZoweTree, { label: "some/node", getProfile: jest.fn() } as unknown as IZoweUSSTreeNode ); - expect(view).toBeInstanceOf(USSAtributeView); + expect(view).toBeInstanceOf(USSAttributeView); }); }); diff --git a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSAttributeView.unit.test.ts b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSAttributeView.unit.test.ts index 43998c0851..5ea37ccb43 100644 --- a/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSAttributeView.unit.test.ts +++ b/packages/zowe-explorer/__tests__/__unit__/trees/uss/USSAttributeView.unit.test.ts @@ -11,7 +11,7 @@ import * as vscode from "vscode"; import { MockedProperty } from "../../../__mocks__/mockUtils"; -import { USSAtributeView } from "../../../../src/trees/uss/USSAttributeView"; +import { USSAttributeView } from "../../../../src/trees/uss/USSAttributeView"; import { UssFSProvider } from "../../../../src/trees/uss/UssFSProvider"; import { IZoweTree } from "../../../../../zowe-explorer-api/src/tree/IZoweTree"; import { IZoweUSSTreeNode } from "../../../../../zowe-explorer-api/src/tree"; @@ -21,7 +21,7 @@ import { MainframeInteraction } from "../../../../../zowe-explorer-api/src/exten import { SharedContext } from "../../../../src/trees/shared/SharedContext"; describe("AttributeView unit tests", () => { - let view: USSAtributeView; + let view: USSAttributeView; const context = { extensionPath: "some/fake/ext/path" } as unknown as vscode.ExtensionContext; const treeProvider = { refreshElement: jest.fn(), refresh: jest.fn() } as unknown as IZoweTree; const createDirMock = jest.spyOn(UssFSProvider.instance, "createDirectory").mockImplementation(); @@ -41,7 +41,7 @@ describe("AttributeView unit tests", () => { getTag: () => Promise.resolve("UTF-8"), } as unknown as MainframeInteraction.IUss); jest.spyOn(SharedContext, "isUssDirectory").mockReturnValue(false); - view = new USSAtributeView(context, treeProvider, node); + view = new USSAttributeView(context, treeProvider, node); }); afterAll(() => { diff --git a/packages/zowe-explorer/l10n/bundle.l10n.json b/packages/zowe-explorer/l10n/bundle.l10n.json index 32897ccc46..d6017b015d 100644 --- a/packages/zowe-explorer/l10n/bundle.l10n.json +++ b/packages/zowe-explorer/l10n/bundle.l10n.json @@ -172,24 +172,6 @@ "Required API functions for pasting (fileList and copy/uploadFromBuffer) were not found.": "Required API functions for pasting (fileList and copy/uploadFromBuffer) were not found.", "Uploading USS files...": "Uploading USS files...", "Error uploading files": "Error uploading files", - "The 'move' function is not implemented for this USS API.": "The 'move' function is not implemented for this USS API.", - "Could not list USS files: Empty path provided in URI": "Could not list USS files: Empty path provided in URI", - "Profile does not exist for this file.": "Profile does not exist for this file.", - "$(sync~spin) Saving USS file...": "$(sync~spin) Saving USS file...", - "Renaming {0} failed due to API error: {1}/File pathError message": { - "message": "Renaming {0} failed due to API error: {1}", - "comment": [ - "File path", - "Error message" - ] - }, - "Deleting {0} failed due to API error: {1}/File nameError message": { - "message": "Deleting {0} failed due to API error: {1}", - "comment": [ - "File name", - "Error message" - ] - }, "Downloaded: {0}/Download time": { "message": "Downloaded: {0}", "comment": [ @@ -260,6 +242,24 @@ "initializeUSSFavorites.error.buttonRemove": "initializeUSSFavorites.error.buttonRemove", "File does not exist. It may have been deleted.": "File does not exist. It may have been deleted.", "$(sync~spin) Pulling from Mainframe...": "$(sync~spin) Pulling from Mainframe...", + "The 'move' function is not implemented for this USS API.": "The 'move' function is not implemented for this USS API.", + "Could not list USS files: Empty path provided in URI": "Could not list USS files: Empty path provided in URI", + "Profile does not exist for this file.": "Profile does not exist for this file.", + "$(sync~spin) Saving USS file...": "$(sync~spin) Saving USS file...", + "Renaming {0} failed due to API error: {1}/File pathError message": { + "message": "Renaming {0} failed due to API error: {1}", + "comment": [ + "File path", + "Error message" + ] + }, + "Deleting {0} failed due to API error: {1}/File nameError message": { + "message": "Deleting {0} failed due to API error: {1}", + "comment": [ + "File name", + "Error message" + ] + }, "{0} location/Node type": { "message": "{0} location", "comment": [ diff --git a/packages/zowe-explorer/l10n/poeditor.json b/packages/zowe-explorer/l10n/poeditor.json index 823c39db81..3bb089cf57 100644 --- a/packages/zowe-explorer/l10n/poeditor.json +++ b/packages/zowe-explorer/l10n/poeditor.json @@ -8,6 +8,12 @@ "viewsContainers.activitybar": { "Zowe Explorer": "" }, + "viewsContainers.panel.tableView": { + "Zowe Resources": "" + }, + "zowe.resources.name": { + "Zowe Resources": "" + }, "zowe.placeholderCommand": { "Placeholder": "" }, @@ -480,12 +486,6 @@ "Required API functions for pasting (fileList and copy/uploadFromBuffer) were not found.": "", "Uploading USS files...": "", "Error uploading files": "", - "The 'move' function is not implemented for this USS API.": "", - "Could not list USS files: Empty path provided in URI": "", - "Profile does not exist for this file.": "", - "$(sync~spin) Saving USS file...": "", - "Renaming {0} failed due to API error: {1}": "", - "Deleting {0} failed due to API error: {1}": "", "Downloaded: {0}": "", "Encoding: {0}": "", "Binary": "", @@ -514,6 +514,12 @@ "initializeUSSFavorites.error.buttonRemove": "", "File does not exist. It may have been deleted.": "", "$(sync~spin) Pulling from Mainframe...": "", + "The 'move' function is not implemented for this USS API.": "", + "Could not list USS files: Empty path provided in URI": "", + "Profile does not exist for this file.": "", + "$(sync~spin) Saving USS file...": "", + "Renaming {0} failed due to API error: {1}": "", + "Deleting {0} failed due to API error: {1}": "", "{0} location": "", "Choose a location to create the {0}": "", "Name of file or directory": "", diff --git a/packages/zowe-explorer/package.json b/packages/zowe-explorer/package.json index d904444c6a..a3e52ba60c 100644 --- a/packages/zowe-explorer/package.json +++ b/packages/zowe-explorer/package.json @@ -45,6 +45,13 @@ "title": "%viewsContainers.activitybar%", "icon": "resources/zowe.svg" } + ], + "panel": [ + { + "id": "zowe-panel", + "icon": "resources/zowe.svg", + "title": "%viewsContainers.panel.tableView%" + } ] }, "views": { @@ -61,6 +68,13 @@ "id": "zowe.jobs.explorer", "name": "%zowe.jobs.explorer%" } + ], + "zowe-panel": [ + { + "type": "webview", + "id": "zowe-resources", + "name": "%zowe.resources.name%" + } ] }, "keybindings": [ diff --git a/packages/zowe-explorer/package.nls.json b/packages/zowe-explorer/package.nls.json index 8276f08764..c8128c205d 100644 --- a/packages/zowe-explorer/package.nls.json +++ b/packages/zowe-explorer/package.nls.json @@ -2,6 +2,8 @@ "displayName": "Zowe Explorer", "description": "VS Code extension, powered by Zowe CLI, that streamlines interaction with mainframe data sets, USS files, and jobs", "viewsContainers.activitybar": "Zowe Explorer", + "viewsContainers.panel.tableView": "Zowe Resources", + "zowe.resources.name": "Zowe Resources", "zowe.placeholderCommand": "Placeholder", "zowe.promptCredentials": "Update Credentials", "zowe.profileManagement": "Manage Profile", diff --git a/packages/zowe-explorer/src/trees/job/JobActions.ts b/packages/zowe-explorer/src/trees/job/JobActions.ts index 873cb3c6ce..3cb12c8bf4 100644 --- a/packages/zowe-explorer/src/trees/job/JobActions.ts +++ b/packages/zowe-explorer/src/trees/job/JobActions.ts @@ -421,7 +421,7 @@ export class JobActions { const failedJobs: { job: zosjobs.IJob; error: string }[] = []; // Build list of common sessions from node selection - const sessionNodes = []; + const sessionNodes = new Set(); for (const jobNode of nodes) { if (!jobNode.job) { continue; @@ -444,11 +444,8 @@ export class JobActions { const cancelled = await jesApis[sesLabel].cancelJob(jobNode.job); if (!cancelled) { failedJobs.push({ job: jobNode.job, error: vscode.l10n.t("The job was not cancelled.") }); - } else if (!sessionNodes.includes(sesNode)) { - setImmediate(() => { - jobsProvider.refreshElement(sesNode); - }); - sessionNodes.push(sesNode); + } else { + sessionNodes.add(sesNode); } } catch (err) { if (err instanceof Error) { @@ -457,9 +454,19 @@ export class JobActions { } } + for (const session of sessionNodes) { + session.dirty = true; + await session.getChildren(); + jobsProvider.refreshElement(session); + } + + // `await`ing the following Gui methods causes unwanted side effects for other features (such as the jobs table view): + // * code execution stops before function returns (unexpected, undefined behavior) + // * before, we used `setImmediate` to delay updates to the jobs tree (to avoid desync), but removing the `await`s resolves the desync. + // * we do not expect the user to respond to these toasts, so we do not need to wait for their promises to be resolved. if (failedJobs.length > 0) { // Display any errors from the API - await Gui.warningMessage( + Gui.warningMessage( vscode.l10n.t({ message: "One or more jobs failed to cancel: {0}", args: [failedJobs.reduce((prev, j) => prev.concat(`\n${j.job.jobname}(${j.job.jobid}): ${j.error}`), "\n")], @@ -470,7 +477,7 @@ export class JobActions { } ); } else { - await Gui.showMessage(vscode.l10n.t("Cancelled selected jobs successfully.")); + Gui.showMessage(vscode.l10n.t("Cancelled selected jobs successfully.")); } } public static async sortJobs(session: IZoweJobTreeNode, jobsProvider: JobTree): Promise { diff --git a/packages/zowe-explorer/src/trees/job/ZoweJobNode.ts b/packages/zowe-explorer/src/trees/job/ZoweJobNode.ts index 023ce56137..f1191c1dce 100644 --- a/packages/zowe-explorer/src/trees/job/ZoweJobNode.ts +++ b/packages/zowe-explorer/src/trees/job/ZoweJobNode.ts @@ -175,6 +175,7 @@ export class ZoweJobNode extends ZoweTreeNode implements IZoweJobTreeNode { ); if (existing) { existing.tooltip = existing.label = newLabel; + (existing as ZoweSpoolNode).spool = spool; elementChildren[newLabel] = existing; } else { const spoolNode = new ZoweSpoolNode({ @@ -237,13 +238,13 @@ export class ZoweJobNode extends ZoweTreeNode implements IZoweJobTreeNode { if (existing) { // If matched, update the label to reflect latest retcode/status existing.tooltip = existing.label = nodeTitle; + existing.job = job; elementChildren[nodeTitle] = existing; } else { const jobNode = new ZoweJobNode({ label: nodeTitle, collapsibleState: vscode.TreeItemCollapsibleState.Collapsed, parentNode: this, - session: this.session, profile: this.getProfile(), job, }); diff --git a/packages/zowe-explorer/src/trees/shared/SharedHistoryView.ts b/packages/zowe-explorer/src/trees/shared/SharedHistoryView.ts index c31df9ad00..c1ac2382a2 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedHistoryView.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedHistoryView.ts @@ -25,7 +25,10 @@ export class SharedHistoryView extends WebView { public constructor(context: ExtensionContext, treeProviders: Definitions.IZoweProviders) { const label = "Edit History"; - super(label, "edit-history", context, (message: object) => this.onDidReceiveMessage(message), true); + super(label, "edit-history", context, { + onDidReceiveMessage: (message: object) => this.onDidReceiveMessage(message), + retainContext: true, + }); this.treeProviders = treeProviders; this.currentSelection = { ds: "search", uss: "search", jobs: "search" }; } diff --git a/packages/zowe-explorer/src/trees/shared/SharedInit.ts b/packages/zowe-explorer/src/trees/shared/SharedInit.ts index 77eac5c882..83728b82af 100644 --- a/packages/zowe-explorer/src/trees/shared/SharedInit.ts +++ b/packages/zowe-explorer/src/trees/shared/SharedInit.ts @@ -10,7 +10,17 @@ */ import * as vscode from "vscode"; -import { FileManagement, Gui, IZoweTree, IZoweTreeNode, Validation, ZosEncoding, ZoweScheme, imperative } from "@zowe/zowe-explorer-api"; +import { + FileManagement, + Gui, + IZoweTree, + IZoweTreeNode, + TableViewProvider, + Validation, + ZosEncoding, + ZoweScheme, + imperative, +} from "@zowe/zowe-explorer-api"; import { SharedActions } from "./SharedActions"; import { SharedHistoryView } from "./SharedHistoryView"; import { SharedTreeProviders } from "./SharedTreeProviders"; @@ -81,6 +91,9 @@ export class SharedInit { }) ); + // Contribute the "Zowe Resources" view as a WebviewView panel in Zowe Explorer. + context.subscriptions.push(vscode.window.registerWebviewViewProvider("zowe-resources", TableViewProvider.getInstance())); + // Webview for editing persistent items on Zowe Explorer context.subscriptions.push( vscode.commands.registerCommand("zowe.editHistory", () => { diff --git a/packages/zowe-explorer/src/trees/uss/USSActions.ts b/packages/zowe-explorer/src/trees/uss/USSActions.ts index df07f9ec48..ef365dac02 100644 --- a/packages/zowe-explorer/src/trees/uss/USSActions.ts +++ b/packages/zowe-explorer/src/trees/uss/USSActions.ts @@ -15,7 +15,7 @@ import * as path from "path"; import * as zosfiles from "@zowe/zos-files-for-zowe-sdk"; import { Gui, imperative, Validation, IZoweUSSTreeNode, Types } from "@zowe/zowe-explorer-api"; import { isBinaryFileSync } from "isbinaryfile"; -import { USSAtributeView } from "./USSAttributeView"; +import { USSAttributeView } from "./USSAttributeView"; import { USSFileStructure } from "./USSFileStructure"; import { ZoweUSSNode } from "./ZoweUSSNode"; import { Constants } from "../../configuration/Constants"; @@ -231,8 +231,8 @@ export class USSActions { } } - public static editAttributes(context: vscode.ExtensionContext, fileProvider: Types.IZoweUSSTreeType, node: IZoweUSSTreeNode): USSAtributeView { - return new USSAtributeView(context, fileProvider, node); + public static editAttributes(context: vscode.ExtensionContext, fileProvider: Types.IZoweUSSTreeType, node: IZoweUSSTreeNode): USSAttributeView { + return new USSAttributeView(context, fileProvider, node); } /** diff --git a/packages/zowe-explorer/src/trees/uss/USSAttributeView.ts b/packages/zowe-explorer/src/trees/uss/USSAttributeView.ts index 4c0e1c3394..8c84c610e1 100644 --- a/packages/zowe-explorer/src/trees/uss/USSAttributeView.ts +++ b/packages/zowe-explorer/src/trees/uss/USSAttributeView.ts @@ -14,7 +14,7 @@ import { Disposable, ExtensionContext } from "vscode"; import { ZoweExplorerApiRegister } from "../../extending/ZoweExplorerApiRegister"; import { SharedContext } from "../shared/SharedContext"; -export class USSAtributeView extends WebView { +export class USSAttributeView extends WebView { private treeProvider: Types.IZoweUSSTreeType; private readonly ussNode: IZoweUSSTreeNode; private readonly ussApi: MainframeInteraction.IUss; @@ -24,7 +24,9 @@ export class USSAtributeView extends WebView { public constructor(context: ExtensionContext, treeProvider: Types.IZoweUSSTreeType, node: IZoweUSSTreeNode) { const label = node.label ? `Edit Attributes: ${node.label as string}` : "Edit Attributes"; - super(label, "edit-attributes", context, (message: object) => this.onDidReceiveMessage(message)); + super(label, "edit-attributes", context, { + onDidReceiveMessage: (message: object) => this.onDidReceiveMessage(message), + }); this.treeProvider = treeProvider; this.ussNode = node; this.canUpdate = node.onUpdate != null; diff --git a/packages/zowe-explorer/src/utils/CertificateWizard.ts b/packages/zowe-explorer/src/utils/CertificateWizard.ts index f35cc31d20..ed550267d7 100644 --- a/packages/zowe-explorer/src/utils/CertificateWizard.ts +++ b/packages/zowe-explorer/src/utils/CertificateWizard.ts @@ -43,7 +43,9 @@ export class CertificateWizard extends WebView { }> = new DeferredPromise(); public constructor(context: vscode.ExtensionContext, opts: CertWizardOpts) { - super(vscode.l10n.t("Certificate Wizard"), "certificate-wizard", context, (message: object) => this.onDidReceiveMessage(message)); + super(vscode.l10n.t("Certificate Wizard"), "certificate-wizard", context, { + onDidReceiveMessage: (message: object) => this.onDidReceiveMessage(message), + }); this.opts = opts; this.panel.onDidDispose(() => { this.userSubmission.reject(userDismissed); diff --git a/packages/zowe-explorer/src/webviews/package.json b/packages/zowe-explorer/src/webviews/package.json index 13ba4f96bf..0c4740cad1 100644 --- a/packages/zowe-explorer/src/webviews/package.json +++ b/packages/zowe-explorer/src/webviews/package.json @@ -11,7 +11,7 @@ "preview": "vite preview", "fresh-clone": "pnpm clean && rimraf node_modules", "clean": "rimraf dist || true", - "package": "echo \"webviews: nothing to package.\"", + "package": "node -e \"fs.accessSync(path.join(__dirname, 'dist'))\" && echo \"webviews: nothing to package.\" || pnpm build", "test": "echo \"webviews: nothing to test\"", "lint": "echo \"webviews: nothing to lint.\"", "lint:html": "echo \"webviews: nothing to lint.\"", @@ -19,10 +19,14 @@ "madge": "echo \"webviews: nothing to madge.\"" }, "dependencies": { + "@szhsin/react-menu": "^4.1.0", "@types/vscode-webview": "^1.57.1", "@vscode/webview-ui-toolkit": "^1.2.2", + "ag-grid-community": "^32.0.2", + "ag-grid-react": "^32.0.2", "lodash": "^4.17.21", - "preact": "^10.16.0" + "preact": "^10.16.0", + "preact-render-to-string": "^6.5.4" }, "devDependencies": { "@preact/preset-vite": "^2.5.0", diff --git a/packages/zowe-explorer/src/webviews/src/edit-history/App.tsx b/packages/zowe-explorer/src/webviews/src/edit-history/App.tsx index 3c9ed2e157..5929236632 100644 --- a/packages/zowe-explorer/src/webviews/src/edit-history/App.tsx +++ b/packages/zowe-explorer/src/webviews/src/edit-history/App.tsx @@ -12,7 +12,7 @@ import { useEffect, useState } from "preact/hooks"; import { VSCodeDivider, VSCodePanels, VSCodePanelTab } from "@vscode/webview-ui-toolkit/react"; import { JSXInternal } from "preact/src/jsx"; -import { isSecureOrigin } from "./components/PersistentUtils"; +import { isSecureOrigin } from "../utils"; import PersistentDataPanel from "./components/PersistentTable/PersistentDataPanel"; import PersistentVSCodeAPI from "./components/PersistentVSCodeAPI"; import PersistentManagerHeader from "./components/PersistentManagerHeader/PersistentManagerHeader"; diff --git a/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentTable/PersistentDataPanel.tsx b/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentTable/PersistentDataPanel.tsx index 5abf8d6e74..6757938843 100644 --- a/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentTable/PersistentDataPanel.tsx +++ b/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentTable/PersistentDataPanel.tsx @@ -12,7 +12,8 @@ import { useEffect, useMemo, useState } from "preact/hooks"; import { VSCodePanelView, VSCodeDataGrid } from "@vscode/webview-ui-toolkit/react"; import { JSXInternal } from "preact/src/jsx"; -import { DataPanelContext, isSecureOrigin } from "../PersistentUtils"; +import { DataPanelContext } from "../PersistentUtils"; +import { isSecureOrigin } from "../../../utils"; import { panelId } from "../../types"; import PersistentToolBar from "../PersistentToolBar/PersistentToolBar"; import PersistentTableData from "./PersistentTableData"; diff --git a/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentUtils.ts b/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentUtils.ts index 86eca22d96..7e5880c439 100644 --- a/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentUtils.ts +++ b/packages/zowe-explorer/src/webviews/src/edit-history/components/PersistentUtils.ts @@ -15,20 +15,6 @@ import { useContext } from "preact/hooks"; export const DataPanelContext = createContext(null); -export function isSecureOrigin(origin: string): boolean { - const eventUrl = new URL(origin); - const isWebUser = - (eventUrl.protocol === document.location.protocol && eventUrl.hostname === document.location.hostname) || - eventUrl.hostname.endsWith(".github.dev"); - const isLocalVSCodeUser = eventUrl.protocol === "vscode-webview:"; - - if (!isWebUser && !isLocalVSCodeUser) { - return false; - } - - return true; -} - export function useDataPanelContext(): DataPanelContextType { const dataPanelContext = useContext(DataPanelContext); if (!dataPanelContext) { diff --git a/packages/zowe-explorer/src/webviews/src/table-view/ActionsBar.tsx b/packages/zowe-explorer/src/webviews/src/table-view/ActionsBar.tsx new file mode 100644 index 0000000000..7f65786449 --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/ActionsBar.tsx @@ -0,0 +1,67 @@ +import { Ref } from "preact/hooks"; +import type { Table } from "@zowe/zowe-explorer-api"; +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { GridApi } from "ag-grid-community"; + +export const ActionsBar = ({ + actions, + gridRef, + itemCount, + vscodeApi, +}: { + actions: Table.Action[]; + gridRef: Ref; + itemCount: number; + vscodeApi: any; +}) => { + return ( +
+
+ {itemCount === 0 ? "No" : itemCount} item{itemCount === 1 ? "" : "s"} selected +
+ + {actions + .filter((action) => (itemCount > 1 ? action.callback.typ === "multi-row" : action.callback.typ.endsWith("row"))) + .map((action, i) => ( + { + const selectedRows = (gridRef.current.api as GridApi).getSelectedNodes(); + if (selectedRows.length === 0) { + return; + } + + vscodeApi.postMessage({ + command: action.command, + data: { + row: action.callback.typ === "single-row" ? selectedRows[0].data : undefined, + rows: + action.callback.typ === "multi-row" + ? selectedRows.reduce((all, row) => ({ ...all, [row.rowIndex!]: row.data }), {}) + : undefined, + }, + }); + }} + > + {action.title} + + ))} + +
+ ); +}; diff --git a/packages/zowe-explorer/src/webviews/src/table-view/App.tsx b/packages/zowe-explorer/src/webviews/src/table-view/App.tsx new file mode 100644 index 0000000000..5e097549aa --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/App.tsx @@ -0,0 +1,16 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { TableView } from "./TableView"; + +export function App() { + return ; +} diff --git a/packages/zowe-explorer/src/webviews/src/table-view/ContextMenu.tsx b/packages/zowe-explorer/src/webviews/src/table-view/ContextMenu.tsx new file mode 100644 index 0000000000..8e99e3eabe --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/ContextMenu.tsx @@ -0,0 +1,137 @@ +import type { Table } from "@zowe/zowe-explorer-api"; +import { useCallback, useRef, useState } from "preact/hooks"; +import { CellContextMenuEvent, ColDef } from "ag-grid-community"; +import { ControlledMenu, MenuItem } from "@szhsin/react-menu"; +import "@szhsin/react-menu/dist/index.css"; +import { wrapFn } from "./types"; + +type MousePt = { x: number; y: number }; + +export type ContextMenuProps = { + selectRow: boolean; + selectedRows: Table.RowData[] | null | undefined; + clickedRow: Table.RowData; + options: Table.ContextMenuOption[]; + colDef: ColDef; + vscodeApi: any; +}; + +/** + * React hook that returns a prepared context menu component and its related states. + * + * @param contextMenu The props for the context menu component (options) + * @returns The result of the hook, with the component to render, open state and cell callback + */ +export const useContextMenu = (contextMenu: ContextMenuProps) => { + const [open, setOpen] = useState(false); + const [anchor, setAnchor] = useState({ x: 0, y: 0 }); + + const gridRefs = useRef({ + colDef: null, + selectedRows: [], + clickedRow: null, + field: undefined, + rowIndex: null, + }); + + /* Opens the context menu and sets the anchor point to mouse coordinates */ + const openMenu = (e: PointerEvent | null | undefined) => { + if (!e) { + return; + } + + setAnchor({ x: e.clientX, y: e.clientY }); + setOpen(true); + }; + + /* Removes 'focused-ctx-menu' class name from other grid cells when context menu is closed. */ + const removeContextMenuClass = () => { + const elems = document.querySelectorAll("div[role='gridcell']"); + elems.forEach((elem) => elem.classList.remove("focused-ctx-menu")); + }; + + const cellMenu = useCallback( + (event: CellContextMenuEvent) => { + // Check if a cell is focused. If so, keep the border around the grid cell by adding a "focused cell" class. + const focusedCell = event.api.getFocusedCell(); + if (contextMenu.selectRow && focusedCell) { + // Only apply the border to grid cell divs contained in valid cells + if (event.event?.target && (event.event?.target as Element).classList.contains("ag-cell-value")) { + const lastCell = (event.event?.target as Element).parentElement?.parentElement!; + lastCell.classList.add("focused-ctx-menu"); + } + } + + // Cache the current column, selected rows and clicked row for later use + gridRefs.current = { + colDef: event.colDef, + selectedRows: event.api.getSelectedRows(), + clickedRow: event.data, + field: event.colDef.field, + rowIndex: event.rowIndex, + }; + + openMenu(event.event as PointerEvent); + }, + [contextMenu.selectRow, gridRefs.current.selectedRows] + ); + + return { + open, + callback: cellMenu, + component: open ? ( + { + removeContextMenuClass(); + setOpen(false); + }} + > + {ContextMenu(gridRefs.current, contextMenu.options, contextMenu.vscodeApi)} + + ) : null, + }; +}; + +export type ContextMenuElemProps = { + anchor: MousePt; + menuItems: Table.ContextMenuOption[]; + vscodeApi: any; +}; + +export const ContextMenu = (gridRefs: any, menuItems: Table.ContextMenuOption[], vscodeApi: any) => { + return menuItems + ?.filter((item) => { + if (item.condition == null) { + return true; + } + + // Wrap function to properly handle named parameters + const cond = new Function(wrapFn(item.condition)); + // Invoke the wrapped function once to get the built function, then invoke it again with the parameters + return cond()(null, gridRefs.clickedRow); + }) + .map((item, i) => ( + { + vscodeApi.postMessage({ + command: item.command, + data: { + rowIndex: gridRefs.rowIndex, + row: { ...gridRefs.clickedRow, actions: undefined }, + field: gridRefs.field, + cell: gridRefs.colDef.valueFormatter + ? gridRefs.colDef.valueFormatter({ value: gridRefs.clickedRow[gridRefs.field] }) + : gridRefs.clickedRow[gridRefs.field], + }, + }); + }} + style={{ borderBottom: "var(--vscode-menu-border)" }} + > + {item.title} + + )); +}; diff --git a/packages/zowe-explorer/src/webviews/src/table-view/TableView.tsx b/packages/zowe-explorer/src/webviews/src/table-view/TableView.tsx new file mode 100644 index 0000000000..8318cc15ba --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/TableView.tsx @@ -0,0 +1,112 @@ +// Required CSS for AG Grid +import "ag-grid-community/styles/ag-grid.css"; +// AG Grid Quartz Theme (used as base theme) +import "ag-grid-community/styles/ag-theme-quartz.css"; +import { AgGridReact } from "ag-grid-react"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { getVsCodeTheme, isSecureOrigin, useMutableObserver } from "../utils"; +import type { Table } from "@zowe/zowe-explorer-api"; +import { TableViewProps, tableProps } from "./types"; +import { useContextMenu } from "./ContextMenu"; +// Custom styling (font family, VS Code color scheme, etc.) +import "./style.css"; +import { ActionsBar } from "./ActionsBar"; +import { actionsColumn } from "./actionsColumn"; + +const vscodeApi = acquireVsCodeApi(); + +export const TableView = ({ actionsCellRenderer, baseTheme, data }: TableViewProps) => { + const [tableData, setTableData] = useState(data); + const [theme, setTheme] = useState(baseTheme ?? "ag-theme-quartz"); + const [selectionCount, setSelectionCount] = useState(0); + const gridRef = useRef(); + + const contextMenu = useContextMenu({ + options: [ + { + title: "Copy cell", + command: "copy-cell", + callback: { + typ: "cell", + fn: () => {}, + }, + }, + { + title: "Copy row", + command: "copy", + callback: { + typ: "single-row", + fn: () => {}, + }, + }, + ...(tableData?.contextOpts?.all ?? []), + ], + selectRow: true, + selectedRows: [], + clickedRow: undefined as any, + colDef: undefined as any, + vscodeApi, + }); + + useEffect(() => { + // Apply the dark version of the AG Grid theme if the user is using a dark or high-contrast theme in VS Code. + const userTheme = getVsCodeTheme(); + if (userTheme !== "vscode-light") { + setTheme("ag-theme-quartz-dark"); + } + + // Disable the event listener for the context menu in the active iframe to prevent VS Code from showing its right-click menu. + window.addEventListener("contextmenu", (e) => e.preventDefault(), true); + + // Set up event listener to handle data changes being sent to the webview. + window.addEventListener("message", (event: any): void => { + if (!isSecureOrigin(event.origin)) { + return; + } + + if (!("data" in event)) { + return; + } + + const response = event.data; + if (response.command === "ondatachanged") { + // Update received from a VS Code extender; update table state + const newData: Table.ViewOpts = response.data; + if (Object.keys(newData.actions).length > 1 || newData.actions.all?.length > 0) { + // Add an extra column to the end of each row if row actions are present + const rows = newData.rows?.map((row: Table.RowData) => { + return { ...row, actions: "" }; + }); + const columns = [...(newData.columns ?? []), actionsColumn(newData, actionsCellRenderer, vscodeApi)]; + setTableData({ ...newData, rows, columns }); + } else { + setTableData(newData); + } + } + }); + + // Once the listener is in place, send a "ready signal" to the TableView instance to handle new data. + vscodeApi.postMessage({ command: "ready" }); + }, []); + + // Observe attributes of the `body` element to detect VS Code theme changes. + useMutableObserver( + document.body, + (_mutations, _observer) => { + const themeAttr = getVsCodeTheme(); + setTheme(themeAttr === "vscode-light" ? "ag-theme-quartz" : "ag-theme-quartz-dark"); + }, + { attributes: true } + ); + + return ( + <> + {tableData?.title ?

{tableData.title}

: null} +
+ {contextMenu.component} + + {tableData ? : null} +
+ + ); +}; diff --git a/packages/zowe-explorer/src/webviews/src/table-view/actionsColumn.tsx b/packages/zowe-explorer/src/webviews/src/table-view/actionsColumn.tsx new file mode 100644 index 0000000000..8923d3198b --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/actionsColumn.tsx @@ -0,0 +1,65 @@ +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; +import { TableViewProps, wrapFn } from "./types"; +import { Table } from "@zowe/zowe-explorer-api"; + +export const actionsColumn = (newData: Table.ViewOpts, actionsCellRenderer: TableViewProps["actionsCellRenderer"], vscodeApi: any) => ({ + ...(newData.columns.find((col) => col.field === "actions") ?? {}), + // Prevent cells from being selectable + cellStyle: { border: "none", outline: "none" }, + field: "actions", + minWidth: 360, + sortable: false, + suppressSizeToFit: true, + // Support a custom cell renderer for row actions + cellRenderer: + actionsCellRenderer ?? + ((params: any) => + // Render any actions for the given row and actions that apply to all rows + newData.actions[params.rowIndex] || newData.actions["all"] ? ( + + {[...(newData.actions[params.rowIndex] || []), ...(newData.actions["all"] || [])] + .filter((action) => { + if (action.condition == null) { + return true; + } + + // Wrap function to properly handle named parameters + const cond = new Function(wrapFn(action.condition)); + // Invoke the wrapped function once to get the built function, then invoke it again with the parameters + return cond()(null, params.data); + }) + .map((action, i) => ( + + vscodeApi.postMessage({ + command: action.command, + data: { + rowIndex: params.node.rowIndex, + row: { ...params.data, actions: undefined }, + field: params.colDef.field, + cell: params.colDef.valueFormatter + ? params.colDef.valueFormatter({ + value: params.data[params.colDef.field], + }) + : params.data[params.colDef.field], + }, + }) + } + style={{ marginRight: "0.25em", width: "fit-content" }} + > + {action.title} + + ))} + + ) : null), +}); diff --git a/packages/zowe-explorer/src/webviews/src/table-view/index.html b/packages/zowe-explorer/src/webviews/src/table-view/index.html new file mode 100644 index 0000000000..3648f8e806 --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/index.html @@ -0,0 +1,16 @@ + + + + + + + + Table View + + +
+ + + diff --git a/packages/zowe-explorer/src/webviews/src/table-view/index.tsx b/packages/zowe-explorer/src/webviews/src/table-view/index.tsx new file mode 100644 index 0000000000..748009dcd9 --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/index.tsx @@ -0,0 +1,15 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { render } from "preact"; +import { App } from "./App"; + +render(, document.getElementById("webviewRoot")!); diff --git a/packages/zowe-explorer/src/webviews/src/table-view/style.css b/packages/zowe-explorer/src/webviews/src/table-view/style.css new file mode 100644 index 0000000000..a404e6ac7c --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/style.css @@ -0,0 +1,55 @@ +.ag-theme-vsc { + max-height: 95vh; + margin-top: 1em; + --ag-icon-font-family: "agGridQuartz"; + --ag-row-hover-color: var(--vscode-list-hoverBackground); + --ag-range-selection-background-color: var(--vscode-list-activeSelectionBackground); + --ag-range-selection-highlight-color: var(--vscode-list-activeSelectionForeground); + --ag-background-color: var(--vscode-editor-background); + --ag-control-panel-background-color: var(--vscode-editorWidget-background); + --ag-tooltip-background-color: var(--vscode-editorWidget-background); + --ag-border-color: var(--vscode-editorWidget-border); + --ag-header-background-color: var(--vscode-keybindingTable-headerBackground); + --ag-range-selection-border-color: var(--vscode-tab-activeForeground); + --ag-foreground-color: var(--vscode-foreground); + --ag-selected-row-background-color: var(--vscode-notebook-selectedCellBackground); +} + +.ctx-menu-open .ag-body-viewport { + overflow: hidden !important; +} + +.focused-ctx-menu { + border: 1px solid var(--vscode-menu-foreground) !important; +} + +.szh-menu { + background-color: var(--vscode-menu-background); + border: 1px solid var(--vscode-menu-border); +} + +.szh-menu__item { + color: var(--vscode-menu-foreground); +} + +.szh-menu__item--hover { + background-color: var(--vscode-menu-selectionBackground); + color: var(--vscode-menu-selectionForeground); +} + +.szh-menu__item--disabled { + color: var(--vscode-debugIcon-breakpointDisabledForeground); +} + +.szh-menu__divider { + background-color: var(--vscode-menu-separatorBackground); +} + +.szh-menu-button { + background-color: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} + +.szh-menu-button:hover { + background-color: var(--vscode-button-hoverBackground); +} diff --git a/packages/zowe-explorer/src/webviews/src/table-view/types.ts b/packages/zowe-explorer/src/webviews/src/table-view/types.ts new file mode 100644 index 0000000000..eda5fceab5 --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/table-view/types.ts @@ -0,0 +1,77 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import type { Table } from "@zowe/zowe-explorer-api"; +import { AgGridReactProps } from "ag-grid-react"; +import { JSXInternal } from "preact/src/jsx"; + +export type ContextMenuState = { + open: boolean; + callback: (event: any) => void; + component: JSXInternal.Element | null; +}; + +export const wrapFn = (s: string) => `{ return ${s} };`; + +type AgGridThemes = "ag-theme-quartz" | "ag-theme-balham" | "ag-theme-material" | "ag-theme-alpine"; +export type TableViewProps = { + actionsCellRenderer?: (params: any) => JSXInternal.Element; + baseTheme?: AgGridThemes; + data?: Table.ViewOpts; +}; + +// Define props for the AG Grid table here +export const tableProps = ( + contextMenu: ContextMenuState, + setSelectionCount: React.Dispatch, + tableData: Table.ViewOpts, + vscodeApi: any +): Partial => ({ + domLayout: "autoHeight", + enableCellTextSelection: true, + ensureDomOrder: true, + rowData: tableData.rows, + columnDefs: tableData.columns?.map((col) => ({ + ...col, + comparator: col.comparator ? new Function(wrapFn(col.comparator))() : undefined, + colSpan: col.colSpan ? new Function(wrapFn(col.colSpan))() : undefined, + rowSpan: col.rowSpan ? new Function(wrapFn(col.rowSpan))() : undefined, + valueFormatter: col.valueFormatter ? new Function(wrapFn(col.valueFormatter))() : undefined, + })), + onCellContextMenu: contextMenu.callback, + onCellValueChanged: tableData.columns?.some((col) => col.editable) + ? (event) => { + vscodeApi.postMessage({ + command: "ontableedit", + data: { + rowIndex: event.rowIndex, + field: event.colDef.field, + value: event.value, + oldValue: event.oldValue, + }, + }); + } + : undefined, + onFilterChanged: (event) => { + const rows: Table.RowData[] = []; + event.api.forEachNodeAfterFilterAndSort((row, _i) => rows.push(row.data)); + vscodeApi.postMessage({ command: "ondisplaychanged", data: rows }); + }, + onSelectionChanged: (event) => { + setSelectionCount(event.api.getSelectedRows().length); + }, + onSortChanged: (event) => { + const rows: Table.RowData[] = []; + event.api.forEachNodeAfterFilterAndSort((row, _i) => rows.push(row.data)); + vscodeApi.postMessage({ command: "ondisplaychanged", data: rows }); + }, + ...(tableData.options ?? {}), +}); diff --git a/packages/zowe-explorer/src/webviews/src/utils.ts b/packages/zowe-explorer/src/webviews/src/utils.ts new file mode 100644 index 0000000000..b08e1d1405 --- /dev/null +++ b/packages/zowe-explorer/src/webviews/src/utils.ts @@ -0,0 +1,34 @@ +/** + * This program and the accompanying materials are made available under the terms of the + * Eclipse Public License v2.0 which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-v20.html + * + * SPDX-License-Identifier: EPL-2.0 + * + * Copyright Contributors to the Zowe Project. + * + */ + +import { useEffect } from "preact/hooks"; + +export function getVsCodeTheme(): string | null { + return document.body.getAttribute("data-vscode-theme-kind"); +} + +export const useMutableObserver = (target: Node, callback: MutationCallback, options: MutationObserverInit | undefined): void => { + useEffect(() => { + const mutationObserver = new MutationObserver((mutations, observer) => callback(mutations, observer)); + mutationObserver.observe(target, options); + return (): void => mutationObserver.disconnect(); + }, [callback, options]); +}; + +export function isSecureOrigin(origin: string): boolean { + const eventUrl = new URL(origin); + const isWebUser = + (eventUrl.protocol === document.location.protocol && eventUrl.hostname === document.location.hostname) || + eventUrl.hostname.endsWith(".github.dev"); + const isLocalVSCodeUser = eventUrl.protocol === "vscode-webview:"; + + return isWebUser || isLocalVSCodeUser; +} diff --git a/packages/zowe-explorer/src/webviews/tsconfig.json b/packages/zowe-explorer/src/webviews/tsconfig.json index 464518df51..819fdfdb60 100644 --- a/packages/zowe-explorer/src/webviews/tsconfig.json +++ b/packages/zowe-explorer/src/webviews/tsconfig.json @@ -5,6 +5,13 @@ "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, + "baseUrl": "./", + "paths": { + "react": ["./node_modules/preact/compat/"], + "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"], + "react-dom": ["./node_modules/preact/compat/"], + "react-dom/*": ["./node_modules/preact/compat/*"] + }, /* Bundler mode */ "moduleResolution": "node", diff --git a/packages/zowe-explorer/src/webviews/vite.config.ts b/packages/zowe-explorer/src/webviews/vite.config.ts index 63643cfaa5..aefe95f88f 100644 --- a/packages/zowe-explorer/src/webviews/vite.config.ts +++ b/packages/zowe-explorer/src/webviews/vite.config.ts @@ -43,6 +43,8 @@ export default defineConfig({ ], root: path.resolve(__dirname, "src"), build: { + chunkSizeWarningLimit: 1000, + cssCodeSplit: false, emptyOutDir: true, outDir: path.resolve(__dirname, "dist"), rollupOptions: { @@ -50,8 +52,17 @@ export default defineConfig({ output: { entryFileNames: `[name]/[name].js`, chunkFileNames: `[name]/[name].js`, - assetFileNames: `assets/[name].[ext]`, + assetFileNames: `[name]/[name].[ext]`, + manualChunks: { + "ag-grid-react": ["ag-grid-react"], + }, }, }, }, + resolve: { + alias: { + react: "preact/compat", + "react-dom": "preact/compat", + }, + }, }); diff --git a/packages/zowe-explorer/tsconfig.json b/packages/zowe-explorer/tsconfig.json index 1db5e0d1f6..b407890679 100644 --- a/packages/zowe-explorer/tsconfig.json +++ b/packages/zowe-explorer/tsconfig.json @@ -23,7 +23,7 @@ }, "types": ["node", "jest"] }, - "exclude": ["node_modules", ".vscode-test"], + "exclude": ["node_modules", "src/webviews/**", ".vscode-test"], "include": [ "src/**/*.ts", // Needed in production for vscode-nls localization to work: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e876a8091..a142928af1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,9 @@ importers: '@zowe/zosmf-for-zowe-sdk': specifier: 8.0.0-next.202407232256 version: 8.0.0-next.202407232256(@zowe/core-for-zowe-sdk@8.0.0-next.202407232256)(@zowe/imperative@8.0.0-next.202407232256) + deep-object-diff: + specifier: ^1.1.9 + version: 1.1.9 mustache: specifier: ^4.2.0 version: 4.2.0 @@ -327,18 +330,30 @@ importers: packages/zowe-explorer/src/webviews: dependencies: + '@szhsin/react-menu': + specifier: ^4.1.0 + version: 4.2.2(react-dom@18.3.1)(react@18.3.1) '@types/vscode-webview': specifier: ^1.57.1 version: 1.57.5 '@vscode/webview-ui-toolkit': specifier: ^1.2.2 version: 1.4.0(react@18.3.1) + ag-grid-community: + specifier: ^32.0.2 + version: 32.0.2 + ag-grid-react: + specifier: ^32.0.2 + version: 32.0.2(react-dom@18.3.1)(react@18.3.1) lodash: specifier: ^4.17.21 version: 4.17.21 preact: specifier: ^10.16.0 version: 10.22.0 + preact-render-to-string: + specifier: ^6.5.4 + version: 6.5.7(preact@10.22.0) devDependencies: '@preact/preset-vite': specifier: ^2.5.0 @@ -2550,6 +2565,18 @@ packages: resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==} dev: true + /@szhsin/react-menu@4.2.2(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-xI1LlPlOAmyjcnBxEwhathJs3YV0U+4hbEKMbR2CXK2O9X+r7g02l5EqB9Slsjj1poVMpgQvf81vOZuCw1HUjg==} + peerDependencies: + react: '>=16.14.0' + react-dom: '>=16.14.0' + dependencies: + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-state: 2.1.1(react-dom@18.3.1)(react@18.3.1) + dev: false + /@szmarczak/http-timer@5.0.1: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} @@ -3795,6 +3822,28 @@ packages: hasBin: true dev: true + /ag-charts-types@10.0.2: + resolution: {integrity: sha512-Nxo5slHOXlaeg0gRIsVnovAosQzzlYfWJtdDy0Aq/VvpJru/PJ+5i2c9aCyEhgRxhBjImsoegwkgRj7gNOWV6Q==} + dev: false + + /ag-grid-community@32.0.2: + resolution: {integrity: sha512-vLJJUjnsG9hNK41GNuW2EHu1W264kxA/poOpcX4kmyrjU5Uzvelsbj3HdKAO9POV28iqyRdKGYfAWdn8QzA7KA==} + dependencies: + ag-charts-types: 10.0.2 + dev: false + + /ag-grid-react@32.0.2(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-IWYsoyJ/Z763rWbE5/9SaT1n5xwIKrm/QzOG14l7i8z5J6JdJwfV0aQFATmEE8Xws2H48vlLcLdW1cv4hwV3eg==} + peerDependencies: + react: ^16.3.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.3.0 || ^17.0.0 || ^18.0.0 + dependencies: + ag-grid-community: 32.0.2 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -5360,6 +5409,10 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + dev: false + /deepmerge-json@1.5.0: resolution: {integrity: sha512-jZRrDmBKjmGcqMFEUJ14FjMJwm05Qaked+1vxaALRtF0UAl7lPU8OLWXFxvoeg3jbQM249VPFVn8g2znaQkEtA==} engines: {node: '>=4.0.0'} @@ -10112,7 +10165,6 @@ packages: /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - dev: true /object-copy@0.1.0: resolution: {integrity: sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==} @@ -10644,6 +10696,14 @@ packages: source-map-js: 1.2.0 dev: true + /preact-render-to-string@6.5.7(preact@10.22.0): + resolution: {integrity: sha512-nACZDdv/ZZciuldVYMcfGqr61DKJeaAfPx96hn6OXoBGhgtU2yGQkA0EpTzWH4SvnwF0syLsL4WK7AIp3Ruc1g==} + peerDependencies: + preact: '>=10' + dependencies: + preact: 10.22.0 + dev: false + /preact@10.22.0: resolution: {integrity: sha512-RRurnSjJPj4rp5K6XoP45Ui33ncb7e4H7WiOHVpjbkvqvA3U+N8Z6Qbo0AE6leGYBV66n8EhEaFixvIu3SkxFw==} @@ -10803,6 +10863,14 @@ packages: sisteransi: 1.0.5 dev: true + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + dev: false + /property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} dev: true @@ -10966,13 +11034,32 @@ packages: strip-json-comments: 2.0.1 dev: true + /react-dom@18.3.1(react@18.3.1): + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + dev: false + /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - dev: true /react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + /react-transition-state@2.1.1(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-kQx5g1FVu9knoz1T1WkapjUgFz08qQ/g1OmuWGi3/AoEFfS0kStxrPlZx81urjCXdz2d+1DqLpU6TyLW/Ro04Q==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} @@ -11422,6 +11509,12 @@ packages: resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} dev: true + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + dependencies: + loose-envify: 1.4.0 + dev: false + /schema-utils@3.3.0: resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} engines: {node: '>= 10.13.0'} diff --git a/samples/vue-webview-sample/src/extension.ts b/samples/vue-webview-sample/src/extension.ts index ffc2940289..a2c546db56 100644 --- a/samples/vue-webview-sample/src/extension.ts +++ b/samples/vue-webview-sample/src/extension.ts @@ -5,8 +5,10 @@ export function activate(context: vscode.ExtensionContext) { console.log('Congratulations, your extension "helloworld-sample" is now active!'); const disposable = vscode.commands.registerCommand("extension.helloWorld", () => { - const webview = new WebView("Sample Webview", "vue-sample", context, (message: Record) => { - vscode.window.showInformationMessage(message.text); + const webview = new WebView("Sample Webview", "vue-sample", context, { + onDidReceiveMessage: (message: Record) => { + vscode.window.showInformationMessage(message.text); + }, }); }); diff --git a/zedc/src/code/prepare.rs b/zedc/src/code/prepare.rs index e1ceea8b25..5b917e1cfb 100644 --- a/zedc/src/code/prepare.rs +++ b/zedc/src/code/prepare.rs @@ -81,6 +81,7 @@ fn code_cli_binary(dir: &Path) -> PathBuf { /// * `zip_path` - The path of the ZIP file to extract /// * `vsc_path` - The path where the archive should be extracted into /// +#[allow(unused_variables)] async fn extract_code_zip(file: &std::fs::File, zip_path: &Path, vsc_path: &Path) -> anyhow::Result<()> { cfg_if::cfg_if! { if #[cfg(target_os = "macos")] {