refactor: fastform and login form

pull/13/head
moonrailgun 4 years ago
parent e5e94abbf0
commit 40e5eb38c0

@ -0,0 +1,11 @@
import React from 'react';
import type { FastFormFieldComponent, FastFormFieldProps } from './field';
export const CustomField: FastFormFieldComponent<{
render: (props: FastFormFieldProps) => React.ReactNode;
}> = React.memo((props) => {
const { render, ...others } = props;
return <>{render(others)}</>;
});
CustomField.displayName = 'CustomField';

@ -0,0 +1,29 @@
import type { ComponentType } from 'react';
/**
*
*/
export interface FastFormContainerProps {
loading: boolean;
submitLabel?: string;
layout?: 'horizontal' | 'vertical';
/**
*
*/
canSubmit?: boolean;
handleSubmit: () => void;
}
export type FastFormContainerComponent =
React.ComponentType<FastFormContainerProps>;
let FastFormContainer: FastFormContainerComponent;
export function regFormContainer(component: FastFormContainerComponent) {
FastFormContainer = component;
}
export function getFormContainer():
| ComponentType<FastFormContainerProps>
| undefined {
return FastFormContainer;
}

@ -0,0 +1,13 @@
import React, { useContext } from 'react';
import type { useFormik } from 'formik';
type FastFormContextType = ReturnType<typeof useFormik>;
export const FastFormContext = React.createContext<FastFormContextType | null>(
null
);
FastFormContext.displayName = 'FastFormContext';
export function useFastFormContext(): FastFormContextType | null {
return useContext(FastFormContext);
}

@ -0,0 +1,52 @@
import { CustomField } from './CustomField';
/**
*
*/
interface FastFormFieldCommon {
name: string; // 字段名
label?: string; // 字段标签
defaultValue?: any; // 默认值
[other: string]: any; // 其他字段
}
export interface FastFormFieldProps extends FastFormFieldCommon {
value: any;
error: string | undefined;
onChange: (val: any) => void; // 修改数据的回调函数
}
/**
*
*/
export type FastFormFieldComponent<T = {}> = React.ComponentType<
FastFormFieldProps & T
>;
const fieldMap = new Map<string, FastFormFieldComponent>();
/**
*
*/
export function regField(type: string, component: FastFormFieldComponent<any>) {
fieldMap.set(type, component);
}
/**
*
*/
export function getField(
type: string
): FastFormFieldComponent<any> | undefined {
return fieldMap.get(type);
}
/**
*
*/
export interface FastFormFieldMeta extends FastFormFieldCommon {
type: string; // 字段类型
}
// 内建字段
regField('custom', CustomField);

@ -0,0 +1,101 @@
import React, { useMemo, useState } from 'react';
import { useFormik } from 'formik';
import _isNil from 'lodash/isNil';
import _fromPairs from 'lodash/fromPairs';
import _isFunction from 'lodash/isFunction';
import _isEmpty from 'lodash/isEmpty';
import type { ObjectSchema } from 'yup';
import { FastFormContext } from './context';
import { FastFormFieldMeta, getField } from './field';
import { getFormContainer } from './container';
/**
*
*/
export interface FastFormProps {
fields: FastFormFieldMeta[]; // 字段详情
schema?: ObjectSchema<any>; // yup schame object 用于表单校验
layout?: 'horizontal' | 'vertical'; // 布局方式(默认水平)
submitLabel?: string; // 提交按钮的标签名
onSubmit: (values: any) => Promise<void> | void; // 点击提交按钮的回调
onChange?: (values: any) => void; // 数据更新回调
}
/**
*
*
*/
export const FastForm: React.FC<FastFormProps> = React.memo((props) => {
const initialValues = useMemo(() => {
return _fromPairs(
props.fields.map((field) => [field.name, field.defaultValue ?? ''])
);
}, [props.fields]);
const [loading, setLoading] = useState(false);
const formik = useFormik({
initialValues,
validationSchema: props.schema,
onSubmit: async (values) => {
setLoading(true);
try {
_isFunction(props.onSubmit) && (await props.onSubmit(values));
} finally {
setLoading(false);
}
},
validate: (values) => {
_isFunction(props.onChange) && props.onChange(values);
},
});
const { handleSubmit, setFieldValue, values, errors } = formik;
const FastFormContainer = getFormContainer();
if (_isNil(FastFormContainer)) {
console.warn('FastFormContainer 没有被注册');
return null;
}
const fieldsRender = useMemo(() => {
return props.fields.map((fieldMeta, i) => {
const fieldName = fieldMeta.name;
const value = values[fieldName];
const error = errors[fieldName];
const Component = getField(fieldMeta.type);
if (_isNil(Component)) {
return null;
} else {
return (
<Component
key={fieldName + i}
{...fieldMeta}
value={value}
error={error}
onChange={(val) => setFieldValue(fieldName, val)}
/>
);
}
});
}, [props.fields, values, errors, setFieldValue]);
return (
<FastFormContext.Provider value={formik}>
<FastFormContainer
loading={loading}
layout={props.layout ?? 'horizontal'}
submitLabel={props.submitLabel}
handleSubmit={handleSubmit}
canSubmit={_isEmpty(errors)}
>
{fieldsRender}
</FastFormContainer>
</FastFormContext.Provider>
);
});
FastForm.displayName = 'FastForm';
FastForm.defaultProps = {
submitLabel: '提交',
};

