Fading Coder

One Final Commit for the Last Sprint

Home > Notes > Content

Advanced Vue 2 Component Architecture: Search Modules, Data Tables, and Hierarchical Trees

Notes May 4 24

Query Builder with Collapsible Filter Panel

Implement a configurable search interface supporting dynamic field types and expandable/collapsible layouts.

Core Features

  • Dynamic Field Rendering: Supports text inputs, numeric fields, date ranges, selects, radio groups, and organization trees
  • Collapsible Layout: Automatically toggles between fixed height (showing first 3 fields) and auto-height (showing all fields)
  • Event Delegation: Custom change handlers passed via configuration objects

Implementation

<template>
  <div class="query-builder">
    <el-form
      :model="filters"
      ref="searchForm"
      :label-width="labelWidth"
      hide-required-asterisk
      :show-message="false"
    >
      <el-row 
        :gutter="20" 
        :style="{ height: isCollapsed ? maxHeight + 'px' : 'auto' }"
        class="filter-row"
      >
        <div ref="filterContainer">
          <el-col :span="contentSpan">
            <el-row>
              <el-col 
                :span="8" 
                v-for="field in schema" 
                :key="field.prop"
              >
                <el-form-item :label="field.label" :prop="field.prop">
                  <!-- Text Input -->
                  <el-input
                    v-if="field.type === 'text'"
                    v-model="filters[field.prop]"
                    :placeholder="field.placeholder || 'Enter ' + field.label"
                    :disabled="field.readonly"
                    :maxlength="field.maxLength || 255"
                    clearable
                  />

                  <!-- Selection -->
                  <el-select
                    v-else-if="field.type === 'select'"
                    v-model="filters[field.prop]"
                    :placeholder="field.placeholder || 'Select ' + field.label"
                    :disabled="field.readonly"
                    :loading="field.loading"
                    clearable
                    filterable
                    @change="handleFieldChange(field, $event)"
                  >
                    <el-option
                      v-for="opt in field.options || dict[field.dictKey]"
                      :key="opt.value"
                      :label="opt.label"
                      :value="opt.value"
                    />
                  </el-select>

                  <!-- Numeric Input -->
                  <el-input-number
                    v-else-if="field.type === 'number'"
                    v-model="filters[field.prop]"
                    :disabled="field.readonly"
                    :min="field.min"
                    :max="field.max"
                    controls-position="right"
                    style="width: 100%"
                  />

                  <!-- Date Range -->
                  <el-date-picker
                    v-else-if="field.type === 'dateRange'"
                    v-model="filters[field.prop]"
                    type="daterange"
                    range-separator="To"
                    start-placeholder="Start"
                    end-placeholder="End"
                    value-format="yyyy-MM-dd"
                    :disabled="field.readonly"
                    style="width: 100%"
                  />

                  <!-- DateTime Range -->
                  <el-date-picker
                    v-else-if="field.type === 'dateTimeRange'"
                    v-model="filters[field.prop]"
                    type="datetimerange"
                    range-separator="To"
                    start-placeholder="Start Date"
                    end-placeholder="End Date"
                    value-format="yyyy-MM-dd HH:mm:ss"
                    :default-time="['00:00:00', '23:59:59']"
                    :disabled="field.readonly"
                    style="width: 100%"
                  />

                  <!-- Radio Group -->
                  <el-radio-group
                    v-else-if="field.type === 'radio'"
                    v-model="filters[field.prop]"
                    :disabled="field.readonly"
                  >
                    <el-radio 
                      v-for="opt in field.options" 
                      :key="opt.value" 
                      :label="opt.value"
                    >
                      {{ opt.label }}
                    </el-radio>
                  </el-radio-group>

                  <!-- Tree Select -->
                  <treeselect
                    v-else-if="field.type === 'tree'"
                    v-model="filters[field.prop]"
                    :options="orgTreeData"
                    placeholder="Select Organization"
                    @select="(node) => $emit('org-select', node)"
                  />
                </el-form-item>
              </el-col>
            </el-row>
          </el-col>
        </div>

        <el-col :span="actionSpan" class="action-column">
          <el-button type="primary" size="small" @click="executeSearch">
            Search
          </el-button>
          <el-button size="small" plain @click="resetFilters">
            Reset
          </el-button>
          
          <el-button
            v-if="schema.length > 3"
            type="text"
            size="small"
            @click="toggleCollapse"
          >
            {{ isCollapsed ? 'Expand' : 'Collapse' }}
            <i :class="isCollapsed ? 'el-icon-arrow-down' : 'el-icon-arrow-up'" />
          </el-button>
        </el-col>
      </el-row>
    </el-form>
  </div>
