-
-
-
- Copyright © 2019-2024 the{" "}
- Canadian Centre for Computational Genomics .{" "}
-
- bento_web (v{pkg.version}) is licensed under the{" "}
- LGPLv3 . The
- source code is available on GitHub .
-
+
+
+
));
export default SiteFooter;
diff --git a/src/components/SiteHeader.js b/src/components/SiteHeader.js
index 6debfb89c..66932600f 100644
--- a/src/components/SiteHeader.js
+++ b/src/components/SiteHeader.js
@@ -1,25 +1,26 @@
-import React, { useMemo, useState } from "react";
+import { memo, useCallback, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link } from "react-router-dom";
import { viewNotifications, useIsAuthenticated, usePerformSignOut, usePerformAuth, useAuthState } from "bento-auth-js";
import { Badge, Layout, Menu, Spin } from "antd";
import {
- BarChartOutlined,
- BellOutlined,
- DashboardOutlined,
- DotChartOutlined,
- FileTextOutlined,
- FolderOpenOutlined,
- LoadingOutlined,
- LoginOutlined,
- LogoutOutlined,
- PieChartOutlined,
- SettingOutlined,
- UserOutlined,
+ ApartmentOutlined,
+ BarChartOutlined,
+ BellOutlined,
+ DashboardOutlined,
+ DotChartOutlined,
+ FileTextOutlined,
+ FolderOpenOutlined,
+ LoadingOutlined,
+ LoginOutlined,
+ LogoutOutlined,
+ PieChartOutlined,
+ SettingOutlined,
+ UserOutlined,
} from "@ant-design/icons";
-import { BENTO_CBIOPORTAL_ENABLED, CUSTOM_HEADER } from "@/config";
+import { BENTO_CBIOPORTAL_ENABLED, BENTO_MONITORING_ENABLED, CUSTOM_HEADER } from "@/config";
import { useEverythingPermissions } from "@/hooks";
import { showNotificationDrawer } from "@/modules/notifications/actions";
import { useNotifications } from "@/modules/notifications/hooks";
@@ -28,211 +29,228 @@ import { matchingMenuKeys, transformMenuItem } from "@/utils/menu";
import OverviewSettingsControl from "./overview/OverviewSettingsControl";
import { useCanQueryAtLeastOneProjectOrDataset, useManagerPermissions } from "@/modules/authz/hooks";
-const LinkedLogo = React.memo(() => (
-
-
-
-
-
+const LinkedLogo = memo(() => (
+
+
+
+
+
));
-const CustomHeaderText = React.memo(() => (
-
{CUSTOM_HEADER}
+const CustomHeaderText = memo(() => (
+
{CUSTOM_HEADER}
));
const SiteHeader = () => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const performAuth = usePerformAuth();
+ const performAuth = usePerformAuth();
- const { permissions, isFetchingPermissions } = useEverythingPermissions();
- const canViewNotifications = useMemo(() => permissions.includes(viewNotifications), [permissions]);
+ const { permissions, isFetchingPermissions } = useEverythingPermissions();
+ const canViewNotifications = useMemo(() => permissions.includes(viewNotifications), [permissions]);
- const {
- hasPermission: canQueryData,
- hasAttempted: hasAttemptedQueryPermissions,
- } = useCanQueryAtLeastOneProjectOrDataset();
- const { permissions: managerPermissions, hasAttempted: hasAttemptedManagerPermissions } = useManagerPermissions();
+ const { hasPermission: canQueryData, hasAttempted: hasAttemptedQueryPermissions } =
+ useCanQueryAtLeastOneProjectOrDataset();
+ const { permissions: managerPermissions, hasAttempted: hasAttemptedManagerPermissions } = useManagerPermissions();
- const { isFetching: openIdConfigFetching } = useSelector((state) => state.openIdConfiguration);
+ const { isFetching: openIdConfigFetching } = useSelector((state) => state.openIdConfiguration);
- const { unreadItems: unreadNotifications } = useNotifications();
+ const { unreadItems: unreadNotifications } = useNotifications();
- const {
- idTokenContents,
- isHandingOffCodeForToken,
- hasAttempted: authHasAttempted,
- } = useAuthState();
- const isAuthenticated = useIsAuthenticated();
+ const { idTokenContents, isHandingOffCodeForToken } = useAuthState();
+ const isAuthenticated = useIsAuthenticated();
- const [modalVisible, setModalVisible] = useState(false);
+ const [modalVisible, setModalVisible] = useState(false);
- const toggleModalVisibility = () => {
- setModalVisible(!modalVisible);
- };
+ const toggleModalVisibility = useCallback(() => {
+ setModalVisible(!modalVisible);
+ }, [modalVisible]);
- const performSignOut = usePerformSignOut();
+ const performSignOut = usePerformSignOut();
- const menuItems = useMemo(
- () => [
+ const menuItems = useMemo(
+ () => [
+ {
+ url: "/overview",
+ icon:
,
+ text: "Overview",
+ key: "overview",
+ },
+ ...(canQueryData
+ ? [
{
- url: "/overview",
- icon:
,
- text: "Overview",
- key: "overview",
+ url: "/data/explorer",
+ icon:
,
+ text: "Explorer",
+ key: "explorer",
},
- ...(canQueryData ? [
- {
- url: "/data/explorer",
- icon:
,
- text: "Explorer",
- key: "explorer",
- },
- ] : []),
+ ]
+ : []),
+ {
+ url: "/genomes",
+ icon:
,
+ text: "Reference Genomes",
+ key: "genomes",
+ },
+ ...(managerPermissions.canManageAnything
+ ? [
+ {
+ key: "data-manager",
+ url: "/data/manager",
+ icon:
,
+ text: "Data Manager",
+ },
+ // For now, only show the services page to users who can manage something, since it's not useful for
+ // end users.
{
- url: "/genomes",
- icon:
,
- text: "Reference Genomes",
- key: "genomes",
+ key: "services",
+ url: "/services",
+ icon:
,
+ text: "Services",
},
- ...(managerPermissions.canManageAnything ? [
+ ]
+ : []),
+ ...(hasAttemptedQueryPermissions && hasAttemptedManagerPermissions
+ ? []
+ : [
+ {
+ key: "loading-admin",
+ text: (
+
+ }
+ />
+ ),
+ disabled: true,
+ },
+ ]),
+ // ---
+ ...(BENTO_CBIOPORTAL_ENABLED
+ ? [
+ {
+ url: "/cbioportal",
+ icon:
,
+ text: "cBioPortal",
+ key: "cbioportal",
+ },
+ ]
+ : []),
+ ...(BENTO_MONITORING_ENABLED && isAuthenticated
+ ? [
+ {
+ url: "/grafana",
+ icon:
,
+ text: "Grafana",
+ key: "grafana",
+ },
+ ]
+ : []),
+ {
+ style: { marginLeft: "auto" },
+ icon:
,
+ text:
Settings ,
+ onClick: toggleModalVisibility,
+ key: "settings",
+ },
+ {
+ disabled: isFetchingPermissions || !canViewNotifications || !isAuthenticated,
+ icon: (
+
+
+
+ ),
+ text: (
+
+ Notifications
+ {unreadNotifications.length > 0 ? ({unreadNotifications.length}) : null}
+
+ ),
+ onClick: () => dispatch(showNotificationDrawer()),
+ key: "notifications",
+ },
+ ...(isAuthenticated
+ ? [
+ {
+ key: "user-menu",
+ icon:
,
+ text: idTokenContents?.preferred_username,
+ children: [
{
- key: "data-manager",
- url: "/data/manager",
- icon:
,
- text: "Data Manager",
+ key: "user-profile",
+ url: "/profile",
+ icon:
,
+ text: "Profile",
},
- // For now, only show the services page to users who can manage something, since it's not useful for
- // end users.
{
- key: "services",
- url: "/services",
- icon:
,
- text: "Services",
+ key: "sign-out-link",
+ onClick: performSignOut,
+ icon:
,
+ text:
Sign Out ,
},
- ] : []),
- ...((hasAttemptedQueryPermissions && hasAttemptedManagerPermissions) ? [] : [{
- key: "loading-admin",
- text: (
-
- } />
- ),
- disabled: true,
- }]),
- // ---
- ...(BENTO_CBIOPORTAL_ENABLED
- ? [
- {
- url: "/cbioportal",
- icon:
,
- text: "cBioPortal",
- key: "cbioportal",
- },
- ]
- : []),
- {
- style: { marginLeft: "auto" },
- icon:
,
- text:
Settings ,
- onClick: toggleModalVisibility,
- key: "settings",
+ ],
},
+ ]
+ : [
{
- disabled: isFetchingPermissions || !canViewNotifications || !isAuthenticated,
- icon: (
-
-
-
- ),
- text: (
-
- Notifications
- {unreadNotifications.length > 0 ? ({unreadNotifications.length}) : null}
-
- ),
- onClick: () => dispatch(showNotificationDrawer()),
- key: "notifications",
+ key: "sign-in",
+ icon:
,
+ text: (
+
+ {openIdConfigFetching || isHandingOffCodeForToken ? "Loading..." : "Sign In"}
+
+ ),
+ onClick: () => performAuth(),
},
- ...(isAuthenticated
- ? [
- {
- key: "user-menu",
- icon:
,
- text: idTokenContents?.preferred_username,
- children: [
- {
- key: "user-profile",
- url: "/profile",
- icon:
,
- text: "Profile",
- },
- {
- key: "sign-out-link",
- onClick: performSignOut,
- icon:
,
- text:
Sign Out ,
- },
- ],
- },
- ]
- : [
- {
- key: "sign-in",
- icon:
,
- text: (
-
- {openIdConfigFetching || isHandingOffCodeForToken ? "Loading..." : "Sign In"}
-
- ),
- onClick: () => performAuth(),
- },
- ]),
- ],
- [
- authHasAttempted,
- canQueryData,
- canViewNotifications,
- hasAttemptedManagerPermissions,
- hasAttemptedQueryPermissions,
- idTokenContents,
- isAuthenticated,
- isHandingOffCodeForToken,
- isFetchingPermissions,
- managerPermissions,
- openIdConfigFetching,
- performAuth,
- performSignOut,
- unreadNotifications,
- ],
- );
-
- return (
- <>
-
-
- {CUSTOM_HEADER && }
- transformMenuItem(i))}
- />
-
-
- >
- );
+ ]),
+ ],
+ [
+ dispatch,
+ canQueryData,
+ canViewNotifications,
+ hasAttemptedManagerPermissions,
+ hasAttemptedQueryPermissions,
+ idTokenContents,
+ isAuthenticated,
+ isHandingOffCodeForToken,
+ isFetchingPermissions,
+ managerPermissions,
+ openIdConfigFetching,
+ toggleModalVisibility,
+ performAuth,
+ performSignOut,
+ unreadNotifications,
+ ],
+ );
+
+ return (
+ <>
+
+
+ {CUSTOM_HEADER && }
+ transformMenuItem(i))}
+ />
+
+
+ >
+ );
};
export default SiteHeader;
diff --git a/src/components/SitePageHeader.js b/src/components/SitePageHeader.js
index 61f12436c..a398b7c14 100644
--- a/src/components/SitePageHeader.js
+++ b/src/components/SitePageHeader.js
@@ -4,43 +4,43 @@ import PropTypes from "prop-types";
import { PageHeader } from "@ant-design/pro-components";
const styles = {
- pageHeader: {
- borderBottom: "1px solid rgb(232, 232, 232)",
- background: "white",
- padding: "12px 24px",
- },
- pageHeaderTitle: {
- fontSize: "1rem",
- lineHeight: "22px",
- margin: "5px 0",
- },
- pageHeaderSubtitle: {
- lineHeight: "23px",
- },
- tabBarHeader: {
- borderBottom: "none",
- paddingBottom: "0",
- },
+ pageHeader: {
+ borderBottom: "1px solid rgb(232, 232, 232)",
+ background: "white",
+ padding: "12px 24px",
+ },
+ pageHeaderTitle: {
+ fontSize: "1rem",
+ lineHeight: "22px",
+ margin: "5px 0",
+ },
+ pageHeaderSubtitle: {
+ lineHeight: "23px",
+ },
+ tabBarHeader: {
+ borderBottom: "none",
+ paddingBottom: "0",
+ },
};
const SitePageHeader = React.memo(({ title, subTitle, withTabBar, style, ...props }) => (
-
{title || ""} }
- subTitle={subTitle ? {title || ""}}
+ subTitle={subTitle ? {subTitle} : undefined}
+ style={{
+ ...styles.pageHeader,
+ ...(withTabBar ? styles.tabBarHeader : {}),
+ ...(style ?? {}),
+ }}
+ />
));
SitePageHeader.propTypes = {
- title: PropTypes.string,
- subTitle: PropTypes.string,
- withTabBar: PropTypes.bool,
- style: PropTypes.object,
+ title: PropTypes.string,
+ subTitle: PropTypes.string,
+ withTabBar: PropTypes.bool,
+ style: PropTypes.object,
};
export default SitePageHeader;
diff --git a/src/components/SitePageLoading.js b/src/components/SitePageLoading.js
index 99778bd11..7a5bce1de 100644
--- a/src/components/SitePageLoading.js
+++ b/src/components/SitePageLoading.js
@@ -1,13 +1,15 @@
import React from "react";
import SitePageHeader from "./SitePageHeader";
-import {Skeleton} from "antd";
+import { Skeleton } from "antd";
-const SitePageLoading = () => <>
+const SitePageLoading = () => (
+ <>
-
-
+
+
->;
+ >
+);
export default SitePageLoading;
diff --git a/src/components/UserProfileContent.js b/src/components/UserProfileContent.js
index bde084cbf..fc71b4227 100644
--- a/src/components/UserProfileContent.js
+++ b/src/components/UserProfileContent.js
@@ -1,34 +1,45 @@
import React from "react";
-import {useSelector} from "react-redux";
-import {Descriptions, Layout, Skeleton} from "antd";
+import { Descriptions, Layout, Skeleton } from "antd";
+
+import { useAuthState } from "bento-auth-js";
import SitePageHeader from "./SitePageHeader";
-import {LAYOUT_CONTENT_STYLE} from "../styles/layoutContent";
+import { LAYOUT_CONTENT_STYLE } from "@/styles/layoutContent";
-const DESCRIPTIONS_STYLE = {maxWidth: 600};
+const DESCRIPTIONS_STYLE = { maxWidth: 600 };
const UserProfileContent = () => {
- const {idTokenContents, isHandingOffCodeForToken, isRefreshingTokens} = useSelector(state => state.auth);
-
- const {preferred_username: username, email, iss, sub} = idTokenContents ?? {};
-
- return <>
-
-
-
- {idTokenContents ? (
-
- {username}
- {iss}
- {sub}
-
- ) : ((isHandingOffCodeForToken || isRefreshingTokens) ? (
-
- ) :
)}
-
-
- >;
+ const { idTokenContents, isHandingOffCodeForToken, isRefreshingTokens } = useAuthState();
+
+ const { preferred_username: username, email, iss, sub } = idTokenContents ?? {};
+
+ return (
+ <>
+
+
+
+ {idTokenContents ? (
+
+
+ {username}
+
+
+ {iss}
+
+
+ {sub}
+
+
+ ) : isHandingOffCodeForToken || isRefreshingTokens ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
};
export default UserProfileContent;
diff --git a/src/components/charts/ChartContainer.jsx b/src/components/charts/ChartContainer.jsx
index b0de6d56b..3be689de7 100644
--- a/src/components/charts/ChartContainer.jsx
+++ b/src/components/charts/ChartContainer.jsx
@@ -3,38 +3,38 @@ import { Empty } from "antd";
import PropTypes from "prop-types";
const TITLE_STYLE = {
- fontStyle: "italic",
- fontWeight: "500",
- margin: "0 0 10px 0",
+ fontStyle: "italic",
+ fontWeight: "500",
+ margin: "0 0 10px 0",
};
const ChartContainer = ({ title, children, empty }) => (
-
-
{title}
- {empty ? : children}
-
+
+
{title}
+ {empty ? : children}
+
);
ChartContainer.propTypes = {
- title: PropTypes.string.isRequired,
- children: PropTypes.node.isRequired,
- empty: PropTypes.bool,
+ title: PropTypes.string.isRequired,
+ children: PropTypes.node.isRequired,
+ empty: PropTypes.bool,
};
const NoDataComponent = ({ height }) => (
-
-
-
+
+
+
);
NoDataComponent.propTypes = {
- height: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
};
export default ChartContainer;
diff --git a/src/components/charts/Histogram.js b/src/components/charts/Histogram.js
index dc1d85271..6a56ef1e0 100644
--- a/src/components/charts/Histogram.js
+++ b/src/components/charts/Histogram.js
@@ -6,25 +6,25 @@ import ChartContainer from "./ChartContainer";
const transformAgeData = (data) => data && data.map(({ ageBin, count }) => ({ x: ageBin, y: count }));
const Histogram = ({ title = "Histogram", data = [], chartHeight = 300, unit = "" }) => {
- const transformedData = useMemo(() => transformAgeData(data), [data]);
+ const transformedData = useMemo(() => transformAgeData(data), [data]);
- return (
-
-
-
- );
+ return (
+
+
+
+ );
};
Histogram.propTypes = {
- title: PropTypes.string,
- data: PropTypes.arrayOf(
- PropTypes.shape({
- ageBin: PropTypes.string.isRequired,
- count: PropTypes.number.isRequired,
- }),
- ),
- chartHeight: PropTypes.number,
- unit: PropTypes.string,
+ title: PropTypes.string,
+ data: PropTypes.arrayOf(
+ PropTypes.shape({
+ ageBin: PropTypes.string.isRequired,
+ count: PropTypes.number.isRequired,
+ }),
+ ),
+ chartHeight: PropTypes.number,
+ unit: PropTypes.string,
};
export default Histogram;
diff --git a/src/components/charts/PieChart.js b/src/components/charts/PieChart.js
index 97b760d5c..0791c7218 100644
--- a/src/components/charts/PieChart.js
+++ b/src/components/charts/PieChart.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo} from "react";
+import React, { useCallback, useMemo } from "react";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
@@ -9,59 +9,61 @@ import { setAutoQueryPageTransition } from "@/modules/explorer/actions";
import ChartContainer from "./ChartContainer";
const PieChart = ({
- title,
- data = [],
- chartThreshold,
- chartHeight = 300,
- dataType,
- labelKey,
- clickable = false,
- sortData = true,
+ title,
+ data = [],
+ chartThreshold,
+ chartHeight = 300,
+ dataType,
+ labelKey,
+ clickable = false,
+ sortData = true,
}) => {
- const dispatch = useDispatch();
- const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
- const onAutoQueryTransition = useCallback((priorPageUrl, type, field, value) =>
- dispatch(setAutoQueryPageTransition(priorPageUrl, type, field, value)), [dispatch]);
+ const onAutoQueryTransition = useCallback(
+ (priorPageUrl, type, field, value) => dispatch(setAutoQueryPageTransition(priorPageUrl, type, field, value)),
+ [dispatch],
+ );
- const handleChartClick = useCallback(
- (pointData) => {
- if (dataType && labelKey && pointData.name !== "Other") {
- onAutoQueryTransition(window.location.href, dataType, labelKey, pointData.name);
- navigate("/data/explorer/search");
- }
- },
- [onAutoQueryTransition, dataType, labelKey, navigate],
- );
+ const handleChartClick = useCallback(
+ (pointData) => {
+ if (dataType && labelKey && pointData.name !== "Other") {
+ onAutoQueryTransition(window.location.href, dataType, labelKey, pointData.name);
+ navigate("/data/explorer/search");
+ }
+ },
+ [onAutoQueryTransition, dataType, labelKey, navigate],
+ );
- const pieChartData = useMemo(() => data.map(({ name, value }) => ({ x: name, y: value })), [data]);
- return (
-
-
-
- );
+ const pieChartData = useMemo(() => data.map(({ name, value }) => ({ x: name, y: value })), [data]);
+ return (
+
+
+
+ );
};
PieChart.propTypes = {
- title: PropTypes.string.isRequired,
- data: PropTypes.arrayOf(
- PropTypes.shape({
- name: PropTypes.string,
- value: PropTypes.number,
- }),
- ).isRequired,
- chartThreshold: PropTypes.number,
- chartHeight: PropTypes.number,
- dataType: PropTypes.string,
- labelKey: PropTypes.string,
- clickable: PropTypes.bool,
- sortData: PropTypes.bool,
+ title: PropTypes.string.isRequired,
+ data: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string,
+ value: PropTypes.number,
+ }),
+ ).isRequired,
+ chartThreshold: PropTypes.number,
+ chartHeight: PropTypes.number,
+ dataType: PropTypes.string,
+ labelKey: PropTypes.string,
+ clickable: PropTypes.bool,
+ sortData: PropTypes.bool,
};
export default PieChart;
diff --git a/src/components/common/BooleanYesNo.js b/src/components/common/BooleanYesNo.js
deleted file mode 100644
index 53a499676..000000000
--- a/src/components/common/BooleanYesNo.js
+++ /dev/null
@@ -1,17 +0,0 @@
-import React from "react";
-import PropTypes from "prop-types";
-import { CheckCircleFilled, CloseCircleFilled } from "@ant-design/icons";
-
-const BooleanYesNo = ({ value }) => {
- if (value) {
- return
Yes;
- } else {
- return
No;
- }
-};
-
-BooleanYesNo.propTypes = {
- value: PropTypes.bool,
-};
-
-export default BooleanYesNo;
diff --git a/src/components/common/BooleanYesNo.tsx b/src/components/common/BooleanYesNo.tsx
new file mode 100644
index 000000000..16dbdbd9b
--- /dev/null
+++ b/src/components/common/BooleanYesNo.tsx
@@ -0,0 +1,23 @@
+import { CheckCircleFilled, CloseCircleFilled } from "@ant-design/icons";
+
+type BooleanYesNoProps = {
+ value?: boolean;
+};
+
+const BooleanYesNo = ({ value }: BooleanYesNoProps) => {
+ if (value) {
+ return (
+
+ Yes
+
+ );
+ } else {
+ return (
+
+ No
+
+ );
+ }
+};
+
+export default BooleanYesNo;
diff --git a/src/components/common/DownloadButton.js b/src/components/common/DownloadButton.js
deleted file mode 100644
index c1b88ae22..000000000
--- a/src/components/common/DownloadButton.js
+++ /dev/null
@@ -1,108 +0,0 @@
-import React, { useCallback } from "react";
-import PropTypes from "prop-types";
-
-import { Button } from "antd";
-import { DownloadOutlined } from "@ant-design/icons";
-
-import { useAccessToken } from "bento-auth-js";
-
-import { AUDIO_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS, VIDEO_FILE_EXTENSIONS } from "../display/FileDisplay";
-
-const BROWSER_RENDERED_EXTENSIONS = [
- ".pdf",
- ".txt",
- ...AUDIO_FILE_EXTENSIONS,
- ...IMAGE_FILE_EXTENSIONS,
- ...VIDEO_FILE_EXTENSIONS,
-];
-
-const FORM_ALLOWED_EXTRA_KEYS = new Set([
- "path", // Used by RunOutputs to download specific WES run artifacts
-]);
-
-const DownloadButton = ({
- disabled,
- uri,
- fileName,
- extraFormData,
- children,
- size,
- type,
- onClick: propsOnClick,
- ...props
-}) => {
- const accessToken = useAccessToken();
-
- const onClick = useCallback((e) => {
- if (!uri) return;
-
- const form = document.createElement("form");
- if (fileName && BROWSER_RENDERED_EXTENSIONS.find((ext) => fileName.toLowerCase().endsWith(ext))) {
- // In Firefox, if we open, e.g., a PDF; it'll open in the PDF viewer instead of downloading.
- // Here, we force it to open in a new tab if it's render-able by the browser (although Chrome will actually
- // download the PDF file, so it'll flash a new tab - this is a compromise solution for viewable file types.)
- form.target = "_blank";
- }
- form.method = "post";
- form.action = uri;
-
- const tokenInput = document.createElement("input");
- tokenInput.setAttribute("type", "hidden");
- tokenInput.setAttribute("name", "token");
- tokenInput.setAttribute("value", accessToken);
- form.appendChild(tokenInput);
-
- Object.entries(extraFormData ?? {})
- .filter(([k, _]) => FORM_ALLOWED_EXTRA_KEYS.has(k)) // Only allowed extra keys
- .forEach(([k, v]) => {
- const extraInput = document.createElement("input");
- extraInput.setAttribute("type", "hidden");
- extraInput.setAttribute("name", k);
- extraInput.setAttribute("value", v.toString());
- form.appendChild(extraInput);
- });
-
- document.body.appendChild(form);
-
- try {
- form.submit();
- } finally {
- // Even if submit raises for some reason, we still need to clean this up; it has a token in it!
- document.body.removeChild(form);
-
- // Call the props-passed onClick event handler after hijacking the event and doing our own thing
- if (propsOnClick) propsOnClick(e);
- }
- }, [uri, accessToken, propsOnClick]);
-
- return (
-
}
- size={size}
- type={type}
- disabled={disabled}
- onClick={onClick}
- {...props}>
- {children === undefined ? "Download" : children}
-
- );
-};
-
-DownloadButton.defaultProps = {
- disabled: false,
- size: "default",
- type: "default",
-};
-
-DownloadButton.propTypes = {
- disabled: PropTypes.bool,
- uri: PropTypes.string,
- fileName: PropTypes.string,
- extraFormData: PropTypes.object,
- children: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
- size: PropTypes.oneOf(["small", "default", "large"]),
- type: PropTypes.oneOf(["primary", "ghost", "dashed", "danger", "link", "default"]),
- onClick: PropTypes.func,
-};
-
-export default DownloadButton;
diff --git a/src/components/common/DownloadButton.tsx b/src/components/common/DownloadButton.tsx
new file mode 100644
index 000000000..ded99b06b
--- /dev/null
+++ b/src/components/common/DownloadButton.tsx
@@ -0,0 +1,90 @@
+import { type MouseEventHandler, useCallback } from "react";
+
+import { Button, type ButtonProps } from "antd";
+import { DownloadOutlined } from "@ant-design/icons";
+
+import { useAccessToken } from "bento-auth-js";
+
+import { AUDIO_FILE_EXTENSIONS, IMAGE_FILE_EXTENSIONS, VIDEO_FILE_EXTENSIONS } from "../display/FileDisplay";
+
+const BROWSER_RENDERED_EXTENSIONS = [
+ ".pdf",
+ ".txt",
+ ...AUDIO_FILE_EXTENSIONS,
+ ...IMAGE_FILE_EXTENSIONS,
+ ...VIDEO_FILE_EXTENSIONS,
+];
+
+const FORM_ALLOWED_EXTRA_KEYS = new Set([
+ "path", // Used by RunOutputs to download specific WES run artifacts
+]);
+
+interface DownloadButtonProps extends ButtonProps {
+ uri: string;
+ fileName: string;
+ extraFormData?: Record
;
+}
+
+const DownloadButton = ({
+ uri,
+ fileName,
+ extraFormData,
+ children,
+ onClick: propsOnClick,
+ ...props
+}: DownloadButtonProps) => {
+ const accessToken = useAccessToken();
+
+ const onClick = useCallback>(
+ (e) => {
+ if (!uri) return;
+
+ const form = document.createElement("form");
+ if (fileName && BROWSER_RENDERED_EXTENSIONS.find((ext) => fileName.toLowerCase().endsWith(ext))) {
+ // In Firefox, if we open, e.g., a PDF; it'll open in the PDF viewer instead of downloading.
+ // Here, we force it to open in a new tab if it's render-able by the browser (although Chrome will actually
+ // download the PDF file, so it'll flash a new tab - this is a compromise solution for viewable file types.)
+ form.target = "_blank";
+ }
+ form.method = "post";
+ form.action = uri;
+
+ const tokenInput = document.createElement("input");
+ tokenInput.setAttribute("type", "hidden");
+ tokenInput.setAttribute("name", "token");
+ if (accessToken) tokenInput.setAttribute("value", accessToken);
+ form.appendChild(tokenInput);
+
+ Object.entries(extraFormData ?? {})
+ .filter(([k, _]) => FORM_ALLOWED_EXTRA_KEYS.has(k)) // Only allowed extra keys
+ .forEach(([k, v]) => {
+ const extraInput = document.createElement("input");
+ extraInput.setAttribute("type", "hidden");
+ extraInput.setAttribute("name", k);
+ extraInput.setAttribute("value", v.toString());
+ form.appendChild(extraInput);
+ });
+
+ document.body.appendChild(form);
+
+ try {
+ form.submit();
+ } finally {
+ // Even if submit raises for some reason, we still need to clean this up; it has a token in it!
+ document.body.removeChild(form);
+
+ // Call the props-passed onClick event handler after hijacking the event and doing our own thing
+ if (propsOnClick) propsOnClick(e);
+ }
+ },
+ [fileName, uri, accessToken, extraFormData, propsOnClick],
+ );
+
+ return (
+ } onClick={onClick} {...props}>
+ {children === undefined ? "Download" : children}
+
+ );
+};
+
+export default DownloadButton;
diff --git a/src/components/common/ErrorText.tsx b/src/components/common/ErrorText.tsx
new file mode 100644
index 000000000..cae442e23
--- /dev/null
+++ b/src/components/common/ErrorText.tsx
@@ -0,0 +1,15 @@
+import { type CSSProperties, type ReactNode, memo } from "react";
+import { COLOR_ANTD_RED_6 } from "@/constants";
+
+export type ErrorTextProps = {
+ children?: ReactNode;
+ style?: CSSProperties;
+};
+
+const ErrorText = memo(({ children, style, ...rest }: ErrorTextProps) => (
+
+ {children}
+
+));
+
+export default ErrorText;
diff --git a/src/components/common/JsonView.js b/src/components/common/JsonView.js
deleted file mode 100644
index d18c77112..000000000
--- a/src/components/common/JsonView.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from "react";
-import ReactJson from "react18-json-view";
-import PropTypes from "prop-types";
-
-const JsonView = ({ src, collapsed, collapseObjectsAfterLength }) => (
-
-);
-
-JsonView.defaultProps = {
- collapsed: 1,
-};
-
-JsonView.propTypes = {
- src: PropTypes.any,
- collapsed: PropTypes.oneOfType([PropTypes.number, PropTypes.bool, PropTypes.func]),
- collapseObjectsAfterLength: PropTypes.number,
-};
-
-export default JsonView;
diff --git a/src/components/common/JsonView.tsx b/src/components/common/JsonView.tsx
new file mode 100644
index 000000000..8aebc3750
--- /dev/null
+++ b/src/components/common/JsonView.tsx
@@ -0,0 +1,21 @@
+import React from "react";
+import ReactJson from "react18-json-view";
+import type { Collapsed } from "react18-json-view/dist/types";
+import type { JSONType } from "ajv";
+
+type JsonViewProps = {
+ src: JSONType | JSONType[] | Record;
+ collapsed?: Collapsed;
+ collapseObjectsAfterLength?: number;
+};
+
+const JsonView = ({ src, collapsed, collapseObjectsAfterLength }: JsonViewProps) => (
+
+);
+
+export default JsonView;
diff --git a/src/components/common/MonospaceText.js b/src/components/common/MonospaceText.js
deleted file mode 100644
index 7a8b33e29..000000000
--- a/src/components/common/MonospaceText.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import React, { memo } from "react";
-import PropTypes from "prop-types";
-
-const MonospaceText = memo(({ children, style }) => (
- {children}
-));
-MonospaceText.propTypes = {
- children: PropTypes.node,
- style: PropTypes.object,
-};
-
-export default MonospaceText;
diff --git a/src/components/common/MonospaceText.tsx b/src/components/common/MonospaceText.tsx
new file mode 100644
index 000000000..0d95531c7
--- /dev/null
+++ b/src/components/common/MonospaceText.tsx
@@ -0,0 +1,14 @@
+import { type CSSProperties, type ReactNode, memo } from "react";
+
+export type MonospaceTextProps = {
+ children?: ReactNode;
+ style?: CSSProperties;
+};
+
+const MonospaceText = memo(({ children, style, ...rest }: MonospaceTextProps) => (
+
+ {children}
+
+));
+
+export default MonospaceText;
diff --git a/src/components/datasets/Dataset.js b/src/components/datasets/Dataset.js
index a20463cb6..e010e0afc 100644
--- a/src/components/datasets/Dataset.js
+++ b/src/components/datasets/Dataset.js
@@ -1,324 +1,345 @@
-import React, {Component} from "react";
-import {connect} from "react-redux";
+import React, { Component } from "react";
+import { connect } from "react-redux";
import PropTypes from "prop-types";
-import {Button, Card, Col, Divider, Empty, Modal, Row, Typography} from "antd";
+import { Button, Card, Col, Divider, Empty, Modal, Row, Typography } from "antd";
import DataUseDisplay from "../DataUseDisplay";
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
import {
- addDatasetLinkedFieldSetIfPossible,
- deleteProjectDatasetIfPossible,
- deleteDatasetLinkedFieldSetIfPossible,
+ addDatasetLinkedFieldSetIfPossible,
+ deleteProjectDatasetIfPossible,
+ deleteDatasetLinkedFieldSetIfPossible,
} from "../../modules/metadata/actions";
import {
- fetchDatasetDataTypesSummariesIfPossible,
- fetchDatasetSummariesIfPossible,
+ fetchDatasetDataTypesSummariesIfPossible,
+ fetchDatasetSummariesIfPossible,
} from "../../modules/datasets/actions";
-import {INITIAL_DATA_USE_VALUE} from "../../duo";
-import {simpleDeepCopy, nop} from "../../utils/misc";
+import { INITIAL_DATA_USE_VALUE } from "../../duo";
+import { simpleDeepCopy, nop } from "../../utils/misc";
import LinkedFieldSetTable from "./linked_field_set/LinkedFieldSetTable";
import LinkedFieldSetModal from "./linked_field_set/LinkedFieldSetModal";
-import {FORM_MODE_ADD, FORM_MODE_EDIT} from "../../constants";
-import {datasetPropTypesShape, projectPropTypesShape} from "../../propTypes";
+import { FORM_MODE_ADD, FORM_MODE_EDIT } from "../../constants";
+import { datasetPropTypesShape, projectPropTypesShape } from "../../propTypes";
import DatasetOverview from "./DatasetOverview";
import DatasetDataTypes from "./DatasetDataTypes";
-
const DATASET_CARD_TABS = [
- {key: "overview", tab: "Overview"},
- {key: "data_types", tab: "Data Types"},
- {key: "linked_field_sets", tab: "Linked Field Sets"},
- {key: "data_use", tab: "Consent Codes and Data Use"},
+ { key: "overview", tab: "Overview" },
+ { key: "data_types", tab: "Data Types" },
+ { key: "linked_field_sets", tab: "Linked Field Sets" },
+ { key: "data_use", tab: "Consent Codes and Data Use" },
];
-
const DEFAULT_BIOSAMPLE_LFS = {
- name: "BIO_SAMPLE_LFS",
- fields: {
- variant: [
- "calls",
- "[item]",
- "sample_id",
- ],
- experiment: [
- "biosample",
- ],
- phenopacket: [
- "biosamples",
- "[item]",
- "id",
- ],
- },
+ name: "BIO_SAMPLE_LFS",
+ fields: {
+ variant: ["calls", "[item]", "sample_id"],
+ experiment: ["biosample"],
+ phenopacket: ["biosamples", "[item]", "id"],
+ },
};
-
class Dataset extends Component {
- // TODO: Editing
+ // TODO: Editing
- static getDerivedStateFromProps(nextProps) {
- if ("value" in nextProps) {
- return {...(nextProps.value || {})}; // TODO: For editing
- }
- return null;
+ static getDerivedStateFromProps(nextProps) {
+ if ("value" in nextProps) {
+ return { ...(nextProps.value || {}) }; // TODO: For editing
}
-
- constructor(props) {
- super(props);
-
- const value = props.value || {};
- this.state = { // TODO: For editing
- identifier: value.identifier || null,
- title: value.title || "",
- description: value.description || "",
- contact_info: value.contact_info || "",
- data_use: simpleDeepCopy(value.data_use || INITIAL_DATA_USE_VALUE),
- linked_field_sets: value.linked_field_sets || [],
-
- fieldSetAdditionModalVisible: false,
-
- fieldSetEditModalVisible: false,
- selectedLinkedFieldSet: {
- data: null,
- index: null,
- },
-
- selectedTab: "overview",
- };
-
- this.handleFieldSetDeletion = this.handleFieldSetDeletion.bind(this);
+ return null;
+ }
+
+ constructor(props) {
+ super(props);
+
+ const value = props.value || {};
+ this.state = {
+ // TODO: For editing
+ identifier: value.identifier || null,
+ title: value.title || "",
+ description: value.description || "",
+ contact_info: value.contact_info || "",
+ data_use: simpleDeepCopy(value.data_use || INITIAL_DATA_USE_VALUE),
+ linked_field_sets: value.linked_field_sets || [],
+
+ fieldSetAdditionModalVisible: false,
+
+ fieldSetEditModalVisible: false,
+ selectedLinkedFieldSet: {
+ data: null,
+ index: null,
+ },
+
+ selectedTab: "overview",
+ };
+
+ this.handleFieldSetDeletion = this.handleFieldSetDeletion.bind(this);
+ }
+
+ componentDidMount() {
+ const { identifier } = this.state;
+ if (identifier) {
+ this.props.fetchDatasetSummary(identifier);
+ this.props.fetchDatasetDataTypesSummary(identifier);
}
-
-
- componentDidMount() {
- const {identifier} = this.state;
- if (identifier) {
- this.props.fetchDatasetSummary(identifier);
- this.props.fetchDatasetDataTypesSummary(identifier);
- }
- }
-
-
- handleFieldSetDeletion(fieldSet, index) {
- const deleteModal = Modal.confirm({
- title: `Are you sure you want to delete the "${fieldSet.name}" linked field set?`,
- content: <>
-
- Doing so will mean users will no longer be able to link
- search results across the data types specified via the following
- linked fields:
-
-
- >,
- width: 720,
- autoFocusButton: "cancel",
- okText: "Delete",
- okType: "danger",
- maskClosable: true,
- onOk: async () => {
- deleteModal.update({okButtonProps: {loading: true}});
- await this.props.deleteLinkedFieldSet(this.state, fieldSet, index);
- deleteModal.update({okButtonProps: {loading: false}});
- },
- });
- }
-
- render() {
- const {identifier, title, selectedTab} = this.state;
-
- const isPrivate = this.props.mode === "private";
-
- const defaultBiosampleLFSDisabled = this.state.linked_field_sets.length !== 0;
-
- const tabContents = {
- overview: ,
- data_types: ,
- linked_field_sets: (
- <>
-
- Linked Field Sets
- {isPrivate ? (
-
- }
- style={{verticalAlign: "top"}}
- type="primary"
- onClick={() => this.setState({fieldSetAdditionModalVisible: true})}>
- Add Linked Field Set
-
- }
- style={{verticalAlign: "top"}}
- type="default"
- disabled={defaultBiosampleLFSDisabled}
- onClick={() => this.props.addLinkedFieldSet(this.state, DEFAULT_BIOSAMPLE_LFS)}>
- Default Biosample Field Set
-
-
- ) : null}
-
-
- Linked Field Sets group common fields (i.e. fields that share the same “value
- space”) between multiple data types. For example, these sets can be used to tell the
- data exploration system that Phenopacket biosample identifiers are the same as variant call
- sample identifiers, and so variant calls with an identifier of “sample1” come from a
- biosample with identifier “sample1”.
-
-
- A word of caution: the more fields added to a Linked Field Set, the longer it takes to search
- the dataset in question.
-
- {(this.state.linked_field_sets || {}).length === 0
- ? (
- <>
-
-
- {isPrivate ? (
- }
- type="primary"
- onClick={() =>
- this.setState({fieldSetAdditionModalVisible: true})}>
- Add Field Link Set
-
- ) : null}
-
- >
- ) : (
-
- {(this.state.linked_field_sets || []).map((fieldSet, i) => (
-
- this.setState({
- fieldSetEditModalVisible: true,
- selectedLinkedFieldSet: {
- data: fieldSet,
- index: i,
- },
- })}>
- {" "}
- Manage Fields
- ,
- this.handleFieldSetDeletion(fieldSet, i)}>
- {" "}
- Delete Set
- ,
- ] : []}>
-
-
-
- ))}
-
- )}
- >
- ),
- data_use: ,
- };
-
- const handleDelete = () => {
- const deleteModal = Modal.confirm({
- title: `Are you sure you want to delete the "${this.state.title}" dataset?`,
- content: <>
-
- All data contained in the dataset will be deleted permanently, and the
- dataset will no longer be available for exploration.
-
- >,
- width: 572,
- autoFocusButton: "cancel",
- okText: "Delete",
- okType: "danger",
- maskClosable: true,
- onOk: async () => {
- deleteModal.update({okButtonProps: {loading: true}});
- await this.props.deleteProjectDataset(this.state);
- deleteModal.update({okButtonProps: {loading: false}});
- },
- });
- };
-
- return (
- {title} {identifier} }
- tabList={DATASET_CARD_TABS}
- activeTabKey={selectedTab}
- tabProps={{ size: "middle" }}
- onTabChange={t => this.setState({selectedTab: t})}
- extra={
- isPrivate ? <>
- }
- style={{marginRight: "8px"}}
- onClick={() => (this.props.onEdit || nop)()}>Edit
- } onClick={handleDelete}>Delete
- {/* TODO: Share button (vFuture) */}
- > : null
- }
+ }
+
+ handleFieldSetDeletion(fieldSet, index) {
+ const deleteModal = Modal.confirm({
+ title: `Are you sure you want to delete the "${fieldSet.name}" linked field set?`,
+ content: (
+ <>
+
+ Doing so will mean users will no longer be able to link search results across the data
+ types specified via the following linked fields:
+
+
+ >
+ ),
+ width: 720,
+ autoFocusButton: "cancel",
+ okText: "Delete",
+ okType: "danger",
+ maskClosable: true,
+ onOk: async () => {
+ deleteModal.update({ okButtonProps: { loading: true } });
+ await this.props.deleteLinkedFieldSet(this.state, fieldSet, index);
+ deleteModal.update({ okButtonProps: { loading: false } });
+ },
+ });
+ }
+
+ render() {
+ const { identifier, title, selectedTab } = this.state;
+
+ const isPrivate = this.props.mode === "private";
+
+ const defaultBiosampleLFSDisabled = this.state.linked_field_sets.length !== 0;
+
+ const tabContents = {
+ overview: ,
+ data_types: ,
+ linked_field_sets: (
+ <>
+
+ Linked Field Sets
+ {isPrivate ? (
+
+ }
+ style={{ verticalAlign: "top" }}
+ type="primary"
+ onClick={() => this.setState({ fieldSetAdditionModalVisible: true })}
+ >
+ Add Linked Field Set
+
+ }
+ style={{ verticalAlign: "top" }}
+ type="default"
+ disabled={defaultBiosampleLFSDisabled}
+ onClick={() => this.props.addLinkedFieldSet(this.state, DEFAULT_BIOSAMPLE_LFS)}
+ >
+ Default Biosample Field Set
+
+
+ ) : null}
+
+
+ Linked Field Sets group common fields (i.e. fields that share the same “value space”) between
+ multiple data types. For example, these sets can be used to tell the data exploration system that
+ Phenopacket biosample identifiers are the same as variant call sample identifiers, and so variant calls with
+ an identifier of “sample1” come from a biosample with identifier “sample1”.
+
+
+ A word of caution: the more fields added to a Linked Field Set, the longer it takes to search the dataset in
+ question.
+
+ {(this.state.linked_field_sets || {}).length === 0 ? (
+ <>
+
+
+ {isPrivate ? (
+ }
+ type="primary"
+ onClick={() => this.setState({ fieldSetAdditionModalVisible: true })}
+ >
+ Add Field Link Set
+
+ ) : null}
+
+ >
+ ) : (
+
+ {(this.state.linked_field_sets || []).map((fieldSet, i) => (
+
+
+ this.setState({
+ fieldSetEditModalVisible: true,
+ selectedLinkedFieldSet: {
+ data: fieldSet,
+ index: i,
+ },
+ })
+ }
+ >
+ Manage Fields
+ ,
+ this.handleFieldSetDeletion(fieldSet, i)}>
+ Delete Set
+ ,
+ ]
+ : []
+ }
+ >
+
+
+
+ ))}
+
+ )}
+ >
+ ),
+ data_use: ,
+ };
+
+ const handleDelete = () => {
+ const deleteModal = Modal.confirm({
+ title: `Are you sure you want to delete the "${this.state.title}" dataset?`,
+ content: (
+ <>
+
+ All data contained in the dataset will be deleted permanently, and the dataset will no longer be available
+ for exploration.
+
+ >
+ ),
+ width: 572,
+ autoFocusButton: "cancel",
+ okText: "Delete",
+ okType: "danger",
+ maskClosable: true,
+ onOk: async () => {
+ deleteModal.update({ okButtonProps: { loading: true } });
+ await this.props.deleteProjectDataset(this.state);
+ deleteModal.update({ okButtonProps: { loading: false } });
+ },
+ });
+ };
+
+ return (
+
+ {title}{" "}
+
- {isPrivate ? <>
- this.setState({fieldSetAdditionModalVisible: false})}
- onCancel={() => this.setState({fieldSetAdditionModalVisible: false})} />
-
- this.setState({fieldSetEditModalVisible: false})}
- onCancel={() => this.setState({fieldSetEditModalVisible: false})} />
- > : null}
- {tabContents[selectedTab]}
-
- );
- }
+ {identifier}
+
+
+ }
+ tabList={DATASET_CARD_TABS}
+ activeTabKey={selectedTab}
+ tabProps={{ size: "middle" }}
+ onTabChange={(t) => this.setState({ selectedTab: t })}
+ extra={
+ isPrivate ? (
+ <>
+ }
+ style={{ marginRight: "8px" }}
+ onClick={() => (this.props.onEdit || nop)()}
+ >
+ Edit
+
+ } onClick={handleDelete}>
+ Delete
+
+ {/* TODO: Share button (vFuture) */}
+ >
+ ) : null
+ }
+ >
+ {isPrivate ? (
+ <>
+ this.setState({ fieldSetAdditionModalVisible: false })}
+ onCancel={() => this.setState({ fieldSetAdditionModalVisible: false })}
+ />
+
+ this.setState({ fieldSetEditModalVisible: false })}
+ onCancel={() => this.setState({ fieldSetEditModalVisible: false })}
+ />
+ >
+ ) : null}
+ {tabContents[selectedTab]}
+
+ );
+ }
}
Dataset.propTypes = {
- // Is the dataset being viewed in the context of the data manager or via discovery?
- mode: PropTypes.oneOf(["public", "private"]),
+ // Is the dataset being viewed in the context of the data manager or via discovery?
+ mode: PropTypes.oneOf(["public", "private"]),
- project: projectPropTypesShape,
+ project: projectPropTypesShape,
- value: datasetPropTypesShape,
+ value: datasetPropTypesShape,
- onEdit: PropTypes.func,
+ onEdit: PropTypes.func,
- addLinkedFieldSet: PropTypes.func,
- deleteProjectDataset: PropTypes.func,
- deleteLinkedFieldSet: PropTypes.func,
+ addLinkedFieldSet: PropTypes.func,
+ deleteProjectDataset: PropTypes.func,
+ deleteLinkedFieldSet: PropTypes.func,
- fetchDatasetSummary: PropTypes.func,
- fetchDatasetDataTypesSummary: PropTypes.func,
+ fetchDatasetSummary: PropTypes.func,
+ fetchDatasetDataTypesSummary: PropTypes.func,
};
-const mapStateToProps = state => ({
- isSavingDataset: state.projects.isSavingDataset,
- isDeletingDataset: state.projects.isDeletingDataset,
+const mapStateToProps = (state) => ({
+ isSavingDataset: state.projects.isSavingDataset,
+ isDeletingDataset: state.projects.isDeletingDataset,
});
const mapDispatchToProps = (dispatch, ownProps) => ({
- addLinkedFieldSet: (dataset, newLinkedFieldSet, onSuccess) =>
- dispatch(addDatasetLinkedFieldSetIfPossible(dataset, newLinkedFieldSet, onSuccess)),
- deleteProjectDataset: dataset => dispatch(deleteProjectDatasetIfPossible(ownProps.project, dataset)),
- deleteLinkedFieldSet: (dataset, linkedFieldSet, linkedFieldSetIndex) =>
- dispatch(deleteDatasetLinkedFieldSetIfPossible(dataset, linkedFieldSet, linkedFieldSetIndex)),
- fetchDatasetSummary: (datasetId) => dispatch(fetchDatasetSummariesIfPossible(datasetId)),
- fetchDatasetDataTypesSummary: (datasetId) => dispatch(fetchDatasetDataTypesSummariesIfPossible(datasetId)),
+ addLinkedFieldSet: (dataset, newLinkedFieldSet, onSuccess) =>
+ dispatch(addDatasetLinkedFieldSetIfPossible(dataset, newLinkedFieldSet, onSuccess)),
+ deleteProjectDataset: (dataset) => dispatch(deleteProjectDatasetIfPossible(ownProps.project, dataset)),
+ deleteLinkedFieldSet: (dataset, linkedFieldSet, linkedFieldSetIndex) =>
+ dispatch(deleteDatasetLinkedFieldSetIfPossible(dataset, linkedFieldSet, linkedFieldSetIndex)),
+ fetchDatasetSummary: (datasetId) => dispatch(fetchDatasetSummariesIfPossible(datasetId)),
+ fetchDatasetDataTypesSummary: (datasetId) => dispatch(fetchDatasetDataTypesSummariesIfPossible(datasetId)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Dataset);
diff --git a/src/components/datasets/DatasetDataTypes.js b/src/components/datasets/DatasetDataTypes.js
index 00324f799..06b821adf 100644
--- a/src/components/datasets/DatasetDataTypes.js
+++ b/src/components/datasets/DatasetDataTypes.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo, useState} from "react";
+import React, { useCallback, useMemo, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import PropTypes from "prop-types";
@@ -17,143 +17,145 @@ import DataTypeSummaryModal from "./datatype/DataTypeSummaryModal";
const NA_TEXT = N/A ;
const DatasetDataTypes = React.memo(({ isPrivate, project, dataset }) => {
- const dispatch = useDispatch();
- const datasetDataTypes = useSelector((state) => state.datasetDataTypes.itemsByID[dataset.identifier]);
- const datasetDataTypeValues = useMemo(() => Object.values(datasetDataTypes?.itemsByID ?? {}), [datasetDataTypes]);
- const datasetSummaries = useSelector((state) => state.datasetSummaries.itemsByID[dataset.identifier]);
-
- const isFetchingDataset = datasetDataTypes?.isFetching ?? false;
-
- const { workflowsByType } = useWorkflows();
- const ingestionWorkflows = workflowsByType.ingestion.items;
-
- const [datatypeSummaryVisible, setDatatypeSummaryVisible] = useState(false);
- const [selectedDataType, setSelectedDataType] = useState(null);
-
- const selectedSummary = datasetSummaries?.data?.[selectedDataType?.id] ?? {};
-
- const handleClearDataType = useCallback((dataType) => {
- genericConfirm({
- title: `Are you sure you want to delete the "${dataType.label || dataType.id}" data type?`,
- content: "Deleting this means all instances of this data type contained in the dataset " +
- "will be deleted permanently, and will no longer be available for exploration.",
- onOk: async () => {
- await dispatch(clearDatasetDataType(dataset.identifier, dataType.id));
- await dispatch(fetchDatasetDataTypesSummariesIfPossible(dataset.identifier));
- },
- });
- }, [dispatch, dataset]);
-
- const showDataTypeSummary = useCallback((dataType) => {
- setSelectedDataType(dataType);
- setDatatypeSummaryVisible(true);
- }, []);
-
- const startIngestionFlow = useStartIngestionFlow();
-
- const dataTypesColumns = useMemo(() => [
- {
- title: "Name",
- key: "label",
- render: (dt) =>
- isPrivate ? (
- showDataTypeSummary(dt)}>
- {dt.label ?? NA_TEXT}
-
- ) : dt.label ?? NA_TEXT,
- defaultSortOrder: "ascend",
- sorter: (a, b) => a.label.localeCompare(b.label),
- },
- {
- title: "Count",
- dataIndex: "count",
- render: (c) => (c ?? NA_TEXT),
+ const dispatch = useDispatch();
+ const datasetDataTypes = useSelector((state) => state.datasetDataTypes.itemsByID[dataset.identifier]);
+ const datasetDataTypeValues = useMemo(() => Object.values(datasetDataTypes?.itemsByID ?? {}), [datasetDataTypes]);
+ const datasetSummaries = useSelector((state) => state.datasetSummaries.itemsByID[dataset.identifier]);
+
+ const isFetchingDataset = datasetDataTypes?.isFetching ?? false;
+
+ const { workflowsByType } = useWorkflows();
+ const ingestionWorkflows = workflowsByType.ingestion.items;
+
+ const [datatypeSummaryVisible, setDatatypeSummaryVisible] = useState(false);
+ const [selectedDataType, setSelectedDataType] = useState(null);
+
+ const selectedSummary = datasetSummaries?.data?.[selectedDataType?.id] ?? {};
+
+ const handleClearDataType = useCallback(
+ (dataType) => {
+ genericConfirm({
+ title: `Are you sure you want to delete the "${dataType.label || dataType.id}" data type?`,
+ content:
+ "Deleting this means all instances of this data type contained in the dataset " +
+ "will be deleted permanently, and will no longer be available for exploration.",
+ onOk: async () => {
+ await dispatch(clearDatasetDataType(dataset.identifier, dataType.id));
+ await dispatch(fetchDatasetDataTypesSummariesIfPossible(dataset.identifier));
},
- ...(isPrivate ? [
+ });
+ },
+ [dispatch, dataset],
+ );
+
+ const showDataTypeSummary = useCallback((dataType) => {
+ setSelectedDataType(dataType);
+ setDatatypeSummaryVisible(true);
+ }, []);
+
+ const startIngestionFlow = useStartIngestionFlow();
+
+ const dataTypesColumns = useMemo(
+ () => [
+ {
+ title: "Name",
+ key: "label",
+ render: (dt) =>
+ isPrivate ? showDataTypeSummary(dt)}>{dt.label ?? NA_TEXT} : dt.label ?? NA_TEXT,
+ defaultSortOrder: "ascend",
+ sorter: (a, b) => a.label.localeCompare(b.label),
+ },
+ {
+ title: "Count",
+ dataIndex: "count",
+ render: (c) => c ?? NA_TEXT,
+ },
+ ...(isPrivate
+ ? [
{
- title: "Actions",
- key: "actions",
- width: 240,
- render: (dt) => {
- const dtIngestionWorkflows = ingestionWorkflows
- .filter((wf) => wf.data_type === dt.id || (wf.tags ?? []).includes(dt.id));
- const dtIngestionWorkflowsByID = Object.fromEntries(
- dtIngestionWorkflows.map((wf) => [wf.id, wf]));
-
- const ingestMenu = {
- onClick: (i) => startIngestionFlow(dtIngestionWorkflowsByID[i.key], {
- // TODO: this requires that exactly this input is present, and may break in the future
- // in a bit of a non-obvious way.
- "project_dataset": `${project.identifier}:${dataset.identifier}`,
- }),
- items: dtIngestionWorkflows.map((wf) => ({ key: wf.id, label: wf.name })),
- };
-
- const ingestDropdown = (
-
- }
- style={{ width: "100%" }}
- disabled={!dtIngestionWorkflows.length}>
- Ingest
-
-
- );
-
- return (
-
-
- {ingestDropdown}
-
-
- }
- disabled={!dt.count}
- onClick={() => handleClearDataType(dt)}
- style={{ width: "100%" }}
- >
- Clear
-
-
-
- );
- },
+ title: "Actions",
+ key: "actions",
+ width: 240,
+ render: (dt) => {
+ const dtIngestionWorkflows = ingestionWorkflows.filter(
+ (wf) => wf.data_type === dt.id || (wf.tags ?? []).includes(dt.id),
+ );
+ const dtIngestionWorkflowsByID = Object.fromEntries(dtIngestionWorkflows.map((wf) => [wf.id, wf]));
+
+ const ingestMenu = {
+ onClick: (i) =>
+ startIngestionFlow(dtIngestionWorkflowsByID[i.key], {
+ // TODO: this requires that exactly this input is present, and may break in the future
+ // in a bit of a non-obvious way.
+ project_dataset: `${project.identifier}:${dataset.identifier}`,
+ }),
+ items: dtIngestionWorkflows.map((wf) => ({ key: wf.id, label: wf.name })),
+ };
+
+ const ingestDropdown = (
+
+ } style={{ width: "100%" }} disabled={!dtIngestionWorkflows.length}>
+ Ingest
+
+
+ );
+
+ return (
+
+ {ingestDropdown}
+
+ }
+ disabled={!dt.count}
+ onClick={() => handleClearDataType(dt)}
+ style={{ width: "100%" }}
+ >
+ Clear
+
+
+
+ );
+ },
},
- ] : null),
- ], [isPrivate, project, dataset, ingestionWorkflows, startIngestionFlow]);
-
- const onDataTypeSummaryModalCancel = useCallback(() => setDatatypeSummaryVisible(false), []);
-
- return (
- <>
-
-
-
- Data Types
-
-
-
- >
- );
+ ]
+ : null),
+ ],
+ [isPrivate, project, dataset, handleClearDataType, ingestionWorkflows, startIngestionFlow, showDataTypeSummary],
+ );
+
+ const onDataTypeSummaryModalCancel = useCallback(() => setDatatypeSummaryVisible(false), []);
+
+ return (
+ <>
+
+
+
+ Data Types
+
+
+
+ >
+ );
});
DatasetDataTypes.propTypes = {
- isPrivate: PropTypes.bool,
- project: projectPropTypesShape,
- dataset: datasetPropTypesShape,
+ isPrivate: PropTypes.bool,
+ project: projectPropTypesShape,
+ dataset: datasetPropTypesShape,
};
export default DatasetDataTypes;
diff --git a/src/components/datasets/DatasetForm.js b/src/components/datasets/DatasetForm.js
index 69440987a..b3f1693f0 100644
--- a/src/components/datasets/DatasetForm.js
+++ b/src/components/datasets/DatasetForm.js
@@ -2,6 +2,7 @@ import React from "react";
import PropTypes from "prop-types";
import { Form, Input } from "antd";
+
const { Item } = Form;
import DataUseInput from "../DataUseInput";
@@ -11,87 +12,86 @@ import { simpleDeepCopy } from "@/utils/misc";
import { useDatsValidator, useDiscoveryValidator } from "@/hooks";
import { DropBoxJsonSelect } from "../manager/DropBoxTreeSelect";
-
-const DatasetForm = ({ initialValue, form, updateMode}) => {
- const discoveryValidator = useDiscoveryValidator();
- const datsValidator = useDatsValidator();
- return (
-
- );
+const DatasetForm = ({ initialValue, form, updateMode }) => {
+ const discoveryValidator = useDiscoveryValidator();
+ const datsValidator = useDatsValidator();
+ return (
+
+ );
};
DatasetForm.propTypes = {
- initialValue: PropTypes.shape({
- title: PropTypes.string,
- description: PropTypes.string,
- contact_info: PropTypes.string,
- data_use: DATA_USE_PROP_TYPE_SHAPE, // TODO: Shared shape for data use
- dats_file: PropTypes.object,
- discovery: PropTypes.object,
- }),
- form: PropTypes.object,
- updateMode: PropTypes.bool,
+ initialValue: PropTypes.shape({
+ title: PropTypes.string,
+ description: PropTypes.string,
+ contact_info: PropTypes.string,
+ data_use: DATA_USE_PROP_TYPE_SHAPE, // TODO: Shared shape for data use
+ dats_file: PropTypes.object,
+ discovery: PropTypes.object,
+ }),
+ form: PropTypes.object,
+ updateMode: PropTypes.bool,
};
export default DatasetForm;
diff --git a/src/components/datasets/DatasetFormModal.js b/src/components/datasets/DatasetFormModal.js
index bba6a7c66..4ad9fa197 100644
--- a/src/components/datasets/DatasetFormModal.js
+++ b/src/components/datasets/DatasetFormModal.js
@@ -13,90 +13,101 @@ import { useProjects } from "@/modules/metadata/hooks";
import { datasetPropTypesShape, projectPropTypesShape, propTypesFormMode } from "@/propTypes";
import { nop } from "@/utils/misc";
-
const DatasetFormModal = ({ project, mode, initialValue, onCancel, onOk, open }) => {
- const dispatch = useDispatch();
-
- const {
- isFetching: projectsFetching,
- isAddingDataset: projectDatasetsAdding,
- isSavingDataset: projectDatasetsSaving,
- } = useProjects();
-
- // const formRef = useRef(null);
- const [form] = Form.useForm();
-
- const handleSuccess = useCallback(async (values) => {
- await dispatch(fetchProjectsWithDatasets()); // TODO: If needed / only this project...
- await (onOk || nop)({ ...(initialValue || {}), values });
- if (mode === FORM_MODE_ADD) form.resetFields();
- }, [dispatch, form]);
-
- const handleCancel = useCallback(() => (onCancel || nop)(), [onCancel]);
- const handleSubmit = useCallback(() => {
- form.validateFields().then((values) => {
- const onSuccess = () => handleSuccess(values);
-
- if (typeof values?.discovery === "string") {
- values["discovery"] = JSON.parse(values["discovery"]);
- }
-
- return (
- mode === FORM_MODE_ADD
- ? dispatch(addProjectDataset(project, values, onSuccess))
- : dispatch(saveProjectDataset({
- ...(initialValue || {}),
- project: project.identifier,
- ...values,
- description: (values.description || "").trim(),
- contact_info: (values.contact_info || "").trim(),
- }, onSuccess))
+ const dispatch = useDispatch();
+
+ const {
+ isFetching: projectsFetching,
+ isAddingDataset: projectDatasetsAdding,
+ isSavingDataset: projectDatasetsSaving,
+ } = useProjects();
+
+ // const formRef = useRef(null);
+ const [form] = Form.useForm();
+
+ const handleSuccess = useCallback(
+ async (values) => {
+ await dispatch(fetchProjectsWithDatasets()); // TODO: If needed / only this project...
+ await (onOk || nop)({ ...(initialValue || {}), values });
+ if (mode === FORM_MODE_ADD) form.resetFields();
+ },
+ [dispatch, form],
+ );
+
+ const handleCancel = useCallback(() => (onCancel || nop)(), [onCancel]);
+ const handleSubmit = useCallback(() => {
+ form
+ .validateFields()
+ .then((values) => {
+ const onSuccess = () => handleSuccess(values);
+
+ if (typeof values?.discovery === "string") {
+ values["discovery"] = JSON.parse(values["discovery"]);
+ }
+
+ return mode === FORM_MODE_ADD
+ ? dispatch(addProjectDataset(project, values, onSuccess))
+ : dispatch(
+ saveProjectDataset(
+ {
+ ...(initialValue || {}),
+ project: project.identifier,
+ ...values,
+ description: (values.description || "").trim(),
+ contact_info: (values.contact_info || "").trim(),
+ },
+ onSuccess,
+ ),
);
- }).catch((err) => {
- console.error(err);
- });
- }, [dispatch, handleSuccess, mode, project, initialValue]);
-
- if (!project) return null;
- return (
- Cancel,
- : }
- type="primary"
- onClick={handleSubmit}
- loading={projectsFetching || projectDatasetsAdding || projectDatasetsSaving}>
- {mode === FORM_MODE_ADD ? "Add" : "Save"}
- ,
- ]}
- onCancel={handleCancel}
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ }, [dispatch, handleSuccess, mode, project, initialValue]);
+
+ if (!project) return null;
+ return (
+
+ Cancel
+ ,
+ : }
+ type="primary"
+ onClick={handleSubmit}
+ loading={projectsFetching || projectDatasetsAdding || projectDatasetsSaving}
>
-
-
- );
+ {mode === FORM_MODE_ADD ? "Add" : "Save"}
+ ,
+ ]}
+ onCancel={handleCancel}
+ >
+
+
+ );
};
DatasetFormModal.defaultProps = {
- mode: FORM_MODE_ADD,
+ mode: FORM_MODE_ADD,
};
DatasetFormModal.propTypes = {
- mode: propTypesFormMode,
- initialValue: datasetPropTypesShape,
+ mode: propTypesFormMode,
+ initialValue: datasetPropTypesShape,
- onOk: PropTypes.func,
- onCancel: PropTypes.func,
+ onOk: PropTypes.func,
+ onCancel: PropTypes.func,
- project: projectPropTypesShape,
+ project: projectPropTypesShape,
- open: PropTypes.bool,
+ open: PropTypes.bool,
};
export default DatasetFormModal;
diff --git a/src/components/datasets/DatasetOverview.js b/src/components/datasets/DatasetOverview.js
index 31dc76392..58abaeffa 100644
--- a/src/components/datasets/DatasetOverview.js
+++ b/src/components/datasets/DatasetOverview.js
@@ -1,63 +1,79 @@
import React, { Fragment, useMemo } from "react";
-import { useSelector } from "react-redux";
import PropTypes from "prop-types";
import { Col, Divider, Row, Spin, Statistic, Typography } from "antd";
import { EM_DASH } from "@/constants";
+import { useDatasetDataTypesByID } from "@/modules/datasets/hooks";
import { datasetPropTypesShape, projectPropTypesShape } from "@/propTypes";
-const DatasetOverview = ({isPrivate, project, dataset}) => {
- const datasetsDataTypes = useSelector((state) => state.datasetDataTypes.itemsByID);
- const dataTypesSummary = Object.values(datasetsDataTypes[dataset.identifier]?.itemsByID || {});
- const isFetchingDataset = datasetsDataTypes[dataset.identifier]?.isFetching;
+const DatasetOverview = ({ isPrivate, project, dataset }) => {
+ const { dataTypesByID, isFetchingDataTypes, hasAttemptedDataTypes } = useDatasetDataTypesByID(dataset.identifier);
- // Count data types which actually have data in them for showing in the overview
- const dataTypeCount = useMemo(
- () => dataTypesSummary
- .filter((value) => (value.count || 0) > 0)
- .length,
- [dataTypesSummary]);
+ // Count data types which actually have data in them for showing in the overview
+ const dataTypesCount = useMemo(
+ () => Object.values(dataTypesByID ?? {}).filter((value) => (value.count || 0) > 0).length,
+ [dataTypesByID],
+ );
+ const dataTypeDisplay = useMemo(() => {
+ if (isFetchingDataTypes) {
+ // refresh: display count based on previous state
+ if (hasAttemptedDataTypes) return dataTypesCount;
+ // first fetch: wait for data to display count
+ return EM_DASH;
+ }
+ return dataTypesCount;
+ }, [dataTypesCount, isFetchingDataTypes, hasAttemptedDataTypes]);
- return <>
- {(dataset.description ?? "").length > 0
- ? (<>
- Description
- {dataset.description.split("\n").map((p, i) =>
- {p} )}
- >) : null}
- {(dataset.contact_info ?? "").length > 0
- ? (<>
- Contact Information
-
- {dataset.contact_info.split("\n").map((p, i) =>
- {p} )}
-
- >) : null}
- {((dataset.description ?? "").length > 0 || (dataset.contact_info ?? "").length > 0)
- ? : null}
-
- {isPrivate ? null : (
-
- )}
-
-
-
-
-
-
-
-
-
- >;
+ return (
+ <>
+ {(dataset.description ?? "").length > 0 ? (
+ <>
+
+ Description
+
+ {dataset.description.split("\n").map((p, i) => (
+ {p}
+ ))}
+ >
+ ) : null}
+ {(dataset.contact_info ?? "").length > 0 ? (
+ <>
+ Contact Information
+
+ {dataset.contact_info.split("\n").map((p, i) => (
+
+ {p}
+
+
+ ))}
+
+ >
+ ) : null}
+ {(dataset.description ?? "").length > 0 || (dataset.contact_info ?? "").length > 0 ? : null}
+
+ {isPrivate ? null : (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ >
+ );
};
DatasetOverview.propTypes = {
- isPrivate: PropTypes.bool,
- project: projectPropTypesShape,
- dataset: datasetPropTypesShape,
+ isPrivate: PropTypes.bool,
+ project: projectPropTypesShape,
+ dataset: datasetPropTypesShape,
};
export default DatasetOverview;
diff --git a/src/components/datasets/datatype/DataTypeSummaryModal.js b/src/components/datasets/datatype/DataTypeSummaryModal.js
index 7dc0384b7..d65a75f5f 100644
--- a/src/components/datasets/datatype/DataTypeSummaryModal.js
+++ b/src/components/datasets/datatype/DataTypeSummaryModal.js
@@ -10,45 +10,45 @@ import GenericSummary from "./GenericSummary";
import PhenopacketSummary from "./PhenopacketSummary";
import VariantSummary from "./VariantSummary";
-const DataTypeSummaryModal = ({dataType, summary, onCancel, open}) => {
- const isFetchingSummaries = useSelector((state) => state.datasetDataTypes.isFetchingAll);
-
- if (!dataType) {
- return <>>;
- }
-
- let Summary = GenericSummary;
- let summaryData = summary;
- switch (dataType.id) {
- case "variant":
- Summary = VariantSummary;
- break;
- case "phenopacket":
- Summary = PhenopacketSummary;
- break;
- default:
- summaryData = summary ?? dataType;
- }
-
- return {
+ const isFetchingSummaries = useSelector((state) => state.datasetDataTypes.isFetchingAll);
+
+ if (!dataType) {
+ return <>>;
+ }
+
+ let Summary = GenericSummary;
+ let summaryData = summary;
+ switch (dataType.id) {
+ case "variant":
+ Summary = VariantSummary;
+ break;
+ case "phenopacket":
+ Summary = PhenopacketSummary;
+ break;
+ default:
+ summaryData = summary ?? dataType;
+ }
+
+ return (
+
- {(!summaryData || isFetchingSummaries)
- ?
- : }
- ;
+ {!summaryData || isFetchingSummaries ? : }
+
+ );
};
DataTypeSummaryModal.propTypes = {
- dataType: PropTypes.object,
- summary: summaryPropTypesShape,
- onCancel: PropTypes.func,
- open: PropTypes.bool,
+ dataType: PropTypes.object,
+ summary: summaryPropTypesShape,
+ onCancel: PropTypes.func,
+ open: PropTypes.bool,
};
export default DataTypeSummaryModal;
diff --git a/src/components/datasets/datatype/GenericSummary.js b/src/components/datasets/datatype/GenericSummary.js
index f9f4a89c1..c2f1766a7 100644
--- a/src/components/datasets/datatype/GenericSummary.js
+++ b/src/components/datasets/datatype/GenericSummary.js
@@ -1,15 +1,21 @@
import React from "react";
-import {Col, Row, Statistic} from "antd";
+import { Col, Row, Statistic } from "antd";
import { summaryPropTypesShape } from "../../../propTypes";
-
-const GenericSummary = ({summary}) => summary
- ?
- : "No summary available";
+const GenericSummary = ({ summary }) =>
+ summary ? (
+
+
+
+
+
+ ) : (
+ "No summary available"
+ );
GenericSummary.propTypes = {
- summary: summaryPropTypesShape,
+ summary: summaryPropTypesShape,
};
export default GenericSummary;
diff --git a/src/components/datasets/datatype/PhenopacketSummary.js b/src/components/datasets/datatype/PhenopacketSummary.js
index fe730e3b9..ddf433556 100644
--- a/src/components/datasets/datatype/PhenopacketSummary.js
+++ b/src/components/datasets/datatype/PhenopacketSummary.js
@@ -5,56 +5,62 @@ import { summaryPropTypesShape } from "@/propTypes";
import PieChart from "@/components/charts/PieChart";
const COMMON_CHART_PROPS = {
- chartHeight: 280,
- dataType: "phenopacket",
- clickable: true,
+ chartHeight: 280,
+ dataType: "phenopacket",
+ clickable: true,
};
const PhenopacketSummary = ({ summary }) => {
- const individualsBySex = Object.entries(summary.data_type_specific?.individuals?.sex ?? {})
- .filter(e => e[1] > 0)
- .map(([name, value]) => ({ name, value }));
- const individualsByKaryotype = Object.entries(summary.data_type_specific?.individuals?.karyotypic_sex ?? {})
- .filter(e => e[1] > 0)
- .map(([name, value]) => ({ name, value }));
- return <>
- Object Counts
-
-
-
-
-
-
-
- {(individualsBySex.length > 0 && individualsByKaryotype.length > 0) ? (
- <>
-
- Overview: Individuals
-
-
-
-
-
-
-
-
- >
- ) : null}
- >;
+ const individualsBySex = Object.entries(summary.data_type_specific?.individuals?.sex ?? {})
+ .filter((e) => e[1] > 0)
+ .map(([name, value]) => ({ name, value }));
+ const individualsByKaryotype = Object.entries(summary.data_type_specific?.individuals?.karyotypic_sex ?? {})
+ .filter((e) => e[1] > 0)
+ .map(([name, value]) => ({ name, value }));
+ return (
+ <>
+ Object Counts
+
+
+
+
+
+
+
+
+
+
+
+ {individualsBySex.length > 0 && individualsByKaryotype.length > 0 ? (
+ <>
+
+ Overview: Individuals
+
+
+
+
+
+
+
+
+ >
+ ) : null}
+ >
+ );
};
PhenopacketSummary.propTypes = {
- summary: summaryPropTypesShape,
+ summary: summaryPropTypesShape,
};
export default PhenopacketSummary;
diff --git a/src/components/datasets/datatype/VariantSummary.js b/src/components/datasets/datatype/VariantSummary.js
index a03f01600..992d1fc94 100644
--- a/src/components/datasets/datatype/VariantSummary.js
+++ b/src/components/datasets/datatype/VariantSummary.js
@@ -1,23 +1,28 @@
import React from "react";
-import {Col, Row, Statistic} from "antd";
+import { Col, Row, Statistic } from "antd";
import { FileOutlined } from "@ant-design/icons";
-import {summaryPropTypesShape} from "../../../propTypes";
+import { summaryPropTypesShape } from "../../../propTypes";
-const VariantSummary = ({summary}) =>
-
-
-
- {summary.data_type_specific?.vcf_files !== undefined ? (
- }
- value={summary.data_type_specific.vcf_files} />
- ) : null}
-
;
+const VariantSummary = ({ summary }) => (
+
+
+
+
+
+
+
+ {summary.data_type_specific?.vcf_files !== undefined ? (
+
+ } value={summary.data_type_specific.vcf_files} />
+
+ ) : null}
+
+);
VariantSummary.propTypes = {
- summary: summaryPropTypesShape,
+ summary: summaryPropTypesShape,
};
export default VariantSummary;
diff --git a/src/components/datasets/linked_field_set/LinkedFieldSetForm.js b/src/components/datasets/linked_field_set/LinkedFieldSetForm.js
index 256dad65e..5df4889b8 100644
--- a/src/components/datasets/linked_field_set/LinkedFieldSetForm.js
+++ b/src/components/datasets/linked_field_set/LinkedFieldSetForm.js
@@ -9,86 +9,92 @@ import { FORM_MODE_ADD } from "@/constants";
import { propTypesFormMode } from "@/propTypes";
import { getFieldSchema } from "@/utils/schema";
-
const FORM_NAME_RULES = [{ required: true }, { min: 3 }];
const LinkedFieldSetForm = ({ form, dataTypes, initialValue, mode }) => {
- const rootSchema = useMemo(() => ({
- "type": "object",
- "properties": Object.fromEntries(Object.entries(dataTypes).map(([k, v]) => [k, {
- "type": "array",
- "items": v.schema,
- }])),
- }), [dataTypes]);
+ const rootSchema = useMemo(
+ () => ({
+ type: "object",
+ properties: Object.fromEntries(
+ Object.entries(dataTypes).map(([k, v]) => [
+ k,
+ {
+ type: "array",
+ items: v.schema,
+ },
+ ]),
+ ),
+ }),
+ [dataTypes],
+ );
- useEffect(() => {
- form.resetFields();
- }, [initialValue]);
+ useEffect(() => {
+ form.resetFields();
+ }, [form, initialValue]); // Reset form values when initial value changes
- const initialListValue = useMemo(
- () => mode === FORM_MODE_ADD
- ? [{}, {}]
- : Object.entries(initialValue?.fields ?? {})
- .sort((a, b) => a[0].localeCompare(b[0]))
- .map(([dt, f]) => {
- const selected = `[dataset item].${dt}.[item].${f.join(".")}`;
- try {
- return { selected, schema: getFieldSchema(rootSchema, selected) };
- } catch (err) {
- // Possibly invalid field (due to migration / data model change), skip it.
- console.error(`Encountered invalid field: ${selected}`);
- return null;
- }
- })
- .filter((f) => f !== null),
- [mode, initialValue, rootSchema]);
+ const initialListValue = useMemo(
+ () =>
+ mode === FORM_MODE_ADD
+ ? [{}, {}]
+ : Object.entries(initialValue?.fields ?? {})
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([dt, f]) => {
+ const selected = `[dataset item].${dt}.[item].${f.join(".")}`;
+ try {
+ return { selected, schema: getFieldSchema(rootSchema, selected) };
+ } catch (err) {
+ // Possibly invalid field (due to migration / data model change), skip it.
+ console.error(`Encountered invalid field: ${selected}`);
+ return null;
+ }
+ })
+ .filter((f) => f !== null),
+ [mode, initialValue, rootSchema],
+ );
- return (
-
-
+ return (
+
+
+
+
+ {(fields, { add, remove }) => (
+ <>
+ {fields.map((field, i) => (
+
+
+
+
+ }
+ type="link"
+ danger={true}
+ disabled={i < 2}
+ style={{ cursor: i < 2 ? "not-allowed" : "pointer" }}
+ onClick={() => remove(field.name)}
+ />
+
+ ))}
+
+ } style={{ width: "100%" }}>
+ Add Linked Field
+
-
- {(fields, { add, remove }) => (
- <>
- {fields.map((field, i) => (
-
-
-
-
- }
- type="link"
- danger={true}
- disabled={i < 2}
- style={{ cursor: i < 2 ? "not-allowed" : "pointer" }}
- onClick={() => remove(field.name)}
- />
-
- ))}
-
- } style={{ width: "100%" }}>
- Add Linked Field
-
-
- >
- )}
-
-
- );
+ >
+ )}
+
+
+ );
};
LinkedFieldSetForm.propTypes = {
- form: PropTypes.object,
- mode: propTypesFormMode,
- dataTypes: PropTypes.objectOf(PropTypes.object),
- initialValue: PropTypes.shape({
- name: PropTypes.string,
- fields: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)),
- }),
+ form: PropTypes.object,
+ mode: propTypesFormMode,
+ dataTypes: PropTypes.objectOf(PropTypes.object),
+ initialValue: PropTypes.shape({
+ name: PropTypes.string,
+ fields: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)),
+ }),
};
export default LinkedFieldSetForm;
diff --git a/src/components/datasets/linked_field_set/LinkedFieldSetModal.js b/src/components/datasets/linked_field_set/LinkedFieldSetModal.js
index 443d4630d..6c0bee2ed 100644
--- a/src/components/datasets/linked_field_set/LinkedFieldSetModal.js
+++ b/src/components/datasets/linked_field_set/LinkedFieldSetModal.js
@@ -11,85 +11,92 @@ import { addDatasetLinkedFieldSetIfPossible, saveDatasetLinkedFieldSetIfPossible
import { datasetPropTypesShape, linkedFieldSetPropTypesShape, propTypesFormMode } from "@/propTypes";
import { nop } from "@/utils/misc";
-
const LinkedFieldSetModal = ({ dataset, linkedFieldSetIndex, linkedFieldSet, mode, open, onCancel, onSubmit }) => {
- const dispatch = useDispatch();
-
- const [form] = Form.useForm();
-
- const dataTypes = useSelector((state) => state.serviceDataTypes.itemsByID);
- const isSavingDataset = useSelector((state) => state.projects.isSavingDataset);
-
- const addLinkedFieldSet = useCallback(
- (newLinkedFieldSet, onSuccess) =>
- dispatch(addDatasetLinkedFieldSetIfPossible(dataset, newLinkedFieldSet, onSuccess)),
- [dispatch, dataset],
- );
-
- const saveLinkedFieldSet = useCallback(
- (linkedFieldSet, onSuccess) =>
- dispatch(saveDatasetLinkedFieldSetIfPossible(dataset, linkedFieldSetIndex, linkedFieldSet, onSuccess)),
- [dispatch, dataset, linkedFieldSetIndex, linkedFieldSet],
- );
-
- const handleSubmit = useCallback(() => {
- form.validateFields().then(async (values) => {
- console.debug("Field set form values", values);
-
- const newLinkedFieldSet = {
- name: values.name,
- fields: Object.fromEntries(values.fields.map(f => {
- const parts = f.selected.split(".").slice(1); // TODO: Condense this with filter (_, i)
- const entry = [parts[0], parts.slice(2)];
- console.debug("Linked field set entry", entry);
- return entry;
- })),
- };
-
- const onSuccess = () => {
- if (onSubmit) onSubmit();
- };
-
- if (mode === FORM_MODE_ADD) {
- await addLinkedFieldSet(newLinkedFieldSet, onSuccess);
- } else {
- await saveLinkedFieldSet(newLinkedFieldSet, onSuccess);
- }
- }).catch((err) => {
- console.error("Encountered error validating fields", err);
- });
- }, [form, onSubmit, mode, addLinkedFieldSet, saveLinkedFieldSet]);
- const handleCancel = useCallback(() => (onCancel ?? nop)(), [onCancel]);
-
- const modalTitle = mode === FORM_MODE_ADD
- ? `Add New Linked Field Set to Dataset "${dataset.title}"`
- : `Edit Linked Field Set "${linkedFieldSet?.name ?? ""}" on Dataset "${dataset.title}"`;
-
- return (
-
-
-
- );
+ const dispatch = useDispatch();
+
+ const [form] = Form.useForm();
+
+ const dataTypes = useSelector((state) => state.serviceDataTypes.itemsByID);
+ const isSavingDataset = useSelector((state) => state.projects.isSavingDataset);
+
+ const addLinkedFieldSet = useCallback(
+ (newLinkedFieldSet, onSuccess) =>
+ dispatch(addDatasetLinkedFieldSetIfPossible(dataset, newLinkedFieldSet, onSuccess)),
+ [dispatch, dataset],
+ );
+
+ const saveLinkedFieldSet = useCallback(
+ (newLinkedFieldSet, onSuccess) =>
+ dispatch(saveDatasetLinkedFieldSetIfPossible(dataset, linkedFieldSetIndex, newLinkedFieldSet, onSuccess)),
+ [dispatch, dataset, linkedFieldSetIndex],
+ );
+
+ const handleSubmit = useCallback(() => {
+ form
+ .validateFields()
+ .then(async (values) => {
+ console.debug("Field set form values", values);
+
+ const newLinkedFieldSet = {
+ name: values.name,
+ fields: Object.fromEntries(
+ values.fields.map((f) => {
+ const parts = f.selected.split(".").slice(1); // TODO: Condense this with filter (_, i)
+ const entry = [parts[0], parts.slice(2)];
+ console.debug("Linked field set entry", entry);
+ return entry;
+ }),
+ ),
+ };
+
+ const onSuccess = () => {
+ if (onSubmit) onSubmit();
+ };
+
+ if (mode === FORM_MODE_ADD) {
+ await addLinkedFieldSet(newLinkedFieldSet, onSuccess);
+ } else {
+ await saveLinkedFieldSet(newLinkedFieldSet, onSuccess);
+ }
+ })
+ .catch((err) => {
+ console.error("Encountered error validating fields", err);
+ });
+ }, [form, onSubmit, mode, addLinkedFieldSet, saveLinkedFieldSet]);
+ const handleCancel = useCallback(() => (onCancel ?? nop)(), [onCancel]);
+
+ const modalTitle =
+ mode === FORM_MODE_ADD
+ ? `Add New Linked Field Set to Dataset "${dataset.title}"`
+ : `Edit Linked Field Set "${linkedFieldSet?.name ?? ""}" on Dataset "${dataset.title}"`;
+
+ return (
+
+
+
+ );
};
LinkedFieldSetModal.defaultProps = {
- mode: FORM_MODE_ADD,
+ mode: FORM_MODE_ADD,
};
LinkedFieldSetModal.propTypes = {
- mode: propTypesFormMode,
- open: PropTypes.bool,
- dataset: datasetPropTypesShape,
- onSubmit: PropTypes.func,
- onCancel: PropTypes.func,
-
- // For editing
- linkedFieldSetIndex: PropTypes.number,
- linkedFieldSet: linkedFieldSetPropTypesShape,
+ mode: propTypesFormMode,
+ open: PropTypes.bool,
+ dataset: datasetPropTypesShape,
+ onSubmit: PropTypes.func,
+ onCancel: PropTypes.func,
+
+ // For editing
+ linkedFieldSetIndex: PropTypes.number,
+ linkedFieldSet: linkedFieldSetPropTypesShape,
};
export default LinkedFieldSetModal;
diff --git a/src/components/datasets/linked_field_set/LinkedFieldSetTable.js b/src/components/datasets/linked_field_set/LinkedFieldSetTable.js
index 3110e4f50..b05b131c9 100644
--- a/src/components/datasets/linked_field_set/LinkedFieldSetTable.js
+++ b/src/components/datasets/linked_field_set/LinkedFieldSetTable.js
@@ -1,33 +1,25 @@
import React from "react";
-import {Table} from "antd";
-import {linkedFieldSetPropTypesShape} from "@/propTypes";
+import { Table } from "antd";
+import { linkedFieldSetPropTypesShape } from "@/propTypes";
import MonospaceText from "@/components/common/MonospaceText";
const COLUMNS = [
- { dataIndex: "dataType", title: "Data Type" },
- { dataIndex: "field", title: "Field", render: (f) => {f.join(".")} },
+ { dataIndex: "dataType", title: "Data Type" },
+ { dataIndex: "field", title: "Field", render: (f) => {f.join(".")} },
];
const LinkedFieldSetTable = ({ linkedFieldSet }) => {
- const data = Object.entries(linkedFieldSet.fields)
- .map(([dataType, field]) => ({dataType, field}))
- .sort((a, b) =>
- a.dataType.localeCompare(b.dataType));
+ const data = Object.entries(linkedFieldSet.fields)
+ .map(([dataType, field]) => ({ dataType, field }))
+ .sort((a, b) => a.dataType.localeCompare(b.dataType));
- return (
-
- );
+ return (
+
+ );
};
LinkedFieldSetTable.propTypes = {
- linkedFieldSet: linkedFieldSetPropTypesShape,
+ linkedFieldSet: linkedFieldSetPropTypesShape,
};
export default LinkedFieldSetTable;
diff --git a/src/components/discovery/DataTypeExplorationModal.js b/src/components/discovery/DataTypeExplorationModal.js
index ecc598b1d..a3c68c411 100644
--- a/src/components/discovery/DataTypeExplorationModal.js
+++ b/src/components/discovery/DataTypeExplorationModal.js
@@ -1,115 +1,120 @@
-import React, {Component} from "react";
+import React, { Component } from "react";
import PropTypes from "prop-types";
-import {Divider, Input, Modal, Radio, Table, Tabs, Typography} from "antd";
+import { Divider, Input, Modal, Radio, Table, Tabs, Typography } from "antd";
import { ShareAltOutlined, TableOutlined } from "@ant-design/icons";
import SchemaTree from "../schema_trees/SchemaTree";
-import {generateSchemaTreeData, generateSchemaTableData} from "../../utils/schema";
-import {nop} from "../../utils/misc";
+import { generateSchemaTreeData, generateSchemaTableData } from "../../utils/schema";
+import { nop } from "../../utils/misc";
// TODO: Add more columns
const FIELD_COLUMNS = [
- {title: "Key", dataIndex: "key", render: t =>
- {t} },
- {title: "JSON Type", dataIndex: ["data", "type"]},
- {title: "Description", dataIndex: ["data", "description"]},
+ {
+ title: "Key",
+ dataIndex: "key",
+ render: (t) => {t} ,
+ },
+ { title: "JSON Type", dataIndex: ["data", "type"] },
+ { title: "Description", dataIndex: ["data", "description"] },
];
class DataTypeExplorationModal extends Component {
- constructor(props) {
- super(props);
- this.state = {
- view: "table",
- filter: "",
- };
+ constructor(props) {
+ super(props);
+ this.state = {
+ view: "table",
+ filter: "",
+ };
- this.onFilterChange = this.onFilterChange.bind(this);
- this.applyFilterToTableData = this.applyFilterToTableData.bind(this);
- this.getTableData = this.getTableData.bind(this);
- }
+ this.onFilterChange = this.onFilterChange.bind(this);
+ this.applyFilterToTableData = this.applyFilterToTableData.bind(this);
+ this.getTableData = this.getTableData.bind(this);
+ }
- onFilterChange(v) {
- this.setState({filter: v.toLocaleLowerCase().trim()});
- }
+ onFilterChange(v) {
+ this.setState({ filter: v.toLocaleLowerCase().trim() });
+ }
- applyFilterToTableData(l) {
- return this.state.filter === ""
- ? l
- : l.filter(f =>
- f.key.toLocaleLowerCase().includes(this.state.filter)
- || (f.data.description || "").toLocaleLowerCase().includes(this.state.filter));
- }
+ applyFilterToTableData(l) {
+ return this.state.filter === ""
+ ? l
+ : l.filter(
+ (f) =>
+ f.key.toLocaleLowerCase().includes(this.state.filter) ||
+ (f.data.description || "").toLocaleLowerCase().includes(this.state.filter),
+ );
+ }
- getTableData(d) {
- // TODO: Cache tree data for data type
- return this.applyFilterToTableData(generateSchemaTableData(generateSchemaTreeData(d.schema)));
- }
+ getTableData(d) {
+ // TODO: Cache tree data for data type
+ return this.applyFilterToTableData(generateSchemaTableData(generateSchemaTreeData(d.schema)));
+ }
- render() {
- const filteredDataTypes = this.props.dataTypes || [];
+ render() {
+ const filteredDataTypes = this.props.dataTypes || [];
- const tabItems = filteredDataTypes.map((dataType) => ({
- key: dataType.id,
- label: dataType.label ?? dataType.id,
- children: this.state.view === "tree" ? (
-
- ) : (
- <>
- this.onFilterChange(e.target.value)}
- placeholder="Search for a field..."
- style={{marginBottom: "16px"}}
- />
-
- >
- ),
- }));
+ const tabItems = filteredDataTypes.map((dataType) => ({
+ key: dataType.id,
+ label: dataType.label ?? dataType.id,
+ children:
+ this.state.view === "tree" ? (
+
+ ) : (
+ <>
+ this.onFilterChange(e.target.value)}
+ placeholder="Search for a field..."
+ style={{ marginBottom: "16px" }}
+ />
+
+ >
+ ),
+ }));
- return
-
- Bento separate data types across multiple queryable data services. For instance,
- clinical and phenotypic data is stored in the Katsu data service, while genomic data
- is stored in the Gohan data service. Each data service has its own set of queryable
- properties, and parameters for multiple data types can be used in the same query. If
- two or more data types are queried at the same time, an aggregation service will look
- for datasets that have linked data objects matching both criteria.
-
-
- To run a query on a data type, click on the “Query Data Type” button and choose the data
- type you want to add query conditions on.
-
+ return (
+
+
+ Bento separate data types across multiple queryable data services. For instance, clinical and phenotypic data
+ is stored in the Katsu data service, while genomic data is stored in the Gohan data service. Each data service
+ has its own set of queryable properties, and parameters for multiple data types can be used in the same query.
+ If two or more data types are queried at the same time, an aggregation service will look for datasets that
+ have linked data objects matching both criteria.
+
+
+ To run a query on a data type, click on the “Query Data Type” button and choose the data type you
+ want to add query conditions on.
+
-
+
- Data Types
-
-
this.setState({view: e.target.value})}
- buttonStyle="solid"
- style={{position: "absolute", top: 0, right: 0, zIndex: 50}}>
- Tree View
- Table Detail View
-
-
-
- ;
- }
+ Data Types
+
+
this.setState({ view: e.target.value })}
+ buttonStyle="solid"
+ style={{ position: "absolute", top: 0, right: 0, zIndex: 50 }}
+ >
+
+ Tree View
+
+
+ Table Detail View
+
+
+
+
+
+ );
+ }
}
DataTypeExplorationModal.propTypes = {
- dataTypes: PropTypes.array, // TODO: Shape
- open: PropTypes.bool,
- onCancel: PropTypes.func,
+ dataTypes: PropTypes.array, // TODO: Shape
+ open: PropTypes.bool,
+ onCancel: PropTypes.func,
};
export default DataTypeExplorationModal;
diff --git a/src/components/discovery/DiscoveryQueryBuilder.js b/src/components/discovery/DiscoveryQueryBuilder.js
index 94af334a9..9260a3191 100644
--- a/src/components/discovery/DiscoveryQueryBuilder.js
+++ b/src/components/discovery/DiscoveryQueryBuilder.js
@@ -1,283 +1,306 @@
-import React, {Component} from "react";
-import {connect} from "react-redux";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useSelector } from "react-redux";
import PropTypes from "prop-types";
import { Button, Card, Dropdown, Empty, Tabs, Typography } from "antd";
import { DownOutlined, PlusOutlined, QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons";
+import { useDatasetDataTypes } from "@/modules/datasets/hooks";
+import {
+ setIsSubmittingSearch,
+ clearSearch,
+ neutralizeAutoQueryPageTransition,
+ addDataTypeQueryForm,
+ updateDataTypeQueryForm,
+ removeDataTypeQueryForm,
+} from "@/modules/explorer/actions";
+import { useDataTypes, useServices } from "@/modules/services/hooks";
+import { useAppDispatch } from "@/store";
+import { nop } from "@/utils/misc";
+import { OP_EQUALS } from "@/utils/search";
+import { getFieldSchema } from "@/utils/schema";
+
import DataTypeExplorationModal from "./DataTypeExplorationModal";
import DiscoverySearchForm from "./DiscoverySearchForm";
-import {nop} from "@/utils/misc";
-
-import {OP_EQUALS} from "@/utils/search";
-import {getFieldSchema} from "@/utils/schema";
-
-import { neutralizeAutoQueryPageTransition, setIsSubmittingSearch } from "@/modules/explorer/actions";
-class DiscoveryQueryBuilder extends Component {
- constructor(props) {
- super(props);
+const DiscoveryQueryBuilder = ({ activeDataset, dataTypeForms, requiredDataTypes, onSubmit, searchLoading }) => {
+ const dispatch = useAppDispatch();
- this.state = {
- schemasModalShown: false,
- };
+ const { isFetching: isFetchingServiceDataTypes, itemsByID: dataTypesByID } = useDataTypes();
+ const dataTypesByDataset = useDatasetDataTypes();
- this.handleSubmit = this.handleSubmit.bind(this);
+ const autoQuery = useSelector((state) => state.explorer.autoQuery);
+ // Mini state machine: when auto query is set:
+ // 1. clear form(s) and set this to true;
+ // 2. re-create forms and wait to receive ref;
+ // 3. if this is true, and we have refs, execute part two of auto-query.
+ const [shouldExecAutoQueryPt2, setShouldExecAutoQueryPt2] = useState(false);
- this.handleFormChange = this.handleFormChange.bind(this);
- this.handleHelpAndSchemasToggle = this.handleHelpAndSchemasToggle.bind(this);
+ const isFetchingTextSearch = useSelector((state) => state.explorer.fetchingTextSearch);
- this.handleAddDataTypeQueryForm = this.handleAddDataTypeQueryForm.bind(this);
- this.handleTabsEdit = this.handleTabsEdit.bind(this);
- this.handleVariantHiddenFieldChange = this.handleVariantHiddenFieldChange.bind(this);
+ const { isFetching: isFetchingServices } = useServices();
- this.handleSetFormRef = this.handleSetFormRef.bind(this);
+ const dataTypesLoading = isFetchingServices || isFetchingServiceDataTypes || dataTypesByDataset.isFetchingAll;
- this.forms = {};
- }
+ const [schemasModalShown, setSchemasModalShown] = useState(false);
+ const [forms, setForms] = useState({});
- componentDidMount() {
- const {
- dataTypesByID,
- requiredDataTypes,
- addDataTypeQueryForm,
- autoQuery,
- } = this.props;
+ const handleAddDataTypeQueryForm = useCallback(
+ (e) => {
+ const keySplit = e.key.split(":");
+ dispatch(addDataTypeQueryForm(activeDataset, dataTypesByID[keySplit[keySplit.length - 1]]));
+ },
+ [dispatch, activeDataset, dataTypesByID],
+ );
- (requiredDataTypes ?? []).forEach(dt => addDataTypeQueryForm(dt));
+ const handleTabsEdit = useCallback(
+ (key, action) => {
+ if (action !== "remove") return;
+ dispatch(removeDataTypeQueryForm(activeDataset, dataTypesByID[key]));
+ },
+ [dispatch, activeDataset, dataTypesByID],
+ );
- if (autoQuery?.isAutoQuery) {
- const { autoQueryType } = autoQuery;
+ useEffect(() => {
+ (requiredDataTypes ?? []).forEach((dt) => dispatch(addDataTypeQueryForm(activeDataset, dt)));
+ }, [dispatch, requiredDataTypes, activeDataset]);
- // Clean old queries (if any)
- Object.values(dataTypesByID).forEach(value => this.handleTabsEdit(value.id, "remove"));
+ useEffect(() => {
+ if (autoQuery?.isAutoQuery && !shouldExecAutoQueryPt2) {
+ const { autoQueryType } = autoQuery;
- // Set type of query
- this.handleAddDataTypeQueryForm({ key: autoQueryType });
+ // Clean old queries (if any)
+ Object.values(dataTypesByID).forEach((value) => handleTabsEdit(value.id, "remove"));
- // The rest of the auto-query is handled by handleSetFormRef() below, upon form load.
- }
- }
-
- handleSubmit = async () => {
- this.props.setIsSubmittingSearch(true);
-
- try {
- await Promise.all(Object.values(this.forms).map((f) => f.validateFields()));
- // TODO: If error, switch to errored tab
- (this.props.onSubmit ?? nop)();
- } catch (err) {
- console.error(err);
- } finally {
- // done whether error caught or not
- this.props.setIsSubmittingSearch(false);
- }
-
- };
-
- handleFormChange(dataType, fields) {
- this.props.updateDataTypeQueryForm(dataType, fields);
- }
+ // Clean old search results
+ dispatch(clearSearch(activeDataset));
- handleVariantHiddenFieldChange(fields) {
- this.props.updateDataTypeQueryForm(this.props.dataTypesByID["variant"], fields);
- }
+ // Set type of query
+ handleAddDataTypeQueryForm({ key: autoQueryType });
- handleHelpAndSchemasToggle() {
- this.setState({schemasModalShown: !this.state.schemasModalShown});
+ // The rest of the auto-query is handled by a second effect, after we receive the new form ref.
+ setShouldExecAutoQueryPt2(true);
}
-
- handleAddDataTypeQueryForm(e) {
- const keySplit = e.key.split(":");
- this.props.addDataTypeQueryForm(this.props.dataTypesByID[keySplit[keySplit.length - 1]]);
+ }, [
+ activeDataset,
+ autoQuery,
+ shouldExecAutoQueryPt2,
+ dataTypesByID,
+ dispatch,
+ handleTabsEdit,
+ handleAddDataTypeQueryForm,
+ ]);
+
+ const handleSubmit = useCallback(async () => {
+ dispatch(setIsSubmittingSearch(true));
+
+ try {
+ await Promise.all(Object.values(forms).map((f) => f.validateFields()));
+ // TODO: If error, switch to errored tab
+ (onSubmit ?? nop)();
+ } catch (err) {
+ console.error(err);
+ } finally {
+ // done whether error caught or not
+ dispatch(setIsSubmittingSearch(false));
}
-
- handleTabsEdit(key, action) {
- if (action !== "remove") return;
- this.props.removeDataTypeQueryForm(this.props.dataTypesByID[key]);
+ }, [dispatch, forms, onSubmit]);
+
+ const handleFormChange = useCallback(
+ (dataType, fields) => {
+ dispatch(updateDataTypeQueryForm(activeDataset, dataType, fields));
+ },
+ [dispatch, activeDataset],
+ );
+
+ const handleVariantHiddenFieldChange = useCallback(
+ (fields) => {
+ dispatch(updateDataTypeQueryForm(activeDataset, dataTypesByID["variant"], fields));
+ },
+ [dispatch, activeDataset, dataTypesByID],
+ );
+
+ const handleHelpAndSchemasToggle = useCallback(() => {
+ setSchemasModalShown(!schemasModalShown);
+ }, [schemasModalShown]);
+
+ const handleSetFormRef = useCallback(
+ (dataType, form) => {
+ setForms({ ...forms, [dataType.id]: form });
+ },
+ [forms],
+ );
+
+ useEffect(() => {
+ if (autoQuery?.isAutoQuery && shouldExecAutoQueryPt2) {
+ const { autoQueryType, autoQueryField, autoQueryValue } = autoQuery;
+
+ const form = forms[autoQueryType];
+
+ if (!form) {
+ // No ref yet; wait for form ref for this data type
+ return;
+ }
+
+ const dataType = dataTypesByID[autoQueryType];
+
+ console.debug(`executing auto-query on data type ${dataType.id}: ${autoQueryField} = ${autoQueryValue}`);
+
+ const fieldSchema = getFieldSchema(dataType.schema, autoQueryField);
+
+ // Set term
+ const fields = [
+ {
+ name: ["conditions"],
+ value: [
+ {
+ field: autoQueryField,
+ // from utils/schema:
+ fieldSchema,
+ negated: false,
+ operation: OP_EQUALS,
+ searchValue: autoQueryValue,
+ },
+ ],
+ },
+ ];
+
+ form.setFields(fields);
+ handleFormChange(dataType, fields); // Not triggered by setFields; do it manually
+
+ (async () => {
+ // Simulate form submission click
+ const s = handleSubmit();
+
+ // Clean up auto-query "paper trail" (that is, the state segment that
+ // was introduced in order to transfer intent from the OverviewContent page)
+ dispatch(neutralizeAutoQueryPageTransition());
+ setShouldExecAutoQueryPt2(false);
+
+ await s;
+ })();
}
-
- handleSetFormRef(dataType, form) {
- const { autoQuery, neutralizeAutoQueryPageTransition, dataTypeFormsByDatasetID, activeDataset } = this.props;
- this.forms[dataType.id] = form;
-
- if (autoQuery?.isAutoQuery) {
- // If we have an auto-query on this form, trigger it when we get the ref, so we can access the form object:
-
- const { autoQueryType, autoQueryField, autoQueryValue } = autoQuery;
- if (autoQueryType !== dataType.id) return;
-
- console.debug(`executing auto-query on data type ${dataType.id}: ${autoQueryField} = ${autoQueryValue}`);
-
- const fieldSchema = getFieldSchema(dataType.schema, autoQueryField);
-
- // Set term
- const fields = [{
- name: ["conditions"],
- value: [{
- field: autoQueryField,
- // from utils/schema:
- fieldSchema,
- negated: false,
- operation: OP_EQUALS,
- searchValue: autoQueryValue,
- }],
- }];
-
- form?.setFields(fields);
- this.handleFormChange(dataType, fields); // Not triggered by setFields; do it manually
-
- (async () => {
- // Simulate form submission click
- const s = this.handleSubmit();
-
- // Clean up auto-query "paper trail" (that is, the state segment that
- // was introduced in order to transfer intent from the OverviewContent page)
- neutralizeAutoQueryPageTransition();
-
- await s;
- })();
- } else {
- // Put form fields back if they were filled out before, and we're not executing a new auto-query:
-
- const stateForm = (dataTypeFormsByDatasetID[activeDataset] ?? [])
- .find((f) => f.dataType.id === dataType.id);
-
- if (!stateForm) return;
-
- form?.setFields(stateForm.formValues);
- }
- }
-
- render() {
- const { activeDataset, dataTypesByDataset, dataTypeForms } = this.props;
-
- const dataTypesForActiveDataset = Object.values(dataTypesByDataset.itemsByID[activeDataset] || {})
- .filter(dt => typeof dt === "object");
-
- const filteredDataTypes = dataTypesForActiveDataset
- .flatMap(Object.values)
- .filter(dt => (dt.queryable ?? true) && dt.count > 0);
-
- // Filter out services without data types and then flat-map the service's data types to make the dropdown.
- const dataTypeMenu = {
- onClick: this.handleAddDataTypeQueryForm,
- items: filteredDataTypes.map((dt) => ({
- key: `${activeDataset}:${dt.id}`,
- label: <>{dt.label ?? dt.id}>,
- })),
- };
-
- const dataTypeTabItems = dataTypeForms.map(({ dataType }) => {
- // Use data type label for tab name, unless it isn't specified - then fall back to ID.
- // This behaviour should be the same everywhere in bento_web or almost anywhere the
- // data type is shown to 'end users'.
- const { id, label } = dataType;
- return ({
- key: id,
- label: label ?? id,
- closable: !(this.props.requiredDataTypes ?? []).includes(id),
- children: (
- this.handleSetFormRef(dataType, form)}
- onChange={(fields) => this.handleFormChange(dataType, fields)}
- handleVariantHiddenFieldChange={this.handleVariantHiddenFieldChange}
- />
- ),
- });
- });
-
- const addConditionsOnDataType = (buttonProps = {style: {float: "right"}}) => (
-
- Data Type
-
- );
-
- return
-
+ Object.values(dataTypesByDataset.itemsByID[activeDataset] || {})
+ .filter((dt) => typeof dt === "object") // just datasets which we know data types for
+ .flatMap(Object.values)
+ .filter((dt) => (dt.queryable ?? true) && dt.count > 0) // just queryable data types w/ a positive count
+ .sort((a, b) => {
+ const labelA = (a.label ?? a.id).toString().toLowerCase();
+ const labelB = (b.label ?? b.id).toString().toLowerCase();
+ return labelA.localeCompare(labelB);
+ }),
+ [dataTypesByDataset, activeDataset],
+ );
+
+ // Filter out services without data types and then flat-map the service's data types to make the dropdown.
+ const dataTypeMenu = useMemo(
+ () => ({
+ onClick: handleAddDataTypeQueryForm,
+ items: enabledDataTypesForDataset.map((dt) => ({
+ key: `${activeDataset}:${dt.id}`,
+ label: <>{dt.label ?? dt.id}>,
+ })),
+ }),
+ [handleAddDataTypeQueryForm, enabledDataTypesForDataset, activeDataset],
+ );
+
+ const dataTypeTabItems = useMemo(
+ () =>
+ dataTypeForms.map(({ dataType }) => {
+ // Use data type label for tab name, unless it isn't specified - then fall back to ID.
+ // This behaviour should be the same everywhere in bento_web or almost anywhere the
+ // data type is shown to 'end users'.
+ const { id, label } = dataType;
+ return {
+ key: id,
+ label: label ?? id,
+ closable: !(requiredDataTypes ?? []).includes(id),
+ children: (
+ handleSetFormRef(dataType, form)}
+ onChange={(fields) => handleFormChange(dataType, fields)}
+ handleVariantHiddenFieldChange={handleVariantHiddenFieldChange}
/>
-
-
- Advanced Search
- {addConditionsOnDataType()}
-
- Help
-
-
-
- {dataTypeForms.length > 0
- ?
- : (
-
- {addConditionsOnDataType({type: "primary"})}
-
- )}
-
- }
- loading={this.props.searchLoading}
- disabled={dataTypeForms.length === 0 || this.props.isFetchingTextSearch}
- onClick={() => this.handleSubmit()}>Search
- ;
- }
-}
+ ),
+ };
+ }),
+ [
+ requiredDataTypes,
+ dataTypeForms,
+ searchLoading,
+ handleSetFormRef,
+ handleFormChange,
+ handleVariantHiddenFieldChange,
+ ],
+ );
+
+ const addConditionsOnDataType = (buttonProps = { style: { float: "right" } }) => (
+
+
+ Data Type
+
+
+ );
+
+ return (
+
+
+
+
+ Advanced Search
+ {addConditionsOnDataType()}
+ }
+ style={{ float: "right", marginRight: "1em" }}
+ disabled={enabledDataTypesForDataset?.length === 0}
+ onClick={handleHelpAndSchemasToggle}
+ >
+ Help
+
+
+
+ {dataTypeForms.length > 0 ? (
+
+ ) : (
+
+ {addConditionsOnDataType({ type: "primary" })}
+
+ )}
+
+ }
+ loading={searchLoading}
+ disabled={dataTypeForms.length === 0 || isFetchingTextSearch}
+ onClick={handleSubmit}
+ >
+ Search
+
+
+ );
+};
DiscoveryQueryBuilder.propTypes = {
- activeDataset: PropTypes.string,
- requiredDataTypes: PropTypes.arrayOf(PropTypes.string),
-
- servicesInfo: PropTypes.arrayOf(PropTypes.object),
- dataTypes: PropTypes.object,
- dataTypesByID: PropTypes.object,
- dataTypesLoading: PropTypes.bool,
- dataTypesByDataset: PropTypes.object,
-
- searchLoading: PropTypes.bool,
- formValues: PropTypes.object,
- dataTypeForms: PropTypes.arrayOf(PropTypes.object).isRequired,
- joinFormValues: PropTypes.object,
- isFetchingTextSearch: PropTypes.bool,
-
- addDataTypeQueryForm: PropTypes.func,
- updateDataTypeQueryForm: PropTypes.func,
- removeDataTypeQueryForm: PropTypes.func,
-
- autoQuery: PropTypes.any, // todo: elaborate
- dataTypeFormsByDatasetID: PropTypes.object,
- neutralizeAutoQueryPageTransition: PropTypes.func,
-
- onSubmit: PropTypes.func,
- setIsSubmittingSearch: PropTypes.func,
+ activeDataset: PropTypes.string,
+ requiredDataTypes: PropTypes.arrayOf(PropTypes.string),
+ dataTypeForms: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSubmit: PropTypes.func,
+ searchLoading: PropTypes.bool,
};
-const mapStateToProps = state => ({
- servicesInfo: state.services.items,
- dataTypesByID: state.serviceDataTypes.itemsByID,
- dataTypesByDataset: state.datasetDataTypes,
-
- autoQuery: state.explorer.autoQuery,
- dataTypeFormsByDatasetID: state.explorer.dataTypeFormsByDatasetID,
- isFetchingTextSearch: state.explorer.fetchingTextSearch || false,
-
- dataTypesLoading: state.services.isFetching
- || state.serviceDataTypes.isFetching
- || state.datasetDataTypes.isFetchingAll,
-});
-
-const mapDispatchToProps = (dispatch) => ({
- neutralizeAutoQueryPageTransition: () => dispatch(neutralizeAutoQueryPageTransition()),
- setIsSubmittingSearch: (isSubmittingSearch) => dispatch(setIsSubmittingSearch(isSubmittingSearch)),
-});
-
-
-export default connect(mapStateToProps, mapDispatchToProps)(DiscoveryQueryBuilder);
+export default DiscoveryQueryBuilder;
diff --git a/src/components/discovery/DiscoverySearchCondition.js b/src/components/discovery/DiscoverySearchCondition.js
index 2d3acd690..d54fae9c7 100644
--- a/src/components/discovery/DiscoverySearchCondition.js
+++ b/src/components/discovery/DiscoverySearchCondition.js
@@ -8,11 +8,10 @@ import SchemaTreeSelect from "../schema_trees/SchemaTreeSelect";
import { constFn, id, nop } from "@/utils/misc";
import { DEFAULT_SEARCH_PARAMETERS, OP_EQUALS, OPERATION_TEXT, UI_SUPPORTED_OPERATIONS } from "@/utils/search";
-
const BOOLEAN_OPTIONS = ["true", "false"];
const NEGATE_SELECT_OPTIONS = [
- { value: "pos", label: "is" },
- { value: "neg", label: "is not" },
+ { value: "pos", label: "is" },
+ { value: "neg", label: "is not" },
];
const DATA_TYPE_FIELD_WIDTH = 224;
const NEGATION_WIDTH = 88;
@@ -20,196 +19,202 @@ const OPERATION_WIDTH = 116;
const CLOSE_WIDTH = 50;
const styles = {
- conditionContainer: { width: "100%" },
- schemaTreeSelect: {
- float: "left",
- width: `${DATA_TYPE_FIELD_WIDTH}px`,
- borderTopRightRadius: "0",
- borderBottomRightRadius: "0",
- },
- negationSelect: { width: `${NEGATION_WIDTH}px`, float: "left" },
- operationSelect: { width: `${OPERATION_WIDTH}px`, float: "left" },
- closeButton: { width: `${CLOSE_WIDTH}px` },
+ conditionContainer: { width: "100%" },
+ schemaTreeSelect: {
+ float: "left",
+ width: `${DATA_TYPE_FIELD_WIDTH}px`,
+ borderTopRightRadius: "0",
+ borderBottomRightRadius: "0",
+ },
+ negationSelect: { width: `${NEGATION_WIDTH}px`, float: "left" },
+ operationSelect: { width: `${OPERATION_WIDTH}px`, float: "left" },
+ closeButton: { width: `${CLOSE_WIDTH}px` },
};
-const toStringOrNull = x => x === null ? null : x.toString();
-
-export const getSchemaTypeTransformer = type => {
- switch (type) {
- case "integer":
- return [s => parseInt(s, 10), toStringOrNull];
- case "number":
- return [s => parseFloat(s), toStringOrNull];
- case "boolean":
- return [s => s === "true", toStringOrNull];
- case "null":
- return [constFn(null), constFn("null")];
- default:
- return [id, id];
- }
+const toStringOrNull = (x) => (x === null ? null : x.toString());
+
+export const getSchemaTypeTransformer = (type) => {
+ switch (type) {
+ case "integer":
+ return [(s) => parseInt(s, 10), toStringOrNull];
+ case "number":
+ return [(s) => parseFloat(s), toStringOrNull];
+ case "boolean":
+ return [(s) => s === "true", toStringOrNull];
+ case "null":
+ return [constFn(null), constFn("null")];
+ default:
+ return [id, id];
+ }
};
const DEFAULT_FIELD_SCHEMA = {
- search: {...DEFAULT_SEARCH_PARAMETERS},
+ search: { ...DEFAULT_SEARCH_PARAMETERS },
};
const fieldStateWithDefaults = ({ field, fieldSchema, negated, operation, searchValue }) => ({
- field: field ?? undefined,
- fieldSchema: fieldSchema ?? DEFAULT_FIELD_SCHEMA,
- negated: negated ?? false,
- operation: operation ?? OP_EQUALS,
- searchValue: searchValue ?? "",
+ field: field ?? undefined,
+ fieldSchema: fieldSchema ?? DEFAULT_FIELD_SCHEMA,
+ negated: negated ?? false,
+ operation: operation ?? OP_EQUALS,
+ searchValue: searchValue ?? "",
});
const DiscoverySearchCondition = ({ dataType, value, onChange, onFieldChange, isExcluded, onRemoveClick }) => {
- const [fieldState, setFieldState] = useState(fieldStateWithDefaults(value));
-
- useEffect(() => {
- if (value) {
- setFieldState(fieldStateWithDefaults(value));
- }
- }, [value]);
-
- const handleChange = useCallback((change) => {
- const newState = {...fieldState, ...change};
- if (value === undefined) setFieldState(newState);
- if (onChange) onChange(newState);
- }, [fieldState, onChange]);
-
- const { field, fieldSchema, operation, negated, searchValue } = fieldState;
-
- const handleSelectedFieldChange = useCallback(
- ({ selected: selectedField, schema: selectedFieldSchema }) => {
- if (field === selectedField) return;
-
- const selectedFieldOperations = selectedFieldSchema.search?.operations ?? [];
-
- const change = {
- field: selectedField,
- fieldSchema: selectedFieldSchema,
- searchValue: "", // Clear search value if the field changes
- // If the new field doesn't have our previously-selected operation, we replace it with the first valid
- // operation for the newly-selected field:
- operation: selectedFieldOperations.includes(operation) ? operation : selectedFieldOperations[0],
- };
-
- (onFieldChange ?? nop)(change);
- handleChange(change);
- },
- [handleChange, onFieldChange, field, operation]);
-
- const handleNegation = useCallback((v) => {
- handleChange({ negated: (v === true || v === "neg") });
- }, [handleChange]);
-
- const handleOperation = useCallback((v) => handleChange({ operation: v }), [handleChange]);
-
- const handleSearchValue = useCallback((e) => {
- handleChange({ searchValue: getSchemaTypeTransformer(fieldSchema.type)[0](e.target.value) });
- }, [handleChange, fieldSchema]);
-
- const handleSearchSelectValue = useCallback((sv) => {
- handleChange({ searchValue: getSchemaTypeTransformer(fieldSchema.type)[0](sv) });
- }, [handleChange, fieldSchema]);
-
- const searchValueTransformed = useMemo(
- () => fieldSchema ? getSchemaTypeTransformer(fieldSchema.type)[1](searchValue) : null,
- [fieldSchema, searchValue]);
-
- const operations = useMemo(() => fieldSchema.search?.operations ?? [], [fieldSchema]);
- const equalsOnly = useMemo(() => operations.includes(OP_EQUALS) && operations.length === 1, [operations]);
- const operationOptions = useMemo(
- () => operations
- .filter((o) => UI_SUPPORTED_OPERATIONS.includes(o))
- .map((o) => ({ value: o, label: OPERATION_TEXT[o] ?? o })),
- [operations]);
-
- const { canNegate, required, type: fieldSchemaSearchType } = fieldSchema.search;
- const canRemove = !(fieldSchemaSearchType === "single" && required);
-
- // Subtract 1 from different elements' widths due to -1 margin-left
- const valueWidth = DATA_TYPE_FIELD_WIDTH
- + (canNegate ? NEGATION_WIDTH - 1 : 0)
- + (equalsOnly ? 0 : OPERATION_WIDTH - 1)
- + (canRemove ? CLOSE_WIDTH - 1 : 0);
-
- const rhsInput = useMemo(() => {
- const inputStyle = { width: `calc(100% - ${valueWidth}px)` };
-
- if (fieldSchema.hasOwnProperty("enum") || fieldSchema.type === "boolean") {
- // Prefix select keys in case there's a "blank" item in the enum, which throws an error
- return (
- o.label.toLocaleLowerCase().includes(i.toLocaleLowerCase())}
- options={(fieldSchema.type === "boolean" ? BOOLEAN_OPTIONS : fieldSchema.enum).map((v) => ({
- value: v,
- label: v,
- }))}
- />
- );
- }
-
- return (
-
- );
- }, [fieldSchema, valueWidth, handleSearchSelectValue, handleSearchValue, searchValueTransformed]);
-
- if (!fieldSchema) return
;
-
- return (
-
-
- {canNegate && ( // Negation
-
- )}
- {!equalsOnly && ( // Operation select
-
- )}
- {rhsInput}
- {canRemove && ( // Condition removal button
- }
- style={styles.closeButton}
- onClick={onRemoveClick ?? nop}
- />
- )}
-
- );
+ const [fieldState, setFieldState] = useState(fieldStateWithDefaults(value));
+
+ useEffect(() => {
+ if (value) {
+ setFieldState(fieldStateWithDefaults(value));
+ }
+ }, [value]);
+
+ const handleChange = useCallback(
+ (change) => {
+ const newState = { ...fieldState, ...change };
+ if (value === undefined) setFieldState(newState);
+ if (onChange) onChange(newState);
+ },
+ [fieldState, onChange, value],
+ );
+
+ const { field, fieldSchema, operation, negated, searchValue } = fieldState;
+
+ const handleSelectedFieldChange = useCallback(
+ ({ selected: selectedField, schema: selectedFieldSchema }) => {
+ if (field === selectedField) return;
+
+ const selectedFieldOperations = selectedFieldSchema.search?.operations ?? [];
+
+ const change = {
+ field: selectedField,
+ fieldSchema: selectedFieldSchema,
+ searchValue: "", // Clear search value if the field changes
+ // If the new field doesn't have our previously-selected operation, we replace it with the first valid
+ // operation for the newly-selected field:
+ operation: selectedFieldOperations.includes(operation) ? operation : selectedFieldOperations[0],
+ };
+
+ (onFieldChange ?? nop)(change);
+ handleChange(change);
+ },
+ [handleChange, onFieldChange, field, operation],
+ );
+
+ const handleNegation = useCallback(
+ (v) => {
+ handleChange({ negated: v === true || v === "neg" });
+ },
+ [handleChange],
+ );
+
+ const handleOperation = useCallback((v) => handleChange({ operation: v }), [handleChange]);
+
+ const handleSearchValue = useCallback(
+ (e) => {
+ handleChange({ searchValue: getSchemaTypeTransformer(fieldSchema.type)[0](e.target.value) });
+ },
+ [handleChange, fieldSchema],
+ );
+
+ const handleSearchSelectValue = useCallback(
+ (sv) => {
+ handleChange({ searchValue: getSchemaTypeTransformer(fieldSchema.type)[0](sv) });
+ },
+ [handleChange, fieldSchema],
+ );
+
+ const searchValueTransformed = useMemo(
+ () => (fieldSchema ? getSchemaTypeTransformer(fieldSchema.type)[1](searchValue) : null),
+ [fieldSchema, searchValue],
+ );
+
+ const operations = useMemo(() => fieldSchema.search?.operations ?? [], [fieldSchema]);
+ const equalsOnly = useMemo(() => operations.includes(OP_EQUALS) && operations.length === 1, [operations]);
+ const operationOptions = useMemo(
+ () =>
+ operations
+ .filter((o) => UI_SUPPORTED_OPERATIONS.includes(o))
+ .map((o) => ({ value: o, label: OPERATION_TEXT[o] ?? o })),
+ [operations],
+ );
+
+ const { canNegate, required, type: fieldSchemaSearchType } = fieldSchema.search;
+ const canRemove = !(fieldSchemaSearchType === "single" && required);
+
+ // Subtract 1 from different elements' widths due to -1 margin-left
+ const valueWidth =
+ DATA_TYPE_FIELD_WIDTH +
+ (canNegate ? NEGATION_WIDTH - 1 : 0) +
+ (equalsOnly ? 0 : OPERATION_WIDTH - 1) +
+ (canRemove ? CLOSE_WIDTH - 1 : 0);
+
+ const rhsInput = useMemo(() => {
+ const inputStyle = { width: `calc(100% - ${valueWidth}px)` };
+
+ if (fieldSchema.hasOwnProperty("enum") || fieldSchema.type === "boolean") {
+ // Prefix select keys in case there's a "blank" item in the enum, which throws an error
+ return (
+ o.label.toLocaleLowerCase().includes(i.toLocaleLowerCase())}
+ options={(fieldSchema.type === "boolean" ? BOOLEAN_OPTIONS : fieldSchema.enum).map((v) => ({
+ value: v,
+ label: v,
+ }))}
+ />
+ );
+ }
+
+ return ;
+ }, [fieldSchema, valueWidth, handleSearchSelectValue, handleSearchValue, searchValueTransformed]);
+
+ if (!fieldSchema) return
;
+
+ return (
+
+
+ {canNegate && ( // Negation
+
+ )}
+ {!equalsOnly && ( // Operation select
+
+ )}
+ {rhsInput}
+ {canRemove && ( // Condition removal button
+ } style={styles.closeButton} onClick={onRemoveClick ?? nop} />
+ )}
+
+ );
};
DiscoverySearchCondition.propTypes = {
- dataType: PropTypes.object,
- isExcluded: PropTypes.func,
- value: PropTypes.object,
- onFieldChange: PropTypes.func,
- onChange: PropTypes.func,
- onRemoveClick: PropTypes.func,
+ dataType: PropTypes.object,
+ isExcluded: PropTypes.func,
+ value: PropTypes.object,
+ onFieldChange: PropTypes.func,
+ onChange: PropTypes.func,
+ onRemoveClick: PropTypes.func,
};
export default DiscoverySearchCondition;
diff --git a/src/components/discovery/DiscoverySearchForm.js b/src/components/discovery/DiscoverySearchForm.js
index 9bf63c149..63e08e3a3 100644
--- a/src/components/discovery/DiscoverySearchForm.js
+++ b/src/components/discovery/DiscoverySearchForm.js
@@ -4,384 +4,396 @@ import PropTypes from "prop-types";
import { Button, Dropdown, Form, Tooltip } from "antd";
import { PlusOutlined } from "@ant-design/icons";
-import {getFields, getFieldSchema} from "@/utils/schema";
+import { getFields, getFieldSchema } from "@/utils/schema";
import {
- DEFAULT_SEARCH_PARAMETERS,
- OP_CASE_INSENSITIVE_CONTAINING,
- OP_EQUALS,
- OP_GREATER_THAN_OR_EQUAL,
- OP_LESS_THAN_OR_EQUAL,
- searchUiMappings,
+ DEFAULT_SEARCH_PARAMETERS,
+ OP_CASE_INSENSITIVE_CONTAINING,
+ OP_EQUALS,
+ OP_GREATER_THAN_OR_EQUAL,
+ OP_LESS_THAN_OR_EQUAL,
+ searchUiMappings,
} from "@/utils/search";
-import DiscoverySearchCondition, {getSchemaTypeTransformer} from "./DiscoverySearchCondition";
+import DiscoverySearchCondition, { getSchemaTypeTransformer } from "./DiscoverySearchCondition";
import VariantSearchHeader from "./VariantSearchHeader";
const TOOLTIP_DELAY_SECONDS = 0.8;
// required by ui not precisely the same as required in spec, possible TODO
const VARIANT_REQUIRED_FIELDS = [
- "[dataset item].assembly_id",
- "[dataset item].chromosome",
- "[dataset item].start",
- "[dataset item].end",
+ "[dataset item].assembly_id",
+ "[dataset item].chromosome",
+ "[dataset item].start",
+ "[dataset item].end",
];
const VARIANT_OPTIONAL_FIELDS = [
- "[dataset item].calls.[item].genotype_type",
- "[dataset item].alternative",
- "[dataset item].reference",
+ "[dataset item].calls.[item].genotype_type",
+ "[dataset item].alternative",
+ "[dataset item].reference",
];
const updateVariantConditions = (conditions, fieldName, searchValue) =>
- conditions.map((c) => c.field === fieldName ? { ...c, searchValue } : c);
+ conditions.map((c) => (c.field === fieldName ? { ...c, searchValue } : c));
const conditionValidator = (rule, { field, fieldSchema, searchValue }) => {
- if (field === undefined) {
- return Promise.reject("A field must be specified for this search condition.");
- }
-
- const transformedSearchValue = getSchemaTypeTransformer(fieldSchema.type)[1](searchValue);
- const isEnum = fieldSchema.hasOwnProperty("enum");
- const isString = fieldSchema.type === "string";
-
- // noinspection JSCheckFunctionSignatures
- if (
- !VARIANT_OPTIONAL_FIELDS.includes(field) &&
- (transformedSearchValue === null ||
- (!isEnum && !transformedSearchValue) ||
- (!isEnum && isString && !transformedSearchValue.trim()) || // Forbid whitespace-only free-text searches
- (isEnum && !fieldSchema.enum.includes(transformedSearchValue)))
- ) {
- return Promise.reject(`This field must have a value: ${field}`);
- }
-
- return Promise.resolve();
+ if (field === undefined) {
+ return Promise.reject("A field must be specified for this search condition.");
+ }
+
+ const transformedSearchValue = getSchemaTypeTransformer(fieldSchema.type)[1](searchValue);
+ const isEnum = fieldSchema.hasOwnProperty("enum");
+ const isString = fieldSchema.type === "string";
+
+ // noinspection JSCheckFunctionSignatures
+ if (
+ !VARIANT_OPTIONAL_FIELDS.includes(field) &&
+ (transformedSearchValue === null ||
+ (!isEnum && !transformedSearchValue) ||
+ (!isEnum && isString && !transformedSearchValue.trim()) || // Forbid whitespace-only free-text searches
+ (isEnum && !fieldSchema.enum.includes(transformedSearchValue)))
+ ) {
+ return Promise.reject(`This field must have a value: ${field}`);
+ }
+
+ return Promise.resolve();
};
-const CONDITION_RULES = [
- { validator: conditionValidator },
-];
+const CONDITION_RULES = [{ validator: conditionValidator }];
const CONDITION_LABEL_COL = {
- lg: { span: 24 },
- xl: { span: 4 },
- xxl: { span: 3 },
+ lg: { span: 24 },
+ xl: { span: 4 },
+ xxl: { span: 3 },
};
const CONDITION_WRAPPER_COL = {
- lg: { span: 24 },
- xl: { span: 20 },
- xxl: { span: 18 },
+ lg: { span: 24 },
+ xl: { span: 20 },
+ xxl: { span: 18 },
};
const ADD_CONDITION_WRAPPER_COL = {
- xl: { span: 24 },
- xxl: { offset: 3, span: 18 },
+ xl: { span: 24 },
+ xxl: { offset: 3, span: 18 },
};
const conditionLabel = (i) => `Condition ${i + 1}`;
-
const PhenopacketDropdownOption = ({ option: { path, ui_name: uiName }, getDataTypeFieldSchema }) => (
-
- {uiName}
-
+
+ {uiName}
+
);
PhenopacketDropdownOption.propTypes = {
- option: PropTypes.shape({
- path: PropTypes.string,
- ui_name: PropTypes.string,
- }),
- getDataTypeFieldSchema: PropTypes.func,
+ option: PropTypes.shape({
+ path: PropTypes.string,
+ ui_name: PropTypes.string,
+ }),
+ getDataTypeFieldSchema: PropTypes.func,
};
-
const DiscoverySearchForm = ({ onChange, dataType, setFormRef, handleVariantHiddenFieldChange }) => {
- const [form] = Form.useForm();
-
- useEffect(() => {
- if (setFormRef) setFormRef(form);
- }, [form, setFormRef]);
-
- const [conditionsHelp, setConditionsHelp] = useState({});
- const initialValues = useRef({});
-
- const isPhenopacketSearch = dataType.id === "phenopacket";
- const isVariantSearch = dataType.id === "variant";
-
- const getDataTypeFieldSchema = useCallback((field) => {
- const fs = field ? getFieldSchema(dataType.schema, field) : {};
- return {
- ...fs,
- search: { ...DEFAULT_SEARCH_PARAMETERS, ...(fs.search ?? {}) },
- };
- }, [dataType]);
-
- const updateHelpFromFieldChange = useCallback((k, change) => {
- setConditionsHelp({
- ...conditionsHelp,
- [k]: change.fieldSchema.description, // can be undefined
- });
- }, [conditionsHelp]);
-
- const getInitialOperator = useCallback((field, fieldSchema) => {
- if (!isVariantSearch) {
- return fieldSchema?.search?.operations?.includes(OP_CASE_INSENSITIVE_CONTAINING)
- ? OP_CASE_INSENSITIVE_CONTAINING
- : OP_EQUALS;
- }
-
- switch (field) {
- case "[dataset item].start":
- return OP_GREATER_THAN_OR_EQUAL;
-
- case "[dataset item].end":
- return OP_LESS_THAN_OR_EQUAL;
-
- // assemblyID, chromosome, genotype, ref, alt
- default:
- return OP_EQUALS;
- }
- }, [isVariantSearch]);
-
- const getConditionsArray = useCallback(() => form.getFieldValue("conditions") ?? [], [form]);
-
- const addCondition = useCallback((field = undefined) => {
- const existingConditions = getConditionsArray();
-
- const fieldSchema = getDataTypeFieldSchema(field);
-
- const newKey = existingConditions.length;
- updateHelpFromFieldChange(newKey, { fieldSchema });
-
- const fieldInitialValue = {
- field,
- fieldSchema,
- negated: false,
- operation: getInitialOperator(field, fieldSchema),
- searchValue: "",
- };
-
- initialValues.current = {
- ...initialValues.current,
- conditions: [...(initialValues.current.conditions ?? []), fieldInitialValue],
- };
-
- form.setFieldsValue({
- conditions: [...existingConditions, fieldInitialValue],
- });
-
- }, [conditionsHelp, getConditionsArray]);
-
- const removeCondition = useCallback((k) => {
- form.setFieldsValue({
- conditions: getConditionsArray().filter((_, i) => k !== i),
- });
- }, [form, getConditionsArray]);
-
- const cannotBeUsed = useCallback(
- (fieldString) => getFieldSchema(dataType.schema, fieldString).search?.type === "single",
- [dataType]);
-
- // On initial component mounting, add required fields (or for variants, add all fields) to the form if no conditions
- // have already been added.
- useEffect(() => {
- if (getConditionsArray().length !== 0) return;
-
- const requiredFields = dataType
- ? getFields(dataType.schema).filter(
- (f) => getFieldSchema(dataType.schema, f).search?.required ?? false)
- : [];
-
- isVariantSearch
- ? [...VARIANT_REQUIRED_FIELDS, ...VARIANT_OPTIONAL_FIELDS].map((c) => addCondition(c))
- // currently unused, since only variant search has required fields:
- : requiredFields.map((c) => addCondition(c));
- }, []);
-
- // methods for user-friendly variant search
-
- // fill hidden variant forms according to input in user-friendly variant search
- const addVariantSearchValues = useCallback((values) => {
- const { assemblyId, chrom, start, end, genotypeType, ref, alt } = values;
-
- let updatedConditionsArray = getConditionsArray();
-
- if (updatedConditionsArray === undefined) {
- return;
- }
-
- // some fields may be undefined, so check for key names, not values
-
- if (values.hasOwnProperty("assemblyId")) {
- updatedConditionsArray = updateVariantConditions(
- updatedConditionsArray, "[dataset item].assembly_id", assemblyId);
- }
-
- if (values.hasOwnProperty("chrom") && values.hasOwnProperty("start") && values.hasOwnProperty("end")) {
- updatedConditionsArray = updateVariantConditions(
- updatedConditionsArray, "[dataset item].chromosome", chrom);
- updatedConditionsArray = updateVariantConditions(updatedConditionsArray, "[dataset item].start", start);
- updatedConditionsArray = updateVariantConditions(updatedConditionsArray, "[dataset item].end", end);
- }
-
- if (values.hasOwnProperty("genotypeType")) {
- updatedConditionsArray = updateVariantConditions(
- updatedConditionsArray, "[dataset item].calls.[item].genotype_type", genotypeType);
- }
-
- if (values.hasOwnProperty("ref")) {
- updatedConditionsArray = updateVariantConditions(updatedConditionsArray, "[dataset item].reference", ref);
- }
-
- if (values.hasOwnProperty("alt")) {
- updatedConditionsArray = updateVariantConditions(
- updatedConditionsArray, "[dataset item].alternative", alt);
- }
-
- handleVariantHiddenFieldChange([{
- name: ["conditions"],
- value: updatedConditionsArray,
- }]);
- }, [getConditionsArray, handleVariantHiddenFieldChange]);
-
- const getHelpText = useCallback(
- (key) => isVariantSearch ? "" : conditionsHelp[key] ?? undefined,
- [isVariantSearch, conditionsHelp]);
-
- const addConditionFromDropdown = useCallback(
- ({ key }) => addCondition(`[dataset item].${key}`),
- [addCondition]);
-
- const phenopacketsSearchOptions = useMemo(() => {
- const phenopacketSearchOptions = searchUiMappings.phenopacket;
- const subjectOptions = Object.values(phenopacketSearchOptions.subject);
- const phenotypicFeaturesOptions = Object.values(phenopacketSearchOptions.phenotypic_features);
- const biosamplesOptions = Object.values(phenopacketSearchOptions.biosamples);
- const diseasesOptions = Object.values(phenopacketSearchOptions.diseases);
- const interpretationOptions = Object.values(phenopacketSearchOptions.interpretations);
- const measurementsOptions = Object.values(phenopacketSearchOptions.measurements);
- const medicalActionsOptions = Object.values(phenopacketSearchOptions.medical_actions);
-
- const optionsMenuItems = (options) =>
- options.map((o) => ({
- key: o.path,
- label: ,
- }));
-
- // longest title padded with marginRight
- return {
- style: { display: "inline-block" },
- onClick: addConditionFromDropdown,
- items: [
- {
- key: "subject",
- label: Subject ,
- children: optionsMenuItems(subjectOptions),
- },
- {
- key: "phenotypicFeatures",
- label: Phenotypic Features ,
- children: optionsMenuItems(phenotypicFeaturesOptions),
- },
- {
- key: "biosamples",
- label: Biosamples ,
- children: optionsMenuItems(biosamplesOptions),
- },
- {
- key: "measurements",
- label: Measurements ,
- children: optionsMenuItems(measurementsOptions),
- },
- {
- key: "diseases",
- label: Diseases ,
- children: optionsMenuItems(diseasesOptions),
- },
- {
- key: "interpretations",
- label: Interpretations ,
- children: optionsMenuItems(interpretationOptions),
- },
- {
- key: "medicalActions",
- label: Medical Actions ,
- children: optionsMenuItems(medicalActionsOptions),
- },
- ],
- };
- }, [getDataTypeFieldSchema]);
-
- const existingUniqueFields = useMemo(
- () =>
- getConditionsArray()
- .map(({ field: f }) => f)
- .filter((f) => f !== undefined && cannotBeUsed(f)),
- [getConditionsArray, cannotBeUsed]);
-
- return (
-
+ {
+ /** @return React.ReactNode[] */
+ (fields) =>
+ fields.map((field, i) => (
+
+ existingUniqueFields.includes(f)}
+ onFieldChange={(change) => updateHelpFromFieldChange(i, change)}
+ onRemoveClick={() => removeCondition(i)}
+ />
+
+ ))
+ }
+
+
+ {isPhenopacketSearch ? (
+
+ }>
+ Add condition
+
+
) : (
- <>
-
- {
- /** @return React.ReactNode[] */
- (fields) => (
- fields.map((field, i) => (
-
- existingUniqueFields.includes(f)}
- onFieldChange={(change) => updateHelpFromFieldChange(i, change)}
- onRemoveClick={() => removeCondition(i)}
- />
-
- ))
- )
- }
-
-
- {isPhenopacketSearch ? (
-
- }>
- Add condition
-
-
- ) : (
- addCondition()}
- style={{ width: "100%" }}
- icon={ }>
- Add condition
-
- )}
-
- >
+ addCondition()} style={{ width: "100%" }} icon={ }>
+ Add condition
+
)}
-
- );
+
+ >
+ )}
+
+ );
};
DiscoverySearchForm.propTypes = {
- form: PropTypes.object,
- onChange: PropTypes.func,
- dataType: PropTypes.object, // TODO: Shape?
- setFormRef: PropTypes.func,
- handleVariantHiddenFieldChange: PropTypes.func.isRequired,
+ form: PropTypes.object,
+ onChange: PropTypes.func,
+ dataType: PropTypes.object, // TODO: Shape?
+ setFormRef: PropTypes.func,
+ handleVariantHiddenFieldChange: PropTypes.func.isRequired,
};
export default DiscoverySearchForm;
diff --git a/src/components/discovery/LocusSearch.js b/src/components/discovery/LocusSearch.js
index 727256afc..14a0f51bd 100644
--- a/src/components/discovery/LocusSearch.js
+++ b/src/components/discovery/LocusSearch.js
@@ -1,129 +1,141 @@
-import React, {useEffect, useState} from "react";
-import { AutoComplete } from "antd";
-import { useDispatch, useSelector } from "react-redux";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { AutoComplete, Tag } from "antd";
import PropTypes from "prop-types";
-import { performGohanGeneSearchIfPossible } from "@/modules/discovery/actions";
-
-// TODOs:
-// style options
+import { useGeneNameSearch, useReferenceGenomes } from "@/modules/reference/hooks";
const parsePosition = (value) => {
- const parse = /(?:CHR|chr)([0-9]{1,2}|X|x|Y|y|M|m):(\d+)-(\d+)/;
- const result = parse.exec(value);
+ const parse = /(?:CHR|chr)([0-9]{1,2}|X|x|Y|y|M|m):(\d+)-(\d+)/;
+ const result = parse.exec(value);
- if (!result) {
- return {chrom: null, start: null, end: null};
- }
+ if (!result) {
+ return { chrom: null, start: null, end: null };
+ }
- const chrom = result[1].toUpperCase(); //for eg 'x', has no effect on numbers
- const start = Number(result[2]);
- const end = Number(result[3]);
- return {chrom, start, end};
+ const chrom = result[1].toUpperCase(); //for eg 'x', has no effect on numbers
+ const start = Number(result[2]);
+ const end = Number(result[3]);
+ return { chrom, start, end };
};
+const looksLikePositionNotation = (value) => !value.includes(" ") && value.includes(":");
+
+const LocusSearch = ({ assemblyId, addVariantSearchValues, handleLocusChange, setLocusValidity }) => {
+ const referenceGenomes = useReferenceGenomes();
+ const [autoCompleteOptions, setAutoCompleteOptions] = useState([]);
+ const [inputValue, setInputValue] = useState("");
+
+ const showAutoCompleteOptions = useMemo(
+ () =>
+ !!referenceGenomes.itemsByID[assemblyId]?.gff3_gz && inputValue.length && !looksLikePositionNotation(inputValue),
+ [referenceGenomes, assemblyId, inputValue],
+ );
+
+ const handlePositionNotation = useCallback(
+ (value) => {
+ const { chrom, start, end } = parsePosition(value);
+ setLocusValidity(chrom && start && end);
+ addVariantSearchValues({ chrom, start, end });
+ },
+ [setLocusValidity, addVariantSearchValues],
+ );
+
+ useEffect(() => {
+ if (looksLikePositionNotation(inputValue)) {
+ handlePositionNotation(inputValue);
+
+ // let user finish typing position before showing error
+ setLocusValidity(true);
+
+ setAutoCompleteOptions([]);
+ }
+ }, [inputValue, handlePositionNotation, setLocusValidity]);
+
+ const handleChange = useCallback((value) => {
+ setInputValue(value);
+ }, []);
+
+ const { data: geneSearchResults } = useGeneNameSearch(assemblyId, showAutoCompleteOptions ? inputValue : null);
+
+ const handleOnBlur = useCallback(() => {
+ // antd has no "select on tab" option
+ // so when tabbing away, handle the current contents of the input
+ // input can be one of three cases:
+ // - an autocomplete selection
+ // - position notation
+ // - incorrect
+
+ // incorrect values are passed as null for handling elsewhere
+
+ const isAutoCompleteOption = inputValue.includes(" ");
+ const isPositionNotation = inputValue.includes(":") && !isAutoCompleteOption;
-const LocusSearch = ({assemblyId, addVariantSearchValues, handleLocusChange, setLocusValidity}) => {
- const [autoCompleteOptions, setAutoCompleteOptions] = useState([]);
- const geneSearchResults = useSelector((state) => state.discovery.geneNameSearchResponse);
- const [inputValue, setInputValue] = useState("");
- const dispatch = useDispatch();
-
- const showAutoCompleteOptions = assemblyId === "GRCh37" || assemblyId === "GRCh38";
-
- const handlePositionNotation = (value) => {
- const {chrom, start, end} = parsePosition(value);
- setLocusValidity(chrom && start && end);
- addVariantSearchValues({chrom, start, end});
- };
-
- const handleChange = (value) => {
-
- setInputValue(value);
-
- if (!value.includes(" ") && value.includes(":")) {
- handlePositionNotation(value);
-
- // let user finish typing position before showing error
- setLocusValidity(true);
-
- setAutoCompleteOptions([]);
- return;
- }
-
- if (!value.length || !showAutoCompleteOptions) {
- return;
- }
-
- dispatch(performGohanGeneSearchIfPossible(value, assemblyId));
- };
-
- const handleOnBlur = () => {
- // antd has no "select on tab" option
- // so when tabbing away, handle the current contents of the input
- // input can be one of three cases:
- // - an autocomplete selection
- // - position notation
- // - incorrect
-
- // incorrect values are passed as null for handling elsewhere
-
- const isAutoCompleteOption = inputValue.includes(" ");
- const isPositionNotation = inputValue.includes(":") && !isAutoCompleteOption;
-
- if (!(isAutoCompleteOption || isPositionNotation)) {
- handleLocusChange({chrom: null, start: null, end: null});
- addVariantSearchValues({chrom: null, start: null, end: null});
- return;
- }
-
- if (isPositionNotation) {
- const position = parsePosition(inputValue);
- handleLocusChange(position);
- addVariantSearchValues(position);
- }
- };
-
- const handleSelect = (value, option) => {
- setInputValue(value);
- const locus = option.locus;
-
- // may not need error checking here, since this is user selection, not user input
- if (!locus) {
- console.warn("handleSelect: locus was false-y; got option:", option);
- return;
- }
-
- addVariantSearchValues(locus);
- handleLocusChange(locus);
- };
-
- useEffect(() => {
- setAutoCompleteOptions(
- (geneSearchResults ?? [])
- .sort((a, b) => (a.name > b.name) ? 1 : -1)
- .map((g) => ({
- value: `${g.name} (${g.chrom}:${g.start}-${g.end})`,
- locus: { "chrom": g.chrom, "start": g.start, "end": g.end },
- })),
- );
- }, [geneSearchResults]);
-
- return (
-
+ if (!(isAutoCompleteOption || isPositionNotation)) {
+ handleLocusChange({ chrom: null, start: null, end: null });
+ addVariantSearchValues({ chrom: null, start: null, end: null });
+ return;
+ }
+
+ if (isPositionNotation) {
+ const position = parsePosition(inputValue);
+ handleLocusChange(position);
+ addVariantSearchValues(position);
+ }
+ }, [inputValue, handleLocusChange, addVariantSearchValues]);
+
+ const handleSelect = useCallback(
+ (value, option) => {
+ setInputValue(value);
+ const locus = option.locus;
+
+ // may not need error checking here, since this is user selection, not user input
+ if (!locus) {
+ console.warn("handleSelect: locus was false-y; got option:", option);
+ return;
+ }
+
+ addVariantSearchValues(locus);
+ handleLocusChange(locus);
+ },
+ [addVariantSearchValues, handleLocusChange],
+ );
+
+ useEffect(() => {
+ setAutoCompleteOptions(
+ (geneSearchResults ?? [])
+ .sort((a, b) => (a.feature_name > b.feature_name ? 1 : -1))
+ .map((g) => ({
+ value: `${g.feature_name} (${g.contig_name}:${g.entries[0].start_pos}-${g.entries[0].end_pos})`,
+ label: (
+ <>
+ {g.feature_name} ({g.contig_name}:{g.entries[0].start_pos}-{g.entries[0].end_pos}
+ )
+ {g.feature_type}
+ >
+ ),
+ locus: {
+ chrom: g.contig_name.replace("chr", ""), // Gohan doesn't accept chr# notation
+ start: g.entries[0].start_pos,
+ end: g.entries[0].end_pos,
+ },
+ })),
);
+ }, [geneSearchResults]);
+
+ return (
+
+ );
};
LocusSearch.propTypes = {
- assemblyId: PropTypes.string,
- addVariantSearchValues: PropTypes.func,
- handleLocusChange: PropTypes.func,
- setLocusValidity: PropTypes.func,
+ assemblyId: PropTypes.string,
+ addVariantSearchValues: PropTypes.func,
+ handleLocusChange: PropTypes.func,
+ setLocusValidity: PropTypes.func,
};
-
export default LocusSearch;
diff --git a/src/components/discovery/VariantSearchHeader.js b/src/components/discovery/VariantSearchHeader.js
index 8d37209a2..6c8a9cec6 100644
--- a/src/components/discovery/VariantSearchHeader.js
+++ b/src/components/discovery/VariantSearchHeader.js
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback } from "react";
+import React, { useState, useEffect, useCallback, useMemo } from "react";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
@@ -7,217 +7,222 @@ import { Form, Input, Select } from "antd";
import LocusSearch from "./LocusSearch";
import { notAlleleCharactersRegex } from "@/utils/misc";
+import { useGohanVariantsOverview } from "@/modules/explorer/hooks";
-
-const isValidLocus = (locus) =>
- locus.chrom !== null && locus.start !== null && locus.end !== null;
-const normalizeAlleleText = (text) =>
- text.toUpperCase().replaceAll(notAlleleCharactersRegex, "");
+const isValidLocus = (locus) => locus.chrom !== null && locus.start !== null && locus.end !== null;
+const normalizeAlleleText = (text) => text.toUpperCase().replaceAll(notAlleleCharactersRegex, "");
const containsInvalid = (text) => {
- const matches = text.toUpperCase().match(notAlleleCharactersRegex);
- return matches && matches.length > 0;
+ const matches = text.toUpperCase().match(notAlleleCharactersRegex);
+ return matches && matches.length > 0;
};
const INITIAL_FIELDS_VALIDITY = {
- "assemblyId": true,
- "locus": true,
+ assemblyId: true,
+ locus: true,
};
// Match style from DiscoverySearchForm
const LABEL_COL = { lg: { span: 24 }, xl: { span: 4 }, xxl: { span: 3 } };
const WRAPPER_COL = { lg: { span: 24 }, xl: { span: 20 }, xxl: { span: 18 } };
-
const VariantSearchHeader = ({ dataType, addVariantSearchValues }) => {
- const [refFormReceivedValidKeystroke, setRefFormReceivedValidKeystroke] = useState(true);
- const [altFormReceivedValidKeystroke, setAltFormReceivedValidKeystroke] = useState(true);
- const [activeRefValue, setActiveRefValue] = useState(null);
- const [activeAltValue, setActiveAltValue] = useState(null);
- const [assemblyId, setAssemblyId] = useState(null);
- const [locus, setLocus] = useState({ chrom: null, start: null, end: null });
- const isSubmitting = useSelector(state => state.explorer.isSubmittingSearch);
-
- // begin with required fields considered valid, so user isn't assaulted with error messages
- const [fieldsValidity, setFieldsValidity] = useState(INITIAL_FIELDS_VALIDITY);
-
- const variantsOverviewResults = useSelector((state) => state.explorer.variantsOverviewResponse);
+ const { data: variantsOverviewResults, isFetching: isFetchingVariantsOverview } = useGohanVariantsOverview();
+ const overviewAssemblyIds = useMemo(() => {
const hasAssemblyIds =
- variantsOverviewResults?.assemblyIDs !== undefined &&
- !variantsOverviewResults?.assemblyIDs.hasOwnProperty("error");
- const overviewAssemblyIds = hasAssemblyIds ? Object.keys(variantsOverviewResults?.assemblyIDs) : [];
-
+ variantsOverviewResults?.assemblyIDs !== undefined &&
+ !variantsOverviewResults?.assemblyIDs.hasOwnProperty("error");
+ return hasAssemblyIds ? Object.keys(variantsOverviewResults?.assemblyIDs) : [];
+ }, [variantsOverviewResults]);
+ const overviewAssemblyIdOptions = useMemo(
+ () => overviewAssemblyIds.map((value) => ({ value, label: value })),
+ [overviewAssemblyIds],
+ );
+
+ const [refFormReceivedValidKeystroke, setRefFormReceivedValidKeystroke] = useState(true);
+ const [altFormReceivedValidKeystroke, setAltFormReceivedValidKeystroke] = useState(true);
+ const [activeRefValue, setActiveRefValue] = useState(null);
+ const [activeAltValue, setActiveAltValue] = useState(null);
+ const [assemblyId, setAssemblyId] = useState(overviewAssemblyIds.length === 1 ? overviewAssemblyIds[0] : null);
+ const [locus, setLocus] = useState({ chrom: null, start: null, end: null });
+ const isSubmitting = useSelector((state) => state.explorer.isSubmittingSearch);
+
+ // begin with required fields considered valid, so user isn't assaulted with error messages
+ const [fieldsValidity, setFieldsValidity] = useState(INITIAL_FIELDS_VALIDITY);
+
+ const genotypeSchema = dataType.schema?.properties?.calls?.items?.properties?.genotype_type;
+ const genotypeSchemaDescription = genotypeSchema?.description;
+ const genotypeOptions = useMemo(
+ () => (genotypeSchema?.enum ?? []).map((value) => ({ value, label: value })),
+ [genotypeSchema],
+ );
+
+ const helpText = useMemo(() => {
const assemblySchema = dataType.schema?.properties?.assembly_id;
- const genotypeSchema = dataType.schema?.properties?.calls?.items?.properties?.genotype_type;
-
- const helpText = {
- "assemblyId": assemblySchema?.description,
- "genotype": genotypeSchema?.description,
- "locus": "Enter gene name (eg \"BRCA1\") or position (\"chr17:41195311-41278381\")",
- "ref/alt": "Combination of nucleotides A, C, T, and G, including N as a wildcard - i.e. AATG, CG, TNN",
+ return {
+ assemblyId: assemblySchema?.description,
+ genotype: genotypeSchemaDescription,
+ // eslint-disable-next-line quotes
+ locus: 'Enter gene name (eg "BRCA1") or position ("chr17:41195311-41278381")',
+ "ref/alt": "Combination of nucleotides A, C, T, and G, including N as a wildcard - i.e. AATG, CG, TNN",
};
-
- // custom validation since this form isn't submitted, it's just used to fill fields in hidden form
- // each field is validated individually elsewhere
- // for final validation, we only need to make sure required fields are non-empty
- const validateVariantSearchForm = useCallback(() => {
- // check assembly
- if (!assemblyId) {
- // change assemblyId help text & outline
- setFieldsValidity({ ...fieldsValidity, "assemblyId": false });
- }
-
- // check locus
- const { chrom, start, end } = locus;
- if (!chrom || !start || !end) {
- // change locus help text & outline
- setFieldsValidity({ ...fieldsValidity, "locus": false });
- }
- }, [assemblyId, locus, fieldsValidity]);
-
- useEffect(() => {
- if (isSubmitting) {
- validateVariantSearchForm();
- }
- }, [isSubmitting]);
-
- const setLocusValidity = useCallback((isValid) => {
- setFieldsValidity({ ...fieldsValidity, "locus": isValid });
- }, [fieldsValidity]);
-
- const handleLocusChange = useCallback((locus) => {
- setLocusValidity(isValidLocus(locus));
-
- // set even if invalid, so we don't keep old values
- setLocus(locus);
- }, [setLocusValidity]);
-
- const handleAssemblyIdChange = useCallback((value) => {
- addVariantSearchValues({ assemblyId: value });
- setAssemblyId(value);
- }, []);
-
- const handleGenotypeChange = useCallback((value) => {
- addVariantSearchValues({ genotypeType: value });
- }, []);
-
- const handleRefChange = useCallback((e) => {
- const latestInputValue = e.target.value;
- const normalizedRef = normalizeAlleleText(latestInputValue);
- const didValueContainInvalidChars = containsInvalid(latestInputValue);
-
- if (didValueContainInvalidChars) {
- setRefFormReceivedValidKeystroke(!didValueContainInvalidChars);
- setTimeout(() => {
- setRefFormReceivedValidKeystroke(true);
- }, 1000);
- }
- setActiveRefValue(normalizedRef);
- addVariantSearchValues({ ref: normalizedRef });
- }, []);
-
- const handleAltChange = useCallback((e) => {
- const latestInputValue = e.target.value;
- const normalizedAlt = normalizeAlleleText(latestInputValue);
- const didValueContainInvalidChars = containsInvalid(latestInputValue);
-
- if (didValueContainInvalidChars) {
- setAltFormReceivedValidKeystroke(!didValueContainInvalidChars);
- setTimeout(() => {
- setAltFormReceivedValidKeystroke(true);
- }, 1000);
- }
-
- setActiveAltValue(normalizedAlt);
- addVariantSearchValues({ alt: normalizedAlt });
- }, []);
-
+ }, [dataType, genotypeSchemaDescription]);
+
+ // custom validation since this form isn't submitted, it's just used to fill fields in hidden form
+ // each field is validated individually elsewhere
+ // for final validation, we only need to make sure required fields are non-empty
+ const validateVariantSearchForm = useCallback(() => {
+ // check assembly
+ if (!assemblyId) {
+ // change assemblyId help text & outline
+ setFieldsValidity({ ...fieldsValidity, assemblyId: false });
+ }
+
+ // check locus
+ const { chrom, start, end } = locus;
+ if (!chrom || !start || !end) {
+ // change locus help text & outline
+ setFieldsValidity({ ...fieldsValidity, locus: false });
+ }
+ }, [assemblyId, locus, fieldsValidity]);
+
+ useEffect(() => {
+ if (isSubmitting) {
+ validateVariantSearchForm();
+ }
+ }, [isSubmitting, validateVariantSearchForm]);
+
+ const setLocusValidity = useCallback(
+ (isValid) => {
+ setFieldsValidity({ ...fieldsValidity, locus: isValid });
+ },
+ [fieldsValidity],
+ );
+
+ const handleLocusChange = useCallback(
+ (locus) => {
+ setLocusValidity(isValidLocus(locus));
+
+ // set even if invalid, so we don't keep old values
+ setLocus(locus);
+ },
+ [setLocusValidity],
+ );
+
+ const handleAssemblyIdChange = useCallback(
+ (value) => {
+ addVariantSearchValues({ assemblyId: value });
+ setAssemblyId(value);
+ },
+ [addVariantSearchValues],
+ );
+
+ const handleGenotypeChange = useCallback(
+ (value) => {
+ addVariantSearchValues({ genotypeType: value });
+ },
+ [addVariantSearchValues],
+ );
+
+ const handleRefChange = useCallback(
+ (e) => {
+ const latestInputValue = e.target.value;
+ const normalizedRef = normalizeAlleleText(latestInputValue);
+ const didValueContainInvalidChars = containsInvalid(latestInputValue);
+
+ if (didValueContainInvalidChars) {
+ setRefFormReceivedValidKeystroke(!didValueContainInvalidChars);
+ setTimeout(() => {
+ setRefFormReceivedValidKeystroke(true);
+ }, 1000);
+ }
+ setActiveRefValue(normalizedRef);
+ addVariantSearchValues({ ref: normalizedRef });
+ },
+ [addVariantSearchValues],
+ );
+
+ const handleAltChange = useCallback(
+ (e) => {
+ const latestInputValue = e.target.value;
+ const normalizedAlt = normalizeAlleleText(latestInputValue);
+ const didValueContainInvalidChars = containsInvalid(latestInputValue);
+
+ if (didValueContainInvalidChars) {
+ setAltFormReceivedValidKeystroke(!didValueContainInvalidChars);
+ setTimeout(() => {
+ setAltFormReceivedValidKeystroke(true);
+ }, 1000);
+ }
+
+ setActiveAltValue(normalizedAlt);
+ addVariantSearchValues({ alt: normalizedAlt });
+ },
+ [addVariantSearchValues],
+ );
+
+ useEffect(() => {
// set default selected assemblyId if only 1 is present
- const shouldTriggerAssemblyIdChange = overviewAssemblyIds.length === 1;
- useEffect(() => {
- if (shouldTriggerAssemblyIdChange) {
- // wait some time before
- // triggering handleAssemblyIdChange to
- // allow for the form and formValues
- // in the parent element to populate
- setTimeout(function() {
- handleAssemblyIdChange(overviewAssemblyIds[0]);
- }, 500);
- }
- }, [shouldTriggerAssemblyIdChange]);
-
-
- return (
- <>
-
- ({ value, label: value }))}
- />
-
-
-
-
-
- ({ value, label: value }))}
- />
-
-
-
-
-
-
-
- >
- );
+ if (overviewAssemblyIds.length === 1) {
+ handleAssemblyIdChange(overviewAssemblyIds[0]);
+ }
+ }, [handleAssemblyIdChange, overviewAssemblyIds]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
};
VariantSearchHeader.propTypes = {
- dataType: PropTypes.object,
- addVariantSearchValues: PropTypes.func,
+ dataType: PropTypes.object,
+ addVariantSearchValues: PropTypes.func,
};
export default VariantSearchHeader;
diff --git a/src/components/display/AudioDisplay.js b/src/components/display/AudioDisplay.js
deleted file mode 100644
index c4bc092a3..000000000
--- a/src/components/display/AudioDisplay.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import React, { useEffect, useRef } from "react";
-import PropTypes from "prop-types";
-
-const AudioDisplay = ({blob}) => {
- const audioRef = useRef(null);
-
- useEffect(() => {
- if (audioRef.current && blob) {
- audioRef.current.src = URL.createObjectURL(blob);
- }
- }, [audioRef, blob]);
-
- return ;
-};
-AudioDisplay.propTypes = {
- blob: PropTypes.instanceOf(Blob),
-};
-
-export default AudioDisplay;
diff --git a/src/components/display/AudioDisplay.tsx b/src/components/display/AudioDisplay.tsx
new file mode 100644
index 000000000..8730b044e
--- /dev/null
+++ b/src/components/display/AudioDisplay.tsx
@@ -0,0 +1,22 @@
+import { useEffect, useRef } from "react";
+import { Skeleton } from "antd";
+import type { BlobDisplayProps } from "./types";
+
+const AudioDisplay = ({ contents, loading }: BlobDisplayProps) => {
+ const audioRef = useRef(null);
+
+ useEffect(() => {
+ if (audioRef.current && contents) {
+ audioRef.current.src = URL.createObjectURL(contents);
+ }
+ }, [audioRef, contents]);
+
+ return (
+ <>
+
+ {!loading && }
+ >
+ );
+};
+
+export default AudioDisplay;
diff --git a/src/components/display/CsvDisplay.js b/src/components/display/CsvDisplay.js
deleted file mode 100644
index a74a4ed18..000000000
--- a/src/components/display/CsvDisplay.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import React, { useEffect, useState } from "react";
-import PropTypes from "prop-types";
-import Papa from "papaparse";
-import { Alert } from "antd";
-
-import SpreadsheetTable, { SPREADSHEET_ROW_KEY_PROP } from "./SpreadsheetTable";
-
-const DEFAULT_COLUMN = { key: "col" };
-
-const CsvDisplay = ({ contents, loading }) => {
- const [parsedData, setParsedData] = useState([]);
- const [parseError, setParseError] = useState("");
- const [isParsing, setIsParsing] = useState(true); // Start in parsing state
- const [columns, setColumns] = useState([DEFAULT_COLUMN]);
-
- useEffect(() => {
- if (contents === undefined || contents === null) return;
-
- setIsParsing(true);
- const rows = [];
- // noinspection JSUnusedGlobalSymbols
- Papa.parse(contents, {
- worker: true,
- step: (res) => {
- if (res.errors?.length) {
- setParseError(res.errors[0].message);
- }
- rows.push(Object.fromEntries(
- [
- [SPREADSHEET_ROW_KEY_PROP, `row${rows.length}`],
- ...res.data.map((v, i) => [`col${i}`, v]),
- ]));
- },
- complete() {
- setIsParsing(false);
- if (!parseError) {
- setColumns(rows[0]
- ? Object.entries(rows[0])
- .filter(([k, _]) => k !== SPREADSHEET_ROW_KEY_PROP)
- .map((_, i) => ({ dataIndex: `col${i}` }))
- : [DEFAULT_COLUMN]);
- setParsedData(rows);
- }
- },
- });
- }, [contents]);
-
- if (parseError) {
- return ;
- }
-
- return (
-
- );
-};
-CsvDisplay.propTypes = {
- contents: PropTypes.string,
- loading: PropTypes.bool,
-};
-
-export default CsvDisplay;
diff --git a/src/components/display/CsvDisplay.tsx b/src/components/display/CsvDisplay.tsx
new file mode 100644
index 000000000..b42403528
--- /dev/null
+++ b/src/components/display/CsvDisplay.tsx
@@ -0,0 +1,85 @@
+import { useEffect, useState } from "react";
+import Papa from "papaparse";
+import { Alert } from "antd";
+
+import SpreadsheetTable, { SPREADSHEET_ROW_KEY_PROP, type SpreadsheetTableProps } from "./SpreadsheetTable";
+import type { BlobDisplayProps } from "./types";
+
+const DEFAULT_COLUMN = { key: "col" };
+
+type CsvRecord = Record;
+type CsvData = CsvRecord[];
+
+type CsvParseResult = [SpreadsheetTableProps["columns"], CsvData];
+
+const CsvDisplay = ({ contents, loading }: BlobDisplayProps) => {
+ const [parsedData, setParsedData] = useState([]);
+ const [parseError, setParseError] = useState("");
+ const [isParsing, setIsParsing] = useState(true); // Start in parsing state
+ const [columns, setColumns] = useState["columns"]>([DEFAULT_COLUMN]);
+
+ useEffect(() => {
+ if (!contents) return;
+
+ setIsParsing(true);
+
+ contents
+ .text()
+ .then(
+ (csvText) =>
+ new Promise((resolve, reject) => {
+ const rows: CsvData = [];
+
+ const innerParseErrors: string[] = [];
+
+ // noinspection JSUnusedGlobalSymbols
+ Papa.parse(csvText, {
+ worker: true,
+ step: (res) => {
+ if (res.errors?.length) {
+ innerParseErrors.push(...res.errors.map((e) => e.message));
+ }
+ rows.push(
+ Object.fromEntries([
+ [SPREADSHEET_ROW_KEY_PROP, `row${rows.length}`],
+ ...res.data.map((v, i) => [`col${i}`, v]),
+ ]),
+ );
+ },
+ complete() {
+ setIsParsing(false);
+
+ if (innerParseErrors.length) {
+ reject(new Error(`Encountered parse error(s): ${innerParseErrors.join("; ")}`));
+ } else {
+ resolve([
+ rows[0]
+ ? Object.entries(rows[0])
+ .filter(([k, _]) => k !== SPREADSHEET_ROW_KEY_PROP)
+ .map((_, i) => ({ dataIndex: `col${i}` }))
+ : [DEFAULT_COLUMN],
+ rows,
+ ]);
+ }
+ },
+ });
+ }),
+ )
+ .then(([columns, rows]: CsvParseResult) => {
+ setColumns(columns);
+ setParsedData(rows);
+ })
+ .catch((err) => {
+ setParseError(err.toString());
+ })
+ .finally(() => setIsParsing(false));
+ }, [contents]);
+
+ if (parseError) {
+ return ;
+ }
+
+ return columns={columns} dataSource={parsedData} loading={loading || isParsing} />;
+};
+
+export default CsvDisplay;
diff --git a/src/components/display/DocxDisplay.js b/src/components/display/DocxDisplay.js
deleted file mode 100644
index 1dd289bab..000000000
--- a/src/components/display/DocxDisplay.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import React, { useEffect, useMemo, useState } from "react";
-import mammoth from "mammoth/mammoth.browser";
-import { Alert, Skeleton, Spin } from "antd";
-import PropTypes from "prop-types";
-
-const MAMMOTH_OPTIONS = {
- convertImage: mammoth.images.imgElement((image) =>
- image.read("base64").then((buffer) => ({
- src: `data:${image.contentType};base64,${buffer}`,
- style: "max-width: 90%; height: auto; margin: 0.5em 5%;",
- })),
- ),
-};
-
-const styles = {
- container: {
- maxWidth: 960, // Maximum width to roughly a nice page
- overflowX: "auto",
- },
-};
-
-const DocxDisplay = ({ contents, loading }) => {
- const [parsing, setParsing] = useState(false);
- const [error, setError] = useState(null);
- const [docHTML, setDocHTML] = useState(null);
-
- useEffect(() => {
- if (!contents) return;
-
- (async () => {
- setDocHTML(null); // reset HTML contents if array buffer contents changes
- setError(null); // reset error if array buffer contents changes
- setParsing(true);
-
- try {
- const res = await mammoth.convertToHtml({ arrayBuffer: contents }, MAMMOTH_OPTIONS);
- res.messages.forEach((msg) => console.info("Received message while parsing .docx:", msg));
- setDocHTML(res.value);
- } catch (err) {
- console.error("Received error while parsing .docx:", err);
- setError(err);
- } finally {
- setParsing(false);
- }
- })();
- }, [contents]);
-
- const innerHTML = useMemo(() => ({ __html: docHTML ?? "
" }), [docHTML]);
-
- const waiting = loading || parsing;
-
- // noinspection JSValidateTypes
- return
- {waiting && }
- {error && (
-
- )}
-
- ;
-};
-DocxDisplay.propTypes = {
- contents: PropTypes.instanceOf(ArrayBuffer),
- loading: PropTypes.bool,
-};
-
-export default DocxDisplay;
diff --git a/src/components/display/DocxDisplay.tsx b/src/components/display/DocxDisplay.tsx
new file mode 100644
index 000000000..15aa30119
--- /dev/null
+++ b/src/components/display/DocxDisplay.tsx
@@ -0,0 +1,63 @@
+import { type CSSProperties, useEffect, useMemo, useState } from "react";
+import mammoth from "mammoth/mammoth.browser";
+import { Alert, Skeleton } from "antd";
+
+import type { BlobDisplayProps } from "./types";
+
+const MAMMOTH_OPTIONS = {
+ convertImage: mammoth.images.imgElement((image) =>
+ image.read("base64").then((buffer) => ({
+ src: `data:${image.contentType};base64,${buffer}`,
+ style: "max-width: 90%; height: auto; margin: 0.5em 5%;",
+ })),
+ ),
+};
+
+const styles: Record = {
+ container: {
+ maxWidth: 960, // Maximum width to roughly a nice page
+ overflowX: "auto",
+ },
+};
+
+const DocxDisplay = ({ contents, loading }: BlobDisplayProps) => {
+ const [parsing, setParsing] = useState(false);
+ const [error, setError] = useState(null);
+ const [docHTML, setDocHTML] = useState(null);
+
+ useEffect(() => {
+ if (!contents) return;
+
+ (async () => {
+ setDocHTML(null); // reset HTML contents if array buffer contents changes
+ setError(null); // reset error if array buffer contents changes
+ setParsing(true);
+
+ try {
+ const res = await mammoth.convertToHtml({ arrayBuffer: await contents.arrayBuffer() }, MAMMOTH_OPTIONS);
+ res.messages.forEach((msg) => console.info("Received message while parsing .docx:", msg));
+ setDocHTML(res.value);
+ } catch (err) {
+ console.error("Received error while parsing .docx:", err);
+ setError((err as Error).toString());
+ } finally {
+ setParsing(false);
+ }
+ })();
+ }, [contents]);
+
+ const innerHTML = useMemo(() => ({ __html: docHTML ?? "
" }), [docHTML]);
+
+ const waiting = loading || parsing;
+
+ // noinspection JSValidateTypes
+ return (
+ <>
+
+ {error && }
+
+ >
+ );
+};
+
+export default DocxDisplay;
diff --git a/src/components/display/FileDisplay.tsx b/src/components/display/FileDisplay.tsx
new file mode 100644
index 000000000..7d0c2ac6e
--- /dev/null
+++ b/src/components/display/FileDisplay.tsx
@@ -0,0 +1,271 @@
+import { useCallback, useEffect, useState } from "react";
+import { Alert, Skeleton, Spin } from "antd";
+import { useAuthorizationHeader } from "bento-auth-js";
+
+import fetch from "cross-fetch";
+
+import type { JSONType } from "ajv";
+
+import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
+import { a11yLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
+
+import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash";
+import dockerfile from "react-syntax-highlighter/dist/esm/languages/hljs/dockerfile";
+import javascript from "react-syntax-highlighter/dist/esm/languages/hljs/javascript";
+import json from "react-syntax-highlighter/dist/esm/languages/hljs/json";
+import markdown from "react-syntax-highlighter/dist/esm/languages/hljs/markdown";
+import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext";
+import python from "react-syntax-highlighter/dist/esm/languages/hljs/python";
+import r from "react-syntax-highlighter/dist/esm/languages/hljs/r";
+import shell from "react-syntax-highlighter/dist/esm/languages/hljs/shell";
+import xml from "react-syntax-highlighter/dist/esm/languages/hljs/xml";
+
+import "react-pdf/dist/esm/Page/AnnotationLayer.css";
+import "react-pdf/dist/esm/Page/TextLayer.css";
+
+import AudioDisplay from "./AudioDisplay";
+import CsvDisplay from "./CsvDisplay";
+import ImageBlobDisplay from "./ImageBlobDisplay";
+import JsonDisplay from "./JsonDisplay";
+import VideoDisplay from "./VideoDisplay";
+import XlsxDisplay from "./XlsxDisplay";
+import MarkdownDisplay from "./MarkdownDisplay";
+import DocxDisplay from "./DocxDisplay";
+import PdfDisplay from "./PdfDisplay";
+import type { BlobDisplayProps } from "./types";
+
+SyntaxHighlighter.registerLanguage("bash", bash);
+SyntaxHighlighter.registerLanguage("dockerfile", dockerfile);
+SyntaxHighlighter.registerLanguage("javascript", javascript);
+SyntaxHighlighter.registerLanguage("json", json);
+SyntaxHighlighter.registerLanguage("markdown", markdown);
+SyntaxHighlighter.registerLanguage("plaintext", plaintext);
+SyntaxHighlighter.registerLanguage("python", python);
+SyntaxHighlighter.registerLanguage("r", r);
+SyntaxHighlighter.registerLanguage("shell", shell);
+SyntaxHighlighter.registerLanguage("xml", xml);
+
+const LANGUAGE_HIGHLIGHTERS: Record = {
+ bash: "bash",
+ js: "javascript",
+ json: "json",
+ md: "markdown",
+ mjs: "javascript",
+ txt: "plaintext",
+ py: "python",
+ R: "r",
+ sh: "shell",
+ xml: "xml",
+
+ // Special files
+ Dockerfile: "dockerfile",
+ README: "plaintext",
+ CHANGELOG: "plaintext",
+};
+
+export const AUDIO_FILE_EXTENSIONS = ["3gp", "aac", "flac", "m4a", "mp3", "ogg", "wav"];
+
+const CSV_LIKE_FILE_EXTENSIONS = ["csv", "tsv"];
+
+export const IMAGE_FILE_EXTENSIONS = ["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"];
+
+export const VIDEO_FILE_EXTENSIONS = ["mp4", "webm"];
+
+// TODO: ".bed",
+// .bed files are basically TSVs, but they can have instructions and can be whitespace-delimited instead
+export const VIEWABLE_FILE_EXTENSIONS = [
+ // Audio
+ ...AUDIO_FILE_EXTENSIONS,
+
+ // Images
+ ...IMAGE_FILE_EXTENSIONS,
+
+ // Videos
+ ...VIDEO_FILE_EXTENSIONS,
+
+ // Documents
+ "docx",
+ "pdf",
+
+ // Tabular data
+ ...CSV_LIKE_FILE_EXTENSIONS,
+ "csv",
+ "tsv",
+ "xls",
+ "xlsx",
+
+ // Code & text formats
+ ...Object.keys(LANGUAGE_HIGHLIGHTERS),
+];
+
+const DEFER_LOADING_FILE_EXTENSIONS = ["pdf"]; // Don't use a fetch() for these extensions
+
+const WrappedJsonDisplay = ({ contents, loading }: BlobDisplayProps) => {
+ const [parsing, setParsing] = useState(false);
+ const [json, setJson] = useState(undefined);
+
+ useEffect(() => {
+ if (contents) {
+ setParsing(true);
+ contents
+ .text()
+ .then((jt) => setJson(JSON.parse(jt)))
+ .finally(() => setParsing(false));
+ }
+ }, [contents]);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+interface WrappedCodeDisplayProps extends BlobDisplayProps {
+ fileExt: string;
+}
+
+const WrappedCodeDisplay = ({ contents, fileExt, loading }: WrappedCodeDisplayProps) => {
+ const [decoding, setDecoding] = useState(false);
+ const [code, setCode] = useState("");
+
+ useEffect(() => {
+ if (contents) {
+ setDecoding(true);
+ contents
+ .text()
+ .then((mt) => setCode(mt))
+ .finally(() => setDecoding(false));
+ }
+ }, [contents, loading]);
+
+ if (fileExt === "md") {
+ return ;
+ } else {
+ return (
+ <>
+
+
+ {code || ""}
+
+ >
+ );
+ }
+};
+
+type FileDisplayProps = {
+ uri?: string;
+ fileName?: string;
+ loading?: boolean;
+};
+
+const FileDisplay = ({ uri, fileName, loading }: FileDisplayProps) => {
+ const authHeader = useAuthorizationHeader();
+
+ const [fileLoadError, setFileLoadError] = useState("");
+ const [loadingFileContents, setLoadingFileContents] = useState(false);
+ const [fileContents, setFileContents] = useState>({});
+
+ const fileExt = fileName ? fileName.split(".").slice(-1)[0].toLowerCase() : "";
+
+ useEffect(() => {
+ // File changed, so reset the load error
+ setFileLoadError("");
+
+ (async () => {
+ if (!fileName) return;
+
+ if (fileExt === "pdf") {
+ setLoadingFileContents(true);
+ }
+
+ if (DEFER_LOADING_FILE_EXTENSIONS.includes(fileExt) || (uri && fileContents.hasOwnProperty(uri))) return;
+
+ if (!uri) {
+ console.error(`Files: something went wrong while trying to load ${uri}`);
+ setFileLoadError("Could not find URI for file.");
+ return;
+ }
+
+ try {
+ setLoadingFileContents(true);
+ const r = await fetch(uri, { headers: authHeader });
+ if (r.ok) {
+ setFileContents({
+ ...fileContents,
+ [uri]: await r.blob(),
+ });
+ } else {
+ setFileLoadError(`Could not load file: ${await r.text()}`);
+ }
+ } catch (e) {
+ console.error(e);
+ setFileLoadError(`Could not load file: ${(e as Error).message}`);
+ } finally {
+ setLoadingFileContents(false);
+ }
+ })();
+ }, [fileName, fileExt, uri, fileContents, authHeader]);
+
+ const onPdfLoad = useCallback(() => {
+ setLoadingFileContents(false);
+ }, []);
+
+ const onPdfFail = useCallback((err: Error) => {
+ setLoadingFileContents(false);
+ setFileLoadError(`Error loading PDF: ${err.message}`);
+ }, []);
+
+ if (!uri || !fileName) {
+ console.error(`Missing URI or file name: uri=${uri}, fileName=${fileName}`);
+ return
;
+ }
+
+ return (
+
+ {(() => {
+ if (fileLoadError) {
+ return (
+
+ );
+ }
+
+ const fc = fileContents[uri]; // undefined for PDF or if not loaded yet
+
+ if (fileExt === "pdf") {
+ // Non-text, content isn't loaded a priori
+ return ;
+ } else if (fileExt === "docx") {
+ return ;
+ } else if (CSV_LIKE_FILE_EXTENSIONS.includes(fileExt)) {
+ return ;
+ } else if (["xls", "xlsx"].includes(fileExt)) {
+ return ;
+ } else if (AUDIO_FILE_EXTENSIONS.includes(fileExt)) {
+ return ;
+ } else if (IMAGE_FILE_EXTENSIONS.includes(fileExt)) {
+ return ;
+ } else if (VIDEO_FILE_EXTENSIONS.includes(fileExt)) {
+ return ;
+ } else if (fileExt === "json") {
+ return ;
+ } else {
+ return ;
+ }
+ })()}
+
+ );
+};
+
+export default FileDisplay;
diff --git a/src/components/display/FileModal.js b/src/components/display/FileModal.js
deleted file mode 100644
index 424513e3b..000000000
--- a/src/components/display/FileModal.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from "react";
-import PropTypes from "prop-types";
-import { Modal } from "antd";
-
-import FileDisplay from "./FileDisplay";
-
-const FileModal = ({ title, open, onCancel, hasTriggered, url, fileName, loading }) => (
-
- {(hasTriggered ?? true) && (
-
- )}
-
-);
-FileModal.propTypes = {
- title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
- open: PropTypes.bool.isRequired,
- onCancel: PropTypes.func.isRequired,
- hasTriggered: PropTypes.bool,
- url: PropTypes.string,
- fileName: PropTypes.string,
- loading: PropTypes.bool,
-};
-
-export default FileModal;
diff --git a/src/components/display/FileModal.tsx b/src/components/display/FileModal.tsx
new file mode 100644
index 000000000..b4e2ffe56
--- /dev/null
+++ b/src/components/display/FileModal.tsx
@@ -0,0 +1,46 @@
+import type { CSSProperties, ReactNode } from "react";
+import { Modal, type ModalProps } from "antd";
+
+import FileDisplay from "./FileDisplay";
+
+const MODAL_STYLE: CSSProperties = {
+ // the flex display allows items which are less wide (e.g., portrait PDFs) to have a narrower modal
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ top: 50, // down from default of 100; gives a bit more screen real estate
+};
+const MODAL_INNER_STYLES: ModalProps["styles"] = {
+ body: {
+ minWidth: "692px",
+ maxWidth: "90vw", // needed, otherwise this ends up being more than the parent width for some reason
+ },
+};
+
+type FileModalProps = {
+ title: ReactNode;
+ open: boolean;
+ onCancel: ModalProps["onCancel"];
+ hasTriggered?: boolean;
+ url?: string;
+ fileName?: string;
+ loading?: boolean;
+};
+
+const FileModal = ({ title, open, onCancel, hasTriggered, url, fileName, loading }: FileModalProps) => (
+
+ {(hasTriggered ?? true) && }
+
+);
+
+export default FileModal;
diff --git a/src/components/display/ImageBlobDisplay.js b/src/components/display/ImageBlobDisplay.js
deleted file mode 100644
index 0c3b87702..000000000
--- a/src/components/display/ImageBlobDisplay.js
+++ /dev/null
@@ -1,22 +0,0 @@
-import React, { useEffect, useRef } from "react";
-import PropTypes from "prop-types";
-
-const ImageBlobDisplay = ({alt, blob}) => {
- const imgRef = useRef(null);
-
- useEffect(() => {
- if (imgRef.current && blob) {
- imgRef.current.src = URL.createObjectURL(blob);
- }
- }, [imgRef, blob]);
-
- return
-
-
;
-};
-ImageBlobDisplay.propTypes = {
- alt: PropTypes.string,
- blob: PropTypes.instanceOf(Blob),
-};
-
-export default ImageBlobDisplay;
diff --git a/src/components/display/ImageBlobDisplay.tsx b/src/components/display/ImageBlobDisplay.tsx
new file mode 100644
index 000000000..ef7e6f2a7
--- /dev/null
+++ b/src/components/display/ImageBlobDisplay.tsx
@@ -0,0 +1,37 @@
+import { type CSSProperties, useEffect, useMemo, useRef } from "react";
+import { Spin } from "antd";
+import type { BlobDisplayProps } from "./types";
+
+const styles: Record = {
+ container: { width: "100%", position: "relative" },
+ img: { maxWidth: "100%", height: "auto", position: "relative", top: 0 },
+};
+
+interface ImageBlobDisplayProps extends BlobDisplayProps {
+ alt: string;
+}
+
+const ImageBlobDisplay = ({ alt, contents, loading }: ImageBlobDisplayProps) => {
+ const imgRef = useRef(null);
+
+ useEffect(() => {
+ if (imgRef.current && contents) {
+ imgRef.current.src = URL.createObjectURL(contents);
+ }
+ }, [imgRef, contents]);
+
+ const imgStyle = useMemo(() => ({ ...styles.img, opacity: loading ? 0 : 1 }), [loading]);
+
+ return (
+
+ {loading && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default ImageBlobDisplay;
diff --git a/src/components/display/JsonDisplay.js b/src/components/display/JsonDisplay.js
deleted file mode 100644
index 45e67edef..000000000
--- a/src/components/display/JsonDisplay.js
+++ /dev/null
@@ -1,130 +0,0 @@
-import React, { useCallback, useEffect, useState } from "react";
-import PropTypes from "prop-types";
-import { Collapse, Select, Typography } from "antd";
-
-import JsonView from "@/components/common/JsonView";
-import MonospaceText from "@/components/common/MonospaceText";
-
-const DEFAULT_JSON_VIEW_OPTIONS = {
- collapsed: true,
-};
-
-const JSON_ARRAY_GROUP_SIZE = 100;
-
-const JsonArrayDisplay = ({ doc, standalone }) => {
-
- const [jsonArrayGroups, setJsonArrayGroups] = useState(null);
- const [selectedJsonGroup, setSelectedJsonGroup] = useState(null);
-
- useEffect(() => {
- if (Array.isArray(doc) && doc.length > JSON_ARRAY_GROUP_SIZE) {
- // Array group selector options
- const arrayGroups = {};
- for (let start = 0; start < doc.length; start += JSON_ARRAY_GROUP_SIZE) {
- const chunk = doc.slice(start, start + JSON_ARRAY_GROUP_SIZE);
- const next = start + JSON_ARRAY_GROUP_SIZE;
- const end = next > doc.length
- ? doc.length
- : next;
- arrayGroups[`${start}-${end}`] = chunk;
- }
- const keys = Object.keys(arrayGroups);
- setJsonArrayGroups(arrayGroups);
- setSelectedJsonGroup(keys[0]);
- } else {
- setJsonArrayGroups(null);
- setSelectedJsonGroup(null);
- }
- }, [doc]);
-
- const onJsonGroupSelect = useCallback((key) => {
- setSelectedJsonGroup(key);
- }, []);
-
- const shouldGroup = doc.length > JSON_ARRAY_GROUP_SIZE;
-
- // Wait for group slicing to avoid render flicker
- if (shouldGroup && !(jsonArrayGroups && selectedJsonGroup)) return
;
-
- const src = shouldGroup ? jsonArrayGroups[selectedJsonGroup] : doc;
-
- return (
- <>
- {standalone && JSON array }
- {shouldGroup &&
- <>
- Grouped array items
- ({ value, label: value }))}
- />
- >
- }
-
- >
- );
-};
-
-JsonArrayDisplay.propTypes = {
- doc: PropTypes.array,
- standalone: PropTypes.bool,
-};
-
-JsonArrayDisplay.defaultProps = {
- standalone: false,
-};
-
-const JsonObjectDisplay = ({ doc }) => {
- const entries = Object.entries(doc);
- return (
- <>
- JSON object
- ({
- key,
- label: {key} ,
- children: ,
- }))} />
- >
- );
-};
-
-JsonObjectDisplay.propTypes = {
- doc: PropTypes.object,
-};
-
-const JsonDisplay = ({ jsonSrc, showObjectWithReactJson }) => {
- if (Array.isArray(jsonSrc)) {
- // Special display for array nav
- return ;
- }
-
- if (typeof jsonSrc === "object") {
- // Display for objects
- return showObjectWithReactJson
- ?
- : ;
- }
-
- // Display primitive
- return {JSON.stringify(jsonSrc)} ;
-};
-
-JsonDisplay.propTypes = {
- jsonSrc: PropTypes.oneOfType([
- PropTypes.object,
- PropTypes.array,
- PropTypes.string,
- PropTypes.bool,
- PropTypes.number,
- ]),
- showObjectWithReactJson: PropTypes.bool,
-};
-
-export default JsonDisplay;
diff --git a/src/components/display/JsonDisplay.tsx b/src/components/display/JsonDisplay.tsx
new file mode 100644
index 000000000..e6a252eba
--- /dev/null
+++ b/src/components/display/JsonDisplay.tsx
@@ -0,0 +1,122 @@
+import { useCallback, useEffect, useState } from "react";
+import type { JSONType } from "ajv";
+import { Collapse, Select, Typography } from "antd";
+
+import JsonView from "@/components/common/JsonView";
+import MonospaceText from "@/components/common/MonospaceText";
+
+const DEFAULT_JSON_VIEW_OPTIONS = {
+ collapsed: true,
+};
+
+const JSON_ARRAY_GROUP_SIZE = 100;
+
+type JsonArrayDisplayProps = {
+ doc: JSONType[];
+ standalone?: boolean;
+};
+
+const JsonArrayDisplay = ({ doc, standalone }: JsonArrayDisplayProps) => {
+ const [jsonArrayGroups, setJsonArrayGroups] = useState | null>(null);
+ const [selectedJsonGroup, setSelectedJsonGroup] = useState(null);
+
+ useEffect(() => {
+ if (Array.isArray(doc) && doc.length > JSON_ARRAY_GROUP_SIZE) {
+ // Array group selector options
+ const arrayGroups: Record = {};
+ for (let start = 0; start < doc.length; start += JSON_ARRAY_GROUP_SIZE) {
+ const chunk = doc.slice(start, start + JSON_ARRAY_GROUP_SIZE);
+ const next = start + JSON_ARRAY_GROUP_SIZE;
+ const end = next > doc.length ? doc.length : next;
+ arrayGroups[`${start}-${end}`] = chunk;
+ }
+ const keys = Object.keys(arrayGroups);
+ setJsonArrayGroups(arrayGroups);
+ setSelectedJsonGroup(keys[0]);
+ } else {
+ setJsonArrayGroups(null);
+ setSelectedJsonGroup(null);
+ }
+ }, [doc]);
+
+ const onJsonGroupSelect = useCallback((key: string) => {
+ setSelectedJsonGroup(key);
+ }, []);
+
+ const shouldGroup = doc.length > JSON_ARRAY_GROUP_SIZE;
+
+ // Wait for group slicing to avoid render flicker
+ if (shouldGroup && !(jsonArrayGroups && selectedJsonGroup)) return
;
+
+ const src = jsonArrayGroups && selectedJsonGroup ? jsonArrayGroups[selectedJsonGroup] : doc;
+
+ return (
+ <>
+ {standalone && JSON array }
+ {shouldGroup && (
+ <>
+ Grouped array items
+ ({ value, label: value }))}
+ />
+ >
+ )}
+
+ >
+ );
+};
+
+type JsonObjectDisplayProps = {
+ doc: Record;
+};
+
+const JsonObjectDisplay = ({ doc }: JsonObjectDisplayProps) => {
+ const entries = Object.entries(doc);
+ return (
+ <>
+ JSON object
+ ({
+ key,
+ label: {key} ,
+ children: ,
+ }))}
+ />
+ >
+ );
+};
+
+type JsonDisplayProps = {
+ jsonSrc?: JSONType;
+ showObjectWithReactJson?: boolean;
+};
+
+const JsonDisplay = ({ jsonSrc, showObjectWithReactJson }: JsonDisplayProps) => {
+ if (Array.isArray(jsonSrc)) {
+ // Special display for array nav
+ return ;
+ }
+
+ if (typeof jsonSrc === "object") {
+ // Display for objects
+ return showObjectWithReactJson ? (
+
+ ) : (
+
+ );
+ }
+
+ // Display primitive
+ return {JSON.stringify(jsonSrc)} ;
+};
+
+export default JsonDisplay;
diff --git a/src/components/display/MarkdownDisplay.js b/src/components/display/MarkdownDisplay.js
deleted file mode 100644
index 45ba37ce5..000000000
--- a/src/components/display/MarkdownDisplay.js
+++ /dev/null
@@ -1,67 +0,0 @@
-import React, { useState } from "react";
-import PropTypes from "prop-types";
-
-import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
-import { a11yLight } from "react-syntax-highlighter/dist/cjs/styles/hljs";
-import { markdown } from "react-syntax-highlighter/dist/cjs/languages/hljs";
-
-SyntaxHighlighter.registerLanguage("markdown", markdown);
-
-import ReactMarkdown from "react-markdown";
-import remarkGfm from "remark-gfm";
-
-import { Radio } from "antd";
-import { CodeOutlined, PicRightOutlined } from "@ant-design/icons";
-
-const REMARK_PLUGINS = [remarkGfm];
-
-/** @type {Object.} */
-const styles = {
- container: {
- position: "relative",
- maxWidth: 960,
- overflowX: "auto",
- },
- header: {
- position: "absolute",
- right: 0,
- top: 0,
- },
- code: {
- fontSize: "12px",
- },
-};
-
-const MarkdownDisplay = ({ contents }) => {
- const [displayMode, setDisplayMode] = useState("render");
-
- // We use a 0-height container for the rendered Markdown instead of trashing it in order to preserve the same width
- // between the rendered and code views of the same content.
-
- return
-
-
setDisplayMode(v.target.value)}>
- Render
- Code
-
-
-
- {contents}
-
- {displayMode === "code" ? (
-
- {contents || ""}
-
- ) : null}
-
;
-};
-MarkdownDisplay.propTypes = {
- contents: PropTypes.string,
-};
-
-export default MarkdownDisplay;
diff --git a/src/components/display/MarkdownDisplay.tsx b/src/components/display/MarkdownDisplay.tsx
new file mode 100644
index 000000000..aafa67690
--- /dev/null
+++ b/src/components/display/MarkdownDisplay.tsx
@@ -0,0 +1,93 @@
+import { type CSSProperties, useCallback, useMemo, useState } from "react";
+
+import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
+import { a11yLight } from "react-syntax-highlighter/dist/esm/styles/hljs";
+import markdown from "react-syntax-highlighter/dist/esm/languages/hljs/markdown";
+
+SyntaxHighlighter.registerLanguage("markdown", markdown);
+
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+import { type CheckboxOptionType, Radio, type RadioChangeEvent, Skeleton } from "antd";
+import { CodeOutlined, PicRightOutlined } from "@ant-design/icons";
+
+const REMARK_PLUGINS = [remarkGfm];
+
+const styles: Record = {
+ container: {
+ position: "relative",
+ maxWidth: 960,
+ overflowX: "auto",
+ },
+ header: {
+ position: "absolute",
+ right: 0,
+ top: 0,
+ },
+ code: {
+ fontSize: "12px",
+ },
+};
+
+const DISPLAY_MODE_OPTIONS: CheckboxOptionType[] = [
+ {
+ label: (
+ <>
+ Render
+ >
+ ),
+ value: "render",
+ },
+ {
+ label: (
+ <>
+ Code
+ >
+ ),
+ value: "code",
+ },
+];
+
+type MarkdownDisplayProps = {
+ contents?: string;
+ loading?: boolean;
+};
+
+const MarkdownDisplay = ({ contents, loading }: MarkdownDisplayProps) => {
+ const [displayMode, setDisplayMode] = useState<"render" | "code">("render");
+
+ const onModeChange = useCallback((v: RadioChangeEvent) => setDisplayMode(v.target.value), []);
+
+ // We use a 0-height container for the rendered Markdown instead of trashing it in order to preserve the same width
+ // between the rendered and code views of the same content.
+
+ const markdownContainerStyle = useMemo(
+ () => ({ overflowY: "hidden", height: displayMode === "code" ? 0 : "auto" }),
+ [displayMode],
+ );
+
+ return (
+
+
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {contents ?? ""}
+
+ {displayMode === "code" ? (
+
+ {contents ?? ""}
+
+ ) : null}
+ >
+ )}
+
+ );
+};
+
+export default MarkdownDisplay;
diff --git a/src/components/display/PdfDisplay.js b/src/components/display/PdfDisplay.js
deleted file mode 100644
index aa8af2dfc..000000000
--- a/src/components/display/PdfDisplay.js
+++ /dev/null
@@ -1,96 +0,0 @@
-import React, { useCallback, useMemo, useState } from "react";
-import PropTypes from "prop-types";
-
-import { Document, Page, pdfjs } from "react-pdf";
-import { Button } from "antd";
-import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
-
-import { useAuthorizationHeader } from "bento-auth-js";
-
-pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.js", import.meta.url).toString();
-
-const BASE_PDF_OPTIONS = {
- cMapUrl: "cmaps/",
- cMapPacked: true,
- standardFontDataUrl: "standard_fonts/",
-};
-
-const SCALES = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
-const INITIAL_SCALE = 2;
-const MAX_SCALE = SCALES.length - 1;
-
-/** @type {Object.} */
-const styles = {
- container: {
- width: "calc(90vw - 32px)",
- minWidth: "660px",
- },
- header: {
- position: "absolute",
- right: 30,
- top: -68,
- },
-};
-
-const PdfDisplay = ({uri, onLoad, onFail}) => {
- const authHeader = useAuthorizationHeader();
-
- const [pdfPageCounts, setPdfPageCounts] = useState({});
- const [scale, setScale] = useState(INITIAL_SCALE);
-
- const pdfOptions = useMemo(() => ({
- ...BASE_PDF_OPTIONS,
- httpHeaders: authHeader,
- }), [authHeader]);
-
- const onLoadSuccess = useCallback(({numPages}) => {
- if (onLoad) onLoad();
- setPdfPageCounts({...pdfPageCounts, [uri]: numPages});
- }, [uri]);
-
- const onLoadError = useCallback(err => {
- console.error(err);
- if (onFail) onFail(err);
- }, []);
-
- const decreaseScale = useCallback(() => {
- const newScale = Math.max(scale - 1, 0);
- console.info("setting PDF zoom scale to", newScale);
- setScale(newScale);
- }, [scale]);
-
- const increaseScale = useCallback(() => {
- const newScale = Math.min(scale + 1, MAX_SCALE);
- console.info("setting PDF zoom scale to", newScale);
- setScale(newScale);
- }, [scale]);
-
- const pageArray = useMemo(() => {
- const pages = [];
- for (let i = 1; i <= pdfPageCounts[uri] ?? 1; i++) {
- pages.push( );
- }
- return pages;
- }, [pdfPageCounts, uri, scale]);
-
- return (
-
- );
-};
-PdfDisplay.propTypes = {
- uri: PropTypes.string,
- onLoad: PropTypes.func,
- onFail: PropTypes.func,
-};
-
-export default PdfDisplay;
diff --git a/src/components/display/PdfDisplay.tsx b/src/components/display/PdfDisplay.tsx
new file mode 100644
index 000000000..6cb9ed32d
--- /dev/null
+++ b/src/components/display/PdfDisplay.tsx
@@ -0,0 +1,109 @@
+import { type CSSProperties, useCallback, useMemo, useState } from "react";
+
+import { Document, Page, pdfjs } from "react-pdf";
+import type { PDFDocumentProxy } from "pdfjs-dist";
+import { Button } from "antd";
+import { MinusOutlined, PlusOutlined } from "@ant-design/icons";
+
+import { useAuthorizationHeader } from "bento-auth-js";
+
+pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
+
+const BASE_PDF_OPTIONS = {
+ cMapUrl: "cmaps/",
+ cMapPacked: true,
+ standardFontDataUrl: "standard_fonts/",
+};
+
+const SCALES = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
+const INITIAL_SCALE = 2;
+const MAX_SCALE = SCALES.length - 1;
+
+const styles: Record = {
+ container: {
+ width: "calc(90vw - 32px)",
+ minWidth: "660px",
+ },
+ header: {
+ position: "absolute",
+ right: 30,
+ top: -42,
+ },
+};
+
+type PdfDisplayProps = {
+ uri: string;
+ onLoad: () => void;
+ onFail: (err: Error) => void;
+};
+
+const PdfDisplay = ({ uri, onLoad, onFail }: PdfDisplayProps) => {
+ const authHeader = useAuthorizationHeader();
+
+ const [pdfPageCounts, setPdfPageCounts] = useState<{ [uri: string]: number }>({});
+ const [scale, setScale] = useState(INITIAL_SCALE);
+
+ const pdfOptions = useMemo(
+ () => ({
+ ...BASE_PDF_OPTIONS,
+ httpHeaders: authHeader,
+ }),
+ [authHeader],
+ );
+
+ const onLoadSuccess = useCallback(
+ ({ numPages }: PDFDocumentProxy) => {
+ if (onLoad) onLoad();
+ setPdfPageCounts({ ...pdfPageCounts, [uri]: numPages });
+ },
+ [onLoad, pdfPageCounts, uri],
+ );
+
+ const onLoadError = useCallback(
+ (err: Error) => {
+ console.error(err);
+ if (onFail) onFail(err);
+ },
+ [onFail],
+ );
+
+ const decreaseScale = useCallback(() => {
+ const newScale = Math.max(scale - 1, 0);
+ console.info("setting PDF zoom scale to", newScale);
+ setScale(newScale);
+ }, [scale]);
+
+ const increaseScale = useCallback(() => {
+ const newScale = Math.min(scale + 1, MAX_SCALE);
+ console.info("setting PDF zoom scale to", newScale);
+ setScale(newScale);
+ }, [scale]);
+
+ const pageArray = useMemo(() => {
+ const pages = [];
+ for (let i = 1; i <= pdfPageCounts[uri] ?? 1; i++) {
+ pages.push( );
+ }
+ return pages;
+ }, [pdfPageCounts, uri, scale]);
+
+ return (
+
+ );
+};
+
+export default PdfDisplay;
diff --git a/src/components/display/SpreadsheetTable.js b/src/components/display/SpreadsheetTable.js
deleted file mode 100644
index 32da2c90d..000000000
--- a/src/components/display/SpreadsheetTable.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from "react";
-import PropTypes from "prop-types";
-import { Table } from "antd";
-
-// For spreadsheets, we generate synthetic keys based on row indices
-export const SPREADSHEET_ROW_KEY_PROP = "__key__";
-
-const TABLE_PAGINATION = { pageSize: 25 };
-const TABLE_SCROLL = { x: true };
-
-const SpreadsheetTable = ({ columns, dataSource, loading, showHeader }) => (
-
-);
-SpreadsheetTable.propTypes = {
- columns: PropTypes.array.isRequired,
- dataSource: PropTypes.array.isRequired,
- loading: PropTypes.bool,
- showHeader: PropTypes.bool,
-};
-
-export default SpreadsheetTable;
diff --git a/src/components/display/SpreadsheetTable.tsx b/src/components/display/SpreadsheetTable.tsx
new file mode 100644
index 000000000..319b1c895
--- /dev/null
+++ b/src/components/display/SpreadsheetTable.tsx
@@ -0,0 +1,31 @@
+import { Table, type TableProps } from "antd";
+import type { ColumnsType } from "antd/lib/table";
+
+// For spreadsheets, we generate synthetic keys based on row indices
+export const SPREADSHEET_ROW_KEY_PROP = "__key__";
+
+const TABLE_PAGINATION: TableProps["pagination"] = { pageSize: 25 };
+const TABLE_SCROLL: TableProps["scroll"] = { x: true };
+
+export type SpreadsheetTableProps = {
+ columns: ColumnsType;
+ dataSource: T[];
+ loading?: boolean;
+ showHeader?: boolean;
+};
+
+const SpreadsheetTable = ({ columns, dataSource, loading, showHeader }: SpreadsheetTableProps) => (
+
+ size="small"
+ bordered={true}
+ showHeader={showHeader ?? false}
+ pagination={TABLE_PAGINATION}
+ scroll={TABLE_SCROLL}
+ rowKey={SPREADSHEET_ROW_KEY_PROP}
+ columns={columns}
+ dataSource={dataSource}
+ loading={loading}
+ />
+);
+
+export default SpreadsheetTable;
diff --git a/src/components/display/VideoDisplay.js b/src/components/display/VideoDisplay.js
deleted file mode 100644
index 2cd35b832..000000000
--- a/src/components/display/VideoDisplay.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import React, { useEffect, useRef } from "react";
-import PropTypes from "prop-types";
-
-const VideoDisplay = ({blob}) => {
- const videoRef = useRef(null);
-
- useEffect(() => {
- if (videoRef.current && blob) {
- videoRef.current.src = URL.createObjectURL(blob);
- }
- }, [videoRef, blob]);
-
- return ;
-};
-VideoDisplay.propTypes = {
- blob: PropTypes.instanceOf(Blob),
-};
-
-export default VideoDisplay;
diff --git a/src/components/display/VideoDisplay.tsx b/src/components/display/VideoDisplay.tsx
new file mode 100644
index 000000000..e2c4dbf64
--- /dev/null
+++ b/src/components/display/VideoDisplay.tsx
@@ -0,0 +1,23 @@
+import { type CSSProperties, useEffect, useRef } from "react";
+import { Spin } from "antd";
+import type { BlobDisplayProps } from "./types";
+
+const VIDEO_STYLE: CSSProperties = { width: "100%" };
+
+const VideoDisplay = ({ contents, loading }: BlobDisplayProps) => {
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ if (videoRef.current && contents) {
+ videoRef.current.src = URL.createObjectURL(contents);
+ }
+ }, [videoRef, contents]);
+
+ return (
+
+
+
+ );
+};
+
+export default VideoDisplay;
diff --git a/src/components/display/XlsxDisplay.js b/src/components/display/XlsxDisplay.js
deleted file mode 100644
index 05f230e20..000000000
--- a/src/components/display/XlsxDisplay.js
+++ /dev/null
@@ -1,70 +0,0 @@
-import React, { useEffect, useState } from "react";
-import PropTypes from "prop-types";
-
-import { read, utils } from "xlsx";
-import { Card } from "antd";
-
-import SpreadsheetTable, { SPREADSHEET_ROW_KEY_PROP } from "./SpreadsheetTable";
-
-const XlsxDisplay = ({ contents }) => {
- const [excelFile, setExcelFile] = useState(null);
-
- const [selectedSheet, setSelectedSheet] = useState(undefined);
- const [sheetColumns, setSheetColumns] = useState([]);
- const [sheetJSON, setSheetJSON] = useState([]);
-
- useEffect(() => {
- if (!contents) return;
- setExcelFile(read(contents));
- }, [contents]);
-
- useEffect(() => {
- if (selectedSheet === undefined && excelFile?.SheetNames?.length) {
- setSelectedSheet(excelFile.SheetNames[0]);
- }
- }, [excelFile]);
-
- useEffect(() => {
- if (excelFile) {
- const json = utils.sheet_to_json(excelFile.Sheets[selectedSheet]);
- if (json.length === 0) return [];
-
- const columnSet = new Set();
- const columns = [];
-
- // explore first 30 rows to find all columns
- json.slice(0, 30).forEach(row => {
- Object.keys(row).forEach(c => {
- if (columnSet.has(c)) return;
- columnSet.add(c);
- columns.push({
- title: c.startsWith("__") ? "" : c,
- dataIndex: c,
- });
- });
- });
-
- setSheetColumns(columns);
- setSheetJSON(json.map((r, i) => ({ ...r, [SPREADSHEET_ROW_KEY_PROP]: `row${i}` })));
- }
- }, [selectedSheet]);
-
- return (
- ({key: s, tab: s}))}
- activeTabKey={selectedSheet}
- onTabChange={(s) => setSelectedSheet(s)}
- >
- acc || v.title !== "", false)}
- />
-
- );
-};
-XlsxDisplay.propTypes = {
- contents: PropTypes.instanceOf(ArrayBuffer),
-};
-
-export default XlsxDisplay;
diff --git a/src/components/display/XlsxDisplay.tsx b/src/components/display/XlsxDisplay.tsx
new file mode 100644
index 000000000..f391651ee
--- /dev/null
+++ b/src/components/display/XlsxDisplay.tsx
@@ -0,0 +1,77 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { read, utils, type WorkBook } from "xlsx";
+import { Card } from "antd";
+
+import SpreadsheetTable, { SPREADSHEET_ROW_KEY_PROP, type SpreadsheetTableProps } from "./SpreadsheetTable";
+import type { BlobDisplayProps } from "./types";
+
+type XlsxRecord = Record;
+type XlsxData = XlsxRecord[];
+type XlsxColumns = SpreadsheetTableProps["columns"];
+
+const XlsxDisplay = ({ contents, loading }: BlobDisplayProps) => {
+ const [excelFile, setExcelFile] = useState(null);
+ const [reading, setReading] = useState(false);
+
+ const [selectedSheet, setSelectedSheet] = useState(undefined);
+ const [sheetColumns, setSheetColumns] = useState([]);
+ const [sheetJSON, setSheetJSON] = useState([]);
+
+ useEffect(() => {
+ if (!contents) return;
+ setReading(true);
+ contents
+ .arrayBuffer()
+ .then((ab) => {
+ setExcelFile(read(ab));
+ })
+ .finally(() => setReading(false));
+ }, [contents]);
+
+ useEffect(() => {
+ if (!excelFile) return;
+
+ if (excelFile.SheetNames.length && selectedSheet === undefined) {
+ setSelectedSheet(excelFile.SheetNames[0]);
+ } else if (selectedSheet !== undefined) {
+ const json: object[] = utils.sheet_to_json(excelFile.Sheets[selectedSheet]);
+ if (json.length === 0) return;
+
+ const columnSet = new Set();
+ const columns: XlsxColumns = [];
+
+ // explore first 30 rows to find all columns
+ json.slice(0, 30).forEach((row) => {
+ Object.keys(row).forEach((c) => {
+ if (columnSet.has(c)) return;
+ columnSet.add(c);
+ columns.push({
+ title: c.startsWith("__") ? "" : c,
+ dataIndex: c,
+ });
+ });
+ });
+
+ setSheetColumns(columns);
+ setSheetJSON(json.map((r, i) => ({ ...r, [SPREADSHEET_ROW_KEY_PROP]: `row${i}` })));
+ }
+ }, [excelFile, selectedSheet]);
+
+ const tabs = useMemo(() => (excelFile?.SheetNames ?? []).map((s) => ({ key: s, label: s })), [excelFile]);
+ const onTabChange = useCallback((s: string) => setSelectedSheet(s), []);
+ const showHeader = useMemo(() => sheetColumns.reduce((acc, v) => acc || v.title !== "", false), [sheetColumns]);
+
+ return (
+
+
+ columns={sheetColumns}
+ dataSource={sheetJSON}
+ showHeader={showHeader}
+ loading={loading || reading}
+ />
+
+ );
+};
+
+export default XlsxDisplay;
diff --git a/src/components/display/types.ts b/src/components/display/types.ts
new file mode 100644
index 000000000..3548d70b2
--- /dev/null
+++ b/src/components/display/types.ts
@@ -0,0 +1,4 @@
+export interface BlobDisplayProps {
+ contents?: Blob;
+ loading?: boolean;
+}
diff --git a/src/components/explorer/ExplorerDatasetSearch.js b/src/components/explorer/ExplorerDatasetSearch.js
index aedd143ba..30d192650 100644
--- a/src/components/explorer/ExplorerDatasetSearch.js
+++ b/src/components/explorer/ExplorerDatasetSearch.js
@@ -11,144 +11,140 @@ import SearchAllRecords from "./SearchAllRecords";
import { fetchDatasetResourcesIfNecessary } from "@/modules/datasets/actions";
import {
- addDataTypeQueryForm,
- performSearchIfPossible,
- removeDataTypeQueryForm,
- updateDataTypeQueryForm,
- setSelectedRows,
- resetTableSortOrder,
- setActiveTab,
+ performSearchIfPossible,
+ setSelectedRows,
+ resetTableSortOrder,
+ setActiveTab,
} from "@/modules/explorer/actions";
+import { useProjects } from "@/modules/metadata/hooks";
import IndividualsTable from "./searchResultsTables/IndividualsTable";
import BiosamplesTable from "./searchResultsTables/BiosamplesTable";
import ExperimentsTable from "./searchResultsTables/ExperimentsTable";
const TAB_KEYS = {
- INDIVIDUAL: "1",
- BIOSAMPLES: "2",
- EXPERIMENTS: "3",
+ INDIVIDUAL: "1",
+ BIOSAMPLES: "2",
+ EXPERIMENTS: "3",
};
const hasNonEmptyArrayProperty = (targetObject, propertyKey) => {
- return targetObject && Array.isArray(targetObject[propertyKey]) && targetObject[propertyKey].length;
+ return targetObject && Array.isArray(targetObject[propertyKey]) && targetObject[propertyKey].length;
};
const EMPTY_ARRAY = [];
const ExplorerDatasetSearch = () => {
- const { dataset: datasetID } = useParams();
- const dispatch = useDispatch();
-
- const datasetsByID = useSelector((state) => state.projects.datasetsByID);
-
- const activeKey = useSelector((state) => state.explorer.activeTabByDatasetID[datasetID]) || TAB_KEYS.INDIVIDUAL;
- const dataTypeForms = useSelector((state) => state.explorer.dataTypeFormsByDatasetID[datasetID]) ?? EMPTY_ARRAY;
- const fetchingSearch = useSelector((state) => state.explorer.fetchingSearchByDatasetID[datasetID] || false);
- const fetchingTextSearch = useSelector((state) => state.explorer.fetchingTextSearch || false);
- const searchResults = useSelector((state) => state.explorer.searchResultsByDatasetID[datasetID] || null);
-
- useEffect(() => {
- console.debug("search results: ", searchResults);
- }, [searchResults]);
-
- const handleSetSelectedRows = useCallback(
- (...args) => dispatch(setSelectedRows(datasetID, ...args)),
- [dispatch, datasetID],
- );
-
- useEffect(() => {
- // Ensure user is at the top of the page after transition
- window.scrollTo(0, 0);
- }, []);
-
- const onTabChange = useCallback((newActiveKey) => {
- dispatch(setActiveTab(datasetID, newActiveKey));
- handleSetSelectedRows([]);
- }, [dispatch, datasetID, handleSetSelectedRows]);
-
- const performSearch = useCallback(() => {
- dispatch(setActiveTab(datasetID, TAB_KEYS.INDIVIDUAL));
- dispatch(resetTableSortOrder(datasetID));
- dispatch(performSearchIfPossible(datasetID));
- }, [dispatch, datasetID]);
-
- useEffect(() => {
- dispatch(fetchDatasetResourcesIfNecessary(datasetID));
- }, [dispatch, datasetID]);
-
- const selectedDataset = datasetsByID[datasetID];
-
- const isFetchingSearchResults = fetchingSearch || fetchingTextSearch;
-
- const hasResults = searchResults && searchResults.searchFormattedResults;
- const hasExperiments = hasNonEmptyArrayProperty(searchResults, "searchFormattedResultsExperiment");
- const hasBiosamples = hasNonEmptyArrayProperty(searchResults, "searchFormattedResultsBiosamples");
- const showTabs = hasResults && (hasExperiments || hasBiosamples);
-
- const tabItems = useMemo(() => searchResults ? [
- {
- key: TAB_KEYS.INDIVIDUAL,
- label: `Individuals (${searchResults.searchFormattedResults.length})`,
- children: (
-
- ),
- },
- ...(hasBiosamples ? [{
- key: TAB_KEYS.BIOSAMPLES,
- label: `Biosamples (${searchResults.searchFormattedResultsBiosamples.length})`,
- children: (
-
- ),
- }] : []),
- ...(hasExperiments ? [{
- key: TAB_KEYS.EXPERIMENTS,
- label: `Experiments (${searchResults.searchFormattedResultsExperiment.length})`,
- children: (
-
- ),
- }] : []),
- ] : [], [searchResults, datasetID]);
-
- if (!selectedDataset) return null;
- return (
- <>
-
- Explore Dataset {selectedDataset.title}
-
-
- dispatch(addDataTypeQueryForm(datasetID, form))}
- updateDataTypeQueryForm={(index, form) => dispatch(updateDataTypeQueryForm(datasetID, index, form))}
- removeDataTypeQueryForm={(index) => dispatch(removeDataTypeQueryForm(datasetID, index))}
- />
- {hasResults &&
- !isFetchingSearchResults &&
- (showTabs ? (
-
- ) : (
-
- ))}
- >
- );
+ const { dataset: datasetID } = useParams();
+ const dispatch = useDispatch();
+
+ const datasetsByID = useProjects().datasetsByID;
+
+ const activeKey = useSelector((state) => state.explorer.activeTabByDatasetID[datasetID]) || TAB_KEYS.INDIVIDUAL;
+ const dataTypeForms = useSelector((state) => state.explorer.dataTypeFormsByDatasetID[datasetID]) ?? EMPTY_ARRAY;
+ const fetchingSearch = useSelector((state) => state.explorer.fetchingSearchByDatasetID[datasetID] || false);
+ const fetchingTextSearch = useSelector((state) => state.explorer.fetchingTextSearch || false);
+ const searchResults = useSelector((state) => state.explorer.searchResultsByDatasetID[datasetID] || null);
+
+ useEffect(() => {
+ console.debug("search results: ", searchResults);
+ }, [searchResults]);
+
+ const handleSetSelectedRows = useCallback(
+ (...args) => dispatch(setSelectedRows(datasetID, ...args)),
+ [dispatch, datasetID],
+ );
+
+ useEffect(() => {
+ // Ensure user is at the top of the page after transition
+ window.scrollTo(0, 0);
+ }, []);
+
+ const onTabChange = useCallback(
+ (newActiveKey) => {
+ dispatch(setActiveTab(datasetID, newActiveKey));
+ handleSetSelectedRows([]);
+ },
+ [dispatch, datasetID, handleSetSelectedRows],
+ );
+
+ const performSearch = useCallback(() => {
+ dispatch(setActiveTab(datasetID, TAB_KEYS.INDIVIDUAL));
+ dispatch(resetTableSortOrder(datasetID));
+ dispatch(performSearchIfPossible(datasetID));
+ }, [dispatch, datasetID]);
+
+ useEffect(() => {
+ dispatch(fetchDatasetResourcesIfNecessary(datasetID));
+ }, [dispatch, datasetID]);
+
+ const selectedDataset = datasetsByID[datasetID];
+
+ const isFetchingSearchResults = fetchingSearch || fetchingTextSearch;
+
+ const hasResults = searchResults && searchResults.searchFormattedResults;
+ const hasExperiments = hasNonEmptyArrayProperty(searchResults, "searchFormattedResultsExperiment");
+ const hasBiosamples = hasNonEmptyArrayProperty(searchResults, "searchFormattedResultsBiosamples");
+ const showTabs = hasResults && (hasExperiments || hasBiosamples);
+
+ const tabItems = useMemo(
+ () =>
+ searchResults
+ ? [
+ {
+ key: TAB_KEYS.INDIVIDUAL,
+ label: `Individuals (${searchResults.searchFormattedResults.length})`,
+ children: ,
+ },
+ ...(hasBiosamples
+ ? [
+ {
+ key: TAB_KEYS.BIOSAMPLES,
+ label: `Biosamples (${searchResults.searchFormattedResultsBiosamples.length})`,
+ children: (
+
+ ),
+ },
+ ]
+ : []),
+ ...(hasExperiments
+ ? [
+ {
+ key: TAB_KEYS.EXPERIMENTS,
+ label: `Experiments (${searchResults.searchFormattedResultsExperiment.length})`,
+ children: (
+
+ ),
+ },
+ ]
+ : []),
+ ]
+ : [],
+ [hasBiosamples, hasExperiments, searchResults, datasetID],
+ );
+
+ if (!selectedDataset) return null;
+ return (
+ <>
+
+ Explore Dataset {selectedDataset.title}
+
+
+
+ {hasResults &&
+ !isFetchingSearchResults &&
+ (showTabs ? (
+
+ ) : (
+
+ ))}
+ >
+ );
};
export default ExplorerDatasetSearch;
diff --git a/src/components/explorer/ExplorerGenomeBrowserContent.js b/src/components/explorer/ExplorerGenomeBrowserContent.js
index 14bdfd293..dae32e93e 100644
--- a/src/components/explorer/ExplorerGenomeBrowserContent.js
+++ b/src/components/explorer/ExplorerGenomeBrowserContent.js
@@ -1,19 +1,21 @@
-import React, {Component} from "react";
+import React, { Component } from "react";
-import {Layout, Typography} from "antd";
+import { Layout, Typography } from "antd";
-import {LAYOUT_CONTENT_STYLE} from "../../styles/layoutContent";
+import { LAYOUT_CONTENT_STYLE } from "../../styles/layoutContent";
class ExplorerGenomeBrowserContent extends Component {
- render() {
- return
-
- Variant Visualizer
- TODO
- {/* */}
-
- ;
- }
+ render() {
+ return (
+
+
+ Variant Visualizer
+ TODO
+ {/* */}
+
+
+ );
+ }
}
export default ExplorerGenomeBrowserContent;
diff --git a/src/components/explorer/ExplorerIndividualContent.js b/src/components/explorer/ExplorerIndividualContent.js
index 95b0c2481..bb9237130 100644
--- a/src/components/explorer/ExplorerIndividualContent.js
+++ b/src/components/explorer/ExplorerIndividualContent.js
@@ -9,10 +9,10 @@ import { matchingMenuKeys, transformMenuItem } from "@/utils/menu";
import { ExplorerIndividualContext } from "./contexts/individual";
import {
- useIsDataEmpty,
- useDeduplicatedIndividualBiosamples,
- useIndividualResources,
- explorerIndividualUrl,
+ useIsDataEmpty,
+ useDeduplicatedIndividualBiosamples,
+ useIndividualResources,
+ explorerIndividualUrl,
} from "./utils";
import SitePageHeader from "../SitePageHeader";
@@ -29,154 +29,162 @@ import IndividualMedicalActions from "./IndividualMedicalActions";
import IndividualMeasurements from "./IndividualMeasurements";
const MENU_STYLE = {
- marginLeft: "-24px",
- marginRight: "-24px",
- marginTop: "-12px",
+ marginLeft: "-24px",
+ marginRight: "-24px",
+ marginTop: "-12px",
};
const headerTitle = (individual) => {
- if (!individual) {
- return null;
- }
- const mainId = individual.id;
- const alternateIds = individual.alternate_ids ?? [];
- return alternateIds.length ? `${mainId} (${alternateIds.join(", ")})` : mainId;
+ if (!individual) {
+ return null;
+ }
+ const mainId = individual.id;
+ const alternateIds = individual.alternate_ids ?? [];
+ return alternateIds.length ? `${mainId} (${alternateIds.join(", ")})` : mainId;
};
const ExplorerIndividualContent = () => {
- const location = useLocation();
- const navigate = useNavigate();
- const { individual: individualID } = useParams();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { individual: individualID } = useParams();
- const [backUrl, setBackUrl] = useState(location.state?.backUrl);
+ const [backUrl, setBackUrl] = useState(location.state?.backUrl);
- useEffect(() => {
- const b = location.state?.backUrl;
- if (b) {
- setBackUrl(b);
- }
- }, [location]);
-
- const { isFetching: individualIsFetching, data: individual } = useIndividual(individualID) ?? {};
-
- const resourcesTuple = useIndividualResources(individual);
- const individualContext = useMemo(() => ({ individualID, resourcesTuple }), [individualID, resourcesTuple]);
-
- const individualUrl = explorerIndividualUrl(individualID);
-
- const overviewPath = "overview";
- const phenotypicFeaturesPath = "phenotypic-features";
- const biosamplesPath = "biosamples";
- const experimentsPath = "experiments";
- const diseasesPath = "diseases";
- const ontologiesPath = "ontologies";
- const tracksPath = "tracks";
- const phenopacketsPath = "phenopackets";
- const interpretationsPath = "interpretations";
- const medicalActionsPath = "medical-actions";
- const measurementsPath = "measurements";
-
- const individualPhenopackets = individual?.phenopackets ?? [];
- const individualMenu = [
- // Overview
- { url: `${individualUrl}/${overviewPath}`, style: {marginLeft: "4px"}, text: "Overview" },
- // Biosamples related menu items
- {
- url: `${individualUrl}/${biosamplesPath}`,
- text: "Biosamples",
- disabled: useIsDataEmpty(individualPhenopackets, "biosamples"),
- },
- {
- url: `${individualUrl}/${measurementsPath}`,
- text: "Measurements",
- disabled: useIsDataEmpty(individualPhenopackets, "measurements"),
- },
- {
- url: `${individualUrl}/${phenotypicFeaturesPath}`,
- text: "Phenotypic Features",
- disabled: useIsDataEmpty(individualPhenopackets, "phenotypic_features"),
- },
- {
- url: `${individualUrl}/${diseasesPath}`,
- text: "Diseases",
- disabled: useIsDataEmpty(individualPhenopackets, "diseases"),
- },
- {
- url: `${individualUrl}/${interpretationsPath}`,
- text: "Interpretations",
- disabled: useIsDataEmpty(individualPhenopackets, "interpretations"),
- },
- {
- url: `${individualUrl}/${medicalActionsPath}`,
- text: "Medical Actions",
- disabled: useIsDataEmpty(individualPhenopackets, "medical_actions"),
- },
- // Experiments related menu items
- {
- url: `${individualUrl}/${experimentsPath}`,
- text: "Experiments",
- disabled: useIsDataEmpty(
- useDeduplicatedIndividualBiosamples(individual),
- "experiments",
- ),
- },
- {url: `${individualUrl}/${tracksPath}`, text: "Tracks"},
- // Extra
- {url: `${individualUrl}/${ontologiesPath}`, text: "Ontologies"},
- {url: `${individualUrl}/${phenopacketsPath}`, text: "Phenopackets JSON"},
- ];
- const selectedKeys = matchingMenuKeys(individualMenu);
-
- return <>
- {
+ useEffect(() => {
+ const b = location.state?.backUrl;
+ if (b) {
+ setBackUrl(b);
+ }
+ }, [location]);
+
+ const { isFetching: individualIsFetching, data: individual } = useIndividual(individualID) ?? {};
+
+ const resourcesTuple = useIndividualResources(individual);
+ const individualContext = useMemo(() => ({ individualID, resourcesTuple }), [individualID, resourcesTuple]);
+
+ const individualUrl = explorerIndividualUrl(individualID);
+
+ const overviewPath = "overview";
+ const phenotypicFeaturesPath = "phenotypic-features";
+ const biosamplesPath = "biosamples";
+ const experimentsPath = "experiments";
+ const diseasesPath = "diseases";
+ const ontologiesPath = "ontologies";
+ const tracksPath = "tracks";
+ const phenopacketsPath = "phenopackets";
+ const interpretationsPath = "interpretations";
+ const medicalActionsPath = "medical-actions";
+ const measurementsPath = "measurements";
+
+ const individualPhenopackets = individual?.phenopackets ?? [];
+ const individualMenu = [
+ // Overview
+ { url: `${individualUrl}/${overviewPath}`, style: { marginLeft: "4px" }, text: "Overview" },
+ // Biosamples related menu items
+ {
+ url: `${individualUrl}/${biosamplesPath}`,
+ text: "Biosamples",
+ disabled: useIsDataEmpty(individualPhenopackets, "biosamples"),
+ },
+ {
+ url: `${individualUrl}/${measurementsPath}`,
+ text: "Measurements",
+ disabled: useIsDataEmpty(individualPhenopackets, "measurements"),
+ },
+ {
+ url: `${individualUrl}/${phenotypicFeaturesPath}`,
+ text: "Phenotypic Features",
+ disabled: useIsDataEmpty(individualPhenopackets, "phenotypic_features"),
+ },
+ {
+ url: `${individualUrl}/${diseasesPath}`,
+ text: "Diseases",
+ disabled: useIsDataEmpty(individualPhenopackets, "diseases"),
+ },
+ {
+ url: `${individualUrl}/${interpretationsPath}`,
+ text: "Interpretations",
+ disabled: useIsDataEmpty(individualPhenopackets, "interpretations"),
+ },
+ {
+ url: `${individualUrl}/${medicalActionsPath}`,
+ text: "Medical Actions",
+ disabled: useIsDataEmpty(individualPhenopackets, "medical_actions"),
+ },
+ // Experiments related menu items
+ {
+ url: `${individualUrl}/${experimentsPath}`,
+ text: "Experiments",
+ disabled: useIsDataEmpty(useDeduplicatedIndividualBiosamples(individual), "experiments"),
+ },
+ { url: `${individualUrl}/${tracksPath}`, text: "Tracks" },
+ // Extra
+ { url: `${individualUrl}/${ontologiesPath}`, text: "Ontologies" },
+ { url: `${individualUrl}/${phenopacketsPath}`, text: "Phenopackets JSON" },
+ ];
+ const selectedKeys = matchingMenuKeys(individualMenu);
+
+ return (
+ <>
+ {
navigate(backUrl);
- setBackUrl(undefined); // Clear back button if we use it
- }) : undefined}
- footer={
-
+ }
+ />
+
+
+
+ {individual && !individualIsFetching ? (
+
+ {/* OVERVIEW */}
+ } />
+ {/* BIOSAMPLES RELATED */}
+ } />
+ } />
+ }
+ />
+ } />
+ }
+ />
+ }
/>
- }
- />
-
-
-
- {(individual && !individualIsFetching) ?
- {/* OVERVIEW */}
- } />
- {/* BIOSAMPLES RELATED */}
- }
- />
- } />
- } />
- } />
- } />
- } />
- {/* EXPERIMENTS RELATED*/}
- } />
- } />
- {/* EXTRA */}
- } />
- } />
- } />
- : }
-
-
-
- >;
+ {/* EXPERIMENTS RELATED*/}
+ } />
+ } />
+ {/* EXTRA */}
+ } />
+ } />
+ } />
+
+ ) : (
+
+ )}
+
+
+
+ >
+ );
};
export default ExplorerIndividualContent;
diff --git a/src/components/explorer/ExplorerSearchContent.js b/src/components/explorer/ExplorerSearchContent.js
index 7acadabd6..ba8634150 100644
--- a/src/components/explorer/ExplorerSearchContent.js
+++ b/src/components/explorer/ExplorerSearchContent.js
@@ -10,45 +10,55 @@ import { LAYOUT_CONTENT_STYLE } from "@/styles/layoutContent";
import { matchingMenuKeys, transformMenuItem } from "@/utils/menu";
const ExplorerSearchContent = () => {
- const projects = useSelector((state) => state.projects.items);
- const isFetchingDependentData = useSelector((state) => state.user.isFetchingDependentData);
+ const projects = useSelector((state) => state.projects.items);
+ const isFetchingDependentData = useSelector((state) => state.user.isFetchingDependentData);
- const menuItems = useMemo(() => projects.map(project => ({
+ const menuItems = useMemo(
+ () =>
+ projects.map((project) => ({
// url: `/data/explorer/projects/${project.identifier}`,
key: project.identifier,
text: project.title,
children: project.datasets.map((dataset) => ({
- url: `/data/explorer/search/${dataset.identifier}`,
- text: dataset.title,
+ url: `/data/explorer/search/${dataset.identifier}`,
+ text: dataset.title,
})),
- })), [projects]);
+ })),
+ [projects],
+ );
- const datasets = useMemo(() => projects.flatMap(p => p.datasets), [projects]);
+ const datasets = useMemo(() => projects.flatMap((p) => p.datasets), [projects]);
- return <>
-
-
-
-
-
p.key)}
- selectedKeys={matchingMenuKeys(menuItems)}
- items={menuItems.map(transformMenuItem)}
- />
-
-
-
- {datasets.length > 0 ? (
-
- } />
- } />
-
- ) : (isFetchingDependentData ? : "No datasets available")}
-
-
- >;
+ return (
+ <>
+
+
+
+
+
p.key)}
+ selectedKeys={matchingMenuKeys(menuItems)}
+ items={menuItems.map(transformMenuItem)}
+ />
+
+
+
+ {datasets.length > 0 ? (
+
+ } />
+ } />
+
+ ) : isFetchingDependentData ? (
+
+ ) : (
+ "No datasets available"
+ )}
+
+
+ >
+ );
};
export default ExplorerSearchContent;
diff --git a/src/components/explorer/ExplorerSearchResultsTable.js b/src/components/explorer/ExplorerSearchResultsTable.js
index ac3432f21..310e69d61 100644
--- a/src/components/explorer/ExplorerSearchResultsTable.js
+++ b/src/components/explorer/ExplorerSearchResultsTable.js
@@ -1,4 +1,4 @@
-import React, {useState, useMemo, useCallback} from "react";
+import React, { useState, useMemo, useCallback } from "react";
import PropTypes from "prop-types";
import { useParams } from "react-router-dom";
import { useSelector, useDispatch } from "react-redux";
@@ -10,174 +10,201 @@ import SearchSummaryModal from "./SearchSummaryModal";
import SearchTracksModal from "./SearchTracksModal";
import {
- setSelectedRows,
- performIndividualsDownloadCSVIfPossible,
- performBiosamplesDownloadCSVIfPossible,
- performExperimentsDownloadCSVIfPossible,
- setTableSortOrder,
+ setSelectedRows,
+ performIndividualsDownloadCSVIfPossible,
+ performBiosamplesDownloadCSVIfPossible,
+ performExperimentsDownloadCSVIfPossible,
+ setTableSortOrder,
} from "@/modules/explorer/actions";
const PAGE_SIZE = 25;
const ExplorerSearchResultsTable = ({
- data,
- activeTab,
- columns,
- currentPage: initialCurrentPage,
- sortOrder,
- sortColumnKey,
+ data,
+ activeTab,
+ columns,
+ currentPage: initialCurrentPage,
+ sortOrder,
+ sortColumnKey,
}) => {
- const { dataset } = useParams();
- const [currentPage, setCurrentPage] = useState(initialCurrentPage || 1);
- const [numResults] = useState(data.length);
-
- const [summaryModalVisible, setSummaryModalVisible] = useState(false);
- const [tracksModalVisible, setTracksModalVisible] = useState(false);
-
- const showingResults = useMemo(() => {
- const start = numResults > 0 ? currentPage * PAGE_SIZE - PAGE_SIZE + 1 : 0;
- const end = Math.min(currentPage * PAGE_SIZE, numResults);
- return `Showing results ${start}-${end} of ${numResults}`;
- }, [currentPage, PAGE_SIZE, numResults]);
-
- const searchResults = useSelector((state) => state.explorer.searchResultsByDatasetID[dataset] || null);
- const selectedRows = useSelector((state) => state.explorer.selectedRowsByDatasetID[dataset]);
- const isFetchingDownload = useSelector((state) => state.explorer.isFetchingDownload || false);
- const fetchingSearch = useSelector((state) => state.explorer.fetchingSearchByDatasetID[dataset] || false);
- const dispatch = useDispatch();
-
- const handleSetSelectedRows = useCallback(
- (...args) => dispatch(setSelectedRows(dataset, ...args)),
- [dispatch, dataset]);
-
- const handlePerformDownloadCSVIfPossible = useCallback((...args) => {
- if (activeTab === "individuals") {
- return dispatch(performIndividualsDownloadCSVIfPossible(dataset, ...args));
- }
- if (activeTab === "biosamples") {
- return dispatch(performBiosamplesDownloadCSVIfPossible(dataset, ...args));
- }
- if (activeTab === "experiments") {
- return dispatch(performExperimentsDownloadCSVIfPossible(dataset, ...args));
- }
- }, [dispatch, dataset, activeTab]);
-
- const onPageChange = useCallback((pageObj, filters, sorter) => {
- setCurrentPage(pageObj.current);
- dispatch(setTableSortOrder(dataset, sorter.field, sorter.order, activeTab, pageObj.current));
- }, [dispatch, dataset, activeTab]);
-
- const tableStyle = useMemo(() => ({
- opacity: fetchingSearch ? 0.5 : 1,
- pointerEvents: fetchingSearch ? "none" : "auto",
- }), [fetchingSearch]);
-
- const rowSelection = useMemo(() => ({
- type: "checkbox",
- selectedRowKeys: selectedRows ?? [],
- onChange: (selectedRowKeys) => {
- handleSetSelectedRows(selectedRowKeys);
+ const { dataset } = useParams();
+ const [currentPage, setCurrentPage] = useState(initialCurrentPage || 1);
+ const [activeFilters, setActiveFilters] = useState([]);
+
+ const [summaryModalVisible, setSummaryModalVisible] = useState(false);
+ const [tracksModalVisible, setTracksModalVisible] = useState(false);
+
+ const filteredData = useMemo(
+ () =>
+ data.filter((item) =>
+ Object.entries(activeFilters).every(
+ ([filterKey, filterValues]) =>
+ filterValues === null || filterValues.length === 0 || filterValues.includes(item[filterKey]),
+ ),
+ ),
+ [data, activeFilters],
+ );
+
+ const showingResults = useMemo(() => {
+ const start = filteredData.length > 0 ? currentPage * PAGE_SIZE - PAGE_SIZE + 1 : 0;
+ const end = Math.min(currentPage * PAGE_SIZE, filteredData.length);
+ return `Showing results ${start}-${end} of ${filteredData.length}`;
+ }, [currentPage, filteredData]);
+
+ const searchResults = useSelector((state) => state.explorer.searchResultsByDatasetID[dataset] || null);
+ const selectedRows = useSelector((state) => state.explorer.selectedRowsByDatasetID[dataset]);
+ const isFetchingDownload = useSelector((state) => state.explorer.isFetchingDownload || false);
+ const fetchingSearch = useSelector((state) => state.explorer.fetchingSearchByDatasetID[dataset] || false);
+ const dispatch = useDispatch();
+
+ const handleSetSelectedRows = useCallback(
+ (...args) => dispatch(setSelectedRows(dataset, ...args)),
+ [dispatch, dataset],
+ );
+
+ const handlePerformDownloadCSVIfPossible = useCallback(
+ (...args) => {
+ if (activeTab === "individuals") {
+ return dispatch(performIndividualsDownloadCSVIfPossible(dataset, ...args));
+ }
+ if (activeTab === "biosamples") {
+ return dispatch(performBiosamplesDownloadCSVIfPossible(dataset, ...args));
+ }
+ if (activeTab === "experiments") {
+ return dispatch(performExperimentsDownloadCSVIfPossible(dataset, ...args));
+ }
+ },
+ [dispatch, dataset, activeTab],
+ );
+
+ const onTableChange = useCallback(
+ (pageObj, filters, sorter) => {
+ const newPage = filters !== activeFilters ? 1 : pageObj.current;
+ setCurrentPage(newPage);
+ setActiveFilters(filters);
+ dispatch(setTableSortOrder(dataset, sorter.field, sorter.order, activeTab, newPage));
+ },
+ [dispatch, dataset, activeTab, activeFilters],
+ );
+
+ const tableStyle = useMemo(
+ () => ({
+ opacity: fetchingSearch ? 0.5 : 1,
+ pointerEvents: fetchingSearch ? "none" : "auto",
+ }),
+ [fetchingSearch],
+ );
+
+ const rowSelection = useMemo(
+ () => ({
+ type: "checkbox",
+ selectedRowKeys: selectedRows ?? [],
+ onChange: (selectedRowKeys) => {
+ handleSetSelectedRows(selectedRowKeys);
+ },
+ selections: [
+ {
+ key: "all-data",
+ text: "Select All Data",
+ onSelect: () => {
+ const filteredKeys = filteredData.map((item) => item.key);
+ handleSetSelectedRows(filteredKeys);
+ },
},
- selections: [
- {
- key: "all-data",
- text: "Select All Data",
- onSelect: () => {
- const allRowKeys = data.map((item) => item.key);
- handleSetSelectedRows(allRowKeys);
- },
- },
- {
- key: "unselect-all-data",
- text: "Unselect all data",
- onSelect: () => handleSetSelectedRows([]),
- },
- ],
- }), [selectedRows, data, handleSetSelectedRows]);
-
- const sortedInfo = useMemo(
- () => ({
- order: sortOrder,
- columnKey: sortColumnKey,
- }),
- [sortOrder, sortColumnKey],
- );
-
- return (
-
-
- {showingResults}
-
-
- {/* TODO: new "visualize tracks" functionality */}
- {/*
handleSetSelectedRows([]),
+ },
+ ],
+ }),
+ [selectedRows, filteredData, handleSetSelectedRows],
+ );
+
+ const sortedInfo = useMemo(
+ () => ({
+ order: sortOrder,
+ columnKey: sortColumnKey,
+ }),
+ [sortOrder, sortColumnKey],
+ );
+
+ return (
+
+
+ {showingResults}
+
+
+ {/* TODO: new "visualize tracks" functionality */}
+ {/* this.setState({tracksModalVisible: true})}
disabled={true}>
Visualize Tracks */}
- }
- style={{ marginRight: "8px" }}
- onClick={() => setSummaryModalVisible(true)}
- >
- View Summary
-
- }
- style={{ marginRight: "8px" }}
- loading={isFetchingDownload}
- onClick={() => handlePerformDownloadCSVIfPossible(selectedRows ?? [], data)}
- >
- Export as CSV
-
-
-
- {summaryModalVisible && (
-
setSummaryModalVisible(false)}
- />
- )}
- {tracksModalVisible && (
- setTracksModalVisible(false)}
- />
- )}
-
-
{
- return record.key;
- }}
- rowSelection={rowSelection}
- />
-
+ }
+ style={{ marginRight: "8px" }}
+ onClick={() => setSummaryModalVisible(true)}
+ >
+ View Summary
+
+ }
+ style={{ marginRight: "8px" }}
+ loading={isFetchingDownload}
+ onClick={() => handlePerformDownloadCSVIfPossible(selectedRows ?? [], filteredData)}
+ >
+ Export as CSV
+
- );
+
+ {summaryModalVisible && (
+ setSummaryModalVisible(false)}
+ />
+ )}
+ {tracksModalVisible && (
+ setTracksModalVisible(false)}
+ />
+ )}
+
+
{
+ return record.key;
+ }}
+ rowSelection={rowSelection}
+ />
+
+
+ );
};
ExplorerSearchResultsTable.propTypes = {
- data: PropTypes.arrayOf(PropTypes.object),
- activeTab: PropTypes.string.isRequired,
- columns: PropTypes.arrayOf(PropTypes.object).isRequired,
- sortOrder: PropTypes.string,
- sortColumnKey: PropTypes.string,
- currentPage: PropTypes.number,
+ data: PropTypes.arrayOf(PropTypes.object),
+ activeTab: PropTypes.string.isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
+ sortOrder: PropTypes.string,
+ sortColumnKey: PropTypes.string,
+ currentPage: PropTypes.number,
};
export default ExplorerSearchResultsTable;
diff --git a/src/components/explorer/ExtraProperties.js b/src/components/explorer/ExtraProperties.js
index a0197d0bb..111a5c503 100644
--- a/src/components/explorer/ExtraProperties.js
+++ b/src/components/explorer/ExtraProperties.js
@@ -4,15 +4,15 @@ import PropTypes from "prop-types";
import { EM_DASH } from "@/constants";
import JsonView from "@/components/common/JsonView";
-const ExtraProperties = ({extraProperties}) => {
- if (!extraProperties || !Object.keys(extraProperties).length) {
- return EM_DASH;
- }
+const ExtraProperties = ({ extraProperties }) => {
+ if (!extraProperties || !Object.keys(extraProperties).length) {
+ return EM_DASH;
+ }
- return ;
+ return ;
};
ExtraProperties.propTypes = {
- extraProperties: PropTypes.object,
+ extraProperties: PropTypes.object,
};
export default ExtraProperties;
diff --git a/src/components/explorer/GenomeBrowser.js b/src/components/explorer/GenomeBrowser.js
index 574199320..327b08314 100644
--- a/src/components/explorer/GenomeBrowser.js
+++ b/src/components/explorer/GenomeBrowser.js
@@ -3,9 +3,9 @@ import React from "react";
// TODO: rewrite this component using views of vcf files, focused on variants of interest
class GenomeBrowser extends React.Component {
- render() {
- return null;
- }
+ render() {
+ return null;
+ }
}
export default GenomeBrowser;
diff --git a/src/components/explorer/IndividualBiosamples.js b/src/components/explorer/IndividualBiosamples.js
index 0333986e4..88e902be9 100644
--- a/src/components/explorer/IndividualBiosamples.js
+++ b/src/components/explorer/IndividualBiosamples.js
@@ -6,10 +6,10 @@ import { Button, Descriptions } from "antd";
import { EM_DASH } from "@/constants";
import {
- biosamplePropTypesShape,
- experimentPropTypesShape,
- individualPropTypesShape,
- ontologyShape,
+ biosamplePropTypesShape,
+ experimentPropTypesShape,
+ individualPropTypesShape,
+ ontologyShape,
} from "@/propTypes";
import { useDeduplicatedIndividualBiosamples } from "./utils";
@@ -26,196 +26,188 @@ import ExtraProperties from "./ExtraProperties";
// highlight those found in search results, if specified
const BiosampleProcedure = ({ procedure }) => {
- if (!procedure) {
- return EM_DASH;
- }
-
- return (
+ if (!procedure) {
+ return EM_DASH;
+ }
+
+ return (
+
+
Code:
+ {procedure.body_site ? (
+
+ Body Site:
+
+ ) : null}
+ {procedure.performed ? (
-
Code: {" "}
- {procedure.body_site ? (
-
- Body Site: {" "}
-
-
- ) : null}
- {procedure.performed ? (
-
- Performed: {" "}
-
-
- ) : null}
+
Performed:
- );
+ ) : null}
+
+ );
};
BiosampleProcedure.propTypes = {
- procedure: PropTypes.shape({
- code: ontologyShape.isRequired,
- body_site: ontologyShape,
- performed: PropTypes.object,
- }),
+ procedure: PropTypes.shape({
+ code: ontologyShape.isRequired,
+ body_site: ontologyShape,
+ performed: PropTypes.object,
+ }),
};
const ExperimentsClickList = ({ experiments, handleExperimentClick }) => {
- if (!experiments?.length) return EM_DASH;
- return experiments?.length ? (
- <>
- {(experiments ?? []).map((e, i) => (
-
- handleExperimentClick(e.id)}>
- {e.experiment_type}
-
- {" "}
-
- ))}
- >
- ) : EM_DASH;
+ if (!experiments?.length) return EM_DASH;
+ return experiments?.length ? (
+ <>
+ {(experiments ?? []).map((e, i) => (
+
+ handleExperimentClick(e.id)}>{e.experiment_type} {" "}
+
+ ))}
+ >
+ ) : (
+ EM_DASH
+ );
};
ExperimentsClickList.propTypes = {
- experiments: PropTypes.arrayOf(experimentPropTypesShape),
- handleExperimentClick: PropTypes.func,
+ experiments: PropTypes.arrayOf(experimentPropTypesShape),
+ handleExperimentClick: PropTypes.func,
};
const BiosampleDetail = ({ biosample, handleExperimentClick }) => {
- return (
-
-
- {biosample.id}
-
-
- {biosample.derived_from_id ? : EM_DASH}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {biosample.hasOwnProperty("measurements") &&
- Object.keys(biosample.measurements).length ? (
-
- ) : (
- EM_DASH
- )}
-
-
-
-
-
-
-
-
- );
+ return (
+
+ {biosample.id}
+
+ {biosample.derived_from_id ? : EM_DASH}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {biosample.hasOwnProperty("measurements") && Object.keys(biosample.measurements).length ? (
+
+ ) : (
+ EM_DASH
+ )}
+
+
+
+
+
+
+
+
+ );
};
BiosampleDetail.propTypes = {
- biosample: biosamplePropTypesShape,
- handleExperimentClick: PropTypes.func,
+ biosample: biosamplePropTypesShape,
+ handleExperimentClick: PropTypes.func,
};
const Biosamples = ({ individual, handleBiosampleClick, handleExperimentClick }) => {
- const { selectedBiosample } = useParams();
-
- useEffect(() => {
- // If, on first load, there's a selected biosample:
- // - find the biosample-${id} element (a span in the table row)
- // - scroll it into view
- setTimeout(() => {
- if (selectedBiosample) {
- const el = document.getElementById(`biosample-${selectedBiosample}`);
- if (!el) return;
- el.scrollIntoView();
- }
- }, 100);
- }, []);
-
- const biosamples = useDeduplicatedIndividualBiosamples(individual);
-
- const columns = useMemo(
- () => [
- {
- title: "Biosample",
- dataIndex: "id",
- render: id => {id} , // scroll anchor wrapper
- },
- {
- title: "Sampled Tissue",
- dataIndex: "sampled_tissue",
- render: tissue => ,
- },
- {
- title: "Experiments",
- key: "experiments",
- render: (_, {experiments}) => (
-
- ),
- },
- ],
- [handleExperimentClick],
- );
-
- const expandedRowRender = useCallback(
- (biosample) => (
-
+ const { selectedBiosample } = useParams();
+
+ useEffect(() => {
+ // If there's a selected biosample:
+ // - find the biosample-${id} element (a span in the table row)
+ // - scroll it into view
+ setTimeout(() => {
+ if (selectedBiosample) {
+ const el = document.getElementById(`biosample-${selectedBiosample}`);
+ if (!el) return;
+ el.scrollIntoView();
+ }
+ }, 100);
+ }, [selectedBiosample]);
+
+ const biosamples = useDeduplicatedIndividualBiosamples(individual);
+
+ const columns = useMemo(
+ () => [
+ {
+ title: "Biosample",
+ dataIndex: "id",
+ render: (id) => {id} , // scroll anchor wrapper
+ },
+ {
+ title: "Sampled Tissue",
+ dataIndex: "sampled_tissue",
+ render: (tissue) => ,
+ },
+ {
+ title: "Experiments",
+ key: "experiments",
+ render: (_, { experiments }) => (
+
),
- [handleExperimentClick],
- );
-
- return (
-
- );
+ },
+ ],
+ [handleExperimentClick],
+ );
+
+ const expandedRowRender = useCallback(
+ (biosample) => ,
+ [handleExperimentClick],
+ );
+
+ return (
+
+ );
};
Biosamples.propTypes = {
- individual: individualPropTypesShape,
- handleBiosampleClick: PropTypes.func,
- handleExperimentClick: PropTypes.func,
+ individual: individualPropTypesShape,
+ handleBiosampleClick: PropTypes.func,
+ handleExperimentClick: PropTypes.func,
};
const IndividualBiosamples = ({ individual }) => {
- const navigate = useNavigate();
-
- const handleExperimentClick = useCallback((eid) => {
- navigate(`../experiments/${eid}`);
- }, [navigate]);
-
- return (
- (
-
- )}
+ const navigate = useNavigate();
+
+ const handleExperimentClick = useCallback(
+ (eid) => {
+ navigate(`../experiments/${eid}`);
+ },
+ [navigate],
+ );
+
+ return (
+ (
+
- );
+ )}
+ />
+ );
};
IndividualBiosamples.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualBiosamples;
diff --git a/src/components/explorer/IndividualDiseases.js b/src/components/explorer/IndividualDiseases.js
index cb498dfd5..a71ed2a3a 100644
--- a/src/components/explorer/IndividualDiseases.js
+++ b/src/components/explorer/IndividualDiseases.js
@@ -14,86 +14,79 @@ import ExtraProperties from "./ExtraProperties";
// highlight those found in search results, if specified
const DISEASES_COLUMNS = [
- {
- title: "Disease",
- dataIndex: "term",
- // Tag the ontology term with a data attribute holding the disease ID. This has no effect, but might
- // help us debug diseases in production if we need it.
- render: (term, disease) => (
-
- ),
- sorter: ontologyTermSorter("term"),
- },
- {
- title: "Excluded",
- dataIndex: "excluded",
- render: renderBoolean("excluded"),
- sorter: booleanFieldSorter("excluded"),
- },
- {
- title: "Onset Age",
- dataIndex: "onset",
- render: (onset) => ( ),
- },
- {
- title: "Resolution Age",
- dataIndex: "resolution",
- render: (resolution) => ( ),
- },
+ {
+ title: "Disease",
+ dataIndex: "term",
+ // Tag the ontology term with a data attribute holding the disease ID. This has no effect, but might
+ // help us debug diseases in production if we need it.
+ render: (term, disease) => ,
+ sorter: ontologyTermSorter("term"),
+ },
+ {
+ title: "Excluded",
+ dataIndex: "excluded",
+ render: renderBoolean("excluded"),
+ sorter: booleanFieldSorter("excluded"),
+ },
+ {
+ title: "Onset Age",
+ dataIndex: "onset",
+ render: (onset) => ,
+ },
+ {
+ title: "Resolution Age",
+ dataIndex: "resolution",
+ render: (resolution) => ,
+ },
];
-const DiseaseDetails = ({disease}) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+const DiseaseDetails = ({ disease }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
DiseaseDetails.propTypes = {
- disease: diseasePropTypesShape,
+ disease: diseasePropTypesShape,
};
const Diseases = ({ diseases, handleSelect }) => (
- ( )}
- />
+ }
+ />
);
Diseases.propTypes = {
- diseases: PropTypes.arrayOf(diseasePropTypesShape),
- handleSelect: PropTypes.func,
+ diseases: PropTypes.arrayOf(diseasePropTypesShape),
+ handleSelect: PropTypes.func,
};
const IndividualDiseases = ({ individual }) => {
- const diseases = useIndividualPhenopacketDataIndex(individual, "diseases");
- return (
- (
-
- )}
- />
- );
+ const diseases = useIndividualPhenopacketDataIndex(individual, "diseases");
+ return (
+ }
+ />
+ );
};
IndividualDiseases.propTypes = {
- individual: individualPropTypesShape.isRequired,
+ individual: individualPropTypesShape.isRequired,
};
export default IndividualDiseases;
diff --git a/src/components/explorer/IndividualExperiments.js b/src/components/explorer/IndividualExperiments.js
index f64f85794..52057f46d 100644
--- a/src/components/explorer/IndividualExperiments.js
+++ b/src/components/explorer/IndividualExperiments.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useState} from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Link, useParams } from "react-router-dom";
import PropTypes from "prop-types";
@@ -21,322 +21,314 @@ import { RoutedIndividualContent, RoutedIndividualContentTable } from "@/compone
import OntologyTerm from "./OntologyTerm";
import ExtraProperties from "./ExtraProperties";
-
-const BiosampleLink = ({ biosample }) => biosample ? (
- {biosample}
-) : EM_DASH;
+const BiosampleLink = ({ biosample }) =>
+ biosample ? {biosample} : EM_DASH;
BiosampleLink.propTypes = {
- biosample: PropTypes.string,
+ biosample: PropTypes.string,
};
-
const VIEWABLE_FILE_FORMATS = ["PDF", "CSV", "TSV"];
const ExperimentResultActions = ({ result }) => {
- const { filename } = result;
-
- const downloadUrls = useSelector((state) => state.drs.downloadUrlsByFilename);
- const url = downloadUrls[filename]?.url;
-
- const [viewModalVisible, setViewModalVisible] = useState(false);
-
- // Slightly different from viewModalVisible - this is just set on the first click of the
- // view button and results in file loading being triggered. if FileDisplay was always
- // immediately shown, it would load all experiment results immediately, which is undesirable
- // behaviour. Instead, we wait until a user clicks it, then load the file, but we don't unmount
- // the component after, so we have the file contents cached.
- const [hasTriggeredViewModal, setHasTriggeredViewModal] = useState(false);
-
- const onViewClick = useCallback(() => {
- setHasTriggeredViewModal(true);
- setViewModalVisible(true);
- }, []);
- const onViewCancel = useCallback(() => setViewModalVisible(false), []);
-
- const resultViewable = url && (
- VIEWABLE_FILE_FORMATS.includes(result.file_format) ||
- !!VIEWABLE_FILE_EXTENSIONS.find(ext => filename.toLowerCase().endsWith(ext)));
-
- return
- {url ? <>
-
- {""}
-
- {" "}
- > : null}
- {resultViewable ? <>
-
View: {result.filename}}
- url={url}
- fileName={result.filename}
- hasTriggered={hasTriggeredViewModal}
- />
-
- } onClick={onViewClick} />
- {" "}
- > : null}
-
-
-
- {result.identifier}
-
-
- {result.description}
-
-
- {result.filename}
-
-
- {result.file_format}
-
-
- {result.genome_assembly_id ?? EM_DASH}
-
-
- {result.data_output_type ?? EM_DASH}
-
-
- {result.usage}
-
-
- {result.creation_date}
-
-
- {result.created_by}
-
-
-
- }
- trigger="click"
- >
-
- } />
-
-
- ;
+ const { filename } = result;
+
+ const downloadUrls = useSelector((state) => state.drs.downloadUrlsByFilename);
+ const url = downloadUrls[filename]?.url;
+
+ const [viewModalVisible, setViewModalVisible] = useState(false);
+
+ // Slightly different from viewModalVisible - this is just set on the first click of the
+ // view button and results in file loading being triggered. if FileDisplay was always
+ // immediately shown, it would load all experiment results immediately, which is undesirable
+ // behaviour. Instead, we wait until a user clicks it, then load the file, but we don't unmount
+ // the component after, so we have the file contents cached.
+ const [hasTriggeredViewModal, setHasTriggeredViewModal] = useState(false);
+
+ const onViewClick = useCallback(() => {
+ setHasTriggeredViewModal(true);
+ setViewModalVisible(true);
+ }, []);
+ const onViewCancel = useCallback(() => setViewModalVisible(false), []);
+
+ const resultViewable =
+ url &&
+ (VIEWABLE_FILE_FORMATS.includes(result.file_format) ||
+ !!VIEWABLE_FILE_EXTENSIONS.find((ext) => filename.toLowerCase().endsWith(ext)));
+
+ return (
+
+ {url ? (
+ <>
+
+
+ {""}
+
+ {" "}
+ >
+ ) : null}
+ {resultViewable ? (
+ <>
+
View: {result.filename}}
+ url={url}
+ fileName={result.filename}
+ hasTriggered={hasTriggeredViewModal}
+ />
+
+ } onClick={onViewClick} />
+ {" "}
+ >
+ ) : null}
+
+
+ {result.identifier}
+ {result.description}
+ {result.filename}
+ {result.file_format}
+ {result.genome_assembly_id ?? EM_DASH}
+ {result.data_output_type ?? EM_DASH}
+ {result.usage}
+ {result.creation_date}
+ {result.created_by}
+
+
+ }
+ trigger="click"
+ >
+
+ } />
+
+
+
+ );
};
ExperimentResultActions.propTypes = {
- result: experimentResultPropTypesShape,
+ result: experimentResultPropTypesShape,
};
const EXPERIMENT_RESULTS_COLUMNS = [
- {
- title: "File Format",
- dataIndex: "file_format",
- },
- {
- title: "Creation Date",
- dataIndex: "creation_date",
- },
- {
- title: "Description",
- dataIndex: "description",
- },
- {
- title: "Filename",
- dataIndex: "filename",
- },
- {
- key: "other_details",
- align: "center",
- render: (_, result) => ,
- },
+ {
+ title: "File Format",
+ dataIndex: "file_format",
+ },
+ {
+ title: "Creation Date",
+ dataIndex: "creation_date",
+ },
+ {
+ title: "Description",
+ dataIndex: "description",
+ },
+ {
+ title: "Filename",
+ dataIndex: "filename",
+ },
+ {
+ key: "other_details",
+ align: "center",
+ render: (_, result) => ,
+ },
];
const ExperimentDetail = ({ experiment }) => {
- const {
- id,
- biosample,
- experiment_type: experimentType,
- experiment_ontology: experimentOntology,
- molecule,
- molecule_ontology: moleculeOntology,
- instrument,
- study_type: studyType,
- extraction_protocol: extractionProtocol,
- library_layout: libraryLayout,
- library_selection: librarySelection,
- library_source: librarySource,
- library_strategy: libraryStrategy,
- experiment_results: experimentResults,
- extra_properties: extraProperties,
- } = experiment;
-
- const sortedExperimentResults = useMemo(
- () =>
- [...(experimentResults || [])].sort((r1, r2) => r1.file_format > r2.file_format ? 1 : -1),
- [experimentResults]);
-
- return (
-
-
Details
-
-
- {id}
-
-
-
-
- {experimentType}
-
- {/*
+ const {
+ id,
+ biosample,
+ experiment_type: experimentType,
+ experiment_ontology: experimentOntology,
+ molecule,
+ molecule_ontology: moleculeOntology,
+ instrument,
+ study_type: studyType,
+ extraction_protocol: extractionProtocol,
+ library_layout: libraryLayout,
+ library_selection: librarySelection,
+ library_source: librarySource,
+ library_strategy: libraryStrategy,
+ experiment_results: experimentResults,
+ extra_properties: extraProperties,
+ } = experiment;
+
+ const sortedExperimentResults = useMemo(
+ () => [...(experimentResults || [])].sort((r1, r2) => (r1.file_format > r2.file_format ? 1 : -1)),
+ [experimentResults],
+ );
+
+ return (
+
+
+ Details
+
+
+
+ {id}
+
+
+
+
+
+ {experimentType}
+
+
+ {/*
experiment_ontology is accidentally an array in Katsu, so this takes the first item
and falls back to just the field (if we fix this in the future)
*/}
-
-
-
- {molecule}
-
-
- {/*
+
+
+
+ {molecule}
+
+
+ {/*
molecule_ontology is accidentally an array in Katsu, so this takes the first item
and falls back to just the field (if we fix this in the future)
*/}
-
-
- {studyType}
- {extractionProtocol}
- {libraryLayout}
- {librarySelection}
- {librarySource}
- {libraryStrategy}
-
-
-
- Platform: {instrument.platform}
-
-
- ID: {instrument.identifier}
-
-
-
-
-
-
-
-
- {sortedExperimentResults.length ? "Results" : "No experiment results"}
-
- {sortedExperimentResults.length ?
: null}
-
- );
+
+
+ {studyType}
+ {extractionProtocol}
+
+ {libraryLayout}
+
+
+ {librarySelection}
+
+
+ {librarySource}
+
+
+ {libraryStrategy}
+
+
+
+
+ Platform: {instrument.platform}
+
+
+ ID: {instrument.identifier}
+
+
+
+
+
+
+
+
+ {sortedExperimentResults.length ? "Results" : "No experiment results"}
+
+ {sortedExperimentResults.length ? (
+
+ ) : null}
+
+ );
};
ExperimentDetail.propTypes = {
- experiment: experimentPropTypesShape,
+ experiment: experimentPropTypesShape,
};
-const expandedExperimentRowRender = (experiment) => (
-
-);
+const expandedExperimentRowRender = (experiment) => ;
+
+const EXPERIMENT_COLUMNS = [
+ {
+ title: "Experiment Type",
+ dataIndex: "experiment_type",
+ render: (type, { id }) => {type} , // scroll anchor wrapper
+ },
+ {
+ title: "Biosample",
+ dataIndex: "biosample",
+ render: (biosample) => ,
+ },
+ {
+ title: "Molecule",
+ dataIndex: "molecule_ontology",
+ render: (mo) => ,
+ },
+ {
+ title: "Experiment Results",
+ key: "experiment_results",
+ // experiment_results can be undefined if no experiment results exist
+ render: (exp) => {exp.experiment_results?.length ?? 0} files ,
+ },
+];
const Experiments = ({ individual, handleExperimentClick }) => {
- const dispatch = useDispatch();
-
- const { selectedExperiment } = useParams();
-
- useEffect(() => {
- // If, on first load, there's a selected experiment:
- // - find the experiment-${id} element (a span in the table row)
- // - scroll it into view
- setTimeout(() => {
- if (selectedExperiment) {
- const el = document.getElementById(`experiment-${selectedExperiment}`);
- if (!el) return;
- el.scrollIntoView();
- }
- }, 100);
- }, []);
-
- const biosamplesData = useDeduplicatedIndividualBiosamples(individual);
- const experimentsData = useMemo(
- () => biosamplesData.flatMap((b) => b?.experiments ?? []),
- [biosamplesData],
- );
-
- useEffect(() => {
- // retrieve any download urls if experiments data changes
-
- const downloadableFiles = experimentsData
- .flatMap((e) => e?.experiment_results ?? [])
- .map((r) => ({ // enforce file_format property
- ...r,
- file_format: r.file_format ?? guessFileType(r.filename),
- }));
-
- dispatch(getFileDownloadUrlsFromDrs(downloadableFiles)).catch(console.error);
- }, [experimentsData]);
-
- const columns = useMemo(
- () => [
- {
- title: "Experiment Type",
- dataIndex: "experiment_type",
- render: (type, { id }) => {type} , // scroll anchor wrapper
- },
- {
- title: "Biosample",
- dataIndex: "biosample",
- render: (biosample) => ,
- },
- {
- title: "Molecule",
- dataIndex: "molecule_ontology",
- render: (mo) => ,
- },
- {
- title: "Experiment Results",
- key: "experiment_results",
- // experiment_results can be undefined if no experiment results exist
- render: (exp) => {exp.experiment_results?.length ?? 0} files ,
- },
- ],
- [handleExperimentClick],
- );
-
- return (
-
- );
+ const dispatch = useDispatch();
+
+ const { selectedExperiment } = useParams();
+
+ useEffect(() => {
+ // If there's a selected experiment:
+ // - find the experiment-${id} element (a span in the table row)
+ // - scroll it into view
+ setTimeout(() => {
+ if (selectedExperiment) {
+ const el = document.getElementById(`experiment-${selectedExperiment}`);
+ if (!el) return;
+ el.scrollIntoView();
+ }
+ }, 100);
+ }, [selectedExperiment]);
+
+ const biosamplesData = useDeduplicatedIndividualBiosamples(individual);
+ const experimentsData = useMemo(() => biosamplesData.flatMap((b) => b?.experiments ?? []), [biosamplesData]);
+
+ useEffect(() => {
+ // retrieve any download urls if experiments data changes
+
+ const downloadableFiles = experimentsData
+ .flatMap((e) => e?.experiment_results ?? [])
+ .map((r) => ({
+ // enforce file_format property
+ ...r,
+ file_format: r.file_format ?? guessFileType(r.filename),
+ }));
+
+ dispatch(getFileDownloadUrlsFromDrs(downloadableFiles)).catch(console.error);
+ }, [dispatch, experimentsData]);
+
+ return (
+
+ );
};
Experiments.propTypes = {
- individual: individualPropTypesShape,
- handleExperimentClick: PropTypes.func,
+ individual: individualPropTypesShape,
+ handleExperimentClick: PropTypes.func,
};
const IndividualExperiments = ({ individual }) => (
- (
-
- )}
- />
+ (
+
+ )}
+ />
);
IndividualExperiments.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualExperiments;
diff --git a/src/components/explorer/IndividualGenes.js b/src/components/explorer/IndividualGenes.js
index e060c5c42..78dd41e7c 100644
--- a/src/components/explorer/IndividualGenes.js
+++ b/src/components/explorer/IndividualGenes.js
@@ -1,105 +1,110 @@
-import React, { useMemo } from "react";
+import React, { useContext, useMemo } from "react";
import { Link } from "react-router-dom";
import { useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Button, Descriptions, List, Table, Typography } from "antd";
+import { ExplorerIndividualContext } from "./contexts/individual";
import { setIgvPosition } from "@/modules/explorer/actions";
import { individualPropTypesShape } from "@/propTypes";
+import { explorerIndividualUrl } from "./utils";
+
// TODO: Only show genes from the relevant dataset, if specified;
// highlight those found in search results, if specified
const StringList = ({ values }) => {
- return (
- (
-
- {item}
-
- )}
- />
- );
+ return (
+ (
+
+ {item}
+
+ )}
+ />
+ );
};
StringList.propTypes = {
- values: PropTypes.arrayOf(PropTypes.string),
+ values: PropTypes.arrayOf(PropTypes.string),
};
export const GeneDescriptor = ({ geneDescriptor }) => {
- return (
-
- {geneDescriptor.value_id}
- {geneDescriptor.symbol}
- {geneDescriptor.description}
-
-
-
-
-
-
-
-
-
-
- );
+ const dispatch = useDispatch();
+ const { individualID } = useContext(ExplorerIndividualContext);
+ const tracksUrl = useMemo(() => {
+ if (individualID) {
+ return `${explorerIndividualUrl(individualID)}/tracks`;
+ }
+ }, [individualID]);
+
+ return (
+
+ {geneDescriptor.value_id}
+
+ dispatch(setIgvPosition(geneDescriptor.symbol))} to={tracksUrl}>
+ {geneDescriptor.symbol}
+
+
+ {geneDescriptor.description}
+
+
+
+
+
+
+
+
+
+
+ );
};
GeneDescriptor.propTypes = {
- geneDescriptor: PropTypes.object,
+ geneDescriptor: PropTypes.object,
};
-const GeneIGVLink = React.memo(({symbol, tracksUrl}) => {
- const dispatch = useDispatch();
- return (
- dispatch(setIgvPosition(symbol))} to={tracksUrl}>
- {symbol}
-
- );
+const GeneIGVLink = React.memo(({ symbol, tracksUrl }) => {
+ const dispatch = useDispatch();
+ return (
+ dispatch(setIgvPosition(symbol))} to={tracksUrl}>
+ {symbol}
+
+ );
});
GeneIGVLink.propTypes = {
- symbol: PropTypes.string,
- tracksUrl: PropTypes.string,
+ symbol: PropTypes.string,
+ tracksUrl: PropTypes.string,
};
-const IndividualGenes = ({individual, tracksUrl}) => {
- const genes = useMemo(
- () => Object.values(
- Object.fromEntries(
- (individual?.phenopackets ?? [])
- .flatMap(p => p.genes)
- .map(g => [g.symbol, g]),
- ),
- ),
- [individual],
- );
+const IndividualGenes = ({ individual, tracksUrl }) => {
+ const genes = useMemo(
+ () =>
+ Object.values(
+ Object.fromEntries((individual?.phenopackets ?? []).flatMap((p) => p.genes).map((g) => [g.symbol, g])),
+ ),
+ [individual],
+ );
- const columns = useMemo(
- () => [
- {
- title: "Symbol",
- dataIndex: "symbol",
- render: (symbol) => ,
- },
- ],
- [tracksUrl],
- );
+ const columns = useMemo(
+ () => [
+ {
+ title: "Symbol",
+ dataIndex: "symbol",
+ render: (symbol) => ,
+ },
+ ],
+ [tracksUrl],
+ );
- return (
-
- );
+ return (
+
+ );
};
IndividualGenes.propTypes = {
- individual: individualPropTypesShape,
- tracksUrl: PropTypes.string,
+ individual: individualPropTypesShape,
+ tracksUrl: PropTypes.string,
};
export default IndividualGenes;
diff --git a/src/components/explorer/IndividualInterpretations.js b/src/components/explorer/IndividualInterpretations.js
index ebc4c900b..2699739ea 100644
--- a/src/components/explorer/IndividualInterpretations.js
+++ b/src/components/explorer/IndividualInterpretations.js
@@ -14,210 +14,213 @@ import VariantDescriptor from "./IndividualVariants";
import BiosampleIDCell from "./searchResultsTables/BiosampleIDCell";
import { RoutedIndividualContent, RoutedIndividualContentTable } from "./RoutedIndividualContent";
-
export const VariantInterpretation = ({ variationInterpretation }) => {
- const [modalVisible, setModalVisible] = useState(false);
-
- const closeModal = () => setModalVisible(false);
- return (
-
-
- {variationInterpretation.acmg_pathogenicity_classification}
-
-
- {variationInterpretation.therapeutic_actionability}
-
-
- setModalVisible(!modalVisible)}>
- {variationInterpretation.variation_descriptor.id}
-
-
-
-
-
-
- );
+ const [modalVisible, setModalVisible] = useState(false);
+
+ const closeModal = () => setModalVisible(false);
+ return (
+
+
+ {variationInterpretation.acmg_pathogenicity_classification}
+
+
+ {variationInterpretation.therapeutic_actionability}
+
+
+ setModalVisible(!modalVisible)}>
+ {variationInterpretation.variation_descriptor.id}
+
+
+
+
+
+
+ );
};
VariantInterpretation.propTypes = {
- variationInterpretation: PropTypes.object,
+ variationInterpretation: PropTypes.object,
};
export const GenomicInterpretationDetails = ({ genomicInterpretation }) => {
- const relatedType = genomicInterpretation?.extra_properties?.__related_type ?? "unknown";
- const relatedLabel = relatedType[0].toUpperCase() + relatedType.slice(1).toLowerCase();
- const isBiosampleRelated = relatedType === "biosample";
-
- const variantInterpretation = genomicInterpretation?.variant_interpretation;
- const geneDescriptor = genomicInterpretation?.gene_descriptor;
-
- return (
-
-
- { isBiosampleRelated
- ?
- : genomicInterpretation.subject_or_biosample_id
- }
-
- {variantInterpretation &&
-
- }
- {geneDescriptor &&
-
- }
-
- );
+ const relatedType = genomicInterpretation?.extra_properties?.__related_type ?? "unknown";
+ const relatedLabel = relatedType[0].toUpperCase() + relatedType.slice(1).toLowerCase();
+ const isBiosampleRelated = relatedType === "biosample";
+
+ const variantInterpretation = genomicInterpretation?.variant_interpretation;
+ const geneDescriptor = genomicInterpretation?.gene_descriptor;
+
+ return (
+
+
+ {isBiosampleRelated ? (
+
+ ) : (
+ genomicInterpretation.subject_or_biosample_id
+ )}
+
+ {variantInterpretation && (
+
+
+
+ )}
+ {geneDescriptor && (
+
+
+
+ )}
+
+ );
};
GenomicInterpretationDetails.propTypes = {
- genomicInterpretation: PropTypes.object,
+ genomicInterpretation: PropTypes.object,
};
-
const INTERPRETATIONS_COLUMNS = [
- {
- title: "ID",
- dataIndex: "id",
- },
- {
- title: "Created",
- dataIndex: "created",
- },
- {
- title: "Updated",
- dataIndex: "updated",
- },
- {
- title: "Progress Status",
- dataIndex: "progress_status",
- },
- {
- title: "Summary",
- dataIndex: "summary",
- },
+ {
+ title: "ID",
+ dataIndex: "id",
+ },
+ {
+ title: "Created",
+ dataIndex: "created",
+ },
+ {
+ title: "Updated",
+ dataIndex: "updated",
+ },
+ {
+ title: "Progress Status",
+ dataIndex: "progress_status",
+ },
+ {
+ title: "Summary",
+ dataIndex: "summary",
+ },
];
const GENOMIC_INTERPRETATION_COLUMNS = [
- {
- title: "ID",
- dataIndex: "id",
- },
- {
- title: "Subject or Biosample ID",
- dataIndex: "subject_or_biosample_id",
- },
- {
- title: "Interpretation Status",
- dataIndex: "interpretation_status",
- },
+ {
+ title: "ID",
+ dataIndex: "id",
+ },
+ {
+ title: "Subject or Biosample ID",
+ dataIndex: "subject_or_biosample_id",
+ },
+ {
+ title: "Interpretation Status",
+ dataIndex: "interpretation_status",
+ },
];
-const expandedGIRowRender = (gi) => ( );
+const expandedGIRowRender = (gi) => ;
const GenomicInterpretations = ({ genomicInterpretations, onGenomicInterpretationClick }) => (
- gi.id.toString()}
- />
+ gi.id.toString()}
+ />
);
GenomicInterpretations.propTypes = {
- genomicInterpretations: PropTypes.arrayOf(PropTypes.object),
- onGenomicInterpretationClick: PropTypes.func,
+ genomicInterpretations: PropTypes.arrayOf(PropTypes.object),
+ onGenomicInterpretationClick: PropTypes.func,
};
-
const IndividualGenomicInterpretations = ({ genomicInterpretations }) => (
- (
-
- )}
- />
+ (
+
+ )}
+ />
);
IndividualGenomicInterpretations.propTypes = {
- genomicInterpretations: PropTypes.arrayOf(PropTypes.object),
+ genomicInterpretations: PropTypes.arrayOf(PropTypes.object),
};
const InterpretationDetail = ({ interpretation }) => {
- const { diagnosis } = interpretation;
-
- const sortedGenomicInterpretations = useMemo(
- () => (diagnosis?.genomic_interpretations ?? [])
- .sort((g1, g2) => g1.id > g2.id ? 1 : -1),
- [diagnosis],
- );
-
- return (
-
- {" "}Diagnosis
-
- {diagnosis ?
-
-
-
- : }
-
-
- {" "}Genomic Interpretations
-
- {sortedGenomicInterpretations.length ? : null}
-
);
+ const { diagnosis } = interpretation;
+
+ const sortedGenomicInterpretations = useMemo(
+ () => (diagnosis?.genomic_interpretations ?? []).sort((g1, g2) => (g1.id > g2.id ? 1 : -1)),
+ [diagnosis],
+ );
+
+ return (
+
+
+ Diagnosis
+
+ {diagnosis ? (
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ Genomic Interpretations
+
+ {sortedGenomicInterpretations.length ? (
+
+ ) : null}
+
+ );
};
InterpretationDetail.propTypes = {
- interpretation: PropTypes.object,
+ interpretation: PropTypes.object,
};
-const expandedInterpretationRowRender = (interpretation) => (
-
-);
+const expandedInterpretationRowRender = (interpretation) => ;
const Interpretations = ({ individual, handleInterpretationClick }) => {
- const interpretationsData = useIndividualInterpretations(individual);
- return (
-
- );
+ const interpretationsData = useIndividualInterpretations(individual);
+ return (
+
+ );
};
Interpretations.propTypes = {
- individual: individualPropTypesShape,
- handleInterpretationClick: PropTypes.func,
+ individual: individualPropTypesShape,
+ handleInterpretationClick: PropTypes.func,
};
const IndividualInterpretations = ({ individual }) => (
- (
-
- )}
- />
+ (
+
+ )}
+ />
);
IndividualInterpretations.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualInterpretations;
diff --git a/src/components/explorer/IndividualMeasurements.js b/src/components/explorer/IndividualMeasurements.js
index b567e25e2..efcffa24d 100644
--- a/src/components/explorer/IndividualMeasurements.js
+++ b/src/components/explorer/IndividualMeasurements.js
@@ -12,220 +12,202 @@ import { RoutedIndividualContent, RoutedIndividualContentTable } from "./RoutedI
import OntologyTerm, { conditionalOntologyRender } from "./OntologyTerm";
import { Procedure } from "./IndividualMedicalActions";
-
const FLEX_COLUMN_STYLE = {
- "display": "flex",
- "flexDirection": "column",
- "gap": "1em",
+ display: "flex",
+ flexDirection: "column",
+ gap: "1em",
};
export const Quantity = ({ quantity, title }) => (
-
-
-
-
- {quantity.value}
- {quantity?.reference_range && (
-
-
-
- Unit: {" "}
-
-
- Low: {` ${quantity.reference_range.low}`}
-
-
- High: {` ${quantity.reference_range.high}`}
-
-
-
- )}
-
+
+
+
+
+ {quantity.value}
+ {quantity?.reference_range && (
+
+
+
+ Unit:
+
+
+ Low:
+ {` ${quantity.reference_range.low}`}
+
+
+ High:
+ {` ${quantity.reference_range.high}`}
+
+
+
+ )}
+
);
Quantity.propTypes = {
- quantity: PropTypes.object,
- title: PropTypes.string,
+ quantity: PropTypes.object,
+ title: PropTypes.string,
};
const COMPLEX_VALUE_COLUMNS = [
- {
- title: "Type",
- key: "type",
- render: conditionalOntologyRender("type"),
- sorter: ontologyTermSorter("type"),
-
- },
- {
- title: "Quantity",
- key: "quantity",
- render: (_, typedQuantity) => (
-
- ),
- },
+ {
+ title: "Type",
+ key: "type",
+ render: conditionalOntologyRender("type"),
+ sorter: ontologyTermSorter("type"),
+ },
+ {
+ title: "Quantity",
+ key: "quantity",
+ render: (_, typedQuantity) => ,
+ },
];
-const ComplexValue = ({complexValue}) => {
- const indexedData = useMemo(
- () => complexValue.typed_quantities.map((tq, idx) => {
- tq["idx"] = idx;
- return tq;
- }),
- [complexValue],
- );
-
- return (
-
- );
+const ComplexValue = ({ complexValue }) => {
+ const indexedData = useMemo(
+ () =>
+ complexValue.typed_quantities.map((tq, idx) => {
+ tq["idx"] = idx;
+ return tq;
+ }),
+ [complexValue],
+ );
+
+ return (
+
+ );
};
ComplexValue.propTypes = {
- complexValue: PropTypes.object,
+ complexValue: PropTypes.object,
};
-
-const Value = ({value}) => (
- <>
- {value?.quantity && }
- {value?.ontology_class && }
- >
+const Value = ({ value }) => (
+ <>
+ {value?.quantity && }
+ {value?.ontology_class && }
+ >
);
Value.propTypes = {
- value: PropTypes.object,
+ value: PropTypes.object,
};
-
-const MeasurementValue = ({measurement}) => (
- <>
- {measurement?.value && (
-
- )}
- {measurement?.complex_value && (
-
- )}
- >
+const MeasurementValue = ({ measurement }) => (
+ <>
+ {measurement?.value && }
+ {measurement?.complex_value && }
+ >
);
MeasurementValue.propTypes = {
- measurement: measurementPropTypesShape,
+ measurement: measurementPropTypesShape,
};
-
const MeasurementDetails = ({ measurement }) => (
-
-
-
-
-
- {measurement?.procedure
- ?
- : EM_DASH
- }
-
-
+
+
+
+
+
+ {measurement?.procedure ? : EM_DASH}
+
+
);
MeasurementDetails.propTypes = {
- measurement: measurementPropTypesShape,
+ measurement: measurementPropTypesShape,
};
-
const MEASUREMENTS_COLUMNS = [
- {
- title: "Assay",
- key: "assay",
- render: conditionalOntologyRender("assay"),
- sorter: ontologyTermSorter("assay"),
- sortDirections: ["descend", "ascend", "descend"],
- },
- {
- title: "Measurement Value",
- key: "value",
- render: (_, measurement) => {
- if (measurement.hasOwnProperty("value")) {
- const value = measurement.value;
- if (value.hasOwnProperty("quantity")) {
- return `${value.quantity.value} (${value.quantity.unit.label})`;
- }
- return ;
- } else if (measurement.hasOwnProperty("complex_value")) {
- return "Complex value (expand for details)";
- }
- return EM_DASH;
- },
- },
- {
- title: "Description",
- dataIndex: "description",
- },
- {
- title: "Procedure Code",
- key: "procedure",
- render: (_, measurement) => (
-
- ),
+ {
+ title: "Assay",
+ key: "assay",
+ render: conditionalOntologyRender("assay"),
+ sorter: ontologyTermSorter("assay"),
+ sortDirections: ["descend", "ascend", "descend"],
+ },
+ {
+ title: "Measurement Value",
+ key: "value",
+ render: (_, measurement) => {
+ if (measurement.hasOwnProperty("value")) {
+ const value = measurement.value;
+ if (value.hasOwnProperty("quantity")) {
+ return `${value.quantity.value} (${value.quantity.unit.label})`;
+ }
+ return ;
+ } else if (measurement.hasOwnProperty("complex_value")) {
+ return "Complex value (expand for details)";
+ }
+ return EM_DASH;
},
+ },
+ {
+ title: "Description",
+ dataIndex: "description",
+ },
+ {
+ title: "Procedure Code",
+ key: "procedure",
+ render: (_, measurement) => ,
+ },
];
-const expandedMeasurementRowRender = (measurement) => (
-
-);
+const expandedMeasurementRowRender = (measurement) => ;
export const MeasurementsTable = ({ measurements }) => {
- const indexedMeasurements = measurements.map((m, idx) => ({...m, idx: `${idx}`}));
- const [expandedRowKeys, setExpandedRowKeys] = useState([]);
- const onExpand = (e, measurement) => {
- setExpandedRowKeys([e ? measurement.idx : undefined]);
- };
- return (
-
- );
+ const indexedMeasurements = measurements.map((m, idx) => ({ ...m, idx: `${idx}` }));
+ const [expandedRowKeys, setExpandedRowKeys] = useState([]);
+ const onExpand = (e, measurement) => {
+ setExpandedRowKeys([e ? measurement.idx : undefined]);
+ };
+ return (
+
+ );
};
MeasurementsTable.propTypes = {
- measurements: PropTypes.arrayOf(measurementPropTypesShape),
+ measurements: PropTypes.arrayOf(measurementPropTypesShape),
};
const RoutedMeasurementsTable = ({ measurements, handleMeasurementClick }) => (
-
+
);
RoutedMeasurementsTable.propTypes = {
- measurements: PropTypes.arrayOf(measurementPropTypesShape),
- handleMeasurementClick: PropTypes.func,
+ measurements: PropTypes.arrayOf(measurementPropTypesShape),
+ handleMeasurementClick: PropTypes.func,
};
const IndividualMeasurements = ({ individual }) => {
- const measurements = useIndividualPhenopacketDataIndex(individual, "measurements");
- return (
- (
-
- )}
- />
- );
+ const measurements = useIndividualPhenopacketDataIndex(individual, "measurements");
+ return (
+ (
+
+ )}
+ />
+ );
};
IndividualMeasurements.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualMeasurements;
diff --git a/src/components/explorer/IndividualMedicalActions.js b/src/components/explorer/IndividualMedicalActions.js
index 6e560359c..03896dd3b 100644
--- a/src/components/explorer/IndividualMedicalActions.js
+++ b/src/components/explorer/IndividualMedicalActions.js
@@ -16,280 +16,252 @@ import TimeElement, { TimeInterval } from "./TimeElement";
import { Quantity } from "./IndividualMeasurements";
const ACTION_TYPES = {
- "procedure": "Procedure",
- "treatment": "Treatment",
- "radiation_therapy": "Radiation Therapy",
- "therapeutic_regimen": "Therapeutic Regimen",
+ procedure: "Procedure",
+ treatment: "Treatment",
+ radiation_therapy: "Radiation Therapy",
+ therapeutic_regimen: "Therapeutic Regimen",
};
const getMedicalActionType = (medicalAction) => {
- for (const [actionType, actionName] of Object.entries(ACTION_TYPES)) {
- if (medicalAction.hasOwnProperty(actionType)) {
- return {
- type: actionType,
- name: actionName,
- };
- }
+ for (const [actionType, actionName] of Object.entries(ACTION_TYPES)) {
+ if (medicalAction.hasOwnProperty(actionType)) {
+ return {
+ type: actionType,
+ name: actionName,
+ };
}
- return {
- type: null,
- name: "Unknown",
- };
+ }
+ return {
+ type: null,
+ name: "Unknown",
+ };
};
-export const Procedure = ({procedure}) => (
-
-
-
-
-
-
-
-
-
-
-
+export const Procedure = ({ procedure }) => (
+
+
+
+
+
+
+
+
+
+
+
);
Procedure.propTypes = {
- procedure: PropTypes.object,
+ procedure: PropTypes.object,
};
const DOSE_INTERVAL_COLUMNS = [
- {
- title: "Quantity",
- render: (_, doseInterval) => (
-
- ),
- key: "quantity",
- },
- {
- title: "Schedule Frequency",
- render: (_, doseInterval) => (
-
- ),
- key: "schedule_frequency",
- },
- {
- title: "Interval",
- render: (_, doseInterval) => (),
- key: "interval",
- },
+ {
+ title: "Quantity",
+ render: (_, doseInterval) => ,
+ key: "quantity",
+ },
+ {
+ title: "Schedule Frequency",
+ render: (_, doseInterval) => ,
+ key: "schedule_frequency",
+ },
+ {
+ title: "Interval",
+ render: (_, doseInterval) => ,
+ key: "interval",
+ },
];
-export const Treatment = ({treatment}) => (
-
-
-
-
-
-
-
-
- {treatment?.dose_intervals ? (
-
-
uniqueId()}
- style={STYLE_FIX_NESTED_TABLE_MARGIN}
- />
-
- ) : EM_DASH}
-
- {treatment.drug_type}
-
- {treatment?.cumulative_dose ? (
-
- ) : EM_DASH}
-
-
+export const Treatment = ({ treatment }) => (
+
+
+
+
+
+
+
+
+ {treatment?.dose_intervals ? (
+
+
uniqueId()}
+ style={STYLE_FIX_NESTED_TABLE_MARGIN}
+ />
+
+ ) : (
+ EM_DASH
+ )}
+
+ {treatment.drug_type}
+
+ {treatment?.cumulative_dose ? : EM_DASH}
+
+
);
Treatment.propTypes = {
- treatment: PropTypes.object,
+ treatment: PropTypes.object,
};
export const RadiationTherapy = ({ radiationTherapy }) => (
-
-
-
-
-
-
-
- {radiationTherapy.dosage}
- {radiationTherapy.fractions}
-
+
+
+
+
+
+
+
+ {radiationTherapy.dosage}
+ {radiationTherapy.fractions}
+
);
RadiationTherapy.propTypes = {
- radiationTherapy: PropTypes.object,
+ radiationTherapy: PropTypes.object,
};
-export const TherapeuticRegimen = ({therapeuticRegimen}) => {
- return (
-
-
- {therapeuticRegimen?.ontology_class &&
-
- }
- {therapeuticRegimen?.external_reference &&
-
-
- ID: {" "}
- {therapeuticRegimen?.external_reference?.id ?? EM_DASH}
-
-
- Reference: {" "}
- {therapeuticRegimen?.external_reference?.reference ?? EM_DASH}
-
-
- Description: {" "}
- {therapeuticRegimen?.external_reference?.description ?? EM_DASH}
-
-
- }
-
-
- {therapeuticRegimen?.start_time &&
-
- }
-
-
- {therapeuticRegimen?.start_time &&
-
- }
-
-
- {therapeuticRegimen?.status ? therapeuticRegimen.status : EM_DASH}
-
-
- );
+export const TherapeuticRegimen = ({ therapeuticRegimen }) => {
+ return (
+
+
+ {therapeuticRegimen?.ontology_class && }
+ {therapeuticRegimen?.external_reference && (
+
+
+ ID: {therapeuticRegimen?.external_reference?.id ?? EM_DASH}
+
+
+ Reference: {therapeuticRegimen?.external_reference?.reference ?? EM_DASH}
+
+
+ Description: {therapeuticRegimen?.external_reference?.description ?? EM_DASH}
+
+
+ )}
+
+
+ {therapeuticRegimen?.start_time && }
+
+
+ {therapeuticRegimen?.start_time && }
+
+
+ {therapeuticRegimen?.status ? therapeuticRegimen.status : EM_DASH}
+
+
+ );
};
TherapeuticRegimen.propTypes = {
- therapeuticRegimen: PropTypes.object,
+ therapeuticRegimen: PropTypes.object,
};
const MedicalActionDetails = ({ medicalAction }) => {
- const actionType = getMedicalActionType(medicalAction);
+ const actionType = getMedicalActionType(medicalAction);
- // The action is the only field always present, other fields are optional.
- return (
-
-
- {medicalAction?.procedure &&
-
- }
- {medicalAction?.treatment &&
-
- }
- {medicalAction?.radiation_therapy &&
-
- }
- {medicalAction?.therapeutic_regimen &&
-
- }
-
-
- { Array.isArray(medicalAction?.adverse_events) ?
- medicalAction.adverse_events.map((advEvent, index) =>
- ,
- )
- : EM_DASH
- }
-
-
- );
+ // The action is the only field always present, other fields are optional.
+ return (
+
+
+ {medicalAction?.procedure && }
+ {medicalAction?.treatment && }
+ {medicalAction?.radiation_therapy && }
+ {medicalAction?.therapeutic_regimen && (
+
+ )}
+
+
+ {Array.isArray(medicalAction?.adverse_events)
+ ? medicalAction.adverse_events.map((advEvent, index) => (
+
+ ))
+ : EM_DASH}
+
+
+ );
};
MedicalActionDetails.propTypes = {
- medicalAction: medicalActionPropTypesShape,
+ medicalAction: medicalActionPropTypesShape,
};
const adverseEventsCount = (medicalAction) => (medicalAction?.adverse_events ?? []).length;
const MEDICAL_ACTIONS_COLUMNS = [
- {
- title: "Action Type",
- key: "action",
- render: (_, medicalAction) => getMedicalActionType(medicalAction).name,
- sorter: (a, b) => {
- const aType = getMedicalActionType(a).type;
- const bType = getMedicalActionType(b).type;
- return aType.localeCompare(bType);
- },
- },
- {
- title: "Treatment Target",
- key: "target",
- render: conditionalOntologyRender("treatment_target"),
- sorter: ontologyTermSorter("treatment_target"),
- },
- {
- title: "Treatment Intent",
- key: "intent",
- render: conditionalOntologyRender("treatment_intent"),
- sorter: ontologyTermSorter("treatment_intent"),
- },
- {
- title: "Response To Treatment",
- key: "response",
- render: conditionalOntologyRender("response_to_treatment"),
- sorter: ontologyTermSorter("response_to_treatment"),
- },
- {
- // Only render count, expand for details
- title: "Adverse Events",
- key: "adverse_events",
- render: (_, medicalAction) => adverseEventsCount(medicalAction),
- sorter: (a, b) => adverseEventsCount(a) - adverseEventsCount(b),
- },
- {
- title: "Treatment Termination Reason",
- key: "termination_reason",
- render: conditionalOntologyRender("treatment_termination_reason"),
- sorter: ontologyTermSorter("treatment_termination_reason"),
+ {
+ title: "Action Type",
+ key: "action",
+ render: (_, medicalAction) => getMedicalActionType(medicalAction).name,
+ sorter: (a, b) => {
+ const aType = getMedicalActionType(a).type;
+ const bType = getMedicalActionType(b).type;
+ return aType.localeCompare(bType);
},
+ },
+ {
+ title: "Treatment Target",
+ key: "target",
+ render: conditionalOntologyRender("treatment_target"),
+ sorter: ontologyTermSorter("treatment_target"),
+ },
+ {
+ title: "Treatment Intent",
+ key: "intent",
+ render: conditionalOntologyRender("treatment_intent"),
+ sorter: ontologyTermSorter("treatment_intent"),
+ },
+ {
+ title: "Response To Treatment",
+ key: "response",
+ render: conditionalOntologyRender("response_to_treatment"),
+ sorter: ontologyTermSorter("response_to_treatment"),
+ },
+ {
+ // Only render count, expand for details
+ title: "Adverse Events",
+ key: "adverse_events",
+ render: (_, medicalAction) => adverseEventsCount(medicalAction),
+ sorter: (a, b) => adverseEventsCount(a) - adverseEventsCount(b),
+ },
+ {
+ title: "Treatment Termination Reason",
+ key: "termination_reason",
+ render: conditionalOntologyRender("treatment_termination_reason"),
+ sorter: ontologyTermSorter("treatment_termination_reason"),
+ },
];
-const expandedMedicalActionRowRender = (medicalAction) => (
-
-);
+const expandedMedicalActionRowRender = (medicalAction) => ;
const MedicalActions = ({ medicalActions, handleMedicalActionClick }) => (
-
+
);
MedicalActions.propTypes = {
- medicalActions: PropTypes.array,
- handleMedicalActionClick: PropTypes.func,
+ medicalActions: PropTypes.array,
+ handleMedicalActionClick: PropTypes.func,
};
-
const IndividualMedicalActions = ({ individual }) => {
- const medicalActions = useIndividualPhenopacketDataIndex(individual, "medical_actions");
- return (
- (
-
- )}
- />
- );
+ const medicalActions = useIndividualPhenopacketDataIndex(individual, "medical_actions");
+ return (
+ (
+
+ )}
+ />
+ );
};
IndividualMedicalActions.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualMedicalActions;
diff --git a/src/components/explorer/IndividualOntologies.js b/src/components/explorer/IndividualOntologies.js
index eaf2860d4..83e56ee60 100644
--- a/src/components/explorer/IndividualOntologies.js
+++ b/src/components/explorer/IndividualOntologies.js
@@ -6,67 +6,70 @@ import { individualPropTypesShape } from "@/propTypes";
import { useIndividualResources } from "./utils";
import MonospaceText from "@/components/common/MonospaceText";
-
// TODO: Only show diseases from the relevant dataset, if specified;
// highlight those found in search results, if specified
const METADATA_COLUMNS = [
- {
- title: "Resource ID",
- dataIndex: "id",
- sorter: (a, b) => a.id.toString().localeCompare(b.id),
- defaultSortOrder: "ascend",
- },
- {
- title: "Name",
- dataIndex: "name",
- sorter: (a, b) => a.name.toString().localeCompare(b.name),
- defaultSortOrder: "ascend",
- },
- {
- title: "Namespace Prefix",
- dataIndex: "namespace_prefix",
- sorter: (a, b) => a.namespace_prefix.toString().localeCompare(b.namespace_prefix),
- defaultSortOrder: "ascend",
- },
- {
- title: "Url",
- dataIndex: "url",
- render: (url) => {url} ,
- defaultSortOrder: "ascend",
- },
- {
- title: "Version",
- dataIndex: "version",
- sorter: (a, b) => a.version.toString().localeCompare(b.version),
- defaultSortOrder: "ascend",
- },
- {
- title: "IRI Prefix",
- dataIndex: "iri_prefix",
- render: (iriPrefix) => {iriPrefix} ,
- defaultSortOrder: "ascend",
- },
+ {
+ title: "Resource ID",
+ dataIndex: "id",
+ sorter: (a, b) => a.id.toString().localeCompare(b.id),
+ defaultSortOrder: "ascend",
+ },
+ {
+ title: "Name",
+ dataIndex: "name",
+ sorter: (a, b) => a.name.toString().localeCompare(b.name),
+ defaultSortOrder: "ascend",
+ },
+ {
+ title: "Namespace Prefix",
+ dataIndex: "namespace_prefix",
+ sorter: (a, b) => a.namespace_prefix.toString().localeCompare(b.namespace_prefix),
+ defaultSortOrder: "ascend",
+ },
+ {
+ title: "Url",
+ dataIndex: "url",
+ render: (url) => (
+
+ {url}
+
+ ),
+ defaultSortOrder: "ascend",
+ },
+ {
+ title: "Version",
+ dataIndex: "version",
+ sorter: (a, b) => a.version.toString().localeCompare(b.version),
+ defaultSortOrder: "ascend",
+ },
+ {
+ title: "IRI Prefix",
+ dataIndex: "iri_prefix",
+ render: (iriPrefix) => {iriPrefix} ,
+ defaultSortOrder: "ascend",
+ },
];
-const IndividualOntologies = ({individual}) => {
- const [resources, isFetching] = useIndividualResources(individual);
+const IndividualOntologies = ({ individual }) => {
+ const [resources, isFetching] = useIndividualResources(individual);
- return (
-
- );
+ return (
+
+ );
};
IndividualOntologies.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualOntologies;
diff --git a/src/components/explorer/IndividualOverview.js b/src/components/explorer/IndividualOverview.js
index 938d438ee..a36ac8a5c 100644
--- a/src/components/explorer/IndividualOverview.js
+++ b/src/components/explorer/IndividualOverview.js
@@ -8,33 +8,28 @@ import OntologyTerm from "./OntologyTerm";
import TimeElement from "./TimeElement";
import ExtraProperties from "./ExtraProperties";
-const IndividualOverview = ({individual}) => {
- if (!individual) return
;
- return (
-
- {individual.date_of_birth || EM_DASH}
- {individual.sex || "UNKNOWN_SEX"}
-
-
-
- {
- individual.karyotypic_sex || "UNKNOWN_KARYOTYPE"}
-
-
- ({label} )}
- />
-
-
-
-
-
- );
+const IndividualOverview = ({ individual }) => {
+ if (!individual) return
;
+ return (
+
+ {individual.date_of_birth || EM_DASH}
+ {individual.sex || "UNKNOWN_SEX"}
+
+
+
+ {individual.karyotypic_sex || "UNKNOWN_KARYOTYPE"}
+
+ {label} } />
+
+
+
+
+
+ );
};
IndividualOverview.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualOverview;
diff --git a/src/components/explorer/IndividualPhenopackets.js b/src/components/explorer/IndividualPhenopackets.js
index 3f60b03bc..7568288ef 100644
--- a/src/components/explorer/IndividualPhenopackets.js
+++ b/src/components/explorer/IndividualPhenopackets.js
@@ -11,36 +11,38 @@ import JsonView from "@/components/common/JsonView";
import { useService } from "@/modules/services/hooks";
const IndividualPhenopackets = ({ individual }) => {
- const dispatch = useDispatch();
-
- const { id: individualId } = individual;
-
- const katsuUrl = useService("metadata")?.url ?? "";
- const downloadUrl = `${katsuUrl}/api/individuals/${individualId}/phenopackets?attachment=1&format=json`;
-
- const phenopacketsByIndividualID = useSelector((state) => state.individuals.phenopacketsByIndividualID);
-
- const { isFetching, data } = phenopacketsByIndividualID[individualId] ?? {};
-
- useEffect(() => {
- dispatch(fetchIndividualPhenopacketsIfNecessary(individualId));
- }, [individualId]);
-
- return (
- <>
- Download JSON
-
- {(data === undefined || isFetching) ? (
-
- ) : (
-
- )}
- >
- );
+ const dispatch = useDispatch();
+
+ const { id: individualId } = individual;
+
+ const katsuUrl = useService("metadata")?.url ?? "";
+ const downloadUrl = `${katsuUrl}/api/individuals/${individualId}/phenopackets?attachment=1&format=json`;
+
+ const phenopacketsByIndividualID = useSelector((state) => state.individuals.phenopacketsByIndividualID);
+
+ const { isFetching, data } = phenopacketsByIndividualID[individualId] ?? {};
+
+ useEffect(() => {
+ dispatch(fetchIndividualPhenopacketsIfNecessary(individualId));
+ }, [dispatch, individualId]);
+
+ return (
+ <>
+
+ Download JSON
+
+
+ {data === undefined || isFetching ? (
+
+ ) : (
+
+ )}
+ >
+ );
};
IndividualPhenopackets.propTypes = {
- individual: individualPropTypesShape.isRequired,
+ individual: individualPropTypesShape.isRequired,
};
export default IndividualPhenopackets;
diff --git a/src/components/explorer/IndividualPhenotypicFeatures.js b/src/components/explorer/IndividualPhenotypicFeatures.js
index 8b91b7e35..95c656bf0 100644
--- a/src/components/explorer/IndividualPhenotypicFeatures.js
+++ b/src/components/explorer/IndividualPhenotypicFeatures.js
@@ -4,11 +4,7 @@ import { LinkOutlined } from "@ant-design/icons";
import { Descriptions } from "antd";
import { EM_DASH } from "@/constants";
-import {
- evidencePropTypesShape,
- individualPropTypesShape,
- phenotypicFeaturePropTypesShape,
-} from "@/propTypes";
+import { evidencePropTypesShape, individualPropTypesShape, phenotypicFeaturePropTypesShape } from "@/propTypes";
import { isValidUrl } from "@/utils/url";
import OntologyTerm, { conditionalOntologyRender } from "./OntologyTerm";
@@ -18,182 +14,173 @@ import { RoutedIndividualContent, RoutedIndividualContentTable } from "./RoutedI
import ExtraProperties from "./ExtraProperties";
const PHENOTYPIC_FEATURES_COLUMNS = [
- {
- title: "Feature",
- key: "feature",
- render: ({ header, type, excluded }) => (
- header ? (
-
- Phenopacket:{" "}
-
- {header}
-
-
- ) : <>
- {" "}
- {excluded ? (
-
- (Excluded: {" "}
- Found to be absent{" "}
-
-
- )
-
- ) : null}
- >
- ),
- onCell: ({ header }) => ({
- colSpan: header ? 2 : 1,
- }),
- },
- {
- title: "Excluded",
- key: "excluded",
- render: renderBoolean("excluded"),
- sorter: booleanFieldSorter("excluded"),
- },
- {
- title: "Severity",
- key: "severity",
- render: conditionalOntologyRender("severity"),
- },
- {
- title: "Onset",
- dataIndex: "onset",
- render: (onset) => ,
- },
- {
- title: "Resolution",
- dataIndex: "resolution",
- render: (resolution) => ,
- },
+ {
+ title: "Feature",
+ key: "feature",
+ render: ({ header, type, excluded }) =>
+ header ? (
+
+ Phenopacket:{" "}
+ {header}
+
+ ) : (
+ <>
+ {" "}
+ {excluded ? (
+
+ (Excluded: Found to be absent{" "}
+
+
+
+ )
+
+ ) : null}
+ >
+ ),
+ onCell: ({ header }) => ({
+ colSpan: header ? 2 : 1,
+ }),
+ },
+ {
+ title: "Excluded",
+ key: "excluded",
+ render: renderBoolean("excluded"),
+ sorter: booleanFieldSorter("excluded"),
+ },
+ {
+ title: "Severity",
+ key: "severity",
+ render: conditionalOntologyRender("severity"),
+ },
+ {
+ title: "Onset",
+ dataIndex: "onset",
+ render: (onset) => ,
+ },
+ {
+ title: "Resolution",
+ dataIndex: "resolution",
+ render: (resolution) => ,
+ },
];
-
const Evidence = ({ evidence }) => {
- if (!evidence) {
- return EM_DASH;
- }
+ if (!evidence) {
+ return EM_DASH;
+ }
- const externalReference = evidence.reference;
- const hasReferenceUrl = isValidUrl(externalReference?.reference);
- return (
-
-
-
-
- {externalReference &&
-
- {externalReference?.id &&
- <>
- ID: {" "}{externalReference.id}{" "}
- {hasReferenceUrl &&
-
-
-
- }
-
- >
- }
- {externalReference?.description &&
- <>
- description: {" "}{externalReference?.description}
-
- >
- }
-
- }
-
- );
+ const externalReference = evidence.reference;
+ const hasReferenceUrl = isValidUrl(externalReference?.reference);
+ return (
+
+
+
+
+ {externalReference && (
+
+ {externalReference?.id && (
+ <>
+ ID: {externalReference.id}{" "}
+ {hasReferenceUrl && (
+
+
+
+ )}
+
+ >
+ )}
+ {externalReference?.description && (
+ <>
+ description: {externalReference?.description}
+
+ >
+ )}
+
+ )}
+
+ );
};
Evidence.propTypes = {
- evidence: evidencePropTypesShape,
+ evidence: evidencePropTypesShape,
};
const PhenotypicFeatureDetail = ({ pf }) => {
- const description = pf?.description;
- const modifiers = pf?.modifiers ?? [];
- const evidence = pf?.evidence ?? [];
- return (
-
-
- {description ? description : EM_DASH}
-
-
- {modifiers.length
- ? modifiers.map((modifier, idx) => )
- : EM_DASH
- }
-
-
- {evidence.length
- // ? evidence.map(evidence => )
- ? evidence.map((evidence, idx) => )
- : EM_DASH
- }
-
-
-
-
-
-
- );
+ const description = pf?.description;
+ const modifiers = pf?.modifiers ?? [];
+ const evidence = pf?.evidence ?? [];
+ return (
+
+ {description ? description : EM_DASH}
+
+ {modifiers.length ? modifiers.map((modifier, idx) => ) : EM_DASH}
+
+
+ {evidence.length
+ ? // ? evidence.map(evidence => )
+ evidence.map((evidence, idx) => )
+ : EM_DASH}
+
+
+
+
+
+ );
};
PhenotypicFeatureDetail.propTypes = {
- pf: phenotypicFeaturePropTypesShape,
- handleFeatureClick: PropTypes.func,
+ pf: phenotypicFeaturePropTypesShape,
+ handleFeatureClick: PropTypes.func,
};
const PhenotypicFeatures = ({ phenotypicFeatures, handleSelect }) => (
- (
-
- )}
- />
+ }
+ />
);
PhenotypicFeatures.propTypes = {
- phenotypicFeatures: PropTypes.arrayOf(phenotypicFeaturePropTypesShape),
- handleSelect: PropTypes.func,
+ phenotypicFeatures: PropTypes.arrayOf(phenotypicFeaturePropTypesShape),
+ handleSelect: PropTypes.func,
};
const IndividualPhenotypicFeatures = ({ individual }) => {
+ const data = useMemo(() => {
+ const phenopackets = individual?.phenopackets ?? [];
+ return phenopackets.flatMap((p) => [
+ ...(phenopackets.length > 1
+ ? [
+ {
+ header: p.id,
+ key: p.id,
+ },
+ ]
+ : []), // If there is just 1 phenopacket, don't include a header row
+ ...(p.phenotypic_features ?? []).map((pf) => ({
+ ...pf,
+ key: `${p.id}:${pf.type.id}:${pf.excluded}`,
+ })),
+ ]);
+ }, [individual]);
- const data = useMemo(() => {
- const phenopackets = (individual?.phenopackets ?? []);
- return phenopackets.flatMap((p) => [
- ...(phenopackets.length > 1 ? [{
- header: p.id,
- key: p.id,
- }] : []), // If there is just 1 phenopacket, don't include a header row
- ...(p.phenotypic_features ?? []).map((pf) => ({
- ...pf,
- key: `${p.id}:${pf.type.id}:${pf.excluded}`,
- })),
- ]);
- }, [individual]);
-
- return (
- (
-
- )}
- />
- );
+ return (
+ (
+
+ )}
+ />
+ );
};
IndividualPhenotypicFeatures.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualPhenotypicFeatures;
diff --git a/src/components/explorer/IndividualTracks.js b/src/components/explorer/IndividualTracks.js
index 48e9b7a83..d278cc579 100644
--- a/src/components/explorer/IndividualTracks.js
+++ b/src/components/explorer/IndividualTracks.js
@@ -14,6 +14,7 @@ import { getIgvUrlsFromDrs } from "@/modules/drs/actions";
import { setIgvPosition } from "@/modules/explorer/actions";
import { useIgvGenomes } from "@/modules/explorer/hooks";
import { useReferenceGenomes } from "@/modules/reference/hooks";
+import { useService } from "@/modules/services/hooks";
import { guessFileType } from "@/utils/files";
import { simpleDeepCopy } from "@/utils/misc";
import { useDeduplicatedIndividualBiosamples } from "./utils";
@@ -48,16 +49,16 @@ const DEBOUNCE_WAIT = 500;
const hasFreshUrls = (files, urls) => files.every((f) => urls.hasOwnProperty(f.filename));
const ALIGNMENT_FORMATS_LOWER = ["bam", "cram"];
-const ANNOTATION_FORMATS_LOWER = ["bigbed"]; // TODO: experiment result: support more
+const ANNOTATION_FORMATS_LOWER = ["bigbed"]; // TODO: experiment result: support more
const MUTATION_FORMATS_LOWER = ["maf"];
-const WIG_FORMATS_LOWER = ["bigwig"]; // TODO: experiment result: support wig/bedGraph?
+const WIG_FORMATS_LOWER = ["bigwig"]; // TODO: experiment result: support wig/bedGraph?
const VARIANT_FORMATS_LOWER = ["vcf", "gvcf"];
const VIEWABLE_FORMATS_LOWER = [
- ...ALIGNMENT_FORMATS_LOWER,
- ...ANNOTATION_FORMATS_LOWER,
- ...MUTATION_FORMATS_LOWER,
- ...WIG_FORMATS_LOWER,
- ...VARIANT_FORMATS_LOWER,
+ ...ALIGNMENT_FORMATS_LOWER,
+ ...ANNOTATION_FORMATS_LOWER,
+ ...MUTATION_FORMATS_LOWER,
+ ...WIG_FORMATS_LOWER,
+ ...VARIANT_FORMATS_LOWER,
];
const expResFileFormatLower = (expRes) => expRes.file_format?.toLowerCase() ?? guessFileType(expRes.filename);
@@ -66,56 +67,56 @@ const expResFileFormatLower = (expRes) => expRes.file_format?.toLowerCase() ?? g
// - an assembly ID, so we can contextualize it correctly
// - a file format in the list of file formats we know how to handle
const isViewableInIgv = (expRes) =>
- !!expRes.genome_assembly_id && VIEWABLE_FORMATS_LOWER.includes(expResFileFormatLower(expRes));
+ !!expRes.genome_assembly_id && VIEWABLE_FORMATS_LOWER.includes(expResFileFormatLower(expRes));
const expResFileFormatToIgvTypeAndFormat = (fileFormat) => {
- const ff = fileFormat.toLowerCase();
+ const ff = fileFormat.toLowerCase();
- if (ALIGNMENT_FORMATS_LOWER.includes(ff)) return ["alignment", ff];
- if (ANNOTATION_FORMATS_LOWER.includes(ff)) return ["annotation", "bigBed"]; // TODO: expand if we support more
- if (MUTATION_FORMATS_LOWER.includes(ff)) return ["mut", ff];
- if (WIG_FORMATS_LOWER.includes(ff)) return ["wig", "bigWig"]; // TODO: expand if we support wig/bedGraph
- if (VARIANT_FORMATS_LOWER.includes(ff)) return ["variant", "vcf"];
+ if (ALIGNMENT_FORMATS_LOWER.includes(ff)) return ["alignment", ff];
+ if (ANNOTATION_FORMATS_LOWER.includes(ff)) return ["annotation", "bigBed"]; // TODO: expand if we support more
+ if (MUTATION_FORMATS_LOWER.includes(ff)) return ["mut", ff];
+ if (WIG_FORMATS_LOWER.includes(ff)) return ["wig", "bigWig"]; // TODO: expand if we support wig/bedGraph
+ if (VARIANT_FORMATS_LOWER.includes(ff)) return ["variant", "vcf"];
- return [undefined, undefined];
+ return [undefined, undefined];
};
const TrackControlTable = React.memo(({ toggleView, allFoundFiles }) => {
- const trackTableColumns = [
- {
- title: "File",
- dataIndex: "filename",
- },
- {
- title: "Format",
- dataIndex: "file_format",
- },
- {
- title: "Assembly ID",
- dataIndex: "genome_assembly_id",
- },
- {
- title: "View track",
- key: "view",
- align: "center",
- render: (_, track) => toggleView(track)} />,
- },
- ]; // Don't bother memoizing since toggleView and allFoundFiles both change with allTracks anyway
-
- return (
-
- );
+ const trackTableColumns = [
+ {
+ title: "File",
+ dataIndex: "filename",
+ },
+ {
+ title: "Format",
+ dataIndex: "file_format",
+ },
+ {
+ title: "Assembly ID",
+ dataIndex: "genome_assembly_id",
+ },
+ {
+ title: "View track",
+ key: "view",
+ align: "center",
+ render: (_, track) => toggleView(track)} />,
+ },
+ ]; // Don't bother memoizing since toggleView and allFoundFiles both change with allTracks anyway
+
+ return (
+
+ );
});
TrackControlTable.propTypes = {
- toggleView: PropTypes.func,
- allFoundFiles: PropTypes.arrayOf(PropTypes.object),
+ toggleView: PropTypes.func,
+ allFoundFiles: PropTypes.arrayOf(PropTypes.object),
};
// Right now, a lot of this code uses filenames. This should not be the case going forward,
@@ -123,287 +124,323 @@ TrackControlTable.propTypes = {
// For now, we treat the filenames as unique identifiers (unfortunately).
const buildIgvTrack = (igvUrls, track) => {
- const [type, format] = expResFileFormatToIgvTypeAndFormat(track.fileFormatLower);
- return {
- type,
- format,
- url: igvUrls[track.filename].url,
- indexURL: igvUrls[track.filename].indexUrl, // May be undefined if this track is not indexed
- name: track.filename,
- squishedCallHeight: SQUISHED_CALL_HEIGHT,
- expandedCallHeight: EXPANDED_CALL_HEIGHT,
- displayMode: DISPLAY_MODE,
- visibilityWindow: VISIBILITY_WINDOW,
- };
+ const [type, format] = expResFileFormatToIgvTypeAndFormat(track.fileFormatLower);
+ return {
+ type,
+ format,
+ url: igvUrls[track.filename].url,
+ indexURL: igvUrls[track.filename].indexUrl, // May be undefined if this track is not indexed
+ name: track.filename,
+ squishedCallHeight: SQUISHED_CALL_HEIGHT,
+ expandedCallHeight: EXPANDED_CALL_HEIGHT,
+ displayMode: DISPLAY_MODE,
+ visibilityWindow: VISIBILITY_WINDOW,
+ };
};
const IGV_JS_ANNOTATION_ALIASES = {
- "hg19": "GRCh37",
- "hg38": "GRCh38",
- "mm9": "NCBI37",
- "mm10": "GRCm38",
+ GRCh37: "hg19",
+ GRCh38: "hg38",
+ NCBI37: "mm9",
+ GRCm38: "mm10",
};
const IndividualTracks = ({ individual }) => {
- const { accessToken } = useSelector((state) => state.auth);
-
- const igvDivRef = useRef();
- const igvBrowserRef = useRef(null);
- const [creatingIgvBrowser, setCreatingIgvBrowser] = useState(false);
-
- const { igvUrlsByFilename: igvUrls, isFetchingIgvUrls } = useSelector((state) => state.drs);
-
- // read stored position only on first render
- const igvPosition = useSelector(
- (state) => state.explorer.igvPosition,
- () => true, // We don't want to re-render anything when the position changes
- );
-
- const dispatch = useDispatch();
-
- const igvGenomes = useIgvGenomes(); // Built-in igv.js genomes (with annotations)
- const referenceGenomes = useReferenceGenomes(); // Reference service genomes
-
- const availableBrowserGenomes = useMemo(() => {
- if (!igvGenomes.hasAttempted || !referenceGenomes.hasAttempted) {
- return {};
- }
-
- const availableGenomes = {};
-
- // For now, we prefer igv.js built-in genomes with the same ID over local copies for the browser, since it comes
- // with gene annotation tracks. TODO: in the future, this should switch to preferring local copies.
- referenceGenomes.items.forEach((g) => {
- availableGenomes[g.id] = { id: g.id, fastaURL: g.fasta, indexURL: g.fai };
- });
- (igvGenomes.data ?? []).forEach((g) => {
- availableGenomes[g.id] = g;
- // Manual aliasing for well-known genome aliases, so that we get extra annotations from the
- // igv.js genomes.json:
- if (g.id in IGV_JS_ANNOTATION_ALIASES) {
- const additionalID = IGV_JS_ANNOTATION_ALIASES[g.id];
- availableGenomes[additionalID] = {...g, id: additionalID};
- }
- });
-
- console.debug("total available genomes:", availableGenomes);
-
- return availableGenomes;
- }, [igvGenomes, referenceGenomes]);
-
- const biosamplesData = useDeduplicatedIndividualBiosamples(individual);
- const viewableResults = useMemo(
- () => {
- const experiments = biosamplesData.flatMap((b) => b?.experiments ?? []);
- const vr = Object.values(
- Object.fromEntries( // Deduplicate experiment results by file name by building an object
- experiments.flatMap((e) => e?.experiment_results ?? [])
- .filter(isViewableInIgv)
- .map((expRes) => {
- /** @type string|undefined */
- const fileFormatLower = expResFileFormatLower(expRes);
- return [
- expRes.filename,
- {
- ...expRes,
- // by default, don't view alignments (user can turn them on in track controls):
- fileFormatLower,
- viewInIgv: !ALIGNMENT_FORMATS_LOWER.includes(fileFormatLower),
- },
- ];
- }),
- ),
- ).sort((r1, r2) => (r1.fileFormatLower ?? "").localeCompare(r2.fileFormatLower ?? ""));
- console.debug("Viewable experiment results:", vr);
- return vr;
- },
- [biosamplesData],
- );
-
- // augmented experiment results with viewInIgv state + cached lowercase / normalized file format:
- const [allTracks, setAllTracks] = useState(simpleDeepCopy(viewableResults));
-
- useEffect(() => {
- // If the set of viewable results changes, reset the track state
- setAllTracks(simpleDeepCopy(viewableResults));
- }, [viewableResults]);
-
- const allFoundFiles = useMemo(
- () => allTracks.filter((t) => !!igvUrls[t.filename]?.url),
- [allTracks, igvUrls],
- );
-
- const [selectedAssemblyID, setSelectedAssemblyID] = useState(undefined);
-
- const trackAssemblyIDs = useMemo(
- () => Array.from(new Set(allFoundFiles.map((t) => t.genome_assembly_id))).sort(),
- [allFoundFiles]);
-
- useEffect(() => {
- if (Object.keys(availableBrowserGenomes).length) {
- if (trackAssemblyIDs.length && trackAssemblyIDs[0]) {
- const asmID = trackAssemblyIDs[0]; // TODO: first available
- console.debug("auto-selected assembly ID:", asmID);
- setSelectedAssemblyID(asmID);
- }
- }
- }, [availableBrowserGenomes, trackAssemblyIDs]);
-
- const [modalVisible, setModalVisible] = useState(false);
-
- const showModal = useCallback(() => setModalVisible(true), []);
- const closeModal = useCallback(() => setModalVisible(false), []);
-
- const toggleView = useCallback((track) => {
- if (!igvBrowserRef.current) return;
-
- const wasViewing = track.viewInIgv;
- setAllTracks(allTracks.map((t) => t.filename === track.filename ? ({ ...track, viewInIgv: !wasViewing }) : t));
-
- if (wasViewing) {
- igvBrowserRef.current.removeTrackByName(track.filename);
- } else {
- igvBrowserRef.current.loadTrack(buildIgvTrack(igvUrls, track)).catch(console.error);
- }
- }, [allTracks]);
-
- const storeIgvPosition = useCallback((referenceFrame) => {
- const { chr, start, end } = referenceFrame[0];
- const position = `${chr}:${start}-${end}`;
- dispatch(setIgvPosition(position));
- }, [dispatch]);
-
- // retrieve urls on mount
- useEffect(() => {
- if (allTracks.length) {
- // don't search if all urls already known
- if (hasFreshUrls(allTracks, igvUrls)) {
- return;
- }
- dispatch(getIgvUrlsFromDrs(allTracks)).catch(console.error);
- }
- }, [allTracks]);
-
- // update access token whenever necessary
- useEffect(() => {
- if (BENTO_URL) {
- igv.setOauthToken(accessToken, new URL(BENTO_URL).host);
- }
- if (BENTO_PUBLIC_URL) {
- igv.setOauthToken(accessToken, new URL(BENTO_PUBLIC_URL).host);
- }
- }, [accessToken]);
-
- // render igv when track urls + reference genomes are ready
- useEffect(() => {
- const cleanup = () => {
- if (igvBrowserRef.current) {
- console.debug("removing igv.js browser instance");
- igv.removeBrowser(igvBrowserRef.current);
- igvBrowserRef.current = null;
- }
- };
-
- if (isFetchingIgvUrls) {
- return cleanup;
- }
-
- if (!allFoundFiles.length || !hasFreshUrls(allTracks, igvUrls)) {
- console.debug("urls not ready");
- console.debug({ igvUrls });
- console.debug({ tracksValid: hasFreshUrls(allTracks, igvUrls) });
- return cleanup;
- }
-
- if (!Object.keys(availableBrowserGenomes).length || !selectedAssemblyID) {
- console.debug("no available browser genomes / selected assembly ID yet");
- return cleanup;
- }
-
- console.debug("igv.createBrowser effect dependencies:",
- [igvUrls, viewableResults, availableBrowserGenomes, selectedAssemblyID]);
-
- if (creatingIgvBrowser || igvBrowserRef.current) {
- console.debug(
- "browser is already being created or exists: creatingIgvBrowser =", creatingIgvBrowser,
- "igvBrowserRef.current =", igvBrowserRef.current);
- return cleanup;
- }
-
- setCreatingIgvBrowser(true);
-
- const initialIgvTracks = allFoundFiles
- .filter((t) => t.viewInIgv && t.genome_assembly_id === selectedAssemblyID && igvUrls[t.filename].url)
- .map((t) => buildIgvTrack(igvUrls, t));
-
- const igvOptions = {
- genome: availableBrowserGenomes[selectedAssemblyID],
- locus: igvPosition,
- tracks: initialIgvTracks,
- };
-
- console.debug("creating igv.js browser with options:", igvOptions, "; tracks:", initialIgvTracks);
-
- igv.createBrowser(igvDivRef.current, igvOptions).then((browser) => {
- browser.on(
- "locuschange",
- debounce((referenceFrame) => {
- storeIgvPosition(referenceFrame);
- }, DEBOUNCE_WAIT),
- );
- igvBrowserRef.current = browser;
- setCreatingIgvBrowser(false);
- console.debug("created igv.js browser instance:", browser);
- }).catch((err) => {
- message.error(`Error creating igv.js browser: ${err}`);
- console.error(err);
- });
-
- return cleanup;
-
- // Use viewableResults as the track dependency, not allFoundFiles, since allFoundFiles is regenerated if a
- // track's visibility changes – allFoundFiles is left out of these dependencies on purpose, otherwise the entire
- // browser will be re-rendered if a track's visibility changes. By using viewableResults as a dependency
- // instead, the browser is only re-rendered if the overall track set (i.e., individual) changes.
- }, [igvUrls, viewableResults, availableBrowserGenomes, selectedAssemblyID]);
-
- return (
- <>
- }
- style={{ marginRight: "8px" }}
- onClick={showModal}
- disabled={!allFoundFiles.length}
- loading={isFetchingIgvUrls}
- >
- Configure Tracks
-
-
- {!allFoundFiles.length && (
- (isFetchingIgvUrls || referenceGenomes.isFetching) ? (
-
- ) : (
-
- )
- )}
-
-
-
- Assembly:{" "}
- setSelectedAssemblyID(v)}
- options={trackAssemblyIDs.map((a) => ({ value: a, label: a }))}
- />
-
-
-
- >
- );
+ const { accessToken } = useSelector((state) => state.auth);
+
+ const igvDivRef = useRef();
+ const igvBrowserRef = useRef(null);
+ const [creatingIgvBrowser, setCreatingIgvBrowser] = useState(false);
+
+ const { igvUrlsByFilename: igvUrls, isFetchingIgvUrls } = useSelector((state) => state.drs);
+
+ // read stored position only on first render
+ const igvPosition = useSelector(
+ (state) => state.explorer.igvPosition,
+ () => true, // We don't want to re-render anything when the position changes
+ );
+
+ const dispatch = useDispatch();
+
+ const referenceService = useService("reference");
+ // Built-in igv.js genomes (with annotations):
+ const { hasAttempted: igvGenomesAttempted, itemsByID: igvGenomesByID } = useIgvGenomes();
+ const referenceGenomes = useReferenceGenomes(); // Reference service genomes
+
+ const availableBrowserGenomes = useMemo(() => {
+ if (!igvGenomesAttempted || !referenceGenomes.hasAttempted) {
+ return {};
+ }
+
+ const availableGenomes = {};
+
+ // For now, we prefer igv.js built-in genomes with the same ID over local copies for the browser, since it comes
+ // with gene annotation tracks. TODO: in the future, this should switch to preferring local copies.
+ referenceGenomes.items.forEach((g) => {
+ availableGenomes[g.id] = {
+ id: g.id,
+ fastaURL: g.fasta,
+ indexURL: g.fai,
+ cytobandURL: (igvGenomesByID[g.id] ?? igvGenomesByID[IGV_JS_ANNOTATION_ALIASES[g.id]])?.cytobandURL,
+ tracks: g.gff3_gz
+ ? [
+ {
+ name: "Features",
+ type: "annotation",
+ format: "gff3",
+ filterTypes: ["chromosome", "region", "gene", "3_utr", "5_utr", "CDS"],
+ url: g.gff3_gz,
+ indexURL: g.gff3_gz_tbi,
+ order: 1000000,
+ visibilityWindow: 5000000,
+ height: 200,
+ },
+ ]
+ : [],
+ };
+ });
+
+ console.debug("total available genomes:", availableGenomes);
+
+ return availableGenomes;
+ }, [igvGenomesAttempted, igvGenomesByID, referenceGenomes]);
+
+ const biosamplesData = useDeduplicatedIndividualBiosamples(individual);
+ const viewableResults = useMemo(() => {
+ const experiments = biosamplesData.flatMap((b) => b?.experiments ?? []);
+ const vr = Object.values(
+ Object.fromEntries(
+ // Deduplicate experiment results by file name by building an object
+ experiments
+ .flatMap((e) => e?.experiment_results ?? [])
+ .filter(isViewableInIgv)
+ .map((expRes) => {
+ /** @type string|undefined */
+ const fileFormatLower = expResFileFormatLower(expRes);
+ return [
+ expRes.filename,
+ {
+ ...expRes,
+ // by default, don't view alignments (user can turn them on in track controls):
+ fileFormatLower,
+ viewInIgv: !ALIGNMENT_FORMATS_LOWER.includes(fileFormatLower),
+ },
+ ];
+ }),
+ ),
+ ).sort((r1, r2) => (r1.fileFormatLower ?? "").localeCompare(r2.fileFormatLower ?? ""));
+ console.debug("Viewable experiment results:", vr);
+ return vr;
+ }, [biosamplesData]);
+
+ // augmented experiment results with viewInIgv state + cached lowercase / normalized file format:
+ const [allTracks, setAllTracks] = useState(simpleDeepCopy(viewableResults));
+
+ useEffect(() => {
+ // If the set of viewable results changes, reset the track state
+ setAllTracks(simpleDeepCopy(viewableResults));
+ }, [viewableResults]);
+
+ const allFoundFiles = useMemo(() => allTracks.filter((t) => !!igvUrls[t.filename]?.url), [allTracks, igvUrls]);
+
+ const [selectedAssemblyID, setSelectedAssemblyID] = useState(undefined);
+
+ const trackAssemblyIDs = useMemo(
+ () => Array.from(new Set(allFoundFiles.map((t) => t.genome_assembly_id))).sort(),
+ [allFoundFiles],
+ );
+
+ useEffect(() => {
+ if (Object.keys(availableBrowserGenomes).length) {
+ if (trackAssemblyIDs.length && trackAssemblyIDs[0]) {
+ const asmID = trackAssemblyIDs[0]; // TODO: first available
+ console.debug("auto-selected assembly ID:", asmID);
+ setSelectedAssemblyID(asmID);
+ }
+ }
+ }, [availableBrowserGenomes, trackAssemblyIDs]);
+
+ const [modalVisible, setModalVisible] = useState(false);
+
+ const showModal = useCallback(() => setModalVisible(true), []);
+ const closeModal = useCallback(() => setModalVisible(false), []);
+
+ const toggleView = useCallback(
+ (track) => {
+ if (!igvBrowserRef.current) return;
+
+ const wasViewing = track.viewInIgv;
+ setAllTracks(allTracks.map((t) => (t.filename === track.filename ? { ...track, viewInIgv: !wasViewing } : t)));
+
+ if (wasViewing) {
+ igvBrowserRef.current.removeTrackByName(track.filename);
+ } else {
+ igvBrowserRef.current.loadTrack(buildIgvTrack(igvUrls, track)).catch(console.error);
+ }
+ },
+ [allTracks, igvUrls],
+ );
+
+ const storeIgvPosition = useCallback(
+ (referenceFrame) => {
+ const { chr, start, end } = referenceFrame[0];
+ const position = `${chr}:${start}-${end}`;
+ dispatch(setIgvPosition(position));
+ },
+ [dispatch],
+ );
+
+ // retrieve urls on mount
+ useEffect(() => {
+ if (allTracks.length) {
+ // don't search if all urls already known
+ if (hasFreshUrls(allTracks, igvUrls)) {
+ return;
+ }
+ dispatch(getIgvUrlsFromDrs(allTracks)).catch(console.error);
+ }
+ }, [dispatch, allTracks, igvUrls]);
+
+ // update access token whenever necessary
+ useEffect(() => {
+ if (BENTO_URL) {
+ igv.setOauthToken(accessToken, new URL(BENTO_URL).host);
+ }
+ if (BENTO_PUBLIC_URL) {
+ igv.setOauthToken(accessToken, new URL(BENTO_PUBLIC_URL).host);
+ }
+ }, [accessToken]);
+
+ // render igv when track urls + reference genomes are ready
+ useEffect(() => {
+ const cleanup = () => {
+ if (igvBrowserRef.current) {
+ console.debug("removing igv.js browser instance");
+ igv.removeBrowser(igvBrowserRef.current);
+ igvBrowserRef.current = null;
+ }
+ };
+
+ if (isFetchingIgvUrls) {
+ return cleanup;
+ }
+
+ if (!allFoundFiles.length || !hasFreshUrls(allTracks, igvUrls)) {
+ console.debug("urls not ready");
+ console.debug({ igvUrls });
+ console.debug({ tracksValid: hasFreshUrls(allTracks, igvUrls) });
+ return cleanup;
+ }
+
+ if (!Object.keys(availableBrowserGenomes).length || !selectedAssemblyID) {
+ console.debug("no available browser genomes / selected assembly ID yet");
+ return cleanup;
+ }
+
+ console.debug("igv.createBrowser effect dependencies:", [
+ igvUrls,
+ viewableResults,
+ availableBrowserGenomes,
+ selectedAssemblyID,
+ ]);
+
+ if (creatingIgvBrowser || igvBrowserRef.current) {
+ console.debug(
+ "browser is already being created or exists: creatingIgvBrowser =",
+ creatingIgvBrowser,
+ "igvBrowserRef.current =",
+ igvBrowserRef.current,
+ );
+ return cleanup;
+ }
+
+ setCreatingIgvBrowser(true);
+
+ const initialIgvTracks = allFoundFiles
+ .filter((t) => t.viewInIgv && t.genome_assembly_id === selectedAssemblyID && igvUrls[t.filename].url)
+ .map((t) => buildIgvTrack(igvUrls, t));
+
+ const selectedBentoReference = referenceGenomes.itemsByID[selectedAssemblyID];
+
+ const igvOptions = {
+ reference: availableBrowserGenomes[selectedAssemblyID],
+ locus: igvPosition,
+ tracks: initialIgvTracks,
+ ...(referenceService && selectedBentoReference?.gff3_gz
+ ? {
+ search: {
+ url: `${referenceService.url}/genomes/$GENOME$/igv-js-features?q=$FEATURE$`,
+ coords: 1,
+ },
+ }
+ : {}),
+ };
+
+ console.debug("creating igv.js browser with options:", igvOptions, "; tracks:", initialIgvTracks);
+
+ igv
+ .createBrowser(igvDivRef.current, igvOptions)
+ .then((browser) => {
+ browser.on(
+ "locuschange",
+ debounce((referenceFrame) => {
+ storeIgvPosition(referenceFrame);
+ }, DEBOUNCE_WAIT),
+ );
+ igvBrowserRef.current = browser;
+ setCreatingIgvBrowser(false);
+ console.debug("created igv.js browser instance:", browser);
+ })
+ .catch((err) => {
+ message.error(`Error creating igv.js browser: ${err}`);
+ console.error(err);
+ });
+
+ return cleanup;
+
+ // Use viewableResults as the track dependency, not allFoundFiles, since allFoundFiles is regenerated if a
+ // track's visibility changes – allFoundFiles is left out of these dependencies on purpose, otherwise the entire
+ // browser will be re-rendered if a track's visibility changes. By using viewableResults as a dependency
+ // instead, the browser is only re-rendered if the overall track set (i.e., individual) changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [igvUrls, viewableResults, availableBrowserGenomes, selectedAssemblyID]);
+
+ return (
+ <>
+ }
+ style={{ marginRight: "8px" }}
+ onClick={showModal}
+ disabled={!allFoundFiles.length}
+ loading={isFetchingIgvUrls}
+ >
+ Configure Tracks
+
+
+ {!allFoundFiles.length &&
+ (isFetchingIgvUrls || referenceGenomes.isFetching ? (
+
+ ) : (
+
+ ))}
+
+
+
+ Assembly:{" "}
+ setSelectedAssemblyID(v)}
+ options={trackAssemblyIDs.map((a) => ({ value: a, label: a }))}
+ />
+
+
+
+ >
+ );
};
IndividualTracks.propTypes = {
- individual: individualPropTypesShape,
+ individual: individualPropTypesShape,
};
export default IndividualTracks;
diff --git a/src/components/explorer/IndividualVariants.js b/src/components/explorer/IndividualVariants.js
index f5cb313e6..05f11781b 100644
--- a/src/components/explorer/IndividualVariants.js
+++ b/src/components/explorer/IndividualVariants.js
@@ -1,15 +1,9 @@
-import React, { useContext, useMemo } from "react";
-import { Link } from "react-router-dom";
-import { useDispatch } from "react-redux";
+import React from "react";
import PropTypes from "prop-types";
-import { Button, Descriptions } from "antd";
-
-import { setIgvPosition } from "@/modules/explorer/actions";
+import { Descriptions } from "antd";
import "./explorer.css";
-import { ExplorerIndividualContext } from "./contexts/individual";
-import { explorerIndividualUrl } from "./utils";
import JsonView from "@/components/common/JsonView";
import OntologyTerm from "./OntologyTerm";
@@ -18,115 +12,98 @@ import { GeneDescriptor } from "./IndividualGenes";
// TODO: Only show variants from the relevant dataset, if specified;
// highlight those found in search results, if specified
-const variantStyle = {margin: "5px"};
+const variantStyle = { margin: "5px" };
const variantExpressionPropType = PropTypes.shape({
- syntax: PropTypes.string,
- value: PropTypes.string,
- version: PropTypes.string,
+ syntax: PropTypes.string,
+ value: PropTypes.string,
+ version: PropTypes.string,
});
-const VariantExpressionDetails = ({variantExpression, geneContext}) => {
- const dispatch = useDispatch();
- const { individualID } = useContext(ExplorerIndividualContext);
- const tracksUrl = useMemo(() => {
- if (individualID) {
- return `${explorerIndividualUrl(individualID)}/tracks`;
- }
- }, [individualID]);
- return (
-
-
- syntax: {" "}{variantExpression.syntax}
- value: {" "}{variantExpression.value}
- version: {" "}{variantExpression.version}
- {(geneContext && tracksUrl) && (
- <>
- gene context: {" "}
- dispatch(setIgvPosition(geneContext))} to={tracksUrl}>
- {geneContext.value_id}
-
- >
- )}
-
-
- );
+const VariantExpressionDetails = ({ variantExpression }) => {
+ return (
+
+
+ syntax: {variantExpression.syntax}
+
+ value: {variantExpression.value}
+
+ version: {variantExpression.version}
+
+
+
+ );
};
VariantExpressionDetails.propTypes = {
- variantExpression: variantExpressionPropType,
- geneContext: PropTypes.object,
+ variantExpression: variantExpressionPropType,
+ geneContext: PropTypes.object,
};
-
-const VariantDescriptor = ({variationDescriptor}) => {
- return (
-
- {variationDescriptor.id}
- {variationDescriptor.variation &&
-
- {/* TODO: VRS type specific display ?*/}
-
-
- }
- {variationDescriptor.label &&
- {variationDescriptor.label}
- }
- {variationDescriptor.description &&
- {variationDescriptor.description}
- }
- {variationDescriptor.gene_context &&
-
-
-
- }
- {(variationDescriptor.expressions && variationDescriptor.gene_context) &&
-
- {variationDescriptor.expressions.map(expr => (
-
- ))}
-
- }
- {variationDescriptor.vfc_record &&
-
-
-
- }
- {variationDescriptor.xrefs &&
- {variationDescriptor.xrefs}
- }
- {variationDescriptor.alternate_labels &&
- {variationDescriptor.alternate_labels}
- }
- {variationDescriptor.extensions &&
-
- {variationDescriptor.extensions}
-
- }
- {variationDescriptor.molecule_context &&
- {variationDescriptor.molecule_context}
- }
- {variationDescriptor.structural_type &&
-
-
-
- }
- {variationDescriptor.vrs_ref_allele_seq &&
-
- {variationDescriptor.vrs_ref_allele_seq}
-
- }
- {variationDescriptor.allelic_state &&
-
-
-
- }
-
- );
+const VariantDescriptor = ({ variationDescriptor }) => {
+ return (
+
+ {variationDescriptor.id}
+ {variationDescriptor.variation && (
+
+ {/* TODO: VRS type specific display ?*/}
+
+
+ )}
+ {variationDescriptor.label && {variationDescriptor.label} }
+ {variationDescriptor.description && (
+ {variationDescriptor.description}
+ )}
+ {variationDescriptor.gene_context && (
+
+
+
+ )}
+ {variationDescriptor.expressions && variationDescriptor.gene_context && (
+
+ {variationDescriptor.expressions.map((expr) => (
+
+ ))}
+
+ )}
+ {variationDescriptor.vfc_record && (
+
+
+
+ )}
+ {variationDescriptor.xrefs && {variationDescriptor.xrefs} }
+ {variationDescriptor.alternate_labels && (
+ {variationDescriptor.alternate_labels}
+ )}
+ {variationDescriptor.extensions && (
+ {variationDescriptor.extensions}
+ )}
+ {variationDescriptor.molecule_context && (
+ {variationDescriptor.molecule_context}
+ )}
+ {variationDescriptor.structural_type && (
+
+
+
+ )}
+ {variationDescriptor.vrs_ref_allele_seq && (
+
+ {variationDescriptor.vrs_ref_allele_seq}
+
+ )}
+ {variationDescriptor.allelic_state && (
+
+
+
+ )}
+
+ );
};
VariantDescriptor.propTypes = {
- variationDescriptor: PropTypes.object,
+ variationDescriptor: PropTypes.object,
};
export default VariantDescriptor;
diff --git a/src/components/explorer/OntologyTerm.js b/src/components/explorer/OntologyTerm.js
index 4927e44f6..5b940de2f 100644
--- a/src/components/explorer/OntologyTerm.js
+++ b/src/components/explorer/OntologyTerm.js
@@ -12,91 +12,84 @@ import { ExplorerIndividualContext } from "./contexts/individual";
import { useResourcesByNamespacePrefix } from "./utils";
export const conditionalOntologyRender = (field) => (_, record) => {
- if (record.hasOwnProperty(field)) {
- const term = record[field];
- return ();
- }
- return EM_DASH;
+ if (record.hasOwnProperty(field)) {
+ const term = record[field];
+ return ;
+ }
+ return EM_DASH;
};
const OntologyTerm = memo(({ term, renderLabel, br }) => {
- const { resourcesTuple } = useContext(ExplorerIndividualContext);
-
- // TODO: perf: might be slow to generate this over and over
- const [resourcesByNamespacePrefix, isFetchingResources] = useResourcesByNamespacePrefix(resourcesTuple);
-
- if (!term) {
- return (
- <>{EM_DASH}>
- );
- }
+ const { resourcesTuple } = useContext(ExplorerIndividualContext);
- useEffect(() => {
- if (!term.id || !term.label) {
- console.error("Invalid term provided to OntologyTerm component:", term);
- }
- }, [term]);
+ // TODO: perf: might be slow to generate this over and over
+ const [resourcesByNamespacePrefix, isFetchingResources] = useResourcesByNamespacePrefix(resourcesTuple);
+ useEffect(() => {
+ if (!term) return;
if (!term.id || !term.label) {
- return (
- <>{EM_DASH}>
- );
+ console.error("Invalid term provided to OntologyTerm component:", term);
}
-
- /**
- * @type {string|null}
- */
- let defLink = null;
-
- if (term.id.includes(":")) {
- const [namespacePrefix, namespaceID] = term.id.split(":");
- const termResource = resourcesByNamespacePrefix[namespacePrefix];
-
- if (termResource?.iri_prefix && !termResource.iri_prefix.includes("example.org")) {
- defLink = `${termResource.iri_prefix}${namespaceID}`;
- } // If resource doesn't exist / isn't linkable, don't include a link
- } // Otherwise, malformed ID - render a disabled link
-
- return (
-
- {renderLabel(term.label)} (ID: {term.id}){" "}
-
-
-
-
-
- {br && }
-
- );
+ }, [term]);
+
+ if (!term || !term.id || !term.label) {
+ return <>{EM_DASH}>;
+ }
+
+ /**
+ * @type {string|null}
+ */
+ let defLink = null;
+
+ if (term.id.includes(":")) {
+ const [namespacePrefix, namespaceID] = term.id.split(":");
+ const termResource = resourcesByNamespacePrefix[namespacePrefix];
+
+ if (termResource?.iri_prefix && !termResource.iri_prefix.includes("example.org")) {
+ defLink = `${termResource.iri_prefix}${namespaceID}`;
+ } // If resource doesn't exist / isn't linkable, don't include a link
+ } // Otherwise, malformed ID - render a disabled link
+
+ return (
+
+ {renderLabel(term.label)} (ID: {term.id}){" "}
+
+
+
+
+
+ {br && }
+
+ );
});
OntologyTerm.propTypes = {
- term: ontologyShape,
- renderLabel: PropTypes.func,
- br: PropTypes.bool,
+ term: ontologyShape,
+ renderLabel: PropTypes.func,
+ br: PropTypes.bool,
};
OntologyTerm.defaultProps = {
- renderLabel: id,
- br: false,
+ renderLabel: id,
+ br: false,
};
export const OntologyTermList = (items) => {
- if (!Array.isArray(items)) {
- return EM_DASH;
- }
- return items.map((ontology, idx) => );
+ if (!Array.isArray(items)) {
+ return EM_DASH;
+ }
+ return items.map((ontology, idx) => );
};
OntologyTermList.propTypes = {
- items: PropTypes.arrayOf(ontologyShape),
+ items: PropTypes.arrayOf(ontologyShape),
};
export default OntologyTerm;
diff --git a/src/components/explorer/RoutedIndividualContent.js b/src/components/explorer/RoutedIndividualContent.js
index bd7adb7dc..c8495cef7 100644
--- a/src/components/explorer/RoutedIndividualContent.js
+++ b/src/components/explorer/RoutedIndividualContent.js
@@ -4,67 +4,78 @@ import PropTypes from "prop-types";
import { Table } from "antd";
-export const RoutedIndividualContentTable = ({data, urlParam, columns, rowKey, handleRowSelect, expandedRowRender}) => {
- const paramValue = useParams()[urlParam];
- const expandedRowKeys = useMemo(() => paramValue ? [paramValue] : [], [paramValue]);
- const onExpand = useCallback(
- (e, record) => {
- let selected = undefined;
- if (e) {
- if (typeof rowKey === "function") {
- selected = rowKey(record);
- } else {
- selected = record[rowKey];
- }
- }
- handleRowSelect(selected);
- },
- [handleRowSelect, rowKey],
- );
- return (
-
- );
+export const RoutedIndividualContentTable = ({
+ data,
+ urlParam,
+ columns,
+ rowKey,
+ handleRowSelect,
+ expandedRowRender,
+}) => {
+ const paramValue = useParams()[urlParam];
+ const expandedRowKeys = useMemo(() => (paramValue ? [paramValue] : []), [paramValue]);
+ const onExpand = useCallback(
+ (e, record) => {
+ let selected = undefined;
+ if (e) {
+ if (typeof rowKey === "function") {
+ selected = rowKey(record);
+ } else {
+ selected = record[rowKey];
+ }
+ }
+ handleRowSelect(selected);
+ },
+ [handleRowSelect, rowKey],
+ );
+ return (
+
+ );
};
RoutedIndividualContentTable.propTypes = {
- data: PropTypes.array,
- urlParam: PropTypes.string,
- columns: PropTypes.array,
- rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
- handleRowSelect: PropTypes.func,
- expandedRowRender: PropTypes.func,
+ data: PropTypes.array,
+ urlParam: PropTypes.string,
+ columns: PropTypes.array,
+ rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
+ handleRowSelect: PropTypes.func,
+ expandedRowRender: PropTypes.func,
};
export const RoutedIndividualContent = ({ renderContent, urlParam }) => {
- const navigate = useNavigate();
+ const navigate = useNavigate();
- const handleRoutedSelection = useCallback((selected) => {
- if (!selected) {
- navigate("", { replace: true });
- return;
- }
- navigate(`${selected}`, { replace: true });
- }, [navigate]);
+ const handleRoutedSelection = useCallback(
+ (selected) => {
+ if (!selected) {
+ navigate("", { replace: true });
+ return;
+ }
+ navigate(`${selected}`, { replace: true });
+ },
+ [navigate],
+ );
- const contentNode = useMemo(
- () => renderContent({ onContentSelect: handleRoutedSelection }),
- [handleRoutedSelection]);
+ const contentNode = useMemo(
+ () => renderContent({ onContentSelect: handleRoutedSelection }),
+ [renderContent, handleRoutedSelection],
+ );
- return (
-
-
-
-
- );
+ return (
+
+
+
+
+ );
};
RoutedIndividualContent.propTypes = {
- renderContent: PropTypes.func,
- urlParam: PropTypes.string,
+ renderContent: PropTypes.func,
+ urlParam: PropTypes.string,
};
diff --git a/src/components/explorer/SearchAllRecords.js b/src/components/explorer/SearchAllRecords.js
index 7a1c96d2c..52daebe80 100644
--- a/src/components/explorer/SearchAllRecords.js
+++ b/src/components/explorer/SearchAllRecords.js
@@ -7,46 +7,64 @@ const { Search } = Input;
import { performFreeTextSearchIfPossible } from "@/modules/explorer/actions";
class SearchAllRecords extends Component {
- constructor(props) {
- super(props);
- this.onSearch = this.onSearch.bind(this);
- }
+ constructor(props) {
+ super(props);
+ this.state = {
+ searchText: "",
+ };
+ this.onSearch = this.onSearch.bind(this);
+ this.handleChange = this.handleChange.bind(this);
+ }
- async onSearch(searchTerm) {
- await this.props.performFreeTextSearchIfPossible(this.props.datasetID, searchTerm);
+ componentDidUpdate(prevProps) {
+ // When isFetchingAdvancedSearch becomes true, clear the search text
+ if (this.props.isFetchingAdvancedSearch && !prevProps.isFetchingAdvancedSearch) {
+ this.setState({ searchText: "" });
}
+ }
- render() {
- return (
-
-
- Text Search
-
-
-
- );
- }
+ handleChange(e) {
+ this.setState({ searchText: e.target.value });
+ }
+
+ async onSearch(searchTerm) {
+ await this.props.performFreeTextSearchIfPossible(this.props.datasetID, searchTerm);
+ }
+
+ render() {
+ return (
+
+
+ Text Search
+
+
+
+ );
+ }
}
SearchAllRecords.propTypes = {
- datasetID: PropTypes.string,
- performFreeTextSearchIfPossible: PropTypes.func,
- searchAllRecords: PropTypes.object,
- isFetchingAdvancedSearch: PropTypes.bool,
- isFetchingTextSearch: PropTypes.bool,
+ datasetID: PropTypes.string,
+ performFreeTextSearchIfPossible: PropTypes.func,
+ searchAllRecords: PropTypes.object,
+ isFetchingAdvancedSearch: PropTypes.bool,
+ isFetchingTextSearch: PropTypes.bool,
};
const mapStateToProps = (state, ownProps) => ({
- isFetchingAdvancedSearch: state.explorer.fetchingSearchByDatasetID[ownProps.datasetID] ?? false,
- isFetchingTextSearch: state.explorer.fetchingTextSearch ?? false,
+ isFetchingAdvancedSearch: state.explorer.fetchingSearchByDatasetID[ownProps.datasetID] ?? false,
+ isFetchingTextSearch: state.explorer.fetchingTextSearch ?? false,
});
export default connect(mapStateToProps, {
- performFreeTextSearchIfPossible,
+ performFreeTextSearchIfPossible,
})(SearchAllRecords);
diff --git a/src/components/explorer/SearchSummaryModal.js b/src/components/explorer/SearchSummaryModal.js
index 1c4481a20..8650b8b54 100644
--- a/src/components/explorer/SearchSummaryModal.js
+++ b/src/components/explorer/SearchSummaryModal.js
@@ -15,151 +15,139 @@ const serializePieChartData = (data) => Object.entries(data).map(([key, value])
const serializeBarChartData = (data) => Object.entries(data).map(([key, value]) => ({ ageBin: key, count: value }));
const createChart = (chartData) => {
- const { type, title, data, ...rest } = chartData;
-
- switch (type) {
- case "PieChart":
- return (
-
- );
- case "BarChart":
- return (
-
- );
- default:
- return null;
- }
+ const { type, title, data, ...rest } = chartData;
+
+ switch (type) {
+ case "PieChart":
+ return ;
+ case "BarChart":
+ return ;
+ default:
+ return null;
+ }
};
const renderCharts = (chartsData) => {
- return chartsData.map((chartData, index) => (
-
- {createChart(chartData)}
-
- ));
+ return chartsData.map((chartData, index) => (
+
+ {createChart(chartData)}
+
+ ));
};
const SearchSummaryModal = ({ searchResults, ...props }) => {
- const [data, setData] = useState(null);
-
- const katsuUrl = useService("metadata")?.url;
- const authorizationHeader = useAuthorizationHeader();
-
- useEffect(() => {
- const ids = searchResults.searchFormattedResults.map(({ key }) => key);
-
- const raw = JSON.stringify({
- id: ids,
- });
-
- const requestOptions = {
- method: "POST",
- headers: new Headers({"Content-Type": "application/json", ...authorizationHeader}),
- body: raw,
- redirect: "follow",
- };
-
- fetch(`${katsuUrl}/api/search_overview`, requestOptions)
- .then((response) => response.json())
- .then((result) => {
- setData(result);
- })
- .catch((error) => console.error("error", error));
- }, [searchResults]);
-
- const phenopacketData = data?.phenopacket?.data_type_specific;
- const experimentData = data?.experiment?.data_type_specific;
-
- const individualsCharts = [
- {
- type: "PieChart",
- title: "Sex",
- data: phenopacketData?.individuals?.sex,
- },
- {
- type: "PieChart",
- title: "Diseases",
- data: phenopacketData?.diseases?.term,
- },
- {
- type: "PieChart",
- title: "Phenotypic Features",
- data: phenopacketData?.phenotypic_features?.type,
- },
- {
- type: "BarChart",
- title: "Ages",
- data: phenopacketData?.individuals?.age,
- },
- ];
-
- const biosamplesCharts = [
- {
- type: "PieChart",
- title: "Biosamples by Tissue",
- data: phenopacketData?.biosamples?.sampled_tissue,
- },
- {
- type: "PieChart",
- title: "Biosamples by Diagnosis",
- data: phenopacketData?.biosamples?.histological_diagnosis,
- },
- ];
-
- const experimentsCharts = [
- {
- type: "PieChart",
- title: "Experiment Types",
- data: experimentData?.experiments?.experiment_type,
- },
- ];
-
- return (
-
- {data ? (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
- <>
- Individuals
- {renderCharts(individualsCharts)}
-
- Biosamples
- {renderCharts(biosamplesCharts)}
-
- Experiments
- {renderCharts(experimentsCharts)}
- >
- >
- ) : (
-
- )}
-
- );
+ const [data, setData] = useState(null);
+
+ const katsuUrl = useService("metadata")?.url;
+ const authorizationHeader = useAuthorizationHeader();
+
+ useEffect(() => {
+ if (!katsuUrl) return;
+
+ const ids = searchResults.searchFormattedResults.map(({ key }) => key);
+
+ const raw = JSON.stringify({
+ id: ids,
+ });
+
+ const requestOptions = {
+ method: "POST",
+ headers: new Headers({ "Content-Type": "application/json", ...authorizationHeader }),
+ body: raw,
+ redirect: "follow",
+ };
+
+ fetch(`${katsuUrl}/api/search_overview`, requestOptions)
+ .then((response) => response.json())
+ .then((result) => {
+ setData(result);
+ })
+ .catch((error) => console.error("error", error));
+ }, [katsuUrl, searchResults, authorizationHeader]);
+
+ const phenopacketData = data?.phenopacket?.data_type_specific;
+ const experimentData = data?.experiment?.data_type_specific;
+
+ const individualsCharts = [
+ {
+ type: "PieChart",
+ title: "Sex",
+ data: phenopacketData?.individuals?.sex,
+ },
+ {
+ type: "PieChart",
+ title: "Diseases",
+ data: phenopacketData?.diseases?.term,
+ },
+ {
+ type: "PieChart",
+ title: "Phenotypic Features",
+ data: phenopacketData?.phenotypic_features?.type,
+ },
+ {
+ type: "BarChart",
+ title: "Ages",
+ data: phenopacketData?.individuals?.age,
+ },
+ ];
+
+ const biosamplesCharts = [
+ {
+ type: "PieChart",
+ title: "Biosamples by Tissue",
+ data: phenopacketData?.biosamples?.sampled_tissue,
+ },
+ {
+ type: "PieChart",
+ title: "Biosamples by Diagnosis",
+ data: phenopacketData?.biosamples?.histological_diagnosis,
+ },
+ ];
+
+ const experimentsCharts = [
+ {
+ type: "PieChart",
+ title: "Experiment Types",
+ data: experimentData?.experiments?.experiment_type,
+ },
+ ];
+
+ return (
+
+ {data ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ <>
+ Individuals
+ {renderCharts(individualsCharts)}
+
+ Biosamples
+ {renderCharts(biosamplesCharts)}
+
+ Experiments
+ {renderCharts(experimentsCharts)}
+ >
+ >
+ ) : (
+
+ )}
+
+ );
};
SearchSummaryModal.propTypes = {
- searchResults: explorerSearchResultsPropTypesShape,
+ searchResults: explorerSearchResultsPropTypesShape,
};
export default SearchSummaryModal;
diff --git a/src/components/explorer/SearchTracksModal.js b/src/components/explorer/SearchTracksModal.js
index f23b58665..4ab6beccb 100644
--- a/src/components/explorer/SearchTracksModal.js
+++ b/src/components/explorer/SearchTracksModal.js
@@ -1,22 +1,24 @@
import React from "react";
-import {Modal} from "antd";
+import { Modal } from "antd";
-import {explorerSearchResultsPropTypesShape} from "@/propTypes";
+import { explorerSearchResultsPropTypesShape } from "@/propTypes";
import GenomeBrowser from "./GenomeBrowser";
-const SearchTracksModal = ({searchResults, ...props}) => {
- const variants = searchResults?.results?.results?.variant || [];
+const SearchTracksModal = ({ searchResults, ...props }) => {
+ const variants = searchResults?.results?.results?.variant || [];
- // TODO: Display some basic statistics about n. of variants/tracks/etc.
+ // TODO: Display some basic statistics about n. of variants/tracks/etc.
- return searchResults ?
-
- : null;
+ return searchResults ? (
+
+
+
+ ) : null;
};
SearchTracksModal.propTypes = {
- searchResults: explorerSearchResultsPropTypesShape,
+ searchResults: explorerSearchResultsPropTypesShape,
};
export default SearchTracksModal;
diff --git a/src/components/explorer/TimeElement.js b/src/components/explorer/TimeElement.js
index dec528b6d..735453933 100644
--- a/src/components/explorer/TimeElement.js
+++ b/src/components/explorer/TimeElement.js
@@ -5,93 +5,97 @@ import { EM_DASH } from "@/constants";
import OntologyTerm from "./OntologyTerm";
const TIME_ELEMENT_TYPES_LABELS = {
- "age": "Age",
- "gestational_age": "Gestational Age",
- "age_range": "Age Range",
- "ontology_class": "Ontology Class",
- "timestamp": "Timestamp",
- "interval": "Interval",
+ age: "Age",
+ gestational_age: "Gestational Age",
+ age_range: "Age Range",
+ ontology_class: "Ontology Class",
+ timestamp: "Timestamp",
+ interval: "Interval",
};
const getTimeElementTypeLabel = (timeElement) => {
- const keys = Object.keys(timeElement);
- if (keys ?? keys.length === 1) {
- // A Phenopacket TimeElement should only have 1 property
- const type = keys[0];
- if (type in TIME_ELEMENT_TYPES_LABELS) {
- const label = TIME_ELEMENT_TYPES_LABELS[type];
- return [type, label];
- }
+ const keys = Object.keys(timeElement);
+ if (keys ?? keys.length === 1) {
+ // A Phenopacket TimeElement should only have 1 property
+ const type = keys[0];
+ if (type in TIME_ELEMENT_TYPES_LABELS) {
+ const label = TIME_ELEMENT_TYPES_LABELS[type];
+ return [type, label];
}
- return [null, "NOT_SUPPORTED"];
+ }
+ return [null, "NOT_SUPPORTED"];
};
-export const TimeInterval = ({timeInterval, br}) => {
- return (
-
- Start: {" "}<>{timeInterval.start}>
- {br ? : " "}
- End: {" "}<>{timeInterval.end}>
-
- );
+export const TimeInterval = ({ timeInterval, br }) => {
+ return (
+
+ Start: <>{timeInterval.start}>
+ {br ? : " "}
+ End: <>{timeInterval.end}>
+
+ );
};
TimeInterval.propTypes = {
- timeInterval: PropTypes.object,
- br: PropTypes.bool,
+ timeInterval: PropTypes.object,
+ br: PropTypes.bool,
};
-const InnerTimeElement = ({type, timeElement}) => {
- switch (type) {
- case "age":
- return {timeElement.age.iso8601duration} ;
- case "gestational_age":
- return
- Weeks: {" "}{timeElement.gestationalAge.weeks}{" "}
- Days: {" "}{timeElement.gestationalAge.days}
- ;
- case "age_range":
- return
- Start: {" "}<>{timeElement.age_range.start.iso8601duration}>{" "}
- End: {" "}<>{timeElement.age_range.end.iso8601duration}>
- ;
- case "ontology_class":
- return ;
- case "timestamp":
- return {timeElement.timestamp} ;
- case "interval":
- return ;
- default:
- return EM_DASH;
- }
+const InnerTimeElement = ({ type, timeElement }) => {
+ switch (type) {
+ case "age":
+ return {timeElement.age.iso8601duration} ;
+ case "gestational_age":
+ return (
+
+ Weeks: {timeElement.gestationalAge.weeks} Days: {" "}
+ {timeElement.gestationalAge.days}
+
+ );
+ case "age_range":
+ return (
+
+ Start: <>{timeElement.age_range.start.iso8601duration}> End: {" "}
+ <>{timeElement.age_range.end.iso8601duration}>
+
+ );
+ case "ontology_class":
+ return ;
+ case "timestamp":
+ return {timeElement.timestamp} ;
+ case "interval":
+ return ;
+ default:
+ return EM_DASH;
+ }
};
InnerTimeElement.propTypes = {
- type: PropTypes.string,
- timeElement: PropTypes.object,
+ type: PropTypes.string,
+ timeElement: PropTypes.object,
};
-const TimeElement = React.memo(({timeElement}) => {
- if (!timeElement) {
- return EM_DASH;
- }
+const TimeElement = React.memo(({ timeElement }) => {
+ if (!timeElement) {
+ return EM_DASH;
+ }
- const [timeType, label] = getTimeElementTypeLabel(timeElement);
+ const [timeType, label] = getTimeElementTypeLabel(timeElement);
- if (!timeType) {
- // Unexpected TimeElement type
- console.error("Bad time element:", timeElement);
- return EM_DASH;
- }
+ if (!timeType) {
+ // Unexpected TimeElement type
+ console.error("Bad time element:", timeElement);
+ return EM_DASH;
+ }
- return (
-
- {label}:
-
-
- );
+ return (
+
+ {label}:
+
+
+ );
});
TimeElement.propTypes = {
- timeElement: PropTypes.object,
+ timeElement: PropTypes.object,
};
export default TimeElement;
diff --git a/src/components/explorer/hooks/explorerHooks.js b/src/components/explorer/hooks/explorerHooks.js
index d37c4cb87..b8f153f6e 100644
--- a/src/components/explorer/hooks/explorerHooks.js
+++ b/src/components/explorer/hooks/explorerHooks.js
@@ -1,31 +1,46 @@
-import { useMemo } from "react";
+import { useCallback, useMemo } from "react";
export const useSortedColumns = (data, tableSortOrder, columnsDefinition) => {
- const sortColumnKey = tableSortOrder?.sortColumnKey;
- const sortOrder = tableSortOrder?.sortOrder;
+ const sortColumnKey = tableSortOrder?.sortColumnKey;
+ const sortOrder = tableSortOrder?.sortOrder;
- const sortData = (dataToSort, sortKey, order) => {
- const column = columnsDefinition.find((col) => col.dataIndex === sortKey);
- if (column && column.sorter) {
- return [...dataToSort].sort((a, b) => {
- return order === "ascend" ? column.sorter(a, b) : column.sorter(b, a);
- });
- }
- return dataToSort;
- };
+ const sortData = useCallback(
+ (dataToSort, sortKey, order) => {
+ const column = columnsDefinition.find((col) => col.dataIndex === sortKey);
+ if (column && column.sorter) {
+ return [...dataToSort].sort((a, b) => {
+ return order === "ascend" ? column.sorter(a, b) : column.sorter(b, a);
+ });
+ }
+ return dataToSort;
+ },
+ [columnsDefinition],
+ );
- const sortedData = useMemo(() => {
- return sortData(data, sortColumnKey, sortOrder);
- }, [data, sortColumnKey, sortOrder, columnsDefinition]);
+ const sortedData = useMemo(
+ () => sortData(data, sortColumnKey, sortOrder),
+ [data, sortData, sortColumnKey, sortOrder],
+ );
- const columnsWithSortOrder = useMemo(() => {
- return columnsDefinition.map((column) => {
- if (column.dataIndex === sortColumnKey) {
- return { ...column, sortOrder };
- }
- return column;
- });
- }, [sortColumnKey, sortOrder, columnsDefinition]);
+ const columnsWithSortOrder = useMemo(
+ () =>
+ columnsDefinition.map((column) => {
+ if (column.dataIndex === sortColumnKey) {
+ return { ...column, sortOrder };
+ }
+ return column;
+ }),
+ [sortColumnKey, sortOrder, columnsDefinition],
+ );
- return { sortedData, columnsWithSortOrder };
+ return { sortedData, columnsWithSortOrder };
};
+
+export const useDynamicTableFilterOptions = (data, key) =>
+ useMemo(() => {
+ const uniqueValues = new Set(data.map((item) => item[key]));
+ return Array.from(uniqueValues).map((value) => ({
+ text: value,
+ value: value,
+ }));
+ }, [data, key]);
diff --git a/src/components/explorer/searchResultsTables/BiosampleIDCell.js b/src/components/explorer/searchResultsTables/BiosampleIDCell.js
index 9c106be11..5f268a0c7 100644
--- a/src/components/explorer/searchResultsTables/BiosampleIDCell.js
+++ b/src/components/explorer/searchResultsTables/BiosampleIDCell.js
@@ -12,22 +12,22 @@ import { explorerIndividualUrl } from "../utils";
* @param {string} individualID (optional) individual ID to link to
*/
const BiosampleIDCell = React.memo(({ biosample, individualID }) => {
- const location = useLocation();
- const { individualID: contextIndividualID } = useContext(ExplorerIndividualContext);
- const usedIndividualID = individualID ?? contextIndividualID;
- return (
-
- {biosample}
-
- );
+ const location = useLocation();
+ const { individualID: contextIndividualID } = useContext(ExplorerIndividualContext);
+ const usedIndividualID = individualID ?? contextIndividualID;
+ return (
+
+ {biosample}
+
+ );
});
BiosampleIDCell.propTypes = {
- biosample: PropTypes.string.isRequired,
- individualID: PropTypes.string,
+ biosample: PropTypes.string.isRequired,
+ individualID: PropTypes.string,
};
export default BiosampleIDCell;
diff --git a/src/components/explorer/searchResultsTables/BiosamplesTable.js b/src/components/explorer/searchResultsTables/BiosamplesTable.js
index 8e9901e6d..cc6c5fd2f 100644
--- a/src/components/explorer/searchResultsTables/BiosamplesTable.js
+++ b/src/components/explorer/searchResultsTables/BiosamplesTable.js
@@ -15,166 +15,153 @@ import { ontologyTermSorter } from "../utils";
const NO_EXPERIMENTS_VALUE = -Infinity;
const customPluralForms = {
- Serology: "Serologies",
+ Serology: "Serologies",
};
const pluralize = (word, count) => {
- if (count <= 1) return word;
+ if (count <= 1) return word;
- if (customPluralForms[word]) {
- return customPluralForms[word];
- } else if (word.slice(-1) !== "s") {
- return word + "s";
- }
+ if (customPluralForms[word]) {
+ return customPluralForms[word];
+ } else if (word.slice(-1) !== "s") {
+ return word + "s";
+ }
- return word;
+ return word;
};
-const ExperimentsRender = ({studiesType}) => {
- const experimentCount = studiesType.reduce((acc, study) => {
- acc[study] = (acc[study] || 0) + 1;
- return acc;
- }, {});
- const formattedExperiments = Object.entries(experimentCount).map(
- ([study, count]) => `${count === studiesType.length ? "" : count + " "}${pluralize(study, count)}`,
- );
- return (
+const ExperimentsRender = ({ studiesType }) => {
+ const experimentCount = studiesType.reduce((acc, study) => {
+ acc[study] = (acc[study] || 0) + 1;
+ return acc;
+ }, {});
+ const formattedExperiments = Object.entries(experimentCount).map(
+ ([study, count]) => `${count === studiesType.length ? "" : count + " "}${pluralize(study, count)}`,
+ );
+ return (
+ <>
+ {studiesType.every((s) => s !== null) ? (
<>
- {studiesType.every((s) => s !== null) ? (
- <>
- {studiesType.length} Experiment{studiesType.length === 1 ? "" : "s"}:{" "}
- {formattedExperiments.join(", ")}
- >
- ) : (
- <>—>
- )}
+ {studiesType.length} Experiment{studiesType.length === 1 ? "" : "s"}: {formattedExperiments.join(", ")}
>
- );
+ ) : (
+ <>—>
+ )}
+ >
+ );
};
ExperimentsRender.propTypes = {
- studiesType: PropTypes.arrayOf(PropTypes.string).isRequired,
+ studiesType: PropTypes.arrayOf(PropTypes.string).isRequired,
};
const experimentsSorter = (a, b) => {
- return countNonNullElements(a.studyTypes) - countNonNullElements(b.studyTypes);
+ return countNonNullElements(a.studyTypes) - countNonNullElements(b.studyTypes);
};
const availableExperimentsRender = (experimentsType) => {
+ if (experimentsType.every((s) => s !== null)) {
+ const experimentCount = experimentsType.reduce((acc, experiment) => {
+ acc[experiment] = (acc[experiment] || 0) + 1;
+ return acc;
+ }, {});
+ const formattedExperiments = Object.entries(experimentCount).map(([experiment, count]) => `${count} ${experiment}`);
+ return formattedExperiments.join(", ");
+ } else {
+ return "—";
+ }
+};
+
+const availableExperimentsSorter = (a, b) => {
+ const highestValue = (experimentsType) => {
if (experimentsType.every((s) => s !== null)) {
- const experimentCount = experimentsType.reduce((acc, experiment) => {
- acc[experiment] = (acc[experiment] || 0) + 1;
- return acc;
- }, {});
- const formattedExperiments = Object.entries(experimentCount).map(
- ([experiment, count]) => `${count} ${experiment}`,
- );
- return formattedExperiments.join(", ");
+ const experimentCount = experimentsType.reduce((acc, experiment) => {
+ acc[experiment] = (acc[experiment] || 0) + 1;
+ return acc;
+ }, {});
+
+ const counts = Object.values(experimentCount);
+ return Math.max(...counts);
} else {
- return "—";
+ return NO_EXPERIMENTS_VALUE;
}
-};
+ };
+ const highA = highestValue(a.experimentTypes);
+ const highB = highestValue(b.experimentTypes);
-const availableExperimentsSorter = (a, b) => {
- const highestValue = (experimentsType) => {
- if (experimentsType.every((s) => s !== null)) {
- const experimentCount = experimentsType.reduce((acc, experiment) => {
- acc[experiment] = (acc[experiment] || 0) + 1;
- return acc;
- }, {});
-
- const counts = Object.values(experimentCount);
- return Math.max(...counts);
- } else {
- return NO_EXPERIMENTS_VALUE;
- }
- };
-
- const highA = highestValue(a.experimentTypes);
- const highB = highestValue(b.experimentTypes);
-
- return highB - highA;
+ return highB - highA;
};
const BIOSAMPLES_COLUMNS = [
- {
- title: "Biosample",
- dataIndex: "biosample",
- render: (biosample, { individual }) => (
-
- ),
- sorter: (a, b) => a.biosample.localeCompare(b.biosample),
- defaultSortOrder: "ascend",
- },
- {
- title: "Individual",
- dataIndex: "individual",
- render: (individual) => ,
- sorter: (a, b) => a.individual.id.localeCompare(b.individual.id),
- sortDirections: ["descend", "ascend", "descend"],
- },
- {
- title: "Experiments",
- dataIndex: "studyTypes",
- render: (studyTypes) => ,
- sorter: experimentsSorter,
- sortDirections: ["descend", "ascend", "descend"],
- },
- {
- title: "Sampled Tissue",
- dataIndex: "sampledTissue",
- // Can't pass individual here to OntologyTerm since it doesn't have a list of phenopackets
- render: (sampledTissue) => ,
- sorter: ontologyTermSorter("sampledTissue"),
- sortDirections: ["descend", "ascend", "descend"],
- },
- {
- title: "Available Experiments",
- dataIndex: "experimentTypes",
- render: availableExperimentsRender,
- sorter: availableExperimentsSorter,
- sortDirections: ["descend", "ascend", "descend"],
- },
+ {
+ title: "Biosample",
+ dataIndex: "biosample",
+ render: (biosample, { individual }) => ,
+ sorter: (a, b) => a.biosample.localeCompare(b.biosample),
+ defaultSortOrder: "ascend",
+ },
+ {
+ title: "Individual",
+ dataIndex: "individual",
+ render: (individual) => ,
+ sorter: (a, b) => a.individual.id.localeCompare(b.individual.id),
+ sortDirections: ["descend", "ascend", "descend"],
+ },
+ {
+ title: "Experiments",
+ dataIndex: "studyTypes",
+ render: (studyTypes) => ,
+ sorter: experimentsSorter,
+ sortDirections: ["descend", "ascend", "descend"],
+ },
+ {
+ title: "Sampled Tissue",
+ dataIndex: "sampledTissue",
+ // Can't pass individual here to OntologyTerm since it doesn't have a list of phenopackets
+ render: (sampledTissue) => ,
+ sorter: ontologyTermSorter("sampledTissue"),
+ sortDirections: ["descend", "ascend", "descend"],
+ },
+ {
+ title: "Available Experiments",
+ dataIndex: "experimentTypes",
+ render: availableExperimentsRender,
+ sorter: availableExperimentsSorter,
+ sortDirections: ["descend", "ascend", "descend"],
+ },
];
const BiosamplesTable = ({ data, datasetID }) => {
- const tableSortOrder = useSelector(
- (state) => state.explorer.tableSortOrderByDatasetID[datasetID]?.["biosamples"],
- );
-
- const { sortedData, columnsWithSortOrder } = useSortedColumns(
- data,
- tableSortOrder,
- BIOSAMPLES_COLUMNS,
- );
-
- return (
-
- );
+ const tableSortOrder = useSelector((state) => state.explorer.tableSortOrderByDatasetID[datasetID]?.["biosamples"]);
+
+ const { sortedData, columnsWithSortOrder } = useSortedColumns(data, tableSortOrder, BIOSAMPLES_COLUMNS);
+
+ return (
+
+ );
};
BiosamplesTable.propTypes = {
- data: PropTypes.arrayOf(
- PropTypes.shape({
- biosample: PropTypes.string.isRequired,
- individual: PropTypes.shape({
- id: PropTypes.string.isRequired,
- }).isRequired,
- studyTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
- sampledTissue: ontologyShape,
- experimentTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
- }),
- ).isRequired,
- datasetID: PropTypes.string.isRequired,
+ data: PropTypes.arrayOf(
+ PropTypes.shape({
+ biosample: PropTypes.string.isRequired,
+ individual: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
+ studyTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ sampledTissue: ontologyShape,
+ experimentTypes: PropTypes.arrayOf(PropTypes.string).isRequired,
+ }),
+ ).isRequired,
+ datasetID: PropTypes.string.isRequired,
};
-
export default BiosamplesTable;
diff --git a/src/components/explorer/searchResultsTables/ExperimentsTable.js b/src/components/explorer/searchResultsTables/ExperimentsTable.js
index 1b67b1abb..7c9e51a96 100644
--- a/src/components/explorer/searchResultsTables/ExperimentsTable.js
+++ b/src/components/explorer/searchResultsTables/ExperimentsTable.js
@@ -1,109 +1,111 @@
-import React from "react";
+import React, { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { useSelector } from "react-redux";
import PropTypes from "prop-types";
-import { useSortedColumns } from "../hooks/explorerHooks";
+import { useSortedColumns, useDynamicTableFilterOptions } from "../hooks/explorerHooks";
import { explorerIndividualUrl } from "../utils";
import BiosampleIDCell from "./BiosampleIDCell";
import ExplorerSearchResultsTable from "../ExplorerSearchResultsTable";
const ExperimentRender = React.memo(({ experimentId, individual }) => {
- const location = useLocation();
- return (
-
- {experimentId}
-
- );
+ const location = useLocation();
+ return (
+
+ {experimentId}
+
+ );
});
ExperimentRender.propTypes = {
- experimentId: PropTypes.string.isRequired,
- individual: PropTypes.shape({
- id: PropTypes.string.isRequired,
- }).isRequired,
+ experimentId: PropTypes.string.isRequired,
+ individual: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
};
-const SEARCH_RESULT_COLUMNS_EXP = [
- {
+const ExperimentsTable = ({ data, datasetID }) => {
+ const tableSortOrder = useSelector((state) => state.explorer.tableSortOrderByDatasetID[datasetID]?.["experiments"]);
+
+ const experimentTypeFilters = useDynamicTableFilterOptions(data, "experimentType");
+
+ const columns = useMemo(
+ () => [
+ {
title: "Experiment",
dataIndex: "experimentId",
render: (experimentId, record) => ,
sorter: (a, b) => a.experimentId.localeCompare(b.experimentId),
defaultSortOrder: "ascend",
- },
- {
+ },
+ {
title: "Individual",
dataIndex: "individual",
render: (individual) => <>{individual.id}>,
sorter: (a, b) => a.individual.id.localeCompare(b.individual.id),
sortDirections: ["descend", "ascend", "descend"],
- },
- {
+ },
+ {
title: "Biosample",
dataIndex: "biosampleId",
render: (biosampleId, record) => (
-
+
),
sorter: (a, b) => a.biosampleId.localeCompare(b.biosampleId),
sortDirections: ["descend", "ascend", "descend"],
- },
- {
+ },
+ {
title: "Study Type",
dataIndex: "studyType",
render: (studyType) => <>{studyType}>,
sorter: (a, b) => a.studyType.localeCompare(b.studyType),
sortDirections: ["descend", "ascend", "descend"],
- },
- {
+ },
+ {
title: "Experiment Type",
dataIndex: "experimentType",
render: (expType) => <>{expType}>,
sorter: (a, b) => a.experimentType.localeCompare(b.experimentType),
sortDirections: ["descend", "ascend", "descend"],
- },
-];
+ filters: experimentTypeFilters,
+ onFilter: (value, record) => record.experimentType === value,
+ },
+ ],
+ [experimentTypeFilters],
+ );
-const ExperimentsTable = ({ data, datasetID }) => {
- const tableSortOrder = useSelector(
- (state) => state.explorer.tableSortOrderByDatasetID[datasetID]?.["experiments"],
- );
+ const { sortedData, columnsWithSortOrder } = useSortedColumns(data, tableSortOrder, columns);
- const { sortedData, columnsWithSortOrder } = useSortedColumns(
- data,
- tableSortOrder,
- SEARCH_RESULT_COLUMNS_EXP,
- );
- return (
-
- );
+ return (
+
+ );
};
ExperimentsTable.propTypes = {
- data: PropTypes.arrayOf(
- PropTypes.shape({
- experimentId: PropTypes.string.isRequired,
- individual: PropTypes.shape({
- id: PropTypes.string.isRequired,
- }).isRequired,
- biosampleId: PropTypes.string.isRequired,
- studyType: PropTypes.string.isRequired,
- experimentType: PropTypes.string.isRequired,
- }),
- ).isRequired,
- datasetID: PropTypes.string.isRequired,
+ data: PropTypes.arrayOf(
+ PropTypes.shape({
+ experimentId: PropTypes.string.isRequired,
+ individual: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ }).isRequired,
+ biosampleId: PropTypes.string.isRequired,
+ studyType: PropTypes.string.isRequired,
+ experimentType: PropTypes.string.isRequired,
+ }),
+ ).isRequired,
+ datasetID: PropTypes.string.isRequired,
};
export default ExperimentsTable;
diff --git a/src/components/explorer/searchResultsTables/IndividualIDCell.js b/src/components/explorer/searchResultsTables/IndividualIDCell.js
index 8e6085434..f0a5d9b12 100644
--- a/src/components/explorer/searchResultsTables/IndividualIDCell.js
+++ b/src/components/explorer/searchResultsTables/IndividualIDCell.js
@@ -1,26 +1,23 @@
import React from "react";
-import {Link, useLocation} from "react-router-dom";
+import { Link, useLocation } from "react-router-dom";
import PropTypes from "prop-types";
import { explorerIndividualUrl } from "../utils";
-const IndividualIDCell = React.memo(({individual: {id, alternate_ids: alternateIds}}) => {
- const location = useLocation();
- const alternateIdsRender = alternateIds?.length ? " (" + alternateIds.join(", ") + ")" : "";
- return (
- <>
-
- {id}
- {" "}
- {alternateIdsRender}
- >
- );
+const IndividualIDCell = React.memo(({ individual: { id, alternate_ids: alternateIds } }) => {
+ const location = useLocation();
+ const alternateIdsRender = alternateIds?.length ? " (" + alternateIds.join(", ") + ")" : "";
+ return (
+ <>
+
+ {id}
+ {" "}
+ {alternateIdsRender}
+ >
+ );
});
IndividualIDCell.propTypes = {
- individual: PropTypes.object.isRequired,
+ individual: PropTypes.object.isRequired,
};
export default IndividualIDCell;
diff --git a/src/components/explorer/searchResultsTables/IndividualsTable.js b/src/components/explorer/searchResultsTables/IndividualsTable.js
index 477c71a50..b8b3de151 100644
--- a/src/components/explorer/searchResultsTables/IndividualsTable.js
+++ b/src/components/explorer/searchResultsTables/IndividualsTable.js
@@ -7,69 +7,65 @@ import BiosampleIDCell from "./BiosampleIDCell";
import IndividualIDCell from "./IndividualIDCell";
const SEARCH_RESULT_COLUMNS = [
- {
- title: "Individual",
- dataIndex: "individual",
- render: (individual) => ,
- sorter: (a, b) => a.individual.id.localeCompare(b.individual.id),
- defaultSortOrder: "ascend",
- },
- {
- title: "Samples",
- dataIndex: "biosamples",
- render: (samples, {individual: {id: individualID}}) => (
- <>
- {samples.length} Sample{samples.length === 1 ? "" : "s"}
- {samples.length ? ": " : ""}
- {samples.map((s, si) =>
-
- {si < samples.length - 1 ? ", " : ""}
- )}
- >
- ),
- sorter: (a, b) => a.biosamples.length - b.biosamples.length,
- sortDirections: ["descend", "ascend", "descend"],
- },
- {
- title: "Experiments",
- dataIndex: "experiments",
- render: (experiments) => (
- <>
- {experiments} Experiment{experiments === 1 ? "" : "s"}
- >
- ),
- sorter: (a, b) => a.experiments - b.experiments,
- sortDirections: ["descend", "ascend", "descend"],
- },
+ {
+ title: "Individual",
+ dataIndex: "individual",
+ render: (individual) => ,
+ sorter: (a, b) => a.individual.id.localeCompare(b.individual.id),
+ defaultSortOrder: "ascend",
+ },
+ {
+ title: "Samples",
+ dataIndex: "biosamples",
+ render: (samples, { individual: { id: individualID } }) => (
+ <>
+ {samples.length} Sample{samples.length === 1 ? "" : "s"}
+ {samples.length ? ": " : ""}
+ {samples.map((s, si) => (
+
+
+ {si < samples.length - 1 ? ", " : ""}
+
+ ))}
+ >
+ ),
+ sorter: (a, b) => a.biosamples.length - b.biosamples.length,
+ sortDirections: ["descend", "ascend", "descend"],
+ },
+ {
+ title: "Experiments",
+ dataIndex: "experiments",
+ render: (experiments) => (
+ <>
+ {experiments} Experiment{experiments === 1 ? "" : "s"}
+ >
+ ),
+ sorter: (a, b) => a.experiments - b.experiments,
+ sortDirections: ["descend", "ascend", "descend"],
+ },
];
const IndividualsTable = ({ data, datasetID }) => {
- const tableSortOrder = useSelector(
- (state) => state.explorer.tableSortOrderByDatasetID[datasetID]?.["individuals"],
- );
+ const tableSortOrder = useSelector((state) => state.explorer.tableSortOrderByDatasetID[datasetID]?.["individuals"]);
- const { sortedData, columnsWithSortOrder } = useSortedColumns(
- data,
- tableSortOrder,
- SEARCH_RESULT_COLUMNS,
- );
+ const { sortedData, columnsWithSortOrder } = useSortedColumns(data, tableSortOrder, SEARCH_RESULT_COLUMNS);
- return (
-
- );
+ return (
+
+ );
};
IndividualsTable.propTypes = {
- data: PropTypes.array.isRequired,
- datasetID: PropTypes.string.isRequired,
+ data: PropTypes.array.isRequired,
+ datasetID: PropTypes.string.isRequired,
};
export default IndividualsTable;
diff --git a/src/components/explorer/styles.js b/src/components/explorer/styles.js
index a2ecb2f09..f3de263c5 100644
--- a/src/components/explorer/styles.js
+++ b/src/components/explorer/styles.js
@@ -1,5 +1,5 @@
export const STYLE_FIX_NESTED_TABLE_MARGIN = {
- // compensate for bad inner nested table styling:
- marginLeft: "-44px",
- padding: "16px 0",
+ // compensate for bad inner nested table styling:
+ marginLeft: "-44px",
+ padding: "16px 0",
};
diff --git a/src/components/explorer/utils.js b/src/components/explorer/utils.js
index 08b65a0e3..4076e001e 100644
--- a/src/components/explorer/utils.js
+++ b/src/components/explorer/utils.js
@@ -4,31 +4,28 @@ import { fetchDatasetResourcesIfNecessary } from "@/modules/datasets/actions";
import { EM_DASH } from "@/constants";
export const useDeduplicatedIndividualBiosamples = (individual) =>
- useMemo(
- () => Object.values(
- Object.fromEntries(
- (individual?.phenopackets ?? [])
- .flatMap(p => p.biosamples)
- .map(b => [b.id, b]),
- ),
- ),
- [individual],
- );
+ useMemo(
+ () =>
+ Object.values(
+ Object.fromEntries((individual?.phenopackets ?? []).flatMap((p) => p.biosamples).map((b) => [b.id, b])),
+ ),
+ [individual],
+ );
export const useIndividualInterpretations = (individual, withDiagnosis = false) =>
- useMemo(
- () => Object.values(
- Object.fromEntries(
- (individual?.phenopackets ?? [])
- .flatMap(p => p.interpretations)
- .filter(i => withDiagnosis ? i.hasOwnProperty("diagnosis") : true)
- .filter(Boolean)
- .map(i => [i.id, i]),
- ),
+ useMemo(
+ () =>
+ Object.values(
+ Object.fromEntries(
+ (individual?.phenopackets ?? [])
+ .flatMap((p) => p.interpretations)
+ .filter((i) => (withDiagnosis ? i.hasOwnProperty("diagnosis") : true))
+ .filter(Boolean)
+ .map((i) => [i.id, i]),
),
- [individual],
- );
-
+ ),
+ [individual, withDiagnosis],
+ );
/**
* Hook to evaluate if the fieldName of an object/array contains data
@@ -37,18 +34,17 @@ export const useIndividualInterpretations = (individual, withDiagnosis = false)
* @returns A bool value, true if "fieldName" is empty
*/
export const useIsDataEmpty = (data, fieldName) => {
- return useMemo(() => {
- if (Array.isArray(data)) {
- // Flatmap the field if data is an array,
- // e.g: data is a list of biosamples, with fieldName="experiments"
- return data.flatMap(item => item[fieldName] ?? []).length === 0;
- }
-
- // Check data[fieldName] directly if data is an object
- return (data[fieldName] ?? []).length === 0;
- }, [data, fieldName]);
-};
+ return useMemo(() => {
+ if (Array.isArray(data)) {
+ // Flatmap the field if data is an array,
+ // e.g: data is a list of biosamples, with fieldName="experiments"
+ return data.flatMap((item) => item[fieldName] ?? []).length === 0;
+ }
+ // Check data[fieldName] directly if data is an object
+ return (data[fieldName] ?? []).length === 0;
+ }, [data, fieldName]);
+};
/**
* Returns the Interpretations that contain the call
@@ -57,111 +53,104 @@ export const useIsDataEmpty = (data, fieldName) => {
* @returns List of GenomicInterpretations filtered for variants or genes call
*/
const useGenomicInterpretationsWithCall = (interpretations, call) =>
- useMemo(
- () => Object.values(
- Object.fromEntries(
- interpretations
- .filter(interp => interp.hasOwnProperty("diagnosis"))
- .filter(interp => interp.diagnosis.hasOwnProperty("genomic_interpretations")
- && interp.diagnosis.genomic_interpretations.length)
- .flatMap(interp => interp.diagnosis.genomic_interpretations)
- .filter(gi => gi.hasOwnProperty(call))
- .map(gi => [gi.subject_or_biosample_id, gi]),
- ),
+ useMemo(
+ () =>
+ Object.values(
+ Object.fromEntries(
+ interpretations
+ .filter((interp) => interp.hasOwnProperty("diagnosis"))
+ .filter(
+ (interp) =>
+ interp.diagnosis.hasOwnProperty("genomic_interpretations") &&
+ interp.diagnosis.genomic_interpretations.length,
+ )
+ .flatMap((interp) => interp.diagnosis.genomic_interpretations)
+ .filter((gi) => gi.hasOwnProperty(call))
+ .map((gi) => [gi.subject_or_biosample_id, gi]),
),
- [interpretations, call],
- );
+ ),
+ [interpretations, call],
+ );
export const useIndividualVariantInterpretations = (individual) => {
- const interpretations = useIndividualInterpretations(individual);
- return useGenomicInterpretationsWithCall(interpretations, "variant_interpretation");
+ const interpretations = useIndividualInterpretations(individual);
+ return useGenomicInterpretationsWithCall(interpretations, "variant_interpretation");
};
export const useIndividualGeneDescriptors = (individual) => {
- const interpretations = useIndividualInterpretations(individual);
- return useGenomicInterpretationsWithCall(interpretations, "gene_descriptor");
+ const interpretations = useIndividualInterpretations(individual);
+ return useGenomicInterpretationsWithCall(interpretations, "gene_descriptor");
};
export const useDatasetResources = (datasetIDOrDatasetIDs) => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const datasetResources = useSelector((state) => state.datasetResources.itemsByID);
+ const datasetResources = useSelector((state) => state.datasetResources.itemsByID);
- const datasetIDs = useMemo(
- () => Array.isArray(datasetIDOrDatasetIDs) ? datasetIDOrDatasetIDs : [datasetIDOrDatasetIDs],
- [datasetIDOrDatasetIDs],
- );
+ const datasetIDs = useMemo(
+ () => (Array.isArray(datasetIDOrDatasetIDs) ? datasetIDOrDatasetIDs : [datasetIDOrDatasetIDs]),
+ [datasetIDOrDatasetIDs],
+ );
+
+ useEffect(() => {
+ datasetIDs.map((d) => dispatch(fetchDatasetResourcesIfNecessary(d)));
+ }, [dispatch, datasetIDs]);
- useEffect(() => {
- datasetIDs.map((d) => dispatch(fetchDatasetResourcesIfNecessary(d)));
- }, [dispatch, datasetIDs]);
-
- return useMemo(
- () => {
- const r = Object.values(
- Object.fromEntries(
- datasetIDs
- .flatMap(d => datasetResources[d]?.data ?? [])
- .map(r => [r.id, r]),
- ),
- );
- const fetching = datasetIDs.reduce((flag, d) => flag || datasetResources[d]?.isFetching, false);
- return [r, fetching];
- },
- [datasetResources, datasetIDs],
+ return useMemo(() => {
+ const r = Object.values(
+ Object.fromEntries(datasetIDs.flatMap((d) => datasetResources[d]?.data ?? []).map((r) => [r.id, r])),
);
+ const fetching = datasetIDs.reduce((flag, d) => flag || datasetResources[d]?.isFetching, false);
+ return [r, fetching];
+ }, [datasetResources, datasetIDs]);
};
export const useIndividualResources = (individual) => {
- // TODO: when individual belongs to a single dataset, use that instead
- const individualDatasets = useMemo(
- () => (individual?.phenopackets ?? []).map(p => p.dataset),
- [individual]);
+ // TODO: when individual belongs to a single dataset, use that instead
+ const individualDatasets = useMemo(() => (individual?.phenopackets ?? []).map((p) => p.dataset), [individual]);
- return useDatasetResources(individualDatasets);
+ return useDatasetResources(individualDatasets);
};
export const useResourcesByNamespacePrefix = ([resources, isFetching]) => {
- return useMemo(
- () => [
- Object.fromEntries(resources.map(r => [r.namespace_prefix, r])),
- isFetching,
- ],
- [resources, isFetching],
- );
+ return useMemo(
+ () => [Object.fromEntries(resources.map((r) => [r.namespace_prefix, r])), isFetching],
+ [resources, isFetching],
+ );
};
export const useIndividualPhenopacketDataIndex = (individual, fieldName) => {
- return useMemo(
- () => (individual?.phenopackets ?? [])
- .flatMap(p => p?.[fieldName] ?? [])
- .map((element, index) => ({ ...element, idx: `${index}` })),
- [individual],
- );
+ return useMemo(
+ () =>
+ (individual?.phenopackets ?? [])
+ .flatMap((p) => p?.[fieldName] ?? [])
+ .map((element, index) => ({ ...element, idx: `${index}` })),
+ [individual, fieldName],
+ );
};
export const ontologyTermSorter = (k) => (a, b) => {
- if (a[k]?.label && b[k]?.label) {
- return a[k].label.toString().localeCompare(b[k].label.toString());
- }
- return 0;
+ if (a[k]?.label && b[k]?.label) {
+ return a[k].label.toString().localeCompare(b[k].label.toString());
+ }
+ return 0;
};
export const booleanFieldSorter = (k) => (a, b) => {
- const aVal = a[k];
- const bVal = b[k];
- if (typeof aVal === "boolean" && typeof bVal === "boolean") {
- return aVal - bVal;
- }
- return 0;
+ const aVal = a[k];
+ const bVal = b[k];
+ if (typeof aVal === "boolean" && typeof bVal === "boolean") {
+ return aVal - bVal;
+ }
+ return 0;
};
export const renderBoolean = (k) => (_, record) => {
- const value = record[k];
- if (typeof value === "boolean") {
- return String(value);
- }
- return EM_DASH;
+ const value = record[k];
+ if (typeof value === "boolean") {
+ return String(value);
+ }
+ return EM_DASH;
};
export const explorerIndividualUrl = (individualID) => `/data/explorer/individuals/${individualID}`;
diff --git a/src/components/manager/ActionContainer.js b/src/components/manager/ActionContainer.js
index 859b679d5..1abbbda4a 100644
--- a/src/components/manager/ActionContainer.js
+++ b/src/components/manager/ActionContainer.js
@@ -2,24 +2,24 @@ import React from "react";
import PropTypes from "prop-types";
const style = {
- display: "flex",
- gap: "12px",
- alignItems: "baseline",
- position: "sticky",
- paddingBottom: 4,
- backgroundColor: "white",
- boxShadow: "0 10px 10px white, 0 -10px 0 white",
- top: 8,
- zIndex: 10,
+ display: "flex",
+ gap: "12px",
+ alignItems: "baseline",
+ position: "sticky",
+ paddingBottom: 4,
+ backgroundColor: "white",
+ boxShadow: "0 10px 10px white, 0 -10px 0 white",
+ top: 8,
+ zIndex: 10,
};
const ActionContainer = ({ children, ...props }) => (
-
- {children}
-
+
+ {children}
+
);
ActionContainer.propTypes = {
- children: PropTypes.node,
+ children: PropTypes.node,
};
export default ActionContainer;
diff --git a/src/components/manager/DatasetTitleDisplay.js b/src/components/manager/DatasetTitleDisplay.js
deleted file mode 100644
index 179cb5e8a..000000000
--- a/src/components/manager/DatasetTitleDisplay.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import React, { memo } from "react";
-import { useSelector } from "react-redux";
-import { Link } from "react-router-dom";
-import PropTypes from "prop-types";
-
-import { EM_DASH } from "@/constants";
-import MonospaceText from "@/components/common/MonospaceText";
-
-const DatasetTitleDisplay = memo(({ datasetID, link }) => {
- const datasetsByID = useSelector(state => state.projects.datasetsByID);
-
- if (!datasetID) return EM_DASH;
-
- const dataset = datasetsByID[datasetID];
-
- if (!dataset) return (
-
- {datasetID} {" "}
- (NOT AVAILABLE)
-
- );
-
- const { title, project } = dataset;
-
- if (!link) return title;
- return {title};
-});
-DatasetTitleDisplay.propTypes = {
- datasetID: PropTypes.string,
- link: PropTypes.bool,
-};
-
-export default DatasetTitleDisplay;
diff --git a/src/components/manager/DatasetTitleDisplay.tsx b/src/components/manager/DatasetTitleDisplay.tsx
new file mode 100644
index 000000000..1573745fe
--- /dev/null
+++ b/src/components/manager/DatasetTitleDisplay.tsx
@@ -0,0 +1,38 @@
+import React from "react";
+import { useSelector } from "react-redux";
+import { Link } from "react-router-dom";
+
+import { EM_DASH } from "@/constants";
+import ErrorText from "@/components/common/ErrorText";
+import MonospaceText from "@/components/common/MonospaceText";
+
+export type DatasetTitleDisplayProps = {
+ datasetID: string;
+ link: boolean;
+};
+
+const DatasetTitleDisplay = ({ datasetID, link }: DatasetTitleDisplayProps) => {
+ // @ts-expect-error We have not typed the state yet
+ const datasetsByID = useSelector((state) => state.projects.datasetsByID);
+
+ if (!datasetID) return EM_DASH;
+
+ const dataset = datasetsByID[datasetID];
+
+ if (!dataset)
+ return (
+
+ {datasetID} (NOT AVAILABLE)
+
+ );
+
+ const { title, project } = dataset;
+
+ if (!link) return title;
+ return {title};
+};
+DatasetTitleDisplay.defaultProps = {
+ link: false,
+};
+
+export default DatasetTitleDisplay;
diff --git a/src/components/manager/DatasetTreeSelect.js b/src/components/manager/DatasetTreeSelect.js
index 5b950bd80..b3eb4aa55 100644
--- a/src/components/manager/DatasetTreeSelect.js
+++ b/src/components/manager/DatasetTreeSelect.js
@@ -1,67 +1,76 @@
-import React, {forwardRef, useCallback, useEffect, useMemo, useState} from "react";
-import {useSelector} from "react-redux";
+import React, { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
+import { useSelector } from "react-redux";
import PropTypes from "prop-types";
-import {Spin, TreeSelect} from "antd";
+import { Spin, TreeSelect } from "antd";
import { useProjects } from "@/modules/metadata/hooks";
export const ID_FORMAT_PROJECT_DATASET = "project:dataset";
export const ID_FORMAT_DATASET = "dataset";
-const DatasetTreeSelect = forwardRef(({value, onChange, style, idFormat}, ref) => {
- const { items: projectItems, isFetching: projectsFetching } = useProjects();
- const servicesFetching = useSelector((state) => state.services.isFetchingAll);
+const DatasetTreeSelect = forwardRef(({ value, onChange, style, idFormat }, ref) => {
+ const { items: projectItems, isFetching: projectsFetching } = useProjects();
+ const servicesFetching = useSelector((state) => state.services.isFetchingAll);
- const [selected, setSelected] = useState(value ?? undefined);
+ const [selected, setSelected] = useState(value ?? undefined);
- useEffect(() => {
- setSelected(value);
- }, [value]);
+ useEffect(() => {
+ setSelected(value);
+ }, [value]);
- const onChangeInner = useCallback((newSelected) => {
- if (!value) setSelected(newSelected);
- if (onChange) {
- onChange(newSelected);
- }
- }, [value, onChange, selected]);
+ const onChangeInner = useCallback(
+ (newSelected) => {
+ if (!value) setSelected(newSelected);
+ if (onChange) {
+ onChange(newSelected);
+ }
+ },
+ [value, onChange],
+ );
- const selectTreeData = useMemo(() => projectItems.map(p => ({
+ const selectTreeData = useMemo(
+ () =>
+ projectItems.map((p) => ({
title: p.title,
selectable: false,
key: p.identifier,
value: p.identifier,
- children: p.datasets.map(d => {
- const key = idFormat === ID_FORMAT_PROJECT_DATASET ? `${p.identifier}:${d.identifier}` : d.identifier;
- return {
- title: d.title,
- key,
- value: key,
- };
+ children: p.datasets.map((d) => {
+ const key = idFormat === ID_FORMAT_PROJECT_DATASET ? `${p.identifier}:${d.identifier}` : d.identifier;
+ return {
+ title: d.title,
+ key,
+ value: key,
+ };
}),
- })), [idFormat, projectItems]);
+ })),
+ [idFormat, projectItems],
+ );
- return
-
- ;
+ return (
+
+
+
+ );
});
DatasetTreeSelect.defaultProps = {
- idFormat: ID_FORMAT_PROJECT_DATASET,
+ idFormat: ID_FORMAT_PROJECT_DATASET,
};
DatasetTreeSelect.propTypes = {
- style: PropTypes.object,
- value: PropTypes.string,
- onChange: PropTypes.func,
- idFormat: PropTypes.oneOf([ID_FORMAT_PROJECT_DATASET, ID_FORMAT_DATASET]),
+ style: PropTypes.object,
+ value: PropTypes.string,
+ onChange: PropTypes.func,
+ idFormat: PropTypes.oneOf([ID_FORMAT_PROJECT_DATASET, ID_FORMAT_DATASET]),
};
export default DatasetTreeSelect;
diff --git a/src/components/manager/DropBoxTreeSelect.js b/src/components/manager/DropBoxTreeSelect.js
index 9a0b001dc..905d4f22c 100644
--- a/src/components/manager/DropBoxTreeSelect.js
+++ b/src/components/manager/DropBoxTreeSelect.js
@@ -11,64 +11,68 @@ import { useDropBox } from "@/modules/manager/hooks";
const sortByName = (a, b) => a.name.localeCompare(b.name);
const generateFileTree = (directory, valid, folderMode, basePrefix) =>
- [...directory]
- .sort(sortByName)
- .filter(entry => !folderMode || entry.contents !== undefined) // Don't show files in folder mode
- .map(entry => {
- const { name, contents, relativePath } = entry;
- const isValid = valid(entry);
- const isFolder = contents !== undefined;
-
- let renderAsLeaf = !isFolder;
- if (folderMode && isFolder) {
- // See if we have at least one nested child... otherwise, render this as a leaf in folder mode.
- renderAsLeaf = contents.findIndex(c => c.contents !== undefined) === -1;
- }
-
- const k = (basePrefix ?? "") + relativePath;
-
- return {
- value: k,
- title: name,
- disabled: !isValid,
- isLeaf: renderAsLeaf,
- selectable: folderMode ? isFolder : !isFolder,
- ...(isFolder && {
- children: generateFileTree(contents, valid, folderMode, basePrefix),
- }),
- };
- });
+ [...directory]
+ .sort(sortByName)
+ .filter((entry) => !folderMode || entry.contents !== undefined) // Don't show files in folder mode
+ .map((entry) => {
+ const { name, contents, relativePath } = entry;
+ const isValid = valid(entry);
+ const isFolder = contents !== undefined;
+
+ let renderAsLeaf = !isFolder;
+ if (folderMode && isFolder) {
+ // See if we have at least one nested child... otherwise, render this as a leaf in folder mode.
+ renderAsLeaf = contents.findIndex((c) => c.contents !== undefined) === -1;
+ }
+
+ const k = (basePrefix ?? "") + relativePath;
+
+ return {
+ value: k,
+ title: name,
+ disabled: !isValid,
+ isLeaf: renderAsLeaf,
+ selectable: folderMode ? isFolder : !isFolder,
+ ...(isFolder && {
+ children: generateFileTree(contents, valid, folderMode, basePrefix),
+ }),
+ };
+ });
const DropBoxTreeSelect = React.forwardRef(({ folderMode, nodeEnabled, basePrefix, ...props }, ref) => {
- const { tree } = useDropBox();
-
- const fileTree = useMemo(
- () => generateFileTree(tree, nodeEnabled ?? getTrue, folderMode, basePrefix),
- [tree, nodeEnabled, folderMode, basePrefix],
- );
-
- return ;
+ const { tree } = useDropBox();
+
+ const fileTree = useMemo(
+ () => generateFileTree(tree, nodeEnabled ?? getTrue, folderMode, basePrefix),
+ [tree, nodeEnabled, folderMode, basePrefix],
+ );
+
+ return (
+
+ );
});
DropBoxTreeSelect.propTypes = {
- folderMode: PropTypes.bool,
- nodeEnabled: PropTypes.func,
- basePrefix: PropTypes.string,
+ folderMode: PropTypes.bool,
+ nodeEnabled: PropTypes.func,
+ basePrefix: PropTypes.string,
};
DropBoxTreeSelect.defaultProps = {
- folderMode: false,
+ folderMode: false,
};
export const DropBoxJsonSelect = ({ form, name, labels, initialValue, rules }) => {
diff --git a/src/components/manager/ManagerAnalysisContent.js b/src/components/manager/ManagerAnalysisContent.js
index 2bab4dc8c..a178be465 100644
--- a/src/components/manager/ManagerAnalysisContent.js
+++ b/src/components/manager/ManagerAnalysisContent.js
@@ -12,27 +12,27 @@ import RunSetupWizard from "./RunSetupWizard";
import RunSetupConfirmDisplay from "./RunSetupConfirmDisplay";
const ManagerAnalysisContent = () => {
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
-
- // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
- // least one workflow.
-
- if (hasAttemptedPermissions && !permissions.includes(analyzeData)) {
- return (
-
- );
- }
-
- return }
- onSubmit={({ selectedWorkflow, inputs }) => {
- dispatch(submitAnalysisWorkflowRun(selectedWorkflow, inputs, "/data/manager/runs", navigate));
- }}
- />;
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
+
+ // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
+ // least one workflow.
+
+ if (hasAttemptedPermissions && !permissions.includes(analyzeData)) {
+ return ;
+ }
+
+ return (
+ }
+ onSubmit={({ selectedWorkflow, inputs }) => {
+ dispatch(submitAnalysisWorkflowRun(selectedWorkflow, inputs, "/data/manager/runs", navigate));
+ }}
+ />
+ );
};
export default ManagerAnalysisContent;
diff --git a/src/components/manager/ManagerDropBoxContent.js b/src/components/manager/ManagerDropBoxContent.js
index e2f54b0b6..ffcd6f1d6 100644
--- a/src/components/manager/ManagerDropBoxContent.js
+++ b/src/components/manager/ManagerDropBoxContent.js
@@ -1,39 +1,35 @@
-import React, {useCallback, useEffect, useMemo, useState} from "react";
-import {useDispatch, useSelector} from "react-redux";
-import {
- RESOURCE_EVERYTHING,
- deleteDropBox,
- ingestDropBox,
- viewDropBox,
-} from "bento-auth-js";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { RESOURCE_EVERYTHING, deleteDropBox, ingestDropBox, viewDropBox } from "bento-auth-js";
import PropTypes from "prop-types";
import { filesize } from "filesize";
import {
- Alert,
- Button,
- Descriptions,
- Dropdown,
- Empty,
- Form,
- Layout,
- Modal,
- Spin,
- Statistic,
- Tree,
- Typography,
- Upload,
- message,
+ Alert,
+ Button,
+ Descriptions,
+ Dropdown,
+ Empty,
+ Form,
+ Input,
+ Layout,
+ Modal,
+ Spin,
+ Statistic,
+ Tree,
+ Typography,
+ Upload,
+ message,
} from "antd";
import {
- DeleteOutlined,
- FileTextOutlined,
- ImportOutlined,
- InfoCircleOutlined,
- PlusCircleOutlined,
- UploadOutlined,
+ DeleteOutlined,
+ FileTextOutlined,
+ ImportOutlined,
+ InfoCircleOutlined,
+ PlusCircleOutlined,
+ UploadOutlined,
} from "@ant-design/icons";
import { LAYOUT_CONTENT_STYLE } from "@/styles/layoutContent";
@@ -49,11 +45,11 @@ import { useStartIngestionFlow } from "./workflowCommon";
import { testFileAgainstPattern } from "@/utils/files";
import { getFalse } from "@/utils/misc";
import {
- beginDropBoxPuttingObjects,
- endDropBoxPuttingObjects,
- putDropBoxObject,
- deleteDropBoxObject,
- invalidateDropBoxTree,
+ beginDropBoxPuttingObjects,
+ endDropBoxPuttingObjects,
+ putDropBoxObject,
+ deleteDropBoxObject,
+ invalidateDropBoxTree,
} from "@/modules/manager/actions";
import { useDropBox } from "@/modules/manager/hooks";
@@ -67,550 +63,630 @@ const DROP_BOX_INFO_CONTAINER_STYLE = { display: "flex", gap: "2em", paddingTop:
const TREE_CONTAINER_STYLE = { minHeight: 72, overflowY: "auto" };
const TREE_DROP_ZONE_OVERLAY_STYLE = {
- position: "absolute",
- left: 0, top: 0, right: 0, bottom: 0,
- backgroundColor: "rgba(176,223,255,0.6)",
- border: "2px dashed rgb(145, 213, 255)",
- zIndex: 10,
- padding: 12,
-
- display: "flex",
- alignItems: "center",
- justifyContent: "center",
+ position: "absolute",
+ left: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: "rgba(176,223,255,0.6)",
+ border: "2px dashed rgb(145, 213, 255)",
+ zIndex: 10,
+ padding: 12,
+
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
};
-const TREE_DROP_ZONE_OVERLAY_ICON_STYLE = {fontSize: 48, color: "#1890ff"};
-
+const TREE_DROP_ZONE_OVERLAY_ICON_STYLE = { fontSize: 48, color: "#1890ff" };
const sortByName = (a, b) => a.name.localeCompare(b.name);
const generateFileTree = (directory) =>
- [...directory]
- .sort(sortByName)
- .map(({ name: title, contents, relativePath: key }) => ({
- title,
- key,
- ...(contents !== undefined ? { children: generateFileTree(contents) } : { isLeaf: true }),
- }));
+ [...directory].sort(sortByName).map(({ name: title, contents, relativePath: key }) => ({
+ title,
+ key,
+ ...(contents !== undefined ? { children: generateFileTree(contents) } : { isLeaf: true }),
+ }));
const generateURIsByRelPath = (entry, acc) => {
- if (Array.isArray(entry)) {
- entry.forEach(e => generateURIsByRelPath(e, acc));
- } else if (entry.uri) {
- acc[entry.relativePath] = entry.uri;
- } else if (entry.contents) {
- entry.contents.forEach(e => generateURIsByRelPath(e, acc));
- }
- return acc;
+ if (Array.isArray(entry)) {
+ entry.forEach((e) => generateURIsByRelPath(e, acc));
+ } else if (entry.uri) {
+ acc[entry.relativePath] = entry.uri;
+ } else if (entry.contents) {
+ entry.contents.forEach((e) => generateURIsByRelPath(e, acc));
+ }
+ return acc;
};
const recursivelyFlattenFileTree = (acc, contents) => {
- contents.forEach(c => {
- if (c.contents !== undefined) {
- recursivelyFlattenFileTree(acc, c.contents);
- } else {
- acc.push(c);
- }
- });
- return acc;
+ contents.forEach((c) => {
+ if (c.contents !== undefined) {
+ recursivelyFlattenFileTree(acc, c.contents);
+ } else {
+ acc.push(c);
+ }
+ });
+ return acc;
};
-const formatTimestamp = timestamp => (new Date(timestamp * 1000)).toLocaleString();
+const formatTimestamp = (timestamp) => new Date(timestamp * 1000).toLocaleString();
-const stopEvent = event => {
- event.preventDefault();
- event.stopPropagation();
+const stopEvent = (event) => {
+ event.preventDefault();
+ event.stopPropagation();
};
-
-const FileUploadForm = (({initialUploadFolder, initialUploadFiles, form}) => {
- const getFileListFromEvent = useCallback(e => Array.isArray(e) ? e : e && e.fileList, []);
-
- const initialValues = useMemo(
- () => ({
- ...(initialUploadFolder ? {parent: initialUploadFolder} : {}),
- ...(initialUploadFiles ? {
- files: initialUploadFiles.map((u, i) => ({
- // ...u doesn't work for File object
- lastModified: u.lastModified,
- name: u.name,
- size: u.size,
- type: u.type,
-
- uid: (-1 * (i + 1)).toString(),
- originFileObj: u,
- })),
- } : {}),
- }),
- [initialUploadFolder, initialUploadFiles],
- );
-
- return
-
-
-
- }>Upload
-
- ;
-});
+const FileUploadForm = ({ initialUploadFolder, initialUploadFiles, form }) => {
+ const getFileListFromEvent = useCallback((e) => (Array.isArray(e) ? e : e && e.fileList), []);
+
+ const initialValues = useMemo(
+ () => ({
+ ...(initialUploadFolder ? { parent: initialUploadFolder } : {}),
+ ...(initialUploadFiles
+ ? {
+ files: initialUploadFiles.map((u, i) => ({
+ // ...u doesn't work for File object
+ lastModified: u.lastModified,
+ name: u.name,
+ size: u.size,
+ type: u.type,
+
+ uid: (-1 * (i + 1)).toString(),
+ originFileObj: u,
+ })),
+ }
+ : {}),
+ }),
+ [initialUploadFolder, initialUploadFiles],
+ );
+
+ return (
+
+
+
+
+
+ }>Upload
+
+
+
+ );
+};
FileUploadForm.propTypes = {
- initialUploadFolder: PropTypes.string,
- initialUploadFiles: PropTypes.arrayOf(PropTypes.object),
- form: PropTypes.object,
+ initialUploadFolder: PropTypes.string,
+ initialUploadFiles: PropTypes.arrayOf(PropTypes.object),
+ form: PropTypes.object,
};
-const FileUploadModal = ({initialUploadFolder, initialUploadFiles, onCancel, open}) => {
- const dispatch = useDispatch();
- const [form] = Form.useForm();
+const FileUploadModal = ({ initialUploadFolder, initialUploadFiles, onCancel, open }) => {
+ const dispatch = useDispatch();
+ const [form] = Form.useForm();
- const isPutting = useSelector(state => state.dropBox.isPuttingFlow);
+ const isPutting = useSelector((state) => state.dropBox.isPuttingFlow);
- useEffect(() => {
- if (open) {
- // If we just re-opened the model, reset the fields
- form.resetFields();
- }
- }, [open, form]);
+ useEffect(() => {
+ if (open) {
+ // If we just re-opened the model, reset the fields
+ form.resetFields();
+ }
+ }, [open, form]);
- const onOk = useCallback(() => {
- if (!form) {
- console.error("missing form");
- return;
- }
+ const onOk = useCallback(() => {
+ if (!form) {
+ console.error("missing form");
+ return;
+ }
- form.validateFields().then((values) => {
- (async () => {
- dispatch(beginDropBoxPuttingObjects());
-
- for (const file of values.files) {
- if (!file.name) {
- console.error("Cannot upload file with no name", file);
- continue;
- }
-
- const path = `${values.parent.replace(/\/$/, "")}/${file.name}`;
-
- try {
- await dispatch(putDropBoxObject(path, file.originFileObj));
- } catch (e) {
- console.error(e);
- message.error(`Error uploading file to drop box path: ${path}`);
- }
- }
-
- // Trigger a reload of the file tree with the newly-uploaded file(s)
- dispatch(invalidateDropBoxTree());
-
- // Finish the object-putting flow
- dispatch(endDropBoxPuttingObjects());
-
- // Close ourselves (the upload modal)
- onCancel();
- })();
- }).catch((err) => {
- console.error(err);
- });
- }, [form]);
-
- return
-
- ;
-};
-FileUploadModal.propTypes = {
- initialUploadFolder: PropTypes.string,
- initialUploadFiles: PropTypes.arrayOf(PropTypes.instanceOf(File)),
- onCancel: PropTypes.func,
- open: PropTypes.bool,
-};
+ form
+ .validateFields()
+ .then((values) => {
+ (async () => {
+ dispatch(beginDropBoxPuttingObjects());
+ for (const file of values.files) {
+ if (!file.name) {
+ console.error("Cannot upload file with no name", file);
+ continue;
+ }
-const FileContentsModal = ({selectedFilePath, open, onCancel}) => {
- const { tree, isFetching: treeLoading } = useDropBox();
+ const path = `${values.parent.replace(/\/$/, "")}/${file.name}`;
- const urisByFilePath = useMemo(() => generateURIsByRelPath(tree, {}), [tree]);
- const uri = useMemo(() => urisByFilePath[selectedFilePath], [urisByFilePath, selectedFilePath]);
+ try {
+ await dispatch(putDropBoxObject(path, file.originFileObj));
+ } catch (e) {
+ console.error(e);
+ message.error(`Error uploading file to drop box path: ${path}`);
+ }
+ }
- // destroyOnClose in order to stop audio/video from playing & avoid memory leaks at the cost of re-fetching
- return (
-
- );
+ // Trigger a reload of the file tree with the newly-uploaded file(s)
+ dispatch(invalidateDropBoxTree());
+
+ // Finish the object-putting flow
+ dispatch(endDropBoxPuttingObjects());
+
+ // Close ourselves (the upload modal)
+ if (onCancel) onCancel();
+ })();
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ }, [dispatch, form, onCancel]);
+
+ return (
+
+
+
+ );
};
-FileContentsModal.propTypes = {
- selectedFilePath: PropTypes.string,
- open: PropTypes.bool,
- onCancel: PropTypes.func,
+FileUploadModal.propTypes = {
+ initialUploadFolder: PropTypes.string,
+ initialUploadFiles: PropTypes.arrayOf(PropTypes.instanceOf(File)),
+ onCancel: PropTypes.func,
+ open: PropTypes.bool,
};
+const FileContentsModal = ({ selectedFilePath, open, onCancel }) => {
+ const { tree, isFetching: treeLoading } = useDropBox();
+
+ const urisByFilePath = useMemo(() => generateURIsByRelPath(tree, {}), [tree]);
+ const uri = useMemo(() => urisByFilePath[selectedFilePath], [urisByFilePath, selectedFilePath]);
+
+ // destroyOnClose in order to stop audio/video from playing & avoid memory leaks at the cost of re-fetching
+ return (
+
+ );
+};
+FileContentsModal.propTypes = {
+ selectedFilePath: PropTypes.string,
+ open: PropTypes.bool,
+ onCancel: PropTypes.func,
+};
const DropBoxInformation = ({ style }) => (
-
+ `}
+ style={style}
+ />
);
DropBoxInformation.propTypes = {
- style: PropTypes.object,
+ style: PropTypes.object,
};
const DROP_BOX_ROOT_KEY = "/";
+const filterTree = (nodes, searchTerm) => {
+ return nodes.reduce((acc, node) => {
+ const matchesSearch = node.title.toLowerCase().includes(searchTerm);
+ const filteredChildren = node.children ? filterTree(node.children, searchTerm) : [];
+ const hasMatchingChildren = filteredChildren.length > 0;
+
+ if (matchesSearch || hasMatchingChildren) {
+ acc.push({
+ ...node,
+ children: filteredChildren,
+ });
+ }
-const ManagerDropBoxContent = () => {
- const dispatch = useDispatch();
-
- const {
- permissions,
- isFetchingPermissions,
- hasAttemptedPermissions,
- } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
-
- const dropBoxService = useService("drop-box");
- const { tree, isFetching: treeLoading, isDeleting } = useDropBox();
-
- const { workflowsByType } = useWorkflows();
- const ingestionWorkflows = workflowsByType.ingestion.items;
- const ingestionWorkflowsByID = workflowsByType.ingestion.itemsByID;
-
- const filesByPath = useMemo(() => Object.fromEntries(
- recursivelyFlattenFileTree([], tree).map(f => [f.relativePath, f])), [tree]);
-
- const treeData = useMemo(() => [
- {
- title: "Drop Box",
- key: DROP_BOX_ROOT_KEY,
- children: generateFileTree(tree, ""),
- },
- ], [tree]);
-
- // Start with drop box root selected at first
- // - Will enable the upload button so that users can quickly upload from initial page load
- const [selectedEntries, setSelectedEntries] = useState([DROP_BOX_ROOT_KEY]);
- const firstSelectedEntry = useMemo(() => selectedEntries[0], [selectedEntries]);
-
- const [draggingOver, setDraggingOver] = useState(false);
-
- const [initialUploadFolder, setInitialUploadFolder] = useState(null);
- const [initialUploadFiles, setInitialUploadFiles] = useState([]);
- const [uploadModal, setUploadModal] = useState(false);
-
- const [fileInfoModal, setFileInfoModal] = useState(false);
- const [fileContentsModal, setFileContentsModal] = useState(false);
-
- const [fileDeleteModal, setFileDeleteModal] = useState(false);
- const [fileDeleteModalTitle, setFileDeleteModalTitle] = useState(""); // cache to allow close animation
-
- const showUploadModal = useCallback(() => setUploadModal(true), []);
- const hideUploadModal = useCallback(() => setUploadModal(false), []);
- const showFileInfoModal = useCallback(() => setFileInfoModal(true), []);
- const hideFileInfoModal = useCallback(() => setFileInfoModal(false), []);
- const showFileContentsModal = useCallback(() => setFileContentsModal(true), []);
- const hideFileContentsModal = useCallback(() => setFileContentsModal(false), []);
-
- const getWorkflowFit = useCallback(w => {
- let workflowSupported = true;
- let entriesLeft = [...selectedEntries];
-
- const inputs = {};
-
- for (const i of w.inputs) {
- const isArray = i.type.endsWith("[]");
- const isFileType = i.type.startsWith("file");
- const isDirType = i.type.startsWith("directory");
-
- if (!isFileType && !isDirType) {
- continue; // Nothing for us to do with non-file/directory inputs
- }
-
- // Find compatible entries which match the specified pattern if one is given.
- const compatEntries = entriesLeft
- .filter(e => (isFileType ? !e.contents : e.contents !== undefined)
- && testFileAgainstPattern(e, i.pattern));
- if (compatEntries.length === 0) {
- workflowSupported = false;
- break;
- }
+ return acc;
+ }, []);
+};
- // Steal the first compatible entry, or all if it's an array
- const entriesToTake = entriesLeft.filter(e => isArray ? compatEntries.includes(e) : e === compatEntries[0]);
- inputs[i.id] = (isArray
- ? entriesToTake.map((e) => BENTO_DROP_BOX_FS_BASE_PATH + e)
- : BENTO_DROP_BOX_FS_BASE_PATH + entriesToTake[0]);
- entriesLeft = entriesLeft.filter(f => !entriesToTake.includes(f));
+const ManagerDropBoxContent = () => {
+ const dispatch = useDispatch();
+
+ const { permissions, isFetchingPermissions, hasAttemptedPermissions } =
+ useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
+
+ const dropBoxService = useService("drop-box");
+ const { tree, isFetching: treeLoading, isDeleting } = useDropBox();
+
+ const { workflowsByType } = useWorkflows();
+ const ingestionWorkflows = workflowsByType.ingestion.items;
+ const ingestionWorkflowsByID = workflowsByType.ingestion.itemsByID;
+
+ const [searchTerm, setSearchTerm] = useState("");
+
+ const handleSearchChange = useCallback((event) => {
+ const newSearchTerm = event.target.value.toLowerCase();
+ setSearchTerm(newSearchTerm);
+ setSelectedEntries(newSearchTerm === "" ? [DROP_BOX_ROOT_KEY] : []);
+ }, []);
+
+ const filesByPath = useMemo(
+ () => Object.fromEntries(recursivelyFlattenFileTree([], tree).map((f) => [f.relativePath, f])),
+ [tree],
+ );
+
+ const treeData = useMemo(() => {
+ const unfilteredTree = generateFileTree(tree);
+ return [
+ {
+ title: "Drop Box",
+ key: DROP_BOX_ROOT_KEY,
+ children: filterTree(unfilteredTree, searchTerm),
+ },
+ ];
+ }, [tree, searchTerm]);
+
+ // Start with drop box root selected at first
+ // - Will enable the upload button so that users can quickly upload from initial page load
+ const [selectedEntries, setSelectedEntries] = useState([DROP_BOX_ROOT_KEY]);
+ const firstSelectedEntry = useMemo(() => selectedEntries[0], [selectedEntries]);
+
+ const [draggingOver, setDraggingOver] = useState(false);
+
+ const [initialUploadFolder, setInitialUploadFolder] = useState(null);
+ const [initialUploadFiles, setInitialUploadFiles] = useState([]);
+ const [uploadModal, setUploadModal] = useState(false);
+
+ const [fileInfoModal, setFileInfoModal] = useState(false);
+ const [fileContentsModal, setFileContentsModal] = useState(false);
+
+ const [fileDeleteModal, setFileDeleteModal] = useState(false);
+ const [fileDeleteModalTitle, setFileDeleteModalTitle] = useState(""); // cache to allow close animation
+
+ const showUploadModal = useCallback(() => setUploadModal(true), []);
+ const hideUploadModal = useCallback(() => setUploadModal(false), []);
+ const showFileInfoModal = useCallback(() => setFileInfoModal(true), []);
+ const hideFileInfoModal = useCallback(() => setFileInfoModal(false), []);
+ const showFileContentsModal = useCallback(() => setFileContentsModal(true), []);
+ const hideFileContentsModal = useCallback(() => setFileContentsModal(false), []);
+
+ const getWorkflowFit = useCallback(
+ (w) => {
+ let workflowSupported = true;
+ let entriesLeft = [...selectedEntries];
+
+ const inputs = {};
+
+ for (const i of w.inputs) {
+ const isArray = i.type.endsWith("[]");
+ const isFileType = i.type.startsWith("file");
+ const isDirType = i.type.startsWith("directory");
+
+ if (!isFileType && !isDirType) {
+ continue; // Nothing for us to do with non-file/directory inputs
}
- if (entriesLeft.length > 0) {
- // If there are unclaimed files remaining at the end, the workflow is not compatible with the
- // total selection of files.
- workflowSupported = false;
+ // Find compatible entries which match the specified pattern if one is given.
+ const compatEntries = entriesLeft.filter(
+ (e) => (isFileType ? !e.contents : e.contents !== undefined) && testFileAgainstPattern(e, i.pattern),
+ );
+ if (compatEntries.length === 0) {
+ workflowSupported = false;
+ break;
}
- return [workflowSupported, inputs];
- }, [selectedEntries]);
-
- const startIngestionFlow = useStartIngestionFlow();
-
- const handleViewFile = useCallback(() => {
- showFileContentsModal();
- }, []);
-
- const workflowsSupported = useMemo(
- () => Object.fromEntries(ingestionWorkflows.map(w => [w.id, getWorkflowFit(w)])),
- [ingestionWorkflows, getWorkflowFit]);
-
- const workflowMenuItemClick = useCallback(
- (i) => startIngestionFlow(ingestionWorkflowsByID[i.key], workflowsSupported[i.key][1]),
- [ingestionWorkflowsByID, startIngestionFlow, workflowsSupported]);
-
- const workflowMenu = useMemo(() => ({
- onClick: workflowMenuItemClick,
- items: ingestionWorkflows.map((w) => ({
- key: w.id,
- disabled: !workflowsSupported[w.id][0],
- label: <>Ingest with Workflow “{w.name}”>,
- })),
- }), [workflowMenuItemClick, ingestionWorkflows, workflowsSupported]);
-
- const handleIngest = useCallback(() => {
- const wfs = Object.entries(workflowsSupported).filter(([_, ws]) => ws[0]);
- if (wfs.length !== 1) return;
- const [wfID, wfSupportedTuple] = wfs[0];
- startIngestionFlow(ingestionWorkflowsByID[wfID], wfSupportedTuple[1]);
- }, [ingestionWorkflowsByID, workflowsSupported, startIngestionFlow]);
-
- const hasViewPermission = permissions.includes(viewDropBox);
- const hasUploadPermission = permissions.includes(ingestDropBox);
- const hasDeletePermission = permissions.includes(deleteDropBox);
-
- const handleContainerDragLeave = useCallback(() => setDraggingOver(false), []);
- const handleDragEnter = useCallback(() => setDraggingOver(true), []);
- const handleDragLeave = useCallback((e) => {
- // Drag end is a bit weird - it's fired when the drag leaves any CHILD element (or the element itself).
- // So we set a parent event on the layout, and stop propagation here - that way the parent's dragLeave
- // only fires if we leave the drop zone.
- stopEvent(e);
- }, []);
- const handleDrop = useCallback(event => {
- stopEvent(event);
- if (!hasUploadPermission) return;
-
- setDraggingOver(false);
-
- const items = event.dataTransfer?.items ?? [];
-
- for (const dti of items) {
- // If we have the webkitGetAsEntry() or getAsEntry() function, we can validate
- // if the dropped item is a folder and show a nice error.
-
- if ((typeof dti?.webkitGetAsEntry) === "function") {
- const entry = dti.webkitGetAsEntry();
- if (!entry) {
- return; // Not a file at all, some random element from the page maybe - exit silently
- }
- if (entry.isDirectory) {
- message.error("Uploading a directory is not supported!");
- return;
- }
- } else if (typeof dti?.getAsEntry === "function") {
- // noinspection JSUnresolvedReference
- if (dti?.getAsEntry().isDirectory) {
- message.error("Uploading a directory is not supported!");
- return;
- }
- }
+ // Steal the first compatible entry, or all if it's an array
+ const entriesToTake = entriesLeft.filter((e) => (isArray ? compatEntries.includes(e) : e === compatEntries[0]));
+ inputs[i.id] = isArray
+ ? entriesToTake.map((e) => BENTO_DROP_BOX_FS_BASE_PATH + e)
+ : BENTO_DROP_BOX_FS_BASE_PATH + entriesToTake[0];
+ entriesLeft = entriesLeft.filter((f) => !entriesToTake.includes(f));
+ }
+
+ if (entriesLeft.length > 0) {
+ // If there are unclaimed files remaining at the end, the workflow is not compatible with the
+ // total selection of files.
+ workflowSupported = false;
+ }
+
+ return [workflowSupported, inputs];
+ },
+ [selectedEntries],
+ );
+
+ const startIngestionFlow = useStartIngestionFlow();
+
+ const handleViewFile = useCallback(() => {
+ showFileContentsModal();
+ }, [showFileContentsModal]);
+
+ const workflowsSupported = useMemo(
+ () => Object.fromEntries(ingestionWorkflows.map((w) => [w.id, getWorkflowFit(w)])),
+ [ingestionWorkflows, getWorkflowFit],
+ );
+
+ const workflowMenuItemClick = useCallback(
+ (i) => startIngestionFlow(ingestionWorkflowsByID[i.key], workflowsSupported[i.key][1]),
+ [ingestionWorkflowsByID, startIngestionFlow, workflowsSupported],
+ );
+
+ const workflowMenu = useMemo(
+ () => ({
+ onClick: workflowMenuItemClick,
+ items: ingestionWorkflows.map((w) => ({
+ key: w.id,
+ disabled: !workflowsSupported[w.id][0],
+ label: <>Ingest with Workflow “{w.name}”>,
+ })),
+ }),
+ [workflowMenuItemClick, ingestionWorkflows, workflowsSupported],
+ );
+
+ const handleIngest = useCallback(() => {
+ const wfs = Object.entries(workflowsSupported).filter(([_, ws]) => ws[0]);
+ if (wfs.length !== 1) return;
+ const [wfID, wfSupportedTuple] = wfs[0];
+ startIngestionFlow(ingestionWorkflowsByID[wfID], wfSupportedTuple[1]);
+ }, [ingestionWorkflowsByID, workflowsSupported, startIngestionFlow]);
+
+ const hasViewPermission = permissions.includes(viewDropBox);
+ const hasUploadPermission = permissions.includes(ingestDropBox);
+ const hasDeletePermission = permissions.includes(deleteDropBox);
+
+ const handleContainerDragLeave = useCallback(() => setDraggingOver(false), []);
+ const handleDragEnter = useCallback(() => setDraggingOver(true), []);
+ const handleDragLeave = useCallback((e) => {
+ // Drag end is a bit weird - it's fired when the drag leaves any CHILD element (or the element itself).
+ // So we set a parent event on the layout, and stop propagation here - that way the parent's dragLeave
+ // only fires if we leave the drop zone.
+ stopEvent(e);
+ }, []);
+ const handleDrop = useCallback(
+ (event) => {
+ stopEvent(event);
+ if (!hasUploadPermission) return;
+
+ setDraggingOver(false);
+
+ const items = event.dataTransfer?.items ?? [];
+
+ for (const dti of items) {
+ // If we have the webkitGetAsEntry() or getAsEntry() function, we can validate
+ // if the dropped item is a folder and show a nice error.
+
+ if (typeof dti?.webkitGetAsEntry === "function") {
+ const entry = dti.webkitGetAsEntry();
+ if (!entry) {
+ return; // Not a file at all, some random element from the page maybe - exit silently
+ }
+ if (entry.isDirectory) {
+ message.error("Uploading a directory is not supported!");
+ return;
+ }
+ } else if (typeof dti?.getAsEntry === "function") {
+ // noinspection JSUnresolvedReference
+ if (dti?.getAsEntry().isDirectory) {
+ message.error("Uploading a directory is not supported!");
+ return;
+ }
}
+ }
+
+ setInitialUploadFolder(DROP_BOX_ROOT_KEY); // Root by default
+ setInitialUploadFiles(Array.from(event.dataTransfer.files));
+ showUploadModal();
+ },
+ [showUploadModal, hasUploadPermission],
+ );
+
+ const selectedFolder = selectedEntries.length === 1 && filesByPath[firstSelectedEntry] === undefined;
+
+ const hideFileDeleteModal = useCallback(() => setFileDeleteModal(false), []);
+ const showFileDeleteModal = useCallback(() => {
+ if (selectedEntries.length !== 1 || selectedFolder) return;
+ // Only set this on open - don't clear it on close, so we don't get a strange effect on modal close where the
+ // title disappears before the modal.
+ setFileDeleteModalTitle(`Are you sure you want to delete '${(firstSelectedEntry ?? "").split("/").at(-1)}'?`);
+ setFileDeleteModal(true);
+ }, [selectedEntries, selectedFolder, firstSelectedEntry]);
+ const handleDelete = useCallback(() => {
+ if (selectedEntries.length !== 1 || selectedFolder) return;
+ (async () => {
+ await dispatch(deleteDropBoxObject(firstSelectedEntry));
+ hideFileDeleteModal();
+ setSelectedEntries([DROP_BOX_ROOT_KEY]);
+ })();
+ }, [dispatch, selectedEntries, selectedFolder, firstSelectedEntry, hideFileDeleteModal]);
+
+ const selectedFileViewable =
+ selectedEntries.length === 1 &&
+ !selectedFolder &&
+ VIEWABLE_FILE_EXTENSIONS.filter((e) => firstSelectedEntry.toLowerCase().endsWith(e)).length > 0;
+
+ const selectedFileInfoAvailable = selectedEntries.length === 1 && firstSelectedEntry in filesByPath;
+ const fileForInfo = selectedFileInfoAvailable ? firstSelectedEntry : "";
+
+ const uploadDisabled = !selectedFolder || !hasUploadPermission;
+ // TODO: at least one ingest:data on all datasets vvv
+ const ingestIntoDatasetDisabled =
+ !dropBoxService ||
+ selectedEntries.length === 0 ||
+ Object.values(workflowsSupported).filter((w) => w[0]).length === 0;
+
+ const handleUpload = useCallback(() => {
+ if (!hasUploadPermission) return;
+ if (selectedFolder) setInitialUploadFolder(selectedEntries[0]);
+ showUploadModal();
+ }, [hasUploadPermission, selectedFolder, selectedEntries, showUploadModal]);
+
+ const deleteDisabled = !dropBoxService || selectedFolder || selectedEntries.length !== 1 || !hasDeletePermission;
+
+ if (hasAttemptedPermissions && !hasViewPermission) {
+ return ;
+ }
+
+ return (
+
+
+ {/* ----------------------------- Start of modals section ----------------------------- */}
+
+
- setInitialUploadFolder(DROP_BOX_ROOT_KEY); // Root by default
- setInitialUploadFiles(Array.from(event.dataTransfer.files));
- showUploadModal();
- }, [showUploadModal, hasUploadPermission]);
-
- const selectedFolder = selectedEntries.length === 1 && filesByPath[firstSelectedEntry] === undefined;
-
- const hideFileDeleteModal = useCallback(() => setFileDeleteModal(false), []);
- const showFileDeleteModal = useCallback(() => {
- if (selectedEntries.length !== 1 || selectedFolder) return;
- // Only set this on open - don't clear it on close, so we don't get a strange effect on modal close where the
- // title disappears before the modal.
- setFileDeleteModalTitle(
- `Are you sure you want to delete '${(firstSelectedEntry ?? "").split("/").at(-1)}'?`);
- setFileDeleteModal(true);
- }, [selectedEntries, selectedFolder]);
- const handleDelete = useCallback(() => {
- if (selectedEntries.length !== 1 || selectedFolder) return;
- (async () => {
- await dispatch(deleteDropBoxObject(firstSelectedEntry));
- hideFileDeleteModal();
- setSelectedEntries([DROP_BOX_ROOT_KEY]);
- })();
- }, [dispatch, selectedEntries]);
-
- const selectedFileViewable = selectedEntries.length === 1 && !selectedFolder &&
- VIEWABLE_FILE_EXTENSIONS.filter(e => firstSelectedEntry.toLowerCase().endsWith(e)).length > 0;
-
- const selectedFileInfoAvailable = selectedEntries.length === 1 && firstSelectedEntry in filesByPath;
- const fileForInfo = selectedFileInfoAvailable ? firstSelectedEntry : "";
-
- const uploadDisabled = !selectedFolder || !hasUploadPermission;
- // TODO: at least one ingest:data on all datasets vvv
- const ingestIntoDatasetDisabled = !dropBoxService ||
- selectedEntries.length === 0 || Object.values(workflowsSupported).filter((w) => w[0]).length === 0;
-
- const handleUpload = useCallback(() => {
- if (!hasUploadPermission) return;
- if (selectedFolder) setInitialUploadFolder(selectedEntries[0]);
- showUploadModal();
- }, [hasUploadPermission, selectedFolder, selectedEntries]);
-
- const deleteDisabled = !dropBoxService || selectedFolder || selectedEntries.length !== 1 || !hasDeletePermission;
-
- if (hasAttemptedPermissions && !hasViewPermission) {
- return (
-
- );
- }
-
- return
-
- {/* ----------------------------- Start of modals section ----------------------------- */}
+
- ]}
+ onCancel={hideFileInfoModal}
+ >
+
+
+ {fileForInfo.split("/").at(-1)}
+
+
+ {fileForInfo}
+
+
+ {filesize(filesByPath[fileForInfo]?.size ?? 0)}
+
+
+ {formatTimestamp(filesByPath[fileForInfo]?.lastModified ?? 0)}
+
+
+ {formatTimestamp(filesByPath[fileForInfo]?.lastMetadataChange ?? 0)}
+
+
+
+
+
+ Doing so will permanently and irrevocably remove this file from the drop box. It will then be unavailable for
+ any ingestion or analysis.
+
+
+ {/* ------------------------------ End of modals section ------------------------------ */}
+
+
+
+ } onClick={handleUpload} disabled={uploadDisabled}>
+ Upload
+
+
+
-
+ Ingest
+
+
+
+ } onClick={showFileInfoModal} disabled={!selectedFileInfoAvailable}>
+ File Info
+
+ } onClick={handleViewFile} disabled={!selectedFileViewable}>
+ View
+
+
+
+
+ }
+ disabled={deleteDisabled}
+ loading={isDeleting}
+ onClick={showFileDeleteModal}
+ >
+ Delete
+
+
+
+ {selectedEntries.length} item{selectedEntries.length === 1 ? "" : "s"} selected
+
+
+
+
+ {isFetchingPermissions || treeLoading || dropBoxService ? (
+
+ {draggingOver && (
+
+ )}
+
+
+ ) : (
+
+ )}
+
+
+
+
acc + f.size, 0))}
/>
-
- ]}
- onCancel={hideFileInfoModal}>
-
-
- {fileForInfo.split("/").at(-1)}
- {fileForInfo}
-
- {filesize(filesByPath[fileForInfo]?.size ?? 0)}
-
- {formatTimestamp(filesByPath[fileForInfo]?.lastModified ?? 0)}
-
- {formatTimestamp(filesByPath[fileForInfo]?.lastMetadataChange ?? 0)}
-
-
-
-
-
- Doing so will permanently and irrevocably remove this file from the drop box. It will then be
- unavailable for any ingestion or analysis.
-
-
- {/* ------------------------------ End of modals section ------------------------------ */}
-
-
-
- } onClick={handleUpload} disabled={uploadDisabled}>Upload
-
- Ingest
-
-
-
- } onClick={showFileInfoModal}
- disabled={!selectedFileInfoAvailable}>
- File Info
-
- } onClick={handleViewFile} disabled={!selectedFileViewable}>
- View
-
-
-
-
- }
- disabled={deleteDisabled}
- loading={isDeleting}
- onClick={showFileDeleteModal}>
- Delete
-
-
- {selectedEntries.length} item{selectedEntries.length === 1 ? "" : "s"} selected
-
-
-
-
- {(isFetchingPermissions || treeLoading || dropBoxService) ? (
-
- ) : }
-
-
-
- acc + f.size, 0))}
- />
-
-
-
-
- ;
+
+
+
+
+
+ );
};
export default ManagerDropBoxContent;
diff --git a/src/components/manager/ManagerExportContent.js b/src/components/manager/ManagerExportContent.js
index 80a9d5c07..98e84e241 100644
--- a/src/components/manager/ManagerExportContent.js
+++ b/src/components/manager/ManagerExportContent.js
@@ -12,27 +12,27 @@ import RunSetupWizard from "./RunSetupWizard";
import RunSetupConfirmDisplay from "./RunSetupConfirmDisplay";
const ManagerExportContent = () => {
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
-
- // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
- // least one workflow.
-
- if (hasAttemptedPermissions && !permissions.includes(exportData)) {
- return (
-
- );
- }
-
- return }
- onSubmit={({ selectedWorkflow, inputs }) => {
- dispatch(submitExportWorkflowRun(selectedWorkflow, inputs, "/data/manager/runs", navigate));
- }}
- />;
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
+
+ // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
+ // least one workflow.
+
+ if (hasAttemptedPermissions && !permissions.includes(exportData)) {
+ return ;
+ }
+
+ return (
+ }
+ onSubmit={({ selectedWorkflow, inputs }) => {
+ dispatch(submitExportWorkflowRun(selectedWorkflow, inputs, "/data/manager/runs", navigate));
+ }}
+ />
+ );
};
export default ManagerExportContent;
diff --git a/src/components/manager/ManagerIngestionContent.js b/src/components/manager/ManagerIngestionContent.js
index fd3925962..9834505db 100644
--- a/src/components/manager/ManagerIngestionContent.js
+++ b/src/components/manager/ManagerIngestionContent.js
@@ -12,31 +12,28 @@ import RunSetupWizard from "./RunSetupWizard";
import RunSetupConfirmDisplay from "./RunSetupConfirmDisplay";
const ManagerIngestionContent = () => {
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
-
- // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
- // least one workflow.
-
- if (
- hasAttemptedPermissions &&
- !(permissions.includes(ingestData) || permissions.includes(ingestReferenceMaterial))
- ) {
- return (
-
- );
- }
-
- return }
- onSubmit={({ selectedWorkflow, inputs }) => {
- dispatch(submitIngestionWorkflowRun(selectedWorkflow, inputs, "/data/manager/runs", navigate));
- }}
- />;
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
+
+ // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
+ // least one workflow.
+
+ if (hasAttemptedPermissions && !(permissions.includes(ingestData) || permissions.includes(ingestReferenceMaterial))) {
+ return ;
+ }
+
+ return (
+ }
+ onSubmit={({ selectedWorkflow, inputs }) => {
+ dispatch(submitIngestionWorkflowRun(selectedWorkflow, inputs, "/data/manager/runs", navigate));
+ }}
+ />
+ );
};
export default ManagerIngestionContent;
diff --git a/src/components/manager/ProjectTitleDisplay.js b/src/components/manager/ProjectTitleDisplay.js
deleted file mode 100644
index f0b6f1480..000000000
--- a/src/components/manager/ProjectTitleDisplay.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from "react";
-import { useSelector } from "react-redux";
-import { Link } from "react-router-dom";
-import PropTypes from "prop-types";
-
-import { EM_DASH } from "@/constants";
-import MonospaceText from "@/components/common/MonospaceText";
-
-const ProjectTitleDisplay = ({ projectID, link }) => {
- const projectsByID = useSelector(state => state.projects.itemsByID);
-
- if (!projectID) return EM_DASH;
-
- const title = projectsByID[projectID]?.title;
-
- if (!title) return (
-
- {projectID} {" "}
- (NOT AVAILABLE)
-
- );
-
- if (!link) return title;
- return {title};
-};
-
-ProjectTitleDisplay.propTypes = {
- projectID: PropTypes.string,
- link: PropTypes.bool,
-};
-
-ProjectTitleDisplay.defaultProps = {
- link: false,
-};
-
-export default ProjectTitleDisplay;
diff --git a/src/components/manager/ProjectTitleDisplay.tsx b/src/components/manager/ProjectTitleDisplay.tsx
new file mode 100644
index 000000000..c502e4b84
--- /dev/null
+++ b/src/components/manager/ProjectTitleDisplay.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { Link } from "react-router-dom";
+
+import { EM_DASH } from "@/constants";
+import ErrorText from "@/components/common/ErrorText";
+import MonospaceText from "@/components/common/MonospaceText";
+import { useProjects } from "@/modules/metadata/hooks";
+
+export type ProjectTitleDisplayProps = {
+ projectID: string;
+ link: boolean;
+};
+
+const ProjectTitleDisplay = ({ projectID, link }: ProjectTitleDisplayProps) => {
+ const { itemsByID: projectsByID } = useProjects();
+
+ if (!projectID) return EM_DASH;
+
+ const title = projectsByID[projectID]?.title;
+
+ if (!title)
+ return (
+
+ {projectID} (NOT AVAILABLE)
+
+ );
+
+ if (!link) return title;
+ return {title};
+};
+
+ProjectTitleDisplay.defaultProps = {
+ link: false,
+};
+
+export default ProjectTitleDisplay;
diff --git a/src/components/manager/RunSetupConfirmDisplay.js b/src/components/manager/RunSetupConfirmDisplay.js
index fa8268f3e..572a2e739 100644
--- a/src/components/manager/RunSetupConfirmDisplay.js
+++ b/src/components/manager/RunSetupConfirmDisplay.js
@@ -10,38 +10,38 @@ import { FORM_BUTTON_COL, FORM_LABEL_COL, FORM_WRAPPER_COL } from "./workflowCom
/** @type {Object.} */
const styles = {
- workflowListItem: { paddingTop: 4, paddingBottom: 0 },
- runButton: { marginTop: 16, float: "right" },
+ workflowListItem: { paddingTop: 4, paddingBottom: 0 },
+ runButton: { marginTop: 16, float: "right" },
};
const RunSetupConfirmDisplay = ({ selectedWorkflow, inputs, handleRunWorkflow, runButtonText }) => {
- const isSubmittingRun = useSelector((state) => state.runs.isSubmittingRun);
+ const isSubmittingRun = useSelector((state) => state.runs.isSubmittingRun);
- return (
-
-
-
-
-
-
-
-
-
- {/* TODO: Back button like the last one */}
-
- {runButtonText}
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+ {/* TODO: Back button like the last one */}
+
+ {runButtonText}
+
+
+
+ );
};
RunSetupConfirmDisplay.propTypes = {
- selectedWorkflow: PropTypes.object,
- inputs: PropTypes.object,
- handleRunWorkflow: PropTypes.func,
- runButtonText: PropTypes.string,
+ selectedWorkflow: PropTypes.object,
+ inputs: PropTypes.object,
+ handleRunWorkflow: PropTypes.func,
+ runButtonText: PropTypes.string,
};
export default RunSetupConfirmDisplay;
diff --git a/src/components/manager/RunSetupInputForm.js b/src/components/manager/RunSetupInputForm.js
index 94b262c95..7fec4d2c4 100644
--- a/src/components/manager/RunSetupInputForm.js
+++ b/src/components/manager/RunSetupInputForm.js
@@ -1,18 +1,14 @@
-import React, { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
-import { useSelector } from "react-redux";
+import { forwardRef, useCallback, useEffect, useMemo, useState } from "react";
import PropTypes from "prop-types";
import Handlebars from "handlebars";
import { Button, Checkbox, Form, Input, Select, Spin } from "antd";
-import {
- FORM_LABEL_COL,
- FORM_WRAPPER_COL,
- FORM_BUTTON_COL,
-} from "./workflowCommon";
+import { FORM_LABEL_COL, FORM_WRAPPER_COL, FORM_BUTTON_COL } from "./workflowCommon";
import { BENTO_DROP_BOX_FS_BASE_PATH } from "@/config";
+import { useBentoServices } from "@/modules/services/hooks";
import { workflowPropTypesShape } from "@/propTypes";
import { testFileAgainstPattern } from "@/utils/files";
import { nop } from "@/utils/misc";
@@ -21,189 +17,202 @@ import DatasetTreeSelect, { ID_FORMAT_PROJECT_DATASET } from "./DatasetTreeSelec
import DropBoxTreeSelect from "./DropBoxTreeSelect";
import { LeftOutlined, RightOutlined } from "@ant-design/icons";
-
const EnumSelect = forwardRef(({ mode, onChange, values: valuesConfig, value }, ref) => {
- const isUrl = typeof valuesConfig === "string";
-
- const [values, setValues] = useState(isUrl ? [] : valuesConfig);
- const [fetching, setFetching] = useState(false);
-
- const bentoServicesByKind = useSelector((state) => state.bentoServices.itemsByKind);
- const serviceUrls = useMemo(
- () => Object.fromEntries(Object.entries(bentoServicesByKind).map(([k, v]) => [k, v.url])),
- [bentoServicesByKind]);
-
- useEffect(() => {
- if (isUrl) {
- setFetching(true);
-
- const url = Handlebars.compile(valuesConfig)({ serviceUrls });
- console.debug(`enum - using values URL: ${url}`);
- fetch(url)
- .then(r => r.json())
- .then(data => {
- if (Array.isArray(data)) {
- setValues(data);
- }
- setFetching(false);
- })
- .catch(err => {
- console.error(err);
- setValues([]);
- setFetching(false);
- });
- }
- }, [isUrl]);
-
- return (
- : null}
- options={values.map((value) => ({ value, label: value }))}
- />
- );
+ const isUrl = typeof valuesConfig === "string";
+
+ const [values, setValues] = useState(isUrl ? [] : valuesConfig);
+ const [fetching, setFetching] = useState(false);
+ const [attemptedFetch, setAttemptedFetch] = useState(false);
+
+ const bentoServicesByKind = useBentoServices().itemsByKind;
+ const serviceUrls = useMemo(
+ () => Object.fromEntries(Object.entries(bentoServicesByKind).map(([k, v]) => [k, v.url])),
+ [bentoServicesByKind],
+ );
+
+ useEffect(() => {
+ // Reset attempted-fetch state when value changes
+ setAttemptedFetch(false);
+ }, [value]);
+
+ useEffect(() => {
+ if (isUrl && !fetching && !attemptedFetch) {
+ setFetching(true);
+
+ const url = Handlebars.compile(valuesConfig)({ serviceUrls });
+ console.debug(`enum - using values URL: ${url}`);
+ fetch(url)
+ .then((r) => r.json())
+ .then((data) => {
+ if (Array.isArray(data)) {
+ setValues(data);
+ }
+ })
+ .catch((err) => {
+ console.error(err);
+ setValues([]);
+ })
+ .finally(() => {
+ setAttemptedFetch(true);
+ setFetching(false);
+ });
+ }
+ }, [isUrl, fetching, attemptedFetch, valuesConfig, serviceUrls]);
+
+ return (
+ : null}
+ options={values.map((value) => ({ value, label: value }))}
+ />
+ );
});
EnumSelect.propTypes = {
- mode: PropTypes.oneOf(["default", "multiple", "tags", "combobox"]),
- onChange: PropTypes.func,
- values: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
+ mode: PropTypes.oneOf(["default", "multiple", "tags", "combobox"]),
+ onChange: PropTypes.func,
+ values: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
};
-
// These properties come from the inputs as listed in the WorkflowDefinition in the workflow-providing service.
// For all possible workflow input types, see:
// https://github.com/bento-platform/bento_lib/blob/master/bento_lib/workflows/models.py
// This component is responsible for transforming these workflow input definitions into form elements.
const getInputComponentAndOptions = ({ id, type, pattern, values, repeatable }) => {
- const dropBoxTreeNodeEnabled = ({ name, contents }) =>
- contents !== undefined || testFileAgainstPattern(name, pattern);
-
- const key = `input-${id}`;
- const isArray = type.endsWith("[]");
-
- switch (type) {
- case "string":
- return [ , {}];
- case "string[]": {
- // TODO: string[] - need to be able to reselect if repeatable
- return [ , {}];
- }
-
- case "number":
- return [ , {}];
- // case "number[]":
-
- case "boolean":
- return [ , { valuePropName: "checked" }];
-
- case "enum":
- case "enum[]": {
- const mode = (isArray && !repeatable) ? "multiple" : "default";
-
- // TODO: enum[] - need to be able to reselect if repeatable
- return [ , {}];
- }
-
- case "file":
- case "file[]":
- // TODO: What about non-unique files?
- // TODO: Don't hard-code configured filesystem path for input files
- return [
- ,
- {},
- ];
-
- case "directory":
- case "directory[]":
- return [
- ,
- {},
- ];
-
- case "project:dataset":
- return [ , {}];
-
- default:
- return [ , {}];
+ const dropBoxTreeNodeEnabled = ({ name, contents }) =>
+ contents !== undefined || testFileAgainstPattern(name, pattern);
+
+ const key = `input-${id}`;
+ const isArray = type.endsWith("[]");
+
+ switch (type) {
+ case "string":
+ return [ , {}];
+ case "string[]": {
+ // TODO: string[] - need to be able to reselect if repeatable
+ return [ , {}];
}
-};
-const RunSetupInputForm = ({ initialValues, onSubmit, workflow, onBack, onChange }) => {
- const handleFinish = useCallback((values) => {
- (onSubmit || nop)(values);
- }, [onSubmit]);
+ case "number":
+ return [ , {}];
+ // case "number[]":
- const handleBack = useCallback(() => onBack(), [onBack]);
+ case "boolean":
+ return [ , { valuePropName: "checked" }];
- const handleFieldsChange = useCallback((_, allFields) => onChange({...allFields}), [onChange]);
+ case "enum":
+ case "enum[]": {
+ const mode = isArray && !repeatable ? "multiple" : "default";
- return , {}];
+ }
+
+ case "file":
+ case "file[]":
+ // TODO: What about non-unique files?
+ // TODO: Don't hard-code configured filesystem path for input files
+ return [
+ ,
+ {},
+ ];
+
+ case "directory":
+ case "directory[]":
+ return [
+ ,
+ {},
+ ];
+
+ case "project:dataset":
+ return [ , {}];
+
+ default:
+ return [ , {}];
+ }
+};
+
+const RunSetupInputForm = ({ initialValues, onSubmit, workflow, onBack, onChange }) => {
+ const handleFinish = useCallback(
+ (values) => {
+ (onSubmit || nop)(values);
+ },
+ [onSubmit],
+ );
+
+ const handleBack = useCallback(() => onBack(), [onBack]);
+
+ const handleFieldsChange = useCallback((_, allFields) => onChange({ ...allFields }), [onChange]);
+
+ return (
+ : undefined}
- {...options}
- >
- {component}
-
- );
- }),
-
-
- <> {/* Funny hack to make the type warning for multiple children in a Form.Item go away */}
- {onBack ? } onClick={handleBack}>Back : null}
-
- Next
-
- >
- ,
- ]}
- ;
+ {[
+ ...workflow.inputs
+ .filter((i) => !i.hidden && !i.injected)
+ .map((i) => {
+ const [component, options] = getInputComponentAndOptions(i);
+ return (
+ : undefined}
+ {...options}
+ >
+ {component}
+
+ );
+ }),
+
+
+ <>
+ {" "}
+ {/* Funny hack to make the type warning for multiple children in a Form.Item go away */}
+ {onBack ? (
+ } onClick={handleBack}>
+ Back
+
+ ) : null}
+
+ Next
+
+ >
+ ,
+ ]}
+
+ );
};
RunSetupInputForm.propTypes = {
- tree: PropTypes.array,
- workflow: workflowPropTypesShape,
- initialValues: PropTypes.object.isRequired, // can be blank object but not undefined
+ tree: PropTypes.array,
+ workflow: workflowPropTypesShape,
+ initialValues: PropTypes.object.isRequired, // can be blank object but not undefined
- onBack: PropTypes.func,
- onSubmit: PropTypes.func,
- onChange: PropTypes.func,
+ onBack: PropTypes.func,
+ onSubmit: PropTypes.func,
+ onChange: PropTypes.func,
};
export default RunSetupInputForm;
diff --git a/src/components/manager/RunSetupInputsTable.js b/src/components/manager/RunSetupInputsTable.js
index eb50adb15..e3ff01b71 100644
--- a/src/components/manager/RunSetupInputsTable.js
+++ b/src/components/manager/RunSetupInputsTable.js
@@ -1,4 +1,4 @@
-import React, {useMemo} from "react";
+import { memo } from "react";
import PropTypes from "prop-types";
import { Table } from "antd";
@@ -7,61 +7,66 @@ import ProjectTitleDisplay from "@/components/manager/ProjectTitleDisplay";
import DatasetTitleDisplay from "@/components/manager/DatasetTitleDisplay";
const COLUMNS = [
- {
- title: "ID",
- dataIndex: "id",
- render: iID => {iID} ,
- },
- {
- title: "Value",
- dataIndex: "value",
- render: (value, input) => {
- if (value === undefined) {
- return EM_DASH;
- }
+ {
+ title: "ID",
+ dataIndex: "id",
+ render: (iID) => {iID} ,
+ },
+ {
+ title: "Value",
+ dataIndex: "value",
+ render: (value, input) => {
+ if (value === undefined) {
+ return EM_DASH;
+ }
- // TODO: link these to new tab: manager page on project/dataset (when we can route datasets)
- if (input.inputConfig.type === "project:dataset") {
- const [projectID, datasetID] = value.split(":");
- return ;
- }
+ // TODO: link these to new tab: manager page on project/dataset (when we can route datasets)
+ if (input.inputConfig.type === "project:dataset") {
+ const [projectID, datasetID] = value.split(":");
+ return (
+
+
Project:
+
+
Dataset:
+
+ );
+ }
- if (Array.isArray(value)) {
- return
- {value.map(v => {v.toString()} )}
- ;
- }
+ if (Array.isArray(value)) {
+ return (
+
+ {value.map((v) => (
+ {v.toString()}
+ ))}
+
+ );
+ }
- return value.toString();
- },
+ return value.toString();
},
+ },
];
-const RunSetupInputsTable = ({ selectedWorkflow, inputs }) => {
- const dataSource = useMemo(
- () => selectedWorkflow.inputs
- .filter(i => !(i.hidden ?? false) && !i.injected)
- .map(i => ({ id: i.id, value: inputs[i.id], inputConfig: i })),
- [inputs]);
+const RunSetupInputsTable = memo(({ selectedWorkflow, inputs }) => {
+ const dataSource = selectedWorkflow.inputs
+ .filter((i) => !(i.hidden ?? false) && !i.injected)
+ .map((i) => ({ id: i.id, value: inputs[i.id], inputConfig: i }));
- return (
-
- );
-};
+ return (
+
+ );
+});
RunSetupInputsTable.propTypes = {
- selectedWorkflow: PropTypes.object,
- inputs: PropTypes.object,
+ selectedWorkflow: PropTypes.object,
+ inputs: PropTypes.object,
};
export default RunSetupInputsTable;
diff --git a/src/components/manager/RunSetupWizard.js b/src/components/manager/RunSetupWizard.js
index e70568a01..6a86f48c2 100644
--- a/src/components/manager/RunSetupWizard.js
+++ b/src/components/manager/RunSetupWizard.js
@@ -1,149 +1,157 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
-import {useLocation} from "react-router-dom";
+import { useLocation } from "react-router-dom";
import PropTypes from "prop-types";
-import {Layout, Steps} from "antd";
+import { Layout, Steps } from "antd";
import RunSetupInputForm from "./RunSetupInputForm";
-import {LAYOUT_CONTENT_STYLE} from "@/styles/layoutContent";
-import {
- STEP_WORKFLOW_SELECTION,
- STEP_INPUT,
- STEP_CONFIRM,
-} from "./workflowCommon";
+import { LAYOUT_CONTENT_STYLE } from "@/styles/layoutContent";
+import { STEP_WORKFLOW_SELECTION, STEP_INPUT, STEP_CONFIRM } from "./workflowCommon";
import WorkflowSelection from "./WorkflowSelection";
import { workflowTypePropType } from "@/propTypes";
const RunSetupWizard = ({
- workflowType,
- workflowSelectionTitle,
- workflowSelectionDescription,
+ workflowType,
+ workflowSelectionTitle,
+ workflowSelectionDescription,
+ confirmDisplay,
+ onSubmit,
+}) => {
+ const location = useLocation();
+
+ const [step, setStep] = useState(STEP_WORKFLOW_SELECTION);
+ const [selectedWorkflow, setSelectedWorkflow] = useState(null);
+
+ const [inputs, setInputs] = useState({});
+ const [initialWorkflowFilterValues, setInitialWorkflowFilterValues] = useState(undefined);
+ const [initialInputValues, setInitialInputValues] = useState({});
+ const [inputFormFields, setInputFormFields] = useState({});
+
+ useEffect(() => {
+ const {
+ step: newStep,
+ initialWorkflowFilterValues: newInitialWorkflowFilterValues,
+ selectedWorkflow: newSelectedWorkflow,
+ initialInputValues: newInitialInputValues,
+ } = location?.state ?? {};
+
+ if (newStep !== undefined) {
+ setStep(newStep);
+ }
+ if (newInitialWorkflowFilterValues !== undefined) {
+ setInitialWorkflowFilterValues(newInitialWorkflowFilterValues);
+ }
+ if (newSelectedWorkflow !== undefined) {
+ setSelectedWorkflow(newSelectedWorkflow);
+ }
+ if (newInitialInputValues !== undefined) {
+ setInitialInputValues(newInitialInputValues);
+ }
+ }, [location]);
+
+ const handleWorkflowClick = useCallback(
+ (workflow) => {
+ if (workflow.id !== selectedWorkflow?.id) {
+ // If we had pre-defined initial values / form values, but we change the workflow, reset these inputs.
+ setInitialInputValues({});
+ setInputFormFields({});
+
+ // Change to the new selected workflow
+ setSelectedWorkflow(workflow);
+ }
+ setStep(STEP_INPUT);
+ },
+ [selectedWorkflow],
+ );
+
+ const stepItems = useMemo(
+ () => [
+ {
+ title: workflowSelectionTitle ?? "Workflow",
+ description: workflowSelectionDescription ?? "Choose a workflow.",
+ },
+ {
+ title: "Input",
+ description: "Select input data for the workflow.",
+ disabled: step < STEP_INPUT && Object.keys(inputs).length === 0,
+ },
+ {
+ title: "Run",
+ description: "Confirm details and run the workflow.",
+ disabled: step < STEP_CONFIRM && (selectedWorkflow === null || Object.keys(inputs).length === 0),
+ },
+ ],
+ [workflowSelectionTitle, workflowSelectionDescription, step, inputs, selectedWorkflow],
+ );
+
+ const getStepContents = useCallback(() => {
+ switch (step) {
+ case STEP_WORKFLOW_SELECTION:
+ return (
+
+ );
+ case STEP_INPUT:
+ return (
+ {
+ setInputs(inputs);
+ setStep(STEP_CONFIRM);
+ }}
+ onBack={() => setStep(STEP_WORKFLOW_SELECTION)}
+ />
+ );
+ case STEP_CONFIRM:
+ return confirmDisplay({
+ selectedWorkflow,
+ inputs,
+ handleRunWorkflow: () => {
+ if (!selectedWorkflow) {
+ console.error("handleRunWorkflow called without a selected workflow");
+ return;
+ }
+ onSubmit({ selectedWorkflow, inputs });
+ },
+ });
+ default:
+ return
;
+ }
+ }, [
confirmDisplay,
+ handleWorkflowClick,
+ initialInputValues,
+ initialWorkflowFilterValues,
+ inputFormFields,
+ inputs,
onSubmit,
-}) => {
- const location = useLocation();
-
- const [step, setStep] = useState(STEP_WORKFLOW_SELECTION);
- const [selectedWorkflow, setSelectedWorkflow] = useState(null);
-
- const [inputs, setInputs] = useState({});
- const [initialWorkflowFilterValues, setInitialWorkflowFilterValues] = useState(undefined);
- const [initialInputValues, setInitialInputValues] = useState({});
- const [inputFormFields, setInputFormFields] = useState({});
-
- useEffect(() => {
- const {
- step: newStep,
- initialWorkflowFilterValues: newInitialWorkflowFilterValues,
- selectedWorkflow: newSelectedWorkflow,
- initialInputValues: newInitialInputValues,
- } = location?.state ?? {};
-
- if (newStep !== undefined) {
- setStep(newStep);
- }
- if (newInitialWorkflowFilterValues !== undefined) {
- setInitialWorkflowFilterValues(newInitialWorkflowFilterValues);
- }
- if (newSelectedWorkflow !== undefined) {
- setSelectedWorkflow(newSelectedWorkflow);
- }
- if (newInitialInputValues !== undefined) {
- setInitialInputValues(newInitialInputValues);
- }
- }, [location]);
-
- const handleWorkflowClick = useCallback((workflow) => {
- if (workflow.id !== selectedWorkflow?.id) {
- // If we had pre-defined initial values / form values, but we change the workflow, reset these inputs.
- setInitialInputValues({});
- setInputFormFields({});
-
- // Change to the new selected workflow
- setSelectedWorkflow(workflow);
- }
- setStep(STEP_INPUT);
- }, [selectedWorkflow]);
-
- const handleInputSubmit = useCallback(inputs => {
- setInputs(inputs);
- setStep(STEP_CONFIRM);
- }, []);
-
- const handleRunWorkflow = useCallback(() => {
- if (!selectedWorkflow) {
- console.error("handleRunWorkflow called without a selected workflow");
- return;
- }
- onSubmit({ selectedWorkflow, inputs });
- }, [selectedWorkflow, inputs]);
-
- const stepItems = useMemo(() => [
- {
- title: workflowSelectionTitle ?? "Workflow",
- description: workflowSelectionDescription ?? "Choose a workflow.",
- },
- {
- title: "Input",
- description: "Select input data for the workflow.",
- disabled: step < STEP_INPUT && Object.keys(inputs).length === 0,
- },
- {
- title: "Run",
- description: "Confirm details and run the workflow.",
- disabled: step < STEP_CONFIRM && (selectedWorkflow === null || Object.keys(inputs).length === 0),
- },
- ], [workflowSelectionTitle, workflowSelectionDescription, step, inputs, selectedWorkflow]);
-
- const getStepContents = useCallback(() => {
- switch (step) {
- case STEP_WORKFLOW_SELECTION:
- return ;
- case STEP_INPUT:
- return setStep(STEP_WORKFLOW_SELECTION)}
- />;
- case STEP_CONFIRM:
- return confirmDisplay({ selectedWorkflow, inputs, handleRunWorkflow });
- default:
- return
;
- }
- }, [
- step,
- inputs,
- selectedWorkflow,
- initialInputValues,
- inputFormFields,
- handleInputSubmit,
- handleWorkflowClick,
- handleRunWorkflow,
- ]);
-
- return (
-
-
-
- {getStepContents()}
-
-
- );
+ selectedWorkflow,
+ step,
+ workflowType,
+ ]);
+
+ return (
+
+
+
+ {getStepContents()}
+
+
+ );
};
RunSetupWizard.propTypes = {
- workflowType: workflowTypePropType,
- workflowSelectionTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
- workflowSelectionDescription: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
- confirmDisplay: PropTypes.func,
- onSubmit: PropTypes.func,
+ workflowType: workflowTypePropType,
+ workflowSelectionTitle: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ workflowSelectionDescription: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
+ confirmDisplay: PropTypes.func,
+ onSubmit: PropTypes.func,
};
export default RunSetupWizard;
diff --git a/src/components/manager/WorkflowListItem.js b/src/components/manager/WorkflowListItem.js
index 941d857a2..0269b435c 100644
--- a/src/components/manager/WorkflowListItem.js
+++ b/src/components/manager/WorkflowListItem.js
@@ -3,123 +3,132 @@ import PropTypes from "prop-types";
import { List, Tag } from "antd";
import {
- CheckSquareOutlined,
- DatabaseOutlined,
- FileOutlined,
- FolderOutlined,
- FontSizeOutlined,
- MenuOutlined,
- NumberOutlined,
- RightOutlined,
+ CheckSquareOutlined,
+ DatabaseOutlined,
+ FileOutlined,
+ FolderOutlined,
+ FontSizeOutlined,
+ MenuOutlined,
+ NumberOutlined,
+ RightOutlined,
} from "@ant-design/icons";
import { workflowPropTypesShape } from "@/propTypes";
import { nop } from "@/utils/misc";
const TYPE_TAG_DISPLAY = {
- number: {
- color: "green",
- icon: ,
- },
- string: {
- color: "purple",
- icon: ,
- },
- boolean: {
- color: "cyan",
- icon: ,
- },
- enum: {
- color: "blue",
- icon: ,
- },
- "project:dataset": {
- color: "magenta",
- icon: ,
- },
- file: {
- color: "volcano",
- icon: ,
- },
- directory: {
- color: "orange",
- icon: ,
- },
+ number: {
+ color: "green",
+ icon: ,
+ },
+ string: {
+ color: "purple",
+ icon: ,
+ },
+ boolean: {
+ color: "cyan",
+ icon: ,
+ },
+ enum: {
+ color: "blue",
+ icon: ,
+ },
+ "project:dataset": {
+ color: "magenta",
+ icon: ,
+ },
+ file: {
+ color: "volcano",
+ icon: ,
+ },
+ directory: {
+ color: "orange",
+ icon: ,
+ },
};
const WorkflowInputTag = ({ id, type, children }) => {
- const display = useMemo(() => TYPE_TAG_DISPLAY[type.replace("[]", "")], [type]);
- return (
-
- {display.icon}
- {id} ({children || type}{type.endsWith("[]") ? " array" : ""})
-
- );
+ const display = useMemo(() => TYPE_TAG_DISPLAY[type.replace("[]", "")], [type]);
+ return (
+
+ {display.icon}
+ {id} ({children || type}
+ {type.endsWith("[]") ? " array" : ""})
+
+ );
};
WorkflowInputTag.propTypes = {
- id: PropTypes.string,
- type: PropTypes.string,
- children: PropTypes.node,
+ id: PropTypes.string,
+ type: PropTypes.string,
+ children: PropTypes.node,
};
const FLEX_1 = { flex: 1 };
const MARGIN_RIGHT_1EM = { marginRight: "1em" };
const WorkflowListItem = ({ onClick, workflow, rightAlignedTags, style }) => {
- const { inputs, name, description, data_type: dt } = workflow;
+ const { inputs, name, description, data_type: dt } = workflow;
- const typeTag = dt ? {dt} : null;
+ const typeTag = dt ? {dt} : null;
- const inputTags = useMemo(
- () =>
- inputs
- .filter(i => !i.hidden && !i.injected) // Filter out hidden/injected inputs
- .map(({ id, type, pattern }) => (
-
- {type.startsWith("file") ? pattern ?? "" : ""}
-
- )),
- [inputs],
- );
+ const inputTags = useMemo(
+ () =>
+ inputs
+ .filter((i) => !i.hidden && !i.injected) // Filter out hidden/injected inputs
+ .map(({ id, type, pattern }) => (
+
+ {type.startsWith("file") ? pattern ?? "" : ""}
+
+ )),
+ [inputs],
+ );
- const selectable = !!onClick; // Can be selected if a click handler exists
+ const selectable = !!onClick; // Can be selected if a click handler exists
- const workflowNameStyle = rightAlignedTags ? FLEX_1 : MARGIN_RIGHT_1EM;
+ const workflowNameStyle = rightAlignedTags ? FLEX_1 : MARGIN_RIGHT_1EM;
- return
- (onClick || nop)()} style={{ display: "flex" }}>
-
- {name}
-
- {typeTag}
- :
- {name}
- {typeTag}
- }
- description={description || ""}
- />
+ return (
+
+ (onClick || nop)()} style={{ display: "flex" }}>
+
+ {name}
+
+
+ {typeTag}
+
+ ) : (
+
+ {name}
+ {typeTag}
+
+ )
+ }
+ description={description || ""}
+ />
-
- Inputs:
- {inputTags}
-
+
+ Inputs:
+ {inputTags}
+
- {/* TODO: parse outputs from WDL. For now, we cannot list them, so we just don't show anything */}
- {/**/}
- {/* Outputs: */}
- {/* {outputTags}*/}
- {/*
*/}
- ;
+ {/* TODO: parse outputs from WDL. For now, we cannot list them, so we just don't show anything */}
+ {/**/}
+ {/* Outputs: */}
+ {/* {outputTags}*/}
+ {/*
*/}
+
+ );
};
WorkflowListItem.propTypes = {
- workflow: workflowPropTypesShape,
- selectable: PropTypes.bool,
- onClick: PropTypes.func,
- rightAlignedTags: PropTypes.bool,
- style: PropTypes.object,
+ workflow: workflowPropTypesShape,
+ selectable: PropTypes.bool,
+ onClick: PropTypes.func,
+ rightAlignedTags: PropTypes.bool,
+ style: PropTypes.object,
};
export default WorkflowListItem;
diff --git a/src/components/manager/WorkflowSelection.js b/src/components/manager/WorkflowSelection.js
index 35944152e..0bfbfbfb1 100644
--- a/src/components/manager/WorkflowSelection.js
+++ b/src/components/manager/WorkflowSelection.js
@@ -9,120 +9,111 @@ import { workflowTypePropType } from "@/propTypes";
import { FORM_LABEL_COL, FORM_WRAPPER_COL } from "./workflowCommon";
const filterValuesPropType = PropTypes.shape({
- text: PropTypes.string,
- tags: PropTypes.arrayOf(PropTypes.string),
+ text: PropTypes.string,
+ tags: PropTypes.arrayOf(PropTypes.string),
});
const WorkflowFilter = ({ loading, tags, value, onChange }) => {
- const onChangeText = useCallback(e => onChange({ ...value, text: e.target.value }), [value, onChange]);
- const onChangeTags = useCallback(tags => onChange({ ...value, tags }), [value, onChange]);
+ const onChangeText = useCallback((e) => onChange({ ...value, text: e.target.value }), [value, onChange]);
+ const onChangeTags = useCallback((tags) => onChange({ ...value, tags }), [value, onChange]);
- return
-
-
-
-
- ({ value: t, label: t }))}
- />
-
-
;
+ return (
+
+
+
+
+
+ ({ value: t, label: t }))}
+ />
+
+
+ );
};
WorkflowFilter.propTypes = {
- loading: PropTypes.bool,
- tags: PropTypes.arrayOf(PropTypes.string),
- value: filterValuesPropType,
- onChange: PropTypes.func,
+ loading: PropTypes.bool,
+ tags: PropTypes.arrayOf(PropTypes.string),
+ value: filterValuesPropType,
+ onChange: PropTypes.func,
};
const INITIAL_FILTER_STATE = {
- text: "",
- tags: [],
+ text: "",
+ tags: [],
};
const WorkflowSelection = ({ workflowType, initialFilterValues, handleWorkflowClick }) => {
- const { workflowsByType, workflowsLoading } = useWorkflows();
+ const { workflowsByType, workflowsLoading } = useWorkflows();
- const workflowsOfType = workflowsByType[workflowType] ?? [];
- const tags = useMemo(
- () => Array.from(new Set(workflowsOfType.items.flatMap(w => [
- ...(w.data_type ? [w.data_type] : []),
- ...(w.tags ?? []),
- ]))),
- [workflowsOfType],
- );
+ const workflowsOfType = useMemo(() => workflowsByType[workflowType] ?? [], [workflowsByType, workflowType]);
+ const tags = useMemo(
+ () =>
+ Array.from(
+ new Set(workflowsOfType.items.flatMap((w) => [...(w.data_type ? [w.data_type] : []), ...(w.tags ?? [])])),
+ ),
+ [workflowsOfType],
+ );
- const [filterValues, setFilterValues] = useState(INITIAL_FILTER_STATE);
+ const [filterValues, setFilterValues] = useState(INITIAL_FILTER_STATE);
- useEffect(() => {
- if (filterValues.text === "" && !filterValues.tags.length && initialFilterValues) {
- setFilterValues({...INITIAL_FILTER_STATE, filterValues});
- }
- }, [initialFilterValues]);
+ useEffect(() => {
+ if (filterValues.text === "" && !filterValues.tags.length && initialFilterValues) {
+ setFilterValues({ ...INITIAL_FILTER_STATE, filterValues });
+ }
+ }, [filterValues, initialFilterValues]);
- /** @type {React.ReactNode[]} */
- const workflowItems = useMemo(
- () => {
- const ftLower = filterValues.text.toLowerCase().trim();
- const ftTags = filterValues.tags;
+ /** @type {React.ReactNode[]} */
+ const workflowItems = useMemo(() => {
+ const ftLower = filterValues.text.toLowerCase().trim();
+ const ftTags = filterValues.tags;
- return workflowsOfType
- .items
- .filter(w => {
- const wTags = new Set(w.tags ?? []);
- return (
- !ftLower ||
- w.name.toLowerCase().includes(ftLower) ||
- w.description.toLowerCase().includes(ftLower) ||
- (w.data_type && w.data_type.includes(ftLower)) ||
- wTags.has(ftLower)
- ) && (
- ftTags.length === 0 ||
- ftTags.reduce((acc, t) => acc && wTags.has(t), true)
- );
- })
- .map(w =>
- handleWorkflowClick(w)}
- />,
- );
- },
- [workflowsOfType, filterValues],
- );
+ return workflowsOfType.items
+ .filter((w) => {
+ const wTags = new Set(w.tags ?? []);
+ return (
+ (!ftLower ||
+ w.name.toLowerCase().includes(ftLower) ||
+ w.description.toLowerCase().includes(ftLower) ||
+ (w.data_type && w.data_type.includes(ftLower)) ||
+ wTags.has(ftLower)) &&
+ (ftTags.length === 0 || ftTags.reduce((acc, t) => acc && wTags.has(t), true))
+ );
+ })
+ .map((w) => (
+ handleWorkflowClick(w)} />
+ ));
+ }, [workflowsOfType, filterValues, handleWorkflowClick]);
- return
-
-
-
-
- {workflowsLoading
- ?
- : {workflowItems}
}
-
-
- ;
+ return (
+
+
+
+
+
+ {workflowsLoading ? : {workflowItems}
}
+
+
+
+ );
};
WorkflowSelection.propTypes = {
- workflowType: workflowTypePropType.isRequired,
- initialFilterValues: filterValuesPropType,
- handleWorkflowClick: PropTypes.func,
+ workflowType: workflowTypePropType.isRequired,
+ initialFilterValues: filterValuesPropType,
+ handleWorkflowClick: PropTypes.func,
};
export default WorkflowSelection;
diff --git a/src/components/manager/access/AccessTabs.tsx b/src/components/manager/access/AccessTabs.tsx
index 8cc645046..b03f0a2d5 100644
--- a/src/components/manager/access/AccessTabs.tsx
+++ b/src/components/manager/access/AccessTabs.tsx
@@ -1,63 +1,44 @@
-import React, { useCallback, useEffect } from "react";
+import React, { useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
-import { Tabs } from "antd";
-import type { TabsProps } from "antd";
+import { Tabs, type TabsProps } from "antd";
-import { viewPermissions, RESOURCE_EVERYTHING } from "bento-auth-js";
+import { useAuthzManagementPermissions } from "@/modules/authz/hooks";
-import { useResourcePermissionsWrapper } from "@/hooks";
-import { fetchGrants, fetchGroups } from "@/modules/authz/actions";
-import { useService } from "@/modules/services/hooks";
-import { useAppDispatch } from "@/store";
-
-import ForbiddenContent from "../../ForbiddenContent";
+import ForbiddenContent from "@/components/ForbiddenContent";
import GrantsTabContent from "./GrantsTabContent";
import GroupsTabContent from "./GroupsTabContent";
const TAB_ITEMS: TabsProps["items"] = [
- {
- key: "grants",
- label: "Grants",
- children: ,
- },
- {
- key: "groups",
- label: "Groups",
- children: ,
- },
+ {
+ key: "grants",
+ label: "Grants",
+ children: ,
+ },
+ {
+ key: "groups",
+ label: "Groups",
+ children: ,
+ },
];
const AccessTabs = () => {
- const dispatch = useAppDispatch();
-
- const navigate = useNavigate();
- const { tab } = useParams();
-
- const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
-
- const hasViewPermission = permissions.includes(viewPermissions);
-
- const authorizationService = useService("authorization");
- useEffect(() => {
- if (authorizationService && permissions.includes(viewPermissions)) {
- dispatch(fetchGrants());
- dispatch(fetchGroups());
- }
- }, [authorizationService, permissions]);
-
- const onTabClick = useCallback((key: string) => {
- navigate(`../${key}`);
- }, [navigate]);
-
- if (hasAttemptedPermissions && !hasViewPermission) {
- return (
-
- );
- }
- return (
-
- );
+ const navigate = useNavigate();
+ const { tab } = useParams();
+
+ const { hasAtLeastOneViewPermissionsGrant, hasAttempted: hasAttemptedPermissions } = useAuthzManagementPermissions();
+
+ const onTabClick = useCallback(
+ (key: string) => {
+ navigate(`../${key}`);
+ },
+ [navigate],
+ );
+
+ if (hasAttemptedPermissions && !hasAtLeastOneViewPermissionsGrant) {
+ return ;
+ }
+ return ;
};
export default AccessTabs;
diff --git a/src/components/manager/access/ExpiryInput.tsx b/src/components/manager/access/ExpiryInput.tsx
new file mode 100644
index 000000000..fb3f06814
--- /dev/null
+++ b/src/components/manager/access/ExpiryInput.tsx
@@ -0,0 +1,69 @@
+import { useCallback, useEffect, useState } from "react";
+import { DatePicker, Radio, type RadioChangeEvent, Space } from "antd";
+import dayjs, { type Dayjs } from "dayjs";
+
+export type ExpiryInputProps = {
+ value?: string | null;
+ onChange?: (value: string | null) => void;
+};
+
+type ExpiryType = "none" | "timestamp";
+const EXPIRY_TYPE_NONE = "none";
+const EXPIRY_TYPE_TIMESTAMP = "timestamp";
+
+const ExpiryInput = ({ value, onChange }: ExpiryInputProps) => {
+ const [expiryType, setExpiryType] = useState(EXPIRY_TYPE_NONE);
+ const [date, setDate] = useState(null);
+
+ useEffect(() => {
+ // if value is undefined, component is "uncontrolled" and we rely on local state only:
+ if (value === undefined) return;
+
+ // otherwise, component is "controlled" and we use the property to update the local state:
+ setExpiryType(value === null ? EXPIRY_TYPE_NONE : EXPIRY_TYPE_TIMESTAMP);
+ if (value !== null) {
+ setDate(dayjs(value));
+ }
+ }, [value]);
+
+ const onRadioChange = useCallback(
+ (e: RadioChangeEvent) => {
+ const newRadioValue: ExpiryType = e.target.value;
+ setExpiryType(newRadioValue);
+ if (onChange) {
+ // Controlled mode
+ onChange(newRadioValue === EXPIRY_TYPE_NONE ? null : date?.toISOString() ?? null);
+ }
+ },
+ [date, onChange],
+ );
+
+ const onPickerChange = useCallback(
+ (d: Dayjs) => {
+ setDate(d);
+ if (onChange) {
+ // Controlled mode
+ onChange(d.toISOString());
+ }
+ },
+ [onChange],
+ );
+
+ return (
+
+
+ None
+
+
+
+
+
+ );
+};
+
+export default ExpiryInput;
diff --git a/src/components/manager/access/ExpiryTimestamp.tsx b/src/components/manager/access/ExpiryTimestamp.tsx
new file mode 100644
index 000000000..7a30008f8
--- /dev/null
+++ b/src/components/manager/access/ExpiryTimestamp.tsx
@@ -0,0 +1,33 @@
+import { type CSSProperties, useMemo } from "react";
+
+import { Popover } from "antd";
+
+import MonospaceText from "@/components/common/MonospaceText";
+import { COLOR_ANTD_RED_6, EM_DASH } from "@/constants";
+
+const ExpiryTimestamp = ({ expiry }: { expiry?: string }) => {
+ const expiryTs = useMemo(() => (expiry ? Date.parse(expiry) : null), [expiry]);
+ const currentTs = Date.now();
+
+ const expired = expiryTs && expiryTs <= currentTs;
+
+ const spanStyle = useMemo((): CSSProperties => (expired ? { color: COLOR_ANTD_RED_6 } : {}), [expired]);
+
+ if (!expiry) return EM_DASH;
+
+ return (
+
+ UTC timestamp: {expiry}
+
+ }
+ >
+
+ {new Date(Date.parse(expiry)).toLocaleString()} {expired ? (EXPIRED) : ""}
+
+
+ );
+};
+
+export default ExpiryTimestamp;
diff --git a/src/components/manager/access/GrantForm.tsx b/src/components/manager/access/GrantForm.tsx
new file mode 100644
index 000000000..4eed3d01e
--- /dev/null
+++ b/src/components/manager/access/GrantForm.tsx
@@ -0,0 +1,617 @@
+import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
+
+import { Alert, Checkbox, Form, Input, Popover, Radio, Select, Space, Spin } from "antd";
+import type { FormInstance, RadioGroupProps, RadioChangeEvent, SelectProps } from "antd";
+
+import { RESOURCE_EVERYTHING, useOpenIdConfig } from "bento-auth-js";
+
+import MonospaceText from "@/components/common/MonospaceText";
+import { useAllPermissions, useGroups } from "@/modules/authz/hooks";
+import type {
+ Grant,
+ GrantSubject,
+ PermissionDefinition,
+ Resource as GrantResource,
+ StoredGroup,
+} from "@/modules/authz/types";
+import { useDataTypes } from "@/modules/services/hooks";
+import type { BentoServiceDataType } from "@/modules/services/types";
+import { useProjects } from "@/modules/metadata/hooks";
+import type { Dataset, Project } from "@/modules/metadata/types";
+
+import ExpiryInput from "./ExpiryInput";
+import Resource from "./Resource";
+import Subject from "./Subject";
+import type { InputChangeEventHandler } from "./types";
+
+const SUBJECT_EVERYONE: GrantSubject = { everyone: true };
+
+type SubjectInputProps = {
+ value?: GrantSubject;
+ onChange?: (v: GrantSubject) => void;
+};
+
+type SubjectType = "everyone" | "iss-sub" | "iss-client" | "group";
+const SUBJECT_TYPE_EVERYONE = "everyone";
+const SUBJECT_TYPE_ISS_SUB = "iss-sub";
+const SUBJECT_TYPE_ISS_CLIENT = "iss-client";
+const SUBJECT_TYPE_GROUP = "group";
+
+const buildSubject = (
+ subjectType: SubjectType,
+ iss: string,
+ sub: string,
+ client: string,
+ group: number | undefined,
+): GrantSubject | undefined => {
+ if (subjectType === SUBJECT_TYPE_EVERYONE) {
+ return SUBJECT_EVERYONE;
+ } else if (subjectType === SUBJECT_TYPE_ISS_SUB) {
+ return { iss, sub };
+ } else if (subjectType === SUBJECT_TYPE_ISS_CLIENT) {
+ return { iss, client };
+ } else if (subjectType === SUBJECT_TYPE_GROUP && group !== undefined) {
+ return { group };
+ }
+};
+
+const handleSubjectChange = (
+ onChange: ((value: GrantSubject) => void) | undefined,
+ subjectType: SubjectType,
+ iss: string,
+ sub: string,
+ client: string,
+ group: number | undefined,
+) => {
+ if (onChange) {
+ const subject = buildSubject(subjectType, iss, sub, client, group);
+ if (subject) onChange(subject);
+ }
+};
+
+const SubjectInput = ({ value, onChange }: SubjectInputProps) => {
+ const groups: StoredGroup[] = useGroups().data;
+
+ const homeIssuer = useOpenIdConfig()?.issuer ?? "";
+
+ const [subjectType, setSubjectType] = useState(SUBJECT_TYPE_EVERYONE);
+ const [iss, setIss] = useState(homeIssuer);
+ const [sub, setSub] = useState("");
+ const [client, setClient] = useState("");
+ const [group, setGroup] = useState(groups[0]?.id);
+
+ useEffect(() => {
+ if (group === undefined && groups.length) {
+ setGroup(groups[0].id);
+ }
+ }, [group, groups]);
+
+ useEffect(() => {
+ if (!value) return;
+ if ("everyone" in value) {
+ setSubjectType(SUBJECT_TYPE_EVERYONE);
+ } else if ("iss" in value) {
+ setIss(value.iss);
+ if ("sub" in value) {
+ setSub(value.sub);
+ setClient("");
+ setSubjectType(SUBJECT_TYPE_ISS_SUB);
+ } else {
+ setSub("");
+ setClient(value.client);
+ setSubjectType(SUBJECT_TYPE_ISS_CLIENT);
+ }
+ } else {
+ // group
+ setGroup(value.group);
+ setSubjectType(SUBJECT_TYPE_GROUP);
+ }
+ }, [value]);
+
+ const onChangeSubjectType = useCallback(
+ (e: RadioChangeEvent) => {
+ const newSubjectType = e.target.value;
+ setSubjectType(newSubjectType);
+ handleSubjectChange(onChange, newSubjectType, iss, sub, client, group);
+ },
+ [onChange, iss, sub, client, group],
+ );
+
+ const subjectTypeOptions = useMemo(
+ () => [
+ { value: SUBJECT_TYPE_ISS_SUB, label: "Issuer URI + Subject ID" },
+ { value: SUBJECT_TYPE_ISS_CLIENT, label: "Issuer URI + Client ID" },
+ { value: SUBJECT_TYPE_GROUP, label: "Group", disabled: groups.length === 0 },
+ { value: SUBJECT_TYPE_EVERYONE, label: },
+ ],
+ [groups],
+ );
+
+ const onChangeIssuer = useCallback(
+ (e) => {
+ const newIss = e.target.value;
+ setIss(newIss);
+ handleSubjectChange(onChange, subjectType, newIss, sub, client, group);
+ },
+ [onChange, subjectType, sub, client, group],
+ );
+
+ const onChangeSubject = useCallback(
+ (e) => {
+ const newSub = e.target.value;
+ setSub(newSub);
+ handleSubjectChange(onChange, subjectType, iss, newSub, client, group);
+ },
+ [onChange, subjectType, iss, client, group],
+ );
+
+ const onChangeClient = useCallback(
+ (e) => {
+ const newClient = e.target.value;
+ setClient(newClient);
+ handleSubjectChange(onChange, subjectType, iss, sub, newClient, group);
+ },
+ [onChange, subjectType, iss, sub, group],
+ );
+
+ const groupOptions = useMemo(
+ () =>
+ groups.map((g: StoredGroup) => ({
+ value: g.id,
+ label: (
+ <>
+ {g.name} (ID: {g.id})
+ >
+ ),
+ })),
+ [groups],
+ );
+
+ const onChangeGroup = useCallback(
+ (v: number) => {
+ setGroup(v);
+ handleSubjectChange(onChange, subjectType, iss, sub, client, v);
+ },
+ [onChange, subjectType, iss, sub, client],
+ );
+
+ return (
+
+
+ {(subjectType === SUBJECT_TYPE_ISS_SUB || subjectType === SUBJECT_TYPE_ISS_CLIENT) && (
+
+
+ {subjectType === SUBJECT_TYPE_ISS_SUB ? (
+
+ ) : (
+
+ )}
+
+ )}
+ {subjectType === SUBJECT_TYPE_GROUP && (
+
+ )}
+ {subjectType === SUBJECT_TYPE_EVERYONE && (
+
+ Warning: The “Everyone” subject applies to ALL USERS, even anonymous ones,
+ e.g., bots and random visitors to the portal!
+ >
+ }
+ type="warning"
+ showIcon={true}
+ />
+ )}
+
+ );
+};
+
+type ResourceSupertype = "everything" | "project-plus";
+const RESOURCE_SUPERTYPE_EVERYTHING = "everything";
+const RESOURCE_SUPERTYPE_PROJECT_PLUS = "project-plus";
+
+const buildResource = (rt: ResourceSupertype, p: Project | null, d: Dataset | null, dt: string): GrantResource => {
+ if (rt === RESOURCE_SUPERTYPE_EVERYTHING || p === null) {
+ return RESOURCE_EVERYTHING;
+ }
+
+ const res: GrantResource = { project: p.identifier };
+
+ if (d) res["dataset"] = d?.identifier;
+ if (dt) res["data_type"] = dt;
+
+ return res;
+};
+
+type ResourceInputProps = {
+ value?: GrantResource;
+ onChange?: (value: GrantResource) => void;
+};
+
+const ResourceInput = ({ value, onChange }: ResourceInputProps) => {
+ // TODO: consolidate when useProjects() is typed
+ const projects: Project[] = useProjects().items;
+ const projectsByID: Record = useProjects().itemsByID;
+ const datasetsByID: Record = useProjects().datasetsByID;
+ const dataTypes: BentoServiceDataType[] = useDataTypes().items;
+
+ const [resourceSupertype, setResourceSupertype] = useState("everything");
+ const [selectedProject, setSelectedProject] = useState(null);
+ const [selectedDataset, setSelectedDataset] = useState(null);
+ const [selectedDataType, setSelectedDataType] = useState("");
+
+ useEffect(() => {
+ if (!value) return;
+ if ("everything" in value) {
+ setResourceSupertype(RESOURCE_SUPERTYPE_EVERYTHING);
+ } else {
+ setResourceSupertype(RESOURCE_SUPERTYPE_PROJECT_PLUS);
+ // TODO: how to handle missing projects? i.e., what if the project is deleted?
+ // - right now, it doesn't matter since we cannot edit grants, but in the future we'll need to check.
+ setSelectedProject(projectsByID[value.project]);
+ // TODO: how to handle missing datasets? i.e., what if the dataset is deleted?
+ // - right now, it doesn't matter since we cannot edit grants, but in the future we'll need to check.
+ if ("dataset" in value && value.dataset) setSelectedDataset(datasetsByID[value.dataset]);
+ if ("data_type" in value && value.data_type) setSelectedDataType(value.data_type);
+ }
+ }, [value, projectsByID, datasetsByID]);
+
+ useEffect(() => {
+ if (!selectedProject && projects.length) {
+ setSelectedProject(projects[0]);
+ }
+ }, [selectedProject, projects]);
+
+ const resourceSupertypeOptions = useMemo(
+ () => [
+ { value: RESOURCE_SUPERTYPE_EVERYTHING, label: },
+ {
+ value: RESOURCE_SUPERTYPE_PROJECT_PLUS,
+ label: "Project + Optional Dataset + Optional Data Type",
+ disabled: !projects.length,
+ },
+ ],
+ [projects],
+ );
+
+ const onChangeResourceSupertype = useCallback(
+ (e: RadioChangeEvent) => {
+ const newResourceSupertype = e.target.value;
+ setResourceSupertype(newResourceSupertype);
+ if (onChange) onChange(buildResource(newResourceSupertype, selectedProject, selectedDataset, selectedDataType));
+ },
+ [onChange, selectedProject, selectedDataset, selectedDataType],
+ );
+
+ const projectOptions = useMemo(
+ (): SelectProps["options"] =>
+ projects.map((p: Project) => ({
+ value: p.identifier,
+ label: p.title,
+ })),
+ [projects],
+ );
+
+ const onChangeProject = useCallback(
+ (v: string) => {
+ const p = projectsByID[v];
+ setSelectedProject(p);
+ setSelectedDataset(null);
+ if (onChange) onChange(buildResource(resourceSupertype, p, null, selectedDataType));
+ },
+ [projectsByID, onChange, resourceSupertype, selectedDataType],
+ );
+
+ const datasetOptions = useMemo((): SelectProps["options"] => {
+ const options: SelectProps["options"] = [{ value: "", label: "All datasets" }];
+
+ if (selectedProject) {
+ options.push(
+ ...(selectedProject.datasets ?? []).map((d) => ({
+ value: d.identifier,
+ label: d.title,
+ })),
+ );
+ }
+
+ return options;
+ }, [selectedProject]);
+
+ const onChangeDataset = useCallback(
+ (v: string) => {
+ const d = datasetsByID[v];
+ setSelectedDataset(d);
+ if (onChange) onChange(buildResource(resourceSupertype, selectedProject, d, selectedDataType));
+ },
+ [datasetsByID, onChange, resourceSupertype, selectedProject, selectedDataType],
+ );
+
+ const dataTypeOptions = useMemo(
+ (): SelectProps["options"] => [
+ { value: "", label: "All data types" },
+ ...dataTypes.map(({ id: value, label }) => ({ value, label })),
+ ],
+ [dataTypes],
+ );
+
+ const onChangeDataType = useCallback(
+ (v: string) => {
+ setSelectedDataType(v);
+ if (onChange) onChange(buildResource(resourceSupertype, selectedProject, selectedDataset, v));
+ },
+ [onChange, resourceSupertype, selectedProject, selectedDataset],
+ );
+
+ return (
+
+
+ {resourceSupertype === RESOURCE_SUPERTYPE_PROJECT_PLUS && (
+
+
+ Project:{" "}
+
+
+
+ Dataset:{" "}
+
+
+
+ Data Type:{" "}
+
+
+
+ )}
+
+ );
+};
+
+type PermissionsInputProps = {
+ id?: string;
+ value?: string[];
+ onChange?: (value: string[]) => void;
+ currentResource: GrantResource;
+};
+
+const newPermissionsDifferent = (checked: string[], newChecked: string[]): boolean => {
+ const newValueSet = new Set([...newChecked]);
+ const checkedSet = new Set([...checked]);
+
+ const difference1 = new Set(checked.filter((c) => !newValueSet.has(c)));
+ const difference2 = new Set(newChecked.filter((c) => !checkedSet.has(c)));
+
+ return !!difference1.size || !!difference2.size;
+};
+
+const permissionCompatibleWithResource = (p: PermissionDefinition, r: GrantResource) => {
+ const validDataTypeNarrowing = p.supports_data_type_narrowing || !("data_type" in r);
+
+ if (p.min_level_required === "dataset") {
+ return validDataTypeNarrowing;
+ } else if (p.min_level_required === "project") {
+ return validDataTypeNarrowing && !("dataset" in r);
+ } else if (p.min_level_required === "instance") {
+ return validDataTypeNarrowing && "everything" in r;
+ }
+
+ throw new Error(`missing handling for permissions level: ${p.min_level_required}`);
+};
+
+const PermissionsInput = ({ id, value, onChange, currentResource, ...rest }: PermissionsInputProps) => {
+ const permissions: PermissionDefinition[] = useAllPermissions().data;
+ const isFetchingPermissions = useAllPermissions().isFetching;
+ const permissionsByID = useMemo(
+ () => Object.fromEntries(permissions.map((p: PermissionDefinition) => [p.id, p])),
+ [permissions],
+ );
+
+ const [checked, setChecked] = useState([]);
+
+ const isInvalid = "aria-invalid" in rest && !!rest["aria-invalid"];
+
+ useEffect(() => {
+ // If we're in controlled mode, i.e., a value array is set, then the checked array should be set directly from the
+ // value when it changes, if the values differ from whatever the checked array currently is internally.
+ if (value !== undefined && newPermissionsDifferent(checked, value)) {
+ setChecked(value);
+ }
+ }, [checked, value]);
+
+ const handleChange = useCallback(
+ (newChecked: string[]) => {
+ const filteredNewChecked = newChecked.filter((cc) =>
+ permissionCompatibleWithResource(permissionsByID[cc], currentResource),
+ );
+ if (newPermissionsDifferent(checked, filteredNewChecked)) {
+ if (value === undefined) {
+ // If we're not in "controlled mode", i.e., we don't have a value array set, then the value should not change,
+ // so we should directly update the checked array.
+ setChecked(filteredNewChecked);
+ }
+ if (onChange) {
+ onChange(filteredNewChecked);
+ }
+ }
+ },
+ [checked, onChange, permissionsByID, currentResource, value],
+ );
+
+ useEffect(() => {
+ const filteredChecked = [...checked, ...checked.flatMap((s) => permissionsByID[s].gives)].filter((c) =>
+ permissionCompatibleWithResource(permissionsByID[c], currentResource),
+ );
+ if (newPermissionsDifferent(checked, filteredChecked)) {
+ handleChange(filteredChecked);
+ }
+ // explicitly don't have checked as a dependency; otherwise, we get an infinite loop
+ }, [currentResource]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const checkboxGroups = useMemo((): ReactNode => {
+ // TODO: use Object.groupBy when available:
+ const permissionsByNoun = permissions.reduce>((acc, p) => {
+ if (!(p.noun in acc)) acc[p.noun] = [];
+ acc[p.noun].push(p);
+ return acc;
+ }, {});
+
+ return Object.entries(permissionsByNoun)
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([k, v]) => {
+ const pByID = Object.fromEntries(v.map((p) => [p.id, p]));
+
+ const pGivenBy: Record = Object.fromEntries(
+ v.map((p) => [p.id, permissions.filter((pp: PermissionDefinition) => pp.gives.includes(p.id))]),
+ );
+
+ const groupOptions = v.map((p) => {
+ const givenBy = pGivenBy[p.id] ?? [];
+ const givenByAnother = givenBy.some((g) => checked.includes(g.id));
+ const disabled = !permissionCompatibleWithResource(p, currentResource);
+ return {
+ value: p.id,
+ label:
+ !disabled && givenByAnother ? (
+
+ Given by:{" "}
+ {givenBy.map((g, gi) => (
+
+ {g.id}
+ {gi !== givenBy.length - 1 ? ", " : ""}
+
+ ))}
+
+ }
+ >
+ {p.verb}
+
+ ) : (
+ {p.verb}
+ ),
+ disabled,
+ };
+ });
+
+ const allDisabled = groupOptions.every((g) => g.disabled);
+
+ const groupValue = checked.filter((c) => c in pByID);
+
+ const onGroupChange = (selected: string[]) => {
+ // Leave checkboxes that are not part of this check group
+ const otherChecked = checked.filter((c) => !(c in pByID));
+
+ const totalChecked = [...otherChecked, ...selected];
+ handleChange(
+ [...new Set([...totalChecked, ...totalChecked.flatMap((s) => permissionsByID[s].gives)])].filter((c) =>
+ permissionCompatibleWithResource(permissionsByID[c], currentResource),
+ ),
+ );
+ };
+
+ return (
+
+
+ {k}
+
+
+
+ );
+ });
+ }, [checked, currentResource, handleChange, isInvalid, permissions, permissionsByID]);
+
+ return (
+
+
+ {checkboxGroups}
+
+
+ );
+};
+
+const GrantForm = ({ form }: { form: FormInstance }) => {
+ const homeIssuer = useOpenIdConfig()?.issuer ?? "";
+ const defaultSubject = useMemo(() => ({ iss: homeIssuer, sub: "" }), [homeIssuer]);
+
+ const currentResource = Form.useWatch("resource", form);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default GrantForm;
diff --git a/src/components/manager/access/GrantSummary.tsx b/src/components/manager/access/GrantSummary.tsx
new file mode 100644
index 000000000..afed68986
--- /dev/null
+++ b/src/components/manager/access/GrantSummary.tsx
@@ -0,0 +1,42 @@
+import { memo } from "react";
+import { Descriptions } from "antd";
+
+import type { StoredGrant } from "@/modules/authz/types";
+
+import PermissionsList from "./PermissionsList";
+import Resource from "./Resource";
+import Subject from "./Subject";
+
+export type GrantSummaryProps = {
+ grant: StoredGrant;
+};
+
+const GrantSummary = memo(({ grant: { id, subject, resource, notes, permissions } }: GrantSummaryProps) => (
+ ,
+ },
+ {
+ label: "Resource",
+ children: ,
+ },
+ {
+ label: "Notes",
+ children: notes,
+ },
+ {
+ label: "Permissions",
+ children: ,
+ },
+ ]}
+ />
+));
+
+export default GrantSummary;
diff --git a/src/components/manager/access/GrantsTabContent.js b/src/components/manager/access/GrantsTabContent.js
index fb39cc4f6..a85ffa4d5 100644
--- a/src/components/manager/access/GrantsTabContent.js
+++ b/src/components/manager/access/GrantsTabContent.js
@@ -1,106 +1,161 @@
-import React, { useMemo } from "react";
-import { useSelector } from "react-redux";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import PropTypes from "prop-types";
-import { Popover, Table } from "antd";
-// import { PlusOutlined } from "@ant-design/icons";
+import { Button, Form, Modal, Typography } from "antd";
+import { DeleteOutlined, PlusOutlined } from "@ant-design/icons";
-import { RESOURCE_EVERYTHING } from "bento-auth-js";
-import { useResourcePermissionsWrapper } from "@/hooks";
+import { editPermissions, makeResourceKey } from "bento-auth-js";
-import { stringifyJSONRenderIfMultiKey, rowKey } from "./utils";
+import ActionContainer from "@/components/manager/ActionContainer";
+import { createGrant, deleteGrant } from "@/modules/authz/actions";
+import { useAuthzManagementPermissions, useGrants } from "@/modules/authz/hooks";
+import { useServices } from "@/modules/services/hooks";
+import { useAppDispatch } from "@/store";
-// import ActionContainer from "../ActionContainer";
-import PermissionsList from "./PermissionsList";
-import Subject from "./Subject";
+import GrantForm from "./GrantForm";
+import GrantSummary from "./GrantSummary";
+import GrantsTable from "./GrantsTable";
+
+const GrantCreationModal = ({ open, closeModal }) => {
+ const dispatch = useAppDispatch();
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ // Instead of resetting fields on close/finish, reset on next open to avoid
+ // a re-render/sudden-form-change hiccup.
+ form.resetFields();
+ }
+ }, [form, open]);
+
+ const onOk = useCallback(() => {
+ setLoading(true);
+ form
+ .validateFields()
+ .then(async (values) => {
+ console.debug("received grant values for creation:", values);
+ await dispatch(createGrant(values));
+ closeModal();
+ // Form will be reset upon next open.
+ })
+ .catch((err) => {
+ console.error(err);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [dispatch, form, closeModal]);
+
+ return (
+
+
+
+ );
+};
+GrantCreationModal.propTypes = {
+ open: PropTypes.bool,
+ closeModal: PropTypes.func,
+};
const GrantsTabContent = () => {
- const isFetchingAllServices = useSelector((state) => state.services.isFetchingAll);
-
- const { data: grants, isFetching: isFetchingGrants } = useSelector(state => state.grants);
- const { data: groups } = useSelector(state => state.groups);
-
- const groupsByID = useMemo(() => Object.fromEntries(groups.map(g => [g.id, g])), [groups]);
-
- const {
- // permissions,
- isFetchingPermissions,
- // hasAttemptedPermissions,
- } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
- // const hasEditPermission = permissions.includes(editPermissions);
-
- const grantsColumns = useMemo(() => [
- {
- title: "ID",
- dataIndex: "id",
- width: 42, // Effectively a minimum width, but means the ID column doesn't take up a weird amount of space
- },
- {
- title: "Subject",
- dataIndex: "subject",
- render: (subject) => (
-
- ),
- },
- {
- title: "Resource",
- dataIndex: "resource",
- render: (resource) => {
- if (resource.everything) {
- return Everything ;
- }
- return {stringifyJSONRenderIfMultiKey(resource)} ;
+ const dispatch = useAppDispatch();
+
+ const isFetchingAllServices = useServices().isFetchingAll;
+
+ const { data: grants, isFetching: isFetchingGrants } = useGrants();
+
+ const {
+ isFetching: isFetchingPermissions,
+ hasAtLeastOneEditPermissionsGrant,
+ grantResourcePermissionsObjects,
+ } = useAuthzManagementPermissions();
+
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const openCreateModal = useCallback(() => setCreateModalOpen(true), []);
+ const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
+ const [deleteModal, deleteModalContextHolder] = Modal.useModal();
+
+ const extraColumns = useMemo(
+ () =>
+ hasAtLeastOneEditPermissionsGrant
+ ? [
+ {
+ title: "Actions",
+ key: "actions",
+ // TODO: hook up edit
+ render: (grant) => {
+ const pObj = grantResourcePermissionsObjects[makeResourceKey(grant.resource)];
+ const pLoading = pObj.isFetching;
+ const canEdit = pObj.permissions.includes(editPermissions);
+ return (
+ <>
+ {/*TODO: no edit grant right now; originally designed to be immutable but this should change*/}
+ {/* } loading={pLoading} disabled={!canEdit}>*/}
+ {/* Edit{" "}*/}
+ }
+ loading={pLoading}
+ disabled={!canEdit}
+ onClick={() => {
+ deleteModal.confirm({
+ title: <>Are you sure you wish to delete grant {grant.id}?>,
+ content: (
+ <>
+
+ Doing so will alter who can view or manipulate data inside this Bento instance, and may
+ even affect your own access!
+
+ setCreateModalOpen(false)} />
+ >
+ ),
+ okButtonProps: { danger: true, icon: },
+ okText: "Delete",
+ onOk: () => dispatch(deleteGrant(grant)),
+ width: 600,
+ maskClosable: true,
+ });
+ }}
+ >
+ Delete
+
+ >
+ );
+ },
},
- },
- {
- title: "Expiry",
- dataIndex: "expiry",
- render: (expiry) => {expiry ?? "—"} ,
- },
- {
- title: "Notes",
- dataIndex: "notes",
- },
- {
- title: "Permissions",
- dataIndex: "permissions",
- render: (permissions) => ,
- },
- // TODO: enable when this becomes more than a viewer
- // ...(hasEditPermission ? [
- // {
- // title: "Actions",
- // key: "actions",
- // // TODO: hook up edit + delete
- // render: () => (
- // <>
- // Edit {" "}
- // Delete
- // >
- // ),
- // },
- // ] : []),
- ], [groupsByID/*, hasEditPermission*/]);
-
- return (
- <>
- {/*{hasEditPermission && (*/}
- {/* */}
- {/* }*/}
- {/* loading={isFetchingPermissions || isFetchingGrants}>*/}
- {/* Create Grant*/}
- {/* */}
- {/* */}
- {/*)}*/}
-
- >
- );
+ ]
+ : [],
+ [dispatch, grantResourcePermissionsObjects, hasAtLeastOneEditPermissionsGrant, deleteModal],
+ );
+
+ return (
+ <>
+ {deleteModalContextHolder}
+ {hasAtLeastOneEditPermissionsGrant && (
+
+ } loading={isFetchingPermissions || isFetchingGrants} onClick={openCreateModal}>
+ Create Grant
+
+
+ )}
+
+
+ >
+ );
};
export default GrantsTabContent;
diff --git a/src/components/manager/access/GrantsTable.tsx b/src/components/manager/access/GrantsTable.tsx
new file mode 100644
index 000000000..c5c398aa0
--- /dev/null
+++ b/src/components/manager/access/GrantsTable.tsx
@@ -0,0 +1,68 @@
+import React, { useMemo } from "react";
+
+import { Table, type TableColumnsType } from "antd";
+
+import type { StoredGrant } from "@/modules/authz/types";
+
+import ExpiryTimestamp from "./ExpiryTimestamp";
+import PermissionsList from "./PermissionsList";
+import Resource from "./Resource";
+import Subject from "./Subject";
+import { rowKey } from "./utils";
+
+export type GrantsTableProps = {
+ grants: StoredGrant[];
+ loading?: boolean;
+ extraColumns?: TableColumnsType;
+};
+
+const GrantsTable = ({ grants, loading, extraColumns }: GrantsTableProps) => {
+ const grantsColumns = useMemo(
+ (): TableColumnsType => [
+ {
+ title: "ID",
+ dataIndex: "id",
+ width: 42, // Effectively a minimum width, but means the ID column doesn't take up a weird amount of space
+ },
+ {
+ title: "Subject",
+ dataIndex: "subject",
+ render: (subject) => ,
+ },
+ {
+ title: "Resource",
+ dataIndex: "resource",
+ render: (resource) => ,
+ },
+ {
+ title: "Expiry",
+ dataIndex: "expiry",
+ render: (expiry) => ,
+ },
+ {
+ title: "Notes",
+ dataIndex: "notes",
+ },
+ {
+ title: "Permissions",
+ dataIndex: "permissions",
+ render: (permissions) => ,
+ },
+ ...(extraColumns ?? []),
+ ],
+ [extraColumns],
+ );
+
+ return (
+
+ size="middle"
+ bordered={true}
+ columns={grantsColumns}
+ dataSource={grants}
+ rowKey={rowKey}
+ loading={loading}
+ />
+ );
+};
+
+export default GrantsTable;
diff --git a/src/components/manager/access/GroupForm.tsx b/src/components/manager/access/GroupForm.tsx
new file mode 100644
index 000000000..5d7b14dad
--- /dev/null
+++ b/src/components/manager/access/GroupForm.tsx
@@ -0,0 +1,295 @@
+import { type ChangeEvent, useCallback, useEffect, useState } from "react";
+
+import {
+ Button,
+ Card,
+ Divider,
+ Form,
+ type FormRule,
+ type FormInstance,
+ Input,
+ List,
+ Radio,
+ type RadioChangeEvent,
+ type RadioGroupProps,
+} from "antd";
+import { PlusOutlined } from "@ant-design/icons";
+
+import { useOpenIdConfig } from "bento-auth-js";
+
+import MonospaceText from "@/components/common/MonospaceText";
+import type { Group, GroupMembership, SpecificSubject } from "@/modules/authz/types";
+
+import ExpiryInput from "./ExpiryInput";
+import Subject from "./Subject";
+import type { InputChangeEventHandler } from "./types";
+
+const MEMBERSHIP_TYPE_OPTIONS: RadioGroupProps["options"] = [
+ { value: "list", label: "Subject / Client List" },
+ { value: "expr", label: "Expression (Bento Query JSON Format)" },
+];
+
+const SPECIFIC_SUBJECT_TYPE_OPTIONS: RadioGroupProps["options"] = [
+ { value: "sub", label: "Issuer + Subject" },
+ { value: "client", label: "Issuer + Client" },
+];
+
+const buildMembership = (
+ membershipType: "expr" | "list",
+ expr: string,
+ members: SpecificSubject[],
+): GroupMembership => {
+ if (membershipType === "expr") {
+ return { expr: JSON.parse(expr) };
+ } else {
+ return { members };
+ }
+};
+
+const isValidJSONArray = (x: string): boolean => {
+ if (x) {
+ try {
+ const y = JSON.parse(x);
+ return Array.isArray(y);
+ } catch (e) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+};
+
+type MembershipInputProps = {
+ onChange?: (v: GroupMembership) => void;
+ value?: GroupMembership;
+};
+
+const MembershipInput = ({ value, onChange, ...rest }: MembershipInputProps) => {
+ const homeIssuer = useOpenIdConfig()?.issuer ?? "";
+
+ const [membershipType, setMembershipType] = useState<"expr" | "list">("list");
+
+ const [members, setMembers] = useState([]);
+ const [expr, setExpr] = useState("");
+ const [exprIsValidJSONArray, setExprIsValidJSONArray] = useState(isValidJSONArray(expr));
+
+ const [memberAddMode, setMemberAddMode] = useState<"sub" | "client">("sub");
+ const [iss, setIss] = useState(homeIssuer);
+ const [issErrorReady, setIssErrorReady] = useState(false);
+ const [subOrClient, setSubOrClient] = useState("");
+ const [subOrClientErrorReady, setSubOrClientErrorReady] = useState(false);
+
+ useEffect(() => {
+ if (value) {
+ if ("expr" in value) {
+ setMembershipType("expr");
+ setExpr(JSON.stringify(value.expr));
+ } else {
+ setMembershipType("list");
+ setMembers(value.members);
+ }
+ }
+ }, [value]);
+
+ const onChangeMembershipType = useCallback(
+ (e: RadioChangeEvent) => {
+ const newMembershipType = e.target.value;
+ setMembershipType(newMembershipType);
+ if (onChange && (newMembershipType === "list" || exprIsValidJSONArray)) {
+ onChange(buildMembership(newMembershipType, expr, members));
+ }
+ },
+ [onChange, expr, exprIsValidJSONArray, members],
+ );
+
+ const onChangeExpr = useCallback(
+ (e) => {
+ const newExpr = e.target.value;
+ setExpr(newExpr);
+
+ const isValid = isValidJSONArray(newExpr);
+ setExprIsValidJSONArray(isValid);
+
+ if (onChange && isValid) {
+ onChange(buildMembership(membershipType, newExpr, members));
+ }
+ },
+ [onChange, membershipType, members],
+ );
+
+ const onChangeMembers = useCallback(
+ (v: SpecificSubject[]) => {
+ setMembers(v);
+ if (onChange && (membershipType === "list" || exprIsValidJSONArray)) {
+ onChange(buildMembership(membershipType, expr, v));
+ }
+ },
+ [onChange, membershipType, exprIsValidJSONArray, expr],
+ );
+
+ const memberListRenderItem = useCallback(
+ (item: SpecificSubject, idx: number) => (
+
+ {
+ const newMembers = [...members];
+ newMembers.splice(idx, 1);
+ onChangeMembers(newMembers);
+ }}
+ />
+
+ ),
+ [members, onChangeMembers],
+ );
+
+ const onChangeMemberAddMode = useCallback((e: RadioChangeEvent) => {
+ setMemberAddMode(e.target.value);
+ setSubOrClient("");
+ }, []);
+
+ const onChangeIssuer = useCallback((e: ChangeEvent) => {
+ setIss(e.target.value);
+ setIssErrorReady(true);
+ }, []);
+ const onChangeSubOrClient = useCallback((e: ChangeEvent) => {
+ setSubOrClient(e.target.value);
+ setSubOrClientErrorReady(true);
+ }, []);
+
+ const onAddMember = useCallback(() => {
+ const issProcessed = iss.trim();
+ const subOrClientProcessed = subOrClient.trim();
+
+ if (!issProcessed) setIssErrorReady(true);
+ if (!subOrClientProcessed) setSubOrClientErrorReady(true);
+ if (!issProcessed || !subOrClientProcessed) return;
+
+ onChangeMembers([
+ ...members,
+ {
+ iss: issProcessed,
+ ...(memberAddMode === "sub" ? { sub: subOrClientProcessed } : { client: subOrClientProcessed }),
+ },
+ ]);
+
+ setSubOrClient("");
+ setIssErrorReady(false);
+ setSubOrClientErrorReady(false);
+ }, [members, onChangeMembers, memberAddMode, iss, subOrClient]);
+
+ return (
+
+
+
+ {membershipType === "list" ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
} onClick={onAddMember}>
+ Add
+
+
+ >
+ ) : (
+
+
+ An expression to be evaluated on a subset of fields from a decoded JWT. For example, an issuer/subject
+ check could be implemented as follows:
+
+
+ ["#and",
+ ["#eq", ["#resolve", "iss"],
+ "iss-value"],
+ ["#eq", ["#resolve", "sub"],
+ "sub-value"]]
+
+
+ Fields which can be resolved are as follows:
+ iss, sub, azp, exp, iat, typ, scope
+
+ >
+ }
+ style={{ marginBottom: 0 }}
+ >
+
+
+ )}
+
+
+ );
+};
+
+const MEMBERSHIP_VALIDATOR_RULES: FormRule[] = [
+ {
+ validator: (_r, v: GroupMembership) => {
+ if ("expr" in v) {
+ const expr = v.expr;
+ if (!(Array.isArray(expr) && expr.length >= 3)) {
+ // 3 is minimum length for a relevant expression here.
+ console.error("Invalid membership expression value:", v);
+ return Promise.reject("Membership expression should be a valid Bento Query-formatted JSON array.");
+ }
+ } else {
+ if (v.members.length === 0) {
+ return Promise.reject("Membership list must have at least one entry.");
+ }
+ }
+ return Promise.resolve();
+ },
+ },
+];
+
+type GroupFormProps = {
+ form: FormInstance;
+};
+
+const GroupForm = ({ form }: GroupFormProps) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default GroupForm;
diff --git a/src/components/manager/access/GroupsTabContent.js b/src/components/manager/access/GroupsTabContent.js
deleted file mode 100644
index baf1a1bf5..000000000
--- a/src/components/manager/access/GroupsTabContent.js
+++ /dev/null
@@ -1,146 +0,0 @@
-import React, { useState } from "react";
-import { useSelector } from "react-redux";
-import PropTypes from "prop-types";
-
-import { Button, List, Modal, Table } from "antd";
-// import { PlusOutlined } from "@ant-design/icons";
-
-import { RESOURCE_EVERYTHING } from "bento-auth-js";
-import { useResourcePermissionsWrapper } from "@/hooks";
-
-import Subject from "./Subject";
-
-import { rowKey } from "./utils";
-
-const GroupMembershipCell = ({ group }) => {
- const [modalOpen, setModalOpen] = useState(false);
-
- const { id, name, membership } = group;
- const { expr, members: membersList } = membership;
-
- if (expr) {
- return (
- <>
- Expression:
- {expr}
- >
- );
- }
-
- if (membersList.length === 0) {
- return 0 entries ;
- }
-
- return (
- <>
- setModalOpen(false)}
- title={`Group: ${name} (ID: ${id}) - Membership`}
- footer={null}
- width={768}
- >
- } />
-
- setModalOpen(true)}>
- {membersList.length} {membersList.length === 1 ? "entry" : "entries"}
-
- >
- );
-};
-GroupMembershipCell.propTypes = {
- group: PropTypes.shape({
- id: PropTypes.number.isRequired,
- name: PropTypes.string.isRequired,
- membership: PropTypes.oneOfType([
- PropTypes.shape({
- expr: PropTypes.array,
- }),
- PropTypes.shape({
- members: PropTypes.arrayOf(PropTypes.oneOfType([
- PropTypes.shape({
- iss: PropTypes.string,
- sub: PropTypes.string,
- }),
- PropTypes.shape({
- iss: PropTypes.string,
- client: PropTypes.string,
- }),
- ])),
- }),
- ]).isRequired,
- }).isRequired,
-};
-
-const GROUPS_COLUMNS = [
- {
- title: "ID",
- dataIndex: "id",
- width: 42, // Effectively a minimum width, but means the ID column doesn't take up a weird amount of space
- render: (id) => {id} ,
- },
- {
- title: "Name",
- dataIndex: "name",
- },
- {
- title: "Membership",
- dataIndex: "membership",
- render: (_, group) => ,
- },
- {
- title: "Expiry",
- dataIndex: "expiry",
- render: (expiry) => {expiry ?? "—"} ,
- },
- {
- title: "Notes",
- dataIndex: "notes",
- },
- // TODO: enable when this becomes more than a viewer
- // {
- // title: "Actions",
- // key: "actions",
- // // TODO: hook up delete
- // render: () => (
- // Delete
- // ),
- // },
-];
-
-const GroupsTabContent = () => {
- const isFetchingAllServices = useSelector((state) => state.services.isFetchingAll);
- const { data: groups, isFetching: isFetchingGroups } = useSelector(state => state.groups);
-
- const {
- // permissions,
- isFetchingPermissions,
- // hasAttemptedPermissions,
- } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
- // const hasEditPermission = permissions.includes(editPermissions);
-
- return (
- <>
- {/**/}
- {/* {hasEditPermission && (*/}
- {/* }*/}
- {/* loading={isFetchingPermissions || isFetchingGroups}>*/}
- {/* Create Group*/}
- {/* */}
- {/* )}*/}
- {/* */}
- {/* No pagination on this table, so we can link to all group ID anchors: */}
-
- >
- );
-};
-
-export default GroupsTabContent;
diff --git a/src/components/manager/access/GroupsTabContent.tsx b/src/components/manager/access/GroupsTabContent.tsx
new file mode 100644
index 000000000..e20f8891f
--- /dev/null
+++ b/src/components/manager/access/GroupsTabContent.tsx
@@ -0,0 +1,366 @@
+import { type CSSProperties, useCallback, useEffect, useMemo, useState } from "react";
+
+import { Button, Form, List, Modal, Table, type TableColumnsType, Typography } from "antd";
+import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
+
+import { editPermissions, RESOURCE_EVERYTHING } from "bento-auth-js";
+import { useResourcePermissionsWrapper } from "@/hooks";
+
+import ActionContainer from "@/components/manager/ActionContainer";
+import { createGroup, deleteGroup, invalidateGroups, saveGroup } from "@/modules/authz/actions";
+import { useGrants, useGroups } from "@/modules/authz/hooks";
+import type { Group, SpecificSubject, StoredGrant, StoredGroup } from "@/modules/authz/types";
+import { useServices } from "@/modules/services/hooks";
+import { useAppDispatch } from "@/store";
+
+import ExpiryTimestamp from "./ExpiryTimestamp";
+import GrantsTable from "./GrantsTable";
+import GroupForm from "./GroupForm";
+import Subject from "./Subject";
+import { rowKey } from "./utils";
+
+const groupMemberListRender = (item: SpecificSubject) => (
+
+
+
+);
+
+const GroupMembershipCell = ({ group }: { group: StoredGroup }) => {
+ const [modalOpen, setModalOpen] = useState(false);
+ const openMembersModal = useCallback(() => setModalOpen(true), []);
+ const closeMembersModal = useCallback(() => setModalOpen(false), []);
+
+ const { id, name, membership } = group;
+
+ if ("expr" in membership) {
+ return (
+ <>
+ Expression:
+ {JSON.stringify(membership.expr)}
+ >
+ );
+ }
+
+ const { members: membersList } = membership;
+
+ if (membersList.length === 0) {
+ return (
+
+ 0 entries
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+ {membersList.length} {membersList.length === 1 ? "entry" : "entries"}
+
+ >
+ );
+};
+
+type DependentGrantsCellProps = {
+ groupGrants: Record;
+ group: StoredGroup;
+};
+
+const GRANTS_MODAL_STYLE: CSSProperties = { maxWidth: 1400 };
+
+const DependentGrantsCell = ({ groupGrants, group }: DependentGrantsCellProps) => {
+ const [modalOpen, setModalOpen] = useState(false);
+ const openGrantsModal = useCallback(() => setModalOpen(true), []);
+ const closeGrantsModal = useCallback(() => setModalOpen(false), []);
+
+ const { id, name } = group;
+ const dependentGrants = groupGrants[id] ?? [];
+
+ return (
+ <>
+
+
+
+
+ {dependentGrants.length} {dependentGrants.length === 1 ? "grant" : "grants"}
+
+ >
+ );
+};
+
+const GROUP_MODAL_WIDTH = 800;
+
+const GroupCreationModal = ({ open, closeModal }: { open: boolean; closeModal: () => void }) => {
+ const dispatch = useAppDispatch();
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ // Instead of resetting fields on close/finish, reset on next open to avoid
+ // a re-render/sudden-form-change hiccup.
+ form.resetFields();
+ }
+ }, [form, open]);
+
+ const onOk = useCallback(() => {
+ setLoading(true);
+ form
+ .validateFields()
+ .then(async (values) => {
+ console.debug("received group values for creation:", values);
+ await dispatch(createGroup(values));
+ closeModal();
+ // Form will be reset upon next open.
+ })
+ .catch((err) => {
+ console.error(err);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [dispatch, form, closeModal]);
+
+ return (
+
+
+
+ );
+};
+
+type GroupEditModalProps = {
+ group: StoredGroup | null;
+ open: boolean;
+ closeModal: () => void;
+};
+
+const GroupEditModal = ({ group, open, closeModal }: GroupEditModalProps) => {
+ const dispatch = useAppDispatch();
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (group) {
+ form.setFieldsValue(group);
+ }
+ }, [form, group]);
+
+ const name: string = Form.useWatch("name", form);
+
+ const onOk = useCallback(() => {
+ setLoading(true);
+ form
+ .validateFields()
+ .then(async (values) => {
+ console.debug("received group values for saving:", values);
+ await dispatch(saveGroup({ ...group, ...values }));
+ closeModal();
+ })
+ .catch((err) => {
+ console.error(err);
+ })
+ .finally(() => {
+ // the PUT request to authorization returns no content, so:
+ // - on success, refresh all groups to get new data.
+ // - on error, refresh all groups to revert optimistically-updated values.
+ dispatch(invalidateGroups());
+ setLoading(false);
+ });
+ }, [dispatch, form, group, closeModal]);
+
+ return (
+
+
+
+ );
+};
+
+const GroupsTabContent = () => {
+ const dispatch = useAppDispatch();
+
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const openCreateModal = useCallback(() => setCreateModalOpen(true), []);
+ const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
+
+ const [editModalOpen, setEditModalOpen] = useState(false);
+ const closeEditModal = useCallback(() => setEditModalOpen(false), []);
+
+ const [selectedGroup, setSelectedGroup] = useState(null);
+
+ const [modal, contextHolder] = Modal.useModal();
+
+ const isFetchingAllServices = useServices().isFetchingAll;
+
+ const { data: grants, isFetching: isFetchingGrants } = useGrants();
+ const { data: groups, isFetching: isFetchingGroups } = useGroups();
+
+ const groupGrants = useMemo(() => {
+ const res: Record = {};
+
+ // TODO: future: replace with Object.groupBy
+ grants.forEach((g: StoredGrant) => {
+ if (!("group" in g.subject)) return;
+ const groupID = g.subject.group;
+ if (!(groupID in res)) {
+ res[groupID] = [g];
+ } else {
+ res[groupID].push(g);
+ }
+ });
+
+ return res;
+ }, [grants]);
+
+ const { permissions, isFetchingPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
+
+ // Right now, we don't have a way to scope groups to projects - so require { resource: everything } to create.
+ const hasEditPermission = permissions.includes(editPermissions);
+
+ const columns = useMemo(
+ (): TableColumnsType => [
+ {
+ title: "ID",
+ dataIndex: "id",
+ width: 42, // Effectively a minimum width, but means the ID column doesn't take up a weird amount of space
+ render: (id) => {id} ,
+ },
+ {
+ title: "Name",
+ dataIndex: "name",
+ },
+ {
+ title: "Membership",
+ dataIndex: "membership",
+ render: (_, group) => ,
+ },
+ {
+ title: "Dependent Grants",
+ key: "grants",
+ render: (group) => ,
+ },
+ {
+ title: "Expiry",
+ dataIndex: "expiry",
+ render: (expiry) => ,
+ },
+ {
+ title: "Notes",
+ dataIndex: "notes",
+ },
+ ...(hasEditPermission
+ ? ([
+ {
+ title: "Actions",
+ key: "actions",
+ // TODO: hook up delete
+ render: (group) => (
+ <>
+ }
+ disabled={editModalOpen}
+ onClick={() => {
+ setSelectedGroup(group);
+ setEditModalOpen(true);
+ }}
+ >
+ Edit
+ {" "}
+ }
+ onClick={() => {
+ const nGrants = groupGrants[group.id]?.length ?? 0;
+ const grantNoun = `grant${nGrants === 1 ? "" : "s"}`;
+
+ modal.confirm({
+ title: (
+ <>
+ Are you sure you wish to delete group “{group.name}” (ID: {group.id}), as well
+ as {nGrants} dependent {grantNoun}?
+ >
+ ),
+ content: (
+
+ Doing so will alter who can view or manipulate data inside this Bento instance, and may even
+ affect your own access!
+
+ ),
+ okButtonProps: { danger: true, icon: },
+ okText: `Delete group and ${nGrants} ${grantNoun}`,
+ onOk: () => dispatch(deleteGroup(group)),
+ width: 600,
+ maskClosable: true,
+ });
+ }}
+ >
+ Delete
+
+ >
+ ),
+ },
+ ] as TableColumnsType)
+ : []),
+ ],
+ [dispatch, modal, hasEditPermission, groupGrants, editModalOpen],
+ );
+
+ return (
+ <>
+ {contextHolder}
+
+ {hasEditPermission && (
+ } loading={isFetchingPermissions || isFetchingGroups} onClick={openCreateModal}>
+ Create Group
+
+ )}
+
+
+
+ {/* No pagination on this table, so we can link to all group ID anchors: */}
+
+ size="middle"
+ bordered={true}
+ pagination={false}
+ columns={columns}
+ dataSource={groups}
+ rowKey={rowKey}
+ loading={isFetchingAllServices || isFetchingPermissions || isFetchingGrants || isFetchingGroups}
+ />
+ >
+ );
+};
+
+export default GroupsTabContent;
diff --git a/src/components/manager/access/ManagerAccessContent.tsx b/src/components/manager/access/ManagerAccessContent.tsx
index c57c6aa6c..7402673ed 100644
--- a/src/components/manager/access/ManagerAccessContent.tsx
+++ b/src/components/manager/access/ManagerAccessContent.tsx
@@ -1,4 +1,3 @@
-import React from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Layout } from "antd";
@@ -8,14 +7,14 @@ import { LAYOUT_CONTENT_STYLE } from "@/styles/layoutContent";
import AccessTabs from "./AccessTabs";
const ManagerAccessContent = () => (
-
-
-
- } />
- } />
-
-
-
+
+
+
+ } />
+ } />
+
+
+
);
export default ManagerAccessContent;
diff --git a/src/components/manager/access/PermissionsList.tsx b/src/components/manager/access/PermissionsList.tsx
index 826edb595..55a920a30 100644
--- a/src/components/manager/access/PermissionsList.tsx
+++ b/src/components/manager/access/PermissionsList.tsx
@@ -1,56 +1,61 @@
-import React, { useCallback, useMemo, useState } from "react";
-import type { CSSProperties, MouseEventHandler } from "react";
-import PropTypes from "prop-types";
+import { type CSSProperties, type MouseEventHandler, useCallback, useMemo, useState } from "react";
import { Typography } from "antd";
const PERMISSIONS_LIST_STYLE: CSSProperties = { margin: 0, padding: 0, listStyle: "none", lineHeight: "1.6em" };
const MAX_COLLAPSED_PERMISSIONS = 4;
-const PermissionsList = ({ permissions }: { permissions: string[] }) => {
- const [showAll, setShowAll] = useState(false);
-
- const sortedPermissions = useMemo(
- () => permissions.sort((a, b) => {
- const as = a.split(":");
- const bs = b.split(":");
- return as[1].localeCompare(bs[1]) || as[0].localeCompare(bs[0]);
- }),
- [permissions]);
-
- const onShowAll: MouseEventHandler = useCallback((e) => {
- setShowAll(true);
- e.preventDefault();
- }, []);
-
- const onCollapse: MouseEventHandler = useCallback((e) => {
- setShowAll(false);
- e.preventDefault();
- }, []);
-
- return (
-
- );
+type PermissionsListProps = {
+ permissions: string[];
};
-PermissionsList.propTypes = {
- permissions: PropTypes.arrayOf(PropTypes.string).isRequired,
+
+const PermissionsList = ({ permissions }: PermissionsListProps) => {
+ const [showAll, setShowAll] = useState(false);
+
+ const sortedPermissions = useMemo(
+ () =>
+ permissions.sort((a, b) => {
+ const as = a.split(":");
+ const bs = b.split(":");
+ return as[1].localeCompare(bs[1]) || as[0].localeCompare(bs[0]);
+ }),
+ [permissions],
+ );
+
+ const onShowAll: MouseEventHandler = useCallback((e) => {
+ setShowAll(true);
+ e.preventDefault();
+ }, []);
+
+ const onCollapse: MouseEventHandler = useCallback((e) => {
+ setShowAll(false);
+ e.preventDefault();
+ }, []);
+
+ return (
+
+ );
};
export default PermissionsList;
diff --git a/src/components/manager/access/Resource.tsx b/src/components/manager/access/Resource.tsx
new file mode 100644
index 000000000..228bffb67
--- /dev/null
+++ b/src/components/manager/access/Resource.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { Popover } from "antd";
+import type { Resource } from "@/modules/authz/types";
+import MonospaceText from "@/components/common/MonospaceText";
+import ProjectTitleDisplay from "@/components/manager/ProjectTitleDisplay";
+import DatasetTitleDisplay from "@/components/manager/DatasetTitleDisplay";
+
+export type ResourceProps = {
+ resource: Resource;
+};
+
+const Resource = ({ resource }: ResourceProps) => {
+ if ("everything" in resource) {
+ return Everything ;
+ }
+
+ return (
+
+ Project:
+ {resource.dataset && (
+ <>
+
+ Dataset:
+ >
+ )}
+ {resource.data_type && (
+ <>
+
+ Data Type: {resource.data_type}
+ >
+ )}
+
+ );
+};
+
+export default Resource;
diff --git a/src/components/manager/access/Subject.js b/src/components/manager/access/Subject.js
deleted file mode 100644
index 14aac7dfe..000000000
--- a/src/components/manager/access/Subject.js
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from "react";
-import { useSelector } from "react-redux";
-import { Link } from "react-router-dom";
-import PropTypes from "prop-types";
-
-import { Popover, Typography } from "antd";
-
-import { stringifyJSONRenderIfMultiKey } from "./utils";
-
-const Subject = ({ subject, groupsByID }) => {
- const currentIDToken = useSelector((state) => state.auth.idTokenContents);
-
- const { sub, client, iss, group, everyone } = subject;
-
- /*
- There are four possible configurations of a subject:
- - { iss: "", sub: "" } (1)
- - { iss: "", client: "" } (2)
- - { group: "" } --> group itself contains either an expression or a list of (1) and/or (2)
- - { everyone: true }
- */
-
- if (sub || client) {
- return (
-
- {sub ? "Subject" : "Client"}: {" "}
- {sub ?? client}
- Issuer: {" "}
- {iss}
- {(sub === currentIDToken?.sub && iss === currentIDToken?.iss) ? <>(this is you) > : null}
-
- );
- } else if (group) {
- const groupDef = groupsByID[group];
- return (
- <>
- Group: {" "}
-
- {groupDef
- ? (<>{groupDef.name} (ID: {group})>)
- : (<>ID: {group}>)}
-
- >
- );
- } else if (everyone) {
- return (
- Everyone
- );
- }
-
- // Base case
- return (
- {stringifyJSONRenderIfMultiKey(subject)}
- );
-};
-Subject.defaultProps = {
- groupsByID: {},
-};
-Subject.propTypes = {
- subject: PropTypes.oneOfType([
- // Combinations: sub+iss, client+iss, group, everyone
- PropTypes.shape({ sub: PropTypes.string, iss: PropTypes.string }),
- PropTypes.shape({ client: PropTypes.string, iss: PropTypes.string }),
- PropTypes.shape({ group: PropTypes.number }),
- PropTypes.shape({ everyone: PropTypes.oneOf([true]) }),
- ]),
- groupsByID: PropTypes.objectOf(PropTypes.shape({
- name: PropTypes.string,
- membership: PropTypes.oneOfType([
- PropTypes.shape({expr: PropTypes.array}),
- PropTypes.shape({
- membership: PropTypes.arrayOf(PropTypes.oneOfType([
- // Combinations: sub+iss, client+iss
- PropTypes.shape({ sub: PropTypes.string, iss: PropTypes.string }),
- PropTypes.shape({ client: PropTypes.string, iss: PropTypes.string }),
- ])),
- }),
- ]),
- expiry: PropTypes.string,
- notes: PropTypes.string,
- })),
-};
-
-export default Subject;
diff --git a/src/components/manager/access/Subject.tsx b/src/components/manager/access/Subject.tsx
new file mode 100644
index 000000000..37b5dc2cd
--- /dev/null
+++ b/src/components/manager/access/Subject.tsx
@@ -0,0 +1,96 @@
+import type { CSSProperties } from "react";
+import { Link } from "react-router-dom";
+
+import { Button, Popover, Typography } from "antd";
+import { CloseOutlined } from "@ant-design/icons";
+import { useAuthState } from "bento-auth-js";
+
+import { useGroupsByID } from "@/modules/authz/hooks";
+import type { GrantSubject, StoredGroup } from "@/modules/authz/types";
+import { stringifyJSONRenderIfMultiKey } from "./utils";
+
+type InnerSubjectProps = {
+ subject: GrantSubject;
+ boldLabel?: boolean;
+};
+
+const InnerSubject = ({ subject, boldLabel }: InnerSubjectProps) => {
+ const { idTokenContents: currentIDToken } = useAuthState();
+
+ const groupsByID: Record = useGroupsByID();
+
+ const renderAsBold = boldLabel ?? true; // default to true
+ const labelStyle = { fontWeight: renderAsBold ? "bold" : "normal" };
+
+ /*
+ There are four possible configurations of a subject:
+ - { iss: "", sub: "" } (1)
+ - { iss: "", client: "" } (2)
+ - { group: } --> group itself contains either an expression or a list of (1) and/or (2)
+ - { everyone: true }
+ */
+
+ if ("iss" in subject) {
+ const { iss } = subject;
+ const isSub = "sub" in subject;
+ return (
+
+ {isSub ? "Subject" : "Client"}: {" "}
+ {isSub ? subject.sub : subject.client}
+
+ Issuer: {iss}
+
+ {isSub && subject.sub === currentIDToken?.sub && iss === currentIDToken?.iss ? (this is you) : null}
+
+ );
+ } else if ("group" in subject) {
+ const { group } = subject;
+ const groupDef = groupsByID[subject.group];
+ return (
+ <>
+ Group: {" "}
+
+ {groupDef ? (
+ <>
+ {groupDef.name} (ID: {group})
+ >
+ ) : (
+ <>ID: {group}>
+ )}
+
+ >
+ );
+ } else if ("everyone" in subject) {
+ return (
+
+ Everyone
+
+ );
+ }
+
+ // Base case
+ return {stringifyJSONRenderIfMultiKey(subject)} ;
+};
+
+export type SubjectProps = InnerSubjectProps & {
+ onClose?: () => void;
+ style?: CSSProperties;
+};
+
+const Subject = ({ subject, boldLabel, onClose, style, ...rest }: SubjectProps) => {
+ return (
+
+ {onClose && (
+ }
+ type="text"
+ onClick={onClose}
+ style={{ position: "absolute", top: 0, right: 0 }}
+ />
+ )}
+
+
+ );
+};
+
+export default Subject;
diff --git a/src/components/manager/access/types.ts b/src/components/manager/access/types.ts
new file mode 100644
index 000000000..34a8d5778
--- /dev/null
+++ b/src/components/manager/access/types.ts
@@ -0,0 +1,3 @@
+import type { ChangeEventHandler } from "react";
+
+export type InputChangeEventHandler = ChangeEventHandler;
diff --git a/src/components/manager/access/utils.js b/src/components/manager/access/utils.js
deleted file mode 100644
index 6a0cea0b2..000000000
--- a/src/components/manager/access/utils.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export const stringifyJSONRenderIfMultiKey = (x) =>
- JSON.stringify(
- x,
- null,
- (typeof x === "object" && Object.keys(x).length > 1) ? 2 : null,
- );
-
-export const rowKey = (row) => row.id.toString();
diff --git a/src/components/manager/access/utils.ts b/src/components/manager/access/utils.ts
new file mode 100644
index 000000000..c18caa671
--- /dev/null
+++ b/src/components/manager/access/utils.ts
@@ -0,0 +1,4 @@
+export const stringifyJSONRenderIfMultiKey = (x: object): string =>
+ JSON.stringify(x, undefined, typeof x === "object" && Object.keys(x).length > 1 ? 2 : undefined);
+
+export const rowKey = (row: { id: number }): string => row.id.toString();
diff --git a/src/components/manager/drs/ManagerDRSContent.js b/src/components/manager/drs/ManagerDRSContent.js
index 7829aab93..7c1522330 100644
--- a/src/components/manager/drs/ManagerDRSContent.js
+++ b/src/components/manager/drs/ManagerDRSContent.js
@@ -24,169 +24,170 @@ import DatasetTitleDisplay from "../DatasetTitleDisplay";
import ProjectTitleDisplay from "../ProjectTitleDisplay";
const TABLE_NESTED_DESCRIPTIONS_STYLE = {
- backgroundColor: "white",
- borderRadius: 3,
- maxWidth: 1400,
+ backgroundColor: "white",
+ borderRadius: 3,
+ maxWidth: 1400,
};
const PROP_TYPES_DRS_OBJECT = PropTypes.shape({
- id: PropTypes.string,
- name: PropTypes.string,
- description: PropTypes.string,
- size: PropTypes.number,
- checksums: PropTypes.arrayOf(PropTypes.shape({
- type: PropTypes.string.isRequired,
- checksum: PropTypes.string.isRequired,
- })),
- access_methods: PropTypes.arrayOf(PropTypes.shape({
- type: PropTypes.string.isRequired,
- access_url: PropTypes.shape({ url: PropTypes.string }),
- })),
- bento: PropTypes.shape({
- project_id: PropTypes.string,
- dataset_id: PropTypes.string,
- data_type: PropTypes.string,
- public: PropTypes.bool,
+ id: PropTypes.string,
+ name: PropTypes.string,
+ description: PropTypes.string,
+ size: PropTypes.number,
+ checksums: PropTypes.arrayOf(
+ PropTypes.shape({
+ type: PropTypes.string.isRequired,
+ checksum: PropTypes.string.isRequired,
}),
+ ),
+ access_methods: PropTypes.arrayOf(
+ PropTypes.shape({
+ type: PropTypes.string.isRequired,
+ access_url: PropTypes.shape({ url: PropTypes.string }),
+ }),
+ ),
+ bento: PropTypes.shape({
+ project_id: PropTypes.string,
+ dataset_id: PropTypes.string,
+ data_type: PropTypes.string,
+ public: PropTypes.bool,
+ }),
});
const DRSObjectDetail = ({ drsObject }) => {
- const { id, description, checksums, access_methods: accessMethods, size, bento } = drsObject;
- return (
-
-
-
- {id}
-
-
- {filesize(size)}
-
- {description && (
-
- {description}
-
+ const { id, description, checksums, access_methods: accessMethods, size, bento } = drsObject;
+ return (
+
+
+
+ {id}
+
+
+ {filesize(size)}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {checksums.map(({ type, checksum }) => (
+
+ {type.toLocaleUpperCase()}:
+ {checksum}
+
+ ))}
+
+
+ {accessMethods.map(({ type, access_url: url }, i) => (
+
+
{type.toLocaleUpperCase()}:
+
+ {["http", "https"].includes(type) ? ( // "http" for back-compat
+
+ {url?.url}
+
+ ) : (
+ url?.url
)}
-
- {checksums.map(({ type, checksum }) => (
-
- {type.toLocaleUpperCase()}:
- {checksum}
-
- ))}
-
-
- {accessMethods.map(({ type, access_url: url }, i) => (
-
-
{type.toLocaleUpperCase()}:
-
- {["http", "https"].includes(type) ? ( // "http" for back-compat
-
- {url?.url}
-
- ) : (
- url?.url
- )}
-
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
- {bento?.data_type ?? EM_DASH}
-
-
-
- );
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {bento?.data_type ?? EM_DASH}
+
+
+
+ );
};
DRSObjectDetail.propTypes = {
- drsObject: PROP_TYPES_DRS_OBJECT,
+ drsObject: PROP_TYPES_DRS_OBJECT,
};
const DRSObjectDeleteWarningParagraph = memo(({ plural }) => (
-
- Be careful that there are no extant references to {plural ? "these objects" : "this object"} anywhere in the
- instance. If there are, triggering this will result in broken links and possibly broken functionality!
-
+
+ Be careful that there are no extant references to {plural ? "these objects" : "this object"} anywhere in the
+ instance. If there are, triggering this will result in broken links and possibly broken functionality!
+
));
DRSObjectDeleteWarningParagraph.propTypes = { plural: PropTypes.bool };
const DRSObjectDeleteButton = ({ drsObject, disabled }) => {
- const dispatch = useDispatch();
- const drsURL = useSelector((state) => state.services.drsService?.url);
-
- const onClick = useCallback(() => {
- Modal.confirm({
- title: <>Are you sure you wish to delete DRS object “{drsObject.name}”?>,
- content: ,
- onOk() {
- return dispatch(deleteDRSObject(drsObject)).catch((err) => console.error(err));
- },
- maskClosable: true,
- });
- }, [dispatch, drsURL, drsObject]);
-
- return (
- } onClick={onClick} disabled={disabled}>
- Delete
- );
+ const dispatch = useDispatch();
+
+ const onClick = useCallback(() => {
+ Modal.confirm({
+ title: <>Are you sure you wish to delete DRS object “{drsObject.name}”?>,
+ content: ,
+ okButtonProps: { danger: true },
+ onOk() {
+ return dispatch(deleteDRSObject(drsObject));
+ },
+ maskClosable: true,
+ });
+ }, [dispatch, drsObject]);
+
+ return (
+ } onClick={onClick} disabled={disabled}>
+ Delete
+
+ );
};
DRSObjectDeleteButton.propTypes = {
- drsObject: PROP_TYPES_DRS_OBJECT,
- disabled: PropTypes.bool,
+ drsObject: PROP_TYPES_DRS_OBJECT,
+ disabled: PropTypes.bool,
};
const SEARCH_CONTAINER_STYLE = {
- flex: 1,
- maxWidth: 800,
+ flex: 1,
+ maxWidth: 800,
};
// noinspection JSUnusedGlobalSymbols
const DRS_TABLE_EXPANDABLE = {
- expandedRowRender: (drsObject) => ,
+ expandedRowRender: (drsObject) => ,
};
const ManagerDRSContent = () => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const projectsByID = useSelector((state) => state.projects.itemsByID);
- const datasetsByID = useSelector((state) => state.projects.datasetsByID);
+ const projectsByID = useSelector((state) => state.projects.itemsByID);
+ const datasetsByID = useSelector((state) => state.projects.datasetsByID);
- // TODO: per-object permissions
- // For now, use whole-node permissions for DRS object viewer
+ // TODO: per-object permissions
+ // For now, use whole-node permissions for DRS object viewer
- // TODO: delete permissions:
- // - disable bulk button if any cannot be deleted
- // - map to resources and get back delete permissions for returned objects
+ // TODO: delete permissions:
+ // - disable bulk button if any cannot be deleted
+ // - map to resources and get back delete permissions for returned objects
- const {
- permissions,
- isFetchingPermissions,
- hasAttemptedPermissions,
- } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
+ const { permissions, isFetchingPermissions, hasAttemptedPermissions } =
+ useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
- const hasQueryPermission = permissions.includes(queryData);
- const hasDownloadPermission = permissions.includes(downloadData);
- const hasDeletePermission = permissions.includes(deleteData);
+ const hasQueryPermission = permissions.includes(queryData);
+ const hasDownloadPermission = permissions.includes(downloadData);
+ const hasDeletePermission = permissions.includes(deleteData);
- const drsURL = useSelector((state) => state.services.drsService?.url);
- const {
- objectSearchResults: rawObjectResults,
- objectSearchIsFetching,
- objectSearchAttempted,
- } = useSelector((state) => state.drs);
+ const drsURL = useSelector((state) => state.services.drsService?.url);
+ const {
+ objectSearchResults: rawObjectResults,
+ objectSearchIsFetching,
+ objectSearchAttempted,
+ } = useSelector((state) => state.drs);
- const objectResults = useMemo(() => rawObjectResults.map((o) => {
+ const objectResults = useMemo(
+ () =>
+ rawObjectResults.map((o) => {
const projectID = o.bento?.project_id;
const datasetID = o.bento?.dataset_id;
@@ -194,176 +195,186 @@ const ManagerDRSContent = () => {
const datasetValid = !datasetID || !!datasetsByID[datasetID];
return { ...o, valid_resource: projectValid && datasetValid };
- }), [rawObjectResults, projectsByID, datasetsByID]);
-
- const objectsByID = useMemo(
- () => Object.fromEntries(objectResults.map((o) => [o.id, o])),
- [objectResults]);
-
- const [searchParams, setSearchParams] = useSearchParams();
- const { q: initialSearchQuery } = searchParams;
- const [searchValue, setSearchValue] = useState(initialSearchQuery ?? "");
- const [selectedRowKeys, setSelectedRowKeys] = useState([]);
-
- const onSearch = useCallback((e) => {
- const q = (e.target?.value ?? e ?? "").trim();
- setSearchValue(q);
- setSearchParams({ q });
- }, []);
-
- const performSearch = useMemo(() => throttle(
+ }),
+ [rawObjectResults, projectsByID, datasetsByID],
+ );
+
+ const objectsByID = useMemo(() => Object.fromEntries(objectResults.map((o) => [o.id, o])), [objectResults]);
+
+ const [searchParams, setSearchParams] = useSearchParams();
+ const { q: initialSearchQuery } = searchParams;
+ const [searchValue, setSearchValue] = useState(initialSearchQuery ?? "");
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]);
+
+ const onSearch = useCallback(
+ (e) => {
+ const q = (e.target?.value ?? e ?? "").trim();
+ setSearchValue(q);
+ setSearchParams({ q });
+ },
+ [setSearchParams],
+ );
+
+ const performSearch = useMemo(
+ () =>
+ throttle(
() => {
- if (!drsURL) return;
+ if (!drsURL) return;
- if (!searchValue) {
- // Behave as if we have never searched before
- dispatch(clearDRSObjectSearch());
- return;
- }
+ if (!searchValue) {
+ // Behave as if we have never searched before
+ dispatch(clearDRSObjectSearch());
+ return;
+ }
- dispatch(performDRSObjectSearch(searchValue)).catch((err) => console.error(err));
+ dispatch(performDRSObjectSearch(searchValue)).catch((err) => console.error(err));
},
300,
{ leading: true, trailing: true },
- ), [dispatch, drsURL, searchValue]);
-
- useEffect(() => {
- performSearch();
- }, [searchValue]);
-
- useEffect(() => {
- setSelectedRowKeys(selectedRowKeys.filter((k) => k in objectsByID));
- }, [objectsByID]);
-
- const onDeleteSelected = useCallback(() => {
- Modal.confirm({
- title: <>
- Are you sure you want to delete {selectedRowKeys.length} DRS
- object{selectedRowKeys.length === 1 ? "" : "s"}?
- >,
- content: ,
- onOk() {
- return (async () => {
- for (const k of selectedRowKeys) {
- if (!(k in objectsByID)) {
- console.warn("Missing DRS object record in search results for ID:", k);
- continue;
- }
-
- console.info("Deleting DRS object:", k);
- await dispatch(deleteDRSObject(objectsByID[k]));
- }
- })();
- },
- maskClosable: true,
- });
- }, [dispatch, selectedRowKeys, objectsByID]);
-
- const tableLocale = useMemo(
- () => ({
- emptyText: objectSearchAttempted ? "No matching objects" : "Search to see matching objects",
- }),
- [objectSearchAttempted],
- );
-
- // noinspection JSUnusedGlobalSymbols
- const columns = useMemo(() => [
- {
- title: "URI",
- dataIndex: "self_uri",
- render: (selfUri) => {selfUri} ,
- },
- {
- title: "Name",
- dataIndex: "name",
- },
- {
- title: "Size",
- dataIndex: "size",
- render: (size) => filesize(size),
- },
- {
- title: "Valid Resource?",
- dataIndex: "valid_resource",
- filters: [
- { text: "Yes", value: true },
- { text: "No", value: false },
- ],
- onFilter: (value, record) => record.valid_resource === value,
- render: (v) => ,
- },
- {
- title: "Actions",
- dataIndex: "",
- key: "actions",
- width: 208,
- render: (record) => {
- const url = record.access_methods[0]?.access_url?.url;
- return (
-
-
-
-
- );
- },
+ ),
+ [dispatch, drsURL, searchValue],
+ );
+
+ useEffect(() => {
+ performSearch();
+ }, [performSearch, searchValue]);
+
+ useEffect(() => {
+ setSelectedRowKeys(selectedRowKeys.filter((k) => k in objectsByID));
+ }, [selectedRowKeys, objectsByID]);
+
+ const onDeleteSelected = useCallback(() => {
+ Modal.confirm({
+ title: (
+ <>
+ Are you sure you want to delete {selectedRowKeys.length} DRS object
+ {selectedRowKeys.length === 1 ? "" : "s"}?
+ >
+ ),
+ content: ,
+ onOk() {
+ return (async () => {
+ for (const k of selectedRowKeys) {
+ if (!(k in objectsByID)) {
+ console.warn("Missing DRS object record in search results for ID:", k);
+ continue;
+ }
+
+ console.info("Deleting DRS object:", k);
+ await dispatch(deleteDRSObject(objectsByID[k]));
+ }
+ })();
+ },
+ maskClosable: true,
+ });
+ }, [dispatch, selectedRowKeys, objectsByID]);
+
+ const tableLocale = useMemo(
+ () => ({
+ emptyText: objectSearchAttempted ? "No matching objects" : "Search to see matching objects",
+ }),
+ [objectSearchAttempted],
+ );
+
+ // noinspection JSUnusedGlobalSymbols
+ const columns = useMemo(
+ () => [
+ {
+ title: "URI",
+ dataIndex: "self_uri",
+ render: (selfUri) => {selfUri} ,
+ },
+ {
+ title: "Name",
+ dataIndex: "name",
+ },
+ {
+ title: "Size",
+ dataIndex: "size",
+ render: (size) => filesize(size),
+ },
+ {
+ title: "Valid Resource?",
+ dataIndex: "valid_resource",
+ filters: [
+ { text: "Yes", value: true },
+ { text: "No", value: false },
+ ],
+ onFilter: (value, record) => record.valid_resource === value,
+ render: (v) => ,
+ },
+ {
+ title: "Actions",
+ dataIndex: "",
+ key: "actions",
+ width: 208,
+ render: (record) => {
+ const url = record.access_methods[0]?.access_url?.url;
+ return (
+
+
+
+
+ );
},
- ], [hasDownloadPermission, hasDeletePermission, projectsByID, datasetsByID]);
-
- // noinspection JSUnusedGlobalSymbols
- const rowSelection = useMemo(() => ({
- type: "checkbox",
- getCheckboxProps: (record) => ({ name: record.id }),
- selectedRowKeys,
- onChange: (keys) => setSelectedRowKeys(keys),
- }), [selectedRowKeys]);
-
- if (hasAttemptedPermissions && !hasQueryPermission) {
- return (
-
- );
- }
-
- return (
-
-
-
-
-
-
-
}
- danger={true}
- onClick={onDeleteSelected}
- disabled={selectedRowKeys.length === 0}
- >Delete Selected{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ""}
-
-
-
-
- );
+ },
+ ],
+ [hasDownloadPermission, hasDeletePermission],
+ );
+
+ // noinspection JSUnusedGlobalSymbols
+ const rowSelection = useMemo(
+ () => ({
+ type: "checkbox",
+ getCheckboxProps: (record) => ({ name: record.id }),
+ selectedRowKeys,
+ onChange: (keys) => setSelectedRowKeys(keys),
+ }),
+ [selectedRowKeys],
+ );
+
+ if (hasAttemptedPermissions && !hasQueryPermission) {
+ return ;
+ }
+
+ return (
+
+
+
+
+
+
+
}
+ danger={true}
+ onClick={onDeleteSelected}
+ disabled={selectedRowKeys.length === 0}
+ >
+ Delete Selected{selectedRowKeys.length > 0 ? ` (${selectedRowKeys.length})` : ""}
+
+
+
+
+
+ );
};
export default ManagerDRSContent;
diff --git a/src/components/manager/projects/ManagerProjectDatasetContent.js b/src/components/manager/projects/ManagerProjectDatasetContent.js
index f0f276931..e84cbc563 100644
--- a/src/components/manager/projects/ManagerProjectDatasetContent.js
+++ b/src/components/manager/projects/ManagerProjectDatasetContent.js
@@ -20,11 +20,10 @@ import { useServices } from "@/modules/services/hooks";
import { useCanManageAtLeastOneProjectOrDataset } from "@/modules/authz/hooks";
import ForbiddenContent from "@/components/ForbiddenContent";
-
const PROJECT_HELP_TEXT_STYLE = {
- maxWidth: "600px",
- marginLeft: "auto",
- marginRight: "auto",
+ maxWidth: "600px",
+ marginLeft: "auto",
+ marginRight: "auto",
};
const SIDEBAR_STYLE = { background: "white" };
@@ -33,114 +32,120 @@ const SIDEBAR_MENU_STYLE = { flex: 1, paddingTop: "8px" };
const SIDEBAR_BUTTON_CONTAINER = { borderRight: "1px solid #e8e8e8", padding: "24px" };
const SIDEBAR_BUTTON_STYLE = { width: "100%" };
-
const ManagerProjectDatasetContent = () => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const {
- hasPermission: canManageProjectsDatasets,
- isFetching: fetchingManagePermissions,
- hasAttempted: attemptedManagePermissions,
- } = useCanManageAtLeastOneProjectOrDataset();
+ const {
+ hasPermission: canManageProjectsDatasets,
+ isFetching: fetchingManagePermissions,
+ hasAttempted: attemptedManagePermissions,
+ } = useCanManageAtLeastOneProjectOrDataset();
- const {
- hasPermission: canCreateProject,
- fetchingPermission: fetchingCanCreateProject,
- } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, createProject);
+ const { hasPermission: canCreateProject, fetchingPermission: fetchingCanCreateProject } =
+ useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, createProject);
- const { items } = useProjects();
- const { isFetchingDependentData } = useSelector(state => state.user);
+ const { items } = useProjects();
+ const { isFetchingDependentData } = useSelector((state) => state.user);
- const { metadataService, isFetchingAll: isFetchingAllServices } = useServices();
+ const { metadataService, isFetchingAll: isFetchingAllServices } = useServices();
- const projectMenuItems = useMemo(() => items.map(project => ({
+ const projectMenuItems = useMemo(
+ () =>
+ items.map((project) => ({
url: `/data/manager/projects/${project.identifier}`,
text: project.title,
- })), [items]);
+ })),
+ [items],
+ );
- const toggleProjectCreationModal = useCallback(
- () => dispatch(toggleProjectCreationModalAction()), [dispatch]);
+ const toggleProjectCreationModal = useCallback(() => dispatch(toggleProjectCreationModalAction()), [dispatch]);
- if (attemptedManagePermissions && !fetchingManagePermissions && !canManageProjectsDatasets) {
- return (
-
- );
- }
+ if (attemptedManagePermissions && !fetchingManagePermissions && !canManageProjectsDatasets) {
+ return ;
+ }
- if (!isFetchingDependentData && projectMenuItems.length === 0) {
- if (!isFetchingAllServices && metadataService === null) {
- return
-
-
-
- ;
- }
-
- return <>
-
-
-
-
- No Projects
-
- To create datasets and ingest data, you have to create a Bento project
- first. Bento projects have a name and description, and let you group related
- datasets together. You can then specify project-wide consent codes and data use
- restrictions to control data access.
-
- } onClick={toggleProjectCreationModal}>
- Create Project
-
-
-
- >;
+ if (!isFetchingDependentData && projectMenuItems.length === 0) {
+ if (!isFetchingAllServices && metadataService === null) {
+ return (
+
+
+
+
+
+ );
}
- return <>
- {canCreateProject && ( )}
+ return (
+ <>
+
-
-
-
-
- }>
- Create Project
-
-
-
-
-
- {/* TODO: Fix project datasets */}
- {projectMenuItems.length > 0 ? (
-
- } />
- } />
-
- ) : (
- isFetchingDependentData ? (
-
- ) : (
-
- )
- )}
-
+
+
+ No Projects
+
+ To create datasets and ingest data, you have to create a Bento project first. Bento projects have a name
+ and description, and let you group related datasets together. You can then specify project-wide consent
+ codes and data use restrictions to control data access.
+
+ } onClick={toggleProjectCreationModal}>
+ Create Project
+
+
+
- >;
+ >
+ );
+ }
+
+ return (
+ <>
+ {canCreateProject && }
+
+
+
+
+
+ }
+ >
+ Create Project
+
+
+
+
+
+ {/* TODO: Fix project datasets */}
+ {projectMenuItems.length > 0 ? (
+
+ } />
+ } />
+
+ ) : isFetchingDependentData ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
};
export default ManagerProjectDatasetContent;
diff --git a/src/components/manager/projects/Project.js b/src/components/manager/projects/Project.js
index ad340d7e7..d4e577f0c 100644
--- a/src/components/manager/projects/Project.js
+++ b/src/components/manager/projects/Project.js
@@ -10,259 +10,258 @@ import { nop, simpleDeepCopy } from "@/utils/misc";
import { projectPropTypesShape } from "@/propTypes";
import ProjectJsonSchema from "./ProjectJsonSchema";
import { useDropBoxFileContent, useHasResourcePermissionWrapper, useResourcePermissionsWrapper } from "@/hooks";
-import {
- createDataset,
- deleteProject,
- editProject,
- makeProjectResource,
- RESOURCE_EVERYTHING,
-} from "bento-auth-js";
+import { createDataset, deleteProject, editProject, makeProjectResource, RESOURCE_EVERYTHING } from "bento-auth-js";
import { INITIAL_DATA_USE_VALUE } from "@/duo";
const SUB_TAB_KEYS = {
- DATASETS: "project-datasets",
- EXTRA_PROPERTIES: "project-json-schemas",
+ DATASETS: "project-datasets",
+ EXTRA_PROPERTIES: "project-json-schemas",
};
const SUB_TAB_ITEMS = [
- { key: SUB_TAB_KEYS.DATASETS, label: "Datasets" },
- { key: SUB_TAB_KEYS.EXTRA_PROPERTIES, label: "Extra Properties" },
+ { key: SUB_TAB_KEYS.DATASETS, label: "Datasets" },
+ { key: SUB_TAB_KEYS.EXTRA_PROPERTIES, label: "Extra Properties" },
];
const Project = ({
- value,
- saving,
- editing,
- onAddDataset,
- onEditDataset,
- onAddJsonSchema,
- onEdit,
- onCancelEdit,
- onSave,
- onDelete,
+ value,
+ saving,
+ editing,
+ onAddDataset,
+ onEditDataset,
+ onAddJsonSchema,
+ onEdit,
+ onCancelEdit,
+ onSave,
+ onDelete,
}) => {
- const resource = makeProjectResource(value.identifier);
+ const resource = makeProjectResource(value.identifier);
- // Project deletion is a permission on the node, so we need these permissions for that purpose.
- const {
- hasPermission: canDeleteProject,
- fetchingPermission: isFetchingGlobalPermissions,
- } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, deleteProject);
+ // Project deletion is a permission on the node, so we need these permissions for that purpose.
+ const { hasPermission: canDeleteProject, fetchingPermission: isFetchingGlobalPermissions } =
+ useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, deleteProject);
- // Project editing/dataset creation is a permission on the project, so we need these permissions for those purposes.
- const {
- permissions: projectPermissions,
- isFetchingPermissions: isFetchingProjectPermissions,
- } = useResourcePermissionsWrapper(resource);
+ // Project editing/dataset creation is a permission on the project, so we need these permissions for those purposes.
+ const { permissions: projectPermissions, isFetchingPermissions: isFetchingProjectPermissions } =
+ useResourcePermissionsWrapper(resource);
- const canEditProject = useMemo(() => projectPermissions.includes(editProject), [projectPermissions]);
- const canCreateDataset = useMemo(() => projectPermissions.includes(createDataset), [projectPermissions]);
+ const canEditProject = useMemo(() => projectPermissions.includes(editProject), [projectPermissions]);
+ const canCreateDataset = useMemo(() => projectPermissions.includes(createDataset), [projectPermissions]);
- const [projectState, setProjectState] = useState({
- identifier: value.identifier,
- title: value.title,
- description: value.description,
- datasets: value.datasets || [],
- project_schemas: value.project_schemas || [],
- discovery: value.discovery || {},
- });
+ const [projectState, setProjectState] = useState({
+ identifier: value.identifier,
+ title: value.title,
+ description: value.description,
+ datasets: value.datasets || [],
+ project_schemas: value.project_schemas || [],
+ discovery: value.discovery || {},
+ });
- const [editingForm] = Form.useForm();
- const newDiscoveryFile = Form.useWatch("discoveryPath", editingForm);
- const newDiscoveryContent = useDropBoxFileContent(newDiscoveryFile);
+ const [editingForm] = Form.useForm();
+ const newDiscoveryFile = Form.useWatch("discoveryPath", editingForm);
+ const newDiscoveryContent = useDropBoxFileContent(newDiscoveryFile);
- const [selectedKey, setSelectedKey] = useState(SUB_TAB_KEYS.DATASETS);
+ const [selectedKey, setSelectedKey] = useState(SUB_TAB_KEYS.DATASETS);
- useEffect(() => {
- if (value) {
- setProjectState({
- ...projectState,
- ...value,
- data_use: simpleDeepCopy(value.data_use || INITIAL_DATA_USE_VALUE),
- discovery: newDiscoveryContent || value.discovery,
- });
- }
- }, [value, newDiscoveryContent]);
+ useEffect(() => {
+ if (value) {
+ setProjectState({
+ ...projectState,
+ ...value,
+ data_use: simpleDeepCopy(value.data_use || INITIAL_DATA_USE_VALUE),
+ discovery: newDiscoveryContent || value.discovery,
+ });
+ }
+ }, [value, newDiscoveryContent]);
-
-
- const handleSave = useCallback(() => {
- editingForm.validateFields().then((values) => {
- // Don't save datasets since it's a related set.
- onSave({
- identifier: projectState.identifier,
- title: values.title || projectState.title,
- description: values.description || projectState.description,
- data_use: values.data_use || projectState.data_use,
- discovery: newDiscoveryContent || values.discovery,
- });
- editingForm.resetFields();
- }).catch((err) => {
- console.error(err);
+ const handleSave = useCallback(() => {
+ editingForm
+ .validateFields()
+ .then((values) => {
+ // Don't save datasets since it's a related set.
+ onSave({
+ identifier: projectState.identifier,
+ title: values.title || projectState.title,
+ description: values.description || projectState.description,
+ data_use: values.data_use || projectState.data_use,
+ discovery: newDiscoveryContent || values.discovery,
});
- }, [onSave, projectState, newDiscoveryContent]);
-
- const handleCancelEdit = useCallback(() => {
editingForm.resetFields();
- (onCancelEdit || nop)();
- }, [onCancelEdit]);
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ }, [onSave, projectState, newDiscoveryContent]);
- return (
-
-
- {editing ? (
- <>
- }
- loading={saving}
- onClick={() => handleSave()}>Save
- }
- style={{ marginLeft: "10px" }}
- disabled={saving}
- onClick={() => handleCancelEdit()}>Cancel
- >
- ) : (
- <>
- }
- loading={isFetchingProjectPermissions}
- disabled={!canEditProject}
- onClick={() => (onEdit || nop)()}
- >Edit
- }
- loading={isFetchingGlobalPermissions}
- disabled={!canDeleteProject}
- style={{ marginLeft: "10px" }}
- onClick={() => (onDelete || nop)()}
- >Delete
- >
- )}
-
- {editing ? (
-
- ) : (
- <>
-
- {projectState.title}
-
- {projectState.description.split("\n").map((p, i) =>
-
{p} )}
- >
- )}
-
setSelectedKey(tab)}
- activeKey={selectedKey}
- items={SUB_TAB_ITEMS}
- size="large"
- />
+ const handleCancelEdit = useCallback(() => {
+ editingForm.resetFields();
+ (onCancelEdit || nop)();
+ }, [onCancelEdit]);
- {selectedKey === SUB_TAB_KEYS.DATASETS
- ? <>
-
- Datasets
-
- }
- loading={isFetchingProjectPermissions}
- disabled={!canCreateDataset}
- style={{ verticalAlign: "top" }}
- onClick={() => (onAddDataset || nop)()}
- >
- Add Dataset
-
-
-
- {(projectState.datasets || []).length > 0
- ? (
-
- {projectState.datasets.sort((d1, d2) => d1.title.localeCompare(d2.title)).map((d) => (
-
- (onEditDataset || nop)(d)}
- />
-
- ))}
-
- ) : (
-
- }
- loading={isFetchingProjectPermissions}
- disabled={!canEditProject}
- onClick={onAddDataset || nop}
- >
- Add Dataset
-
-
- )}
- >
- : <>
-
- Extra Properties JSON schemas
-
- }
- loading={isFetchingProjectPermissions}
- disabled={!canEditProject}
- style={{ verticalAlign: "top" }}
- onClick={onAddJsonSchema || nop}>
- Add JSON schema
-
-
-
- {projectState.project_schemas.length > 0
- ? projectState.project_schemas.map(pjs =>
-
-
-
-
-
,
- ) : (
-
- }
- onClick={onAddJsonSchema}
- loading={isFetchingProjectPermissions}
- disabled={!canEditProject}
- >
- Add JSON schema
-
-
- )
+ return (
+
+
+ {editing ? (
+ <>
+ } loading={saving} onClick={() => handleSave()}>
+ Save
+
+ }
+ style={{ marginLeft: "10px" }}
+ disabled={saving}
+ onClick={() => handleCancelEdit()}
+ >
+ Cancel
+
+ >
+ ) : (
+ <>
+ }
+ loading={isFetchingProjectPermissions}
+ disabled={!canEditProject}
+ onClick={() => (onEdit || nop)()}
+ >
+ Edit
+
+ }
+ loading={isFetchingGlobalPermissions}
+ disabled={!canDeleteProject}
+ style={{ marginLeft: "10px" }}
+ onClick={() => (onDelete || nop)()}
+ >
+ Delete
+
+ >
+ )}
+
+ {editing ? (
+
+ ) : (
+ <>
+
+ {projectState.title}
+
+ {projectState.description.split("\n").map((p, i) => (
+
+ {p}
+
+ ))}
+ >
+ )}
+
setSelectedKey(tab)} activeKey={selectedKey} items={SUB_TAB_ITEMS} size="large" />
- }
-
- >
- }
-
- );
+ {selectedKey === SUB_TAB_KEYS.DATASETS ? (
+ <>
+
+ Datasets
+
+ }
+ loading={isFetchingProjectPermissions}
+ disabled={!canCreateDataset}
+ style={{ verticalAlign: "top" }}
+ onClick={() => (onAddDataset || nop)()}
+ >
+ Add Dataset
+
+
+
+ {(projectState.datasets || []).length > 0 ? (
+
+ {projectState.datasets
+ .sort((d1, d2) => d1.title.localeCompare(d2.title))
+ .map((d) => (
+
+ (onEditDataset || nop)(d)}
+ />
+
+ ))}
+
+ ) : (
+
+ }
+ loading={isFetchingProjectPermissions}
+ disabled={!canEditProject}
+ onClick={onAddDataset || nop}
+ >
+ Add Dataset
+
+
+ )}
+ >
+ ) : (
+ <>
+
+ Extra Properties JSON schemas
+
+ }
+ loading={isFetchingProjectPermissions}
+ disabled={!canEditProject}
+ style={{ verticalAlign: "top" }}
+ onClick={onAddJsonSchema || nop}
+ >
+ Add JSON schema
+
+
+
+ {projectState.project_schemas.length > 0 ? (
+ projectState.project_schemas.map((pjs) => (
+
+
+
+
+
+ ))
+ ) : (
+
+ }
+ onClick={onAddJsonSchema}
+ loading={isFetchingProjectPermissions}
+ disabled={!canEditProject}
+ >
+ Add JSON schema
+
+
+ )}
+ >
+ )}
+
+ );
};
Project.propTypes = {
- value: projectPropTypesShape.isRequired,
+ value: projectPropTypesShape.isRequired,
- editing: PropTypes.bool,
- saving: PropTypes.bool,
+ editing: PropTypes.bool,
+ saving: PropTypes.bool,
- onDelete: PropTypes.func,
- onEdit: PropTypes.func,
- onCancelEdit: PropTypes.func,
- onSave: PropTypes.func,
- onAddDataset: PropTypes.func,
- onEditDataset: PropTypes.func,
- onAddJsonSchema: PropTypes.func,
+ onDelete: PropTypes.func,
+ onEdit: PropTypes.func,
+ onCancelEdit: PropTypes.func,
+ onSave: PropTypes.func,
+ onAddDataset: PropTypes.func,
+ onEditDataset: PropTypes.func,
+ onAddJsonSchema: PropTypes.func,
};
export default Project;
diff --git a/src/components/manager/projects/ProjectCreationModal.js b/src/components/manager/projects/ProjectCreationModal.js
index 7eb4a4857..3c4be1440 100644
--- a/src/components/manager/projects/ProjectCreationModal.js
+++ b/src/components/manager/projects/ProjectCreationModal.js
@@ -9,51 +9,59 @@ import ProjectForm from "./ProjectForm";
import { toggleProjectCreationModal } from "@/modules/manager/actions";
import { createProjectIfPossible } from "@/modules/metadata/actions";
-
+import { useProjects } from "@/modules/metadata/hooks";
const ProjectCreationModal = () => {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- const [form] = Form.useForm();
+ const [form] = Form.useForm();
+ const showCreationModal = useSelector((state) => state.manager.projectCreationModal);
+ const isCreatingProject = useProjects().isCreating;
- const showCreationModal = useSelector(state => state.manager.projectCreationModal);
- const isCreatingProject = useSelector(state => state.projects.isCreating);
+ const handleCreateCancel = useCallback(() => {
+ form.resetFields();
+ dispatch(toggleProjectCreationModal());
+ }, [form, dispatch]);
- const handleCreateCancel = useCallback(() => {
+ const handleCreateSubmit = useCallback(() => {
+ form
+ .validateFields()
+ .then(async (values) => {
+ console.log("VALUESSS", values);
+ await dispatch(createProjectIfPossible(values, navigate));
form.resetFields();
dispatch(toggleProjectCreationModal());
- }, [form, dispatch]);
-
- const handleCreateSubmit = useCallback(() => {
- form.validateFields().then(async (values) => {
- console.log("VALUESSS", values);
- await dispatch(createProjectIfPossible(values, navigate));
- form.resetFields();
- dispatch(toggleProjectCreationModal());
- }).catch((err) => {
- console.error(err);
- });
- }, [form, dispatch]);
-
- return (
- Cancel,
- }
- type="primary"
- onClick={handleCreateSubmit}
- loading={isCreatingProject}>Create,
- ]}
- onCancel={handleCreateCancel}
- >
-
-
- );
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ }, [form, dispatch]);
+ return (
+
+ Cancel
+ ,
+ }
+ type="primary"
+ onClick={handleCreateSubmit}
+ loading={isCreatingProject}
+ >
+ Create
+ ,
+ ]}
+ onCancel={handleCreateCancel}
+ >
+
+
+ );
};
export default ProjectCreationModal;
diff --git a/src/components/manager/projects/ProjectForm.js b/src/components/manager/projects/ProjectForm.js
index a91d9a80e..750e4f82a 100644
--- a/src/components/manager/projects/ProjectForm.js
+++ b/src/components/manager/projects/ProjectForm.js
@@ -1,57 +1,53 @@
import React, { useEffect } from "react";
import PropTypes from "prop-types";
-import { Form, Input } from "antd";
+import { Form, Input, Typography } from "antd";
import { DropBoxJsonSelect } from "../DropBoxTreeSelect";
-import { Typography } from "../../../../node_modules/antd/es/index";
import { useDiscoveryValidator } from "@/hooks";
const ProjectForm = ({ form, style, initialValues }) => {
- useEffect(() => {
- if (initialValues) {
- form.setFieldsValue(initialValues);
- }
- }, [initialValues]);
- const discoveryValidator = useDiscoveryValidator();
- return
-
-
-
-
-
-
- Public Discovery Configuration
- ,
- select: "Config file",
- defaultContent: "Discovery config",
- updatedContent: "New discovery config",
- }}
- initialValue={initialValues?.discovery}
- rules={[{ validator: discoveryValidator }]}
- />
- ;
+ useEffect(() => {
+ if (initialValues) {
+ form.setFieldsValue(initialValues);
+ }
+ }, [initialValues]);
+ const discoveryValidator = useDiscoveryValidator();
+ return (
+
+
+
+
+
+
+
+ Public Discovery Configuration
+
+ ),
+ select: "Config file",
+ defaultContent: "Discovery config",
+ updatedContent: "New discovery config",
+ }}
+ initialValue={initialValues?.discovery}
+ rules={[{ validator: discoveryValidator }]}
+ />
+
+ );
};
ProjectForm.propTypes = {
- form: PropTypes.object,
- style: PropTypes.object,
- initialValues: PropTypes.shape({
- title: PropTypes.string,
- description: PropTypes.string,
- discovery: PropTypes.object,
- }),
+ form: PropTypes.object,
+ style: PropTypes.object,
+ initialValues: PropTypes.shape({
+ title: PropTypes.string,
+ description: PropTypes.string,
+ discovery: PropTypes.object,
+ }),
};
export default ProjectForm;
diff --git a/src/components/manager/projects/ProjectJsonSchema.js b/src/components/manager/projects/ProjectJsonSchema.js
index 60e4c7bc0..0238011fb 100644
--- a/src/components/manager/projects/ProjectJsonSchema.js
+++ b/src/components/manager/projects/ProjectJsonSchema.js
@@ -11,80 +11,80 @@ import { projectJsonSchemaTypesShape } from "@/propTypes";
// Custom style based on Typography.Text in 'code' mode, with colors for dark backgrounds
const CODE_STYLE = {
- "margin": "0 0.2em",
- "padding": "0.2em 0.4em 0.1em",
- "fontSize": "85%",
- "fontFamily": "monospace",
- "background": "rgba(0, 0, 0, 0.06)",
- "border": "1px solid",
- "borderColor": "white",
- "color": "white",
- "borderRadius": "3px",
+ margin: "0 0.2em",
+ padding: "0.2em 0.4em 0.1em",
+ fontSize: "85%",
+ fontFamily: "monospace",
+ background: "rgba(0, 0, 0, 0.06)",
+ border: "1px solid",
+ borderColor: "white",
+ color: "white",
+ borderRadius: "3px",
};
-export const ExtraPropertiesCode = ({tooltip}) => {
- if (tooltip) {
- return extra_properties ;
- }
- return (extra_properties );
+export const ExtraPropertiesCode = ({ tooltip }) => {
+ if (tooltip) {
+ return extra_properties ;
+ }
+ return extra_properties ;
};
ExtraPropertiesCode.propTypes = {
- tooltip: PropTypes.bool,
+ tooltip: PropTypes.bool,
};
ExtraPropertiesCode.defaultProps = {
- tooltip: false,
+ tooltip: false,
};
const ProjectJsonSchema = ({ projectSchema }) => {
+ const dispatch = useDispatch();
- const dispatch = useDispatch();
+ const handleDelete = useCallback(() => {
+ const deleteModal = Modal.confirm({
+ title: `Are you sure you want to delete the "${projectSchema.schema_type}" project JSON schema?`,
+ content: (
+
+ Doing so will mean that data validation will not be enforced for entities of type{" "}
+ {projectSchema.schema_type} in project {projectSchema.project}.
+
+ ),
+ width: 720,
+ autoFocusButton: "cancel",
+ okText: "Delete",
+ okType: "danger",
+ maskClosable: true,
+ onOk: async () => {
+ deleteModal.update({ okButtonProps: { loading: true } });
+ await dispatch(deleteProjectJsonSchema(projectSchema));
+ deleteModal.update({ okButtonProps: { loading: false } });
+ },
+ });
+ }, [dispatch, projectSchema]);
- const handleDelete = useCallback(() => {
- const deleteModal = Modal.confirm({
- title: `Are you sure you want to delete the "${projectSchema.schema_type}" project JSON schema?`,
- content:
-
- Doing so will mean that data validation will not be enforced for entities
- of type {projectSchema.schema_type} in project {projectSchema.project}.
- ,
- width: 720,
- autoFocusButton: "cancel",
- okText: "Delete",
- okType: "danger",
- maskClosable: true,
- onOk: async () => {
- deleteModal.update({ okButtonProps: { loading: true } });
- await dispatch(deleteProjectJsonSchema(projectSchema));
- deleteModal.update({ okButtonProps: { loading: false } });
- },
- });
- }, [projectSchema]);
+ return (
+ } onClick={handleDelete}>
+ Delete
+
+ }
+ >
+
+ {projectSchema.required ? "Yes" : "No"}
- return (
- } onClick={handleDelete}>Delete
- }
- >
-
-
- {projectSchema.required ? "Yes" : "No"}
-
-
-
-
-
-
-
- );
+
+
+
+
+
+ );
};
ProjectJsonSchema.propTypes = {
- projectSchema: projectJsonSchemaTypesShape.isRequired,
+ projectSchema: projectJsonSchemaTypesShape.isRequired,
};
export default ProjectJsonSchema;
diff --git a/src/components/manager/projects/ProjectJsonSchemaForm.js b/src/components/manager/projects/ProjectJsonSchemaForm.js
index 613240fcb..0c42cd7cb 100644
--- a/src/components/manager/projects/ProjectJsonSchemaForm.js
+++ b/src/components/manager/projects/ProjectJsonSchemaForm.js
@@ -8,126 +8,140 @@ import JsonView from "@/components/common/JsonView";
import { ExtraPropertiesCode } from "./ProjectJsonSchema";
const ajv = new Ajv({
- allErrors: true,
- strict: true,
+ allErrors: true,
+ strict: true,
});
// Does not actually query over http, the URI is the key to the draft-07 meta-schema
const validateSchema = ajv.getSchema("http://json-schema.org/draft-07/schema");
const getSchemaTypeOptions = (schemaTypes) => {
- if (typeof schemaTypes === "object" && schemaTypes !== null) {
- return Object.entries(schemaTypes).map(([key, value]) => ({
- key,
- value: key,
- text: value.toUpperCase(),
- }));
- } else {
- return [];
- }
+ if (typeof schemaTypes === "object" && schemaTypes !== null) {
+ return Object.entries(schemaTypes).map(([key, value]) => ({
+ key,
+ value: key,
+ text: value.toUpperCase(),
+ }));
+ } else {
+ return [];
+ }
};
const JsonSchemaInput = ({ value, onChange }) => {
- const onDrop = useCallback((files) => {
- files.forEach((file) => {
- const reader = new FileReader();
- reader.onabort = () => console.error("file reading was aborted");
- reader.onerror = () => console.error("file reading has failed");
- reader.onload = () => {
- const json = JSON.parse(reader.result);
- if (validateSchema(json)) {
- // Validate against draft-07 meta schema
- onChange(json);
- } else {
- message.error("Selected file is an invalid JSON schema definition.");
- }
- };
- reader.readAsText(file);
- });
- }, [onChange]);
+ const onDrop = useCallback(
+ (files) => {
+ files.forEach((file) => {
+ const reader = new FileReader();
+ reader.onabort = () => console.error("file reading was aborted");
+ reader.onerror = () => console.error("file reading has failed");
+ reader.onload = () => {
+ const json = JSON.parse(reader.result);
+ if (validateSchema(json)) {
+ // Validate against draft-07 meta schema
+ onChange(json);
+ } else {
+ message.error("Selected file is an invalid JSON schema definition.");
+ }
+ };
+ reader.readAsText(file);
+ });
+ },
+ [onChange],
+ );
- const { getRootProps, getInputProps } = useDropzone({
- onDrop,
- maxFiles: 1,
- accept: {
- "application/json": [".json"],
- },
- });
+ const { getRootProps, getInputProps } = useDropzone({
+ onDrop,
+ maxFiles: 1,
+ accept: {
+ "application/json": [".json"],
+ },
+ });
- return (
-
-
-
-
Drag and drop a JSON Schema file here, or click to select files
-
- {value && (
- <>
-
-
onChange(null)}>Remove
- >
- )}
-
- );
+ return (
+
+
+
+
Drag and drop a JSON Schema file here, or click to select files
+
+ {value && (
+ <>
+
+
onChange(null)}>
+ Remove
+
+ >
+ )}
+
+ );
};
JsonSchemaInput.propTypes = {
- value: PropTypes.object,
- onChange: PropTypes.func,
+ value: PropTypes.object,
+ onChange: PropTypes.func,
};
const ProjectJsonSchemaForm = ({ form, schemaTypes, initialValues }) => {
- return (
- The data type on which this schema will be applied
- }>
- Schema Type
-
- }
- name="schemaType"
- initialValue={initialValues.schemaType}
- rules={[{ required: true }]}
- >
- ({ value, label }))}
- />
-
-
-
-
- Check to make the field required
- }>
- Required
-
- }
- name="required"
- initialValue={initialValues.required}
- valuePropName="checked"
- >
-
-
-
- );
+ return (
+
+ The data type on which this schema will be applied
+
+ }
+ >
+ Schema Type
+
+ }
+ name="schemaType"
+ initialValue={initialValues.schemaType}
+ rules={[{ required: true }]}
+ >
+ ({ value, label }))} />
+
+
+
+
+
+ Check to make the field required
+
+ }
+ >
+ Required
+
+ }
+ name="required"
+ initialValue={initialValues.required}
+ valuePropName="checked"
+ >
+
+
+
+ );
};
const JSON_SCHEMA_FORM_SHAPE = PropTypes.shape({
- schemaType: PropTypes.string,
- required: PropTypes.bool,
- jsonSchema: PropTypes.object,
+ schemaType: PropTypes.string,
+ required: PropTypes.bool,
+ jsonSchema: PropTypes.object,
});
ProjectJsonSchemaForm.propTypes = {
- form: PropTypes.object, // FormInstance
- schemaTypes: PropTypes.objectOf(PropTypes.string).isRequired,
- initialValues: JSON_SCHEMA_FORM_SHAPE,
+ form: PropTypes.object, // FormInstance
+ schemaTypes: PropTypes.objectOf(PropTypes.string).isRequired,
+ initialValues: JSON_SCHEMA_FORM_SHAPE,
};
export default ProjectJsonSchemaForm;
diff --git a/src/components/manager/projects/ProjectJsonSchemaModal.js b/src/components/manager/projects/ProjectJsonSchemaModal.js
index 33c61e2a1..5e246306f 100644
--- a/src/components/manager/projects/ProjectJsonSchemaModal.js
+++ b/src/components/manager/projects/ProjectJsonSchemaModal.js
@@ -1,82 +1,88 @@
import React, { useCallback } from "react";
-import { useDispatch, useSelector } from "react-redux";
+import { useDispatch } from "react-redux";
import PropTypes from "prop-types";
import { Button, Form, Modal } from "antd";
import { PlusOutlined } from "@ant-design/icons";
import { createProjectJsonSchema } from "@/modules/metadata/actions";
+import { useProjectJsonSchemaTypes } from "@/modules/metadata/hooks";
+
import ProjectJsonSchemaForm from "./ProjectJsonSchemaForm";
const ProjectJsonSchemaModal = ({ projectId, open, onOk, onCancel }) => {
- const dispatch = useDispatch();
-
- const isFetchingExtraPropertiesSchemaTypes = useSelector((state) =>
- state.projects.isFetchingExtraPropertiesSchemaTypes);
- const extraPropertiesSchemaTypes = useSelector((state) => state.projects.extraPropertiesSchemaTypes);
- const isCreatingJsonSchema = useSelector((state) => state.projects.isCreatingJsonSchema);
+ const dispatch = useDispatch();
- const [form] = Form.useForm();
+ const { isFetchingExtraPropertiesSchemaTypes, isCreatingJsonSchema, extraPropertiesSchemaTypes } =
+ useProjectJsonSchemaTypes();
- const cancelReset = useCallback(() => {
- form.resetFields();
- onCancel();
- }, [form, onCancel]);
+ const [form] = Form.useForm();
- const handleCreateSubmit = useCallback(() => {
+ const cancelReset = useCallback(() => {
+ form.resetFields();
+ onCancel();
+ }, [form, onCancel]);
- form.validateFields().then((values) => {
- console.log(values);
+ const handleCreateSubmit = useCallback(() => {
+ form
+ .validateFields()
+ .then((values) => {
+ console.log(values);
- const payload = {
- "project": projectId,
- "schemaType": values.schemaType,
- "required": values.required,
- "jsonSchema": values.jsonSchema,
- };
+ const payload = {
+ project: projectId,
+ schemaType: values.schemaType,
+ required: values.required,
+ jsonSchema: values.jsonSchema,
+ };
- return dispatch(createProjectJsonSchema(payload)).then(() => {
- form.resetFields();
- onOk();
- });
- }).catch((err) => console.error(err));
- }, [projectId, onOk]);
+ return dispatch(createProjectJsonSchema(payload)).then(() => {
+ form.resetFields();
+ onOk();
+ });
+ })
+ .catch((err) => console.error(err));
+ }, [dispatch, form, projectId, onOk]);
- return (
- Cancel,
- }
- type="primary"
- onClick={handleCreateSubmit}
- loading={isCreatingJsonSchema || isFetchingExtraPropertiesSchemaTypes}
- disabled={!extraPropertiesSchemaTypes || Object.keys(extraPropertiesSchemaTypes).length === 0}
- >Create,
- ]}
+ return (
+
+ Cancel
+ ,
+ }
+ type="primary"
+ onClick={handleCreateSubmit}
+ loading={isCreatingJsonSchema || isFetchingExtraPropertiesSchemaTypes}
+ disabled={!extraPropertiesSchemaTypes || Object.keys(extraPropertiesSchemaTypes).length === 0}
>
-
-
- );
+ Create
+ ,
+ ]}
+ >
+
+
+ );
};
ProjectJsonSchemaModal.propTypes = {
- projectId: PropTypes.string.isRequired,
- open: PropTypes.bool,
+ projectId: PropTypes.string.isRequired,
+ open: PropTypes.bool,
- onOk: PropTypes.func,
- onCancel: PropTypes.func,
+ onOk: PropTypes.func,
+ onCancel: PropTypes.func,
};
export default ProjectJsonSchemaModal;
diff --git a/src/components/manager/projects/ProjectSkeleton.js b/src/components/manager/projects/ProjectSkeleton.js
index 53e74735c..6c5c870f8 100644
--- a/src/components/manager/projects/ProjectSkeleton.js
+++ b/src/components/manager/projects/ProjectSkeleton.js
@@ -1,6 +1,4 @@
import React from "react";
-import {Skeleton} from "antd";
+import { Skeleton } from "antd";
-export default () =>
- ;
+export default () => ;
diff --git a/src/components/manager/projects/RoutedProject.js b/src/components/manager/projects/RoutedProject.js
index fb58eb083..c5001bf81 100644
--- a/src/components/manager/projects/RoutedProject.js
+++ b/src/components/manager/projects/RoutedProject.js
@@ -14,131 +14,133 @@ import { FORM_MODE_ADD, FORM_MODE_EDIT } from "@/constants";
import { deleteProjectIfPossible, saveProjectIfPossible } from "@/modules/metadata/actions";
import { beginProjectEditing, endProjectEditing } from "@/modules/manager/actions";
-
const RoutedProject = () => {
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- const { project: selectedProjectID } = useParams();
-
- const projectsByID = useSelector((state) => state.projects.itemsByID);
- const loadingProjects = useSelector((state) => state.projects.isCreating || state.projects.isFetching);
- const editingProject = useSelector((state) => state.manager.editingProject);
- const savingProject = useSelector((state) => state.projects.isSaving);
-
- const [datasetAdditionModal, setDatasetAdditionModal] = useState(false);
- const [datasetEditModal, setDatasetEditModal] = useState(false);
- const [jsonSchemaModal, setJsonSchemaModal] = useState(false);
- const [selectedDataset, setSelectedDataset] = useState(null);
-
- const project = projectsByID[selectedProjectID];
-
- useEffect(() => {
- if (!projectsByID[selectedProjectID] && !loadingProjects) {
- navigate("/data/manager/projects/");
- }
- }, [projectsByID, loadingProjects, selectedProjectID]);
-
- useEffect(() => {
- // end project editing on project changes
- if (editingProject) {
- dispatch(endProjectEditing());
- }
- }, [selectedProjectID]);
-
- const showDatasetAdditionModal = useCallback(() => {
- setDatasetAdditionModal(true);
- }, []);
-
- const hideDatasetAdditionModal = useCallback(() => {
- setDatasetAdditionModal(false);
- }, []);
-
- const hideDatasetEditModal = useCallback(() => {
- setDatasetEditModal(false);
- }, []);
-
- const setJsonSchemaModalVisible = useCallback((visible) => {
- setJsonSchemaModal(visible);
- }, []);
-
- const handleProjectSave = useCallback((newProject) => {
- dispatch(saveProjectIfPossible(newProject));
- }, [dispatch]);
-
- const handleProjectDelete = useCallback(() => {
- if (!project) return;
- const deleteModal = Modal.confirm({
- title: `Are you sure you want to delete the "${project.title}" project?`,
- content: (
- <>
- All data contained in the project will be deleted permanently, and
- datasets will no longer be available for exploration.
- >
- ),
- width: 576,
- autoFocusButton: "cancel",
- okText: "Delete",
- okType: "danger",
- maskClosable: true,
- onOk: async () => {
- deleteModal.update({ okButtonProps: { loading: true } });
- await dispatch(deleteProjectIfPossible(project));
- deleteModal.update({ okButtonProps: { loading: false } });
- },
- });
- }, [dispatch, project]);
-
- const handleDatasetEdit = useCallback((dataset) => {
- setSelectedDataset(dataset);
- setDatasetEditModal(true);
- }, []);
-
- if (!selectedProjectID) {
- return null;
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const { project: selectedProjectID } = useParams();
+
+ const projectsByID = useSelector((state) => state.projects.itemsByID);
+ const loadingProjects = useSelector((state) => state.projects.isCreating || state.projects.isFetching);
+ const editingProject = useSelector((state) => state.manager.editingProject);
+ const savingProject = useSelector((state) => state.projects.isSaving);
+
+ const [datasetAdditionModal, setDatasetAdditionModal] = useState(false);
+ const [datasetEditModal, setDatasetEditModal] = useState(false);
+ const [jsonSchemaModal, setJsonSchemaModal] = useState(false);
+ const [selectedDataset, setSelectedDataset] = useState(null);
+
+ const project = projectsByID[selectedProjectID];
+
+ useEffect(() => {
+ if (!projectsByID[selectedProjectID] && !loadingProjects) {
+ navigate("/data/manager/projects/");
}
+ }, [projectsByID, loadingProjects, selectedProjectID]);
- if (!project) return ;
- return (
+ useEffect(() => {
+ // end project editing on project changes
+ if (editingProject) {
+ dispatch(endProjectEditing());
+ }
+ }, [selectedProjectID]);
+
+ const showDatasetAdditionModal = useCallback(() => {
+ setDatasetAdditionModal(true);
+ }, []);
+
+ const hideDatasetAdditionModal = useCallback(() => {
+ setDatasetAdditionModal(false);
+ }, []);
+
+ const hideDatasetEditModal = useCallback(() => {
+ setDatasetEditModal(false);
+ }, []);
+
+ const setJsonSchemaModalVisible = useCallback((visible) => {
+ setJsonSchemaModal(visible);
+ }, []);
+
+ const handleProjectSave = useCallback(
+ (newProject) => {
+ dispatch(saveProjectIfPossible(newProject));
+ },
+ [dispatch],
+ );
+
+ const handleProjectDelete = useCallback(() => {
+ if (!project) return;
+ const deleteModal = Modal.confirm({
+ title: `Are you sure you want to delete the "${project.title}" project?`,
+ content: (
<>
-
-
-
-
- setJsonSchemaModalVisible(false)}
- onCancel={() => setJsonSchemaModalVisible(false)}
- />
-
- dispatch(beginProjectEditing())}
- onCancelEdit={() => dispatch(endProjectEditing())}
- onSave={handleProjectSave}
- onAddDataset={showDatasetAdditionModal}
- onEditDataset={handleDatasetEdit}
- onAddJsonSchema={() => setJsonSchemaModalVisible(true)}
- />
+ All data contained in the project will be deleted permanently, and datasets will no longer be available for
+ exploration.
>
- );
+ ),
+ width: 576,
+ autoFocusButton: "cancel",
+ okText: "Delete",
+ okType: "danger",
+ maskClosable: true,
+ onOk: async () => {
+ deleteModal.update({ okButtonProps: { loading: true } });
+ await dispatch(deleteProjectIfPossible(project));
+ deleteModal.update({ okButtonProps: { loading: false } });
+ },
+ });
+ }, [dispatch, project]);
+
+ const handleDatasetEdit = useCallback((dataset) => {
+ setSelectedDataset(dataset);
+ setDatasetEditModal(true);
+ }, []);
+
+ if (!selectedProjectID) {
+ return null;
+ }
+
+ if (!project) return ;
+ return (
+ <>
+
+
+
+
+ setJsonSchemaModalVisible(false)}
+ onCancel={() => setJsonSchemaModalVisible(false)}
+ />
+
+ dispatch(beginProjectEditing())}
+ onCancelEdit={() => dispatch(endProjectEditing())}
+ onSave={handleProjectSave}
+ onAddDataset={showDatasetAdditionModal}
+ onEditDataset={handleDatasetEdit}
+ onAddJsonSchema={() => setJsonSchemaModalVisible(true)}
+ />
+ >
+ );
};
export default RoutedProject;
diff --git a/src/components/manager/runs/ManagerRunsContent.js b/src/components/manager/runs/ManagerRunsContent.js
index a2717e771..7f97f7ab2 100644
--- a/src/components/manager/runs/ManagerRunsContent.js
+++ b/src/components/manager/runs/ManagerRunsContent.js
@@ -11,29 +11,26 @@ import RunDetailContent from "./RunDetailContent";
import ForbiddenContent from "@/components/ForbiddenContent";
import { useResourcePermissionsWrapper } from "@/hooks";
-
const ManagerRunsContent = () => {
- const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
-
- // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
- // least one workflow.
-
- if (hasAttemptedPermissions && !permissions.includes(viewRuns)) {
- return (
-
- );
- }
-
- return (
-
-
-
- } />
- } />
-
-
-
- );
+ const { permissions, hasAttemptedPermissions } = useResourcePermissionsWrapper(RESOURCE_EVERYTHING);
+
+ // TODO: each workflow should have definitions for permissions scopes, so we can instead check if we can run at
+ // least one workflow.
+
+ if (hasAttemptedPermissions && !permissions.includes(viewRuns)) {
+ return ;
+ }
+
+ return (
+
+
+
+ } />
+ } />
+
+
+
+ );
};
export default ManagerRunsContent;
diff --git a/src/components/manager/runs/Run.js b/src/components/manager/runs/Run.js
index c4b870801..e37009742 100644
--- a/src/components/manager/runs/Run.js
+++ b/src/components/manager/runs/Run.js
@@ -14,64 +14,70 @@ import { runPropTypesShape } from "@/propTypes";
import { nop } from "@/utils/misc";
import MonospaceText from "@/components/common/MonospaceText";
-
const TABS = {
- "request": RunRequest,
- "run_log": RunLog,
- "task_logs": RunTaskLogs,
- "outputs": RunOutputs,
+ request: RunRequest,
+ run_log: RunLog,
+ task_logs: RunTaskLogs,
+ outputs: RunOutputs,
};
-
const Run = memo(({ run: runOrUndefined, tab, onChangeTab, onBack }) => {
- const run = runOrUndefined ?? {};
- const currentTab = tab ?? "request";
+ const run = runOrUndefined ?? {};
+ const currentTab = tab ?? "request";
- const runLog = run.details?.run_log ?? {};
- const endTime = runLog.end_time;
+ const runLog = run.details?.run_log ?? {};
+ const endTime = runLog.end_time;
- const tabItems = [ // Don't need to memoize this; the React.memo() wrapper should take care of it
- { key: "request", label: "Request" },
- { key: "run_log", label: "Run Log" },
- /* { key: "task_logs", label: "Task Logs" }, TODO: Implement in WES */
+ const tabItems = [
+ // Don't need to memoize this; the React.memo() wrapper should take care of it
+ { key: "request", label: "Request" },
+ { key: "run_log", label: "Run Log" },
+ /* { key: "task_logs", label: "Task Logs" }, TODO: Implement in WES */
- // This is not part of the WES standard, so don't even render the tab if the key doesn't exist
- ...(run.details?.outputs
- ? [
- {
- key: "outputs",
- label: "Outputs",
- // If we have the outputs key, but there aren't any outputs or the workflow isn't finished yet,
- // leave the tab disabled:
- disabled: !endTime || (run.details?.outputs ?? {}).length === 0,
- },
- ] : []),
- ];
+ // This is not part of the WES standard, so don't even render the tab if the key doesn't exist
+ ...(run.details?.outputs
+ ? [
+ {
+ key: "outputs",
+ label: "Outputs",
+ // If we have the outputs key, but there aren't any outputs or the workflow isn't finished yet,
+ // leave the tab disabled:
+ disabled: !endTime || (run.details?.outputs ?? {}).length === 0,
+ },
+ ]
+ : []),
+ ];
- const Content = TABS[tab];
+ const Content = TABS[tab];
- return (
- <>
- Run {run.run_id} >}
- tags={{run.state} }
- style={{ padding: 0 }}
- footer={ }
- onBack={onBack || nop}>
-
-
-
-
-
-
- >
- );
+ return (
+ <>
+
+ Run {run.run_id}
+ >
+ }
+ tags={{run.state} }
+ style={{ padding: 0 }}
+ footer={ }
+ onBack={onBack || nop}
+ >
+
+
+
+
+
+
+ >
+ );
});
Run.propTypes = {
- tab: PropTypes.oneOf(["request", "run_log", "task_logs", "outputs"]),
- run: runPropTypesShape,
- onBack: PropTypes.func,
- onChangeTab: PropTypes.func,
+ tab: PropTypes.oneOf(["request", "run_log", "task_logs", "outputs"]),
+ run: runPropTypesShape,
+ onBack: PropTypes.func,
+ onChangeTab: PropTypes.func,
};
export default Run;
diff --git a/src/components/manager/runs/RunDetailContent.js b/src/components/manager/runs/RunDetailContent.js
index 1c8e773be..6d5e9cabb 100644
--- a/src/components/manager/runs/RunDetailContent.js
+++ b/src/components/manager/runs/RunDetailContent.js
@@ -7,36 +7,40 @@ import { Skeleton } from "antd";
import Run from "./Run";
const styles = {
- skeletonContainer: {
- marginTop: "12px",
- marginLeft: "24px",
- marginRight: "24px",
- },
+ skeletonContainer: {
+ marginTop: "12px",
+ marginLeft: "24px",
+ marginRight: "24px",
+ },
};
const RunDetailContentInner = () => {
- const navigate = useNavigate();
- const { id, tab } = useParams();
+ const navigate = useNavigate();
+ const { id, tab } = useParams();
- const runsByID = useSelector((state) => state.runs.itemsByID);
+ const runsByID = useSelector((state) => state.runs.itemsByID);
- // TODO: 404
- const run = runsByID[id] || null;
- const loading = (run?.details ?? null) === null;
+ // TODO: 404
+ const run = runsByID[id] || null;
+ const loading = (run?.details ?? null) === null;
- const onChangeTab = useCallback((key) => navigate(`../${key}`), [navigate]);
- const onBack = useCallback(() => navigate("/data/manager/runs"), [navigate]);
+ const onChangeTab = useCallback((key) => navigate(`../${key}`), [navigate]);
+ const onBack = useCallback(() => navigate("/data/manager/runs"), [navigate]);
- return loading
- ?
- : ;
+ return loading ? (
+
+
+
+ ) : (
+
+ );
};
const RunDetailContent = () => (
-
- } />
- } />
-
+
+ } />
+ } />
+
);
export default RunDetailContent;
diff --git a/src/components/manager/runs/RunLastContent.js b/src/components/manager/runs/RunLastContent.js
index 7244bd036..dad552c51 100644
--- a/src/components/manager/runs/RunLastContent.js
+++ b/src/components/manager/runs/RunLastContent.js
@@ -1,103 +1,105 @@
-import React, {useState, useMemo} from "react";
-import {useSelector} from "react-redux";
+import React, { useState, useMemo } from "react";
+import { useSelector } from "react-redux";
import PropTypes from "prop-types";
-import {Table, Modal} from "antd";
+import { Table, Modal } from "antd";
import { MoreOutlined } from "@ant-design/icons";
const COLUMNS_LAST_CONTENT = [
- {
- title: "Date",
- dataIndex: "date",
- key: "date",
- render: (date) => formatDate(date),
- },
- {title: "Data Type", dataIndex: "dataType", key: "dataType"},
- {title: "Dataset ID", dataIndex: "datasetId", key: "datasetId"},
- {
- title: "Ingested Files",
- dataIndex: "fileNames",
- key: "fileNamesLength",
- render: (fileNames) => fileNames.length,
- },
- {
- title: "File Names",
- dataIndex: "fileNames",
- key: "fileNames",
- render: (fileNames, record) => ,
- },
+ {
+ title: "Date",
+ dataIndex: "date",
+ key: "date",
+ render: (date) => formatDate(date),
+ },
+ { title: "Data Type", dataIndex: "dataType", key: "dataType" },
+ { title: "Dataset ID", dataIndex: "datasetId", key: "datasetId" },
+ {
+ title: "Ingested Files",
+ dataIndex: "fileNames",
+ key: "fileNamesLength",
+ render: (fileNames) => fileNames.length,
+ },
+ {
+ title: "File Names",
+ dataIndex: "fileNames",
+ key: "fileNames",
+ render: (fileNames, record) => ,
+ },
];
const modalListStyle = {
- whiteSpace: "nowrap",
- overflow: "hidden",
- textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
};
const formatDate = (date) => {
- const dateObj = new Date(date);
- return dateObj.toLocaleString("en-CA", {
- weekday: "long",
- year: "numeric",
- month: "long",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- hour12: true,
- });
+ const dateObj = new Date(date);
+ return dateObj.toLocaleString("en-CA", {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: true,
+ });
};
-function FileNamesCell({fileNames, dataType}) {
- const [isModalVisible, setIsModalVisible] = useState(false);
-
- // If fileNames exceed 4, truncates list for initial display
- // with the middle replaced by an 'more' icon.
- const isTruncated = fileNames.length > 4;
- const truncatedFileNames = isTruncated
- ? [...fileNames.slice(0, 2), , ...fileNames.slice(-2)]
- : fileNames;
-
- const divStyle = isTruncated
- ? {
- display: "flex",
- flexDirection: "column",
- alignItems: "flex-start",
- justifyContent: "center",
- cursor: "pointer",
- }
- : {};
-
- const openModal = () => isTruncated && setIsModalVisible(true);
-
- const closeModal = () => setIsModalVisible(false);
-
- return (
- <>
-
- {truncatedFileNames.map((element, index) =>
- typeof element === "string" ?
{element}
: element,
- )}
-
-
-
- {fileNames.map((fileName, index) => (
- - {fileName}
- ))}
-
-
- >
- );
+function FileNamesCell({ fileNames, dataType }) {
+ const [isModalVisible, setIsModalVisible] = useState(false);
+
+ // If fileNames exceed 4, truncates list for initial display
+ // with the middle replaced by an 'more' icon.
+ const isTruncated = fileNames.length > 4;
+ const truncatedFileNames = isTruncated
+ ? [...fileNames.slice(0, 2), , ...fileNames.slice(-2)]
+ : fileNames;
+
+ const divStyle = isTruncated
+ ? {
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "flex-start",
+ justifyContent: "center",
+ cursor: "pointer",
+ }
+ : {};
+
+ const openModal = () => isTruncated && setIsModalVisible(true);
+
+ const closeModal = () => setIsModalVisible(false);
+
+ return (
+ <>
+
+ {truncatedFileNames.map((element, index) =>
+ typeof element === "string" ?
{element}
: element,
+ )}
+
+
+
+ {fileNames.map((fileName, index) => (
+
+ - {fileName}
+
+ ))}
+
+
+ >
+ );
}
FileNamesCell.propTypes = {
- fileNames: PropTypes.arrayOf(PropTypes.string).isRequired,
- dataType: PropTypes.string.isRequired,
+ fileNames: PropTypes.arrayOf(PropTypes.string).isRequired,
+ dataType: PropTypes.string.isRequired,
};
const buildKeyFromRecord = (record) => `${record.dataType}-${record.datasetId}`;
@@ -106,91 +108,88 @@ const fileNameFromPath = (path) => path.split("/").at(-1);
const namespacedInput = (workflowId, input) => `${workflowId}.${input.id}`;
-const getFirstProjectDatasetInputFromWorkflow = (workflowId, {inputs}) =>
- inputs
- .filter(input => input.type === "project:dataset")
- .map(input => namespacedInput(workflowId, input))[0];
+const getFirstProjectDatasetInputFromWorkflow = (workflowId, { inputs }) =>
+ inputs.filter((input) => input.type === "project:dataset").map((input) => namespacedInput(workflowId, input))[0];
-const getFileInputsFromWorkflow = (workflowId, {inputs}) =>
- inputs
- .filter(input => ["file", "file[]"].includes(input.type))
- .map(input => namespacedInput(workflowId, input));
+const getFileInputsFromWorkflow = (workflowId, { inputs }) =>
+ inputs.filter((input) => ["file", "file[]"].includes(input.type)).map((input) => namespacedInput(workflowId, input));
const processIngestions = (data, currentDatasets) => {
- const currentDatasetIds = new Set((currentDatasets || []).map((ds) => ds.identifier));
-
- const ingestions = {};
-
- data.forEach((run) => {
- if (run.state !== "COMPLETE") {
- return;
+ const currentDatasetIds = new Set((currentDatasets || []).map((ds) => ds.identifier));
+
+ const ingestions = {};
+
+ data.forEach((run) => {
+ if (run.state !== "COMPLETE") {
+ return;
+ }
+
+ const workflowParams = run.details.request.workflow_params;
+ const { workflow_id: workflowId, workflow_metadata: workflowMetadata } = run.details.request.tags;
+
+ const projectDatasetKey = getFirstProjectDatasetInputFromWorkflow(workflowId, workflowMetadata);
+ if (!projectDatasetKey) {
+ return;
+ }
+
+ const datasetId = workflowParams[projectDatasetKey].split(":")[1];
+ if (datasetId === undefined || !currentDatasetIds.has(datasetId)) {
+ return;
+ }
+
+ if (!workflowMetadata.data_type) {
+ return;
+ }
+
+ const fileNames = getFileInputsFromWorkflow(workflowId ?? workflowMetadata.id, workflowMetadata)
+ .flatMap((key) => {
+ const paramValue = workflowParams[key];
+ if (!paramValue) {
+ // Key isn't in workflow params or is null
+ // - possibly optional field or something else going wrong
+ return [];
}
-
- const workflowParams = run.details.request.workflow_params;
- const { workflow_id: workflowId, workflow_metadata: workflowMetadata } = run.details.request.tags;
-
- const projectDatasetKey = getFirstProjectDatasetInputFromWorkflow(workflowId, workflowMetadata);
- if (!projectDatasetKey) {
- return;
- }
-
- const datasetId = workflowParams[projectDatasetKey].split(":")[1];
- if (datasetId === undefined || !currentDatasetIds.has(datasetId)) {
- return;
- }
-
- if (!workflowMetadata.data_type) {
- return;
- }
-
- const fileNames =
- getFileInputsFromWorkflow(workflowId ?? workflowMetadata.id, workflowMetadata)
- .flatMap(key => {
- const paramValue = workflowParams[key];
- if (!paramValue) {
- // Key isn't in workflow params or is null
- // - possibly optional field or something else going wrong
- return [];
- }
- return Array.isArray(paramValue) ? paramValue : [paramValue];
- })
- .map(fileNameFromPath);
-
- const date = Date.parse(run.details.run_log.end_time);
-
- const currentIngestion = { date, dataType: workflowMetadata.data_type, datasetId, fileNames };
- const dataTypeAndDatasetId = buildKeyFromRecord(currentIngestion);
-
- if (ingestions[dataTypeAndDatasetId]) {
- const existingDate = ingestions[dataTypeAndDatasetId].date;
- if (date > existingDate) {
- ingestions[dataTypeAndDatasetId].date = date;
- }
- ingestions[dataTypeAndDatasetId].fileNames.push(...fileNames);
- } else {
- ingestions[dataTypeAndDatasetId] = currentIngestion;
- }
- }, {});
-
- return Object.values(ingestions).sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
+ return Array.isArray(paramValue) ? paramValue : [paramValue];
+ })
+ .map(fileNameFromPath);
+
+ const date = Date.parse(run.details.run_log.end_time);
+
+ const currentIngestion = { date, dataType: workflowMetadata.data_type, datasetId, fileNames };
+ const dataTypeAndDatasetId = buildKeyFromRecord(currentIngestion);
+
+ if (ingestions[dataTypeAndDatasetId]) {
+ const existingDate = ingestions[dataTypeAndDatasetId].date;
+ if (date > existingDate) {
+ ingestions[dataTypeAndDatasetId].date = date;
+ }
+ ingestions[dataTypeAndDatasetId].fileNames.push(...fileNames);
+ } else {
+ ingestions[dataTypeAndDatasetId] = currentIngestion;
+ }
+ }, {});
+
+ return Object.values(ingestions).sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
};
const LastIngestionTable = () => {
- const servicesFetching = useSelector(state => state.services.isFetchingAll);
- const {items: runs, isFetching: runsFetching} = useSelector((state) => state.runs);
- const currentProjects = useSelector((state) => state.projects.items);
- const currentDatasets = useMemo(() => currentProjects.flatMap(p => p.datasets), [currentProjects]);
- const ingestions = useMemo(() => processIngestions(runs, currentDatasets), [runs, currentDatasets]);
-
- return ;
+ const servicesFetching = useSelector((state) => state.services.isFetchingAll);
+ const { items: runs, isFetching: runsFetching } = useSelector((state) => state.runs);
+ const currentProjects = useSelector((state) => state.projects.items);
+ const currentDatasets = useMemo(() => currentProjects.flatMap((p) => p.datasets), [currentProjects]);
+ const ingestions = useMemo(() => processIngestions(runs, currentDatasets), [runs, currentDatasets]);
+
+ return (
+
+ );
};
export default LastIngestionTable;
diff --git a/src/components/manager/runs/RunListContent.js b/src/components/manager/runs/RunListContent.js
index dfb58a474..fb558f5f3 100644
--- a/src/components/manager/runs/RunListContent.js
+++ b/src/components/manager/runs/RunListContent.js
@@ -10,60 +10,66 @@ import { useRuns } from "@/modules/wes/hooks";
import { RUN_REFRESH_TIMEOUT, RUN_TABLE_COLUMNS } from "./utils";
const RunListContent = () => {
- const dispatch = useDispatch();
- const runRefreshTimeout = useRef(null);
+ const dispatch = useDispatch();
+ const runRefreshTimeout = useRef(null);
- const { items: runs } = useRuns();
- const mappedRuns = useMemo(() => runs.map((r) => ({
+ const { items: runs } = useRuns();
+ const mappedRuns = useMemo(
+ () =>
+ runs.map((r) => ({
...r,
startTime: r.details?.run_log?.start_time,
endTime: r.details?.run_log?.end_time,
- })), [runs]);
+ })),
+ [runs],
+ );
- const servicesFetching = useSelector((state) => state.services.isFetchingAll);
- const runsFetching = useSelector((state) => state.runs.isFetching);
+ const servicesFetching = useSelector((state) => state.services.isFetchingAll);
+ const runsFetching = useSelector((state) => state.runs.isFetching);
- useEffect(() => {
- dispatch(fetchAllRunDetailsIfNeeded()).catch((err) => console.error(err));
+ useEffect(() => {
+ dispatch(fetchAllRunDetailsIfNeeded()).catch((err) => console.error(err));
- const _clearInterval = () => {
- if (runRefreshTimeout.current) {
- clearInterval(runRefreshTimeout.current);
- }
- };
+ const _clearInterval = () => {
+ if (runRefreshTimeout.current) {
+ clearInterval(runRefreshTimeout.current);
+ }
+ };
- _clearInterval();
+ _clearInterval();
- runRefreshTimeout.current = setInterval(() => {
- dispatch(fetchAllRunDetailsIfNeeded()).catch((err) => {
- console.error(err);
- _clearInterval();
- });
- }, RUN_REFRESH_TIMEOUT);
+ runRefreshTimeout.current = setInterval(() => {
+ dispatch(fetchAllRunDetailsIfNeeded()).catch((err) => {
+ console.error(err);
+ _clearInterval();
+ });
+ }, RUN_REFRESH_TIMEOUT);
- return _clearInterval;
- }, [dispatch]);
+ return _clearInterval;
+ }, [dispatch]);
- // TODO: Loading for individual rows
- return (
-
-
- Latest Ingested Files
-
-
-
-
- );
+ // TODO: Loading for individual rows
+ return (
+
+
+
+ Latest Ingested Files
+
+
+
+
+
+ );
};
export default RunListContent;
diff --git a/src/components/manager/runs/RunLog.js b/src/components/manager/runs/RunLog.js
index 4ce495b29..1434520de 100644
--- a/src/components/manager/runs/RunLog.js
+++ b/src/components/manager/runs/RunLog.js
@@ -1,68 +1,70 @@
-import React, {useEffect} from "react";
-import {useDispatch, useSelector} from "react-redux";
+import React, { useEffect } from "react";
+import { useDispatch, useSelector } from "react-redux";
import PropTypes from "prop-types";
-import {AnsiUp} from "ansi_up";
+import { AnsiUp } from "ansi_up";
-import {Descriptions, Skeleton} from "antd";
+import { Descriptions, Skeleton } from "antd";
-import {fetchRunLogStreamsIfPossibleAndNeeded} from "../../../modules/wes/actions";
-import {runPropTypesShape} from "../../../propTypes";
+import { fetchRunLogStreamsIfPossibleAndNeeded } from "../../../modules/wes/actions";
+import { runPropTypesShape } from "../../../propTypes";
import MonospaceText from "@/components/common/MonospaceText";
-
const ansiUp = new AnsiUp();
+const LogOutput = ({ log }) => {
+ if (log === null) return ;
-const LogOutput = ({log}) => {
- if (log === null) return ;
-
- return
;
+ return (
+
+ );
};
LogOutput.propTypes = {
- log: PropTypes.shape({
- data: PropTypes.string,
- }),
+ log: PropTypes.shape({
+ data: PropTypes.string,
+ }),
};
-const RunLog = ({run}) => {
- const dispatch = useDispatch();
+const RunLog = ({ run }) => {
+ const dispatch = useDispatch();
- const {isFetching: isFetchingRuns, streamsByID: runLogStreams} = useSelector((state) => state.runs);
+ const { isFetching: isFetchingRuns, streamsByID: runLogStreams } = useSelector((state) => state.runs);
- useEffect(() => {
- if (isFetchingRuns) return;
- dispatch(fetchRunLogStreamsIfPossibleAndNeeded(run.run_id));
- }, [dispatch, run, isFetchingRuns]);
+ useEffect(() => {
+ if (isFetchingRuns) return;
+ dispatch(fetchRunLogStreamsIfPossibleAndNeeded(run.run_id));
+ }, [dispatch, run, isFetchingRuns]);
- const stdout = runLogStreams[run.run_id]?.stdout ?? null;
- const stderr = runLogStreams[run.run_id]?.stderr ?? null;
+ const stdout = runLogStreams[run.run_id]?.stdout ?? null;
+ const stderr = runLogStreams[run.run_id]?.stderr ?? null;
- const runLog = run?.details?.run_log ?? {};
+ const runLog = run?.details?.run_log ?? {};
- return
-
- {runLog.cmd}
-
-
- {runLog.name}
-
-
- {runLog.exit_code === null ? "N/A" : runLog.exit_code}
-
- stdout} span={3}>
-
-
- stderr} span={3}>
-
-
- ;
+ return (
+
+
+ {runLog.cmd}
+
+
+ {runLog.name}
+
+
+ {runLog.exit_code === null ? "N/A" : runLog.exit_code}
+
+ stdout} span={3}>
+
+
+ stderr} span={3}>
+
+
+
+ );
};
RunLog.propTypes = {
- run: runPropTypesShape,
+ run: runPropTypesShape,
};
export default RunLog;
diff --git a/src/components/manager/runs/RunOutputs.js b/src/components/manager/runs/RunOutputs.js
index 9828e86a1..e3f125366 100644
--- a/src/components/manager/runs/RunOutputs.js
+++ b/src/components/manager/runs/RunOutputs.js
@@ -10,96 +10,87 @@ import DownloadButton from "@/components/common/DownloadButton";
import MonospaceText from "@/components/common/MonospaceText";
import { useService } from "@/modules/services/hooks";
-
const RunOutputValue = ({ runID, item: { name, type, value } }) => {
- const wesUrl = useService("wes")?.url;
+ const wesUrl = useService("wes")?.url;
- const typeNoOpt = type.replace(/\?$/, "");
+ const typeNoOpt = type.replace(/\?$/, "");
- if (typeNoOpt.startsWith("Array[")) {
- const innerType = typeNoOpt
- .replace(/^Array\[/, "")
- .replace(/]$/, "");
- return (
-
- {(value ?? []).map((v, vi) => (
-
-
-
- ))}
-
- );
- } else if (typeNoOpt === "String") {
- return {value} ;
- } else if (["Float", "Int", "Boolean"].includes(typeNoOpt)) {
- return {(value ?? EM_DASH).toString()} ;
- } else if (typeNoOpt === "File") {
- if (value) {
- return (
-
- {value}
-
-
- );
- } else {
- return {EM_DASH} ;
- }
- } else { // Base case: JSON stringify
- return {JSON.stringify(value)} ;
+ if (typeNoOpt.startsWith("Array[")) {
+ const innerType = typeNoOpt.replace(/^Array\[/, "").replace(/]$/, "");
+ return (
+
+ {(value ?? []).map((v, vi) => (
+
+
+
+ ))}
+
+ );
+ } else if (typeNoOpt === "String") {
+ return {value} ;
+ } else if (["Float", "Int", "Boolean"].includes(typeNoOpt)) {
+ return {(value ?? EM_DASH).toString()} ;
+ } else if (typeNoOpt === "File") {
+ if (value) {
+ return (
+
+ {value}
+
+
+ );
+ } else {
+ return {EM_DASH} ;
}
+ } else {
+ // Base case: JSON stringify
+ return {JSON.stringify(value)} ;
+ }
};
RunOutputValue.propTypes = {
- runID: PropTypes.string,
- item: PropTypes.shape({
- name: PropTypes.string,
- type: PropTypes.string,
- value: PropTypes.any,
- }),
+ runID: PropTypes.string,
+ item: PropTypes.shape({
+ name: PropTypes.string,
+ type: PropTypes.string,
+ value: PropTypes.any,
+ }),
};
const RunOutputs = memo(({ run }) => {
- const outputItems = Object.entries(run.details?.outputs ?? {}).map(([k, v]) => ({ ...v, name: k }));
+ const outputItems = Object.entries(run.details?.outputs ?? {}).map(([k, v]) => ({ ...v, name: k }));
- const columns = [
- {
- title: "Name",
- dataIndex: "name",
- render: (name) => {name} ,
- },
- {
- title: "Type",
- dataIndex: "type",
- },
- {
- title: "Value",
- dataIndex: "value",
- render: (_, item) => ,
- },
- // {
- // key: "actions",
- // title: "Actions",
- // render: () => <>TODO>,
- // },
- ];
+ const columns = [
+ {
+ title: "Name",
+ dataIndex: "name",
+ render: (name) => {name} ,
+ },
+ {
+ title: "Type",
+ dataIndex: "type",
+ },
+ {
+ title: "Value",
+ dataIndex: "value",
+ render: (_, item) => ,
+ },
+ // {
+ // key: "actions",
+ // title: "Actions",
+ // render: () => <>TODO>,
+ // },
+ ];
- return (
-
- );
+ return (
+
+ );
});
RunOutputs.propTypes = {
- run: runPropTypesShape,
+ run: runPropTypesShape,
};
export default RunOutputs;
diff --git a/src/components/manager/runs/RunRequest.js b/src/components/manager/runs/RunRequest.js
index 1e09011c7..edb98fabf 100644
--- a/src/components/manager/runs/RunRequest.js
+++ b/src/components/manager/runs/RunRequest.js
@@ -6,55 +6,53 @@ import { Descriptions, List, Tag } from "antd";
import JsonView from "@/components/common/JsonView";
import WorkflowListItem from "../WorkflowListItem";
-const RunRequest = ({run}) => {
- const details = run?.details;
+const RunRequest = ({ run }) => {
+ const details = run?.details;
- if (!details) return
;
+ if (!details) return
;
- const runDataType = details.request.tags.workflow_metadata.data_type;
+ const runDataType = details.request.tags.workflow_metadata.data_type;
- return
- {runDataType && (
-
- {runDataType}
-
- )}
-
-
+ return (
+
+ {runDataType && (
+
+ {runDataType}
-
- {details.request.workflow_type}
-
-
- {details.request.workflow_type_version}
-
-
-
- {details.request.workflow_url}
-
-
-
-
-
-
-
-
-
-
- ;
+ )}
+
+
+
+ {details.request.workflow_type}
+ {details.request.workflow_type_version}
+
+
+ {details.request.workflow_url}
+
+
+
+
+
+
+
+
+
+
+
+ );
};
RunRequest.propTypes = {
- run: PropTypes.shape({
- details: PropTypes.shape({
- request: PropTypes.shape({
- workflow_type: PropTypes.string,
- workflow_type_version: PropTypes.string,
- workflow_url: PropTypes.string,
- tags: PropTypes.object,
- }),
- }),
+ run: PropTypes.shape({
+ details: PropTypes.shape({
+ request: PropTypes.shape({
+ workflow_type: PropTypes.string,
+ workflow_type_version: PropTypes.string,
+ workflow_url: PropTypes.string,
+ tags: PropTypes.object,
+ }),
}),
+ }),
};
export default RunRequest;
diff --git a/src/components/manager/runs/RunTaskLogs.js b/src/components/manager/runs/RunTaskLogs.js
index e9532d460..fb8c3fbe6 100644
--- a/src/components/manager/runs/RunTaskLogs.js
+++ b/src/components/manager/runs/RunTaskLogs.js
@@ -1,10 +1,10 @@
-import React, {Component} from "react";
+import React, { Component } from "react";
class RunTaskLogs extends Component {
- render() {
- // const details = this.props.details || {};
- return TODO
;
- }
+ render() {
+ // const details = this.props.details || {};
+ return TODO
;
+ }
}
export default RunTaskLogs;
diff --git a/src/components/manager/runs/utils.js b/src/components/manager/runs/utils.js
index 48d209d65..3323cb422 100644
--- a/src/components/manager/runs/utils.js
+++ b/src/components/manager/runs/utils.js
@@ -1,102 +1,104 @@
import React from "react";
-import {Link} from "react-router-dom";
-
-import {Tag} from "antd";
+import { Link } from "react-router-dom";
+import { Tag } from "antd";
export const RUN_REFRESH_TIMEOUT = 7500;
-
-export const renderDate = date => date ? new Date(Date.parse(date)).toLocaleString("en-CA") : "";
+export const renderDate = (date) => (date ? new Date(Date.parse(date)).toLocaleString("en-CA") : "");
export const sortDate = (a, b, dateProperty, bDateProperty = undefined) => {
- const aDate = new Date(Date.parse(a[dateProperty])).getTime();
- const bDate = new Date(Date.parse(b[bDateProperty ?? dateProperty])).getTime();
+ const aDate = new Date(Date.parse(a[dateProperty])).getTime();
+ const bDate = new Date(Date.parse(b[bDateProperty ?? dateProperty])).getTime();
- if (aDate && bDate) {
- return aDate - bDate;
- } else if (aDate && !bDate) {
- return -1; // keep empty run times at the top (B > A)
- } else if (!aDate && bDate) {
- return 1; // keep empty run times at the top (A > B)
- } else {
- return 0;
- }
+ if (aDate && bDate) {
+ return aDate - bDate;
+ } else if (aDate && !bDate) {
+ return -1; // keep empty run times at the top (B > A)
+ } else if (!aDate && bDate) {
+ return 1; // keep empty run times at the top (A > B)
+ } else {
+ return 0;
+ }
};
// If end times are unset, sort by start times
const sortEndDate = (a, b) => {
- let prop = "endTime";
- let bProp = undefined;
- if (!a.endTime && !b.endTime) {
- // If neither workflow has an end time, sort by start time instead
- prop = "startTime";
- } else if (!a.endTime && b.endTime) {
- // If 'a' has no end time (i.e., it crashed right away), use a.startTime as a.endTime
- prop = "startTime";
- bProp = "endTime";
- } else if (a.endTime && !b.endTime) {
- // Switched version of above case
- prop = "endTime";
- bProp = "startTime";
- }
- return sortDate(a, b, prop, bProp);
+ let prop = "endTime";
+ let bProp = undefined;
+ if (!a.endTime && !b.endTime) {
+ // If neither workflow has an end time, sort by start time instead
+ prop = "startTime";
+ } else if (!a.endTime && b.endTime) {
+ // If 'a' has no end time (i.e., it crashed right away), use a.startTime as a.endTime
+ prop = "startTime";
+ bProp = "endTime";
+ } else if (a.endTime && !b.endTime) {
+ // Switched version of above case
+ prop = "endTime";
+ bProp = "startTime";
+ }
+ return sortDate(a, b, prop, bProp);
};
export const RUN_STATE_TAG_COLORS = {
- UNKNOWN: "",
- QUEUED: "blue",
- INITIALIZING: "cyan",
- RUNNING: "geekblue",
- PAUSED: "orange",
- COMPLETE: "green",
- EXECUTOR_ERROR: "red",
- SYSTEM_ERROR: "volcano",
- CANCELED: "magenta",
- CANCELING: "purple",
+ UNKNOWN: "",
+ QUEUED: "blue",
+ INITIALIZING: "cyan",
+ RUNNING: "geekblue",
+ PAUSED: "orange",
+ COMPLETE: "green",
+ EXECUTOR_ERROR: "red",
+ SYSTEM_ERROR: "volcano",
+ CANCELED: "magenta",
+ CANCELING: "purple",
};
const runName = (w) => w.details?.run_log?.name ?? "";
const runType = (w) => w.details?.request?.tags?.workflow_metadata?.type ?? "";
export const RUN_TABLE_COLUMNS = [
- {
- title: "Run ID",
- dataIndex: "run_id",
- sorter: (a, b) => a.run_id.localeCompare(b.run_id),
- render: runID => {runID},
- },
- {
- title: "Purpose",
- dataIndex: ["details", "request", "tags", "workflow_metadata", "type"],
- width: 120,
- sorter: (a, b) => runType(a).localeCompare(runType(b)),
- },
- {
- title: "Name",
- dataIndex: ["details", "run_log", "name"],
- sorter: (a, b) => runName(a).localeCompare(runName(b)),
- },
- {
- title: "Started",
- dataIndex: "startTime",
- width: 205,
- render: renderDate,
- sorter: (a, b) => sortDate(a, b, "startTime"),
- },
- {
- title: "Ended",
- dataIndex: "endTime",
- width: 205,
- render: renderDate,
- sorter: sortEndDate,
- defaultSortOrder: "descend",
- },
- {
- title: "State",
- dataIndex: "state",
- width: 150,
- render: state => {state} ,
- sorter: (a, b) => a.state.localeCompare(b.state),
- },
+ {
+ title: "Run ID",
+ dataIndex: "run_id",
+ sorter: (a, b) => a.run_id.localeCompare(b.run_id),
+ render: (runID) => (
+
+ {runID}
+
+ ),
+ },
+ {
+ title: "Purpose",
+ dataIndex: ["details", "request", "tags", "workflow_metadata", "type"],
+ width: 120,
+ sorter: (a, b) => runType(a).localeCompare(runType(b)),
+ },
+ {
+ title: "Name",
+ dataIndex: ["details", "run_log", "name"],
+ sorter: (a, b) => runName(a).localeCompare(runName(b)),
+ },
+ {
+ title: "Started",
+ dataIndex: "startTime",
+ width: 205,
+ render: renderDate,
+ sorter: (a, b) => sortDate(a, b, "startTime"),
+ },
+ {
+ title: "Ended",
+ dataIndex: "endTime",
+ width: 205,
+ render: renderDate,
+ sorter: sortEndDate,
+ defaultSortOrder: "descend",
+ },
+ {
+ title: "State",
+ dataIndex: "state",
+ width: 150,
+ render: (state) => {state} ,
+ sorter: (a, b) => a.state.localeCompare(b.state),
+ },
];
diff --git a/src/components/manager/workflowCommon.js b/src/components/manager/workflowCommon.js
index 971d16481..f208deaaa 100644
--- a/src/components/manager/workflowCommon.js
+++ b/src/components/manager/workflowCommon.js
@@ -1,12 +1,12 @@
import { useNavigate } from "react-router-dom";
import { useCallback } from "react";
-export const FORM_LABEL_COL = {md: {span: 24}, lg: {span: 4}, xl: {span: 6}};
-export const FORM_WRAPPER_COL = {md: {span: 24}, lg: {span: 16}, xl: {span: 12}};
+export const FORM_LABEL_COL = { md: { span: 24 }, lg: { span: 4 }, xl: { span: 6 } };
+export const FORM_WRAPPER_COL = { md: { span: 24 }, lg: { span: 16 }, xl: { span: 12 } };
export const FORM_BUTTON_COL = {
- md: {span: 24},
- lg: {offset: 4, span: 16},
- xl: {offset: 6, span: 12},
+ md: { span: 24 },
+ lg: { offset: 4, span: 16 },
+ xl: { offset: 6, span: 12 },
};
export const STEP_WORKFLOW_SELECTION = 0;
@@ -14,18 +14,21 @@ export const STEP_INPUT = 1;
export const STEP_CONFIRM = 2;
export const useStartIngestionFlow = () => {
- const navigate = useNavigate();
- return useCallback((selectedWorkflow, initialInputValues = undefined) => {
- navigate("/data/manager/ingestion", {
- state: {
- step: STEP_INPUT,
- initialWorkflowFilterValues: {
- text: "",
- tags: [...selectedWorkflow.tags],
- },
- selectedWorkflow,
- initialInputValues,
- },
- });
- }, [navigate]);
+ const navigate = useNavigate();
+ return useCallback(
+ (selectedWorkflow, initialInputValues = undefined) => {
+ navigate("/data/manager/ingestion", {
+ state: {
+ step: STEP_INPUT,
+ initialWorkflowFilterValues: {
+ text: "",
+ tags: [...selectedWorkflow.tags],
+ },
+ selectedWorkflow,
+ initialInputValues,
+ },
+ });
+ },
+ [navigate],
+ );
};
diff --git a/src/components/notifications/NotificationDrawer.js b/src/components/notifications/NotificationDrawer.js
index d6c2ba984..f52458948 100644
--- a/src/components/notifications/NotificationDrawer.js
+++ b/src/components/notifications/NotificationDrawer.js
@@ -9,38 +9,50 @@ import NotificationList from "./NotificationList";
import { hideNotificationDrawer, markAllNotificationsAsRead } from "@/modules/notifications/actions";
import { useNotifications } from "@/modules/notifications/hooks";
-
const NotificationDrawer = React.memo(() => {
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- const markAllAsRead = useCallback(() => {
- dispatch(markAllNotificationsAsRead()).catch((err) => console.error(err));
- }, [dispatch]);
- const seeAllNotifications = useCallback(() => {
- dispatch(hideNotificationDrawer());
- navigate("/notifications");
- }, [dispatch, navigate]);
- const hideNotificationDrawer_ = useCallback(() => {
- dispatch(hideNotificationDrawer());
- }, [dispatch]);
-
- const { unreadItems: unreadNotifications, isMarkingAllAsRead, drawerVisible } = useNotifications();
-
- return
-
- Mark All as Read
- See All Notifications
-
-
-
-
-
- ;
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const markAllAsRead = useCallback(() => {
+ dispatch(markAllNotificationsAsRead()).catch((err) => console.error(err));
+ }, [dispatch]);
+ const seeAllNotifications = useCallback(() => {
+ dispatch(hideNotificationDrawer());
+ navigate("/notifications");
+ }, [dispatch, navigate]);
+ const hideNotificationDrawer_ = useCallback(() => {
+ dispatch(hideNotificationDrawer());
+ }, [dispatch]);
+
+ const { unreadItems: unreadNotifications, isMarkingAllAsRead, drawerVisible } = useNotifications();
+
+ return (
+
+
+
+ Mark All as Read
+
+
+ See All Notifications
+
+
+
+
+
+
+
+ );
});
export default NotificationDrawer;
diff --git a/src/components/notifications/NotificationList.js b/src/components/notifications/NotificationList.js
index d2c571ece..5f6af24eb 100644
--- a/src/components/notifications/NotificationList.js
+++ b/src/components/notifications/NotificationList.js
@@ -10,99 +10,125 @@ import { markNotificationAsRead } from "@/modules/notifications/actions";
import { notificationPropTypesShape } from "@/propTypes";
import { NOTIFICATION_WES_RUN_COMPLETED, NOTIFICATION_WES_RUN_FAILED, navigateToWESRun } from "@/utils/notifications";
-
const sortNotificationTimestamps = (a, b) => b.timestamp - a.timestamp;
const notificationMetaStyle = { marginBottom: "8px" };
const notificationTimestampStyle = {
- color: "#999",
- float: "right",
- fontStyle: "italic",
- fontWeight: "normal",
+ color: "#999",
+ float: "right",
+ fontStyle: "italic",
+ fontWeight: "normal",
};
-
const NotificationList = ({ notifications, small }) => {
- const dispatch = useDispatch();
- const navigate = useNavigate();
-
- /** @type boolean */
- const fetchingNotifications = useSelector(state => state.services.isFetchingAll || state.notifications.isFetching);
-
- const markAsRead = useCallback(id => dispatch(markNotificationAsRead(id)), [dispatch]);
-
- const getNotificationActions = useCallback(
- ({ id, notification_type: notificationType, action_target: actionTarget }) => {
- switch (notificationType) {
- case NOTIFICATION_WES_RUN_COMPLETED:
- case NOTIFICATION_WES_RUN_FAILED:
- return [
- {
- // If they act on this notification, they read it.
- markAsRead(id);
- dispatch(navigateToWESRun(actionTarget, navigate));
- }}>
- Run Details
- ,
- ];
- default:
- return [];
- }
- }, [dispatch, navigate]);
-
- const listItemRender = useCallback(n => (
- }
- style={{ padding: 0 }}
- loading={n.isMarkingAsRead ?? false}
- onClick={() => markAsRead(n.id)}>
- Mark as Read
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ /** @type boolean */
+ const fetchingNotifications = useSelector((state) => state.services.isFetchingAll || state.notifications.isFetching);
+
+ const markAsRead = useCallback((id) => dispatch(markNotificationAsRead(id)), [dispatch]);
+
+ const getNotificationActions = useCallback(
+ ({ id, notification_type: notificationType, action_target: actionTarget }) => {
+ switch (notificationType) {
+ case NOTIFICATION_WES_RUN_COMPLETED:
+ case NOTIFICATION_WES_RUN_FAILED:
+ return [
+ {
+ // If they act on this notification, they read it.
+ markAsRead(id);
+ dispatch(navigateToWESRun(actionTarget, navigate));
+ }}
+ >
+ Run Details
+ ,
+ ];
+ default:
+ return [];
+ }
+ },
+ [dispatch, navigate, markAsRead],
+ );
+
+ const listItemRender = useCallback(
+ (n) => (
+ }
+ style={{ padding: 0 }}
+ loading={n.isMarkingAsRead ?? false}
+ onClick={() => markAsRead(n.id)}
+ >
+ Mark as Read
,
- ]),
- ]}>
- {n.title} {n.timestamp.toLocaleString()} >}
- style={notificationMetaStyle}
- />
- {n.description}
-
- ), [getNotificationActions, markAsRead]);
-
- const processedNotifications = useMemo(() => notifications.map(n => ({
- ...n,
- timestamp: new Date(Date.parse(n.timestamp)),
- })).sort(sortNotificationTimestamps), [notifications]);
-
- const isSmall = small ?? false;
- const pagination = useMemo(() => ({
- hideOnSinglePage: isSmall,
- pageSize: isSmall ? 5 : 10,
- size: isSmall ? "small" : "",
- }), [isSmall]);
-
- return (
-
+
+ {n.title} {n.timestamp.toLocaleString()}
+ >
+ }
+ style={notificationMetaStyle}
/>
- );
+ {n.description}
+
+ ),
+ [getNotificationActions, markAsRead],
+ );
+
+ const processedNotifications = useMemo(
+ () =>
+ notifications
+ .map((n) => ({
+ ...n,
+ timestamp: new Date(Date.parse(n.timestamp)),
+ }))
+ .sort(sortNotificationTimestamps),
+ [notifications],
+ );
+
+ const isSmall = small ?? false;
+ const pagination = useMemo(
+ () => ({
+ hideOnSinglePage: isSmall,
+ pageSize: isSmall ? 5 : 10,
+ size: isSmall ? "small" : "",
+ }),
+ [isSmall],
+ );
+
+ return (
+
+ );
};
NotificationList.propTypes = {
- notifications: PropTypes.arrayOf(notificationPropTypesShape),
- small: PropTypes.bool,
+ notifications: PropTypes.arrayOf(notificationPropTypesShape),
+ small: PropTypes.bool,
- fetchingNotifications: PropTypes.bool,
+ fetchingNotifications: PropTypes.bool,
- markNotificationAsRead: PropTypes.func,
- navigateToWESRun: PropTypes.func,
+ markNotificationAsRead: PropTypes.func,
+ navigateToWESRun: PropTypes.func,
};
export default NotificationList;
diff --git a/src/components/notifications/NotificationsContent.js b/src/components/notifications/NotificationsContent.js
index 3de8be327..60d6895b7 100644
--- a/src/components/notifications/NotificationsContent.js
+++ b/src/components/notifications/NotificationsContent.js
@@ -1,25 +1,28 @@
import React from "react";
-import {useSelector} from "react-redux";
+import { useSelector } from "react-redux";
-import {Layout, Typography} from "antd";
+import { Layout, Typography } from "antd";
import NotificationList from "./NotificationList";
import SitePageHeader from "../SitePageHeader";
-
const NotificationsContent = React.memo(() => {
- const ns = useSelector(state => state.notifications.items);
- return <>
-
-
-
- Unread
- !n.read)} />
- Read
- n.read)} />
-
-
- >;
+ const ns = useSelector((state) => state.notifications.items);
+ return (
+ <>
+
+
+
+ Unread
+ !n.read)} />
+
+ Read
+
+ n.read)} />
+
+
+ >
+ );
});
export default NotificationsContent;
diff --git a/src/components/overview/ChartCollection.js b/src/components/overview/ChartCollection.js
index e515811c1..113c94609 100644
--- a/src/components/overview/ChartCollection.js
+++ b/src/components/overview/ChartCollection.js
@@ -8,42 +8,39 @@ import Histogram from "../charts/Histogram";
const CHART_HEIGHT = 285;
const ChartCollection = ({ charts, dataType, isFetching }) => (
-
- {charts
- .map((c, i) => (
-
-
- {c.type === "PIE" ? (
-
- ) : (
-
- )}
-
-
- ))}
-
+
+ {charts.map((c, i) => (
+
+
+ {c.type === "PIE" ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
);
ChartCollection.propTypes = {
- charts: PropTypes.arrayOf(PropTypes.shape({
- title: PropTypes.string.isRequired,
- data: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, value: PropTypes.number })),
- fieldLabel: PropTypes.string,
- thresholdFraction: PropTypes.number,
- })),
- dataType: PropTypes.string.isRequired,
- isFetching: PropTypes.bool,
+ charts: PropTypes.arrayOf(
+ PropTypes.shape({
+ title: PropTypes.string.isRequired,
+ data: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, value: PropTypes.number })),
+ fieldLabel: PropTypes.string,
+ thresholdFraction: PropTypes.number,
+ }),
+ ),
+ dataType: PropTypes.string.isRequired,
+ isFetching: PropTypes.bool,
};
export default ChartCollection;
diff --git a/src/components/overview/ClinicalSummary.js b/src/components/overview/ClinicalSummary.js
index 3bc2d737c..3009a6145 100644
--- a/src/components/overview/ClinicalSummary.js
+++ b/src/components/overview/ClinicalSummary.js
@@ -9,84 +9,90 @@ import StatisticCollection from "./StatisticCollection";
import ChartCollection from "./ChartCollection";
const ClinicalSummary = ({ overviewSummary }) => {
- const { data, isFetching, hasAttempted } = overviewSummary;
- const { phenopacket, experiment } = data ?? {};
+ const { data, isFetching, hasAttempted } = overviewSummary;
+ const { phenopacket, experiment } = data ?? {};
- const { data_type_specific: phenoSpecific } = phenopacket ?? {};
- const { data_type_specific: expSpecific } = experiment ?? {};
+ const { data_type_specific: phenoSpecific } = phenopacket ?? {};
+ const { data_type_specific: expSpecific } = experiment ?? {};
- const statistics = useMemo(() => [
- {
- title: "Participants",
- value: phenoSpecific?.individuals?.count,
- },
- {
- title: "Biosamples",
- value: phenoSpecific?.biosamples?.count,
- },
- {
- title: "Diseases",
- value: phenoSpecific?.diseases?.count,
- },
- {
- title: "Phenotypic Features",
- value: phenoSpecific?.phenotypic_features?.count,
- },
- {
- title: "Experiments",
- value: expSpecific?.experiments?.count,
- },
- ], [phenoSpecific]);
+ const statistics = useMemo(
+ () => [
+ {
+ title: "Participants",
+ value: phenoSpecific?.individuals?.count,
+ },
+ {
+ title: "Biosamples",
+ value: phenoSpecific?.biosamples?.count,
+ },
+ {
+ title: "Diseases",
+ value: phenoSpecific?.diseases?.count,
+ },
+ {
+ title: "Phenotypic Features",
+ value: phenoSpecific?.phenotypic_features?.count,
+ },
+ {
+ title: "Experiments",
+ value: expSpecific?.experiments?.count,
+ },
+ ],
+ [phenoSpecific, expSpecific],
+ );
- const charts = useMemo(() => [
- getPieChart({
- title: "Individuals",
- data: phenoSpecific?.individuals?.sex,
- fieldLabel: "[dataset item].subject.sex",
- thresholdFraction: 0,
- }),
- getPieChart({
- title: "Diseases",
- data: phenoSpecific?.diseases?.term,
- fieldLabel: "[dataset item].diseases.[item].term.label",
- }),
- {
- title: "Ages",
- data: binAges(phenoSpecific?.individuals?.age),
- type: "HISTOGRAM",
- },
- getPieChart({
- title: "Biosamples",
- data: phenoSpecific?.biosamples?.sampled_tissue,
- fieldLabel: "[dataset item].biosamples.[item].sampled_tissue.label",
- }),
- getPieChart({
- title: "Phenotypic Features",
- data: phenoSpecific?.phenotypic_features?.type,
- fieldLabel: "[dataset item].phenotypic_features.[item].type.label",
- }),
- ], [phenoSpecific]);
+ const charts = useMemo(
+ () => [
+ getPieChart({
+ title: "Individuals",
+ data: phenoSpecific?.individuals?.sex,
+ fieldLabel: "[dataset item].subject.sex",
+ thresholdFraction: 0,
+ }),
+ getPieChart({
+ title: "Diseases",
+ data: phenoSpecific?.diseases?.term,
+ fieldLabel: "[dataset item].diseases.[item].term.label",
+ }),
+ {
+ title: "Ages",
+ data: binAges(phenoSpecific?.individuals?.age),
+ type: "HISTOGRAM",
+ },
+ getPieChart({
+ title: "Biosamples",
+ data: phenoSpecific?.biosamples?.sampled_tissue,
+ fieldLabel: "[dataset item].biosamples.[item].sampled_tissue.label",
+ }),
+ getPieChart({
+ title: "Phenotypic Features",
+ data: phenoSpecific?.phenotypic_features?.type,
+ fieldLabel: "[dataset item].phenotypic_features.[item].type.label",
+ }),
+ ],
+ [phenoSpecific],
+ );
- return (
- <>
-
- Clinical/Phenotypic Data
-
-
-
-
-
-
-
- >
- );
+ return (
+ <>
+
+ Clinical/Phenotypic Data
+
+
+
+
+
+
+
+ >
+ );
};
ClinicalSummary.propTypes = {
- overviewSummary: PropTypes.shape({
- data: PropTypes.object,
- isFetching: PropTypes.bool,
- hasAttempted: PropTypes.bool,
- }),
+ overviewSummary: PropTypes.shape({
+ data: PropTypes.object,
+ isFetching: PropTypes.bool,
+ hasAttempted: PropTypes.bool,
+ }),
};
export default ClinicalSummary;
@@ -95,35 +101,35 @@ export default ClinicalSummary;
// input is object: {age1: count1, age2: count2....}
// outputs an array [{bin1: bin1count}, {bin2: bin2count}...]
const binAges = (ages) => {
- if (!ages) return null;
+ if (!ages) return null;
- const ageBinCounts = {
- 0: 0,
- 10: 0,
- 20: 0,
- 30: 0,
- 40: 0,
- 50: 0,
- 60: 0,
- 70: 0,
- 80: 0,
- 90: 0,
- 100: 0,
- 110: 0,
- };
+ const ageBinCounts = {
+ 0: 0,
+ 10: 0,
+ 20: 0,
+ 30: 0,
+ 40: 0,
+ 50: 0,
+ 60: 0,
+ 70: 0,
+ 80: 0,
+ 90: 0,
+ 100: 0,
+ 110: 0,
+ };
- for (const [age, count] of Object.entries(ages)) {
- const ageBin = 10 * Math.floor(Number(age) / 10);
- ageBinCounts[ageBin] += count;
- }
+ for (const [age, count] of Object.entries(ages)) {
+ const ageBin = 10 * Math.floor(Number(age) / 10);
+ ageBinCounts[ageBin] += count;
+ }
- // only show ages 110+ if present
- if (!ageBinCounts[110]) {
- delete ageBinCounts[110];
- }
+ // only show ages 110+ if present
+ if (!ageBinCounts[110]) {
+ delete ageBinCounts[110];
+ }
- // return histogram-friendly array
- return Object.keys(ageBinCounts).map((age) => {
- return { ageBin: age, count: ageBinCounts[age] };
- });
+ // return histogram-friendly array
+ return Object.keys(ageBinCounts).map((age) => {
+ return { ageBin: age, count: ageBinCounts[age] };
+ });
};
diff --git a/src/components/overview/ExperimentsSummary.js b/src/components/overview/ExperimentsSummary.js
index 1fc5ac727..b2c96cbd1 100644
--- a/src/components/overview/ExperimentsSummary.js
+++ b/src/components/overview/ExperimentsSummary.js
@@ -9,63 +9,69 @@ import StatisticCollection from "./StatisticCollection";
import ChartCollection from "./ChartCollection";
const ExperimentsSummary = ({ overviewSummary }) => {
- const { data, isFetching, hasAttempted } = overviewSummary;
- const experimentsSummary = data?.experiment?.data_type_specific?.experiments ?? {};
+ const { data, isFetching, hasAttempted } = overviewSummary;
+ const experimentsSummary = useMemo(() => data?.experiment?.data_type_specific?.experiments ?? {}, [data]);
- // TODO: most of these have "other" categories, so counts here are ambiguous or simply incorrect
- const statistics = useMemo(() => [
- { title: "Experiments", value: experimentsSummary.count ?? 0 },
- { title: "Experiment Types", value: Object.keys(experimentsSummary.experiment_type || {}).length },
- { title: "Molecules Used", value: Object.keys(experimentsSummary.molecule || {}).length },
- { title: "Library Strategies", value: Object.keys(experimentsSummary.library_strategy || {}).length },
- ], [experimentsSummary]);
+ // TODO: most of these have "other" categories, so counts here are ambiguous or simply incorrect
+ const statistics = useMemo(
+ () => [
+ { title: "Experiments", value: experimentsSummary.count ?? 0 },
+ { title: "Experiment Types", value: Object.keys(experimentsSummary.experiment_type || {}).length },
+ { title: "Molecules Used", value: Object.keys(experimentsSummary.molecule || {}).length },
+ { title: "Library Strategies", value: Object.keys(experimentsSummary.library_strategy || {}).length },
+ ],
+ [experimentsSummary],
+ );
- const charts = useMemo(() => [
- getPieChart({
- title: "Study Types",
- data: experimentsSummary.study_type,
- fieldLabel: "[dataset item].study_type",
- }),
- getPieChart({
- title: "Experiment Types",
- data: experimentsSummary.experiment_type,
- fieldLabel: "[dataset item].experiment_type",
- }),
- getPieChart({
- title: "Molecules Used",
- data: experimentsSummary.molecule,
- fieldLabel: "[dataset item].molecule",
- }),
- getPieChart({
- title: "Library Strategies",
- data: experimentsSummary.library_strategy,
- fieldLabel: "[dataset item].library_strategy",
- }),
- getPieChart({
- title: "Library Selections",
- data: experimentsSummary.library_selection,
- fieldLabel: "[dataset item].library_selection",
- }),
- ], [experimentsSummary]);
+ const charts = useMemo(
+ () => [
+ getPieChart({
+ title: "Study Types",
+ data: experimentsSummary.study_type,
+ fieldLabel: "[dataset item].study_type",
+ }),
+ getPieChart({
+ title: "Experiment Types",
+ data: experimentsSummary.experiment_type,
+ fieldLabel: "[dataset item].experiment_type",
+ }),
+ getPieChart({
+ title: "Molecules Used",
+ data: experimentsSummary.molecule,
+ fieldLabel: "[dataset item].molecule",
+ }),
+ getPieChart({
+ title: "Library Strategies",
+ data: experimentsSummary.library_strategy,
+ fieldLabel: "[dataset item].library_strategy",
+ }),
+ getPieChart({
+ title: "Library Selections",
+ data: experimentsSummary.library_selection,
+ fieldLabel: "[dataset item].library_selection",
+ }),
+ ],
+ [experimentsSummary],
+ );
- return (
- <>
- Experiments
-
-
-
-
-
-
- >
- );
+ return (
+ <>
+ Experiments
+
+
+
+
+
+
+ >
+ );
};
ExperimentsSummary.propTypes = {
- overviewSummary: PropTypes.shape({
- data: PropTypes.object,
- isFetching: PropTypes.bool,
- hasAttempted: PropTypes.bool,
- }),
+ overviewSummary: PropTypes.shape({
+ data: PropTypes.object,
+ isFetching: PropTypes.bool,
+ hasAttempted: PropTypes.bool,
+ }),
};
export default ExperimentsSummary;
diff --git a/src/components/overview/OverviewSettingsControl.js b/src/components/overview/OverviewSettingsControl.js
index 0450077e8..1d27fb5d0 100644
--- a/src/components/overview/OverviewSettingsControl.js
+++ b/src/components/overview/OverviewSettingsControl.js
@@ -17,86 +17,84 @@ const toolTipFormatter = (value) => `${value}%`;
const sliderTooltip = { formatter: toolTipFormatter };
const OverviewSettingsControl = ({ modalVisible, toggleModalVisibility }) => {
- const otherThresholdPercentage = useSelector((state) => state.explorer.otherThresholdPercentage);
- const [inputValue, setInputValue] = useState(
- otherThresholdPercentage ?? DEFAULT_OTHER_THRESHOLD_PERCENTAGE,
- );
+ const otherThresholdPercentage = useSelector((state) => state.explorer.otherThresholdPercentage);
+ const [inputValue, setInputValue] = useState(otherThresholdPercentage ?? DEFAULT_OTHER_THRESHOLD_PERCENTAGE);
- //preserve earlier setting in case user cancels
- const [previousThreshold, setPreviousThreshold] = useState(
- otherThresholdPercentage ?? DEFAULT_OTHER_THRESHOLD_PERCENTAGE,
- );
- const dispatch = useDispatch();
+ //preserve earlier setting in case user cancels
+ const [previousThreshold, setPreviousThreshold] = useState(
+ otherThresholdPercentage ?? DEFAULT_OTHER_THRESHOLD_PERCENTAGE,
+ );
+ const dispatch = useDispatch();
- const handleChange = (newValue) => {
- setInputValue(newValue);
- setOtherThresholdPercentageInStore(newValue);
- };
+ const handleChange = (newValue) => {
+ setInputValue(newValue);
+ setOtherThresholdPercentageInStore(newValue);
+ };
- const handleEnter = (e) => {
- e.preventDefault();
- setValueAndCloseModal();
- };
+ const handleEnter = (e) => {
+ e.preventDefault();
+ setValueAndCloseModal();
+ };
- const setOtherThresholdPercentageInStore = (value) => {
- dispatch(setOtherThresholdPercentage(value));
- };
+ const setOtherThresholdPercentageInStore = (value) => {
+ dispatch(setOtherThresholdPercentage(value));
+ };
- // only write to localStorage on close instead of at every change
- const setValueAndCloseModal = () => {
- setOtherThresholdPercentageInStore(inputValue);
- setPreviousThreshold(inputValue);
- writeToLocalStorage("otherThresholdPercentage", inputValue);
- toggleModalVisibility();
- };
+ // only write to localStorage on close instead of at every change
+ const setValueAndCloseModal = () => {
+ setOtherThresholdPercentageInStore(inputValue);
+ setPreviousThreshold(inputValue);
+ writeToLocalStorage("otherThresholdPercentage", inputValue);
+ toggleModalVisibility();
+ };
- const cancelModal = () => {
- setInputValue(previousThreshold);
- setOtherThresholdPercentageInStore(previousThreshold);
- toggleModalVisibility();
- };
+ const cancelModal = () => {
+ setInputValue(previousThreshold);
+ setOtherThresholdPercentageInStore(previousThreshold);
+ toggleModalVisibility();
+ };
- return (
-
-
-
-
-
-
-
-
- Combine categories below this percentage into an "Other" category
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+ Combine categories below this percentage into an "Other" category
+
+
+ );
};
OverviewSettingsControl.propTypes = {
- modalVisible: PropTypes.bool,
- toggleModalVisibility: PropTypes.func,
+ modalVisible: PropTypes.bool,
+ toggleModalVisibility: PropTypes.func,
};
export default OverviewSettingsControl;
diff --git a/src/components/overview/StatisticCollection.js b/src/components/overview/StatisticCollection.js
index f26b14be5..1edbc48a5 100644
--- a/src/components/overview/StatisticCollection.js
+++ b/src/components/overview/StatisticCollection.js
@@ -2,22 +2,22 @@ import React from "react";
import PropTypes from "prop-types";
import { Col, Spin, Statistic } from "antd";
-const StatisticCollection = React.memo(({statistics, isFetching}) => {
- return (
- <>
- {statistics.map((s, i) => (
-
-
-
-
-
- ))}
- >
- );
+const StatisticCollection = React.memo(({ statistics, isFetching }) => {
+ return (
+ <>
+ {statistics.map((s, i) => (
+
+
+
+
+
+ ))}
+ >
+ );
});
StatisticCollection.propTypes = {
- statistics: PropTypes.arrayOf(PropTypes.shape({ title: PropTypes.string, value: PropTypes.number })),
- isFetching: PropTypes.bool,
+ statistics: PropTypes.arrayOf(PropTypes.shape({ title: PropTypes.string, value: PropTypes.number })),
+ isFetching: PropTypes.bool,
};
export default StatisticCollection;
diff --git a/src/components/overview/VariantsSummary.js b/src/components/overview/VariantsSummary.js
index ac33f42eb..53fd998f2 100644
--- a/src/components/overview/VariantsSummary.js
+++ b/src/components/overview/VariantsSummary.js
@@ -1,37 +1,29 @@
import React from "react";
import { useSelector } from "react-redux";
import { BiDna } from "react-icons/bi";
-import { Col, Row, Spin, Statistic, Typography} from "antd";
+import { Col, Row, Spin, Statistic, Typography } from "antd";
-const VariantsSummary = ( ) => {
+const VariantsSummary = () => {
+ const fetchingVariantsOverview = useSelector((state) => state.explorer.fetchingVariantsOverview);
+ const variantsOverviewResults = useSelector((state) => state.explorer.variantsOverviewResponse);
+ const hasSampleIds =
+ variantsOverviewResults?.sampleIDs !== undefined && !variantsOverviewResults?.sampleIDs.hasOwnProperty("error");
+ // retrieve list of values from `sampleIDs`
+ // assumes (1 sampleId == 1 file)
+ const numVarFilesFromSampleIds = hasSampleIds ? Object.keys(variantsOverviewResults.sampleIDs).length : 0;
- const fetchingVariantsOverview = useSelector(state => state.explorer.fetchingVariantsOverview);
- const variantsOverviewResults = useSelector(state => state.explorer.variantsOverviewResponse);
- const hasSampleIds =
- variantsOverviewResults?.sampleIDs !== undefined &&
- !variantsOverviewResults?.sampleIDs.hasOwnProperty("error");
- // retrieve list of values from `sampleIDs`
- // assumes (1 sampleId == 1 file)
- const numVarFilesFromSampleIds =
- hasSampleIds ?
- Object.keys(variantsOverviewResults.sampleIDs).length :
- 0;
-
- return (
- <>
- Variants
-
-
-
- }
- value={numVarFilesFromSampleIds} />
-
-
-
- >
- );
+ return (
+ <>
+ Variants
+
+
+
+ } value={numVarFilesFromSampleIds} />
+
+
+
+ >
+ );
};
export default VariantsSummary;
diff --git a/src/components/schema_trees/SchemaTree.js b/src/components/schema_trees/SchemaTree.js
index 0097900d0..7f6bb27ba 100644
--- a/src/components/schema_trees/SchemaTree.js
+++ b/src/components/schema_trees/SchemaTree.js
@@ -1,19 +1,15 @@
import React from "react";
import PropTypes from "prop-types";
-import {Tree} from "antd";
+import { Tree } from "antd";
-import {generateSchemaTreeData} from "../../utils/schema";
+import { generateSchemaTreeData } from "../../utils/schema";
-const SchemaTree = ({schema}) => (
-
- {schema
- ?
- : null}
-
+const SchemaTree = ({ schema }) => (
+ {schema ? : null}
);
SchemaTree.propTypes = {
- schema: PropTypes.object,
+ schema: PropTypes.object,
};
export default SchemaTree;
diff --git a/src/components/schema_trees/SchemaTreeSelect.js b/src/components/schema_trees/SchemaTreeSelect.js
index 22be6e317..809dc0f90 100644
--- a/src/components/schema_trees/SchemaTreeSelect.js
+++ b/src/components/schema_trees/SchemaTreeSelect.js
@@ -1,63 +1,68 @@
-import React, {Component} from "react";
+import React, { Component } from "react";
-import {TreeSelect} from "antd";
+import { TreeSelect } from "antd";
-import {ROOT_SCHEMA_ID, generateSchemaTreeData, getFieldSchema} from "../../utils/schema";
+import { ROOT_SCHEMA_ID, generateSchemaTreeData, getFieldSchema } from "../../utils/schema";
import PropTypes from "prop-types";
class SchemaTreeSelect extends Component {
- static getDerivedStateFromProps(nextProps) {
- if ("value" in nextProps) {
- return {...(nextProps.value ?? {})};
- }
- return null;
- }
-
- constructor(props) {
- super(props);
- const value = props.value ?? {};
- this.state = {
- selected: value.selected ?? undefined,
- schema: value.schema ?? undefined,
- };
+ static getDerivedStateFromProps(nextProps) {
+ if ("value" in nextProps) {
+ return { ...(nextProps.value ?? {}) };
}
+ return null;
+ }
- onChange(selected) {
- // Set the state directly unless value is bound
- if (!("value" in this.props)) {
- this.setState({selected, schema: getFieldSchema(this.props.schema, selected)});
- }
+ constructor(props) {
+ super(props);
+ const value = props.value ?? {};
+ this.state = {
+ selected: value.selected ?? undefined,
+ schema: value.schema ?? undefined,
+ };
+ }
- // Update the change handler bound to the component
- if (this.props.onChange) {
- this.props.onChange({...this.state, selected, schema: getFieldSchema(this.props.schema, selected)});
- }
+ onChange(selected) {
+ // Set the state directly unless value is bound
+ if (!("value" in this.props)) {
+ this.setState({ selected, schema: getFieldSchema(this.props.schema, selected) });
}
- render() {
- return (
-
- );
+ // Update the change handler bound to the component
+ if (this.props.onChange) {
+ this.props.onChange({ ...this.state, selected, schema: getFieldSchema(this.props.schema, selected) });
}
+ }
+
+ render() {
+ return (
+
+ );
+ }
}
SchemaTreeSelect.propTypes = {
- style: PropTypes.object,
- disabled: PropTypes.bool,
- schema: PropTypes.object,
- isExcluded: PropTypes.func,
- onChange: PropTypes.func,
- value: PropTypes.object,
+ style: PropTypes.object,
+ disabled: PropTypes.bool,
+ schema: PropTypes.object,
+ isExcluded: PropTypes.func,
+ onChange: PropTypes.func,
+ value: PropTypes.object,
};
export default SchemaTreeSelect;
diff --git a/src/components/services/ServiceDetail.js b/src/components/services/ServiceDetail.js
index bdd787ce4..3d6baf9ee 100644
--- a/src/components/services/ServiceDetail.js
+++ b/src/components/services/ServiceDetail.js
@@ -10,62 +10,65 @@ import ServiceOverview from "./ServiceOverview";
import { matchingMenuKeys, transformMenuItem } from "@/utils/menu";
const styles = {
- // TODO: Deduplicate with data manager
- menu: {
- marginLeft: "-24px",
- marginRight: "-24px",
- marginTop: "-12px",
- },
- suspenseFallback: {
- padding: "24px",
- backgroundColor: "white",
- },
+ // TODO: Deduplicate with data manager
+ menu: {
+ marginLeft: "-24px",
+ marginRight: "-24px",
+ marginTop: "-12px",
+ },
+ suspenseFallback: {
+ padding: "24px",
+ backgroundColor: "white",
+ },
};
const SuspenseFallback = React.memo(() => (
-
+
+
+
));
const ServiceDetail = () => {
- // TODO: 404
- const navigate = useNavigate();
- const { kind } = useParams();
+ // TODO: 404
+ const navigate = useNavigate();
+ const { kind } = useParams();
- const serviceInfoByKind = useSelector((state) => state.services.itemsByKind);
+ const serviceInfoByKind = useSelector((state) => state.services.itemsByKind);
- const serviceInfo = useMemo(() => serviceInfoByKind[kind], [kind, serviceInfoByKind]);
+ const serviceInfo = useMemo(() => serviceInfoByKind[kind], [kind, serviceInfoByKind]);
- const menuItems = useMemo(() => [
- { url: `/services/${kind}/overview`, style: { marginLeft: "4px" }, text: "Overview" },
- ], [kind]);
- const selectedKeys = matchingMenuKeys(menuItems);
+ const menuItems = useMemo(
+ () => [{ url: `/services/${kind}/overview`, style: { marginLeft: "4px" }, text: "Overview" }],
+ [kind],
+ );
+ const selectedKeys = matchingMenuKeys(menuItems);
- const onBack = useCallback(() => navigate("/services"), [navigate]);
+ const onBack = useCallback(() => navigate("/services"), [navigate]);
- return (
- <>
-
- }
- withTabBar={true}
- onBack={onBack}
- />
- }>
-
- } />
- } />
-
-
- >
- );
+ return (
+ <>
+
+ }
+ withTabBar={true}
+ onBack={onBack}
+ />
+ }>
+
+ } />
+ } />
+
+
+ >
+ );
};
export default ServiceDetail;
diff --git a/src/components/services/ServiceOverview.js b/src/components/services/ServiceOverview.js
index c60824f28..99e0f3584 100644
--- a/src/components/services/ServiceOverview.js
+++ b/src/components/services/ServiceOverview.js
@@ -10,30 +10,34 @@ import { LAYOUT_CONTENT_STYLE } from "@/styles/layoutContent";
const TITLE_STYLE = { marginTop: 0 };
const ServiceOverview = () => {
- const { kind } = useParams();
-
- const serviceInfo = useService(kind);
- const bentoServiceInfo = useBentoService(kind);
-
- const loading = !(serviceInfo && bentoServiceInfo);
-
- if (loading) return ;
- return (
-
-
-
-
- Service Info
-
-
-
- Bento Service Configuration
-
-
-
-
-
- );
+ const { kind } = useParams();
+
+ const serviceInfo = useService(kind);
+ const bentoServiceInfo = useBentoService(kind);
+
+ const loading = !(serviceInfo && bentoServiceInfo);
+
+ if (loading) return ;
+ return (
+
+
+
+
+
+ Service Info
+
+
+
+
+
+ Bento Service Configuration
+
+
+
+
+
+
+ );
};
export default ServiceOverview;
diff --git a/src/components/services/ServiceRequestModal.js b/src/components/services/ServiceRequestModal.js
index f1df7d249..93e10ac94 100644
--- a/src/components/services/ServiceRequestModal.js
+++ b/src/components/services/ServiceRequestModal.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo, useState} from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
import PropTypes from "prop-types";
import { useAuthorizationHeader } from "bento-auth-js";
@@ -7,115 +7,112 @@ import { Button, Divider, Form, Input, Modal, Skeleton } from "antd";
import JsonDisplay from "../display/JsonDisplay";
import { useBentoServices } from "@/modules/services/hooks";
-const ServiceRequestModal = ({service, onCancel}) => {
- const bentoServicesByKind = useBentoServices().itemsByKind;
- const serviceUrl = useMemo(() => bentoServicesByKind[service]?.url, [bentoServicesByKind, service]);
+const ServiceRequestModal = ({ service, onCancel }) => {
+ const bentoServicesByKind = useBentoServices().itemsByKind;
+ const serviceUrl = useMemo(() => bentoServicesByKind[service]?.url, [bentoServicesByKind, service]);
- const [requestPath, setRequestPath] = useState("service-info");
- const [requestLoading, setRequestLoading] = useState(false);
- const [requestData, setRequestData] = useState(null);
- const [requestIsJSON, setRequestIsJSON] = useState(false);
+ const [requestPath, setRequestPath] = useState("service-info");
+ const [requestLoading, setRequestLoading] = useState(false);
+ const [requestData, setRequestData] = useState(null);
+ const [requestIsJSON, setRequestIsJSON] = useState(false);
- const [hasAttempted, setHasAttempted] = useState(false);
+ const [hasAttempted, setHasAttempted] = useState(false);
- const authHeader = useAuthorizationHeader();
+ const authHeader = useAuthorizationHeader();
- const performRequestModalGet = useCallback(() => {
- if (!serviceUrl) {
- setRequestData(null);
- return;
- }
- (async () => {
- setRequestLoading(true);
+ const performRequestModalGet = useCallback(() => {
+ if (!serviceUrl) {
+ setRequestData(null);
+ return;
+ }
+ (async () => {
+ setRequestLoading(true);
- const p = requestPath.replace(/^\//, "");
- try {
- const res = await fetch(`${serviceUrl}/${p}`, {
- headers: authHeader,
- });
+ const p = requestPath.replace(/^\//, "");
+ try {
+ const res = await fetch(`${serviceUrl}/${p}`, {
+ headers: authHeader,
+ });
- if ((res.headers.get("content-type") ?? "").includes("application/json")) {
- const data = await res.json();
- setRequestIsJSON(true);
- setRequestData(data);
- } else {
- const data = await res.text();
- setRequestIsJSON(false);
- setRequestData(data);
- }
- } finally {
- setRequestLoading(false);
- setRequestPath(p); // With starting '/' trimmed off if needed
- }
- })();
- }, [serviceUrl, requestPath, authHeader]);
+ if ((res.headers.get("content-type") ?? "").includes("application/json")) {
+ const data = await res.json();
+ setRequestIsJSON(true);
+ setRequestData(data);
+ } else {
+ const data = await res.text();
+ setRequestIsJSON(false);
+ setRequestData(data);
+ }
+ } finally {
+ setRequestLoading(false);
+ setRequestPath(p); // With starting '/' trimmed off if needed
+ }
+ })();
+ }, [serviceUrl, requestPath, authHeader]);
- useEffect(() => {
- setRequestData(null);
- setRequestIsJSON(false);
- setRequestPath("service-info");
- setHasAttempted(false);
- }, [service]);
+ useEffect(() => {
+ setRequestData(null);
+ setRequestIsJSON(false);
+ setRequestPath("service-info");
+ setHasAttempted(false);
+ }, [service]);
- useEffect(() => {
- if (!hasAttempted) {
- performRequestModalGet();
- setHasAttempted(true);
- }
- }, [hasAttempted, performRequestModalGet]);
+ useEffect(() => {
+ if (!hasAttempted) {
+ performRequestModalGet();
+ setHasAttempted(true);
+ }
+ }, [hasAttempted, performRequestModalGet]);
- const formSubmit = useCallback(e => {
- performRequestModalGet();
- e.preventDefault();
- }, [performRequestModalGet]);
+ const formSubmit = useCallback(
+ (e) => {
+ performRequestModalGet();
+ e.preventDefault();
+ },
+ [performRequestModalGet],
+ );
- return (
-
-
- setRequestPath(e.target.value)}
- />
-
-
- GET
-
-
-
- {requestLoading ? : (
- requestIsJSON
- ?
- : (
-
-
- {((typeof requestData) === "string" || requestData === null)
- ? requestData
- : JSON.stringify(requestData)}
-
-
- )
- )}
-
- );
+ return (
+
+
+ setRequestPath(e.target.value)}
+ />
+
+
+
+ GET
+
+
+
+
+ {requestLoading ? (
+
+ ) : requestIsJSON ? (
+
+ ) : (
+
+
+ {typeof requestData === "string" || requestData === null ? requestData : JSON.stringify(requestData)}
+
+
+ )}
+
+ );
};
ServiceRequestModal.propTypes = {
- service: PropTypes.string,
- onCancel: PropTypes.func,
+ service: PropTypes.string,
+ onCancel: PropTypes.func,
};
export default ServiceRequestModal;
diff --git a/src/config.js b/src/config.js
index 0d7858f99..6bb23327f 100644
--- a/src/config.js
+++ b/src/config.js
@@ -5,21 +5,26 @@ export const BENTO_PUBLIC_URL = BENTO_WEB_CONFIG.BENTO_PUBLIC_URL ?? process.env
export const BENTO_URL_NO_TRAILING_SLASH = BENTO_URL.replace(/\/$/g, "");
// Use || here instead of ??: the first true value should override any previous false (which is not null-ish)
-export const BENTO_CBIOPORTAL_ENABLED = BENTO_WEB_CONFIG.BENTO_CBIOPORTAL_ENABLED
- || ["true", "1", "yes"].includes(process.env.BENTO_CBIOPORTAL_ENABLED || "");
-export const BENTO_CBIOPORTAL_PUBLIC_URL = BENTO_WEB_CONFIG.BENTO_CBIOPORTAL_PUBLIC_URL ??
- process.env.BENTO_CBIOPORTAL_PUBLIC_URL ?? null;
+export const BENTO_CBIOPORTAL_ENABLED =
+ BENTO_WEB_CONFIG.BENTO_CBIOPORTAL_ENABLED ||
+ ["true", "1", "yes"].includes(process.env.BENTO_CBIOPORTAL_ENABLED || "");
+export const BENTO_CBIOPORTAL_PUBLIC_URL =
+ BENTO_WEB_CONFIG.BENTO_CBIOPORTAL_PUBLIC_URL ?? process.env.BENTO_CBIOPORTAL_PUBLIC_URL ?? null;
export const CUSTOM_HEADER = BENTO_WEB_CONFIG.CUSTOM_HEADER ?? process.env.CUSTOM_HEADER ?? null;
+export const BENTO_GRAFANA_URL = `${BENTO_URL_NO_TRAILING_SLASH}/api/grafana`;
+export const BENTO_MONITORING_ENABLED =
+ BENTO_WEB_CONFIG.BENTO_MONITORING_ENABLED ||
+ ["true", "1", "yes"].includes(process.env.BENTO_MONITORING_ENABLED || "");
+
/** @type {string} */
export const CLIENT_ID = BENTO_WEB_CONFIG.CLIENT_ID ?? process.env.CLIENT_ID ?? "";
/** @type {string} */
-export const OPENID_CONFIG_URL = BENTO_WEB_CONFIG.OPENID_CONFIG_URL
- ?? process.env.OPENID_CONFIG_URL ?? "";
+export const OPENID_CONFIG_URL = BENTO_WEB_CONFIG.OPENID_CONFIG_URL ?? process.env.OPENID_CONFIG_URL ?? "";
export const AUTH_CALLBACK_URL = `${BENTO_URL_NO_TRAILING_SLASH}/callback`;
-export const IDP_BASE_URL = OPENID_CONFIG_URL ? (new URL(OPENID_CONFIG_URL)).origin : null;
+export const IDP_BASE_URL = OPENID_CONFIG_URL ? new URL(OPENID_CONFIG_URL).origin : null;
-export const BENTO_DROP_BOX_FS_BASE_PATH = BENTO_WEB_CONFIG.BENTO_DROP_BOX_FS_BASE_PATH ??
- process.env.BENTO_DROP_BOX_FS_BASE_PATH ?? "/data";
+export const BENTO_DROP_BOX_FS_BASE_PATH =
+ BENTO_WEB_CONFIG.BENTO_DROP_BOX_FS_BASE_PATH ?? process.env.BENTO_DROP_BOX_FS_BASE_PATH ?? "/data";
diff --git a/src/constants.js b/src/constants.js
index 3177a651d..16498545d 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -8,3 +8,5 @@ export const EM_DASH = "—";
export const DEFAULT_OTHER_THRESHOLD_PERCENTAGE = 4;
export const IGV_JS_GENOMES_JSON_URL = "https://s3.amazonaws.com/igv.org.genomes/genomes.json";
+
+export const COLOR_ANTD_RED_6 = "#f5222d";
diff --git a/src/dataTypes/phenopacket.js b/src/dataTypes/phenopacket.js
index 99329a6b2..23b60a7bc 100644
--- a/src/dataTypes/phenopacket.js
+++ b/src/dataTypes/phenopacket.js
@@ -1,22 +1,17 @@
// Ordered according to https://phenopacket-schema.readthedocs.io/en/v2/sex.html#data-model
-export const SEX_VALUES = [
- "UNKNOWN_SEX",
- "FEMALE",
- "MALE",
- "OTHER_SEX",
-];
+export const SEX_VALUES = ["UNKNOWN_SEX", "FEMALE", "MALE", "OTHER_SEX"];
// Ordered according to https://phenopacket-schema.readthedocs.io/en/v2/karyotypicsex.html#data-model
export const KARYOTYPIC_SEX_VALUES = [
- "UNKNOWN_KARYOTYPE",
- "XX",
- "XY",
- "XO",
- "XXY",
- "XXX",
- "XXYY",
- "XXXY",
- "XXXX",
- "XYY",
- "OTHER_KARYOTYPE",
+ "UNKNOWN_KARYOTYPE",
+ "XX",
+ "XY",
+ "XO",
+ "XXY",
+ "XXX",
+ "XXYY",
+ "XXXY",
+ "XXXX",
+ "XYY",
+ "OTHER_KARYOTYPE",
];
diff --git a/src/duo.js b/src/duo.js
index d2306a888..8ccd2209a 100644
--- a/src/duo.js
+++ b/src/duo.js
@@ -1,15 +1,16 @@
import PropTypes from "prop-types";
import {
- AuditOutlined,
- BankOutlined,
- ClockCircleOutlined,
- DatabaseOutlined,
- DollarOutlined,
- ExceptionOutlined,
- FileDoneOutlined,
- GlobalOutlined,
- ReconciliationOutlined,
- TeamOutlined, UserOutlined,
+ AuditOutlined,
+ BankOutlined,
+ ClockCircleOutlined,
+ DatabaseOutlined,
+ DollarOutlined,
+ ExceptionOutlined,
+ FileDoneOutlined,
+ GlobalOutlined,
+ ReconciliationOutlined,
+ TeamOutlined,
+ UserOutlined,
} from "@ant-design/icons";
const DUO_PCC_GENERAL_RESEARCH_USE = "GRU";
@@ -36,178 +37,184 @@ const DUO_RETURN_TO_DATABASE_OR_RESOURCE = "RTN";
const DUO_TIME_LIMIT_ON_USE = "TS";
const DUO_USER_SPECIFIC_RESTRICTION = "US";
-
export const PRIMARY_CONSENT_CODE_KEYS = [
- DUO_PCC_GENERAL_RESEARCH_USE,
- DUO_PCC_GENERAL_RESEARCH_USE_AND_CLINICAL_CARE,
- DUO_PCC_HEALTH_MEDICAL_BIOMEDICAL_RESEARCH,
- DUO_PCC_DISEASE_SPECIFIC_RESEARCH,
- DUO_PCC_POPULATION_ORIGINS_OR_ANCESTRY_RESEARCH,
- DUO_PCC_NO_RESTRICTION,
+ DUO_PCC_GENERAL_RESEARCH_USE,
+ DUO_PCC_GENERAL_RESEARCH_USE_AND_CLINICAL_CARE,
+ DUO_PCC_HEALTH_MEDICAL_BIOMEDICAL_RESEARCH,
+ DUO_PCC_DISEASE_SPECIFIC_RESEARCH,
+ DUO_PCC_POPULATION_ORIGINS_OR_ANCESTRY_RESEARCH,
+ DUO_PCC_NO_RESTRICTION,
];
export const PRIMARY_CONSENT_CODE_INFO = {
- [DUO_PCC_GENERAL_RESEARCH_USE]: {
- title: "General Research Use",
- content: "This primary category consent code indicates that use is allowed for general research use for any " +
- "research purpose. This includes but is not limited to: health/medical/biomedical purposes, fundamental " +
- "biology research, the study of population origins or ancestry, statistical methods and algorithms " +
- "development, and social-sciences research.",
- },
- [DUO_PCC_GENERAL_RESEARCH_USE_AND_CLINICAL_CARE]: {
- title: "General Research Use + Clinical Care",
- content: "This primary category consent code indicates that use is allowed for health/medical/biomedical " +
- "purposes and other biological research, including the study of population origins or ancestry.",
- },
- [DUO_PCC_HEALTH_MEDICAL_BIOMEDICAL_RESEARCH]: {
- title: "Health/Medical/Biomedical Research",
- content: "This primary category consent code indicates that use is allowed for health/medical/biomedical " +
- "purposes; does not include the study of population origins or ancestry.",
- },
- [DUO_PCC_DISEASE_SPECIFIC_RESEARCH]: {
- title: "Disease-Specific Research",
- content: "This primary category consent code indicates that use is allowed provided it is related to the " +
- "specified disease.",
- },
- [DUO_PCC_POPULATION_ORIGINS_OR_ANCESTRY_RESEARCH]: {
- title: "Population Origins or Ancestry Research",
- content: " primary category consent code indicates that use of the data is limited to the study of " +
- "population origins or ancestry.",
- },
- [DUO_PCC_NO_RESTRICTION]: {
- title: "No Restriction",
- content: "This consent code primary category indicates there is no restriction on use.",
- },
+ [DUO_PCC_GENERAL_RESEARCH_USE]: {
+ title: "General Research Use",
+ content:
+ "This primary category consent code indicates that use is allowed for general research use for any " +
+ "research purpose. This includes but is not limited to: health/medical/biomedical purposes, fundamental " +
+ "biology research, the study of population origins or ancestry, statistical methods and algorithms " +
+ "development, and social-sciences research.",
+ },
+ [DUO_PCC_GENERAL_RESEARCH_USE_AND_CLINICAL_CARE]: {
+ title: "General Research Use + Clinical Care",
+ content:
+ "This primary category consent code indicates that use is allowed for health/medical/biomedical " +
+ "purposes and other biological research, including the study of population origins or ancestry.",
+ },
+ [DUO_PCC_HEALTH_MEDICAL_BIOMEDICAL_RESEARCH]: {
+ title: "Health/Medical/Biomedical Research",
+ content:
+ "This primary category consent code indicates that use is allowed for health/medical/biomedical " +
+ "purposes; does not include the study of population origins or ancestry.",
+ },
+ [DUO_PCC_DISEASE_SPECIFIC_RESEARCH]: {
+ title: "Disease-Specific Research",
+ content:
+ "This primary category consent code indicates that use is allowed provided it is related to the " +
+ "specified disease.",
+ },
+ [DUO_PCC_POPULATION_ORIGINS_OR_ANCESTRY_RESEARCH]: {
+ title: "Population Origins or Ancestry Research",
+ content:
+ " primary category consent code indicates that use of the data is limited to the study of " +
+ "population origins or ancestry.",
+ },
+ [DUO_PCC_NO_RESTRICTION]: {
+ title: "No Restriction",
+ content: "This consent code primary category indicates there is no restriction on use.",
+ },
};
-
export const SECONDARY_CONSENT_CODE_KEYS = [
- DUO_SCC_GENETIC_STUDIES_ONLY,
- DUO_SCC_NO_GENERAL_METHODS_RESEARCH,
- DUO_SCC_RESEARCH_SPECIFIC_RESTRICTIONS,
- DUO_SCC_RESEARCH_USE_ONLY,
+ DUO_SCC_GENETIC_STUDIES_ONLY,
+ DUO_SCC_NO_GENERAL_METHODS_RESEARCH,
+ DUO_SCC_RESEARCH_SPECIFIC_RESTRICTIONS,
+ DUO_SCC_RESEARCH_USE_ONLY,
];
export const SECONDARY_CONSENT_CODE_INFO = {
- [DUO_SCC_GENETIC_STUDIES_ONLY]: {
- title: "Genetic Studies Only",
- content: "This secondary category consent code indicates that use is limited to genetic studies only " +
- "(i.e., no phenotype-only research)",
- },
- [DUO_SCC_NO_GENERAL_METHODS_RESEARCH]: {
- title: "No General Methods Research",
- content: "This secondary category consent code indicates that use includes methods development research " +
- "(e.g., development of software or algorithms) only within the bounds of other use limitations.",
- },
- [DUO_SCC_RESEARCH_SPECIFIC_RESTRICTIONS]: {
- title: "Research-Specific Restrictions",
- content: "This secondary category consent code indicates that use is limited to studies of a certain " +
- "research type.",
- },
- [DUO_SCC_RESEARCH_USE_ONLY]: {
- title: "Research Use Only",
- content: "This secondary category consent code indicates that use is limited to research purposes " +
- "(e.g., does not include its use in clinical care).",
- },
+ [DUO_SCC_GENETIC_STUDIES_ONLY]: {
+ title: "Genetic Studies Only",
+ content:
+ "This secondary category consent code indicates that use is limited to genetic studies only " +
+ "(i.e., no phenotype-only research)",
+ },
+ [DUO_SCC_NO_GENERAL_METHODS_RESEARCH]: {
+ title: "No General Methods Research",
+ content:
+ "This secondary category consent code indicates that use includes methods development research " +
+ "(e.g., development of software or algorithms) only within the bounds of other use limitations.",
+ },
+ [DUO_SCC_RESEARCH_SPECIFIC_RESTRICTIONS]: {
+ title: "Research-Specific Restrictions",
+ content:
+ "This secondary category consent code indicates that use is limited to studies of a certain " + "research type.",
+ },
+ [DUO_SCC_RESEARCH_USE_ONLY]: {
+ title: "Research Use Only",
+ content:
+ "This secondary category consent code indicates that use is limited to research purposes " +
+ "(e.g., does not include its use in clinical care).",
+ },
};
-
export const DATA_USE_KEYS = [
- DUO_COLLABORATION_REQUIRED,
- DUO_ETHICS_APPROVAL_REQUIRED,
- DUO_GEOGRAPHICAL_RESTRICTION,
- DUO_INSTITUTION_SPECIFIC_RESTRICTION,
- DUO_NOT_FOR_PROFIT_USE_ONLY,
- DUO_PROJECT_SPECIFIC_RESTRICTION,
- DUO_PUBLICATION_MORATORIUM,
- DUO_PUBLICATION_REQUIRED,
- DUO_RETURN_TO_DATABASE_OR_RESOURCE,
- DUO_TIME_LIMIT_ON_USE,
- DUO_USER_SPECIFIC_RESTRICTION,
+ DUO_COLLABORATION_REQUIRED,
+ DUO_ETHICS_APPROVAL_REQUIRED,
+ DUO_GEOGRAPHICAL_RESTRICTION,
+ DUO_INSTITUTION_SPECIFIC_RESTRICTION,
+ DUO_NOT_FOR_PROFIT_USE_ONLY,
+ DUO_PROJECT_SPECIFIC_RESTRICTION,
+ DUO_PUBLICATION_MORATORIUM,
+ DUO_PUBLICATION_REQUIRED,
+ DUO_RETURN_TO_DATABASE_OR_RESOURCE,
+ DUO_TIME_LIMIT_ON_USE,
+ DUO_USER_SPECIFIC_RESTRICTION,
];
export const DATA_USE_INFO = {
- [DUO_COLLABORATION_REQUIRED]: {
- Icon: TeamOutlined,
- title: "Collaboration Required",
- content: "This requirement indicates that the requester must agree to collaboration with the primary " +
- "study investigator(s).",
- },
- [DUO_ETHICS_APPROVAL_REQUIRED]: {
- Icon: ReconciliationOutlined,
- title: "Ethics Approval Required",
- content: "This requirement indicates that the requester must provide documentation of local IRB/ERB approval.",
- },
- [DUO_GEOGRAPHICAL_RESTRICTION]: {
- Icon: GlobalOutlined,
- title: "Geographical Restriction",
- content: "This requirement indicates that use is limited to within a specific geographic region.",
- },
- [DUO_INSTITUTION_SPECIFIC_RESTRICTION]: {
- Icon: BankOutlined,
- title: "Institution-Specific Restriction",
- content: "This requirement indicates that use is limited to use within an approved institution.",
- },
- [DUO_NOT_FOR_PROFIT_USE_ONLY]: {
- Icon: DollarOutlined, // Gets modified elsewhere via stacking
- title: "Not-For-Profit Use Only",
- content: "This requirement indicates that use of the data is limited to not-for-profit organizations " +
- "and not-for-profit use, non-commercial use.",
- },
- [DUO_PROJECT_SPECIFIC_RESTRICTION]: {
- Icon: AuditOutlined,
- title: "Project-Specific Restriction",
- content: "This requirement indicates that use is limited to use within an approved project.",
- },
- [DUO_PUBLICATION_MORATORIUM]: {
- Icon: ExceptionOutlined,
- title: "Publication Moratorium",
- content: "This requirement indicates that requester agrees not to publish results of studies until a " +
- "specific date",
- },
- [DUO_PUBLICATION_REQUIRED]: {
- Icon: FileDoneOutlined,
- title: "Publication Required",
- content: "This requirement indicates that requester agrees to make results of studies using the data " +
- "available to the larger scientific community.",
- },
- [DUO_RETURN_TO_DATABASE_OR_RESOURCE]: {
- Icon: DatabaseOutlined,
- title: "Return to Database or Resource",
- content: "This requirement indicates that the requester must return derived/enriched data to the " +
- "database/resource.",
- },
- [DUO_TIME_LIMIT_ON_USE]: {
- Icon: ClockCircleOutlined,
- title: "Time Limit on Use",
- content: "This requirement indicates that use is approved for a specific number of months.",
- },
- [DUO_USER_SPECIFIC_RESTRICTION]: {
- Icon: UserOutlined,
- title: "User-Specific Restriction",
- content: "This requirement indicates that use is limited to use by approved users.",
- },
+ [DUO_COLLABORATION_REQUIRED]: {
+ Icon: TeamOutlined,
+ title: "Collaboration Required",
+ content:
+ "This requirement indicates that the requester must agree to collaboration with the primary " +
+ "study investigator(s).",
+ },
+ [DUO_ETHICS_APPROVAL_REQUIRED]: {
+ Icon: ReconciliationOutlined,
+ title: "Ethics Approval Required",
+ content: "This requirement indicates that the requester must provide documentation of local IRB/ERB approval.",
+ },
+ [DUO_GEOGRAPHICAL_RESTRICTION]: {
+ Icon: GlobalOutlined,
+ title: "Geographical Restriction",
+ content: "This requirement indicates that use is limited to within a specific geographic region.",
+ },
+ [DUO_INSTITUTION_SPECIFIC_RESTRICTION]: {
+ Icon: BankOutlined,
+ title: "Institution-Specific Restriction",
+ content: "This requirement indicates that use is limited to use within an approved institution.",
+ },
+ [DUO_NOT_FOR_PROFIT_USE_ONLY]: {
+ Icon: DollarOutlined, // Gets modified elsewhere via stacking
+ title: "Not-For-Profit Use Only",
+ content:
+ "This requirement indicates that use of the data is limited to not-for-profit organizations " +
+ "and not-for-profit use, non-commercial use.",
+ },
+ [DUO_PROJECT_SPECIFIC_RESTRICTION]: {
+ Icon: AuditOutlined,
+ title: "Project-Specific Restriction",
+ content: "This requirement indicates that use is limited to use within an approved project.",
+ },
+ [DUO_PUBLICATION_MORATORIUM]: {
+ Icon: ExceptionOutlined,
+ title: "Publication Moratorium",
+ content:
+ "This requirement indicates that requester agrees not to publish results of studies until a " + "specific date",
+ },
+ [DUO_PUBLICATION_REQUIRED]: {
+ Icon: FileDoneOutlined,
+ title: "Publication Required",
+ content:
+ "This requirement indicates that requester agrees to make results of studies using the data " +
+ "available to the larger scientific community.",
+ },
+ [DUO_RETURN_TO_DATABASE_OR_RESOURCE]: {
+ Icon: DatabaseOutlined,
+ title: "Return to Database or Resource",
+ content:
+ "This requirement indicates that the requester must return derived/enriched data to the " + "database/resource.",
+ },
+ [DUO_TIME_LIMIT_ON_USE]: {
+ Icon: ClockCircleOutlined,
+ title: "Time Limit on Use",
+ content: "This requirement indicates that use is approved for a specific number of months.",
+ },
+ [DUO_USER_SPECIFIC_RESTRICTION]: {
+ Icon: UserOutlined,
+ title: "User-Specific Restriction",
+ content: "This requirement indicates that use is limited to use by approved users.",
+ },
};
-
const DATA_USE_CODE_ITEM_SHAPE = PropTypes.shape({
- code: PropTypes.string,
- data: PropTypes.object, // TODO
+ code: PropTypes.string,
+ data: PropTypes.object, // TODO
});
export const DATA_USE_PROP_TYPE_SHAPE = PropTypes.shape({
- consent_code: PropTypes.shape({
- primary_category: DATA_USE_CODE_ITEM_SHAPE,
- secondary_categories: PropTypes.arrayOf(DATA_USE_CODE_ITEM_SHAPE),
- }),
- data_use_requirements: PropTypes.arrayOf(DATA_USE_CODE_ITEM_SHAPE),
+ consent_code: PropTypes.shape({
+ primary_category: DATA_USE_CODE_ITEM_SHAPE,
+ secondary_categories: PropTypes.arrayOf(DATA_USE_CODE_ITEM_SHAPE),
+ }),
+ data_use_requirements: PropTypes.arrayOf(DATA_USE_CODE_ITEM_SHAPE),
});
-
export const INITIAL_DATA_USE_VALUE = {
- consent_code: {
- primary_category: null,
- secondary_categories: [],
- },
- data_use_requirements: [],
+ consent_code: {
+ primary_category: null,
+ secondary_categories: [],
+ },
+ data_use_requirements: [],
};
diff --git a/src/events.js b/src/events.js
index c7cbc810c..784c31c27 100644
--- a/src/events.js
+++ b/src/events.js
@@ -7,14 +7,14 @@ const handlerSets = [notificationEvents, wesEvents];
// Global message handler
export default async (message, navigate) => {
- console.debug("Handling event", message);
+ console.debug("Handling event", message);
- const handlers = handlerSets
- .flatMap(Object.entries)
- .filter(([p, _]) => message.channel.match(new RegExp(p)) !== null)
- .map(([_, h]) => h);
+ const handlers = handlerSets
+ .flatMap(Object.entries)
+ .filter(([p, _]) => message.channel.match(new RegExp(p)) !== null)
+ .map(([_, h]) => h);
- for (const handler of handlers) {
- await store.dispatch(handler(message.message, navigate));
- }
+ for (const handler of handlers) {
+ await store.dispatch(handler(message.message, navigate));
+ }
};
diff --git a/src/hooks.ts b/src/hooks.ts
index ce27869cd..5473704b7 100644
--- a/src/hooks.ts
+++ b/src/hooks.ts
@@ -3,65 +3,64 @@ import { useCallback, useEffect, useState } from "react";
import Ajv, { SchemaObject } from "ajv";
import {
- RESOURCE_EVERYTHING,
- useAuthorizationHeader,
- useHasResourcePermission,
- useResourcePermissions,
- type Resource,
+ RESOURCE_EVERYTHING,
+ useAuthorizationHeader,
+ useHasResourcePermission,
+ useResourcePermissions,
+ type Resource,
} from "bento-auth-js";
import { type RootState, useAppSelector } from "@/store";
import { useService } from "@/modules/services/hooks";
import { ARRAY_BUFFER_FILE_EXTENSIONS, BLOB_FILE_EXTENSIONS } from "@/components/display/FileDisplay";
-
// AUTHORIZATION:
// Wrapper hooks for bento-auth-js permissions hooks, which expect a 'authzUrl' argument.
// bento-auth-js does not assume that the 'authzUrl' is accessible from the store (left to the client app to provide).
// These wrapper hooks grab the 'authzUrl' from the store's services.
export type ResourcePermissionEval = {
- fetchingPermission: boolean, // Indicates the permission is being fetched from the authz service.
- hasPermission: boolean, // Indicates the user has the requested resource permission.
+ fetchingPermission: boolean; // Indicates the permission is being fetched from the authz service.
+ hasPermission: boolean; // Indicates the user has the requested resource permission.
};
/**
* Evaluate if the user has a permission on a given resource
*/
export const useHasResourcePermissionWrapper = (resource: Resource, permission: string): ResourcePermissionEval => {
- const authzUrl = useService("authorization")?.url;
+ const authzUrl = useService("authorization")?.url;
- const { isFetching: fetchingPermission, hasPermission } = useHasResourcePermission(resource, authzUrl, permission);
+ const { isFetching: fetchingPermission, hasPermission } = useHasResourcePermission(resource, authzUrl, permission);
- return {
- fetchingPermission,
- hasPermission,
- };
+ return {
+ fetchingPermission,
+ hasPermission,
+ };
};
export type ResourcePermissions = {
- permissions: string[], // The list of permissions the user has on the resource
- isFetchingPermissions: boolean, // Indicates if the permissions are being fetched.
- hasAttemptedPermissions: boolean, // Indicates if a permissions fetch was attempted.
+ permissions: string[]; // The list of permissions the user has on the resource
+ isFetchingPermissions: boolean; // Indicates if the permissions are being fetched.
+ hasAttemptedPermissions: boolean; // Indicates if a permissions fetch was attempted.
};
/**
* Returns the user's permissions for a given resource
*/
export const useResourcePermissionsWrapper = (resource: Resource): ResourcePermissions => {
- const authzUrl = useService("authorization")?.url;
-
- const {
- permissions,
- isFetching: isFetchingPermissions,
- hasAttempted: hasAttemptedPermissions,
- } = useResourcePermissions(resource, authzUrl);
-
- return {
- permissions,
- isFetchingPermissions,
- hasAttemptedPermissions,
- };
+ const authzUrl = useService("authorization")?.url;
+
+ const {
+ permissions,
+ isFetching: isFetchingPermissions,
+ hasAttempted: hasAttemptedPermissions,
+ } = useResourcePermissions(resource, authzUrl);
+
+ return {
+ permissions,
+ isFetchingPermissions,
+ hasAttemptedPermissions,
+ };
};
/**
@@ -73,96 +72,97 @@ export const useEverythingPermissions = () => useResourcePermissionsWrapper(RESO
* Returns true if the OpenID config hasn't been loaded yet.
*/
export const useOpenIDConfigNotLoaded = (): boolean => {
- const {
- hasAttempted: openIdConfigHasAttempted,
- isFetching: openIdConfigFetching,
- } = useSelector((state: RootState) => state.openIdConfiguration);
-
- // Need `=== false`, since if this is loaded from localStorage from a prior version, it'll be undefined and prevent
- // the page from showing.
- return openIdConfigHasAttempted === false || openIdConfigFetching;
-};
+ const { hasAttempted: openIdConfigHasAttempted, isFetching: openIdConfigFetching } = useSelector(
+ (state: RootState) => state.openIdConfiguration,
+ );
-export const useDropBoxFileContent = (filePath?: string) => {
- const file = useSelector((state: RootState) =>
- state.dropBox.tree.find((f: { filePath: string | undefined; }) => f?.filePath === filePath));
- const authHeader = useAuthorizationHeader();
-
- const [fileContents, setFileContents] = useState(null);
-
- const fileExt = filePath?.split(".").slice(-1)[0].toLowerCase();
-
- // fetch effect
- useEffect(() => {
- setFileContents(null);
- (async () => {
- if (!file || !fileExt) return;
- if (!file?.uri) {
- console.error(`Files: something went wrong while trying to load ${file?.name}`);
- return;
- }
- if (fileExt === "pdf") {
- console.error("Cannot retrieve PDF with useDropBoxFileContent");
- return;
- }
-
- try {
- const r = await fetch(file.uri, { headers: authHeader });
- if (r.ok) {
- let content;
- if (ARRAY_BUFFER_FILE_EXTENSIONS.includes(fileExt)) {
- content = await r.arrayBuffer();
- } else if (BLOB_FILE_EXTENSIONS.includes(fileExt)) {
- content = await r.blob();
- } else {
- const text = await r.text();
- content = (fileExt === "json" ? JSON.parse(text) : text);
- }
- setFileContents(content);
- } else {
- console.error(`Could not load file: ${r.body}`);
- }
- } catch (e) {
- console.error(e);
- }
- })();
- }, [file, fileExt, authHeader]);
-
- return fileContents;
+ // Need `=== false`, since if this is loaded from localStorage from a prior version, it'll be undefined and prevent
+ // the page from showing.
+ return openIdConfigHasAttempted === false || openIdConfigFetching;
};
-
-export const useJsonSchemaValidator = (schema: SchemaObject, acceptFalsyValue: boolean) => {
- const ajv = new Ajv();
- return useCallback((rule: unknown, value: unknown) => {
- if (!schema) {
- return Promise.reject(new Error("No JSON schema provided, cannot validate."));
- }
-
- if (!value && acceptFalsyValue) {
- return Promise.resolve();
- }
- const valid = ajv.validate(schema, value);
-
- if (valid) {
- return Promise.resolve();
+export const useDropBoxFileContent = (filePath?: string) => {
+ const file = useSelector((state: RootState) =>
+ state.dropBox.tree.find((f: { filePath: string | undefined }) => f?.filePath === filePath),
+ );
+ const authHeader = useAuthorizationHeader();
+
+ const [fileContents, setFileContents] = useState(null);
+
+ const fileExt = filePath?.split(".").slice(-1)[0].toLowerCase();
+
+ // fetch effect
+ useEffect(() => {
+ setFileContents(null);
+ (async () => {
+ if (!file || !fileExt) return;
+ if (!file?.uri) {
+ console.error(`Files: something went wrong while trying to load ${file?.name}`);
+ return;
+ }
+ if (fileExt === "pdf") {
+ console.error("Cannot retrieve PDF with useDropBoxFileContent");
+ return;
+ }
+
+ try {
+ const r = await fetch(file.uri, { headers: authHeader });
+ if (r.ok) {
+ let content;
+ if (ARRAY_BUFFER_FILE_EXTENSIONS.includes(fileExt)) {
+ content = await r.arrayBuffer();
+ } else if (BLOB_FILE_EXTENSIONS.includes(fileExt)) {
+ content = await r.blob();
+ } else {
+ const text = await r.text();
+ content = fileExt === "json" ? JSON.parse(text) : text;
+ }
+ setFileContents(content);
} else {
- return Promise.reject(new Error(ajv.errorsText(ajv.errors)));
+ console.error(`Could not load file: ${r.body}`);
}
+ } catch (e) {
+ console.error(e);
+ }
+ })();
+ }, [file, fileExt, authHeader]);
- }, [ajv, schema]);
+ return fileContents;
+};
+
+export const useJsonSchemaValidator = (schema: SchemaObject, acceptFalsyValue: boolean) => {
+ const ajv = new Ajv();
+ return useCallback(
+ (rule: unknown, value: unknown) => {
+ if (!schema) {
+ return Promise.reject(new Error("No JSON schema provided, cannot validate."));
+ }
+
+ if (!value && acceptFalsyValue) {
+ return Promise.resolve();
+ }
+ const valid = ajv.validate(schema, value);
+
+ if (valid) {
+ return Promise.resolve();
+ } else {
+ return Promise.reject(new Error(ajv.errorsText(ajv.errors)));
+ }
+ },
+ [ajv, schema],
+ );
};
export const useDiscoveryValidator = () => {
- const discoverySchema = useAppSelector(state => state.discovery.discoverySchema);
- return useJsonSchemaValidator(discoverySchema, true);
+ const discoverySchema = useAppSelector((state) => state.discovery.discoverySchema);
+ return useJsonSchemaValidator(discoverySchema, true);
};
export const useDatsValidator = () => {
- // Simply verify that the file is a valid JSON object.
- // The backend will perform the more expensive validation
- const datsSchema = {
- "type": "object",
- };
- return useJsonSchemaValidator(datsSchema, false);
+ // Simply verify that the file is a valid JSON object.
+ // The backend will perform the more expensive validation
+ const datsSchema = {
+ type: "object",
+ };
+ return useJsonSchemaValidator(datsSchema, false);
};
diff --git a/src/index.tsx b/src/index.tsx
index 2a5d3b55a..ff66cf055 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -14,22 +14,24 @@ import App from "./components/App";
import { store } from "./store";
document.addEventListener("DOMContentLoaded", () => {
- const container = document.getElementById("root")!;
- const root = createRoot(container);
- root.render(
-
-
-
-
-
-
- ,
- );
+ const container = document.getElementById("root")!;
+ const root = createRoot(container);
+ root.render(
+
+
+
+
+
+
+ ,
+ );
});
diff --git a/src/modules/authz/actions.js b/src/modules/authz/actions.js
index e18146ec7..48c8951c5 100644
--- a/src/modules/authz/actions.js
+++ b/src/modules/authz/actions.js
@@ -1,20 +1,112 @@
-import { createNetworkActionTypes, networkAction } from "@/utils/actions";
+import { message } from "antd";
+import { basicAction, createNetworkActionTypes, networkAction } from "@/utils/actions";
+import { jsonRequest } from "@/utils/requests";
+
+const authzService = (state) => state.services.itemsByKind.authorization;
+const authzURL = (state) => authzService(state)?.url;
+
+// FETCH_ALL_PERMISSIONS: fetch list of available permissions (not on a specific resource/subject, but in general what
+// permissions are defined.
+export const FETCH_ALL_PERMISSIONS = createNetworkActionTypes("FETCH_ALL_PERMISSIONS");
+export const fetchAllPermissions = networkAction(() => (_dispatch, getState) => ({
+ types: FETCH_ALL_PERMISSIONS,
+ check: (state) => !!authzService(state) && !state.allPermissions.isFetching && !state.allPermissions.data.length,
+ url: `${authzURL(getState())}/all_permissions/`,
+ publicEndpoint: true,
+}));
+
+const authzMutateCheck = (reducer) => (state) => {
+ const { isFetching, isCreating, isSaving, isDeleting, isInvalid } = state[reducer];
+ return authzService(state) && !isFetching && !isCreating && !isSaving && !isDeleting && !isInvalid;
+};
+
+const grantMutateCheck = authzMutateCheck("grants");
export const FETCH_GRANTS = createNetworkActionTypes("FETCH_GRANTS");
export const fetchGrants = networkAction(() => (_dispatch, getState) => ({
- types: FETCH_GRANTS,
- check: (state) => state.services.itemsByKind.authorization
- && !state.grants.isFetching
- && !state.grants.data.length,
- url: `${getState().services.itemsByKind.authorization.url}/grants/`,
+ types: FETCH_GRANTS,
+ check: (state) => {
+ const { data, isFetching, isCreating, isDeleting, isInvalid } = state.grants;
+ return !!authzService(state) && !isFetching && !isCreating && !isDeleting && (!data.length || isInvalid);
+ },
+ url: `${authzURL(getState())}/grants/`,
+}));
+
+export const INVALIDATE_GRANTS = "INVALIDATE_GRANTS";
+export const invalidateGrants = basicAction(INVALIDATE_GRANTS);
+
+export const CREATE_GRANT = createNetworkActionTypes("CREATE_GRANT");
+export const createGrant = networkAction((grant) => (_dispatch, getState) => ({
+ types: CREATE_GRANT,
+ check: grantMutateCheck,
+ req: jsonRequest(grant, "POST"),
+ url: `${authzURL(getState())}/grants/`,
+ onSuccess: () => {
+ message.success("Grant created successfully!");
+ },
}));
+export const DELETE_GRANT = createNetworkActionTypes("DELETE_GRANT");
+export const deleteGrant = networkAction(({ id: grantID }) => (_dispatch, getState) => ({
+ types: DELETE_GRANT,
+ check: grantMutateCheck,
+ req: { method: "DELETE" },
+ url: `${authzURL(getState())}/grants/${grantID}`,
+ params: { grantID },
+ onSuccess: () => {
+ message.success(`Grant ${grantID} deleted successfully!`);
+ },
+}));
+
+const groupMutateCheck = authzMutateCheck("groups");
export const FETCH_GROUPS = createNetworkActionTypes("FETCH_GROUPS");
export const fetchGroups = networkAction(() => (_dispatch, getState) => ({
- types: FETCH_GROUPS,
- check: (state) => state.services.itemsByKind.authorization
- && !state.groups.isFetching
- && !state.groups.data.length,
- url: `${getState().services.itemsByKind.authorization.url}/groups/`,
+ types: FETCH_GROUPS,
+ check: (state) =>
+ !!authzService(state) && !state.groups.isFetching && (!state.groups.data.length || state.groups.isInvalid),
+ url: `${authzURL(getState())}/groups/`,
+}));
+
+export const INVALIDATE_GROUPS = "INVALIDATE_GROUPS";
+export const invalidateGroups = basicAction(INVALIDATE_GROUPS);
+
+export const CREATE_GROUP = createNetworkActionTypes("CREATE_GROUP");
+export const createGroup = networkAction((group) => (_dispatch, getState) => ({
+ types: CREATE_GROUP,
+ check: groupMutateCheck,
+ req: jsonRequest(group, "POST"),
+ url: `${authzURL(getState())}/groups/`,
+ onSuccess: () => {
+ message.success(`Group "${group.name}" created successfully!`);
+ },
+}));
+
+export const SAVE_GROUP = createNetworkActionTypes("SAVE_GROUP");
+export const saveGroup = networkAction(
+ (group) => (_dispatch, getState) =>
+ console.log(group) || {
+ types: SAVE_GROUP,
+ check: groupMutateCheck,
+ params: { group },
+ req: jsonRequest(group, "PUT"),
+ url: `${authzURL(getState())}/groups/${group.id}`,
+ onSuccess: () => {
+ message.success(`Group "${group.name}" saved successfully!`);
+ },
+ },
+);
+
+export const DELETE_GROUP = createNetworkActionTypes("DELETE_GROUP");
+export const deleteGroup = networkAction((group) => (dispatch, getState) => ({
+ types: DELETE_GROUP,
+ check: groupMutateCheck,
+ req: { method: "DELETE" },
+ url: `${authzURL(getState())}/groups/${group.id}`,
+ params: { groupID: group.id },
+ onSuccess: () => {
+ message.success(`Group "${group.name}" (ID: ${group.id}) and associated grants deleted successfully!`);
+ // Group deletion can cascade to grants, so invalidate them to trigger re-fetch:
+ return dispatch(invalidateGrants());
+ },
}));
diff --git a/src/modules/authz/hooks.js b/src/modules/authz/hooks.js
index 4e87f0cbd..b25ed2b21 100644
--- a/src/modules/authz/hooks.js
+++ b/src/modules/authz/hooks.js
@@ -1,129 +1,248 @@
-import { useMemo } from "react";
+import { useEffect, useMemo } from "react";
import {
- analyzeData,
- createDataset,
- createProject,
- deleteData,
- deleteProject,
- editDataset,
- editProject,
- exportData,
- ingestData,
- ingestReferenceMaterial,
- queryData,
- useResourcesPermissions,
- viewDropBox,
- viewPermissions,
- viewRuns,
+ analyzeData,
+ createDataset,
+ createProject,
+ deleteData,
+ deleteProject,
+ editDataset,
+ editPermissions,
+ editProject,
+ exportData,
+ ingestData,
+ ingestReferenceMaterial,
+ makeResourceKey,
+ queryData,
+ RESOURCE_EVERYTHING,
+ useResourcesPermissions,
+ viewDropBox,
+ viewPermissions,
+ viewRuns,
} from "bento-auth-js";
import { useEverythingPermissions } from "@/hooks";
import { useProjectsAndDatasetsAsAuthzResources } from "@/modules/metadata/hooks";
import { useService } from "@/modules/services/hooks";
+import { useAppDispatch, useAppSelector } from "@/store";
+import { fetchAllPermissions, fetchGrants, fetchGroups } from "./actions";
export const useProjectDatasetPermissions = () => {
- const authz = useService("authorization");
- const projectDatasetResources = useProjectsAndDatasetsAsAuthzResources();
- return useResourcesPermissions(projectDatasetResources, authz?.url);
+ const authz = useService("authorization");
+ const projectDatasetResources = useProjectsAndDatasetsAsAuthzResources();
+ return useResourcesPermissions(projectDatasetResources, authz?.url);
};
+const RESOURCE_EVERYTHING_KEY = makeResourceKey(RESOURCE_EVERYTHING);
+
const PROJECT_DATASET_QUERY_PERMISSIONS = [queryData];
const PROJECT_DATASET_MANAGEMENT_PERMISSIONS = [
- createProject,
- createDataset,
- editProject,
- editDataset,
- deleteProject,
- deleteData,
- ingestData,
+ createProject,
+ createDataset,
+ editProject,
+ editDataset,
+ deleteProject,
+ deleteData,
+ ingestData,
];
const _hasOneOfListedPermissions = (permissionList, permissions) => permissionList.some((p) => permissions.includes(p));
-const useHasPermissionOnAtLeastOneProjectOrDataset = (permissionList) => {
- const {
- permissions: globalPermissions,
- isFetchingPermissions: fetchingEverythingPermissions,
- hasAttemptedPermissions: attemptedEverythingPermissions,
- } = useEverythingPermissions();
+// AUTHZ STATE HOOKS
+
+export const useAllPermissions = () => {
+ const dispatch = useAppDispatch();
+ const authz = useService("authorization");
+
+ useEffect(() => {
+ dispatch(fetchAllPermissions()).catch((err) => console.error(err));
+ }, [dispatch, authz]);
+
+ return useAppSelector((state) => state.allPermissions);
+};
+
+export const useGrants = () => {
+ const dispatch = useAppDispatch();
+ const authz = useService("authorization");
+
+ const grantsState = useAppSelector((state) => state.grants);
+
+ useEffect(() => {
+ dispatch(fetchGrants()).catch((err) => console.error(err));
+ }, [dispatch, authz, grantsState.isInvalid]);
+
+ return grantsState;
+};
+
+export const useGroups = () => {
+ const dispatch = useAppDispatch();
+ const authz = useService("authorization");
- const pdp = useProjectDatasetPermissions();
+ const groupsState = useAppSelector((state) => state.groups);
- const hasPermission = useMemo(
- () => _hasOneOfListedPermissions(permissionList, globalPermissions) || (
- Object.values(pdp).some((ps) => _hasOneOfListedPermissions(permissionList, ps.permissions))
- ),
- [globalPermissions, pdp]);
+ useEffect(() => {
+ dispatch(fetchGroups()).catch((err) => console.error(err));
+ }, [dispatch, authz, groupsState.isInvalid]);
- const isFetching = useMemo(
- () => fetchingEverythingPermissions || Object.values(pdp).some((ps) => ps.isFetching),
- [fetchingEverythingPermissions, pdp]);
+ return groupsState;
+};
+
+export const useGroupsByID = () => {
+ const { itemsByID } = useGroups();
+ return itemsByID;
+};
- const hasAttempted = useMemo(
- () => attemptedEverythingPermissions && Object.values(pdp).every((ps) => ps.hasAttempted),
- [attemptedEverythingPermissions, pdp]);
+// PERMISSIONS LOGIC HOOKS
- return { hasPermission, isFetching, hasAttempted };
+const useHasPermissionOnAtLeastOneProjectOrDataset = (permissionList) => {
+ // Since this is "at least one", we can return hasAttempted/hasPermission 'early' even if still fetching things,
+ // especially if we have the permissions on { "everything": true }.
+
+ const {
+ permissions: globalPermissions,
+ isFetchingPermissions: fetchingEverythingPermissions,
+ hasAttemptedPermissions: attemptedEverythingPermissions,
+ } = useEverythingPermissions();
+
+ const pdp = useProjectDatasetPermissions();
+
+ const hasPermissionGlobal = useMemo(
+ () => _hasOneOfListedPermissions(permissionList, globalPermissions),
+ [permissionList, globalPermissions],
+ );
+
+ const hasPermission = useMemo(
+ () =>
+ hasPermissionGlobal ||
+ Object.values(pdp).some((ps) => _hasOneOfListedPermissions(permissionList, ps.permissions)),
+ [permissionList, hasPermissionGlobal, pdp],
+ );
+
+ const isFetching = useMemo(
+ () =>
+ // If we don't have global permissions, checking if we're fetching individual resources.
+ // If we do, it overrides everything, so only the everything-resource permissions fetch matters.
+ fetchingEverythingPermissions || (!hasPermissionGlobal && Object.values(pdp).some((ps) => ps.isFetching)),
+ [hasPermissionGlobal, fetchingEverythingPermissions, pdp],
+ );
+
+ // { "everything": true } permissions overrides everything, so we use some more-complex logic to discern
+ // hasAttempted - early return if we a) have attempted { "everything": true } permissions, and b) in fact have
+ // everything permissions.
+ const hasAttempted = useMemo(
+ () =>
+ // don't need to worry about project/dataset-specific permissions if we have permissions on everything:
+ (hasPermissionGlobal && attemptedEverythingPermissions) ||
+ (attemptedEverythingPermissions && Object.values(pdp).every((ps) => ps.hasAttempted)),
+ [hasPermissionGlobal, attemptedEverythingPermissions, pdp],
+ );
+
+ return { hasPermission, isFetching, hasAttempted };
};
export const useCanQueryAtLeastOneProjectOrDataset = () =>
- useHasPermissionOnAtLeastOneProjectOrDataset(PROJECT_DATASET_QUERY_PERMISSIONS);
+ useHasPermissionOnAtLeastOneProjectOrDataset(PROJECT_DATASET_QUERY_PERMISSIONS);
export const useCanManageAtLeastOneProjectOrDataset = () =>
- useHasPermissionOnAtLeastOneProjectOrDataset(PROJECT_DATASET_MANAGEMENT_PERMISSIONS);
+ useHasPermissionOnAtLeastOneProjectOrDataset(PROJECT_DATASET_MANAGEMENT_PERMISSIONS);
+
+export const useAuthzManagementPermissions = () => {
+ const { data: grants, isFetching: isFetchingGrants } = useGrants();
+
+ // Get existing project/dataset resource permissions
+ const projectDatasetPermissions = useProjectDatasetPermissions();
+
+ // Build set of resources to check our "at least one (view|edit|...)" permissions constants with. We manually
+ // include the "everything" resource to allow early-determining some permissions before grants list has been loaded
+ // from the authorization service, since if a user has everything-permissions they override more narrow resource
+ // permissions anyway.
+ const grantResources = useMemo(
+ () =>
+ [...new Set([RESOURCE_EVERYTHING_KEY, ...grants.map((g) => makeResourceKey(g.resource))])].map((rk) =>
+ JSON.parse(rk),
+ ), // convert to serialized JSON-format key, deduplicate, and de-serialize
+ [grants],
+ );
+
+ const authzService = useService("authorization");
+ const grantResourcePermissions = useResourcesPermissions(grantResources, authzService?.url);
+
+ return useMemo(() => {
+ const combinedPermissions = Object.fromEntries([
+ ...Object.entries(projectDatasetPermissions),
+ ...Object.entries(grantResourcePermissions),
+ ]);
+
+ const isFetchingPermissions = Object.values(combinedPermissions).some((pd) => pd.isFetching);
+
+ const hasAtLeastOneViewPermissionsGrant = Object.values(combinedPermissions).some((pd) =>
+ pd.permissions.includes(viewPermissions),
+ );
+ const hasAtLeastOneEditPermissionsGrant = Object.values(combinedPermissions).some((pd) =>
+ pd.permissions.includes(editPermissions),
+ );
+
+ return {
+ isFetching: isFetchingGrants || isFetchingPermissions,
+ hasAttempted: Object.values(combinedPermissions).every((pd) => pd.hasAttempted),
+ hasAtLeastOneViewPermissionsGrant,
+ hasAtLeastOneEditPermissionsGrant,
+ grantResourcePermissionsObjects: grantResourcePermissions,
+ permissionsObjects: combinedPermissions,
+ };
+ }, [isFetchingGrants, projectDatasetPermissions, grantResourcePermissions]);
+};
export const useManagerPermissions = () => {
- const { permissions, isFetchingPermissions, hasAttemptedPermissions } = useEverythingPermissions();
- const {
- hasPermission: canManageProjectsDatasets,
- isFetching: isFetchingManageProjectsDatasetsPermissions,
- hasAttempted: hasAttemptedManageProjectsDatasetsPermissions,
- } = useCanManageAtLeastOneProjectOrDataset();
-
- return useMemo(() => {
- const canViewDropBox = permissions.includes(viewDropBox);
- const canIngest = permissions.includes(ingestData) || permissions.includes(ingestReferenceMaterial);
- const canAnalyzeData = permissions.includes(analyzeData);
- const canExportData = permissions.includes(exportData);
- const canQueryData = permissions.includes(queryData);
- const canViewRuns = permissions.includes(viewRuns);
- const canViewPermissions = permissions.includes(viewPermissions);
-
- const canManageAnything = (
- canManageProjectsDatasets ||
- canViewDropBox ||
- canIngest ||
- canAnalyzeData ||
- canExportData ||
- canViewRuns ||
- canViewPermissions
- );
-
- const isFetching = isFetchingPermissions || isFetchingManageProjectsDatasetsPermissions;
- const hasAttempted = hasAttemptedPermissions && hasAttemptedManageProjectsDatasetsPermissions;
-
- return ({
- permissions: {
- canManageProjectsDatasets,
- canViewDropBox,
- canIngest,
- canAnalyzeData,
- canExportData,
- canQueryData,
- canViewRuns,
- canViewPermissions,
- canManageAnything,
- },
- isFetching,
- hasAttempted,
- });
- }, [
- permissions,
- isFetchingPermissions,
- hasAttemptedPermissions,
+ const { permissions, isFetchingPermissions, hasAttemptedPermissions } = useEverythingPermissions();
+ const {
+ hasPermission: canManageProjectsDatasets,
+ isFetching: isFetchingManageProjectsDatasetsPermissions,
+ hasAttempted: hasAttemptedManageProjectsDatasetsPermissions,
+ } = useCanManageAtLeastOneProjectOrDataset();
+
+ return useMemo(() => {
+ const canViewDropBox = permissions.includes(viewDropBox);
+ const canIngest = permissions.includes(ingestData) || permissions.includes(ingestReferenceMaterial);
+ const canAnalyzeData = permissions.includes(analyzeData);
+ const canExportData = permissions.includes(exportData);
+ const canQueryData = permissions.includes(queryData);
+ const canViewRuns = permissions.includes(viewRuns);
+ const canViewPermissions = permissions.includes(viewPermissions);
+
+ const canManageAnything =
+ canManageProjectsDatasets ||
+ canViewDropBox ||
+ canIngest ||
+ canAnalyzeData ||
+ canExportData ||
+ canViewRuns ||
+ canViewPermissions;
+
+ const isFetching = isFetchingPermissions || isFetchingManageProjectsDatasetsPermissions;
+ const hasAttempted = hasAttemptedPermissions && hasAttemptedManageProjectsDatasetsPermissions;
+
+ return {
+ permissions: {
canManageProjectsDatasets,
- isFetchingManageProjectsDatasetsPermissions,
- hasAttemptedManageProjectsDatasetsPermissions,
- ]);
+ canViewDropBox,
+ canIngest,
+ canAnalyzeData,
+ canExportData,
+ canQueryData,
+ canViewRuns,
+ canViewPermissions,
+ canManageAnything,
+ },
+ isFetching,
+ hasAttempted,
+ };
+ }, [
+ permissions,
+ isFetchingPermissions,
+ hasAttemptedPermissions,
+ canManageProjectsDatasets,
+ isFetchingManageProjectsDatasetsPermissions,
+ hasAttemptedManageProjectsDatasetsPermissions,
+ ]);
};
diff --git a/src/modules/authz/reducers.js b/src/modules/authz/reducers.js
index 2c1f7311d..a697d29e7 100644
--- a/src/modules/authz/reducers.js
+++ b/src/modules/authz/reducers.js
@@ -1,39 +1,167 @@
-import { FETCH_GRANTS, FETCH_GROUPS } from "./actions";
+import {
+ CREATE_GRANT,
+ CREATE_GROUP,
+ DELETE_GRANT,
+ DELETE_GROUP,
+ FETCH_ALL_PERMISSIONS,
+ FETCH_GRANTS,
+ FETCH_GROUPS,
+ INVALIDATE_GRANTS,
+ INVALIDATE_GROUPS,
+ SAVE_GROUP,
+} from "./actions";
+import { arrayToObjectByProperty, objectWithoutProp } from "@/utils/misc";
+
+export const allPermissions = (
+ state = {
+ data: [],
+ isFetching: false,
+ },
+ action,
+) => {
+ switch (action.type) {
+ case FETCH_ALL_PERMISSIONS.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_ALL_PERMISSIONS.RECEIVE:
+ return { ...state, data: action.data };
+ case FETCH_ALL_PERMISSIONS.FINISH:
+ return { ...state, isFetching: false };
+ default:
+ return state;
+ }
+};
export const grants = (
- state = {
- data: [],
- isFetching: false,
- },
- action,
+ state = {
+ data: [],
+ itemsByID: {},
+ isFetching: false,
+ isCreating: false,
+ isDeleting: false,
+ isInvalid: false,
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_GRANTS.REQUEST:
- return {...state, isFetching: true};
- case FETCH_GRANTS.RECEIVE:
- return {...state, data: action.data, isFetching: false};
- case FETCH_GRANTS.FINISH:
- return {...state, isFetching: false};
- default:
- return state;
- }
+ switch (action.type) {
+ case FETCH_GRANTS.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_GRANTS.RECEIVE:
+ return {
+ ...state,
+ data: action.data,
+ itemsByID: arrayToObjectByProperty(action.data, "id"),
+ isInvalid: false,
+ };
+ case FETCH_GRANTS.FINISH:
+ return { ...state, isFetching: false };
+
+ case INVALIDATE_GRANTS:
+ return { ...state, isInvalid: true };
+
+ case CREATE_GRANT.REQUEST:
+ return { ...state, isCreating: true };
+ case CREATE_GRANT.RECEIVE:
+ return {
+ ...state,
+ data: [...state.data, action.data],
+ itemsByID: { ...state.itemsByID, [action.data.id]: action.data },
+ };
+ case CREATE_GRANT.FINISH:
+ return { ...state, isCreating: false };
+
+ case DELETE_GRANT.REQUEST:
+ return { ...state, isDeleting: true };
+ case DELETE_GRANT.RECEIVE:
+ return {
+ ...state,
+ data: state.data.filter((g) => g.id !== action.grantID),
+ itemsByID: objectWithoutProp(state.itemsByID, action.grantID),
+ };
+ case DELETE_GRANT.FINISH:
+ return { ...state, isDeleting: false };
+
+ default:
+ return state;
+ }
};
export const groups = (
- state = {
- data: [],
- isFetching: false,
- },
- action,
+ state = {
+ data: [],
+ itemsByID: {},
+ oldItemsByID: {}, // for storing old items and allowing optimistic updates, reverting if necessary.
+ isFetching: false,
+ isCreating: false,
+ isSaving: false,
+ isDeleting: false,
+ isInvalid: false,
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_GROUPS.REQUEST:
- return {...state, isFetching: true};
- case FETCH_GROUPS.RECEIVE:
- return {...state, data: action.data, isFetching: false};
- case FETCH_GROUPS.FINISH:
- return {...state, isFetching: false};
- default:
- return state;
+ switch (action.type) {
+ case FETCH_GROUPS.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_GROUPS.RECEIVE:
+ return {
+ ...state,
+ data: action.data,
+ itemsByID: arrayToObjectByProperty(action.data, "id"),
+ isInvalid: false,
+ };
+ case FETCH_GROUPS.FINISH:
+ return { ...state, isFetching: false };
+
+ case INVALIDATE_GROUPS:
+ return { ...state, isInvalid: true };
+
+ case CREATE_GROUP.REQUEST:
+ return { ...state, isCreating: true };
+ case CREATE_GROUP.RECEIVE:
+ return {
+ ...state,
+ data: [...state.data, action.data],
+ itemsByID: { ...state.itemsByID, [action.data.id]: action.data },
+ };
+ case CREATE_GROUP.FINISH:
+ return { ...state, isCreating: false };
+
+ case SAVE_GROUP.REQUEST: {
+ const groupID = action.group.id;
+ return {
+ ...state,
+ isSaving: true,
+ // Optimistically update the group while we wait for the PUT/subsequent (presumed) invalidate
+ data: state.data.map((g) => (g.id === groupID ? action.group : g)),
+ oldItemsByID: { [groupID]: state.itemsByID[groupID] },
+ itemsByID: { ...state.itemsByID, [groupID]: action.group },
+ };
}
+ case SAVE_GROUP.ERROR: {
+ const groupID = action.group.id;
+ const oldItem = state.itemsByID[groupID];
+ return {
+ ...state,
+ // Revert optimistic update
+ data: state.data.map((g) => (g.id === groupID ? oldItem : g)),
+ oldItemsByID: objectWithoutProp(state.oldItemsByID, groupID),
+ itemsByID: { ...state.itemsByID, [groupID]: oldItem },
+ };
+ }
+ case SAVE_GROUP.FINISH:
+ return { ...state, isSaving: false };
+
+ case DELETE_GROUP.REQUEST:
+ return { ...state, isDeleting: true };
+ case DELETE_GROUP.RECEIVE:
+ return {
+ ...state,
+ data: state.data.filter((g) => g.id !== action.groupID),
+ itemsByID: objectWithoutProp(state.itemsByID, action.groupID),
+ };
+ case DELETE_GROUP.FINISH:
+ return { ...state, isDeleting: false };
+
+ default:
+ return state;
+ }
};
diff --git a/src/modules/authz/types.ts b/src/modules/authz/types.ts
new file mode 100644
index 000000000..8e08402be
--- /dev/null
+++ b/src/modules/authz/types.ts
@@ -0,0 +1,52 @@
+import type { Resource } from "bento-auth-js";
+export type { Resource } from "bento-auth-js";
+
+// TODO: move some/all of these types to bento-auth-js
+
+export type PermissionDefinition = {
+ id: string;
+ verb: string;
+ noun: string;
+ min_level_required: "instance" | "project" | "dataset";
+ supports_data_type_narrowing: boolean;
+ gives: string[];
+};
+
+export type SpecificSubject =
+ | {
+ iss: string;
+ sub: string;
+ }
+ | {
+ iss: string;
+ client: string;
+ };
+
+export type GrantSubject = { everyone: true } | SpecificSubject | { group: number };
+
+export interface Grant {
+ subject: GrantSubject;
+ resource: Resource;
+ expiry: string | null;
+ notes: string;
+ permissions: string[]; // TODO: specific strings
+}
+
+export interface StoredGrant extends Grant {
+ id: number;
+ created: string;
+}
+
+export type GroupMembership = { expr: Array } | { members: SpecificSubject[] };
+
+export interface Group {
+ name: string;
+ membership: GroupMembership;
+ expiry: string;
+ notes: string;
+}
+
+export interface StoredGroup extends Group {
+ id: number;
+ created: string;
+}
diff --git a/src/modules/datasets/actions.js b/src/modules/datasets/actions.js
index e5c7764d1..58b8e9eae 100644
--- a/src/modules/datasets/actions.js
+++ b/src/modules/datasets/actions.js
@@ -1,5 +1,5 @@
-import {beginFlow, createFlowActionTypes, createNetworkActionTypes, endFlow, networkAction} from "../../utils/actions";
-import {getDataServices} from "../services/utils";
+import { beginFlow, createFlowActionTypes, createNetworkActionTypes, endFlow, networkAction } from "@/utils/actions";
+import { getDataServices } from "../services/utils";
export const FETCHING_DATASETS_DATA_TYPES = createFlowActionTypes("FETCHING_DATASETS_DATA_TYPES");
export const FETCH_DATASET_DATA_TYPES_SUMMARY = createNetworkActionTypes("FETCH_DATASET_DATA_TYPES_SUMMARY");
@@ -10,50 +10,51 @@ export const FETCHING_ALL_DATASET_SUMMARIES = createFlowActionTypes("FETCHING_AL
export const FETCH_DATASET_RESOURCES = createNetworkActionTypes("FETCH_DATASET_RESOURCES");
const fetchDatasetDataTypesSummary = networkAction((serviceInfo, datasetID) => ({
- types: FETCH_DATASET_DATA_TYPES_SUMMARY,
- params: {serviceInfo, datasetID},
- url: `${serviceInfo.url}/datasets/${datasetID}/data-types`,
+ types: FETCH_DATASET_DATA_TYPES_SUMMARY,
+ params: { serviceInfo, datasetID },
+ url: `${serviceInfo.url}/datasets/${datasetID}/data-types`,
}));
export const fetchDatasetDataTypesSummariesIfPossible = (datasetID) => async (dispatch, getState) => {
- if (getState().datasetDataTypes.itemsByID?.[datasetID]?.isFetching) return;
- await Promise.all(
- getDataServices(getState()).map(serviceInfo => dispatch(fetchDatasetDataTypesSummary(serviceInfo, datasetID))),
- );
+ if (getState().datasetDataTypes.itemsByID?.[datasetID]?.isFetching) return;
+ await Promise.all(
+ getDataServices(getState()).map((serviceInfo) => dispatch(fetchDatasetDataTypesSummary(serviceInfo, datasetID))),
+ );
};
export const fetchDatasetsDataTypes = () => async (dispatch, getState) => {
- dispatch(beginFlow(FETCHING_DATASETS_DATA_TYPES));
- await Promise.all(
- Object.keys(getState().projects.datasetsByID).map(datasetID =>
- dispatch(fetchDatasetDataTypesSummariesIfPossible(datasetID))),
- );
- dispatch(endFlow(FETCHING_DATASETS_DATA_TYPES));
+ dispatch(beginFlow(FETCHING_DATASETS_DATA_TYPES));
+ await Promise.all(
+ Object.keys(getState().projects.datasetsByID).map((datasetID) =>
+ dispatch(fetchDatasetDataTypesSummariesIfPossible(datasetID)),
+ ),
+ );
+ dispatch(endFlow(FETCHING_DATASETS_DATA_TYPES));
};
const fetchDatasetSummary = networkAction((serviceInfo, datasetID) => ({
- types: FETCH_DATASET_SUMMARY,
- params: {serviceInfo, datasetID},
- url: `${serviceInfo.url}/datasets/${datasetID}/summary`,
+ types: FETCH_DATASET_SUMMARY,
+ params: { serviceInfo, datasetID },
+ url: `${serviceInfo.url}/datasets/${datasetID}/summary`,
}));
export const fetchDatasetSummariesIfPossible = (datasetID) => async (dispatch, getState) => {
- if (getState().datasetSummaries.isFetchingAll) return;
- dispatch(beginFlow(FETCHING_ALL_DATASET_SUMMARIES));
- await Promise.all(
- getDataServices(getState()).map(serviceInfo => dispatch(fetchDatasetSummary(serviceInfo, datasetID))),
- );
- dispatch(endFlow(FETCHING_ALL_DATASET_SUMMARIES));
+ if (getState().datasetSummaries.isFetchingAll) return;
+ dispatch(beginFlow(FETCHING_ALL_DATASET_SUMMARIES));
+ await Promise.all(
+ getDataServices(getState()).map((serviceInfo) => dispatch(fetchDatasetSummary(serviceInfo, datasetID))),
+ );
+ dispatch(endFlow(FETCHING_ALL_DATASET_SUMMARIES));
};
-const fetchDatasetResources = networkAction((datasetID) => (dispatch, getState) => ({
- types: FETCH_DATASET_RESOURCES,
- params: {datasetID},
- url: `${getState().services.metadataService.url}/api/datasets/${datasetID}/resources`,
- err: "Error fetching dataset resources",
+const fetchDatasetResources = networkAction((datasetID) => (_dispatch, getState) => ({
+ types: FETCH_DATASET_RESOURCES,
+ params: { datasetID },
+ url: `${getState().services.metadataService.url}/api/datasets/${datasetID}/resources`,
+ err: "Error fetching dataset resources",
}));
export const fetchDatasetResourcesIfNecessary = (datasetID) => (dispatch, getState) => {
- const datasetResources = getState().datasetResources.itemsByID[datasetID];
- if (datasetResources?.isFetching || datasetResources?.data) return;
- return dispatch(fetchDatasetResources(datasetID));
+ const datasetResources = getState().datasetResources.itemsByID[datasetID];
+ if (datasetResources?.isFetching || datasetResources?.data) return;
+ return dispatch(fetchDatasetResources(datasetID));
};
diff --git a/src/modules/datasets/hooks.js b/src/modules/datasets/hooks.js
new file mode 100644
index 000000000..0cefee788
--- /dev/null
+++ b/src/modules/datasets/hooks.js
@@ -0,0 +1,39 @@
+import { useEffect, useMemo } from "react";
+import { useProjects } from "@/modules/metadata/hooks";
+import { useAppDispatch, useAppSelector } from "@/store";
+import { fetchDatasetDataTypesSummariesIfPossible, fetchDatasetsDataTypes } from "@/modules/datasets/actions";
+
+export const useDatasetDataTypes = () => {
+ /**
+ * Fetches the data type summaries for ALL datasets.
+ * returns the store's values.
+ */
+ const dispatch = useAppDispatch();
+ const { datasetsByID } = useProjects();
+ useEffect(() => {
+ if (Object.keys(datasetsByID).length) {
+ dispatch(fetchDatasetsDataTypes()).catch(console.error);
+ }
+ }, [dispatch, datasetsByID]);
+ return useAppSelector((state) => state.datasetDataTypes);
+};
+
+export const useDatasetDataTypesByID = (datasetId) => {
+ /**
+ * Fetches the data types ONLY for the given dataset.
+ * Returns the store's value for the given dataset ID.
+ */
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(fetchDatasetDataTypesSummariesIfPossible(datasetId));
+ }, [dispatch, datasetId]);
+
+ const dataTypes = useAppSelector((state) => state.datasetDataTypes.itemsByID[datasetId]);
+ return useMemo(() => {
+ return {
+ dataTypesByID: dataTypes?.itemsByID,
+ isFetchingDataTypes: dataTypes?.isFetching ?? true,
+ hasAttemptedDataTypes: dataTypes?.hasAttempted ?? false,
+ };
+ }, [dataTypes]);
+};
diff --git a/src/modules/datasets/reducers.js b/src/modules/datasets/reducers.js
index 524469c58..5c241d047 100644
--- a/src/modules/datasets/reducers.js
+++ b/src/modules/datasets/reducers.js
@@ -1,130 +1,134 @@
import {
- FETCHING_DATASETS_DATA_TYPES,
- FETCH_DATASET_DATA_TYPES_SUMMARY,
- FETCH_DATASET_SUMMARY,
- FETCHING_ALL_DATASET_SUMMARIES, FETCH_DATASET_RESOURCES,
+ FETCHING_DATASETS_DATA_TYPES,
+ FETCH_DATASET_DATA_TYPES_SUMMARY,
+ FETCH_DATASET_SUMMARY,
+ FETCHING_ALL_DATASET_SUMMARIES,
+ FETCH_DATASET_RESOURCES,
} from "./actions";
export const datasetDataTypes = (
- state = {
- itemsByID: {},
- isFetchingAll: false,
- },
- action,
+ state = {
+ itemsByID: {},
+ isFetchingAll: false,
+ },
+ action,
) => {
- switch (action.type) {
- case FETCHING_DATASETS_DATA_TYPES.BEGIN:
- return {...state, isFetchingAll: true};
- case FETCHING_DATASETS_DATA_TYPES.END:
- return {...state, isFetchingAll: false};
- case FETCH_DATASET_DATA_TYPES_SUMMARY.REQUEST:{
- const {datasetID} = action;
- return {
- ...state,
- itemsByID: {
- ...state.itemsByID,
- [datasetID]: {
- itemsByID: state.itemsByID[datasetID]?.itemsByID ?? {},
- isFetching: true,
- },
- },
- };
- }
- case FETCH_DATASET_DATA_TYPES_SUMMARY.RECEIVE:{
- const {datasetID} = action;
- const itemsByID = Object.fromEntries(action.data.map(d => [d.id, d]));
- return {
- ...state,
- itemsByID: {
- ...state.itemsByID,
- [datasetID]: {
- itemsByID: {
- ...state.itemsByID[datasetID].itemsByID,
- ...itemsByID,
- },
- },
- },
- };
- }
- case FETCH_DATASET_DATA_TYPES_SUMMARY.FINISH:
- case FETCH_DATASET_DATA_TYPES_SUMMARY.ERROR:{
- const {datasetID} = action;
- return {
- ...state,
- itemsByID: {
- ...state.itemsByID,
- [datasetID]: {
- ...state.itemsByID[datasetID],
- isFetching: false,
- },
- },
- };
- }
- default:
- return state;
+ switch (action.type) {
+ case FETCHING_DATASETS_DATA_TYPES.BEGIN:
+ return { ...state, isFetchingAll: true };
+ case FETCHING_DATASETS_DATA_TYPES.END:
+ return { ...state, isFetchingAll: false };
+ case FETCH_DATASET_DATA_TYPES_SUMMARY.REQUEST: {
+ const { datasetID } = action;
+ return {
+ ...state,
+ itemsByID: {
+ ...state.itemsByID,
+ [datasetID]: {
+ itemsByID: state.itemsByID[datasetID]?.itemsByID ?? {},
+ hasAttempted: state.itemsByID[datasetID]?.hasAttempted ?? false,
+ isFetching: true,
+ },
+ },
+ };
}
-};
-
-
-const datasetItemSet = (oldState, datasetID, key, value) => {
- // If value is an object, spread with key's oldState
- // Else, set key with value as is (array | boolean | string | undefined)
- const newValue = "object" === typeof value && !Array.isArray(value) ? {
- ...(oldState.itemsByID[datasetID]?.[key] ?? {}),
- ...value,
- } : value;
- const newState = {
- ...oldState,
+ case FETCH_DATASET_DATA_TYPES_SUMMARY.RECEIVE: {
+ const { datasetID } = action;
+ const itemsByID = Object.fromEntries(action.data.map((d) => [d.id, d]));
+ return {
+ ...state,
itemsByID: {
- ...oldState.itemsByID,
- [datasetID]: {
- ...(oldState.itemsByID[datasetID] ?? {}),
- [key]: newValue,
+ ...state.itemsByID,
+ [datasetID]: {
+ itemsByID: {
+ ...state.itemsByID[datasetID].itemsByID,
+ ...itemsByID,
},
+ },
+ },
+ };
+ }
+ case FETCH_DATASET_DATA_TYPES_SUMMARY.FINISH:
+ case FETCH_DATASET_DATA_TYPES_SUMMARY.ERROR: {
+ const { datasetID } = action;
+ return {
+ ...state,
+ itemsByID: {
+ ...state.itemsByID,
+ [datasetID]: {
+ ...state.itemsByID[datasetID],
+ isFetching: false,
+ hasAttempted: true,
+ },
},
- };
- return newState;
+ };
+ }
+ default:
+ return state;
+ }
};
+const datasetItemSet = (oldState, datasetID, key, value) => {
+ // If value is an object, spread with key's oldState
+ // Else, set key with value as is (array | boolean | string | undefined)
+ const newValue =
+ "object" === typeof value && !Array.isArray(value)
+ ? {
+ ...(oldState.itemsByID[datasetID]?.[key] ?? {}),
+ ...value,
+ }
+ : value;
+ const newState = {
+ ...oldState,
+ itemsByID: {
+ ...oldState.itemsByID,
+ [datasetID]: {
+ ...(oldState.itemsByID[datasetID] ?? {}),
+ [key]: newValue,
+ },
+ },
+ };
+ return newState;
+};
export const datasetSummaries = (
- state = {
- isFetchingAll: false,
- itemsByID: {},
- },
- action,
+ state = {
+ isFetchingAll: false,
+ itemsByID: {},
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_DATASET_SUMMARY.REQUEST:
- return datasetItemSet(state, action.datasetID, "isFetching", true);
- case FETCH_DATASET_SUMMARY.RECEIVE:
- return datasetItemSet(state, action.datasetID, "data", action.data);
- case FETCH_DATASET_SUMMARY.FINISH:
- return datasetItemSet(state, action.datasetID, "isFetching", false);
- case FETCHING_ALL_DATASET_SUMMARIES.BEGIN:
- return {...state, isFetchingAll: true};
- case FETCHING_ALL_DATASET_SUMMARIES.END:
- case FETCHING_ALL_DATASET_SUMMARIES.TERMINATE:
- return {...state, isFetchingAll: false};
- default:
- return state;
- }
+ switch (action.type) {
+ case FETCH_DATASET_SUMMARY.REQUEST:
+ return datasetItemSet(state, action.datasetID, "isFetching", true);
+ case FETCH_DATASET_SUMMARY.RECEIVE:
+ return datasetItemSet(state, action.datasetID, "data", action.data);
+ case FETCH_DATASET_SUMMARY.FINISH:
+ return datasetItemSet(state, action.datasetID, "isFetching", false);
+ case FETCHING_ALL_DATASET_SUMMARIES.BEGIN:
+ return { ...state, isFetchingAll: true };
+ case FETCHING_ALL_DATASET_SUMMARIES.END:
+ case FETCHING_ALL_DATASET_SUMMARIES.TERMINATE:
+ return { ...state, isFetchingAll: false };
+ default:
+ return state;
+ }
};
export const datasetResources = (
- state = {
- itemsByID: {},
- },
- action,
+ state = {
+ itemsByID: {},
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_DATASET_RESOURCES.REQUEST:
- return datasetItemSet(state, action.datasetID, "isFetching", true);
- case FETCH_DATASET_RESOURCES.RECEIVE:
- return datasetItemSet(state, action.datasetID, "data", action.data);
- case FETCH_DATASET_RESOURCES.FINISH:
- return datasetItemSet(state, action.datasetID, "isFetching", false);
- default:
- return state;
- }
+ switch (action.type) {
+ case FETCH_DATASET_RESOURCES.REQUEST:
+ return datasetItemSet(state, action.datasetID, "isFetching", true);
+ case FETCH_DATASET_RESOURCES.RECEIVE:
+ return datasetItemSet(state, action.datasetID, "data", action.data);
+ case FETCH_DATASET_RESOURCES.FINISH:
+ return datasetItemSet(state, action.datasetID, "isFetching", false);
+ default:
+ return state;
+ }
};
diff --git a/src/modules/discovery/actions.js b/src/modules/discovery/actions.js
index 1827b5ffe..94a4207d5 100644
--- a/src/modules/discovery/actions.js
+++ b/src/modules/discovery/actions.js
@@ -1,32 +1,16 @@
-import {createNetworkActionTypes, networkAction} from "../../utils/actions";
+import { createNetworkActionTypes, networkAction } from "@/utils/actions";
-
-export const PERFORM_GOHAN_GENE_SEARCH = createNetworkActionTypes("GOHAN_GENE_SEARCH");
export const FETCH_DISCOVERY_SCHEMA = createNetworkActionTypes("FETCH_DISCOVERY_SCHEMA");
export const FETCH_DATS_SCHEMA = createNetworkActionTypes("FETCH_DATS_SCHEMA");
-export const performGohanGeneSearchIfPossible = (searchTerm, assemblyId) => (dispatch, getState) => {
- const gohanUrl = getState()?.services?.itemsByKind?.gohan?.url;
- if (!gohanUrl) return;
- const queryString = `/genes/search?term=${searchTerm}&assemblyId=${assemblyId}`;
- const searchUrl = `${gohanUrl}${queryString}`;
- dispatch(performGohanGeneSearch(searchUrl));
-};
-
-const performGohanGeneSearch = networkAction((searchUrl) => () => ({
- types: PERFORM_GOHAN_GENE_SEARCH,
- url: searchUrl,
- err: "error performing Gohan gene search",
-}));
-
const _fetchDiscoverySchema = networkAction(() => (dispatch, getState) => ({
- types: FETCH_DISCOVERY_SCHEMA,
- url: `${getState().bentoServices.itemsByKind.metadata.url}/api/schemas/discovery`,
- err: "Error fetching discovery JSON schema",
+ types: FETCH_DISCOVERY_SCHEMA,
+ url: `${getState().bentoServices.itemsByKind.metadata.url}/api/schemas/discovery`,
+ err: "Error fetching discovery JSON schema",
}));
export const fetchDiscoverySchema = () => (dispatch, getState) => {
- const metadataUrl = getState()?.bentoServices?.itemsByKind?.metadata?.url;
- if (!metadataUrl) return Promise.resolve();
- return dispatch(_fetchDiscoverySchema());
+ const metadataUrl = getState()?.bentoServices?.itemsByKind?.metadata?.url;
+ if (!metadataUrl) return Promise.resolve();
+ return dispatch(_fetchDiscoverySchema());
};
diff --git a/src/modules/discovery/reducers.js b/src/modules/discovery/reducers.js
index e4a3ec3b5..28bfb55eb 100644
--- a/src/modules/discovery/reducers.js
+++ b/src/modules/discovery/reducers.js
@@ -1,27 +1,18 @@
-import {
- PERFORM_GOHAN_GENE_SEARCH,
- FETCH_DISCOVERY_SCHEMA,
-} from "./actions";
+import { FETCH_DISCOVERY_SCHEMA } from "./actions";
export const discovery = (
- state = {
- geneNameSearchResponse: [],
- discoverySchema: {},
- },
- action,
+ state = {
+ discoverySchema: {},
+ },
+ action,
) => {
- switch (action.type) {
- case PERFORM_GOHAN_GENE_SEARCH.RECEIVE:
- return {
- ...state,
- geneNameSearchResponse: action.data.results,
- };
- case FETCH_DISCOVERY_SCHEMA.RECEIVE:
- return {
- ...state,
- discoverySchema: action.data,
- };
- default:
- return state;
- }
+ switch (action.type) {
+ case FETCH_DISCOVERY_SCHEMA.RECEIVE:
+ return {
+ ...state,
+ discoverySchema: action.data,
+ };
+ default:
+ return state;
+ }
};
diff --git a/src/modules/drs/actions.js b/src/modules/drs/actions.js
index f26a360a2..4a1848ab7 100644
--- a/src/modules/drs/actions.js
+++ b/src/modules/drs/actions.js
@@ -1,9 +1,6 @@
import { message } from "antd";
-import {
- createNetworkActionTypes,
- networkAction,
-} from "@/utils/actions";
+import { createNetworkActionTypes, networkAction } from "@/utils/actions";
import { guessFileType } from "@/utils/files";
export const PERFORM_DRS_OBJECT_SEARCH = createNetworkActionTypes("PERFORM_DRS_OBJECT_SEARCH");
@@ -13,21 +10,23 @@ export const DELETE_DRS_OBJECT = createNetworkActionTypes("DELETE_DRS_OBJECT");
export const PERFORM_SEARCH_BY_FUZZY_NAME = createNetworkActionTypes("PERFORM_SEARCH_BY_FUZZY_NAME");
export const RETRIEVE_URLS_FOR_IGV = {
- BEGIN: "RETRIEVE_URLS_FOR_IGV.BEGIN",
- END: "RETRIEVE_URLS_FOR_IGV.END",
- ERROR: "RETRIEVE_URLS_FOR_IGV.ERROR",
+ BEGIN: "RETRIEVE_URLS_FOR_IGV.BEGIN",
+ END: "RETRIEVE_URLS_FOR_IGV.END",
+ ERROR: "RETRIEVE_URLS_FOR_IGV.ERROR",
};
export const RETRIEVE_URLS_FOR_DOWNLOAD = {
- BEGIN: "RETRIEVE_URLS_FOR_DOWNLOAD.BEGIN",
- END: "RETRIEVE_URLS_FOR_DOWNLOAD.END",
- ERROR: "RETRIEVE_URLS_FOR_DOWNLOAD.ERROR",
+ BEGIN: "RETRIEVE_URLS_FOR_DOWNLOAD.BEGIN",
+ END: "RETRIEVE_URLS_FOR_DOWNLOAD.END",
+ ERROR: "RETRIEVE_URLS_FOR_DOWNLOAD.ERROR",
};
const drsObjectDownloadUrl = (drsUrl, objId) => `${drsUrl}/objects/${objId}/download`;
// for igv-viewable files, get data (and maybe index file) urls in a single network call
-const getDrsUrls = (fileObj, skipIndex = false) => async (dispatch, getState) => {
+const getDrsUrls =
+ (fileObj, skipIndex = false) =>
+ async (dispatch, getState) => {
const filename = fileObj.filename;
const drsUrl = getState().services.drsService.url;
@@ -40,169 +39,165 @@ const getDrsUrls = (fileObj, skipIndex = false) => async (dispatch, getState) =>
await dispatch(performFuzzyNameSearch(fuzzySearchUrl));
console.debug(`Completed fuzzy search for ${filename}`);
- const result = { url: null, ...(shouldFetchIndex ? {indexUrl: null} : {}) };
+ const result = { url: null, ...(shouldFetchIndex ? { indexUrl: null } : {}) };
const fuzzySearchObj = getState()?.drs?.fuzzySearchResponse;
if (fuzzySearchObj === undefined) {
- const msg = `Something went wrong when pinging ${fuzzySearchUrl} ; fuzzySearchResponse is undefined`;
- console.error(msg);
- message.error(msg);
- return { [filename]: result };
+ const msg = `Something went wrong when pinging ${fuzzySearchUrl} ; fuzzySearchResponse is undefined`;
+ console.error(msg);
+ message.error(msg);
+ return { [filename]: result };
}
const dataFileId = fuzzySearchObj.find((obj) => obj.name === filename)?.id;
if (dataFileId === undefined) {
- console.error(`Something went wrong when obtaining data file ID for ${filename}`);
- return { [filename]: result };
+ console.error(`Something went wrong when obtaining data file ID for ${filename}`);
+ return { [filename]: result };
}
result.url = drsObjectDownloadUrl(drsUrl, dataFileId);
if (shouldFetchIndex) {
- const indexFilename = indexFileName(filename);
+ const indexFilename = indexFileName(filename);
- result.indexUrl = null;
+ result.indexUrl = null;
- const indexFileId = fuzzySearchObj.find((obj) => obj.name === indexFilename)?.id;
- if (indexFileId === undefined) {
- console.error(`Something went wrong when obtaining index file ID for ${indexFilename}`);
- return { [filename]: result };
- }
+ const indexFileId = fuzzySearchObj.find((obj) => obj.name === indexFilename)?.id;
+ if (indexFileId === undefined) {
+ console.error(`Something went wrong when obtaining index file ID for ${indexFilename}`);
+ return { [filename]: result };
+ }
- result.indexUrl = drsObjectDownloadUrl(drsUrl, indexFileId);
+ result.indexUrl = drsObjectDownloadUrl(drsUrl, indexFileId);
}
const urls = { [filename]: result };
console.debug(`retrieved DRS urls: ${JSON.stringify(urls)}`);
return urls;
-};
+ };
const groupDrsUrls = (urls) => urls.reduce((obj, item) => Object.assign(obj, item), {});
// TODO: completely deduplicate these two functions
export const getIgvUrlsFromDrs = (fileObjects) => async (dispatch, getState) => {
- if (!getState().services.drsService) {
- console.error("DRS not found");
- return;
- }
-
- console.log("initiating getIgvUrlsFromDrs");
-
- const dispatchedSearches = fileObjects.map((f) => dispatch(getDrsUrls(f)));
-
- dispatch(beginIgvUrlSearch());
-
- try {
- // reduce array to object that's addressable by filename
- const urlsObj = groupDrsUrls(await Promise.all(dispatchedSearches));
- console.debug(`received drs urls for igv: ${JSON.stringify(urlsObj)}`);
- dispatch(setDrsUrlsForIgv(urlsObj));
- } catch (err) {
- console.error(err);
- dispatch(errorIgvUrlSearch());
- }
+ if (!getState().services.drsService) {
+ console.error("DRS not found");
+ return;
+ }
+
+ console.log("initiating getIgvUrlsFromDrs");
+
+ const dispatchedSearches = fileObjects.map((f) => dispatch(getDrsUrls(f)));
+
+ dispatch(beginIgvUrlSearch());
+
+ try {
+ // reduce array to object that's addressable by filename
+ const urlsObj = groupDrsUrls(await Promise.all(dispatchedSearches));
+ console.debug(`received drs urls for igv: ${JSON.stringify(urlsObj)}`);
+ dispatch(setDrsUrlsForIgv(urlsObj));
+ } catch (err) {
+ console.error(err);
+ dispatch(errorIgvUrlSearch());
+ }
};
export const getFileDownloadUrlsFromDrs = (fileObjects) => async (dispatch, getState) => {
- if (!getState().services.drsService) {
- console.error("DRS not found");
- return;
- }
-
- console.log("initiating getFileDownloadUrlsFromDrs");
-
- const dispatchedSearches = fileObjects.map((f) => dispatch(getDrsUrls(f, true)));
-
- dispatch(beginDownloadUrlsSearch());
-
- try {
- // reduce array to object that's addressable by filename
- const urlsObj = groupDrsUrls(await Promise.all(dispatchedSearches));
- console.debug("received download urls from drs:", urlsObj);
- dispatch(setDownloadUrls(urlsObj));
- } catch (err) {
- console.error(err);
- dispatch(errorDownloadUrls());
- }
+ if (!getState().services.drsService) {
+ console.error("DRS not found");
+ return;
+ }
+
+ console.log("initiating getFileDownloadUrlsFromDrs");
+
+ const dispatchedSearches = fileObjects.map((f) => dispatch(getDrsUrls(f, true)));
+
+ dispatch(beginDownloadUrlsSearch());
+
+ try {
+ // reduce array to object that's addressable by filename
+ const urlsObj = groupDrsUrls(await Promise.all(dispatchedSearches));
+ console.debug("received download urls from drs:", urlsObj);
+ dispatch(setDownloadUrls(urlsObj));
+ } catch (err) {
+ console.error(err);
+ dispatch(errorDownloadUrls());
+ }
};
-
export const performDRSObjectSearch = networkAction((q) => (dispatch, getState) => ({
- types: PERFORM_DRS_OBJECT_SEARCH,
- params: { q },
- url: `${getState().services.drsService.url}/search?${new URLSearchParams({ q, with_bento_properties: "true" })}`,
- err: "Error while searching for DRS objects",
+ types: PERFORM_DRS_OBJECT_SEARCH,
+ params: { q },
+ url: `${getState().services.drsService.url}/search?${new URLSearchParams({ q, with_bento_properties: "true" })}`,
+ err: "Error while searching for DRS objects",
}));
export const clearDRSObjectSearch = () => ({ type: CLEAR_DRS_OBJECT_SEARCH });
-
export const deleteDRSObject = networkAction((drsObject) => (dispatch, getState) => ({
- types: DELETE_DRS_OBJECT,
- params: { drsObject },
- url: `${getState().services.drsService.url}/objects/${drsObject.id}`,
- req: { method: "DELETE" },
- err: "Error while deleting DRS object",
- onSuccess: () => {
- message.success(`DRS object "${drsObject.name}" deleted successfully!`);
- },
+ types: DELETE_DRS_OBJECT,
+ params: { drsObject },
+ url: `${getState().services.drsService.url}/objects/${drsObject.id}`,
+ req: { method: "DELETE" },
+ err: "Error while deleting DRS object",
+ onSuccess: () => {
+ message.success(`DRS object "${drsObject.name}" deleted successfully!`);
+ },
}));
-
const performFuzzyNameSearch = networkAction((fuzzySearchUrl) => () => ({
- types: PERFORM_SEARCH_BY_FUZZY_NAME,
- url: fuzzySearchUrl,
+ types: PERFORM_SEARCH_BY_FUZZY_NAME,
+ url: fuzzySearchUrl,
}));
const beginIgvUrlSearch = () => ({
- type: RETRIEVE_URLS_FOR_IGV.BEGIN,
+ type: RETRIEVE_URLS_FOR_IGV.BEGIN,
});
const setDrsUrlsForIgv = (urls) => ({
- type: RETRIEVE_URLS_FOR_IGV.END,
- urls: urls,
+ type: RETRIEVE_URLS_FOR_IGV.END,
+ urls: urls,
});
const errorIgvUrlSearch = () => ({
- type: RETRIEVE_URLS_FOR_IGV.ERROR,
+ type: RETRIEVE_URLS_FOR_IGV.ERROR,
// err: 'error retrieving DRS urls for IGV'
});
const beginDownloadUrlsSearch = () => ({
- type: RETRIEVE_URLS_FOR_DOWNLOAD.BEGIN,
+ type: RETRIEVE_URLS_FOR_DOWNLOAD.BEGIN,
});
const setDownloadUrls = (urls) => ({
- type: RETRIEVE_URLS_FOR_DOWNLOAD.END,
- urls: urls,
+ type: RETRIEVE_URLS_FOR_DOWNLOAD.END,
+ urls: urls,
});
const errorDownloadUrls = () => ({
- type: RETRIEVE_URLS_FOR_DOWNLOAD.ERROR,
+ type: RETRIEVE_URLS_FOR_DOWNLOAD.ERROR,
});
-
const isIndexedFileType = (fileObj) => hasIndex(fileObj.file_format ?? guessFileType(fileObj.filename));
const indexSuffix = {
- "gvcf": ".tbi",
- "vcf": ".tbi",
- "bam": ".bai",
- "cram": ".crai",
+ gvcf: ".tbi",
+ vcf: ".tbi",
+ bam: ".bai",
+ cram: ".crai",
};
const indexFileName = (filename) => filename + indexSuffix[guessFileType(filename)];
const hasIndex = (fileType) => {
- switch (fileType.toLowerCase()) {
- case "gvcf":
- case "vcf":
- case "bam":
- case "cram":
- return true;
-
- default:
- return false;
- }
+ switch (fileType.toLowerCase()) {
+ case "gvcf":
+ case "vcf":
+ case "bam":
+ case "cram":
+ return true;
+
+ default:
+ return false;
+ }
};
diff --git a/src/modules/drs/reducers.js b/src/modules/drs/reducers.js
index 2e93eca5f..7ada764e9 100644
--- a/src/modules/drs/reducers.js
+++ b/src/modules/drs/reducers.js
@@ -1,78 +1,78 @@
import {
- PERFORM_SEARCH_BY_FUZZY_NAME,
- RETRIEVE_URLS_FOR_IGV,
- RETRIEVE_URLS_FOR_DOWNLOAD,
- PERFORM_DRS_OBJECT_SEARCH,
- DELETE_DRS_OBJECT,
- CLEAR_DRS_OBJECT_SEARCH,
+ PERFORM_SEARCH_BY_FUZZY_NAME,
+ RETRIEVE_URLS_FOR_IGV,
+ RETRIEVE_URLS_FOR_DOWNLOAD,
+ PERFORM_DRS_OBJECT_SEARCH,
+ DELETE_DRS_OBJECT,
+ CLEAR_DRS_OBJECT_SEARCH,
} from "./actions";
export const drs = (
- state = {
- isFuzzySearching: false,
- fuzzySearchResponse: {},
- igvUrlsByFilename: {},
- isFetchingIgvUrls: false,
- downloadUrlsByFilename: {},
- isFetchingDownloadUrls: false,
+ state = {
+ isFuzzySearching: false,
+ fuzzySearchResponse: {},
+ igvUrlsByFilename: {},
+ isFetchingIgvUrls: false,
+ downloadUrlsByFilename: {},
+ isFetchingDownloadUrls: false,
- objectSearchResults: [],
- objectSearchIsFetching: false,
- objectSearchAttempted: false,
+ objectSearchResults: [],
+ objectSearchIsFetching: false,
+ objectSearchAttempted: false,
- isDeleting: false,
- },
- action,
+ isDeleting: false,
+ },
+ action,
) => {
- switch (action.type) {
- // PERFORM_SEARCH_BY_FUZZY_NAME
- case PERFORM_SEARCH_BY_FUZZY_NAME.REQUEST:
- return { ...state, isFuzzySearching: true };
- case PERFORM_SEARCH_BY_FUZZY_NAME.RECEIVE:
- return { ...state, fuzzySearchResponse: action.data };
- case PERFORM_SEARCH_BY_FUZZY_NAME.FINISH:
- return { ...state, isFuzzySearching: false };
+ switch (action.type) {
+ // PERFORM_SEARCH_BY_FUZZY_NAME
+ case PERFORM_SEARCH_BY_FUZZY_NAME.REQUEST:
+ return { ...state, isFuzzySearching: true };
+ case PERFORM_SEARCH_BY_FUZZY_NAME.RECEIVE:
+ return { ...state, fuzzySearchResponse: action.data };
+ case PERFORM_SEARCH_BY_FUZZY_NAME.FINISH:
+ return { ...state, isFuzzySearching: false };
- // RETRIEVE_URLS_FOR_IGV
- case RETRIEVE_URLS_FOR_IGV.BEGIN:
- return { ...state, isFetchingIgvUrls: true };
- case RETRIEVE_URLS_FOR_IGV.END:
- return { ...state, isFetchingIgvUrls: false, igvUrlsByFilename: action.urls };
- case RETRIEVE_URLS_FOR_IGV.ERROR:
- return { ...state, isFetchingIgvUrls: false };
+ // RETRIEVE_URLS_FOR_IGV
+ case RETRIEVE_URLS_FOR_IGV.BEGIN:
+ return { ...state, isFetchingIgvUrls: true };
+ case RETRIEVE_URLS_FOR_IGV.END:
+ return { ...state, isFetchingIgvUrls: false, igvUrlsByFilename: action.urls };
+ case RETRIEVE_URLS_FOR_IGV.ERROR:
+ return { ...state, isFetchingIgvUrls: false };
- // RETRIEVE_URLS_FOR_DOWNLOAD
- case RETRIEVE_URLS_FOR_DOWNLOAD.BEGIN:
- return { ...state, isFetchingDownloadUrls: true };
- case RETRIEVE_URLS_FOR_DOWNLOAD.END:
- return { ...state, isFetchingDownloadUrls: false, downloadUrlsByFilename: action.urls };
- case RETRIEVE_URLS_FOR_DOWNLOAD.ERROR:
- return { ...state, isFetchingDownloadUrls: false };
+ // RETRIEVE_URLS_FOR_DOWNLOAD
+ case RETRIEVE_URLS_FOR_DOWNLOAD.BEGIN:
+ return { ...state, isFetchingDownloadUrls: true };
+ case RETRIEVE_URLS_FOR_DOWNLOAD.END:
+ return { ...state, isFetchingDownloadUrls: false, downloadUrlsByFilename: action.urls };
+ case RETRIEVE_URLS_FOR_DOWNLOAD.ERROR:
+ return { ...state, isFetchingDownloadUrls: false };
- // PERFORM_DRS_OBJECT_SEARCH
- case PERFORM_DRS_OBJECT_SEARCH.REQUEST:
- return { ...state, objectSearchIsFetching: true };
- case PERFORM_DRS_OBJECT_SEARCH.RECEIVE:
- return { ...state, objectSearchResults: action.data };
- case PERFORM_DRS_OBJECT_SEARCH.FINISH:
- return { ...state, objectSearchIsFetching: false, objectSearchAttempted: true };
+ // PERFORM_DRS_OBJECT_SEARCH
+ case PERFORM_DRS_OBJECT_SEARCH.REQUEST:
+ return { ...state, objectSearchIsFetching: true };
+ case PERFORM_DRS_OBJECT_SEARCH.RECEIVE:
+ return { ...state, objectSearchResults: action.data };
+ case PERFORM_DRS_OBJECT_SEARCH.FINISH:
+ return { ...state, objectSearchIsFetching: false, objectSearchAttempted: true };
- // CLEAR_DRS_OBJECT_SEARCH
- case CLEAR_DRS_OBJECT_SEARCH:
- return { ...state, objectSearchResults: [], objectSearchAttempted: false };
+ // CLEAR_DRS_OBJECT_SEARCH
+ case CLEAR_DRS_OBJECT_SEARCH:
+ return { ...state, objectSearchResults: [], objectSearchAttempted: false };
- // DELETE_DRS_OBJECT
- case DELETE_DRS_OBJECT.REQUEST:
- return { ...state, isDeleting: true };
- case DELETE_DRS_OBJECT.RECEIVE:
- return {
- ...state,
- objectSearchResults: state.objectSearchResults.filter((o) => o.id !== action.drsObject.id),
- };
- case DELETE_DRS_OBJECT.FINISH:
- return { ...state, isDeleting: false };
+ // DELETE_DRS_OBJECT
+ case DELETE_DRS_OBJECT.REQUEST:
+ return { ...state, isDeleting: true };
+ case DELETE_DRS_OBJECT.RECEIVE:
+ return {
+ ...state,
+ objectSearchResults: state.objectSearchResults.filter((o) => o.id !== action.drsObject.id),
+ };
+ case DELETE_DRS_OBJECT.FINISH:
+ return { ...state, isDeleting: false };
- default:
- return state;
- }
+ default:
+ return state;
+ }
};
diff --git a/src/modules/explorer/actions.js b/src/modules/explorer/actions.js
index ee8ab547c..f9bdec8a9 100644
--- a/src/modules/explorer/actions.js
+++ b/src/modules/explorer/actions.js
@@ -6,6 +6,7 @@ import { extractQueriesFromDataTypeForms } from "@/utils/search";
export const PERFORM_GET_GOHAN_VARIANTS_OVERVIEW = createNetworkActionTypes("GET_GOHAN_VARIANTS_OVERVIEW");
export const PERFORM_SEARCH = createNetworkActionTypes("EXPLORER.PERFORM_SEARCH");
export const SET_IS_SUBMITTING_SEARCH = "EXPLORER.SET_IS_SUBMITTING_SEARCH";
+export const CLEAR_SEARCH = "EXPLORER.CLEAR_SEARCH";
export const PERFORM_INDIVIDUAL_CSV_DOWNLOAD = createNetworkActionTypes("EXPLORER.PERFORM_INDIVIDUAL_CSV_DOWNLOAD");
export const PERFORM_BIOSAMPLE_CSV_DOWNLOAD = createNetworkActionTypes("EXPLORER.PERFORM_BIOSAMPLE_CSV_DOWNLOAD");
export const PERFORM_EXPERIMENT_CSV_DOWNLOAD = createNetworkActionTypes("EXPLORER.PERFORM_EXPERIMENT_CSV_DOWNLOAD");
@@ -24,207 +25,204 @@ export const FETCH_IGV_GENOMES = createNetworkActionTypes("EXPLORER.FETCH_IGV_GE
export const SET_IGV_POSITION = "EXPLORER.SET_IGV_POSITION";
const performSearch = networkAction((datasetID, dataTypeQueries, excludeFromAutoJoin = []) => (dispatch, getState) => ({
- types: PERFORM_SEARCH,
- url: `${getState().services.aggregationService.url}/dataset-search/${datasetID}`,
- params: { datasetID },
- req: jsonRequest(
- {
- data_type_queries: dataTypeQueries,
- join_query: null, // Will be autofilled by the aggregation service
- exclude_from_auto_join: excludeFromAutoJoin,
- },
- "POST",
- ),
- err: "Error performing search",
+ types: PERFORM_SEARCH,
+ url: `${getState().services.aggregationService.url}/dataset-search/${datasetID}`,
+ params: { datasetID },
+ req: jsonRequest(
+ {
+ data_type_queries: dataTypeQueries,
+ join_query: null, // Will be autofilled by the aggregation service
+ exclude_from_auto_join: excludeFromAutoJoin,
+ },
+ "POST",
+ ),
+ err: "Error performing search",
}));
export const performSearchIfPossible = (datasetID) => (dispatch, getState) => {
- if (getState().explorer.fetchingSearchByDatasetID[datasetID]) return;
+ if (getState().explorer.fetchingSearchByDatasetID[datasetID]) return;
- const dataTypeQueries = extractQueriesFromDataTypeForms(getState().explorer.dataTypeFormsByDatasetID[datasetID]);
- const excludeFromAutoJoin = [];
+ const dataTypeQueries = extractQueriesFromDataTypeForms(getState().explorer.dataTypeFormsByDatasetID[datasetID]);
+ const excludeFromAutoJoin = [];
- // TODO: What to do if phenopacket data type not present?
- // Must include phenopacket/experiment query so we can include the data in the results.
- if (!dataTypeQueries.hasOwnProperty("phenopacket")) dataTypeQueries["phenopacket"] = true;
- if (!dataTypeQueries.hasOwnProperty("experiment")) {
- // We want all phenopackets matching the actual search query to be
- // included, even if 0 experiments are present – so if there aren't any
- // specific queries on experiments themselves, we exclude them from
- // filtering the phenopackets by way of the join query.
+ // TODO: What to do if phenopacket data type not present?
+ // Must include phenopacket/experiment query so we can include the data in the results.
+ if (!dataTypeQueries.hasOwnProperty("phenopacket")) dataTypeQueries["phenopacket"] = true;
+ if (!dataTypeQueries.hasOwnProperty("experiment")) {
+ // We want all phenopackets matching the actual search query to be
+ // included, even if 0 experiments are present – so if there aren't any
+ // specific queries on experiments themselves, we exclude them from
+ // filtering the phenopackets by way of the join query.
- dataTypeQueries["experiment"] = true;
- excludeFromAutoJoin.push("experiment");
- }
+ dataTypeQueries["experiment"] = true;
+ excludeFromAutoJoin.push("experiment");
+ }
- return dispatch(performSearch(datasetID, dataTypeQueries, excludeFromAutoJoin));
+ return dispatch(performSearch(datasetID, dataTypeQueries, excludeFromAutoJoin));
};
// allows coordination between "real" search form and the variants UI form presented to the user
export const setIsSubmittingSearch = (isSubmittingSearch) => ({
- type: SET_IS_SUBMITTING_SEARCH,
- isSubmittingSearch,
+ type: SET_IS_SUBMITTING_SEARCH,
+ isSubmittingSearch,
});
+export const clearSearch = (datasetID) => (dispatch, getState) => {
+ if (getState().explorer.fetchingSearchByDatasetID[datasetID]) return;
+ return dispatch({ type: CLEAR_SEARCH, datasetID });
+};
+
// Helper function for CSV download functions
const performCSVDownloadHelper = (actionTypes, urlPath) =>
- networkAction((ids) => (dispatch, getState) => ({
- types: actionTypes,
- url: `${getState().services.itemsByArtifact.metadata.url}/api/batch/${urlPath}`,
- req: jsonRequest(
- {
- id: ids,
- format: "csv",
- },
- "POST",
- ),
- parse: (r) => r.blob(),
- err: `Error fetching ${urlPath} CSV`,
- }));
+ networkAction((ids) => (dispatch, getState) => ({
+ types: actionTypes,
+ url: `${getState().services.itemsByArtifact.metadata.url}/api/batch/${urlPath}`,
+ req: jsonRequest(
+ {
+ id: ids,
+ format: "csv",
+ },
+ "POST",
+ ),
+ parse: (r) => r.blob(),
+ err: `Error fetching ${urlPath} CSV`,
+ }));
const performIndividualsDownloadCSV = performCSVDownloadHelper(PERFORM_INDIVIDUAL_CSV_DOWNLOAD, "individuals");
export const performIndividualsDownloadCSVIfPossible =
- (datasetId, individualIds, allSearchResults) => (dispatch, _getState) => {
- const ids = individualIds.length ? individualIds : allSearchResults.map((sr) => sr.key);
- return dispatch(performIndividualsDownloadCSV(ids));
- };
+ (datasetId, individualIds, allSearchResults) => (dispatch, _getState) => {
+ const ids = individualIds.length ? individualIds : allSearchResults.map((sr) => sr.key);
+ return dispatch(performIndividualsDownloadCSV(ids));
+ };
// Action to perform the request to download biosamples CSV
const performBiosamplesDownloadCSV = performCSVDownloadHelper(PERFORM_BIOSAMPLE_CSV_DOWNLOAD, "biosamples");
// Function to download biosamples CSV if possible
export const performBiosamplesDownloadCSVIfPossible =
- (datasetId, biosampleIds, allSearchResults) => (dispatch, _getState) => {
- const ids = biosampleIds.length ? biosampleIds : allSearchResults.map((sr) => sr.key);
- return dispatch(performBiosamplesDownloadCSV(ids));
- };
+ (datasetId, biosampleIds, allSearchResults) => (dispatch, _getState) => {
+ const ids = biosampleIds.length ? biosampleIds : allSearchResults.map((sr) => sr.key);
+ return dispatch(performBiosamplesDownloadCSV(ids));
+ };
// Action to perform the request to download experiments CSV
const performExperimentsDownloadCSV = performCSVDownloadHelper(PERFORM_EXPERIMENT_CSV_DOWNLOAD, "experiments");
// Function to download experiments CSV if possible
export const performExperimentsDownloadCSVIfPossible =
- (datasetId, experimentIds, allSearchResults) => (dispatch, _getState) => {
- const ids = experimentIds.length ? experimentIds : allSearchResults.map((sr) => sr.key);
- return dispatch(performExperimentsDownloadCSV(ids));
- };
+ (datasetId, experimentIds, allSearchResults) => (dispatch, _getState) => {
+ const ids = experimentIds.length ? experimentIds : allSearchResults.map((sr) => sr.key);
+ return dispatch(performExperimentsDownloadCSV(ids));
+ };
export const addDataTypeQueryForm = (datasetID, dataType) => ({
- type: ADD_DATA_TYPE_QUERY_FORM,
- datasetID,
- dataType,
+ type: ADD_DATA_TYPE_QUERY_FORM,
+ datasetID,
+ dataType,
});
export const updateDataTypeQueryForm = (datasetID, dataType, fields) => ({
- type: UPDATE_DATA_TYPE_QUERY_FORM,
- datasetID,
- dataType,
- fields,
+ type: UPDATE_DATA_TYPE_QUERY_FORM,
+ datasetID,
+ dataType,
+ fields,
});
export const removeDataTypeQueryForm = (datasetID, dataType) => ({
- type: REMOVE_DATA_TYPE_QUERY_FORM,
- datasetID,
- dataType,
+ type: REMOVE_DATA_TYPE_QUERY_FORM,
+ datasetID,
+ dataType,
});
export const setSelectedRows = (datasetID, selectedRows) => ({
- type: SET_SELECTED_ROWS,
- datasetID,
- selectedRows,
+ type: SET_SELECTED_ROWS,
+ datasetID,
+ selectedRows,
});
export const setTableSortOrder = (datasetID, sortColumnKey, sortOrder, activeTab, currentPage) => ({
- type: SET_TABLE_SORT_ORDER,
- datasetID,
- sortColumnKey,
- sortOrder,
- activeTab,
- currentPage,
+ type: SET_TABLE_SORT_ORDER,
+ datasetID,
+ sortColumnKey,
+ sortOrder,
+ activeTab,
+ currentPage,
});
export const resetTableSortOrder = (datasetID) => ({
- type: RESET_TABLE_SORT_ORDER,
- datasetID,
+ type: RESET_TABLE_SORT_ORDER,
+ datasetID,
});
export const setActiveTab = (datasetID, activeTab) => ({
- type: SET_ACTIVE_TAB,
- datasetID,
- activeTab,
+ type: SET_ACTIVE_TAB,
+ datasetID,
+ activeTab,
});
export const setAutoQueryPageTransition = (priorPageUrl, type, field, value) => ({
- type: SET_AUTO_QUERY_PAGE_TRANSITION,
- isAutoQuery: true,
- pageUrlBeforeAutoQuery: priorPageUrl,
- autoQueryType: type,
- autoQueryField: field,
- autoQueryValue: value,
+ type: SET_AUTO_QUERY_PAGE_TRANSITION,
+ isAutoQuery: true,
+ pageUrlBeforeAutoQuery: priorPageUrl,
+ autoQueryType: type,
+ autoQueryField: field,
+ autoQueryValue: value,
});
export const neutralizeAutoQueryPageTransition = () => ({
- type: NEUTRALIZE_AUTO_QUERY_PAGE_TRANSITION,
- isAutoQuery: false,
- pageUrlBeforeAutoQuery: undefined,
- autoQueryType: undefined,
- autoQueryField: undefined,
- autoQueryValue: undefined,
+ type: NEUTRALIZE_AUTO_QUERY_PAGE_TRANSITION,
+ isAutoQuery: false,
+ pageUrlBeforeAutoQuery: undefined,
+ autoQueryType: undefined,
+ autoQueryField: undefined,
+ autoQueryValue: undefined,
});
// free-text search
// search unpaginated for now, since that's how standard queries are currently handled
const performFreeTextSearch = networkAction(
- (datasetID, term) => (dispatch, getState) => (
- console.log("performFreeTextSearch", datasetID, term) ||
- {
- types: FREE_TEXT_SEARCH,
- params: { datasetID },
- url:
- `${getState().services.metadataService.url}/api/individuals?search=${term}&page_size=10000` +
- "&format=bento_search_result",
- err: `Error searching in all records with term ${term}`,
- }
- ),
+ (datasetID, term) => (dispatch, getState) =>
+ console.log("performFreeTextSearch", datasetID, term) || {
+ types: FREE_TEXT_SEARCH,
+ params: { datasetID },
+ url:
+ `${getState().services.metadataService.url}/api/individuals?search=${term}&page_size=10000` +
+ "&format=bento_search_result",
+ err: `Error searching in all records with term ${term}`,
+ },
);
export const performFreeTextSearchIfPossible = (datasetID, term) => (dispatch, _getState) => {
- return dispatch(performFreeTextSearch(datasetID, term));
+ return dispatch(performFreeTextSearch(datasetID, term));
};
export const setOtherThresholdPercentage = (threshold) => ({
- type: SET_OTHER_THRESHOLD_PERCENTAGE,
- otherThresholdPercentage: threshold,
+ type: SET_OTHER_THRESHOLD_PERCENTAGE,
+ otherThresholdPercentage: threshold,
});
export const setIgvPosition = (igvPosition) => ({
- type: SET_IGV_POSITION,
- igvPosition,
+ type: SET_IGV_POSITION,
+ igvPosition,
});
const _fetchIgvGenomes = networkAction(() => ({
- types: FETCH_IGV_GENOMES,
- url: IGV_JS_GENOMES_JSON_URL,
- err: "Error fetching igv.js genomes",
+ types: FETCH_IGV_GENOMES,
+ url: IGV_JS_GENOMES_JSON_URL,
+ err: "Error fetching igv.js genomes",
}));
export const fetchIgvGenomes = () => (dispatch, getState) => {
- if (getState().igvGenomes.hasAttempted || getState().igvGenomes.isFetching) {
- return Promise.resolve();
- }
- return dispatch(_fetchIgvGenomes());
+ if (getState().igvGenomes.hasAttempted || getState().igvGenomes.isFetching) {
+ return Promise.resolve();
+ }
+ return dispatch(_fetchIgvGenomes());
};
-export const performGetGohanVariantsOverviewIfPossible = () => (dispatch, getState) => {
- const gohanUrl = getState()?.services?.itemsByKind?.gohan?.url;
- if (!gohanUrl) return Promise.resolve();
- const overviewPath = "/variants/overview";
- const getUrl = `${gohanUrl}${overviewPath}`;
- return dispatch(performGetGohanVariantsOverview(getUrl));
-};
-const performGetGohanVariantsOverview = networkAction((getUrl) => () => ({
- types: PERFORM_GET_GOHAN_VARIANTS_OVERVIEW,
- url: getUrl,
- err: "error getting Gohan variants overview",
+export const performGetGohanVariantsOverviewIfPossible = networkAction(() => (_dispatch, getState) => ({
+ types: PERFORM_GET_GOHAN_VARIANTS_OVERVIEW,
+ url: `${getState().services.itemsByKind.gohan?.url}/variants/overview`,
+ check: (state) => !!state.services.itemsByKind.gohan,
+ err: "error getting Gohan variants overview",
}));
diff --git a/src/modules/explorer/hooks.js b/src/modules/explorer/hooks.js
index 06fee8e48..26ba8994f 100644
--- a/src/modules/explorer/hooks.js
+++ b/src/modules/explorer/hooks.js
@@ -1,11 +1,31 @@
import { useEffect } from "react";
-import { useDispatch, useSelector } from "react-redux";
-import { fetchIgvGenomes } from "./actions";
+import { useSelector } from "react-redux";
+
+import { useService } from "@/modules/services/hooks";
+import { useAppDispatch } from "@/store";
+
+import { fetchIgvGenomes, performGetGohanVariantsOverviewIfPossible } from "./actions";
export const useIgvGenomes = () => {
- const dispatch = useDispatch();
- useEffect(() => {
- dispatch(fetchIgvGenomes());
- }, [dispatch]);
- return useSelector((state) => state.igvGenomes);
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(fetchIgvGenomes());
+ }, [dispatch]);
+ return useSelector((state) => state.igvGenomes);
+};
+
+export const useGohanVariantsOverview = () => {
+ const dispatch = useAppDispatch();
+ const gohan = useService("gohan");
+
+ useEffect(() => {
+ if (gohan) {
+ dispatch(performGetGohanVariantsOverviewIfPossible()).catch(console.error);
+ }
+ }, [dispatch, gohan]);
+
+ const data = useSelector((state) => state.explorer.variantsOverviewResponse);
+ const isFetching = useSelector((state) => state.explorer.fetchingVariantsOverview);
+
+ return { data, isFetching };
};
diff --git a/src/modules/explorer/reducers.js b/src/modules/explorer/reducers.js
index e14e77bb0..c691663c3 100644
--- a/src/modules/explorer/reducers.js
+++ b/src/modules/explorer/reducers.js
@@ -1,419 +1,426 @@
// https://github.com/eligrey/FileSaver.js/
import FileSaver from "file-saver";
-import {
- addDataTypeFormIfPossible,
- removeDataTypeFormIfPossible,
- updateDataTypeFormIfPossible,
-} from "../../utils/search";
+import { addDataTypeFormIfPossible, removeDataTypeFormIfPossible, updateDataTypeFormIfPossible } from "@/utils/search";
-import { readFromLocalStorage } from "../../utils/localStorageUtils";
+import { readFromLocalStorage } from "@/utils/localStorageUtils";
-import { DEFAULT_OTHER_THRESHOLD_PERCENTAGE } from "../../constants";
+import { DEFAULT_OTHER_THRESHOLD_PERCENTAGE } from "@/constants";
import {
- PERFORM_GET_GOHAN_VARIANTS_OVERVIEW,
- PERFORM_SEARCH,
- SET_IS_SUBMITTING_SEARCH,
- PERFORM_INDIVIDUAL_CSV_DOWNLOAD,
- PERFORM_BIOSAMPLE_CSV_DOWNLOAD,
- PERFORM_EXPERIMENT_CSV_DOWNLOAD,
- ADD_DATA_TYPE_QUERY_FORM,
- REMOVE_DATA_TYPE_QUERY_FORM,
- UPDATE_DATA_TYPE_QUERY_FORM,
- SET_SELECTED_ROWS,
- SET_TABLE_SORT_ORDER,
- RESET_TABLE_SORT_ORDER,
- SET_ACTIVE_TAB,
- SET_AUTO_QUERY_PAGE_TRANSITION,
- NEUTRALIZE_AUTO_QUERY_PAGE_TRANSITION,
- FREE_TEXT_SEARCH,
- SET_OTHER_THRESHOLD_PERCENTAGE,
- SET_IGV_POSITION,
- FETCH_IGV_GENOMES,
+ PERFORM_GET_GOHAN_VARIANTS_OVERVIEW,
+ PERFORM_SEARCH,
+ SET_IS_SUBMITTING_SEARCH,
+ PERFORM_INDIVIDUAL_CSV_DOWNLOAD,
+ PERFORM_BIOSAMPLE_CSV_DOWNLOAD,
+ PERFORM_EXPERIMENT_CSV_DOWNLOAD,
+ ADD_DATA_TYPE_QUERY_FORM,
+ REMOVE_DATA_TYPE_QUERY_FORM,
+ UPDATE_DATA_TYPE_QUERY_FORM,
+ SET_SELECTED_ROWS,
+ SET_TABLE_SORT_ORDER,
+ RESET_TABLE_SORT_ORDER,
+ SET_ACTIVE_TAB,
+ SET_AUTO_QUERY_PAGE_TRANSITION,
+ NEUTRALIZE_AUTO_QUERY_PAGE_TRANSITION,
+ FREE_TEXT_SEARCH,
+ SET_OTHER_THRESHOLD_PERCENTAGE,
+ SET_IGV_POSITION,
+ FETCH_IGV_GENOMES,
+ CLEAR_SEARCH,
} from "./actions";
+import { objectWithoutProp } from "@/utils/misc";
// TODO: Could this somehow be combined with discovery?
export const explorer = (
- state = {
- variantsOverviewResponse: {},
+ state = {
+ variantsOverviewResponse: {},
+ fetchingVariantsOverview: false,
+ dataTypeFormsByDatasetID: {},
+ fetchingSearchByDatasetID: {},
+ searchResultsByDatasetID: {},
+ selectedRowsByDatasetID: {},
+ tableSortOrderByDatasetID: {},
+ activeTabByDatasetID: {},
+ isFetchingDownload: false,
+ fetchingTextSearch: false,
+ isSubmittingSearch: false,
+
+ autoQuery: {
+ isAutoQuery: false,
+ },
+ otherThresholdPercentage: readFromLocalStorage("otherThresholdPercentage") ?? DEFAULT_OTHER_THRESHOLD_PERCENTAGE,
+ igvPosition: undefined,
+ },
+ action,
+) => {
+ switch (action.type) {
+ case PERFORM_GET_GOHAN_VARIANTS_OVERVIEW.RECEIVE:
+ return {
+ ...state,
+ variantsOverviewResponse: action.data,
+ };
+ case PERFORM_GET_GOHAN_VARIANTS_OVERVIEW.REQUEST:
+ return {
+ ...state,
+ fetchingVariantsOverview: true,
+ };
+ case PERFORM_GET_GOHAN_VARIANTS_OVERVIEW.FINISH:
+ return {
+ ...state,
fetchingVariantsOverview: false,
- dataTypeFormsByDatasetID: {},
- fetchingSearchByDatasetID: {},
- searchResultsByDatasetID: {},
- selectedRowsByDatasetID: {},
- tableSortOrderByDatasetID: {},
- activeTabByDatasetID: {},
+ };
+ case PERFORM_SEARCH.REQUEST:
+ return {
+ ...state,
+ fetchingSearchByDatasetID: {
+ ...state.fetchingSearchByDatasetID,
+ [action.datasetID]: true,
+ },
+ };
+ case PERFORM_SEARCH.RECEIVE:
+ return {
+ ...state,
+ searchResultsByDatasetID: {
+ ...state.searchResultsByDatasetID,
+ [action.datasetID]: {
+ results: action.data,
+ searchFormattedResults: tableSearchResults(action.data),
+ searchFormattedResultsExperiment: tableSearchResultsExperiments(action.data),
+ searchFormattedResultsBiosamples: generateBiosampleObjects(action.data),
+ },
+ },
+ selectedRowsByDatasetID: {
+ ...state.selectedRowsByDatasetID,
+ [action.datasetID]: [],
+ },
+ };
+ case PERFORM_SEARCH.FINISH:
+ return {
+ ...state,
+ fetchingSearchByDatasetID: {
+ ...state.fetchingSearchByDatasetID,
+ [action.datasetID]: false,
+ },
+ };
+
+ case SET_IS_SUBMITTING_SEARCH:
+ return {
+ ...state,
+ isSubmittingSearch: action.isSubmittingSearch,
+ };
+
+ case CLEAR_SEARCH:
+ return {
+ ...state,
+ searchResultsByDatasetID: objectWithoutProp(state.searchResultsByDatasetID, action.datasetID),
+ selectedRowsByDatasetID: { ...state.selectedRowsByDatasetID, [action.datasetID]: [] },
+ };
+
+ case PERFORM_INDIVIDUAL_CSV_DOWNLOAD.REQUEST:
+ return {
+ ...state,
+ isFetchingDownload: true,
+ };
+ case PERFORM_INDIVIDUAL_CSV_DOWNLOAD.RECEIVE:
+ FileSaver.saveAs(action.data, "data.csv"); //new Blob([data], {type: "application/octet-stream"})
+
+ return {
+ ...state,
isFetchingDownload: false,
- fetchingTextSearch: false,
- isSubmittingSearch: false,
-
- autoQuery: {
- isAutoQuery: false,
+ };
+ case PERFORM_INDIVIDUAL_CSV_DOWNLOAD.FINISH:
+ return {
+ ...state,
+ isFetchingDownload: false,
+ };
+
+ case PERFORM_BIOSAMPLE_CSV_DOWNLOAD.REQUEST:
+ return {
+ ...state,
+ isFetchingDownload: true,
+ };
+ case PERFORM_BIOSAMPLE_CSV_DOWNLOAD.RECEIVE:
+ FileSaver.saveAs(action.data, "biosamples.csv");
+
+ return {
+ ...state,
+ isFetchingDownload: false,
+ };
+ case PERFORM_BIOSAMPLE_CSV_DOWNLOAD.FINISH:
+ return {
+ ...state,
+ isFetchingDownload: false,
+ };
+
+ case PERFORM_EXPERIMENT_CSV_DOWNLOAD.REQUEST:
+ return {
+ ...state,
+ isFetchingDownload: true,
+ };
+ case PERFORM_EXPERIMENT_CSV_DOWNLOAD.RECEIVE:
+ FileSaver.saveAs(action.data, "experiments.csv");
+
+ return {
+ ...state,
+ isFetchingDownload: false,
+ };
+ case PERFORM_EXPERIMENT_CSV_DOWNLOAD.FINISH:
+ return {
+ ...state,
+ isFetchingDownload: false,
+ };
+
+ case ADD_DATA_TYPE_QUERY_FORM:
+ return {
+ ...state,
+ dataTypeFormsByDatasetID: {
+ ...state.dataTypeFormsByDatasetID,
+ [action.datasetID]: addDataTypeFormIfPossible(
+ state.dataTypeFormsByDatasetID[action.datasetID] || [],
+ action.dataType,
+ ),
},
- otherThresholdPercentage:
- readFromLocalStorage("otherThresholdPercentage") ?? DEFAULT_OTHER_THRESHOLD_PERCENTAGE,
- igvPosition: undefined,
- },
- action,
-) => {
- switch (action.type) {
- case PERFORM_GET_GOHAN_VARIANTS_OVERVIEW.RECEIVE:
- return {
- ...state,
- variantsOverviewResponse: action.data,
- };
- case PERFORM_GET_GOHAN_VARIANTS_OVERVIEW.REQUEST:
- return {
- ...state,
- fetchingVariantsOverview: true,
- };
- case PERFORM_GET_GOHAN_VARIANTS_OVERVIEW.FINISH:
- return {
- ...state,
- fetchingVariantsOverview: false,
- };
- case PERFORM_SEARCH.REQUEST:
- return {
- ...state,
- fetchingSearchByDatasetID: {
- ...state.fetchingSearchByDatasetID,
- [action.datasetID]: true,
- },
- };
- case PERFORM_SEARCH.RECEIVE:
- return {
- ...state,
- searchResultsByDatasetID: {
- ...state.searchResultsByDatasetID,
- [action.datasetID]: {
- results: action.data,
- searchFormattedResults: tableSearchResults(action.data),
- searchFormattedResultsExperiment: tableSearchResultsExperiments(action.data),
- searchFormattedResultsBiosamples: generateBiosampleObjects(action.data),
- },
- },
- selectedRowsByDatasetID: {
- ...state.selectedRowsByDatasetID,
- [action.datasetID]: [],
- },
- };
- case PERFORM_SEARCH.FINISH:
- return {
- ...state,
- fetchingSearchByDatasetID: {
- ...state.fetchingSearchByDatasetID,
- [action.datasetID]: false,
- },
- };
-
- case SET_IS_SUBMITTING_SEARCH:
- return {
- ...state,
- isSubmittingSearch: action.isSubmittingSearch,
- };
-
- case PERFORM_INDIVIDUAL_CSV_DOWNLOAD.REQUEST:
- return {
- ...state,
- isFetchingDownload: true,
- };
- case PERFORM_INDIVIDUAL_CSV_DOWNLOAD.RECEIVE:
- FileSaver.saveAs(action.data, "data.csv"); //new Blob([data], {type: "application/octet-stream"})
-
- return {
- ...state,
- isFetchingDownload: false,
- };
- case PERFORM_INDIVIDUAL_CSV_DOWNLOAD.FINISH:
- return {
- ...state,
- isFetchingDownload: false,
- };
-
- case PERFORM_BIOSAMPLE_CSV_DOWNLOAD.REQUEST:
- return {
- ...state,
- isFetchingDownload: true,
- };
- case PERFORM_BIOSAMPLE_CSV_DOWNLOAD.RECEIVE:
- FileSaver.saveAs(action.data, "biosamples.csv");
-
- return {
- ...state,
- isFetchingDownload: false,
- };
- case PERFORM_BIOSAMPLE_CSV_DOWNLOAD.FINISH:
- return {
- ...state,
- isFetchingDownload: false,
- };
-
- case PERFORM_EXPERIMENT_CSV_DOWNLOAD.REQUEST:
- return {
- ...state,
- isFetchingDownload: true,
- };
- case PERFORM_EXPERIMENT_CSV_DOWNLOAD.RECEIVE:
- FileSaver.saveAs(action.data, "experiments.csv");
-
- return {
- ...state,
- isFetchingDownload: false,
- };
- case PERFORM_EXPERIMENT_CSV_DOWNLOAD.FINISH:
- return {
- ...state,
- isFetchingDownload: false,
- };
-
- case ADD_DATA_TYPE_QUERY_FORM:
- return {
- ...state,
- dataTypeFormsByDatasetID: {
- ...state.dataTypeFormsByDatasetID,
- [action.datasetID]: addDataTypeFormIfPossible(
- state.dataTypeFormsByDatasetID[action.datasetID] || [],
- action.dataType,
- ),
- },
- };
- case UPDATE_DATA_TYPE_QUERY_FORM:
- return {
- ...state,
- dataTypeFormsByDatasetID: {
- ...state.dataTypeFormsByDatasetID,
- [action.datasetID]: updateDataTypeFormIfPossible(
- state.dataTypeFormsByDatasetID[action.datasetID] || [],
- action.dataType,
- action.fields,
- ),
- },
- };
- case REMOVE_DATA_TYPE_QUERY_FORM:
- return {
- ...state,
- dataTypeFormsByDatasetID: {
- ...state.dataTypeFormsByDatasetID,
- [action.datasetID]: removeDataTypeFormIfPossible(
- state.dataTypeFormsByDatasetID[action.datasetID] || [],
- action.dataType,
- ),
- },
- };
-
- case SET_SELECTED_ROWS:
- return {
- ...state,
- selectedRowsByDatasetID: {
- ...state.selectedRowsByDatasetID,
- [action.datasetID]: action.selectedRows,
- },
- };
-
- case SET_TABLE_SORT_ORDER:
- return {
- ...state,
- tableSortOrderByDatasetID: {
- ...state.tableSortOrderByDatasetID,
- [action.datasetID]: {
- ...state.tableSortOrderByDatasetID[action.datasetID],
- [action.activeTab]: {
- sortColumnKey: action.sortColumnKey,
- sortOrder: action.sortOrder,
- currentPage: action.currentPage,
- },
- },
- },
- };
-
- case RESET_TABLE_SORT_ORDER: {
- const updatedTableSortOrder = { ...state.tableSortOrderByDatasetID };
- delete updatedTableSortOrder[action.datasetID];
-
- return {
- ...state,
- tableSortOrderByDatasetID: updatedTableSortOrder,
- };
- }
+ };
+ case UPDATE_DATA_TYPE_QUERY_FORM:
+ return {
+ ...state,
+ dataTypeFormsByDatasetID: {
+ ...state.dataTypeFormsByDatasetID,
+ [action.datasetID]: updateDataTypeFormIfPossible(
+ state.dataTypeFormsByDatasetID[action.datasetID] || [],
+ action.dataType,
+ action.fields,
+ ),
+ },
+ };
+ case REMOVE_DATA_TYPE_QUERY_FORM:
+ return {
+ ...state,
+ dataTypeFormsByDatasetID: {
+ ...state.dataTypeFormsByDatasetID,
+ [action.datasetID]: removeDataTypeFormIfPossible(
+ state.dataTypeFormsByDatasetID[action.datasetID] || [],
+ action.dataType,
+ ),
+ },
+ };
+
+ case SET_SELECTED_ROWS:
+ return {
+ ...state,
+ selectedRowsByDatasetID: {
+ ...state.selectedRowsByDatasetID,
+ [action.datasetID]: action.selectedRows,
+ },
+ };
+
+ case SET_TABLE_SORT_ORDER:
+ return {
+ ...state,
+ tableSortOrderByDatasetID: {
+ ...state.tableSortOrderByDatasetID,
+ [action.datasetID]: {
+ ...state.tableSortOrderByDatasetID[action.datasetID],
+ [action.activeTab]: {
+ sortColumnKey: action.sortColumnKey,
+ sortOrder: action.sortOrder,
+ currentPage: action.currentPage,
+ },
+ },
+ },
+ };
- case SET_ACTIVE_TAB: {
- return {
- ...state,
- activeTabByDatasetID: {
- ...state.activeTabByDatasetID,
- [action.datasetID]: action.activeTab,
- },
- };
+ case RESET_TABLE_SORT_ORDER: {
+ const updatedTableSortOrder = { ...state.tableSortOrderByDatasetID };
+ delete updatedTableSortOrder[action.datasetID];
- }
+ return {
+ ...state,
+ tableSortOrderByDatasetID: updatedTableSortOrder,
+ };
+ }
- // Auto-Queries start here ----
- case SET_AUTO_QUERY_PAGE_TRANSITION:
- return {
- ...state,
- autoQuery: {
- isAutoQuery: true,
- pageUrlBeforeAutoQuery: action.pageUrlBeforeAutoQuery,
- autoQueryType: action.autoQueryType,
- autoQueryField: action.autoQueryField,
- autoQueryValue: action.autoQueryValue,
- },
- };
-
- case NEUTRALIZE_AUTO_QUERY_PAGE_TRANSITION:
- return {
- ...state,
- autoQuery: {
- isAutoQuery: false,
- pageUrlBeforeAutoQuery: undefined,
- autoQueryType: undefined,
- autoQueryField: undefined,
- autoQueryValue: undefined,
- },
- };
- //.. and end here.. ----
-
- // free-text search
- case FREE_TEXT_SEARCH.REQUEST:
- return {
- ...state,
- fetchingTextSearch: true,
- };
- case FREE_TEXT_SEARCH.RECEIVE:
- return {
- ...state,
- searchResultsByDatasetID: {
- ...state.searchResultsByDatasetID,
- [action.datasetID]: {
- results: freeTextResults(action.data),
- searchFormattedResults: tableSearchResults(action.data),
- searchFormattedResultsExperiments: tableSearchResultsExperiments(action.data),
- searchFormattedResultsBiosamples: generateBiosampleObjects(action.data),
- },
- },
- };
- case FREE_TEXT_SEARCH.FINISH:
- return {
- ...state,
- fetchingTextSearch: false,
- };
- case SET_OTHER_THRESHOLD_PERCENTAGE:
- return {
- ...state,
- otherThresholdPercentage: action.otherThresholdPercentage,
- };
- case SET_IGV_POSITION:
- return {
- ...state,
- igvPosition: action.igvPosition,
- };
- default:
- return state;
+ case SET_ACTIVE_TAB: {
+ return {
+ ...state,
+ activeTabByDatasetID: {
+ ...state.activeTabByDatasetID,
+ [action.datasetID]: action.activeTab,
+ },
+ };
}
+
+ // Auto-Queries start here ----
+ case SET_AUTO_QUERY_PAGE_TRANSITION:
+ return {
+ ...state,
+ autoQuery: {
+ isAutoQuery: true,
+ pageUrlBeforeAutoQuery: action.pageUrlBeforeAutoQuery,
+ autoQueryType: action.autoQueryType,
+ autoQueryField: action.autoQueryField,
+ autoQueryValue: action.autoQueryValue,
+ },
+ };
+
+ case NEUTRALIZE_AUTO_QUERY_PAGE_TRANSITION:
+ return {
+ ...state,
+ autoQuery: {
+ isAutoQuery: false,
+ pageUrlBeforeAutoQuery: undefined,
+ autoQueryType: undefined,
+ autoQueryField: undefined,
+ autoQueryValue: undefined,
+ },
+ };
+ //.. and end here.. ----
+
+ // free-text search
+ case FREE_TEXT_SEARCH.REQUEST:
+ return {
+ ...state,
+ fetchingTextSearch: true,
+ };
+ case FREE_TEXT_SEARCH.RECEIVE:
+ return {
+ ...state,
+ searchResultsByDatasetID: {
+ ...state.searchResultsByDatasetID,
+ [action.datasetID]: {
+ results: freeTextResults(action.data),
+ searchFormattedResults: tableSearchResults(action.data),
+ searchFormattedResultsExperiment: tableSearchResultsExperiments(action.data),
+ searchFormattedResultsBiosamples: generateBiosampleObjects(action.data),
+ },
+ },
+ };
+ case FREE_TEXT_SEARCH.FINISH:
+ return {
+ ...state,
+ fetchingTextSearch: false,
+ };
+ case SET_OTHER_THRESHOLD_PERCENTAGE:
+ return {
+ ...state,
+ otherThresholdPercentage: action.otherThresholdPercentage,
+ };
+ case SET_IGV_POSITION:
+ return {
+ ...state,
+ igvPosition: action.igvPosition,
+ };
+ default:
+ return state;
+ }
};
-export const igvGenomes = (state = { data: null, isFetching: false, hasAttempted: false }, action) => {
- switch (action.type) {
- case FETCH_IGV_GENOMES.REQUEST:
- return {...state, isFetching: true};
- case FETCH_IGV_GENOMES.RECEIVE:
- return {...state, data: action.data};
- case FETCH_IGV_GENOMES.FINISH:
- return {...state, isFetching: false, hasAttempted: true};
- default:
- return state;
- }
+export const igvGenomes = (state = { items: [], itemsByID: {}, isFetching: false, hasAttempted: false }, action) => {
+ switch (action.type) {
+ case FETCH_IGV_GENOMES.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_IGV_GENOMES.RECEIVE:
+ return {
+ ...state,
+ items: action.data ?? [],
+ itemsByID: Object.fromEntries((action.data ?? []).map((g) => [g.id, g])),
+ };
+ case FETCH_IGV_GENOMES.FINISH:
+ return { ...state, isFetching: false, hasAttempted: true };
+ default:
+ return state;
+ }
};
// helpers
const tableSearchResultsExperiments = (searchResults) => {
- const results = searchResults.results || [];
+ const results = searchResults.results || [];
- return results.flatMap((result) => {
- if (!result.experiments_with_biosamples) {
- return [];
- }
+ return results.flatMap((result) => {
+ if (!result.experiments_with_biosamples) {
+ return [];
+ }
- return result.experiments_with_biosamples.flatMap((sample) => {
- const experiment = sample.experiment;
- if (!experiment || experiment.experiment_id === null) {
- return [];
- }
-
- return {
- subjectId: result.subject_id,
- key: experiment.experiment_id,
- alternateIds: result.alternate_ids,
- experimentId: experiment.experiment_id,
- experimentType: experiment.experiment_type,
- studyType: experiment.study_type,
- biosampleId: sample.biosample_id,
- individual: {
- id: result.subject_id,
- alternate_ids: result.alternate_ids ?? [],
- },
- };
- });
+ return result.experiments_with_biosamples.flatMap((sample) => {
+ const experiment = sample.experiment;
+ if (!experiment || experiment.experiment_id === null) {
+ return [];
+ }
+
+ return {
+ subjectId: result.subject_id,
+ key: experiment.experiment_id,
+ alternateIds: result.alternate_ids,
+ experimentId: experiment.experiment_id,
+ experimentType: experiment.experiment_type,
+ studyType: experiment.study_type,
+ biosampleId: sample.biosample_id,
+ individual: {
+ id: result.subject_id,
+ alternate_ids: result.alternate_ids ?? [],
+ },
+ };
});
+ });
};
function generateBiosampleObjects(searchResults) {
- return (searchResults?.results ?? [])
- .flatMap((result) => {
- if (!result["experiments_with_biosamples"]) {
- return [];
- }
-
- const biosampleIdToIndex = {};
-
- return result["experiments_with_biosamples"].reduce((objects, biosample) => {
- const biosampleId = biosample["biosample_id"];
-
- if (biosampleId) {
- const index =
- biosampleId in biosampleIdToIndex
- ? biosampleIdToIndex[biosampleId]
- : (biosampleIdToIndex[biosampleId] = objects.length);
-
- objects[index] = objects[index] || {
- subjectId: result["subject_id"],
- key: biosampleId,
- biosample: biosampleId,
- alternateIds: result["alternate_ids"],
- individual: {
- id: result["subject_id"],
- alternateIds: result["alternate_ids"] || [],
- },
- experimentIds: [],
- experimentTypes: [],
- studyTypes: [],
- sampledTissue: biosample["sampled_tissue"],
- };
- objects[index].experimentIds.push(biosample.experiment["experiment_id"]);
- objects[index].experimentTypes.push(biosample.experiment["experiment_type"]);
- objects[index].studyTypes.push(biosample.experiment["study_type"]);
- }
- return objects;
- }, []);
- })
- .filter((entry) => entry.key !== null && entry.key !== undefined);
+ return (searchResults?.results ?? [])
+ .flatMap((result) => {
+ if (!result["experiments_with_biosamples"]) {
+ return [];
+ }
+
+ const biosampleIdToIndex = {};
+
+ return result["experiments_with_biosamples"].reduce((objects, biosample) => {
+ const biosampleId = biosample["biosample_id"];
+
+ if (biosampleId) {
+ const index =
+ biosampleId in biosampleIdToIndex
+ ? biosampleIdToIndex[biosampleId]
+ : (biosampleIdToIndex[biosampleId] = objects.length);
+
+ objects[index] = objects[index] || {
+ subjectId: result["subject_id"],
+ key: biosampleId,
+ biosample: biosampleId,
+ alternateIds: result["alternate_ids"],
+ individual: {
+ id: result["subject_id"],
+ alternateIds: result["alternate_ids"] || [],
+ },
+ experimentIds: [],
+ experimentTypes: [],
+ studyTypes: [],
+ sampledTissue: biosample["sampled_tissue"],
+ };
+ objects[index].experimentIds.push(biosample.experiment["experiment_id"]);
+ objects[index].experimentTypes.push(biosample.experiment["experiment_type"]);
+ objects[index].studyTypes.push(biosample.experiment["study_type"]);
+ }
+ return objects;
+ }, []);
+ })
+ .filter((entry) => entry.key !== null && entry.key !== undefined);
}
const tableSearchResults = (searchResults) => {
- const results = (searchResults || {}).results || [];
+ const results = (searchResults || {}).results || [];
- return results.map((p) => {
- return {
- key: p.subject_id,
- individual: {
- id: p.subject_id,
- alternate_ids: p.alternate_ids ?? [],
- },
- biosamples: p.biosamples,
- experiments: p.num_experiments,
- };
- });
+ return results.map((p) => {
+ return {
+ key: p.subject_id,
+ individual: {
+ id: p.subject_id,
+ alternate_ids: p.alternate_ids ?? [],
+ },
+ biosamples: p.biosamples,
+ experiments: p.num_experiments,
+ };
+ });
};
// free-text search helpers
@@ -423,14 +430,14 @@ const tableSearchResults = (searchResults) => {
// but free-text results are not in the same format as regular queries,
// so need their own formatting functions
function freeTextResults(_searchResults) {
- // TODO:
- // most information expected here is missing in free-text search response
- // can be added in a future katsu version
- // but all that information is ignored by bento_web except variants info, so just return that
-
- return {
- results: {
- variant: [], //TODO
- },
- };
+ // TODO:
+ // most information expected here is missing in free-text search response
+ // can be added in a future katsu version
+ // but all that information is ignored by bento_web except variants info, so just return that
+
+ return {
+ results: {
+ variant: [], //TODO
+ },
+ };
}
diff --git a/src/modules/manager/actions.js b/src/modules/manager/actions.js
index 6148125d2..d3c89dede 100644
--- a/src/modules/manager/actions.js
+++ b/src/modules/manager/actions.js
@@ -1,12 +1,6 @@
-import {message} from "antd";
-
-import {
- basicAction,
- createNetworkActionTypes,
- createFlowActionTypes,
- networkAction,
-} from "@/utils/actions";
+import { message } from "antd";
+import { basicAction, createNetworkActionTypes, createFlowActionTypes, networkAction } from "@/utils/actions";
export const TOGGLE_PROJECT_CREATION_MODAL = "TOGGLE_PROJECT_CREATION_MODAL";
@@ -20,51 +14,50 @@ export const DELETE_DROP_BOX_OBJECT = createNetworkActionTypes("DELETE_DROP_BOX_
export const DROP_BOX_PUTTING_OBJECTS = createFlowActionTypes("DROP_BOX_PUTTING_OBJECTS");
-
export const toggleProjectCreationModal = basicAction(TOGGLE_PROJECT_CREATION_MODAL);
export const beginProjectEditing = basicAction(PROJECT_EDITING.BEGIN);
export const endProjectEditing = basicAction(PROJECT_EDITING.END);
-
-export const fetchDropBoxTree = networkAction(() => (dispatch, getState) => ({
- types: FETCH_DROP_BOX_TREE,
- check: (state) => state.services.dropBoxService
- && !state.dropBox.isFetching
- && (!state.dropBox.tree.length || state.dropBox.isInvalidated),
- url: `${getState().services.dropBoxService.url}/tree`,
- err: "Error fetching drop box file tree",
+export const fetchDropBoxTree = networkAction(() => (_dispatch, getState) => ({
+ types: FETCH_DROP_BOX_TREE,
+ check: (state) =>
+ state.services.dropBoxService &&
+ !state.dropBox.isFetching &&
+ (!state.dropBox.tree.length || state.dropBox.isInvalid),
+ url: `${getState().services.dropBoxService.url}/tree`,
+ err: "Error fetching drop box file tree",
}));
export const invalidateDropBoxTree = basicAction(INVALIDATE_DROP_BOX_TREE);
const dropBoxObjectPath = (getState, path) =>
- `${getState().services.dropBoxService.url}/objects/${path.replace(/^\//, "")}`;
-
-export const putDropBoxObject = networkAction((path, file) => async (dispatch, getState) => ({
- types: PUT_DROP_BOX_OBJECT,
- url: dropBoxObjectPath(getState, path),
- req: {
- method: "PUT",
- body: await file.arrayBuffer(),
- },
- onSuccess: () => {
- message.success(`Successfully uploaded file to drop box path: ${path}`);
- },
- err: `Error uploading file to drop box path: ${path}`,
+ `${getState().services.dropBoxService.url}/objects/${path.replace(/^\//, "")}`;
+
+export const putDropBoxObject = networkAction((path, file) => async (_dispatch, getState) => ({
+ types: PUT_DROP_BOX_OBJECT,
+ url: dropBoxObjectPath(getState, path),
+ req: {
+ method: "PUT",
+ body: await file.arrayBuffer(),
+ },
+ onSuccess: () => {
+ message.success(`Successfully uploaded file to drop box path: ${path}`);
+ },
+ err: `Error uploading file to drop box path: ${path}`,
}));
export const beginDropBoxPuttingObjects = basicAction(DROP_BOX_PUTTING_OBJECTS.BEGIN);
export const endDropBoxPuttingObjects = basicAction(DROP_BOX_PUTTING_OBJECTS.END);
-export const deleteDropBoxObject = networkAction(path => async (dispatch, getState) => ({
- types: DELETE_DROP_BOX_OBJECT,
- url: dropBoxObjectPath(getState, path),
- req: {method: "DELETE"},
- onSuccess: () => {
- message.success(`Successfully deleted file at drop box path: ${path}`);
- dispatch(invalidateDropBoxTree());
- return dispatch(fetchDropBoxTree());
- },
- err: `Error deleting file at drop box path: ${path}`,
+export const deleteDropBoxObject = networkAction((path) => async (dispatch, getState) => ({
+ types: DELETE_DROP_BOX_OBJECT,
+ url: dropBoxObjectPath(getState, path),
+ req: { method: "DELETE" },
+ onSuccess: () => {
+ message.success(`Successfully deleted file at drop box path: ${path}`);
+ dispatch(invalidateDropBoxTree());
+ return dispatch(fetchDropBoxTree());
+ },
+ err: `Error deleting file at drop box path: ${path}`,
}));
diff --git a/src/modules/manager/hooks.js b/src/modules/manager/hooks.js
index 8c67b4da3..f72f2ee53 100644
--- a/src/modules/manager/hooks.js
+++ b/src/modules/manager/hooks.js
@@ -7,37 +7,35 @@ import { useService } from "@/modules/services/hooks";
import { fetchDropBoxTree } from "./actions";
import { fetchIndividual } from "@/modules/metadata/actions";
-
export const useDropBox = () => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const dropBox = useService("drop-box"); // TODO: associate this with the network action somehow
- const { hasPermission } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, viewDropBox);
+ const dropBox = useService("drop-box"); // TODO: associate this with the network action somehow
+ const { hasPermission } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, viewDropBox);
- useEffect(() => {
- // If hasPermission changes to true, this will automatically dispatch the drop box tree fetch method.
- if (hasPermission) {
- dispatch(fetchDropBoxTree()).catch((err) => console.error(err));
- }
- }, [dispatch, dropBox, hasPermission]);
+ useEffect(() => {
+ // If hasPermission changes to true, this will automatically dispatch the drop box tree fetch method.
+ if (hasPermission) {
+ dispatch(fetchDropBoxTree()).catch((err) => console.error(err));
+ }
+ }, [dispatch, dropBox, hasPermission]);
- return useSelector((state) => state.dropBox);
+ return useSelector((state) => state.dropBox);
};
-
export const useIndividual = (individualID) => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const metadataService = useService("metadata");
- const individuals = useSelector((state) => state.individuals.itemsByID);
+ const metadataService = useService("metadata");
+ const individuals = useSelector((state) => state.individuals.itemsByID);
- useEffect(() => {
- if (metadataService && individualID) {
- // If we've loaded the metadata service, and we have an individual selected (or the individual ID changed),
- // we should load individual data.
- dispatch(fetchIndividual(individualID)).catch(console.error);
- }
- }, [dispatch, metadataService, individualID]);
+ useEffect(() => {
+ if (metadataService && individualID) {
+ // If we've loaded the metadata service, and we have an individual selected (or the individual ID changed),
+ // we should load individual data.
+ dispatch(fetchIndividual(individualID)).catch(console.error);
+ }
+ }, [dispatch, metadataService, individualID]);
- return individuals[individualID];
+ return individuals[individualID];
};
diff --git a/src/modules/manager/reducers.js b/src/modules/manager/reducers.js
index ea9c3e39b..ffe30d020 100644
--- a/src/modules/manager/reducers.js
+++ b/src/modules/manager/reducers.js
@@ -1,77 +1,76 @@
import {
- TOGGLE_PROJECT_CREATION_MODAL,
- PROJECT_EDITING,
- FETCH_DROP_BOX_TREE,
- PUT_DROP_BOX_OBJECT,
- DROP_BOX_PUTTING_OBJECTS,
- DELETE_DROP_BOX_OBJECT,
- INVALIDATE_DROP_BOX_TREE,
+ TOGGLE_PROJECT_CREATION_MODAL,
+ PROJECT_EDITING,
+ FETCH_DROP_BOX_TREE,
+ PUT_DROP_BOX_OBJECT,
+ DROP_BOX_PUTTING_OBJECTS,
+ DELETE_DROP_BOX_OBJECT,
+ INVALIDATE_DROP_BOX_TREE,
} from "./actions";
-
export const manager = (
- state = {
- projectCreationModal: false,
- editingProject: false,
- jsonSchemaCreationModal: false,
- },
- action,
+ state = {
+ projectCreationModal: false,
+ editingProject: false,
+ jsonSchemaCreationModal: false,
+ },
+ action,
) => {
- switch (action.type) {
- case TOGGLE_PROJECT_CREATION_MODAL:
- return {...state, projectCreationModal: !state.projectCreationModal};
+ switch (action.type) {
+ case TOGGLE_PROJECT_CREATION_MODAL:
+ return { ...state, projectCreationModal: !state.projectCreationModal };
- case PROJECT_EDITING.BEGIN:
- return {...state, editingProject: true};
+ case PROJECT_EDITING.BEGIN:
+ return { ...state, editingProject: true };
- case PROJECT_EDITING.END:
- return {...state, editingProject: false};
+ case PROJECT_EDITING.END:
+ return { ...state, editingProject: false };
- default:
- return state;
- }
+ default:
+ return state;
+ }
};
export const dropBox = (
- state = {
- isFetching: false,
- isPutting: false,
- isPuttingFlow: false,
- isDeleting: false,
- isInvalidated: false,
- hasAttempted: false,
- tree: [],
- },
- action,
+ state = {
+ isFetching: false,
+ isPutting: false,
+ isPuttingFlow: false,
+ isDeleting: false,
+ isInvalid: false,
+ hasAttempted: false,
+ tree: [],
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_DROP_BOX_TREE.REQUEST:
- return { ...state, isFetching: true };
- case FETCH_DROP_BOX_TREE.RECEIVE:
- return { ...state, tree: action.data };
- case FETCH_DROP_BOX_TREE.FINISH:
- return { ...state, isFetching: false, hasAttempted: true, isInvalidated: false };
+ switch (action.type) {
+ case FETCH_DROP_BOX_TREE.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_DROP_BOX_TREE.RECEIVE:
+ return { ...state, tree: action.data };
+ case FETCH_DROP_BOX_TREE.FINISH:
+ return { ...state, isFetching: false, hasAttempted: true, isInvalid: false };
- case INVALIDATE_DROP_BOX_TREE:
- return { ...state, isInvalidated: true };
+ case INVALIDATE_DROP_BOX_TREE:
+ return { ...state, isInvalid: true };
- case PUT_DROP_BOX_OBJECT.REQUEST:
- return { ...state, isPutting: true };
- case PUT_DROP_BOX_OBJECT.FINISH:
- return { ...state, isPutting: false };
+ case PUT_DROP_BOX_OBJECT.REQUEST:
+ return { ...state, isPutting: true };
+ case PUT_DROP_BOX_OBJECT.FINISH:
+ return { ...state, isPutting: false };
- case DROP_BOX_PUTTING_OBJECTS.BEGIN:
- return { ...state, isPuttingFlow: true };
- case DROP_BOX_PUTTING_OBJECTS.END:
- case DROP_BOX_PUTTING_OBJECTS.TERMINATE:
- return { ...state, isPuttingFlow: false };
+ case DROP_BOX_PUTTING_OBJECTS.BEGIN:
+ return { ...state, isPuttingFlow: true };
+ case DROP_BOX_PUTTING_OBJECTS.END:
+ case DROP_BOX_PUTTING_OBJECTS.TERMINATE:
+ return { ...state, isPuttingFlow: false };
- case DELETE_DROP_BOX_OBJECT.REQUEST:
- return { ...state, isDeleting: true };
- case DELETE_DROP_BOX_OBJECT.FINISH:
- return { ...state, isDeleting: false };
+ case DELETE_DROP_BOX_OBJECT.REQUEST:
+ return { ...state, isDeleting: true };
+ case DELETE_DROP_BOX_OBJECT.FINISH:
+ return { ...state, isDeleting: false };
- default:
- return state;
- }
+ default:
+ return state;
+ }
};
diff --git a/src/modules/metadata/actions.js b/src/modules/metadata/actions.js
index 63cd94654..52f72eeb4 100644
--- a/src/modules/metadata/actions.js
+++ b/src/modules/metadata/actions.js
@@ -28,307 +28,309 @@ export const FETCH_OVERVIEW_SUMMARY = createNetworkActionTypes("FETCH_OVERVIEW_S
export const DELETE_DATASET_DATA_TYPE = createNetworkActionTypes("DELETE_DATASET_DATA_TYPE");
-export const clearDatasetDataType = networkAction((datasetId, dataTypeID) => (dispatch, getState) => {
- const { service_base_url: serviceBaseUrl } = getState().serviceDataTypes.itemsByID[dataTypeID];
- return {
- types: DELETE_DATASET_DATA_TYPE,
- url: `${serviceBaseUrl}datasets/${datasetId}/data-types/${dataTypeID}`,
- req: {
- method: "DELETE",
- },
- onError: (error) => {
- // Needs to re throw for project/dataset deletion error handling
- throw error;
- },
- };
+export const clearDatasetDataType = networkAction((datasetId, dataTypeID) => (_dispatch, getState) => {
+ const { service_base_url: serviceBaseUrl } = getState().serviceDataTypes.itemsByID[dataTypeID];
+ // noinspection JSUnusedGlobalSymbols
+ return {
+ types: DELETE_DATASET_DATA_TYPE,
+ url: `${serviceBaseUrl}datasets/${datasetId}/data-types/${dataTypeID}`,
+ req: {
+ method: "DELETE",
+ },
+ onError: (error) => {
+ // Needs to re throw for project/dataset deletion error handling
+ throw error;
+ },
+ };
});
-const fetchProjects = networkAction(() => (dispatch, getState) => ({
- types: FETCH_PROJECTS,
- url: `${getState().services.metadataService.url}/api/projects`,
- paginated: true,
- err: "Error fetching projects",
+const fetchProjects = networkAction(() => (_dispatch, getState) => ({
+ types: FETCH_PROJECTS,
+ url: `${getState().services.metadataService.url}/api/projects`,
+ publicEndpoint: true,
+ paginated: true,
+ err: "Error fetching projects",
}));
// TODO: if needed fetching + invalidation
export const fetchProjectsWithDatasets = () => (dispatch, getState) => {
- const state = getState();
+ const state = getState();
- if (!state.services.itemsByKind.metadata) return Promise.resolve();
- if (state.projects.isFetching || state.projects.isCreating || state.projects.isDeleting || state.projects.isSaving)
- return Promise.resolve();
+ if (!state.services.itemsByKind.metadata) return Promise.resolve();
+ if (state.projects.isFetching || state.projects.isCreating || state.projects.isDeleting || state.projects.isSaving)
+ return Promise.resolve();
- return dispatch(fetchProjects());
+ return dispatch(fetchProjects());
};
-const createProject = networkAction((project, navigate) => (dispatch, getState) => ({
- types: CREATE_PROJECT,
- url: `${getState().services.metadataService.url}/api/projects`,
- req: jsonRequest(project, "POST"),
- err: "Error creating project",
- onSuccess: (data) => {
- if (navigate) navigate(`/data/manager/projects/${data.identifier}`);
- message.success(`Project '${data.title}' created!`);
- },
+const createProject = networkAction((project, navigate) => (_dispatch, getState) => ({
+ types: CREATE_PROJECT,
+ url: `${getState().services.metadataService.url}/api/projects`,
+ req: jsonRequest(project, "POST"),
+ err: "Error creating project",
+ onSuccess: (data) => {
+ if (navigate) navigate(`/data/manager/projects/${data.identifier}`);
+ message.success(`Project '${data.title}' created!`);
+ },
}));
export const createProjectIfPossible = (project, navigate) => (dispatch, getState) => {
- // TODO: Need object response from POST (is this done??)
- if (getState().projects.isCreating) return Promise.resolve();
- return dispatch(createProject(project, navigate));
+ // TODO: Need object response from POST (is this done??)
+ if (getState().projects.isCreating) return Promise.resolve();
+ return dispatch(createProject(project, navigate));
};
-const _fetchExtraPropertiesSchemaTypes = networkAction(() => (dispatch, getState) => ({
- types: FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES,
- url: `${getState().services.metadataService.url}/api/extra_properties_schema_types`,
- error: "Error fetching extra properties schema types",
+const _fetchExtraPropertiesSchemaTypes = networkAction(() => (_dispatch, getState) => ({
+ types: FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES,
+ url: `${getState().services.metadataService.url}/api/extra_properties_schema_types`,
+ error: "Error fetching extra properties schema types",
}));
export const fetchExtraPropertiesSchemaTypes = () => (dispatch, getState) => {
- if (!getState().services.itemsByKind.metadata) return Promise.resolve();
- return dispatch(_fetchExtraPropertiesSchemaTypes());
+ if (!getState().services.itemsByKind.metadata) return Promise.resolve();
+ return dispatch(_fetchExtraPropertiesSchemaTypes());
};
-export const createProjectJsonSchema = networkAction((projectJsonSchema) => (dispatch, getState) => ({
- types: CREATE_PROJECT_JSON_SCHEMA,
- check: (state) => !state.projects.isCreatingJsonSchema,
- url: `${getState().services.metadataService.url}/api/project_json_schemas`,
- req: jsonRequest(projectJsonSchema, "POST"),
- err: "Error creating project JSON schema",
- onSuccess: () => {
- message.success(`Project JSON schema for ${projectJsonSchema.schema_type} created!`);
- },
+export const createProjectJsonSchema = networkAction((projectJsonSchema) => (_dispatch, getState) => ({
+ types: CREATE_PROJECT_JSON_SCHEMA,
+ check: (state) => !state.projects.isCreatingJsonSchema,
+ url: `${getState().services.metadataService.url}/api/project_json_schemas`,
+ req: jsonRequest(projectJsonSchema, "POST"),
+ err: "Error creating project JSON schema",
+ onSuccess: () => {
+ message.success(`Project JSON schema for ${projectJsonSchema.schema_type} created!`);
+ },
}));
-export const deleteProjectJsonSchema = networkAction((projectJsonSchema) => (dispatch, getState) => ({
- types: DELETE_PROJECT_JSON_SCHEMA,
- params: { projectJsonSchema },
- url: `${getState().services.metadataService.url}/api/project_json_schemas/${projectJsonSchema.id}`,
- req: jsonRequest(projectJsonSchema, "DELETE"),
- err: "Error while deleting project JSON schema",
- onSuccess: () => {
- message.success(`Project JSON schema for ${projectJsonSchema.schema_type} was deleted!`);
- },
+export const deleteProjectJsonSchema = networkAction((projectJsonSchema) => (_dispatch, getState) => ({
+ types: DELETE_PROJECT_JSON_SCHEMA,
+ params: { projectJsonSchema },
+ url: `${getState().services.metadataService.url}/api/project_json_schemas/${projectJsonSchema.id}`,
+ req: jsonRequest(projectJsonSchema, "DELETE"),
+ err: "Error while deleting project JSON schema",
+ onSuccess: () => {
+ message.success(`Project JSON schema for ${projectJsonSchema.schema_type} was deleted!`);
+ },
}));
-export const deleteProject = networkAction((project) => (dispatch, getState) => ({
- types: DELETE_PROJECT,
- params: { project },
- url: `${getState().services.metadataService.url}/api/projects/${project.identifier}`,
- req: { method: "DELETE" },
- err: `Error deleting project '${project.title}'`, // TODO: More user-friendly, detailed error
- onSuccess: () => message.success(`Project '${project.title}' deleted!`),
+export const deleteProject = networkAction((project) => (_dispatch, getState) => ({
+ types: DELETE_PROJECT,
+ params: { project },
+ url: `${getState().services.metadataService.url}/api/projects/${project.identifier}`,
+ req: { method: "DELETE" },
+ err: `Error deleting project '${project.title}'`, // TODO: More user-friendly, detailed error
+ onSuccess: () => message.success(`Project '${project.title}' deleted!`),
}));
export const deleteProjectIfPossible = (project) => async (dispatch, getState) => {
- if (getState().projects.isDeleting) return;
-
- // Remove data without destroying project/datasets first
- try {
- await Promise.all(project.datasets.map((ds) => dispatch(clearDatasetDataTypes(ds.identifier))));
- await dispatch(deleteProject(project));
- } catch (err) {
- console.error(err);
- message.error(`Error deleting project '${project.title}'`);
- }
+ if (getState().projects.isDeleting) return;
+
+ // Remove data without destroying project/datasets first
+ try {
+ await Promise.all(project.datasets.map((ds) => dispatch(clearDatasetDataTypes(ds.identifier))));
+ await dispatch(deleteProject(project));
+ } catch (err) {
+ console.error(err);
+ message.error(`Error deleting project '${project.title}'`);
+ }
};
export const clearDatasetDataTypes = (datasetId) => async (dispatch, getState) => {
- // only clear data types which can yield counts - `queryable` is a proxy for this
- const dataTypes = Object.values(getState().datasetDataTypes.itemsByID[datasetId].itemsByID).filter(
- (dt) => dt.queryable,
- );
- return await Promise.all(dataTypes.map((dt) => dispatch(clearDatasetDataType(datasetId, dt.id))));
+ // only clear data types which can yield counts - `queryable` is a proxy for this
+ const dataTypes = Object.values(getState().datasetDataTypes.itemsByID[datasetId].itemsByID).filter(
+ (dt) => dt.queryable,
+ );
+ return await Promise.all(dataTypes.map((dt) => dispatch(clearDatasetDataType(datasetId, dt.id))));
};
const saveProject = networkAction((project) => (dispatch, getState) => ({
- types: SAVE_PROJECT,
- url: `${getState().services.metadataService.url}/api/projects/${project.identifier}`,
- req: jsonRequest(project, "PUT"),
- err: `Error saving project '${project.title}'`, // TODO: More user-friendly error
- onSuccess: () => {
- dispatch(endProjectEditing());
- message.success(`Project '${project.title}' saved!`);
- },
+ types: SAVE_PROJECT,
+ url: `${getState().services.metadataService.url}/api/projects/${project.identifier}`,
+ req: jsonRequest(project, "PUT"),
+ err: `Error saving project '${project.title}'`, // TODO: More user-friendly error
+ onSuccess: () => {
+ dispatch(endProjectEditing());
+ message.success(`Project '${project.title}' saved!`);
+ },
}));
export const saveProjectIfPossible = (project) => (dispatch, getState) => {
- if (getState().projects.isDeleting || getState().projects.isSaving) return;
- return dispatch(saveProject(project));
+ if (getState().projects.isDeleting || getState().projects.isSaving) return;
+ return dispatch(saveProject(project));
};
-export const addProjectDataset = networkAction((project, dataset, onSuccess = nop) => (dispatch, getState) => ({
- types: ADD_PROJECT_DATASET,
- url: `${getState().services.metadataService.url}/api/datasets`,
- req: jsonRequest({ ...dataset, project: project.identifier }, "POST"),
- err: `Error adding dataset to project '${project.title}'`, // TODO: More user-friendly error
- // TODO: END ACTION?
- onSuccess: async () => {
- await onSuccess();
- message.success(`Added dataset '${dataset.title}' to project ${project.title}!`);
- },
+export const addProjectDataset = networkAction((project, dataset, onSuccess = nop) => (_dispatch, getState) => ({
+ types: ADD_PROJECT_DATASET,
+ url: `${getState().services.metadataService.url}/api/datasets`,
+ req: jsonRequest({ ...dataset, project: project.identifier }, "POST"),
+ err: `Error adding dataset to project '${project.title}'`, // TODO: More user-friendly error
+ // TODO: END ACTION?
+ onSuccess: async () => {
+ await onSuccess();
+ message.success(`Added dataset '${dataset.title}' to project ${project.title}!`);
+ },
}));
-export const saveProjectDataset = networkAction((dataset, onSuccess = nop) => (dispatch, getState) => ({
- types: SAVE_PROJECT_DATASET,
- url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
- // Filter out read-only props
- // TODO: PATCH
- req: jsonRequest(objectWithoutProps(dataset, ["identifier", "created", "updated"]), "PUT"),
- err: `Error saving dataset '${dataset.title}'`,
- onSuccess: async () => {
- await onSuccess();
- message.success(`Saved dataset '${dataset.title}'`);
- },
+export const saveProjectDataset = networkAction((dataset, onSuccess = nop) => (_dispatch, getState) => ({
+ types: SAVE_PROJECT_DATASET,
+ url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
+ // Filter out read-only props
+ // TODO: PATCH
+ req: jsonRequest(objectWithoutProps(dataset, ["identifier", "created", "updated"]), "PUT"),
+ err: `Error saving dataset '${dataset.title}'`,
+ onSuccess: async () => {
+ await onSuccess();
+ message.success(`Saved dataset '${dataset.title}'`);
+ },
}));
-export const deleteProjectDataset = networkAction((project, dataset) => (dispatch, getState) => ({
- types: DELETE_PROJECT_DATASET,
- params: { project, dataset },
- url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
- req: { method: "DELETE" },
- err: `Error deleting dataset '${dataset.title}'`,
+export const deleteProjectDataset = networkAction((project, dataset) => (_dispatch, getState) => ({
+ types: DELETE_PROJECT_DATASET,
+ params: { project, dataset },
+ url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
+ req: { method: "DELETE" },
+ err: `Error deleting dataset '${dataset.title}'`,
}));
export const deleteProjectDatasetIfPossible = (project, dataset) => async (dispatch, getState) => {
- if (
- getState().projects.isAddingDataset ||
- getState().projects.isSavingDataset ||
- getState().projects.isDeletingDataset
- )
- return;
- try {
- await dispatch(clearDatasetDataTypes(dataset.identifier));
- await dispatch(deleteProjectDataset(project, dataset));
- } catch (err) {
- console.error(err);
- message.error(`Error deleting dataset '${dataset.title}'`);
- }
+ if (
+ getState().projects.isAddingDataset ||
+ getState().projects.isSavingDataset ||
+ getState().projects.isDeletingDataset
+ )
+ return;
+ try {
+ await dispatch(clearDatasetDataTypes(dataset.identifier));
+ await dispatch(deleteProjectDataset(project, dataset));
+ } catch (err) {
+ console.error(err);
+ message.error(`Error deleting dataset '${dataset.title}'`);
+ }
};
-const addDatasetLinkedFieldSet = networkAction((dataset, linkedFieldSet, onSuccess) => (dispatch, getState) => ({
- types: ADD_DATASET_LINKED_FIELD_SET,
- url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
- req: jsonRequest({ linked_field_sets: [...dataset.linked_field_sets, linkedFieldSet] }, "PATCH"),
- err: `Error adding linked field set '${linkedFieldSet.name}' to dataset '${dataset.title}'`,
- onSuccess: async () => {
- await onSuccess();
- message.success(`Added linked field set '${linkedFieldSet.name}' to dataset '${dataset.title}'`);
- },
+const addDatasetLinkedFieldSet = networkAction((dataset, linkedFieldSet, onSuccess) => (_dispatch, getState) => ({
+ types: ADD_DATASET_LINKED_FIELD_SET,
+ url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
+ req: jsonRequest({ linked_field_sets: [...dataset.linked_field_sets, linkedFieldSet] }, "PATCH"),
+ err: `Error adding linked field set '${linkedFieldSet.name}' to dataset '${dataset.title}'`,
+ onSuccess: async () => {
+ await onSuccess();
+ message.success(`Added linked field set '${linkedFieldSet.name}' to dataset '${dataset.title}'`);
+ },
}));
export const addDatasetLinkedFieldSetIfPossible =
- (dataset, linkedFieldSet, onSuccess = nop) =>
- (dispatch, getState) => {
- if (
- getState().projects.isAddingDataset ||
- getState().projects.isSavingDataset ||
- getState().projects.isDeletingDataset
- )
- return Promise.resolve();
- return dispatch(addDatasetLinkedFieldSet(dataset, linkedFieldSet, onSuccess));
- };
+ (dataset, linkedFieldSet, onSuccess = nop) =>
+ (dispatch, getState) => {
+ if (
+ getState().projects.isAddingDataset ||
+ getState().projects.isSavingDataset ||
+ getState().projects.isDeletingDataset
+ )
+ return Promise.resolve();
+ return dispatch(addDatasetLinkedFieldSet(dataset, linkedFieldSet, onSuccess));
+ };
const saveDatasetLinkedFieldSet = networkAction(
- (dataset, index, linkedFieldSet, onSuccess) => (dispatch, getState) => ({
- types: SAVE_DATASET_LINKED_FIELD_SET,
- url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
- req: jsonRequest(
- {
- linked_field_sets: dataset.linked_field_sets.map((l, i) => (i === index ? linkedFieldSet : l)),
- },
- "PATCH",
- ),
- err: `Error saving linked field set '${linkedFieldSet.name}' in dataset '${dataset.title}'`,
- onSuccess: async () => {
- await onSuccess();
- message.success(`Saved linked field set '${linkedFieldSet.name}' in dataset '${dataset.title}'`);
- },
- }),
+ (dataset, index, linkedFieldSet, onSuccess) => (_dispatch, getState) => ({
+ types: SAVE_DATASET_LINKED_FIELD_SET,
+ url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
+ req: jsonRequest(
+ {
+ linked_field_sets: dataset.linked_field_sets.map((l, i) => (i === index ? linkedFieldSet : l)),
+ },
+ "PATCH",
+ ),
+ err: `Error saving linked field set '${linkedFieldSet.name}' in dataset '${dataset.title}'`,
+ onSuccess: async () => {
+ await onSuccess();
+ message.success(`Saved linked field set '${linkedFieldSet.name}' in dataset '${dataset.title}'`);
+ },
+ }),
);
export const saveDatasetLinkedFieldSetIfPossible =
- (dataset, index, linkedFieldSet, onSuccess = nop) =>
- (dispatch, getState) => {
- if (
- getState().projects.isAddingDataset ||
- getState().projects.isSavingDataset ||
- getState().projects.isDeletingDataset
- ) {
- return Promise.resolve();
- }
- return dispatch(saveDatasetLinkedFieldSet(dataset, index, linkedFieldSet, onSuccess));
- };
+ (dataset, index, linkedFieldSet, onSuccess = nop) =>
+ (dispatch, getState) => {
+ if (
+ getState().projects.isAddingDataset ||
+ getState().projects.isSavingDataset ||
+ getState().projects.isDeletingDataset
+ ) {
+ return Promise.resolve();
+ }
+ return dispatch(saveDatasetLinkedFieldSet(dataset, index, linkedFieldSet, onSuccess));
+ };
const deleteDatasetLinkedFieldSet = networkAction(
- (dataset, linkedFieldSet, linkedFieldSetIndex) => (dispatch, getState) => ({
- types: DELETE_DATASET_LINKED_FIELD_SET,
- url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
- req: jsonRequest(
- {
- linked_field_sets: dataset.linked_field_sets.filter((_, i) => i !== linkedFieldSetIndex),
- },
- "PATCH",
- ),
- err: `Error deleting linked field set '${linkedFieldSet.name}' from dataset '${dataset.title}'`,
- onSuccess: () =>
- message.success(`Deleted linked field set '${linkedFieldSet.name}' from dataset '${dataset.title}'`),
- }),
+ (dataset, linkedFieldSet, linkedFieldSetIndex) => (_dispatch, getState) => ({
+ types: DELETE_DATASET_LINKED_FIELD_SET,
+ url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`,
+ req: jsonRequest(
+ {
+ linked_field_sets: dataset.linked_field_sets.filter((_, i) => i !== linkedFieldSetIndex),
+ },
+ "PATCH",
+ ),
+ err: `Error deleting linked field set '${linkedFieldSet.name}' from dataset '${dataset.title}'`,
+ onSuccess: () =>
+ message.success(`Deleted linked field set '${linkedFieldSet.name}' from dataset '${dataset.title}'`),
+ }),
);
export const deleteDatasetLinkedFieldSetIfPossible =
- (dataset, linkedFieldSet, linkedFieldSetIndex) => (dispatch, getState) => {
- if (
- getState().projects.isAddingDataset ||
- getState().projects.isSavingDataset ||
- getState().projects.isDeletingDataset
- ) {
- return Promise.resolve();
- }
- return dispatch(deleteDatasetLinkedFieldSet(dataset, linkedFieldSet, linkedFieldSetIndex));
- };
-
-export const fetchIndividual = networkAction((individualID) => (dispatch, getState) => ({
- types: FETCH_INDIVIDUAL,
- check: (state) => {
- const individualRecord = state.individuals.itemsByID[individualID] || {};
- // Don't fetch if already fetching or loaded:
- return state.services.metadataService && !individualRecord.isFetching && !individualRecord.data;
- },
- params: { individualID },
- url: `${getState().services.metadataService.url}/api/individuals/${individualID}`,
- err: `Error fetching individual ${individualID}`,
+ (dataset, linkedFieldSet, linkedFieldSetIndex) => (dispatch, getState) => {
+ if (
+ getState().projects.isAddingDataset ||
+ getState().projects.isSavingDataset ||
+ getState().projects.isDeletingDataset
+ ) {
+ return Promise.resolve();
+ }
+ return dispatch(deleteDatasetLinkedFieldSet(dataset, linkedFieldSet, linkedFieldSetIndex));
+ };
+
+export const fetchIndividual = networkAction((individualID) => (_dispatch, getState) => ({
+ types: FETCH_INDIVIDUAL,
+ check: (state) => {
+ const individualRecord = state.individuals.itemsByID[individualID] || {};
+ // Don't fetch if already fetching or loaded:
+ return state.services.metadataService && !individualRecord.isFetching && !individualRecord.data;
+ },
+ params: { individualID },
+ url: `${getState().services.metadataService.url}/api/individuals/${individualID}`,
+ err: `Error fetching individual ${individualID}`,
}));
-const fetchIndividualPhenopackets = networkAction((individualID) => (dispatch, getState) => ({
- types: FETCH_INDIVIDUAL_PHENOPACKETS,
- params: { individualID },
- url: `${getState().services.metadataService.url}/api/individuals/${individualID}/phenopackets`,
- err: `Error fetching phenopackets for individual ${individualID}`,
+const fetchIndividualPhenopackets = networkAction((individualID) => (_dispatch, getState) => ({
+ types: FETCH_INDIVIDUAL_PHENOPACKETS,
+ params: { individualID },
+ url: `${getState().services.metadataService.url}/api/individuals/${individualID}/phenopackets`,
+ err: `Error fetching phenopackets for individual ${individualID}`,
}));
export const fetchIndividualPhenopacketsIfNecessary = (individualID) => (dispatch, getState) => {
- const record = getState().individuals.phenopacketsByIndividualID[individualID] || {};
- if (!getState().services.metadataService.url) return Promise.resolve();
- if (record.isFetching || record.data) return Promise.resolve(); // Don't fetch if already fetching or loaded.
- return dispatch(fetchIndividualPhenopackets(individualID));
+ const record = getState().individuals.phenopacketsByIndividualID[individualID] || {};
+ if (!getState().services.metadataService.url) return Promise.resolve();
+ if (record.isFetching || record.data) return Promise.resolve(); // Don't fetch if already fetching or loaded.
+ return dispatch(fetchIndividualPhenopackets(individualID));
};
-const _fetchOverviewSummary = networkAction(() => (dispatch, getState) => ({
- types: FETCH_OVERVIEW_SUMMARY,
- url: `${getState().services.metadataService.url}/api/overview`,
- err: "Error fetching overview summary metadata",
+const _fetchOverviewSummary = networkAction(() => (_dispatch, getState) => ({
+ types: FETCH_OVERVIEW_SUMMARY,
+ url: `${getState().services.metadataService.url}/api/overview`,
+ err: "Error fetching overview summary metadata",
}));
export const fetchOverviewSummary = () => (dispatch, getState) => {
- if (!getState().services.itemsByKind.metadata) return Promise.resolve();
- return dispatch(_fetchOverviewSummary());
+ if (!getState().services.itemsByKind.metadata) return Promise.resolve();
+ return dispatch(_fetchOverviewSummary());
};
export const fetchOverviewSummaryIfNeeded = () => (dispatch, getState) => {
- const state = getState();
- if (state.overviewSummary.isFetching || Object.keys(state.overviewSummary.data).length) {
- return Promise.resolve();
- }
- return dispatch(fetchOverviewSummary());
+ const state = getState();
+ if (state.overviewSummary.isFetching || Object.keys(state.overviewSummary.data).length) {
+ return Promise.resolve();
+ }
+ return dispatch(fetchOverviewSummary());
};
diff --git a/src/modules/metadata/hooks.js b/src/modules/metadata/hooks.js
index eeabf98c6..b1cbfd1c2 100644
--- a/src/modules/metadata/hooks.js
+++ b/src/modules/metadata/hooks.js
@@ -1,31 +1,52 @@
+import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { makeProjectDatasetResource, makeProjectResource } from "bento-auth-js";
import { useService } from "@/modules/services/hooks";
-import { useEffect } from "react";
-import { fetchOverviewSummaryIfNeeded, fetchProjectsWithDatasets } from "@/modules/metadata/actions";
+import { fetchExtraPropertiesSchemaTypes, fetchOverviewSummaryIfNeeded, fetchProjectsWithDatasets } from "./actions";
export const useProjects = () => {
- const dispatch = useDispatch();
- const metadataService = useService("metadata");
- useEffect(() => {
- dispatch(fetchProjectsWithDatasets()).catch((err) => console.error(err));
- }, [dispatch, metadataService]);
- return useSelector((state) => state.projects);
+ const dispatch = useDispatch();
+ const metadataService = useService("metadata");
+ useEffect(() => {
+ dispatch(fetchProjectsWithDatasets()).catch((err) => console.error(err));
+ }, [dispatch, metadataService]);
+ return useSelector((state) => state.projects);
};
export const useProjectsAndDatasetsAsAuthzResources = () => {
- const { items: projects } = useProjects();
- return projects.flatMap(({ identifier: projectID, datasets }) => [
+ const { items: projects } = useProjects();
+ return useMemo(
+ () =>
+ projects.flatMap(({ identifier: projectID, datasets }) => [
makeProjectResource(projectID),
- ...(datasets.map((dataset) => makeProjectDatasetResource(projectID, dataset.identifier))),
- ]);
+ ...datasets.map((dataset) => makeProjectDatasetResource(projectID, dataset.identifier)),
+ ]),
+ [projects],
+ );
};
export const useOverviewSummary = () => {
- const dispatch = useDispatch();
- const metadataService = useService("metadata");
- useEffect(() => {
- dispatch(fetchOverviewSummaryIfNeeded()).catch((err) => console.error(err));
- }, [dispatch, metadataService]);
- return useSelector((state) => state.overviewSummary);
+ const dispatch = useDispatch();
+ const metadataService = useService("metadata");
+ useEffect(() => {
+ dispatch(fetchOverviewSummaryIfNeeded()).catch((err) => console.error(err));
+ }, [dispatch, metadataService]);
+ return useSelector((state) => state.overviewSummary);
+};
+
+export const useProjectJsonSchemaTypes = () => {
+ const dispatch = useDispatch();
+ const metadataService = useService("metadata");
+ useEffect(() => {
+ dispatch(fetchExtraPropertiesSchemaTypes());
+ }, [dispatch, metadataService]);
+ const { isFetchingExtraPropertiesSchemaTypes, isCreatingJsonSchema, extraPropertiesSchemaTypes } = useProjects();
+ return useMemo(
+ () => ({
+ isFetchingExtraPropertiesSchemaTypes,
+ isCreatingJsonSchema,
+ extraPropertiesSchemaTypes,
+ }),
+ [isFetchingExtraPropertiesSchemaTypes, isCreatingJsonSchema, extraPropertiesSchemaTypes],
+ );
};
diff --git a/src/modules/metadata/reducers.js b/src/modules/metadata/reducers.js
index cfcc947e9..423788859 100644
--- a/src/modules/metadata/reducers.js
+++ b/src/modules/metadata/reducers.js
@@ -1,410 +1,385 @@
-import {objectWithoutProp} from "@/utils/misc";
+import { objectWithoutProp } from "@/utils/misc";
import {
- FETCH_PROJECTS,
-
- CREATE_PROJECT,
- DELETE_PROJECT,
- SAVE_PROJECT,
-
- ADD_PROJECT_DATASET,
- SAVE_PROJECT_DATASET,
- DELETE_PROJECT_DATASET,
-
- ADD_DATASET_LINKED_FIELD_SET,
- SAVE_DATASET_LINKED_FIELD_SET,
- DELETE_DATASET_LINKED_FIELD_SET,
-
- FETCH_INDIVIDUAL,
- FETCH_INDIVIDUAL_PHENOPACKETS,
-
- FETCH_OVERVIEW_SUMMARY,
-
- FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES,
-
- CREATE_PROJECT_JSON_SCHEMA,
- DELETE_PROJECT_JSON_SCHEMA,
+ FETCH_PROJECTS,
+ CREATE_PROJECT,
+ DELETE_PROJECT,
+ SAVE_PROJECT,
+ ADD_PROJECT_DATASET,
+ SAVE_PROJECT_DATASET,
+ DELETE_PROJECT_DATASET,
+ ADD_DATASET_LINKED_FIELD_SET,
+ SAVE_DATASET_LINKED_FIELD_SET,
+ DELETE_DATASET_LINKED_FIELD_SET,
+ FETCH_INDIVIDUAL,
+ FETCH_INDIVIDUAL_PHENOPACKETS,
+ FETCH_OVERVIEW_SUMMARY,
+ FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES,
+ CREATE_PROJECT_JSON_SCHEMA,
+ DELETE_PROJECT_JSON_SCHEMA,
} from "./actions";
-
const projectSort = (a, b) => a.title.localeCompare(b.title);
export const projects = (
- state = {
- isFetching: false,
- isCreating: false,
- isDeleting: false,
- isSaving: false,
+ state = {
+ isFetching: false,
+ isCreating: false,
+ isDeleting: false,
+ isSaving: false,
+ isAddingDataset: false,
+ isSavingDataset: false,
+ isDeletingDataset: false,
+
+ extraPropertiesSchemaTypes: {},
+ isFetchingExtraPropertiesSchemaTypes: false,
+ isCreatingJsonSchema: false,
+ isDeletingJsonSchema: false,
+
+ items: [],
+ itemsByID: {},
+
+ datasetsByID: {},
+ },
+ action,
+) => {
+ switch (action.type) {
+ case FETCH_PROJECTS.REQUEST:
+ return { ...state, isFetching: true };
+
+ case FETCH_PROJECTS.RECEIVE:
+ return {
+ ...state,
+ items: action.data.toSorted(projectSort),
+ itemsByID: Object.fromEntries(action.data.map((p) => [p.identifier, p])),
+ datasetsByID: Object.fromEntries(
+ action.data.flatMap((p) => p.datasets.map((d) => [d.identifier, { ...d, project: p.identifier }])),
+ ),
+ };
+
+ case FETCH_PROJECTS.FINISH:
+ return { ...state, isFetching: false };
+
+ case CREATE_PROJECT.REQUEST:
+ return { ...state, isCreating: true };
+
+ case CREATE_PROJECT.RECEIVE:
+ return {
+ ...state,
+ items: [...state.items, action.data].sort(projectSort),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.data.identifier]: action.data,
+ },
+ };
+
+ case CREATE_PROJECT.FINISH:
+ return { ...state, isCreating: false };
+
+ case DELETE_PROJECT.REQUEST:
+ return { ...state, isDeleting: true };
+
+ case DELETE_PROJECT.RECEIVE:
+ return {
+ ...state,
+ items: state.items.filter((p) => p.identifier !== action.project.identifier),
+ itemsByID: Object.fromEntries(
+ Object.entries(objectWithoutProp(state.itemsByID, action.project.identifier)).filter(
+ ([projectID, _]) => projectID !== action.project.identifier,
+ ),
+ ),
+ };
+
+ case DELETE_PROJECT.FINISH:
+ return { ...state, isDeleting: false };
+
+ case SAVE_PROJECT.REQUEST:
+ return { ...state, isSaving: true };
+
+ case SAVE_PROJECT.RECEIVE:
+ return {
+ ...state,
+ items: [...state.items.filter((p) => p.identifier !== action.data.identifier), action.data].sort(projectSort),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.data.identifier]: action.data,
+ },
+ };
+
+ case SAVE_PROJECT.FINISH:
+ return { ...state, isSaving: false };
+
+ // ADD_PROJECT_DATASET
+ case ADD_PROJECT_DATASET.REQUEST:
+ return { ...state, isAddingDataset: true };
+
+ case ADD_PROJECT_DATASET.RECEIVE: {
+ const newDataset = action.data;
+ const projectID = newDataset.project;
+ return {
+ ...state,
isAddingDataset: false,
- isSavingDataset: false,
- isDeletingDataset: false,
+ items: state.items.map((p) =>
+ p.identifier === newDataset.project ? { ...p, datasets: [...p.datasets, newDataset] } : p,
+ ),
+ itemsByID: {
+ ...state.itemsByID,
+ [projectID]: {
+ ...(state.itemsByID[projectID] || {}),
+ datasets: [...(state.itemsByID[projectID]?.datasets || []), newDataset],
+ },
+ },
+ datasetsByID: {
+ ...state.datasetsByID,
+ [newDataset.identifier]: action.data,
+ },
+ };
+ }
- extraPropertiesSchemaTypes: {},
- isFetchingExtraPropertiesSchemaTypes: false,
- isCreatingJsonSchema: false,
- isDeletingJsonSchema: false,
+ case ADD_PROJECT_DATASET.FINISH:
+ return { ...state, isAddingDataset: false };
+
+ // DELETE_PROJECT_DATASET
+ case DELETE_PROJECT_DATASET.REQUEST:
+ return { ...state, isDeletingDataset: true };
+
+ case DELETE_PROJECT_DATASET.RECEIVE: {
+ const deleteDataset = (d) => d.identifier !== action.dataset.identifier;
+ return {
+ ...state,
+ items: state.items.map((p) =>
+ p.identifier === action.project.identifier ? { ...p, datasets: p.datasets.filter(deleteDataset) } : p,
+ ),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.project.identifier]: {
+ ...(state.itemsByID[action.project.identifier] || {}),
+ datasets: ((state.itemsByID[action.project.identifier] || {}).datasets || []).filter(deleteDataset),
+ },
+ },
+ datasetsByID: Object.fromEntries(Object.entries(state.datasetsByID).filter(([_, d]) => deleteDataset(d))),
+ };
+ }
- items: [],
- itemsByID: {},
+ case DELETE_PROJECT_DATASET.FINISH:
+ return { ...state, isDeletingDataset: false };
+
+ case SAVE_PROJECT_DATASET.REQUEST:
+ case ADD_DATASET_LINKED_FIELD_SET.REQUEST:
+ case SAVE_DATASET_LINKED_FIELD_SET.REQUEST:
+ case DELETE_DATASET_LINKED_FIELD_SET.REQUEST:
+ return { ...state, isSavingDataset: true };
+
+ case SAVE_PROJECT_DATASET.RECEIVE:
+ case ADD_DATASET_LINKED_FIELD_SET.RECEIVE:
+ case SAVE_DATASET_LINKED_FIELD_SET.RECEIVE:
+ case DELETE_DATASET_LINKED_FIELD_SET.RECEIVE: {
+ const replaceDataset = (d) => (d.identifier === action.data.identifier ? { ...d, ...action.data } : d);
+ return {
+ ...state,
+ items: state.items.map((p) =>
+ p.identifier === action.data.project ? { ...p, datasets: p.datasets.map(replaceDataset) } : p,
+ ),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.data.project]: {
+ ...(state.itemsByID[action.data.project] || {}),
+ datasets: ((state.itemsByID[action.data.project] || {}).datasets || []).map(replaceDataset),
+ },
+ },
+ };
+ }
- datasetsByID: {},
- },
- action,
-) => {
- switch (action.type) {
- case FETCH_PROJECTS.REQUEST:
- return {...state, isFetching: true};
-
- case FETCH_PROJECTS.RECEIVE:
- return {
- ...state,
- items: action.data.toSorted(projectSort),
- itemsByID: Object.fromEntries(action.data.map(p => [p.identifier, p])),
- datasetsByID: Object.fromEntries(
- action.data.flatMap(p => p.datasets.map((d) => [d.identifier, { ...d, project: p.identifier }])),
- ),
- };
-
- case FETCH_PROJECTS.FINISH:
- return {...state, isFetching: false};
-
- case CREATE_PROJECT.REQUEST:
- return {...state, isCreating: true};
-
- case CREATE_PROJECT.RECEIVE:
- return {
- ...state,
- items: [...state.items, action.data].sort(projectSort),
- itemsByID: {
- ...state.itemsByID,
- [action.data.identifier]: action.data,
- },
- };
-
- case CREATE_PROJECT.FINISH:
- return {...state, isCreating: false};
-
-
- case DELETE_PROJECT.REQUEST:
- return {...state, isDeleting: true};
-
- case DELETE_PROJECT.RECEIVE:
- return {
- ...state,
- items: state.items.filter(p => p.identifier !== action.project.identifier),
- itemsByID: Object.fromEntries(Object.entries(objectWithoutProp(state.itemsByID,
- action.project.identifier)).filter(([projectID, _]) => projectID !== action.project.identifier)),
- };
-
- case DELETE_PROJECT.FINISH:
- return {...state, isDeleting: false};
-
-
- case SAVE_PROJECT.REQUEST:
- return {...state, isSaving: true};
-
- case SAVE_PROJECT.RECEIVE:
- return {
- ...state,
- items: [...state.items.filter(p => p.identifier !== action.data.identifier), action.data]
- .sort(projectSort),
- itemsByID: {
- ...state.itemsByID,
- [action.data.identifier]: action.data,
- },
- };
-
- case SAVE_PROJECT.FINISH:
- return {...state, isSaving: false};
-
-
- // ADD_PROJECT_DATASET
- case ADD_PROJECT_DATASET.REQUEST:
- return {...state, isAddingDataset: true};
-
- case ADD_PROJECT_DATASET.RECEIVE: {
- const newDataset = action.data;
- const projectID = newDataset.project;
- return {
- ...state,
- isAddingDataset: false,
- items: state.items.map(p => p.identifier === newDataset.project
- ? {...p, datasets: [...p.datasets, newDataset]}
- : p,
- ),
- itemsByID: {
- ...state.itemsByID,
- [projectID]: {
- ...(state.itemsByID[projectID] || {}),
- datasets: [...(state.itemsByID[projectID]?.datasets || []), newDataset],
- },
- },
- datasetsByID: {
- ...state.datasetsByID,
- [newDataset.identifier]: action.data,
- },
- };
- }
-
- case ADD_PROJECT_DATASET.FINISH:
- return {...state, isAddingDataset: false};
-
-
- // DELETE_PROJECT_DATASET
- case DELETE_PROJECT_DATASET.REQUEST:
- return {...state, isDeletingDataset: true};
-
- case DELETE_PROJECT_DATASET.RECEIVE: {
- const deleteDataset = d => d.identifier !== action.dataset.identifier;
- return {
- ...state,
- items: state.items.map(p => p.identifier === action.project.identifier
- ? {...p, datasets: p.datasets.filter(deleteDataset)}
- : p,
- ),
- itemsByID: {
- ...state.itemsByID,
- [action.project.identifier]: {
- ...(state.itemsByID[action.project.identifier] || {}),
- datasets: ((state.itemsByID[action.project.identifier] || {}).datasets || [])
- .filter(deleteDataset),
- },
- },
- datasetsByID: Object.fromEntries(
- Object.entries(state.datasetsByID).filter(([_, d]) => deleteDataset(d)),
- ),
- };
- }
-
- case DELETE_PROJECT_DATASET.FINISH:
- return {...state, isDeletingDataset: false};
-
-
- case SAVE_PROJECT_DATASET.REQUEST:
- case ADD_DATASET_LINKED_FIELD_SET.REQUEST:
- case SAVE_DATASET_LINKED_FIELD_SET.REQUEST:
- case DELETE_DATASET_LINKED_FIELD_SET.REQUEST:
- return {...state, isSavingDataset: true};
-
- case SAVE_PROJECT_DATASET.RECEIVE:
- case ADD_DATASET_LINKED_FIELD_SET.RECEIVE:
- case SAVE_DATASET_LINKED_FIELD_SET.RECEIVE:
- case DELETE_DATASET_LINKED_FIELD_SET.RECEIVE: {
- const replaceDataset = d => d.identifier === action.data.identifier ? {...d, ...action.data} : d;
- return {
- ...state,
- items: state.items.map(p => p.identifier === action.data.project
- ? {...p, datasets: p.datasets.map(replaceDataset)}
- : p,
- ),
- itemsByID: {
- ...state.itemsByID,
- [action.data.project]: {
- ...(state.itemsByID[action.data.project] || {}),
- datasets: ((state.itemsByID[action.data.project] || {}).datasets || []).map(replaceDataset),
- },
- },
- };
- }
-
- case SAVE_PROJECT_DATASET.FINISH:
- case ADD_DATASET_LINKED_FIELD_SET.FINISH:
- case SAVE_DATASET_LINKED_FIELD_SET.FINISH:
- case DELETE_DATASET_LINKED_FIELD_SET.FINISH:
- return {...state, isSavingDataset: false};
-
-
- // FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES
- case FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES.REQUEST:
- return {...state, isFetchingExtraPropertiesSchemaTypes: true};
- case FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES.RECEIVE:
- return {...state, extraPropertiesSchemaTypes: action.data};
- case FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES.FINISH:
- return {...state, isFetchingExtraPropertiesSchemaTypes: false};
-
- // CREATE_PROJECT_JSON_SCHEMA
- case CREATE_PROJECT_JSON_SCHEMA.REQUEST:
- return {...state, isCreatingJsonSchema: true};
- case CREATE_PROJECT_JSON_SCHEMA.RECEIVE:
- return {
- ...state,
- items: state.items.map(p => p.identifier === action.data.project
- ? {...p, project_schemas: [...p.project_schemas, action.data]}
- : p,
- ),
- itemsByID: {
- ...state.itemsByID,
- [action.data.project]: {
- ...(state.itemsByID[action.data.project] || {}),
- project_schemas: [
- ...(state.itemsByID[action.data.project]?.project_schemas ?? []),
- action.data,
- ],
- },
- },
- };
- case CREATE_PROJECT_JSON_SCHEMA.FINISH:
- return {...state, isCreatingJsonSchema: false};
-
- // DELETE_PROJECT_JSON_SCHEMA
- case DELETE_PROJECT_JSON_SCHEMA.REQUEST:
- return {...state, isDeletingJsonSchema: true};
- case DELETE_PROJECT_JSON_SCHEMA.RECEIVE: {
- const deleteSchema = pjs => pjs.id !== action.projectJsonSchema.id;
- return {
- ...state,
- items: state.items.map(p => p.identifier === action.projectJsonSchema.project
- ? {...p, project_schemas: p.project_schemas.filter(deleteSchema)}
- : p,
- ),
- itemsByID: {
- ...state.itemsByID,
- [action.projectJsonSchema.project]: {
- ...(state.itemsByID[action.projectJsonSchema.project] || {}),
- project_schemas: (state.itemsByID[action.projectJsonSchema.project]?.project_schemas ?? [])
- .filter(deleteSchema),
-
- },
- },
- };
- }
- case DELETE_PROJECT_JSON_SCHEMA.FINISH:
- return {...state, isDeletingJsonSchema: false};
-
-
- default:
- return state;
+ case SAVE_PROJECT_DATASET.FINISH:
+ case ADD_DATASET_LINKED_FIELD_SET.FINISH:
+ case SAVE_DATASET_LINKED_FIELD_SET.FINISH:
+ case DELETE_DATASET_LINKED_FIELD_SET.FINISH:
+ return { ...state, isSavingDataset: false };
+
+ // FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES
+ case FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES.REQUEST:
+ return { ...state, isFetchingExtraPropertiesSchemaTypes: true };
+ case FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES.RECEIVE:
+ return { ...state, extraPropertiesSchemaTypes: action.data };
+ case FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES.FINISH:
+ return { ...state, isFetchingExtraPropertiesSchemaTypes: false };
+
+ // CREATE_PROJECT_JSON_SCHEMA
+ case CREATE_PROJECT_JSON_SCHEMA.REQUEST:
+ return { ...state, isCreatingJsonSchema: true };
+ case CREATE_PROJECT_JSON_SCHEMA.RECEIVE:
+ return {
+ ...state,
+ items: state.items.map((p) =>
+ p.identifier === action.data.project ? { ...p, project_schemas: [...p.project_schemas, action.data] } : p,
+ ),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.data.project]: {
+ ...(state.itemsByID[action.data.project] || {}),
+ project_schemas: [...(state.itemsByID[action.data.project]?.project_schemas ?? []), action.data],
+ },
+ },
+ };
+ case CREATE_PROJECT_JSON_SCHEMA.FINISH:
+ return { ...state, isCreatingJsonSchema: false };
+
+ // DELETE_PROJECT_JSON_SCHEMA
+ case DELETE_PROJECT_JSON_SCHEMA.REQUEST:
+ return { ...state, isDeletingJsonSchema: true };
+ case DELETE_PROJECT_JSON_SCHEMA.RECEIVE: {
+ const deleteSchema = (pjs) => pjs.id !== action.projectJsonSchema.id;
+ return {
+ ...state,
+ items: state.items.map((p) =>
+ p.identifier === action.projectJsonSchema.project
+ ? { ...p, project_schemas: p.project_schemas.filter(deleteSchema) }
+ : p,
+ ),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.projectJsonSchema.project]: {
+ ...(state.itemsByID[action.projectJsonSchema.project] || {}),
+ project_schemas: (state.itemsByID[action.projectJsonSchema.project]?.project_schemas ?? []).filter(
+ deleteSchema,
+ ),
+ },
+ },
+ };
}
-};
+ case DELETE_PROJECT_JSON_SCHEMA.FINISH:
+ return { ...state, isDeletingJsonSchema: false };
+ default:
+ return state;
+ }
+};
export const biosamples = (
- state = {
- itemsByID: {},
- },
- action,
+ state = {
+ itemsByID: {},
+ },
+ action,
) => {
- switch (action.type) {
- default:
- return state;
- }
+ switch (action.type) {
+ default:
+ return state;
+ }
};
export const individuals = (
- state = {
- itemsByID: {},
- phenopacketsByIndividualID: {},
- },
- action,
+ state = {
+ itemsByID: {},
+ phenopacketsByIndividualID: {},
+ },
+ action,
) => {
- switch (action.type) {
- // FETCH_INDIVIDUAL
-
- case FETCH_INDIVIDUAL.REQUEST:
- return {
- ...state,
- itemsByID: {
- ...state.itemsByID,
- [action.individualID]: {
- ...(state.itemsByID[action.individualID] || {}),
- isFetching: true,
- },
- },
- };
- case FETCH_INDIVIDUAL.RECEIVE:
- return {
- ...state,
- itemsByID: {
- ...state.itemsByID,
- [action.individualID]: {
- ...(state.itemsByID[action.individualID] || {}),
- data: action.data,
- },
- },
- };
- case FETCH_INDIVIDUAL.FINISH:
- return {
- ...state,
- itemsByID: {
- ...state.itemsByID,
- [action.individualID]: {
- ...(state.itemsByID[action.individualID] || {}),
- isFetching: false,
- },
- },
- };
-
- // FETCH_INDIVIDUAL_PHENOPACKETS
-
- case FETCH_INDIVIDUAL_PHENOPACKETS.REQUEST: {
- const { individualID } = action;
- return {
- ...state,
- phenopacketsByIndividualID: {
- ...state.phenopacketsByIndividualID,
- [individualID]: {
- ...(state.phenopacketsByIndividualID[individualID] ?? {}),
- isFetching: true,
- },
- },
- };
- }
- case FETCH_INDIVIDUAL_PHENOPACKETS.RECEIVE: {
- const { individualID, data } = action;
- return {
- ...state,
- phenopacketsByIndividualID: {
- ...state.phenopacketsByIndividualID,
- [individualID]: {
- ...(state.phenopacketsByIndividualID[individualID] ?? {}),
- data,
- },
- },
- };
- }
- case FETCH_INDIVIDUAL_PHENOPACKETS.FINISH: {
- const { individualID } = action;
- return {
- ...state,
- phenopacketsByIndividualID: {
- ...state.phenopacketsByIndividualID,
- [individualID]: {
- ...(state.phenopacketsByIndividualID[individualID] ?? {}),
- isFetching: false,
- },
- },
- };
- }
-
- default:
- return state;
+ switch (action.type) {
+ // FETCH_INDIVIDUAL
+
+ case FETCH_INDIVIDUAL.REQUEST:
+ return {
+ ...state,
+ itemsByID: {
+ ...state.itemsByID,
+ [action.individualID]: {
+ ...(state.itemsByID[action.individualID] || {}),
+ isFetching: true,
+ },
+ },
+ };
+ case FETCH_INDIVIDUAL.RECEIVE:
+ return {
+ ...state,
+ itemsByID: {
+ ...state.itemsByID,
+ [action.individualID]: {
+ ...(state.itemsByID[action.individualID] || {}),
+ data: action.data,
+ },
+ },
+ };
+ case FETCH_INDIVIDUAL.FINISH:
+ return {
+ ...state,
+ itemsByID: {
+ ...state.itemsByID,
+ [action.individualID]: {
+ ...(state.itemsByID[action.individualID] || {}),
+ isFetching: false,
+ },
+ },
+ };
+
+ // FETCH_INDIVIDUAL_PHENOPACKETS
+
+ case FETCH_INDIVIDUAL_PHENOPACKETS.REQUEST: {
+ const { individualID } = action;
+ return {
+ ...state,
+ phenopacketsByIndividualID: {
+ ...state.phenopacketsByIndividualID,
+ [individualID]: {
+ ...(state.phenopacketsByIndividualID[individualID] ?? {}),
+ isFetching: true,
+ },
+ },
+ };
+ }
+ case FETCH_INDIVIDUAL_PHENOPACKETS.RECEIVE: {
+ const { individualID, data } = action;
+ return {
+ ...state,
+ phenopacketsByIndividualID: {
+ ...state.phenopacketsByIndividualID,
+ [individualID]: {
+ ...(state.phenopacketsByIndividualID[individualID] ?? {}),
+ data,
+ },
+ },
+ };
+ }
+ case FETCH_INDIVIDUAL_PHENOPACKETS.FINISH: {
+ const { individualID } = action;
+ return {
+ ...state,
+ phenopacketsByIndividualID: {
+ ...state.phenopacketsByIndividualID,
+ [individualID]: {
+ ...(state.phenopacketsByIndividualID[individualID] ?? {}),
+ isFetching: false,
+ },
+ },
+ };
}
-};
+ default:
+ return state;
+ }
+};
export const overviewSummary = (
- state = {
- data: {},
- isFetching: false,
- hasAttempted: false,
- },
- action,
+ state = {
+ data: {},
+ isFetching: false,
+ hasAttempted: false,
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_OVERVIEW_SUMMARY.REQUEST:
- return { ...state, data: {}, isFetching: true };
- case FETCH_OVERVIEW_SUMMARY.RECEIVE:
- return { ...state, data: action.data };
- case FETCH_OVERVIEW_SUMMARY.FINISH:
- return {
- ...state,
- data: state.data,
- isFetching: false,
- hasAttempted: true,
- };
-
- default:
- return state;
- }
-};
+ switch (action.type) {
+ case FETCH_OVERVIEW_SUMMARY.REQUEST:
+ return { ...state, data: {}, isFetching: true };
+ case FETCH_OVERVIEW_SUMMARY.RECEIVE:
+ return { ...state, data: action.data };
+ case FETCH_OVERVIEW_SUMMARY.FINISH:
+ return {
+ ...state,
+ data: state.data,
+ isFetching: false,
+ hasAttempted: true,
+ };
+ default:
+ return state;
+ }
+};
diff --git a/src/modules/metadata/types.ts b/src/modules/metadata/types.ts
new file mode 100644
index 000000000..cb465156e
--- /dev/null
+++ b/src/modules/metadata/types.ts
@@ -0,0 +1,28 @@
+export type ProjectJSONSchema = {
+ name: string;
+ fields: Record;
+};
+
+export type Dataset = {
+ identifier: string;
+ title: string;
+ description: string;
+ contact_info: string;
+ dats_file: object;
+ additional_resources: string[];
+
+ created: string; // ISO timestamp string
+ updated: string; // ISO timestamp string
+};
+
+export type Project = {
+ identifier: string;
+ title: string;
+ description: string;
+
+ project_schemas?: ProjectJSONSchema[];
+ datasets?: Dataset[];
+
+ created: string; // ISO timestamp string
+ updated: string; // ISO timestamp string
+};
diff --git a/src/modules/notifications/actions.js b/src/modules/notifications/actions.js
index 6ea30136a..093b05d25 100644
--- a/src/modules/notifications/actions.js
+++ b/src/modules/notifications/actions.js
@@ -8,32 +8,32 @@ export const hideNotificationDrawer = basicAction(HIDE_NOTIFICATION_DRAWER);
export const ADD_NOTIFICATION = "ADD_NOTIFICATION";
-export const addNotification = data => ({
- type: ADD_NOTIFICATION,
- data,
+export const addNotification = (data) => ({
+ type: ADD_NOTIFICATION,
+ data,
});
export const FETCH_NOTIFICATIONS = createNetworkActionTypes("FETCH_NOTIFICATIONS");
export const fetchNotifications = networkAction(() => (dispatch, getState) => ({
- types: FETCH_NOTIFICATIONS,
- check: (state) => state.services.notificationService && !state.notifications.isFetching,
- url: `${getState().services.notificationService.url}/notifications`,
- err: "Error fetching notifications",
+ types: FETCH_NOTIFICATIONS,
+ check: (state) => state.services.notificationService && !state.notifications.isFetching,
+ url: `${getState().services.notificationService.url}/notifications`,
+ err: "Error fetching notifications",
}));
export const MARK_NOTIFICATION_AS_READ = createNetworkActionTypes("MARK_NOTIFICATION_AS_READ");
-export const markNotificationAsRead = networkAction(notificationID => (dispatch, getState) => ({
- types: MARK_NOTIFICATION_AS_READ,
- params: {notificationID},
- url: `${getState().services.notificationService.url}/notifications/${notificationID}/read`,
- req: {method: "PUT"},
- err: "Error marking notification as read",
+export const markNotificationAsRead = networkAction((notificationID) => (dispatch, getState) => ({
+ types: MARK_NOTIFICATION_AS_READ,
+ params: { notificationID },
+ url: `${getState().services.notificationService.url}/notifications/${notificationID}/read`,
+ req: { method: "PUT" },
+ err: "Error marking notification as read",
}));
export const MARK_ALL_NOTIFICATIONS_AS_READ = createNetworkActionTypes("MARK_ALL_NOTIFICATIONS_AS_READ");
export const markAllNotificationsAsRead = networkAction(() => (dispatch, getState) => ({
- types: MARK_ALL_NOTIFICATIONS_AS_READ,
- url: `${getState().services.notificationService.url}/notifications/all-read`,
- req: {method: "PUT"},
- err: "Error marking all notifications as read",
+ types: MARK_ALL_NOTIFICATIONS_AS_READ,
+ url: `${getState().services.notificationService.url}/notifications/all-read`,
+ req: { method: "PUT" },
+ err: "Error marking all notifications as read",
}));
diff --git a/src/modules/notifications/events.js b/src/modules/notifications/events.js
index c036dc64f..b6766dd49 100644
--- a/src/modules/notifications/events.js
+++ b/src/modules/notifications/events.js
@@ -10,50 +10,50 @@ const NOTIFICATION_WES_RUN_FAILED = "wes_run_failed";
const NOTIFICATION_WES_RUN_COMPLETED = "wes_run_completed";
export default {
- [/^bento\.service\.notification$/.source]: (message, navigate) => async dispatch => {
- if (message.type !== EVENT_NOTIFICATION) return;
-
- const messageData = message.data || {};
-
- await dispatch(addNotification(messageData));
-
- const notificationData = {
- // Assume message data has at least ID, title, description, and read, although it should have everything
- ...messageData,
- notification_type: messageData.notification_type || "generic",
- action_target: messageData.action_target || null,
- };
-
- const notificationBasics = {
- message: notificationData.title,
- description: notificationData.description,
- };
-
- const wesClickAction = () => {
- dispatch(markNotificationAsRead(notificationData.id));
- dispatch(navigateToWESRun(notificationData.action_target, navigate));
- };
-
- switch (message.data.notification_type) {
- case NOTIFICATION_WES_RUN_FAILED:
- notification.error({
- ...notificationBasics,
- onClick: wesClickAction,
- });
- break;
-
- case NOTIFICATION_WES_RUN_COMPLETED:
- // Notify the user that the workflow succeeded
- notification.success({
- ...notificationBasics,
- onClick: wesClickAction,
- });
- // Reload overview data when a workflow completes
- dispatch(fetchOverviewSummary());
- break;
-
- default:
- notification.open(notificationBasics);
- }
- },
+ [/^bento\.service\.notification$/.source]: (message, navigate) => async (dispatch) => {
+ if (message.type !== EVENT_NOTIFICATION) return;
+
+ const messageData = message.data || {};
+
+ await dispatch(addNotification(messageData));
+
+ const notificationData = {
+ // Assume message data has at least ID, title, description, and read, although it should have everything
+ ...messageData,
+ notification_type: messageData.notification_type || "generic",
+ action_target: messageData.action_target || null,
+ };
+
+ const notificationBasics = {
+ message: notificationData.title,
+ description: notificationData.description,
+ };
+
+ const wesClickAction = () => {
+ dispatch(markNotificationAsRead(notificationData.id));
+ dispatch(navigateToWESRun(notificationData.action_target, navigate));
+ };
+
+ switch (message.data.notification_type) {
+ case NOTIFICATION_WES_RUN_FAILED:
+ notification.error({
+ ...notificationBasics,
+ onClick: wesClickAction,
+ });
+ break;
+
+ case NOTIFICATION_WES_RUN_COMPLETED:
+ // Notify the user that the workflow succeeded
+ notification.success({
+ ...notificationBasics,
+ onClick: wesClickAction,
+ });
+ // Reload overview data when a workflow completes
+ dispatch(fetchOverviewSummary());
+ break;
+
+ default:
+ notification.open(notificationBasics);
+ }
+ },
};
diff --git a/src/modules/notifications/hooks.js b/src/modules/notifications/hooks.js
index b496c98c7..5660f4b4d 100644
--- a/src/modules/notifications/hooks.js
+++ b/src/modules/notifications/hooks.js
@@ -7,18 +7,18 @@ import { useService } from "@/modules/services/hooks";
import { fetchNotifications } from "./actions";
export const useNotifications = () => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const service = useService("notification");
+ const service = useService("notification");
- // TODO: notifications should eventually be user-specific and no longer need this permission/UI code.
- const { hasPermission } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, viewNotifications);
+ // TODO: notifications should eventually be user-specific and no longer need this permission/UI code.
+ const { hasPermission } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, viewNotifications);
- useEffect(() => {
- if (hasPermission) {
- dispatch(fetchNotifications()).catch((err) => console.error(err));
- }
- }, [dispatch, service, hasPermission]);
+ useEffect(() => {
+ if (hasPermission) {
+ dispatch(fetchNotifications()).catch((err) => console.error(err));
+ }
+ }, [dispatch, service, hasPermission]);
- return useSelector((state) => state.notifications);
+ return useSelector((state) => state.notifications);
};
diff --git a/src/modules/notifications/reducers.js b/src/modules/notifications/reducers.js
index 8f9a53c91..18c863a6c 100644
--- a/src/modules/notifications/reducers.js
+++ b/src/modules/notifications/reducers.js
@@ -1,111 +1,112 @@
import {
- SHOW_NOTIFICATION_DRAWER,
- HIDE_NOTIFICATION_DRAWER,
- ADD_NOTIFICATION,
- FETCH_NOTIFICATIONS,
- MARK_NOTIFICATION_AS_READ,
- MARK_ALL_NOTIFICATIONS_AS_READ,
+ SHOW_NOTIFICATION_DRAWER,
+ HIDE_NOTIFICATION_DRAWER,
+ ADD_NOTIFICATION,
+ FETCH_NOTIFICATIONS,
+ MARK_NOTIFICATION_AS_READ,
+ MARK_ALL_NOTIFICATIONS_AS_READ,
} from "./actions";
const unreadNotifications = (items) => items.filter((n) => !n.read);
export const notifications = (
- state = {
- isFetching: false,
- isMarkingAsRead: false,
- isMarkingAllAsRead: false,
- drawerVisible: false,
- items: [],
- itemsByID: {},
- unreadItems: [],
- },
- action,
+ state = {
+ isFetching: false,
+ isMarkingAsRead: false,
+ isMarkingAllAsRead: false,
+ drawerVisible: false,
+ items: [],
+ itemsByID: {},
+ unreadItems: [],
+ },
+ action,
) => {
- const replaceNotificationInArray = (rp) => state.items.map(i => i.id === action.notificationID ? rp(i) : i);
- const replaceNotificationInObject = (rp) => ({
- ...state.itemsByID,
- [action.notificationID]: {
- ...(state.itemsByID[action.notificationID] ?? {}),
- ...rp(state.itemsByID[action.notificationID] ?? {}),
- },
- });
+ const replaceNotificationInArray = (rp) => state.items.map((i) => (i.id === action.notificationID ? rp(i) : i));
+ const replaceNotificationInObject = (rp) => ({
+ ...state.itemsByID,
+ [action.notificationID]: {
+ ...(state.itemsByID[action.notificationID] ?? {}),
+ ...rp(state.itemsByID[action.notificationID] ?? {}),
+ },
+ });
- switch (action.type) {
- case ADD_NOTIFICATION: {
- const items = [...state.items, action.data];
- return {
- ...state,
- items,
- unreadItems: unreadNotifications(items),
- itemsByID: {
- ...state.itemsByID,
- [action.data.id]: action.data,
- },
- };
- }
+ switch (action.type) {
+ case ADD_NOTIFICATION: {
+ const items = [...state.items, action.data];
+ return {
+ ...state,
+ items,
+ unreadItems: unreadNotifications(items),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.data.id]: action.data,
+ },
+ };
+ }
- case FETCH_NOTIFICATIONS.REQUEST:
- return {...state, isFetching: true};
- case FETCH_NOTIFICATIONS.RECEIVE:
- return {
- ...state,
- items: action.data,
- unreadItems: unreadNotifications(action.data),
- itemsByID: Object.fromEntries(action.data.map(n => [n.id, n])),
- };
- case FETCH_NOTIFICATIONS.FINISH:
- return {...state, isFetching: false};
+ case FETCH_NOTIFICATIONS.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_NOTIFICATIONS.RECEIVE:
+ return {
+ ...state,
+ items: action.data,
+ unreadItems: unreadNotifications(action.data),
+ itemsByID: Object.fromEntries(action.data.map((n) => [n.id, n])),
+ };
+ case FETCH_NOTIFICATIONS.FINISH:
+ return { ...state, isFetching: false };
- case MARK_NOTIFICATION_AS_READ.REQUEST: {
- const rp = i => ({...i, isMarkingAsRead: true});
- return {
- ...state,
- isMarkingAsRead: true,
- items: replaceNotificationInArray(rp),
- unreadItems: replaceNotificationInArray(rp),
- itemsByID: replaceNotificationInObject(rp),
- };
- }
- case MARK_NOTIFICATION_AS_READ.RECEIVE: {
- const rp = i => ({...i, read: true});
- const items = replaceNotificationInArray(rp);
- return {
- ...state,
- items: items,
- unreadItems: unreadNotifications(items),
- itemsByID: replaceNotificationInObject(rp),
- };
- }
- case MARK_NOTIFICATION_AS_READ.FINISH: {
- const rp = i => ({...i, isMarkingAsRead: false});
- return {
- ...state,
- isMarkingAsRead: false,
- items: replaceNotificationInArray(rp),
- unreadItems: replaceNotificationInArray(rp), // should do nothing unless there's an error
- itemsByID: replaceNotificationInObject(rp),
- };
- }
+ case MARK_NOTIFICATION_AS_READ.REQUEST: {
+ const rp = (i) => ({ ...i, isMarkingAsRead: true });
+ return {
+ ...state,
+ isMarkingAsRead: true,
+ items: replaceNotificationInArray(rp),
+ unreadItems: replaceNotificationInArray(rp),
+ itemsByID: replaceNotificationInObject(rp),
+ };
+ }
+ case MARK_NOTIFICATION_AS_READ.RECEIVE: {
+ const rp = (i) => ({ ...i, read: true });
+ const items = replaceNotificationInArray(rp);
+ return {
+ ...state,
+ items: items,
+ unreadItems: unreadNotifications(items),
+ itemsByID: replaceNotificationInObject(rp),
+ };
+ }
+ case MARK_NOTIFICATION_AS_READ.FINISH: {
+ const rp = (i) => ({ ...i, isMarkingAsRead: false });
+ return {
+ ...state,
+ isMarkingAsRead: false,
+ items: replaceNotificationInArray(rp),
+ unreadItems: replaceNotificationInArray(rp), // should do nothing unless there's an error
+ itemsByID: replaceNotificationInObject(rp),
+ };
+ }
- case MARK_ALL_NOTIFICATIONS_AS_READ.REQUEST:
- return {...state, isMarkingAllAsRead: true};
- case MARK_ALL_NOTIFICATIONS_AS_READ.RECEIVE:
- return {
- ...state,
- items: state.items.map(i => !i.read ? {...i, read: true} : i),
- unreadItems: [],
- itemsByID: Object.fromEntries(Object.entries(state.itemsByID)
- .map(([k, v]) => [k, v.read ? v : {...v, read: true}])),
- };
- case MARK_ALL_NOTIFICATIONS_AS_READ.FINISH:
- return {...state, isMarkingAllAsRead: false};
+ case MARK_ALL_NOTIFICATIONS_AS_READ.REQUEST:
+ return { ...state, isMarkingAllAsRead: true };
+ case MARK_ALL_NOTIFICATIONS_AS_READ.RECEIVE:
+ return {
+ ...state,
+ items: state.items.map((i) => (!i.read ? { ...i, read: true } : i)),
+ unreadItems: [],
+ itemsByID: Object.fromEntries(
+ Object.entries(state.itemsByID).map(([k, v]) => [k, v.read ? v : { ...v, read: true }]),
+ ),
+ };
+ case MARK_ALL_NOTIFICATIONS_AS_READ.FINISH:
+ return { ...state, isMarkingAllAsRead: false };
- case SHOW_NOTIFICATION_DRAWER:
- return {...state, drawerVisible: true};
- case HIDE_NOTIFICATION_DRAWER:
- return {...state, drawerVisible: false};
+ case SHOW_NOTIFICATION_DRAWER:
+ return { ...state, drawerVisible: true };
+ case HIDE_NOTIFICATION_DRAWER:
+ return { ...state, drawerVisible: false };
- default:
- return state;
- }
+ default:
+ return state;
+ }
};
diff --git a/src/modules/reference/actions.js b/src/modules/reference/actions.js
index f5aecd680..6dffb5870 100644
--- a/src/modules/reference/actions.js
+++ b/src/modules/reference/actions.js
@@ -3,40 +3,41 @@ import { createNetworkActionTypes, networkAction } from "@/utils/actions";
export const FETCH_REFERENCE_GENOMES = createNetworkActionTypes("REFERENCE.FETCH_REFERENCE_GENOMES");
export const DELETE_REFERENCE_GENOME = createNetworkActionTypes("REFERENCE.DELETE_REFERENCE_GENOME");
-const fetchReferenceGenomes = networkAction(() => (dispatch, getState) => ({
- types: FETCH_REFERENCE_GENOMES,
- url: `${getState().services.itemsByKind.reference.url}/genomes`,
- err: "Error fetching reference genomes",
+const fetchReferenceGenomes = networkAction(() => (_dispatch, getState) => ({
+ types: FETCH_REFERENCE_GENOMES,
+ url: `${getState().services.itemsByKind.reference.url}/genomes`,
+ publicEndpoint: true,
+ err: "Error fetching reference genomes",
}));
export const fetchReferenceGenomesIfNeeded = () => (dispatch, getState) => {
- const state = getState();
- if (
- !state.services.itemsByKind.reference
- || state.referenceGenomes.isFetching
- || state.referenceGenomes.items.length
- ) {
- return Promise.resolve();
- }
- return dispatch(fetchReferenceGenomes());
+ const state = getState();
+ if (
+ !state.services.itemsByKind.reference ||
+ state.referenceGenomes.isFetching ||
+ state.referenceGenomes.items.length
+ ) {
+ return Promise.resolve();
+ }
+ return dispatch(fetchReferenceGenomes());
};
-const deleteReferenceGenome = networkAction((genomeID) => (dispatch, getState) => ({
- types: DELETE_REFERENCE_GENOME,
- params: {genomeID},
- url: `${getState().services.itemsByKind.reference.url}/genomes/${genomeID}`,
- req: { method: "DELETE" },
- err: `Error deleting reference genome ${genomeID}`,
+const deleteReferenceGenome = networkAction((genomeID) => (_dispatch, getState) => ({
+ types: DELETE_REFERENCE_GENOME,
+ params: { genomeID },
+ url: `${getState().services.itemsByKind.reference.url}/genomes/${genomeID}`,
+ req: { method: "DELETE" },
+ err: `Error deleting reference genome ${genomeID}`,
}));
export const deleteReferenceGenomeIfPossible = (genomeID) => (dispatch, getState) => {
- const state = getState();
- if (
- !state.services.itemsByKind.reference
- || state.referenceGenomes.isFetching
- || state.referenceGenomes.isDeletingIDs[genomeID]
- ) {
- return Promise.resolve();
- }
- return dispatch(deleteReferenceGenome(genomeID));
+ const state = getState();
+ if (
+ !state.services.itemsByKind.reference ||
+ state.referenceGenomes.isFetching ||
+ state.referenceGenomes.isDeletingIDs[genomeID]
+ ) {
+ return Promise.resolve();
+ }
+ return dispatch(deleteReferenceGenome(genomeID));
};
diff --git a/src/modules/reference/hooks.js b/src/modules/reference/hooks.js
index efcd54219..4b04b3593 100644
--- a/src/modules/reference/hooks.js
+++ b/src/modules/reference/hooks.js
@@ -1,14 +1,63 @@
-import { useEffect } from "react";
+import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
+import { useAuthorizationHeader } from "bento-auth-js";
+
import { useService } from "@/modules/services/hooks";
import { fetchReferenceGenomesIfNeeded } from "./actions";
export const useReferenceGenomes = () => {
- const dispatch = useDispatch();
- const referenceService = useService("reference");
- useEffect(() => {
- dispatch(fetchReferenceGenomesIfNeeded());
- }, [dispatch, referenceService]);
- return useSelector((state) => state.referenceGenomes);
+ const dispatch = useDispatch();
+ const referenceService = useService("reference");
+ useEffect(() => {
+ dispatch(fetchReferenceGenomesIfNeeded());
+ }, [dispatch, referenceService]);
+ return useSelector((state) => state.referenceGenomes);
+};
+
+/**
+ * @param {string | undefined} referenceGenomeID
+ * @param {string | null | undefined} nameQuery
+ */
+export const useGeneNameSearch = (referenceGenomeID, nameQuery) => {
+ const referenceService = useService("reference");
+
+ const authHeader = useAuthorizationHeader();
+
+ const [hasAttempted, setHasAttempted] = useState(false);
+ const [isFetching, setIsFetching] = useState(false);
+ const [data, setData] = useState([]);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!referenceService || !referenceGenomeID || !nameQuery) return;
+
+ const params = new URLSearchParams({ name: nameQuery, name_fzy: "true", limit: "10" });
+ const searchUrl = `${referenceService.url}/genomes/${referenceGenomeID}/features?${params.toString()}`;
+
+ setError(null);
+
+ (async () => {
+ setIsFetching(true);
+
+ try {
+ const res = await fetch(searchUrl, { headers: { Accept: "application/json", ...authHeader } });
+ const resData = await res.json();
+ if (res.ok) {
+ console.debug("Genome feature search - got results:", resData.results);
+ setData(resData.results);
+ } else {
+ setError(`Genome feature search failed with message: ${resData.message}`);
+ }
+ } catch (e) {
+ console.error(e);
+ setError(`Genome feature search failed: ${e.toString()}`);
+ } finally {
+ setIsFetching(false);
+ setHasAttempted(true);
+ }
+ })();
+ }, [referenceService, referenceGenomeID, nameQuery, authHeader]);
+
+ return { hasAttempted, isFetching, data, error };
};
diff --git a/src/modules/reference/reducers.js b/src/modules/reference/reducers.js
index c129b1d86..5d39830d8 100644
--- a/src/modules/reference/reducers.js
+++ b/src/modules/reference/reducers.js
@@ -2,44 +2,44 @@ import { DELETE_REFERENCE_GENOME, FETCH_REFERENCE_GENOMES } from "./actions";
import { arrayToObjectByProperty, objectWithoutProp } from "@/utils/misc";
export const referenceGenomes = (
- state = {
- hasAttempted: false,
- isFetching: false,
- isDeletingIDs: {},
- items: [],
- itemsByID: {},
- },
- action,
+ state = {
+ hasAttempted: false,
+ isFetching: false,
+ isDeletingIDs: {},
+ items: [],
+ itemsByID: {},
+ },
+ action,
) => {
- switch (action.type) {
- // FETCH_REFERENCE_GENOMES
- case FETCH_REFERENCE_GENOMES.REQUEST:
- return {...state, isFetching: true};
- case FETCH_REFERENCE_GENOMES.RECEIVE:
- return {
- ...state,
- items: action.data,
- itemsByID: arrayToObjectByProperty(action.data, "id"),
- };
- case FETCH_REFERENCE_GENOMES.FINISH:
- return {...state, isFetching: false, hasAttempted: true};
+ switch (action.type) {
+ // FETCH_REFERENCE_GENOMES
+ case FETCH_REFERENCE_GENOMES.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_REFERENCE_GENOMES.RECEIVE:
+ return {
+ ...state,
+ items: action.data,
+ itemsByID: arrayToObjectByProperty(action.data, "id"),
+ };
+ case FETCH_REFERENCE_GENOMES.FINISH:
+ return { ...state, isFetching: false, hasAttempted: true };
- // DELETE_REFERENCE_GENOME
- case DELETE_REFERENCE_GENOME.REQUEST:
- return {...state, isDeletingIDs: {...state.isDeletingIDs, [action.genomeID]: true}};
- case DELETE_REFERENCE_GENOME.RECEIVE: {
- const { genomeID } = action;
- return {
- ...state,
- items: state.items.filter((g) => g.id !== genomeID),
- itemsByID: objectWithoutProp(state.itemsByID, genomeID),
- };
- }
- case DELETE_REFERENCE_GENOME.FINISH: {
- return {...state, isDeletingIDs: objectWithoutProp(state.isDeletingIDs, action.genomeID)};
- }
-
- default:
- return state;
+ // DELETE_REFERENCE_GENOME
+ case DELETE_REFERENCE_GENOME.REQUEST:
+ return { ...state, isDeletingIDs: { ...state.isDeletingIDs, [action.genomeID]: true } };
+ case DELETE_REFERENCE_GENOME.RECEIVE: {
+ const { genomeID } = action;
+ return {
+ ...state,
+ items: state.items.filter((g) => g.id !== genomeID),
+ itemsByID: objectWithoutProp(state.itemsByID, genomeID),
+ };
+ }
+ case DELETE_REFERENCE_GENOME.FINISH: {
+ return { ...state, isDeletingIDs: objectWithoutProp(state.isDeletingIDs, action.genomeID) };
}
+
+ default:
+ return state;
+ }
};
diff --git a/src/modules/reference/types.ts b/src/modules/reference/types.ts
new file mode 100644
index 000000000..0d41bc4f4
--- /dev/null
+++ b/src/modules/reference/types.ts
@@ -0,0 +1,25 @@
+import type { OntologyTerm } from "@/types/ontology";
+
+export type Contig = {
+ name: string;
+ aliases: string[];
+ md5: string;
+ ga4gh: string;
+ length: number;
+ circular: boolean;
+ refget_uris: string[];
+};
+
+export type Genome = {
+ id: string;
+ aliases: string[];
+ md5: string;
+ ga4gh: string;
+ fasta: string;
+ fai: string;
+ gff3_gz: string;
+ gff3_gz_tbi: string;
+ taxon: OntologyTerm;
+ contigs: Contig[];
+ uri: string;
+};
diff --git a/src/modules/services/actions.js b/src/modules/services/actions.js
index 04da76a99..1c368a84e 100644
--- a/src/modules/services/actions.js
+++ b/src/modules/services/actions.js
@@ -1,13 +1,12 @@
-import {BENTO_PUBLIC_URL} from "@/config";
+import { BENTO_PUBLIC_URL } from "@/config";
import {
- createNetworkActionTypes,
- createFlowActionTypes,
- networkAction,
-
- beginFlow,
- endFlow,
- terminateFlow,
+ createNetworkActionTypes,
+ createFlowActionTypes,
+ networkAction,
+ beginFlow,
+ endFlow,
+ terminateFlow,
} from "@/utils/actions";
/**
@@ -16,7 +15,6 @@ import {
* @property {string} url
*/
-
export const LOADING_ALL_SERVICE_DATA = createFlowActionTypes("LOADING_ALL_SERVICE_DATA");
export const FETCH_BENTO_SERVICES = createNetworkActionTypes("FETCH_BENTO_SERVICES");
@@ -26,36 +24,37 @@ export const FETCH_WORKFLOWS = createNetworkActionTypes("FETCH_WORKFLOWS");
const SERVICE_REGISTRY = `${BENTO_PUBLIC_URL}/api/service-registry`;
-
export const fetchBentoServices = networkAction(() => ({
- types: FETCH_BENTO_SERVICES,
- check: (state) => !state.bentoServices.isFetching && !Object.keys(state.bentoServices.itemsByKind).length,
- url: `${SERVICE_REGISTRY}/bento-services`,
- err: "Error fetching Bento services list",
+ types: FETCH_BENTO_SERVICES,
+ check: (state) => !state.bentoServices.isFetching && !Object.keys(state.bentoServices.itemsByKind).length,
+ url: `${SERVICE_REGISTRY}/bento-services`,
+ publicEndpoint: true,
+ err: "Error fetching Bento services list",
}));
export const fetchServices = networkAction(() => ({
- types: FETCH_SERVICES,
- check: (state) => !state.services.isFetching && !state.services.items.length,
- url: `${SERVICE_REGISTRY}/services`,
- err: "Error fetching services",
+ types: FETCH_SERVICES,
+ check: (state) => !state.services.isFetching && !state.services.items.length,
+ url: `${SERVICE_REGISTRY}/services`,
+ publicEndpoint: true,
+ err: "Error fetching services",
}));
export const fetchDataTypes = networkAction(() => ({
- types: FETCH_DATA_TYPES,
- check: (state) => !state.serviceDataTypes.isFetching && !state.serviceDataTypes.items.length,
- url: `${SERVICE_REGISTRY}/data-types`,
- err: "Error fetching data types",
+ types: FETCH_DATA_TYPES,
+ check: (state) => !state.serviceDataTypes.isFetching && !state.serviceDataTypes.items.length,
+ url: `${SERVICE_REGISTRY}/data-types`,
+ err: "Error fetching data types",
}));
export const fetchWorkflows = networkAction(() => ({
- types: FETCH_WORKFLOWS,
- check: (state) => !state.serviceWorkflows.isFetching && !Object.keys(state.serviceWorkflows.items).length,
- url: `${SERVICE_REGISTRY}/workflows`,
- err: "Error fetching workflows",
+ types: FETCH_WORKFLOWS,
+ check: (state) => !state.serviceWorkflows.isFetching && !Object.keys(state.serviceWorkflows.items).length,
+ url: `${SERVICE_REGISTRY}/workflows`,
+ publicEndpoint: true,
+ err: "Error fetching workflows",
}));
-
export const fetchServicesWithMetadataAndDataTypes = () => async (dispatch, getState) => {
dispatch(beginFlow(LOADING_ALL_SERVICE_DATA));
@@ -68,24 +67,27 @@ export const fetchServicesWithMetadataAndDataTypes = () => async (dispatch, getS
]);
})(),
- dispatch(fetchDataTypes()),
- dispatch(fetchWorkflows()),
- ]);
+ dispatch(fetchDataTypes()),
+ dispatch(fetchWorkflows()),
+ ]);
- if (!getState().services.items) {
- // Something went wrong, terminate early
- dispatch(terminateFlow(LOADING_ALL_SERVICE_DATA));
- return;
- }
+ if (!getState().services.items) {
+ // Something went wrong, terminate early
+ dispatch(terminateFlow(LOADING_ALL_SERVICE_DATA));
+ return;
+ }
- dispatch(endFlow(LOADING_ALL_SERVICE_DATA));
+ dispatch(endFlow(LOADING_ALL_SERVICE_DATA));
};
-export const fetchServicesWithMetadataAndDataTypesIfNeeded = () =>
- (dispatch, getState) => {
- const state = getState();
- if ((Object.keys(state.bentoServices.itemsByArtifact).length === 0 || state.services.items.length === 0 ||
- state.serviceDataTypes.items.length === 0) && !state.services.isFetchingAll) {
- return dispatch(fetchServicesWithMetadataAndDataTypes());
- }
- };
+export const fetchServicesWithMetadataAndDataTypesIfNeeded = () => (dispatch, getState) => {
+ const state = getState();
+ if (
+ (Object.keys(state.bentoServices.itemsByArtifact).length === 0 ||
+ state.services.items.length === 0 ||
+ state.serviceDataTypes.items.length === 0) &&
+ !state.services.isFetchingAll
+ ) {
+ return dispatch(fetchServicesWithMetadataAndDataTypes());
+ }
+};
diff --git a/src/modules/services/hooks.js b/src/modules/services/hooks.js
index 297c1425f..f800d62dd 100644
--- a/src/modules/services/hooks.js
+++ b/src/modules/services/hooks.js
@@ -1,70 +1,78 @@
import { useEffect, useMemo } from "react";
-import { useDispatch, useSelector } from "react-redux";
+import { useSelector } from "react-redux";
import { fetchBentoServices, fetchServices } from "./actions";
import { fetchExtraPropertiesSchemaTypes } from "@/modules/metadata/actions";
import { fetchDiscoverySchema } from "@/modules/discovery/actions";
import { performGetGohanVariantsOverviewIfPossible } from "@/modules/explorer/actions";
+import { useAppDispatch } from "@/store";
export const useBentoServices = () => {
- const dispatch = useDispatch();
- useEffect(() => {
- dispatch(fetchBentoServices()).catch((err) => console.error(err));
- }, [dispatch]);
- return useSelector((state) => state.bentoServices);
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(fetchBentoServices()).catch((err) => console.error(err));
+ }, [dispatch]);
+ return useSelector((state) => state.bentoServices);
};
export const useBentoService = (kind) => {
- const bentoServices = useBentoServices();
- return bentoServices.itemsByKind[kind];
+ const bentoServices = useBentoServices();
+ return bentoServices.itemsByKind[kind];
};
export const useServices = () => {
- const dispatch = useDispatch();
- useEffect(() => {
- dispatch(fetchServices()).catch((err) => console.error(err));
- }, [dispatch]);
- return useSelector((state) => state.services); // From service registry; service-info style
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(fetchServices()).catch((err) => console.error(err));
+ }, [dispatch]);
+ return useSelector((state) => state.services); // From service registry; service-info style
};
export const useService = (kind) => {
- const services = useServices();
- return services.itemsByKind[kind];
+ const services = useServices();
+ return services.itemsByKind[kind];
+};
+
+export const useDataTypes = () => {
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(fetchDataTypes()).catch((err) => console.error(err));
+ }, [dispatch]);
+ return useSelector((state) => state.serviceDataTypes);
};
export const useWorkflows = () => {
- const isFetchingAllServices = useServices().isFetchingAll;
- const {
- isFetching: isFetchingServiceWorkflows,
- items: serviceWorkflows,
- } = useSelector((state) => state.serviceWorkflows);
+ const isFetchingAllServices = useServices().isFetchingAll;
+ const { isFetching: isFetchingServiceWorkflows, items: serviceWorkflows } = useSelector(
+ (state) => state.serviceWorkflows,
+ );
- const workflowsLoading = isFetchingAllServices || isFetchingServiceWorkflows;
+ const workflowsLoading = isFetchingAllServices || isFetchingServiceWorkflows;
- return useMemo(() => {
- const workflowsByType = {
- ingestion: { items: [], itemsByID: {} },
- analysis: { items: [], itemsByID: {} },
- export: { items: [], itemsByID: {} },
- };
+ return useMemo(() => {
+ const workflowsByType = {
+ ingestion: { items: [], itemsByID: {} },
+ analysis: { items: [], itemsByID: {} },
+ export: { items: [], itemsByID: {} },
+ };
- Object.entries(serviceWorkflows).forEach(([workflowType, workflowTypeWorkflows]) => {
- if (!(workflowType in workflowsByType)) return;
+ Object.entries(serviceWorkflows).forEach(([workflowType, workflowTypeWorkflows]) => {
+ if (!(workflowType in workflowsByType)) return;
- // noinspection JSCheckFunctionSignatures
- Object.entries(workflowTypeWorkflows).forEach(([k, v]) => {
- const wf = { ...v, id: k };
- workflowsByType[workflowType].items.push(wf);
- workflowsByType[workflowType].itemsByID[k] = wf;
- });
- });
+ // noinspection JSCheckFunctionSignatures
+ Object.entries(workflowTypeWorkflows).forEach(([k, v]) => {
+ const wf = { ...v, id: k };
+ workflowsByType[workflowType].items.push(wf);
+ workflowsByType[workflowType].itemsByID[k] = wf;
+ });
+ });
- return { workflowsByType, workflowsLoading };
- }, [serviceWorkflows, workflowsLoading]);
+ return { workflowsByType, workflowsLoading };
+ }, [serviceWorkflows, workflowsLoading]);
};
export const useMetadataDependentData = () => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const metadata = useBentoService("metadata");
useEffect(() => {
if (!metadata?.url) return;
@@ -74,7 +82,7 @@ export const useMetadataDependentData = () => {
};
export const useGohanDependentData = () => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const gohan = useBentoService("gohan");
useEffect(() => {
if (!gohan?.url) return;
diff --git a/src/modules/services/reducers.js b/src/modules/services/reducers.js
index d25e86e06..7b6899d79 100644
--- a/src/modules/services/reducers.js
+++ b/src/modules/services/reducers.js
@@ -1,171 +1,168 @@
import {
- LOADING_ALL_SERVICE_DATA,
-
- FETCH_BENTO_SERVICES,
- FETCH_SERVICES,
- FETCH_DATA_TYPES,
- FETCH_WORKFLOWS,
+ LOADING_ALL_SERVICE_DATA,
+ FETCH_BENTO_SERVICES,
+ FETCH_SERVICES,
+ FETCH_DATA_TYPES,
+ FETCH_WORKFLOWS,
} from "./actions";
-import {normalizeServiceInfo} from "@/utils/serviceInfo";
-
export const bentoServices = (
- state = {
- isFetching: false,
- itemsByArtifact: {},
- itemsByKind: {},
- },
- action,
+ state = {
+ isFetching: false,
+ itemsByArtifact: {},
+ itemsByKind: {},
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_BENTO_SERVICES.REQUEST:
- return {...state, isFetching: true};
- case FETCH_BENTO_SERVICES.RECEIVE:
- if (Array.isArray(action.data)) {
- // Handle old CHORD services format
- // TODO: Remove when no longer relevant
- console.warn("The old chord_services.json format will be deprecated soon.");
- const byArtifact = Object.fromEntries(action.data.map(s => [s.type.artifact, s]));
- return {
- ...state,
- itemsByArtifact: byArtifact,
- itemsByKind: byArtifact, // technically wrong but only rarely; deprecated code to be removed
- };
- }
-
- // Handle the new Bento services format: an object with the docker-compose service ID as the key
- return {
- ...state,
- itemsByArtifact: Object.fromEntries(Object.entries(action.data).map(([composeID, service]) => ([
- service.artifact,
- {...service, composeID},
- ]))),
- itemsByKind: Object.fromEntries(Object.entries(action.data).map(([composeID, service]) => ([
- service.service_kind ?? service.artifact,
- {...service, composeID},
- ]))),
- };
- case FETCH_BENTO_SERVICES.FINISH:
- return {...state, isFetching: false};
-
- default:
- return state;
- }
+ switch (action.type) {
+ case FETCH_BENTO_SERVICES.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_BENTO_SERVICES.RECEIVE:
+ if (Array.isArray(action.data)) {
+ // Handle old CHORD services format
+ // TODO: Remove when no longer relevant
+ console.warn("The old chord_services.json format will be deprecated soon.");
+ const byArtifact = Object.fromEntries(action.data.map((s) => [s.type.artifact, s]));
+ return {
+ ...state,
+ itemsByArtifact: byArtifact,
+ itemsByKind: byArtifact, // technically wrong but only rarely; deprecated code to be removed
+ };
+ }
+
+ // Handle the new Bento services format: an object with the docker-compose service ID as the key
+ return {
+ ...state,
+ itemsByArtifact: Object.fromEntries(
+ Object.entries(action.data).map(([composeID, service]) => [service.artifact, { ...service, composeID }]),
+ ),
+ itemsByKind: Object.fromEntries(
+ Object.entries(action.data).map(([composeID, service]) => [
+ service.service_kind ?? service.artifact,
+ { ...service, composeID },
+ ]),
+ ),
+ };
+ case FETCH_BENTO_SERVICES.FINISH:
+ return { ...state, isFetching: false };
+
+ default:
+ return state;
+ }
};
export const services = (
- state = {
- isFetching: false,
- isFetchingAll: false, // TODO: Rename this, since it means more "all data including other stuff"
- items: [],
- itemsByID: {},
- itemsByArtifact: {},
- itemsByKind: {},
-
- aggregationService: null,
- dropBoxService: null,
- eventRelay: null,
- metadataService: null,
- notificationService: null,
- wesService: null,
- },
- action,
+ state = {
+ isFetching: false,
+ isFetchingAll: false, // TODO: Rename this, since it means more "all data including other stuff"
+ items: [],
+ itemsByID: {},
+ itemsByArtifact: {},
+ itemsByKind: {},
+
+ aggregationService: null,
+ dropBoxService: null,
+ eventRelay: null,
+ metadataService: null,
+ notificationService: null,
+ wesService: null,
+ },
+ action,
) => {
- switch (action.type) {
- case LOADING_ALL_SERVICE_DATA.BEGIN:
- return {...state, isFetchingAll: true};
-
- case LOADING_ALL_SERVICE_DATA.END:
- case LOADING_ALL_SERVICE_DATA.TERMINATE:
- return {...state, isFetchingAll: false};
-
- case FETCH_SERVICES.REQUEST:
- return {...state, isFetching: true};
-
- case FETCH_SERVICES.RECEIVE: {
- // Filter out services without a valid serviceInfo.type & normalize service infos across spec versions:
- const items = action.data.filter(s => s?.type).map(normalizeServiceInfo);
- const itemsByID = Object.fromEntries(items.map(s => [s.id, s]));
- const itemsByKind = Object.fromEntries(items.map(s => [s.bento?.serviceKind ?? s.type.artifact, s]));
- const itemsByArtifact = Object.fromEntries(items.map(s => [s.type.artifact, s]));
-
- return {
- ...state,
-
- items,
- itemsByID,
- itemsByKind,
- itemsByArtifact,
-
- // Backwards-compatibility with older Bento versions, where this was called 'federation'
- aggregationService: itemsByKind["aggregation"] ?? itemsByKind["federation"] ?? null,
- dropBoxService: itemsByKind["drop-box"] ?? null,
- drsService: itemsByKind["drs"] ?? null,
- eventRelay: itemsByKind["event-relay"] ?? null,
- notificationService: itemsByKind["notification"] ?? null,
- metadataService: itemsByKind["metadata"] ?? null,
- wesService: itemsByKind["wes"] ?? null,
-
- lastUpdated: action.receivedAt,
- };
- }
-
- case FETCH_SERVICES.FINISH:
- return {...state, isFetching: false};
-
- default:
- return state;
+ switch (action.type) {
+ case LOADING_ALL_SERVICE_DATA.BEGIN:
+ return { ...state, isFetchingAll: true };
+
+ case LOADING_ALL_SERVICE_DATA.END:
+ case LOADING_ALL_SERVICE_DATA.TERMINATE:
+ return { ...state, isFetchingAll: false };
+
+ case FETCH_SERVICES.REQUEST:
+ return { ...state, isFetching: true };
+
+ case FETCH_SERVICES.RECEIVE: {
+ // Filter out services without a valid serviceInfo.type:
+ const items = action.data.filter((s) => s?.type);
+ const itemsByID = Object.fromEntries(items.map((s) => [s.id, s]));
+ const itemsByKind = Object.fromEntries(items.map((s) => [s.bento?.serviceKind ?? s.type.artifact, s]));
+ const itemsByArtifact = Object.fromEntries(items.map((s) => [s.type.artifact, s]));
+
+ return {
+ ...state,
+
+ items,
+ itemsByID,
+ itemsByKind,
+ itemsByArtifact,
+
+ // Backwards-compatibility with older Bento versions, where this was called 'federation'
+ aggregationService: itemsByKind["aggregation"] ?? itemsByKind["federation"] ?? null,
+ dropBoxService: itemsByKind["drop-box"] ?? null,
+ drsService: itemsByKind["drs"] ?? null,
+ eventRelay: itemsByKind["event-relay"] ?? null,
+ notificationService: itemsByKind["notification"] ?? null,
+ metadataService: itemsByKind["metadata"] ?? null,
+ wesService: itemsByKind["wes"] ?? null,
+
+ lastUpdated: action.receivedAt,
+ };
}
-};
+ case FETCH_SERVICES.FINISH:
+ return { ...state, isFetching: false };
+
+ default:
+ return state;
+ }
+};
export const serviceDataTypes = (
- state = {
- isFetching: false,
- itemsByID: {},
- items: [],
- },
- action,
+ state = {
+ isFetching: false,
+ itemsByID: {},
+ items: [],
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_DATA_TYPES.REQUEST:
- return {...state, isFetching: true};
- case FETCH_DATA_TYPES.RECEIVE:
- return {
- ...state,
- items: action.data,
- itemsByID: Object.fromEntries(action.data.map(dt => [dt.id, dt])),
- };
- case FETCH_DATA_TYPES.FINISH:
- return {...state, isFetching: false};
-
- default:
- return state;
- }
+ switch (action.type) {
+ case FETCH_DATA_TYPES.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_DATA_TYPES.RECEIVE:
+ return {
+ ...state,
+ items: action.data,
+ itemsByID: Object.fromEntries(action.data.map((dt) => [dt.id, dt])),
+ };
+ case FETCH_DATA_TYPES.FINISH:
+ return { ...state, isFetching: false };
+
+ default:
+ return state;
+ }
};
export const serviceWorkflows = (
- state = {
- isFetching: false,
- items: {}, // by purpose and then by workflow ID
- },
- action,
+ state = {
+ isFetching: false,
+ items: {}, // by purpose and then by workflow ID
+ },
+ action,
) => {
- switch (action.type) {
- case FETCH_WORKFLOWS.REQUEST:
- return {...state, isFetching: true};
- case FETCH_WORKFLOWS.RECEIVE:
- return {
- ...state,
- items: action.data,
- };
- case FETCH_WORKFLOWS.FINISH:
- return {
- ...state,
- isFetching: false,
- };
-
- default:
- return state;
- }
+ switch (action.type) {
+ case FETCH_WORKFLOWS.REQUEST:
+ return { ...state, isFetching: true };
+ case FETCH_WORKFLOWS.RECEIVE:
+ return {
+ ...state,
+ items: action.data,
+ };
+ case FETCH_WORKFLOWS.FINISH:
+ return {
+ ...state,
+ isFetching: false,
+ };
+
+ default:
+ return state;
+ }
};
diff --git a/src/modules/services/types.ts b/src/modules/services/types.ts
new file mode 100644
index 000000000..fd229e692
--- /dev/null
+++ b/src/modules/services/types.ts
@@ -0,0 +1,47 @@
+export type GA4GHServiceInfo = {
+ id: string;
+ name: string;
+ version: string;
+ type: {
+ group: string;
+ artifact: string;
+ version: string;
+ };
+
+ organization: {
+ name: string;
+ url: string;
+ };
+ contactUrl?: string;
+
+ environment: "dev" | "prod";
+ url: string;
+
+ bento?: {
+ serviceKind: string;
+ gitTag?: string;
+ gitBranch?: string;
+ gitCommit?: string;
+ gitRepository?: string;
+ };
+};
+
+export type BentoService = {
+ service_kind: string;
+ url_template: string;
+ repository: string;
+ url: string;
+};
+
+export interface BentoDataType {
+ id: string;
+ label: string;
+ queryable: boolean;
+ schema: object;
+ metadata_schema: object;
+ count?: number;
+}
+
+export interface BentoServiceDataType extends BentoDataType {
+ service_base_url: string;
+}
diff --git a/src/modules/services/utils.js b/src/modules/services/utils.js
index 5b8777661..7daed128c 100644
--- a/src/modules/services/utils.js
+++ b/src/modules/services/utils.js
@@ -1,2 +1,2 @@
export const getDataServices = (state) =>
- state.services.items.filter(serviceInfo => serviceInfo.bento?.dataService ?? false);
+ state.services.items.filter((serviceInfo) => serviceInfo.bento?.dataService ?? false);
diff --git a/src/modules/user/actions.js b/src/modules/user/actions.js
index 460383a76..2c582529d 100644
--- a/src/modules/user/actions.js
+++ b/src/modules/user/actions.js
@@ -1,35 +1,35 @@
import { beginFlow, createFlowActionTypes, endFlow } from "@/utils/actions";
import { nop } from "@/utils/misc";
import { fetchDatasetsDataTypes } from "../datasets/actions";
-import { fetchServicesWithMetadataAndDataTypesIfNeeded } from "../services/actions";
import { fetchProjectsWithDatasets } from "../metadata/actions";
+import { fetchServicesWithMetadataAndDataTypesIfNeeded } from "../services/actions";
export const FETCHING_USER_DEPENDENT_DATA = createFlowActionTypes("FETCHING_USER_DEPENDENT_DATA");
export const fetchUserDependentData = (servicesCb) => async (dispatch, getState) => {
- const { idTokenContents, hasAttempted } = getState().auth;
- const { isFetchingDependentData } = getState().user;
+ const { idTokenContents, hasAttempted } = getState().auth;
+ const { isFetchingDependentData } = getState().user;
- // If action is already being executed elsewhere, leave.
- if (isFetchingDependentData) return;
+ // If action is already being executed elsewhere, leave.
+ if (isFetchingDependentData) return;
- // The reason this flow is only triggered the first time it is called
- // is because we want to silently check the user / auth status without
- // any loading indicators afterward.
- if (hasAttempted) return;
+ // The reason this flow is only triggered the first time it is called
+ // is because we want to silently check the user / auth status without
+ // any loading indicators afterward.
+ if (hasAttempted) return;
- dispatch(beginFlow(FETCHING_USER_DEPENDENT_DATA));
- try {
- if (idTokenContents) {
- // If we're newly authenticated as an owner, we run all actions that may have changed with authentication
- // (via the callback).
- // TODO: invalidate projects/datasets/other user-dependent data
- await dispatch(fetchServicesWithMetadataAndDataTypesIfNeeded());
- await (servicesCb || nop)();
- await dispatch(fetchProjectsWithDatasets());
- await dispatch(fetchDatasetsDataTypes());
- }
- } finally {
- dispatch(endFlow(FETCHING_USER_DEPENDENT_DATA));
+ dispatch(beginFlow(FETCHING_USER_DEPENDENT_DATA));
+ try {
+ if (idTokenContents) {
+ // If we're newly authenticated as an owner, we run all actions that may have changed with authentication
+ // (via the callback).
+ // TODO: invalidate projects/datasets/other user-dependent data
+ await dispatch(fetchServicesWithMetadataAndDataTypesIfNeeded());
+ await (servicesCb || nop)();
+ await dispatch(fetchProjectsWithDatasets());
+ await dispatch(fetchDatasetsDataTypes());
}
+ } finally {
+ dispatch(endFlow(FETCHING_USER_DEPENDENT_DATA));
+ }
};
diff --git a/src/modules/user/reducers.js b/src/modules/user/reducers.js
index 40221203c..04fa2e2e5 100644
--- a/src/modules/user/reducers.js
+++ b/src/modules/user/reducers.js
@@ -1,19 +1,19 @@
import { FETCHING_USER_DEPENDENT_DATA } from "./actions";
export const user = (
- state = {
- isFetchingUserDependentData: false,
- },
- action,
+ state = {
+ isFetchingUserDependentData: false,
+ },
+ action,
) => {
- switch (action.type) {
- // FETCHING_USER_DEPENDENT_DATA
- case FETCHING_USER_DEPENDENT_DATA.BEGIN:
- return { ...state, isFetchingDependentData: true };
- case FETCHING_USER_DEPENDENT_DATA.END:
- case FETCHING_USER_DEPENDENT_DATA.TERMINATE:
- return { ...state, isFetchingDependentData: false, hasAttempted: true };
- default:
- return state;
- }
+ switch (action.type) {
+ // FETCHING_USER_DEPENDENT_DATA
+ case FETCHING_USER_DEPENDENT_DATA.BEGIN:
+ return { ...state, isFetchingDependentData: true };
+ case FETCHING_USER_DEPENDENT_DATA.END:
+ case FETCHING_USER_DEPENDENT_DATA.TERMINATE:
+ return { ...state, isFetchingDependentData: false, hasAttempted: true };
+ default:
+ return state;
+ }
};
diff --git a/src/modules/wes/actions.js b/src/modules/wes/actions.js
index dae37443f..b3b8c4dc0 100644
--- a/src/modules/wes/actions.js
+++ b/src/modules/wes/actions.js
@@ -1,4 +1,4 @@
-import {message} from "antd";
+import { message } from "antd";
import { createNetworkActionTypes, networkAction } from "@/utils/actions";
import { createFormData } from "@/utils/requests";
@@ -10,12 +10,11 @@ export const FETCH_RUN_LOG_STDERR = createNetworkActionTypes("FETCH_RUN_LOG_STDE
export const SUBMIT_WORKFLOW_RUN = createNetworkActionTypes("SUBMIT_WORKFLOW_RUN");
-
export const fetchRuns = networkAction(() => (dispatch, getState) => ({
- types: FETCH_RUNS,
- check: (state) => state.services.itemsByKind.wes && !state.runs.isFetching,
- url: `${getState().services.wesService.url}/runs?with_details=true`,
- err: "Error fetching WES runs",
+ types: FETCH_RUNS,
+ check: (state) => state.services.itemsByKind.wes && !state.runs.isFetching,
+ url: `${getState().services.wesService.url}/runs?with_details=true`,
+ err: "Error fetching WES runs",
}));
/**
@@ -26,125 +25,127 @@ export const fetchRuns = networkAction(() => (dispatch, getState) => ({
* @return {{data, runID, type: string, ts: number | undefined}}
*/
export const receiveRunDetails = (runID, data, timestamp = undefined) => ({
- type: FETCH_RUN_DETAILS.RECEIVE,
- runID,
- data,
- receivedAt: timestamp ?? new Date().getTime(),
+ type: FETCH_RUN_DETAILS.RECEIVE,
+ runID,
+ data,
+ receivedAt: timestamp ?? new Date().getTime(),
});
-export const fetchRunDetails = networkAction(runID => (dispatch, getState) => ({
- types: FETCH_RUN_DETAILS,
- params: {runID},
- url: `${getState().services.wesService.url}/runs/${runID}`,
- err: `Error fetching run details for run ${runID}`,
+export const fetchRunDetails = networkAction((runID) => (dispatch, getState) => ({
+ types: FETCH_RUN_DETAILS,
+ params: { runID },
+ url: `${getState().services.wesService.url}/runs/${runID}`,
+ err: `Error fetching run details for run ${runID}`,
}));
-
const RUN_DONE_STATES = ["COMPLETE", "EXECUTOR_ERROR", "SYSTEM_ERROR", "CANCELED"];
-export const fetchRunDetailsIfNeeded = runID => async (dispatch, getState) => {
- const run = getState().runs.itemsByID[runID]; // run | undefined
+export const fetchRunDetailsIfNeeded = (runID) => async (dispatch, getState) => {
+ const run = getState().runs.itemsByID[runID]; // run | undefined
- const needsUpdate = !run || (
- !run.isFetching && (
- !run.details || (
- !RUN_DONE_STATES.includes(run.state) &&
- run.details.run_log.exit_code === null &&
- run.details.run_log.end_time === "")));
+ const needsUpdate =
+ !run ||
+ (!run.isFetching &&
+ (!run.details ||
+ (!RUN_DONE_STATES.includes(run.state) &&
+ run.details.run_log.exit_code === null &&
+ run.details.run_log.end_time === "")));
- if (!needsUpdate) return;
+ if (!needsUpdate) return;
- await dispatch(fetchRunDetails(runID));
- if (getState().runs.itemsByID[runID].details) { // need to re-fetch run from state to check if we've got details
- await dispatch(fetchRunLogs(runID));
- }
+ await dispatch(fetchRunDetails(runID));
+ if (getState().runs.itemsByID[runID].details) {
+ // need to re-fetch run from state to check if we've got details
+ await dispatch(fetchRunLogs(runID));
+ }
};
export const fetchAllRunDetailsIfNeeded = () => (dispatch, getState) =>
- Promise.all(getState().runs.items.map(r => dispatch(fetchRunDetailsIfNeeded(r.run_id))));
-
-
-export const fetchRunLogStdOut = networkAction(runDetails => ({
- types: FETCH_RUN_LOG_STDOUT,
- params: {runID: runDetails.run_id},
- url: runDetails.run_log.stdout,
- parse: r => r.text(),
- err: `Error fetching stdout for run ${runDetails.run_id}`,
+ Promise.all(getState().runs.items.map((r) => dispatch(fetchRunDetailsIfNeeded(r.run_id))));
+
+export const fetchRunLogStdOut = networkAction((runDetails) => ({
+ types: FETCH_RUN_LOG_STDOUT,
+ params: { runID: runDetails.run_id },
+ url: runDetails.run_log.stdout,
+ parse: (r) => r.text(),
+ err: `Error fetching stdout for run ${runDetails.run_id}`,
}));
-export const fetchRunLogStdErr = networkAction(runDetails => ({
- types: FETCH_RUN_LOG_STDERR,
- params: {runID: runDetails.run_id},
- url: runDetails.run_log.stderr,
- parse: r => r.text(),
- err: `Error fetching stderr for run ${runDetails.run_id}`,
+export const fetchRunLogStdErr = networkAction((runDetails) => ({
+ types: FETCH_RUN_LOG_STDERR,
+ params: { runID: runDetails.run_id },
+ url: runDetails.run_log.stderr,
+ parse: (r) => r.text(),
+ err: `Error fetching stderr for run ${runDetails.run_id}`,
}));
-export const fetchRunLogs = runID => (dispatch, getState) => Promise.all([
+export const fetchRunLogs = (runID) => (dispatch, getState) =>
+ Promise.all([
dispatch(fetchRunLogStdOut(getState().runs.itemsByID[runID].details)),
dispatch(fetchRunLogStdErr(getState().runs.itemsByID[runID].details)),
-]);
-
-export const fetchRunLogStreamsIfPossibleAndNeeded = runID => (dispatch, getState) => {
- if (getState().runs.isFetching) return;
- const run = getState().runs.itemsByID[runID];
- if (!run || run.isFetching || !run.details) return;
- const runStreams = getState().runs.streamsByID[runID] || {};
- if (runStreams.stdout?.isFetching || runStreams.stderr?.isFetching) return Promise.resolve();
- if (RUN_DONE_STATES.includes(run.state)
- && runStreams.hasOwnProperty("stdout")
- && runStreams.stdout.data !== null
- && runStreams.hasOwnProperty("stderr")
- && runStreams.stderr.data !== null) return Promise.resolve(); // No new output expected
- return Promise.all([
- dispatch(fetchRunLogStdOut(run.details)),
- dispatch(fetchRunLogStdErr(run.details)),
- ]);
+ ]);
+
+export const fetchRunLogStreamsIfPossibleAndNeeded = (runID) => (dispatch, getState) => {
+ if (getState().runs.isFetching) return;
+ const run = getState().runs.itemsByID[runID];
+ if (!run || run.isFetching || !run.details) return;
+ const runStreams = getState().runs.streamsByID[runID] || {};
+ if (runStreams.stdout?.isFetching || runStreams.stderr?.isFetching) return Promise.resolve();
+ if (
+ RUN_DONE_STATES.includes(run.state) &&
+ runStreams.hasOwnProperty("stdout") &&
+ runStreams.stdout.data !== null &&
+ runStreams.hasOwnProperty("stderr") &&
+ runStreams.stderr.data !== null
+ )
+ return Promise.resolve(); // No new output expected
+ return Promise.all([dispatch(fetchRunLogStdOut(run.details)), dispatch(fetchRunLogStdErr(run.details))]);
};
-
export const submitWorkflowRun = networkAction(
- (serviceBaseUrl, workflow, inputs, onSuccess, errorMessage) => (dispatch, getState) => {
- const serviceUrlRStrip = serviceBaseUrl.replace(/\/$/, "");
-
- const runRequest = {
- workflow_params: Object.fromEntries(Object.entries(inputs ?? {})
- .map(([k, v]) => [`${workflow.id}.${k}`, v])),
- workflow_type: "WDL", // TODO: Should eventually not be hard-coded
- workflow_type_version: "1.0", // TODO: "
- workflow_engine_parameters: {}, // TODO: Currently unused
- workflow_url: `${serviceUrlRStrip}/workflows/${workflow.id}.wdl`,
- tags: {
- workflow_id: workflow.id,
- workflow_metadata: workflow,
- },
- };
-
- return {
- types: SUBMIT_WORKFLOW_RUN,
- params: { request: runRequest },
- url: `${getState().services.wesService.url}/runs`,
- req: {
- method: "POST",
- body: createFormData(runRequest),
- },
- err: errorMessage,
- onSuccess,
- };
- });
+ (serviceBaseUrl, workflow, inputs, onSuccess, errorMessage) => (dispatch, getState) => {
+ const serviceUrlRStrip = serviceBaseUrl.replace(/\/$/, "");
+
+ const runRequest = {
+ workflow_params: Object.fromEntries(Object.entries(inputs ?? {}).map(([k, v]) => [`${workflow.id}.${k}`, v])),
+ workflow_type: "WDL", // TODO: Should eventually not be hard-coded
+ workflow_type_version: "1.0", // TODO: "
+ workflow_engine_parameters: {}, // TODO: Currently unused
+ workflow_url: `${serviceUrlRStrip}/workflows/${workflow.id}.wdl`,
+ tags: {
+ workflow_id: workflow.id,
+ workflow_metadata: workflow,
+ },
+ };
+
+ return {
+ types: SUBMIT_WORKFLOW_RUN,
+ params: { request: runRequest },
+ url: `${getState().services.wesService.url}/runs`,
+ req: {
+ method: "POST",
+ body: createFormData(runRequest),
+ },
+ err: errorMessage,
+ onSuccess,
+ };
+ },
+);
const _workflowSubmitAction = (type) => (workflow, inputs, redirect, navigate) => (dispatch) =>
- dispatch(submitWorkflowRun(
- workflow.service_base_url,
- workflow,
- inputs,
- run => { // onSuccess
- message.success(
- `${type.charAt(0).toUpperCase()}${type.substring(1)} with run ID "${run.run_id}" submitted!`);
- if (redirect) navigate(redirect);
- },
- `Error submitting ${type} workflow`, // errorMessage
- ));
+ dispatch(
+ submitWorkflowRun(
+ workflow.service_base_url,
+ workflow,
+ inputs,
+ (run) => {
+ // onSuccess
+ message.success(`${type.charAt(0).toUpperCase()}${type.substring(1)} with run ID "${run.run_id}" submitted!`);
+ if (redirect) navigate(redirect);
+ },
+ `Error submitting ${type} workflow`, // errorMessage
+ ),
+ );
export const submitIngestionWorkflowRun = _workflowSubmitAction("ingestion");
export const submitAnalysisWorkflowRun = _workflowSubmitAction("analysis");
diff --git a/src/modules/wes/events.js b/src/modules/wes/events.js
index 7547eb7be..4a135c36f 100644
--- a/src/modules/wes/events.js
+++ b/src/modules/wes/events.js
@@ -1,16 +1,16 @@
-import {fetchRunLogs, receiveRunDetails} from "./actions";
+import { fetchRunLogs, receiveRunDetails } from "./actions";
const EVENT_WES_RUN_UPDATED = "wes_run_updated";
export default {
- [/^bento\.service\.wes$/.source]: (message) => async (dispatch) => {
- if (message.type === EVENT_WES_RUN_UPDATED) {
- const { data: runDetails, timestamp } = message;
- dispatch(receiveRunDetails(runDetails.run_id, runDetails, timestamp));
- if (runDetails.run_log.exit_code !== null) {
- // Event just finished, trigger a stdout/stderr update
- await dispatch(fetchRunLogs(runDetails.run_id));
- }
- }
- },
+ [/^bento\.service\.wes$/.source]: (message) => async (dispatch) => {
+ if (message.type === EVENT_WES_RUN_UPDATED) {
+ const { data: runDetails, timestamp } = message;
+ dispatch(receiveRunDetails(runDetails.run_id, runDetails, timestamp));
+ if (runDetails.run_log.exit_code !== null) {
+ // Event just finished, trigger a stdout/stderr update
+ await dispatch(fetchRunLogs(runDetails.run_id));
+ }
+ }
+ },
};
diff --git a/src/modules/wes/hooks.js b/src/modules/wes/hooks.js
index 35291c5b4..a8619aae9 100644
--- a/src/modules/wes/hooks.js
+++ b/src/modules/wes/hooks.js
@@ -8,17 +8,17 @@ import { useService } from "@/modules/services/hooks";
import { fetchRuns } from "@/modules/wes/actions";
export const useRuns = () => {
- const dispatch = useDispatch();
+ const dispatch = useDispatch();
- const wes = useService("wes"); // TODO: associate this with the network action somehow
- const { hasPermission } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, viewRuns);
+ const wes = useService("wes"); // TODO: associate this with the network action somehow
+ const { hasPermission } = useHasResourcePermissionWrapper(RESOURCE_EVERYTHING, viewRuns);
- useEffect(() => {
- // If hasPermission changes to true, this will automatically dispatch the drop box fetch method.
- if (hasPermission) {
- dispatch(fetchRuns()).catch((err) => console.error(err));
- }
- }, [dispatch, wes, hasPermission]);
+ useEffect(() => {
+ // If hasPermission changes to true, this will automatically dispatch the drop box fetch method.
+ if (hasPermission) {
+ dispatch(fetchRuns()).catch((err) => console.error(err));
+ }
+ }, [dispatch, wes, hasPermission]);
- return useSelector((state) => state.runs);
+ return useSelector((state) => state.runs);
};
diff --git a/src/modules/wes/reducers.js b/src/modules/wes/reducers.js
index 910745049..3552bf2c1 100644
--- a/src/modules/wes/reducers.js
+++ b/src/modules/wes/reducers.js
@@ -1,187 +1,185 @@
import {
- FETCH_RUNS,
-
- FETCH_RUN_DETAILS,
- FETCH_RUN_LOG_STDOUT,
- FETCH_RUN_LOG_STDERR,
-
- SUBMIT_WORKFLOW_RUN,
+ FETCH_RUNS,
+ FETCH_RUN_DETAILS,
+ FETCH_RUN_LOG_STDOUT,
+ FETCH_RUN_LOG_STDERR,
+ SUBMIT_WORKFLOW_RUN,
} from "./actions";
-
const INITIAL_RUNS_STATE = {
- isFetching: false,
- isSubmittingRun: false,
- items: [],
- itemsByID: {},
- streamsByID: {},
+ isFetching: false,
+ isSubmittingRun: false,
+ items: [],
+ itemsByID: {},
+ streamsByID: {},
};
-
const streamRequest = (state = INITIAL_RUNS_STATE, action, stream) => {
- const existingRun = (state.streamsByID[action.runID] || {});
- const existingStreamData = (existingRun[stream] || {}).data;
- return {
- ...state,
- streamsByID: {
- ...state.streamsByID,
- [action.runID]: {
- ...existingRun,
- [stream]: {isFetching: true, data: existingStreamData === undefined ? null : existingStreamData},
- },
- },
- };
+ const existingRun = state.streamsByID[action.runID] || {};
+ const existingStreamData = (existingRun[stream] || {}).data;
+ return {
+ ...state,
+ streamsByID: {
+ ...state.streamsByID,
+ [action.runID]: {
+ ...existingRun,
+ [stream]: { isFetching: true, data: existingStreamData === undefined ? null : existingStreamData },
+ },
+ },
+ };
};
const streamReceive = (state = INITIAL_RUNS_STATE, action, stream) => ({
+ ...state,
+ streamsByID: {
+ ...state.streamsByID,
+ [action.runID]: {
+ ...(state.streamsByID[action.runID] || {}),
+ [stream]: { isFetching: false, data: action.data },
+ },
+ },
+});
+
+const streamError = (state = INITIAL_RUNS_STATE, action, stream) => {
+ const existingRun = state.streamsByID[action.runID] || {};
+ const existingStreamData = (existingRun[stream] || {}).data;
+ return {
...state,
streamsByID: {
- ...state.streamsByID,
- [action.runID]: {
- ...(state.streamsByID[action.runID] || {}),
- [stream]: {isFetching: false, data: action.data},
- },
+ ...state.streamsByID,
+ [action.runID]: {
+ ...existingRun,
+ [stream]: { isFetching: false, data: existingStreamData === undefined ? null : existingStreamData },
+ },
},
+ };
+};
+
+const makeRunSkeleton = (run, request) => ({
+ ...run,
+ state: "QUEUED", // Default initial state
+ timestamp: null, // Will get replaced with a UTC timestamp when we receive updates or events
+ run_log: null,
+ request,
+ outputs: {}, // TODO: is this the right default value? will be fine for now
+ isFetching: false,
});
-const streamError = (state = INITIAL_RUNS_STATE, action, stream) => {
- const existingRun = (state.streamsByID[action.runID] || {});
- const existingStreamData = (existingRun[stream] || {}).data;
- return {
+export const runs = (state = INITIAL_RUNS_STATE, action) => {
+ switch (action.type) {
+ case FETCH_RUNS.REQUEST:
+ return { ...state, isFetching: true };
+
+ case FETCH_RUNS.RECEIVE:
+ return {
...state,
- streamsByID: {
- ...state.streamsByID,
- [action.runID]: {
- ...existingRun,
- [stream]: {isFetching: false, data: existingStreamData === undefined ? null : existingStreamData},
+ items: action.data.map((r) => ({
+ ...r,
+ details: r.details || null,
+ isFetching: false,
+ })),
+ itemsByID: Object.fromEntries(
+ action.data.map((r) => [
+ r.run_id,
+ {
+ ...r,
+ details: r.details || null,
+ isFetching: false,
},
- },
- };
-};
+ ]),
+ ),
+ };
+ case FETCH_RUNS.FINISH:
+ return { ...state, isFetching: false };
-const makeRunSkeleton = (run, request) => ({
- ...run,
- state: "QUEUED", // Default initial state
- timestamp: null, // Will get replaced with a UTC timestamp when we receive updates or events
- run_log: null,
- request,
- outputs: {}, // TODO: is this the right default value? will be fine for now
- isFetching: false,
-});
+ case FETCH_RUN_DETAILS.REQUEST: {
+ const newItem = { ...(state.itemsByID[action.runID] ?? {}), isFetching: true };
+ return {
+ ...state,
+ items: state.items.map((r) => (r.run_id === action.runID ? newItem : r)),
+ itemsByID: { ...state.itemsByID, [action.runID]: newItem },
+ };
+ }
+ case FETCH_RUN_DETAILS.RECEIVE: {
+ // Pull state out of received details to ensure it's up-to-date in both places
+ // Assign a timestamp when we get this action if there isn't one passed in; technically this can still
+ // create a race condition with the websocket events, since if a websocket event is fired after an HTTP
+ // update response is sent from WES, but before we receive the response, the state will be wrong.
+
+ const timestamp = action.receivedAt; // UTC timestamp
+ const existingItem = state.itemsByID[action.runID] ?? {};
+ const stateUpdate =
+ !existingItem.timestamp || timestamp > existingItem.timestamp ? { timestamp, state: action.data.state } : {};
+
+ console.debug(
+ `run ${action.runID}: existing item timestamp:`,
+ existingItem.timestamp,
+ "| new timestamp:",
+ timestamp,
+ "new state:",
+ stateUpdate ?? `keep existing (${existingItem.state})`,
+ );
+
+ const newItem = {
+ ...existingItem,
+ ...stateUpdate,
+ details: { ...action.data, state: stateUpdate.state ?? action.data.state },
+ };
+
+ return {
+ ...state,
+ items: state.items.map((r) => (r.run_id === action.runID ? newItem : r)),
+ itemsByID: { ...state.itemsByID, [action.runID]: newItem },
+ };
+ }
-export const runs = (
- state = INITIAL_RUNS_STATE,
- action,
-) => {
- switch (action.type) {
- case FETCH_RUNS.REQUEST:
- return {...state, isFetching: true};
-
- case FETCH_RUNS.RECEIVE:
- return {
- ...state,
- items: action.data.map(r => ({
- ...r,
- details: r.details || null,
- isFetching: false,
- })),
- itemsByID: Object.fromEntries(action.data.map(r => [r.run_id, {
- ...r,
- details: r.details || null,
- isFetching: false,
- }])),
- };
-
- case FETCH_RUNS.FINISH:
- return {...state, isFetching: false};
-
- case FETCH_RUN_DETAILS.REQUEST: {
- const newItem = { ...(state.itemsByID[action.runID] ?? {}), isFetching: true };
- return {
- ...state,
- items: state.items.map(r => r.run_id === action.runID ? newItem : r),
- itemsByID: { ...state.itemsByID, [action.runID]: newItem },
- };
- }
-
- case FETCH_RUN_DETAILS.RECEIVE: {
- // Pull state out of received details to ensure it's up-to-date in both places
- // Assign a timestamp when we get this action if there isn't one passed in; technically this can still
- // create a race condition with the websocket events, since if a websocket event is fired after an HTTP
- // update response is sent from WES, but before we receive the response, the state will be wrong.
-
- const timestamp = action.receivedAt; // UTC timestamp
- const existingItem = state.itemsByID[action.runID] ?? {};
- const stateUpdate = !existingItem.timestamp || (timestamp > existingItem.timestamp)
- ? { timestamp, state: action.data.state }
- : {};
-
- console.debug(
- `run ${action.runID}: existing item timestamp:`, existingItem.timestamp, "| new timestamp:", timestamp,
- "new state:", stateUpdate ?? `keep existing (${existingItem.state})`);
-
- const newItem = {
- ...existingItem,
- ...stateUpdate,
- details: { ...action.data, state: stateUpdate.state ?? action.data.state },
- };
-
- return {
- ...state,
- items: state.items.map(r => r.run_id === action.runID ? newItem : r),
- itemsByID: { ...state.itemsByID, [action.runID]: newItem },
- };
- }
-
- case FETCH_RUN_DETAILS.FINISH:
- return {
- ...state,
- items: state.items.map(r => r.run_id === action.runID ? {...r, isFetching: false} : r),
- itemsByID: {
- ...state.itemsByID,
- [action.runID]: {...(state.itemsByID[action.runID] || {}), isFetching: false},
- },
- };
-
-
- case FETCH_RUN_LOG_STDOUT.REQUEST:
- return streamRequest(state, action, "stdout");
- case FETCH_RUN_LOG_STDOUT.RECEIVE:
- return streamReceive(state, action, "stdout");
- case FETCH_RUN_LOG_STDOUT.ERROR:
- return streamError(state, action, "stdout");
-
- case FETCH_RUN_LOG_STDERR.REQUEST:
- return streamRequest(state, action, "stderr");
- case FETCH_RUN_LOG_STDERR.RECEIVE:
- return streamReceive(state, action, "stderr");
- case FETCH_RUN_LOG_STDERR.ERROR:
- return streamError(state, action, "stderr");
-
- // SUBMIT_WORKFLOW_RUN
-
- case SUBMIT_WORKFLOW_RUN.REQUEST:
- return {...state, isSubmittingRun: true};
-
- case SUBMIT_WORKFLOW_RUN.RECEIVE: {
- // Create basic run object with no other details
- // action.data is of structure {run_id} with no other props
- const {data, request} = action;
- const runSkeleton = makeRunSkeleton(data, request);
- return {
- ...state,
- items: [...state.items, runSkeleton],
- itemsByID: {...state.itemsByID, [data.run_id]: runSkeleton},
- };
- }
-
- case SUBMIT_WORKFLOW_RUN.FINISH:
- return {...state, isSubmittingRun: false};
-
-
- default:
- return state;
+ case FETCH_RUN_DETAILS.FINISH:
+ return {
+ ...state,
+ items: state.items.map((r) => (r.run_id === action.runID ? { ...r, isFetching: false } : r)),
+ itemsByID: {
+ ...state.itemsByID,
+ [action.runID]: { ...(state.itemsByID[action.runID] || {}), isFetching: false },
+ },
+ };
+
+ case FETCH_RUN_LOG_STDOUT.REQUEST:
+ return streamRequest(state, action, "stdout");
+ case FETCH_RUN_LOG_STDOUT.RECEIVE:
+ return streamReceive(state, action, "stdout");
+ case FETCH_RUN_LOG_STDOUT.ERROR:
+ return streamError(state, action, "stdout");
+
+ case FETCH_RUN_LOG_STDERR.REQUEST:
+ return streamRequest(state, action, "stderr");
+ case FETCH_RUN_LOG_STDERR.RECEIVE:
+ return streamReceive(state, action, "stderr");
+ case FETCH_RUN_LOG_STDERR.ERROR:
+ return streamError(state, action, "stderr");
+
+ // SUBMIT_WORKFLOW_RUN
+
+ case SUBMIT_WORKFLOW_RUN.REQUEST:
+ return { ...state, isSubmittingRun: true };
+
+ case SUBMIT_WORKFLOW_RUN.RECEIVE: {
+ // Create basic run object with no other details
+ // action.data is of structure {run_id} with no other props
+ const { data, request } = action;
+ const runSkeleton = makeRunSkeleton(data, request);
+ return {
+ ...state,
+ items: [...state.items, runSkeleton],
+ itemsByID: { ...state.itemsByID, [data.run_id]: runSkeleton },
+ };
}
+
+ case SUBMIT_WORKFLOW_RUN.FINISH:
+ return { ...state, isSubmittingRun: false };
+
+ default:
+ return state;
+ }
};
diff --git a/src/modules/wes/types.ts b/src/modules/wes/types.ts
new file mode 100644
index 000000000..980a3c566
--- /dev/null
+++ b/src/modules/wes/types.ts
@@ -0,0 +1,26 @@
+export type WorkflowType = "ingestion" | "analysis" | "export";
+
+// TODO: expand def
+export type WorkflowInput = {
+ id: string;
+ required?: boolean;
+ type: string;
+ help: string;
+ injected?: boolean;
+ key?: string;
+ pattern?: string;
+};
+
+export interface Workflow {
+ name: string;
+ type: WorkflowType;
+ description: string;
+ file: string;
+ data_type?: string | null;
+ tags?: string[];
+ inputs: WorkflowInput[];
+}
+
+export interface ServiceWorkflow extends Workflow {
+ service_base_url: string;
+}
diff --git a/src/propTypes.js b/src/propTypes.js
index 0a75e1bee..0b6247c6e 100644
--- a/src/propTypes.js
+++ b/src/propTypes.js
@@ -1,132 +1,134 @@
import PropTypes from "prop-types";
-import {FORM_MODE_ADD, FORM_MODE_EDIT} from "./constants";
-import {KARYOTYPIC_SEX_VALUES, SEX_VALUES} from "./dataTypes/phenopacket";
+import { FORM_MODE_ADD, FORM_MODE_EDIT } from "./constants";
+import { KARYOTYPIC_SEX_VALUES, SEX_VALUES } from "./dataTypes/phenopacket";
export const propTypesFormMode = PropTypes.oneOf([FORM_MODE_ADD, FORM_MODE_EDIT]);
export const serviceInfoPropTypesShape = PropTypes.shape({
- id: PropTypes.string.isRequired,
- name: PropTypes.string.isRequired,
- type: PropTypes.shape({
- group: PropTypes.string.isRequired,
- artifact: PropTypes.string.isRequired,
- version: PropTypes.string.isRequired,
- }).isRequired,
- description: PropTypes.string,
- organization: PropTypes.shape({
- name: PropTypes.string.isRequired,
- url: PropTypes.string.isRequired,
- }),
- contactUrl: PropTypes.string,
- documentationUrl: PropTypes.string,
- createdAt: PropTypes.string,
- updatedAt: PropTypes.string,
- environment: PropTypes.string,
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ type: PropTypes.shape({
+ group: PropTypes.string.isRequired,
+ artifact: PropTypes.string.isRequired,
version: PropTypes.string.isRequired,
- git_tag: PropTypes.string,
- git_branch: PropTypes.string,
- bento: PropTypes.shape({
- serviceKind: PropTypes.string,
- dataService: PropTypes.bool,
- gitTag: PropTypes.string,
- gitBranch: PropTypes.string,
- gitCommit: PropTypes.string,
- }),
+ }).isRequired,
+ description: PropTypes.string,
+ organization: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ }),
+ contactUrl: PropTypes.string,
+ documentationUrl: PropTypes.string,
+ createdAt: PropTypes.string,
+ updatedAt: PropTypes.string,
+ environment: PropTypes.string,
+ version: PropTypes.string.isRequired,
+ git_tag: PropTypes.string,
+ git_branch: PropTypes.string,
+ bento: PropTypes.shape({
+ serviceKind: PropTypes.string,
+ dataService: PropTypes.bool,
+ gitTag: PropTypes.string,
+ gitBranch: PropTypes.string,
+ gitCommit: PropTypes.string,
+ }),
});
export const bentoServicePropTypesMixin = {
- service_kind: PropTypes.string,
- artifact: PropTypes.string,
- repository: PropTypes.string,
- disabled: PropTypes.bool,
- url_template: PropTypes.string,
- url: PropTypes.string,
+ service_kind: PropTypes.string,
+ artifact: PropTypes.string,
+ repository: PropTypes.string,
+ disabled: PropTypes.bool,
+ url_template: PropTypes.string,
+ url: PropTypes.string,
};
export const linkedFieldSetPropTypesShape = PropTypes.shape({
- name: PropTypes.string,
- fields: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), // TODO: Properties pattern?
+ name: PropTypes.string,
+ fields: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), // TODO: Properties pattern?
});
// Prop types object shape for a single dataset object.
export const datasetPropTypesShape = PropTypes.shape({
- identifier: PropTypes.string,
- title: PropTypes.string,
- description: PropTypes.string,
- contact_info: PropTypes.string,
- data_use: PropTypes.object, // TODO: Shape
- linked_field_sets: PropTypes.arrayOf(linkedFieldSetPropTypesShape),
- created: PropTypes.string,
- updated: PropTypes.string,
-
- // May not be present if nested (project ID)
- project: PropTypes.string,
+ identifier: PropTypes.string,
+ title: PropTypes.string,
+ description: PropTypes.string,
+ contact_info: PropTypes.string,
+ data_use: PropTypes.object, // TODO: Shape
+ linked_field_sets: PropTypes.arrayOf(linkedFieldSetPropTypesShape),
+ created: PropTypes.string,
+ updated: PropTypes.string,
+
+ // May not be present if nested (project ID)
+ project: PropTypes.string,
});
export const projectJsonSchemaTypesShape = PropTypes.shape({
- id: PropTypes.string,
- schema_type: PropTypes.string,
- project: PropTypes.string,
- required: PropTypes.bool,
- json_schema: PropTypes.object, // TODO: Shape
+ id: PropTypes.string,
+ schema_type: PropTypes.string,
+ project: PropTypes.string,
+ required: PropTypes.bool,
+ json_schema: PropTypes.object, // TODO: Shape
});
// Prop types object shape for a single project object.
export const projectPropTypesShape = PropTypes.shape({
- identifier: PropTypes.string,
- title: PropTypes.string,
- description: PropTypes.string,
- datasets: PropTypes.arrayOf(datasetPropTypesShape),
- project_schemas: PropTypes.arrayOf(projectJsonSchemaTypesShape),
- created: PropTypes.string,
- updated: PropTypes.string,
- discovery: PropTypes.object,
+ identifier: PropTypes.string,
+ title: PropTypes.string,
+ description: PropTypes.string,
+ datasets: PropTypes.arrayOf(datasetPropTypesShape),
+ project_schemas: PropTypes.arrayOf(projectJsonSchemaTypesShape),
+ discovery: PropTypes.object,
+ created: PropTypes.string,
+ updated: PropTypes.string,
});
// Prop types object shape for a single notification object.
export const notificationPropTypesShape = PropTypes.shape({
- id: PropTypes.string.isRequired,
- title: PropTypes.string.isRequired,
- description: PropTypes.string,
- notification_type: PropTypes.string,
- action_target: PropTypes.string,
- read: PropTypes.bool,
- timestamp: PropTypes.string, // TODO: de-serialize?
+ id: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ notification_type: PropTypes.string,
+ action_target: PropTypes.string,
+ read: PropTypes.bool,
+ timestamp: PropTypes.string, // TODO: de-serialize?
});
// Prop types object shape for a run object.
// TODO: Missing stuff
export const runPropTypesShape = PropTypes.shape({
+ run_id: PropTypes.string,
+ state: PropTypes.string,
+
+ // withDetails=true
+ details: PropTypes.shape({
run_id: PropTypes.string,
state: PropTypes.string,
-
- // withDetails=true
- details: PropTypes.shape({
- run_id: PropTypes.string,
- state: PropTypes.string,
- request: PropTypes.shape({
- workflow_params: PropTypes.object,
- workflow_type: PropTypes.string,
- workflow_type_version: PropTypes.string,
- workflow_engine_parameters: PropTypes.object,
- workflow_url: PropTypes.string,
- tags: PropTypes.object,
- }),
- run_log: PropTypes.shape({
- name: PropTypes.string,
- cmd: PropTypes.string,
- start_time: PropTypes.string, // TODO: De-serialize?
- end_time: PropTypes.string, // TODO: De-serialize?
- stdout: PropTypes.string,
- stderr: PropTypes.string,
- exit_code: PropTypes.number,
- }),
- // with outputs
- outputs: PropTypes.objectOf(PropTypes.shape({
- type: PropTypes.string,
- value: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.number, PropTypes.bool]),
- })),
+ request: PropTypes.shape({
+ workflow_params: PropTypes.object,
+ workflow_type: PropTypes.string,
+ workflow_type_version: PropTypes.string,
+ workflow_engine_parameters: PropTypes.object,
+ workflow_url: PropTypes.string,
+ tags: PropTypes.object,
+ }),
+ run_log: PropTypes.shape({
+ name: PropTypes.string,
+ cmd: PropTypes.string,
+ start_time: PropTypes.string, // TODO: De-serialize?
+ end_time: PropTypes.string, // TODO: De-serialize?
+ stdout: PropTypes.string,
+ stderr: PropTypes.string,
+ exit_code: PropTypes.number,
}),
+ // with outputs
+ outputs: PropTypes.objectOf(
+ PropTypes.shape({
+ type: PropTypes.string,
+ value: PropTypes.oneOfType([PropTypes.string, PropTypes.array, PropTypes.number, PropTypes.bool]),
+ }),
+ ),
+ }),
});
// Prop types object shape for a single table summary object.
@@ -134,240 +136,274 @@ export const summaryPropTypesShape = PropTypes.object;
// Prop types object shape for a single workflow object.
export const workflowPropTypesShape = PropTypes.shape({
- id: PropTypes.string,
- service_base_url: PropTypes.string,
-
- // "Real" properties
- name: PropTypes.string,
- description: PropTypes.string,
- data_type: PropTypes.string,
- inputs: PropTypes.arrayOf(PropTypes.shape({
- type: PropTypes.string.isRequired,
- id: PropTypes.string.isRequired,
- pattern: PropTypes.string, // File type only
- required: PropTypes.bool,
- injected: PropTypes.bool,
- repeatable: PropTypes.bool,
- })),
+ id: PropTypes.string,
+ service_base_url: PropTypes.string,
+
+ // "Real" properties
+ name: PropTypes.string,
+ description: PropTypes.string,
+ data_type: PropTypes.string,
+ inputs: PropTypes.arrayOf(
+ PropTypes.shape({
+ type: PropTypes.string.isRequired,
+ id: PropTypes.string.isRequired,
+ pattern: PropTypes.string, // File type only
+ required: PropTypes.bool,
+ injected: PropTypes.bool,
+ repeatable: PropTypes.bool,
+ }),
+ ),
});
export const workflowTypePropType = PropTypes.oneOf(["ingestion", "analysis", "export"]);
// Shape of a phenopackets ontology object
export const ontologyShape = PropTypes.shape({
- id: PropTypes.string, // CURIE ID
- label: PropTypes.string, // Term label
+ id: PropTypes.string, // CURIE ID
+ label: PropTypes.string, // Term label
});
const agePropTypesShape = PropTypes.shape({
- age: PropTypes.string, // ISO duration string
+ age: PropTypes.string, // ISO duration string
});
const ageRangePropTypesShape = PropTypes.shape({
- start: agePropTypesShape,
- end: agePropTypesShape,
+ start: agePropTypesShape,
+ end: agePropTypesShape,
});
// Prop types object shape for a single biosample object from the metadata service.
export const biosamplePropTypesShape = PropTypes.shape({
- id: PropTypes.string.isRequired,
- derived_from_id: PropTypes.string,
- procedure: PropTypes.shape({
- code: ontologyShape.isRequired,
- body_site: ontologyShape,
- created: PropTypes.string, // ISO datetime string
- updated: PropTypes.string, // ISO datetime string
- }),
- description: PropTypes.string,
- sampled_tissue: ontologyShape,
- sample_type: ontologyShape,
- individual_age_at_collection: PropTypes.oneOfType([agePropTypesShape, ageRangePropTypesShape]),
-
- histological_diagnosis: ontologyShape,
- tumor_progression: ontologyShape,
- tumor_grade: ontologyShape,
- pathological_stage: ontologyShape,
- pathological_tnm_finding: PropTypes.arrayOf(ontologyShape),
- diagnostic_markers: PropTypes.arrayOf(ontologyShape),
-
- material_sample: ontologyShape,
- sample_processing: ontologyShape,
- sample_storage: ontologyShape,
-
- created: PropTypes.string, // ISO datetime string
- updated: PropTypes.string, // ISO datetime string
+ id: PropTypes.string.isRequired,
+ derived_from_id: PropTypes.string,
+ procedure: PropTypes.shape({
+ code: ontologyShape.isRequired,
+ body_site: ontologyShape,
+ created: PropTypes.string, // ISO datetime string
+ updated: PropTypes.string, // ISO datetime string
+ }),
+ description: PropTypes.string,
+ sampled_tissue: ontologyShape,
+ sample_type: ontologyShape,
+ individual_age_at_collection: PropTypes.oneOfType([agePropTypesShape, ageRangePropTypesShape]),
+
+ histological_diagnosis: ontologyShape,
+ tumor_progression: ontologyShape,
+ tumor_grade: ontologyShape,
+ pathological_stage: ontologyShape,
+ pathological_tnm_finding: PropTypes.arrayOf(ontologyShape),
+ diagnostic_markers: PropTypes.arrayOf(ontologyShape),
+
+ material_sample: ontologyShape,
+ sample_processing: ontologyShape,
+ sample_storage: ontologyShape,
+
+ created: PropTypes.string, // ISO datetime string
+ updated: PropTypes.string, // ISO datetime string
});
// Prop types object shape for a single patient individual object from the metadata service.
export const individualPropTypesShape = PropTypes.shape({
- id: PropTypes.string.isRequired,
- alternate_ids: PropTypes.arrayOf(PropTypes.string),
+ id: PropTypes.string.isRequired,
+ alternate_ids: PropTypes.arrayOf(PropTypes.string),
- date_of_birth: PropTypes.string,
- age: PropTypes.object, // TODO: Shape
- sex: PropTypes.oneOf(SEX_VALUES),
- karyotypic_sex: PropTypes.oneOf(KARYOTYPIC_SEX_VALUES),
- taxonomy: ontologyShape,
+ date_of_birth: PropTypes.string,
+ age: PropTypes.object, // TODO: Shape
+ sex: PropTypes.oneOf(SEX_VALUES),
+ karyotypic_sex: PropTypes.oneOf(KARYOTYPIC_SEX_VALUES),
+ taxonomy: ontologyShape,
- phenopackets: PropTypes.arrayOf(PropTypes.object), // TODO
- biosamples: PropTypes.arrayOf(biosamplePropTypesShape),
+ phenopackets: PropTypes.arrayOf(PropTypes.object), // TODO
+ biosamples: PropTypes.arrayOf(biosamplePropTypesShape),
- created: PropTypes.string, // ISO datetime string
- updated: PropTypes.string, // ISO datetime string
+ created: PropTypes.string, // ISO datetime string
+ updated: PropTypes.string, // ISO datetime string
});
// Prop types object shape for a single phenopacket disease object.
export const diseasePropTypesShape = PropTypes.shape({
- id: PropTypes.number.isRequired,
- term: ontologyShape.isRequired,
- onset: PropTypes.object, // TODO
- disease_stage: PropTypes.arrayOf(ontologyShape),
- tnm_finding: PropTypes.arrayOf(ontologyShape),
- created: PropTypes.string, // ISO datetime string
- updated: PropTypes.string, // ISO datetime string
+ id: PropTypes.number.isRequired,
+ term: ontologyShape.isRequired,
+ onset: PropTypes.object, // TODO
+ disease_stage: PropTypes.arrayOf(ontologyShape),
+ tnm_finding: PropTypes.arrayOf(ontologyShape),
+ created: PropTypes.string, // ISO datetime string
+ updated: PropTypes.string, // ISO datetime string
});
export const evidencePropTypesShape = PropTypes.shape({
- evidence_code: ontologyShape.isRequired,
- reference: PropTypes.shape({
- id: PropTypes.string,
- reference: PropTypes.string,
- description: PropTypes.string,
- }),
+ evidence_code: ontologyShape.isRequired,
+ reference: PropTypes.shape({
+ id: PropTypes.string,
+ reference: PropTypes.string,
+ description: PropTypes.string,
+ }),
});
// Prop types object shape for a single phenopacket phenotypic feature object.
export const phenotypicFeaturePropTypesShape = PropTypes.shape({
- type: ontologyShape.isRequired,
- excluded: PropTypes.bool,
- created: PropTypes.string, // ISO datetime string
- updated: PropTypes.string, // ISO datetime string
+ type: ontologyShape.isRequired,
+ excluded: PropTypes.bool,
+ created: PropTypes.string, // ISO datetime string
+ updated: PropTypes.string, // ISO datetime string
});
export const resourcePropTypesShape = PropTypes.shape({
- id: PropTypes.string,
- name: PropTypes.string,
- namespace_prefix: PropTypes.string,
- url: PropTypes.string,
- version: PropTypes.string,
- iri_prefix: PropTypes.string,
- extra_properties: PropTypes.object,
+ id: PropTypes.string,
+ name: PropTypes.string,
+ namespace_prefix: PropTypes.string,
+ url: PropTypes.string,
+ version: PropTypes.string,
+ iri_prefix: PropTypes.string,
+ extra_properties: PropTypes.object,
});
export const phenopacketPropTypesShape = PropTypes.shape({
- id: PropTypes.string.isRequired,
- subject: PropTypes.oneOfType([individualPropTypesShape, PropTypes.string]).isRequired,
- biosamples: biosamplePropTypesShape.isRequired,
- diseases: diseasePropTypesShape.isRequired,
- phenotypic_features: phenotypicFeaturePropTypesShape,
- meta_data: PropTypes.shape({
- created: PropTypes.string,
- created_by: PropTypes.string,
- submitted_by: PropTypes.string,
- resources: PropTypes.arrayOf(resourcePropTypesShape),
- phenopacket_schema_version: PropTypes.string,
- }).isRequired,
- created: PropTypes.string, // ISO datetime string
- updated: PropTypes.string, // ISO datetime string
+ id: PropTypes.string.isRequired,
+ subject: PropTypes.oneOfType([individualPropTypesShape, PropTypes.string]).isRequired,
+ biosamples: biosamplePropTypesShape.isRequired,
+ diseases: diseasePropTypesShape.isRequired,
+ phenotypic_features: phenotypicFeaturePropTypesShape,
+ meta_data: PropTypes.shape({
+ created: PropTypes.string,
+ created_by: PropTypes.string,
+ submitted_by: PropTypes.string,
+ resources: PropTypes.arrayOf(resourcePropTypesShape),
+ phenopacket_schema_version: PropTypes.string,
+ }).isRequired,
+ created: PropTypes.string, // ISO datetime string
+ updated: PropTypes.string, // ISO datetime string
});
export const experimentResultPropTypesShape = PropTypes.shape({
- identifier: PropTypes.string,
- description: PropTypes.string,
- filename: PropTypes.string,
- genome_assembly_id: PropTypes.string,
- file_format: PropTypes.oneOf([
- "SAM", "BAM", "CRAM", "BAI", "CRAI", "VCF", "BCF", "MAF", "GVCF", "BigWig", "BigBed", "FASTA",
- "FASTQ", "TAB", "SRA", "SRF", "SFF", "GFF", "TABIX", "PDF", "CSV", "TSV", "JPEG", "PNG", "GIF",
- "MARKDOWN", "MP3", "M4A", "MP4", "DOCX", "XLS", "XLSX", "UNKNOWN", "OTHER",
- ]),
- data_output_type: PropTypes.oneOf(["Raw data", "Derived data"]),
- usage: PropTypes.string,
- creation_date: PropTypes.string,
- created_by: PropTypes.string,
+ identifier: PropTypes.string,
+ description: PropTypes.string,
+ filename: PropTypes.string,
+ genome_assembly_id: PropTypes.string,
+ file_format: PropTypes.oneOf([
+ "SAM",
+ "BAM",
+ "CRAM",
+ "BAI",
+ "CRAI",
+ "VCF",
+ "BCF",
+ "MAF",
+ "GVCF",
+ "BigWig",
+ "BigBed",
+ "FASTA",
+ "FASTQ",
+ "TAB",
+ "SRA",
+ "SRF",
+ "SFF",
+ "GFF",
+ "TABIX",
+ "PDF",
+ "CSV",
+ "TSV",
+ "JPEG",
+ "PNG",
+ "GIF",
+ "MARKDOWN",
+ "MP3",
+ "M4A",
+ "MP4",
+ "DOCX",
+ "XLS",
+ "XLSX",
+ "UNKNOWN",
+ "OTHER",
+ ]),
+ data_output_type: PropTypes.oneOf(["Raw data", "Derived data"]),
+ usage: PropTypes.string,
+ creation_date: PropTypes.string,
+ created_by: PropTypes.string,
});
export const experimentPropTypesShape = PropTypes.shape({
- id: PropTypes.string.isRequired,
- study_type: PropTypes.string,
+ id: PropTypes.string.isRequired,
+ study_type: PropTypes.string,
- experiment_type: PropTypes.string.isRequired,
- experiment_ontology: PropTypes.arrayOf(ontologyShape), // TODO: Array of ontology terms
+ experiment_type: PropTypes.string.isRequired,
+ experiment_ontology: PropTypes.arrayOf(ontologyShape), // TODO: Array of ontology terms
- molecule: PropTypes.string,
- molecule_ontology: PropTypes.arrayOf(ontologyShape), // TODO: Array of ontology terms
+ molecule: PropTypes.string,
+ molecule_ontology: PropTypes.arrayOf(ontologyShape), // TODO: Array of ontology terms
- library_strategy: PropTypes.string,
- library_source: PropTypes.string,
- library_selection: PropTypes.string,
- library_layout: PropTypes.string,
+ library_strategy: PropTypes.string,
+ library_source: PropTypes.string,
+ library_selection: PropTypes.string,
+ library_layout: PropTypes.string,
- extraction_protocol: PropTypes.string,
- reference_registry_id: PropTypes.string,
+ extraction_protocol: PropTypes.string,
+ reference_registry_id: PropTypes.string,
- biosample: PropTypes.string, // Biosample foreign key
- instrument: PropTypes.shape({
- identifier: PropTypes.string,
- platform: PropTypes.string,
- description: PropTypes.string,
- model: PropTypes.string,
- }),
+ biosample: PropTypes.string, // Biosample foreign key
+ instrument: PropTypes.shape({
+ identifier: PropTypes.string,
+ platform: PropTypes.string,
+ description: PropTypes.string,
+ model: PropTypes.string,
+ }),
- experiment_results: PropTypes.arrayOf(experimentResultPropTypesShape),
+ experiment_results: PropTypes.arrayOf(experimentResultPropTypesShape),
- qc_flags: PropTypes.arrayOf(PropTypes.string),
+ qc_flags: PropTypes.arrayOf(PropTypes.string),
- created: PropTypes.string, // ISO datetime string
- updated: PropTypes.string, // ISO datetime string
+ created: PropTypes.string, // ISO datetime string
+ updated: PropTypes.string, // ISO datetime string
});
export const overviewSummaryPropTypesShape = PropTypes.shape({
- data: PropTypes.shape({
- // TODO: more precision
- phenopackets: PropTypes.number,
- data_type_specific: PropTypes.shape({
- biosamples: PropTypes.object,
- diseases: PropTypes.object,
- individuals: PropTypes.object,
- phenotypic_features: PropTypes.object,
- }),
+ data: PropTypes.shape({
+ // TODO: more precision
+ phenopackets: PropTypes.number,
+ data_type_specific: PropTypes.shape({
+ biosamples: PropTypes.object,
+ diseases: PropTypes.object,
+ individuals: PropTypes.object,
+ phenotypic_features: PropTypes.object,
}),
+ }),
});
-
// Explorer search results format
export const explorerSearchResultsPropTypesShape = PropTypes.shape({
- results: PropTypes.shape({
- // TODO: Other data types
- experiment: PropTypes.arrayOf(experimentPropTypesShape),
- phenopacket: PropTypes.arrayOf(phenopacketPropTypesShape),
- variant: PropTypes.arrayOf(PropTypes.object), // TODO: Variant prop types shape
+ results: PropTypes.shape({
+ // TODO: Other data types
+ experiment: PropTypes.arrayOf(experimentPropTypesShape),
+ phenopacket: PropTypes.arrayOf(phenopacketPropTypesShape),
+ variant: PropTypes.arrayOf(PropTypes.object), // TODO: Variant prop types shape
+ }),
+ searchFormattedResults: PropTypes.arrayOf(
+ PropTypes.shape({
+ key: PropTypes.string.isRequired,
+ individual: PropTypes.shape({
+ id: PropTypes.string.isRequired,
+ alternate_ids: PropTypes.arrayOf(PropTypes.string),
+ }),
+ biosamples: PropTypes.arrayOf(PropTypes.string),
+ experiments: PropTypes.number,
}),
- searchFormattedResults: PropTypes.arrayOf(PropTypes.shape({
- key: PropTypes.string.isRequired,
- individual: PropTypes.shape({
- id: PropTypes.string.isRequired,
- alternate_ids: PropTypes.arrayOf(PropTypes.string),
- }),
- biosamples: PropTypes.arrayOf(PropTypes.string),
- experiments: PropTypes.number,
- })),
+ ),
});
export const medicalActionPropTypesShape = PropTypes.shape({
- action: PropTypes.object,
- treatment_target: ontologyShape,
- treatment_intent: ontologyShape,
- response_to_treatment: ontologyShape,
- adverse_events: PropTypes.arrayOf(ontologyShape),
- treatment_termination_reason: ontologyShape,
+ action: PropTypes.object,
+ treatment_target: ontologyShape,
+ treatment_intent: ontologyShape,
+ response_to_treatment: ontologyShape,
+ adverse_events: PropTypes.arrayOf(ontologyShape),
+ treatment_termination_reason: ontologyShape,
});
export const measurementPropTypesShape = PropTypes.shape({
- description: PropTypes.string,
- assay: ontologyShape,
- value: PropTypes.object,
- complexValue: PropTypes.object,
- timeObserved: PropTypes.object,
- procedure: PropTypes.object,
+ description: PropTypes.string,
+ assay: ontologyShape,
+ value: PropTypes.object,
+ complexValue: PropTypes.object,
+ timeObserved: PropTypes.object,
+ procedure: PropTypes.object,
});
diff --git a/src/reducers.ts b/src/reducers.ts
index 5e4d5978f..0e7d38d3d 100644
--- a/src/reducers.ts
+++ b/src/reducers.ts
@@ -2,81 +2,66 @@ import { combineReducers } from "redux";
import { AuthReducer as auth, OIDCReducer as openIdConfiguration } from "bento-auth-js";
-import { grants, groups } from "./modules/authz/reducers";
+import { allPermissions, grants, groups } from "./modules/authz/reducers";
import { drs } from "./modules/drs/reducers";
-import { discovery } from "./modules/discovery/reducers";
import { explorer, igvGenomes } from "./modules/explorer/reducers";
-import {
- projects,
-
- biosamples,
- individuals,
-
- overviewSummary,
-} from "./modules/metadata/reducers";
+import { projects, biosamples, individuals, overviewSummary } from "./modules/metadata/reducers";
import { manager, dropBox } from "./modules/manager/reducers";
import { notifications } from "./modules/notifications/reducers";
import { referenceGenomes } from "./modules/reference/reducers";
-import {
- bentoServices,
- services,
- serviceDataTypes,
- serviceWorkflows,
-} from "./modules/services/reducers";
+import { bentoServices, services, serviceDataTypes, serviceWorkflows } from "./modules/services/reducers";
import { datasetDataTypes, datasetResources, datasetSummaries } from "./modules/datasets/reducers";
import { user } from "./modules/user/reducers";
import { runs } from "./modules/wes/reducers";
const rootReducer = combineReducers({
- // Auth module
- auth,
- openIdConfiguration,
- user,
-
- // Authz module
- grants,
- groups,
+ // Auth module
+ auth,
+ openIdConfiguration,
+ user,
- // DRS module
- drs,
+ // Authz module
+ allPermissions,
+ grants,
+ groups,
- // Discovery module
- discovery,
+ // DRS module
+ drs,
- // Explorer module
- explorer,
- igvGenomes,
+ // Explorer module
+ explorer,
+ igvGenomes,
- // Metadata module
- projects,
+ // Metadata module
+ projects,
- biosamples,
- individuals,
- overviewSummary,
+ biosamples,
+ individuals,
+ overviewSummary,
- // Manager module
- manager,
- dropBox,
+ // Manager module
+ manager,
+ dropBox,
- // Notifications module
- notifications,
+ // Notifications module
+ notifications,
- // Reference module
- referenceGenomes,
+ // Reference module
+ referenceGenomes,
- // Services module
- bentoServices,
- services,
- serviceDataTypes,
- serviceWorkflows,
+ // Services module
+ bentoServices,
+ services,
+ serviceDataTypes,
+ serviceWorkflows,
- // Dataset module
- datasetDataTypes,
- datasetSummaries,
- datasetResources,
+ // Dataset module
+ datasetDataTypes,
+ datasetSummaries,
+ datasetResources,
- // WES module
- runs,
+ // WES module
+ runs,
});
export default rootReducer;
diff --git a/src/session.worker.js b/src/session.worker.js
index 445516696..550d054ac 100644
--- a/src/session.worker.js
+++ b/src/session.worker.js
@@ -7,5 +7,5 @@
// network action to get a new access & refresh token.
setInterval(() => {
- self.postMessage(true); // Send a window-inactive-resilient ping
-}, 120000); // every 2 minutes
+ self.postMessage(true); // Send a window-inactive-resilient ping
+}, 120000); // every 2 minutes
diff --git a/src/store.ts b/src/store.ts
index 2e90b3827..81e40deea 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -13,22 +13,22 @@ import rootReducer from "./reducers";
// These options prevent delay warnings caused by large states in DEV mode by increasing the warning delay.
// See Redux Toolkit doc: https://redux-toolkit.js.org/api/getDefaultMiddleware#development
const IMMUTABILITY_OPTIONS = {
- thunk: true,
- immutableCheck: false,
- serializableCheck: false,
+ thunk: true,
+ immutableCheck: false,
+ serializableCheck: false,
};
const persistedState: { openIdConfiguration?: OIDCSliceState } = {};
-const persistedOpenIDConfig = readFromLocalStorage(LS_OPENID_CONFIG_KEY);
+const persistedOpenIDConfig = readFromLocalStorage(LS_OPENID_CONFIG_KEY);
if (persistedOpenIDConfig) {
- console.debug("attempting to load OpenID configuration from localStorage");
- persistedState.openIdConfiguration = persistedOpenIDConfig;
+ console.debug("attempting to load OpenID configuration from localStorage");
+ persistedState.openIdConfiguration = persistedOpenIDConfig;
}
export const store = configureStore({
- reducer: rootReducer,
- middleware: (getDefaultMiddleware) => getDefaultMiddleware(IMMUTABILITY_OPTIONS),
- preloadedState: persistedState,
+ reducer: rootReducer,
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware(IMMUTABILITY_OPTIONS),
+ preloadedState: persistedState,
});
export type RootState = ReturnType;
@@ -47,28 +47,28 @@ export const useAppSelector: TypedUseSelectorHook = useSelector;
* See Redux store.subscribe doc: https://redux.js.org/api/store#subscribelistener
*/
const observeStore = (store: ToolkitStore, select: (state: RootState) => T, onChange: (state: T) => void) => {
- let currentState: T;
+ let currentState: T;
- const handleChange = () => {
- const nextState = select(store.getState());
- if (nextState !== currentState) {
- currentState = nextState;
- onChange(currentState);
- }
- };
+ const handleChange = () => {
+ const nextState = select(store.getState());
+ if (nextState !== currentState) {
+ currentState = nextState;
+ onChange(currentState);
+ }
+ };
- const unsubscribe = store.subscribe(handleChange);
- handleChange();
- return unsubscribe;
+ const unsubscribe = store.subscribe(handleChange);
+ handleChange();
+ return unsubscribe;
};
observeStore(
- store,
- (state) => state.openIdConfiguration,
- (currentState) => {
- const { data, expiry, isFetching } = currentState;
- if (data && expiry && !isFetching) {
- writeToLocalStorage(LS_OPENID_CONFIG_KEY, { data, expiry, isFetching });
- }
- },
+ store,
+ (state) => state.openIdConfiguration,
+ (currentState) => {
+ const { data, expiry, isFetching } = currentState;
+ if (data && expiry && !isFetching) {
+ writeToLocalStorage(LS_OPENID_CONFIG_KEY, { data, expiry, isFetching });
+ }
+ },
);
diff --git a/src/styles/layoutContent.ts b/src/styles/layoutContent.ts
index 05f4852cb..2aed206d6 100644
--- a/src/styles/layoutContent.ts
+++ b/src/styles/layoutContent.ts
@@ -1,7 +1,7 @@
import type { CSSProperties } from "react";
export const LAYOUT_CONTENT_STYLE: CSSProperties = {
- background: "white",
- padding: "24px",
- position: "relative",
+ background: "white",
+ padding: "24px",
+ position: "relative",
};
diff --git a/src/types/menu.ts b/src/types/menu.ts
new file mode 100644
index 000000000..e558a8b43
--- /dev/null
+++ b/src/types/menu.ts
@@ -0,0 +1,20 @@
+import type { CSSProperties, ReactNode } from "react";
+
+interface BentoBaseMenuItem {
+ text?: string;
+ disabled?: boolean;
+ icon?: ReactNode;
+ style?: CSSProperties;
+ children?: BentoMenuItem[];
+ onClick?: () => void;
+}
+
+interface BentoKeyMenuItem extends BentoBaseMenuItem {
+ key: string;
+}
+
+interface BentoURLMenuItem extends BentoBaseMenuItem {
+ url: string;
+}
+
+export type BentoMenuItem = BentoKeyMenuItem | BentoURLMenuItem;
diff --git a/src/types/ontology.ts b/src/types/ontology.ts
new file mode 100644
index 000000000..46001b6aa
--- /dev/null
+++ b/src/types/ontology.ts
@@ -0,0 +1,4 @@
+export type OntologyTerm = {
+ id: string;
+ label: string;
+};
diff --git a/src/utils/actions.js b/src/utils/actions.js
index 16b2c96ca..d6e7eafe6 100644
--- a/src/utils/actions.js
+++ b/src/utils/actions.js
@@ -7,150 +7,155 @@ import { BENTO_PUBLIC_URL, BENTO_URL, IDP_BASE_URL } from "@/config";
export const basicAction = (t) => () => ({ type: t });
export const createNetworkActionTypes = (name) => ({
- REQUEST: `${name}.REQUEST`,
- RECEIVE: `${name}.RECEIVE`,
- ERROR: `${name}.ERROR`,
- FINISH: `${name}.FINISH`,
+ REQUEST: `${name}.REQUEST`,
+ RECEIVE: `${name}.RECEIVE`,
+ ERROR: `${name}.ERROR`,
+ FINISH: `${name}.FINISH`,
});
export const createFlowActionTypes = (name) => ({
- BEGIN: `${name}.BEGIN`,
- END: `${name}.END`,
- TERMINATE: `${name}.TERMINATE`,
+ BEGIN: `${name}.BEGIN`,
+ END: `${name}.END`,
+ TERMINATE: `${name}.TERMINATE`,
});
const _unpaginatedNetworkFetch = async (url, _baseUrl, req, parse) => {
- const response = await fetch(url, req);
+ const response = await fetch(url, req);
+ if (!response.ok) {
+ const errorData = await parse(response);
+ const errorsArray = errorData.errors ?? [];
+ throw new Error(errorData.message || `${response.status} ${response.statusText}`, { cause: errorsArray });
+ }
+ return response.status === 204 ? null : await parse(response);
+};
+
+const _paginatedNetworkFetch = async (url, baseUrl, req, parse) => {
+ const results = [];
+ const _fetchNext = async (pageUrl) => {
+ const response = await fetch(pageUrl, req);
if (!response.ok) {
+ try {
const errorData = await parse(response);
- const errorsArray = errorData.errors ?? [];
- throw new Error(errorData.message || `${response.status} ${response.statusText}`, {"cause": errorsArray});
+ throw new Error(errorData.message);
+ } catch (_) {
+ throw new Error("Invalid response encountered");
+ }
}
- return response.status === 204 ? null : await parse(response);
-};
-const _paginatedNetworkFetch = async (url, baseUrl, req, parse) => {
- const results = [];
- const _fetchNext = async (pageUrl) => {
- const response = await fetch(pageUrl, req);
- if (!response.ok) {
- try {
- const errorData = await parse(response);
- throw new Error(errorData.message);
- } catch (_) {
- throw new Error("Invalid response encountered");
- }
- }
-
- const data = await parse(response);
- if (!data.hasOwnProperty("results")) throw "Missing results set";
- const pageResults = data.results;
- const nextUrl = data.next ? baseUrl + data.next : null;
- if (!(pageResults instanceof Array)) throw "Invalid results set";
- results.push(...pageResults);
- if (nextUrl) await _fetchNext(nextUrl);
- };
- await _fetchNext(url);
- return results;
+ const data = await parse(response);
+ if (!data.hasOwnProperty("results")) throw "Missing results set";
+ const pageResults = data.results;
+ const nextUrl = data.next ? baseUrl + data.next : null;
+ if (!(pageResults instanceof Array)) throw "Invalid results set";
+ results.push(...pageResults);
+ if (nextUrl) await _fetchNext(nextUrl);
+ };
+ await _fetchNext(url);
+ return results;
};
const _networkAction =
- (fn, ...args) =>
- async (dispatch, getState) => {
- let fnResult = await fn(...args);
- if (typeof fnResult === "function") {
- // Needs dispatch / getState, resolve those.
- fnResult = await fnResult(dispatch, getState);
- }
-
- const { types, check, params, url, baseUrl, req, err, onSuccess, onError, paginated } = fnResult;
-
- // if we're currently auto-authenticating, don't start any network requests; otherwise, they have a good
- // chance of getting interrupted when the auth redirect happens.
- if (getState().auth.isAutoAuthenticating) return;
-
- // if a check function is specified and evaluates to false on the state, don't fire the action.
- if (check && !check(getState())) return;
-
- // Only include access token when we are making a request to this Bento node or the IdP!
- // Otherwise, we could leak it to external sites.
-
- const token =
- url.startsWith("/") ||
- (BENTO_URL && url.startsWith(BENTO_URL)) ||
- (BENTO_PUBLIC_URL && url.startsWith(BENTO_PUBLIC_URL)) ||
- (IDP_BASE_URL && url.startsWith(IDP_BASE_URL))
- ? getState().auth.accessToken
- : null;
-
- const finalReq = {
- ...(req ?? {
- method: "GET", // Default request method
- }),
- headers: {
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- ...(req?.headers ?? {}),
- },
- };
-
- let { parse } = fnResult;
- if (!parse) parse = (r) => r.json();
-
- dispatch({ type: types.REQUEST, ...params });
- try {
- const data = await (paginated ? _paginatedNetworkFetch : _unpaginatedNetworkFetch)(
- url,
- baseUrl,
- finalReq,
- parse,
- );
- dispatch({
- type: types.RECEIVE,
- ...params,
- ...(data === null ? {} : { data }),
- receivedAt: new Date().getTime(), // UTC timestamp
- });
- if (onSuccess) await onSuccess(data);
- } catch (e) {
- handleNetworkErrorMessaging(getState(), e, err);
- dispatch({ type: types.ERROR, ...params, caughtError: e });
- if (onError) await onError(e);
- }
- dispatch({ type: types.FINISH, ...params });
- };
-
-// Curried version
-export const networkAction =
- (fn) =>
- (...args) =>
- _networkAction(fn, ...args);
+ (fn, ...args) =>
+ async (dispatch, getState) => {
+ let fnResult = await fn(...args);
+ if (typeof fnResult === "function") {
+ // Needs dispatch / getState, resolve those.
+ fnResult = await fnResult(dispatch, getState);
+ }
+ const {
+ types, // Network action types
+ check, // Optional check function for whether to dispatch the action
+ params, // Action parameters
+ url, // Network request URL
+ baseUrl, // Optional base URL for next page (paginated requests only)
+ req, // Request initialization object (method, headers, etc.)
+ publicEndpoint, // Whether the endpoint is public (i.e., don't include credentials, skip OPTIONS check)
+ err, // Optional custom error message to display if the request fails
+ onSuccess, // Optional success callback (can be sync or async)
+ onError, // Optional error callback (can be sync or async)
+ paginated, // Whether the response data is in a Django-style paginated format.
+ } = fnResult;
+
+ // if we're currently auto-authenticating, don't start any network requests; otherwise, they have a good
+ // chance of getting interrupted when the auth redirect happens.
+ if (getState().auth.isAutoAuthenticating) return;
+
+ // if a check function is specified and evaluates to false on the state, don't fire the action.
+ if (check && !check(getState())) return;
+
+ // Only include access token when we are making a request to this Bento node or the IdP!
+ // Otherwise, we could leak it to external sites.
+
+ const token = publicEndpoint
+ ? null
+ : url.startsWith("/") ||
+ (BENTO_URL && url.startsWith(BENTO_URL)) ||
+ (BENTO_PUBLIC_URL && url.startsWith(BENTO_PUBLIC_URL)) ||
+ (IDP_BASE_URL && url.startsWith(IDP_BASE_URL))
+ ? getState().auth.accessToken
+ : null;
+
+ const finalReq = {
+ ...(req ?? {
+ method: "GET", // Default request method
+ }),
+ headers: {
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...(req?.headers ?? {}),
+ },
+ };
-const handleNetworkErrorMessaging = (state, e, reduxErrDetail) => {
- // if we're currently auto-authenticating, and it's a network error, suppress it.
- if (e.toString().includes("NetworkError when attempting") && state.auth.isAutoAuthenticating) {
- return;
+ let { parse } = fnResult;
+ if (!parse) parse = (r) => r.json();
+
+ dispatch({ type: types.REQUEST, ...params });
+ try {
+ const data = await (paginated ? _paginatedNetworkFetch : _unpaginatedNetworkFetch)(url, baseUrl, finalReq, parse);
+ dispatch({
+ type: types.RECEIVE,
+ ...params,
+ ...(data === null ? {} : { data }),
+ receivedAt: new Date().getTime(), // UTC timestamp
+ });
+ if (onSuccess) await onSuccess(data);
+ } catch (e) {
+ handleNetworkErrorMessaging(getState(), e, err);
+ dispatch({ type: types.ERROR, ...params, caughtError: e });
+ if (onError) await onError(e);
}
+ dispatch({ type: types.FINISH, ...params });
+ };
- console.error(e, reduxErrDetail);
+// Curried version
+export const networkAction =
+ (fn) =>
+ (...args) =>
+ _networkAction(fn, ...args);
- // prefer any cause messages to the top-level "message" string
- if (e.cause && e.cause.length) {
- const errorDetails = e.cause.map((c) => c.message ?? "");
- errorDetails.forEach((ed) => {
- message.error(formatErrorMessage(reduxErrDetail, ed));
- });
- } else {
- message.error(formatErrorMessage(reduxErrDetail, e.message));
- }
+const handleNetworkErrorMessaging = (state, e, reduxErrDetail) => {
+ // if we're currently auto-authenticating, and it's a network error, suppress it.
+ if (e.toString().includes("NetworkError when attempting") && state.auth.isAutoAuthenticating) {
+ return;
+ }
+
+ console.error(e, reduxErrDetail);
+
+ // prefer any cause messages to the top-level "message" string
+ if (e.cause && e.cause.length) {
+ const errorDetails = e.cause.map((c) => c.message ?? "");
+ errorDetails.forEach((ed) => {
+ message.error(formatErrorMessage(reduxErrDetail, ed));
+ });
+ } else {
+ message.error(formatErrorMessage(reduxErrDetail, e.message));
+ }
};
const formatErrorMessage = (errorMessageIntro, errorDetail) => {
- return errorMessageIntro
- ? errorMessageIntro + (errorDetail ? `: ${errorDetail}` : "")
- : errorDetail;
+ return errorMessageIntro ? errorMessageIntro + (errorDetail ? `: ${errorDetail}` : "") : errorDetail;
};
-export const beginFlow = (types) => async (dispatch) => await dispatch({ type: types.BEGIN });
-export const endFlow = (types) => async (dispatch) => await dispatch({ type: types.END });
-export const terminateFlow = (types) => async (dispatch) => await dispatch({ type: types.TERMINATE });
+export const beginFlow = (types) => (dispatch) => dispatch({ type: types.BEGIN });
+export const endFlow = (types) => (dispatch) => dispatch({ type: types.END });
+export const terminateFlow = (types) => (dispatch) => dispatch({ type: types.TERMINATE });
diff --git a/src/utils/colors.ts b/src/utils/colors.ts
index cb6836fee..e85206694 100644
--- a/src/utils/colors.ts
+++ b/src/utils/colors.ts
@@ -1,25 +1,25 @@
// Thanks to Google Charts
const COLORS = [
- "#3366CC",
- "#DC3912",
- "#FF9900",
- "#109618",
- "#990099",
- "#3B3EAC",
- "#0099C6",
- "#DD4477",
- "#66AA00",
- "#B82E2E",
- "#316395",
- "#994499",
- "#22AA99",
- "#AAAA11",
- "#6633CC",
- "#E67300",
- "#8B0707",
- "#329262",
- "#5574A6",
- "#3B3EAC",
+ "#3366CC",
+ "#DC3912",
+ "#FF9900",
+ "#109618",
+ "#990099",
+ "#3B3EAC",
+ "#0099C6",
+ "#DD4477",
+ "#66AA00",
+ "#B82E2E",
+ "#316395",
+ "#994499",
+ "#22AA99",
+ "#AAAA11",
+ "#6633CC",
+ "#E67300",
+ "#8B0707",
+ "#329262",
+ "#5574A6",
+ "#3B3EAC",
];
export default COLORS;
diff --git a/src/utils/files.ts b/src/utils/files.ts
index d153c0b61..e940144a7 100644
--- a/src/utils/files.ts
+++ b/src/utils/files.ts
@@ -4,43 +4,43 @@ export type GenomicsFileType = "gvcf" | "vcf" | "maf" | "sam" | "bam" | "cram" |
// file type guesses for igv files, for cases where this information is missing
export const guessFileType = (filename: string): GenomicsFileType | undefined => {
- const filenameLower = filename.toLowerCase();
-
- // variant:
- if (filenameLower.endsWith(".g.vcf") || filenameLower.endsWith(".g.vcf.gz")) return "gvcf";
- if (filenameLower.endsWith(".vcf") || filenameLower.endsWith(".vcf.gz")) return "vcf";
- // mutation:
- if (filenameLower.endsWith(".maf")) return "maf";
- // alignment:
- if (filenameLower.endsWith(".sam")) return "sam";
- if (filenameLower.endsWith(".bam")) return "bam";
- if (filenameLower.endsWith(".cram")) return "cram";
- // wig:
- if (filenameLower.endsWith(".bw") || filenameLower.endsWith(".bigwig")) return "bigwig";
-
- // expand here accordingly
- return undefined;
+ const filenameLower = filename.toLowerCase();
+
+ // variant:
+ if (filenameLower.endsWith(".g.vcf") || filenameLower.endsWith(".g.vcf.gz")) return "gvcf";
+ if (filenameLower.endsWith(".vcf") || filenameLower.endsWith(".vcf.gz")) return "vcf";
+ // mutation:
+ if (filenameLower.endsWith(".maf")) return "maf";
+ // alignment:
+ if (filenameLower.endsWith(".sam")) return "sam";
+ if (filenameLower.endsWith(".bam")) return "bam";
+ if (filenameLower.endsWith(".cram")) return "cram";
+ // wig:
+ if (filenameLower.endsWith(".bw") || filenameLower.endsWith(".bigwig")) return "bigwig";
+
+ // expand here accordingly
+ return undefined;
};
const FILE_TEST_REGEX_CACHE: Record = {};
const _getFileRegExp = (pattern: string): RegExp => {
- if (pattern in FILE_TEST_REGEX_CACHE) {
- return FILE_TEST_REGEX_CACHE[pattern];
- }
+ if (pattern in FILE_TEST_REGEX_CACHE) {
+ return FILE_TEST_REGEX_CACHE[pattern];
+ }
- const r = new RegExp(pattern, "i");
- FILE_TEST_REGEX_CACHE[pattern] = r;
- return r;
+ const r = new RegExp(pattern, "i");
+ FILE_TEST_REGEX_CACHE[pattern] = r;
+ return r;
};
export const testFileAgainstPattern = (fileName: string, pattern: string): boolean => {
- if (!pattern) {
- // No pattern => everything matches
- return true;
- }
- const r = _getFileRegExp(pattern);
- return r.test(fileName);
+ if (!pattern) {
+ // No pattern => everything matches
+ return true;
+ }
+ const r = _getFileRegExp(pattern);
+ return r.test(fileName);
};
export const dropBoxTreeNodeEnabledJson = (entry: DropBoxEntry) => {
diff --git a/src/utils/localStorageUtils.js b/src/utils/localStorageUtils.js
deleted file mode 100644
index fb68a7460..000000000
--- a/src/utils/localStorageUtils.js
+++ /dev/null
@@ -1,20 +0,0 @@
-export const writeToLocalStorage = (item, value) => {
- localStorage.setItem(item, JSON.stringify(value));
-};
-
-export const readFromLocalStorage = (item) => {
- let value;
- try {
- value = JSON.parse(localStorage.getItem(item));
- } catch (e) {
- console.error(e);
- return null;
- }
- return value;
-};
-
-export const popLocalStorageItem = key => {
- const val = localStorage.getItem(key);
- localStorage.removeItem(key);
- return val;
-};
diff --git a/src/utils/localStorageUtils.ts b/src/utils/localStorageUtils.ts
new file mode 100644
index 000000000..9b9baa240
--- /dev/null
+++ b/src/utils/localStorageUtils.ts
@@ -0,0 +1,16 @@
+export const writeToLocalStorage = (item: string, value: string | number | object) => {
+ localStorage.setItem(item, JSON.stringify(value));
+};
+
+export const readFromLocalStorage = (item: string): T | null => {
+ const ls = localStorage.getItem(item);
+ if (ls !== null) {
+ try {
+ return JSON.parse(ls);
+ } catch (e) {
+ console.error(e);
+ return null;
+ }
+ }
+ return null;
+};
diff --git a/src/utils/menu.js b/src/utils/menu.js
deleted file mode 100644
index 821f8cadc..000000000
--- a/src/utils/menu.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from "react";
-import {Link} from "react-router-dom";
-
-// Custom menu renderer
-export const transformMenuItem = (i) => {
- const baseItem = {
- key: i.key ?? i.url,
- style: i.style ?? {},
- disabled: i.disabled ?? false,
- };
-
- if (i.hasOwnProperty("children")) {
- return {
- ...baseItem,
- label: (
-
- {i.icon ?? null}
- {i.text ? {i.text} : null}
-
- ),
- children: (i.children ?? []).map((ii) => transformMenuItem(ii)),
- };
- }
-
- return {
- ...baseItem,
- ...(i.onClick ? {onClick: i.onClick} : {}),
- label: i.url ? (
-
- {i.icon ?? null}
- {i.text ? {i.text} : null}
-
- ) : (
-
- {i.icon ?? null}
- {i.text ? {i.text} : null}
-
- ),
- };
-};
-
-const currentUrlMatches = (i) => i.url && window.location.pathname.startsWith(i.url);
-export const matchingMenuKeys = (menuItems) => menuItems
- .filter(i => currentUrlMatches(i) || (i.children ?? []).length > 0)
- .flatMap(i => [
- ...(currentUrlMatches(i) ? [i.key ?? i.url ?? ""] : []),
- ...matchingMenuKeys(i.children ?? []),
- ]);
diff --git a/src/utils/menu.tsx b/src/utils/menu.tsx
new file mode 100644
index 000000000..a6572851b
--- /dev/null
+++ b/src/utils/menu.tsx
@@ -0,0 +1,51 @@
+import { Link } from "react-router-dom";
+import type { BentoMenuItem } from "@/types/menu";
+import type { ItemType } from "antd/es/menu/interface";
+
+// Custom menu renderer
+export const transformMenuItem = (i: BentoMenuItem): ItemType => {
+ const baseItem = {
+ key: "key" in i ? i.key : i.url,
+ style: i.style ?? {},
+ disabled: i.disabled ?? false,
+ };
+
+ if (i.hasOwnProperty("children")) {
+ return {
+ ...baseItem,
+ label: (
+
+ {i.icon ?? null}
+ {i.text ? {i.text} : null}
+
+ ),
+ children: (i.children ?? []).map((ii) => transformMenuItem(ii)),
+ };
+ }
+
+ return {
+ ...baseItem,
+ ...(i.onClick ? { onClick: i.onClick } : {}),
+ label:
+ "url" in i ? (
+
+ {i.icon ?? null}
+ {i.text ? {i.text} : null}
+
+ ) : (
+
+ {i.icon ?? null}
+ {i.text ? {i.text} : null}
+
+ ),
+ };
+};
+
+const currentUrlMatches = (i: BentoMenuItem) => "url" in i && window.location.pathname.startsWith(i.url);
+export const matchingMenuKeys = (menuItems: BentoMenuItem[]): string[] =>
+ menuItems
+ .filter((i) => currentUrlMatches(i) || (i.children ?? []).length > 0)
+ .flatMap((i) => [
+ ...(currentUrlMatches(i) ? ["key" in i ? i.key : i.url] : []),
+ ...matchingMenuKeys(i.children ?? []),
+ ]);
diff --git a/src/utils/misc.js b/src/utils/misc.js
deleted file mode 100644
index fafcd145c..000000000
--- a/src/utils/misc.js
+++ /dev/null
@@ -1,19 +0,0 @@
-// Functional utilities
-export const id = (x) => x; // id is a function that returns its first passed parameter
-export const nop = () => {};
-export const constFn = (x) => () => x; // constFn(null) creates a function that always returns null
-export const getFalse = constFn(false);
-export const getTrue = constFn(true);
-
-export const countNonNullElements = (arr) => {
- return arr.filter((item) => item !== null).length;
-}; // counts the non-null elements in any array
-
-// Object utilities
-export const simpleDeepCopy = (o) => JSON.parse(JSON.stringify(o));
-export const objectWithoutProps = (o, ps) => Object.fromEntries(Object.entries(o).filter(([p2]) => !ps.includes(p2)));
-export const objectWithoutProp = (o, p) => objectWithoutProps(o, [p]);
-export const arrayToObjectByProperty = (l, p) => Object.fromEntries(l.map(l => [l[p], l]));
-
-// REGEX
-export const notAlleleCharactersRegex = new RegExp("[^ACGTN]", "g");
diff --git a/src/utils/misc.ts b/src/utils/misc.ts
new file mode 100644
index 000000000..64b267080
--- /dev/null
+++ b/src/utils/misc.ts
@@ -0,0 +1,25 @@
+// Functional utilities
+export const id = (x: T) => x; // id is a function that returns its first passed parameter
+export const nop = () => {};
+export const constFn =
+ (x: T) =>
+ () =>
+ x; // constFn(null) creates a function that always returns null
+export const getFalse = constFn(false);
+export const getTrue = constFn(true);
+
+export const countNonNullElements = (arr: unknown[]) => {
+ return arr.filter((item) => item !== null).length;
+}; // counts the non-null elements in any array
+
+// Object utilities
+export type RecordKey = string | number | symbol;
+export const simpleDeepCopy = (o: object) => JSON.parse(JSON.stringify(o));
+export const objectWithoutProps = (o: object, ps: RecordKey[]) =>
+ Object.fromEntries(Object.entries(o).filter(([p2]) => !ps.includes(p2)));
+export const objectWithoutProp = (o: object, p: RecordKey) => objectWithoutProps(o, [p]);
+export const arrayToObjectByProperty = >(l: T[], p: RecordKey) =>
+ Object.fromEntries(l.map((item) => [item[p], item]));
+
+// REGEX
+export const notAlleleCharactersRegex = new RegExp("[^ACGTN]", "g");
diff --git a/src/utils/notifications.js b/src/utils/notifications.js
index cfd033d91..03cc4921e 100644
--- a/src/utils/notifications.js
+++ b/src/utils/notifications.js
@@ -1,9 +1,9 @@
-import {hideNotificationDrawer} from "@/modules/notifications/actions";
+import { hideNotificationDrawer } from "@/modules/notifications/actions";
export const NOTIFICATION_WES_RUN_COMPLETED = "wes_run_completed";
export const NOTIFICATION_WES_RUN_FAILED = "wes_run_failed";
-export const navigateToWESRun = (target, navigate) => async dispatch => {
- await dispatch(hideNotificationDrawer());
- navigate(`/data/manager/runs/${target}/request`);
+export const navigateToWESRun = (target, navigate) => async (dispatch) => {
+ await dispatch(hideNotificationDrawer());
+ navigate(`/data/manager/runs/${target}/request`);
};
diff --git a/src/utils/overview.js b/src/utils/overview.js
index 1e44f7120..cd888a79b 100644
--- a/src/utils/overview.js
+++ b/src/utils/overview.js
@@ -1,13 +1,13 @@
export const mapNameValueFields = (data) =>
- Object.entries(data ?? {})
- .map(([name, value]) => ({ name, value }))
- .sort((a, b) => a.value - b.value);
+ Object.entries(data ?? {})
+ .map(([name, value]) => ({ name, value }))
+ .sort((a, b) => a.value - b.value);
export const getPieChart = ({ title, data, fieldLabel, thresholdFraction }) => ({
- title,
- data: mapNameValueFields(data),
- fieldLabel,
- type: "PIE",
- clickableOther: "Other" in (data ?? {}),
- thresholdFraction,
+ title,
+ data: mapNameValueFields(data),
+ fieldLabel,
+ type: "PIE",
+ clickableOther: "Other" in (data ?? {}),
+ thresholdFraction,
});
diff --git a/src/utils/requests.js b/src/utils/requests.js
index e3dd6f2cb..cea9533e7 100644
--- a/src/utils/requests.js
+++ b/src/utils/requests.js
@@ -1,20 +1,15 @@
const SERIALIZED_TYPES = ["object", "array"];
-export const createFormData = obj => {
- const formData = new FormData();
- Object.entries(obj).forEach(([k, v]) =>
- formData.append(k, (SERIALIZED_TYPES.includes(typeof v)) ? JSON.stringify(v) : v));
- return formData;
-};
-
-export const createURLSearchParams = obj => {
- const usp = new URLSearchParams();
- Object.entries(obj).forEach(([k, v]) => usp.set(k, typeof v === "object" ? JSON.stringify(v) : v));
- return usp;
+export const createFormData = (obj) => {
+ const formData = new FormData();
+ Object.entries(obj).forEach(([k, v]) =>
+ formData.append(k, SERIALIZED_TYPES.includes(typeof v) ? JSON.stringify(v) : v),
+ );
+ return formData;
};
export const jsonRequest = (body, method = "GET", extraHeaders = undefined) => ({
- method,
- headers: {"Content-Type": "application/json", ...(extraHeaders ?? {})},
- body: JSON.stringify(body),
+ method,
+ headers: { "Content-Type": "application/json", ...(extraHeaders ?? {}) },
+ body: JSON.stringify(body),
});
diff --git a/src/utils/schema.js b/src/utils/schema.js
index 52d4acabb..ab3e9e886 100644
--- a/src/utils/schema.js
+++ b/src/utils/schema.js
@@ -5,13 +5,11 @@ import { QuestionCircleOutlined } from "@ant-design/icons";
import MonospaceText from "@/components/common/MonospaceText";
-
export const ROOT_SCHEMA_ID = "[dataset item]";
const ARRAY_ITEM_ID = "[item]";
const getFalse = () => false;
-
/**
* Generates schema tree data and uses it to decide whether to include the current node in a searchable list fragment.
* @param {object} node - The schema node to check.
@@ -21,10 +19,13 @@ const getFalse = () => false;
* @returns {object[]} - An array with a tree node element for use in Ant Design components, or an empty array.
*/
const searchFragment = (node, name, prefix = "", isExcluded = getFalse) => {
- if (!node) return [];
- const result = generateSchemaTreeData(node, name, prefix, isExcluded);
- return (node.hasOwnProperty("search") || node.type === "object" && result.children.length > 0 ||
- node.type === "array" && result.children.length > 0) ? [result] : [];
+ if (!node) return [];
+ const result = generateSchemaTreeData(node, name, prefix, isExcluded);
+ return node.hasOwnProperty("search") ||
+ (node.type === "object" && result.children.length > 0) ||
+ (node.type === "array" && result.children.length > 0)
+ ? [result]
+ : [];
};
/**
@@ -34,11 +35,15 @@ const searchFragment = (node, name, prefix = "", isExcluded = getFalse) => {
* @returns {number}
*/
const sortSchemaEntries = (a, b) => {
- if (a[1].hasOwnProperty("search") && b[1].hasOwnProperty("search")
- && a[1].search.hasOwnProperty("order") && b[1].search.hasOwnProperty("order")) {
- return a[1].search.order - b[1].search.order;
- }
- return a[0].localeCompare(b[0]);
+ if (
+ a[1].hasOwnProperty("search") &&
+ b[1].hasOwnProperty("search") &&
+ a[1].search.hasOwnProperty("order") &&
+ b[1].search.hasOwnProperty("order")
+ ) {
+ return a[1].search.order - b[1].search.order;
+ }
+ return a[0].localeCompare(b[0]);
};
/**
@@ -49,64 +54,68 @@ const sortSchemaEntries = (a, b) => {
* @param {function} isExcluded - A function determining if a key is disabled.
* @returns {{children, selectable, disabled, titleSelected, title, value, key}} - Ant tree node for the schema node.
*/
-export const generateSchemaTreeData = (
- node,
- name = ROOT_SCHEMA_ID,
- prefix = "",
- isExcluded = getFalse,
-) => {
- const key = `${prefix}${name}`;
- const displayType = (node.type instanceof Array) ? node.type.join(" or ") : node.type;
-
- const selectable = node.hasOwnProperty("search")
- && node.search.hasOwnProperty("operations")
- && node.search.operations.length > 0
- && !isExcluded(key);
-
- let children = [];
-
- // Only include fields that are searchable, objects that have all searchable properties, and arrays that have items
- // that are searchable in some way. This is done using searchFragment, which returns [] if a node and all its
- // children are not searchable.
- switch (node.type) {
- case "object":
- children = Object.entries(node.properties ?? {})
- .sort(sortSchemaEntries)
- .flatMap(([name, node]) => searchFragment(node, name, `${key}.`, isExcluded));
- break;
-
- case "array":
- children = searchFragment(node.items, ARRAY_ITEM_ID, `${key}.`, isExcluded);
- break;
- }
-
- return {
- key,
- value: key,
- data: node,
- title:
- {name} - {displayType}
- {node.description ? (
-
- {key.replace(`${ROOT_SCHEMA_ID}.`, "")}
- }>
- } type="link" size="small" style={{marginLeft: "8px"}}/>
-
- ) : null}
- ,
- titleSelected: {key.split(".").slice(1).join(".")} ,
- selectable,
- disabled: !selectable && (!["object", "array"].includes(node.type)
- || children.filter(c => !c.disabled).length === 0),
- children,
- };
+export const generateSchemaTreeData = (node, name = ROOT_SCHEMA_ID, prefix = "", isExcluded = getFalse) => {
+ const key = `${prefix}${name}`;
+ const displayType = node.type instanceof Array ? node.type.join(" or ") : node.type;
+
+ const selectable =
+ node.hasOwnProperty("search") &&
+ node.search.hasOwnProperty("operations") &&
+ node.search.operations.length > 0 &&
+ !isExcluded(key);
+
+ let children = [];
+
+ // Only include fields that are searchable, objects that have all searchable properties, and arrays that have items
+ // that are searchable in some way. This is done using searchFragment, which returns [] if a node and all its
+ // children are not searchable.
+ switch (node.type) {
+ case "object":
+ children = Object.entries(node.properties ?? {})
+ .sort(sortSchemaEntries)
+ .flatMap(([name, node]) => searchFragment(node, name, `${key}.`, isExcluded));
+ break;
+
+ case "array":
+ children = searchFragment(node.items, ARRAY_ITEM_ID, `${key}.`, isExcluded);
+ break;
+ }
+
+ return {
+ key,
+ value: key,
+ data: node,
+ title: (
+
+ {name} - {displayType}
+ {node.description ? (
+ {key.replace(`${ROOT_SCHEMA_ID}.`, "")}}
+ >
+ } type="link" size="small" style={{ marginLeft: "8px" }} />
+
+ ) : null}
+
+ ),
+ titleSelected: (
+
+ {key.split(".").slice(1).join(".")}
+
+ ),
+ selectable,
+ disabled:
+ !selectable && (!["object", "array"].includes(node.type) || children.filter((c) => !c.disabled).length === 0),
+ children,
+ };
};
/**
@@ -114,16 +123,18 @@ export const generateSchemaTreeData = (
* @param {object} treeData - Tree data created via generateSchemaTreeData.
* @returns {array} - List of table data to use as dataSource in an Ant table component.
*/
-export const generateSchemaTableData = treeData =>
- [
- ...(treeData.key === ROOT_SCHEMA_ID
- ? []
- : [{
- ...Object.fromEntries(Object.entries(treeData).filter(p => p[0] !== "children")),
- key: treeData.key.replace(`${ROOT_SCHEMA_ID}.`, ""),
- }]),
- ...(treeData.children ?? []).flatMap(c => generateSchemaTableData(c)),
- ].sort((a, b) => a.key.localeCompare(b.key));
+export const generateSchemaTableData = (treeData) =>
+ [
+ ...(treeData.key === ROOT_SCHEMA_ID
+ ? []
+ : [
+ {
+ ...Object.fromEntries(Object.entries(treeData).filter((p) => p[0] !== "children")),
+ key: treeData.key.replace(`${ROOT_SCHEMA_ID}.`, ""),
+ },
+ ]),
+ ...(treeData.children ?? []).flatMap((c) => generateSchemaTableData(c)),
+ ].sort((a, b) => a.key.localeCompare(b.key));
/**
* Resolves a particular field's schema from the overall object schema and a dot-notation field string.
@@ -132,41 +143,45 @@ export const generateSchemaTableData = treeData =>
* @return {object} - Schema for the field.
*/
export const getFieldSchema = (schema, fieldString) => {
- const components = fieldString.split(".");
- if (components.length === 0 || components[0] !== ROOT_SCHEMA_ID) {
- // Field string doesn't correspond to the format mandated by the Bento front end.
- throw new Error("Invalid format for field string.");
- }
-
- let currentSchema = schema;
- let currentComponent = 0;
- while (currentComponent < components.length - 1) {
- switch (currentSchema.type) {
- case "object":
- currentComponent++;
- if (!currentSchema.hasOwnProperty("properties")
- || !currentSchema.properties.hasOwnProperty(components[currentComponent])) {
- throw new Error(`Invalid field specified in field string (missing property, at ${
- components.slice(currentComponent)})`);
- }
- currentSchema = currentSchema.properties[components[currentComponent]];
- break;
-
- case "array":
- currentComponent++;
- if (!currentSchema.hasOwnProperty("items") || components[currentComponent] !== ARRAY_ITEM_ID) {
- throw new Error(`Invalid field specified in field string (missing [item], at ${
- components.slice(currentComponent)})`);
- }
- currentSchema = currentSchema.items;
- break;
-
- default:
- throw new Error("Cannot access properties of primitives.");
+ const components = fieldString.split(".");
+ if (components.length === 0 || components[0] !== ROOT_SCHEMA_ID) {
+ // Field string doesn't correspond to the format mandated by the Bento front end.
+ throw new Error("Invalid format for field string.");
+ }
+
+ let currentSchema = schema;
+ let currentComponent = 0;
+ while (currentComponent < components.length - 1) {
+ switch (currentSchema.type) {
+ case "object":
+ currentComponent++;
+ if (
+ !currentSchema.hasOwnProperty("properties") ||
+ !currentSchema.properties.hasOwnProperty(components[currentComponent])
+ ) {
+ throw new Error(
+ `Invalid field specified in field string (missing property, at ${components.slice(currentComponent)})`,
+ );
}
+ currentSchema = currentSchema.properties[components[currentComponent]];
+ break;
+
+ case "array":
+ currentComponent++;
+ if (!currentSchema.hasOwnProperty("items") || components[currentComponent] !== ARRAY_ITEM_ID) {
+ throw new Error(
+ `Invalid field specified in field string (missing [item], at ${components.slice(currentComponent)})`,
+ );
+ }
+ currentSchema = currentSchema.items;
+ break;
+
+ default:
+ throw new Error("Cannot access properties of primitives.");
}
+ }
- return currentSchema;
+ return currentSchema;
};
/**
@@ -174,15 +189,15 @@ export const getFieldSchema = (schema, fieldString) => {
* @param {object} schema - The schema to extract keys from.
* @returns {string[]} - An array of fields in dot-delimited key representation.
*/
-export const getFields = schema => {
- // TODO: Deduplicate with tree select
- if (!schema) return [];
- const treeData = generateSchemaTreeData(schema, ROOT_SCHEMA_ID, "", getFalse);
- const getFieldsFromTreeData = (node, acc) => {
- acc.push(node.key);
- node.children.map(c => getFieldsFromTreeData(c, acc));
- };
- const acc = [];
- getFieldsFromTreeData(treeData, acc);
- return acc;
+export const getFields = (schema) => {
+ // TODO: Deduplicate with tree select
+ if (!schema) return [];
+ const treeData = generateSchemaTreeData(schema, ROOT_SCHEMA_ID, "", getFalse);
+ const getFieldsFromTreeData = (node, acc) => {
+ acc.push(node.key);
+ node.children.map((c) => getFieldsFromTreeData(c, acc));
+ };
+ const acc = [];
+ getFieldsFromTreeData(treeData, acc);
+ return acc;
};
diff --git a/src/utils/search.js b/src/utils/search.js
index 9347b440e..164d0a20b 100644
--- a/src/utils/search.js
+++ b/src/utils/search.js
@@ -1,4 +1,4 @@
-import {simpleDeepCopy} from "./misc";
+import { simpleDeepCopy } from "./misc";
export const OP_EQUALS = "eq";
export const OP_LESS_THAN = "lt";
@@ -12,214 +12,222 @@ export const OP_CASE_INSENSITIVE_ENDS_WITH = "iew";
export const OP_CASE_INSENSITIVE_LIKE = "ilike";
export const OPERATION_TEXT = {
- [OP_EQUALS]: "=",
- [OP_LESS_THAN]: "<",
- [OP_LESS_THAN_OR_EQUAL]: "\u2264",
- [OP_GREATER_THAN]: ">",
- [OP_GREATER_THAN_OR_EQUAL]: "\u2265",
- [OP_CONTAINING]: "containing (case-sensitive)",
- [OP_CASE_INSENSITIVE_CONTAINING]: "containing",
- [OP_CASE_INSENSITIVE_STARTS_WITH]: "starting with",
- [OP_CASE_INSENSITIVE_ENDS_WITH]: "ending with",
- [OP_CASE_INSENSITIVE_LIKE]: "SQL—ILIKE",
+ [OP_EQUALS]: "=",
+ [OP_LESS_THAN]: "<",
+ [OP_LESS_THAN_OR_EQUAL]: "\u2264",
+ [OP_GREATER_THAN]: ">",
+ [OP_GREATER_THAN_OR_EQUAL]: "\u2265",
+ [OP_CONTAINING]: "containing (case-sensitive)",
+ [OP_CASE_INSENSITIVE_CONTAINING]: "containing",
+ [OP_CASE_INSENSITIVE_STARTS_WITH]: "starting with",
+ [OP_CASE_INSENSITIVE_ENDS_WITH]: "ending with",
+ [OP_CASE_INSENSITIVE_LIKE]: "SQL—ILIKE",
};
export const UI_SUPPORTED_OPERATIONS = Object.keys(OPERATION_TEXT);
export const DEFAULT_SEARCH_PARAMETERS = {
- operations: [OP_EQUALS, OP_LESS_THAN, OP_LESS_THAN_OR_EQUAL, OP_GREATER_THAN, OP_GREATER_THAN_OR_EQUAL,
- OP_CONTAINING, OP_CASE_INSENSITIVE_CONTAINING],
- canNegate: true,
- required: false,
- type: "unlimited",
- queryable: "all",
+ operations: [
+ OP_EQUALS,
+ OP_LESS_THAN,
+ OP_LESS_THAN_OR_EQUAL,
+ OP_GREATER_THAN,
+ OP_GREATER_THAN_OR_EQUAL,
+ OP_CONTAINING,
+ OP_CASE_INSENSITIVE_CONTAINING,
+ ],
+ canNegate: true,
+ required: false,
+ type: "unlimited",
+ queryable: "all",
};
const VARIANT_OPTIONAL_FIELDS = [
- "[dataset item].calls.[item].genotype_type",
- "[dataset item].alternative",
- "[dataset item].reference",
+ "[dataset item].calls.[item].genotype_type",
+ "[dataset item].alternative",
+ "[dataset item].reference",
];
export const addDataTypeFormIfPossible = (dataTypeForms, dataType) =>
- (dataTypeForms.map(d => d.dataType.id).includes(dataType.id))
- ? dataTypeForms
- : [...(dataTypeForms ?? []), {dataType, formValues: []}];
+ dataTypeForms.map((d) => d.dataType.id).includes(dataType.id)
+ ? dataTypeForms
+ : [...(dataTypeForms ?? []), { dataType, formValues: [] }];
export const updateDataTypeFormIfPossible = (dataTypeForms, dataType, fields) =>
- dataTypeForms.map(d => d.dataType.id === dataType.id
- ? {...d, formValues: simpleDeepCopy(fields)} : d); // TODO: Hack-y deep clone
+ dataTypeForms.map((d) => (d.dataType.id === dataType.id ? { ...d, formValues: simpleDeepCopy(fields) } : d)); // TODO: Hack-y deep clone
export const removeDataTypeFormIfPossible = (dataTypeForms, dataType) =>
- dataTypeForms.filter(d => d.dataType.id !== dataType.id);
+ dataTypeForms.filter((d) => d.dataType.id !== dataType.id);
+export const extractQueryConditionsFromFormValues = (formValues) => formValues[0]?.value ?? [];
-export const extractQueryConditionsFromFormValues = formValues => formValues[0]?.value ?? [];
+export const conditionsToQuery = (conditions) => {
+ // temp hack: remove any optional variant fields that are empty
+ // greatly simplifies management of variant forms UI
+ const afterVariantCleaning = conditions.filter(
+ ({ field, searchValue }) => !(VARIANT_OPTIONAL_FIELDS.includes(field) && !searchValue),
+ );
-export const conditionsToQuery = conditions => {
+ const filteredConditions = afterVariantCleaning.filter((c) => c.field);
+ if (filteredConditions.length === 0) return null;
- // temp hack: remove any optional variant fields that are empty
- // greatly simplifies management of variant forms UI
- const afterVariantCleaning = conditions.filter(({ field, searchValue }) =>
- (!(VARIANT_OPTIONAL_FIELDS.includes(field) && !searchValue)));
-
- const filteredConditions = afterVariantCleaning.filter(c => c.field);
- if (filteredConditions.length === 0) return null;
-
- return filteredConditions
- .map(({ field, negated, operation, searchValue }) =>
- (exp => negated ? ["#not", exp] : exp)( // Negate expression if needed
- [`#${operation}`,
- ["#resolve", ...field.split(".").slice(1)],
- searchValue],
- ))
- .reduce((se, v) => ["#and", se, v]);
+ return filteredConditions
+ .map(({ field, negated, operation, searchValue }) =>
+ ((exp) => (negated ? ["#not", exp] : exp))(
+ // Negate expression if needed
+ [`#${operation}`, ["#resolve", ...field.split(".").slice(1)], searchValue],
+ ),
+ )
+ .reduce((se, v) => ["#and", se, v]);
};
-export const extractQueriesFromDataTypeForms = dataTypeForms => Object.fromEntries(dataTypeForms
- .map(d => [d.dataType.id, conditionsToQuery(extractQueryConditionsFromFormValues(d.formValues))])
- .filter(c => c[1] !== null));
+export const extractQueriesFromDataTypeForms = (dataTypeForms) =>
+ Object.fromEntries(
+ dataTypeForms
+ .map((d) => [d.dataType.id, conditionsToQuery(extractQueryConditionsFromFormValues(d.formValues))])
+ .filter((c) => c[1] !== null),
+ );
export const searchUiMappings = {
- "phenopacket": {
- "id": "id",
- "subject": {
- "id": {
- "path": "subject.id",
- "ui_name": "Subject ID",
- },
- "sex": {
- "path": "subject.sex",
- "ui_name": "Sex",
- },
- "karyotypic_sex": {
- "path": "subject.karyotypic_sex",
- "ui_name": "Karyotypic sex",
- },
- "taxonomy": {
- "path": "subject.taxonomy.label",
- "ui_name": "Subject Taxonomy",
- },
- },
- "phenotypic_features": {
- "description": {
- "path": "phenotypic_features.[item].description",
- "ui_name": "Phenotypic feature description",
- },
- "type": {
- "path": "phenotypic_features.[item].type.label",
- "ui_name": "Phenotypic feature type",
- },
- "severity": {
- "path": "phenotypic_features.[item].severity.label",
- "ui_name": "Phenotypic feature severity",
- },
- "modifiers": {
- "path": "phenotypic_features.[item].modifiers.[item].label",
- "ui_name": "Phenotypic feature modifier",
- },
- // TODO: new search for Phenopacket TimeElement
- // "onset": {
- // "path": "phenotypic_features.[item].onset.label",
- // "ui_name": "Phenotypic feature onset",
- // },
- },
- "biosamples": {
- "description": {
- "path": "biosamples.[item].description",
- "ui_name": "Biosample description",
- },
- "sampled_tissue": {
- "path": "biosamples.[item].sampled_tissue.label",
- "ui_name": "Sampled tissue",
- },
- "taxonomy": {
- "path": "biosamples.[item].taxonomy.label",
- "ui_name": "Biosample taxonomy",
- },
- "histological_diagnosis": {
- "path": "biosamples.[item].histological_diagnosis.label",
- "ui_name": "Biosample histological diagnosis",
- },
- "tumor_progression": {
- "path": "biosamples.[item].tumor_progression.label",
- "ui_name": "Tumor progression",
- },
- "tumor_grade": {
- "path": "biosamples.[item].tumor_grade.label",
- "ui_name": "Tumor grade",
- },
- "diagnostic_markers": {
- "path": "biosamples.[item].diagnostic_markers.[item].label",
- "ui_name": "Diagnostic markers",
- },
- "procedure": {
- "path": "biosamples.[item].procedure.code.label",
- "ui_name": "Procedure",
- },
- },
- "diseases": {
- "term": {
- "path": "diseases.[item].term.label",
- "ui_name": "Disease",
- },
- "disease_stage": {
- "path": "diseases.[item].disease_stage.[item].label",
- "ui_name": "Disease stage",
- },
- "clinical_tnm_finding": {
- "path": "diseases.[item].clinical_tnm_finding.[item].label",
- "ui_name": "TNM finding",
- },
- },
- "interpretations": {
- "progress_status": {
- "path": "interpretations.[item].progress_status",
- "ui_name": "Progress Status",
- },
- "summary": {
- "path": "interpretations.[item].summary",
- "ui_name": "Summary",
- },
- "diagnosis": {
- "path": "interpretations.[item].diagnosis.disease.label",
- "ui_name": "Diagnosis Disease",
- },
- },
- "measurements": {
- "description": {
- "path": "measurements.[item].description",
- "ui_name": "Description",
- },
- "assay": {
- "path": "measurements.[item].assay.label",
- "ui_name": "Assay",
- },
- "procedure": {
- "path": "measurements.[item].procedure.code.label",
- "ui_name": "Procedure",
- },
- },
- "medical_actions": {
- "treatment_target": {
- "path": "medical_actions.[item].treatment_target.label",
- "ui_name": "Treatment Target",
- },
- "treatment_intent": {
- "path": "medical_actions.[item].treatment_intent.label",
- "ui_name": "Treatment Intent",
- },
- "response_to_treatment": {
- "path": "medical_actions.[item].response_to_treatment.label",
- "ui_name": "Response To Treatment",
- },
- "adverse_events": {
- "path": "medical_actions.[item].adverse_events.[item].label",
- "ui_name": "Adverse Events",
- },
- "treatment_termination_reason": {
- "path": "medical_actions.[item].treatment_termination_reason.label",
- "ui_name": "Treatment Termination Reason",
- },
- },
+ phenopacket: {
+ id: "id",
+ subject: {
+ id: {
+ path: "subject.id",
+ ui_name: "Subject ID",
+ },
+ sex: {
+ path: "subject.sex",
+ ui_name: "Sex",
+ },
+ karyotypic_sex: {
+ path: "subject.karyotypic_sex",
+ ui_name: "Karyotypic sex",
+ },
+ taxonomy: {
+ path: "subject.taxonomy.label",
+ ui_name: "Subject Taxonomy",
+ },
+ },
+ phenotypic_features: {
+ description: {
+ path: "phenotypic_features.[item].description",
+ ui_name: "Phenotypic feature description",
+ },
+ type: {
+ path: "phenotypic_features.[item].type.label",
+ ui_name: "Phenotypic feature type",
+ },
+ severity: {
+ path: "phenotypic_features.[item].severity.label",
+ ui_name: "Phenotypic feature severity",
+ },
+ modifiers: {
+ path: "phenotypic_features.[item].modifiers.[item].label",
+ ui_name: "Phenotypic feature modifier",
+ },
+ // TODO: new search for Phenopacket TimeElement
+ // "onset": {
+ // "path": "phenotypic_features.[item].onset.label",
+ // "ui_name": "Phenotypic feature onset",
+ // },
+ },
+ biosamples: {
+ description: {
+ path: "biosamples.[item].description",
+ ui_name: "Biosample description",
+ },
+ sampled_tissue: {
+ path: "biosamples.[item].sampled_tissue.label",
+ ui_name: "Sampled tissue",
+ },
+ taxonomy: {
+ path: "biosamples.[item].taxonomy.label",
+ ui_name: "Biosample taxonomy",
+ },
+ histological_diagnosis: {
+ path: "biosamples.[item].histological_diagnosis.label",
+ ui_name: "Biosample histological diagnosis",
+ },
+ tumor_progression: {
+ path: "biosamples.[item].tumor_progression.label",
+ ui_name: "Tumor progression",
+ },
+ tumor_grade: {
+ path: "biosamples.[item].tumor_grade.label",
+ ui_name: "Tumor grade",
+ },
+ diagnostic_markers: {
+ path: "biosamples.[item].diagnostic_markers.[item].label",
+ ui_name: "Diagnostic markers",
+ },
+ procedure: {
+ path: "biosamples.[item].procedure.code.label",
+ ui_name: "Procedure",
+ },
+ },
+ diseases: {
+ term: {
+ path: "diseases.[item].term.label",
+ ui_name: "Disease",
+ },
+ disease_stage: {
+ path: "diseases.[item].disease_stage.[item].label",
+ ui_name: "Disease stage",
+ },
+ clinical_tnm_finding: {
+ path: "diseases.[item].clinical_tnm_finding.[item].label",
+ ui_name: "TNM finding",
+ },
+ },
+ interpretations: {
+ progress_status: {
+ path: "interpretations.[item].progress_status",
+ ui_name: "Progress Status",
+ },
+ summary: {
+ path: "interpretations.[item].summary",
+ ui_name: "Summary",
+ },
+ diagnosis: {
+ path: "interpretations.[item].diagnosis.disease.label",
+ ui_name: "Diagnosis Disease",
+ },
+ },
+ measurements: {
+ description: {
+ path: "measurements.[item].description",
+ ui_name: "Description",
+ },
+ assay: {
+ path: "measurements.[item].assay.label",
+ ui_name: "Assay",
+ },
+ procedure: {
+ path: "measurements.[item].procedure.code.label",
+ ui_name: "Procedure",
+ },
+ },
+ medical_actions: {
+ treatment_target: {
+ path: "medical_actions.[item].treatment_target.label",
+ ui_name: "Treatment Target",
+ },
+ treatment_intent: {
+ path: "medical_actions.[item].treatment_intent.label",
+ ui_name: "Treatment Intent",
+ },
+ response_to_treatment: {
+ path: "medical_actions.[item].response_to_treatment.label",
+ ui_name: "Response To Treatment",
+ },
+ adverse_events: {
+ path: "medical_actions.[item].adverse_events.[item].label",
+ ui_name: "Adverse Events",
+ },
+ treatment_termination_reason: {
+ path: "medical_actions.[item].treatment_termination_reason.label",
+ ui_name: "Treatment Termination Reason",
+ },
},
+ },
};
diff --git a/src/utils/serviceInfo.js b/src/utils/serviceInfo.js
deleted file mode 100644
index 120bdc2e4..000000000
--- a/src/utils/serviceInfo.js
+++ /dev/null
@@ -1,20 +0,0 @@
-export const normalizeServiceInfo = serviceInfo => {
- // Backwards compatibility for:
- // - old type ("group:artifact:version")
- // - and new ({"group": "...", "artifact": "...", "version": "..."})
-
- let serviceType = serviceInfo.type;
- if (typeof serviceType === "string") {
- const [group, artifact, version] = serviceType.split(":");
- serviceType = {
- group,
- artifact,
- version,
- };
- }
-
- return {
- ...serviceInfo,
- type: serviceType,
- };
-};
diff --git a/src/utils/url.ts b/src/utils/url.ts
index eddf486c2..f4abea61c 100644
--- a/src/utils/url.ts
+++ b/src/utils/url.ts
@@ -1,7 +1,7 @@
export const isValidUrl = (url: string): boolean => {
- try {
- return Boolean(new URL(url));
- } catch (e) {
- return false;
- }
+ try {
+ return Boolean(new URL(url));
+ } catch (e) {
+ return false;
+ }
};
diff --git a/static/config.js b/static/config.js
index fdd3922d1..b9b756a14 100644
--- a/static/config.js
+++ b/static/config.js
@@ -5,6 +5,7 @@ BENTO_WEB_CONFIG = {
BENTO_PUBLIC_URL: null,
BENTO_CBIOPORTAL_ENABLED: false,
BENTO_CBIOPORTAL_PUBLIC_URL: null,
+ BENTO_MONITORING_ENABLED: false,
BENTO_DROP_BOX_FS_BASE_PATH: null,
CUSTOM_HEADER: null,
};
diff --git a/tsconfig.json b/tsconfig.json
index 4840f7116..24e657712 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,10 +1,12 @@
{
"compilerOptions": {
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "target": "es2015",
+
"outDir": "./dist/",
"sourceMap": true,
"strictNullChecks": true,
- "module": "ES2022",
- "target": "es6",
"downlevelIteration": true,
"allowJs": true,
"skipLibCheck": true,
@@ -12,7 +14,6 @@
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
- "moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
@@ -20,6 +21,7 @@
"baseUrl": "./",
"paths": {
"@/*": ["./src/*"],
+ "mammoth/mammoth.browser": ["./node_modules/mammoth/lib/index.d.ts"],
}
},
"include": ["./src/"]
diff --git a/webpack.config.js b/webpack.config.js
index 7f577a280..26d642f03 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -70,6 +70,7 @@ module.exports = {
BENTO_URL: null,
BENTO_PUBLIC_URL: null,
BENTO_CBIOPORTAL_ENABLED: false,
+ BENTO_MONITORING_ENABLED: false,
BENTO_CBIOPORTAL_PUBLIC_URL: null,
BENTO_DROP_BOX_FS_BASE_PATH: null,
CUSTOM_HEADER: null,