packages/org-unit-tree/src/OrgUnitTree.component.js

Summary

Maintainability
D
1 day
Test Coverage
import React from 'react';
import PropTypes from 'prop-types';
import LinearProgress from '@material-ui/core/LinearProgress';
import StopIcon from '@material-ui/icons/Stop';

import TreeView from '@dhis2/d2-ui-core/tree-view/TreeView.component';
import styles from './styles/OrgUnitTree.component.styles';
import OUFolderIconComponent from './OUFolderIcon.component';
import OUCheckboxComponent from './OUCheckbox.component';
import { loadChildren } from './utils';

class OrgUnitTree extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            children:
                props.root.children === false ||
                (Array.isArray(props.root.children) &&
                    props.root.children.length === 0)
                    ? []
                    : undefined,
            loading: false,
        };
        if (
            props.root.children &&
            typeof props.root.children.toArray === 'function' &&
            !props.root.children.hasUnloadedData
        ) {
            this.state.children = props.root.children
                .toArray()
                // Sort here since the API returns nested children in random order
                .sort((a, b) => a.displayName.localeCompare(b.displayName));
        }
    }

    componentDidMount() {
        if (
            this.props.initiallyExpanded.some(ou =>
                ou.includes(`/${this.props.root.id}`)
            )
        ) {
            this.loadChildren();
        }
    }

    componentWillReceiveProps(newProps) {
        if (
            newProps.initiallyExpanded.some(ou =>
                ou.includes(`/${newProps.root.id}`)
            ) ||
            newProps.idsThatShouldBeReloaded.includes(newProps.root.id)
        ) {
            this.loadChildren();
        }
    }

    onCollapse = orgUnit => {
        if (typeof this.props.onCollapse === 'function') {
            this.props.onCollapse(orgUnit);
        }
    };

    onExpand = orgUnit => {
        this.loadChildren();

        if (typeof this.props.onExpand === 'function') {
            this.props.onExpand(orgUnit);
        }
    };

    onContextMenuClick = e => {
        e.preventDefault();
        e.stopPropagation();

        if (this.props.onContextMenuClick !== undefined) {
            this.props.onContextMenuClick(
                e,
                this.props.root,
                this.hasChildren(),
                this.loadChildren
            );
        }
    };

    setChildState = children => {
        let data = children;

        if (this.props.onChildrenLoaded) {
            this.props.onChildrenLoaded(children);
        }

        if (!Array.isArray(children)) {
            data = children.toArray();
        }

        this.setState({
            children: data.sort((a, b) =>
                a.displayName.localeCompare(b.displayName)
            ),
            loading: false,
        });
    };

    hideChildren = () => {
        this.setChildState([]);
    };

    loadChildren = async () => {
        if (this.state.children !== undefined) {
            return this.state.children;
        }

        if (
            (this.state.children === undefined && !this.state.loading) ||
            this.props.idsThatShouldBeReloaded.indexOf(this.props.root.id) >= 0
        ) {
            this.setState({ loading: true });

            const children = await loadChildren(
                this.props.root,
                this.props.displayNameProperty,
                this.props.forceReloadChildren,
                this.props.isUserDataViewFallback
            );

            this.setChildState(children);

            return children;
        }
    };

    handleSelectClick = e => {
        if (this.props.onSelectClick) {
            this.props.onSelectClick(e, this.props.root);
        }
        e.stopPropagation();
    };

    hasChildren = () =>
        this.state.children === undefined ||
        (Array.isArray(this.state.children) && this.state.children.length > 0);

    shouldIncludeOrgUnit = orgUnit => {
        if (
            !this.props.orgUnitsPathsToInclude ||
            this.props.orgUnitsPathsToInclude.length === 0
        ) {
            return true;
        }
        return !!this.props.orgUnitsPathsToInclude.some(ou =>
            ou.includes(`/${orgUnit.id}`)
        );
    };

    setCurrentRoot = e => {
        e.stopPropagation();

        this.props.onChangeCurrentRoot(this.props.root);
    };

    renderChild(orgUnit, expandedProp) {
        if (!this.shouldIncludeOrgUnit(orgUnit)) {
            return null;
        }

        const highlighted =
            this.props.searchResults.includes(orgUnit.path) &&
            this.props.highlightSearchResults;

        return (
            <OrgUnitTree
                key={orgUnit.id}
                root={orgUnit}
                onExpand={this.onExpand}
                onCollapse={this.onCollapse}
                selected={this.props.selected}
                initiallyExpanded={expandedProp}
                onSelectClick={this.props.onSelectClick}
                onContextMenuClick={this.props.onContextMenuClick}
                currentRoot={this.props.currentRoot}
                onChangeCurrentRoot={this.props.onChangeCurrentRoot}
                labelStyle={{
                    ...this.props.labelStyle,
                    fontWeight: highlighted
                        ? 500
                        : this.props.labelStyle.fontWeight,
                    color: highlighted ? 'orange' : 'inherit',
                }}
                selectedLabelStyle={this.props.selectedLabelStyle}
                arrowSymbol={this.props.arrowSymbol}
                idsThatShouldBeReloaded={this.props.idsThatShouldBeReloaded}
                hideCheckboxes={this.props.hideCheckboxes}
                onChildrenLoaded={this.props.onChildrenLoaded}
                hideMemberCount={this.props.hideMemberCount}
                orgUnitsPathsToInclude={this.props.orgUnitsPathsToInclude}
                treeStyle={this.props.treeStyle}
                searchResults={this.props.searchResults}
                highlightSearchResults={this.props.highlightSearchResults}
                forceReloadChildren={this.props.forceReloadChildren}
                showFolderIcon={this.props.showFolderIcon}
                disableSpacer={this.props.disableSpacer}
                checkboxColor={this.props.checkboxColor}
                displayNameProperty={this.props.displayNameProperty}
                isUserDataViewFallback={this.props.isUserDataViewFallback}
            />
        );
    }

    renderChildren() {
        // If initiallyExpanded is an array, remove the current root id and pass the rest on
        // If it's a string, pass it on unless it's the current root id
        const expandedProp = Array.isArray(this.props.initiallyExpanded)
            ? this.props.initiallyExpanded.filter(
                  id => id !== this.props.root.id
              )
            : (this.props.initiallyExpanded !== this.props.root.id &&
                  this.props.initiallyExpanded) ||
              [];

        if (
            Array.isArray(this.state.children) &&
            this.state.children.length > 0
        ) {
            return this.state.children.map(orgUnit =>
                this.renderChild(orgUnit, expandedProp)
            );
        }

        if (this.state.loading) {
            return (
                <div style={styles.progress}>
                    <LinearProgress style={styles.progressBar} />
                </div>
            );
        }

        return null;
    }

    renderLabel(
        isSelected,
        isSelectable,
        isInitiallyExpanded,
        canBecomeCurrentRoot,
        currentOu,
        hasChildren,
        memberCount
    ) {
        const labelStyle = {
            ...styles.label,
            fontWeight: isSelected ? 500 : 300,
            color: isSelected ? 'orange' : 'inherit',
            cursor: canBecomeCurrentRoot ? 'pointer' : 'default',
            ...(isSelected
                ? this.props.selectedLabelStyle
                : this.props.labelStyle),
        };

        return (
            <div
                style={labelStyle}
                onClick={
                    canBecomeCurrentRoot
                        ? this.setCurrentRoot
                        : isSelectable
                            ? this.handleSelectClick
                            : undefined
                }
                onContextMenu={this.onContextMenuClick}
                role="button"
                tabIndex={0}
            >
                {isSelectable &&
                    !this.props.hideCheckboxes && (
                        <OUCheckboxComponent
                            checked={isSelected}
                            disabled={!isSelectable}
                            onClick={this.handleSelectClick}
                            color={this.props.checkboxColor}
                        />
                    )}
                {this.props.showFolderIcon &&
                    hasChildren && (
                        <OUFolderIconComponent
                            isExpanded={isInitiallyExpanded}
                            styles={this.props.labelStyle.folderIcon}
                        />
                    )}
                {this.props.showFolderIcon &&
                    !hasChildren && (
                        <StopIcon
                            style={{
                                ...styles.stopIcon,
                                ...this.props.labelStyle.stopIcon,
                            }}
                        />
                    )}
                <span style={this.props.labelStyle.text}>
                    {currentOu.displayName}
                </span>
                {hasChildren &&
                    !this.props.hideMemberCount &&
                    !!memberCount && (
                        <span style={styles.memberCount}>({memberCount})</span>
                    )}
            </div>
        );
    }

    render() {
        const currentOu = this.props.root;
        const hasChildren = this.hasChildren();
        const isSelectable = !!this.props.onSelectClick;
        const pathRegEx = new RegExp(`/${currentOu.id}$`);
        const memberRegEx = new RegExp(`/${currentOu.id}`);
        const isSelected =
            this.props.selected &&
            this.props.selected.some(ou => pathRegEx.test(ou));
        const isCurrentRoot =
            this.props.currentRoot &&
            this.props.currentRoot.id === currentOu.id;
        const isInitiallyExpanded = this.props.initiallyExpanded.some(ou =>
            ou.includes(`/${currentOu.id}`)
        );
        const canBecomeCurrentRoot =
            this.props.onChangeCurrentRoot && !isCurrentRoot && hasChildren;

        const memberCount =
            this.props.selected !== undefined
                ? this.props.selected.filter(ou => memberRegEx.test(ou)).length
                : currentOu.memberCount;

        const ouContainerStyle = {
            ...styles.ouContainer,
            ...(isCurrentRoot ? styles.currentOuContainer : {}),
            ...this.props.treeStyle,
        };

        const label = this.renderLabel(
            isSelected,
            isSelectable,
            isInitiallyExpanded,
            canBecomeCurrentRoot,
            currentOu,
            hasChildren,
            memberCount
        );

        if (hasChildren) {
            return (
                <TreeView
                    label={label}
                    onExpand={this.onExpand}
                    onCollapse={this.onCollapse}
                    model={this.props.root}
                    initiallyExpanded={isInitiallyExpanded}
                    arrowSymbol={this.props.arrowSymbol}
                    className="orgunit with-children"
                    style={ouContainerStyle}
                    persistent
                >
                    {this.renderChildren()}
                </TreeView>
            );
        }

        return (
            <div
                onClick={isSelectable ? this.handleSelectClick : undefined}
                className="orgunit without-children"
                style={ouContainerStyle}
                role="button"
                tabIndex={0}
            >
                {!this.props.disableSpacer && <div style={styles.spacer} />}
                {label}
            </div>
        );
    }
}

