Skip to content

Commit 9b3945f

Browse files
authored
feat: implement column sorting (#1622)
**Summary:** This updates the data viewer code to support sorting columns. On the backend, this is supported by passing a new `sortModel` to our `getRows` function. ITC connections support this by creating an order by statement, but REST connections are a bit more complicated. With a non-empty sort model, the general process for REST is to: - Create a sorted view - Query that view and get results - Delete the view This is consistent with how studio sorts data. On the front-end, a `ColumnMenu` has been introduced to facilitate adding and removing sorting criteria. Last, these changes required exposing l10n messages to the `ColumnMenu` component. To do this, a few changes were made to `WebView`: - `WebView` is now responsible for rendering it's content - `WebView.render` makes use of some new class methods to generate content: - `body`: returns the html that should show up in the `<body>` section of a webview - `scripts` (optional): An array of relative script sources to load for the webview - `styles` (optional): An array of relative style sources to load for the webview - `l10nMessages` (optional): A map of l10n translation messages to expose to React (NOTE: These messages can be read using the new `localize` helper) The changes to `WebView` should make future webview panels work in a more consistent way. **Testing:** - [x] Test sorting a single column - [x] Test sorting multiple columns (test sorting 3 or more columns and deleting a "middle" sort to make sure sort indexing is maintained) - [x] Make sure sorting columns pops the grid back to the first row - [x] Test sorting for REST and ITC-based connections - [x] Make sure the column menu displays as expected - [x] If the parent menu and child menu fit left to right, display them as such - [x] If the parent menu fits in the view, but the child menu _doesn't_, the child menu should be displayed to the left - [x] If neither the parent menu _not_ the child menu fit, the parent menu should be adjusted to be near the right edge of the screen, and the child menu should be displayed to the left - [x] Make sure column menu can be dismissed by clicking outside of the menu, or by pressing `esc` - [x] Test opening properties view for a table, and opening properties view for a column within a table
1 parent c0360ea commit 9b3945f

33 files changed

+1045
-235
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). If you introduce breaking changes, please group them together in the "Changed" section using the **BREAKING:** prefix.
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- Added the ability to sort columns in data viewer and view column properties ([#1622](https://github.com/sassoftware/vscode-sas-extension/pull/1622))
12+
713
## [v1.17.0] - 2025-09-26
814

915
### Added

client/src/components/LibraryNavigator/LibraryModel.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
import { ProgressLocation, l10n, window } from "vscode";
44

5+
import type { SortModelItem } from "ag-grid-community";
56
import { Writable } from "stream";
67

78
import PaginatedResultSet from "./PaginatedResultSet";
@@ -27,12 +28,17 @@ class LibraryModel {
2728
item: LibraryItem,
2829
): PaginatedResultSet<{ data: TableData; error?: Error }> {
2930
return new PaginatedResultSet<{ data: TableData; error?: Error }>(
30-
async (start: number, end: number) => {
31+
async (start: number, end: number, sortModel: SortModelItem[]) => {
3132
await this.libraryAdapter.setup();
3233
const limit = end - start + 1;
3334
try {
3435
return {
35-
data: await this.libraryAdapter.getRows(item, start, limit),
36+
data: await this.libraryAdapter.getRows(
37+
item,
38+
start,
39+
limit,
40+
sortModel,
41+
),
3642
};
3743
} catch (e) {
3844
return { error: e, data: { rows: [], count: 0 } };

client/src/components/LibraryNavigator/PaginatedResultSet.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
import type { SortModelItem } from "ag-grid-community";
34

45
class PaginatedResultSet<T> {
5-
private queryForData: (start: number, end: number) => Promise<T>;
6+
constructor(
7+
protected readonly queryForData: PaginatedResultSet<T>["getData"],
8+
) {}
69

7-
constructor(queryForData: (start: number, end: number) => Promise<T>) {
8-
this.queryForData = queryForData;
9-
}
10-
11-
public async getData(start: number, end: number): Promise<T> {
12-
return await this.queryForData(start, end);
10+
public async getData(
11+
start: number,
12+
end: number,
13+
sortModel: SortModelItem[],
14+
): Promise<T> {
15+
return await this.queryForData(start, end, sortModel);
1316
}
1417
}
1518

client/src/components/LibraryNavigator/index.ts

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ class LibraryNavigator implements SubscriptionProvider {
5656
item.uid,
5757
paginator,
5858
fetchColumns,
59+
(columnName: string) => {
60+
this.displayTableProperties(item, true, columnName);
61+
},
5962
),
6063
item.uid,
6164
);
@@ -105,25 +108,7 @@ class LibraryNavigator implements SubscriptionProvider {
105108
commands.registerCommand(
106109
"SAS.showTableProperties",
107110
async (item: LibraryItem) => {
108-
try {
109-
const tableInfo = await this.libraryDataProvider.getTableInfo(item);
110-
const columns = await this.libraryDataProvider.fetchColumns(item);
111-
112-
this.webviewManager.render(
113-
new TablePropertiesViewer(
114-
this.extensionUri,
115-
item.uid,
116-
tableInfo,
117-
columns,
118-
false, // Show properties tab
119-
),
120-
`properties-${item.uid}`,
121-
);
122-
} catch (error) {
123-
window.showErrorMessage(
124-
`Failed to load table properties: ${error.message}`,
125-
);
126-
}
111+
await this.displayTableProperties(item);
127112
},
128113
),
129114
commands.registerCommand("SAS.collapseAllLibraries", () => {
@@ -143,6 +128,34 @@ class LibraryNavigator implements SubscriptionProvider {
143128
this.libraryDataProvider.useAdapter(this.libraryAdapterForConnectionType());
144129
}
145130

131+
private async displayTableProperties(
132+
item: LibraryItem,
133+
showPropertiesTab: boolean = false,
134+
focusedColumn?: string,
135+
) {
136+
try {
137+
const tableInfo = await this.libraryDataProvider.getTableInfo(item);
138+
const columns = await this.libraryDataProvider.fetchColumns(item);
139+
140+
this.webviewManager.render(
141+
new TablePropertiesViewer(
142+
this.extensionUri,
143+
item.uid,
144+
tableInfo,
145+
columns,
146+
showPropertiesTab,
147+
focusedColumn,
148+
),
149+
`properties-${item.uid}`,
150+
true,
151+
);
152+
} catch (error) {
153+
window.showErrorMessage(
154+
`Failed to load table properties: ${error.message}`,
155+
);
156+
}
157+
}
158+
146159
private libraryAdapterForConnectionType(): LibraryAdapter | undefined {
147160
const activeProfile = profileConfig.getProfileByName(
148161
profileConfig.getActiveProfile(),

client/src/components/LibraryNavigator/types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
import type { SortModelItem } from "ag-grid-community";
4+
35
import { ColumnCollection, TableInfo } from "../../connection/rest/api/compute";
46

57
export const LibraryType = "library";
@@ -39,7 +41,12 @@ export interface LibraryAdapter {
3941
items: LibraryItem[];
4042
count: number;
4143
}>;
42-
getRows(item: LibraryItem, start: number, limit: number): Promise<TableData>;
44+
getRows(
45+
item: LibraryItem,
46+
start: number,
47+
limit: number,
48+
sortModel: SortModelItem[],
49+
): Promise<TableData>;
4350
getRowsAsCSV(
4451
item: LibraryItem,
4552
start: number,

client/src/connection/itc/ItcLibraryAdapter.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33
import { l10n } from "vscode";
44

5+
import type { SortModelItem } from "ag-grid-community";
56
import { ChildProcessWithoutNullStreams } from "child_process";
67

78
import { onRunError } from "../../commands/run";
@@ -92,11 +93,13 @@ class ItcLibraryAdapter implements LibraryAdapter {
9293
item: LibraryItem,
9394
start: number,
9495
limit: number,
96+
sortModel: SortModelItem[],
9597
): Promise<TableData> {
9698
const { rows: rawRowValues, count } = await this.getDatasetInformation(
9799
item,
98100
start,
99101
limit,
102+
sortModel,
100103
);
101104

102105
const rows = rawRowValues.map((line, idx: number): TableRow => {
@@ -125,7 +128,7 @@ class ItcLibraryAdapter implements LibraryAdapter {
125128
}
126129
: {};
127130

128-
const { rows } = await this.getRows(item, start, limit);
131+
const { rows } = await this.getRows(item, start, limit, []);
129132

130133
rows.unshift(columns);
131134
// Fetching csv doesn't rely on count. Instead, we get the count
@@ -177,10 +180,13 @@ class ItcLibraryAdapter implements LibraryAdapter {
177180
item: LibraryItem,
178181
start: number,
179182
limit: number,
183+
sortModel: SortModelItem[],
180184
): Promise<{ rows: Array<string[]>; count: number }> {
181-
const fullTableName = `${item.library}.${item.name}`;
185+
const sortString = sortModel
186+
.map((col) => `${col.colId} ${col.sort}`)
187+
.join(",");
182188
const code = `
183-
$runner.GetDatasetRecords("${fullTableName}", ${start}, ${limit})
189+
$runner.GetDatasetRecords("${item.library}","${item.name}", ${start}, ${limit}, "${sortString}")
184190
`;
185191
const output = await executeRawCode(code);
186192
try {

client/src/connection/itc/script.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,20 @@ class SASRunner{
244244
Write-Host "${LineCodes.ResultsFetchedCode}"
245245
}
246246
247-
[void]GetDatasetRecords([string]$tableName, [int]$start = 0, [int]$limit = 100) {
247+
[void]GetDatasetRecords([string]$library, [string]$table, [int]$start = 0, [int]$limit = 100, [string]$sortCriteria = "") {
248248
$objRecordSet = New-Object -comobject ADODB.Recordset
249249
$objRecordSet.ActiveConnection = $this.dataConnection # This is needed to set the properties for sas formats.
250250
$objRecordSet.Properties.Item("SAS Formats").Value = "_ALL_"
251251
252+
$tableName = $library + "." + $table
253+
if ($sortCriteria -ne "") {
254+
$epoch = [datetime]::FromFileTimeUtc(0)
255+
$currentUtcTime = (Get-Date).ToUniversalTime()
256+
$ts = [int64]($currentUtcTime - $epoch).TotalSeconds
257+
$tableName = "WORK.temp_$ts"
258+
$this.dataConnection.Execute("CREATE VIEW $tableName AS SELECT * FROM $library.$table ORDER BY $sortCriteria")
259+
}
260+
252261
$objRecordSet.Open(
253262
$tableName,
254263
[System.Reflection.Missing]::Value, # Use the active connection
@@ -266,7 +275,6 @@ class SASRunner{
266275
}
267276
268277
$objRecordSet.AbsolutePosition = $start + 1
269-
270278
for ($j = 0; $j -lt $limit -and $objRecordSet.EOF -eq $False; $j++) {
271279
$cell = [List[object]]::new()
272280
for ($i = 0; $i -lt $fields; $i++) {
@@ -288,6 +296,10 @@ class SASRunner{
288296
$result | Add-Member -MemberType NoteProperty -Name "rows" -Value $records
289297
$result | Add-Member -MemberType NoteProperty -Name "count" -Value $count
290298
299+
if ($sortCriteria -ne "") {
300+
$this.dataConnection.Execute("DROP VIEW $tableName")
301+
}
302+
291303
Write-Host $(ConvertTo-Json -Depth 10 -InputObject $result -Compress)
292304
}
293305
@@ -588,7 +600,7 @@ class SASRunner{
588600
$objRecordSet = New-Object -comobject ADODB.Recordset
589601
$objRecordSet.ActiveConnection = $this.dataConnection
590602
$query = @"
591-
select memname, memtype, crdate, modate, nobs, nvar, compress,
603+
select memname, memtype, crdate, modate, nobs, nvar, compress,
592604
memlabel, typemem, filesize, delobs
593605
from sashelp.vtable
594606
where libname='$libname' and memname='$memname';

client/src/connection/rest/RestLibraryAdapter.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
3+
import type { SortModelItem } from "ag-grid-community";
34
import { AxiosResponse } from "axios";
45

56
import { getSession } from "..";
@@ -46,10 +47,15 @@ class RestLibraryAdapter implements LibraryAdapter {
4647
}
4748

4849
public async getRows(
49-
item: LibraryItem,
50+
item: Pick<LibraryItem, "name" | "library">,
5051
start: number,
5152
limit: number,
53+
sortModel: SortModelItem[],
5254
): Promise<TableData> {
55+
if (sortModel.length > 0) {
56+
return await this.getSortedRows(item, start, limit, sortModel);
57+
}
58+
5359
const { data } = await this.retryOnFail<RowCollection>(
5460
async () =>
5561
await this.dataAccessApi.getRows(
@@ -71,6 +77,43 @@ class RestLibraryAdapter implements LibraryAdapter {
7177
};
7278
}
7379

80+
private async getSortedRows(
81+
item: Pick<LibraryItem, "name" | "library">,
82+
start: number,
83+
limit: number,
84+
sortModel: SortModelItem[],
85+
): Promise<TableData> {
86+
const { data: viewData } = await this.retryOnFail(
87+
async () =>
88+
await this.dataAccessApi.createView(
89+
{
90+
sessionId: this.sessionId,
91+
libref: item.library || "",
92+
tableName: item.name,
93+
viewRequest: {
94+
sortBy: sortModel.map((sortModelItem) => ({
95+
key: sortModelItem.colId,
96+
direction:
97+
sortModelItem.sort === "asc" ? "ascending" : "descending",
98+
})),
99+
},
100+
},
101+
requestOptions,
102+
),
103+
);
104+
105+
const results = await this.getRows(
106+
{ library: viewData.libref, name: viewData.name },
107+
start,
108+
limit,
109+
[],
110+
);
111+
112+
await this.deleteTable({ library: viewData.libref, name: viewData.name });
113+
114+
return results;
115+
}
116+
74117
public async getRowsAsCSV(
75118
item: LibraryItem,
76119
start: number,
@@ -177,15 +220,18 @@ class RestLibraryAdapter implements LibraryAdapter {
177220
}
178221
}
179222

180-
public async deleteTable(item: LibraryItem): Promise<void> {
223+
public async deleteTable({
224+
library,
225+
name,
226+
}: Pick<LibraryItem, "library" | "name">): Promise<void> {
181227
await this.setup();
182228
try {
183229
await this.retryOnFail(
184230
async () =>
185231
await this.dataAccessApi.deleteTable({
186232
sessionId: this.sessionId,
187-
libref: item.library,
188-
tableName: item.name,
233+
libref: library,
234+
tableName: name,
189235
}),
190236
);
191237
// eslint-disable-next-line @typescript-eslint/no-unused-vars

0 commit comments

Comments
 (0)