@ -0,0 +1,17 @@
/* eslint-disable id-blacklist */
import { string, object, ref } from 'yup';
import type { ObjectShape } from 'yup/lib/object';
/**
* FastFormSchema
*
*
*/
export function createFastFormSchema(fieldMap: ObjectShape) {
return object().shape(fieldMap);
}
export const fieldSchema = {
string,
ref,
};

@ -1,2 +1,17 @@
// api
export { buildStorage } from './api/buildStorage';
// components
export { FastForm } from './components/FastForm/index';
export { CustomField } from './components/FastForm/CustomField';
export type {
FastFormFieldComponent,
FastFormFieldProps,
FastFormFieldMeta,
} from './components/FastForm/field';
export { regField } from './components/FastForm/field';
export { regFormContainer } from './components/FastForm/container';
export type { FastFormContainerComponent } from './components/FastForm/container';
// manager
export { getStorage, setStorage, useStorage } from './manager/storage';

@ -0,0 +1,113 @@
import { buildCachedRegFn, buildRegFn } from '../buildRegFn';
describe('buildRegFn should be ok', () => {
test('normal', () => {
const [get, set] = buildRegFn('test');
const fn = jest.fn();
set(fn);
get();
get(1);
get(2);
const fn2 = jest.fn();
set(fn2);
get(3);
expect(fn.mock.calls.length).toBe(3);
expect(fn.mock.calls[0]).toEqual([]);
expect(fn.mock.calls[1]).toEqual([1]);
expect(fn.mock.calls[2]).toEqual([2]);
expect(fn2.mock.calls[0]).toEqual([3]);
});
test('with default', () => {
const fn = jest.fn();
const [get, set] = buildRegFn('test', fn);
get();
get(1);
get(2);
const fn2 = jest.fn();
set(fn2);
get(3);
expect(fn.mock.calls.length).toBe(3);
expect(fn.mock.calls[0]).toEqual([]);
expect(fn.mock.calls[1]).toEqual([1]);
expect(fn.mock.calls[2]).toEqual([2]);
expect(fn2.mock.calls[0]).toEqual([3]);
});
});
describe('buildCachedRegFn should be ok', () => {
test('normal', () => {
const [get, set] = buildCachedRegFn('test');
const fn = jest.fn();
set(fn);
get(1);
const fn2 = jest.fn();
set(fn2);
get(2);
expect(fn.mock.calls[0]).toEqual([1]);
expect(fn2.mock.calls[0]).toEqual([2]);
});
test('should be cache value', () => {
const [get, set] = buildCachedRegFn('test');
const fn = jest.fn((v) => v);
set(fn);
const res1 = get(1);
const res2 = get(1);
expect(fn.mock.calls.length).toBe(1);
expect(fn.mock.calls[0]).toEqual([1]);
expect(res1).toBe(res2);
});
test('should be refresh if re-set', () => {
const [get, set] = buildCachedRegFn('test');
const fn = jest.fn((v) => v);
set(fn);
get(1);
get(1);
const fn2 = jest.fn((v) => v);
set(fn2);
get(1);
get(1);
expect(fn.mock.calls.length).toBe(1);
expect(fn.mock.calls[0]).toEqual([1]);
expect(fn2.mock.calls.length).toBe(1);
expect(fn2.mock.calls[0]).toEqual([1]);
});
test('should call forever if return null', () => {
const [get, set] = buildCachedRegFn('test');
const fn = jest.fn(() => null);
set(fn);
get();
get();
get();
expect(fn.mock.calls.length).toBe(3);
});
test('should call forever if return undefined', () => {
const [get, set] = buildCachedRegFn('test');
const fn = jest.fn(() => undefined);
set(fn);
get();
get();
get();
expect(fn.mock.calls.length).toBe(3);
});
});

