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.
Files changed (34) hide show
  1. package/README.md +273 -0
  2. package/dist/favicon.svg +4 -0
  3. package/dist/index.js +1531 -0
  4. package/dist/index.umd +1 -0
  5. package/dist/style.css +1 -0
  6. package/package.json +51 -0
  7. package/src/App.vue +469 -0
  8. package/src/api/viewer.ts +163 -0
  9. package/src/components/DataTable.test.ts +533 -0
  10. package/src/components/DataTable.vue +810 -0
  11. package/src/components/DataTableWithSearch.test.ts +628 -0
  12. package/src/components/DataTableWithSearch.vue +277 -0
  13. package/src/components/DataViewer.vue +310 -0
  14. package/src/components/SearchToolbar.test.ts +521 -0
  15. package/src/components/SearchToolbar.vue +406 -0
  16. package/src/components/composables/index.ts +2 -0
  17. package/src/components/composables/useTableSelection.test.ts +248 -0
  18. package/src/components/composables/useTableSelection.ts +44 -0
  19. package/src/components/composables/useTableSummary.test.ts +341 -0
  20. package/src/components/composables/useTableSummary.ts +129 -0
  21. package/src/components/filters/BoolFilter.vue +103 -0
  22. package/src/components/filters/DateRangeFilter.vue +194 -0
  23. package/src/components/filters/NumberRangeFilter.vue +160 -0
  24. package/src/components/filters/SelectFilter.vue +464 -0
  25. package/src/components/filters/TextFilter.vue +230 -0
  26. package/src/components/filters/index.ts +5 -0
  27. package/src/examples/EnhancedTableExample.vue +136 -0
  28. package/src/index.ts +32 -0
  29. package/src/main.ts +14 -0
  30. package/src/types/index.ts +159 -0
  31. package/src/utils/README.md +140 -0
  32. package/src/utils/schemaHelper.test.ts +215 -0
  33. package/src/utils/schemaHelper.ts +44 -0
  34. 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
+ })