173 lines
7.5 KiB
TypeScript
173 lines
7.5 KiB
TypeScript
import { computed, defineComponent } from 'vue'
|
|
import { readFileSync } from 'node:fs'
|
|
import { resolve } from 'node:path'
|
|
import { shallowMount } from '@vue/test-utils'
|
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
import QueryToolbar, { type QueryField } from './QueryToolbar.vue'
|
|
import LayoutPreferenceList from './LayoutPreferenceList.vue'
|
|
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
|
|
|
|
const FieldStub = defineComponent({
|
|
inheritAttrs: false,
|
|
props: { placeholder: String },
|
|
emits: ['keyup'],
|
|
template: '<input :data-placeholder="placeholder" @keyup="$emit(\'keyup\', $event)" />',
|
|
})
|
|
const ButtonStub = defineComponent({
|
|
props: { disabled: Boolean, loading: Boolean },
|
|
template: '<button type="button" :disabled="disabled" :data-loading="String(loading)"><slot /></button>',
|
|
})
|
|
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
|
|
|
|
const fields: QueryField[] = [
|
|
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '关键词' },
|
|
{ key: 'status', type: 'select', label: '状态', placeholder: '状态' },
|
|
{ key: 'createdRange', type: 'daterange', label: '创建日期', placeholder: '创建日期' },
|
|
]
|
|
|
|
function mountToolbar(props: Record<string, unknown> = {}) {
|
|
return shallowMount(QueryToolbar, {
|
|
props: {
|
|
modelValue: { keyword: '', status: '', createdRange: [] },
|
|
'onUpdate:modelValue': () => undefined,
|
|
ariaLabel: '订单筛选',
|
|
fields,
|
|
preferenceKey: 'orders-query',
|
|
...props,
|
|
},
|
|
global: {
|
|
provide: { [UI_PREFERENCE_SCOPE as symbol]: computed(() => 'user-1') },
|
|
stubs: {
|
|
ElInput: FieldStub,
|
|
ElSelect: FieldStub,
|
|
ElDatePicker: FieldStub,
|
|
ElButton: ButtonStub,
|
|
ElPopover: PopoverStub,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
describe('QueryToolbar layout preferences', () => {
|
|
beforeEach(() => localStorage.clear())
|
|
|
|
it('restores and updates the user field order', async () => {
|
|
localStorage.setItem(preferenceStorageKey('query', 'user-1', 'orders-query'), JSON.stringify({
|
|
version: 1,
|
|
order: ['status', 'keyword', 'createdRange'],
|
|
hidden: [],
|
|
}))
|
|
const wrapper = mountToolbar()
|
|
expect(wrapper.findAll('input').map((input) => input.attributes('data-placeholder')))
|
|
.toEqual(['状态', '关键词', '创建日期'])
|
|
|
|
wrapper.findComponent(LayoutPreferenceList).vm.$emit('move', 'createdRange', 0)
|
|
await wrapper.vm.$nextTick()
|
|
expect(wrapper.findAll('input').map((input) => input.attributes('data-placeholder')))
|
|
.toEqual(['创建日期', '状态', '关键词'])
|
|
})
|
|
|
|
it('keeps desktop actions grouped with the final filter when the toolbar wraps', () => {
|
|
const wrapper = mountToolbar()
|
|
expect(wrapper.find('.query-toolbar__tail input').attributes('data-placeholder')).toBe('创建日期')
|
|
expect(wrapper.find('.query-toolbar__tail .query-toolbar__actions').exists()).toBe(true)
|
|
|
|
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
|
|
expect(source).toContain('.query-toolbar__tail { display: flex; width: auto; max-width: 100%; min-width: 0; flex: 0 1 auto; align-items: flex-end; flex-wrap: wrap; gap: 10px; }')
|
|
expect(source).not.toContain('.query-toolbar__filters { display: contents; }')
|
|
})
|
|
|
|
it('derives compact widths by field type while explicit business widths win', () => {
|
|
const compactFields: QueryField[] = [
|
|
{ key: 'status', type: 'select', label: '短状态' },
|
|
{ key: 'date', type: 'date', label: '日期' },
|
|
{ key: 'keyword', type: 'input', label: '长关键词' },
|
|
{ key: 'range', type: 'daterange', label: '日期范围' },
|
|
{ key: 'warehouse', type: 'select', label: '业务显式宽度', width: 236 },
|
|
]
|
|
const wrapper = mountToolbar({
|
|
modelValue: { status: '', date: '', keyword: '', range: [], warehouse: '' },
|
|
fields: compactFields,
|
|
availableFields: compactFields,
|
|
})
|
|
const styles = wrapper.findAll('.query-toolbar__field').map((field) => field.attributes('style'))
|
|
expect(styles).toEqual([
|
|
'--query-field-width: 180px;',
|
|
'--query-field-width: 180px;',
|
|
'--query-field-width: 260px;',
|
|
'--query-field-width: 300px;',
|
|
'--query-field-width: 236px;',
|
|
])
|
|
})
|
|
|
|
it('keeps a short status and long keyword left aligned without stretching or desktop overflow', () => {
|
|
const mixedFields: QueryField[] = [
|
|
{ key: 'status', type: 'select', label: '状态' },
|
|
{ key: 'keyword', type: 'input', label: '订单号/入仓码/客户名称' },
|
|
]
|
|
const wrapper = mountToolbar({
|
|
modelValue: { status: '', keyword: '' },
|
|
fields: mixedFields,
|
|
availableFields: mixedFields,
|
|
})
|
|
const renderedFields = wrapper.findAll('.query-toolbar__field')
|
|
expect(renderedFields[0].attributes('style')).toBe('--query-field-width: 180px;')
|
|
expect(renderedFields[1].attributes('style')).toBe('--query-field-width: 260px;')
|
|
expect(renderedFields[1].classes()).toContain('query-toolbar__tail')
|
|
expect(renderedFields[1].find('.query-toolbar__actions').exists()).toBe(true)
|
|
|
|
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
|
|
expect(source).toContain('justify-content: flex-start; flex-wrap: wrap;')
|
|
expect(source).toContain('flex: 0 0 var(--query-field-width)')
|
|
expect(source).toContain('overflow-x: clip')
|
|
expect(source).toContain('@media (max-width: 720px)')
|
|
expect(source).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
|
|
expect(source).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
|
|
})
|
|
|
|
it('submits the current filters once when Enter is released in a text input', async () => {
|
|
const wrapper = mountToolbar({ modelValue: { keyword: 'AKR1', status: 'ACTIVE', createdRange: [] } })
|
|
const textInput = wrapper.findAll('input')[0]
|
|
|
|
await textInput.trigger('keyup', { key: 'Enter' })
|
|
|
|
expect(wrapper.emitted('search')).toEqual([[
|
|
{ keyword: 'AKR1', status: 'ACTIVE', createdRange: [] },
|
|
]])
|
|
})
|
|
|
|
it('does not submit Enter from select or date controls, composition, or repeated key events', async () => {
|
|
const wrapper = mountToolbar()
|
|
const controls = wrapper.findAll('input')
|
|
|
|
await controls[1].trigger('keyup', { key: 'Enter' })
|
|
await controls[2].trigger('keyup', { key: 'Enter' })
|
|
await controls[0].trigger('keyup', { key: 'Enter', isComposing: true })
|
|
await controls[0].trigger('keyup', { key: 'Enter', repeat: true })
|
|
|
|
expect(wrapper.emitted('search')).toBeUndefined()
|
|
})
|
|
|
|
it('resets all filters once and blocks search and reset while loading', async () => {
|
|
const wrapper = mountToolbar({
|
|
modelValue: { keyword: 'AKR1', status: 'ACTIVE', createdRange: ['2026-08-01', '2026-08-14'] },
|
|
})
|
|
|
|
;(wrapper.vm as unknown as { reset: () => void }).reset()
|
|
expect(wrapper.emitted('reset')).toEqual([[
|
|
{ keyword: '', status: '', createdRange: [] },
|
|
]])
|
|
expect(wrapper.emitted('search')).toEqual([[
|
|
{ keyword: '', status: '', createdRange: [] },
|
|
]])
|
|
|
|
await wrapper.setProps({ loading: true })
|
|
;(wrapper.vm as unknown as { search: () => void; reset: () => void }).search()
|
|
;(wrapper.vm as unknown as { search: () => void; reset: () => void }).reset()
|
|
expect(wrapper.emitted('search')).toHaveLength(1)
|
|
expect(wrapper.emitted('reset')).toHaveLength(1)
|
|
expect(wrapper.findAllComponents(ButtonStub)[0].props('loading')).toBe(true)
|
|
expect(wrapper.findAllComponents(ButtonStub)[1].props('disabled')).toBe(true)
|
|
})
|
|
})
|