</template>

<script>
import Treeselect from '@riophae/vue-treeselect'
import '@riophae/vue-treeselect/dist/vue-treeselect.css'

export default {
  name: 'QueryBuilder',
  components: { Treeselect },
  props: {
    schema: {
      type: Array,
      default: () => []
    },
    labelWidth: {
      type: String,
      default: '120px'
    },
    contentSpan: {
      type: Number,
      default: 19
    },
    actionSpan: {
      type: Number,
      default: 5
    },
    maxHeight: {
      type: Number,
      default: 45
    },
    defaultExpanded: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      filters: {},
      isCollapsed: true,
      orgTreeData: []
    }
  },
  watch: {
    defaultExpanded: {
      immediate: true,
      handler(val) {
        this.isCollapsed = !val
      }
    }
  },
  methods: {
    handleFieldChange(field, value) {
      if (field.onChange) {
        this.$emit(field.onChange, value)
      }
    },
    toggleCollapse() {
      this.isCollapsed = !this.isCollapsed
      this.$nextTick(() => {
        this.$emit('height-change')
      })
    },
    executeSearch() {
      this.$refs.searchForm.validate(valid => {
        if (valid) {
          this.$emit('search', { ...this.filters })
        }
      })
    },
    resetFilters() {
      const preserveState = { isCollapsed: this.isCollapsed }
      Object.assign(this.$data, this.$options.data())
      Object.assign(this.$data, preserveState)
      this.$emit('reset')
    }
  }
}
</script>

Usage Example

<template>
  <QueryBuilder
    ref="queryPanel"
    :schema="filterSchema"
    @search="loadTableData"
    @reset="handleReset"
    @height-change="recalculateTableHeight"
  />
</template>

<script>
export default {
  data() {
    return {
      filterSchema: [
        {
          type: 'text',
          prop: 'studentId',
          label: 'Student ID',
          placeholder: 'Enter student number'
        },
        {
          type: 'select',
          prop: 'studentName',
          label: 'Student Name',
          options: [], // Populated dynamically
          placeholder: 'Select student'
        },
        {
          type: 'dateRange',
          prop: 'enrollmentDate',
          label: 'Enrollment Period'
        }
      ]
    }
  }
}
</script>

Data Grid with Dynamic Columns

A reusable table component featuring pagination, row selection, and customizable cell rendering through scoped slots.

