Source: 05-CTable.jsx

/**
 * Main component.
 *
 * Includes server interaction logic, pagination, and other.
 *
 * @arg {string} this.props.endpoint - Url to endpoint.
 * @arg {Object} this.props.params - Additional parameters to select query
 * @arg {undefined|Boolean} this.props.no_pagination - Disable pagination.
 * @arg {String[]} this.props.tabs - Tabs names.
 * @arg {Object} this.props.filters - Set presistent filter as column => value.
 * @arg {Object[]} this.props.columns -  List of columns. Each column will be passed as props to column object.
 * @arg {string} this.props.columns[].name - Name of column.
 * @arg {string} this.props.columns[].title - Title of column.
 * @arg {Class} this.props.columns[].kind - Column class derived from CTableColumn.
 * @arg {string} this.props.columns[].footnote - Footnote for column editor.
 */

class CTable extends Component {
    constructor() {
        super();

        this.recordsOnPageChanged = this.recordsOnPageChanged.bind(this);
        this.toFirstPage = this.toFirstPage.bind(this);
        this.toLastPage = this.toLastPage.bind(this);
        this.toNextPage = this.toNextPage.bind(this);
        this.toPrevPage = this.toPrevPage.bind(this);
        this.toPage = this.toPage.bind(this);
        this.toTab = this.toTab.bind(this);

        this.state = {
            records:[],

            columns: [],
            endpoint: '',

            opened_editors:[],
            opened_subtables:[],

            current_page: 0,
            records_on_page: 25,
            total_records: 0,
            total_pages: 0,
            current_tab:0,
            waiting_active: false,
        };

        this.changes = [];
        this.valids = [];
        this.changes_handlers = [];
        this.subtables_params = {};
        this.options_cache = {};
    }

    componentDidMount() {
        this.state.columns = this.props.columns;
        this.state.endpoint = this.props.endpoint;
        this.setState({});
        this.reload();
    }

    /**
     * Getting column index by column name.
     *
     * For call from column class.
     *
     * @param {string} name - Column name.
     * @returns {int|null} Column index in current table or `null`.
     */

    column_by_name(name){
        for(var i = 0; i < this.props.columns.length; i++){
            if(this.props.columns[i].name == name){
                return i;
            }
        }
        return null;
    }

    /**
     * Open subtable in table.
     *
     * For call from column class.
     *
     * @param {int} row - Row index.
     * @param {List[]} keys - Constrains for subtable.
     * @param {string} keys[].0 - Subtable column name.
     * @param {string} keys[].1 - Current table column name which value will be used as constrain.
     * @param {Object} config - Subtable props including columns.
     */

    open_subtable(row, keys, config){
        if (this.state.opened_subtables.includes(row)){
            this.state.opened_subtables = this.state.opened_subtables.filter(function(item){return item !== row;});
        } else {
            this.state.opened_subtables.push(row);
            this.subtables_params[row] = clone(config);
            self = this;
            this.subtables_params[row].filters = keys.map(function(item){return [item[0], self.state.records[row][item[1]]];});
        }
        this.setState({});
    }

    /**
     * Toggle row editor interface.
     *
     * For call from column class.
     *
     * @param {int} row - Row number. `-1` for new record.
     */

    edit_row(row){
        var self = this;
        if (this.state.opened_editors.includes(row)){
            this.state.opened_editors = this.state.opened_editors.filter(function(item){return item !== row;});
            this.changes = this.changes.filter(function(item){return !(item[0] == row);});
        } else {
            this.state.opened_editors.push(row);
            if (row < 0){
                this.state.columns.map(function (item, i) {
                    if (typeof self.state.columns[i].default !== 'undefined'){
                        self.changes.push([row, i,  self.state.columns[i].default]);
                    }
                });
            }
        }
        this.setState({});
    }

    /**
     * Delete row action.
     *
     * For call from column class. Reload table.
     *
     * @param {int} row - Row number.
     */

    delete_row(row){
        var xkeys = {};
        var self = this;

        this.state.columns.map(function (item, i) {
            if(item.is_key == true){
                xkeys[item.name] = self.state.records[row][item.name];
            }

        });

        this.setState({waiting_active: true});

        var fd = new FormData();
        fd.append('delete', JSON.stringify(xkeys));

        fetch(this.props.endpoint, { method: "POST", body: fd})
        .then(function(response){
            if (response.ok) { return response.json(); }
            else {
                alert(self.props.lang.server_error + response.status);
                self.setState({waiting_active: false});
            }})
        .then(function (result) {
            if (!result) return;
            if(result.Result == 'OK'){
                self.options_cache = {};
                self.state.opened_editors = self.state.opened_editors.filter(function(item){return item !== row;});
                self.reload();
            } else {
                alert(self.props.lang.error + result.Message);
                self.setState({waiting_active: false});
            }
        });
    }

