diff --git a/package-lock.json b/package-lock.json
index f54ce57cf..f37338690 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -37,7 +37,8 @@
"redux": "4.0.x",
"redux-logic": "3.x",
"reselect": "4.0.x",
- "string-natural-compare": "3.0.x"
+ "string-natural-compare": "3.0.x",
+ "zarrita": "^0.3.2"
},
"devDependencies": {
"@babel/cli": "7.x",
@@ -4089,6 +4090,40 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true
},
+ "node_modules/@zarrita/core": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@zarrita/core/-/core-0.0.3.tgz",
+ "integrity": "sha512-fWv51b+xbYnws1pkNDPwJQoDa76aojxplHyMup82u11UAiet3gURMsrrkhM6YbeTgSY1A8oGxDOrvar3SiZpLA==",
+ "dependencies": {
+ "@zarrita/storage": "^0.0.2",
+ "@zarrita/typedarray": "^0.0.1",
+ "numcodecs": "^0.2.2"
+ }
+ },
+ "node_modules/@zarrita/indexing": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@zarrita/indexing/-/indexing-0.0.3.tgz",
+ "integrity": "sha512-Q61d9MYX6dsK1DLltEpwx4mJWCZHj0TXiaEN4QpxNDtToa/EoyytP/pYHPypO4GXBscZooJ6eZkKT5FMx9PVfg==",
+ "dependencies": {
+ "@zarrita/core": "^0.0.3",
+ "@zarrita/storage": "^0.0.2",
+ "@zarrita/typedarray": "^0.0.1"
+ }
+ },
+ "node_modules/@zarrita/storage": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.0.2.tgz",
+ "integrity": "sha512-uFt4abAoiOYLroalNDAnVaQxA17zGKrQ0waYKmTVm+bNonz8ggKZP+0FqMhgUZITGChqoANHuYTazbuU5AFXWA==",
+ "dependencies": {
+ "reference-spec-reader": "^0.2.0",
+ "unzipit": "^1.3.6"
+ }
+ },
+ "node_modules/@zarrita/typedarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@zarrita/typedarray/-/typedarray-0.0.1.tgz",
+ "integrity": "sha512-ZdvNjYP1bEuQXoSTVkemV99w42jHYrJ3nh9golCLd4MVBlrVbfZo4wWgBslU4JZUaDikhFSH+GWMDgAq/rI32g=="
+ },
"node_modules/7zip-bin": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.1.1.tgz",
@@ -13880,6 +13915,14 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/numcodecs": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.2.2.tgz",
+ "integrity": "sha512-Y5K8mv80yb4MgVpcElBkUeMZqeE4TrovxRit/dTZvoRl6YkB6WEjY+fiUjGCblITnt3T3fmrDg8yRWu0gOLjhQ==",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/nwsapi": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz",
@@ -15941,6 +15984,11 @@
"redux": ">=3.5.2"
}
},
+ "node_modules/reference-spec-reader": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
+ "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ=="
+ },
"node_modules/refractor": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
@@ -18141,6 +18189,17 @@
"yaku": "^0.16.6"
}
},
+ "node_modules/unzipit": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz",
+ "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==",
+ "dependencies": {
+ "uzip-module": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -18218,6 +18277,11 @@
"uuid": "dist/bin/uuid"
}
},
+ "node_modules/uzip-module": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz",
+ "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA=="
+ },
"node_modules/v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@@ -19318,6 +19382,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/zarrita": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.3.2.tgz",
+ "integrity": "sha512-Zx9nS28C2tXZhF1BmQkgQGi0M/Z5JiM/KCMa+fEYtr/MnIzyizR4sKRA/sXjDP1iuylILWTJAWWBJD//0ONXCA==",
+ "dependencies": {
+ "@zarrita/core": "^0.0.3",
+ "@zarrita/indexing": "^0.0.3",
+ "@zarrita/storage": "^0.0.2"
+ }
+ },
"packages/desktop": {
"name": "fms-file-explorer-desktop",
"version": "7.0.0",
@@ -22477,6 +22551,40 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"dev": true
},
+ "@zarrita/core": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@zarrita/core/-/core-0.0.3.tgz",
+ "integrity": "sha512-fWv51b+xbYnws1pkNDPwJQoDa76aojxplHyMup82u11UAiet3gURMsrrkhM6YbeTgSY1A8oGxDOrvar3SiZpLA==",
+ "requires": {
+ "@zarrita/storage": "^0.0.2",
+ "@zarrita/typedarray": "^0.0.1",
+ "numcodecs": "^0.2.2"
+ }
+ },
+ "@zarrita/indexing": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/@zarrita/indexing/-/indexing-0.0.3.tgz",
+ "integrity": "sha512-Q61d9MYX6dsK1DLltEpwx4mJWCZHj0TXiaEN4QpxNDtToa/EoyytP/pYHPypO4GXBscZooJ6eZkKT5FMx9PVfg==",
+ "requires": {
+ "@zarrita/core": "^0.0.3",
+ "@zarrita/storage": "^0.0.2",
+ "@zarrita/typedarray": "^0.0.1"
+ }
+ },
+ "@zarrita/storage": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.0.2.tgz",
+ "integrity": "sha512-uFt4abAoiOYLroalNDAnVaQxA17zGKrQ0waYKmTVm+bNonz8ggKZP+0FqMhgUZITGChqoANHuYTazbuU5AFXWA==",
+ "requires": {
+ "reference-spec-reader": "^0.2.0",
+ "unzipit": "^1.3.6"
+ }
+ },
+ "@zarrita/typedarray": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/@zarrita/typedarray/-/typedarray-0.0.1.tgz",
+ "integrity": "sha512-ZdvNjYP1bEuQXoSTVkemV99w42jHYrJ3nh9golCLd4MVBlrVbfZo4wWgBslU4JZUaDikhFSH+GWMDgAq/rI32g=="
+ },
"7zip-bin": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.1.1.tgz",
@@ -29949,6 +30057,11 @@
}
}
},
+ "numcodecs": {
+ "version": "0.2.2",
+ "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.2.2.tgz",
+ "integrity": "sha512-Y5K8mv80yb4MgVpcElBkUeMZqeE4TrovxRit/dTZvoRl6YkB6WEjY+fiUjGCblITnt3T3fmrDg8yRWu0gOLjhQ=="
+ },
"nwsapi": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz",
@@ -31427,6 +31540,11 @@
"rxjs": "^6.6.6"
}
},
+ "reference-spec-reader": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz",
+ "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ=="
+ },
"refractor": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz",
@@ -33139,6 +33257,14 @@
"yaku": "^0.16.6"
}
},
+ "unzipit": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz",
+ "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==",
+ "requires": {
+ "uzip-module": "^1.0.2"
+ }
+ },
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -33202,6 +33328,11 @@
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
},
+ "uzip-module": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz",
+ "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA=="
+ },
"v8-compile-cache": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
@@ -34006,6 +34137,16 @@
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
+ },
+ "zarrita": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.3.2.tgz",
+ "integrity": "sha512-Zx9nS28C2tXZhF1BmQkgQGi0M/Z5JiM/KCMa+fEYtr/MnIzyizR4sKRA/sXjDP1iuylILWTJAWWBJD//0ONXCA==",
+ "requires": {
+ "@zarrita/core": "^0.0.3",
+ "@zarrita/indexing": "^0.0.3",
+ "@zarrita/storage": "^0.0.2"
+ }
}
}
}
diff --git a/package.json b/package.json
index 4c97188ee..4526050e1 100644
--- a/package.json
+++ b/package.json
@@ -91,6 +91,7 @@
"redux": "4.0.x",
"redux-logic": "3.x",
"reselect": "4.0.x",
- "string-natural-compare": "3.0.x"
+ "string-natural-compare": "3.0.x",
+ "zarrita": "^0.3.2"
}
}
diff --git a/packages/core/App.tsx b/packages/core/App.tsx
index eec917b17..033b23a11 100644
--- a/packages/core/App.tsx
+++ b/packages/core/App.tsx
@@ -3,9 +3,10 @@ import { initializeIcons, loadTheme } from "@fluentui/react";
import classNames from "classnames";
import { uniqueId } from "lodash";
import * as React from "react";
-import { batch, useDispatch, useSelector } from "react-redux";
+import { useDispatch, useSelector } from "react-redux";
import ContextMenu from "./components/ContextMenu";
+import DataSourcePrompt from "./components/DataSourcePrompt";
import Modal from "./components/Modal";
import DirectoryTree from "./components/DirectoryTree";
import FileDetails from "./components/FileDetails";
@@ -14,7 +15,7 @@ import StatusMessage from "./components/StatusMessage";
import TutorialTooltip from "./components/TutorialTooltip";
import QuerySidebar from "./components/QuerySidebar";
import { FileExplorerServiceBaseUrl } from "./constants";
-import { interaction, metadata, selection } from "./state";
+import { interaction, selection } from "./state";
import "./styles/global.css";
import styles from "./App.module.css";
@@ -42,6 +43,7 @@ export default function App(props: AppProps) {
const { fileExplorerServiceBaseUrl = FileExplorerServiceBaseUrl.PRODUCTION } = props;
const dispatch = useDispatch();
+ const hasQuerySelected = useSelector(selection.selectors.hasQuerySelected);
const isDarkTheme = useSelector(selection.selectors.getIsDarkTheme);
const shouldDisplaySmallFont = useSelector(selection.selectors.getShouldDisplaySmallFont);
const platformDependentServices = useSelector(
@@ -70,14 +72,8 @@ export default function App(props: AppProps) {
}, [platformDependentServices, dispatch]);
// Set data source base urls
- // And kick off the process of requesting metadata needed by the application.
React.useEffect(() => {
- batch(() => {
- dispatch(interaction.actions.setFileExplorerServiceBaseUrl(fileExplorerServiceBaseUrl));
- dispatch(metadata.actions.requestAnnotations());
- dispatch(metadata.actions.requestDataSources());
- dispatch(selection.actions.setAnnotationHierarchy([]));
- });
+ dispatch(interaction.actions.initializeApp(fileExplorerServiceBaseUrl));
}, [dispatch, fileExplorerServiceBaseUrl]);
return (
@@ -92,8 +88,14 @@ export default function App(props: AppProps) {
-
-
+ {hasQuerySelected ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
diff --git a/packages/core/components/AnnotationPicker/index.tsx b/packages/core/components/AnnotationPicker/index.tsx
index b1486d17e..01ac0be69 100644
--- a/packages/core/components/AnnotationPicker/index.tsx
+++ b/packages/core/components/AnnotationPicker/index.tsx
@@ -10,17 +10,16 @@ import { metadata, selection } from "../../state";
interface Props {
id?: string;
- enableAllAnnotations?: boolean;
disabledTopLevelAnnotations?: boolean;
hasSelectAllCapability?: boolean;
disableUnavailableAnnotations?: boolean;
className?: string;
title?: string;
- selections: Annotation[];
+ selections: string[];
annotationSubMenuRenderer?: (
item: ListItem
) => React.ReactElement>;
- setSelections: (annotations: Annotation[]) => void;
+ setSelections: (annotations: string[]) => void;
}
/**
@@ -28,25 +27,22 @@ interface Props {
* downloading a manifest.
*/
export default function AnnotationPicker(props: Props) {
- const annotations = useSelector(metadata.selectors.getSortedAnnotations).filter(
- (annotation) =>
- !props.disabledTopLevelAnnotations ||
- !TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(annotation.name)
- );
+ const annotations = useSelector(metadata.selectors.getSortedAnnotations);
const unavailableAnnotations = useSelector(
selection.selectors.getUnavailableAnnotationsForHierarchy
);
const areAvailableAnnotationLoading = useSelector(
selection.selectors.getAvailableAnnotationsForHierarchyLoading
);
-
const recentAnnotationNames = useSelector(selection.selectors.getRecentAnnotations);
+
const recentAnnotations = recentAnnotationNames.flatMap((name) =>
annotations.filter((annotation) => annotation.name === name)
);
// Define buffer item
const bufferBar = {
+ name: "buffer",
selected: false,
disabled: false,
isBuffer: true,
@@ -55,47 +51,47 @@ export default function AnnotationPicker(props: Props) {
};
// combine all annotation lists and buffer item objects
- const rawItems = [...recentAnnotations, bufferBar, ...annotations];
-
- const items = uniqBy(
- rawItems.flatMap((annotation) => {
- if (annotation instanceof Annotation) {
- return {
- selected: props.selections.some(
- (selected) => selected.name === annotation.name
- ),
- disabled:
- !props.enableAllAnnotations &&
- unavailableAnnotations.some(
- (unavailable) => unavailable.name === annotation.name
- ),
- recent:
- recentAnnotationNames.includes(annotation.name) &&
- !props.selections.some((selected) => selected.name === annotation.name),
- loading: !props.enableAllAnnotations && areAvailableAnnotationLoading,
- description: annotation.description,
- data: annotation,
- value: annotation.name,
- displayValue: annotation.displayName,
- };
- } else {
- // This is reached if the 'annotation' is a spacer.
+ const nonUniqueItems = [...recentAnnotations, bufferBar, ...annotations]
+ .filter(
+ (annotation) =>
+ !props.disabledTopLevelAnnotations ||
+ !TOP_LEVEL_FILE_ANNOTATION_NAMES.includes(annotation.name)
+ )
+ .map((annotation) => {
+ // This is reached if the 'annotation' is a spacer.
+ if (!(annotation instanceof Annotation)) {
return annotation;
}
- }),
- "value"
- );
+
+ const isSelected = props.selections.some((selected) => selected === annotation.name);
+ return {
+ selected: isSelected,
+ recent: recentAnnotationNames.includes(annotation.name) && !isSelected,
+ disabled:
+ props.disableUnavailableAnnotations &&
+ unavailableAnnotations.some(
+ (unavailable) => unavailable.name === annotation.name
+ ),
+ loading: props.disableUnavailableAnnotations && areAvailableAnnotationLoading,
+ description: annotation.description,
+ data: annotation,
+ value: annotation.name,
+ displayValue: annotation.displayName,
+ };
+ });
+
+ const items = uniqBy(nonUniqueItems, "value");
const removeSelection = (item: ListItem) => {
props.setSelections(
- props.selections.filter((annotation) => annotation.name !== item.data?.name)
+ props.selections.filter((annotation) => annotation !== item.data?.name)
);
};
const addSelection = (item: ListItem) => {
// Should never be undefined, included as guard statement to satisfy compiler
if (item.data) {
- props.setSelections([...props.selections, item.data]);
+ props.setSelections([...props.selections, item.data.name]);
}
};
@@ -108,7 +104,9 @@ export default function AnnotationPicker(props: Props) {
onDeselect={removeSelection}
onSelect={addSelection}
onSelectAll={
- props.hasSelectAllCapability ? () => props.setSelections?.(annotations) : undefined
+ props.hasSelectAllCapability
+ ? () => props.setSelections?.(annotations.map((a) => a.name))
+ : undefined
}
onDeselectAll={() => props.setSelections([])}
subMenuRenderer={props.annotationSubMenuRenderer}
diff --git a/packages/core/components/Modal/DataSourcePrompt/DataSourcePrompt.module.css b/packages/core/components/DataSourcePrompt/DataSourcePrompt.module.css
similarity index 98%
rename from packages/core/components/Modal/DataSourcePrompt/DataSourcePrompt.module.css
rename to packages/core/components/DataSourcePrompt/DataSourcePrompt.module.css
index 6fa93df73..6734de630 100644
--- a/packages/core/components/Modal/DataSourcePrompt/DataSourcePrompt.module.css
+++ b/packages/core/components/DataSourcePrompt/DataSourcePrompt.module.css
@@ -76,7 +76,7 @@
}
.text, .warning {
- font-size: smaller;
+ text-align: center;
}
.warning {
diff --git a/packages/core/components/Modal/DataSourcePrompt/index.tsx b/packages/core/components/DataSourcePrompt/index.tsx
similarity index 86%
rename from packages/core/components/Modal/DataSourcePrompt/index.tsx
rename to packages/core/components/DataSourcePrompt/index.tsx
index 192e8ad9e..0107a927e 100644
--- a/packages/core/components/Modal/DataSourcePrompt/index.tsx
+++ b/packages/core/components/DataSourcePrompt/index.tsx
@@ -3,15 +3,13 @@ import { throttle } from "lodash";
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";
-import { ModalProps } from "..";
-import BaseModal from "../BaseModal";
-import { Source } from "../../../entity/FileExplorerURL";
-import { interaction, selection } from "../../../state";
+import { interaction, selection } from "../../state";
+import { Source } from "../../entity/FileExplorerURL";
import styles from "./DataSourcePrompt.module.css";
-interface Props extends ModalProps {
- isEditing?: boolean;
+interface Props {
+ hideTitle?: boolean;
}
const DATA_SOURCE_DETAILS = [
@@ -29,22 +27,26 @@ const DATA_SOURCE_DETAILS = [
/**
* Dialog meant to prompt user to select a data source option
*/
-export default function DataSourcePrompt({ onDismiss }: Props) {
+export default function DataSourcePrompt(props: Props) {
const dispatch = useDispatch();
- const dataSourceToReplace = useSelector(interaction.selectors.getDataSourceForVisibleModal);
+ const selectedDataSources = useSelector(selection.selectors.getSelectedDataSources);
+ const dataSourceInfo = useSelector(interaction.selectors.getDataSourceInfoForVisibleModal);
+ const { source: sourceToReplace, query } = dataSourceInfo || {};
const [dataSourceURL, setDataSourceURL] = React.useState("");
const [isDataSourceDetailExpanded, setIsDataSourceDetailExpanded] = React.useState(false);
const addOrReplaceQuery = (source: Source) => {
- if (dataSourceToReplace) {
+ if (sourceToReplace) {
dispatch(selection.actions.replaceDataSource(source));
+ } else if (query) {
+ dispatch(selection.actions.changeDataSources([...selectedDataSources, source]));
} else {
dispatch(
selection.actions.addQuery({
name: `New ${source.name} Query`,
- parts: { source },
+ parts: { sources: [source] },
})
);
}
@@ -62,7 +64,7 @@ export default function DataSourcePrompt({ onDismiss }: Props) {
return;
}
addOrReplaceQuery({ name, type: extension, uri: selectedFile });
- onDismiss();
+ dispatch(interaction.actions.hideVisibleModal());
}
};
const onEnterURL = throttle(
@@ -88,20 +90,20 @@ export default function DataSourcePrompt({ onDismiss }: Props) {
type: extensionGuess as "csv" | "json" | "parquet",
uri: dataSourceURL,
});
- onDismiss();
+ dispatch(interaction.actions.hideVisibleModal());
},
10000,
{ leading: true, trailing: false }
);
- const body = (
+ return (
<>
- {dataSourceToReplace && (
+ {sourceToReplace && (
Notice
There was an error loading the data source file "
- {dataSourceToReplace.name}". Please re-select the data source file or a
+ {sourceToReplace.name}". Please re-select the data source file or a
replacement.
@@ -111,10 +113,10 @@ export default function DataSourcePrompt({ onDismiss }: Props) {
)}
+ {!props.hideTitle && Choose a data source }
- Please provide a ".csv", ".parquet", or ".json" file
- containing metadata about some files. See more details for information about what a
- data source file should look like...
+ To get started, load a CSV, Parquet, or JSON file containing metadata (annotations)
+ about your files to view them.
{isDataSourceDetailExpanded ? (
@@ -180,6 +182,4 @@ export default function DataSourcePrompt({ onDismiss }: Props) {
>
);
-
- return ;
}
diff --git a/packages/core/components/FileDetails/index.tsx b/packages/core/components/FileDetails/index.tsx
index a93f7942f..2cc9f7801 100644
--- a/packages/core/components/FileDetails/index.tsx
+++ b/packages/core/components/FileDetails/index.tsx
@@ -78,6 +78,13 @@ function resizeHandleDoubleClick() {
export default function FileDetails(props: Props) {
const [windowState, windowDispatch] = React.useReducer(windowStateReducer, INITIAL_STATE);
const [fileDetails, isLoading] = useFileDetails();
+ const [thumbnailPath, setThumbnailPath] = React.useState(undefined);
+
+ React.useEffect(() => {
+ if (fileDetails) {
+ fileDetails.getPathToThumbnail().then(setThumbnailPath);
+ }
+ }, [fileDetails]);
// If FileDetails pane is minimized, set its width to the width of the WindowActionButtons. Else, let it be
// defined by whatever the CSS determines (setting an inline style to undefined will prompt ReactDOM to not apply
@@ -85,7 +92,6 @@ export default function FileDetails(props: Props) {
const minimizedWidth =
windowState.state === WindowState.MINIMIZED ? WINDOW_ACTION_BUTTON_WIDTH : undefined;
- const thumbnailPath = fileDetails?.getPathToThumbnail();
let thumbnailHeight = undefined;
let thumbnailWidth = undefined;
if (windowState.state === WindowState.DEFAULT) {
diff --git a/packages/core/components/FileList/ColumnPicker.tsx b/packages/core/components/FileList/ColumnPicker.tsx
index 132afe65b..2aec5dc8a 100644
--- a/packages/core/components/FileList/ColumnPicker.tsx
+++ b/packages/core/components/FileList/ColumnPicker.tsx
@@ -2,25 +2,30 @@ import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import AnnotationPicker from "../AnnotationPicker";
-import { selection } from "../../state";
+import { metadata, selection } from "../../state";
/**
* Picker for selecting which columns to display in the file list.
*/
export default function ColumnPicker() {
const dispatch = useDispatch();
+ const annotations = useSelector(metadata.selectors.getAnnotations);
const columnAnnotations = useSelector(selection.selectors.getAnnotationsToDisplay);
return (
{
+ title="Select metadata to display as columns"
+ selections={columnAnnotations.map((a) => a.name)}
+ setSelections={(selectedAnnotations) => {
// Prevent de-selecting all columns
- if (!annotations.length) {
+ if (!selectedAnnotations.length) {
dispatch(selection.actions.setDisplayAnnotations([columnAnnotations[0]]));
} else {
- dispatch(selection.actions.setDisplayAnnotations(annotations));
+ dispatch(
+ selection.actions.setDisplayAnnotations(
+ annotations.filter((a) => selectedAnnotations.includes(a.name))
+ )
+ );
}
}}
/>
diff --git a/packages/core/components/FileList/LazilyRenderedThumbnail.tsx b/packages/core/components/FileList/LazilyRenderedThumbnail.tsx
index e6329782a..05a23778c 100644
--- a/packages/core/components/FileList/LazilyRenderedThumbnail.tsx
+++ b/packages/core/components/FileList/LazilyRenderedThumbnail.tsx
@@ -51,6 +51,14 @@ export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailPr
const file = fileSet.getFileByIndex(overallIndex);
const thumbnailSize = measuredWidth / fileGridColCount - 2 * MARGIN;
+ const [thumbnailPath, setThumbnailPath] = React.useState(undefined);
+
+ React.useEffect(() => {
+ if (file) {
+ file.getPathToThumbnail().then(setThumbnailPath);
+ }
+ }, [file]);
+
const isSelected = React.useMemo(() => {
return fileSelection.isSelected(fileSet, overallIndex);
}, [fileSelection, fileSet, overallIndex]);
@@ -88,7 +96,6 @@ export default function LazilyRenderedThumbnail(props: LazilyRenderedThumbnailPr
let content;
if (file) {
- const thumbnailPath = file.getPathToThumbnail();
const filenameForRender = clipFileName(file?.name);
content = (
;
+ }
+
+ export class HTTPStore implements Store {
+ constructor(baseUrl: string);
+ getItem(key: string): Promise
;
+ }
+
+ export function open(params: {
+ store: Store;
+ path: string;
+ }): Promise<{ getRaw(): Promise }>;
+}
diff --git a/packages/core/components/Modal/CodeSnippet/test/CodeSnippet.test.tsx b/packages/core/components/Modal/CodeSnippet/test/CodeSnippet.test.tsx
index 859520af3..5d1c9959a 100644
--- a/packages/core/components/Modal/CodeSnippet/test/CodeSnippet.test.tsx
+++ b/packages/core/components/Modal/CodeSnippet/test/CodeSnippet.test.tsx
@@ -13,9 +13,11 @@ describe(" ", () => {
visibleModal: ModalType.CodeSnippet,
},
selection: {
- dataSource: {
- uri: "fake-uri.test",
- },
+ dataSources: [
+ {
+ uri: "fake-uri.test",
+ },
+ ],
},
});
diff --git a/packages/core/components/Modal/DataSource/index.tsx b/packages/core/components/Modal/DataSource/index.tsx
new file mode 100644
index 000000000..5ca6a915b
--- /dev/null
+++ b/packages/core/components/Modal/DataSource/index.tsx
@@ -0,0 +1,18 @@
+import * as React from "react";
+
+import { ModalProps } from "..";
+import BaseModal from "../BaseModal";
+import DataSourcePrompt from "../../DataSourcePrompt";
+
+/**
+ * Dialog meant to prompt user to select a data source option
+ */
+export default function DataSource(props: ModalProps) {
+ return (
+ }
+ title="Choose a data source"
+ onDismiss={props.onDismiss}
+ />
+ );
+}
diff --git a/packages/core/components/Modal/MetadataManifest/index.tsx b/packages/core/components/Modal/MetadataManifest/index.tsx
index 1c9b40c91..ee8a9724e 100644
--- a/packages/core/components/Modal/MetadataManifest/index.tsx
+++ b/packages/core/components/Modal/MetadataManifest/index.tsx
@@ -6,8 +6,7 @@ import { useDispatch, useSelector } from "react-redux";
import { ModalProps } from "..";
import BaseModal from "../BaseModal";
import AnnotationPicker from "../../AnnotationPicker";
-import * as modalSelectors from "../selectors";
-import { interaction } from "../../../state";
+import { interaction, metadata } from "../../../state";
import styles from "./MetadataManifest.module.css";
@@ -17,18 +16,26 @@ import styles from "./MetadataManifest.module.css";
*/
export default function MetadataManifest({ onDismiss }: ModalProps) {
const dispatch = useDispatch();
- const annotationsPreviouslySelected = useSelector(
- modalSelectors.getAnnotationsPreviouslySelected
- );
- const [selectedAnnotations, setSelectedAnnotations] = React.useState(
- annotationsPreviouslySelected
- );
+ const annotations = useSelector(metadata.selectors.getAnnotations);
+ const annotationsPreviouslySelected = useSelector(interaction.selectors.getCsvColumns);
const fileTypeForVisibleModal = useSelector(interaction.selectors.getFileTypeForVisibleModal);
+ const [selectedAnnotations, setSelectedAnnotations] = React.useState([]);
+
+ // Update the selected annotations when the previously selected annotations
+ // or list of all annotations change like on data source change
+ React.useEffect(() => {
+ const annotationsPreviouslySelectedAvailable = (
+ annotationsPreviouslySelected || []
+ ).filter((annotationName) =>
+ annotations.some((annotation) => annotationName === annotation.name)
+ );
+ setSelectedAnnotations(annotationsPreviouslySelectedAvailable);
+ }, [annotations, annotationsPreviouslySelected]);
+
const onDownload = () => {
- const selectedAnnotationNames = selectedAnnotations.map((annotation) => annotation.name);
dispatch(
- interaction.actions.downloadManifest(selectedAnnotationNames, fileTypeForVisibleModal)
+ interaction.actions.downloadManifest(selectedAnnotations, fileTypeForVisibleModal)
);
onDismiss();
};
diff --git a/packages/core/components/Modal/index.tsx b/packages/core/components/Modal/index.tsx
index c982f2472..faeb5a141 100644
--- a/packages/core/components/Modal/index.tsx
+++ b/packages/core/components/Modal/index.tsx
@@ -3,7 +3,7 @@ import { useDispatch, useSelector } from "react-redux";
import { interaction } from "../../state";
import CodeSnippet from "./CodeSnippet";
-import DataSourcePrompt from "./DataSourcePrompt";
+import DataSource from "./DataSource";
import MetadataManifest from "./MetadataManifest";
export interface ModalProps {
@@ -12,7 +12,7 @@ export interface ModalProps {
export enum ModalType {
CodeSnippet = 1,
- DataSourcePrompt = 2,
+ DataSource = 2,
MetadataManifest = 3,
}
@@ -30,8 +30,8 @@ export default function Modal() {
switch (visibleModal) {
case ModalType.CodeSnippet:
return ;
- case ModalType.DataSourcePrompt:
- return ;
+ case ModalType.DataSource:
+ return ;
case ModalType.MetadataManifest:
return ;
default:
diff --git a/packages/core/components/Modal/selectors.ts b/packages/core/components/Modal/selectors.ts
deleted file mode 100644
index 2ec2bead2..000000000
--- a/packages/core/components/Modal/selectors.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { createSelector } from "reselect";
-
-import * as interactionSelectors from "../../state/interaction/selectors";
-import * as metadataSelectors from "../../state/metadata/selectors";
-
-/**
- * Returns Annotation instances for those annotations that were previously used to generate
- * either a CSV manifest or dataset (via Python snippet generation).
- */
-export const getAnnotationsPreviouslySelected = createSelector(
- [interactionSelectors.getCsvColumns, metadataSelectors.getAnnotations],
- (annotationDisplayNames, annotations) =>
- annotations.filter((annotation) => annotationDisplayNames?.includes(annotation.displayName))
-);
diff --git a/packages/core/components/QueryPart/QueryDataSource.tsx b/packages/core/components/QueryPart/QueryDataSource.tsx
index dc819fe2e..11339d632 100644
--- a/packages/core/components/QueryPart/QueryDataSource.tsx
+++ b/packages/core/components/QueryPart/QueryDataSource.tsx
@@ -1,39 +1,110 @@
+import { List } from "@fluentui/react";
import * as React from "react";
+import { useDispatch, useSelector } from "react-redux";
import QueryPart from ".";
+import ListRow, { ListItem } from "../ListPicker/ListRow";
import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants";
import { Source } from "../../entity/FileExplorerURL";
+import { interaction, metadata, selection } from "../../state";
interface Props {
- dataSources: (Source | undefined)[];
+ dataSources: Source[];
}
/**
* Component responsible for rendering the "Data Source" part of the query
*/
export default function QueryDataSource(props: Props) {
+ const dispatch = useDispatch();
+ const selectedQuery = useSelector(selection.selectors.getSelectedQuery);
+ const dataSources = useSelector(metadata.selectors.getDataSources);
+ const selectedDataSources = useSelector(selection.selectors.getSelectedDataSources);
+
+ const addDataSourceOptions: ListItem[] = [
+ ...dataSources.map((source) => ({
+ displayValue: source.name,
+ value: source.name,
+ disabled:
+ selectedDataSources.length <= 1 &&
+ selectedDataSources.some((selected) => source.name === selected.name),
+ data: source,
+ selected: selectedDataSources.some((selected) => source.name === selected.name),
+ })),
+ {
+ displayValue: "New Data Source...",
+ value: "New Data Source...",
+ selected: false,
+ },
+ ];
+
return (
TODO: To be implemented in another ticket
}
- rows={props.dataSources.map((dataSource) => {
- // TODO: This should change when we move towards
- // having a blank data source only possible
- // on an empty load
- // https://github.com/AllenInstitute/aics-fms-file-explorer-app/issues/105
- if (!dataSource) {
- return {
- id: AICS_FMS_DATA_SOURCE_NAME,
- title: AICS_FMS_DATA_SOURCE_NAME,
- };
- }
-
- return {
- id: dataSource.name,
- title: dataSource.name,
- };
- })}
+ disabled={selectedDataSources[0]?.name === AICS_FMS_DATA_SOURCE_NAME}
+ onDelete={
+ selectedDataSources.length > 1
+ ? (dataSource) =>
+ dispatch(
+ selection.actions.changeDataSources(
+ selectedDataSources.filter((s) => s.name !== dataSource)
+ )
+ )
+ : undefined
+ }
+ onRenderAddMenuList={
+ selectedDataSources[0]?.name === AICS_FMS_DATA_SOURCE_NAME
+ ? undefined
+ : () => (
+
+ String(item.value)}
+ items={addDataSourceOptions}
+ onRenderCell={(item) =>
+ item && (
+
+ item.data
+ ? dispatch(
+ selection.actions.changeDataSources([
+ ...selectedDataSources,
+ item.data as Source,
+ ])
+ )
+ : dispatch(
+ interaction.actions.promptForDataSource(
+ {
+ query: selectedQuery,
+ }
+ )
+ )
+ }
+ onDeselect={() =>
+ item.data &&
+ selectedDataSources.length > 1 &&
+ dispatch(
+ selection.actions.changeDataSources(
+ selectedDataSources.filter(
+ (source) =>
+ source.name !== item.data?.name
+ )
+ )
+ )
+ }
+ />
+ )
+ }
+ />
+
+ )
+ }
+ rows={props.dataSources.map((dataSource) => ({
+ id: dataSource.name,
+ title: dataSource.name,
+ }))}
/>
);
}
diff --git a/packages/core/components/QueryPart/QueryFilter.tsx b/packages/core/components/QueryPart/QueryFilter.tsx
index 8f2497154..02360cc3c 100644
--- a/packages/core/components/QueryPart/QueryFilter.tsx
+++ b/packages/core/components/QueryPart/QueryFilter.tsx
@@ -11,6 +11,7 @@ import { metadata, selection } from "../../state";
import Annotation from "../../entity/Annotation";
interface Props {
+ disabled?: boolean;
filters: FileFilter[];
}
@@ -26,6 +27,7 @@ export default function QueryFilter(props: Props) {
return (
@@ -38,14 +40,11 @@ export default function QueryFilter(props: Props) {
onRenderAddMenuList={() => (
(
)}
- selections={annotations.filter((annotation) =>
- props.filters.some((f) => f.name === annotation.name)
- )}
+ selections={props.filters.map((filter) => filter.name)}
setSelections={() => dispatch(selection.actions.setFileFilters([]))}
/>
)}
diff --git a/packages/core/components/QueryPart/QueryGroup.tsx b/packages/core/components/QueryPart/QueryGroup.tsx
index ee393fdf1..a14b5039b 100644
--- a/packages/core/components/QueryPart/QueryGroup.tsx
+++ b/packages/core/components/QueryPart/QueryGroup.tsx
@@ -1,13 +1,13 @@
import * as React from "react";
-import { useDispatch, useSelector } from "react-redux";
+import { useDispatch } from "react-redux";
import QueryPart from ".";
import AnnotationPicker from "../AnnotationPicker";
import Tutorial from "../../entity/Tutorial";
-import { metadata, selection } from "../../state";
-import Annotation from "../../entity/Annotation";
+import { selection } from "../../state";
interface Props {
+ disabled?: boolean;
groups: string[];
}
@@ -17,14 +17,6 @@ interface Props {
export default function QueryGroup(props: Props) {
const dispatch = useDispatch();
- const annotations = useSelector(metadata.selectors.getSortedAnnotations);
-
- const selectedAnnotations = props.groups
- .map((annotationName) =>
- annotations.find((annotation) => annotation.name === annotationName)
- )
- .filter((a) => !!a) as Annotation[];
-
const onDelete = (annotationName: string) => {
dispatch(selection.actions.removeFromAnnotationHierarchy(annotationName));
};
@@ -36,6 +28,7 @@ export default function QueryGroup(props: Props) {
return (
{
- dispatch(
- selection.actions.setAnnotationHierarchy(annotations.map((a) => a.name))
- );
+ dispatch(selection.actions.setAnnotationHierarchy(annotations));
}}
/>
)}
- rows={selectedAnnotations.map((annotation) => ({
- id: annotation.name,
- title: annotation.displayName,
+ // TODO: Should we care about display name?? seems time to make the name of
+ // annotations just the display name for top level annotations bro
+ rows={props.groups.map((annotation) => ({
+ id: annotation,
+ title: annotation,
}))}
/>
);
diff --git a/packages/core/components/QueryPart/QueryPart.module.css b/packages/core/components/QueryPart/QueryPart.module.css
index e9cfd872c..869ae825c 100644
--- a/packages/core/components/QueryPart/QueryPart.module.css
+++ b/packages/core/components/QueryPart/QueryPart.module.css
@@ -35,3 +35,8 @@
margin: 0;
padding-top: 8px
}
+
+.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
diff --git a/packages/core/components/QueryPart/QuerySort.tsx b/packages/core/components/QueryPart/QuerySort.tsx
index 0001c6ca0..ede34fc23 100644
--- a/packages/core/components/QueryPart/QuerySort.tsx
+++ b/packages/core/components/QueryPart/QuerySort.tsx
@@ -1,13 +1,14 @@
import * as React from "react";
-import { useDispatch, useSelector } from "react-redux";
+import { useDispatch } from "react-redux";
import QueryPart from ".";
import AnnotationPicker from "../AnnotationPicker";
-import { metadata, selection } from "../../state";
+import { selection } from "../../state";
import FileSort, { SortOrder } from "../../entity/FileSort";
import Tutorial from "../../entity/Tutorial";
interface Props {
+ disabled?: boolean;
sort?: FileSort;
}
@@ -17,11 +18,10 @@ interface Props {
export default function QuerySort(props: Props) {
const dispatch = useDispatch();
- const annotations = useSelector(metadata.selectors.getSortedAnnotations);
-
return (
dispatch(selection.actions.setSortColumn())}
@@ -39,13 +39,11 @@ export default function QuerySort(props: Props) {
annotation.name === props.sort?.annotationName
- )}
+ selections={props.sort?.annotationName ? [props.sort.annotationName] : []}
setSelections={(annotations) => {
const newAnnotation = annotations.filter(
- (annotation) => annotation.name !== props.sort?.annotationName
- )?.[0].name;
+ (annotation) => annotation !== props.sort?.annotationName
+ )[0];
dispatch(
selection.actions.setSortColumn(
newAnnotation
diff --git a/packages/core/components/QueryPart/index.tsx b/packages/core/components/QueryPart/index.tsx
index bbf80bf29..51323d3bb 100644
--- a/packages/core/components/QueryPart/index.tsx
+++ b/packages/core/components/QueryPart/index.tsx
@@ -4,6 +4,7 @@ import {
IRenderFunction,
PrimaryButton,
} from "@fluentui/react";
+import classNames from "classnames";
import * as React from "react";
import { DragDropContext, OnDragEndResponder } from "react-beautiful-dnd";
@@ -14,6 +15,7 @@ import styles from "./QueryPart.module.css";
interface Props {
title: string;
+ disabled?: boolean;
tutorialId?: string;
addButtonIconName: string;
rows: QueryPartRowItem[];
@@ -41,10 +43,11 @@ export default function QueryPart(props: Props) {
};
return (
-
+
{
setIsExpanded(props.isSelected);
}, [props.isSelected]);
- const decodedURL = React.useMemo(
- () => (props.isSelected ? currentQueryParts : props.query.parts),
- [props.query.parts, currentQueryParts, props.isSelected]
+ const queryComponents = React.useMemo(
+ () => (props.isSelected ? currentQueryParts : props.query?.parts),
+ [props.query?.parts, currentQueryParts, props.isSelected]
);
const onQueryUpdate = (updatedQuery: QueryType) => {
@@ -76,12 +78,13 @@ export default function Query(props: QueryProps) {
{props.isSelected &&
}
- Data Source: {decodedURL.source?.name}
+ Data Source: {" "}
+ {queryComponents.sources.map((source) => source.name).join(", ")}
- {!!decodedURL.hierarchy.length && (
+ {!!queryComponents.hierarchy.length && (
Groupings: {" "}
- {decodedURL.hierarchy
+ {queryComponents.hierarchy
.map(
(a) =>
annotations.find((annotation) => annotation.name === a)
@@ -90,18 +93,18 @@ export default function Query(props: QueryProps) {
.join(", ")}
)}
- {!!decodedURL.filters.length && (
+ {!!queryComponents.filters.length && (
Filters: {" "}
- {decodedURL.filters
+ {queryComponents.filters
.map((filter) => `${filter.name}: ${filter.value}`)
.join(", ")}
)}
- {!!decodedURL.sortColumn && (
+ {!!queryComponents.sortColumn && (
- Sort: {decodedURL.sortColumn.annotationName} (
- {decodedURL.sortColumn.order})
+ Sort: {queryComponents.sortColumn.annotationName} (
+ {queryComponents.sortColumn.order})
)}
@@ -134,10 +137,10 @@ export default function Query(props: QueryProps) {
/>
-
-
-
-
+
+
+
+
1}
diff --git a/packages/core/components/QuerySidebar/index.tsx b/packages/core/components/QuerySidebar/index.tsx
index 95c745362..f5410ed3e 100644
--- a/packages/core/components/QuerySidebar/index.tsx
+++ b/packages/core/components/QuerySidebar/index.tsx
@@ -7,7 +7,6 @@ import Query from "./Query";
import { HELP_OPTIONS } from "./tutorials";
import { ModalType } from "../Modal";
import SvgIcon from "../SvgIcon";
-import FileExplorerURL, { DEFAULT_AICS_FMS_QUERY } from "../../entity/FileExplorerURL";
import Tutorial from "../../entity/Tutorial";
import { interaction, selection } from "../../state";
import { AICS_LOGO } from "../../icons";
@@ -23,48 +22,12 @@ interface QuerySidebarProps {
*/
export default function QuerySidebar(props: QuerySidebarProps) {
const dispatch = useDispatch();
+ const isOnWeb = useSelector(interaction.selectors.isOnWeb);
const queries = useSelector(selection.selectors.getQueries);
const selectedQuery = useSelector(selection.selectors.getSelectedQuery);
- const isAicsEmployee = useSelector(interaction.selectors.isAicsEmployee);
- const isOnWeb = useSelector(interaction.selectors.isOnWeb);
const dataSources = useSelector(interaction.selectors.getAllDataSources);
const currentGlobalURL = useSelector(selection.selectors.getEncodedFileExplorerUrl);
- // Select query by default if none is selected
- React.useEffect(() => {
- if (!selectedQuery && queries.length) {
- dispatch(selection.actions.changeQuery(queries[0]));
- }
- }, [selectedQuery, queries, dispatch]);
-
- // Determine a default query to render or prompt the user for a data source
- // if no default is accessible
- React.useEffect(() => {
- if (!queries.length) {
- if (!window.location.search) {
- if (isAicsEmployee === true) {
- // If the user is an AICS employee and there is no query in the URL, add a default query
- dispatch(
- selection.actions.addQuery({
- name: "New AICS Query",
- parts: DEFAULT_AICS_FMS_QUERY,
- })
- );
- } else if (isAicsEmployee === false) {
- // If no query is selected and there is no query in the URL, prompt the user to select a data source
- dispatch(interaction.actions.setVisibleModal(ModalType.DataSourcePrompt));
- }
- } else if (isAicsEmployee === undefined) {
- dispatch(
- selection.actions.addQuery({
- name: "New Query",
- parts: FileExplorerURL.decode(window.location.search),
- })
- );
- }
- }
- }, [isAicsEmployee, queries, dispatch]);
-
React.useEffect(() => {
if (selectedQuery) {
const newurl =
@@ -91,7 +54,7 @@ export default function QuerySidebar(props: QuerySidebarProps) {
dispatch(
selection.actions.addQuery({
name: `New ${source.name} query`,
- parts: { source },
+ parts: { sources: [source] },
})
);
},
@@ -102,7 +65,7 @@ export default function QuerySidebar(props: QuerySidebarProps) {
text: "New Data Source...",
iconProps: { iconName: "NewFolder" },
onClick: () => {
- dispatch(interaction.actions.setVisibleModal(ModalType.DataSourcePrompt));
+ dispatch(interaction.actions.setVisibleModal(ModalType.DataSource));
},
},
],
@@ -161,13 +124,23 @@ export default function QuerySidebar(props: QuerySidebarProps) {
data-is-scrollable="true"
data-is-focusable="true"
>
- {queries.map((query) => (
+ {queries.length ? (
+ queries.map((query) => (
+
+ ))
+ ) : (
- ))}
+ )}
({
+ name: item.name,
+ value: item.type !== "space" ? 0 : null,
+ }));
+}
+
+export async function renderZarrThumbnailURL(zarrUrl: string): Promise {
+ try {
+ const store = new FetchStore(zarrUrl);
+ const root = zarr.root(store);
+ const group = await zarr.open(root, { kind: "group" });
+
+ if (
+ !group.attrs ||
+ !Array.isArray(group.attrs.multiscales) ||
+ group.attrs.multiscales.length === 0
+ ) {
+ throw new Error("Invalid multiscales attribute structure");
+ }
+
+ const { multiscales } = group.attrs;
+ const datasets = multiscales[0].datasets;
+ const lowestResolutionDataset = datasets[datasets.length - 1];
+ const lowestResolutionLocation = root.resolve(lowestResolutionDataset.path);
+ const lowestResolution = await zarr.open(lowestResolutionLocation, { kind: "array" });
+
+ // Determine Slice
+ const axes = transformAxes(multiscales[0].axes);
+ const zIndex = axes.findIndex((item) => item.name === "z");
+ if (zIndex !== -1) {
+ const zSliceIndex = Math.ceil(lowestResolution.shape[zIndex] / 2);
+ axes[zIndex].value = zSliceIndex;
+ }
+
+ const lowestResolutionView = await zarrGet(
+ lowestResolution,
+ axes.map((item) => item.value)
+ );
+ const u16data = lowestResolutionView.data as Uint16Array;
+
+ // Normalize Data
+ const min = Math.min(...u16data);
+ const max = Math.max(...u16data);
+ const normalizedData = new Uint8Array(u16data.length);
+ for (let i = 0; i < u16data.length; i++) {
+ normalizedData[i] = Math.round((255 * (u16data[i] - min)) / (max - min));
+ }
+
+ // Build Canvas
+ const width = lowestResolution.shape[axes.findIndex((item) => item.name === "x")];
+ const height = lowestResolution.shape[axes.findIndex((item) => item.name === "y")];
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const context = canvas.getContext("2d");
+
+ if (!context) {
+ throw new Error("Failed to get canvas context");
+ }
+
+ // Draw data
+ const imageData = context.createImageData(width, height);
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ const idx = (y * width + x) * 4;
+ const value = normalizedData[y * width + x];
+ imageData.data[idx] = value; // Red
+ imageData.data[idx + 1] = value; // Green
+ imageData.data[idx + 2] = value; // Blue
+ imageData.data[idx + 3] = 255; // Alpha
+ }
+ }
+
+ context.putImageData(imageData, 0, 0);
+
+ // Convert data to data URL
+ return canvas.toDataURL("image/png");
+ } catch (error) {
+ console.error("Error reading Zarr image:", error);
+ throw error;
+ }
+}
diff --git a/packages/core/entity/FileDetail/index.ts b/packages/core/entity/FileDetail/index.ts
index c950572ee..7484042ba 100644
--- a/packages/core/entity/FileDetail/index.ts
+++ b/packages/core/entity/FileDetail/index.ts
@@ -1,11 +1,11 @@
import { FmsFileAnnotation } from "../../services/FileService";
+import { renderZarrThumbnailURL } from "./RenderZarrThumbnailURL";
const RENDERABLE_IMAGE_FORMATS = [".jpg", ".jpeg", ".png", ".gif"];
// Should probably record this somewhere we can dynamically adjust to, or perhaps just in the file
// document itself, alas for now this will do.
const HARD_CODED_AICS_S3_BUCKET_PATH = "http://production.files.allencell.org.s3.amazonaws.com";
-
const AICS_FMS_FILES_NGINX_SERVER = "http://aics.corp.alleninstitute.org/labkey/fmsfiles/image";
/**
@@ -160,8 +160,7 @@ export default class FileDetail {
return this.fileDetail.annotations.find((annotation) => annotation.name === annotationName);
}
- public getPathToThumbnail(): string | undefined {
- // If no thumbnail present try to render the file itself as the thumbnail
+ public async getPathToThumbnail(): Promise {
if (!this.thumbnail) {
const isFileRenderableImage = RENDERABLE_IMAGE_FORMATS.some((format) =>
this.name.toLowerCase().endsWith(format)
@@ -178,6 +177,17 @@ export default class FileDetail {
if (this.thumbnail?.startsWith("/allen")) {
return `${AICS_FMS_FILES_NGINX_SERVER}${this.thumbnail}`;
}
+
+ if (this.cloudPath.endsWith(".zarr")) {
+ try {
+ // const thumbnailURL = await renderZarrThumbnailURL(this.cloudPath);
+ const thumbnailURL = await renderZarrThumbnailURL(this.cloudPath);
+ return thumbnailURL;
+ } catch (error) {
+ console.error("Error generating Zarr thumbnail:", error);
+ throw new Error("Unable to generate Zarr thumbnail");
+ }
+ }
return this.thumbnail;
}
}
diff --git a/packages/core/entity/FileExplorerURL/index.ts b/packages/core/entity/FileExplorerURL/index.ts
index 679ed3016..f61e58d96 100644
--- a/packages/core/entity/FileExplorerURL/index.ts
+++ b/packages/core/entity/FileExplorerURL/index.ts
@@ -6,14 +6,14 @@ import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants";
export interface Source {
name: string;
- type: "csv" | "json" | "parquet";
+ type?: "csv" | "json" | "parquet";
uri?: string | File;
}
// Components of the application state this captures
export interface FileExplorerURLComponents {
hierarchy: string[];
- source?: Source;
+ sources: Source[];
filters: FileFilter[];
openFolders: FileFolder[];
sortColumn?: FileSort;
@@ -23,6 +23,7 @@ export const EMPTY_QUERY_COMPONENTS: FileExplorerURLComponents = {
hierarchy: [],
filters: [],
openFolders: [],
+ sources: [],
};
const BEGINNING_OF_TODAY = new Date();
@@ -44,6 +45,7 @@ export const PAST_YEAR_FILTER = new FileFilter(
export const DEFAULT_AICS_FMS_QUERY: FileExplorerURLComponents = {
hierarchy: [],
openFolders: [],
+ sources: [{ name: AICS_FMS_DATA_SOURCE_NAME }],
filters: [PAST_YEAR_FILTER],
sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC),
};
@@ -72,12 +74,18 @@ export default class FileExplorerURL {
urlComponents.openFolders?.map((folder) => {
params.append("openFolder", JSON.stringify(folder.fileFolder));
});
+ urlComponents.sources?.map((source) => {
+ params.append(
+ "source",
+ JSON.stringify({
+ ...source,
+ uri: source.uri instanceof String ? source.uri : undefined,
+ })
+ );
+ });
if (urlComponents.sortColumn) {
params.append("sort", JSON.stringify(urlComponents.sortColumn.toJSON()));
}
- if (urlComponents.source) {
- params.append("source", JSON.stringify(urlComponents.source));
- }
return params.toString();
}
@@ -91,7 +99,7 @@ export default class FileExplorerURL {
const unparsedOpenFolders = params.getAll("openFolder");
const unparsedFilters = params.getAll("filter");
- const unparsedSource = params.get("source");
+ const unparsedSources = params.getAll("source");
const hierarchy = params.getAll("group");
const unparsedSort = params.get("sort");
const hierarchyDepth = hierarchy.length;
@@ -112,7 +120,7 @@ export default class FileExplorerURL {
filters: unparsedFilters
.map((unparsedFilter) => JSON.parse(unparsedFilter))
.map((parsedFilter) => new FileFilter(parsedFilter.name, parsedFilter.value)),
- source: unparsedSource ? JSON.parse(unparsedSource) : undefined,
+ sources: unparsedSources.map((unparsedSource) => JSON.parse(unparsedSource)),
openFolders: unparsedOpenFolders
.map((unparsedFolder) => JSON.parse(unparsedFolder))
.filter((parsedFolder) => parsedFolder.length <= hierarchyDepth)
@@ -125,12 +133,13 @@ export default class FileExplorerURL {
userOS: string
) {
if (
- urlComponents?.source?.name === AICS_FMS_DATA_SOURCE_NAME ||
- !urlComponents?.source?.uri
+ (urlComponents?.sources?.length && urlComponents.sources.length > 1) ||
+ urlComponents?.sources?.[0]?.name === AICS_FMS_DATA_SOURCE_NAME ||
+ !urlComponents?.sources?.[0]?.uri
) {
return "# Coming soon";
}
- const sourceString = this.convertDataSourceToPython(urlComponents?.source, userOS);
+ const sourceString = this.convertDataSourceToPython(urlComponents?.sources?.[0], userOS);
const groupByQueryString =
urlComponents.hierarchy
?.map((annotation) => this.convertGroupByToPython(annotation))
diff --git a/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts b/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts
index 86b2269e2..725927b51 100644
--- a/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts
+++ b/packages/core/entity/FileExplorerURL/test/fileexplorerurl.test.ts
@@ -38,7 +38,7 @@ describe("FileExplorerURL", () => {
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)),
sortColumn: new FileSort(AnnotationName.FILE_SIZE, SortOrder.DESC),
- source: mockSource,
+ sources: [mockSource],
};
// Act
@@ -46,7 +46,7 @@ describe("FileExplorerURL", () => {
// Assert
expect(result).to.be.equal(
- "group=Cell+Line&group=Donor+Plasmid&group=Lifting%3F&filter=%7B%22name%22%3A%22Cas9%22%2C%22value%22%3A%22spCas9%22%7D&filter=%7B%22name%22%3A%22Donor+Plasmid%22%2C%22value%22%3A%22ACTB-mEGFP%22%7D&openFolder=%5B%22AICS-0%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Cfalse%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Ctrue%5D&sort=%7B%22annotationName%22%3A%22file_size%22%2C%22order%22%3A%22DESC%22%7D&source=%7B%22name%22%3A%22Fake+Collection%22%2C%22type%22%3A%22csv%22%7D"
+ "group=Cell+Line&group=Donor+Plasmid&group=Lifting%3F&filter=%7B%22name%22%3A%22Cas9%22%2C%22value%22%3A%22spCas9%22%7D&filter=%7B%22name%22%3A%22Donor+Plasmid%22%2C%22value%22%3A%22ACTB-mEGFP%22%7D&openFolder=%5B%22AICS-0%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Cfalse%5D&openFolder=%5B%22AICS-0%22%2C%22ACTB-mEGFP%22%2Ctrue%5D&source=%7B%22name%22%3A%22Fake+Collection%22%2C%22type%22%3A%22csv%22%7D&sort=%7B%22annotationName%22%3A%22file_size%22%2C%22order%22%3A%22DESC%22%7D"
);
});
@@ -56,6 +56,7 @@ describe("FileExplorerURL", () => {
hierarchy: [],
filters: [],
openFolders: [],
+ sources: [],
};
// Act
@@ -85,7 +86,7 @@ describe("FileExplorerURL", () => {
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)),
sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC),
- source: mockSource,
+ sources: [mockSource],
};
const encodedUrl = FileExplorerURL.encode(components);
const encodedUrlWithWhitespace = " " + encodedUrl + " ";
@@ -104,7 +105,7 @@ describe("FileExplorerURL", () => {
filters: [],
openFolders: [],
sortColumn: undefined,
- source: undefined,
+ sources: [],
};
const encodedUrl = FileExplorerURL.encode(components);
@@ -121,6 +122,7 @@ describe("FileExplorerURL", () => {
hierarchy: ["Cell Line"],
filters: [],
openFolders: [new FileFolder(["AICS-0"]), new FileFolder(["AICS-0", false])],
+ sources: [],
};
const encodedUrl = FileExplorerURL.encode(components);
@@ -147,6 +149,7 @@ describe("FileExplorerURL", () => {
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
openFolders: expectedOpenFolders.map((folder) => new FileFolder(folder)),
sortColumn: new FileSort(AnnotationName.FILE_PATH, "Garbage" as any),
+ sources: [],
};
const encodedUrl = FileExplorerURL.encode(components);
@@ -161,7 +164,7 @@ describe("FileExplorerURL", () => {
const expectedAnnotationNames = ["Cell Line", "Donor Plasmid", "Lifting?"];
const components: Partial = {
hierarchy: expectedAnnotationNames,
- source: mockSourceWithUri,
+ sources: [mockSourceWithUri],
};
const expectedPandasGroups = expectedAnnotationNames.map(
(annotation) => `.groupby('${annotation}', group_keys=True).apply(lambda x: x)`
@@ -183,7 +186,7 @@ describe("FileExplorerURL", () => {
];
const components: Partial = {
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
- source: mockSourceWithUri,
+ sources: [mockSourceWithUri],
};
const expectedPandasQueries = expectedFilters.map(
(filter) => `\`${filter.name}\`=="${filter.value}"`
@@ -205,7 +208,7 @@ describe("FileExplorerURL", () => {
];
const components: Partial = {
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
- source: mockSourceWithUri,
+ sources: [mockSourceWithUri],
};
const expectedPandasQueries = expectedFilters.map(
(filter) => `\`${filter.name}\`=="${filter.value}"`
@@ -223,7 +226,7 @@ describe("FileExplorerURL", () => {
// Arrange
const components: Partial = {
sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC),
- source: mockSourceWithUri,
+ sources: [mockSourceWithUri],
};
const expectedPandasSort = `.sort_values(by='${AnnotationName.UPLOADED}', ascending=False`;
const expectedResult = `df${expectedPandasSort}`;
@@ -238,7 +241,7 @@ describe("FileExplorerURL", () => {
it("provides info on converting external data source to pandas dataframe", () => {
// Arrange
const components: Partial = {
- source: mockSourceWithUri,
+ sources: [mockSourceWithUri],
};
const expectedResult = `df = pd.read_csv('${mockSourceWithUri.uri}').astype('str')`;
@@ -252,7 +255,7 @@ describe("FileExplorerURL", () => {
it("adds raw flag in pandas conversion code for Windows OS", () => {
// Arrange
const components: Partial = {
- source: mockSourceWithUri,
+ sources: [mockSourceWithUri],
};
const expectedResult = `df = pd.read_csv(r'${mockSourceWithUri.uri}').astype('str')`;
@@ -274,7 +277,7 @@ describe("FileExplorerURL", () => {
hierarchy: expectedAnnotationNames,
filters: expectedFilters.map(({ name, value }) => new FileFilter(name, value)),
sortColumn: new FileSort(AnnotationName.UPLOADED, SortOrder.DESC),
- source: mockSourceWithUri,
+ sources: [mockSourceWithUri],
};
const expectedResult = /df\.groupby\(.*\)\.query\(.*\)\.query\(.*\)\.sort_values\(.*\)/i;
diff --git a/packages/core/entity/SQLBuilder/index.ts b/packages/core/entity/SQLBuilder/index.ts
index 5caaa0324..3a6bb13af 100644
--- a/packages/core/entity/SQLBuilder/index.ts
+++ b/packages/core/entity/SQLBuilder/index.ts
@@ -1,3 +1,5 @@
+import { castArray } from "lodash";
+
/**
* A simple SQL query builder.
*/
@@ -20,8 +22,12 @@ export default class SQLBuilder {
return this;
}
- public from(statement: string): SQLBuilder {
- this.fromStatement = statement;
+ public from(statement: string | string[]): SQLBuilder {
+ const statementAsArray = castArray(statement);
+ if (!statementAsArray.length) {
+ throw new Error('"FROM" statement requires at least one argument');
+ }
+ this.fromStatement = statementAsArray.sort().join(", ");
return this;
}
diff --git a/packages/core/errors/DataSourcePreparationError.ts b/packages/core/errors/DataSourcePreparationError.ts
new file mode 100644
index 000000000..86dced748
--- /dev/null
+++ b/packages/core/errors/DataSourcePreparationError.ts
@@ -0,0 +1,14 @@
+export default class DataSourcePreparationError extends Error {
+ public sourceName: string;
+
+ constructor(message: string, sourceName: string) {
+ super(message);
+
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, DataSourcePreparationError);
+ }
+
+ this.name = "DataSourcePreparationError";
+ this.sourceName = sourceName;
+ }
+}
diff --git a/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts b/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts
index e5443eb50..39fa0a5a1 100644
--- a/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts
+++ b/packages/core/services/AnnotationService/DatabaseAnnotationService/index.ts
@@ -3,18 +3,11 @@ import DatabaseService from "../../DatabaseService";
import DatabaseServiceNoop from "../../DatabaseService/DatabaseServiceNoop";
import Annotation from "../../../entity/Annotation";
import FileFilter from "../../../entity/FileFilter";
-import { AnnotationType } from "../../../entity/AnnotationFormatter";
import SQLBuilder from "../../../entity/SQLBuilder";
interface Config {
databaseService: DatabaseService;
- dataSourceName: string;
-}
-
-interface DescribeQueryResult {
- [key: string]: string;
- column_name: string;
- column_type: string;
+ dataSourceNames: string[];
}
interface SummarizeQueryResult {
@@ -28,44 +21,20 @@ interface SummarizeQueryResult {
*/
export default class DatabaseAnnotationService implements AnnotationService {
private readonly databaseService: DatabaseService;
- private readonly dataSourceName: string;
+ private readonly dataSourceNames: string[];
constructor(
- config: Config = { dataSourceName: "Unknown", databaseService: new DatabaseServiceNoop() }
+ config: Config = { dataSourceNames: [], databaseService: new DatabaseServiceNoop() }
) {
- this.dataSourceName = config.dataSourceName;
+ this.dataSourceNames = config.dataSourceNames;
this.databaseService = config.databaseService;
}
- private static columnTypeToAnnotationType(columnType: string): string {
- switch (columnType) {
- case "INTEGER":
- case "BIGINT":
- // TODO: Add support for column types
- // https://github.com/AllenInstitute/aics-fms-file-explorer-app/issues/60
- // return AnnotationType.NUMBER;
- case "VARCHAR":
- case "TEXT":
- default:
- return AnnotationType.STRING;
- }
- }
-
/**
* Fetch all annotations.
*/
- public async fetchAnnotations(): Promise {
- const sql = `DESCRIBE "${this.dataSourceName}"`;
- const rows = (await this.databaseService.query(sql)) as DescribeQueryResult[];
- return rows.map(
- (row) =>
- new Annotation({
- annotationDisplayName: row["column_name"],
- annotationName: row["column_name"],
- description: "",
- type: DatabaseAnnotationService.columnTypeToAnnotationType(row["column_type"]),
- })
- );
+ public fetchAnnotations(): Promise {
+ return this.databaseService.fetchAnnotations(this.dataSourceNames);
}
/**
@@ -75,7 +44,7 @@ export default class DatabaseAnnotationService implements AnnotationService {
const select_key = "select_key";
const sql = new SQLBuilder()
.select(`DISTINCT "${annotation}" AS ${select_key}`)
- .from(this.dataSourceName)
+ .from(this.dataSourceNames)
.toSQL();
const rows = await this.databaseService.query(sql);
return [
@@ -114,7 +83,8 @@ export default class DatabaseAnnotationService implements AnnotationService {
const sqlBuilder = new SQLBuilder()
.select(`DISTINCT "${hierarchy[path.length]}"`)
- .from(this.dataSourceName);
+ .from(this.dataSourceNames);
+
Object.keys(filtersByAnnotation).forEach((annotation) => {
const annotationValues = filtersByAnnotation[annotation];
if (annotationValues[0] === null) {
@@ -125,6 +95,7 @@ export default class DatabaseAnnotationService implements AnnotationService {
);
}
});
+
const rows = await this.databaseService.query(sqlBuilder.toSQL());
return rows.map((row) => row[hierarchy[path.length]]);
}
@@ -136,7 +107,7 @@ export default class DatabaseAnnotationService implements AnnotationService {
public async fetchAvailableAnnotationsForHierarchy(annotations: string[]): Promise {
const sql = new SQLBuilder()
.summarize()
- .from(this.dataSourceName)
+ .from(this.dataSourceNames)
.where(annotations.map((annotation) => `"${annotation}" IS NOT NULL`))
.toSQL();
const rows = (await this.databaseService.query(sql)) as SummarizeQueryResult[];
diff --git a/packages/core/services/AnnotationService/DatabaseAnnotationService/test/DatabaseAnnotationService.test.ts b/packages/core/services/AnnotationService/DatabaseAnnotationService/test/DatabaseAnnotationService.test.ts
index be9b5b2ea..2a2c2e0e4 100644
--- a/packages/core/services/AnnotationService/DatabaseAnnotationService/test/DatabaseAnnotationService.test.ts
+++ b/packages/core/services/AnnotationService/DatabaseAnnotationService/test/DatabaseAnnotationService.test.ts
@@ -1,34 +1,11 @@
import { expect } from "chai";
-import Annotation from "../../../../entity/Annotation";
import FileFilter from "../../../../entity/FileFilter";
import DatabaseServiceNoop from "../../../DatabaseService/DatabaseServiceNoop";
import DatabaseAnnotationService from "..";
describe("DatabaseAnnotationService", () => {
- describe("fetchAnnotations", () => {
- const annotations = ["A", "B", "Cc", "dD"].map((name) => ({
- name,
- }));
- class MockDatabaseService extends DatabaseServiceNoop {
- public query(): Promise<{ [key: string]: string }[]> {
- return Promise.resolve(annotations);
- }
- }
- const databaseService = new MockDatabaseService();
-
- it("issues request for all available Annotations", async () => {
- const annotationService = new DatabaseAnnotationService({
- dataSourceName: "Unknown",
- databaseService,
- });
- const actualAnnotations = await annotationService.fetchAnnotations();
- expect(actualAnnotations.length).to.equal(annotations.length);
- expect(actualAnnotations[0]).to.be.instanceOf(Annotation);
- });
- });
-
describe("fetchAnnotationValues", () => {
const annotations = ["A", "B", "Cc", "dD"].map((name, index) => ({
select_key: name.toLowerCase() + index,
@@ -44,7 +21,7 @@ describe("DatabaseAnnotationService", () => {
const annotation = "foo";
const annotationService = new DatabaseAnnotationService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["a", "b or c"],
databaseService,
});
const actualValues = await annotationService.fetchValues(annotation);
@@ -68,7 +45,7 @@ describe("DatabaseAnnotationService", () => {
it("issues a request for annotation values for the first level of the annotation hierarchy", async () => {
const annotationService = new DatabaseAnnotationService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["d"],
databaseService,
});
const values = await annotationService.fetchRootHierarchyValues(["foo"], []);
@@ -77,7 +54,7 @@ describe("DatabaseAnnotationService", () => {
it("issues a request for annotation values for the first level of the annotation hierarchy with filters", async () => {
const annotationService = new DatabaseAnnotationService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["e"],
databaseService,
});
const filter = new FileFilter("bar", "barValue");
@@ -102,7 +79,7 @@ describe("DatabaseAnnotationService", () => {
const expectedValues = ["A0", "B1", "Cc2", "dD3"];
const annotationService = new DatabaseAnnotationService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["ghjiasd", "second source"],
databaseService,
});
const values = await annotationService.fetchHierarchyValuesUnderPath(
@@ -117,7 +94,7 @@ describe("DatabaseAnnotationService", () => {
const expectedValues = ["A0", "B1", "Cc2", "dD3"];
const annotationService = new DatabaseAnnotationService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["mock1"],
databaseService,
});
const filter = new FileFilter("bar", "barValue");
@@ -145,7 +122,7 @@ describe("DatabaseAnnotationService", () => {
it("issues request for annotations that can be combined with current hierarchy", async () => {
const annotationService = new DatabaseAnnotationService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["mock1"],
databaseService,
});
const values = await annotationService.fetchAvailableAnnotationsForHierarchy([
diff --git a/packages/core/services/DatabaseService/DatabaseServiceNoop.ts b/packages/core/services/DatabaseService/DatabaseServiceNoop.ts
index 84243cf85..de48feaaa 100644
--- a/packages/core/services/DatabaseService/DatabaseServiceNoop.ts
+++ b/packages/core/services/DatabaseService/DatabaseServiceNoop.ts
@@ -1,8 +1,12 @@
import DatabaseService from ".";
-export default class DatabaseServiceNoop implements DatabaseService {
- public addDataSource() {
- return Promise.reject("DatabaseServiceNoop:addDataSource");
+export default class DatabaseServiceNoop extends DatabaseService {
+ public execute(): Promise {
+ return Promise.reject("DatabaseServiceNoop:execute");
+ }
+
+ public prepareDataSources() {
+ return Promise.reject("DatabaseServiceNoop::prepareDataSources");
}
public saveQuery(): Promise {
@@ -12,4 +16,8 @@ export default class DatabaseServiceNoop implements DatabaseService {
public query(): Promise<{ [key: string]: string }[]> {
return Promise.reject("DatabaseServiceNoop:query");
}
+
+ protected addDataSource(): Promise {
+ return Promise.reject("DatabaseServiceNoop:addDataSource");
+ }
}
diff --git a/packages/core/services/DatabaseService/index.ts b/packages/core/services/DatabaseService/index.ts
index 98bec99a5..bb1a9fce9 100644
--- a/packages/core/services/DatabaseService/index.ts
+++ b/packages/core/services/DatabaseService/index.ts
@@ -1,18 +1,159 @@
+import { isEmpty } from "lodash";
+
+import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants";
+import Annotation from "../../entity/Annotation";
+import { AnnotationType } from "../../entity/AnnotationFormatter";
+import { Source } from "../../entity/FileExplorerURL";
+import SQLBuilder from "../../entity/SQLBuilder";
+
/**
* Service reponsible for querying against a database
*/
-export default interface DatabaseService {
- addDataSource(
- name: string,
- type: "csv" | "json" | "parquet",
- uri: File | string
- ): Promise;
-
- saveQuery(
- destination: string,
- sql: string,
- format: "csv" | "parquet" | "json"
+export default abstract class DatabaseService {
+ private currentAggregateSource?: string;
+ // Initialize with AICS FMS data source name to pretend it always exists
+ protected readonly existingDataSources = new Set([AICS_FMS_DATA_SOURCE_NAME]);
+ private readonly dataSourceToAnnotationsMap: Map = new Map();
+
+ public abstract saveQuery(
+ _destination: string,
+ _sql: string,
+ _format: "csv" | "parquet" | "json"
): Promise;
- query(sql: string): Promise<{ [key: string]: string }[]>;
+ public abstract query(_sql: string): Promise<{ [key: string]: string }[]>;
+
+ protected abstract addDataSource(_dataSource: Source): Promise;
+
+ protected abstract execute(_sql: string): Promise;
+
+ private static columnTypeToAnnotationType(columnType: string): string {
+ switch (columnType) {
+ case "INTEGER":
+ case "BIGINT":
+ // TODO: Add support for column types
+ // https://github.com/AllenInstitute/aics-fms-file-explorer-app/issues/60
+ // return AnnotationType.NUMBER;
+ case "VARCHAR":
+ case "TEXT":
+ default:
+ return AnnotationType.STRING;
+ }
+ }
+
+ constructor() {
+ // 'this' scope gets lost when a higher order class (ex. DatabaseService)
+ // calls a lower level class (ex. DatabaseServiceWeb)
+ this.addDataSource = this.addDataSource.bind(this);
+ this.execute = this.execute.bind(this);
+ this.query = this.query.bind(this);
+ }
+
+ public async prepareDataSources(dataSources: Source[]): Promise {
+ await Promise.all(dataSources.map(this.addDataSource));
+
+ // Because when querying multiple data sources column differences can complicate the queries
+ // preparing a table ahead of time that is the aggregate of the data sources is most optimal
+ // should look toward some way of reducing the memory footprint if that becomes an issue
+ if (dataSources.length > 1) {
+ await this.aggregateDataSources(dataSources);
+ }
+ }
+
+ protected async deleteDataSource(dataSource: string): Promise {
+ this.existingDataSources.delete(dataSource);
+ this.dataSourceToAnnotationsMap.delete(dataSource);
+ await this.execute(`DROP TABLE IF EXISTS "${dataSource}"`);
+ }
+
+ private async aggregateDataSources(dataSources: Source[]): Promise {
+ const viewName = dataSources
+ .map((source) => source.name)
+ .sort()
+ .join(", ");
+
+ if (this.currentAggregateSource) {
+ // Prevent adding the same data source multiple times by shortcutting out here
+ if (this.currentAggregateSource === viewName) {
+ return;
+ }
+
+ // Otherwise, if an old aggregate exists, delete it
+ await this.deleteDataSource(this.currentAggregateSource);
+ }
+
+ const columnsSoFar = new Set();
+ for (const dataSource of dataSources) {
+ // Fetch information about this data source
+ const annotationsInDataSource = await this.fetchAnnotations([dataSource.name]);
+ const columnsInDataSource = annotationsInDataSource.map(
+ (annotation) => annotation.name
+ );
+ const newColumns = columnsInDataSource.filter((column) => !columnsSoFar.has(column));
+
+ // If there are no columns / data added yet we need to create the table from
+ // scratch so we can provide an easy shortcut around the default way of adding
+ // data to a table
+ if (columnsSoFar.size === 0) {
+ await this.execute(
+ `CREATE TABLE "${viewName}" AS SELECT *, '${dataSource.name}' AS "Data source" FROM "${dataSource.name}"`
+ );
+ this.currentAggregateSource = viewName;
+ } else {
+ // If adding data to an existing table we will need to add any new columns
+ // unsure why but seemingly unable to add multiple columns in one alter table
+ // statement so we will need to loop through and add them one by one
+ if (newColumns.length) {
+ const alterTableSQL = newColumns
+ .map((column) => `ALTER TABLE "${viewName}" ADD COLUMN "${column}" VARCHAR`)
+ .join("; ");
+ await this.execute(alterTableSQL);
+ }
+
+ // After we have added any new columns to the table schema we just need
+ // to insert the data from the new table to this table replacing any non-existent
+ // columns with an empty value (null)
+ const columnsSoFarArr = [...columnsSoFar, ...newColumns];
+ await this.execute(`
+ INSERT INTO "${viewName}" ("${columnsSoFarArr.join('", "')}", "Data source")
+ SELECT ${columnsSoFarArr
+ .map((column) =>
+ columnsInDataSource.includes(column) ? `"${column}"` : "NULL"
+ )
+ .join(", ")}, '${dataSource.name}' AS "Data source"
+ FROM "${dataSource.name}"
+ `);
+ }
+
+ // Add the new columns from this data source to the existing columns
+ // to avoid adding duplicate columns
+ newColumns.forEach((column) => columnsSoFar.add(column));
+ }
+ }
+
+ public async fetchAnnotations(dataSourceNames: string[]): Promise {
+ const aggregateDataSourceName = dataSourceNames.sort().join(", ");
+ if (!this.dataSourceToAnnotationsMap.has(aggregateDataSourceName)) {
+ const sql = new SQLBuilder()
+ .from('information_schema"."columns')
+ .where(`table_name = '${aggregateDataSourceName}'`)
+ .toSQL();
+ const rows = await this.query(sql);
+ if (isEmpty(rows)) {
+ throw new Error(`Unable to fetch annotations for ${aggregateDataSourceName}`);
+ }
+ const annotations = rows.map(
+ (row) =>
+ new Annotation({
+ annotationDisplayName: row["column_name"],
+ annotationName: row["column_name"],
+ description: "",
+ type: DatabaseService.columnTypeToAnnotationType(row["data_type"]),
+ })
+ );
+ this.dataSourceToAnnotationsMap.set(aggregateDataSourceName, annotations);
+ }
+
+ return this.dataSourceToAnnotationsMap.get(aggregateDataSourceName) || [];
+ }
}
diff --git a/packages/core/services/DatabaseService/test/DatabaseService.test.ts b/packages/core/services/DatabaseService/test/DatabaseService.test.ts
new file mode 100644
index 000000000..145bda4ba
--- /dev/null
+++ b/packages/core/services/DatabaseService/test/DatabaseService.test.ts
@@ -0,0 +1,34 @@
+import { expect } from "chai";
+
+import DatabaseService from "..";
+
+describe("DatabaseService", () => {
+ describe("fetchAnnotations", () => {
+ const annotations = ["A", "B", "Cc", "dD"].map((name) => ({
+ name,
+ }));
+
+ // DatabaseService is abstract so we need a dummy impl to test
+ // implemented methods
+ class DatabaseServiceDummyImpl extends DatabaseService {
+ saveQuery(): Promise {
+ throw new Error("Not implemented in dummy impl");
+ }
+ execute(): Promise {
+ throw new Error("Not implemented in dummy impl");
+ }
+ addDataSource(): Promise {
+ throw new Error("Not implemented in dummy impl");
+ }
+ query(): Promise {
+ return Promise.resolve(annotations);
+ }
+ }
+
+ it("issues request for all available Annotations", async () => {
+ const service = new DatabaseServiceDummyImpl();
+ const actualAnnotations = await service.fetchAnnotations(["foo"]);
+ expect(actualAnnotations.length).to.equal(4);
+ });
+ });
+});
diff --git a/packages/core/services/FileDownloadService/FileDownloadServiceNoop.ts b/packages/core/services/FileDownloadService/FileDownloadServiceNoop.ts
index bfcbf9b92..1b721b8f1 100644
--- a/packages/core/services/FileDownloadService/FileDownloadServiceNoop.ts
+++ b/packages/core/services/FileDownloadService/FileDownloadServiceNoop.ts
@@ -16,9 +16,9 @@ export default class FileDownloadServiceNoop implements FileDownloadService {
});
}
- prepareHttpResourceForDownload(): Promise {
- return Promise.resolve(
- "Triggered prepareHttpResourceForDownload on FileDownloadServiceNoop; returning without triggering a download."
+ prepareHttpResourceForDownload(): Promise {
+ return Promise.reject(
+ "Triggered prepareHttpResourceForDownload on FileDownloadServiceNoop"
);
}
diff --git a/packages/core/services/FileDownloadService/index.ts b/packages/core/services/FileDownloadService/index.ts
index 0f1256943..66f468702 100644
--- a/packages/core/services/FileDownloadService/index.ts
+++ b/packages/core/services/FileDownloadService/index.ts
@@ -41,7 +41,7 @@ export default interface FileDownloadService {
/**
* Retrieve a Blob from a server over HTTP.
*/
- prepareHttpResourceForDownload(url: string, postBody: string): Promise;
+ prepareHttpResourceForDownload(url: string, postBody: string): Promise;
/**
* Attempt to cancel an active download request, deleting the downloaded artifact if present.
diff --git a/packages/core/services/FileService/DatabaseFileService/index.ts b/packages/core/services/FileService/DatabaseFileService/index.ts
index 598f419c0..07abde0d4 100644
--- a/packages/core/services/FileService/DatabaseFileService/index.ts
+++ b/packages/core/services/FileService/DatabaseFileService/index.ts
@@ -12,7 +12,7 @@ import SQLBuilder from "../../../entity/SQLBuilder";
interface Config {
databaseService: DatabaseService;
- dataSourceName: string;
+ dataSourceNames: string[];
downloadService: FileDownloadService;
}
@@ -22,7 +22,7 @@ interface Config {
export default class DatabaseFileService implements FileService {
private readonly databaseService: DatabaseService;
private readonly downloadService: FileDownloadService;
- private readonly dataSourceName: string;
+ private readonly dataSourceNames: string[];
private static convertDatabaseRowToFileDetail(
row: { [key: string]: string },
@@ -51,24 +51,32 @@ export default class DatabaseFileService implements FileService {
return new FileDetail({
annotations: [
...annotations,
- ...Object.entries(omit(row, ...annotations.keys())).map(([name, values]: any) => ({
- name,
- values: `${values}`.split(",").map((value: string) => value.trim()),
- })),
+ ...Object.entries(omit(row, ...annotations.keys())).flatMap(([name, values]: any) =>
+ values !== null
+ ? [
+ {
+ name,
+ values: `${values}`
+ .split(",")
+ .map((value: string) => value.trim()),
+ },
+ ]
+ : []
+ ),
],
});
}
constructor(
config: Config = {
- dataSourceName: "Unknown",
+ dataSourceNames: [],
databaseService: new DatabaseServiceNoop(),
downloadService: new FileDownloadServiceNoop(),
}
) {
this.databaseService = config.databaseService;
this.downloadService = config.downloadService;
- this.dataSourceName = config.dataSourceName;
+ this.dataSourceNames = config.dataSourceNames;
}
public async getCountOfMatchingFiles(fileSet: FileSet): Promise {
@@ -76,10 +84,11 @@ export default class DatabaseFileService implements FileService {
const sql = fileSet
.toQuerySQLBuilder()
.select(`COUNT(*) AS ${select_key}`)
- .from(this.dataSourceName)
+ .from(this.dataSourceNames)
// Remove sort if present
.orderBy()
.toSQL();
+
const rows = await this.databaseService.query(sql);
return parseInt(rows[0][select_key], 10);
}
@@ -103,10 +112,11 @@ export default class DatabaseFileService implements FileService {
public async getFiles(request: GetFilesRequest): Promise {
const sql = request.fileSet
.toQuerySQLBuilder()
- .from(this.dataSourceName)
+ .from(this.dataSourceNames)
.offset(request.from * request.limit)
.limit(request.limit)
.toSQL();
+
const rows = await this.databaseService.query(sql);
return rows.map((row, index) =>
DatabaseFileService.convertDatabaseRowToFileDetail(
@@ -126,17 +136,17 @@ export default class DatabaseFileService implements FileService {
): Promise {
const sqlBuilder = new SQLBuilder()
.select(annotations.map((annotation) => `"${annotation}"`).join(", "))
- .from(this.dataSourceName);
+ .from(this.dataSourceNames);
selections.forEach((selection) => {
selection.indexRanges.forEach((indexRange) => {
const subQuery = new SQLBuilder()
.select('"File Path"')
- .from(this.dataSourceName as string)
+ .from(this.dataSourceNames)
.whereOr(
Object.entries(selection.filters).map(([column, values]) => {
const commaSeperatedValues = values.map((v) => `'${v}'`).join(", ");
- return `"${column}" IN (${commaSeperatedValues}}`;
+ return `"${column}" IN (${commaSeperatedValues})`;
})
)
.offset(indexRange.start)
@@ -150,7 +160,7 @@ export default class DatabaseFileService implements FileService {
);
}
- sqlBuilder.whereOr(`"File Path" IN (${subQuery})`);
+ sqlBuilder.whereOr(`"File Path" IN (${subQuery.toSQL()})`);
});
});
diff --git a/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts b/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts
index 2fdb2cb7d..ddce55286 100644
--- a/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts
+++ b/packages/core/services/FileService/DatabaseFileService/test/DatabaseFileService.test.ts
@@ -28,7 +28,7 @@ describe("DatabaseFileService", () => {
describe("getFiles", () => {
it("issues request for files that match given parameters", async () => {
const databaseFileService = new DatabaseFileService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["whatever", "and another"],
databaseService,
downloadService: new FileDownloadServiceNoop(),
});
@@ -83,7 +83,7 @@ describe("DatabaseFileService", () => {
it("issues request for aggregated information about given files", async () => {
// Arrange
const fileService = new DatabaseFileService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["whatever"],
databaseService,
downloadService: new FileDownloadServiceNoop(),
});
@@ -105,7 +105,7 @@ describe("DatabaseFileService", () => {
describe("getCountOfMatchingFiles", () => {
it("issues request for count of files matching given parameters", async () => {
const fileService = new DatabaseFileService({
- dataSourceName: "Unknown",
+ dataSourceNames: ["whatever"],
databaseService,
downloadService: new FileDownloadServiceNoop(),
});
diff --git a/packages/core/services/FileService/HttpFileService/index.ts b/packages/core/services/FileService/HttpFileService/index.ts
index b8c79761b..b46308dfc 100644
--- a/packages/core/services/FileService/HttpFileService/index.ts
+++ b/packages/core/services/FileService/HttpFileService/index.ts
@@ -115,7 +115,7 @@ export default class HttpFileService extends HttpServiceBase implements FileServ
const postData = JSON.stringify({ annotations, selections });
const url = `${this.baseUrl}/${HttpFileService.BASE_CSV_DOWNLOAD_URL}${this.pathSuffix}`;
- const manifestAsString = await this.downloadService.prepareHttpResourceForDownload(
+ const manifestAsJSON = await this.downloadService.prepareHttpResourceForDownload(
url,
postData
);
@@ -125,7 +125,7 @@ export default class HttpFileService extends HttpServiceBase implements FileServ
name,
id: name,
path: url,
- data: manifestAsString,
+ data: new Blob([manifestAsJSON as BlobPart], { type: "application/json" }),
},
uniqueId()
);
diff --git a/packages/core/state/interaction/actions.ts b/packages/core/state/interaction/actions.ts
index f568de9d8..57d9f7ca8 100644
--- a/packages/core/state/interaction/actions.ts
+++ b/packages/core/state/interaction/actions.ts
@@ -21,15 +21,20 @@ export const PROMPT_FOR_DATA_SOURCE = makeConstant(STATE_BRANCH_NAME, "prompt-fo
type PartialSource = Omit;
+export interface DataSourcePromptInfo {
+ source?: PartialSource;
+ query?: string;
+}
+
export interface PromptForDataSource {
type: string;
- payload: PartialSource;
+ payload: DataSourcePromptInfo;
}
-export function promptForDataSource(dataSource: PartialSource): PromptForDataSource {
+export function promptForDataSource(info: DataSourcePromptInfo): PromptForDataSource {
return {
type: PROMPT_FOR_DATA_SOURCE,
- payload: dataSource,
+ payload: info,
};
}
@@ -205,21 +210,18 @@ export function setIsAicsEmployee(isAicsEmployee: boolean): SetIsAicsEmployee {
}
/**
- * SET CONNECTION CONFIGURATION FOR THE FILE EXPLORER SERVICE
+ * Set connection configuration and kick off any tasks to initialize the app
*/
-export const SET_FILE_EXPLORER_SERVICE_BASE_URL = makeConstant(
- STATE_BRANCH_NAME,
- "set-file-explorer-service-connection-config"
-);
+export const INITIALIZE_APP = makeConstant(STATE_BRANCH_NAME, "initialize-app");
-export interface SetFileExplorerServiceBaseUrl {
+export interface InitializeApp {
type: string;
payload: string;
}
-export function setFileExplorerServiceBaseUrl(baseUrl: string): SetFileExplorerServiceBaseUrl {
+export function initializeApp(baseUrl: string): InitializeApp {
return {
- type: SET_FILE_EXPLORER_SERVICE_BASE_URL,
+ type: INITIALIZE_APP,
payload: baseUrl,
};
}
diff --git a/packages/core/state/interaction/logics.ts b/packages/core/state/interaction/logics.ts
index 74d86c3f8..869472f10 100644
--- a/packages/core/state/interaction/logics.ts
+++ b/packages/core/state/interaction/logics.ts
@@ -25,7 +25,7 @@ import {
OpenWithDefaultAction,
PROMPT_FOR_NEW_EXECUTABLE,
setUserSelectedApplication,
- SET_FILE_EXPLORER_SERVICE_BASE_URL,
+ INITIALIZE_APP,
setIsAicsEmployee,
} from "./actions";
import * as interactionSelectors from "./selectors";
@@ -41,15 +41,45 @@ import FileDetail from "../../entity/FileDetail";
import { AnnotationName } from "../../entity/Annotation";
import FileSelection from "../../entity/FileSelection";
import NumericRange from "../../entity/NumericRange";
+import FileExplorerURL, { DEFAULT_AICS_FMS_QUERY } from "../../entity/FileExplorerURL";
/**
* Interceptor responsible for checking if the user is able to access the AICS network
*/
const checkAicsEmployee = createLogic({
- type: SET_FILE_EXPLORER_SERVICE_BASE_URL,
+ type: INITIALIZE_APP,
async process(deps: ReduxLogicDeps, dispatch, done) {
+ const queries = selection.selectors.getQueries(deps.getState());
+ const selectedQuery = selection.selectors.getSelectedQuery(deps.getState());
const fileService = interactionSelectors.getHttpFileService(deps.getState());
+
+ // Redimentary check to see if the user is an AICS Employee by
+ // checking if the AICS network is accessible
const isAicsEmployee = await fileService.isNetworkAccessible();
+
+ // If no query is currently selected attempt to choose one for the user
+ if (!selectedQuery) {
+ // If there are query args representing a query we can extract that
+ // into the query to render (ex. when refreshing a page)
+ if (window.location.search) {
+ dispatch(
+ selection.actions.addQuery({
+ name: "New Query",
+ parts: FileExplorerURL.decode(window.location.search),
+ })
+ );
+ } else if (queries.length) {
+ dispatch(selection.actions.changeQuery(queries[0]));
+ } else if (isAicsEmployee) {
+ dispatch(
+ selection.actions.addQuery({
+ name: "New AICS FMS Query",
+ parts: DEFAULT_AICS_FMS_QUERY,
+ })
+ );
+ }
+ }
+
dispatch(setIsAicsEmployee(isAicsEmployee) as AnyAction);
done();
},
@@ -448,18 +478,18 @@ const refresh = createLogic({
async process(deps: ReduxLogicDeps, dispatch, done) {
try {
const { getState } = deps;
+ const hierarchy = selection.selectors.getAnnotationHierarchy(getState());
const annotationService = interactionSelectors.getAnnotationService(getState());
// Refresh list of annotations & which annotations are available
- const hierarchy = selection.selectors.getAnnotationHierarchy(getState());
const [annotations, availableAnnotations] = await Promise.all([
annotationService.fetchAnnotations(),
annotationService.fetchAvailableAnnotationsForHierarchy(hierarchy),
]);
dispatch(metadata.actions.receiveAnnotations(annotations));
dispatch(selection.actions.setAvailableAnnotations(availableAnnotations));
- } catch (e) {
- console.error("Error encountered while refreshing");
+ } catch (err) {
+ console.error(`Error encountered while refreshing: ${err}`);
const annotations = metadata.selectors.getAnnotations(deps.getState());
dispatch(selection.actions.setAvailableAnnotations(annotations.map((a) => a.name)));
} finally {
diff --git a/packages/core/state/interaction/reducer.ts b/packages/core/state/interaction/reducer.ts
index c126ff36e..5f24a3aed 100644
--- a/packages/core/state/interaction/reducer.ts
+++ b/packages/core/state/interaction/reducer.ts
@@ -8,7 +8,7 @@ import {
REFRESH,
REMOVE_STATUS,
SET_USER_SELECTED_APPLICATIONS,
- SET_FILE_EXPLORER_SERVICE_BASE_URL,
+ INITIALIZE_APP,
SET_STATUS,
SET_VISIBLE_MODAL,
SHOW_CONTEXT_MENU,
@@ -20,10 +20,11 @@ import {
PROMPT_FOR_DATA_SOURCE,
DownloadManifestAction,
DOWNLOAD_MANIFEST,
+ DataSourcePromptInfo,
+ PromptForDataSource,
} from "./actions";
import { ContextMenuItem, PositionReference } from "../../components/ContextMenu";
import { ModalType } from "../../components/Modal";
-import { Source } from "../../entity/FileExplorerURL";
import FileFilter from "../../entity/FileFilter";
import { PlatformDependentServices } from "../../services";
import ApplicationInfoServiceNoop from "../../services/ApplicationInfoService/ApplicationInfoServiceNoop";
@@ -37,12 +38,12 @@ import DatabaseServiceNoop from "../../services/DatabaseService/DatabaseServiceN
export interface InteractionStateBranch {
applicationVersion?: string;
- dataSourceForVisibleModal?: Source;
contextMenuIsVisible: boolean;
contextMenuItems: ContextMenuItem[];
contextMenuPositionReference: PositionReference;
contextMenuOnDismiss?: () => void;
csvColumns?: string[];
+ dataSourceInfoForVisibleModal?: DataSourcePromptInfo;
fileExplorerServiceBaseUrl: string;
fileTypeForVisibleModal: "csv" | "json" | "parquet";
fileFiltersForVisibleModal: FileFilter[];
@@ -147,7 +148,7 @@ export default makeReducer(
...state,
csvColumns: action.payload.annotations,
}),
- [SET_FILE_EXPLORER_SERVICE_BASE_URL]: (state, action) => ({
+ [INITIALIZE_APP]: (state, action) => ({
...state,
fileExplorerServiceBaseUrl: action.payload,
}),
@@ -162,10 +163,10 @@ export default makeReducer(
fileTypeForVisibleModal: action.payload.fileType,
fileFiltersForVisibleModal: action.payload.fileFilters,
}),
- [PROMPT_FOR_DATA_SOURCE]: (state, action) => ({
+ [PROMPT_FOR_DATA_SOURCE]: (state, action: PromptForDataSource) => ({
...state,
- visibleModal: ModalType.DataSourcePrompt,
- dataSourceForVisibleModal: action.payload,
+ visibleModal: ModalType.DataSource,
+ dataSourceInfoForVisibleModal: action.payload,
}),
},
initialState
diff --git a/packages/core/state/interaction/selectors.ts b/packages/core/state/interaction/selectors.ts
index 8bfb67ba5..d4de838d3 100644
--- a/packages/core/state/interaction/selectors.ts
+++ b/packages/core/state/interaction/selectors.ts
@@ -2,7 +2,7 @@ import { uniqBy } from "lodash";
import { createSelector } from "reselect";
import { State } from "../";
-import { getDataSource, getPythonConversion } from "../selection/selectors";
+import { getSelectedDataSources, getPythonConversion } from "../selection/selectors";
import { AnnotationService, FileService } from "../../services";
import DatasetService, {
DataSource,
@@ -22,8 +22,8 @@ export const getContextMenuPositionReference = (state: State) =>
state.interaction.contextMenuPositionReference;
export const getContextMenuOnDismiss = (state: State) => state.interaction.contextMenuOnDismiss;
export const getCsvColumns = (state: State) => state.interaction.csvColumns;
-export const getDataSourceForVisibleModal = (state: State) =>
- state.interaction.dataSourceForVisibleModal;
+export const getDataSourceInfoForVisibleModal = (state: State) =>
+ state.interaction.dataSourceInfoForVisibleModal;
export const getFileExplorerServiceBaseUrl = (state: State) =>
state.interaction.fileExplorerServiceBaseUrl;
export const getFileFiltersForVisibleModal = (state: State) =>
@@ -105,12 +105,12 @@ export const getHttpFileService = createSelector(
);
export const getFileService = createSelector(
- [getHttpFileService, getDataSource, getPlatformDependentServices, getRefreshKey],
- (httpFileService, dataSource, platformDependentServices): FileService => {
- if (dataSource && dataSource?.name !== AICS_FMS_DATA_SOURCE_NAME) {
+ [getHttpFileService, getSelectedDataSources, getPlatformDependentServices, getRefreshKey],
+ (httpFileService, dataSourceNames, platformDependentServices): FileService => {
+ if (dataSourceNames[0]?.name !== AICS_FMS_DATA_SOURCE_NAME) {
return new DatabaseFileService({
databaseService: platformDependentServices.databaseService,
- dataSourceName: dataSource.name,
+ dataSourceNames: dataSourceNames.map((source) => source.name),
downloadService: platformDependentServices.fileDownloadService,
});
}
@@ -124,7 +124,7 @@ export const getAnnotationService = createSelector(
getApplicationVersion,
getUserName,
getFileExplorerServiceBaseUrl,
- getDataSource,
+ getSelectedDataSources,
getPlatformDependentServices,
getRefreshKey,
],
@@ -132,13 +132,13 @@ export const getAnnotationService = createSelector(
applicationVersion,
userName,
fileExplorerBaseUrl,
- dataSource,
+ dataSources,
platformDependentServices
): AnnotationService => {
- if (dataSource && dataSource?.name !== AICS_FMS_DATA_SOURCE_NAME) {
+ if (dataSources.length && dataSources[0]?.name !== AICS_FMS_DATA_SOURCE_NAME) {
return new DatabaseAnnotationService({
databaseService: platformDependentServices.databaseService,
- dataSourceName: dataSource.name,
+ dataSourceNames: dataSources.map((source) => source.name),
});
}
return new HttpAnnotationService({
diff --git a/packages/core/state/interaction/test/logics.test.ts b/packages/core/state/interaction/test/logics.test.ts
index e3e4ef4eb..17dd32195 100644
--- a/packages/core/state/interaction/test/logics.test.ts
+++ b/packages/core/state/interaction/test/logics.test.ts
@@ -43,6 +43,7 @@ import NotificationServiceNoop from "../../../services/NotificationService/Notif
import HttpFileService from "../../../services/FileService/HttpFileService";
import HttpAnnotationService from "../../../services/AnnotationService/HttpAnnotationService";
import FileDetail, { FmsFile } from "../../../entity/FileDetail";
+import DatabaseServiceNoop from "../../../services/DatabaseService/DatabaseServiceNoop";
describe("Interaction logics", () => {
const fileSelection = new FileSelection().select({
@@ -57,6 +58,12 @@ describe("Interaction logics", () => {
}
}
+ class MockDatabaseService extends DatabaseServiceNoop {
+ saveQuery() {
+ return Promise.resolve(new Uint8Array());
+ }
+ }
+
describe("downloadManifest", () => {
const sandbox = createSandbox();
@@ -103,10 +110,12 @@ describe("Interaction logics", () => {
const state = mergeState(initialState, {
interaction: {
platformDependentServices: {
+ databaseService: new MockDatabaseService(),
fileDownloadService: new FileDownloadServiceNoop(),
},
},
selection: {
+ dataSources: [{ name: "mock" }],
fileSelection,
},
});
diff --git a/packages/core/state/selection/actions.ts b/packages/core/state/selection/actions.ts
index 642f19270..8aa036180 100644
--- a/packages/core/state/selection/actions.ts
+++ b/packages/core/state/selection/actions.ts
@@ -8,7 +8,6 @@ import FileSet from "../../entity/FileSet";
import FileSort from "../../entity/FileSort";
import NumericRange from "../../entity/NumericRange";
import Tutorial from "../../entity/Tutorial";
-import { DataSource } from "../../services/DataSourceService";
import {
EMPTY_QUERY_COMPONENTS,
FileExplorerURLComponents,
@@ -551,21 +550,21 @@ export function decodeFileExplorerURL(decodedFileExplorerURL: string): DecodeFil
}
/**
- * CHANGE_DATA_SOURCE
+ * CHANGE_DATA_SOURCES
*
- * Intention to update the data source queries are run against.
+ * Intention to update the data sources queries are run against.
*/
-export const CHANGE_DATA_SOURCE = makeConstant(STATE_BRANCH_NAME, "change-data-source");
+export const CHANGE_DATA_SOURCES = makeConstant(STATE_BRANCH_NAME, "change-data-sources");
-export interface ChangeDataSourceAction {
- payload?: DataSource;
+export interface ChangeDataSourcesAction {
+ payload: Source[];
type: string;
}
-export function changeDataSource(dataSource?: DataSource): ChangeDataSourceAction {
+export function changeDataSources(dataSources: Source[]): ChangeDataSourcesAction {
return {
- payload: dataSource,
- type: CHANGE_DATA_SOURCE,
+ payload: dataSources,
+ type: CHANGE_DATA_SOURCES,
};
}
diff --git a/packages/core/state/selection/logics.ts b/packages/core/state/selection/logics.ts
index 9c13195b9..c69076634 100644
--- a/packages/core/state/selection/logics.ts
+++ b/packages/core/state/selection/logics.ts
@@ -20,10 +20,7 @@ import {
SET_ANNOTATION_HIERARCHY,
SELECT_NEARBY_FILE,
setSortColumn,
- changeDataSource,
- CHANGE_DATA_SOURCE,
CHANGE_QUERY,
- ChangeDataSourceAction,
SetAnnotationHierarchyAction,
RemoveFromAnnotationHierarchyAction,
ReorderAnnotationHierarchyAction,
@@ -36,6 +33,9 @@ import {
REPLACE_DATA_SOURCE,
ReplaceDataSource,
REMOVE_QUERY,
+ changeDataSources,
+ ChangeDataSourcesAction,
+ CHANGE_DATA_SOURCES,
} from "./actions";
import { interaction, metadata, ReduxLogicDeps, selection } from "../";
import * as selectionSelectors from "./selectors";
@@ -46,6 +46,8 @@ import FileFolder from "../../entity/FileFolder";
import FileSelection from "../../entity/FileSelection";
import FileSet from "../../entity/FileSet";
import HttpAnnotationService from "../../services/AnnotationService/HttpAnnotationService";
+import { DataSource } from "../../services/DataSourceService";
+import DataSourcePreparationError from "../../errors/DataSourcePreparationError";
/**
* Interceptor responsible for transforming payload of SELECT_FILE actions to account for whether the intention is to
@@ -294,25 +296,12 @@ const toggleFileFolderCollapse = createLogic({
const decodeFileExplorerURLLogics = createLogic({
async process(deps: ReduxLogicDeps, dispatch, done) {
const encodedURL = deps.action.payload;
- const dataSources = interaction.selectors.getAllDataSources(deps.getState());
- const { hierarchy, filters, openFolders, sortColumn, source } = FileExplorerURL.decode(
+ const { hierarchy, filters, openFolders, sortColumn, sources } = FileExplorerURL.decode(
encodedURL
);
- let selectedDataSource = dataSources.find((c) => c.name === source?.name);
- // It is possible the user was sent a novel data source in the URL
- if (source && !selectedDataSource) {
- const newDataSource = {
- ...source,
- id: source.name,
- version: 1,
- };
- dispatch(metadata.actions.receiveDataSources([...dataSources, newDataSource]));
- selectedDataSource = newDataSource;
- }
-
batch(() => {
- dispatch(changeDataSource(selectedDataSource));
+ dispatch(changeDataSources(sources));
dispatch(setAnnotationHierarchy(hierarchy));
dispatch(setFileFilters(filters));
dispatch(setOpenFileFolders(openFolders));
@@ -445,18 +434,52 @@ const selectNearbyFile = createLogic({
* a refresh action so that the resources pertain to the current data source
*/
const changeDataSourceLogic = createLogic({
+ type: CHANGE_DATA_SOURCES,
async process(deps: ReduxLogicDeps, dispatch, done) {
- const action: ChangeDataSourceAction = deps.action;
- const dataSource = action.payload;
+ const { payload: selectedDataSources } = deps.action as ChangeDataSourcesAction;
const dataSources = interaction.selectors.getAllDataSources(deps.getState());
- if (dataSource && !dataSources.some((dataSource) => dataSource.id === dataSource.id)) {
- dispatch(metadata.actions.receiveDataSources([...dataSources, dataSource]));
+ const { databaseService } = interaction.selectors.getPlatformDependentServices(
+ deps.getState()
+ );
+
+ const newSelectedDataSources: DataSource[] = [];
+ const existingSelectedDataSources: DataSource[] = [];
+ selectedDataSources.forEach((source) => {
+ const existingSource = dataSources.find((s) => s.name === source.name);
+ if (existingSource) {
+ existingSelectedDataSources.push(existingSource);
+ } else {
+ newSelectedDataSources.push({ ...source, id: source.name });
+ }
+ });
+
+ // It is possible the user was sent a novel data source in the URL
+ if (selectedDataSources.length > existingSelectedDataSources.length) {
+ dispatch(
+ metadata.actions.receiveDataSources([...dataSources, ...newSelectedDataSources])
+ );
+ }
+
+ // Prepare the data sources ahead of querying against them below
+ try {
+ await databaseService.prepareDataSources(selectedDataSources);
+ } catch (err) {
+ const errMsg = `Error encountered while preparing data sources (Full error: ${
+ (err as Error).message
+ })`;
+ console.error(errMsg);
+ if (err instanceof DataSourcePreparationError) {
+ dispatch(
+ interaction.actions.promptForDataSource({ source: { name: err.sourceName } })
+ );
+ } else {
+ alert(errMsg);
+ }
}
dispatch(interaction.actions.refresh() as AnyAction);
done();
},
- type: CHANGE_DATA_SOURCE,
});
/**
@@ -464,6 +487,28 @@ const changeDataSourceLogic = createLogic({
*/
const addQueryLogic = createLogic({
async process(deps: ReduxLogicDeps, dispatch, done) {
+ const { payload: newQuery } = deps.action as AddQuery;
+ const { databaseService } = interaction.selectors.getPlatformDependentServices(
+ deps.getState()
+ );
+
+ // Prepare the data sources ahead of querying against them below
+ try {
+ await databaseService.prepareDataSources(newQuery.parts.sources);
+ } catch (err) {
+ const errMsg = `Error encountered while preparing data sources (Full error: ${
+ (err as Error).message
+ })`;
+ console.error(errMsg);
+ if (err instanceof DataSourcePreparationError) {
+ dispatch(
+ interaction.actions.promptForDataSource({ source: { name: err.sourceName } })
+ );
+ } else {
+ alert(errMsg);
+ }
+ }
+
dispatch(changeQuery(deps.action.payload));
done();
},
@@ -498,9 +543,6 @@ const addQueryLogic = createLogic({
const changeQueryLogic = createLogic({
async process(deps: ReduxLogicDeps, dispatch, done) {
const { payload: newlySelectedQuery } = deps.action as ChangeQuery;
- const { databaseService } = interaction.selectors.getPlatformDependentServices(
- deps.getState()
- );
const currentQueries = selectionSelectors.getQueries(deps.getState());
const currentQueryParts = selectionSelectors.getCurrentQueryParts(deps.getState());
const updatedQueries = currentQueries.map((query) => ({
@@ -511,19 +553,6 @@ const changeQueryLogic = createLogic({
: query.parts,
}));
- if (newlySelectedQuery.parts.source?.uri) {
- try {
- await databaseService.addDataSource(
- newlySelectedQuery.parts.source.name,
- newlySelectedQuery.parts.source.type,
- newlySelectedQuery.parts.source.uri
- );
- } catch (error) {
- console.error("Failed to add data source, prompting for replacement", error);
- dispatch(interaction.actions.promptForDataSource(newlySelectedQuery.parts.source));
- }
- }
-
dispatch(
decodeFileExplorerURL(FileExplorerURL.encode(newlySelectedQuery.parts)) as AnyAction
);
@@ -549,25 +578,27 @@ const removeQueryLogic = createLogic({
const replaceDataSourceLogic = createLogic({
type: REPLACE_DATA_SOURCE,
async process(deps: ReduxLogicDeps, dispatch, done) {
- const {
- payload: { name, type, uri },
- } = deps.ctx.replaceDataSourceAction as ReplaceDataSource;
+ const { payload: replacementSource } = deps.ctx
+ .replaceDataSourceAction as ReplaceDataSource;
const { databaseService } = interaction.selectors.getPlatformDependentServices(
deps.getState()
);
+ // Prepare the data sources ahead of querying against them below
try {
- if (uri) {
- await databaseService.addDataSource(name, type, uri);
+ await databaseService.prepareDataSources([replacementSource]);
+ } catch (err) {
+ const errMsg = `Error encountered while replacing data sources (Full error: ${
+ (err as Error).message
+ })`;
+ console.error(errMsg);
+ if (err instanceof DataSourcePreparationError) {
+ dispatch(
+ interaction.actions.promptForDataSource({ source: { name: err.sourceName } })
+ );
+ } else {
+ alert(errMsg);
}
- } catch (error) {
- console.error("Failed to add data source, prompting for replacement", error);
- dispatch(
- interaction.actions.promptForDataSource({
- name,
- uri,
- })
- );
}
dispatch(interaction.actions.refresh() as AnyAction);
@@ -578,7 +609,7 @@ const replaceDataSourceLogic = createLogic({
deps.ctx.replaceDataSourceAction = deps.action;
const queries = selectionSelectors.getQueries(deps.getState());
const updatedQueries = queries.map((query) => {
- if (query.parts.source?.name !== replacementDataSource.name) {
+ if (query.parts.sources[0]?.name !== replacementDataSource.name) {
return query;
}
diff --git a/packages/core/state/selection/reducer.ts b/packages/core/state/selection/reducer.ts
index 1693faa4a..33277c6fa 100644
--- a/packages/core/state/selection/reducer.ts
+++ b/packages/core/state/selection/reducer.ts
@@ -1,5 +1,5 @@
import { makeReducer } from "@aics/redux-utils";
-import { castArray, omit, uniq } from "lodash";
+import { castArray, omit, uniq, uniqBy } from "lodash";
import interaction from "../interaction";
import { THUMBNAIL_SIZE_TO_NUM_COLUMNS } from "../../constants";
@@ -19,7 +19,7 @@ import {
RESET_COLUMN_WIDTH,
SORT_COLUMN,
SET_SORT_COLUMN,
- CHANGE_DATA_SOURCE,
+ CHANGE_DATA_SOURCES,
SELECT_TUTORIAL,
ADJUST_GLOBAL_FONT_SIZE,
Query,
@@ -32,12 +32,13 @@ import {
SET_FILE_GRID_COLUMN_COUNT,
REMOVE_QUERY,
RemoveQuery,
+ ChangeDataSourcesAction,
SetSortColumnAction,
SetFileFiltersAction,
} from "./actions";
import FileSort, { SortOrder } from "../../entity/FileSort";
import Tutorial from "../../entity/Tutorial";
-import { DataSource } from "../../services/DataSourceService";
+import { Source } from "../../entity/FileExplorerURL";
export interface SelectionStateBranch {
annotationHierarchy: string[];
@@ -46,7 +47,7 @@ export interface SelectionStateBranch {
columnWidths: {
[index: string]: number; // columnName to widthPercent mapping
};
- dataSource?: DataSource;
+ dataSources: Source[];
displayAnnotations: Annotation[];
fileGridColumnCount: number;
fileSelection: FileSelection;
@@ -65,13 +66,14 @@ export interface SelectionStateBranch {
export const initialState = {
annotationHierarchy: [],
availableAnnotationsForHierarchy: [],
- availableAnnotationsForHierarchyLoading: false,
+ availableAnnotationsForHierarchyLoading: true,
columnWidths: {
[AnnotationName.FILE_NAME]: 0.4,
[AnnotationName.KIND]: 0.2,
[AnnotationName.TYPE]: 0.25,
[AnnotationName.FILE_SIZE]: 0.15,
},
+ dataSources: [],
displayAnnotations: [],
isDarkTheme: true,
fileGridColumnCount: THUMBNAIL_SIZE_TO_NUM_COLUMNS.LARGE,
@@ -137,11 +139,9 @@ export default makeReducer(
sortColumn: new FileSort(action.payload, SortOrder.DESC),
};
},
- [CHANGE_DATA_SOURCE]: (state, action) => ({
+ [CHANGE_DATA_SOURCES]: (state, action: ChangeDataSourcesAction) => ({
...state,
- annotationHierarchy: [],
- dataSource: action.payload,
- filters: [],
+ dataSources: uniqBy(action.payload, "name"),
fileSelection: new FileSelection(),
openFileFolders: [],
}),
@@ -211,7 +211,7 @@ export default makeReducer(
...state,
openFileFolders: action.payload,
}),
- [interaction.actions.SET_FILE_EXPLORER_SERVICE_BASE_URL]: (state) => ({
+ [interaction.actions.INITIALIZE_APP]: (state) => ({
...state,
// Reset file selections when pointed at a new backend
diff --git a/packages/core/state/selection/selectors.ts b/packages/core/state/selection/selectors.ts
index fb500cecd..c8b49f71d 100644
--- a/packages/core/state/selection/selectors.ts
+++ b/packages/core/state/selection/selectors.ts
@@ -5,9 +5,6 @@ import { State } from "../";
import Annotation from "../../entity/Annotation";
import FileExplorerURL, { FileExplorerURLComponents } from "../../entity/FileExplorerURL";
import FileFilter from "../../entity/FileFilter";
-import FileFolder from "../../entity/FileFolder";
-import FileSort from "../../entity/FileSort";
-import { DataSource } from "../../services/DataSourceService";
import { getAnnotations } from "../metadata/selectors";
import { AICS_FMS_DATA_SOURCE_NAME } from "../../constants";
@@ -19,13 +16,13 @@ export const getAvailableAnnotationsForHierarchy = (state: State) =>
export const getAvailableAnnotationsForHierarchyLoading = (state: State) =>
state.selection.availableAnnotationsForHierarchyLoading;
export const getColumnWidths = (state: State) => state.selection.columnWidths;
-export const getDataSource = (state: State) => state.selection.dataSource;
export const getFileGridColumnCount = (state: State) => state.selection.fileGridColumnCount;
export const getFileFilters = (state: State) => state.selection.filters;
export const getFileSelection = (state: State) => state.selection.fileSelection;
export const getIsDarkTheme = (state: State) => state.selection.isDarkTheme;
export const getOpenFileFolders = (state: State) => state.selection.openFileFolders;
export const getRecentAnnotations = (state: State) => state.selection.recentAnnotations;
+export const getSelectedDataSources = (state: State) => state.selection.dataSources;
export const getSelectedQuery = (state: State) => state.selection.selectedQuery;
export const getShouldDisplaySmallFont = (state: State) => state.selection.shouldDisplaySmallFont;
export const getShouldDisplayThumbnailView = (state: State) =>
@@ -36,25 +33,27 @@ export const getQueries = (state: State) => state.selection.queries;
const getPlatformDependentServices = (state: State) => state.interaction.platformDependentServices; // Importing normally creates a circular dependency
// COMPOSED SELECTORS
+export const hasQuerySelected = createSelector([getSelectedQuery], (query): boolean => !!query);
+
export const isQueryingAicsFms = createSelector(
- [getDataSource],
- (dataSource): boolean => !dataSource || dataSource.name === AICS_FMS_DATA_SOURCE_NAME
+ [getSelectedDataSources],
+ (dataSources): boolean => dataSources[0]?.name === AICS_FMS_DATA_SOURCE_NAME
);
export const getCurrentQueryParts = createSelector(
- [getAnnotationHierarchy, getFileFilters, getOpenFileFolders, getSortColumn, getDataSource],
- (
- hierarchy: string[],
- filters: FileFilter[],
- openFolders: FileFolder[],
- sortColumn?: FileSort,
- source?: DataSource
- ): FileExplorerURLComponents => ({
+ [
+ getAnnotationHierarchy,
+ getFileFilters,
+ getOpenFileFolders,
+ getSortColumn,
+ getSelectedDataSources,
+ ],
+ (hierarchy, filters, openFolders, sortColumn, sources): FileExplorerURLComponents => ({
hierarchy,
filters,
openFolders,
sortColumn,
- source,
+ sources,
})
);
@@ -70,23 +69,16 @@ export const getPythonConversion = createSelector(
getFileFilters,
getOpenFileFolders,
getSortColumn,
- getDataSource,
+ getSelectedDataSources,
],
- (
- platformDependentServices,
- hierarchy: string[],
- filters: FileFilter[],
- openFolders: FileFolder[],
- sortColumn?: FileSort,
- source?: DataSource
- ) => {
+ (platformDependentServices, hierarchy, filters, openFolders, sortColumn, sources) => {
return FileExplorerURL.convertToPython(
{
hierarchy,
filters,
openFolders,
sortColumn,
- source,
+ sources,
},
platformDependentServices.executionEnvService.getOS()
);
diff --git a/packages/core/state/selection/test/logics.test.ts b/packages/core/state/selection/test/logics.test.ts
index 83f673245..31e120fae 100644
--- a/packages/core/state/selection/test/logics.test.ts
+++ b/packages/core/state/selection/test/logics.test.ts
@@ -23,7 +23,7 @@ import {
setAnnotationHierarchy,
selectNearbyFile,
SET_SORT_COLUMN,
- changeDataSource,
+ changeDataSources,
} from "../actions";
import { initialState, interaction } from "../../";
import Annotation, { AnnotationName } from "../../../entity/Annotation";
@@ -31,7 +31,7 @@ import FileFilter from "../../../entity/FileFilter";
import selectionLogics from "../logics";
import { annotationsJson } from "../../../entity/Annotation/mocks";
import NumericRange from "../../../entity/NumericRange";
-import FileExplorerURL, { Source } from "../../../entity/FileExplorerURL";
+import FileExplorerURL from "../../../entity/FileExplorerURL";
import FileFolder from "../../../entity/FileFolder";
import FileSet from "../../../entity/FileSet";
import FileSelection from "../../../entity/FileSelection";
@@ -686,7 +686,7 @@ describe("Selection logics", () => {
});
// Act
- store.dispatch(changeDataSource({} as any));
+ store.dispatch(changeDataSources([{}] as any[]));
await logicMiddleware.whenComplete();
// Assert
@@ -925,13 +925,14 @@ describe("Selection logics", () => {
});
describe("decodeFileExplorerURL", () => {
- const mockDataSource: DataSource = {
- id: "1234148",
- name: "Test Data Source",
- version: 1,
- type: "csv",
- uri: "",
- };
+ const mockDataSources: DataSource[] = [
+ {
+ id: "1234148",
+ name: "Test Data Source",
+ version: 1,
+ type: "csv",
+ },
+ ];
beforeEach(() => {
const datasetService = new DatasetService();
@@ -948,7 +949,7 @@ describe("Selection logics", () => {
const state = mergeState(initialState, {
metadata: {
annotations,
- dataSources: [mockDataSource],
+ dataSources: mockDataSources,
},
});
const { store, logicMiddleware, actions } = configureMockStore({
@@ -959,17 +960,12 @@ describe("Selection logics", () => {
const filters = [new FileFilter(annotations[3].name, "20x")];
const openFolders = [["a"], ["a", false]].map((folder) => new FileFolder(folder));
const sortColumn = new FileSort(AnnotationName.UPLOADED, SortOrder.DESC);
- const source: Source = {
- name: mockDataSource.name,
- uri: "",
- type: "csv",
- };
const encodedURL = FileExplorerURL.encode({
hierarchy,
filters,
openFolders,
sortColumn,
- source,
+ sources: mockDataSources,
});
// Act
@@ -1001,7 +997,7 @@ describe("Selection logics", () => {
payload: sortColumn,
})
).to.be.true;
- expect(actions.includesMatch(changeDataSource(mockDataSource))).to.be.true;
+ expect(actions.includesMatch(changeDataSources(mockDataSources))).to.be.true;
});
});
});
diff --git a/packages/core/state/selection/test/reducer.test.ts b/packages/core/state/selection/test/reducer.test.ts
index 2f3ce0ce6..0ae120167 100644
--- a/packages/core/state/selection/test/reducer.test.ts
+++ b/packages/core/state/selection/test/reducer.test.ts
@@ -15,16 +15,10 @@ import { DataSource } from "../../../services/DataSourceService";
describe("Selection reducer", () => {
[
- {
- actionConstant: selection.actions.SET_ANNOTATION_HIERARCHY,
- expectedAction: selection.actions.setAnnotationHierarchy([]),
- },
- {
- actionConstant: interaction.actions.SET_FILE_EXPLORER_SERVICE_BASE_URL,
- expectedAction: interaction.actions.setFileExplorerServiceBaseUrl("base"),
- },
- ].forEach(({ actionConstant, expectedAction }) =>
- it(`clears selected file state when ${actionConstant} is fired`, () => {
+ selection.actions.setAnnotationHierarchy([]),
+ interaction.actions.initializeApp("base"),
+ ].forEach((expectedAction) =>
+ it(`clears selected file state when ${expectedAction.type} is fired`, () => {
// arrange
const prevSelection = new FileSelection().select({
fileSet: new FileSet(),
@@ -47,8 +41,8 @@ describe("Selection reducer", () => {
})
);
- describe(selection.actions.CHANGE_DATA_SOURCE, () => {
- it("clears hierarchy, filters, file selection, and open folders", () => {
+ describe(selection.actions.CHANGE_DATA_SOURCES, () => {
+ it("clears file selection and open folders", () => {
// Arrange
const state = {
...selection.initialState,
@@ -61,22 +55,25 @@ describe("Selection reducer", () => {
filters: [new FileFilter("file_id", "1238401234")],
openFileFolders: [new FileFolder(["AICS-11"])],
};
- const dataSource: DataSource = {
- name: "My Tiffs",
- version: 2,
- type: "csv",
- id: "13123019",
- uri: "",
- };
+ const dataSources: DataSource[] = [
+ {
+ name: "My Tiffs",
+ version: 2,
+ type: "csv",
+ id: "13123019",
+ uri: "",
+ },
+ ];
// Act
- const actual = selection.reducer(state, selection.actions.changeDataSource(dataSource));
+ const actual = selection.reducer(
+ state,
+ selection.actions.changeDataSources(dataSources)
+ );
// Assert
- expect(actual.annotationHierarchy).to.be.empty;
- expect(actual.dataSource).to.deep.equal(dataSource);
+ expect(actual.dataSources).to.deep.equal(dataSources);
expect(actual.fileSelection.count()).to.equal(0);
- expect(actual.filters).to.be.empty;
expect(actual.openFileFolders).to.be.empty;
});
});
@@ -252,14 +249,6 @@ describe("Selection reducer", () => {
// Arrange
const initialSelectionState = { ...selection.initialState };
- // (sanity-check) available annotations are not loading before refresh
- expect(
- selection.selectors.getAvailableAnnotationsForHierarchyLoading({
- ...initialState,
- selection: initialSelectionState,
- })
- ).to.be.false;
-
// Act
const nextSelectionState = selection.reducer(
initialSelectionState,
diff --git a/packages/desktop/src/services/DatabaseServiceElectron.ts b/packages/desktop/src/services/DatabaseServiceElectron.ts
index 1373da2b2..7cdea694e 100644
--- a/packages/desktop/src/services/DatabaseServiceElectron.ts
+++ b/packages/desktop/src/services/DatabaseServiceElectron.ts
@@ -5,26 +5,93 @@ import * as path from "path";
import duckdb from "duckdb";
import { DatabaseService } from "../../../core/services";
+import { Source } from "../../../core/entity/FileExplorerURL";
+import DataSourcePreparationError from "../../../core/errors/DataSourcePreparationError";
-export default class DatabaseServiceElectron implements DatabaseService {
+export default class DatabaseServiceElectron extends DatabaseService {
private database: duckdb.Database;
- private readonly existingDataSources = new Set();
constructor() {
+ super();
this.database = new duckdb.Database(":memory:");
}
- public async addDataSource(
- name: string,
- type: "csv" | "json" | "parquet",
- uri: File | string
- ): Promise {
+ /**
+ * Saves the result of the query to the designated location.
+ * May return a value if the location is not a physical location but rather
+ * a temporary database location (buffer)
+ */
+ public saveQuery(
+ destination: string,
+ sql: string,
+ format: "csv" | "json" | "parquet"
+ ): Promise {
+ const saveOptions = [`FORMAT '${format}'`];
+ if (format === "csv") {
+ saveOptions.push("HEADER");
+ }
+ return new Promise((resolve, reject) => {
+ this.database.run(
+ `COPY (${sql}) TO '${destination}.${format}' (${saveOptions.join(", ")});`,
+ (err: any, result: any) => {
+ if (err) {
+ reject(err.message);
+ } else {
+ resolve(result);
+ }
+ }
+ );
+ });
+ }
+
+ public query(sql: string): Promise {
+ return new Promise((resolve, reject) => {
+ try {
+ this.database.all(sql, (err: any, tableData: any) => {
+ if (err) {
+ reject(err.message);
+ } else {
+ resolve(tableData);
+ }
+ });
+ } catch (error) {
+ return Promise.reject(`${error}`);
+ }
+ });
+ }
+
+ public async reset(): Promise {
+ await this.close();
+ this.database = new duckdb.Database(":memory:");
+ }
+
+ public close(): Promise {
+ return new Promise((resolve, reject) => {
+ this.database.close((err) => {
+ if (err) {
+ reject(err.message);
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ protected async addDataSource(dataSource: Source): Promise {
+ const { name, type, uri } = dataSource;
if (this.existingDataSources.has(name)) {
return; // no-op
}
+ if (!type || !uri) {
+ throw new DataSourcePreparationError(
+ "Data source type and URI are missing",
+ dataSource.name
+ );
+ }
let source: string;
let tempLocation;
+ this.existingDataSources.add(name);
try {
if (typeof uri === "string") {
source = uri;
@@ -71,8 +138,9 @@ export default class DatabaseServiceElectron implements DatabaseService {
);
}
});
-
- this.existingDataSources.add(name);
+ } catch (err) {
+ await this.deleteDataSource(name);
+ throw new DataSourcePreparationError((err as Error).message, name);
} finally {
if (tempLocation) {
await fs.promises.unlink(tempLocation);
@@ -80,34 +148,14 @@ export default class DatabaseServiceElectron implements DatabaseService {
}
}
- /**
- * Saves the result of the query to the designated location.
- * May return a value if the location is not a physical location but rather
- * a temporary database location (buffer)
- */
- public saveQuery(destination: string, sql: string, format: string): Promise {
- return new Promise((resolve, reject) => {
- this.database.run(
- `COPY (${sql}) TO '${destination}.${format}' (FORMAT '${format}');`,
- (err: any, result: any) => {
- if (err) {
- reject(err.message);
- } else {
- resolve(result);
- }
- }
- );
- });
- }
-
- public query(sql: string): Promise {
+ protected async execute(sql: string): Promise {
return new Promise((resolve, reject) => {
try {
- this.database.all(sql, (err: any, tableData: any) => {
+ this.database.exec(sql, (err: any) => {
if (err) {
reject(err.message);
} else {
- resolve(tableData);
+ resolve();
}
});
} catch (error) {
@@ -115,21 +163,4 @@ export default class DatabaseServiceElectron implements DatabaseService {
}
});
}
-
- public async reset(): Promise {
- await this.close();
- this.database = new duckdb.Database(":memory:");
- }
-
- public close(): Promise {
- return new Promise((resolve, reject) => {
- this.database.close((err) => {
- if (err) {
- reject(err.message);
- } else {
- resolve();
- }
- });
- });
- }
}
diff --git a/packages/desktop/src/services/FileDownloadServiceElectron.ts b/packages/desktop/src/services/FileDownloadServiceElectron.ts
index cf6c23e03..46c25d015 100644
--- a/packages/desktop/src/services/FileDownloadServiceElectron.ts
+++ b/packages/desktop/src/services/FileDownloadServiceElectron.ts
@@ -317,9 +317,8 @@ export default class FileDownloadServiceElectron
});
}
- public async prepareHttpResourceForDownload(url: string, postBody: string): Promise {
- const responseAsJSON = await this.rawPost(url, postBody);
- return JSON.stringify(responseAsJSON);
+ public prepareHttpResourceForDownload(url: string, postBody: string): Promise {
+ return this.rawPost(url, postBody);
}
public cancelActiveRequest(downloadRequestId: string) {
diff --git a/packages/desktop/src/services/test/DatabaseServiceElectron.test.ts b/packages/desktop/src/services/test/DatabaseServiceElectron.test.ts
index b29aaba91..b18595a37 100644
--- a/packages/desktop/src/services/test/DatabaseServiceElectron.test.ts
+++ b/packages/desktop/src/services/test/DatabaseServiceElectron.test.ts
@@ -23,7 +23,7 @@ describe("DatabaseServiceElectron", () => {
await service.close();
});
- describe("addDataSource", () => {
+ describe("prepareDataSources", () => {
it("creates table from file of type csv", async () => {
// Arrange
const tempFileName = "test.csv";
@@ -31,7 +31,7 @@ describe("DatabaseServiceElectron", () => {
await fs.promises.writeFile(tempFile, "color\nblue\ngreen\norange");
// Act
- await service.addDataSource(tempFileName, "csv", tempFile);
+ await service.prepareDataSources([{ name: tempFileName, type: "csv", uri: tempFile }]);
// Assert
const result = await service.query(`SELECT * FROM "${tempFileName}"`);
@@ -48,7 +48,7 @@ describe("DatabaseServiceElectron", () => {
);
// Act
- await service.addDataSource(tempFileName, "json", tempFile);
+ await service.prepareDataSources([{ name: tempFileName, type: "json", uri: tempFile }]);
// Assert
const result = await service.query(`SELECT * FROM "${tempFileName}"`);
@@ -79,7 +79,7 @@ describe("DatabaseServiceElectron", () => {
// Assert
const fileStat = await fs.promises.stat(`${destination}.${format}`);
- expect(fileStat.size).to.equal(0);
+ expect(fileStat.size).to.equal(215);
});
});
});
diff --git a/packages/web/src/services/DatabaseServiceWeb.ts b/packages/web/src/services/DatabaseServiceWeb.ts
index d65b03782..b4b9d5e74 100644
--- a/packages/web/src/services/DatabaseServiceWeb.ts
+++ b/packages/web/src/services/DatabaseServiceWeb.ts
@@ -1,10 +1,11 @@
import * as duckdb from "@duckdb/duckdb-wasm";
import { DatabaseService } from "../../../core/services";
+import DataSourcePreparationError from "../../../core/errors/DataSourcePreparationError";
+import { Source } from "../../../core/entity/FileExplorerURL";
-export default class DatabaseServiceWeb implements DatabaseService {
+export default class DatabaseServiceWeb extends DatabaseService {
private database: duckdb.AsyncDuckDB | undefined;
- private readonly existingDataSources = new Set();
public async initialize(logLevel: duckdb.LogLevel = duckdb.LogLevel.INFO) {
const allBundles = duckdb.getJsDelivrBundles();
@@ -25,53 +26,6 @@ export default class DatabaseServiceWeb implements DatabaseService {
URL.revokeObjectURL(worker_url);
}
- public async addDataSource(
- name: string,
- type: "csv" | "json" | "parquet",
- uri: File | string
- ): Promise {
- if (!this.database) {
- throw new Error("Database failed to initialize");
- }
- if (!this.existingDataSources.has(name)) {
- if (uri instanceof File) {
- await this.database.registerFileHandle(
- name,
- uri,
- duckdb.DuckDBDataProtocol.BROWSER_FILEREADER,
- true
- );
- } else {
- const protocol = uri.startsWith("s3")
- ? duckdb.DuckDBDataProtocol.S3
- : duckdb.DuckDBDataProtocol.HTTP;
-
- await this.database.registerFileURL(name, uri, protocol, false);
- }
-
- const connection = await this.database.connect();
- try {
- if (type === "parquet") {
- await connection.query(
- `CREATE TABLE "${name}" AS FROM parquet_scan('${name}');`
- );
- } else if (type === "json") {
- await connection.query(
- `CREATE TABLE "${name}" AS FROM read_json_auto('${name}');`
- );
- } else {
- // Default to CSV
- await connection.query(
- `CREATE TABLE "${name}" AS FROM read_csv_auto('${name}', header=true);`
- );
- }
- this.existingDataSources.add(name);
- } finally {
- await connection.close();
- }
- }
- }
-
/**
* Saves the result of the query to the designated location.
* Returns an array representating the data from the query in the format designated
@@ -118,4 +72,69 @@ export default class DatabaseServiceWeb implements DatabaseService {
public async close(): Promise {
this.database?.detach();
}
+
+ public async addDataSource(dataSource: Source): Promise {
+ const { name, type, uri } = dataSource;
+ if (!this.database) {
+ throw new Error("Database failed to initialize");
+ }
+ if (this.existingDataSources.has(name)) {
+ return;
+ }
+ if (!type || !uri) {
+ throw new DataSourcePreparationError(
+ "Data source type and URI are missing",
+ dataSource.name
+ );
+ }
+
+ this.existingDataSources.add(name);
+ try {
+ if (uri instanceof File) {
+ await this.database.registerFileHandle(
+ name,
+ uri,
+ duckdb.DuckDBDataProtocol.BROWSER_FILEREADER,
+ true
+ );
+ } else if ((uri as any) instanceof String) {
+ const protocol = uri.startsWith("s3")
+ ? duckdb.DuckDBDataProtocol.S3
+ : duckdb.DuckDBDataProtocol.HTTP;
+
+ await this.database.registerFileURL(name, uri, protocol, false);
+ } else {
+ throw new Error(
+ `URI is of unexpected type, should be File instance or String: ${uri}`
+ );
+ }
+
+ if (type === "parquet") {
+ await this.execute(`CREATE TABLE "${name}" AS FROM parquet_scan('${name}');`);
+ } else if (type === "json") {
+ await this.execute(`CREATE TABLE "${name}" AS FROM read_json_auto('${name}');`);
+ } else {
+ // Default to CSV
+ await this.execute(
+ `CREATE TABLE "${name}" AS FROM read_csv_auto('${name}', header=true);`
+ );
+ }
+ } catch (err) {
+ await this.deleteDataSource(name);
+ throw new DataSourcePreparationError((err as Error).message, name);
+ }
+ }
+
+ protected async execute(sql: string): Promise {
+ if (!this.database) {
+ throw new Error("Database failed to initialize");
+ }
+
+ const connection = await this.database.connect();
+ try {
+ await connection.query(sql);
+ } finally {
+ await connection.close();
+ }
+ }
}
diff --git a/packages/web/src/services/FileDownloadServiceWeb.ts b/packages/web/src/services/FileDownloadServiceWeb.ts
index 33ab5df10..1921503b9 100644
--- a/packages/web/src/services/FileDownloadServiceWeb.ts
+++ b/packages/web/src/services/FileDownloadServiceWeb.ts
@@ -39,9 +39,8 @@ export default class FileDownloadServiceWeb extends HttpServiceBase implements F
}
}
- public async prepareHttpResourceForDownload(url: string, postBody: string): Promise {
- const responseAsJSON = await this.rawPost(url, postBody);
- return JSON.stringify(responseAsJSON);
+ public prepareHttpResourceForDownload(url: string, postBody: string): Promise {
+ return this.rawPost(url, postBody);
}
public getDefaultDownloadDirectory(): Promise {