From 161820dbc1c30e146622dc2f1abb2321b1ef9b01 Mon Sep 17 00:00:00 2001 From: Vben Date: Thu, 19 Sep 2024 21:56:49 +0800 Subject: [PATCH] fix: improve the scroll bar flashing when the modal box is opened (#4438) --- packages/@core/base/shared/src/utils/dom.ts | 16 ++ .../@core/composables/src/use-scroll-lock.ts | 8 +- .../__snapshots__/config.test.ts.snap | 114 ++++++++++ .../preferences/__tests__/config.test.ts | 10 + .../{src => __tests__}/preferences.test.ts | 6 +- packages/@core/preferences/tsconfig.json | 2 +- .../ui-kit/form-ui/__tests__/form-api.test.ts | 146 ++++++++++++ packages/@core/ui-kit/form-ui/src/form-api.ts | 10 +- packages/@core/ui-kit/form-ui/tsconfig.json | 2 +- playground/src/locales/langs/en-US.json | 3 +- playground/src/locales/langs/zh-CN.json | 3 +- .../src/router/routes/modules/examples.ts | 8 + playground/src/views/examples/form/api.vue | 208 ++++++++++++++++++ playground/src/views/examples/form/basic.vue | 3 +- playground/src/views/examples/form/custom.vue | 6 +- .../src/views/examples/form/dynamic.vue | 17 -- playground/src/views/examples/form/query.vue | 6 +- playground/src/views/examples/form/rules.vue | 4 +- 18 files changed, 530 insertions(+), 42 deletions(-) create mode 100644 packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap create mode 100644 packages/@core/preferences/__tests__/config.test.ts rename packages/@core/preferences/{src => __tests__}/preferences.test.ts (97%) create mode 100644 packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts create mode 100644 playground/src/views/examples/form/api.vue diff --git a/packages/@core/base/shared/src/utils/dom.ts b/packages/@core/base/shared/src/utils/dom.ts index 16d6ddef7ac..aa39597db1e 100644 --- a/packages/@core/base/shared/src/utils/dom.ts +++ b/packages/@core/base/shared/src/utils/dom.ts @@ -69,3 +69,19 @@ export function getScrollbarWidth() { scrollDiv.remove(); return scrollbarWidth; } + +export function needsScrollbar() { + const doc = document.documentElement; + const body = document.body; + + // 检查 body 的 overflow-y 样式 + const overflowY = window.getComputedStyle(body).overflowY; + + // 如果明确设置了需要滚动条的样式 + if (overflowY === 'scroll' || overflowY === 'auto') { + return doc.scrollHeight > window.innerHeight; + } + + // 在其他情况下,根据 scrollHeight 和 innerHeight 比较判断 + return doc.scrollHeight > window.innerHeight; +} diff --git a/packages/@core/composables/src/use-scroll-lock.ts b/packages/@core/composables/src/use-scroll-lock.ts index b3f40d6bcfd..3703d2228c1 100644 --- a/packages/@core/composables/src/use-scroll-lock.ts +++ b/packages/@core/composables/src/use-scroll-lock.ts @@ -1,4 +1,4 @@ -import { getScrollbarWidth } from '@vben-core/shared/utils'; +import { getScrollbarWidth, needsScrollbar } from '@vben-core/shared/utils'; import { useScrollLock as _useScrollLock, @@ -13,6 +13,9 @@ export function useScrollLock() { const scrollbarWidth = getScrollbarWidth(); tryOnBeforeMount(() => { + if (!needsScrollbar()) { + return; + } document.body.style.paddingRight = `${scrollbarWidth}px`; const layoutFixedNodes = document.querySelectorAll( @@ -30,6 +33,9 @@ export function useScrollLock() { }); tryOnBeforeUnmount(() => { + if (!needsScrollbar()) { + return; + } isLocked.value = false; const layoutFixedNodes = document.querySelectorAll( `.${SCROLL_FIXED_CLASS}`, diff --git a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap b/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap new file mode 100644 index 00000000000..a74c79e0028 --- /dev/null +++ b/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap @@ -0,0 +1,114 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`defaultPreferences immutability test > should not modify the config object 1`] = ` +{ + "app": { + "accessMode": "frontend", + "authPageLayout": "panel-right", + "checkUpdatesInterval": 1, + "colorGrayMode": false, + "colorWeakMode": false, + "compact": false, + "contentCompact": "wide", + "defaultAvatar": "https://unpkg.com/@vbenjs/static-source@0.1.6/source/avatar-v1.webp", + "dynamicTitle": true, + "enableCheckUpdates": true, + "enablePreferences": true, + "enableRefreshToken": false, + "isMobile": false, + "layout": "sidebar-nav", + "locale": "zh-CN", + "loginExpiredMode": "page", + "name": "Vben Admin", + "preferencesButtonPosition": "auto", + "watermark": false, + }, + "breadcrumb": { + "enable": true, + "hideOnlyOne": false, + "showHome": false, + "showIcon": true, + "styleType": "normal", + }, + "copyright": { + "companyName": "Vben", + "companySiteLink": "https://www.vben.pro", + "date": "2024", + "enable": true, + "icp": "", + "icpLink": "", + }, + "footer": { + "enable": true, + "fixed": false, + }, + "header": { + "enable": true, + "hidden": false, + "mode": "fixed", + }, + "logo": { + "enable": true, + "source": "https://unpkg.com/@vbenjs/static-source@0.1.6/source/logo-v1.webp", + }, + "navigation": { + "accordion": true, + "split": true, + "styleType": "rounded", + }, + "shortcutKeys": { + "enable": true, + "globalLockScreen": true, + "globalLogout": true, + "globalPreferences": true, + "globalSearch": true, + }, + "sidebar": { + "collapsed": false, + "collapsedShowTitle": false, + "enable": true, + "expandOnHover": true, + "extraCollapse": true, + "hidden": false, + "width": 224, + }, + "tabbar": { + "dragable": true, + "enable": true, + "height": 38, + "keepAlive": true, + "persist": true, + "showIcon": true, + "showMaximize": true, + "showMore": true, + "showRefresh": true, + "styleType": "chrome", + }, + "theme": { + "builtinType": "default", + "colorDestructive": "hsl(348 100% 61%)", + "colorPrimary": "hsl(212 100% 45%)", + "colorSuccess": "hsl(144 57% 58%)", + "colorWarning": "hsl(42 84% 61%)", + "mode": "dark", + "radius": "0.5", + "semiDarkHeader": false, + "semiDarkSidebar": true, + }, + "transition": { + "enable": true, + "loading": true, + "name": "fade-slide", + "progress": true, + }, + "widget": { + "fullscreen": true, + "globalSearch": true, + "languageToggle": true, + "lockScreen": true, + "notification": true, + "sidebarToggle": true, + "themeToggle": true, + }, +} +`; diff --git a/packages/@core/preferences/__tests__/config.test.ts b/packages/@core/preferences/__tests__/config.test.ts new file mode 100644 index 00000000000..f7c9bb3611d --- /dev/null +++ b/packages/@core/preferences/__tests__/config.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; + +import { defaultPreferences } from '../src/config'; + +describe('defaultPreferences immutability test', () => { + // 创建快照,确保默认配置对象不被修改 + it('should not modify the config object', () => { + expect(defaultPreferences).toMatchSnapshot(); + }); +}); diff --git a/packages/@core/preferences/src/preferences.test.ts b/packages/@core/preferences/__tests__/preferences.test.ts similarity index 97% rename from packages/@core/preferences/src/preferences.test.ts rename to packages/@core/preferences/__tests__/preferences.test.ts index 169358ec4d8..37657d89f77 100644 --- a/packages/@core/preferences/src/preferences.test.ts +++ b/packages/@core/preferences/__tests__/preferences.test.ts @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { defaultPreferences } from './config'; -import { PreferenceManager } from './preferences'; -import { isDarkTheme } from './update-css-variables'; +import { defaultPreferences } from '../src/config'; +import { PreferenceManager } from '../src/preferences'; +import { isDarkTheme } from '../src/update-css-variables'; describe('preferences', () => { let preferenceManager: PreferenceManager; diff --git a/packages/@core/preferences/tsconfig.json b/packages/@core/preferences/tsconfig.json index ce1a891fb84..bbb80506a11 100644 --- a/packages/@core/preferences/tsconfig.json +++ b/packages/@core/preferences/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "@vben/tsconfig/web.json", - "include": ["src"], + "include": ["src", "__tests__"], "exclude": ["node_modules"] } diff --git a/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts new file mode 100644 index 00000000000..79734d7c1fd --- /dev/null +++ b/packages/@core/ui-kit/form-ui/__tests__/form-api.test.ts @@ -0,0 +1,146 @@ +// 假设这个文件为 FormApi.ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FormApi } from '../src/form-api'; + +vi.mock('@vben-core/shared/utils', () => ({ + bindMethods: vi.fn(), + createMerge: vi.fn((mergeFn) => { + return (stateOrFn, prev) => { + mergeFn(prev, 'key', stateOrFn); + return { ...prev, ...stateOrFn }; + }; + }), + isFunction: (fn: any) => typeof fn === 'function', + StateHandler: vi.fn().mockImplementation(() => ({ + reset: vi.fn(), + setConditionTrue: vi.fn(), + waitForCondition: vi.fn().mockResolvedValue(true), + })), +})); + +describe('formApi', () => { + let formApi: FormApi; + + beforeEach(() => { + formApi = new FormApi(); + }); + + it('should initialize with default state', () => { + expect(formApi.state).toEqual( + expect.objectContaining({ + actionWrapperClass: '', + collapsed: false, + collapsedRows: 1, + commonConfig: {}, + handleReset: undefined, + handleSubmit: undefined, + layout: 'horizontal', + resetButtonOptions: {}, + schema: [], + showCollapseButton: false, + showDefaultActions: true, + submitButtonOptions: {}, + wrapperClass: 'grid-cols-1', + }), + ); + expect(formApi.isMounted).toBe(false); + }); + + it('should mount form actions', async () => { + const formActions: any = { + meta: {}, + resetForm: vi.fn(), + setFieldValue: vi.fn(), + setValues: vi.fn(), + submitForm: vi.fn(), + validate: vi.fn(), + values: { name: 'test' }, + }; + + await formApi.mount(formActions); + expect(formApi.isMounted).toBe(true); + expect(formApi.form).toEqual(formActions); + }); + + it('should get values from form', async () => { + const formActions: any = { + meta: {}, + values: { name: 'test' }, + }; + + await formApi.mount(formActions); + const values = await formApi.getValues(); + expect(values).toEqual({ name: 'test' }); + }); + + it('should set field value', async () => { + const setFieldValueMock = vi.fn(); + const formActions: any = { + meta: {}, + setFieldValue: setFieldValueMock, + values: { name: 'test' }, + }; + + await formApi.mount(formActions); + await formApi.setFieldValue('name', 'new value'); + expect(setFieldValueMock).toHaveBeenCalledWith( + 'name', + 'new value', + undefined, + ); + }); + + it('should reset form', async () => { + const resetFormMock = vi.fn(); + const formActions: any = { + meta: {}, + resetForm: resetFormMock, + values: { name: 'test' }, + }; + + await formApi.mount(formActions); + await formApi.resetForm(); + expect(resetFormMock).toHaveBeenCalled(); + }); + + it('should call handleSubmit on submit', async () => { + const handleSubmitMock = vi.fn(); + const formActions: any = { + meta: {}, + submitForm: vi.fn().mockResolvedValue(true), + values: { name: 'test' }, + }; + + const state = { + handleSubmit: handleSubmitMock, + }; + + formApi.setState(state); + await formApi.mount(formActions); + + const result = await formApi.submitForm(); + expect(formActions.submitForm).toHaveBeenCalled(); + expect(handleSubmitMock).toHaveBeenCalledWith({ name: 'test' }); + expect(result).toEqual({ name: 'test' }); + }); + + it('should unmount form and reset state', () => { + formApi.unmounted(); + expect(formApi.isMounted).toBe(false); + expect(formApi.stateHandler.reset).toHaveBeenCalled(); + }); + + it('should validate form', async () => { + const validateMock = vi.fn().mockResolvedValue(true); + const formActions: any = { + meta: {}, + validate: validateMock, + }; + + await formApi.mount(formActions); + const isValid = await formApi.validate(); + expect(validateMock).toHaveBeenCalled(); + expect(isValid).toBe(true); + }); +}); diff --git a/packages/@core/ui-kit/form-ui/src/form-api.ts b/packages/@core/ui-kit/form-ui/src/form-api.ts index c49f01f438d..191f4233fd2 100644 --- a/packages/@core/ui-kit/form-ui/src/form-api.ts +++ b/packages/@core/ui-kit/form-ui/src/form-api.ts @@ -43,13 +43,13 @@ function getDefaultState(): VbenFormProps { } export class FormApi { - // private prevState!: ModalState; - private state: null | VbenFormProps = null; // private api: Pick; public form = {} as FormActions; - isMounted = false; + // private prevState!: ModalState; + public state: null | VbenFormProps = null; + stateHandler: StateHandler; public store: Store; @@ -92,6 +92,10 @@ export class FormApi { this.store.batch(cb); } + getState() { + return this.state; + } + async getValues() { const form = await this.getForm(); return form.values; diff --git a/packages/@core/ui-kit/form-ui/tsconfig.json b/packages/@core/ui-kit/form-ui/tsconfig.json index ce1a891fb84..bbb80506a11 100644 --- a/packages/@core/ui-kit/form-ui/tsconfig.json +++ b/packages/@core/ui-kit/form-ui/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", "extends": "@vben/tsconfig/web.json", - "include": ["src"], + "include": ["src", "__tests__"], "exclude": ["node_modules"] } diff --git a/playground/src/locales/langs/en-US.json b/playground/src/locales/langs/en-US.json index 95f3c8831b6..3388c849ad4 100644 --- a/playground/src/locales/langs/en-US.json +++ b/playground/src/locales/langs/en-US.json @@ -78,7 +78,8 @@ "query": "Query Form", "rules": "Form Rules", "dynamic": "Dynamic Form", - "custom": "Custom Component" + "custom": "Custom Component", + "api": "Api" }, "captcha": { "title": "Captcha", diff --git a/playground/src/locales/langs/zh-CN.json b/playground/src/locales/langs/zh-CN.json index 91d4962c2b0..d568610814b 100644 --- a/playground/src/locales/langs/zh-CN.json +++ b/playground/src/locales/langs/zh-CN.json @@ -78,7 +78,8 @@ "query": "查询表单", "rules": "表单校验", "dynamic": "动态表单", - "custom": "自定义组件" + "custom": "自定义组件", + "api": "Api" }, "captcha": { "title": "验证码", diff --git a/playground/src/router/routes/modules/examples.ts b/playground/src/router/routes/modules/examples.ts index c946e79c2bc..beb0299800d 100644 --- a/playground/src/router/routes/modules/examples.ts +++ b/playground/src/router/routes/modules/examples.ts @@ -99,6 +99,14 @@ const routes: RouteRecordRaw[] = [ title: $t('page.examples.form.custom'), }, }, + { + name: 'FormApiExample', + path: '/examples/form/api', + component: () => import('#/views/examples/form/api.vue'), + meta: { + title: $t('page.examples.form.api'), + }, + }, ], }, ], diff --git a/playground/src/views/examples/form/api.vue b/playground/src/views/examples/form/api.vue new file mode 100644 index 00000000000..32f1222548b --- /dev/null +++ b/playground/src/views/examples/form/api.vue @@ -0,0 +1,208 @@ + + + diff --git a/playground/src/views/examples/form/basic.vue b/playground/src/views/examples/form/basic.vue index 1320e3a719f..a92ec258d26 100644 --- a/playground/src/views/examples/form/basic.vue +++ b/playground/src/views/examples/form/basic.vue @@ -14,12 +14,11 @@ const [BaseForm, baseFormApi] = useVbenForm({ class: 'w-full', }, }, - // 使用 tailwindcss grid布局 // 提交函数 handleSubmit: onSubmit, // 垂直布局,label和input在不同行,值为vertical - layout: 'horizontal', // 水平布局,label和input在同一行 + layout: 'horizontal', schema: [ { // 组件需要在 #/adapter.ts内注册,并加上类型 diff --git a/playground/src/views/examples/form/custom.vue b/playground/src/views/examples/form/custom.vue index c275659677f..baaeedc5831 100644 --- a/playground/src/views/examples/form/custom.vue +++ b/playground/src/views/examples/form/custom.vue @@ -16,12 +16,11 @@ const [BaseForm] = useVbenForm({ }, labelClass: 'w-2/6', }, - // 使用 tailwindcss grid布局 // 提交函数 handleSubmit: onSubmit, // 垂直布局,label和input在不同行,值为vertical - layout: 'horizontal', // 水平布局,label和input在同一行 + layout: 'horizontal', schema: [ { // 组件需要在 #/adapter.ts内注册,并加上类型 @@ -31,7 +30,6 @@ const [BaseForm] = useVbenForm({ suffix: () => h('span', { class: 'text-red-600' }, '元'), }, { - // 组件需要在 #/adapter.ts内注册,并加上类型 component: 'Input', fieldName: 'field1', label: '自定义组件slot', @@ -41,14 +39,12 @@ const [BaseForm] = useVbenForm({ }), }, { - // 组件需要在 #/adapter.ts内注册,并加上类型 component: h(Input, { placeholder: '请输入' }), fieldName: 'field2', label: '自定义组件', rules: 'required', }, { - // 组件需要在 #/adapter.ts内注册,并加上类型 component: 'Input', fieldName: 'field3', label: '自定义组件(slot)', diff --git a/playground/src/views/examples/form/dynamic.vue b/playground/src/views/examples/form/dynamic.vue index 217d177fc8d..f5545aae620 100644 --- a/playground/src/views/examples/form/dynamic.vue +++ b/playground/src/views/examples/form/dynamic.vue @@ -6,10 +6,8 @@ import { Button, Card, message } from 'ant-design-vue'; import { useVbenForm } from '#/adapter'; const [Form, formApi] = useVbenForm({ - // 使用 tailwindcss grid布局 // 提交函数 handleSubmit: onSubmit, - // 水平布局,label和input在同一行 schema: [ { component: 'Switch', @@ -55,12 +53,9 @@ const [Form, formApi] = useVbenForm({ show(values) { return !!values.field2Switch; }, - // 只有指定的字段改变时,才会触发 triggerFields: ['field2Switch'], }, - // 字段名 fieldName: 'field2', - // 界面显示的label label: '字段2', }, { @@ -69,12 +64,9 @@ const [Form, formApi] = useVbenForm({ disabled(values) { return !!values.field3Switch; }, - // 只有指定的字段改变时,才会触发 triggerFields: ['field3Switch'], }, - // 字段名 fieldName: 'field3', - // 界面显示的label label: '字段3', }, { @@ -83,12 +75,9 @@ const [Form, formApi] = useVbenForm({ required(values) { return !!values.field4Switch; }, - // 只有指定的字段改变时,才会触发 triggerFields: ['field4Switch'], }, - // 字段名 fieldName: 'field4', - // 界面显示的label label: '字段4', }, { @@ -100,13 +89,10 @@ const [Form, formApi] = useVbenForm({ } return null; }, - // 只有指定的字段改变时,才会触发 triggerFields: ['field1'], }, - // 字段名 fieldName: 'field5', help: '当字段1的值为`123`时,必填', - // 界面显示的label label: '动态rules', }, { @@ -150,13 +136,10 @@ const [Form, formApi] = useVbenForm({ } return {}; }, - // 只有指定的字段改变时,才会触发 triggerFields: ['field2'], }, - // 字段名 fieldName: 'field6', help: '当字段2的值为`123`时,更改下拉选项', - // 界面显示的label label: '动态配置', }, { diff --git a/playground/src/views/examples/form/query.vue b/playground/src/views/examples/form/query.vue index d65ea23bf7b..f288be1bcf8 100644 --- a/playground/src/views/examples/form/query.vue +++ b/playground/src/views/examples/form/query.vue @@ -18,9 +18,8 @@ const [QueryForm] = useVbenForm({ // 提交函数 handleSubmit: onSubmit, // 垂直布局,label和input在不同行,值为vertical - layout: 'horizontal', - // 使用 tailwindcss grid布局 // 水平布局,label和input在同一行 + layout: 'horizontal', schema: [ { // 组件需要在 #/adapter.ts内注册,并加上类型 @@ -101,9 +100,8 @@ const [QueryForm1] = useVbenForm({ // 提交函数 handleSubmit: onSubmit, // 垂直布局,label和input在不同行,值为vertical - layout: 'horizontal', - // 使用 tailwindcss grid布局 // 水平布局,label和input在同一行 + layout: 'horizontal', schema: (() => { const schema = []; for (let index = 0; index < 14; index++) { diff --git a/playground/src/views/examples/form/rules.vue b/playground/src/views/examples/form/rules.vue index 81744037a84..d27f57c9aa1 100644 --- a/playground/src/views/examples/form/rules.vue +++ b/playground/src/views/examples/form/rules.vue @@ -13,12 +13,11 @@ const [Form, formApi] = useVbenForm({ class: 'w-full', }, }, - // 使用 tailwindcss grid布局 // 提交函数 handleSubmit: onSubmit, // 垂直布局,label和input在不同行,值为vertical - layout: 'horizontal', // 水平布局,label和input在同一行 + layout: 'horizontal', schema: [ { // 组件需要在 #/adapter.ts内注册,并加上类型 @@ -80,7 +79,6 @@ const [Form, formApi] = useVbenForm({ }, fieldName: 'number', label: '数字', - // 预处理函数,将空字符串或null转换为undefined rules: 'required', }, {