diff --git a/dashboard/src/actions/metadataTreeActions.js b/dashboard/src/actions/metadataTreeActions.js new file mode 100644 index 0000000000..2c294ffb1a --- /dev/null +++ b/dashboard/src/actions/metadataTreeActions.js @@ -0,0 +1,96 @@ +import * as TYPES from "./types"; + +import store from "store/store"; + +const { getState } = store; + +// Helper functions +const isChecked = (dataItem, checkedItems) => + checkedItems && checkedItems.some((item) => item === dataItem.key); +const setChildNodes = (childNodes, isChecked) => { + childNodes.forEach(function iter(a) { + a.checkProps.checked = isChecked; + Array.isArray(a.children) && a.children.forEach(iter); + }); +}; +const getCheckedItemsKey = (ary) => + ary.reduce( + (a, b) => + a.concat(b.key, "children" in b ? getCheckedItemsKey(b.children) : []), + [] + ); +const updateChildKeysList = (checked, checkedItems, childKeys) => + checked + ? [...checkedItems, ...childKeys] + : checkedItems.filter((x) => !childKeys.includes(x)); + +export const mapTree = (item) => { + const retVal = { ...item }; + if (item.children) { + retVal.children = item.children.map((child) => mapTree(child)); + item.checkProps.checked = null; + let seen = undefined; + for (const c of item.children) { + if (c.checkProps.checked == null) { + return retVal; + } else if (seen === undefined) { + seen = c.checkProps.checked; + } else if (seen !== c.checkProps.checked) return retVal; + } + item.checkProps.checked = seen; + } else { + const checkedItems = getState().overview.checkedItems; + item.checkProps.checked = isChecked(item, checkedItems); + } + + return retVal; +}; + +export const onCheck = + (evt, treeViewItem, dataType) => async (dispatch, getState) => { + const checked = evt.target.checked; + const treeData = [...getState().overview.treeData]; + let checkedItems = getState().overview.checkedItems; + const { options } = treeData.find((item) => item.title === dataType); + if ("children" in treeViewItem) { + const childNodes = treeViewItem.children; + const childKeys = getCheckedItemsKey(childNodes); + + setChildNodes(childNodes, checked); + treeViewItem.checkProps.checked = checked; + + checkedItems = updateChildKeysList(checked, checkedItems, childKeys); + } else if ("children" in options[0]) { + // if first child + const childNodes = options[0]["children"]; + const node = childNodes.find((item) => item.key === treeViewItem.key); + node.checkProps.checked = checked; + // if only child of the parent, push the parent + if (childNodes.length === 1) { + options[0].checkProps.checked = checked; + + checkedItems = updateChildKeysList( + checked, + checkedItems, + options[0].key + ); + } + } else { + // leaf node + const node = options.find((item) => treeViewItem.key.includes(item.key)); + node.checkProps.checked = checked; + } + if (checked) { + checkedItems = [...checkedItems, treeViewItem.key]; + } else { + checkedItems = checkedItems.filter((item) => item !== treeViewItem.key); + } + dispatch({ + type: TYPES.SET_TREE_DATA, + payload: treeData, + }); + dispatch({ + type: TYPES.SET_METADATA_CHECKED_KEYS, + payload: checkedItems, + }); + }; diff --git a/dashboard/src/actions/overviewActions.js b/dashboard/src/actions/overviewActions.js index 7185a7b5c3..b09d438fdc 100644 --- a/dashboard/src/actions/overviewActions.js +++ b/dashboard/src/actions/overviewActions.js @@ -23,6 +23,10 @@ export const getDatasets = () => async (dispatch, getState) => { params.append("metadata", CONSTANTS.DATASET_UPLOADED); params.append("metadata", CONSTANTS.SERVER_DELETION); params.append("metadata", CONSTANTS.USER_FAVORITE); + params.append("metadata", "server"); + params.append("metadata", "dataset"); + params.append("metadata", "global"); + params.append("metadata", "user"); params.append("mine", "true"); dispatch(setSelectedRuns([])); @@ -38,7 +42,6 @@ export const getDatasets = () => async (dispatch, getState) => { type: TYPES.USER_RUNS, payload: data, }); - dispatch(initializeRuns()); } } @@ -70,7 +73,6 @@ const initializeRuns = () => (dispatch, getState) => { clearEditableFields(item); item[CONSTANTS.NAME_VALIDATED] = CONSTANTS.SUCCESS; - item[CONSTANTS.IS_ITEM_SEEN] = !!item?.metadata?.[CONSTANTS.DASHBOARD_SEEN]; item[CONSTANTS.IS_ITEM_FAVORITED] = !!item?.metadata?.[CONSTANTS.USER_FAVORITE]; @@ -147,8 +149,16 @@ export const updateDataset = ); for (const key in response.data.metadata) { - if (key in item.metadata) + if (key in item.metadata) { item.metadata[key] = response.data.metadata[key]; + if (checkNestedPath(key, item.metadata)) { + item.metadata = setValueFromPath( + key, + item.metadata, + response.data.metadata[key] + ); + } + } } dispatch({ type: TYPES.USER_RUNS, @@ -407,3 +417,156 @@ const clearEditableFields = (item) => { item[CONSTANTS.IS_DIRTY_NAME] = false; item[CONSTANTS.IS_DIRTY_SERVER_DELETE] = false; }; +export const setMetadataModal = (isOpen) => ({ + type: TYPES.SET_METADATA_MODAL, + payload: isOpen, +}); +/** + * Function to get keySummary and send to parse data + * @function + * @param {Function} dispatch - dispatch method of redux store + * @param {Function} getState - getstate method of redux store + * @return {Function} - dispatch the action and update the state + */ +export const getKeySummary = async (dispatch, getState) => { + try { + const endpoints = getState().apiEndpoint.endpoints; + const response = await API.get(uriTemplate(endpoints, "datasets_list"), { + params: { keysummary: true }, + }); + if (response.status === 200) { + if (response.data.keys) { + dispatch(parseKeySummaryforTree(response.data.keys)); + } + } + } catch (error) { + dispatch(showToast(DANGER, ERROR_MSG)); + } +}; +/** + * Function to parse keySummary for the Tree View with checkboxes + * @function + * @param {Object} keySummary - dataset key summary + * @return {Function} - dispatch the action and update the state + */ +export const parseKeySummaryforTree = (keySummary) => (dispatch, getState) => { + const parsedData = []; + + const checkedItems = [...getState().overview.checkedItems]; + + for (const [item, subitem] of Object.entries(keySummary)) { + const dataObj = { title: item, options: [] }; + + for (const [key, value] of Object.entries(subitem)) { + const aggregateKey = `${item}${CONSTANTS.KEYS_JOIN_BY}${key}`; + if (!isServerInternal(aggregateKey)) { + const isChecked = checkedItems.includes(aggregateKey); + const obj = constructTreeObj(aggregateKey, isChecked); + if (value) { + // has children + obj["children"] = constructChildTreeObj( + aggregateKey, + value, + checkedItems + ); + } + dataObj.options.push(obj); + } + } + parsedData.push(dataObj); + } + dispatch({ + type: TYPES.SET_METADATA_CHECKED_KEYS, + payload: checkedItems, + }); + dispatch({ + type: TYPES.SET_TREE_DATA, + payload: parsedData, + }); +}; + +const constructChildTreeObj = (aggregateKey, entity, checkedItems) => { + const childObj = []; + for (const item in entity) { + if (!isServerInternal(`${aggregateKey}${CONSTANTS.KEYS_JOIN_BY}${item}`)) { + const newKey = `${aggregateKey}${CONSTANTS.KEYS_JOIN_BY}${item}`; + const isParentChecked = checkedItems.includes(aggregateKey); + + const isChecked = isParentChecked || checkedItems.includes(newKey); + if (isParentChecked && !checkedItems.includes(newKey)) { + checkedItems.push(newKey); + } + const obj = constructTreeObj(newKey, isChecked); + + if (entity[item]) { + obj["children"] = constructChildTreeObj( + newKey, + entity[item], + checkedItems + ); + } + childObj.push(obj); + } + } + return childObj; +}; + +const constructTreeObj = (aggregateKey, isChecked) => ({ + name: aggregateKey.split(CONSTANTS.KEYS_JOIN_BY).pop(), + key: aggregateKey, + id: aggregateKey, + checkProps: { + "aria-label": `${aggregateKey}-check`, + checked: isChecked, + }, +}); + +const nonEssentialKeys = [ + CONSTANTS.KEY_INDEX_REGEX, + CONSTANTS.KEY_OPERATIONS_REGEX, + CONSTANTS.KEY_TOOLS_REGEX, + CONSTANTS.KEY_ITERATIONS_REGEX, + CONSTANTS.KEY_TARBALL_PATH_REGEX, +]; + +const isServerInternal = (string) => + nonEssentialKeys.some((e) => string.match(e)); + +/** + * Function to update metadata + * @function + * @param {String} path - nested key to update + * @param {Object} obj - nested object + * @param {String} obj - new value to be updated in the object + * @return {Object} - updated object with new value + */ + +const setValueFromPath = (path, obj, value) => { + const [head, ...rest] = path.split("."); + + return { + ...obj, + [head]: rest.length + ? setValueFromPath(rest.join("."), obj[head], value) + : value, + }; +}; + +/** + * Function to check if the nested object has the given path of key + * @function + * @param {String} path - path of key + * @param {Object} obj - nested object + * @return {Boolean} - true/false if the object has/not the key + */ + +const checkNestedPath = function (path, obj = {}) { + const args = path.split("."); + for (let i = 0; i < args.length; i++) { + if (!obj || !obj.hasOwnProperty(args[i])) { + return false; + } + obj = obj[args[i]]; + } + return true; +}; diff --git a/dashboard/src/actions/types.js b/dashboard/src/actions/types.js index 67080e4323..454e231268 100644 --- a/dashboard/src/actions/types.js +++ b/dashboard/src/actions/types.js @@ -42,6 +42,9 @@ export const SET_LOADING_FLAG = "SET_LOADING_FLAG"; export const SELECTED_SAVED_RUNS = "SELECTED_SAVED_RUNS"; export const TOGGLE_RELAY_MODAL = "TOGGLE_RELAY_MODAL"; export const SET_RELAY_DATA = "SET_RELAY_DATA"; +export const SET_METADATA_MODAL = "SET_METADATA_MODAL"; +export const SET_TREE_DATA = "SET_TREE_DATA"; +export const SET_METADATA_CHECKED_KEYS = "SET_METADATA_CHECKED_KEYS"; /* TABLE OF CONTENT */ export const GET_TOC_DATA = "GET_TOC_DATA"; diff --git a/dashboard/src/assets/constants/overviewConstants.js b/dashboard/src/assets/constants/overviewConstants.js index b96111c873..b489610023 100644 --- a/dashboard/src/assets/constants/overviewConstants.js +++ b/dashboard/src/assets/constants/overviewConstants.js @@ -18,6 +18,12 @@ export const IS_DIRTY_SERVER_DELETE = "isDirtyDate"; export const IS_EDIT = "isEdit"; export const IS_ITEM_FAVORITED = "isItemFavorited"; export const IS_ITEM_SEEN = "isItemSeen"; +export const KEYS_JOIN_BY = "*"; +export const KEY_INDEX_REGEX = /^server[.*]index-map$/; +export const KEY_ITERATIONS_REGEX = /^dataset[.*]metalog[.*]iterations/; +export const KEY_OPERATIONS_REGEX = /^dataset[.*]operations$/; +export const KEY_TOOLS_REGEX = /^dataset[.*]metalog[.*]tools/; +export const KEY_TARBALL_PATH_REGEX = /^server[.*]tarball-path$/; export const NAME_COPY = "name_copy"; export const NAME_ERROR_MSG = "name_errorMsg"; export const NAME_KEY = "name"; diff --git a/dashboard/src/modules/components/OverviewComponent/MetadataTreeComponent.jsx b/dashboard/src/modules/components/OverviewComponent/MetadataTreeComponent.jsx new file mode 100644 index 0000000000..87d26db30c --- /dev/null +++ b/dashboard/src/modules/components/OverviewComponent/MetadataTreeComponent.jsx @@ -0,0 +1,38 @@ +import "./index.less"; + +import { mapTree, onCheck } from "actions/metadataTreeActions"; +import { useDispatch, useSelector } from "react-redux"; + +import React from "react"; +import { TreeView } from "@patternfly/react-core"; + +const MetadataTreeView = () => { + const { treeData } = useSelector((state) => state.overview); + const dispatch = useDispatch(); + const onCheckHandler = (evt, treeViewItem, dataType) => { + dispatch(onCheck(evt, treeViewItem, dataType)); + }; + return ( +
+ {treeData && + treeData.length > 0 && + treeData.map((item) => { + const mapped = item.options.map((item) => mapTree(item)); + return ( +
+

{item.title}

+ + onCheckHandler(evt, treeItem, item.title) + } + /> +
+ ); + })} +
+ ); +}; + +export default MetadataTreeView; diff --git a/dashboard/src/modules/components/OverviewComponent/NewRunsComponent.jsx b/dashboard/src/modules/components/OverviewComponent/NewRunsComponent.jsx index 3e8f2e5d0f..5e6d7f5690 100644 --- a/dashboard/src/modules/components/OverviewComponent/NewRunsComponent.jsx +++ b/dashboard/src/modules/components/OverviewComponent/NewRunsComponent.jsx @@ -9,15 +9,22 @@ import { USER_FAVORITE, } from "assets/constants/overviewConstants"; import { + ExpandableRowContent, InnerScrollContainer, OuterScrollContainer, TableComposable, Tbody, + Td, Th, Thead, Tr, } from "@patternfly/react-table"; -import { NewRunsRow, RenderPagination } from "./common-component"; +import { + MetaDataModal, + MetadataRow, + NewRunsRow, + RenderPagination, +} from "./common-component"; import React, { useCallback, useState } from "react"; import { deleteDataset, @@ -29,11 +36,17 @@ import { } from "actions/overviewActions"; import { useDispatch, useSelector } from "react-redux"; +import { uid } from "utils/helper"; + const NewRunsComponent = () => { const dispatch = useDispatch(); - const { newRuns, initNewRuns, selectedRuns } = useSelector( - (state) => state.overview - ); + const { + newRuns, + initNewRuns, + selectedRuns, + isMetadataModalOpen, + checkedItems, + } = useSelector((state) => state.overview); const [perPage, setPerPage] = useState(ROWS_PER_PAGE); const [page, setPage] = useState(START_PAGE_NUMBER); @@ -126,6 +139,7 @@ const NewRunsComponent = () => { ? [...otherExpandedRunNames, run.name] : otherExpandedRunNames; }); + const isRunExpanded = useCallback( (run) => expandedRunNames.includes(run.name), [expandedRunNames] @@ -167,29 +181,46 @@ const NewRunsComponent = () => { {initNewRuns.map((item, rowIndex) => { const rowActions = moreActionItems(item); return ( - - - updateTblValue(val, NAME_KEY, item.resource_id) - } - /> - + <> + + + updateTblValue(val, NAME_KEY, item.resource_id) + } + /> + + {checkedItems && checkedItems.length > 0 ? ( + + + +
+ +
+
+ + + ) : null} + ); })} @@ -206,6 +237,7 @@ const NewRunsComponent = () => { /> + {isMetadataModalOpen && } ); }; diff --git a/dashboard/src/modules/components/OverviewComponent/common-component.jsx b/dashboard/src/modules/components/OverviewComponent/common-component.jsx index 409abebd8d..75dffa9e07 100644 --- a/dashboard/src/modules/components/OverviewComponent/common-component.jsx +++ b/dashboard/src/modules/components/OverviewComponent/common-component.jsx @@ -2,13 +2,23 @@ import "./index.less"; import * as CONSTANTS from "assets/constants/overviewConstants"; -import { ActionsColumn, Td } from "@patternfly/react-table"; +import { + ActionsColumn, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@patternfly/react-table"; import { Button, DatePicker, Dropdown, DropdownItem, DropdownToggle, + Modal, + ModalVariant, Pagination, Text, TextContent, @@ -19,6 +29,7 @@ import { CaretDownIcon, CheckIcon, PencilAltIcon, + PficonTemplateIcon, RedoIcon, TimesIcon, } from "@patternfly/react-icons"; @@ -29,11 +40,17 @@ import { SERVER_DELETION, } from "assets/constants/overviewConstants"; import React, { useState } from "react"; -import { getDatasets, updateMultipleDataset } from "actions/overviewActions"; +import { + getDatasets, + setMetadataModal, + updateMultipleDataset, +} from "actions/overviewActions"; import { useDispatch, useSelector } from "react-redux"; +import MetadataTreeView from "./MetadataTreeComponent"; import { formatDateTime } from "utils/dateFunctions"; import { setRelayModalState } from "actions/relayActions"; +import { uid } from "utils/helper"; export const Heading = (props) => { return ( @@ -138,6 +155,13 @@ export const NewRunsHeading = () => { > Pull dataset + ( ); + +export const MetaDataModal = () => { + const { isMetadataModalOpen } = useSelector((state) => state.overview); + const dispatch = useDispatch(); + const handleModalToggle = () => { + dispatch(setMetadataModal(false)); + }; + return ( + + + + ); +}; + +export const MetadataRow = (props) => { + const { checkedItems, item } = props; + const columnNames = { + key: "Metadata", + value: "Value", + }; + return ( + + + + + + + {checkedItems.map((attr) => { + const levels = attr.split(CONSTANTS.KEYS_JOIN_BY); + const showKey = attr.split(CONSTANTS.KEYS_JOIN_BY).join("."); + const showValue = levels.reduce( + (a, level) => a?.[level] ?? "", + item.metadata + ); + return ( + <> + {showValue !== "" && showValue.constructor !== Object && ( + + + + + )} + + ); + })} + +
+ {columnNames.key} + {columnNames.value}
{showKey}{showValue.toString()}
+ ); +}; diff --git a/dashboard/src/modules/components/OverviewComponent/index.jsx b/dashboard/src/modules/components/OverviewComponent/index.jsx index 961d50047f..b18468676c 100644 --- a/dashboard/src/modules/components/OverviewComponent/index.jsx +++ b/dashboard/src/modules/components/OverviewComponent/index.jsx @@ -17,6 +17,7 @@ import { Separator, } from "./common-component"; import React, { useEffect } from "react"; +import { getDatasets, getKeySummary } from "actions/overviewActions"; import { useDispatch, useSelector } from "react-redux"; import { EmptyTable } from "../TableComponent/common-components"; @@ -24,7 +25,6 @@ import ExpiringSoonComponent from "./ExpiringSoonComponent"; import NewRunsComponent from "./NewRunsComponent"; import RelayComponent from "modules/components/RelayUIComponent"; import SavedRunsComponent from "./SavedRunsComponent"; -import { getDatasets } from "actions/overviewActions"; const OverviewComponent = () => { const dispatch = useDispatch(); @@ -38,6 +38,7 @@ const OverviewComponent = () => { useEffect(() => { if (Object.keys(endpoints).length > 0) { dispatch(getDatasets()); + dispatch(getKeySummary); } }, [dispatch, endpoints]); @@ -84,7 +85,7 @@ const OverviewComponent = () => { - + { diff --git a/dashboard/src/modules/components/OverviewComponent/index.less b/dashboard/src/modules/components/OverviewComponent/index.less index a6efde36a5..9d4004b430 100644 --- a/dashboard/src/modules/components/OverviewComponent/index.less +++ b/dashboard/src/modules/components/OverviewComponent/index.less @@ -15,12 +15,6 @@ button { padding: 0; } - .no-runs-wrapper { - color: #6a6e73; - } - } - .saved-runs-container.not-expanded { - height: 80%; } .saved-runs-container { padding: 2vh; @@ -50,35 +44,52 @@ } .newruns-table-container { height: 90%; - .pf-c-scroll-outer-wrapper { min-height: 100%; } + .unseen-row { + background-color: #efefef; + } + .keyClass { + padding-right: 5px; + } + .box { + thead { + border-bottom: 1px solid #d2d2d2; + } + th { + border-top: none; + } + } } .pf-c-pagination { padding: 0; } } - .unseen-row { - background-color: #efefef; + + .separator { + margin: 3vh 0; } -} -.separator { - margin: 3vh 0; -} -.dashboard-loading-container { - display: grid; - place-content: center; + .dashboard-loading-container { + display: grid; + place-content: center; - svg { - text-align: center; - margin: 0 auto; + svg { + text-align: center; + margin: 0 auto; + } + .heading-h2 { + text-align: center; + font-weight: 700; + margin: 2vh 0; + font-size: 1.2rem; + } } - .heading-h2 { - text-align: center; - font-weight: 700; - margin: 2vh 0; - font-size: 1.2rem; +} +.treeview-container { + text-transform: capitalize; + .title { + font-weight: bold; } } diff --git a/dashboard/src/reducers/overviewReducer.js b/dashboard/src/reducers/overviewReducer.js index 35fd537b19..27d31f9788 100644 --- a/dashboard/src/reducers/overviewReducer.js +++ b/dashboard/src/reducers/overviewReducer.js @@ -1,5 +1,4 @@ import * as TYPES from "../actions/types"; - const initialState = { datasets: [], savedRuns: [], @@ -9,9 +8,12 @@ const initialState = { selectedRuns: [], selectedSavedRuns: [], expiringRuns: [], + isMetadataModalOpen: false, loadingDone: !!sessionStorage.getItem("loadingDone"), isRelayModalOpen: false, relayInput: "", + treeData: [], + checkedItems: ["dataset*access", "dataset*metalog*run"], }; const OverviewReducer = (state = initialState, action = {}) => { @@ -72,6 +74,21 @@ const OverviewReducer = (state = initialState, action = {}) => { ...state, relayInput: payload, }; + case TYPES.SET_METADATA_MODAL: + return { + ...state, + isMetadataModalOpen: payload, + }; + case TYPES.SET_METADATA_CHECKED_KEYS: + return { + ...state, + checkedItems: payload, + }; + case TYPES.SET_TREE_DATA: + return { + ...state, + treeData: payload, + }; default: return state; } diff --git a/dashboard/src/utils/helper.js b/dashboard/src/utils/helper.js index e6adc7a165..693d2efccd 100644 --- a/dashboard/src/utils/helper.js +++ b/dashboard/src/utils/helper.js @@ -19,3 +19,11 @@ export const uriTemplate = (endpoints, name, args = {}) => { endpoints.uri[name].template ); }; + +export const isEmptyObject = (obj) => { + return ( + obj && + Object.keys(obj).length === 0 && + Object.getPrototypeOf(obj) === Object.prototype + ); +};