Merge branch 'vbenjs:main' into main

This commit is contained in:
zhb410181
2026-07-23 15:25:16 +08:00
committed by GitHub
50 changed files with 1779 additions and 472 deletions

View File

@@ -1,3 +1,5 @@
import type { BaseFormComponentType } from '../src/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FormApi } from '../src/form-api';
@@ -106,6 +108,99 @@ describe('formApi', () => {
expect(formActions.values).toEqual(originalValuesSnapshot);
});
it('should encode submissions and decode complete values with a codec', async () => {
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const setValues = vi.fn();
const codecFormApi = new FormApi<
FilterFormValues,
BaseFormComponentType,
Record<never, never>,
FilterSubmitValues
>({
codec: {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags.split(','),
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
},
});
const formActions: any = {
meta: {},
setValues,
values: { period: [1, 2], tags: ['admin', 'user'] },
};
await codecFormApi.mount(formActions, new Map());
expect(await codecFormApi.getValues()).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(await codecFormApi.getValueSnapshot()).toEqual({
rawValues: { period: [1, 2], tags: ['admin', 'user'] },
values: { endTime: 2, startTime: 1, tags: 'admin,user' },
});
await codecFormApi.setSubmitValues(
{ endTime: 4, startTime: 3, tags: 'editor' },
false,
);
expect(setValues).toHaveBeenCalledWith(
{ period: [3, 4], tags: ['editor'] },
false,
);
});
it('should scan deprecated schema transforms once for unchanged state', async () => {
const getChildren = vi.fn(() => []);
const schema = {
component: 'text',
fieldName: 'name',
get children() {
return getChildren();
},
} as any;
const codecFormApi = new FormApi({
codec: {
decode: (values) => values,
encode: (values) => values,
},
schema: [schema],
});
const formActions: any = {
meta: {},
values: { name: 'Ada' },
};
await codecFormApi.mount(formActions, new Map());
expect(getChildren).toHaveBeenCalledTimes(1);
await codecFormApi.getValues();
await codecFormApi.getValues();
expect(getChildren).toHaveBeenCalledTimes(1);
});
it('should format child schema values inside array fields', async () => {
formApi.setState({
schema: [

View File

@@ -0,0 +1,81 @@
import type { FormCodec } from '../src/types';
import { describe, expect, expectTypeOf, it } from 'vitest';
import {
decodeFormValues,
encodeFormValues,
FormCodecError,
} from '../src/form-codec';
interface FilterFormValues {
period: [number, number];
tags: string[];
}
interface FilterSubmitValues {
endTime: number;
startTime: number;
tags: string;
}
const filterCodec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode(values) {
return {
period: [values.startTime, values.endTime],
tags: values.tags ? values.tags.split(',') : [],
};
},
encode(values) {
return {
endTime: values.period[1],
startTime: values.period[0],
tags: values.tags.join(','),
};
},
};
describe('form codec', () => {
it('encodes and decodes complete form values', () => {
const submitValues = encodeFormValues(filterCodec, {
period: [1, 2],
tags: ['admin', 'user'],
});
expect(submitValues).toEqual({
endTime: 2,
startTime: 1,
tags: 'admin,user',
});
expect(decodeFormValues(filterCodec, submitValues)).toEqual({
period: [1, 2],
tags: ['admin', 'user'],
});
expectTypeOf(submitValues).toEqualTypeOf<FilterSubmitValues>();
});
it('reports the failed codec phase without mutating inputs', () => {
const values = Object.freeze({ period: [1, 2], tags: ['admin'] }) as {
period: [number, number];
tags: string[];
};
const codec: FormCodec<FilterFormValues, FilterSubmitValues> = {
decode: filterCodec.decode,
encode() {
throw new Error('broken encoder');
},
};
expect(() => encodeFormValues(codec, values)).toThrowError(FormCodecError);
let codecError: unknown;
try {
encodeFormValues(codec, values);
} catch (error) {
codecError = error;
}
expect(codecError).toBeInstanceOf(FormCodecError);
expect(codecError).toMatchObject({ phase: 'encode' });
expect(values).toEqual({ period: [1, 2], tags: ['admin'] });
});
});

View File

@@ -1,3 +1,5 @@
import type { BaseFormComponentType } from '../src/types';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { setupVbenForm } from '../src/config';
@@ -14,6 +16,83 @@ afterEach(() => {
});
describe('form api compatibility', () => {
it('keeps deprecated value transforms and warns once per API', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi({
arrayToStringFields: ['tags'],
fieldMappingTime: [['period', ['startTime', 'endTime'], null]],
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: (value: string) => value.trim(),
},
],
});
const form = {
meta: {},
values: {
name: ' Ada ',
period: [1, 2],
tags: ['admin', 'user'],
},
} as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({
endTime: 2,
name: 'Ada',
startTime: 1,
tags: 'admin,user',
});
expect(warning).toHaveBeenCalledTimes(3);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `schema.valueFormat` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `fieldMappingTime` is deprecated. Use the form-level `codec` instead.',
);
expect(warning).toHaveBeenCalledWith(
'[Vben Form] `arrayToStringFields` is deprecated. Use the form-level `codec` instead.',
);
});
it('prefers the codec when deprecated transforms are also configured', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const formApi = new FormApi<
{ name: string },
BaseFormComponentType,
Record<never, never>,
{ normalizedName: string }
>({
codec: {
decode(values) {
return { name: values.normalizedName };
},
encode(values) {
return { normalizedName: values.name.toUpperCase() };
},
},
schema: [
{
component: 'input',
fieldName: 'name',
valueFormat: () => 'legacy',
},
],
});
const form = { meta: {}, values: { name: 'Ada' } } as any;
formApi.mount(form);
expect(await formApi.getValues()).toEqual({ normalizedName: 'ADA' });
expect(warning).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'[Vben Form] The form `codec` takes precedence over deprecated `valueFormat`, `fieldMappingTime`, and `arrayToStringFields` options.',
);
});
it('forwards defineRules and warns only once in development', async () => {
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {});
const legacyRule = () => 'legacy error';

View File

@@ -653,4 +653,181 @@ describe('useVbenForm integration', () => {
expect(formApi.form.getFieldError('username')).toBeUndefined();
});
it('passes formatted values and raw values to handleSubmit callback', async () => {
const handleSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
codec: {
decode(values: Readonly<{ normalizedName: string }>) {
return { name: values.normalizedName };
},
encode(values: Readonly<{ name: string }>) {
return { normalizedName: values.name.trim().toUpperCase() };
},
},
handleSubmit,
schema: [
{
component: TestInput,
defaultValue: '',
fieldName: 'name',
rules: 'required',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' test ');
await formApi.validateAndSubmit();
await flushPromises();
expect(handleSubmit).toHaveBeenCalledOnce();
expect(handleSubmit).toHaveBeenCalledWith(
{ normalizedName: 'TEST' },
{ name: ' test ' },
);
});
it('does not expose raw values through native form submit events', async () => {
const onSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
schema: [
{
component: TestInput,
defaultValue: '',
fieldName: 'name',
rules: 'required',
valueFormat: (value) => value.trim().toUpperCase(),
},
],
});
const wrapper = mount(Form, { attrs: { onSubmit } });
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' raw ');
await wrapper.get('form').trigger('submit');
await flushPromises();
expect(onSubmit).toHaveBeenCalledOnce();
expect(onSubmit).toHaveBeenCalledWith(undefined);
});
it('calls handleReset with formatted values on reset button click', async () => {
const handleReset = vi.fn();
const [Form, formApi] = useVbenForm({
codec: {
decode(values: Readonly<{ normalizedName: string }>) {
return { name: values.normalizedName };
},
encode(values: Readonly<{ name: string }>) {
return { normalizedName: values.name.toUpperCase() };
},
},
handleReset,
schema: [
{
component: TestInput,
defaultValue: 'hello',
fieldName: 'name',
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
expect(await formApi.getValues()).toEqual({ normalizedName: 'HELLO' });
const resetButton = wrapper.findAll('button')[0];
expect(resetButton).toBeDefined();
if (!resetButton) return;
await resetButton.trigger('click');
await flushPromises();
expect(handleReset).toHaveBeenCalledOnce();
expect(handleReset).toHaveBeenCalledWith({ normalizedName: 'HELLO' });
});
it('setValues with multiple fields emits only the final values', async () => {
const handleValuesChange = vi.fn();
const [Form, formApi] = useVbenForm({
handleValuesChange,
schema: [
{ component: TestInput, fieldName: 'first' },
{ component: TestInput, fieldName: 'second' },
{ component: TestInput, fieldName: 'third' },
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
const initialCallCount = handleValuesChange.mock.calls.length;
await formApi.setValues({ first: 'a', second: 'b', third: 'c' });
await flushPromises();
const calls = handleValuesChange.mock.calls.slice(initialCallCount);
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toEqual({
first: 'a',
second: 'b',
third: 'c',
});
});
it('retains initial values for unspecified fields on partial reset', async () => {
const [Form, formApi] = useVbenForm({
schema: [
{ component: TestInput, defaultValue: 'original', fieldName: 'name' },
{ component: TestInput, defaultValue: 'keep', fieldName: 'alias' },
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setValues({ alias: 'changed', name: 'changed' });
await formApi.reset({ values: { name: 'reset' } });
await flushPromises();
expect(await formApi.getValues()).toEqual({
alias: 'keep',
name: 'reset',
});
});
it('applies valueFormat consistently across value APIs', async () => {
const handleSubmit = vi.fn();
const [Form, formApi] = useVbenForm({
handleSubmit,
schema: [
{
component: TestInput,
fieldName: 'name',
valueFormat: (value) => (value ? value.trim().toUpperCase() : ''),
},
],
});
const wrapper = mount(Form);
wrappers.push(wrapper);
await flushPromises();
await formApi.setFieldValue('name', ' hello ');
await flushPromises();
expect(await formApi.getValues()).toEqual({ name: 'HELLO' });
expect(await formApi.getValueSnapshot()).toEqual({
rawValues: { name: ' hello ' },
values: { name: 'HELLO' },
});
await formApi.validateAndSubmit();
await flushPromises();
expect(handleSubmit).toHaveBeenCalledWith(
{ name: 'HELLO' },
{ name: ' hello ' },
);
});
});

View File

@@ -6,6 +6,7 @@ import type {
FormFieldOptions,
FormItemDependencies,
FormValidationResult,
FormValueSnapshot,
VbenFormAdapterOptions,
VbenFormProps,
} from '../src/types';
@@ -22,6 +23,12 @@ interface AccountFormValues {
roles: string[];
}
interface AccountSubmitValues {
email: string;
nickname: string;
roles: string;
}
describe('form public types', () => {
it('keeps the compatibility alias and stable method signatures', () => {
expectTypeOf<FormActions>().toEqualTypeOf<FormContextApi>();
@@ -142,6 +149,96 @@ describe('form public types', () => {
>().toEqualTypeOf<AccountFormValues>();
});
it('keeps form and submit values distinct with a codec', () => {
const options: VbenFormProps<
BaseFormComponentType,
Record<never, never>,
AccountFormValues,
AccountSubmitValues
> = {
codec: {
decode(values) {
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleSubmit(values, rawValues) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(rawValues).toEqualTypeOf<Readonly<AccountFormValues>>();
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
handleValuesChange(values, _fieldsChanged, getFormattedValues) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountFormValues>>();
expectTypeOf(getFormattedValues()).toEqualTypeOf<AccountSubmitValues>();
},
schema: [],
};
const [, formApi] = useVbenForm<
AccountFormValues,
BaseFormComponentType,
Record<never, never>,
AccountSubmitValues
>(options);
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
expectTypeOf(formApi.getValueSnapshot()).resolves.toEqualTypeOf<
FormValueSnapshot<AccountFormValues, AccountSubmitValues>
>();
expectTypeOf(formApi.setSubmitValues)
.parameter(0)
.toEqualTypeOf<AccountSubmitValues>();
});
it('infers submit values from an inline codec', () => {
const [, formApi] = useVbenForm({
codec: {
decode(values) {
expectTypeOf(values).toEqualTypeOf<Readonly<AccountSubmitValues>>();
return {
email: values.email,
profile: { nickname: values.nickname },
roles: values.roles.split(','),
};
},
encode(values: Readonly<AccountFormValues>) {
return {
email: values.email,
nickname: values.profile.nickname,
roles: values.roles.join(','),
};
},
},
handleReset(values) {
expectTypeOf(values).toEqualTypeOf<AccountSubmitValues>();
},
schema: [],
});
expectTypeOf(
formApi.getValues(),
).resolves.toEqualTypeOf<AccountSubmitValues>();
expectTypeOf(
formApi.getRawValues(),
).resolves.toEqualTypeOf<AccountFormValues>();
});
it('exposes canonical names alongside deprecated aliases', () => {
expectTypeOf<FormContextApi['reset']>().toEqualTypeOf<
FormContextApi['resetForm']

View File

@@ -29,14 +29,16 @@ import {
import { warnDeprecatedOnce } from './deprecation';
import { resolveFieldNamePath } from './field-name';
import { decodeFormValues, encodeFormValues } from './form-codec';
import { updateFormSchemaList } from './form-render/schema';
import { formatFormValues } from './form-value-transform';
type FormApiProps<
TValues extends FormValues,
TFormValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = VbenFormProps<T, P, TValues>;
TSubmitValues extends FormValues,
> = VbenFormProps<T, P, TFormValues, TSubmitValues>;
type FormApiSchema<
TValues extends FormValues,
@@ -45,10 +47,11 @@ type FormApiSchema<
> = FormSchema<T, P, TValues>;
function getDefaultState<
TValues extends FormValues,
TFormValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
>(): FormApiProps<TValues, T, P> {
TSubmitValues extends FormValues,
>(): FormApiProps<TFormValues, T, P, TSubmitValues> {
return {
actionWrapperClass: '',
collapsed: false,
@@ -73,18 +76,19 @@ function getDefaultState<
}
export class FormApi<
TValues extends FormValues = FormValues,
TFormValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TFormValues,
> {
// private api: Pick<VbenFormProps, 'handleReset' | 'handleSubmit'>;
public form = {} as FormActions<TValues>;
public form = {} as FormActions<TFormValues>;
isMounted = false;
public state: FormApiProps<TValues, T, P> | null = null;
public state: FormApiProps<TFormValues, T, P, TSubmitValues> | null = null;
stateHandler: StateHandler;
public store: Store<FormApiProps<TValues, T, P>>;
public store: Store<FormApiProps<TFormValues, T, P, TSubmitValues>>;
/**
* 组件实例映射
@@ -92,16 +96,22 @@ export class FormApi<
private componentRefMap: Map<string, unknown> = new Map();
// 最后一次点击提交时的表单值
private latestSubmissionValues: null | Partial<TValues> = null;
private latestSubmissionValues: null | Partial<TSubmitValues> = null;
private prevState: FormApiProps<TValues, T, P> | null = null;
private legacyTransformWarningState: null | Pick<
FormApiProps<TFormValues, T, P, TSubmitValues>,
'arrayToStringFields' | 'codec' | 'fieldMappingTime' | 'schema'
> = null;
constructor(options: FormApiProps<TValues, T, P> = {}) {
private prevState: FormApiProps<TFormValues, T, P, TSubmitValues> | null =
null;
constructor(options: FormApiProps<TFormValues, T, P, TSubmitValues> = {}) {
const { ...storeState } = options;
const defaultState = getDefaultState<TValues, T, P>();
const defaultState = getDefaultState<TFormValues, T, P, TSubmitValues>();
this.store = new Store<FormApiProps<TValues, T, P>>({
this.store = new Store<FormApiProps<TFormValues, T, P, TSubmitValues>>({
...defaultState,
...storeState,
});
@@ -118,21 +128,31 @@ export class FormApi<
}
async clearValidation(
fieldNames?: FormFieldName<TValues> | FormFieldName<TValues>[],
fieldNames?: FormFieldName<TFormValues> | FormFieldName<TFormValues>[],
) {
const form = await this.getForm();
form.clearValidation(fieldNames);
}
formatValues<TResult extends FormValues = TValues>(
formatValues(rawValues: Readonly<TFormValues>): TSubmitValues;
/** @deprecated Declare the submit type on `useVbenForm` instead. */
formatValues<TResult extends FormValues>(
rawValues: Readonly<FormValues>,
) {
): TResult;
formatValues(rawValues: Readonly<FormValues>): FormValues {
this.warnLegacyValueTransforms();
if (this.state?.codec) {
return encodeFormValues(
this.state.codec,
rawValues as Readonly<TFormValues>,
);
}
return formatFormValues(
toRaw(rawValues),
this.state?.schema ?? [],
this.state?.fieldMappingTime,
this.state?.arrayToStringFields,
) as TResult;
);
}
/**
@@ -196,41 +216,52 @@ export class FormApi<
return this.latestSubmissionValues || {};
}
async getRawValues<TResult extends FormValues = TValues>() {
async getRawValues(): Promise<TFormValues>;
/** @deprecated Declare the form value type on `useVbenForm` instead. */
async getRawValues<TResult extends FormValues>(): Promise<TResult>;
async getRawValues(): Promise<FormValues> {
const form = await this.getForm();
return cloneDeep(toRaw(form.values ?? {})) as unknown as TResult;
return cloneDeep(toRaw(form.values ?? {}));
}
getState() {
return this.state;
}
async getValues<TResult extends FormValues = TValues>() {
async getValues(): Promise<TSubmitValues>;
/** @deprecated Declare the submit type on `useVbenForm` instead. */
async getValues<TResult extends FormValues>(): Promise<TResult>;
async getValues(): Promise<FormValues> {
const form = await this.getForm();
return this.formatValues<TResult>(toRaw(form.values ?? {}));
return this.formatValues(toRaw(form.values ?? {}));
}
async getValueSnapshot<TResult extends FormValues = TValues>(): Promise<
async getValueSnapshot(): Promise<
FormValueSnapshot<TFormValues, TSubmitValues>
>;
/** @deprecated Declare form and submit value types on `useVbenForm`. */
async getValueSnapshot<TResult extends FormValues>(): Promise<
FormValueSnapshot<TResult>
> {
const rawValues = await this.getRawValues<TResult>();
>;
async getValueSnapshot(): Promise<FormValueSnapshot> {
const rawValues = await this.getRawValues();
return {
rawValues,
values: this.formatValues<TResult>(rawValues),
values: this.formatValues(rawValues),
};
}
async isFieldValid(fieldName: FormFieldName<TValues>) {
async isFieldValid(fieldName: FormFieldName<TFormValues>) {
const form = await this.getForm();
return form.isFieldValid(fieldName);
}
merge(formApi: FormApi<any, any, any>) {
merge(formApi: FormApi<any, any, any, any>) {
const chain = [this, formApi];
const proxy = new Proxy(formApi, {
get(target: any, prop: any) {
if (prop === 'merge') {
return (nextFormApi: FormApi<any, any, any>) => {
return (nextFormApi: FormApi<any, any, any, any>) => {
chain.push(nextFormApi);
return proxy;
};
@@ -266,7 +297,7 @@ export class FormApi<
}
mount(
formActions: FormActions<TValues>,
formActions: FormActions<TFormValues>,
componentRefMap?: Map<string, unknown>,
) {
if (!this.isMounted) {
@@ -275,9 +306,7 @@ export class FormApi<
const initialValues = this.form.values
? this.formatValues(toRaw(this.form.values))
: {};
this.setLatestSubmissionValues({
...initialValues,
} as Partial<TValues>);
this.setLatestSubmissionValues(initialValues);
this.componentRefMap =
componentRefMap ?? this.componentRefMap ?? new Map();
this.isMounted = true;
@@ -302,13 +331,16 @@ export class FormApi<
/**
* 重置表单
*/
async reset(state?: FormResetState<TValues>, opts?: FormResetOptions) {
async reset(state?: FormResetState<TFormValues>, opts?: FormResetOptions) {
const form = await this.getForm();
return form.reset(state, opts);
}
/** @deprecated Use `reset` instead. */
async resetForm(state?: FormResetState<TValues>, opts?: FormResetOptions) {
async resetForm(
state?: FormResetState<TFormValues>,
opts?: FormResetOptions,
) {
warnDeprecatedOnce(
'form-api-reset-form',
'[Vben Form] `formApi.resetForm()` is deprecated. Use `formApi.reset()` instead.',
@@ -359,27 +391,27 @@ export class FormApi<
}
}
async setFieldValue<TFieldName extends FormFieldName<TValues>>(
async setFieldValue<TFieldName extends FormFieldName<TFormValues>>(
field: TFieldName,
value: FormFieldValue<TValues, NoInfer<TFieldName>>,
value: FormFieldValue<TFormValues, NoInfer<TFieldName>>,
shouldValidate?: boolean,
) {
const form = await this.getForm();
await form.setFieldValue(field, value, shouldValidate);
}
setLatestSubmissionValues(values: null | Partial<TValues>) {
setLatestSubmissionValues(values: null | Partial<TSubmitValues>) {
this.latestSubmissionValues = {
...toRaw(values),
} as Partial<TValues>;
} as Partial<TSubmitValues>;
}
setState(
stateOrFn:
| ((
prev: FormApiProps<TValues, T, P>,
) => Partial<FormApiProps<TValues, T, P>>)
| Partial<FormApiProps<TValues, T, P>>,
prev: FormApiProps<TFormValues, T, P, TSubmitValues>,
) => Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>)
| Partial<FormApiProps<TFormValues, T, P, TSubmitValues>>,
) {
if (isFunction(stateOrFn)) {
this.store.setState((prev) => {
@@ -390,6 +422,21 @@ export class FormApi<
}
}
async setSubmitValues(
values: TSubmitValues,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
const codec = this.state?.codec;
if (!codec) {
throw new Error(
'[Vben Form] `setSubmitValues()` requires a form `codec`.',
);
}
const formValues = decodeFormValues(codec, values);
await this.setValues(formValues, filterFields, shouldValidate);
}
/**
* 设置表单值
* @param fields record
@@ -397,7 +444,7 @@ export class FormApi<
* @param shouldValidate
*/
async setValues(
fields: Partial<TValues>,
fields: Partial<TFormValues>,
filterFields: boolean = true,
shouldValidate: boolean = false,
) {
@@ -445,8 +492,8 @@ export class FormApi<
}
return result;
};
const filteredFields = filterValue(fields) as Partial<TValues>;
form.setValues(filteredFields as Partial<TValues>, shouldValidate);
const filteredFields = filterValue(fields) as Partial<TFormValues>;
form.setValues(filteredFields as Partial<TFormValues>, shouldValidate);
}
async submit(e?: Event) {
@@ -475,8 +522,8 @@ export class FormApi<
this.stateHandler.reset();
}
updateSchema(schema: Partial<FormApiSchema<TValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TValues, T, P>>[] = [...schema];
updateSchema(schema: Partial<FormApiSchema<TFormValues, T, P>>[]) {
const updated: Partial<FormApiSchema<TFormValues, T, P>>[] = [...schema];
const hasField = updated.every(
(item) => Reflect.has(item, 'fieldName') && item.fieldName,
);
@@ -523,7 +570,7 @@ export class FormApi<
return this.validateAndSubmit();
}
async validateField(fieldName: FormFieldName<TValues>) {
async validateField(fieldName: FormFieldName<TFormValues>) {
const form = await this.getForm();
const validateResult = await form.validateField(fieldName);
@@ -568,9 +615,74 @@ export class FormApi<
for (const schema of deletedSchema) {
this.form?.setFieldValue?.(
schema.fieldName,
undefined as FormFieldValue<TValues, string>,
undefined as FormFieldValue<TFormValues, string>,
);
}
}
}
private warnLegacyValueTransforms() {
const warningState = {
arrayToStringFields: this.state?.arrayToStringFields,
codec: this.state?.codec,
fieldMappingTime: this.state?.fieldMappingTime,
schema: this.state?.schema ?? [],
};
const previousState = this.legacyTransformWarningState;
if (
previousState &&
previousState.arrayToStringFields === warningState.arrayToStringFields &&
previousState.codec === warningState.codec &&
previousState.fieldMappingTime === warningState.fieldMappingTime &&
previousState.schema === warningState.schema
) {
return;
}
this.legacyTransformWarningState = warningState;
const hasValueFormat = (
items: FormApiSchema<TFormValues, T, P>[],
): boolean => {
return items.some((schema) => {
if (schema.valueFormat) {
return true;
}
const children = 'children' in schema ? schema.children : undefined;
return Array.isArray(children) && hasValueFormat(children);
});
};
const usesValueFormat = hasValueFormat(warningState.schema);
const usesFieldMappingTime =
(warningState.fieldMappingTime?.length ?? 0) > 0;
const usesArrayToStringFields =
(warningState.arrayToStringFields?.length ?? 0) > 0;
const usesLegacyTransform =
usesValueFormat || usesFieldMappingTime || usesArrayToStringFields;
if (warningState.codec && usesLegacyTransform) {
warnDeprecatedOnce(
'form-codec-legacy-transform-conflict',
'[Vben Form] The form `codec` takes precedence over deprecated `valueFormat`, `fieldMappingTime`, and `arrayToStringFields` options.',
);
return;
}
if (usesValueFormat) {
warnDeprecatedOnce(
'form-schema-value-format',
'[Vben Form] `schema.valueFormat` is deprecated. Use the form-level `codec` instead.',
);
}
if (usesFieldMappingTime) {
warnDeprecatedOnce(
'form-field-mapping-time',
'[Vben Form] `fieldMappingTime` is deprecated. Use the form-level `codec` instead.',
);
}
if (usesArrayToStringFields) {
warnDeprecatedOnce(
'form-array-to-string-fields',
'[Vben Form] `arrayToStringFields` is deprecated. Use the form-level `codec` instead.',
);
}
}
}

View File

@@ -0,0 +1,40 @@
import type { FormCodec, FormValues } from './types';
export type FormCodecPhase = 'decode' | 'encode';
export class FormCodecError extends Error {
override readonly cause: unknown;
readonly phase: FormCodecPhase;
constructor(phase: FormCodecPhase, cause: unknown) {
super(`[Vben Form] Failed to ${phase} form values.`);
this.name = 'FormCodecError';
this.cause = cause;
this.phase = phase;
}
}
export function decodeFormValues<
TFormValues extends FormValues,
TSubmitValues extends FormValues,
>(
codec: FormCodec<TFormValues, TSubmitValues>,
values: Readonly<TSubmitValues>,
) {
try {
return codec.decode(values);
} catch (error) {
throw new FormCodecError('decode', error);
}
}
export function encodeFormValues<
TFormValues extends FormValues,
TSubmitValues extends FormValues,
>(codec: FormCodec<TFormValues, TSubmitValues>, values: Readonly<TFormValues>) {
try {
return codec.encode(values);
} catch (error) {
throw new FormCodecError('encode', error);
}
}

View File

@@ -69,7 +69,7 @@ const formComponent = 'form';
const formComponentProps = computed(() => {
return props.form
? {
onSubmit: props.form.handleSubmit((val) => emits('submit', val)),
onSubmit: props.form.handleSubmit(() => emits('submit', undefined)),
}
: {
onSubmit: (event: Event) => {

View File

@@ -10,6 +10,8 @@ import type {
import { computed, shallowRef } from 'vue';
import { mergeWithArrayOverride } from '@vben-core/shared/utils';
import { useForm } from '@tanstack/vue-form';
import { createRuntimeFieldComponent } from './form-runtime-field';
@@ -218,7 +220,19 @@ export function useFormRuntime<TValues extends FormValues>(
invalidateFieldValidation(fieldName);
}
manualErrors.value = new Map();
rawForm.reset(resetState?.values as TValues | undefined, options);
const partialValues = resetState?.values;
let resetValues: TValues | undefined;
if (partialValues) {
resetValues = options?.force
? (partialValues as TValues)
: (mergeWithArrayOverride(
partialValues,
rawForm.options.defaultValues ?? defaultValues,
) as TValues);
}
rawForm.reset(resetValues, {
keepDefaultValues: options?.keepDefaultValues,
});
}
async function submit() {
@@ -244,13 +258,13 @@ export function useFormRuntime<TValues extends FormValues>(
typeof fieldName
>;
},
handleSubmit(callback) {
return async (event) => {
handleSubmit(callback?) {
return async (event?: Event) => {
event?.preventDefault();
event?.stopPropagation();
const result = await validate();
if (result.valid) {
await callback(values.value);
await callback?.(values.value as TValues);
}
};
},

View File

@@ -1,13 +1,18 @@
export { setupVbenForm } from './config';
export { FormCodecError } from './form-codec';
export type { FormCodecPhase } from './form-codec';
export type {
BaseFormComponentType,
ExtendedFormApi,
FormActions,
FormCodec,
FormContextApi,
FormLayout,
FormSchemaContext,
FormValues,
FormValueSnapshot,
VbenFormActionSlotProps,
VbenFormComponent,
VbenFormDefaultSlotProps,

View File

@@ -9,6 +9,16 @@ import type { FormApi } from './form-api';
export type FormValues = Record<string, any>;
export interface FormCodec<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> {
/** 将提交值转换为表单组件值。 */
decode: (values: Readonly<TSubmitValues>) => TFormValues;
/** 将表单组件值转换为提交值。 */
encode: (values: Readonly<TFormValues>) => TSubmitValues;
}
export type FormFieldName<TValues extends FormValues = FormValues> =
| Extract<keyof TValues, string>
| (Record<never, never> & string);
@@ -116,9 +126,12 @@ export interface FormValidationResult {
valid: boolean;
}
export interface FormValueSnapshot<TValues extends FormValues = FormValues> {
rawValues: Readonly<TValues>;
values: TValues;
export interface FormValueSnapshot<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> {
rawValues: Readonly<TFormValues>;
values: TSubmitValues;
}
export interface FormResetState<TValues extends FormValues = FormValues> {
@@ -141,7 +154,7 @@ export interface FormContextApi<TValues extends FormValues = FormValues> {
fieldName: TFieldName,
) => FormFieldValue<TValues, TFieldName>;
handleSubmit: (
callback: (values: TValues) => Promise<void> | void,
callback?: (values: TValues) => Promise<void> | void,
) => (event?: Event) => Promise<void>;
isFieldValid: (fieldName: string) => boolean;
readonly meta: FormMeta;
@@ -205,8 +218,9 @@ export interface VbenFormActionSlotProps<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> {
formApi: ExtendedFormApi<TValues, T, P>;
formApi: ExtendedFormApi<TValues, T, P, TSubmitValues>;
values: TValues;
}
@@ -214,7 +228,8 @@ export interface VbenFormDefaultSlotProps<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> {
TSubmitValues extends FormValues = TValues,
> extends VbenFormActionSlotProps<TValues, T, P, TSubmitValues> {
shapes: FormShape[];
}
@@ -223,7 +238,8 @@ export interface VbenFormFieldSlotProps<
TFieldName extends KnownFormFieldName<TValues> = KnownFormFieldName<TValues>,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> extends VbenFormActionSlotProps<TValues, T, P> {
TSubmitValues extends FormValues = TValues,
> extends VbenFormActionSlotProps<TValues, T, P, TSubmitValues> {
componentField: FormComponentField<TValues[TFieldName], TFieldName>;
disabled: boolean;
field: FormRuntimeField<TValues[TFieldName]>;
@@ -236,12 +252,19 @@ type VbenFormFieldSlots<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
TSubmitValues extends FormValues,
> =
string extends Extract<keyof TValues, string>
? Record<string, ((props: any) => any) | undefined>
: {
[TFieldName in KnownFormFieldName<TValues>]?: (
props: VbenFormFieldSlotProps<TValues, TFieldName, T, P>,
props: VbenFormFieldSlotProps<
TValues,
TFieldName,
T,
P,
TSubmitValues
>,
) => any;
};
@@ -249,21 +272,33 @@ export type VbenFormSlots<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = VbenFormFieldSlots<TValues, T, P> & {
default?: (props: VbenFormDefaultSlotProps<TValues, T, P>) => any;
'expand-after'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'expand-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'reset-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
'submit-before'?: (props: VbenFormActionSlotProps<TValues, T, P>) => any;
TSubmitValues extends FormValues = TValues,
> = VbenFormFieldSlots<TValues, T, P, TSubmitValues> & {
default?: (
props: VbenFormDefaultSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'expand-after'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'expand-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'reset-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
'submit-before'?: (
props: VbenFormActionSlotProps<TValues, T, P, TSubmitValues>,
) => any;
};
export type VbenFormComponent<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TSubmitValues extends FormValues = TValues,
> = new () => {
$props: VbenFormProps<T, P, TValues>;
$slots: VbenFormSlots<TValues, T, P>;
$props: VbenFormProps<T, P, TValues, TSubmitValues>;
$slots: VbenFormSlots<TValues, T, P, TSubmitValues>;
};
export interface FormSchemaContext<TValues extends FormValues = FormValues> {
@@ -512,6 +547,7 @@ type MappedComponentProps<P, TValues extends FormValues = FormValues> =
* 把一个字段拆分写入到其他字段,例如 `startTime` / `endTime`
* - 返回其他值:会将当前字段恢复/写回为该返回值
* - `setValue` 回调签名为 `(key, nextValue) => void`
* @deprecated Use the form-level `codec` instead.
*/
export type FormValueFormat<TValues extends FormValues = FormValues> = (
value: any,
@@ -548,6 +584,7 @@ interface FormSchemaBody<TValues extends FormValues = FormValues> extends Omit<
* 获取表单值时格式化当前字段。
* - 返回值不为 `undefined` 时,会回写到当前 fieldName
* - 返回值为 `undefined` 时,可通过 setValue 写入一个或多个目标字段
* @deprecated Use the form-level `codec` instead.
*/
valueFormat?: FormValueFormat<TValues>;
}
@@ -637,15 +674,19 @@ export interface VbenFormFieldArrayProps<
showIndex?: boolean;
}
export type HandleSubmitFn<TValues extends FormValues = FormValues> = (
values: TValues,
rawValues: Readonly<TValues>,
export type HandleSubmitFn<
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = (
values: TSubmitValues,
rawValues: Readonly<TFormValues>,
) => Promise<void> | void;
export type HandleResetFn<TValues extends FormValues = FormValues> = (
values: TValues,
export type HandleResetFn<TSubmitValues extends FormValues = FormValues> = (
values: TSubmitValues,
) => Promise<void> | void;
/** @deprecated Use the form-level `codec` instead. */
export type FieldMappingTimeItem = [
string,
[string, string],
@@ -657,8 +698,10 @@ export type FieldMappingTimeItem = [
)?,
];
/** @deprecated Use the form-level `codec` instead. */
export type FieldMappingTime = FieldMappingTimeItem[];
/** @deprecated Use the form-level `codec` instead. */
export type ArrayToStringFields = Array<
| [string[], string?] // 嵌套数组格式,可选分隔符
| string // 单个字段,使用默认分隔符
@@ -682,6 +725,7 @@ export interface FormRenderProps<
> {
/**
* 表单字段数组映射字符串配置 默认使用","
* @deprecated Use the form-level `codec` instead.
*/
arrayToStringFields?: ArrayToStringFields;
/**
@@ -717,6 +761,7 @@ export interface FormRenderProps<
componentMap: Record<BaseFormComponentType, Component>;
/**
* 表单字段映射到时间格式
* @deprecated Use the form-level `codec` instead.
*/
fieldMappingTime?: FieldMappingTime;
/**
@@ -757,6 +802,7 @@ export interface VbenFormProps<
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TValues,
> extends Omit<
FormRenderProps<T, P, TValues>,
'componentBindEventMap' | 'componentMap' | 'form'
@@ -780,6 +826,7 @@ export interface VbenFormProps<
actionWrapperClass?: ClassType;
/**
* 表单字段数组映射字符串配置 默认使用","
* @deprecated Use the form-level `codec` instead.
*/
arrayToStringFields?: ArrayToStringFields;
@@ -787,8 +834,11 @@ export interface VbenFormProps<
* submitOnChange改变时防抖时间 | 默认300ms
*/
changeDebouncedTime?: number;
/** 表单组件值与提交值之间的双向编解码器。 */
codec?: FormCodec<TValues, TSubmitValues>;
/**
* 表单字段映射
* @deprecated Use the form-level `codec` instead.
*/
fieldMappingTime?: FieldMappingTime;
/**
@@ -798,18 +848,18 @@ export interface VbenFormProps<
/**
* 表单重置回调
*/
handleReset?: HandleResetFn<TValues>;
handleReset?: HandleResetFn<NoInfer<TSubmitValues>>;
/**
* 表单提交回调
*/
handleSubmit?: HandleSubmitFn<TValues>;
handleSubmit?: HandleSubmitFn<TValues, TSubmitValues>;
/**
* 表单值变化回调
*/
handleValuesChange?: (
values: Readonly<TValues>,
fieldsChanged: string[],
getFormattedValues: () => TValues,
getFormattedValues: () => TSubmitValues,
) => void;
/**
@@ -851,9 +901,12 @@ export type ExtendedFormApi<
TValues extends FormValues = FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = FormApi<TValues, T, P> & {
useStore: <TResult = NoInfer<VbenFormProps<T, P, TValues>>>(
selector?: (state: NoInfer<VbenFormProps<T, P, TValues>>) => TResult,
TSubmitValues extends FormValues = TValues,
> = FormApi<TValues, T, P, TSubmitValues> & {
useStore: <TResult = NoInfer<VbenFormProps<T, P, TValues, TSubmitValues>>>(
selector?: (
state: NoInfer<VbenFormProps<T, P, TValues, TSubmitValues>>,
) => TResult,
) => Readonly<Ref<TResult>>;
};

View File

@@ -17,7 +17,11 @@ type UseVbenFormReturn<
TValues extends FormValues,
T extends BaseFormComponentType,
P extends Record<string, any>,
> = readonly [VbenFormComponent<TValues, T, P>, ExtendedFormApi<TValues, T, P>];
TSubmitValues extends FormValues = TValues,
> = readonly [
VbenFormComponent<TValues, T, P, TSubmitValues>,
ExtendedFormApi<TValues, T, P, TSubmitValues>,
];
export function useVbenForm<
T extends BaseFormComponentType = BaseFormComponentType,
@@ -28,14 +32,17 @@ export function useVbenForm<
TValues extends FormValues,
T extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
>(options: VbenFormProps<T, P, TValues>): UseVbenFormReturn<TValues, T, P>;
TSubmitValues extends FormValues = TValues,
>(
options: VbenFormProps<T, P, TValues, TSubmitValues>,
): UseVbenFormReturn<TValues, T, P, TSubmitValues>;
export function useVbenForm(
options: VbenFormProps<any, any, any>,
): UseVbenFormReturn<any, any, any> {
options: VbenFormProps<any, any, any, any>,
): UseVbenFormReturn<any, any, any, any> {
const IS_REACTIVE = isReactive(options);
const api = new FormApi<any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any>;
const api = new FormApi<any, any, any, any>(options);
const extendedApi = api as ExtendedFormApi<any, any, any, any>;
extendedApi.useStore = (selector: any) => {
return useSelector(api.store, selector);
};
@@ -65,5 +72,10 @@ export function useVbenForm(
);
}
return [Form, extendedApi] as unknown as UseVbenFormReturn<any, any, any>;
return [Form, extendedApi] as unknown as UseVbenFormReturn<
any,
any,
any,
any
>;
}

View File

@@ -23,7 +23,7 @@ import {
// 通过 extends 会导致热更新卡死,所以重复写了一遍
interface Props extends VbenFormProps {
formApi?: ExtendedFormApi<any, any, any>;
formApi?: ExtendedFormApi<any, any, any, any>;
}
const props = defineProps<Props>();

View File

@@ -43,18 +43,20 @@ const close = () => {
open.value = false;
};
const handleViewAll = () => {
function handleViewAll() {
emit('viewAll');
close();
};
}
const handleMakeAll = () => {
function handleMakeAll() {
emit('makeAll');
};
}
const handleClear = () => {
function handleClear() {
emit('clear');
};
}
defineExpose({ toggle });
</script>
<template>
<VbenPopover v-model:open="open" content-class="relative right-2 w-90 p-0">

View File

@@ -8,6 +8,8 @@ import { $t } from '@vben/locales';
import Sortable from 'sortablejs';
import SelectItem from './select-item.vue';
interface Item {
key: string;
label: string;
@@ -67,14 +69,13 @@ onUnmounted(() => {
sortableInstance = null;
});
function setPosition(key: string, event: Event) {
const value = (event.target as HTMLSelectElement).value as
| 'auto'
| 'fixed'
| 'header'
| 'none'
| 'user-dropdown';
emit('updatePosition', key, value);
function setPosition(key: string, value: string | undefined) {
if (!value) return;
emit(
'updatePosition',
key,
value as 'auto' | 'fixed' | 'header' | 'none' | 'user-dropdown',
);
nextTick(() => {
emit(
'updateOrder',
@@ -91,25 +92,21 @@ function setPosition(key: string, event: Event) {
v-for="item in sortableList"
:key="item.key"
:data-key="item.key"
class="bg-accent flex items-center gap-2 rounded-md px-2 py-1.5"
class="bg-accent flex items-center rounded-md pl-2"
>
<GripVertical
class="drag-handle size-4 shrink-0 cursor-grab text-muted-foreground active:cursor-grabbing"
/>
<span class="min-w-0 flex-1 truncate text-sm">{{ item.label }}</span>
<select
:value="item.position"
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs"
@change="(e) => setPosition(item.key, e)"
<SelectItem
:items="item.positionItems ?? positionItems"
:model-value="item.position"
class="min-w-0 flex-1"
@update:model-value="
(v: string | undefined) => setPosition(item.key, v)
"
>
<option
v-for="opt in item.positionItems ?? positionItems"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<span class="truncate">{{ item.label }}</span>
</SelectItem>
</div>
</div>
<div v-if="hiddenList.length > 0" class="pt-2">
@@ -119,26 +116,22 @@ function setPosition(key: string, event: Event) {
<div
v-for="item in hiddenList"
:key="item.key"
class="flex items-center gap-2 rounded-md px-2 py-1"
class="flex items-center rounded-md"
>
<span
class="text-muted-foreground min-w-0 flex-1 truncate text-sm line-through decoration-dotted"
<SelectItem
:items="item.positionItems ?? positionItems"
:model-value="item.position"
class="min-w-0 flex-1"
@update:model-value="
(v: string | undefined) => setPosition(item.key, v)
"
>
{{ item.label }}
</span>
<select
:value="item.position"
class="bg-background h-7 w-28 shrink-0 rounded border px-1 text-xs"
@change="(e) => setPosition(item.key, e)"
>
<option
v-for="opt in item.positionItems ?? positionItems"
:key="opt.value"
:value="opt.value"
<span
class="text-muted-foreground truncate line-through decoration-dotted"
>
{{ opt.label }}
</option>
</select>
{{ item.label }}
</span>
</SelectItem>
</div>
</div>
</div>

View File

@@ -94,10 +94,10 @@ const positionMap: Record<string, string> = {
languageToggle: 'widgetLanguageToggleButtonPosition',
timezone: 'widgetTimezoneButtonPosition',
fullscreen: 'widgetFullscreenButtonPosition',
refresh: 'widgetRefreshButtonPosition',
notification: 'widgetNotificationButtonPosition',
lockScreenBtn: 'widgetLockScreenButtonPosition',
logoutBtn: 'widgetLogoutButtonPosition',
refresh: 'widgetRefreshButtonPosition',
};
const labelMap: Record<string, string> = {

View File

@@ -12,6 +12,8 @@ import {
VbenIconButton,
} from '@vben-core/shadcn-ui';
withDefaults(defineProps<{ showButton?: boolean }>(), { showButton: true });
const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
const timezoneStore = useTimezoneStore();
@@ -47,17 +49,20 @@ const [Modal, modalApi] = useVbenModal({
},
});
const handleClick = () => {
function open() {
modalApi.open();
};
}
defineExpose({ open });
</script>
<template>
<div>
<VbenIconButton
v-if="showButton"
:tooltip="$t('ui.widgets.timezone.setTimezone')"
class="hover:animate-[shrink_0.3s_ease-in-out]"
@click="handleClick"
@click="open"
>
<TimezoneIcon class="size-4 text-foreground" />
</VbenIconButton>

View File

@@ -8,6 +8,7 @@ import { computed, ref, useTemplateRef, watch } from 'vue';
import { SUPPORT_LANGUAGES } from '@vben/constants';
import { useHoverToggle, useRefresh } from '@vben/hooks';
import {
createIconifyIcon,
Languages,
LockKeyhole,
LogOut,
@@ -40,7 +41,7 @@ import {
VbenIconButton,
} from '@vben-core/shadcn-ui';
import { useMagicKeys, whenever } from '@vueuse/core';
import { useFullscreen, useMagicKeys, whenever } from '@vueuse/core';
import { GlobalSearch } from '../global-search';
import { LockScreenModal } from '../lock-screen';
@@ -101,9 +102,11 @@ const {
globalLogoutShortcutKey,
globalLockScreenShortcutKey,
globalSearchShortcutKey,
isDark,
preferencesButtonPosition,
} = usePreferences();
const { refresh } = useRefresh();
const { toggle: toggleFullscreen } = useFullscreen();
const accessStore = useAccessStore();
const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal,
@@ -118,6 +121,10 @@ const refTrigger = useTemplateRef('refTrigger');
const refContent = useTemplateRef('refContent');
const refPreferences = useTemplateRef('refPreferences');
const refGlobalSearch = useTemplateRef('refGlobalSearch');
const refTimezone = useTemplateRef('refTimezone');
const refNotification = useTemplateRef('refNotification');
const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
const [openPopover, hoverWatcher] = useHoverToggle(
[refTrigger, refContent],
() => props.hoverDelay,
@@ -247,6 +254,28 @@ function handleGlobalSearch() {
refGlobalSearch.value?.open();
}
// 主题切换
function handleThemeToggleSelect(event?: Event) {
event?.preventDefault();
updatePreferences({ theme: { mode: isDark.value ? 'light' : 'dark' } });
}
// 时区
function handleTimezoneSelect() {
refTimezone.value?.open();
}
// 全屏切换
function handleFullscreenSelect() {
toggleFullscreen();
}
// 通知
function handleNotificationSelect(event?: Event) {
event?.preventDefault();
refNotification.value?.toggle();
}
// 语言切换 - 阻止 Radix 默认关闭外层 dropdown,就地展开/收起 locale 列表
const showLanguageList = ref(false);
function handleLanguageToggleSelect(event?: Event) {
@@ -322,6 +351,12 @@ if (preferences.shortcutKeys.enable) {
:show-button="false"
/>
<TimezoneButton
v-if="showTimezoneInDropdown"
ref="refTimezone"
:show-button="false"
/>
<DropdownMenu v-model:open="openPopover" :modal="false">
<DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
<div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent">
@@ -428,6 +463,7 @@ if (preferences.shortcutKeys.enable) {
<DropdownMenuItem
v-if="showThemeToggleInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleThemeToggleSelect"
>
<ThemeToggle class="mr-2" />
{{ $t('preferences.theme.title') }}
@@ -461,22 +497,27 @@ if (preferences.shortcutKeys.enable) {
<DropdownMenuItem
v-if="showTimezoneInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleTimezoneSelect"
>
<TimezoneButton class="mr-2" />
<VbenIconButton class="mr-2" @click="handleTimezoneSelect">
<TimezoneIcon class="size-4" />
</VbenIconButton>
{{ $t('ui.widgets.timezone.setTimezone') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showFullscreenInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleFullscreenSelect"
>
<VbenFullScreen class="mr-2" />
<VbenFullScreen class="mr-2" @click.stop />
{{ $t('preferences.widget.fullscreen') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="showNotificationInDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@select="handleNotificationSelect"
>
<Notification class="mr-2" />
<Notification ref="refNotification" class="mr-2" />
{{ $t('preferences.widget.notification') }}
</DropdownMenuItem>
<DropdownMenuItem

View File

@@ -3,6 +3,7 @@ import type { VxeGridInstance } from 'vxe-table';
import type {
BaseFormComponentType,
ExtendedFormApi,
FormValues,
} from '@vben-core/form-ui';
import type { VxeGridProps } from './types';
@@ -34,14 +35,16 @@ export class VxeGridApi<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> {
public formApi = {} as ExtendedFormApi;
public formApi = {} as ExtendedFormApi<TFormValues, D, P, TSubmitValues>;
// private prevState: null | VxeGridProps = null;
public grid = {} as VxeGridInstance<T>;
public state: null | VxeGridProps<T, D, P> = null;
public state: null | VxeGridProps<T, D, P, TFormValues, TSubmitValues> = null;
public store: Store<VxeGridProps<T, D, P>>;
public store: Store<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>;
/**
* 已读行 helper(在 mount 中初始化,业务能力全部封装在 useViewedRow 中)
@@ -52,12 +55,26 @@ export class VxeGridApi<
private stateHandler: StateHandler;
constructor(options: VxeGridProps<T, D, P> = {} as VxeGridProps<T, D, P>) {
constructor(
options: VxeGridProps<
T,
D,
P,
TFormValues,
TSubmitValues
> = {} as VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
) {
const storeState = { ...options };
const defaultState = getDefaultState();
this.store = new Store<VxeGridProps<T, D, P>>(
mergeWithArrayOverride(storeState, defaultState) as VxeGridProps<T, D, P>,
this.store = new Store<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>(
mergeWithArrayOverride(storeState, defaultState) as VxeGridProps<
T,
D,
P,
TFormValues,
TSubmitValues
>,
);
this.store.subscribe((state) => {
@@ -106,7 +123,10 @@ export class VxeGridApi<
this.viewedRowHelper?.markAsViewed(record);
}
mount(instance: null | VxeGridInstance, formApi: ExtendedFormApi) {
mount(
instance: null | VxeGridInstance,
formApi: ExtendedFormApi<TFormValues, D, P, TSubmitValues>,
) {
if (!this.isMounted && instance) {
this.grid = instance;
this.formApi = formApi;
@@ -138,7 +158,11 @@ export class VxeGridApi<
this.viewedRowHelper?.removeKeys(keys);
}
setGridOptions(options: Partial<VxeGridProps<T, D, P>['gridOptions']>) {
setGridOptions(
options: Partial<
VxeGridProps<T, D, P, TFormValues, TSubmitValues>['gridOptions']
>,
) {
this.setState({
gridOptions: options,
});
@@ -154,8 +178,10 @@ export class VxeGridApi<
setState(
stateOrFn:
| ((prev: VxeGridProps<T, D, P>) => Partial<VxeGridProps<T, D, P>>)
| Partial<VxeGridProps<T, D, P>>,
| ((
prev: VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
) => Partial<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>)
| Partial<VxeGridProps<T, D, P, TFormValues, TSubmitValues>>,
) {
if (isFunction(stateOrFn)) {
this.store.setState((prev) => {

View File

@@ -10,7 +10,11 @@ import type { Ref } from 'vue';
import type { ClassType, DeepPartial } from '@vben/types';
import type { BaseFormComponentType, VbenFormProps } from '@vben-core/form-ui';
import type {
BaseFormComponentType,
FormValues,
VbenFormProps,
} from '@vben-core/form-ui';
import type { VxeGridApi } from './api';
@@ -124,6 +128,8 @@ export interface VxeGridProps<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> {
/**
* 数据
@@ -156,7 +162,7 @@ export interface VxeGridProps<
/**
* 表单配置
*/
formOptions?: VbenFormProps<D, P>;
formOptions?: VbenFormProps<D, P, TFormValues, TSubmitValues>;
/**
* 显示搜索表单
*/
@@ -175,9 +181,13 @@ export type ExtendedVxeGridApi<
D extends Record<string, any> = any,
F extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
> = VxeGridApi<D, F, P> & {
useStore: <S = NoInfer<VxeGridProps<D, F, P>>>(
selector?: (state: NoInfer<VxeGridProps<D, F, P>>) => S,
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
> = VxeGridApi<D, F, P, TFormValues, TSubmitValues> & {
useStore: <S = NoInfer<VxeGridProps<D, F, P, TFormValues, TSubmitValues>>>(
selector?: (
state: NoInfer<VxeGridProps<D, F, P, TFormValues, TSubmitValues>>,
) => S,
) => Readonly<Ref<S>>;
};

View File

@@ -2,7 +2,7 @@ import type { VxeGridSlots, VxeGridSlotTypes } from 'vxe-table';
import type { SlotsType } from 'vue';
import type { BaseFormComponentType } from '@vben-core/form-ui';
import type { BaseFormComponentType, FormValues } from '@vben-core/form-ui';
import type { ExtendedVxeGridApi, VxeGridProps } from './types';
@@ -23,24 +23,28 @@ export function useVbenVxeGrid<
T extends Record<string, any> = any,
D extends BaseFormComponentType = BaseFormComponentType,
P extends Record<string, any> = Record<never, never>,
>(options: VxeGridProps<T, D, P>) {
TFormValues extends FormValues = FormValues,
TSubmitValues extends FormValues = TFormValues,
>(options: VxeGridProps<T, D, P, TFormValues, TSubmitValues>) {
// const IS_REACTIVE = isReactive(options);
const api = new VxeGridApi<T, D, P>(options);
const extendedApi: ExtendedVxeGridApi<T, D, P> = api as ExtendedVxeGridApi<
T,
D,
P
>;
const api = new VxeGridApi<T, D, P, TFormValues, TSubmitValues>(options);
const extendedApi: ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues> =
api as ExtendedVxeGridApi<T, D, P, TFormValues, TSubmitValues>;
extendedApi.useStore = (selector) => {
return useStore(api.store, selector);
};
const Grid = defineComponent(
(props: VxeGridProps<T, D, P>, { attrs, slots }) => {
(
props: VxeGridProps<T, D, P, TFormValues, TSubmitValues>,
{ attrs, slots },
) => {
onBeforeUnmount(() => {
api.unmount();
});
api.setState({ ...props, ...attrs } as Partial<VxeGridProps<T, D, P>>);
api.setState({ ...props, ...attrs } as Partial<
VxeGridProps<T, D, P, TFormValues, TSubmitValues>
>);
return () =>
h(
VxeGrid,
@@ -48,7 +52,7 @@ export function useVbenVxeGrid<
...props,
...attrs,
api: extendedApi as ExtendedVxeGridApi,
},
} as any,
slots,
);
},

View File

@@ -194,24 +194,24 @@
"title": "小部件",
"globalSearch": "全局搜索",
"globalSearchPosition": "全局搜索位置",
"fullscreen": "启用全屏",
"fullscreen": "全屏",
"fullscreenPosition": "全屏按钮位置",
"themeToggle": "启用主题切换",
"themeToggle": "主题切换",
"themeTogglePosition": "主题切换位置",
"languageToggle": "语言切换",
"languageTogglePosition": "语言切换位置",
"notification": "启用通知",
"notification": "通知",
"notificationPosition": "通知位置",
"sidebarToggle": "启用侧边栏切换",
"lockScreen": "启用锁屏",
"lockScreen": "锁屏",
"lockScreenPosition": "锁屏按钮位置",
"logoutButtonPosition": "退出按钮位置",
"header": "顶栏",
"userDropdown": "用户下拉窗",
"hidden": "已隐藏",
"refresh": "启用刷新",
"refresh": "刷新",
"refreshPosition": "刷新按钮位置",
"timezone": "启用时区",
"timezone": "时区",
"timezonePosition": "时区按钮位置"
},
"antd": {