cmspsgp31/anubis

View on GitHub
anubis/frontend/src/components/record_list.js

Summary

Maintainability
F
4 days
Test Coverage
// Copyright © 2016, Ugo Pozo
//             2016, Câmara Municipal de São Paulo

// record_list.js - component for listing records that match a search.

// This file is part of Anubis.

// Anubis is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.

// Anubis is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.

// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.


import React from 'react';
import I from 'immutable';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import {PropTypes as RPropTypes} from 'react';
import IPropTypes from 'react-immutable-proptypes';

import {connect} from 'react-redux';
import {RaisedButton, Toolbar, ToolbarGroup, CircularProgress, DropDownMenu,
    IconButton, MenuItem, Tabs, Tab, Popover, Menu} from 'material-ui';
import {NavigationArrowUpward, NavigationArrowDownward, NavigationChevronLeft,
    NavigationChevronRight, SocialShare} from 'material-ui/svg-icons';
import {Link} from 'react-router';
import {routeActions} from 'react-router-redux';
import {bindActionCreators} from 'redux';
import {Sticky, StickyContainer} from 'react-sticky';

import Actions from 'actions';

const getStateProps = state => {
    const searchResults = state.get('searchResults');
    const results = state.getIn(['searchResults', 'results']);
    const modelName = state.getIn(['searchResults', 'model']);
    const modelData = state.getIn(['models', modelName]);
    const searchApi = state.getIn(['applicationData', 'searchApi']);
    const searchHtml = state.getIn(['applicationData', 'searchHtml']);
    const searchAndDetailsHtml = state.getIn(['applicationData',
                                              'searchAndDetailsHtml']);
    const isSearch = state.getIn(['searchResults', 'visible']);
    const sorting = state.getIn(['searchResults', 'sorting']);
    const pagination = state.getIn(['searchResults', 'pagination']);
    const selection = state.getIn(['searchResults', 'selection']);
    const actions = state.getIn(['searchResults', 'actions']);
    const cache = state.getIn(['cache', 'searchResults']);
    const baseURL = state.getIn(['applicationData', 'baseURL']);
    const sortingDefaults = state.getIn(['applicationData', 'sortingDefaults']);
    const models = state.get('models');
    const error = state.getIn(['searchResults', 'error']);

    return {results, modelName, modelData, searchApi, searchHtml, isSearch,
            searchAndDetailsHtml, sorting, pagination, selection, cache,
            baseURL, searchResults, models, sortingDefaults, error, actions};
};

const getDispatchProps = dispatch => ({
    fetchSearch: bindActionCreators(Actions.fetchSearch, dispatch),
    restoreSearch: bindActionCreators(Actions.restoreSearch, dispatch),
    clearSearch: bindActionCreators(Actions.clearSearch, dispatch),
    clearSearchCache: bindActionCreators(Actions.clearSearchCache, dispatch),
    setGlobalError: bindActionCreators(Actions.setGlobalError, dispatch),
    goTo: url => dispatch(routeActions.push(url)),
    replaceWith: url => dispatch(routeActions.replace(url)),
    enableEditor: bindActionCreators(Actions.enableEditor, dispatch),
    disableEditor: bindActionCreators(Actions.disableEditor, dispatch),
    startServerAction: bindActionCreators(Actions.startServerAction, dispatch),
    cancelServerAction: bindActionCreators(Actions.cancelServerAction,
                                           dispatch),
    toggleSelectionSearch: bindActionCreators(Actions.toggleSelectionSearch,
                                              dispatch),
});