    /**
     * Save row action.
     *
     * For call from column class. Reload table.
     *
     * @param row {int} Row number. `-1` for new record.
     */

    save_row(row){
        if(this.valids.filter(function(item){return item[0] == row;}).filter(function(item){return item[2] == false;}).length > 0)
            return;
        if(this.changes.length == 0)
            return;

        var xvalues = {};
        var self = this;
        var cmd = 'update';

        if (row >= 0){
            this.state.columns.map(function (item, i) {
                if(item.is_key == true){
                    xvalues[item.name] = self.state.records[row][item.name];
                }
            });
            this.changes.filter(function(item){return item[0] == row;}).map(function (item) {
                if(self.state.columns[item[1]].name != ''){
                    xvalues[self.state.columns[item[1]].name] = item[2];
                }
            });
        } else {
            cmd = 'insert';
            this.changes.filter(function(item){return item[0] == row;}).map(function (item) {
                if (self.state.columns[item[1]].name != ''){
//                if ((!self.state.columns[item[1]].is_key) && (self.state.columns[item[1]].name != '')){
                    xvalues[self.state.columns[item[1]].name] = item[2];
                }
            });
        }

        if (typeof this.props.filters !== 'undefined'){
            this.props.filters.forEach(function(item){xvalues[item[0]] = item[1];});
        }

        this.setState({waiting_active: true});

        var fd = new FormData();
        fd.append(cmd, JSON.stringify(xvalues));

        fetch(this.props.endpoint, { method: "POST", body: fd})
        .then(function(response){
            if (response.ok) {return response.json();}
            else {
                alert(self.props.lang.server_error + response.status);
                self.setState({waiting_active: false});
            }})
        .then(function (result) {
            if (!result) return;
            if(result.Result == 'OK'){
                self.options_cache = {};
                self.state.opened_editors = self.state.opened_editors.filter(function(item){return item !== row;});
                self.reload();
            } else {
                alert(self.props.lang.error + result.Message);
                self.setState({waiting_active: false});
            }
        });
    }

    /**
     * Change filtering for column.
     *
     * For call from column class. Reload table.
     *
     * @param col {int} Column number.
     * @param value {*} Filter value.
     */

    change_filter_for_column(col, value){
        this.state.columns[col].filtering = value;
        this.state.current_page = 0;
        this.reload();
    }

    /**
     * Change searching for column.
     *
     * For call from column class. Reload table.
     *
     * @param col {int} Column number.
     * @param value {*} Search value.
     */

    change_search_for_column(col, value){
        this.state.columns[col].searching = value;
        this.state.current_page = 0;
        this.reload();
    }

    /**
     * Change sorting for column.
     *
     * For call from column class. Reload table.
     *
     * @param col {int} Column number.
     * @param value {string} Sort order: "ASC", "DESC" and "" for no sorting.
     */

    change_sort_for_column(col, value){
        this.state.columns[col].sorting = value;
        this.reload();
    }

    /**
     * Notify that value in editor is changed.
     *
     * Value stored in table cache and will be sended then `save_row` called.
     * All subscribed widgets will be notifed by `on_changes` call.
     *
     * For call from column class.
     *
     * @param col {int} Column number.
     * @param row {int} Row number. -1 in new row.
     * @param value {Any|null} Value. Set null - reset changes.
     */

    notify_changes(row, col, value){
        this.changes = this.changes.filter(function(item){return !( (item[0] == row) && (item[1] == col) );});
        this.changes_handlers = this.changes_handlers.filter(function(item){return item[2].current != null;});
        this.changes_handlers.filter(function(item){return ((item[0] == row) && (item[1] == col));}).map(function(item){item[3].on_changes(row, col, value);});
        if (value !== null)
            this.changes.push([row, col, value]);
    }

    /**
     * Notify that value in editor is valid or invalid.
     *
     * If any editor notified about invalid value, row will not be saved.
     *
     * For call from column class.
     *
     * @param col {int} Column number.
     * @param row {int} Row number. -1 in new row.
     * @param is_valid {Boolean} Validation status.
     */