function orgUnitPathPropValidator(
    propValue,
    key,
    compName,
    location,
    propFullName
) {
    if (!/(\/[a-zA-Z][a-zA-Z0-9]{10})+/.test(propValue[key])) {
        return new Error(
            `Invalid org unit path \`${
                propValue[key]
            }\` supplied to \`${compName}.${propFullName}\``
        );
    }
    return undefined;
}

OrgUnitTree.propTypes = {
    /**
     * The root OrganisationUnit of the tree, ModelBase
     *
     * If the root OU is known to have no children, the `children` property of the root OU should be either
     * `false` or an empty array. If the children property is undefined, the children will be fetched from
     * the server when the tree is expanded.
     */
    root: PropTypes.object.isRequired,

    /**
     * Display name property
     */
    displayNameProperty: PropTypes.string,

    /**
     * An array of paths of selected OUs
     *
     * The path of an OU is the UIDs of the OU and all its parent OUs separated by slashes (/)
     */
    selected: PropTypes.arrayOf(orgUnitPathPropValidator),

    /**
     * An array of OU paths that will be expanded automatically as soon as they are encountered
     *
     * The path of an OU is the UIDs of the OU and all its parent OUs separated by slashes (/)
     */
    initiallyExpanded: PropTypes.arrayOf(orgUnitPathPropValidator),

    /**
     * onExpand callback is triggered when user expands organisation unit
     *
     * Will receive one argument - OU that was expanded
     */
    onExpand: PropTypes.func,

    /**
     * onCollapse callback is triggered when user collapses organisation unit
     *
     * Will receive one argument - OU that was collapsed
     */
    onCollapse: PropTypes.func,

    /**
     * onSelectClick callback, which is triggered when a click triggers the selection of an organisation unit
     *
     * The onSelectClick callback will receive two arguments: The original click event, and the OU that was clicked
     */
    onSelectClick: PropTypes.func,

    /**
     * onChangeCurrentRoot callback, which is triggered when the change current root label is clicked. Setting this also
     * enables the display of the change current root label
     *
     * the onChangeCurrentRoot callback will receive two arguments: The original click event, and the organisation unit
     * model object that was selected as the new root
     */
    onChangeCurrentRoot: PropTypes.func,

    /**
     * Organisation unit model representing the current root
     */
    currentRoot: PropTypes.object,

    /**
     * onChildrenLoaded callback, which is triggered when the children of this root org unit have been loaded
     *
     * The callback receives one argument: A D2 ModelCollection object that contains all the newly loaded org units
     */
    onChildrenLoaded: PropTypes.func,

    /**
     * Custom styling for OU labels
     */
    labelStyle: PropTypes.object,

    /**
     * Custom styling for trees
     */
    treeStyle: PropTypes.object,

    /**
     * Custom styling for the labels of selected OUs
     */
    selectedLabelStyle: PropTypes.object,

    /**
     * An array of organisation unit IDs that should be reloaded from the API
     */
    idsThatShouldBeReloaded: PropTypes.arrayOf(PropTypes.string),

    /**
     * Custom arrow symbol
     */
    arrowSymbol: PropTypes.string,

    /**
     * If true, don't display checkboxes next to org unit labels
     */
    hideCheckboxes: PropTypes.bool,

    /**
     * if true, don't display the selected member count next to org unit labels
     */
    hideMemberCount: PropTypes.bool,

    /**
     * Array of paths of Organisation Units to include on tree. If not defined or empty, all children from root to leafs will be shown
     */
    orgUnitsPathsToInclude: PropTypes.array,

    /**
     * If true `root.children.load` (a method on d2.ModelCollectionProperty) will be called with forceReload set to true, which is required
     * for dynamic OrgUnitTrees, i.e. in cases where parent-child relations are updated
     */
    forceReloadChildren: PropTypes.bool,

    /**
     * Results from search
     */
    searchResults: PropTypes.array,

    /**
     * Indicates if search results should be highlighted
     */
    highlightSearchResults: PropTypes.bool,

    /**
     * Indicates if showing folder icon is enabled
     */
    showFolderIcon: PropTypes.bool,

    /**
     * Prop indicating if spacer should be enabled
     */
    disableSpacer: PropTypes.bool,

    /**
     * Prop indicating checkbox color
     */
    checkboxColor: PropTypes.string,

    /**
     * Prop function invoked when user opens context menu against org unit
     */
    onContextMenuClick: PropTypes.func,

    /**
     * Indicates the userDataViewFallback parameter must be used in the requests made when expanding the tree
     */
    isUserDataViewFallback: PropTypes.bool,
};

OrgUnitTree.defaultProps = {
    displayNameProperty: 'displayName',
    selected: [],
    initiallyExpanded: [],
    onSelectClick: undefined,
    onContextMenuClick: undefined,
    onExpand: undefined,
    onCollapse: undefined,
    onChangeCurrentRoot: undefined,
    currentRoot: undefined,
    onChildrenLoaded: undefined,
    labelStyle: {},
    treeStyle: {},
    selectedLabelStyle: {},
    idsThatShouldBeReloaded: [],
    arrowSymbol: undefined,
    hideCheckboxes: false,
    hideMemberCount: false,
    orgUnitsPathsToInclude: null,
    forceReloadChildren: false,
    searchResults: [],
    highlightSearchResults: false,
    showFolderIcon: false,
    disableSpacer: false,
    checkboxColor: 'primary',
    isUserDataViewFallback: false,
};

export default OrgUnitTree;