diff --git a/src/components/ConfirmationModal.js b/src/components/ConfirmationModal.js new file mode 100644 index 000000000..07a666cfa --- /dev/null +++ b/src/components/ConfirmationModal.js @@ -0,0 +1,15 @@ +import { Modal } from "antd"; + +const { confirm } = Modal; + +const genericConfirm = ({title, content, onOk, onCancel, ...rest}) => { + confirm({ + title: title, + content: content, + onOk: onOk, + onCancel, + ...rest, + }); +}; + +export default genericConfirm; diff --git a/src/components/ServiceContent.js b/src/components/ServiceContent.js index 381173995..30d365aea 100644 --- a/src/components/ServiceContent.js +++ b/src/components/ServiceContent.js @@ -40,7 +40,6 @@ const ServiceContent = () => { /> - {/* TODO: Tables */} Services diff --git a/src/components/ServiceList.js b/src/components/ServiceList.js index 22278c4c6..81c43f9d8 100644 --- a/src/components/ServiceList.js +++ b/src/components/ServiceList.js @@ -215,16 +215,19 @@ const ServiceList = () => { const [requestModalService, setRequestModalService] = useState(null); const dataSource = useSelector((state) => - Object.entries(state.bentoServices.itemsByKind).map(([kind, service]) => ({ - ...service, - key: kind, - serviceInfo: state.services.itemsByKind[kind] ?? null, - status: { - status: kind in state.services.itemsByKind, - dataService: service.data_service, - }, - loading: state.services.isFetching, - })), + Object.entries(state.bentoServices.itemsByKind).map(([kind, service]) => { + const serviceInfo = state.services.itemsByKind[kind] ?? null; + return { + ...service, + key: kind, + serviceInfo, + status: { + status: kind in state.services.itemsByKind, + dataService: serviceInfo?.bento?.dataService, + }, + loading: state.services.isFetching, + }; + }), ); const isAuthenticated = useSelector( diff --git a/src/components/datasets/Dataset.js b/src/components/datasets/Dataset.js index b6ae04b7e..75fe1ebc9 100644 --- a/src/components/datasets/Dataset.js +++ b/src/components/datasets/Dataset.js @@ -12,19 +12,24 @@ import { deleteDatasetLinkedFieldSetIfPossible, } from "../../modules/metadata/actions"; +import { + fetchDatasetDataTypesSummaryIfPossible, + fetchDatasetSummaryIfPossible, +} from "../../modules/datasets/actions"; + 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 DatasetOverview from "./DatasetOverview"; -import DatasetTables from "./DatasetTables"; import {FORM_MODE_ADD, FORM_MODE_EDIT} from "../../constants"; import {datasetPropTypesShape, projectPropTypesShape} from "../../propTypes"; +import DatasetDataTypes from "./DatasetDataTypes"; const DATASET_CARD_TABS = [ {key: "overview", tab: "Overview"}, - {key: "tables", tab: "Data Tables"}, + {key: "data_types", tab: "Data Types"}, {key: "linked_field_sets", tab: "Linked Field Sets"}, {key: "data_use", tab: "Consent Codes and Data Use"}, ]; @@ -71,7 +76,6 @@ class Dataset extends Component { contact_info: value.contact_info || "", data_use: simpleDeepCopy(value.data_use || INITIAL_DATA_USE_VALUE), linked_field_sets: value.linked_field_sets || [], - tables: value.tables || [], fieldSetAdditionModalVisible: false, @@ -82,13 +86,20 @@ class Dataset extends Component { }, selectedTab: "overview", - selectedTable: null, }; this.handleFieldSetDeletion = this.handleFieldSetDeletion.bind(this); } + componentDidMount() { + if (this.state.identifier) { + this.props.fetchDatasetSummary(this.state.identifier); + this.props.fetchDatasetDataTypesSummary(this.state.identifier); + } + } + + handleFieldSetDeletion(fieldSet, index) { const deleteModal = Modal.confirm({ title: `Are you sure you want to delete the "${fieldSet.name}" linked field set?`, @@ -123,13 +134,11 @@ class Dataset extends Component { const tabContents = { overview: , - tables: , + isPrivate={isPrivate} />, + data_types: , linked_field_sets: ( <> @@ -281,24 +290,21 @@ Dataset.propTypes = { mode: PropTypes.oneOf(["public", "private"]), project: projectPropTypesShape, - strayTables: PropTypes.arrayOf(PropTypes.object), value: datasetPropTypesShape, - isFetchingTables: PropTypes.bool, - onEdit: PropTypes.func, - onTableIngest: PropTypes.func, + onDatasetIngest: PropTypes.func, addLinkedFieldSet: PropTypes.func, deleteProjectDataset: PropTypes.func, deleteLinkedFieldSet: PropTypes.func, + + fetchDatasetSummary: PropTypes.func, + fetchDatasetDataTypesSummary: PropTypes.func, }; const mapStateToProps = state => ({ - isFetchingTables: state.services.isFetchingAll - || state.projectTables.isFetching - || state.projects.isFetchingWithTables, // TODO: Hiccup isSavingDataset: state.projects.isSavingDataset, isDeletingDataset: state.projects.isDeletingDataset, }); @@ -309,6 +315,8 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ deleteProjectDataset: dataset => dispatch(deleteProjectDatasetIfPossible(ownProps.project, dataset)), deleteLinkedFieldSet: (dataset, linkedFieldSet, linkedFieldSetIndex) => dispatch(deleteDatasetLinkedFieldSetIfPossible(dataset, linkedFieldSet, linkedFieldSetIndex)), + fetchDatasetSummary: (datasetId) => dispatch(fetchDatasetSummaryIfPossible(datasetId)), + fetchDatasetDataTypesSummary: (datasetId) => dispatch(fetchDatasetDataTypesSummaryIfPossible(datasetId)), }); export default connect(mapStateToProps, mapDispatchToProps)(Dataset); diff --git a/src/components/datasets/DatasetDataTypes.js b/src/components/datasets/DatasetDataTypes.js new file mode 100644 index 000000000..4aecd931a --- /dev/null +++ b/src/components/datasets/DatasetDataTypes.js @@ -0,0 +1,132 @@ +import React, {useCallback, useMemo, useState} from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { Button, Col, Row, Table, Typography } from "antd"; + +import PropTypes from "prop-types"; +import { datasetPropTypesShape, projectPropTypesShape } from "../../propTypes"; +import { clearDatasetDataType } from "../../modules/metadata/actions"; +import { fetchDatasetDataTypesSummaryIfPossible } from "../../modules/datasets/actions"; +import genericConfirm from "../ConfirmationModal"; +import DataTypeSummaryModal from "./datatype/DataTypeSummaryModal"; +import { nop } from "../../utils/misc"; + +const NA_TEXT = N/A; + +const DatasetDataTypes = React.memo( + ({isPrivate, project, dataset, onDatasetIngest}) => { + const dispatch = useDispatch(); + const datasetDataTypes = useSelector((state) => Object.values( + state.datasetDataTypes.itemsById[dataset.identifier]?.itemsById)); + const datasetSummaries = useSelector((state) => state.datasetSummaries.itemsById[dataset.identifier]); + const isFetchingDataset = useSelector( + (state) => state.datasetDataTypes.itemsById[dataset.identifier]?.isFetching); + + const [datatypeSummaryVisible, setDatatypeSummaryVisible] = useState(false); + const [selectedDataType, setSelectedDataType] = useState(null); + + const selectedSummary = (selectedDataType !== null && datasetSummaries) + ? datasetSummaries[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(fetchDatasetDataTypesSummaryIfPossible(dataset.identifier)); + }, + }); + }, [dispatch, dataset]); + + const showDatatypeSummary = useCallback((dataType) => { + setSelectedDataType(dataType); + setDatatypeSummaryVisible(true); + }, []); + + 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: 230, + render: (dt) => ( + + + + + + + + + ), + }, + ] : null), + ], [isPrivate, project, dataset, onDatasetIngest]); + + const onDataTypeSummaryModalCancel = useCallback(() => setDatatypeSummaryVisible(false), []); + + return ( + <> + + + + Data Types + + + + + ); + }); + +DatasetDataTypes.propTypes = { + isPrivate: PropTypes.bool, + project: projectPropTypesShape, + dataset: datasetPropTypesShape, + onDatasetIngest: PropTypes.func, +}; + +export default DatasetDataTypes; diff --git a/src/components/datasets/DatasetFormModal.js b/src/components/datasets/DatasetFormModal.js index 601602a54..57e2fb157 100644 --- a/src/components/datasets/DatasetFormModal.js +++ b/src/components/datasets/DatasetFormModal.js @@ -10,7 +10,7 @@ import DatasetForm from "./DatasetForm"; import { addProjectDataset, saveProjectDataset, - fetchProjectsWithDatasetsAndTables, + fetchProjectsWithDatasets, } from "../../modules/metadata/actions"; import {nop} from "../../utils/misc"; @@ -53,7 +53,7 @@ class DatasetFormModal extends Component { } async handleSuccess(values) { - await this.props.fetchProjectsWithDatasetsAndTables(); // TODO: If needed / only this project... + await this.props.fetchProjectsWithDatasets(); // TODO: If needed / only this project... await (this.props.onOk || nop)({...(this.props.initialValue || {}), values}); if ((this.props.mode || FORM_MODE_ADD) === FORM_MODE_ADD) this.form.resetFields(); } @@ -107,7 +107,7 @@ DatasetFormModal.propTypes = { addProjectDataset: PropTypes.func, saveProjectDataset: PropTypes.func, - fetchProjectsWithDatasetsAndTables: PropTypes.func, + fetchProjectsWithDatasets: PropTypes.func, }; const mapStateToProps = state => ({ @@ -120,5 +120,5 @@ const mapStateToProps = state => ({ export default connect(mapStateToProps, { addProjectDataset, saveProjectDataset, - fetchProjectsWithDatasetsAndTables, + fetchProjectsWithDatasets, })(DatasetFormModal); diff --git a/src/components/datasets/DatasetOverview.js b/src/components/datasets/DatasetOverview.js index 0cb36b84a..4d60a3f89 100644 --- a/src/components/datasets/DatasetOverview.js +++ b/src/components/datasets/DatasetOverview.js @@ -1,4 +1,4 @@ -import React, {Component, Fragment} from "react"; +import React, {Fragment, useMemo} from "react"; import PropTypes from "prop-types"; import {Col, Divider, Row, Spin, Statistic, Typography} from "antd"; @@ -6,52 +6,59 @@ import {Col, Divider, Row, Spin, Statistic, Typography} from "antd"; import {datasetPropTypesShape, projectPropTypesShape} from "../../propTypes"; import {EM_DASH} from "../../constants"; +import { useSelector } from "react-redux"; -class DatasetOverview extends Component { - render() { - const project = this.props.project ?? {}; - const dataset = this.props.dataset ?? {}; - 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} - - {this.props.isPrivate ? null : ( - - )} - - - - - - - - - - ; - } -} +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; + + // 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]); + + 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, - isFetchingTables: PropTypes.bool, }; export default DatasetOverview; diff --git a/src/components/datasets/DatasetTables.js b/src/components/datasets/DatasetTables.js deleted file mode 100644 index 797bc292f..000000000 --- a/src/components/datasets/DatasetTables.js +++ /dev/null @@ -1,203 +0,0 @@ -import React, {useMemo, useState} from "react"; -import PropTypes from "prop-types"; - -import { useSelector, useDispatch } from "react-redux"; - -import { Button, Col, Row, Table, Typography } from "antd"; - -import TableAdditionModal from "./table/TableAdditionModal"; -import TableDeletionModal from "./table/TableDeletionModal"; - -import { - addProjectTable, - deleteProjectTableIfPossible, - fetchProjectsWithDatasetsAndTables, -} from "../../modules/metadata/actions"; -import { nop } from "../../utils/misc"; -import { fetchTableSummaryIfPossible } from "../../modules/tables/actions"; -import TableSummaryModal from "./table/TableSummaryModal"; -import { datasetPropTypesShape, projectPropTypesShape } from "../../propTypes"; - -const NA_TEXT = N/A; - -const DatasetTables = ({ isPrivate, project, dataset, onTableIngest, isFetchingTables }) => { - const dispatch = useDispatch(); - - const serviceInfoByKind = useSelector((state) => state.services.itemsByKind); - - const dataTypesByKind = useSelector(state => state.serviceDataTypes.dataTypesByServiceKind); - const dataTypesByID = useMemo( - () => Object.fromEntries( - Object.values(dataTypesByKind ?? {}) - .flatMap(v => (v?.items ?? [])) - .map(dt => [dt.id, dt])), - [dataTypesByKind]); - - const [additionModalVisible, setAdditionModalVisible] = useState(false); - const [deletionModalVisible, setDeletionModalVisible] = useState(false); - const [tableSummaryModalVisible, setTableSummaryModalVisible] = useState(false); - const [selectedTable, setSelectedTable] = useState(null); - - const handleAdditionSubmit = async (values) => { - const [serviceKind, dataTypeID] = values.dataType.split(":"); - const serviceInfo = serviceInfoByKind[serviceKind]; - await dispatch(addProjectTable(project, dataset.identifier, serviceInfo, dataTypeID, values.name)); - - await dispatch(fetchProjectsWithDatasetsAndTables()); // TODO: If needed / only this project... - - setAdditionModalVisible(false); - }; - - const handleTableDeletionClick = (t) => { - setSelectedTable(t); - setDeletionModalVisible(true); - }; - - const handleTableDeletionSubmit = async () => { - if (selectedTable === null) return; - await dispatch(deleteProjectTableIfPossible(project, selectedTable)); - await dispatch(fetchProjectsWithDatasetsAndTables()); // TODO: If needed / only this project... - - setDeletionModalVisible(false); - }; - - const showTableSummaryModal = (table) => { - dispatch(fetchTableSummaryIfPossible(serviceInfoByKind[table.service_artifact], table.table_id)); - setTableSummaryModalVisible(true); - setSelectedTable(table); - }; - - const tableListColumns = [ - { - title: "ID", - dataIndex: "table_id", - render: (tableID, t) => - isPrivate ? ( - showTableSummaryModal(t)}> - {tableID} - - ) : ( - {tableID} - ), - }, - { - title: "Name", - dataIndex: "name", - render: (n) => (n ? n : NA_TEXT), - defaultSortOrder: "ascend", - sorter: (a, b) => (a.name && b.name ? a.name.localeCompare(b.name) : a.table_id.localeCompare(b.table_id)), - }, - { - title: "Data Type", - dataIndex: "data_type", - render: dtID => dataTypesByID[dtID]?.label ?? dtID, - }, - ...(isPrivate - ? [ - { - title: "Actions", - key: "actions", - width: 230 /*330,*/, - render: (t) => ( - - - - - {/* TODO: Edit Table Name: v0.2 */} - {/**/} - {t.manageable !== false ? ( - - - - ) : null} - - ), - }, - ] - : []), - ]; - - dataset = dataset || {}; - const tables = (dataset.tables || []).map((t) => ({ - ...t, - name: t.name || null, - })); - return ( - <> - - Tables - {isPrivate ? ( -
- {/* TODO: Implement v0.2 - {(strayTables || []).length > 0 ? ( - - ) : null} */} - -
- ) : null} -
- -
(TODO: List of files)} TODO: Implement v0.2 - columns={tableListColumns} - loading={isFetchingTables} - /> - - setTableSummaryModalVisible(false)} - /> - - setAdditionModalVisible(false)} - /> - - setDeletionModalVisible(false)} - /> - - ); -}; - -DatasetTables.propTypes = { - isPrivate: PropTypes.bool, - project: projectPropTypesShape, - dataset: datasetPropTypesShape, - onTableIngest: PropTypes.func, - isFetchingTables: PropTypes.bool, -}; - -export default DatasetTables; diff --git a/src/components/datasets/datatype/DataTypeSummaryModal.js b/src/components/datasets/datatype/DataTypeSummaryModal.js new file mode 100644 index 000000000..57bdd6fd5 --- /dev/null +++ b/src/components/datasets/datatype/DataTypeSummaryModal.js @@ -0,0 +1,52 @@ +import React from "react"; +import { useSelector } from "react-redux"; +import PropTypes from "prop-types"; + +import { Modal, Skeleton, Tag } from "antd"; + +import { summaryPropTypesShape } from "../../../propTypes"; + +import GenericSummary from "./GenericSummary"; +import PhenopacketSummary from "./PhenopacketSummary"; +import VariantSummary from "./VariantSummary"; + +const DataTypeSummaryModal = ({dataType, summary, onCancel, visible}) => { + if (!dataType) { + return <>; + } + + const isFetchingSummaries = useSelector((state) => state.datasetDataTypes.isFetching); + + let Summary = GenericSummary; + let summaryData = summary; + switch (dataType.id) { + case "variant": + Summary = VariantSummary; + break; + case "phenopacket": + Summary = PhenopacketSummary; + break; + default: + summaryData = summary ?? dataType; + } + + return {dataType.id}} + > + {(!summaryData || isFetchingSummaries) + ? + : } + ; +}; + +DataTypeSummaryModal.propTypes = { + dataType: PropTypes.object, + summary: summaryPropTypesShape, + onCancel: PropTypes.func, + visible: PropTypes.bool, +}; + +export default DataTypeSummaryModal; diff --git a/src/components/datasets/table/summaries/GenericSummary.js b/src/components/datasets/datatype/GenericSummary.js similarity index 85% rename from src/components/datasets/table/summaries/GenericSummary.js rename to src/components/datasets/datatype/GenericSummary.js index ed3710055..f9f4a89c1 100644 --- a/src/components/datasets/table/summaries/GenericSummary.js +++ b/src/components/datasets/datatype/GenericSummary.js @@ -1,8 +1,8 @@ import React from "react"; import {Col, Row, Statistic} from "antd"; +import { summaryPropTypesShape } from "../../../propTypes"; -import {summaryPropTypesShape} from "../../../../propTypes"; const GenericSummary = ({summary}) => summary ? diff --git a/src/components/datasets/table/summaries/PhenopacketSummary.js b/src/components/datasets/datatype/PhenopacketSummary.js similarity index 76% rename from src/components/datasets/table/summaries/PhenopacketSummary.js rename to src/components/datasets/datatype/PhenopacketSummary.js index a7c322a9b..c60174031 100644 --- a/src/components/datasets/table/summaries/PhenopacketSummary.js +++ b/src/components/datasets/datatype/PhenopacketSummary.js @@ -1,22 +1,19 @@ import React from "react"; +import { Col, Divider, Row, Statistic, Typography } from "antd"; -import {Col, Divider, Row, Statistic, Typography} from "antd"; +import { VictoryLabel, VictoryPie } from "victory"; +import VictoryPieWrapSVG from "../../VictoryPieWrapSVG"; -import {VictoryLabel, VictoryPie} from "victory"; -import VictoryPieWrapSVG from "../../../VictoryPieWrapSVG"; +import { summaryPropTypesShape } from "../../../propTypes"; +import { VICTORY_PIE_LABEL_PROPS, VICTORY_PIE_PROPS } from "../../../styles/victory"; -import {summaryPropTypesShape} from "../../../../propTypes"; -import {VICTORY_PIE_LABEL_PROPS, VICTORY_PIE_PROPS} from "../../../../styles/victory"; - - -const PhenopacketSummary = ({summary}) => { +const PhenopacketSummary = ({ summary }) => { const individualsBySex = Object.entries(summary.data_type_specific.individuals.sex) .filter(e => e[1] > 0) - .map(([x, y]) => ({x, y})); + .map(([x, y]) => ({ x, y })); const individualsByKaryotype = Object.entries(summary.data_type_specific.individuals.karyotypic_sex) .filter(e => e[1] > 0) - .map(([x, y]) => ({x, y})); - + .map(([x, y]) => ({ x, y })); return <> Object Counts @@ -46,10 +43,6 @@ const PhenopacketSummary = ({summary}) => { ) : null} - {/*Overview: Biosamples*/} - {/**/} - - {/**/} ; }; diff --git a/src/components/datasets/table/summaries/VariantSummary.js b/src/components/datasets/datatype/VariantSummary.js similarity index 92% rename from src/components/datasets/table/summaries/VariantSummary.js rename to src/components/datasets/datatype/VariantSummary.js index 9f3c1fda5..a64fea72c 100644 --- a/src/components/datasets/table/summaries/VariantSummary.js +++ b/src/components/datasets/datatype/VariantSummary.js @@ -2,7 +2,7 @@ import React from "react"; import {Col, Icon, Row, Statistic} from "antd"; -import {summaryPropTypesShape} from "../../../../propTypes"; +import {summaryPropTypesShape} from "../../../propTypes"; const VariantSummary = ({summary}) => diff --git a/src/components/datasets/table/TableAdditionModal.js b/src/components/datasets/table/TableAdditionModal.js deleted file mode 100644 index 9da045f35..000000000 --- a/src/components/datasets/table/TableAdditionModal.js +++ /dev/null @@ -1,75 +0,0 @@ -import React, {Component} from "react"; -import {connect} from "react-redux"; -import PropTypes from "prop-types"; -import {Button, Modal} from "antd"; - -import TableForm from "./TableForm"; - -import {nop} from "../../../utils/misc"; -import {datasetPropTypesShape, projectPropTypesShape} from "../../../propTypes"; - - -const modalTitle = (dataset, project) => - `Add Table to Dataset "${dataset?.title || ""}" (Project "${project?.title || ""}")`; - -class TableAdditionModal extends Component { - componentDidMount() { - this.handleSubmit = this.handleSubmit.bind(this); - } - - handleSubmit() { - this.form.validateFields(async (err, values) => { - if (err) { - console.error(err); - return; - } - - await (this.props.onSubmit || nop)(values); - this.form.resetFields(); - }); - } - - render() { - const handleCancel = () => (this.props.onCancel || nop)(); - return ( - Cancel, - , - ]} - onCancel={handleCancel}> - this.form = form} /> - - ); - } -} -TableAdditionModal.propTypes = { - visible: PropTypes.bool, - - projectTablesAdding: PropTypes.bool, - projectTablesFetching: PropTypes.bool, - projectsFetchingWithTables: PropTypes.bool, - - project: projectPropTypesShape, - dataset: datasetPropTypesShape, - - onCancel: PropTypes.func, - onSubmit: PropTypes.func, -}; - -const mapStateToProps = state => ({ - projectTablesAdding: state.projectTables.isAdding, - projectTablesFetching: state.projectTables.isFetching, - projectsFetchingWithTables: state.projects.projectsFetchingWithTables, -}); - - -export default connect(mapStateToProps)(TableAdditionModal); diff --git a/src/components/datasets/table/TableDeletionModal.js b/src/components/datasets/table/TableDeletionModal.js deleted file mode 100644 index 99aa11a5e..000000000 --- a/src/components/datasets/table/TableDeletionModal.js +++ /dev/null @@ -1,50 +0,0 @@ -import React, {Component} from "react"; -import {connect} from "react-redux"; -import PropTypes from "prop-types"; - -import {Button, Modal, Typography} from "antd"; - -import {nop} from "../../../utils/misc"; - - -// TODO: Replace with Modal.confirm -class TableDeletionModal extends Component { - render() { - return ( - (this.props.onCancel || nop)()}>Cancel, - , - ]} - onCancel={this.props.onCancel || nop}> - - Deleting this table means all data contained in the table will be deleted permanently, and - will no longer be available for exploration. - - - ); - } -} - -TableDeletionModal.propTypes = { - visible: PropTypes.bool, - table: PropTypes.object, - - isDeletingTable: PropTypes.bool, - - onSubmit: PropTypes.func, - onCancel: PropTypes.func, -}; - -const mapStateToProps = state => ({ - isDeletingTable: state.serviceTables.isDeleting || state.projectTables.isDeleting, -}); - -export default connect(mapStateToProps)(TableDeletionModal); diff --git a/src/components/datasets/table/TableForm.js b/src/components/datasets/table/TableForm.js deleted file mode 100644 index c57556ebb..000000000 --- a/src/components/datasets/table/TableForm.js +++ /dev/null @@ -1,53 +0,0 @@ -import React, {Component} from "react"; -import PropTypes from "prop-types"; - -import {connect} from "react-redux"; - -import {Form, Input, Select} from "antd"; - - -// TODO: Load available data types from store - -class TableForm extends Component { - render() { - const dataTypeOptions = this.props.dataTypes.map(dts => - {dts.dataType.id}); - - return
- - {this.props.form.getFieldDecorator("name", { - initialValue: this.props.initialValue?.name || "", - rules: [{required: true}, {min: 3}], - })()} - - - {this.props.form.getFieldDecorator("dataType", { - initialValue: this.props.initialValue?.dataType || null, - rules: [{required: true}], - })()} - - ; - } -} - -TableForm.propTypes = { - dataTypes: PropTypes.arrayOf(PropTypes.shape({ - dataType: PropTypes.object, // TODO: Shape - serviceKind: PropTypes.string, - })), - initialValue: PropTypes.shape({ - name: PropTypes.string, - dataType: PropTypes.string, - }), - style: PropTypes.object, -}; - -const mapStateToProps = state => ({ - dataTypes: Object.entries(state.serviceDataTypes.dataTypesByServiceKind) - .filter(([k, _]) => state.bentoServices.itemsByKind[k]?.manageable_tables ?? false) - .flatMap(([serviceKind, dts]) => - (dts.items || []).map(dataType => ({dataType, serviceKind}))), -}); - -export default connect(mapStateToProps, null, null, {forwardRef: true})( - Form.create({name: "table_form"})(TableForm)); diff --git a/src/components/datasets/table/TableSummaryModal.js b/src/components/datasets/table/TableSummaryModal.js deleted file mode 100644 index 0a1541a2f..000000000 --- a/src/components/datasets/table/TableSummaryModal.js +++ /dev/null @@ -1,58 +0,0 @@ -import React, {Component} from "react"; -import {connect} from "react-redux"; -import PropTypes from "prop-types"; - -import {Modal, Skeleton, Tag} from "antd"; - -import {nop} from "../../../utils/misc"; - -import GenericSummary from "./summaries/GenericSummary"; -import PhenopacketSummary from "./summaries/PhenopacketSummary"; -import VariantSummary from "./summaries/VariantSummary"; -import {summaryPropTypesShape} from "../../../propTypes"; - - -class TableSummaryModal extends Component { - render() { - const table = this.props.table || {}; - - let Summary = GenericSummary; - switch (table.data_type) { - case "variant": - Summary = VariantSummary; - break; - case "phenopacket": - Summary = PhenopacketSummary; - break; - } - - return - {table.data_type} - Table “{table.name || table.table_id || ""}”: Summary - } - footer={null} - width={754} - onCancel={() => (this.props.onCancel || nop)()}> - {(!this.props.summary || this.props.isFetchingSummaries) - ? - : } - ; - } -} - -TableSummaryModal.propTypes = { - table: PropTypes.object, // TODO: Shared shape - onCancel: PropTypes.func, - - isFetchingSummaries: PropTypes.bool, - summary: summaryPropTypesShape, -}; - -const mapStateToProps = (state, ownProps) => ({ - isFetchingSummaries: state.tableSummaries.isFetching, - summary: (state.tableSummaries.summariesByServiceArtifactAndTableID[(ownProps.table || {}).service_artifact] - || {})[(ownProps.table || {}).table_id], -}); - -export default connect(mapStateToProps)(TableSummaryModal); diff --git a/src/components/discovery/DataTypeExplorationModal.js b/src/components/discovery/DataTypeExplorationModal.js index ff2b6dac4..1e2c09c01 100644 --- a/src/components/discovery/DataTypeExplorationModal.js +++ b/src/components/discovery/DataTypeExplorationModal.js @@ -46,6 +46,8 @@ class DataTypeExplorationModal extends Component { } render() { + const filteredDataTypes = this.props.dataTypes || []; + return Table Detail View - {Object.values(this.props.dataTypes).flatMap(ds => (ds.items ?? []) - .filter(dataType => (dataType.queryable ?? true) && dataType.count > 0) - .map(dataType => - - {this.state.view === "tree" ? ( - - ) : ( - <> - this.onFilterChange(e.target.value)} - placeholder="Search for a field..." - style={{marginBottom: "16px"}} - /> -
- - )} - , - ))} - + {filteredDataTypes.map(dataType => ( + + {this.state.view === "tree" ? ( + + ) : ( + <> + this.onFilterChange(e.target.value)} + placeholder="Search for a field..." + style={{marginBottom: "16px"}} + /> +
+ + )} + + ))} + ; } } DataTypeExplorationModal.propTypes = { - dataTypes: PropTypes.object, // TODO: Shape + dataTypes: PropTypes.array, // TODO: Shape visible: PropTypes.bool, onCancel: PropTypes.func, }; diff --git a/src/components/discovery/DiscoveryQueryBuilder.js b/src/components/discovery/DiscoveryQueryBuilder.js index 8e4085e39..38b7067b6 100644 --- a/src/components/discovery/DiscoveryQueryBuilder.js +++ b/src/components/discovery/DiscoveryQueryBuilder.js @@ -131,19 +131,21 @@ class DiscoveryQueryBuilder extends Component { } render() { + const { activeDataset, dataTypesByDataset} = 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 = ( - {this.props.servicesInfo - .filter(s => (this.props.dataTypes[s.id]?.items ?? []).length) - .flatMap(s => - this.props.dataTypes[s.id].items - .filter(dt => (dt.queryable ?? true) && dt.count > 0) - .map(dt => - {dt.label ?? dt.id}, - ), - ) - } + {filteredDataTypes.map(dt => ( + {dt.label ?? dt.id} + ))} ); @@ -169,15 +171,16 @@ class DiscoveryQueryBuilder extends Component { }); const addConditionsOnDataType = (buttonProps = {style: {float: "right"}}) => ( - + ); return @@ -185,8 +188,12 @@ class DiscoveryQueryBuilder extends Component { Advanced Search {addConditionsOnDataType()} - + {this.props.dataTypeForms.length > 0 @@ -218,6 +225,7 @@ class DiscoveryQueryBuilder extends Component { } DiscoveryQueryBuilder.propTypes = { + activeDataset: PropTypes.string, isInternal: PropTypes.bool, requiredDataTypes: PropTypes.arrayOf(PropTypes.string), @@ -225,6 +233,7 @@ DiscoveryQueryBuilder.propTypes = { dataTypes: PropTypes.object, dataTypesByID: PropTypes.object, dataTypesLoading: PropTypes.bool, + dataTypesByDataset: PropTypes.object, searchLoading: PropTypes.bool, formValues: PropTypes.object, @@ -247,12 +256,12 @@ const mapStateToProps = state => ({ servicesInfo: state.services.items, dataTypes: state.serviceDataTypes.dataTypesByServiceID, dataTypesByID: state.serviceDataTypes.itemsByID, + dataTypesByDataset: state.datasetDataTypes, autoQuery: state.explorer.autoQuery, isFetchingTextSearch: state.explorer.fetchingTextSearch || false, - dataTypesLoading: state.services.isFetching || state.serviceDataTypes.isFetchingAll - || Object.keys(state.serviceDataTypes.dataTypesByServiceID).length === 0, + dataTypesLoading: state.services.isFetching || state.datasetDataTypes.isFetchingAll, }); const mapDispatchToProps = (dispatch) => ({ diff --git a/src/components/explorer/ExplorerDatasetSearch.js b/src/components/explorer/ExplorerDatasetSearch.js index 5e76db821..c41072a50 100644 --- a/src/components/explorer/ExplorerDatasetSearch.js +++ b/src/components/explorer/ExplorerDatasetSearch.js @@ -19,9 +19,9 @@ import { setActiveTab, } from "../../modules/explorer/actions"; -import IndividualsTable from "./IndividualsTable"; -import BiosamplesTable from "./BiosamplesTable"; -import ExperimentsTable from "./ExperimentsTable"; +import IndividualsTable from "./searchResultsTables/IndividualsTable"; +import BiosamplesTable from "./searchResultsTables/BiosamplesTable"; +import ExperimentsTable from "./searchResultsTables/ExperimentsTable"; const { TabPane } = Tabs; @@ -39,11 +39,7 @@ const ExplorerDatasetSearch = () => { const { dataset } = useParams(); const dispatch = useDispatch(); - const datasetsByID = useSelector((state) => - Object.fromEntries( - state.projects.items.flatMap((p) => p.datasets.map((d) => [d.identifier, { ...d, project: p.identifier }])), - ), - ); + const datasetsByID = useSelector((state) => state.projects.datasetsByID); const activeKey = useSelector((state) => state.explorer.activeTabByDatasetID[dataset]) || TAB_KEYS.INDIVIDUAL; const dataTypeForms = useSelector((state) => state.explorer.dataTypeFormsByDatasetID[dataset] || []); @@ -89,6 +85,7 @@ const ExplorerDatasetSearch = () => { Explore Dataset {selectedDataset.title} { + datasetID={dataset} + /> {hasBiosamples && ( + datasetID={dataset} + /> )} {hasExperiments && ( + datasetID={dataset} + /> )} ) : ( - + ))} ); diff --git a/src/components/explorer/ExplorerSearchResultsTable.js b/src/components/explorer/ExplorerSearchResultsTable.js index 1bb229e72..0e48c2b9e 100644 --- a/src/components/explorer/ExplorerSearchResultsTable.js +++ b/src/components/explorer/ExplorerSearchResultsTable.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } 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"; @@ -44,9 +44,11 @@ const ExplorerSearchResultsTable = ({ const fetchingSearch = useSelector((state) => state.explorer.fetchingSearchByDatasetID[dataset] || false); const dispatch = useDispatch(); - const handleSetSelectedRows = (...args) => dispatch(setSelectedRows(dataset, ...args)); + const handleSetSelectedRows = useCallback( + (...args) => dispatch(setSelectedRows(dataset, ...args)), + [dispatch, dataset]); - const handlePerformIndividualsDownloadCSVIfPossible = (...args) => { + const handlePerformDownloadCSVIfPossible = useCallback((...args) => { if (activeTab === "individuals") { return dispatch(performIndividualsDownloadCSVIfPossible(dataset, ...args)); } @@ -56,19 +58,19 @@ const ExplorerSearchResultsTable = ({ if (activeTab === "experiments") { return dispatch(performExperimentsDownloadCSVIfPossible(dataset, ...args)); } - }; + }, [dispatch, dataset, activeTab]); - const onPageChange = (pageObj, filters, sorter) => { + const onPageChange = useCallback((pageObj, filters, sorter) => { setCurrentPage(pageObj.current); dispatch(setTableSortOrder(dataset, sorter.field, sorter.order, activeTab, pageObj.current)); - }; + }, [dispatch, dataset, activeTab]); - const tableStyle = { + const tableStyle = useMemo(() => ({ opacity: fetchingSearch ? 0.5 : 1, pointerEvents: fetchingSearch ? "none" : "auto", - }; + }), [fetchingSearch]); - const rowSelection = { + const rowSelection = useMemo(() => ({ type: "checkbox", selectedRowKeys: selectedRows, onChange: (selectedRowKeys) => { @@ -89,7 +91,7 @@ const ExplorerSearchResultsTable = ({ onSelect: () => handleSetSelectedRows([]), }, ], - }; + }), [selectedRows, data, handleSetSelectedRows]); const sortedInfo = useMemo( () => ({ @@ -123,7 +125,7 @@ const ExplorerSearchResultsTable = ({ icon="export" style={{ marginRight: "8px" }} loading={isFetchingDownload} - onClick={() => handlePerformIndividualsDownloadCSVIfPossible(selectedRows, data)} + onClick={() => handlePerformDownloadCSVIfPossible(selectedRows, data)} > Export as CSV @@ -167,27 +169,7 @@ const ExplorerSearchResultsTable = ({ ); }; -ExplorerSearchResultsTable.defaultProps = { - fetchingSearch: false, - searchResults: null, - selectedRows: [], - dataStructure: [], - setSelectedRows: () => {}, - performIndividualsDownloadCSVIfPossible: () => {}, - isFetchingDownload: false, - type: "", - data: [], -}; - ExplorerSearchResultsTable.propTypes = { - fetchingSearch: PropTypes.bool, - searchResults: PropTypes.object, - selectedRows: PropTypes.arrayOf(PropTypes.string), - dataStructure: PropTypes.arrayOf(PropTypes.object), - setSelectedRows: PropTypes.func.isRequired, - isFetchingDownload: PropTypes.bool, - performIndividualsDownloadCSVIfPossible: PropTypes.func.isRequired, - type: PropTypes.string, data: PropTypes.arrayOf(PropTypes.object), activeTab: PropTypes.string.isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/src/components/explorer/IndividualBiosamples.js b/src/components/explorer/IndividualBiosamples.js index 604136ecf..f51350932 100644 --- a/src/components/explorer/IndividualBiosamples.js +++ b/src/components/explorer/IndividualBiosamples.js @@ -33,7 +33,7 @@ class IndividualBiosamples extends Component { {biosamplesData.map((biosample, i) => ( <> Biosample {biosample.id}} layout="horizontal" bordered={true} column={1} diff --git a/src/components/explorer/searchResultsTables/BiosampleIDCell.js b/src/components/explorer/searchResultsTables/BiosampleIDCell.js new file mode 100644 index 000000000..0ddfd6cfa --- /dev/null +++ b/src/components/explorer/searchResultsTables/BiosampleIDCell.js @@ -0,0 +1,25 @@ +import React from "react"; +import {Link, useLocation} from "react-router-dom"; +import PropTypes from "prop-types"; + +const BiosampleIDCell = React.memo(({ biosample, individualId }) => { + const location = useLocation(); + return ( + + {biosample} + + ); +}); + +BiosampleIDCell.propTypes = { + biosample: PropTypes.string.isRequired, + individualId: PropTypes.string.isRequired, +}; + +export default BiosampleIDCell; diff --git a/src/components/explorer/BiosamplesTable.js b/src/components/explorer/searchResultsTables/BiosamplesTable.js similarity index 80% rename from src/components/explorer/BiosamplesTable.js rename to src/components/explorer/searchResultsTables/BiosamplesTable.js index c738298fd..132246c4a 100644 --- a/src/components/explorer/BiosamplesTable.js +++ b/src/components/explorer/searchResultsTables/BiosamplesTable.js @@ -1,37 +1,15 @@ import React from "react"; import PropTypes from "prop-types"; -import { useSortedColumns } from "./hooks/explorerHooks"; -import { Link, useLocation } from "react-router-dom"; +import { useSortedColumns } from "../hooks/explorerHooks"; import { useSelector } from "react-redux"; -import { countNonNullElements } from "../../utils/misc"; -import ExplorerSearchResultsTable from "./ExplorerSearchResultsTable"; +import { countNonNullElements } from "../../../utils/misc"; -const NO_EXPERIMENTS_VALUE = -Infinity; +import ExplorerSearchResultsTable from "../ExplorerSearchResultsTable"; -const BiosampleRender = ({ biosample, alternateIds, individualId }) => { - const location = useLocation(); - const alternateIdsList = alternateIds ?? []; - const listRender = alternateIdsList.length ? ` (${alternateIdsList.join(", ")})` : ""; - return ( - <> - - {biosample} - {" "} - {listRender} - - ); -}; +import BiosampleIDCell from "./BiosampleIDCell"; +import IndividualIDCell from "./IndividualIDCell"; -BiosampleRender.propTypes = { - biosample: PropTypes.string.isRequired, - alternateIds: PropTypes.arrayOf(PropTypes.string), - individualId: PropTypes.string.isRequired, -}; +const NO_EXPERIMENTS_VALUE = -Infinity; const customPluralForms = { Serology: "Serologies", @@ -129,20 +107,14 @@ const SEARCH_RESULT_COLUMNS_BIOSAMPLE = [ { title: "Biosample", dataIndex: "biosample", - render: (biosample, record) => ( - - ), + render: (biosample, {individual}) => , sorter: (a, b) => a.biosample.localeCompare(b.biosample), defaultSortOrder: "ascend", }, { title: "Individual", dataIndex: "individual", - render: (individual) => <>{individual.id}, + render: (individual) => , sorter: (a, b) => a.individual.id.localeCompare(b.individual.id), sortDirections: ["descend", "ascend", "descend"], }, @@ -197,7 +169,6 @@ BiosamplesTable.propTypes = { data: PropTypes.arrayOf( PropTypes.shape({ biosample: PropTypes.string.isRequired, - alternateIds: PropTypes.arrayOf(PropTypes.string), individual: PropTypes.shape({ id: PropTypes.string.isRequired, }).isRequired, diff --git a/src/components/explorer/ExperimentsTable.js b/src/components/explorer/searchResultsTables/ExperimentsTable.js similarity index 86% rename from src/components/explorer/ExperimentsTable.js rename to src/components/explorer/searchResultsTables/ExperimentsTable.js index 4eb098213..2743e8ceb 100644 --- a/src/components/explorer/ExperimentsTable.js +++ b/src/components/explorer/searchResultsTables/ExperimentsTable.js @@ -2,14 +2,11 @@ import React 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 ExplorerSearchResultsTable from "./ExplorerSearchResultsTable"; +import { useSortedColumns } from "../hooks/explorerHooks"; +import ExplorerSearchResultsTable from "../ExplorerSearchResultsTable"; -const ExperimentRender = ({ experimentId, individual }) => { +const ExperimentRender = React.memo(({ experimentId, individual }) => { const location = useLocation(); - const alternateIds = individual.alternate_ids ?? []; - const listRender = alternateIds.length ? `(${alternateIds.join(", ")})` : ""; - return ( <> { }} > {experimentId} - {" "} - {listRender} + ); -}; +}); ExperimentRender.propTypes = { experimentId: PropTypes.string.isRequired, individual: PropTypes.shape({ id: PropTypes.string.isRequired, - alternate_ids: PropTypes.arrayOf(PropTypes.string), }).isRequired, }; @@ -101,7 +96,6 @@ ExperimentsTable.propTypes = { experimentId: PropTypes.string.isRequired, individual: PropTypes.shape({ id: PropTypes.string.isRequired, - alternate_ids: PropTypes.arrayOf(PropTypes.string), }).isRequired, biosampleId: PropTypes.string.isRequired, studyType: PropTypes.string.isRequired, diff --git a/src/components/explorer/searchResultsTables/IndividualIDCell.js b/src/components/explorer/searchResultsTables/IndividualIDCell.js new file mode 100644 index 000000000..f0287b097 --- /dev/null +++ b/src/components/explorer/searchResultsTables/IndividualIDCell.js @@ -0,0 +1,27 @@ +import React from "react"; +import {Link, useLocation} from "react-router-dom"; +import PropTypes from "prop-types"; + +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, +}; + +export default IndividualIDCell; diff --git a/src/components/explorer/IndividualsTable.js b/src/components/explorer/searchResultsTables/IndividualsTable.js similarity index 66% rename from src/components/explorer/IndividualsTable.js rename to src/components/explorer/searchResultsTables/IndividualsTable.js index 20584396b..fd0f5119b 100644 --- a/src/components/explorer/IndividualsTable.js +++ b/src/components/explorer/searchResultsTables/IndividualsTable.js @@ -1,49 +1,30 @@ import React from "react"; import PropTypes from "prop-types"; -import { useSortedColumns } from "./hooks/explorerHooks"; -import { Link, useLocation } from "react-router-dom"; +import { useSortedColumns } from "../hooks/explorerHooks"; import { useSelector } from "react-redux"; -import ExplorerSearchResultsTable from "./ExplorerSearchResultsTable"; - -const IndividualRender = ({individual}) => { - const location = useLocation(); - const alternateIds = individual.alternate_ids ?? []; - const listRender = alternateIds.length ? " (" + alternateIds.join(", ") + ")" : ""; - return ( - <> - - {individual.id} - {" "} - {listRender} - - ); -}; - -IndividualRender.propTypes = { - individual: PropTypes.object.isRequired, -}; +import ExplorerSearchResultsTable from "../ExplorerSearchResultsTable"; +import BiosampleIDCell from "./BiosampleIDCell"; +import IndividualIDCell from "./IndividualIDCell"; const SEARCH_RESULT_COLUMNS = [ { title: "Individual", dataIndex: "individual", - render: (individual) => , + render: (individual) => , sorter: (a, b) => a.individual.id.localeCompare(b.individual.id), defaultSortOrder: "ascend", }, { title: "Samples", dataIndex: "biosamples", - render: (samples) => ( + render: (samples, {individual: {id: individualId}}) => ( <> {samples.length} Sample{samples.length === 1 ? "" : "s"} {samples.length ? ": " : ""} - {samples.join(", ")} + {samples.map((s, si) => <> + + {si < samples.length - 1 ? ", " : ""} + )} ), sorter: (a, b) => a.biosamples.length - b.biosamples.length, diff --git a/src/components/manager/DataTypeSelect.js b/src/components/manager/DataTypeSelect.js new file mode 100644 index 000000000..3962e34f1 --- /dev/null +++ b/src/components/manager/DataTypeSelect.js @@ -0,0 +1,63 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Select, Spin } from "antd"; +import PropTypes from "prop-types"; +import { useSelector } from "react-redux"; + +const DataTypeSelect = ({value, workflows, onChange}) => { + const [selected, setSelected] = useState(value ?? undefined); + const servicesFetching = useSelector((state) => state.services.isFetchingAll); + const workflowsFetching = useSelector((state) => state.serviceWorkflows.isFetchingAll); + const { + itemsByID: dataTypes, + isFetchingAll: isFetchingDataTypes, + } = useSelector((state) => state.serviceDataTypes); + + const labels = useMemo(() => { + if (!dataTypes) return {}; + return Object.fromEntries( + Object.values(dataTypes).map(dt => [dt.id, dt.label]), + ); + }, dataTypes); + + useEffect(() => { + setSelected(value); + }, [value]); + + const onChangeInner = useCallback((newSelected) => { + if (!value) setSelected(newSelected); + if (onChange) { + onChange(newSelected); + } + }, [value, onChange]); + + const options = useMemo(() => { + if (!Array.isArray(workflows)) { + return []; + } + const dataTypes = new Set(workflows.map((w) => w.data_type)); + return Array.from(dataTypes) + // filter out workflow types for which we have no labels (mcode) + .filter(dt => dt in labels) + .map((dt) => + + {labels[dt]} ({{dt}}) + , + ); + }, [workflows, dataTypes, labels]); + + return ( + + + + ); +}; + +DataTypeSelect.propTypes = { + workflows: PropTypes.array, + onChange: PropTypes.func, + value: PropTypes.string, +}; + +export default DataTypeSelect; diff --git a/src/components/manager/DatasetSelectionModal.js b/src/components/manager/DatasetSelectionModal.js new file mode 100644 index 000000000..a57932b38 --- /dev/null +++ b/src/components/manager/DatasetSelectionModal.js @@ -0,0 +1,47 @@ +import React, {useCallback, useState} from "react"; +import PropTypes from "prop-types"; + +import {Form, Modal} from "antd"; + +import DatasetTreeSelect from "./DatasetTreeSelect"; + +import {nop} from "../../utils/misc"; + +const WIDTH_100 = {width: "100%"}; + +const DatasetSelectionModal = ({dataType, title, visible, onCancel, onOk}) => { + + const [selectedProject, setSelectedProject] = useState(undefined); + const [selectedDataset, setSelectedDataset] = useState(undefined); + + const onChangeInner = useCallback((project, dataset) => { + setSelectedProject(project); + setSelectedDataset(dataset); + }, []); + + const onOkInner = useCallback( + () => (onOk || nop)(selectedProject, selectedDataset, dataType), + [onOk, selectedProject, selectedDataset, dataType], + ); + + return +
+ + + + +
; +}; + +DatasetSelectionModal.propTypes = { + dataType: PropTypes.string, + title: PropTypes.string, + visible: PropTypes.bool, + onCancel: PropTypes.func, + onOk: PropTypes.func, +}; + +export default DatasetSelectionModal; diff --git a/src/components/manager/DatasetTreeSelect.js b/src/components/manager/DatasetTreeSelect.js new file mode 100644 index 000000000..0ade47abc --- /dev/null +++ b/src/components/manager/DatasetTreeSelect.js @@ -0,0 +1,55 @@ +import React, {useCallback, useEffect, useMemo, useState} from "react"; +import {useSelector} from "react-redux"; +import PropTypes from "prop-types"; + +import {Spin, TreeSelect} from "antd"; + +const DatasetTreeSelect = ({value, onChange, style}) => { + const {items: projectItems, isFetching: projectsFetching} = useSelector((state) => state.projects); + const servicesFetching = useSelector((state) => state.services.isFetchingAll); + + const [selected, setSelected] = useState(value ?? undefined); + + useEffect(() => { + setSelected(value); + }, [value]); + + const onChangeInner = useCallback((newSelected) => { + if (!value) setSelected(newSelected); + if (onChange) { + const [project, dataset] = newSelected.split(":"); + onChange(project, dataset); + } + }, [value, onChange, selected]); + + const selectTreeData = useMemo(() => projectItems.map(p => ({ + title: p.title, + selectable: false, + key: p.identifier, + value: p.identifier, + children: p.datasets.map(d => ({ + title: d.title, + key: `${p.identifier}:${d.identifier}`, + value: `${p.identifier}:${d.identifier}`, + })), + })), [projectItems]); + + return + + ; +}; + +DatasetTreeSelect.propTypes = { + style: PropTypes.object, + value: PropTypes.string, + onChange: PropTypes.func, +}; + +export default DatasetTreeSelect; diff --git a/src/components/manager/ManagerDropBoxContent.js b/src/components/manager/ManagerDropBoxContent.js index 8cb67397c..9807640db 100644 --- a/src/components/manager/ManagerDropBoxContent.js +++ b/src/components/manager/ManagerDropBoxContent.js @@ -39,7 +39,7 @@ import {LAYOUT_CONTENT_STYLE} from "../../styles/layoutContent"; import DownloadButton from "./DownloadButton"; import DropBoxTreeSelect from "./DropBoxTreeSelect"; import JsonDisplay from "../JsonDisplay"; -import TableSelectionModal from "./TableSelectionModal"; +import DatasetSelectionModal from "./DatasetSelectionModal"; import {BENTO_DROP_BOX_FS_BASE_PATH} from "../../config"; import {STEP_INPUT} from "./workflowCommon"; @@ -459,7 +459,7 @@ const ManagerDropBoxContent = () => { const [fileDeleteModal, setFileDeleteModal] = useState(false); const [selectedWorkflow, setSelectedWorkflow] = useState(null); - const [tableSelectionModal, setTableSelectionModal] = useState(false); + const [datasetSelectionModal, setDatasetSelectionModal] = useState(false); const showUploadModal = useCallback(() => setUploadModal(true), []); const hideUploadModal = useCallback(() => setUploadModal(false), []); @@ -468,11 +468,11 @@ const ManagerDropBoxContent = () => { const showFileContentsModal = useCallback(() => setFileContentsModal(true), []); const hideFileContentsModal = useCallback(() => setFileContentsModal(false), []); - const showTableSelectionModal = useCallback(workflow => { + const showDatasetSelectionModal = useCallback(workflow => { setSelectedWorkflow(workflow); - setTableSelectionModal(true); + setDatasetSelectionModal(true); }, []); - const hideTableSelectionModal = useCallback(() => setTableSelectionModal(false), []); + const hideDatasetSelectionModal = useCallback(() => setDatasetSelectionModal(false), []); const getWorkflowFit = useCallback(w => { let workflowSupported = true; @@ -482,7 +482,7 @@ const ManagerDropBoxContent = () => { for (const i of w.inputs.filter(i => i.type.startsWith("file"))) { const isFileArray = i.type.endsWith("[]"); - // Find tables that support the data type + // Find datasets that support the data type // TODO // Find files where 1+ of the valid extensions (e.g. jpeg or jpg) match. @@ -509,10 +509,14 @@ const ManagerDropBoxContent = () => { return [workflowSupported, inputs]; }, [selectedEntries]); - const ingestIntoTable = useCallback(tableKey => { + const ingestIntoDataset = useCallback((project, dataset, dataType) => { history.push("/admin/data/manager/ingestion", { step: STEP_INPUT, - workflowSelectionValues: {selectedTable: tableKey}, + workflowSelectionValues: { + selectedProject: project, + selectedDataset: dataset, + selectedDataType: dataType, + }, selectedWorkflow, initialInputValues: getWorkflowFit(selectedWorkflow)[1], }); @@ -531,7 +535,7 @@ const ManagerDropBoxContent = () => { {ingestionWorkflows.map(w => ( w2.id === w.id) === -1} - onClick={() => showTableSelectionModal(w)}> + onClick={() => showDatasetSelectionModal(w)}> Ingest with Workflow “{w.name}” ))} @@ -540,7 +544,7 @@ const ManagerDropBoxContent = () => { const handleIngest = useCallback(() => { if (workflowsSupported.length !== 1) return; - showTableSelectionModal(workflowsSupported[0]); + showDatasetSelectionModal(workflowsSupported[0]); }, [workflowsSupported]); const hasUploadPermission = permissions.includes(ingestDropBox); @@ -641,12 +645,12 @@ const ManagerDropBoxContent = () => { onCancel={hideUploadModal} /> - ingestIntoTable(tableKey)} + visible={datasetSelectionModal} + title="Select a Dataset to Ingest Into" + onCancel={hideDatasetSelectionModal} + onOk={ingestIntoDataset} /> { const {workflows, workflowsLoading} = useSelector(workflowsStateToPropsMixin); - const {selectedTable} = values; + const {selectedProject, selectedDataset, selectedDataType} = values; const workflowItems = workflows.ingestion - .filter(w => w.data_type === (selectedTable ? selectedTable.split(":")[1] : null)) + .filter(w => selectedDataset && selectedDataType && w.data_type === selectedDataType) .map(w => { />, ); + const onChange = useCallback(({ + project = selectedProject, + dataset = selectedDataset, + dataType = selectedDataType, + }) => { + setValues({ + selectedProject: project, + selectedDataset: dataset, + selectedDataType: dataType, + }); + }, [selectedDataset, selectedDataType, setValues]); + return
- - setValues({selectedTable: t})} - value={selectedTable} + + onChange({project: p, dataset: d})} + value={selectedDataset} + /> + + + onChange({dataType: dt})} + value={selectedDataType} /> - {selectedTable + {selectedDataset && selectedDataType ? {workflowsLoading ? : {workflowItems}} : + description="Select a dataset and data type to see available workflows"/> } ; }; IngestWorkflowSelection.propTypes = { - values: PropTypes.shape({ - selectedTable: PropTypes.string, - }), + values: workflowTarget, setValues: PropTypes.func, handleWorkflowClick: PropTypes.func, }; -const IngestConfirmDisplay = ({selectedTable, selectedWorkflow, inputs, handleRunWorkflow}) => { +const TitleAndID = React.memo(({title, id}) => title ? {title} ({id}) : {id}); +TitleAndID.propTypes = { + title: PropTypes.string, + id: PropTypes.string, +}; + +const STYLE_RUN_INGESTION = {marginTop: "16px", float: "right"}; + +const IngestConfirmDisplay = ({target, selectedWorkflow, inputs, handleRunWorkflow}) => { const projectsByID = useSelector(state => state.projects.itemsByID); - const tablesByServiceID = useSelector(state => state.serviceTables.itemsByServiceID); const isSubmittingIngestionRun = useSelector(state => state.runs.isSubmittingIngestionRun); + const datasetsByID = useSelector((state) => state.projects.datasetsByID); - const getTableName = (serviceID, tableID) => tablesByServiceID[serviceID]?.tablesByID?.[tableID]?.name; - const formatWithNameIfPossible = (name, id) => name ? `${name} (${id})` : id; + const {selectedProject, selectedDataset} = target; - const [projectID, dataType, tableID] = selectedTable.split(":"); - const projectTitle = projectsByID[projectID]?.title || null; - const tableName = getTableName(selectedWorkflow.serviceID, tableID); + const projectTitle = projectsByID[selectedProject]?.title || null; + const datasetTitle = datasetsByID[selectedDataset]?.title || null; return (
- {formatWithNameIfPossible(projectTitle, projectID)} - - - {dataType} + - - {formatWithNameIfPossible(tableName, tableID)} + + @@ -98,7 +119,7 @@ const IngestConfirmDisplay = ({selectedTable, selectedWorkflow, inputs, handleRu {/* TODO: Back button like the last one */}
{iID}, - }, - { - title: "Value", - dataIndex: "value", - render: value => - value === undefined - ? EM_DASH - : ( - value instanceof Array - ?
    {value.map(v =>
  • {v.toString()}
  • )}
- : value.toString() - ), - }, - ]} - rowKey="id" - dataSource={selectedWorkflow.inputs.map(i => ({id: i.id, value: inputs[i.id]}))} - /> -); +const RUN_SETUP_INPUTS_COLUMNS = [ + { + title: "ID", + dataIndex: "id", + render: iID => {iID}, + }, + { + title: "Value", + dataIndex: "value", + render: value => + value === undefined + ? EM_DASH + : ( + value instanceof Array + ?
    {value.map(v =>
  • {v.toString()}
  • )}
+ : value.toString() + ), + }, +]; + +const RunSetupInputsTable = ({ selectedWorkflow, inputs }) => { + const dataSource = useMemo( + () => selectedWorkflow.inputs + .filter(i => !(i.hidden ?? false)) + .map(i => ({ id: i.id, value: inputs[i.id] })), + [inputs]); + return ( +
+ ); +}; RunSetupInputsTable.propTypes = { selectedWorkflow: PropTypes.object, inputs: PropTypes.object, diff --git a/src/components/manager/TableSelectionModal.js b/src/components/manager/TableSelectionModal.js deleted file mode 100644 index cd21a8713..000000000 --- a/src/components/manager/TableSelectionModal.js +++ /dev/null @@ -1,41 +0,0 @@ -import React, {Component} from "react"; -import PropTypes from "prop-types"; - -import {Form, Modal} from "antd"; - -import TableTreeSelect from "./TableTreeSelect"; - -import {nop} from "../../utils/misc"; - -class TableSelectionModal extends Component { - constructor(props) { - super(props); - this.state = {selected: undefined}; - } - - render() { - return (this.props.onCancel || nop)()} - onOk={() => (this.props.onOk || nop)(this.state.selected)}> - - - this.setState({selected: table})} /> - - - ; - } -} - -TableSelectionModal.propTypes = { - dataType: PropTypes.string, - title: PropTypes.string, - visible: PropTypes.bool, - onCancel: PropTypes.func, - onOk: PropTypes.func, -}; - -export default TableSelectionModal; diff --git a/src/components/manager/TableTreeSelect.js b/src/components/manager/TableTreeSelect.js deleted file mode 100644 index edc67705a..000000000 --- a/src/components/manager/TableTreeSelect.js +++ /dev/null @@ -1,116 +0,0 @@ -import React, {Component} from "react"; -import {connect} from "react-redux"; -import PropTypes from "prop-types"; - -import {Spin, Tag, TreeSelect} from "antd"; - -import {nop} from "../../utils/misc"; - -class TableTreeSelect extends Component { - static getDerivedStateFromProps(nextProps) { - if ("value" in nextProps) { - return {selected: nextProps.value || undefined}; - } - return null; - } - - constructor(props) { - super(props); - this.state = {selected: props.value || undefined}; - } - - onChange(selected) { - // Set the state directly unless value is bound - if (!("value" in this.props)) this.setState({selected}); - - // Update the change handler bound to the component - if (this.props.onChange) this.props.onChange(this.state.selected); - } - - render() { - // TODO: Handle table loading better - - const getTableName = (serviceID, tableID) => - this.props.tablesByServiceID[serviceID]?.tablesByID?.[tableID]?.name; - - const dataType = this.props.dataType ?? null; - - const selectTreeData = this.props.projects.map(p => ({ - title: p.title, - selectable: false, - key: `project:${p.identifier}`, - value: `project:${p.identifier}`, - data: p, - children: p.datasets.map(d => ({ - title: d.title, - selectable: false, - key: `dataset:${d.identifier}`, - value: `dataset:${d.identifier}`, - data: d, - children: (this.props.projectTables[p.identifier] ?? []) - .filter(t => t.dataset === d.identifier && - ((this.props.tablesByServiceID?.[t.service_id]?.tablesByID ?? {}) - .hasOwnProperty(t.table_id))) - .map(t => ({ - ...t, - tableName: getTableName(t.service_id, t.table_id) ?? "", - dataType: this.props.tablesByServiceID[t.service_id].tablesByID[t.table_id].data_type, - })) - .map(t => ({ - title: <> - {t.dataType} - {t.tableName}  - ({t.table_id}) - , - disabled: !(dataType === null || dataType === t.dataType), - isLeaf: true, - key: `${p.identifier}:${t.dataType}:${t.table_id}`, - value: `${p.identifier}:${t.dataType}:${t.table_id}`, - data: t, - })), - })), - })); - - return - { - const filter = v.toLocaleLowerCase().trim(); - if (filter === "") return true; - return n.key.toLocaleLowerCase().includes(filter) - || n.props.data.title.toLocaleLowerCase().includes(filter) - || (n.props.data.dataType || "").toLocaleLowerCase().includes(filter); - }} - onChange={this.props.onChange ?? nop} - value={this.state.selected} - treeData={selectTreeData} - treeDefaultExpandAll={true} /> - ; - } -} - -TableTreeSelect.propTypes = { - style: PropTypes.object, - - value: PropTypes.string, - - dataType: PropTypes.string, - onChange: PropTypes.func, - - projects: PropTypes.array, - projectTables: PropTypes.object, // TODO: Shape - tablesByServiceID: PropTypes.objectOf(PropTypes.object), // TODO: Shape - - servicesLoading: PropTypes.bool, - projectsLoading: PropTypes.bool, -}; - -const mapStateToProps = state => ({ - projects: state.projects.items, - projectTables: state.projectTables.itemsByProjectID, - tablesByServiceID: state.serviceTables.itemsByServiceID, - servicesLoading: state.services.isFetchingAll, - projectsLoading: state.projects.isFetching, -}); - -export default connect(mapStateToProps)(TableTreeSelect); diff --git a/src/components/manager/projects/Project.js b/src/components/manager/projects/Project.js index 091ba3e54..ed1cfcddb 100644 --- a/src/components/manager/projects/Project.js +++ b/src/components/manager/projects/Project.js @@ -139,13 +139,9 @@ class Project extends Component { key={d.identifier} mode="private" project={this.props.value} - value={{ - ...d, - tables: this.props.tables.filter(t => t.dataset === d.identifier), - }} - strayTables={this.props.strayTables} + value={d} onEdit={() => (this.props.onEditDataset || nop)(d)} - onTableIngest={this.props.onTableIngest || nop} + onDatasetIngest={this.props.onDatasetIngest || nop} /> , @@ -194,8 +190,6 @@ class Project extends Component { Project.propTypes = { value: projectPropTypesShape, - tables: PropTypes.arrayOf(PropTypes.object), // TODO: shape - strayTables: PropTypes.arrayOf(PropTypes.object), // TODO: shape (this is currently heterogeneous) editing: PropTypes.bool, saving: PropTypes.bool, @@ -208,7 +202,7 @@ Project.propTypes = { onEditDataset: PropTypes.func, onAddJsonSchema: PropTypes.func, - onTableIngest: PropTypes.func, + onDatasetIngest: PropTypes.func, }; export default Project; diff --git a/src/components/manager/projects/RoutedProject.js b/src/components/manager/projects/RoutedProject.js index 3f0c5d50a..22a2536ab 100644 --- a/src/components/manager/projects/RoutedProject.js +++ b/src/components/manager/projects/RoutedProject.js @@ -29,7 +29,7 @@ class RoutedProject extends Component { this.showDatasetAdditionModal = this.showDatasetAdditionModal.bind(this); this.hideDatasetAdditionModal = this.hideDatasetAdditionModal.bind(this); this.hideDatasetEditModal = this.hideDatasetEditModal.bind(this); - this.ingestIntoTable = this.ingestIntoTable.bind(this); + this.ingestIntoDataset = this.ingestIntoDataset.bind(this); this.handleDeleteProject = this.handleDeleteProject.bind(this); } @@ -40,9 +40,17 @@ class RoutedProject extends Component { } } - ingestIntoTable(p, t) { - this.props.history.push("/admin/data/manager/ingestion", - {workflowSelectionValues: {selectedTable: `${p.identifier}:${t.data_type}:${t.id}`}}); + ingestIntoDataset(p, d, dt) { + this.props.history.push( + "/admin/data/manager/ingestion", + { + workflowSelectionValues: { + selectedProject: p.identifier, + selectedDataset: d.identifier, + selectedDataType: dt.id, + }, + }, + ); } handleProjectSave(project) { @@ -96,54 +104,6 @@ class RoutedProject extends Component { const project = this.props.projectsByID[this.props.match.params.project]; if (!project) return ; - const tables = this.props.serviceTablesByServiceID; - - /** - * @typedef {Object} ProjectTable - * @property {string} table_id - * @property {string} service_id - * @property {string} dataset - * @property {string} data_type - * @property {string} sample - * @type {ProjectTable[]} - */ - const projectTableRecords = this.props.projectTablesByProjectID[selectedProjectID] || []; - - const bentoServicesByKind = this.props.bentoServicesByKind; - const serviceDataTypesByServiceID = this.props.serviceDataTypesByServiceID; - - const manageableDataTypes = this.props.services - .filter(s => { - const cs = bentoServicesByKind[s.bento?.serviceKind ?? s.type.artifact] ?? {}; - return ( - cs.data_service && // Service in question must be a data service to have manageable tables ... - cs.manageable_tables && // ... and it must have manageable tables specified ... - serviceDataTypesByServiceID[s.id]?.items?.length // ... and it must have >=1 data type. - ); - }) - .flatMap(s => serviceDataTypesByServiceID[s.id].items.map(dt => dt.id)); - - console.log("ptr", projectTableRecords); - console.log("tbl", tables); - - const tableList = projectTableRecords - .filter(tableOwnership => - tableOwnership.table_id in (tables[tableOwnership.service_id]?.tablesByID ?? {})) - .map(tableOwnership => ({ - ...tableOwnership, - ...tables[tableOwnership.service_id].tablesByID[tableOwnership.table_id], - })); - - console.log("tll", tableList); - - // TODO: Inconsistent schemas - const strayTables = [ - ...this.props.serviceTables.filter(t2 => - !this.props.projectTables.map(to => to.table_id).includes(t2.id) && - manageableDataTypes.includes(t2.data_type)).map(t => ({...t, table_id: t.id})), - ...this.props.projectTables.filter(to => !this.props.servicesByID.hasOwnProperty(to.service_id)), - ]; - return <> this.setJsonSchemaModalVisible(false)} /> this.handleDeleteProject(project)} @@ -178,7 +136,7 @@ class RoutedProject extends Component { datasetEditModal: true, })} onAddJsonSchema={() => this.setJsonSchemaModalVisible(true)} - onTableIngest={(p, t) => this.ingestIntoTable(p, t)}/> + onDatasetIngest={(p, d, dt) => this.ingestIntoDataset(p, d, dt)}/> ; } } @@ -198,15 +156,9 @@ RoutedProject.propTypes = { isFetching: PropTypes.bool, })), - serviceTables: PropTypes.arrayOf(PropTypes.object), // TODO: Shape - serviceTablesByServiceID: PropTypes.objectOf(PropTypes.object), // TODO: Shape - projects: PropTypes.arrayOf(projectPropTypesShape), projectsByID: PropTypes.objectOf(projectPropTypesShape), - projectTables: PropTypes.arrayOf(PropTypes.object), // TODO: Shape - projectTablesByProjectID: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.object)), // TODO: Shape - loadingProjects: PropTypes.bool, isDeletingProject: PropTypes.bool, @@ -228,15 +180,9 @@ const mapStateToProps = state => ({ serviceDataTypesByServiceID: state.serviceDataTypes.dataTypesByServiceID, - serviceTables: state.serviceTables.items, - serviceTablesByServiceID: state.serviceTables.itemsByServiceID, - projects: state.projects.items, projectsByID: state.projects.itemsByID, - projectTables: state.projectTables.items, - projectTablesByProjectID: state.projectTables.itemsByProjectID, - loadingProjects: state.projects.isAdding || state.projects.isFetching, isDeletingProject: state.projects.isDeleting, diff --git a/src/components/manager/runs/RunLastContent.js b/src/components/manager/runs/RunLastContent.js index 7f6163684..6ffcbd50c 100644 --- a/src/components/manager/runs/RunLastContent.js +++ b/src/components/manager/runs/RunLastContent.js @@ -11,7 +11,7 @@ const COLUMNS_LAST_CONTENT = [ render: (date) => formatDate(date), }, {title: "Data Type", dataIndex: "dataType", key: "dataType"}, - {title: "Table ID", dataIndex: "tableId", key: "tableId"}, + {title: "Dataset ID", dataIndex: "datasetId", key: "datasetId"}, { title: "Ingested Files", dataIndex: "fileNames", @@ -98,7 +98,7 @@ FileNamesCell.propTypes = { dataType: PropTypes.string.isRequired, }; -const buildKeyFromRecord = (record) => `${record.dataType}-${record.tableId}`; +const buildKeyFromRecord = (record) => `${record.dataType}-${record.datasetId}`; const fileNameFromPath = (path) => path.split("/").at(-1); @@ -107,8 +107,8 @@ const getFileInputsFromWorkflow = (workflowId, {inputs}) => .filter(input => ["file", "file[]"].includes(input.type)) .map(input => `${workflowId}.${input.id}`); -const processIngestions = (data, currentTables) => { - const currentTableIds = new Set((currentTables || []).map((table) => table.table_id)); +const processIngestions = (data, currentDatasets) => { + const currentDatasetIds = new Set((currentDatasets || []).map((ds) => ds.identifier)); const ingestionsByDataType = data.reduce((ingestions, run) => { if (run.state !== "COMPLETE") { @@ -118,10 +118,11 @@ const processIngestions = (data, currentTables) => { const { workflow_id: workflowId, workflow_metadata: workflowMetadata, - table_id: tableId, + dataset_id: datasetId, } = run.details.request.tags; - if (tableId === undefined || !currentTableIds.has(tableId)) { + + if (datasetId === undefined || !currentDatasetIds.has(datasetId)) { return ingestions; } @@ -140,19 +141,18 @@ const processIngestions = (data, currentTables) => { const date = Date.parse(run.details.run_log.end_time); - const currentIngestion = { date, dataType: workflowMetadata.data_type, tableId, fileNames }; - const dataTypeAndTableId = buildKeyFromRecord(currentIngestion); + const currentIngestion = { date, dataType: workflowMetadata.data_type, datasetId, fileNames }; + const dataTypeAndDatasetId = buildKeyFromRecord(currentIngestion); - if (ingestions[dataTypeAndTableId]) { - const existingDate = ingestions[dataTypeAndTableId].date; + if (ingestions[dataTypeAndDatasetId]) { + const existingDate = ingestions[dataTypeAndDatasetId].date; if (date > existingDate) { - ingestions[dataTypeAndTableId].date = date; + ingestions[dataTypeAndDatasetId].date = date; } - ingestions[dataTypeAndTableId].fileNames.push(...fileNames); + ingestions[dataTypeAndDatasetId].fileNames.push(...fileNames); } else { - ingestions[dataTypeAndTableId] = currentIngestion; + ingestions[dataTypeAndDatasetId] = currentIngestion; } - return ingestions; }, {}); @@ -162,18 +162,16 @@ const processIngestions = (data, currentTables) => { const LastIngestionTable = () => { const servicesFetching = useSelector(state => state.services.isFetchingAll); const {items: runs, isFetching: runsFetching} = useSelector((state) => state.runs); - const { - items: currentTables, - isFetching: projectTablesFetching, - } = useSelector((state) => state.projectTables); - const ingestions = useMemo(() => processIngestions(runs, currentTables), [runs, currentTables]); + const currentDatasets = useSelector((state) => state.projects.items.flatMap(p => p.datasets)); + const ingestions = useMemo(() => processIngestions(runs, currentDatasets), [runs, currentDatasets]); return
; }; diff --git a/src/components/manager/runs/RunListContent.js b/src/components/manager/runs/RunListContent.js index 1f8199f6d..3ac7a00cd 100644 --- a/src/components/manager/runs/RunListContent.js +++ b/src/components/manager/runs/RunListContent.js @@ -4,12 +4,11 @@ import PropTypes from "prop-types"; import { Table, Typography } from "antd"; -import { fetchAllRunDetailsIfNeeded } from "../../../modules/wes/actions"; +import LastIngestionTable from "./RunLastContent"; +import { fetchAllRunDetailsIfNeeded } from "../../../modules/wes/actions"; import { RUN_REFRESH_TIMEOUT, RUN_TABLE_COLUMNS } from "./utils"; -import LastIngestionTable from "./RunLastContent"; - class RunListContent extends Component { constructor(props) { super(props); @@ -33,18 +32,22 @@ class RunListContent extends Component { // TODO: Loading for individual rows render() { return ( - <> - Latest Ingested Files - - Workflow Runs -
- +
+
+ Latest Ingested Files + +
+
+ Workflow Runs +
+ + ); } } diff --git a/src/components/manager/runs/RunRequest.js b/src/components/manager/runs/RunRequest.js index 034e77e9e..3dc56e985 100644 --- a/src/components/manager/runs/RunRequest.js +++ b/src/components/manager/runs/RunRequest.js @@ -9,25 +9,39 @@ import ReactJson from "react-json-view"; import WorkflowListItem from "../WorkflowListItem"; const RunRequest = ({run}) => { - const tablesByServiceID = useSelector(state => state.serviceTables.itemsByServiceID); + const projectsById = useSelector((state) => state.projects.itemsByID); const details = run?.details; if (!details) return
; - const serviceID = details.request.tags.workflow_metadata.serviceID; - const tableDataType = details.request.tags.workflow_metadata.data_type; - const tableID = details.request.tags.table_id; - const tableName = tablesByServiceID[serviceID]?.tablesByID?.[tableID]?.name; + const runDataType = details.request.tags.workflow_metadata.data_type; - // TODO: Link to some "table" page from the table description item here + const {project_id: projectId, dataset_id: datasetId} = details.request.tags; - const idFragment = {tableID}; + const project = projectsById[projectId] ?? null; + const dataset = project ? (project.datasets.find(d => d.identifier === datasetId) ?? null) : null; + + const projectTitle = project?.title ?? null; + const datasetTitle = dataset?.title ?? null; + + const projectIdFragment = {projectId}; + const datasetIdFragment = {datasetId}; return - {tableID !== undefined && ( - - {tableDataType} {tableName ? <>{tableName} ({idFragment}) : idFragment} + {project !== null && ( + + {projectTitle ? <>{projectTitle} ({projectIdFragment}) : projectIdFragment} + + )} + {dataset !== null && ( + + {datasetTitle ? <>{datasetTitle} ({datasetIdFragment}) : datasetIdFragment} + + )} + {dataset !== null && ( + + {runDataType} )} diff --git a/src/components/manager/runs/utils.js b/src/components/manager/runs/utils.js index 94e22a78d..b2f9e186c 100644 --- a/src/components/manager/runs/utils.js +++ b/src/components/manager/runs/utils.js @@ -7,7 +7,7 @@ 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) => (new Date(Date.parse(a[dateProperty])).getTime() || Infinity) - diff --git a/src/modules/auth/actions.js b/src/modules/auth/actions.js index 7c680e9b6..e1803e4ca 100644 --- a/src/modules/auth/actions.js +++ b/src/modules/auth/actions.js @@ -10,14 +10,15 @@ import { networkAction, } from "../../utils/actions"; +import { fetchDatasetsDataTypes } from "../../modules/datasets/actions"; import { fetchDropBoxTreeOrFail } from "../manager/actions"; import { - fetchProjectsWithDatasetsAndTables, + fetchProjectsWithDatasets, fetchOverviewSummary, fetchExtraPropertiesSchemaTypes, } from "../metadata/actions"; import { fetchNotifications } from "../notifications/actions"; -import { fetchServicesWithMetadataAndDataTypesAndTablesIfNeeded } from "../services/actions"; +import { fetchServicesWithMetadataAndDataTypesIfNeeded } from "../services/actions"; import { fetchRuns } from "../wes/actions"; import { performGetGohanVariantsOverviewIfPossible } from "../explorer/actions"; @@ -59,10 +60,11 @@ export const fetchUserDependentData = (servicesCb) => async (dispatch, getState) try { if (idTokenContents) { // If we're newly authenticated as an owner, we run all actions that need authentication (via the callback). - await dispatch(fetchServicesWithMetadataAndDataTypesAndTablesIfNeeded( + await dispatch(fetchServicesWithMetadataAndDataTypesIfNeeded( () => dispatch(fetchServiceDependentData()))); await (servicesCb || nop)(); - await dispatch(fetchProjectsWithDatasetsAndTables()); // TODO: If needed, remove if !hasAttempted + await dispatch(fetchProjectsWithDatasets()); // TODO: If needed, remove if !hasAttempted + await dispatch(fetchDatasetsDataTypes()); } } finally { dispatch(endFlow(FETCHING_USER_DEPENDENT_DATA)); diff --git a/src/modules/datasets/actions.js b/src/modules/datasets/actions.js new file mode 100644 index 000000000..265922b6c --- /dev/null +++ b/src/modules/datasets/actions.js @@ -0,0 +1,38 @@ +import {beginFlow, createFlowActionTypes, createNetworkActionTypes, endFlow, networkAction} from "../../utils/actions"; + +export const FETCHING_DATASETS_DATATYPE = createFlowActionTypes("FETCHING_DATASETS_DATATYPE"); +export const FETCH_DATASET_DATATYPE = createNetworkActionTypes("FETCH_DATASET_DATATYPE"); +export const FETCH_DATASET_SUMMARY = createNetworkActionTypes("FETCH_DATASET_SUMMARY"); + +const fetchDatasetDataTypeSummary = networkAction((serviceInfo, datasetID) => ({ + types: FETCH_DATASET_DATATYPE, + params: {serviceInfo, datasetID}, + url: `${serviceInfo.url}/datasets/${datasetID}/data-types`, +})); + +export const fetchDatasetDataTypesSummaryIfPossible = (datasetID) => async (dispatch, getState) => { + if (getState().datasetDataTypes.isFetching) return; + await dispatch(fetchDatasetDataTypeSummary(getState().services.itemsByArtifact.metadata, datasetID)); + await dispatch(fetchDatasetDataTypeSummary(getState().services.itemsByArtifact.gohan, datasetID)); +}; + +export const fetchDatasetsDataTypes = () => async (dispatch, getState) => { + dispatch(beginFlow(FETCHING_DATASETS_DATATYPE)); + await Promise.all( + Object.keys(getState().projects.datasetsByID).map(datasetID => + dispatch(fetchDatasetDataTypesSummaryIfPossible(datasetID))), + ); + dispatch(endFlow(FETCHING_DATASETS_DATATYPE)); +}; + +const fetchDatasetSummary = networkAction((serviceInfo, datasetID) => ({ + types: FETCH_DATASET_SUMMARY, + params: {serviceInfo, datasetID}, + url: `${serviceInfo.url}/datasets/${datasetID}/summary`, +})); + +export const fetchDatasetSummaryIfPossible = (datasetID) => async (dispatch, getState) => { + if (getState().datasetSummaries.isFetching) return; + await dispatch(fetchDatasetSummary(getState().services.itemsByArtifact.metadata, datasetID)); + await dispatch(fetchDatasetSummary(getState().services.itemsByArtifact.gohan, datasetID)); +}; diff --git a/src/modules/datasets/reducers.js b/src/modules/datasets/reducers.js new file mode 100644 index 000000000..614dd82cf --- /dev/null +++ b/src/modules/datasets/reducers.js @@ -0,0 +1,104 @@ +import {FETCHING_DATASETS_DATATYPE, FETCH_DATASET_DATATYPE, FETCH_DATASET_SUMMARY} from "./actions"; + +export const datasetDataTypes = ( + state = { + itemsById: {}, + isFetchingAll: false, + }, + action, +) => { + switch (action.type) { + case FETCHING_DATASETS_DATATYPE.BEGIN: + return {...state, isFetchingAll: true}; + case FETCHING_DATASETS_DATATYPE.END: + return {...state, isFetchingAll: false}; + case FETCH_DATASET_DATATYPE.REQUEST:{ + const {datasetID} = action; + return { + ...state, + itemsById: { + ...state.itemsById, + [datasetID]: { + itemsById: {...(state.itemsById[datasetID]?.itemsById ?? {})}, + isFetching: true, + }, + }, + }; + } + case FETCH_DATASET_DATATYPE.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_DATATYPE.FINISH: + case FETCH_DATASET_DATATYPE.ERROR:{ + const {datasetID} = action; + return { + ...state, + itemsById: { + ...state.itemsById, + [datasetID]: { + ...state.itemsById[datasetID], + isFetching: false, + }, + }, + }; + } + default: + return state; + } +}; + + +export const datasetSummaries = ( + state = { + itemsById: {}, + }, + action, +) => { + switch (action.type) { + case FETCH_DATASET_SUMMARY.REQUEST:{ + const {datasetID} = action; + return { + ...state, + itemsById: { + ...state.itemsById, + [datasetID]: { + ...(state.itemsById[datasetID] ?? {}), + }, + }, + }; + } + case FETCH_DATASET_SUMMARY.RECEIVE:{ + const {datasetID} = action; + return { + ...state, + itemsById: { + ...state.itemsById, + [datasetID]: { + ...state.itemsById[datasetID], + ...action.data, + }, + }, + }; + } + case FETCH_DATASET_SUMMARY.FINISH: + case FETCH_DATASET_SUMMARY.ERROR: + return { + ...state, + }; + default: + return state; + } +}; diff --git a/src/modules/explorer/actions.js b/src/modules/explorer/actions.js index f27bc7e0f..bcdca5f33 100644 --- a/src/modules/explorer/actions.js +++ b/src/modules/explorer/actions.js @@ -23,7 +23,7 @@ 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}/private/dataset-search/${datasetID}`, + url: `${getState().services.aggregationService.url}/dataset-search/${datasetID}`, params: { datasetID }, req: jsonRequest( { diff --git a/src/modules/metadata/actions.js b/src/modules/metadata/actions.js index acfd45674..76f545fb0 100644 --- a/src/modules/metadata/actions.js +++ b/src/modules/metadata/actions.js @@ -1,31 +1,12 @@ -import fetch from "cross-fetch"; import {message} from "antd"; -import { - ADDING_SERVICE_TABLE, - DELETING_SERVICE_TABLE, - endAddingServiceTable, - endDeletingServiceTable, -} from "../services/actions"; import {endProjectEditing} from "../manager/actions"; - -import { - createNetworkActionTypes, - createFlowActionTypes, - networkAction, - - beginFlow, - endFlow, - terminateFlow, -} from "../../utils/actions"; +import { createNetworkActionTypes, networkAction } from "../../utils/actions"; import {nop, objectWithoutProps} from "../../utils/misc"; import {jsonRequest} from "../../utils/requests"; -import {makeAuthorizationHeader} from "../../lib/auth/utils"; export const FETCH_PROJECTS = createNetworkActionTypes("FETCH_PROJECTS"); -export const FETCH_PROJECT_TABLES = createNetworkActionTypes("FETCH_PROJECT_TABLES"); -export const FETCHING_PROJECTS_WITH_TABLES = createFlowActionTypes("FETCHING_PROJECTS_WITH_TABLES"); export const CREATE_PROJECT = createNetworkActionTypes("CREATE_PROJECT"); export const DELETE_PROJECT = createNetworkActionTypes("DELETE_PROJECT"); @@ -42,16 +23,25 @@ export const ADD_DATASET_LINKED_FIELD_SET = createNetworkActionTypes("ADD_DATASE export const SAVE_DATASET_LINKED_FIELD_SET = createNetworkActionTypes("SAVE_DATASET_LINKED_FIELD_SET"); export const DELETE_DATASET_LINKED_FIELD_SET = createNetworkActionTypes("DELETE_DATASET_LINKED_FIELD_SET"); -export const PROJECT_TABLE_ADDITION = createFlowActionTypes("PROJECT_TABLE_ADDITION"); -export const PROJECT_TABLE_DELETION = createFlowActionTypes("PROJECT_TABLE_DELETION"); - export const FETCH_INDIVIDUAL = createNetworkActionTypes("FETCH_INDIVIDUAL"); export const FETCH_OVERVIEW_SUMMARY = createNetworkActionTypes("FETCH_OVERVIEW_SUMMARY"); - -const endProjectTableAddition = (project, table) => ({type: PROJECT_TABLE_ADDITION.END, project, table}); -const endProjectTableDeletion = (project, tableID) => ({type: PROJECT_TABLE_DELETION.END, project, tableID}); - +export const DELETE_DATASET_DATA_TYPE = createNetworkActionTypes("DELETE_DATASET_DATA_TYPE"); + +export const clearDatasetDataType = networkAction((datasetId, dataType) => (dispatch, getState) => { + // TODO: more robust mapping from dataType to url. + const serviceUrl = dataType === "variant" + ? getState().services.itemsByKind.gohan.url + : getState().services.itemsByKind.metadata.url; + console.log(serviceUrl); + return { + types: DELETE_DATASET_DATA_TYPE, + url: `${serviceUrl}/datasets/${datasetId}/data-types/${dataType}`, + req: { + method: "DELETE", + }, + }; +}); export const fetchProjects = networkAction(() => (dispatch, getState) => ({ types: FETCH_PROJECTS, @@ -61,27 +51,15 @@ export const fetchProjects = networkAction(() => (dispatch, getState) => ({ })); -export const fetchProjectTables = networkAction(projectsByID => (dispatch, getState) => ({ - types: FETCH_PROJECT_TABLES, - params: {projectsByID}, - url: `${getState().services.metadataService.url}/api/table_ownership`, - paginated: true, - err: "Error fetching tables", -})); - - // TODO: if needed fetching + invalidation -export const fetchProjectsWithDatasetsAndTables = () => async (dispatch, getState) => { +export const fetchProjectsWithDatasets = () => async (dispatch, getState) => { const state = getState(); if (state.projects.isFetching || state.projects.isCreating || state.projects.isDeleting || state.projects.isSaving) return; - dispatch(beginFlow(FETCHING_PROJECTS_WITH_TABLES)); await dispatch(fetchProjects()); - await dispatch(fetchProjectTables(getState().projects.itemsByID)); - dispatch(endFlow(FETCHING_PROJECTS_WITH_TABLES)); }; @@ -150,8 +128,6 @@ export const deleteProject = networkAction(project => (dispatch, getState) => ({ export const deleteProjectIfPossible = project => (dispatch, getState) => { if (getState().projects.isDeleting) return; return dispatch(deleteProject(project)); - - // TODO: Do we need to delete project tables as well? What to do here?? }; @@ -203,7 +179,6 @@ export const deleteProjectDataset = networkAction((project, dataset) => (dispatc url: `${getState().services.metadataService.url}/api/datasets/${dataset.identifier}`, req: {method: "DELETE"}, err: `Error deleting dataset '${dataset.title}'`, - // TODO: Do we need to delete project tables as well? What to do here?? })); export const deleteProjectDatasetIfPossible = (project, dataset) => (dispatch, getState) => { @@ -278,175 +253,6 @@ export const deleteDatasetLinkedFieldSetIfPossible = (dataset, linkedFieldSet, l }; -// TODO: Split into network actions, use onSuccess -export const addProjectTable = (project, datasetID, serviceInfo, dataType, tableName) => - async (dispatch, getState) => { - if (getState().projectTables.isAdding) return; // TODO: or isDeleting - - const authHeaders = makeAuthorizationHeader(getState().auth.accessToken); - - dispatch(beginFlow(PROJECT_TABLE_ADDITION)); - dispatch(beginFlow(ADDING_SERVICE_TABLE)); - - const terminate = () => { - message.error(`Error adding new table '${tableName}'`); - dispatch(terminateFlow(ADDING_SERVICE_TABLE)); - dispatch(terminateFlow(PROJECT_TABLE_ADDITION)); - }; - - await fetch(`${serviceInfo.url}/tables?data-type=${dataType}`, { - method: "OPTIONS", - headers: authHeaders, - }); - - try { - const serviceResponse = await fetch( - `${serviceInfo.url}/tables`, - jsonRequest({ - name: tableName.trim(), - metadata: {}, - data_type: dataType, - dataset: datasetID, // This will only be used by the metadata service to create the ownership - }, "POST", authHeaders)); - - if (!serviceResponse.ok) { - console.error(serviceResponse); - terminate(); - return; - } - - const serviceTable = await serviceResponse.json(); - // Backwards compatibility for: - // - old type ("group:artifact:version") - // - and new ({"group": "...", "artifact": "...", "version": "..."}) - const serviceArtifact = (typeof serviceInfo.type === "string") - ? serviceInfo.type.split(":")[1] - : serviceInfo.type.artifact; - - try { - // If table is created in the metadata service, it'll handle automatically creating the ownership record - const projectResponse = await ( - serviceArtifact === "metadata" ? fetch( - `${getState().services.metadataService.url}/api/table_ownership/${serviceTable.id}`, - {method: "GET", headers: authHeaders}, - ) : fetch( - `${getState().services.metadataService.url}/api/table_ownership`, - jsonRequest({ - table_id: serviceTable.id, - service_id: serviceInfo.id, - service_artifact: serviceArtifact, - data_type: dataType, - - dataset: datasetID, - sample: null, // TODO: Sample ID if wanted // TODO: Deprecate? - }, "POST", authHeaders), - ) - ); - - if (!projectResponse.ok) { - // TODO: Delete previously-created service dataset - console.error(projectResponse); - terminate(); - return; - } - - const projectTable = await projectResponse.json(); - message.success("Table added!"); // TODO: Nicer GUI success message - dispatch(endAddingServiceTable(serviceInfo, dataType, serviceTable)); - dispatch(endProjectTableAddition(project, projectTable)); // TODO: Check params here - } catch (e) { - // TODO: Delete previously-created service dataset - console.error(e); - terminate(); - } - } catch (e) { - console.error(e); - terminate(); - } - }; - - -// TODO: Split into network actions, use onSuccess -const deleteProjectTable = (project, table) => async (dispatch, getState) => { - dispatch(beginFlow(PROJECT_TABLE_DELETION)); - dispatch(beginFlow(DELETING_SERVICE_TABLE)); - - const serviceInfo = getState().services.itemsByID[table.service_id]; - const authHeaders = makeAuthorizationHeader(getState().auth.accessToken); - - const terminate = () => { - message.error(`Error deleting table '${table.name}'`); - dispatch(terminateFlow(DELETING_SERVICE_TABLE)); - dispatch(terminateFlow(PROJECT_TABLE_DELETION)); - }; - - const handleFailure = e => { - console.error(e); - message.error("Error deleting table"); - terminate(); - }; - - const deleteReqInit = { - method: "DELETE", - headers: authHeaders, - }; - - // Delete from service - try { - console.debug(`deleting table ${table.table_id}`); - const serviceResponse = await fetch(`${serviceInfo.url}/tables/${table.table_id}`, deleteReqInit); - if (!serviceResponse.ok) return handleFailure(serviceResponse); - } catch (e) { - return handleFailure(e); - } - - // Delete from project metadata - try { - if ((serviceInfo.bento?.serviceKind ?? serviceInfo.type.artifact) !== "metadata") { - // Only manually delete the table ownership record if we're not deleting from Katsu, since Katsu - // handles its own table ownership deletion. - - const projectResponse = await fetch( - `${getState().services.metadataService.url}/api/table_ownership/${table.table_id}`, - deleteReqInit, - ); - - if (!projectResponse.ok) { - // TODO: Handle partial failure / out-of-sync - return handleFailure(projectResponse); - } - } - } catch (e) { - // TODO: Handle partial failure / out-of-sync - return handleFailure(e); - } - - // Success - - message.success("Table deleted!"); // TODO: Nicer GUI success message - - dispatch(endDeletingServiceTable(serviceInfo.id, table.table_id)); // TODO: Check params here - dispatch(endProjectTableDeletion(project, table.table_id)); // TODO: Check params here -}; - -export const deleteProjectTableIfPossible = (project, table) => (dispatch, getState) => { - if (getState().projectTables.isDeleting) return; - - const service = getState().services.itemsByID[table.service_id]; - if (!service) { - throw new Error(`Service not found: ${table.service_id}`); - } - - const serviceKind = service.bento?.serviceKind ?? service.type.artifact; - const bentoServiceInfo = getState().bentoServices.itemsByKind[serviceKind]; - if (!bentoServiceInfo.manageable_tables) { - // If manageable_tables is set and not true, we can't delete the table. - return; - } - return dispatch(deleteProjectTable(project, table)); -}; - - const fetchIndividual = networkAction(individualID => (dispatch, getState) => ({ types: FETCH_INDIVIDUAL, params: {individualID}, diff --git a/src/modules/metadata/reducers.js b/src/modules/metadata/reducers.js index b24d4930b..26e2653fb 100644 --- a/src/modules/metadata/reducers.js +++ b/src/modules/metadata/reducers.js @@ -2,8 +2,6 @@ import {objectWithoutProp} from "../../utils/misc"; import { FETCH_PROJECTS, - FETCH_PROJECT_TABLES, - FETCHING_PROJECTS_WITH_TABLES, CREATE_PROJECT, DELETE_PROJECT, @@ -17,9 +15,6 @@ import { SAVE_DATASET_LINKED_FIELD_SET, DELETE_DATASET_LINKED_FIELD_SET, - PROJECT_TABLE_ADDITION, - PROJECT_TABLE_DELETION, - FETCH_INDIVIDUAL, FETCH_OVERVIEW_SUMMARY, @@ -36,7 +31,6 @@ const projectSort = (a, b) => a.title.localeCompare(b.title); export const projects = ( state = { isFetching: false, - isFetchingWithTables: false, isCreating: false, isDeleting: false, isSaving: false, @@ -51,6 +45,8 @@ export const projects = ( items: [], itemsByID: {}, + + datasetsByID: {}, }, action, ) => { @@ -63,20 +59,15 @@ export const projects = ( ...state, items: action.data.sort(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]), + ), }; case FETCH_PROJECTS.FINISH: return {...state, isFetching: false}; - - case FETCHING_PROJECTS_WITH_TABLES.BEGIN: - return {...state, isFetchingWithTables: true}; - - case FETCHING_PROJECTS_WITH_TABLES.END: - case FETCHING_PROJECTS_WITH_TABLES.TERMINATE: - return {...state, isFetchingWithTables: false}; - - case CREATE_PROJECT.REQUEST: return {...state, isCreating: true}; @@ -127,25 +118,63 @@ export const projects = ( return {...state, isSaving: false}; + // ADD_PROJECT_DATASET case ADD_PROJECT_DATASET.REQUEST: return {...state, isAddingDataset: true}; - case ADD_PROJECT_DATASET.RECEIVE: + case ADD_PROJECT_DATASET.RECEIVE: { + const newDataset = action.data; + const projectID = newDataset.project; return { ...state, isAddingDataset: false, - items: state.items.map(p => p.identifier === action.data.project - ? {...p, datasets: [...p.datasets, action.data]} + items: state.items.map(p => p.identifier === newDataset.project + ? {...p, datasets: [...p.datasets, newDataset]} : p, ), itemsByID: { ...state.itemsByID, - [action.data.project]: { - ...(state.itemsByID[action.data.project] || {}), - datasets: [...((state.itemsByID[action.data.project] || {}).datasets || []), action.data], + [projectID]: { + ...(state.itemsByID[projectID] || {}), + datasets: [...(state.itemsByID[projectID]?.datasets || []), newDataset], + }, + }, + datasetsByID: { + ...state.datasetsByID, + [newDataset.identifier]: action.data, + }, + }; + } + + + // 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: @@ -182,31 +211,6 @@ export const projects = ( return {...state, isSavingDataset: false}; - 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), - }, - }, - }; - } - - case DELETE_PROJECT_DATASET.FINISH: - return {...state, isDeletingDataset: false}; - // FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES case FETCH_EXTRA_PROPERTIES_SCHEMA_TYPES.REQUEST: return {...state, isFetchingExtraPropertiesSchemaTypes: true}; @@ -271,109 +275,6 @@ export const projects = ( }; -export const projectTables = ( - state = { - isFetching: false, - isFetchingAll: false, - isAdding: false, - isDeleting: false, - items: [], - itemsByProjectID: {}, - }, - action, -) => { - switch (action.type) { - case CREATE_PROJECT.RECEIVE: - // TODO: Might want to re-fetch upon project creation instead... - return { - ...state, - itemsByProjectID: { - ...state.itemsByProjectID, - [action.data.id]: [], - }, - }; - - case DELETE_PROJECT.RECEIVE: - return { - ...state, - items: state.items.filter(t => t.project_id !== action.project.identifier), - itemsByProjectID: objectWithoutProp(state.itemsByProjectID, action.project.identifier), - }; - - case FETCH_PROJECT_TABLES.REQUEST: - return {...state, isFetching: true}; - - case FETCH_PROJECT_TABLES.RECEIVE: - return { - ...state, - isFetching: false, - items: [ - ...state.items, - ...action.data - .map(t => ({ - ...t, - project_id: (Object.entries(action.projectsByID) - .filter(([_, project]) => project.datasets.map(d => d.identifier) - .includes(t.dataset))[0] || [])[0] || null, - })) - .filter(t => t.project_id !== null && !state.items.map(t => t.table_id).includes(t.table_id)), - ], - itemsByProjectID: { // TODO: Improve performance by maybe returning project ID on server side? - ...state.itemsByProjectID, - ...Object.fromEntries(Object.entries(action.projectsByID).map(([projectID, project]) => - [projectID, action.data.filter(t => project.datasets - .map(d => d.identifier) - .includes(t.dataset))], - )), - }, - }; - - case FETCH_PROJECT_TABLES.FINISH: - return {...state, isFetching: false}; - - case PROJECT_TABLE_ADDITION.BEGIN: - return {...state, isAdding: true}; - - case PROJECT_TABLE_ADDITION.END: - // TODO - return { - ...state, - isAdding: false, - items: [...state.items, action.table], - itemsByProjectID: { - ...state.itemsByProjectID, - [action.project.identifier]: [...(state.itemsByProjectID[action.project.identifier] || []), - action.table], - }, - }; - - case PROJECT_TABLE_ADDITION.TERMINATE: - return {...state, isAdding: false}; - - case PROJECT_TABLE_DELETION.BEGIN: - return {...state, isDeleting: true}; - - case PROJECT_TABLE_DELETION.END: - return { - ...state, - isDeleting: false, - items: state.items.filter(t => t.table_id !== action.tableID), - itemsByProjectID: { - ...state.itemsByProjectID, - [action.project.identifier]: (state.itemsByProjectID[action.project.identifier] || []) - .filter(t => t.id !== action.tableID), - }, - }; - - case PROJECT_TABLE_DELETION.TERMINATE: - return {...state, isDeleting: false}; - - default: - return state; - } -}; - - export const biosamples = ( state = { itemsByID: {}, diff --git a/src/modules/services/actions.js b/src/modules/services/actions.js index b1ddae8dc..556a0e336 100644 --- a/src/modules/services/actions.js +++ b/src/modules/services/actions.js @@ -10,15 +10,10 @@ import { terminateFlow, } from "../../utils/actions"; -import {createURLSearchParams} from "../../utils/requests"; - - /** * @typedef {Object} BentoService * @property {string} artifact * @property {string} url - * @property {boolean} data_service - * @property {?boolean} manageable_tables */ @@ -30,32 +25,10 @@ export const FETCH_SERVICES = createNetworkActionTypes("FETCH_SERVICES"); export const FETCH_SERVICE_DATA_TYPES = createNetworkActionTypes("FETCH_SERVICE_DATA_TYPES"); export const LOADING_SERVICE_DATA_TYPES = createFlowActionTypes("LOADING_SERVICE_DATA_TYPES"); -export const FETCH_SERVICE_TABLES = createNetworkActionTypes("FETCH_SERVICE_TABLES"); -export const LOADING_SERVICE_TABLES = createFlowActionTypes("LOADING_SERVICE_TABLES"); - -export const ADDING_SERVICE_TABLE = createFlowActionTypes("ADDING_SERVICE_TABLE"); -export const DELETING_SERVICE_TABLE = createFlowActionTypes("DELETING_SERVICE_TABLE"); - export const FETCH_SERVICE_WORKFLOWS = createNetworkActionTypes("FETCH_SERVICE_WORKFLOWS"); export const LOADING_SERVICE_WORKFLOWS = createFlowActionTypes("LOADING_SERVICE_WORKFLOWS"); -export const endAddingServiceTable = (serviceInfo, dataTypeID, table) => ({ - type: ADDING_SERVICE_TABLE.END, - serviceInfo, - dataTypeID, - table, -}); - - -export const endDeletingServiceTable = (serviceInfo, dataTypeID, tableID) => ({ - type: DELETING_SERVICE_TABLE.END, - serviceInfo, - dataTypeID, - tableID, -}); - - export const fetchBentoServices = networkAction(() => ({ types: FETCH_BENTO_SERVICES, url: `${BENTO_PUBLIC_URL}/api/service-registry/bento-services`, @@ -75,13 +48,6 @@ export const fetchDataServiceDataTypes = networkAction((serviceInfo) => ({ err: `Error fetching data types from service '${serviceInfo.name}'`, })); -export const fetchDataServiceDataTypeTables = networkAction((serviceInfo, dataType) => ({ - types: FETCH_SERVICE_TABLES, - params: {serviceInfo, dataTypeID: dataType.id}, - url: `${serviceInfo.url}/tables?${createURLSearchParams({"data-type": dataType.id}).toString()}`, - err: `Error fetching tables from service '${serviceInfo.name}' (data type ${dataType.id})`, -})); - export const fetchDataServiceWorkflows = networkAction((serviceInfo) => ({ types: FETCH_SERVICE_WORKFLOWS, params: {serviceInfo}, @@ -89,7 +55,7 @@ export const fetchDataServiceWorkflows = networkAction((serviceInfo) => ({ })); -export const fetchServicesWithMetadataAndDataTypesAndTables = (onServiceFetchFinish) => async (dispatch, getState) => { +export const fetchServicesWithMetadataAndDataTypes = (onServiceFetchFinish) => async (dispatch, getState) => { dispatch(beginFlow(LOADING_ALL_SERVICE_DATA)); // Fetch Services @@ -113,7 +79,7 @@ export const fetchServicesWithMetadataAndDataTypesAndTables = (onServiceFetchFin ...s, bentoService: getState().bentoServices.itemsByKind[serviceKind] ?? null, }; - }).filter(s => s.bentoService?.data_service ?? false); + }).filter(s => s.bento?.dataService ?? false); // - Custom stuff to start - explicitly don't wait for this promise to finish since it runs parallel to this flow. if (onServiceFetchFinish) onServiceFetchFinish(); @@ -133,23 +99,15 @@ export const fetchServicesWithMetadataAndDataTypesAndTables = (onServiceFetchFin })(), ]); - // Fetch Data Service Local Tables - // - skip services that don't provide data or don't have data types - dispatch(beginFlow(LOADING_SERVICE_TABLES)); - await Promise.all(dataServicesInfo.flatMap(s => - (getState().serviceDataTypes.dataTypesByServiceID[s.id]?.items ?? []) - .map(dt => dispatch(fetchDataServiceDataTypeTables(s, dt))))); - dispatch(endFlow(LOADING_SERVICE_TABLES)); - dispatch(endFlow(LOADING_ALL_SERVICE_DATA)); }; -export const fetchServicesWithMetadataAndDataTypesAndTablesIfNeeded = (onServiceFetchFinish) => +export const fetchServicesWithMetadataAndDataTypesIfNeeded = (onServiceFetchFinish) => (dispatch, getState) => { const state = getState(); if ((Object.keys(state.bentoServices.itemsByArtifact).length === 0 || state.services.items.length === 0 || Object.keys(state.serviceDataTypes.dataTypesByServiceID).length === 0) && !state.services.isFetchingAll) { - return dispatch(fetchServicesWithMetadataAndDataTypesAndTables(onServiceFetchFinish)); + return dispatch(fetchServicesWithMetadataAndDataTypes(onServiceFetchFinish)); } }; diff --git a/src/modules/services/reducers.js b/src/modules/services/reducers.js index 7198ce73c..ed1f7c810 100644 --- a/src/modules/services/reducers.js +++ b/src/modules/services/reducers.js @@ -1,5 +1,3 @@ -import {objectWithoutProp} from "../../utils/misc"; - import { LOADING_ALL_SERVICE_DATA, @@ -9,12 +7,6 @@ import { FETCH_SERVICE_DATA_TYPES, LOADING_SERVICE_DATA_TYPES, - FETCH_SERVICE_TABLES, - LOADING_SERVICE_TABLES, - - ADDING_SERVICE_TABLE, - DELETING_SERVICE_TABLE, - FETCH_SERVICE_WORKFLOWS, LOADING_SERVICE_WORKFLOWS, } from "./actions"; @@ -241,138 +233,6 @@ export const serviceDataTypes = ( } }; -export const serviceTables = ( - state = { - isFetchingAll: false, - isCreating: false, - isDeleting: false, - items: [], - itemsByServiceID: {}, - }, - action, -) => { - switch (action.type) { - case LOADING_SERVICE_TABLES.BEGIN: - return {...state, isFetchingAll: true}; - - case LOADING_SERVICE_TABLES.END: - case LOADING_SERVICE_TABLES.TERMINATE: - return {...state, isFetchingAll: false}; - - case FETCH_SERVICE_TABLES.REQUEST: { - const {serviceInfo} = action; - return { - ...state, - itemsByServiceID: { - ...state.itemsByServiceID, - [serviceInfo.id]: { - ...(state.itemsByServiceID[serviceInfo.id] ?? {}), - isFetching: true, - }, - }, - }; - } - - case FETCH_SERVICE_TABLES.RECEIVE: { - const {serviceInfo: {id: serviceID}, data, dataTypeID} = action; - - const newTables = data.map(t => ({ - ...t, - service_id: serviceID, - data_type: dataTypeID, - })).filter(t => - !(state.itemsByServiceID[serviceID]?.tablesByID ?? {}).hasOwnProperty(t.id)); - - return { - ...state, - items: [...state.items, ...newTables], - itemsByServiceID: { - ...state.itemsByServiceID, - [serviceID]: { - ...(state.itemsByServiceID[serviceID] ?? {}), - isFetching: false, - tables: [ - ...(state.itemsByServiceID[serviceID]?.tables ?? []), - ...data, - ], - tablesByID: { - ...(state.itemsByServiceID[serviceID]?.tablesByID ?? {}), - ...Object.fromEntries(newTables.map(t => [t.id, t])), - }, - }, - }, - }; - } - - case FETCH_SERVICE_TABLES.ERROR: { - const {serviceInfo: {id: serviceID}} = action; - return { - ...state, - itemsByServiceID: { - ...state.itemsByServiceID, - [serviceID]: { - ...(state.itemsByServiceID[serviceID] ?? {}), - isFetching: false, - }, - }, - }; - } - - case ADDING_SERVICE_TABLE.BEGIN: - return {...state, isCreating: true}; - - case ADDING_SERVICE_TABLE.END: { - const {serviceInfo: {id: serviceID}, table} = action; - return { - ...state, - itemsByServiceID: { - ...state.itemsByServiceID, - [serviceID]: { - ...(state.itemsByServiceID[serviceID] ?? {}), - tables: [...(state.itemsByServiceID[serviceID]?.tables ?? []), table], - tablesByID: { - ...(state.itemsByServiceID[serviceID]?.tablesByID ?? {}), - [table.id]: table, - }, - }, - }, - }; - } - - case ADDING_SERVICE_TABLE.TERMINATE: - return {...state, isCreating: false}; - - case DELETING_SERVICE_TABLE.BEGIN: - return {...state, isDeleting: true}; - - case DELETING_SERVICE_TABLE.END: { - const {serviceInfo: {id: serviceID}, tableID} = action; - return { - ...state, - isDeleting: false, - itemsByServiceID: { - ...state.itemsByServiceID, - [serviceID]: { - ...(state.itemsByServiceID[serviceID] ?? {}), - tables: (state.itemsByServiceID[serviceID]?.tables ?? []) - .filter(t => t.id !== tableID), - tablesByID: objectWithoutProp( - (state.itemsByServiceID[serviceID]?.tablesByID ?? {}), - tableID, - ), - }, - }, - }; - } - - case DELETING_SERVICE_TABLE.TERMINATE: - return {...state, isDeleting: false}; - - default: - return state; - } -}; - export const serviceWorkflows = ( state = { isFetchingAll: false, diff --git a/src/modules/tables/actions.js b/src/modules/tables/actions.js deleted file mode 100644 index f9c2e23ea..000000000 --- a/src/modules/tables/actions.js +++ /dev/null @@ -1,14 +0,0 @@ -import {createNetworkActionTypes, networkAction} from "../../utils/actions"; - -export const FETCH_TABLE_SUMMARY = createNetworkActionTypes("FETCH_TABLE_SUMMARY"); - -const fetchTableSummary = networkAction((serviceInfo, tableID) => ({ - types: FETCH_TABLE_SUMMARY, - params: {serviceInfo, tableID}, - url: `${serviceInfo.url}/tables/${tableID}/summary`, // TODO: Private... -})); - -export const fetchTableSummaryIfPossible = (serviceInfo, tableID) => (dispatch, getState) => { - if (getState().tableSummaries.isFetching) return; - return dispatch(fetchTableSummary(serviceInfo, tableID)); -}; diff --git a/src/modules/tables/reducers.js b/src/modules/tables/reducers.js deleted file mode 100644 index 52ac1005e..000000000 --- a/src/modules/tables/reducers.js +++ /dev/null @@ -1,31 +0,0 @@ -import {FETCH_TABLE_SUMMARY} from "./actions"; - -export const tableSummaries = ( - state = { - isFetching: false, - summariesByServiceArtifactAndTableID: {}, - }, - action, -) => { - switch (action.type) { - case FETCH_TABLE_SUMMARY.REQUEST: - return {...state, isFetching: true}; - case FETCH_TABLE_SUMMARY.RECEIVE: { - const {serviceInfo: {type: {artifact}}, tableID, data} = action; - return { - ...state, - summariesByServiceArtifactAndTableID: { - ...state.summariesByServiceArtifactAndTableID, - [artifact]: { - ...(state.summariesByServiceArtifactAndTableID[artifact] || {}), - [tableID]: data, - }, - }, - }; - } - case FETCH_TABLE_SUMMARY.FINISH: - return {...state, isFetching: false}; - default: - return state; - } -}; diff --git a/src/modules/wes/actions.js b/src/modules/wes/actions.js index 62997622a..a257aac2c 100644 --- a/src/modules/wes/actions.js +++ b/src/modules/wes/actions.js @@ -128,23 +128,26 @@ export const submitWorkflowRun = networkAction( }); -export const submitIngestionWorkflowRun = (serviceInfo, tableID, workflow, inputs, redirect, hist) => (dispatch) => - dispatch(submitWorkflowRun( - SUBMIT_INGESTION_RUN, - serviceInfo, - workflow, - {tableID}, // params - inputs, - { // tags - ingestion_url: `${serviceInfo.url}/private/ingest`, - table_id: tableID, // TODO - }, - run => { // onSuccess - message.success(`Ingestion with run ID "${run.run_id}" submitted!`); - if (redirect) hist.push(redirect); - }, - "Error submitting ingestion workflow", // errorMessage - )); +export const submitIngestionWorkflowRun = + (serviceInfo, projectID, datasetID, dataType, workflow, inputs, redirect, hist) => + (dispatch) => + dispatch(submitWorkflowRun( + SUBMIT_INGESTION_RUN, + serviceInfo, + workflow, + {projectID, datasetID, dataType}, // params + inputs, + { // tags + project_id: projectID, + dataset_id: datasetID, + data_type: dataType, + }, + run => { // onSuccess + message.success(`Ingestion with run ID "${run.run_id}" submitted!`); + if (redirect) hist.push(redirect); + }, + "Error submitting ingestion workflow", // errorMessage + )); export const submitAnalysisWorkflowRun = (serviceInfo, workflow, inputs, redirect, hist) => (dispatch) => diff --git a/src/propTypes.js b/src/propTypes.js index e9f5b3225..45f33c717 100644 --- a/src/propTypes.js +++ b/src/propTypes.js @@ -27,6 +27,7 @@ export const serviceInfoPropTypesShape = PropTypes.shape({ git_branch: PropTypes.string, bento: PropTypes.shape({ serviceKind: PropTypes.string, + dataService: PropTypes.bool, gitTag: PropTypes.string, gitBranch: PropTypes.string, gitCommit: PropTypes.string, @@ -37,8 +38,6 @@ export const bentoServicePropTypesMixin = { service_kind: PropTypes.string, artifact: PropTypes.string, repository: PropTypes.string, - data_service: PropTypes.bool, - manageable_tables: PropTypes.bool, disabled: PropTypes.bool, url_template: PropTypes.string, url: PropTypes.string, @@ -147,6 +146,13 @@ export const runPropTypesShape = PropTypes.shape({ // Prop types object shape for a single table summary object. export const summaryPropTypesShape = PropTypes.object; +// Prop types object shape describing the target of a workflow (project, dataset and data-type) +export const workflowTarget = PropTypes.shape({ + selectedProject: PropTypes.string, + selectedDataset: PropTypes.string, + selectedDataType: PropTypes.string, +}); + // Gives components which include this in their state to props connection access to workflows and loading status. export const workflowsStateToPropsMixin = state => { const workflowsByType = { diff --git a/src/reducers.js b/src/reducers.js index 57a2ea7f9..c5a25689b 100644 --- a/src/reducers.js +++ b/src/reducers.js @@ -6,7 +6,6 @@ import {discovery} from "./modules/discovery/reducers"; import {explorer} from "./modules/explorer/reducers"; import { projects, - projectTables, biosamples, individuals, @@ -19,10 +18,9 @@ import { bentoServices, services, serviceDataTypes, - serviceTables, serviceWorkflows, } from "./modules/services/reducers"; -import {tableSummaries} from "./modules/tables/reducers"; +import {datasetDataTypes, datasetSummaries} from "./modules/datasets/reducers"; import {runs} from "./modules/wes/reducers"; const rootReducer = combineReducers({ @@ -41,7 +39,6 @@ const rootReducer = combineReducers({ // Metadata module projects, - projectTables, biosamples, individuals, @@ -58,11 +55,11 @@ const rootReducer = combineReducers({ bentoServices, services, serviceDataTypes, - serviceTables, serviceWorkflows, - // Table module - tableSummaries, + // Dataset module + datasetDataTypes, + datasetSummaries, // WES module runs,