@connect(getStateProps, getDispatchProps)
export default class RecordList extends React.Component {
    static propTypes = {
        actions: IPropTypes.mapContains({
            title: RPropTypes.string,
            description: RPropTypes.string,
            fields: IPropTypes.listOf(IPropTypes.map),
            models: IPropTypes.listOf(RPropTypes.string),
        }),
        baseURL: RPropTypes.string,
        cache: IPropTypes.map,
        clearSearch: RPropTypes.func,
        clearSearchCache: RPropTypes.func,
        disableEditor: RPropTypes.func,
        enableEditor: RPropTypes.func,
        error: RPropTypes.any,
        fetchSearch: RPropTypes.func,
        goTo: RPropTypes.func,
        isSearch: RPropTypes.bool,
        modelName: RPropTypes.string,
        models: IPropTypes.map,
        pagination: IPropTypes.map,
        params: RPropTypes.shape({
            splat: RPropTypes.string,
            model: RPropTypes.string,
            sorting: RPropTypes.string,
            page: RPropTypes.string,
        }),
        replaceWith: RPropTypes.func,
        restoreSearch: RPropTypes.func,
        results: IPropTypes.listOf(IPropTypes.map),
        searchAndDetailsHtml: RPropTypes.string,
        searchApi: RPropTypes.string,
        searchHtml: RPropTypes.string,
        searchResults: IPropTypes.map,
        selection: IPropTypes.listOf(RPropTypes.string),
        setGlobalError: RPropTypes.func,
        sorting: IPropTypes.map,
        sortingDefaults: IPropTypes.map,
        startServerAction: RPropTypes.func,
        templates: RPropTypes.object,
        toggleSelectionSearch: RPropTypes.func,
    }

    static contextTypes = {
        muiTheme: RPropTypes.object,
    }

    state = {
        showActions: false,
        anchor: null,
    };

    componentWillMount() {
        this.props.enableEditor();

        if (!this.props.isSearch) {
            this.fetchSearch(this.props.params);
        }
        else {
            this.conditionalCache(this.props.params);
        }
    }

    componentDidUpdate(previousProps) {
        this.props.enableEditor();

        if (this.props.error) {
            this.props.setGlobalError(this.props.error);
            this.props.replaceWith(this.props.baseURL);
            return;
        }

        const params = this.props.params;
        const searchParams = [params.model, params.splat, params.sorting,
                            params.page];

        const prevParams = previousProps.params;
        const prevSearchParams = [prevParams.model, prevParams.splat,
                                  prevParams.sorting, prevParams.page];

        const same = _.zipWith(searchParams, prevSearchParams, (a, b) => a == b)
            .every(x => x);

        if (!same) {
            this.props.clearSearch();
            this.fetchSearch(this.props.params);
        }
    }

    componentWillUnmount() {
        this.props.enableEditor();

        if (this.props.isSearch) this.props.clearSearch();
    }

    get searchApi() {
        /*eslint-disable no-unused-vars */
        const model = this.props.params.model;
        const expr = this.props.params.splat;
        const page = this.props.params.page;
        const sorting = this.props.params.sorting;
        /*eslint-enable no-unused-vars*/

        return eval("`" + this.props.searchApi + "`");
    }

    searchAndDetailsHtml(id) {
        /*eslint-disable no-unused-vars */
        const model = this.props.params.model;
        const expr = this.props.params.splat;
        const page = this.props.params.page;
        const sorting = this.props.params.sorting;
        /*eslint-enable no-unused-vars*/

        return eval("`" + this.props.searchAndDetailsHtml + "`");
    }

    searchHtml({model, expr, page, sorting}) {
        model = model || this.props.params.model;
        expr = expr || this.props.params.splat;
        page = page || this.props.params.page;
        sorting = sorting || this.props.params.sorting;

        return eval("`" + this.props.searchHtml + "`");
    }

    fetchSearch(params) {
        const model = params.model || "_default";
        const expr = params.splat;
        const page = params.page || "0";
        const sorting = params.sorting || "+none";

        const cached = (this.props.cache) ?
            this.props.cache.getIn([model, expr, sorting, `${page}`]) :
            null;

        if (cached) this.props.restoreSearch(cached);
        else {
            this.props.disableEditor();
            this.props.fetchSearch(this.searchApi);
        }
    }

    conditionalCache(params) {
        const model = params.model || "_default";
        const expr = params.splat;
        const page = params.page || "0";
        const sorting = params.sorting || "+none";

        const cached = (this.props.cache) ?
            this.props.cache.getIn([model, expr, sorting, `${page}`]) :
            null;

        if (!cached) this.props.restoreSearch(this.props.searchResults);
    }


    _clearCache() {
        const model = this.props.params.model || "_default";
        const expr = this.props.params.splat;
        const page = this.props.params.page || "0";
        const sorting = this.props.params.sorting || "+none";

        this.props.clearSearchCache({model, expr, page, sorting});
        this.props.clearSearch();
        this.props.fetchSearch(this.searchApi);
    }

    goTo(params) {
        this.props.goTo(this.searchHtml(params));
    }

    goToRoot() {
        this.props.goTo(this.props.baseURL);
    }

