diff --git a/app/controllers/vm_common.rb b/app/controllers/vm_common.rb index 1fa5c734741..9700e57d152 100644 --- a/app/controllers/vm_common.rb +++ b/app/controllers/vm_common.rb @@ -298,15 +298,14 @@ def snap_pressed @active = @snap_selected.current? if @snap_selected @center_toolbar = 'x_vm_snapshot' @explorer = true - render :update do |page| - page << javascript_prologue - page << javascript_reload_toolbars - - page.replace("flash_msg_div", :partial => "layouts/flash_msg") - page << "miqScrollTop();" if @flash_array.present? - page.replace("desc_content", :partial => "/vm_common/snapshots_desc", - :locals => {:selected => params[:id]}) + formatted_time = format_timezone(@snap_selected[:create_time].to_time, Time.zone, "view") + number_to_human_size(@snap_selected[:total_size], :precision => 2) + if @snap_selected[:total_size] == nil || @snap_selected[:total_size] == 0 + formatted_size = '' + else + formatted_size = _("%{number} bytes") % {:number => number_with_delimiter(@snap_selected[:total_size], :delimiter => ",", :separator => ".")} end + render :json => {:data => {:data => @snap_selected, :size => formatted_size, :time => formatted_time}}, :status => 200 end def disks diff --git a/app/javascript/components/vm-snapshot-tree-select/index.jsx b/app/javascript/components/vm-snapshot-tree-select/index.jsx new file mode 100644 index 00000000000..e8233066dff --- /dev/null +++ b/app/javascript/components/vm-snapshot-tree-select/index.jsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import './styles.css'; +import SnapshotTree from './snapshot-tree'; + +const VMSnapshotTreeSelect = ({ + tree, selected, size, time, +}) => { + const [snapshot, setSnapshot] = useState({ ...selected, size, time }); + + // eslint-disable-next-line react/prop-types + return ( +
+
+
+
+

+ + {__('Description')} + +

+
+
+ {snapshot.data ? snapshot.data.description : snapshot.description || ''} +
+
+
+
+

+ + {__('Size')} + +

+
+
+ {snapshot.size || ''} +
+
+
+
+

+ + {__('Created')} + +

+
+
+ {snapshot.time || ''} +
+
+
+ +
+ ); +}; + +VMSnapshotTreeSelect.propTypes = { + tree: PropTypes.objectOf(PropTypes.any).isRequired, + selected: PropTypes.objectOf(PropTypes.any), + size: PropTypes.string, + time: PropTypes.string, +}; + +VMSnapshotTreeSelect.defaultProps = { + selected: {}, + size: '', + time: '', +}; + +export default VMSnapshotTreeSelect; diff --git a/app/javascript/components/vm-snapshot-tree-select/snapshot-tree.jsx b/app/javascript/components/vm-snapshot-tree-select/snapshot-tree.jsx new file mode 100644 index 00000000000..f321886e111 --- /dev/null +++ b/app/javascript/components/vm-snapshot-tree-select/snapshot-tree.jsx @@ -0,0 +1,174 @@ +import React, { useEffect, useState } from 'react'; +import { + Camera16, ChevronRight16, ChevronDown16, VirtualMachine16, +} from '@carbon/icons-react'; +import TreeView, { flattenTree } from 'react-accessible-treeview'; +import './styles.css'; +import PropTypes from 'prop-types'; + +const allNodeData = []; + +const convertData = (node) => { + allNodeData.push( + { + name: node.text, + id: node.key, + selectable: node.selectable, + tooltip: node.tooltip, + icon: node.icon, + } + ); + const treeData = { + name: node.text, // Use the `text` property as the `name` + id: node.key, // Use the `key` property as the `id` + children: node.nodes ? node.nodes.map(convertData) : [], // Recursively process children + }; + + return treeData; +}; + +const SnapshotTree = ({ nodes, setSnapshot }) => { + const [selectedNode, setSelectedNode] = useState(''); + + const data = { + name: '', + children: nodes.map(convertData), + }; + const treeData = flattenTree(data); + const expandedIds = []; + treeData.forEach((node) => { + expandedIds.push(node.id); + + allNodeData.forEach((nodeData) => { + if (nodeData.id === node.id) { + const metadata = { + selectable: nodeData.selectable || false, + tooltip: nodeData.tooltip || nodeData.name, + icon: nodeData.icon || 'fa fa-camera', + }; + node.metadata = metadata; + } + }); + }); + + const nodeClick = (e, node) => { + if (node.metadata.selectable === false) { + // If the clicked node is already selected or root is selected, do nothing + return; + } + + const ids = node.id.split('-'); + const shortId = ids[ids.length - 1]; + miqSparkleOn(); + http.post(`/${ManageIQ.controller}/snap_pressed/${encodeURIComponent(shortId)}`).then((response) => { + if (response.data) { + const tempData = response.data; + tempData.size = response.data.size; + tempData.time = response.data.time; + setSnapshot(tempData); + } + miqSparkleOff(); + }); + + e.stopPropagation(); + setSelectedNode(e.target.id); + }; + + const addSelectedClassName = () => { + // Remove 'selected' class from all elements + const selectedElements = document.querySelectorAll('.selected-snapshot'); + selectedElements.forEach((el) => { + el.classList.remove('selected-snapshot'); + }); + + const selectedElement = document.getElementById(selectedNode); + if (selectedElement) { + selectedElement.parentNode.classList.add('selected-snapshot'); + } + }; + + useEffect(() => { + addSelectedClassName(); + }, [selectedNode]); + + useEffect(() => { + treeData.forEach((node) => { + if (node.name.includes(__('(Active)'))) { + setSelectedNode(node.id); + } + }); + }, []); + + const ArrowIcon = (isOpen) => { + let icon = ; + if (isOpen && isOpen.isOpen) { + icon = ; + } + return
{icon}
; + }; + + const NodeIcon = (icon) => { + if (icon === 'pficon pficon-virtual-machine') { + return ; + } + return ; + }; + + // First pull in node data and go through flattened tree to add metadata like icons and selectable + // Then add icons, tooltip and special handling + + return ( +
+
+ ( +
+ {isBranch && } + {element.metadata && element.metadata.icon && ( +
+ +
+ )} + nodeClick(e, element)} + onKeyDown={(e) => e.key === 'Enter' && nodeClick(e)} + role="button" + tabIndex={0} + className="name" + > + {element.name} + +
+ )} + /> +
+
+ ); +}; + +SnapshotTree.propTypes = { + nodes: PropTypes.arrayOf(PropTypes.any).isRequired, + setSnapshot: PropTypes.func.isRequired, +}; + +export default SnapshotTree; diff --git a/app/javascript/components/vm-snapshot-tree-select/styles.css b/app/javascript/components/vm-snapshot-tree-select/styles.css new file mode 100644 index 00000000000..803a83edb25 --- /dev/null +++ b/app/javascript/components/vm-snapshot-tree-select/styles.css @@ -0,0 +1,100 @@ + .snapshot-details-div { + .snapshot-details { + margin-left: 30px; + margin-bottom: 20px; + } + + .snapshot-detail-title { + width: 80px; + justify-content: right; + display: inline-flex; + margin-right: 30px; + } + + .snapshot-detail-value { + display: inline-flex; + } + } + + .checkbox { + font-size: 16px; + user-select: none; + min-height: 320px; + padding: 20px; + box-sizing: content-box; + } + + .selected-snapshot { + background-color: #0f62fe; + color: white; + width: 100%; + margin-left: 0px; + pointer-events: none; + + .arrow-div { + pointer-events: all; + } + + span { + pointer-events: all; + } + } + + .arrow-div { + display: inline-flex; + margin-right: 5px; + } + + .node-icon-div { + margin-right: 5px; + display: inline-flex; + } + + .checkbox .tree, + .checkbox .tree-node, + .checkbox .tree-node-group { + list-style: none; + margin: 0; + padding: 0; + } + + .checkbox .tree-branch-wrapper, + .checkbox .tree-node__leaf { + outline: none; + } + + .checkbox .tree-node { + cursor: pointer; + } + + .checkbox .tree-node .name:hover { + background: rgba(0, 0, 0, 0.1); + } + + .checkbox .tree-node--focused .name { + background: rgba(0, 0, 0, 0.2); + } + + .checkbox .tree-node { + display: inline-block; + } + + .checkbox .checkbox-icon { + margin: 0 5px; + vertical-align: middle; + } + + .checkbox button { + border: none; + background: transparent; + cursor: pointer; + } + + .checkbox .arrow { + margin-left: 5px; + vertical-align: middle; + } + + .checkbox .arrow--open { + transform: rotate(90deg); + } diff --git a/app/javascript/packs/component-definitions-common.js b/app/javascript/packs/component-definitions-common.js index 164edaa3905..954d9501658 100644 --- a/app/javascript/packs/component-definitions-common.js +++ b/app/javascript/packs/component-definitions-common.js @@ -163,6 +163,7 @@ import VmFloatingIPsForm from '../components/vm-floating-ips/vm-floating-ips-for import VmResizeForm from '../components/vm-resize-form/vm-resize-form'; import VmServerRelationshipForm from '../components/vm-server-relationship-form'; import VmSnapshotForm from '../components/vm-snapshot-form/vm-snapshot-form'; +import VmSnapshotTreeSelect from '../components/vm-snapshot-tree-select'; import VolumeMappingForm from '../components/volume-mapping-form'; import WidgetChart from '../components/dashboard-widgets/widget-chart'; import WidgetError from '../components/dashboard-widgets/widget-error'; @@ -352,6 +353,7 @@ ManageIQ.component.addReact('VmFloatingIPsForm', VmFloatingIPsForm); ManageIQ.component.addReact('VmResizeForm', VmResizeForm); ManageIQ.component.addReact('VmServerRelationshipForm', VmServerRelationshipForm); ManageIQ.component.addReact('VmSnapshotForm', VmSnapshotForm); +ManageIQ.component.addReact('VmSnapshotTreeSelect', VmSnapshotTreeSelect); ManageIQ.component.addReact('VolumeMappingForm', VolumeMappingForm); ManageIQ.component.addReact('WidgetChart', WidgetChart); ManageIQ.component.addReact('WidgetError', WidgetError); diff --git a/app/javascript/spec/vm-snapshot-tree/__snapshots__/vm-snapshot-tree.spec.js.snap b/app/javascript/spec/vm-snapshot-tree/__snapshots__/vm-snapshot-tree.spec.js.snap new file mode 100644 index 00000000000..f06a1042add --- /dev/null +++ b/app/javascript/spec/vm-snapshot-tree/__snapshots__/vm-snapshot-tree.spec.js.snap @@ -0,0 +1,3588 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VM Snaspthot Tree Select should render snapshot tree 1`] = ` +
+
+ +
+
+`; + +exports[`VM Snaspthot Tree Select should submit select API call 1`] = ` + + +
+
+ +
    + +
    + +
    +
    + +
    + + test-root + +
, + "sn-1":
+
+ +
+
+ +
+ + test-child-1 + +
, + "sn-1_sn-2":
+
+ +
+ + test-child-2 + +
, + }, + } + } + level={1} + multiSelect={false} + nodeAction="check" + nodeRefs={ + Object { + "current": Object { + "root":
  • +
    +
    + +
    +
    + +
    + + test-root + +
    +
      +
    • +
      +
      + +
      +
      + +
      + + test-child-1 + +
      +
        +
      • +
        +
        + +
        + + test-child-2 + +
        +
      • +
      +
    • +
    +
  • , + "sn-1":
  • +
    +
    + +
    +
    + +
    + + test-child-1 + +
    +
      +
    • +
      +
      + +
      + + test-child-2 + +
      +
    • +
    +
  • , + "sn-1_sn-2":
    +
    + +
    + + test-child-2 + +
    , + }, + } + } + nodeRenderer={[Function]} + posinset={1} + propagateCollapse={false} + propagateSelect={false} + propagateSelectUpwards={true} + selectedIds={Set {}} + setsize={1} + state={ + Object { + "controlledIds": Set {}, + "disabledIds": Set {}, + "expandedIds": Set { + 0, + "root", + "sn-1", + "sn-1_sn-2", + }, + "halfSelectedIds": Set {}, + "isFocused": false, + "lastInteractedWith": null, + "lastManuallyToggled": null, + "lastUserSelect": "root", + "selectedIds": Set {}, + "tabbableId": "root", + } + } + tabbableId="root" + togglableSelect={true} + > +
  • +
    + +
    + + + + + + + +
    +
    +
    + + + + + + + + + + +
    + + test-root + +
    + +
    + +
    +
    + +
    + + test-root + +
  • , + "sn-1":
    +
    + +
    +
    + +
    + + test-child-1 + +
    , + "sn-1_sn-2":
    +
    + +
    + + test-child-2 + +
    , + }, + } + } + level={1} + multiSelect={false} + nodeAction="check" + nodeRefs={ + Object { + "current": Object { + "root":
  • +
    +
    + +
    +
    + +
    + + test-root + +
    +
      +
    • +
      +
      + +
      +
      + +
      + + test-child-1 + +
      +
        +
      • +
        +
        + +
        + + test-child-2 + +
        +
      • +
      +
    • +
    +
  • , + "sn-1":
  • +
    +
    + +
    +
    + +
    + + test-child-1 + +
    +
      +
    • +
      +
      + +
      + + test-child-2 + +
      +
    • +
    +
  • , + "sn-1_sn-2":
    +
    + +
    + + test-child-2 + +
    , + }, + } + } + nodeRenderer={[Function]} + propagateCollapse={false} + propagateSelect={false} + propagateSelectUpwards={true} + selectedIds={Set {}} + state={ + Object { + "controlledIds": Set {}, + "disabledIds": Set {}, + "expandedIds": Set { + 0, + "root", + "sn-1", + "sn-1_sn-2", + }, + "halfSelectedIds": Set {}, + "isFocused": false, + "lastInteractedWith": null, + "lastManuallyToggled": null, + "lastUserSelect": "root", + "selectedIds": Set {}, + "tabbableId": "root", + } + } + tabbableId="root" + togglableSelect={true} + > +
      + +
      + +
      +
      + +
      + + test-root + + , + "sn-1":
      +
      + +
      +
      + +
      + + test-child-1 + +
      , + "sn-1_sn-2":
      +
      + +
      + + test-child-2 + +
      , + }, + } + } + level={2} + multiSelect={false} + nodeAction="check" + nodeRefs={ + Object { + "current": Object { + "root":
    • +
      +
      + +
      +
      + +
      + + test-root + +
      +
        +
      • +
        +
        + +
        +
        + +
        + + test-child-1 + +
        +
          +
        • +
          +
          + +
          + + test-child-2 + +
          +
        • +
        +
      • +
      +
    • , + "sn-1":
    • +
      +
      + +
      +
      + +
      + + test-child-1 + +
      +
        +
      • +
        +
        + +
        + + test-child-2 + +
        +
      • +
      +
    • , + "sn-1_sn-2":
      +
      + +
      + + test-child-2 + +
      , + }, + } + } + nodeRenderer={[Function]} + posinset={1} + propagateCollapse={false} + propagateSelect={false} + propagateSelectUpwards={true} + selectedIds={Set {}} + setsize={1} + state={ + Object { + "controlledIds": Set {}, + "disabledIds": Set {}, + "expandedIds": Set { + 0, + "root", + "sn-1", + "sn-1_sn-2", + }, + "halfSelectedIds": Set {}, + "isFocused": false, + "lastInteractedWith": null, + "lastManuallyToggled": null, + "lastUserSelect": "root", + "selectedIds": Set {}, + "tabbableId": "root", + } + } + tabbableId="root" + togglableSelect={true} + > +
    • +
      + +
      + + + + + + + +
      +
      +
      + + + + + + + + + + +
      + + test-child-1 + +
      + +
      + +
      +
      + +
      + + test-root + + , + "sn-1":
      +
      + +
      +
      + +
      + + test-child-1 + +
      , + "sn-1_sn-2":
      +
      + +
      + + test-child-2 + +
      , + }, + } + } + level={2} + multiSelect={false} + nodeAction="check" + nodeRefs={ + Object { + "current": Object { + "root":
    • +
      +
      + +
      +
      + +
      + + test-root + +
      +
        +
      • +
        +
        + +
        +
        + +
        + + test-child-1 + +
        +
          +
        • +
          +
          + +
          + + test-child-2 + +
          +
        • +
        +
      • +
      +
    • , + "sn-1":
    • +
      +
      + +
      +
      + +
      + + test-child-1 + +
      +
        +
      • +
        +
        + +
        + + test-child-2 + +
        +
      • +
      +
    • , + "sn-1_sn-2":
      +
      + +
      + + test-child-2 + +
      , + }, + } + } + nodeRenderer={[Function]} + propagateCollapse={false} + propagateSelect={false} + propagateSelectUpwards={true} + selectedIds={Set {}} + state={ + Object { + "controlledIds": Set {}, + "disabledIds": Set {}, + "expandedIds": Set { + 0, + "root", + "sn-1", + "sn-1_sn-2", + }, + "halfSelectedIds": Set {}, + "isFocused": false, + "lastInteractedWith": null, + "lastManuallyToggled": null, + "lastUserSelect": "root", + "selectedIds": Set {}, + "tabbableId": "root", + } + } + tabbableId="root" + togglableSelect={true} + > +
        + +
        + +
        +
        + +
        + + test-root + + , + "sn-1":
        +
        + +
        +
        + +
        + + test-child-1 + +
        , + "sn-1_sn-2":
        +
        + +
        + + test-child-2 + +
        , + }, + } + } + level={3} + multiSelect={false} + nodeAction="check" + nodeRefs={ + Object { + "current": Object { + "root":
      • +
        +
        + +
        +
        + +
        + + test-root + +
        +
          +
        • +
          +
          + +
          +
          + +
          + + test-child-1 + +
          +
            +
          • +
            +
            + +
            + + test-child-2 + +
            +
          • +
          +
        • +
        +
      • , + "sn-1":
      • +
        +
        + +
        +
        + +
        + + test-child-1 + +
        +
          +
        • +
          +
          + +
          + + test-child-2 + +
          +
        • +
        +
      • , + "sn-1_sn-2":
        +
        + +
        + + test-child-2 + +
        , + }, + } + } + nodeRenderer={[Function]} + posinset={1} + propagateCollapse={false} + propagateSelect={false} + propagateSelectUpwards={true} + selectedIds={Set {}} + setsize={1} + state={ + Object { + "controlledIds": Set {}, + "disabledIds": Set {}, + "expandedIds": Set { + 0, + "root", + "sn-1", + "sn-1_sn-2", + }, + "halfSelectedIds": Set {}, + "isFocused": false, + "lastInteractedWith": null, + "lastManuallyToggled": null, + "lastUserSelect": "root", + "selectedIds": Set {}, + "tabbableId": "root", + } + } + tabbableId="root" + togglableSelect={true} + > +
      • +
        +
        + + + + + + + + + + +
        + + test-child-2 + +
        +
      • +
        +
      + + +
      +
    + + + + + + + +
    +
    +`; diff --git a/app/javascript/spec/vm-snapshot-tree/vm-snapshot-tree.spec.js b/app/javascript/spec/vm-snapshot-tree/vm-snapshot-tree.spec.js new file mode 100644 index 00000000000..f1661fe58c3 --- /dev/null +++ b/app/javascript/spec/vm-snapshot-tree/vm-snapshot-tree.spec.js @@ -0,0 +1,51 @@ +import React from 'react'; +import toJson from 'enzyme-to-json'; +import fetchMock from 'fetch-mock'; +import { shallow } from 'enzyme'; +import VmSnapshotTree from '../../components/vm-snapshot-tree-select/snapshot-tree'; +import { mount } from '../helpers/mountForm'; + +describe('VM Snaspthot Tree Select', () => { + const url = `/${ManageIQ.controller}/snap_pressed/${encodeURIComponent(1)}`; + const nodes = [{ + class: 'no-cursor', + icon: 'pficon pficon-folder-close', + key: 'root', + selectable: false, + text: 'test-root', + tooltip: 'test-root', + state: { expanded: true }, + nodes: [{ + icon: 'fa fa-camera', + key: 'sn-1', + selectable: true, + text: 'test-child-1', + tooltip: 'test-child-1', + state: { expanded: true }, + nodes: [{ + icon: 'fa fa-camera', + key: 'sn-1_sn-2', + selectable: true, + text: 'test-child-2', + tooltip: 'test-child-2', + state: { expanded: true }, + }], + }], + }]; + + it('should render snapshot tree', (done) => { + const wrapper = shallow( {}} />); + setImmediate(() => { + wrapper.update(); + expect(toJson(wrapper)).toMatchSnapshot(); + done(); + }); + }); + + it('should submit select API call', async(done) => { + fetchMock.postOnce(url, {}); + const wrapper = mount( {}} />); + expect(toJson(wrapper)).toMatchSnapshot(); + done(); + }); +}); diff --git a/app/views/vm_common/_config.html.haml b/app/views/vm_common/_config.html.haml index 534f68663a4..412f88f2236 100644 --- a/app/views/vm_common/_config.html.haml +++ b/app/views/vm_common/_config.html.haml @@ -84,7 +84,6 @@ - when "snapshot_info" = render :partial => "vm_common/snapshots_desc" - = render :partial => "vm_common/snapshots_tree" - when "vmtree_info" = render :partial => "vm_common/vmtree" diff --git a/app/views/vm_common/_snapshots_desc.html.haml b/app/views/vm_common/_snapshots_desc.html.haml index ba3b7713691..9d7bce73b1f 100644 --- a/app/views/vm_common/_snapshots_desc.html.haml +++ b/app/views/vm_common/_snapshots_desc.html.haml @@ -4,30 +4,11 @@ - unless session[:snap_selected].nil? - selected_id = Snapshot.find(session[:snap_selected]) -#desc_content.desc_content - - if session[:snap_selected].present? || @record.snapshots.count > 0 - .form-horizontal - .form-group - %label.control-label.col-sm-2 - = _('Description') - .col-md-10 - %p.form-control-static - = selected_id[:description] - .form-group - %label.control-label.col-sm-2 - = _('Size') - .col-md-10 - - unless selected_id[:total_size].blank? - %p.form-control-static - = number_to_human_size(selected_id[:total_size], :precision => 2) - = _("(%{number} bytes)") % {:number => number_with_delimiter(selected_id[:total_size], :delimiter => ",", :separator => ".")} - .form-group - %label.control-label.col-sm-2 - = _('Created On') - .col-md-10 - - unless selected_id[:create_time].blank? - %p.form-control-static - = format_timezone(selected_id[:create_time].to_time, Time.zone, "view") - %hr - - else - = render :partial => 'layouts/info_msg', :locals => {:message => _("%{record_name} has no snapshots") % {:record_name => @record.name}} +- time = format_timezone(selected_id[:create_time].to_time, Time.zone, "view") +- if selected_id[:total_size] == nil || selected_id[:total_size] == 0 + - size = '' +- else + = number_to_human_size(selected_id[:total_size], :precision => 2) + - size = _("%{number} bytes") % {:number => number_with_delimiter(selected_id[:total_size], :delimiter => ",", :separator => ".")} + += react('VmSnapshotTreeSelect', {tree: @snapshot_tree, selected: selected_id, size: size, time: time}) diff --git a/app/views/vm_common/_snapshots_tree.html.haml b/app/views/vm_common/_snapshots_tree.html.haml deleted file mode 100644 index 6cc87711e6f..00000000000 --- a/app/views/vm_common/_snapshots_tree.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -#snapshots_tree_div - - if @record.snapshots.count > 0 - %fieldset - %h3= _('Available Snapshots') - = render(:partial => 'shared/tree', :locals => {:tree => @snapshot_tree, :name => @snapshot_tree.name}) diff --git a/spec/controllers/vm_infra_controller_spec.rb b/spec/controllers/vm_infra_controller_spec.rb index 8c8175db115..8c68457d289 100644 --- a/spec/controllers/vm_infra_controller_spec.rb +++ b/spec/controllers/vm_infra_controller_spec.rb @@ -96,7 +96,6 @@ post :show, :params => {:id => vm_vmware.id, :display => 'snapshot_info'}, :xhr => true expect(response.status).to eq(200) expect(response).to render_template('vm_common/_snapshots_desc') - expect(response).to render_template('vm_common/_snapshots_tree') end it 'can open the right size tab' do