Source: components/table.vue

<template>
    <Header :name="$route.name" :title="title ?? $t($route.name)" v-if="!parent" :backButton="backButton" />
    <div ref="parentElement" @keydown="handleKeyDown($event, true)">

        <!-- Overlays for inline editing -->
        <q-input v-if="overlays.overlayInput" class="input-box" outlined ref="overlayInput" v-model="editedItem"
            type="text" @blur="closeOverlay" @keydown="handleKeyDown($event, false)" :style="overlayStyle" />
        <JsonEditor v-if="overlays.overlayJson" ref="overlayJson" v-model="editedItem" @blur="closeOverlay"
            @keydown="handleKeyDown($event, false)" :style="overlayStyle" />
        <q-input v-if="overlays.overlayText" class="textarea" type="textarea" rows=8 ref="overlayText"
            v-model="editedItem" @blur="closeOverlay" @keydown="handleKeyDown($event, false)" :style="overlayStyle"
            :iconPicker="false" />
        <autocomplete v-if="overlays.overlaySelect" ref="overlaySelect" class="input-box" v-model="editedItem"
            :options="overlaySelectOptions.options" :option-label="overlaySelectOptions.optionLabel"
            :option-value="overlaySelectOptions.optionValue" @blur="closeOverlay"
            @update:model-value="editedItemChanged" emit-value map-options :style="overlayStyle" />
        <icon-picker v-if="overlays.overlayIcon" ref="overlayIcon" v-model="editedItem" @blur="closeOverlay"
            @update:model-value="editedItemChanged" :style="overlayStyle" />

        <q-dialog v-model="showDetails">
            <table-details v-if="showDetails && details" :details="details" :parent="current"
                @close="showDetails = false" />
        </q-dialog>
        <div v-if="contextValuesLocal" class="row">
            <Autocomplete v-for="cv of contextValuesLocal" :key="cv.name" v-model="cv.value" :label="cv.label"
                :option-label="cv.optionLabel" :option-value="cv.optionValue" dense :options="cv.options"
                @update:model-value="reload" :style="{ width: cv.width ?? '100px' }" map-options emit-value />
        </div>
        <!-- Toolbar before the table -->
        <div class="header-container toolbar">
            <div class="left-item">
                <!-- <q-btn v-if="backButton" dense flat icon="arrow_back" @click="$router.go(-1)">
                    <q-tooltip>{{ $t("Back") }}</q-tooltip>
                </q-btn> -->
                <q-btn v-for="action of  tableActions " dense flat :icon="action.icon"
                    :color="action.iconColor ?? 'primary'" no-caps @click="runTableAction(action)">
                    <div v-if="action.label" v-html="action.label"></div>
                    <q-tooltip v-if="action.tooltip">{{ $t(action.tooltip) }}</q-tooltip>
                </q-btn>
                <q-btn dense flat icon="filter_alt" color="primary" @click="showFilter = true">
                    <q-tooltip>{{ $t("Filter form") }}</q-tooltip>
                </q-btn>
                <q-btn v-if="filterSet" dense flat icon="filter_alt_off" color="primary" @click="clearFilter">
                    <q-tooltip>{{ $t("Clear filter") }}</q-tooltip></q-btn>
                <q-btn dense flat icon="refresh" color="primary" @click="reload">
                    <q-tooltip>{{ $t("Reload data") }}</q-tooltip></q-btn>
                <q-btn dense flat :icon="grid ? 'view_list' : 'view_module'" color="primary" @click="grid = !grid">
                    <q-tooltip>{{ $t("Toggle grid view") }}</q-tooltip></q-btn>
                <q-btn v-if="standardEditing" class="text-bold" dense flat icon="add" color="primary" @click="addRow()">
                    <q-tooltip>{{ $t("Add new row") }}</q-tooltip></q-btn>
                <q-btn v-if="$store.formChanged" dense flat icon="save" color="positive" @click="saveRows"><q-tooltip>{{
        $t("Save changes") }}</q-tooltip></q-btn>
                <q-btn v-if="$store.formChanged" dense flat icon="undo" color="negative"
                    @click="undoChanges"><q-tooltip>{{
        $t("Undo changes") }}</q-tooltip></q-btn>
            </div>
            <div class="right-item">
                <span v-if="nRows > 0">
                    {{ $t("Records") }} {{ from }}-{{ to }} {{ $t("of") }} {{ nRows }}
                </span>
                <span v-else>{{ $t("No data available") }}</span>
                <q-btn v-if="from > 1" dense flat icon="first_page" color="primary" @click="$refs.table.scrollTo(0)">
                    <q-tooltip>{{ $t("First record") }}</q-tooltip>
                </q-btn>
                <q-btn v-if="to < nRows" dense flat icon="last_page" color="primary"
                    @click="$refs.table.scrollTo(nRows - 1)">
                    <q-tooltip>{{ $t("Last record") }}</q-tooltip>
                </q-btn>
                <q-btn v-if="nRows > 0" dense flat icon="download" color="primary" @click="exportTable">
                    <q-tooltip>{{ $t("Download table") }}</q-tooltip>
                </q-btn>
            </div>
        </div>

        <!-- The table -->
        <q-table v-if="loaded" ref="table" class="my-sticky-header-table" @virtual-scroll="scroll"
            :table-style="tableStyle" dense flat bordered :rows="filterSet ? rowsFiltered : rows" :columns="columns"
            :visible-columns="visibleColumns" :row-key="key" virtual-scroll virtual-scroll-slice-size=1
            v-model:pagination="pagination" :rows-per-page-options="[0]" :selection="selection"
            v-model:selected="selectedRows" hide-bottom :selected-rows-label="selectedRowsLabel" :grid="grid">

            <!-- Header -->
            <template v-slot:header="props">

                <!-- column names -->
                <q-tr :props="props">
                    <q-th v-if="selection != 'none'" style="width:'15px'">
                        <q-checkbox dense v-if="selection == 'multiple'" v-model="props.selected" />
                    </q-th>
                    <q-th v-if="standardEditing || rowActions || isAdmin">
                    </q-th>
                    <q-th v-for="  col  in  props.cols " :key="col.name" :props="props">
                        <span class="text-bold" v-html="col.label" :style="{ display: 'inline-block' }"></span>
                    </q-th>
                </q-tr>

                <!-- filter row -->
                <q-tr :props="props">
                    <q-td v-if="selection != 'none'" style="width:'15px'" />
                    <q-td v-if="standardEditing || rowActions || isAdmin" />
                    <q-td v-for="  col   in   props.cols  " :key="col.name" :props="props" class="q-pa-none  q-ma-none">
                        <q-checkbox v-if="col.type == 'boolean'" v-model="filter[col.name]" dense
                            toggle-indeterminate />
                        <q-input v-else type="search" placeholder="Search" placeholder-color="lightgray" clearable dense
                            v-model="filter[col.name]" class="q-my-none q-py-none">
                            <template v-slot:after>
                                <q-icon size="xs" name="search"></q-icon>
                            </template>
                        </q-input>
                    </q-td>
                </q-tr>
            </template>

            <!-- grid rows -->
            <template v-slot:item="props">
                <div class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3 grid-style-transition">
                    <!-- :style="props.selected ? 'transform: scale(0.95);' : ''"> -->
                    <q-card flat>
                        <!-- :class="props.selected ? 'bg-grey-2' : ''"> -->
                        <q-card-section v-if="selection != 'none' || standardEditing || rowActions">
                            <q-checkbox v-if="selection != 'none'" dense v-model="props.selected"
                                :label="props.row.name"></q-checkbox>
                            <table-row-toolbar :parent="this" :props="props" :columns="columns"
                                :rowActions="rowActions" />
                        </q-card-section>
                        <q-separator></q-separator>
                        <q-list dense>
                            <q-item v-for="col  in  props.cols" :key="col.name">
                                <span class="label">{{ col.label }}</span>&nbsp;<q-space />
                                <!-- <span v-html="col.value"></span> -->
                                <span @click="showOverlay(props.key + '-' + col.index, col, props)">
                                    <q-checkbox dense v-if="col.type == 'boolean'" v-model="props.row[col.index]"
                                        :disable="!standardEditing || col.disabled || noInlineEditing"
                                        @update:model-value="changedRows[props.row[0]] = [...props.row]; $store.formChanged = true;" />
                                    <span v-else :ref="props.key + '-' + col.index"
                                        v-html="col.password ? '********' : col.value" class="q-pa-none q-ma-none"
                                        :style="{ display: 'inline-block', overflow: 'hidden', maxWidth: col.width + '!important', maxHeight: col.height + '!important', verticalAlign: 'middle' }" />
                                </span>
                            </q-item>
                        </q-list>
                    </q-card>
                </div>
            </template>

            <!-- normal rows -->
            <template v-slot:body="props">
                <q-tr :props="props" :class="{ 'background': (props.pageIndex % 2 == 0) }">
                    <q-td v-if="selection != 'none'">
                        <q-checkbox dense v-model="props.selected" />
                    </q-td>
                    <q-td v-if="standardEditing || rowActions || isAdmin">
                        <table-row-toolbar :parent="this" :props="props" :columns="columns" :rowActions="rowActions" />
                        <!-- <q-btn class="nompy" :size="btnSize" v-if="standardEditing" dense flat icon="edit"
                            color="primary" @click="editRow(props.row)">
                            <q-tooltip>{{ $t("Edit row") }}</q-tooltip>
                        </q-btn>
                        <q-btn class="nompy" :size="btnSize" v-if="standardEditing" dense flat icon="delete"
                            color="negative" @click="deleteRow(props.row)">
                            <q-tooltip>{{ $t("Delete row") }}</q-tooltip>
                        </q-btn>
                        <q-btn class="nompy" :size="btnSize" v-if="details" dense flat icon="expand_more"
                            color="primary" @click="openDetails(props.row)"><q-tooltip>{{ $t("Detail tables")
                                }}</q-tooltip></q-btn>
                        <q-btn class="nompy" :size="btnSize" v-for="   action    of    rowActions   " dense flat
                            :icon="action.icon" :color="action.iconColor ?? 'primary'"
                            @click="runRowAction(action, props.row)">
                            <q-tooltip v-if="action.tooltip">{{ $t(action.tooltip) }}</q-tooltip>
                        </q-btn>
                        <q-btn class="nompy" :size="btnSize" v-if="isAdmin" dense flat icon="info" color="primary"
                            @click="showRowInfo(props.row)">
                            <q-tooltip>{{ $t("Row info") }}</q-tooltip></q-btn> -->
                    </q-td>
                    <q-td v-for="   col    in    props.cols   " :key="col.name" :props="props"
                        :style="{ maxWidth: col.width + ' !important', verticalAlign: 'middle' }"
                        @click="showOverlay(props.key + '-' + col.index, col, props)" class="q-pa-none q-ma-none">
                        <!-- <div class="text-left q-gutter-md" :style="{ maxWidth: col.width }"> -->
                        <q-checkbox dense v-if="col.type == 'boolean'" v-model="props.row[col.index]"
                            :disable="!standardEditing || col.disabled || noInlineEditing"
                            @update:model-value="changedRows[props.row[0]] = [...props.row]; $store.formChanged = true;" />
                        <span v-else :ref="props.key + '-' + col.index" v-html="col.password ? '********' : col.value"
                            class="q-pa-none q-ma-none"
                            :style="{ display: 'inline-block', overflow: 'hidden', maxWidth: col.width + '!important', maxHeight: col.height + '!important', verticalAlign: 'middle' }" />
                    </q-td>
                </q-tr>
            </template>
        </q-table>

        <!-- The dialog for filtering the table -->
        <q-dialog v-model="showFilter">
            <q-card style="overflow: hidden">
                <q-toolbar dark dense flat>
                    <q-toolbar-title>{{ $t("Filter") }}</q-toolbar-title>
                    <q-btn round margin="xs" size="xs" padding="xs" icon="close" @click="showFilter = false"></q-btn>
                </q-toolbar>
                <q-card-section :style="filterStyle">
                    <div class="row items-center" v-for="col in columns" :key="col.name">
                        <q-input v-model="filter[col.name]" :label="col.label" dense style="width:120px">
                            <template v-slot:label>
                                <label for="my-input" v-html="col.label"></label>
                            </template>
                        </q-input>
                        <span v-if="col.compare == 'interval'">&nbsp;_&nbsp</span>
                        <q-input v-if="col.compare == 'interval'" label=" " v-model="filter2[col.name]" dense
                            style="width:120px" />
                    </div>
                </q-card-section>
            </q-card>
        </q-dialog>

        <table-row-editor v-if="inEdit" :parent=this @save="saveRow" @cancel="inEdit = false" :editStyle="editStyle" />
    </div>