    notify_valids(row, col, is_valid){
        this.valids = this.valids.filter(function(item){return !( (item[0] == row) && (item[1] == col) );});
        this.valids.push([row, col, is_valid]);
    }

    /**
     * Subscribe editor to notification about other column changed.
     *
     * Editor should provide ref for unsubscribe if closed.
     *
     * For call from column class.
     *
     * @param row {int} Current row number. -1 in new row.
     * @param col {int} Observed column number.
     * @param ref {PreactRef} Reference to editor.
     * @param obj {CTableColumn} Object to interact.
     */

    subscribe_to_changes(row, col, ref, obj){
        this.changes_handlers.push([row, col, ref, obj]);
    }

    /**
     * Reloading table data.
     */

    reload() {
        if(this.state.columns.length == 0) return;

        var column_filters = {};
        var column_searches = {};
        var column_orders = {};

        var self = this;

        if (typeof this.props.filters !== 'undefined'){
            this.props.filters.forEach(
                function(item){
                    column_filters[item[0]] = item[1];
                });
        }

        this.state.columns.forEach(
            function(item){
                if ((item.name != '') && (typeof item.filtering != 'undefined') && (item.filtering !== null))
                 column_filters[item.name] = item.filtering;
            });

        this.state.columns.forEach(
            function(item){
                if ((item.name != '') && (typeof item.searching != 'undefined') && (item.searching !== null))
                    column_searches[item.name] = item.searching;
            });

        this.state.columns.forEach(
            function(item){
                if ((item.name != '') && (typeof item.sorting != 'undefined') && (item.sorting !== null))
                    column_orders[item.name] = item.sorting;
            });

        var query = {start:(this.props.no_pagination ? 0 : this.state.records_on_page*this.state.current_page),
                     page:(this.props.no_pagination ? 0 : this.state.records_on_page),
                     column_filters:column_filters, column_searches:column_searches, column_orders:column_orders};


        var query_params = new URLSearchParams({select : JSON.stringify(query), ...this.props.params});

        this.setState({waiting_active: true});

        fetch(this.props.endpoint + '?' + query_params.toString())
        .then(function(response){
            if (response.ok) {return response.json();}
            else {
                alert(self.props.lang.server_error + response.status);
                self.setState({waiting_active: false});
            }})
        .then(function (result) {
            if (!result) return;
            if(result.Result == 'OK'){
                self.changes = [];
                self.setState({records: result.Records, total_records: result.TotalRecordCount,
                               records_on_page: self.state.records_on_page,
                               current_page: self.state.current_page,
                               opened_editors: [], opened_subtables: [], waiting_active: false,
                               total_pages: Math.floor(result.TotalRecordCount / self.state.records_on_page) - (result.TotalRecordCount % self.state.records_on_page == 0 ? 1 : 0)
                });
            } else {
                alert(self.props.lang.error + result.Message);
                self.setState({waiting_active: false});
            }
        });
    }

    /**
     * Getting row with unsaved changes.
     *
     * @param row {int} Row index. -1 in new row.
     * @param only_changes {Boolean} Getting only changes or full row with changes.
     *
     * @return {Object} Unsaved values.
     */

    row_changes_summary(row, only_changes = false) {
        var xvalues = {};
        var self = this;

        if (row >= 0){
            if (!only_changes){
                xvalues = this.state.records[row];
            }
        }

        this.changes.filter(function(item){return item[0] == row;}).map(
            function (item) {
                if(self.state.columns[item[1]].name != ''){
                    xvalues[self.state.columns[item[1]].name] = item[2];
                }
            });

        return xvalues;
    }

    load_options(endpoint, params, elem, ref) {

        var query_params = new URLSearchParams({options : '{}', ...params});

        var url = endpoint + '?'+ query_params.toString();
        var self = this;
        if (url in this.options_cache){
            if (this.options_cache[url] == null){
                setTimeout(function()
                    {
                        self.load_options(endpoint, params, elem, ref);
                    }, 100);
            } else {
                elem.setState({options: this.options_cache[url]});
            }
        } else {
            this.options_cache[url] = null;
            fetch(url)
            .then(function(response){
                if (response.ok) {return response.json();}
                else {
                    alert(self.props.lang.server_error + response.status)
                    self.setState({waiting_active: false});
                }})
            .then(function (result) {
                if (!result) return;
                if(result.Result == 'OK'){
                    self.options_cache[url] = result.Options;
                    //if(ref.current != null){
                        elem.setState({options: self.options_cache[url]});
                    //}
                } else {
                    alert(self.props.lang.error + result.Message);
                    self.setState({waiting_active: false});
                }});
        }

    }