@ -7,8 +7,10 @@
"license": "GPLv3",
"private": true,
"dependencies": {
"formik": "^2.2.9",
"lodash": "^4.17.21",
"react-native-storage": "npm:@trpgengine/react-native-storage@^1.0.1"
"react-native-storage": "npm:@trpgengine/react-native-storage@^1.0.1",
"yup": "^0.32.9"
},
"devDependencies": {
"@types/lodash": "^4.14.170"

@ -27,6 +27,7 @@
"tailwindcss": "^2.2.4"
},
"devDependencies": {
"@types/jest": "^26.0.23",
"@types/mini-css-extract-plugin": "^1.4.3",
"@types/node": "^15.12.5",
"@types/react": "^17.0.11",
@ -40,6 +41,7 @@
"esbuild-loader": "^2.13.1",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.3.2",
"jest": "^27.0.6",
"mini-css-extract-plugin": "^1.6.2",
"postcss": "^8.3.5",
"postcss-loader": "^6.1.0",

@ -0,0 +1,21 @@
import { getValidateStatus } from '../utils';
describe('getValidateStatus', () => {
test('enter undefined', () => {
const status = getValidateStatus(undefined);
expect(status).toBe('');
});
test('enter empty string', () => {
const status = getValidateStatus('');
expect(status).toBe('');
});
test('enter string', () => {
const status = getValidateStatus('any string');
expect(status).toBe('error');
});
});

@ -0,0 +1,69 @@
import React, { useMemo } from 'react';
import {
FastForm,
regField,
FastFormContainerComponent,
regFormContainer,
} from 'pawchat-shared';
import { Form, Button } from 'antd';
import { FastFormText } from './types/Text';
import { FastFormTextArea } from './types/TextArea';
import { FastFormPassword } from './types/Password';
import { FastFormSelect } from './types/Select';
import { FastFormCustom } from './types/Custom';
regField('text', FastFormText);
regField('textarea', FastFormTextArea);
regField('password', FastFormPassword);
regField('select', FastFormSelect);
regField('custom', FastFormCustom);
const WebFastFormContainer: FastFormContainerComponent = React.memo((props) => {
const layout = props.layout;
const submitButtonRender = useMemo(() => {
return (
<Form.Item
wrapperCol={
layout === 'vertical'
? { xs: 24 }
: { sm: 24, md: { span: 16, offset: 8 } }
}
>
<Button
loading={props.loading}
type="primary"
size="large"
htmlType="button"
style={{ width: '100%' }}
onClick={() => props.handleSubmit()}
disabled={props.canSubmit === false}
>
{props.submitLabel ?? '提交'}
</Button>
</Form.Item>
);
}, [
props.loading,
props.handleSubmit,
props.canSubmit,
props.submitLabel,
layout,
]);
return (
<Form
layout={layout}
labelCol={layout === 'vertical' ? { xs: 24 } : { sm: 24, md: 8 }}
wrapperCol={layout === 'vertical' ? { xs: 24 } : { sm: 24, md: 16 }}
>
{props.children}
{submitButtonRender}
</Form>
);
});
WebFastFormContainer.displayName = 'WebFastFormContainer';
regFormContainer(WebFastFormContainer);
export const WebFastForm = FastForm;
WebFastForm.displayName = 'WebFastForm';

@ -0,0 +1,22 @@
import React from 'react';
import { Form } from 'antd';
import _get from 'lodash/get';
import _isNil from 'lodash/isNil';
import type {
FastFormFieldComponent,
FastFormFieldProps,
} from 'pawchat-shared';
import { CustomField } from 'pawchat-shared';
export const FastFormCustom: FastFormFieldComponent<{
render: (props: FastFormFieldProps) => React.ReactNode;
}> = React.memo((props) => {
const { label } = props;
return (
<Form.Item label={label}>
<CustomField {...props} />
</Form.Item>
);
});
FastFormCustom.displayName = 'FastFormCustom';

@ -0,0 +1,27 @@
import React from 'react';
import { Input, Form } from 'antd';
import type { FastFormFieldComponent } from 'pawchat-shared';
import { getValidateStatus } from '../utils';
export const FastFormPassword: FastFormFieldComponent = React.memo((props) => {
const { name, label, value, onChange, error, maxLength, placeholder } = props;
return (
<Form.Item
label={label}
validateStatus={getValidateStatus(error)}
help={error}
>
<Input.Password
name={name}
type="password"
size="large"
maxLength={maxLength}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</Form.Item>
);
});
FastFormPassword.displayName = 'FastFormPassword';

@ -0,0 +1,38 @@
import React, { useEffect } from 'react';
import { Select, Form } from 'antd';
import _get from 'lodash/get';
import _isNil from 'lodash/isNil';
import type { FastFormFieldComponent } from 'pawchat-shared';
const Option = Select.Option;
interface FastFormSelectOptionsItem {
label: string;
value: string;
}
export const FastFormSelect: FastFormFieldComponent<{
options: FastFormSelectOptionsItem[];
}> = React.memo((props) => {
const { name, label, value, onChange, options } = props;
useEffect(() => {
if (_isNil(value) || value === '') {
// 如果没有值的话则自动设置默认值
onChange(_get(options, [0, 'value']));
}
}, []);
return (
<Form.Item label={label}>
<Select size="large" value={value} onChange={(value) => onChange(value)}>
{options.map((option, i) => (
<Option key={`${option.value}${i}`} value={option.value}>
{option.label}
</Option>
))}
</Select>
</Form.Item>
);
});
FastFormSelect.displayName = 'FastFormSelect';

@ -0,0 +1,26 @@
import React from 'react';
import { Input, Form } from 'antd';
import type { FastFormFieldComponent } from 'pawchat-shared';
import { getValidateStatus } from '../utils';
export const FastFormText: FastFormFieldComponent = React.memo((props) => {
const { name, label, value, onChange, error, maxLength, placeholder } = props;
return (
<Form.Item
label={label}
validateStatus={getValidateStatus(error)}
help={error}
>
<Input
name={name}
size="large"
maxLength={maxLength}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</Form.Item>
);
});
FastFormText.displayName = 'FastFormText';

@ -0,0 +1,26 @@
import React from 'react';
import { Input, Form } from 'antd';
import type { FastFormFieldComponent } from 'pawchat-shared';
import { getValidateStatus } from '../utils';
export const FastFormTextArea: FastFormFieldComponent = React.memo((props) => {
const { name, label, value, onChange, error, maxLength, placeholder } = props;
return (
<Form.Item
label={label}
validateStatus={getValidateStatus(error)}
help={error}
>
<Input.TextArea
name={name}
rows={4}
maxLength={maxLength}
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</Form.Item>
);
});
FastFormTextArea.displayName = 'FastFormTextArea';

@ -0,0 +1,10 @@
/**
*
*/
export function getValidateStatus(error: string | undefined): 'error' | '' {
if (error === undefined || error === '') {
return '';
} else {
return 'error';
}
}

@ -4,5 +4,6 @@ import ReactDOM from 'react-dom';
import { App } from './App';
import 'tailwindcss/tailwind.css';
import 'antd/dist/antd.css';
ReactDOM.render(<App />, document.querySelector('#app'));

@ -1,6 +1,32 @@
import React from 'react';
import { FastFormFieldMeta } from 'pawchat-shared';
import { WebFastForm } from '../../components/WebFastForm';
import { useCallback } from 'react';
const fields: FastFormFieldMeta[] = [
{
type: 'text',
name: 'email',
label: '邮箱',
},
{
type: 'password',
name: 'password',
label: '密码',
},
];
export const LoginView: React.FC = React.memo(() => {
return <div>Login</div>;
const handleLogin = useCallback((values) => {
console.log('values', values);
}, []);
return (
<div className="w-96 text-white">
<div className="text-xl"> Paw Chat</div>
<WebFastForm layout="vertical" fields={fields} onSubmit={handleLogin} />
</div>
);
});
LoginView.displayName = 'LoginView';

@ -6,7 +6,7 @@ import bgImage from '../../../assets/images/bg.jpg';
export const EntryRoute = React.memo(() => {
return (
<div className="h-full flex flex-row">
<div className="w-142 sm:w-full bg-gray-600 text-white">
<div className="w-142 sm:w-full bg-gray-600 h-full flex items-center justify-center">
<Switch>
<Route path="/entry/login" component={LoginView} />
<Redirect to="/entry/login" />
@ -14,7 +14,7 @@ export const EntryRoute = React.memo(() => {
</div>
<div
className="flex-1 sm:hidden bg-center bg-cover bg-no-repeat"
style={{ backgroundImage: `url(${bgImage})` }}
// style={{ backgroundImage: `url(${bgImage})` }}
/>
</div>
);

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save