Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions app/javascript/components/tree-view/field.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import Tree, { ActionTypes } from '@manageiq/react-ui-components/dist/wooden-tree';
import {
ControlLabel, FieldLevelHelp, FormGroup, HelpBlock,
} from 'patternfly-react';

import RequiredLabel from '../../forms/required-label';
import { convert, flatten } from './helpers';
import { useFieldApi, useFormApi } from '@@ddf';

const setChecked = (keys = []) => ({ state, ...node }) => ({ ...node, state: { ...state, checked: keys.includes(node.attr.key) } });

const TreeViewField = ({ loadData, lazyLoadData, ...props }) => {
const [{ nodes }, setState] = useState({});

const { input: { value }, meta } = useFieldApi(props);
const formOptions = useFormApi();

useEffect(() => {
loadData().then((values) => {
setState(state => ({ ...state, nodes: flatten(convert(values, setChecked(value))) }));
});
}, [loadData]);

const actionMapper = {
[ActionTypes.EXPANDED]: Tree.nodeExpanded,
[ActionTypes.CHECKED]: Tree.nodeChecked,
[ActionTypes.DISABLED]: Tree.nodeDisabled,
[ActionTypes.SELECTED]: Tree.nodeSelected,
[ActionTypes.LOADING]: Tree.nodeLoading,
[ActionTypes.CHILD_NODES]: (node, value) => {
// There's a bug in the react-wooden-tree, the passed value here contains all the child
// nodes and that would force the tree to mount nested children to the parent node twice.
// For now we're filtering out the child nodes manually by counting the number of dots in
// their identifier.
const min = value.reduce((min, key) => {
const nl = key.split('.').length;
return nl > min ? min : nl;
}, undefined);

return Tree.nodeChildren(node, value.filter(key => key.split('.').length === min));
},
[ActionTypes.CHECKED_DIRECTLY]: (node, value) => {
const values = formOptions.getFieldState(props.name).value;
formOptions.change(props.name, value ? [...values, node.attr.key] : values.filter(item => item !== node.attr.key));

return Tree.nodeChecked(node, value);
},
};

const lazyLoad = node => lazyLoadData(node).then((result) => {
const data = flatten(convert(result, setChecked(value)));

let subtree = {};
Object.keys(data).forEach((key) => {
if (key !== '') {
// Creating the node id from the parent id.
const nodeId = `${node.nodeId}.${key}`;
// Updating the children ids, so it does not point to something else.
const element = { ...data[key], nodeId, nodes: data[key].nodes.map(child => `${node.nodeId}.${child}`) };
subtree = { ...subtree, [nodeId]: element };
}
});
return subtree;
});

const onDataChange = commands => setState(state => ({
...state,
nodes: commands.reduce(
(nodes, { type, value, nodeId }) => (
type === ActionTypes.ADD_NODES
? Tree.addNodes(nodes, value)
: Tree.nodeUpdater(nodes, actionMapper[type](Tree.nodeSelector(nodes, nodeId), value))
), nodes,
),
}));

return (
<FormGroup validationState={meta.error ? 'error' : null}>
<ControlLabel>
{props.isRequired ? <RequiredLabel label={props.label} /> : props.label }
{props.helperText && <FieldLevelHelp content={props.helperText} />}
</ControlLabel>
<Tree
data={nodes}
nodeIcon=""
expandIcon="fa fa-fw fa-angle-right"
collapseIcon="fa fa-fw fa-angle-down"
loadingIcon="fa fa-fw fa-spinner fa-pulse"
checkedIcon="fa fa-fw fa-check-square-o"
uncheckedIcon="fa fa-fw fa-square-o"
selectedIcon=""
partiallyCheckedIcon="fa fa-fw fa-check-square"
preventDeselect
showCheckbox
callbacks={{ onDataChange, lazyLoad }}
{...props}
/>
{meta.error && <HelpBlock>{ meta.error }</HelpBlock>}
</FormGroup>
);
};

TreeViewField.propTypes = {
loadData: PropTypes.func,
lazyLoadData: PropTypes.func,
};

export default TreeViewField;
57 changes: 57 additions & 0 deletions app/javascript/components/tree-view/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Tree from '@manageiq/react-ui-components/dist/wooden-tree';

