diff --git a/client/src/components/LibraryNavigator/LibraryModel.ts b/client/src/components/LibraryNavigator/LibraryModel.ts index 05a690389..f43c0904e 100644 --- a/client/src/components/LibraryNavigator/LibraryModel.ts +++ b/client/src/components/LibraryNavigator/LibraryModel.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ProgressLocation, l10n, window } from "vscode"; +import { SortModelItem } from "ag-grid-community"; import { Writable } from "stream"; import PaginatedResultSet from "./PaginatedResultSet"; @@ -27,12 +28,17 @@ class LibraryModel { item: LibraryItem, ): PaginatedResultSet<{ data: TableData; error?: Error }> { return new PaginatedResultSet<{ data: TableData; error?: Error }>( - async (start: number, end: number) => { + async (start: number, end: number, sortModel: SortModelItem[]) => { await this.libraryAdapter.setup(); const limit = end - start + 1; try { return { - data: await this.libraryAdapter.getRows(item, start, limit), + data: await this.libraryAdapter.getRows( + item, + start, + limit, + sortModel, + ), }; } catch (e) { return { error: e, data: { rows: [], count: 0 } }; diff --git a/client/src/components/LibraryNavigator/PaginatedResultSet.ts b/client/src/components/LibraryNavigator/PaginatedResultSet.ts index c8d19cafa..1a4a26c52 100644 --- a/client/src/components/LibraryNavigator/PaginatedResultSet.ts +++ b/client/src/components/LibraryNavigator/PaginatedResultSet.ts @@ -1,15 +1,18 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { SortModelItem } from "ag-grid-community"; class PaginatedResultSet { - private queryForData: (start: number, end: number) => Promise; + constructor( + protected readonly queryForData: PaginatedResultSet["getData"], + ) {} - constructor(queryForData: (start: number, end: number) => Promise) { - this.queryForData = queryForData; - } - - public async getData(start: number, end: number): Promise { - return await this.queryForData(start, end); + public async getData( + start: number, + end: number, + sortModel: SortModelItem[], + ): Promise { + return await this.queryForData(start, end, sortModel); } } diff --git a/client/src/components/LibraryNavigator/types.ts b/client/src/components/LibraryNavigator/types.ts index b67d567e1..201336c9a 100644 --- a/client/src/components/LibraryNavigator/types.ts +++ b/client/src/components/LibraryNavigator/types.ts @@ -1,5 +1,7 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { SortModelItem } from "ag-grid-community"; + import { ColumnCollection } from "../../connection/rest/api/compute"; export const LibraryType = "library"; @@ -39,7 +41,12 @@ export interface LibraryAdapter { items: LibraryItem[]; count: number; }>; - getRows(item: LibraryItem, start: number, limit: number): Promise; + getRows( + item: LibraryItem, + start: number, + limit: number, + sortModel: SortModelItem[], + ): Promise; getRowsAsCSV( item: LibraryItem, start: number, diff --git a/client/src/connection/itc/ItcLibraryAdapter.ts b/client/src/connection/itc/ItcLibraryAdapter.ts index 6739672fa..622aa647b 100644 --- a/client/src/connection/itc/ItcLibraryAdapter.ts +++ b/client/src/connection/itc/ItcLibraryAdapter.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { l10n } from "vscode"; +import { SortModelItem } from "ag-grid-community"; import { ChildProcessWithoutNullStreams } from "child_process"; import { onRunError } from "../../commands/run"; @@ -91,6 +92,7 @@ class ItcLibraryAdapter implements LibraryAdapter { item: LibraryItem, start: number, limit: number, + sortModel: SortModelItem[], ): Promise { const { rows: rawRowValues, count } = await this.getDatasetInformation( item, diff --git a/client/src/connection/rest/RestLibraryAdapter.ts b/client/src/connection/rest/RestLibraryAdapter.ts index b29b065a9..e31f62fba 100644 --- a/client/src/connection/rest/RestLibraryAdapter.ts +++ b/client/src/connection/rest/RestLibraryAdapter.ts @@ -1,5 +1,6 @@ // Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { SortModelItem } from "ag-grid-community"; import { AxiosResponse } from "axios"; import { getSession } from ".."; @@ -41,10 +42,15 @@ class RestLibraryAdapter implements LibraryAdapter { } public async getRows( - item: LibraryItem, + item: Pick, start: number, limit: number, + sortModel: SortModelItem[], ): Promise { + if (sortModel.length > 0) { + return await this.getSortedRows(item, start, limit, sortModel); + } + const { data } = await this.retryOnFail( async () => await this.dataAccessApi.getRows( @@ -66,6 +72,43 @@ class RestLibraryAdapter implements LibraryAdapter { }; } + private async getSortedRows( + item: Pick, + start: number, + limit: number, + sortModel: SortModelItem[], + ): Promise { + const { data: viewData } = await this.retryOnFail( + async () => + await this.dataAccessApi.createView( + { + sessionId: this.sessionId, + libref: item.library || "", + tableName: item.name, + viewRequest: { + sortBy: sortModel.map((sortModelItem) => ({ + key: sortModelItem.colId, + direction: + sortModelItem.sort === "asc" ? "ascending" : "descending", + })), + }, + }, + requestOptions, + ), + ); + + const results = await this.getRows( + { library: viewData.libref, name: viewData.name }, + start, + limit, + [], + ); + + await this.deleteTable({ library: viewData.libref, name: viewData.name }); + + return results; + } + public async getRowsAsCSV( item: LibraryItem, start: number, @@ -155,15 +198,18 @@ class RestLibraryAdapter implements LibraryAdapter { } } - public async deleteTable(item: LibraryItem): Promise { + public async deleteTable({ + library, + name, + }: Pick): Promise { await this.setup(); try { await this.retryOnFail( async () => await this.dataAccessApi.deleteTable({ sessionId: this.sessionId, - libref: item.library, - tableName: item.name, + libref: library, + tableName: name, }), ); // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/client/src/panels/DataViewer.ts b/client/src/panels/DataViewer.ts index fd8d3dcd3..2f31facc7 100644 --- a/client/src/panels/DataViewer.ts +++ b/client/src/panels/DataViewer.ts @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Uri, window } from "vscode"; +import { SortModelItem } from "ag-grid-community"; + import PaginatedResultSet from "../components/LibraryNavigator/PaginatedResultSet"; import { TableData } from "../components/LibraryNavigator/types"; import { Column } from "../connection/rest/api/compute"; @@ -66,7 +68,7 @@ class DataViewer extends WebView { event: Event & { key: string; command: string; - data?: { start?: number; end?: number }; + data?: { start?: number; end?: number; sortModel?: SortModelItem[] }; }, ): Promise { switch (event.command) { @@ -74,6 +76,7 @@ class DataViewer extends WebView { const { data, error } = await this._paginator.getData( event.data!.start!, event.data!.end!, + event.data!.sortModel!, ); if (error) { await window.showErrorMessage(error.message); diff --git a/client/src/webview/ColumnHeader.tsx b/client/src/webview/ColumnHeader.tsx new file mode 100644 index 000000000..a83b13dc3 --- /dev/null +++ b/client/src/webview/ColumnHeader.tsx @@ -0,0 +1,99 @@ +import { useRef } from "react"; + +import { AgColumn, GridApi } from "ag-grid-community"; + +import { ColumnHeaderProps } from "./ColumnHeaderMenu"; + +const getIconForColumnType = (type: string) => { + switch (type.toLocaleLowerCase()) { + case "float": + case "num": + return "float"; + case "date": + return "date"; + case "time": + return "time"; + case "datetime": + return "date-time"; + case "currency": + return "currency"; + case "char": + return "char"; + default: + return ""; + } +}; + +const ColumnHeader = ({ + api, + column, + currentColumn: getCurrentColumn, + columnType, + setColumnMenu, + theme, +}: { + api: GridApi; + column: AgColumn; + currentColumn: () => AgColumn | undefined; + columnType: string; + setColumnMenu: (props: ColumnHeaderProps) => void; + theme: string; +}) => { + const ref = useRef(undefined!); + const currentColumn = getCurrentColumn(); + + return ( +
+
+ + {column.colId} + {column.sort === "asc" && ( + + + + )} + {column.sort === "desc" && ( + + + + )} +
+ +
+
+
+ ); +}; + +export default ColumnHeader; diff --git a/client/src/webview/ColumnHeaderMenu.tsx b/client/src/webview/ColumnHeaderMenu.tsx new file mode 100644 index 000000000..fe1aefa8c --- /dev/null +++ b/client/src/webview/ColumnHeaderMenu.tsx @@ -0,0 +1,229 @@ +import { Fragment, useRef, useState } from "react"; + +import { AgColumn } from "ag-grid-community"; + +export interface ColumnHeaderProps { + left: number; + top: number; + column: AgColumn; + sortColumn: (direction: "asc" | "desc") => void; + dismissMenu: () => void; + theme: string; +} + +interface MenuItem { + name: string; + checked?: boolean; + onPress?: () => void; + children?: (MenuItem | string)[]; +} + +const GridMenu = ({ + menuItems, + theme, + top, + left, + subMenu, +}: { + menuItems: (MenuItem | string)[]; + theme: string; + top: number; + left: number; + subMenu?: boolean; +}) => { + const menuRef = useRef(undefined); + const [subMenuItems, setSubMenuItems] = useState<(MenuItem | string)[]>([]); + const className = subMenu + ? `ag-menu ag-ltr ag-popup-child ${theme}` + : `ag-menu ag-column-menu ag-ltr ag-popup-child ag-popup-positioned-under ${theme}`; + + return ( + + {subMenuItems.length > 0 && ( + + )} +
+
+ {menuItems.map((menuItem, index) => { + if (typeof menuItem === "string") { + return ( + + ); + } + return ( +
+
+ +
+
+ ); + })} +
+
+
+ ); +}; + +const ColumnHeaderMenu = ({ + left, + top, + column, + sortColumn, + dismissMenu, + theme, +}: ColumnHeaderProps) => { + const menuItems = [ + { + name: "Sort", + children: [ + { + name: "Ascending", + checked: column.sort === "asc", + onPress: () => { + sortColumn(column.sort === "asc" ? null : "asc"); + dismissMenu(); + }, + }, + { + name: "Descending", + checked: column.sort === "desc", + onPress: () => { + sortColumn(column.sort === "desc" ? null : "desc"); + dismissMenu(); + }, + }, + "separator", + { + name: "Remove Sorting", + }, + { + name: "Remove all sorting", + }, + ], + }, + ]; + + if (1 === 1) { + return ( + + ); + } + return ( +
+
    +
  • + Sort +
      +
    • + {column.sort === "asc" && } + +
    • +
    • + {column.sort === "desc" && } + +
    • +
    +
  • +
+
+ ); +}; + +export default ColumnHeaderMenu; diff --git a/client/src/webview/DataViewer.css b/client/src/webview/DataViewer.css index 2c58b8fea..45545012a 100644 --- a/client/src/webview/DataViewer.css +++ b/client/src/webview/DataViewer.css @@ -74,3 +74,134 @@ body, background: url(../../../icons/dark/tableHeaderCharacterTypeDark.svg) center no-repeat; } + +.header-menu { + position: absolute; + z-index: 999; +} +.header-menu > ul { + position: relative; + bottom: 0; + right: 100%; +} +.header-menu ul { + background: green; + background: var(--vscode-editorHoverWidget-background); + border: 1px solid var(--vscode-editorHoverWidget-border); + padding: 0; + margin: 0; + list-style-type: none; + min-width: 100px; + color: var(--vscode-editorWidget-foreground); + box-shadow: var(--vscode-widget-shadow); +} + +.ag-header-cell-label button, +.header-menu button { + background: none; + border: none; + margin: 0; + padding: 0; +} + +.header-menu button { + color: var(--vscode-editorWidget-foreground); +} + +.header-menu li { + padding: 0.5rem; + white-space: nowrap; +} + +.header-menu li:hover { + background: var(--vscode-toolbar-hoverBackground); +} + +.header-menu > ul > li { + position: relative; +} + +.header-menu > ul > li > ul { + display: none; +} + +.header-menu > ul > li:hover > ul { + display: block; + position: absolute; + left: 100%; + top: 0; +} + +.header-menu li > span:has(+ ul) { + display: block; + background: url(../../../icons/light/chevron-right.svg) right center no-repeat; +} + +.vscode-dark .header-menu li > span:has(+ ul) { + background: url(../../../icons/dark/chevron-right.svg) right center no-repeat; +} + +.ag-header-cell-label { + position: relative; +} + +.ag-header-cell-label .dropdown { + position: absolute; + right: 0; +} + +button { + cursor: pointer; +} + +.ag-header-cell-label button { + display: none; + width: 16px; + height: 16px; +} + +.ag-header-cell-label button span { + background: url(../../../icons/light/more.svg); + background-size: 16px 16px; +} + +.ag-header-cell-label button:hover, +.ag-header-cell-label button.active { + background: var(--vscode-toolbar-hoverBackground); +} + +.vscode-dark .ag-header-cell-label button { + background: url(../../../icons/dark/more.svg); + background-size: 16px 16px; +} + +.ag-header-cell-label:hover button, +.ag-header-cell-label button.active { + display: block; +} + +.sort-icon-wrapper .sort-icon { + width: 16px; + height: 16px; + display: inline-block; +} + +.sort-icon-wrapper .sort-icon.ascending { + background: url(../../../icons/light/arrow-up.svg); + background-size: 16px 16px; +} + +.vscode-dark .sort-icon-wrapper .sort-icon.ascending { + background: url(../../../icons/dark/arrow-up.svg); + background-size: 16px 16px; +} + +.sort-icon-wrapper .sort-icon.descending { + background: url(../../../icons/light/arrow-down.svg); + background-size: 16px 16px; +} + +.vscode-dark .sort-icon-wrapper .sort-icon.descending { + background: url(../../../icons/dark/arrow-down.svg); + background-size: 16px 16px; +} diff --git a/client/src/webview/DataViewer.tsx b/client/src/webview/DataViewer.tsx index 53fd86725..ce6af16c4 100644 --- a/client/src/webview/DataViewer.tsx +++ b/client/src/webview/DataViewer.tsx @@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client"; import { AgGridReact } from "ag-grid-react"; import "."; +import ColumnHeaderMenu from "./ColumnHeaderMenu"; import useDataViewer from "./useDataViewer"; import "./DataViewer.css"; @@ -20,7 +21,6 @@ const gridStyles = { }; const DataViewer = () => { - const { columns, onGridReady } = useDataViewer(); const theme = useMemo(() => { const themeKind = document .querySelector("[data-vscode-theme-kind]") @@ -35,25 +35,29 @@ const DataViewer = () => { return "ag-theme-alpine-dark"; } }, []); + const { columns, onGridReady, columnMenu } = useDataViewer(theme); if (columns.length === 0) { return null; } return ( -
- +
+ {columnMenu && } +
+ +
); }; diff --git a/client/src/webview/columnHeaderTemplate.ts b/client/src/webview/columnHeaderTemplate.ts deleted file mode 100644 index 33c499f9a..000000000 --- a/client/src/webview/columnHeaderTemplate.ts +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -const getIconForColumnType = (type: string) => { - switch (type.toLocaleLowerCase()) { - case "float": - case "num": - return "float"; - case "date": - return "date"; - case "time": - return "time"; - case "datetime": - return "date-time"; - case "currency": - return "currency"; - case "char": - return "char"; - default: - return ""; - } -}; - -// Taken from https://www.ag-grid.com/react-data-grid/column-headers/#provided-component -const columnHeaderTemplate = (columnType: string) => ` - -`; - -export default columnHeaderTemplate; diff --git a/client/src/webview/useDataViewer.ts b/client/src/webview/useDataViewer.tsx similarity index 72% rename from client/src/webview/useDataViewer.ts rename to client/src/webview/useDataViewer.tsx index 7169f12a1..d1dcd548c 100644 --- a/client/src/webview/useDataViewer.ts +++ b/client/src/webview/useDataViewer.tsx @@ -1,6 +1,6 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AllCommunityModule, @@ -8,12 +8,14 @@ import { GridReadyEvent, IGetRowsParams, ModuleRegistry, + SortModelItem, } from "ag-grid-community"; import { v4 } from "uuid"; import { TableData } from "../components/LibraryNavigator/types"; import { Column } from "../connection/rest/api/compute"; -import columnHeaderTemplate from "./columnHeaderTemplate"; +import ColumnHeader from "./ColumnHeader"; +import { ColumnHeaderProps } from "./ColumnHeaderMenu"; declare const acquireVsCodeApi; const vscode = acquireVsCodeApi(); @@ -34,12 +36,16 @@ const clearQueryTimeout = (): void => { clearTimeout(queryTableDataTimeoutId); queryTableDataTimeoutId = null; }; -const queryTableData = (start: number, end: number): Promise => { +const queryTableData = ( + start: number, + end: number, + sortModel: SortModelItem[], +): Promise => { const requestKey = v4(); vscode.postMessage({ command: "request:loadData", key: requestKey, - data: { start, end }, + data: { start, end, sortModel }, }); return new Promise((resolve, reject) => { @@ -95,31 +101,39 @@ const fetchColumns = (): Promise => { }); }; -const useDataViewer = () => { +const useDataViewer = (theme: string) => { const [columns, setColumns] = useState([]); + const [columnMenu, setColumnMenu] = useState(); + + const columnMenuRef = useRef(columnMenu); + useEffect(() => { + columnMenuRef.current = columnMenu; + }, [columnMenu]); const onGridReady = useCallback( (event: GridReadyEvent) => { const dataSource = { rowCount: undefined, getRows: async (params: IGetRowsParams) => { - await queryTableData(params.startRow, params.endRow).then( - ({ rows, count }: TableData) => { - const rowData = rows.map(({ cells }) => { - const row = cells.reduce( - (carry, cell, index) => ({ - ...carry, - [columns[index].field]: cell, - }), - {}, - ); - - return row; - }); - - params.successCallback(rowData, count); - }, - ); + await queryTableData( + params.startRow, + params.endRow, + params.sortModel, + ).then(({ rows, count }: TableData) => { + const rowData = rows.map(({ cells }) => { + const row = cells.reduce( + (carry, cell, index) => ({ + ...carry, + [columns[index].field]: cell, + }), + {}, + ); + + return row; + }); + + params.successCallback(rowData, count); + }); }, }; @@ -136,14 +150,18 @@ const useDataViewer = () => { fetchColumns().then((columnsData) => { const columns: ColDef[] = columnsData.map((column) => ({ field: column.name, - headerName: column.name, + headerComponent: ColumnHeader, headerComponentParams: { - template: columnHeaderTemplate(column.type), + columnType: column.type, + setColumnMenu, + currentColumn: () => columnMenuRef.current?.column, + theme, }, })); columns.unshift({ field: "#", suppressMovable: true, + sortable: false, }); setColumns(columns); @@ -158,7 +176,7 @@ const useDataViewer = () => { }; }, []); - return { columns, onGridReady }; + return { columns, onGridReady, columnMenu }; }; export default useDataViewer; diff --git a/client/test/components/LibraryNavigator/PaginatedResultSet.test.ts b/client/test/components/LibraryNavigator/PaginatedResultSet.test.ts index e5bd522b5..8c7de0c63 100644 --- a/client/test/components/LibraryNavigator/PaginatedResultSet.test.ts +++ b/client/test/components/LibraryNavigator/PaginatedResultSet.test.ts @@ -27,7 +27,7 @@ describe("PaginatedResultSet", async function () { async () => mockAxiosResponse, ); - expect(await paginatedResultSet.getData(0, 100)).to.deep.equal( + expect(await paginatedResultSet.getData(0, 100, [])).to.deep.equal( mockAxiosResponse, ); }); diff --git a/icons/dark/arrow-down.svg b/icons/dark/arrow-down.svg new file mode 100644 index 000000000..11848d89d --- /dev/null +++ b/icons/dark/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/dark/arrow-up.svg b/icons/dark/arrow-up.svg new file mode 100644 index 000000000..1889f1841 --- /dev/null +++ b/icons/dark/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/dark/check.svg b/icons/dark/check.svg new file mode 100644 index 000000000..cea818ef5 --- /dev/null +++ b/icons/dark/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/dark/chevron-right.svg b/icons/dark/chevron-right.svg new file mode 100644 index 000000000..87e8a8df4 --- /dev/null +++ b/icons/dark/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/dark/more.svg b/icons/dark/more.svg new file mode 100644 index 000000000..105cd7e93 --- /dev/null +++ b/icons/dark/more.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/icons/light/arrow-down.svg b/icons/light/arrow-down.svg new file mode 100644 index 000000000..3b9527c06 --- /dev/null +++ b/icons/light/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/light/arrow-up.svg b/icons/light/arrow-up.svg new file mode 100644 index 000000000..4ed3c5166 --- /dev/null +++ b/icons/light/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/light/check.svg b/icons/light/check.svg new file mode 100644 index 000000000..3f25ee082 --- /dev/null +++ b/icons/light/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/light/chevron-right.svg b/icons/light/chevron-right.svg new file mode 100644 index 000000000..72cc78181 --- /dev/null +++ b/icons/light/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/light/more.svg b/icons/light/more.svg new file mode 100644 index 000000000..0b41b680c --- /dev/null +++ b/icons/light/more.svg @@ -0,0 +1,5 @@ + + + + +