<template>
  <div class="data-grid">
    <div class="toolbar">
      <slot name="actions" />
    </div>

    <el-table
      ref="dataTable"
      v-loading="loading"
      element-loading-text="Loading..."
      :data="rows"
      :row-key="rowKey"
      :height="tableHeight"
      highlight-current-row
      show-overflow-tooltip
      :row-class-name="rowClassName"
      @selection-change="handleSelectionChange"
    >
      <!-- Selection Column -->
      <el-table-column
        v-if="selectable"
        type="selection"
        width="50"
        :reserve-selection="persistSelection"
        fixed
      />

      <!-- Index Column -->
      <el-table-column
        v-if="showIndex"
        type="index"
        label="#"
        width="50"
        align="center"
        fixed
      />

      <!-- Dynamic Columns -->
      <el-table-column
        v-for="col in columns"
        :key="col.key"
        :prop="col.key"
        :label="col.title"
        :width="col.width"
        :min-width="col.minWidth"
        :align="col.align || 'left'"
        :fixed="col.fixed"
        show-overflow-tooltip
      >
        <template #default="{ row, $index }">
          <slot 
            v-if="col.slot" 
            :name="col.slot" 
            :row="row" 
            :index="$index"
            :column="col"
          />
          <span v-else>{{ row[col.key] || '-' }}</span>
        </template>
      </el-table-column>
    </el-table>

    <pagination
      v-if="!hidePagination && total > 0"
      :total="total"
      :page.sync="pagination.pageNum"
      :limit.sync="pagination.pageSize"
      :page-sizes="[15, 30, 50, 100]"
      @pagination="handlePageChange"
    />
  </div>
</template>

<script>
export default {
  name: 'DataGrid',
  props: {
    rows: Array,
    columns: Array,
    total: {
      type: Number,
      default: 0
    },
    loading: Boolean,
    pagination: {
      type: Object,
      default: () => ({ pageNum: 1, pageSize: 15 })
    },
    selectable: {
      type: Boolean,
      default: true
    },
    singleSelect: {
      type: Boolean,
      default: false
    },
    persistSelection: {
      type: Boolean,
      default: false
    },
    showIndex: {
      type: Boolean,
      default: true
    },
    hidePagination: Boolean,
    rowKey: {
      type: String,
      default: 'id'
    },
    height: String
  },
  data() {
    return {
      tableHeight: this.height || 'calc(100vh - 240px)'
    }
  },
  watch: {
    height(newVal) {
      this.tableHeight = newVal
    }
  },
  methods: {
    handleSelectionChange(selection) {
      let result = selection
      if (this.singleSelect && selection.length > 1) {
        // Keep only the most recent selection for single-select mode
        result = [selection[selection.length - 1]]
        this.$refs.dataTable.clearSelection()
        this.$refs.dataTable.toggleRowSelection(result[0], true)
      }
      this.$emit('selection-change', this.deduplicateByKey(result, this.rowKey))
    },
    deduplicateByKey(arr, key) {
      const seen = new Set()
      return arr.filter(item => {
        const val = item[key]
        if (seen.has(val)) return false
        seen.add(val)
        return true
      })
    },
    handlePageChange() {
      this.$emit('page-change', this.pagination)
    },
    rowClassName({ rowIndex }) {
      return rowIndex % 2 === 0 ? 'even-row' : 'odd-row'
    }
  }
}
</script>

Integration Pattern

<DataGrid
  :rows="studentList"
  :columns="tableSchema"
  :total="recordCount"
  :loading="isLoading"
  :pagination="queryParams"
  :height="dynamicHeight"
  @page-change="fetchData"
>
  <template #actions>
    <el-button type="primary" @click="createRecord">Add New</el-button>
  </template>
  
  <template #operation="{ row }">
    <el-button type="text" @click="editRecord(row)">Edit</el-button>
    <el-button type="text" class="danger" @click="deleteRecord(row)">Delete</el-button>
  </template>
</DataGrid>

Dynamic Form Engine

Configurable form component supporting validation, nested field groups, and asynchronous data fetching.

