Advanced Vue 2 Component Architecture: Search Modules, Data Tables, and Hierarchical Trees
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)