/**
* Helper
* Changes class to className.
* Sets undefined checkbox to null.
* Moves the key to attr.key.
*/
const convert = (tree, fn) => tree.map((n) => {
let node = { ...n };
if (node.class) {
node = { ...node, classes: node.class };
delete node.class;
}

// The server cannot send null, just undefined, this is why it is tested as a string.
if (node.state.checked === 'undefined') {
node = { ...node, state: { ...node.state, checked: null } };
} else if (node.state.checked === null) {
node = { ...node, state: { ...node.state, checked: false } };
}

if (node.key) {
node = { ...node, attr: { key: node.key } };
delete node.key;
}

if (node.nodes) {
node = { ...node, nodes: convert(node.nodes, fn) };
}

return fn ? fn(node) : node;
});

const flatten = data => Tree.convertHierarchicalTree(Tree.initHierarchicalTree(data));

/**
* Helper
* Activates the first active node in the tree, which is passed trought the props.
*
* FIXME Delete this function when bakcend is sending data where the initial node is activated,
* or when all the trees calling a reducer if they want to have an activated node at the start.
*/

const activateNode = (tree, doActivate, key) => {
if (!doActivate) {
return tree;
}

let node = Tree.nodeSelector(tree, Tree.nodeSearch(tree, null, 'key', key)[0]);
node = { ...node, state: { ...node.state, selected: true } };
return { ...tree, [node.nodeId]: node };
};

const callBack = (nodeId, type, value, namespace) => ({ nodeId, type, value, namespace });

export { convert, flatten, activateNode, callBack };
63 changes: 5 additions & 58 deletions app/javascript/components/tree-view/hierarchical-tree-view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,68 +2,15 @@
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Tree, {
Node,
} from '@manageiq/react-ui-components/dist/wooden-tree';
import Tree, { Node } from '@manageiq/react-ui-components/dist/wooden-tree';

import { http } from '../../http_api';
import { combineReducers } from '../../helpers/redux';
import reducers, { ACTIONS } from './reducers/index';
import basicStore from './reducers/basicStore';

const flatten = data => Tree.convertHierarchicalTree(Tree.initHierarchicalTree(data));

const callBack = (nodeId, type, value, namespace) => ({
nodeId, type, value, namespace,
});

/**
* Helper
* Changes class to className.
* Sets undefined checkbox to null.
* Moves the key to attr.key.
*/
const convert = tree => tree.map((n) => {
let node = { ...n };
if (node.class) {
node = { ...node, classes: node.class };
delete node.class;
}

// The server cannot send null, just undefined, this is why it is tested as a string.
if (node.state.checked === 'undefined') {
node = { ...node, state: { ...node.state, checked: null } };
} else if (node.state.checked === null) {
node = { ...node, state: { ...node.state, checked: false } };
}

if (node.key) {
node = { ...node, attr: { key: node.key } };
delete node.key;
}

if (node.nodes) {
node = { ...node, nodes: convert(node.nodes) };
}

return node;
});

/**
* Helper
* Activates the first active node in the tree, which is passed trought the props.
*
* FIXME Delete this function when bakcend is sending data where the initial node is activated,
* or when all the trees calling a reducer if they want to have an activated node at the start.
*/
const activateNode = (tree, doActivate, key) => {
if (!doActivate) {
return tree;
}

let node = Tree.nodeSelector(tree, Tree.nodeSearch(tree, null, 'key', key)[0]);
node = { ...node, state: { ...node.state, selected: true } };
return { ...tree, [node.nodeId]: node };
};
import {
convert, callBack, activateNode, flatten,
} from './helpers';

const HierarchicalTreeView = (props) => {
const {
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/forms/mappers/componentMapper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import PasswordField from '../../components/async-credentials/password-field';
import Select from '../../components/select';
import { DataDrivenFormCodeEditor } from '../../components/code-editor';
import FieldArray from '../../components/field-array';
import TreeViewField from '../../components/tree-view/field';

const mapper = {
...componentMapper,
Expand All @@ -22,6 +23,7 @@ const mapper = {
note: props => <div className={props.className} role="alert">{props.label}</div>,
'password-field': PasswordField,
'validate-credentials': AsyncCredentials,
'tree-view': TreeViewField,
[componentTypes.SELECT]: Select,
};

Expand Down