Skip to content

Commit

Permalink
feat: allow convert to flow for explorer files (#552)
Browse files Browse the repository at this point in the history
**Summary**
This does a few things to improve the sasnb -> dataflow experience:
 - Provides visual indication of converting a notebook to dataflow
 - allows converting sasnb files to notebooks from the explorer view (We default to storing notebooks in "My Folder")
 - updates messaging to indicate _where_ a notebook was created (mainly to clear up confusion about where the dataflow went)
 - updates sas content to display created dataflow files, and updates content model to display `FileOpenError` when trying to open files

**Testing**
 - [x] Attempted to "Convert to flow..." from sas content and local filesystem
 - [x] Attempted to open a flow file in vscode
 - [x] Test renaming dataflow
 - [x] Test renaming non-dataflow file
 - [x] Test deleting dataflow
 - [x] Test favoriting dataflow
  • Loading branch information
scottdover authored Oct 19, 2023
1 parent 4c172f6 commit 086bc27
Show file tree
Hide file tree
Showing 8 changed files with 170 additions and 83 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

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.

## [Unreleased]

### Added

- Added the ability to use `Convert to flow...` for sas notebooks in the local filesystem ([#552](https://github.com/sassoftware/vscode-sas-extension/pull/552))

## [v1.4.1] - 2023-09-29

### Fixed
Expand Down
66 changes: 40 additions & 26 deletions client/src/components/ContentNavigator/ContentDataProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { ViyaProfile } from "../profile";
import { ContentModel } from "./ContentModel";
import {
FAVORITES_FOLDER_TYPE,
MYFOLDER_TYPE,
Messages,
ROOT_FOLDER_TYPE,
TRASH_FOLDER_TYPE,
Expand Down Expand Up @@ -304,15 +305,6 @@ class ContentDataProvider
return this.model.saveContentToUri(uri, new TextDecoder().decode(content));
}

public associateFlow(
name: string,
uri: Uri,
parent: ContentItem,
studioSessionId: string,
): Promise<string> {
return this.model.associateFlowFile(name, uri, parent, studioSessionId);
}

public async deleteResource(item: ContentItem): Promise<boolean> {
if (!(await closeFileIfOpen(item))) {
return false;
Expand Down Expand Up @@ -403,43 +395,65 @@ class ContentDataProvider
this.reveal(resource);
}

public async testStudioConnection(): Promise<string> {
return await this.model.testStudioConnection();
public async acquireStudioSessionId(endpoint: string): Promise<string> {
if (endpoint && !this.model.connected()) {
await this.connect(endpoint);
}
return await this.model.acquireStudioSessionId();
}

public async convertNotebookToFlow(
item: ContentItem,
name: string,
inputName: string,
outputName: string,
content: string,
studioSessionId: string,
): Promise<string | undefined> {
const parent = await this.getParent(item);
const resourceUri = getUri(item);
parentItem?: ContentItem,
): Promise<string> {
if (!parentItem) {
const rootFolders = await this.model.getChildren();
const myFolder = rootFolders.find(
(rootFolder) => rootFolder.type === MYFOLDER_TYPE,
);
if (!myFolder) {
return "";
}
parentItem = myFolder;
}

try {
// get the content of the notebook file
const contentString: string =
await this.provideTextDocumentContent(resourceUri);
// convert the notebook file to a .flw file
const flowDataString = convertNotebookToFlow(
contentString,
item.name,
name,
content,
inputName,
outputName,
);
const flowDataUint8Array = new TextEncoder().encode(flowDataString);
if (flowDataUint8Array.length === 0) {
window.showErrorMessage(Messages.NoCodeToConvert);
return;
}
const newUri = await this.createFile(parent, name, flowDataUint8Array);
const newUri = await this.createFile(
parentItem,
outputName,
flowDataUint8Array,
);
this.handleCreationResponse(
parent,
parentItem,
newUri,
l10n.t(Messages.NewFileCreationError, { name: name }),
l10n.t(Messages.NewFileCreationError, { name: inputName }),
);
// associate the new .flw file with SAS Studio
return await this.associateFlow(name, newUri, parent, studioSessionId);
await this.model.associateFlowFile(
outputName,
newUri,
parentItem,
studioSessionId,
);
} catch (error) {
window.showErrorMessage(error);
}

return parentItem.name;
}

public refresh(): void {
Expand Down
44 changes: 32 additions & 12 deletions client/src/components/ContentNavigator/ContentModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {
} from "../../connection/studio";
import { SASAuthProvider } from "../AuthProvider";
import {
DATAFLOW_TYPE,
FAVORITES_FOLDER_TYPE,
FILE_TYPE,
FILE_TYPES,
FOLDER_TYPE,
FOLDER_TYPES,
Expand Down Expand Up @@ -56,6 +56,10 @@ export class ContentModel {
this.viyaCadence = "";
}

public connected(): boolean {
return this.authorized;
}

public async connect(baseURL: string): Promise<void> {
this.connection = axios.create({ baseURL });
this.connection.interceptors.response.use(
Expand Down Expand Up @@ -189,9 +193,15 @@ export class ContentModel {

public async getContentByUri(uri: Uri): Promise<string> {
const resourceId = getResourceId(uri);
const res = await this.connection.get(resourceId + "/content", {
transformResponse: (response) => response,
});
let res;
try {
res = await this.connection.get(resourceId + "/content", {
transformResponse: (response) => response,
});
} catch (e) {
throw new Error(Messages.FileOpenError);
}

this.fileTokenMaps[resourceId] = {
etag: res.headers.etag,
lastModified: res.headers["last-modified"],
Expand Down Expand Up @@ -297,17 +307,14 @@ export class ContentModel {
);
}

const patchResponse = await this.connection.patch(
const patchResponse = await this.connection.put(
uri,
{ name },
{ ...res.data, name },
{
headers: {
"If-Unmodified-Since": fileTokenMap.lastModified,
"If-Match": fileTokenMap.etag,
"Content-Type":
!isContainer(item) && !itemIsReference
? "application/vnd.sas.file+json"
: undefined,
"Content-Type": fetchItemContentType(item),
},
},
);
Expand Down Expand Up @@ -365,7 +372,7 @@ export class ContentModel {
}
}

public async testStudioConnection(): Promise<string> {
public async acquireStudioSessionId(): Promise<string> {
try {
const result = await createStudioSession(this.connection);
return result;
Expand Down Expand Up @@ -668,7 +675,7 @@ export class ContentModel {

const getPermission = (item: ContentItem): Permission => {
const itemType = getTypeName(item);
return [FOLDER_TYPE, FILE_TYPE].includes(itemType) // normal folders and files
return [FOLDER_TYPE, ...FILE_TYPES].includes(itemType) // normal folders and files
? {
write: !!getLink(item.links, "PUT", "update"),
delete: !!getLink(item.links, "DELETE", "deleteResource"),
Expand All @@ -684,3 +691,16 @@ const getPermission = (item: ContentItem): Permission => {
!!getLink(item.links, "POST", "createChild"),
};
};

const fetchItemContentType = (item: ContentItem): string | undefined => {
const itemIsReference = item.type === "reference";
if (itemIsReference || isContainer(item)) {
return undefined;
}

if (item.contentType === DATAFLOW_TYPE) {
return "application/json";
}

return "application/vnd.sas.file+json";
};
5 changes: 3 additions & 2 deletions client/src/components/ContentNavigator/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ export const ROOT_FOLDER = {
};

export const FILE_TYPE = "file";
export const FILE_TYPES = [FILE_TYPE];
export const DATAFLOW_TYPE = "dataFlow";
export const FILE_TYPES = [FILE_TYPE, DATAFLOW_TYPE];
export const FOLDER_TYPE = "folder";
export const MYFOLDER_TYPE = "myFolder";
export const TRASH_FOLDER_TYPE = "trashFolder";
Expand Down Expand Up @@ -85,7 +86,7 @@ export const Messages = {
),
ConvertNotebookToFlowPrompt: l10n.t("Enter a name for the new .flw file"),
NotebookToFlowConversionSuccess: l10n.t(
"The notebook has been successfully converted to a flow. You can now open it in SAS Studio.",
"The notebook has been successfully converted to a flow and saved into the following folder: {folderName}. You can now open it in SAS Studio.",
),
NotebookToFlowConversionError: l10n.t(
"Error converting the notebook file to .flw format.",
Expand Down
107 changes: 73 additions & 34 deletions client/src/components/ContentNavigator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,29 @@ import {
ConfigurationChangeEvent,
Disposable,
ExtensionContext,
ProgressLocation,
Uri,
commands,
l10n,
window,
workspace,
} from "vscode";

import { basename } from "path";

import { profileConfig } from "../../commands/profile";
import { SubscriptionProvider } from "../SubscriptionProvider";
import { ConnectionType } from "../profile";
import ContentDataProvider from "./ContentDataProvider";
import { ContentModel } from "./ContentModel";
import { Messages } from "./const";
import { ContentItem } from "./types";
import { isContainer as getIsContainer, isItemInRecycleBin } from "./utils";
import {
isContainer as getIsContainer,
getUri,
isContentItem,
isItemInRecycleBin,
} from "./utils";

const fileValidator = (value: string): string | null =>
/^([^/<>;\\{}?#]+)\.\w+$/.test(
Expand Down Expand Up @@ -236,60 +244,91 @@ class ContentNavigator implements SubscriptionProvider {
}),
commands.registerCommand(
"SAS.convertNotebookToFlow",
async (resource: ContentItem) => {
async (resource: ContentItem | Uri) => {
const inputName = isContentItem(resource)
? resource.name
: basename(resource.fsPath);
// Open window to chose the name and location of the new .flw file
const name = await window.showInputBox({
const outputName = await window.showInputBox({
prompt: Messages.ConvertNotebookToFlowPrompt,
value: resource.name.replace(".sasnb", ".flw"),
value: inputName.replace(".sasnb", ".flw"),
validateInput: flowFileValidator,
});

if (!name) {
if (!outputName) {
// User canceled the input box
return;
}
const studioSessionId =
await this.contentDataProvider.testStudioConnection();
if (!studioSessionId) {
window.showErrorMessage(Messages.StudioConnectionError);
return;
}

if (
await this.contentDataProvider.convertNotebookToFlow(
resource,
name,
studioSessionId,
)
) {
window.showInformationMessage(
Messages.NotebookToFlowConversionSuccess,
);
} else {
window.showErrorMessage(Messages.NotebookToFlowConversionError);
}
await window.withProgress(
{
location: ProgressLocation.Notification,
title: l10n.t("Converting SAS notebook to flow..."),
},
async () => {
// Make sure we're connected
const endpoint = this.viyaEndpoint();
const studioSessionId =
await this.contentDataProvider.acquireStudioSessionId(endpoint);
if (!studioSessionId) {
window.showErrorMessage(Messages.StudioConnectionError);
return;
}

const content = isContentItem(resource)
? await this.contentDataProvider.provideTextDocumentContent(
getUri(resource),
)
: (await workspace.fs.readFile(resource)).toString();

const folderName =
await this.contentDataProvider.convertNotebookToFlow(
inputName,
outputName,
content,
studioSessionId,
isContentItem(resource)
? await this.contentDataProvider.getParent(resource)
: undefined,
);

if (folderName) {
window.showInformationMessage(
l10n.t(Messages.NotebookToFlowConversionSuccess, {
folderName,
}),
);
} else {
window.showErrorMessage(Messages.NotebookToFlowConversionError);
}
},
);
},
),
workspace.onDidChangeConfiguration(
async (event: ConfigurationChangeEvent) => {
if (event.affectsConfiguration("SAS.connectionProfiles")) {
const activeProfile = profileConfig.getProfileByName(
profileConfig.getActiveProfile(),
);
if (activeProfile) {
if (
activeProfile.connectionType === ConnectionType.Rest &&
!activeProfile.serverId
) {
await this.contentDataProvider.connect(activeProfile.endpoint);
}
const endpoint = this.viyaEndpoint();
if (endpoint) {
await this.contentDataProvider.connect(endpoint);
}
}
},
),
];
}

private viyaEndpoint(): string {
const activeProfile = profileConfig.getProfileByName(
profileConfig.getActiveProfile(),
);
return activeProfile &&
activeProfile.connectionType === ConnectionType.Rest &&
!activeProfile.serverId
? activeProfile.endpoint
: "";
}

private async handleCreationResponse(
resource: ContentItem,
newUri: Uri | undefined,
Expand Down
Loading

0 comments on commit 086bc27

Please sign in to comment.