Last active
December 30, 2020 09:04
-
-
Save buhichan/80cf4b5688832866993847b6d57721ca to your computer and use it in GitHub Desktop.
model (rxjs) based form
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Form, Col, Row, Button } from "antd" | |
import { FormItemProps } from "antd/lib/form" | |
import { useObservables } from "./use-observables" | |
import * as React from "react" | |
import { AbstractControl, ValidationInfo, FormControls, FormControlList } from "./model" | |
import { CloseOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons" | |
type FormItemRenderChildren<T, Meta> = (inputProps: { value?: T; onChange?: (v: T) => void }, behavior: Meta | null, err: ValidationInfo) => React.ReactNode | |
export function FormItem<T, Meta>({ | |
field, | |
children, | |
...rest | |
}: Omit<FormItemProps, "name" | "children"> & { | |
field: AbstractControl<T, Meta> | |
children: FormItemRenderChildren<T, Meta> | |
}) { | |
const [error, value, meta] = useObservables(field.error, field.value, field.metadata) | |
return ( | |
<Form.Item labelCol={{span:4}} wrapperCol={{span:20}} hasFeedback help={!!error ? error : undefined} validateStatus={!!error ? "error" : undefined} {...rest}> | |
{children( | |
{ | |
value: value === null ? undefined : value, | |
onChange: field.change, | |
}, | |
meta, | |
error | |
)} | |
</Form.Item> | |
) | |
} | |
//eslint-disable-next-line | |
export function FormList<Meta, Children extends AbstractControl<any, any>>({ | |
field, | |
children, | |
...rest | |
}: Omit<FormItemProps, "name" | "children"> & { | |
field: FormControlList<Meta, Children> | |
children: (child: Children, arrayMeta: Meta | null, index: number) => React.ReactNode | |
}) { | |
const [items, metadata, error] = useObservables(field.children, field.metadata, field.error) | |
return ( | |
<Form.Item labelCol={{span:4}} wrapperCol={{span:20}} hasFeedback help={!!error ? error : undefined} validateStatus={!!error ? "error" : undefined} {...rest}> | |
<Row> | |
<Col span={24}> | |
{items.map((x, i) => { | |
return ( | |
<Row gutter={8} key={x.id}> | |
<Col> | |
<Button | |
icon={<MinusOutlined />} | |
onClick={() => { | |
field.delete(i) | |
}} | |
></Button> | |
</Col> | |
<Col span={22} key={x.id}> | |
{children(x.control, metadata, i)} | |
</Col> | |
</Row> | |
) | |
})} | |
</Col> | |
</Row> | |
<Row> | |
<Col span={24}> | |
<Button | |
icon={<PlusOutlined />} | |
onClick={() => { | |
//eslint-disable-next-line | |
field.push({} as any) | |
}} | |
></Button> | |
</Col> | |
</Row> | |
</Form.Item> | |
) | |
} | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { BehaviorSubject, combineLatest, empty, identity, Observable, ObservableInput, of, Subject } from "rxjs" | |
import { map, publishReplay, refCount, scan, startWith, switchMap } from "rxjs/operators" | |
export interface AbstractControl<Type, Meta = unknown> { | |
value: Observable<Type> | |
error: Observable<ValidationInfo> | |
change(value: Type): void | |
metadata: Observable<Meta> | |
} | |
// export interface FormControl<Type> extends AbstractControl<Type> { | |
// change(value: Type): void | |
// } | |
export type ValidationInfo = string | void | null | undefined | |
export type Validator<Type> = (v: Type) => ObservableInput<ValidationInfo> | |
export type FormControlOptions<Type, Meta> = { | |
validator?: Validator<Type> | |
middleware?: (nextValue: Type, prevValue: Type | undefined) => Type | |
metadata?: Observable<Meta> | |
} | |
export class FormControl<Type, Meta = never> implements AbstractControl<Type, Meta> { | |
constructor(public defaultValue: Type, protected options?: FormControlOptions<Type, Meta>) {} | |
metadata = this.options?.metadata || empty() | |
private change$ = new Subject<Type>() | |
change = (v: Type) => { | |
this.change$.next(v) | |
} | |
value = this.change$.pipe( | |
this.options?.middleware ? scan((prev, cur) => this.options!.middleware!(cur, prev)) : identity, | |
startWith(this.defaultValue), | |
publishReplay(1), | |
refCount() | |
) | |
error = !this.options?.validator ? (empty() as Observable<ValidationInfo>) : this.value.pipe(switchMap(this.options.validator)) | |
} | |
export class FormControlList<Meta, Children extends AbstractControl<unknown>, Type = ValueOfControl<Children>> implements AbstractControl<Type[]> { | |
constructor( | |
public defaultValue: Type[], | |
public createChild: (x: Type) => Children, | |
private options?: Omit<FormControlOptions<Type[], Meta>, "middleware"> | |
) {} | |
children: BehaviorSubject< | |
{ | |
id: number | |
control: Children | |
}[] | |
> = new BehaviorSubject( | |
this.defaultValue.map(value => { | |
return { | |
id: this.curId++, | |
control: this.createChild(value), | |
} | |
}) | |
) | |
currentValue = this.defaultValue | |
metadata = this.options?.metadata || empty() | |
value = this.children.pipe( | |
switchMap(x => { | |
return x.length === 0 ? of([]) : combineLatest(x.map(x => (x.control.value as unknown) as Type[])) | |
}) | |
) | |
error = combineLatest( | |
this.children.pipe( | |
switchMap(x => { | |
return combineLatest(x.map(x => x.control.error)) | |
}), | |
map(x => x.find(x => !!x)) | |
), | |
!this.options?.validator ? of(null) : this.value.pipe(switchMap(this.options.validator)) | |
).pipe( | |
map(([childErr, selfErr]) => { | |
return childErr || selfErr | |
}) | |
) | |
change = (value: Type[]) => { | |
this.children.next( | |
value.map((item, i) => { | |
return { | |
id: this.curId++, | |
control: this.createChild(item), | |
} | |
}) | |
) | |
} | |
private curId = 0 | |
push(value: Type) { | |
this.children.next( | |
this.children.value.concat({ | |
id: this.curId++, | |
control: this.createChild(value), | |
}) | |
) | |
} | |
insert(value: Type, index: number) { | |
const clone = this.children.value.slice() | |
clone.splice(index, 0, { | |
id: this.curId++, | |
control: this.createChild(value), | |
}) | |
this.children.next(clone) | |
} | |
delete(index: number) { | |
const clone = this.children.value.slice() | |
clone.splice(index, 1) | |
this.children.next(clone) | |
} | |
swap(indexA: number, indexB: number) { | |
const clone = this.children.value.slice() | |
const tmp = clone[indexA] | |
clone[indexA] = clone[indexB] | |
clone[indexB] = tmp | |
this.children.next(clone) | |
} | |
} | |
type ValueOfControl<T> = T extends AbstractControl<infer V> ? V : never | |
//eslint-disable-next-line | |
export class FormControls<Children extends Record<string, AbstractControl<any, any>>, Meta, Type = { [k in keyof Children]: ValueOfControl<Children[k]> }> | |
implements AbstractControl<Type, Meta> { | |
constructor(public children: Children, private options?: FormControlOptions<Type, Meta>) {} | |
metadata = this.options?.metadata || empty() | |
private formEntries = (Object.keys(this.children) as (keyof Children)[]).map(k => { | |
return [k, this.children[k]] as const | |
}) | |
value = combineLatest( | |
this.formEntries.map(([k, control]) => { | |
return control.value.pipe(map(value => [k, value] as const)) | |
}) | |
).pipe( | |
map(kvs => { | |
return kvs.reduce((res, [k, v]) => { | |
res[k as keyof Type] = v | |
return res | |
}, {} as Type) | |
}), | |
this.options?.middleware ? scan((prev, cur) => this.options!.middleware!(cur, prev)) : identity | |
) | |
error = combineLatest([ | |
...this.formEntries.map(([k, control]) => { | |
return control.error | |
}), | |
!this.options?.validator ? of(null) : this.value.pipe(switchMap(this.options.validator)), | |
]).pipe( | |
map(x => { | |
return x.find(x => !!x) | |
}) | |
) | |
change = (value: Type) => { | |
for (const k in value) { | |
this.children[k]?.change(value[k]) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useMemo } from "react"; | |
import ReactDOM from "react-dom"; | |
import { Button, DatePicker, Select, version, Input, Form } from "antd"; | |
import "antd/dist/antd.css"; | |
import { FormControl, FormControlList, FormControls } from "./model" | |
import { from, of } from "rxjs"; | |
import { map, publishReplay, refCount, take, withLatestFrom } from "rxjs/operators"; | |
import { FormItem, FormList } from "./component"; | |
import moment from "moment"; | |
interface IsolationRule { | |
product: number, | |
alarmRegId: number, | |
startTime: Date, | |
endTime: Date, | |
remark: string, | |
filter: { | |
field: string, | |
operator: string, | |
value: string | |
}[] | |
} | |
const AlarmDutyProductOptions = async ()=>[ | |
{label:"产品1",value:1}, | |
{label:"产品2",value:2}, | |
{label:"产品3",value:3}, | |
] | |
const AlarmRegOptions = async ()=>[ | |
{label:"注册ID1",value:1}, | |
{label:"注册ID2",value:2}, | |
{label:"注册Id3",value:3}, | |
] | |
const GetMonitorFilter = async ()=>[ | |
{label: "ip", value:"ip"}, | |
{label: "region", value:"region", options: [ | |
{ | |
label:"shanghai", | |
value:"sh", | |
}, | |
{ | |
label:"beijing", | |
value:"bj", | |
} | |
]}, | |
] | |
function editBlockingRuleFormModel() { | |
const product = new FormControl(undefined as undefined | number, { | |
metadata: from(AlarmDutyProductOptions()), | |
validator: async v=> { | |
if(!v){ | |
return "必填字段哦" | |
} | |
} | |
}) | |
const alarmRegId = new FormControl(undefined as undefined | number, { | |
metadata: from(AlarmRegOptions()), | |
}) | |
const startTime = new FormControl(moment().subtract(3,'d')) | |
const endTime = new FormControl(moment()) | |
const remark = new FormControl("", { | |
middleware: (curv, prev)=>{ | |
console.log('prevValue remark is ', prev) | |
return curv.trim() | |
} | |
}) | |
const monitorFilterOptions = from(GetMonitorFilter()).pipe(publishReplay(1), refCount()) | |
const filter = new FormControlList([] as IsolationRule["filter"], item => { | |
const field = new FormControl(item.field, { | |
metadata: monitorFilterOptions, | |
}) | |
const operator = new FormControl(item.operator, { | |
metadata: of([ | |
{ label: "相等(单值精确匹配)", value: "equal" }, | |
{ label: "或者(多值至少精确匹配一个)", value: "or" }, | |
{ label: "包含(多值至少包含一个)", value: "includes" }, | |
{ label: "正则(多值至少正则匹配一个, 请注意转义)", value: "regex" }, | |
]), | |
}) | |
const valueMeta$ = field.value.pipe( | |
withLatestFrom(monitorFilterOptions), | |
map(([field, fieldOptions]) => { | |
const option = fieldOptions.find(x => x.value === field) | |
const options = option?.options | |
if (Array.isArray(options) && options.length > 0) { | |
return { | |
isSelect: true, | |
options, | |
} | |
} else { | |
return { | |
isSelect: false, | |
} | |
} | |
}) | |
) | |
const value = new FormControl(item.value, { | |
metadata: valueMeta$, | |
}) | |
valueMeta$.subscribe(()=>{ | |
value.change("") | |
}) | |
return new FormControls({ | |
field, | |
operator, | |
value, | |
}) | |
}) | |
return new FormControls({ | |
product, | |
alarmRegId, | |
startTime, | |
remark, | |
endTime, | |
filter, | |
}) | |
} | |
export default function App(){ | |
const form2 = useMemo(editBlockingRuleFormModel,[]) | |
const [submittedValue, setSubmittedValue] = React.useState({}) | |
return ( | |
<form | |
style={{padding: 16}} | |
onSubmit={e => { | |
e.preventDefault() | |
form2.value.pipe(take(1)).toPromise().then(setSubmittedValue) | |
}} | |
> | |
<FormItem label="产品" field={form2.children.product}> | |
{(props, options) => { | |
return <Select options={options || []} {...props} /> | |
}} | |
</FormItem> | |
<FormItem label="告警ID" field={form2.children.alarmRegId}> | |
{(props, options) => { | |
return <Select options={options || []} {...props} /> | |
}} | |
</FormItem> | |
<FormItem label="开始时间" field={form2.children.startTime}> | |
{(props, options) => { | |
return ( | |
<DatePicker | |
value={moment(props.value)} | |
onChange={v => { | |
v && props.onChange?.(v) | |
}} | |
/> | |
) | |
}} | |
</FormItem> | |
<FormItem label="失效时间" field={form2.children.endTime}> | |
{(props, options) => { | |
return ( | |
<DatePicker | |
value={moment(props.value)} | |
onChange={v => { | |
v && props.onChange?.(v) | |
}} | |
/> | |
) | |
}} | |
</FormItem> | |
<FormItem label="备注" field={form2.children.remark}> | |
{props => { | |
return <Input.TextArea value={props.value} onChange={e => props.onChange?.(e.target.value)} /> | |
}} | |
</FormItem> | |
<FormList label="过滤" field={form2.children.filter}> | |
{field => { | |
return ( | |
<> | |
<FormItem label="字段" field={field.children.field}> | |
{(props, options) => { | |
return <Select {...props} options={options?.map(x => ({ label: x.label, value: x.value })) || []} /> | |
}} | |
</FormItem> | |
<FormItem label="操作符" field={field.children.operator}> | |
{(props, options) => { | |
return <Select {...props} options={options || []} /> | |
}} | |
</FormItem> | |
<FormItem label="值" field={field.children.value}> | |
{(props, options) => { | |
return options?.isSelect ? ( | |
<Select | |
value={props.value} | |
options={options.options || []} | |
onChange={v => { | |
props.onChange?.(v) | |
}} | |
/> | |
) : ( | |
<Input.TextArea | |
{...props} | |
onChange={e => { | |
console.log(e.target.value) | |
props.onChange?.(e.target.value) | |
}} | |
/> | |
) | |
}} | |
</FormItem> | |
</> | |
) | |
}} | |
</FormList> | |
<Button htmlType="submit">Submit</Button> | |
<Button onClick={()=>{ | |
form2.change({ | |
product: 2, | |
alarmRegId: 1, | |
startTime: moment(), | |
endTime: moment(), | |
remark: "114514", | |
filter: [ | |
{ | |
field:"ip", | |
operator: "equal", | |
value:"114.514.19.19" | |
} | |
] | |
}) | |
}}>用 form.change来填充初始值</Button> | |
<div> | |
<div>你提交的值是</div> | |
<pre>{JSON.stringify(submittedValue,null,"\t")}</pre> | |
</div> | |
</form> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
code sandbox: https://codesandbox.io/s/relaxed-fire-s0r08?file=/src/App.tsx:0-8222