<template>
  <el-form
    ref="dynamicForm"
    :model="formState"
    :rules="validationRules"
    label-width="120px"
  >
    <el-row :gutter="gutter">
      <el-col
        v-for="field in fields"
        :key="field.name"
        :span="field.span || 8"
        v-show="!field.hidden"
      >
        <el-form-item :label="field.label" :prop="field.name">
          <!-- Text Input -->
          <el-input
            v-if="field.type === 'input'"
            v-model="formState[field.name]"
            :disabled="isDisabled(field)"
            :placeholder="field.placeholder || 'Enter ' + field.label"
            clearable
          />

          <!-- Text Area -->
          <el-input
            v-else-if="field.type === 'textarea'"
            type="textarea"
            :rows="field.rows || 3"
            v-model="formState[field.name]"
            :disabled="isDisabled(field)"
            :maxlength="field.maxLength || 500"
            show-word-limit
          />

          <!-- Number -->
          <el-input-number
            v-else-if="field.type === 'number'"
            v-model="formState[field.name]"
            :min="field.min"
            :max="field.max"
            :disabled="isDisabled(field)"
            controls-position="right"
            style="width: 100%"
          />

          <!-- Select -->
          <el-select
            v-else-if="field.type === 'select'"
            v-model="formState[field.name]"
            :disabled="isDisabled(field)"
            :multiple="field.multiple"
            :placeholder="field.placeholder || 'Select ' + field.label"
            filterable
            clearable
            style="width: 100%"
            @change="(val) => handleChange(field.name, val)"
          >
            <el-option
              v-for="opt in field.options || dictionary[field.dict]"
              :key="opt.value"
              :label="opt.label"
              :value="opt.value"
            />
          </el-select>

          <!-- Radio -->
          <el-radio-group
            v-else-if="field.type === 'radio'"
            v-model="formState[field.name]"
            :disabled="isDisabled(field)"
            @input="(val) => $emit('radio-change', field.name, val)"
          >
            <el-radio
              v-for="opt in dictionary[field.dict] || field.options"
              :key="opt.value"
              :label="opt.value"
            >
              {{ opt.label }}
            </el-radio>
          </el-radio-group>

          <!-- Date Picker -->
          <el-date-picker
            v-else-if="field.type === 'date'"
            v-model="formState[field.name]"
            type="date"
            value-format="yyyy-MM-dd"
            :placeholder="field.placeholder || 'Select date'"
            :disabled="isDisabled(field)"
            style="width: 100%"
          />

          <!-- Date Range -->
          <el-date-picker
            v-else-if="field.type === 'daterange'"
            v-model="formState[field.name]"
            type="daterange"
            value-format="yyyy-MM-dd HH:mm:ss"
            range-separator="To"
            start-placeholder="Start"
            end-placeholder="End"
            :disabled="isDisabled(field)"
            style="width: 100%"
          />

          <!-- Autocomplete -->
          <el-autocomplete
            v-else-if="field.type === 'autocomplete'"
            v-model="formState[field.name]"
            :fetch-suggestions="(query, cb) => $emit('search-suggestions', field.name, query, cb)"
            :placeholder="field.placeholder || 'Search...'"
            :disabled="isDisabled(field)"
            style="width: 100%"
            @select="(item) => $emit('autocomplete-select', field.name, item)"
          />

          <!-- Tree Select -->
          <treeselect
            v-else-if="field.type === 'tree'"
            v-model="formState[field.name]"
            :options="treeData"
            :disabled="isDisabled(field)"
            placeholder="Select..."
          />

          <!-- Custom Slot -->
          <slot v-else-if="field.type === 'custom'" :name="field.name" :field="field" />
        </el-form-item>
      </el-col>
    </el-row>
  </el-form>
</template>

<script>
import Treeselect from '@riophae/vue-treeselect'

export default {
  name: 'DynamicForm',
  components: { Treeselect },
  props: {
    fields: Array,
    formState: Object,
    validationRules: Object,
    editable: {
      type: Boolean,
      default: true
    },
    gutter: {
      type: Number,
      default: 24
    },
    dictionary: {
      type: Object,
      default: () => ({})
    }
  },
  data() {
    return {
      treeData: []
    }
  },
  methods: {
    isDisabled(field) {
      return field.disabled || !this.editable
    },
    handleChange(fieldName, value) {
      this.$emit('field-change', { field: fieldName, value })
    },
    async validate() {
      try {
        await this.$refs.dynamicForm.validate()
        return true
      } catch (e) {
        return false
      }
    },
    reset() {
      this.$refs.dynamicForm.resetFields()
    },
    getCleanData() {
      return Object.fromEntries(
        Object.entries(this.formState).filter(([_, v]) => v !== '' && v !== null && v !== undefined)
      )
    }
  }
}
</script>

