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 2

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...

Skipping Errors in MySQL Asynchronous Replication

When a replica halts because the SQL thread encounters an error, you can resume replication by skipping the problematic event(s). Two common approaches are available. Methods to Skip Errors 1) Skip a...

Leave a Comment

Anonymous

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