    _makeSorting({by, ascending}) {
        const prefix = (ascending) ? "+" : "-";

        return `${prefix}${by}`;
    }

    getPaginationElement() {
        if (!this.props.pagination) return null;

        const pagination = this.props.pagination;
        const allPages = pagination.get('allPages');
        const total = pagination.get('recordCount');
        const currentPage = pagination.get('currentPage');
        const prevPage = pagination.get('previousPageNumber');
        const nextPage = pagination.get('nextPageNumber');

        return (
            <ToolbarGroup>
                <IconButton
                    disabled={!prevPage}
                    onTouchTap={() => this.goTo({page: prevPage})}
                    style={{float: "left", top: "3px"}}
                >
                    <NavigationChevronLeft />
                </IconButton>
                <DropDownMenu
                    disabled={total == 0}
                    onChange={(ev, i, value) => this.goTo({page: value})}
                    style={{marginRight: "0px"}}
                    value={`${currentPage}`}
                >
                    {allPages.map(([num, from, to]) => {
                        let text = `Resultados ${from} a ${to} de ${total}`;

                        if (total == 0) {
                            text = "Nenhum resultado encontrado.";
                        }

                        return (
                            <MenuItem
                                key={`${num}`}
                                primaryText={text}
                                value={`${num}`}
                            />
                        );
                    }).toJS()}
                </DropDownMenu>
                <IconButton
                    disabled={!nextPage}
                    onTouchTap={() => this.goTo({page: nextPage})}
                    style={{float: "left", top: "3px"}}
                >
                    <NavigationChevronRight />
                </IconButton>
            </ToolbarGroup>
        );
    }

    getSortingMenu() {
        if (!this.props.sorting.get('available')) return null;

        const available = this.props.sorting.getIn(['available',
                                                    this.props.modelName]);
        const current = this.props.sorting.getIn(['current', 'by']);
        const currentAsc = this.props.sorting.getIn(['current', 'ascending']);

        let upwardColor = (currentAsc) ?
            this.context.muiTheme.flatButton.primaryTextColor :
            this.context.muiTheme.flatButton.textColor;

        let downwardColor = (!currentAsc) ?
            this.context.muiTheme.flatButton.primaryTextColor :
            this.context.muiTheme.flatButton.textColor;

        return (
            <ToolbarGroup>
                <DropDownMenu
                    key="sortSelector"
                    onChange={(ev, i, value) => {
                        const sorting = this._makeSorting({
                            by: value,
                            ascending: currentAsc,
                        });

                        this.goTo({sorting});
                    }}
                    style={{marginRight: "0px"}}
                    value={current}
                >
                    {available.map(([type, desc]) => {
                        return (
                            <MenuItem
                                key={`sort_${type}`}
                                primaryText={desc}
                                value={type}
                            />
                        );
                    }).toJS()}
                </DropDownMenu>
                <IconButton
                    key="sortAsc"
                    onTouchTap={() => {
                        if (!currentAsc) {
                            const sorting = this._makeSorting({
                                by: current,
                                ascending: true,
                            });
                            this.goTo({sorting});
                        }
                    }}
                    style={{float: "left", top: "3px"}}
                >
                    <NavigationArrowUpward
                        color={upwardColor}
                        key="sortAscArrow"
                    />
                </IconButton>
                <IconButton
                    key="sortDesc"
                    onTouchTap={() => {
                        if (currentAsc) {
                            const sorting = this._makeSorting({
                                by: current,
                                ascending: false,
                            });
                            this.goTo({sorting});
                        }
                    }}
                    style={{float: "left", top: "3px"}}
                >
                    <NavigationArrowDownward
                        color={downwardColor}
                        key="sortDescArrow"
                    />
                </IconButton>
            </ToolbarGroup>
        );
    }

    getModelSwitcher() {
        if (this.props.models.size < 2) return null;

        const models = this.props.models
            .map((model, key) => model.set("key", key))
            .toList()
            .sortBy(model => model.get('order'))
            .toJS();

        const selectedModel = this.props.modelName || models[0].key;

        return (
            <Tabs
                onChange={value => this.goTo({
                    model: value,
                    page: "1",
                    sorting: this.props.sortingDefaults.get(value),
                })}
                value={selectedModel}
            >
                {models.map(model =>
                    <Tab
                        key={`switch_to_${model.key}`}
                        label={model.names[1]}
                        value={model.key}
                    />)}
            </Tabs>
        );
    }