Multi-Section Form Pattern

<template>
  <div v-for="section in formSections" :key="section.title">
    <h3>{{ section.title }}</h3>
    <DynamicForm
      :ref="section.ref"
      :fields="section.fields"
      :formState="section.model"
      :validationRules="section.rules"
      @field-change="handleFieldChange"
    />
  </div>
</template>

<script>
export default {
  data() {
    return {
      formSections: [
        {
          title: 'Basic Information',
          ref: 'basicForm',
          fields: [
            { name: 'studentNo', label: 'Student No', type: 'input', disabled: true },
            { name: 'name', label: 'Name', type: 'input', rules: [{ required: true }] },
            { name: 'type', label: 'Type', type: 'select', dict: 'student_types' }
          ],
          model: { studentNo: '', name: '', type: '' },
          rules: {
            name: [{ required: true, message: 'Name required' }]
          }
        }
      ]
    }
  },
  methods: {
    async submitAll() {
      const validations = await Promise.all(
        this.formSections.map(s => this.$refs[s.ref][0].validate())
      )
      if (validations.every(v => v)) {
        const payload = this.formSections.reduce((acc, section) => {
          return { ...acc, ...this.$refs[section.ref][0].getCleanData() }
        }, {})
        console.log('Submitting:', payload)
      }
    }
  }
}
</script>

Hierarchical Tree Components

Static Tree (Full Data Load)

<template>
  <div class="tree-wrapper">
    <el-tree
      ref="tree"
      node-key="id"
      :data="treeData"
      :props="{ label: 'name', children: 'items' }"
      :default-expanded-keys="defaultExpanded"
      :filter-node-method="filterMethod"
      highlight-current
      @node-click="(node) => $emit('node-select', node)"
    />
  </div>
</template>

<script>
export default {
  name: 'StaticTree',
  props: {
    treeData: Array,
    defaultExpandRoot: {
      type: Boolean,
      default: true
    }
  },
  computed: {
    defaultExpanded() {
      return this.defaultExpandRoot && this.treeData.length 
        ? [this.treeData[0].id] 
        : []
    }
  },
  methods: {
    filterMethod(query, node) {
      return node.name.toLowerCase().includes(query.toLowerCase())
    },
    filter(query) {
      this.$refs.tree.filter(query)
    }
  }
}
</script>

Lazy-Load Tree with Remote Search

Handles large datasets by loading children on demand while supporting keyword search across the entire hierarchy.

<template>
  <div class="lazy-tree">
    <div class="search-bar">
      <el-input
        v-model="searchKeyword"
        placeholder="Search..."
        prefix-icon="el-icon-search"
        clearable
        @keyup.enter="performSearch"
      />
      <el-button type="primary" size="small" @click="performSearch">
        Search
      </el-button>
    </div>

    <el-tree
      ref="lazyTree"
      node-key="id"
      :props="{ label: 'title', children: 'children', isLeaf: 'leaf' }"
      :load="loadNode"
      lazy
      :key="refreshKey"
      :default-expanded-keys="expandedKeys"
      highlight-current
      @current-change="(data) => $emit('select', data)"
    />
  </div>
</template>