    recordsOnPageChanged(e){
        var new_rec_on_page = parseInt(e.target.value);
        if (new_rec_on_page == this.state.records_on_page) return;
        this.state.records_on_page = new_rec_on_page;
        this.state.current_page = 0;
        this.reload();
    }

    toFirstPage(e){
        if (this.state.current_page != 0){
            this.state.current_page = 0;
            this.reload();
        }
    }

    toLastPage(e){
        if (this.state.current_page != this.state.total_pages){
            this.state.current_page = this.state.total_pages;
            this.reload();
        }
    }

    toNextPage(e){
        if (this.state.current_page < this.state.total_pages){
            this.state.current_page = this.state.current_page + 1;
            this.reload();
        }
    }

    toPrevPage(e){
        if (this.state.current_page - 1 >= 0){
            this.state.current_page = this.state.current_page - 1;
            this.reload();
        }
    }

    toPage(e){
        this.state.current_page = parseInt(e.target.value);
        this.reload();
    }

    toTab(e){
        this.setState({current_tab: e.target.dataset.tabindex})
    }


    get_pages(){
        if (this.state.total_records == 0){
            return [0];
        }
        var pages = [];
        for(var i = 0; i <= Math.floor(this.state.total_records / this.state.records_on_page); i++){
            pages.push(i);
        }
        if(this.state.total_records % this.state.records_on_page == 0){
            pages.pop();
        }
        return pages;
    }

    /**
     * Return count of visible columns.
     */

    visible_column_count() {
        return this.state.columns.filter(function(item){return item.hide_column != true;}).length;
    }

