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 || ''}
+
+
+
+
+
+ {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`] = `
+
+
+
+
,
+ "sn-1":
,
+ "sn-1_sn-2":
,
+ },
+ }
+ }
+ level={1}
+ multiSelect={false}
+ nodeAction="check"
+ nodeRefs={
+ Object {
+ "current": Object {
+ "root":
+
+
+ ,
+ "sn-1":
+
+
+ ,
+ "sn-1_sn-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": ,
+ "sn-1_sn-2": ,
+ },
+ }
+ }
+ level={1}
+ multiSelect={false}
+ nodeAction="check"
+ nodeRefs={
+ Object {
+ "current": Object {
+ "root":
+
+
+ ,
+ "sn-1":
+
+
+ ,
+ "sn-1_sn-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": ,
+ "sn-1_sn-2": ,
+ },
+ }
+ }
+ level={2}
+ multiSelect={false}
+ nodeAction="check"
+ nodeRefs={
+ Object {
+ "current": Object {
+ "root": -
+
+
+
,
+ "sn-1": -
+
+
+
,
+ "sn-1_sn-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": ,
+ "sn-1_sn-2": ,
+ },
+ }
+ }
+ level={2}
+ multiSelect={false}
+ nodeAction="check"
+ nodeRefs={
+ Object {
+ "current": Object {
+ "root": -
+
+
+
,
+ "sn-1": -
+
+
+
,
+ "sn-1_sn-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": ,
+ "sn-1_sn-2": ,
+ },
+ }
+ }
+ level={3}
+ multiSelect={false}
+ nodeAction="check"
+ nodeRefs={
+ Object {
+ "current": Object {
+ "root": -
+
+
+
,
+ "sn-1": -
+
+
+
,
+ "sn-1_sn-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}
+ >
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
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