<script>
export default {
  name: 'LazyTree',
  props: {
    loadApi: Function,
    searchApi: Function,
    rootParams: Object
  },
  data() {
    return {
      searchKeyword: '',
      expandedKeys: [],
      refreshKey: Date.now()
    }
  },
  methods: {
    async loadNode(node, resolve) {
      if (node.level === 0) {
        // Root level
        if (this.searchKeyword) {
          const results = await this.searchApi({ keyword: this.searchKeyword })
          this.renderSearchResults(node, results)
        } else {
          const roots = await this.loadApi(this.rootParams)
          this.expandedKeys = roots.length ? [roots[0].id] : []
          resolve(roots.map(r => ({ ...r, leaf: !r.hasChildren })))
        }
      } else {
        // Child levels
        const children = await this.loadApi({ parentId: node.data.id })
        resolve(children.map(c => ({ ...c, leaf: !c.hasChildren })))
      }
    },
    renderSearchResults(parentNode, data) {
      if (!data || !data.length) return
      
      const buildTree = (items) => {
        return items.map(item => ({
          ...item,
          leaf: !item.hasChildren,
          children: item.children ? buildTree(item.children) : undefined
        }))
      }
      
      parentNode.doCreateChildren(buildTree(data))
      parentNode.expanded = true
      parentNode.loaded = true
      
      // Recursively expand children
      parentNode.childNodes.forEach(child => {
        if (child.data.children?.length) {
          this.renderSearchResults(child, child.data.children)
        }
      })
    },
    performSearch() {
      this.refreshKey = Date.now()
      this.expandedKeys = []
    }
  }
}
</script>

Multi-Select Tree with Deduplication

Extends the lazy tree to support checkbox selection with intelligent deduplication (selecting a parent auto-selects all children, but only the parent is included in the output).

<script>
export default {
  // ... extends LazyTree
  methods: {
    getSelectedNodes() {
      const store = this.$refs.lazyTree.store
      const selected = []
      
      const traverse = (node) => {
        const children = node.root ? node.root.childNodes : node.childNodes
        
        children.forEach(child => {
          if (child.checked) {
            selected.push(child.data)
          } else if (child.indeterminate) {
            // Partially selected - check children
            traverse(child)
          }
        })
      }
      
      traverse(store)
      return selected
    }
  }
}
</script>

Personnel Selector (Transfer)

Simple wrapper around Element UI's Transfer component for user selection scenarios.

<template>
  <el-transfer
    v-model="selectedIds"
    :data="userPool"
    :titles="['Available', 'Selected']"
    :filterable="true"
    filter-placeholder="Search by name..."
  />
</template>

<script>
export default {
  name: 'PersonnelSelector',
  props: {
    userPool: {
      type: Array,
      default: () => []
      // Expected format: [{ key: 'userId', label: 'User Name' }, ...]
    },
    value: {
      type: Array,
      default: () => []
    }
  },
  computed: {
    selectedIds: {
      get() {
        return this.value
      },
      set(val) {
        this.$emit('input', val)
      }
    }
  }
}
</script>

Global Registration

Register components globally to avoid repetitive imports:

// main.js
import Vue from 'vue'
import QueryBuilder from '@/components/QueryBuilder.vue'
import DataGrid from '@/components/DataGrid.vue'
import DynamicForm from '@/components/DynamicForm.vue'

Vue.component('QueryBuilder', QueryBuilder)
Vue.component('DataGrid', DataGrid)
Vue.component('DynamicForm', DynamicForm)

Related Articles

Designing Alertmanager Templates for Prometheus Notifications

How to craft Alertmanager templates to format alert messages, improving clarity and presentation. Alertmanager uses Go’s text/template engine with additional helper functions. Alerting rules referenc...

Deploying a Maven Web Application to Tomcat 9 Using the Tomcat Manager

Tomcat 9 does not provide a dedicated Maven plugin. The Tomcat Manager interface, however, is backward-compatible, so the Tomcat 7 Maven Plugin can be used to deploy to Tomcat 9. This guide shows two...

Spring Boot MyBatis with Two MySQL DataSources Using Druid

Required dependencies application.properties: define two data sources and poooling Java configuration for both data sources MyBatis mappers for each data source Controller endpoints to verify both co...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.