    getActionsMenu() {
        if (this.props.actions.size == 0) return null;

        return (
            <ToolbarGroup>
                <RaisedButton
                    label="Ações"
                    onTouchTap={() => this.setState({showActions: true})}
                    ref={c => {
                        if (!this.state.anchor) {
                            const elem = ReactDOM.findDOMNode(c);
                            this.setState({anchor: elem});
                        }
                    }}
                />
                <Popover
                    anchorEl={this.state.anchor}
                    anchorOrigin={{
                        vertical: 'bottom',
                        horizontal: 'left',
                    }}
                    canAutoPosition={false}
                    onRequestClose={() => this.setState({showActions: false})}
                    open={this.state.showActions}
                >
                    <Menu
                        onEscKeyDown={() => this.setState({showActions: false})}
                    >
                        {this.props.actions.map((action, key) =>
                            <MenuItem
                                key={key}
                                onTouchTap={() => {
                                    this.setState({showActions: false});
                                    this.props.startServerAction(key);
                                }}
                                primaryText={action.get('title')}
                            />
                        ).valueSeq().toArray()}
                    </Menu>
                </Popover>

            </ToolbarGroup>
        );
    }

    render() {
        let contents = (
            <div style={{textAlign: "center"}}>
                <CircularProgress
                    size={80}
                    thickness={7}
                />
            </div>
        );

        if (this.props.error) {
            contents = <div></div>;
        }
        else if (this.props.isSearch) {
            let Item = this.props.templates[this.props.modelName];

            let tiles = this.props.results.map(record => {
                const id = record.get('id', null);
                const link = (id) ? this.searchAndDetailsHtml(id) : null;
                const groupName = (!id) ? record.get('__groupName') : null;
                const extraStyle = (Item.getExtraStyle) ?
                    Item.getExtraStyle(record) :
                    {};

                const handleToggleSelect = () => {
                    this.props.toggleSelectionSearch({ids: I.List([`${id}`])});
                };

                const isSelected = (id) ? this.props.selection.toSet()
                    .has(`${id}`) : false;

                return (
                    <li
                        key={`li_${id || groupName}`}
                        style={{
                            minWidth: "200px",
                            margin: "10px",
                            ...extraStyle,
                        }}
                    >
                        <Item
                            goTo={this.props.goTo}
                            groupName={groupName}
                            id_={id}
                            isSelected={isSelected}
                            key={id || groupName}
                            link={link}
                            makeLink={this.searchAndDetailsHtml.bind(this)}
                            record={record}
                            onSelect={handleToggleSelect}
                        />
                    </li>
                );

            }).toJS();

            let pagination = this.getPaginationElement();
            let sorting = this.getSortingMenu();
            let actions = this.getActionsMenu();
            let models = this.getModelSwitcher();

            contents = (
                <div style={{marginBottom: "56px"}}>
                    <Sticky
                        style={{
                            boxShadow: '0 10px 15px 0 rgba(0, 0, 0, 0.4)',
                            position: 'relative',
                            zIndex: 1500,
                            marginBottom: 10,
                        }}
                    >
                        {models}
                        <Toolbar
                            style={{
                                display: "flex",
                                justifyContent: "space-around",
                                alignItems: "stretch",
                                flexFlow: "row wrap",
                                height: "",
                                minHeight: "56px",
                            }}
                        >
                            {pagination}
                            {sorting}
                            {actions}
                            <ToolbarGroup lastChild>
                                <RaisedButton
                                    label="Recarregar"
                                    onTouchTap={() => this._clearCache()}
                                    primary
                                />
                                <RaisedButton
                                    label="Limpar"
                                    onTouchTap={() => this.goToRoot()}
                                    secondary
                                    style={{marginLeft: "0px"}}
                                />
                                <Link to={this.searchHtml({})}>
                                    <IconButton style={{top: "3px"}}>
                                        <SocialShare />
                                    </IconButton>
                                </Link>
                            </ToolbarGroup>
                        </Toolbar>
                    </Sticky>
                    <StickyContainer>
                        <ul
                            style={{
                                display: "flex",
                                flexFlow: "row wrap",
                                listStyle: "none",
                                paddingBottom: 10,
                            }}
                        >
                            {tiles}
                        </ul>
                    </StickyContainer>
                </div>
            );
        }

        return contents;
    }
}