    render() {

        var self = this;

        var tbody = <tbody>
            {self.state.opened_editors.includes(-1) ? <tr><td colspan={self.visible_column_count()}>
                <div class="panel" style="padding: 0.5em">
                {typeof self.props.tabs !== 'undefined' ? <div class="panel-tabs"> {self.props.tabs.map(function(tab,k){return <a class={self.state.current_tab == k ? "" : "is-active"} data-tabindex={k} onClick={self.toTab}>{tab}</a>; })} </div> : '' }
                {typeof self.props.tabs !== 'undefined' ?<>
                  {self.props.tabs.map(function(tab,k){return <div class={self.state.current_tab == k ? "" : "is-hidden"}>
                      {self.state.columns.map(function(column,j){
                           return <>
                               {(column.hide_editor != true && column.tab == k) ? h(column.kind, { role: "editor", is_new:true, table: self, column: j, row: -1, key: j*1000000, ...column }) : ''}
                           </>; })}
                  </div>; })}
                  {self.state.columns.map(function(column,j){
                           return <>
                               {(column.hide_editor != true && column.tab == -1) ? h(column.kind, { role: "editor", is_new:true, table: self, column: j, row: -1, key: j*1000000, ...column }) : ''}
                           </>; })}</>
                : self.state.columns.map(function(column,j){
                    return <>
                        {column.hide_editor != true ? h(column.kind, { role: "editor", is_new:true, table: self, column: j, row: -1, key: j*1000000, ...column }) : ''}
                    </>; })
                }
                </div>
                </td></tr> : ''}

            {self.state.records.map(function(cell,i){
                return <> <tr  class={(self.state.opened_editors.includes(i) || self.state.opened_subtables.includes(i)) ? "is-selected" : ""}> {
                    self.state.columns.map(function(column,j){return (column.hide_column != true ? <td>                           {h(column.kind, { role: "cell", table: self, column: j, row: i, key: j*1000000+i, ...column })}</td> : '');})
                } </tr>
                {self.state.opened_editors.includes(i) ? <tr><td colspan={self.visible_column_count()}>
                <div class="panel" style="padding: 0.5em">
                {typeof self.props.tabs !== 'undefined' ? <div class="panel-tabs"> {self.props.tabs.map(function(tab,k){return <a class={self.state.current_tab == k ? "" : "is-active"} data-tabindex={k} onClick={self.toTab}>{tab}</a>; })} </div> : '' }
                {typeof self.props.tabs !== 'undefined' ?<>
                  {self.props.tabs.map(function(tab,k){return <div class={self.state.current_tab == k ? "" : "is-hidden"}>
                      {self.state.columns.map(function(column,j){
                           return <>
                               {(column.hide_editor != true && column.tab == k) ? h(column.kind, { role: "editor", is_new:false, table: self, column: j, row: i, key: j*1000000+i, ...column }) : ''}
                           </>; })}
                  </div>; })}
                  {self.state.columns.map(function(column,j){
                           return <>
                               {(column.hide_editor != true && column.tab == -1) ? h(column.kind, { role: "editor", is_new:false, table: self, column: j, row: i, key: j*1000000+i, ...column }) : ''}
                           </>; })}</>
                : self.state.columns.map(function(column,j){
                    return <>
                        {column.hide_editor != true ? h(column.kind, { role: "editor", is_new:false, table: self, column: j, row: i, key: j*1000000+i, ...column }) : ''}
                    </>; })
                }
                </div>
                </td></tr> : ''}
                {self.state.opened_subtables.includes(i) ? <tr><td colspan={self.visible_column_count()}>
                {h(CTable, {lang:self.props.lang, ...self.subtables_params[i]})}
                </td></tr> : ''}
                </>; }) }
                {self.state.records.length == 0 ? <tr><td colspan={self.visible_column_count()}><div class="has-text-centered">{this.state.waiting_active ? self.props.lang.loading : self.props.lang.no_data}</div></td></tr> : ''}
        </tbody>;

        var current_page_block = <div class="button"> {this.props.lang.current_page} {this.state.current_page+1} {this.props.lang.from} {this.state.total_pages+1}.</div>;

        if(this.state.total_pages < 0){
            var current_page_block = <div class="button"> {this.props.lang.no_pages}.</div>;
        }

        var pager = <div class="field has-addons" style="justify-content:center;">
                        <div class="control">
                            <div class="button" onClick={this.toFirstPage} title={this.props.lang.to_first_page}><span class="icon">⇤</span></div>
                        </div>
                        <div class="control">
                            <div class="button" onClick={this.toPrevPage} title={this.props.lang.to_prev_page}><span class="icon">←</span></div>
                        </div>
                        <div class="control">
                            <div class="select">
                                <select value={this.state.current_page} onChange={this.toPage}>
                                    {this.get_pages().map(function (page){return <option value={page}>{page+1}</option>;})}
                                </select>
                            </div>
                        </div>
                        <div class="control">
                            <div class="button" onClick={this.toNextPage} title={this.props.lang.to_next_page}><span class="icon">→</span></div>
                        </div>
                        <div class="control">
                            <div class="button" onClick={this.toLastPage} title={this.props.lang.to_last_page}><span class="icon">⇥</span></div>
                        </div>
                        <div class="control">
                            <div class="button">{this.props.lang.show_by}</div>
                        </div>
                        <div class="control">
                            <div class="select">
                                <select value={this.state.records_on_page} onChange={this.recordsOnPageChanged}>
                                    <option value="2">2</option>
                                    <option value="5">5</option>
                                    <option value="10">10</option>
                                    <option value="25">25</option>
                                    <option value="50">50</option>
                                    <option value="100">100</option>
                                    <option value="50000">{this.props.lang.all}</option>
                                </select>
                            </div>
                        </div>
                        <div class="control">
                            {current_page_block}
                        </div>
                    </div>;


        return <div class="table-container ctable-table-container">
            <div class={this.state.waiting_active ? "ctable-loader-wrapper-active" : "ctable-loader-wrapper" } >
                <div class="button is-large is-loading is-info"></div>
            </div>
            <table class="table" style="width: 100%;">
                <thead>
                    <tr>
                        {this.state.columns.map(function (column,i) { return (column.hide_column != true ? <th>{h(column.kind, { role: "header", table: self, column: i, key: i, ...column})} </th>  : ''); } ) }
                    </tr>
                    <tr>
                        {this.state.columns.map(function (column,i) { return (column.hide_column != true ? <th>{h(column.kind, { role: "search", table: self, column: i, key: i, ...column })} </th> : ''); } ) }
                    </tr>
                </thead>
                <tfoot>
                    <tr>
                        {this.state.columns.map(function (column,i) { return (column.hide_column != true ? <th>{h(column.kind, { role: "footer", table: self, column: i, key: i, ...column })} </th> : ''); } ) }
                    </tr>
                </tfoot>
                {tbody}
            </table>
            {this.props.no_pagination ? '' : pager}
        </div>;
    }
}