foggy-data-viewer 1.0.1-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +273 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.js +1531 -0
- package/dist/index.umd +1 -0
- package/dist/style.css +1 -0
- package/package.json +51 -0
- package/src/App.vue +469 -0
- package/src/api/viewer.ts +163 -0
- package/src/components/DataTable.test.ts +533 -0
- package/src/components/DataTable.vue +810 -0
- package/src/components/DataTableWithSearch.test.ts +628 -0
- package/src/components/DataTableWithSearch.vue +277 -0
- package/src/components/DataViewer.vue +310 -0
- package/src/components/SearchToolbar.test.ts +521 -0
- package/src/components/SearchToolbar.vue +406 -0
- package/src/components/composables/index.ts +2 -0
- package/src/components/composables/useTableSelection.test.ts +248 -0
- package/src/components/composables/useTableSelection.ts +44 -0
- package/src/components/composables/useTableSummary.test.ts +341 -0
- package/src/components/composables/useTableSummary.ts +129 -0
- package/src/components/filters/BoolFilter.vue +103 -0
- package/src/components/filters/DateRangeFilter.vue +194 -0
- package/src/components/filters/NumberRangeFilter.vue +160 -0
- package/src/components/filters/SelectFilter.vue +464 -0
- package/src/components/filters/TextFilter.vue +230 -0
- package/src/components/filters/index.ts +5 -0
- package/src/examples/EnhancedTableExample.vue +136 -0
- package/src/index.ts +32 -0
- package/src/main.ts +14 -0
- package/src/types/index.ts +159 -0
- package/src/utils/README.md +140 -0
- package/src/utils/schemaHelper.test.ts +215 -0
- package/src/utils/schemaHelper.ts +44 -0
- package/src/vite-env.d.ts +7 -0
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import SearchToolbar from './SearchToolbar.vue'
|
|
4
|
+
import type { EnhancedColumnSchema, SliceRequestDef } from '@/types'
|
|
5
|
+
|
|
6
|
+
// Mock filter components
|
|
7
|
+
vi.mock('./filters', () => ({
|
|
8
|
+
TextFilter: { name: 'TextFilter', template: '<div class="text-filter"></div>' },
|
|
9
|
+
NumberRangeFilter: { name: 'NumberRangeFilter', template: '<div class="number-filter"></div>' },
|
|
10
|
+
DateRangeFilter: { name: 'DateRangeFilter', template: '<div class="date-filter"></div>' },
|
|
11
|
+
SelectFilter: { name: 'SelectFilter', template: '<div class="select-filter"></div>' },
|
|
12
|
+
BoolFilter: { name: 'BoolFilter', template: '<div class="bool-filter"></div>' }
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
describe('SearchToolbar', () => {
|
|
16
|
+
const mockColumns: EnhancedColumnSchema[] = [
|
|
17
|
+
{ name: 'id', type: 'INTEGER', title: 'ID', filterable: true, filterType: 'number' },
|
|
18
|
+
{ name: 'name', type: 'TEXT', title: '名称', filterable: true, filterType: 'text' },
|
|
19
|
+
{ name: 'amount', type: 'MONEY', title: '金额', filterable: true, filterType: 'number' },
|
|
20
|
+
{ name: 'date', type: 'DAY', title: '日期', filterable: true, filterType: 'date' },
|
|
21
|
+
{ name: 'status', type: 'BOOL', title: '状态', filterable: true, filterType: 'bool' },
|
|
22
|
+
{ name: 'category', type: 'TEXT', title: '分类', filterable: false } // 不可筛选
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
describe('Rendering', () => {
|
|
26
|
+
it('should render successfully', () => {
|
|
27
|
+
const wrapper = mount(SearchToolbar, {
|
|
28
|
+
props: {
|
|
29
|
+
columns: mockColumns
|
|
30
|
+
}
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
expect(wrapper.exists()).toBe(true)
|
|
34
|
+
expect(wrapper.find('.search-toolbar').exists()).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should render only filterable columns', () => {
|
|
38
|
+
const wrapper = mount(SearchToolbar, {
|
|
39
|
+
props: {
|
|
40
|
+
columns: mockColumns
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const fields = wrapper.findAll('.search-field-item')
|
|
45
|
+
// 5个可筛选字段
|
|
46
|
+
expect(fields).toHaveLength(5)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should render only searchable fields when specified', () => {
|
|
50
|
+
const wrapper = mount(SearchToolbar, {
|
|
51
|
+
props: {
|
|
52
|
+
columns: mockColumns,
|
|
53
|
+
searchableFields: ['name', 'amount']
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const fields = wrapper.findAll('.search-field-item')
|
|
58
|
+
expect(fields).toHaveLength(2)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should filter out non-existent fields from searchableFields', () => {
|
|
62
|
+
const wrapper = mount(SearchToolbar, {
|
|
63
|
+
props: {
|
|
64
|
+
columns: mockColumns,
|
|
65
|
+
searchableFields: ['name', 'nonexistent', 'amount']
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const fields = wrapper.findAll('.search-field-item')
|
|
70
|
+
expect(fields).toHaveLength(2)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should render field labels correctly', () => {
|
|
74
|
+
const wrapper = mount(SearchToolbar, {
|
|
75
|
+
props: {
|
|
76
|
+
columns: mockColumns,
|
|
77
|
+
searchableFields: ['name', 'amount']
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const labels = wrapper.findAll('.field-label')
|
|
82
|
+
expect(labels[0].text()).toBe('名称')
|
|
83
|
+
expect(labels[1].text()).toBe('金额')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('Layout', () => {
|
|
88
|
+
it('should render horizontal layout by default', () => {
|
|
89
|
+
const wrapper = mount(SearchToolbar, {
|
|
90
|
+
props: {
|
|
91
|
+
columns: mockColumns
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
expect(wrapper.find('.layout-horizontal').exists()).toBe(true)
|
|
96
|
+
expect(wrapper.find('.layout-vertical').exists()).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('should render vertical layout when specified', () => {
|
|
100
|
+
const wrapper = mount(SearchToolbar, {
|
|
101
|
+
props: {
|
|
102
|
+
columns: mockColumns,
|
|
103
|
+
layout: 'vertical'
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
expect(wrapper.find('.layout-vertical').exists()).toBe(true)
|
|
108
|
+
expect(wrapper.find('.layout-horizontal').exists()).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('Actions', () => {
|
|
113
|
+
it('should show action buttons by default', () => {
|
|
114
|
+
const wrapper = mount(SearchToolbar, {
|
|
115
|
+
props: {
|
|
116
|
+
columns: mockColumns
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(wrapper.find('.search-actions').exists()).toBe(true)
|
|
121
|
+
expect(wrapper.findAll('.btn')).toHaveLength(2)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('should hide action buttons when showActions is false', () => {
|
|
125
|
+
const wrapper = mount(SearchToolbar, {
|
|
126
|
+
props: {
|
|
127
|
+
columns: mockColumns,
|
|
128
|
+
showActions: false
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
expect(wrapper.find('.search-actions').exists()).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should emit search event when search button clicked', async () => {
|
|
136
|
+
const wrapper = mount(SearchToolbar, {
|
|
137
|
+
props: {
|
|
138
|
+
columns: mockColumns
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const searchBtn = wrapper.find('.btn-primary')
|
|
143
|
+
await searchBtn.trigger('click')
|
|
144
|
+
|
|
145
|
+
expect(wrapper.emitted('search')).toBeTruthy()
|
|
146
|
+
expect(wrapper.emitted('search')).toHaveLength(1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('should emit reset event when reset button clicked', async () => {
|
|
150
|
+
const wrapper = mount(SearchToolbar, {
|
|
151
|
+
props: {
|
|
152
|
+
columns: mockColumns
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const resetBtn = wrapper.find('.btn-default')
|
|
157
|
+
await resetBtn.trigger('click')
|
|
158
|
+
|
|
159
|
+
expect(wrapper.emitted('reset')).toBeTruthy()
|
|
160
|
+
expect(wrapper.emitted('reset')).toHaveLength(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should clear filters when reset clicked', async () => {
|
|
164
|
+
const wrapper = mount(SearchToolbar, {
|
|
165
|
+
props: {
|
|
166
|
+
columns: mockColumns,
|
|
167
|
+
modelValue: [{ field: 'name', op: '=', value: 'test' }]
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const resetBtn = wrapper.find('.btn-default')
|
|
172
|
+
await resetBtn.trigger('click')
|
|
173
|
+
|
|
174
|
+
const emitted = wrapper.emitted('update:modelValue')
|
|
175
|
+
expect(emitted).toBeTruthy()
|
|
176
|
+
expect(emitted![emitted!.length - 1]).toEqual([[]])
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('Filter Updates', () => {
|
|
181
|
+
it('should emit update:modelValue when filter changes', () => {
|
|
182
|
+
const wrapper = mount(SearchToolbar, {
|
|
183
|
+
props: {
|
|
184
|
+
columns: mockColumns
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// 模拟内部更新过滤器
|
|
189
|
+
const vm = wrapper.vm as any
|
|
190
|
+
vm.updateFilter('name', [{ field: 'name', op: '=', value: 'test' }])
|
|
191
|
+
|
|
192
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should merge multiple filter fields', () => {
|
|
196
|
+
const wrapper = mount(SearchToolbar, {
|
|
197
|
+
props: {
|
|
198
|
+
columns: mockColumns
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const vm = wrapper.vm as any
|
|
203
|
+
vm.updateFilter('name', [{ field: 'name', op: '=', value: 'test' }])
|
|
204
|
+
vm.updateFilter('amount', [{ field: 'amount', op: '>=', value: 100 }])
|
|
205
|
+
|
|
206
|
+
const emitted = wrapper.emitted('update:modelValue')
|
|
207
|
+
const lastEmit = emitted![emitted!.length - 1][0] as SliceRequestDef[]
|
|
208
|
+
|
|
209
|
+
expect(lastEmit).toHaveLength(2)
|
|
210
|
+
expect(lastEmit.find(s => s.field === 'name')).toBeTruthy()
|
|
211
|
+
expect(lastEmit.find(s => s.field === 'amount')).toBeTruthy()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('should remove filter when value is null', () => {
|
|
215
|
+
const wrapper = mount(SearchToolbar, {
|
|
216
|
+
props: {
|
|
217
|
+
columns: mockColumns,
|
|
218
|
+
modelValue: [
|
|
219
|
+
{ field: 'name', op: '=', value: 'test' },
|
|
220
|
+
{ field: 'amount', op: '>=', value: 100 }
|
|
221
|
+
]
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
const vm = wrapper.vm as any
|
|
226
|
+
vm.updateFilter('name', null)
|
|
227
|
+
|
|
228
|
+
const emitted = wrapper.emitted('update:modelValue')
|
|
229
|
+
const lastEmit = emitted![emitted!.length - 1][0] as SliceRequestDef[]
|
|
230
|
+
|
|
231
|
+
expect(lastEmit).toHaveLength(1)
|
|
232
|
+
expect(lastEmit[0].field).toBe('amount')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should emit search event after filter update when search button clicked', async () => {
|
|
236
|
+
const wrapper = mount(SearchToolbar, {
|
|
237
|
+
props: {
|
|
238
|
+
columns: mockColumns
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const vm = wrapper.vm as any
|
|
243
|
+
vm.updateFilter('name', [{ field: 'name', op: '=', value: 'test' }])
|
|
244
|
+
|
|
245
|
+
const searchBtn = wrapper.find('.btn-primary')
|
|
246
|
+
await searchBtn.trigger('click')
|
|
247
|
+
|
|
248
|
+
expect(wrapper.emitted('search')).toBeTruthy()
|
|
249
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe('ModelValue Sync', () => {
|
|
254
|
+
it('should initialize with modelValue prop', () => {
|
|
255
|
+
const initialSlices: SliceRequestDef[] = [
|
|
256
|
+
{ field: 'name', op: '=', value: 'test' }
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
const wrapper = mount(SearchToolbar, {
|
|
260
|
+
props: {
|
|
261
|
+
columns: mockColumns,
|
|
262
|
+
modelValue: initialSlices
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const vm = wrapper.vm as any
|
|
267
|
+
expect(vm.filterValues).toBeDefined()
|
|
268
|
+
expect(vm.filterValues.name).toEqual(initialSlices)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('should update when modelValue prop changes', async () => {
|
|
272
|
+
const wrapper = mount(SearchToolbar, {
|
|
273
|
+
props: {
|
|
274
|
+
columns: mockColumns,
|
|
275
|
+
modelValue: []
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
const newSlices: SliceRequestDef[] = [
|
|
280
|
+
{ field: 'name', op: '=', value: 'updated' }
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
await wrapper.setProps({ modelValue: newSlices })
|
|
284
|
+
|
|
285
|
+
const vm = wrapper.vm as any
|
|
286
|
+
expect(vm.filterValues.name).toEqual(newSlices)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('should clear internal state when modelValue is empty', async () => {
|
|
290
|
+
const wrapper = mount(SearchToolbar, {
|
|
291
|
+
props: {
|
|
292
|
+
columns: mockColumns,
|
|
293
|
+
modelValue: [{ field: 'name', op: '=', value: 'test' }]
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
await wrapper.setProps({ modelValue: [] })
|
|
298
|
+
|
|
299
|
+
const vm = wrapper.vm as any
|
|
300
|
+
expect(Object.keys(vm.filterValues)).toHaveLength(0)
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('Exposed Methods', () => {
|
|
305
|
+
it('should expose clearFilters method', () => {
|
|
306
|
+
const wrapper = mount(SearchToolbar, {
|
|
307
|
+
props: {
|
|
308
|
+
columns: mockColumns,
|
|
309
|
+
modelValue: [{ field: 'name', op: '=', value: 'test' }]
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const vm = wrapper.vm as any
|
|
314
|
+
expect(vm.clearFilters).toBeDefined()
|
|
315
|
+
|
|
316
|
+
vm.clearFilters()
|
|
317
|
+
|
|
318
|
+
const emitted = wrapper.emitted('update:modelValue')
|
|
319
|
+
const lastEmit = emitted![emitted!.length - 1][0] as SliceRequestDef[]
|
|
320
|
+
expect(lastEmit).toEqual([])
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('should expose getFilters method', () => {
|
|
324
|
+
const slices: SliceRequestDef[] = [
|
|
325
|
+
{ field: 'name', op: '=', value: 'test' },
|
|
326
|
+
{ field: 'amount', op: '>=', value: 100 }
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
const wrapper = mount(SearchToolbar, {
|
|
330
|
+
props: {
|
|
331
|
+
columns: mockColumns,
|
|
332
|
+
modelValue: slices
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const vm = wrapper.vm as any
|
|
337
|
+
expect(vm.getFilters).toBeDefined()
|
|
338
|
+
|
|
339
|
+
const filters = vm.getFilters()
|
|
340
|
+
expect(filters).toHaveLength(2)
|
|
341
|
+
expect(filters).toEqual(expect.arrayContaining(slices))
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('Filter Component Rendering', () => {
|
|
346
|
+
it('should render correct filter component for text type', () => {
|
|
347
|
+
const wrapper = mount(SearchToolbar, {
|
|
348
|
+
props: {
|
|
349
|
+
columns: mockColumns,
|
|
350
|
+
searchableFields: ['name']
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
expect(wrapper.find('.text-filter').exists()).toBe(true)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('should render correct filter component for number type', () => {
|
|
358
|
+
const wrapper = mount(SearchToolbar, {
|
|
359
|
+
props: {
|
|
360
|
+
columns: mockColumns,
|
|
361
|
+
searchableFields: ['amount']
|
|
362
|
+
}
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
expect(wrapper.find('.number-filter').exists()).toBe(true)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('should render correct filter component for date type', () => {
|
|
369
|
+
const wrapper = mount(SearchToolbar, {
|
|
370
|
+
props: {
|
|
371
|
+
columns: mockColumns,
|
|
372
|
+
searchableFields: ['date']
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
expect(wrapper.find('.date-filter').exists()).toBe(true)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('should render correct filter component for bool type', () => {
|
|
380
|
+
const wrapper = mount(SearchToolbar, {
|
|
381
|
+
props: {
|
|
382
|
+
columns: mockColumns,
|
|
383
|
+
searchableFields: ['status']
|
|
384
|
+
}
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
expect(wrapper.find('.bool-filter').exists()).toBe(true)
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
describe('Integration', () => {
|
|
392
|
+
it('should work in complete workflow', async () => {
|
|
393
|
+
const wrapper = mount(SearchToolbar, {
|
|
394
|
+
props: {
|
|
395
|
+
columns: mockColumns,
|
|
396
|
+
searchableFields: ['name', 'amount']
|
|
397
|
+
}
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const vm = wrapper.vm as any
|
|
401
|
+
|
|
402
|
+
// 1. 初始状态
|
|
403
|
+
expect(vm.getFilters()).toEqual([])
|
|
404
|
+
|
|
405
|
+
// 2. 更新第一个过滤器
|
|
406
|
+
vm.updateFilter('name', [{ field: 'name', op: '=', value: 'test' }])
|
|
407
|
+
expect(vm.getFilters()).toHaveLength(1)
|
|
408
|
+
|
|
409
|
+
// 3. 更新第二个过滤器
|
|
410
|
+
vm.updateFilter('amount', [{ field: 'amount', op: '>=', value: 100 }])
|
|
411
|
+
expect(vm.getFilters()).toHaveLength(2)
|
|
412
|
+
|
|
413
|
+
// 4. 点击搜索按钮
|
|
414
|
+
const searchBtn = wrapper.find('.btn-primary')
|
|
415
|
+
await searchBtn.trigger('click')
|
|
416
|
+
expect(wrapper.emitted('search')).toBeTruthy()
|
|
417
|
+
|
|
418
|
+
// 5. 点击重置按钮
|
|
419
|
+
const resetBtn = wrapper.find('.btn-default')
|
|
420
|
+
await resetBtn.trigger('click')
|
|
421
|
+
expect(wrapper.emitted('reset')).toBeTruthy()
|
|
422
|
+
|
|
423
|
+
const emitted = wrapper.emitted('update:modelValue')
|
|
424
|
+
const lastEmit = emitted![emitted!.length - 1][0] as SliceRequestDef[]
|
|
425
|
+
expect(lastEmit).toEqual([])
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
it('should handle rapid filter changes', () => {
|
|
429
|
+
const wrapper = mount(SearchToolbar, {
|
|
430
|
+
props: {
|
|
431
|
+
columns: mockColumns
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
const vm = wrapper.vm as any
|
|
436
|
+
|
|
437
|
+
// 快速连续更新
|
|
438
|
+
for (let i = 1; i <= 5; i++) {
|
|
439
|
+
vm.updateFilter('name', [{ field: 'name', op: '=', value: `test${i}` }])
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const filters = vm.getFilters()
|
|
443
|
+
expect(filters).toHaveLength(1)
|
|
444
|
+
expect(filters[0].value).toBe('test5')
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('should maintain other filters when one is cleared', () => {
|
|
448
|
+
const wrapper = mount(SearchToolbar, {
|
|
449
|
+
props: {
|
|
450
|
+
columns: mockColumns,
|
|
451
|
+
modelValue: [
|
|
452
|
+
{ field: 'name', op: '=', value: 'test' },
|
|
453
|
+
{ field: 'amount', op: '>=', value: 100 }
|
|
454
|
+
]
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
const vm = wrapper.vm as any
|
|
459
|
+
vm.updateFilter('name', null)
|
|
460
|
+
|
|
461
|
+
const filters = vm.getFilters()
|
|
462
|
+
expect(filters).toHaveLength(1)
|
|
463
|
+
expect(filters[0].field).toBe('amount')
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
describe('Edge Cases', () => {
|
|
468
|
+
it('should handle empty columns array', () => {
|
|
469
|
+
const wrapper = mount(SearchToolbar, {
|
|
470
|
+
props: {
|
|
471
|
+
columns: []
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
expect(wrapper.find('.search-fields').exists()).toBe(true)
|
|
476
|
+
expect(wrapper.findAll('.search-field-item')).toHaveLength(0)
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('should handle columns without filterable flag', () => {
|
|
480
|
+
const columnsWithoutFilterable: EnhancedColumnSchema[] = [
|
|
481
|
+
{ name: 'id', type: 'INTEGER', title: 'ID' }
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
const wrapper = mount(SearchToolbar, {
|
|
485
|
+
props: {
|
|
486
|
+
columns: columnsWithoutFilterable
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
// 没有 filterable: false 的列应该被视为可筛选
|
|
491
|
+
expect(wrapper.findAll('.search-field-item')).toHaveLength(1)
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('should handle searchableFields with empty array', () => {
|
|
495
|
+
const wrapper = mount(SearchToolbar, {
|
|
496
|
+
props: {
|
|
497
|
+
columns: mockColumns,
|
|
498
|
+
searchableFields: []
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
// 空数组时,length === 0,不进行过滤,显示所有可筛选列
|
|
503
|
+
expect(wrapper.findAll('.search-field-item')).toHaveLength(5)
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('should handle null/undefined in modelValue gracefully', async () => {
|
|
507
|
+
const wrapper = mount(SearchToolbar, {
|
|
508
|
+
props: {
|
|
509
|
+
columns: mockColumns,
|
|
510
|
+
modelValue: undefined
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
const vm = wrapper.vm as any
|
|
515
|
+
expect(vm.getFilters()).toEqual([])
|
|
516
|
+
|
|
517
|
+
await wrapper.setProps({ modelValue: null as any })
|
|
518
|
+
expect(vm.getFilters()).toEqual([])
|
|
519
|
+
})
|
|
520
|
+
})
|
|
521
|
+
})
|