Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/translate query to python snippet #98

Merged
merged 8 commits into from
May 22, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,39 @@
.code {
cursor: text;
user-select: text !important;
}
min-width: 300px;
}

.button-menu, .button-menu button {
background-color: var(--secondary-background-color);
color: var(--secondary-text-color);
}

.button-menu i, .button-menu li > div {
color: var(--secondary-text-color);
}

.button-menu :is(a, button):hover, .button-menu button:hover i {
background-color: var(--highlight-background-color);
color: var(--highlight-text-color) !important;
}

.code-actions {
max-width: 100%;
padding: 0 var(--padding) 0 var(--padding);

/* flex parent */
display: flex;
}

.action-button {
background-color: var(--primary-background-color);
border: none;
border-radius: 0;
color: var(--primary-text-color);
width: 200px;
}

.action-button i, .action-button:hover i {
color: var(--primary-text-color) !important;
}
30 changes: 29 additions & 1 deletion packages/core/components/Modal/CodeSnippet/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IconButton, TooltipHost } from "@fluentui/react";
import { ActionButton, IContextualMenuItem, IconButton, TooltipHost } from "@fluentui/react";
import classNames from "classnames";
import * as React from "react";
import { useSelector } from "react-redux";
import SyntaxHighlighter from "react-syntax-highlighter";
Expand All @@ -18,9 +19,19 @@ export default function CodeSnippet({ onDismiss }: ModalProps) {
const pythonSnippet = useSelector(interaction.selectors.getPythonSnippet);
const code = pythonSnippet?.code;
const setup = pythonSnippet?.setup;
const languageOptions: IContextualMenuItem[] = [
{
key: "python",
text: "Python 3.8+ (pandas)",
onClick() {
setLanguage("Python 3.8+ (pandas)");
},
},
];

const [isSetupCopied, setSetupCopied] = React.useState(false);
const [isCodeCopied, setCodeCopied] = React.useState(false);
const [language, setLanguage] = React.useState(languageOptions[0].text);

const onCopySetup = () => {
setup && navigator.clipboard.writeText(setup);
Expand All @@ -44,6 +55,19 @@ export default function CodeSnippet({ onDismiss }: ModalProps) {

const body = (
<>
<div className={styles.header}>
<h4>Language</h4>
<div className={styles.codeActions}>
<ActionButton
className={classNames(styles.actionButton, styles.copyButton)}
menuProps={{
className: styles.buttonMenu,
items: languageOptions,
}}
text={language}
/>
</div>
</div>
<div className={styles.header}>
<h4>Setup</h4>
<TooltipHost content={isSetupCopied ? "Copied to clipboard!" : undefined}>
Expand Down Expand Up @@ -75,6 +99,10 @@ export default function CodeSnippet({ onDismiss }: ModalProps) {
</div>
<SyntaxHighlighter
className={styles.code}
lineProps={{ style: { wordBreak: "break-all", whiteSpace: "pre-wrap" } }}
wrapLines
showLineNumbers={false}
showInlineLineNumbers={false}
language="python"
onMouseDown={stopPropagationHandler}
onMouseMove={stopPropagationHandler}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { configureMockStore, mergeState } from "@aics/redux-utils";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { expect } from "chai";
import * as React from "react";
import { Provider } from "react-redux";
Expand All @@ -12,6 +12,11 @@ describe("<CodeSnippet />", () => {
interaction: {
visibleModal: ModalType.CodeSnippet,
},
selection: {
dataSource: {
uri: "fake-uri.test",
},
},
});

it("is visible when should not be hidden", () => {
Expand All @@ -30,8 +35,8 @@ describe("<CodeSnippet />", () => {

it("displays snippet when present in state", async () => {
// Arrange
const setup = "pip install pandas";
const code = "TODO";
const setup = /pip install (")?pandas/;
const code = "#No options selected";
const { store } = configureMockStore({ state: visibleDialogState });
const { findByText } = render(
<Provider store={store}>
Expand All @@ -40,7 +45,30 @@ describe("<CodeSnippet />", () => {
);

// Assert
expect(await findByText(setup)).to.exist;
expect(screen.findByText((_, element) => element?.textContent?.match(setup) !== null)).to
.exist;
expect(await findByText(code)).to.exist;
});

it("displays temporary 'coming soon' message for internal data sources", async () => {
// Arrange
const code = "# Coming soon";
const internalDataSourceState = {
...visibleDialogState,
selection: {
dataSource: {
uri: undefined,
},
},
};
const { store } = configureMockStore({ state: internalDataSourceState });
const { findByText } = render(
<Provider store={store}>
<Modal />
</Provider>
);

// Assert
expect(await findByText(code)).to.exist;
});
});
102 changes: 102 additions & 0 deletions packages/core/entity/FileExplorerURL/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AnnotationName } from "../Annotation";
import FileFilter from "../FileFilter";
import FileFolder from "../FileFolder";
import FileSort, { SortOrder } from "../FileSort";
import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants";

export interface Source {
name: string;
Expand Down Expand Up @@ -118,4 +119,105 @@ export default class FileExplorerURL {
.map((parsedFolder) => new FileFolder(parsedFolder)),
};
}

public static convertToPython(
urlComponents: Partial<FileExplorerURLComponents>,
userOS: string
) {
if (
urlComponents?.source?.name === AICS_FMS_DATA_SOURCE_NAME ||
!urlComponents?.source?.uri
) {
return "# Coming soon";
}
const sourceString = this.convertDataSourceToPython(urlComponents?.source, userOS);
const groupByQueryString =
urlComponents.hierarchy
?.map((annotation) => this.convertGroupByToPython(annotation))
.join("") || "";

// Group filters by name and use OR to concatenate same filter values
const filterGroups = new Map();
urlComponents.filters?.forEach((filter) => {
const pythonQueryString = filterGroups.get(filter.name);
if (!pythonQueryString) {
filterGroups.set(filter.name, this.convertFilterToPython(filter));
} else {
filterGroups.set(
filter.name,
pythonQueryString.concat(` | ${this.convertFilterToPython(filter)}`)
);
}
});

// Chain the filters together
let filterQueryString = "";
filterGroups.forEach((value) => {
filterQueryString = filterQueryString.concat(`.query('${value}')`);
});

const sortQueryString = urlComponents.sortColumn
? this.convertSortToPython(urlComponents.sortColumn)
: "";
// const fuzzy = [] // TO DO: support fuzzy filtering

const hasQueryElements = groupByQueryString || filterQueryString || sortQueryString;
const imports = "import pandas as pd\n\n";
const comment = hasQueryElements ? "#Query on dataframe df" : "#No options selected";
const fullQueryString = `${comment}${
aswallace marked this conversation as resolved.
Show resolved Hide resolved
hasQueryElements &&
`\ndf_queried = df${groupByQueryString}${filterQueryString}${sortQueryString}`
}`;
return `${imports}${sourceString}${fullQueryString}`;
}

private static convertSortToPython(sortColumn: FileSort) {
return `.sort_values(by='${sortColumn.annotationName}', ascending=${
sortColumn.order == "ASC" ? "True" : "False"
})`;
}

private static convertGroupByToPython(annotation: string) {
return `.groupby('${annotation}', group_keys=True).apply(lambda x: x)`;
}

private static convertFilterToPython(filter: FileFilter) {
// TO DO: Support querying non-string types
if (filter.value.includes("RANGE")) {
return;
// let begin, end;
// return `\`${filter.name}\`>="${begin}"&\`${filter.name}\`<"${end}"`
}
return `\`${filter.name}\`=="${filter.value}"`;
}

private static convertDataSourceToPython(source: Source | undefined, userOS: string) {
const isUsingWindowsOS = userOS === "Windows_NT" || userOS.includes("Windows NT");
const rawFlagForWindows = isUsingWindowsOS ? "r" : "";

if (typeof source?.uri === "string") {
const comment = "#Convert current datasource file to a pandas dataframe";

// Currently suggest setting all fields to strings; otherwise pandas assumes type conversions
// TO DO: Address different non-string type conversions
const code = `df = pd.read_${source.type}(${rawFlagForWindows}'${source.uri}').astype('str')`;
// This only works if we assume that the file types will only be csv, parquet or json

return `${comment}\n${code}\n\n`;
} else if (source?.uri) {
// Any other type, i.e., File. `instanceof` breaks testing library
// Adding strings to avoid including unwanted white space
const inputFileLineComment =
" # Unable to automatically determine " +
"local file location in the browser. Modify this variable to " +
"represent the full path to your .csv, .json, or .parquet data sources\n";
const inputFileError =
"if not input_file:\n" +
'\traise Exception("Must supply the data source location for the query")\n';
const inputFileCode = 'input_file = ""' + inputFileLineComment + inputFileError;

const conversionCode = `df = pd.read_${source.type}(input_file).astype('str')`;
return `${inputFileCode}\n${conversionCode}\n\n`;
} else return ""; // Safeguard. Should not reach else
}
}
Loading
Loading