</template>

<script>

/**
 * Generic table component
 * 
 * @component
 * @name Table
 * @example
 * <Table />
 */

import { TableEditMixin } from '../mixins/table-edit.js';
import { TableMixin } from '../mixins/table.js';
import { TableCustomMixin } from '../mixins/table-custom.js';
import Autocomplete from './autocomplete.vue';
import JsonEditor from "./json-editor.vue";
import IconPicker from "./icon-picker.vue";
import Header from './header.vue';
import TableRowEditor from './table-row-editor.vue';
import TableRowToolbar from './table-row-toolbar.vue';
import { defineAsyncComponent } from 'vue';

export default {
    name: "Table",
    mixins: [TableMixin, TableEditMixin, TableCustomMixin],
    components: { Header, TableRowEditor, TableRowToolbar, Autocomplete, JsonEditor, IconPicker, TableDetails: defineAsyncComponent(() => import('./table-details.vue')) },
    beforeRouteEnter(to, from, next) {
        next(vm => { vm.init(to.name); });
    },
    beforeRouteUpdate(to, from, next) {
        this.init(to.name);
        next();
    },
    watch: {
        filter: {
            handler(val) {
                this.filterSet = this.columns.find(col => this.filter[col.name]?.toString().length > 0 || this.filter2[col.name]?.toString().length > 0);
            },
            deep: true
        }
    },
    data: () => ({
    }),
    computed: {
        btnSize() {
            return this.$q.screen.gt.sm ? 'sm' : 'md';
        },
        nRows() {
            if (!this.rows) return 0;
            return this.filterSet ? this.rowsFiltered.length : this.rows.length;
        },
        editColumns() {
            let ec = [...this.columns];
            this.swapIdAndValColumns(ec);
            return ec.filter(col => this.showColInEdit(col));
        },
        editStyle() {
            return {
                maxHeight: (this.$q.screen.height - 200) + 'px', overflow: 'auto', minWidth: "600px"
            };
        },
        filterStyle() {
            return { maxHeight: (this.$q.screen.height - 200) + 'px', overflow: 'auto' };
        },
        tableStyle() {
            return `height: ${this.$q.screen.height - (this.contextValuesLocal ? 150 : 110)}px; width: ${this.$q.screen.width - 5 - (this.$store.drawer ? this.$store.drawerWidth : 0)}px;`;
        },
        rowsFiltered() {
            return this.rows.filter(row => {
                for (let col of this.columns.filter(col => !(this.filter[col.name] === '') || !(this.filter2[col.name] === ''))) {
                    if (col.compare == 'interval') {
                        if (this.filter[col.name] != '' && row[col.index] < this.filter[col.name]) return false;
                        if (this.filter2[col.name] != '' && row[col.index] > this.filter2[col.name]) return false;
                    } else if (col.type == 'boolean') {
                        if (this.filter[col.name] == null) return true;
                        if (row[col.index] != this.filter[col.name]) return false;
                    } else {
                        if (!row[col.index]?.toString().toLowerCase().includes(this.filter[col.name].toLowerCase())) {
                            return false;
                        }
                    }
                }
                return true;
            });
        },
    },
    methods: {
        /**
         * Clear the filter
         */
        clearFilter() {
            for (let col of this.columns) {
                this.filter[col.name] = ''; this.filter2[col.name] = '';
            }
        },
        /**
         * Scroll event
         * @param {*} details 
         */
        scroll(details) {
            this.from = details.from + 1;
            this.to = details.to + 1;
            this.closeAllOverlays();
        },
        /**
         * Text displaying the number of selected rows
         */
        selectedRowsLabel() {
            this.selectedRows.length + ' ' + this.$t('selected');
        },

        /**
         * Opens the details view for a given row.
         *
         * @param {Object} row - The row object containing the data for the selected row.
         */
        openDetails(row) {
            let parentName = "";
            if (this.parentName) {
                if (!this.frugal) {
                    parentName = row[this.parentName];
                } else {
                    parentName = row[this.columns.find(x => x.name == this.parentName).index];
                }
            }
            this.current = { key: this.parentKey, value: row[0], name: parentName };
            this.showDetails = true;
        },

        /**
         * Handles the keydown event.
         * 
         * @param {Event} event - The keydown event object.
         * @param {boolean} isParent - Indicates whether the event is triggered by a parent component.
         */
        handleKeyDown(event, isParent) {
            if (event.ctrlKey && event.key === 'f') {
                this.showFilter = true;
                event.preventDefault();
                this.closeOverlay();
            } else if (event.altKey && event.key === 'a') {
                this.addRow();
                event.preventDefault();
                this.closeOverlay();
            } else if (event.ctrlKey && event.key === 's') {
                event.preventDefault();
                event.stopPropagation();
                this.saveRows();
                this.closeOverlay();
            } else if (event.ctrlKey && event.key === 'u') {
                this.undoChanges();
                event.preventDefault();
                this.closeOverlay();
            } else if (!isParent) {
                this.editedItemChanged();
            }
        },
    }
}
</script>

<style scoped>
.label {
    color: gray;
}

.toolbar {
    top: 76px;
    width: 100%;
    z-index: 100;
}

.my-sticky-header-table {
    background-color: white;
}

.my-sticky-header-table thead tr:first-child th {
    /* background-color: #1976D2; */
    background-color: var(--q-primary);
    color: white;
}

.my-sticky-header-table {
    top: 0px !important;
}

.my-sticky-header-table thead tr:first-child th {
    position: sticky;
    z-index: 1;
    top: 0px;
}

:deep(.input-box .q-field__control),
:deep(.input-box .q-field__marginal) {
    height: 32px;
    padding: 0px 2px 2px 2px;
    border-radius: 0px;
    background-color: white;
}

:deep(.text-box .q-field__control),
:deep(.text-box .q-field__marginal) {
    height: 100px;
    padding: 0px 2px 2px 2px;
    border-radius: 0px;
    background-color: white;
}

.textarea {
    outline: 1px solid #ccc;
    background-color: white;
}
</style>