1.1.0 • Published 9 months ago

react-icecream-form v1.1.0

Weekly downloads
-
License
MIT
Repository
-
Last release
9 months ago

react-icecream-form

集成 formstate-x & 部分交互逻辑的表单

react-icecream-form 在 react-icecream 的基础上:

  1. 提供 Form (& ModalFormDrawerForm) 的封装后版本,内置了表单提交的校验逻辑
  2. 提供 FormItem 的封装后版本,可以省略校验状态信息或 state 实例
  3. 提供各输入组件(TextInputSelect 等)的封装后版本,接受直接传入 FieldState 实例
  4. 提供了用于构造表单状态的 hook useFormstateX

如 3 中提到的,react-icecream-form 模块对所有 react-icecream 内置的输入组件进行了适配,使其支持直接传入 FieldState 实例,而不是通过 value / onChange 来绑定输入;除此之外,适配后的输入组件可以被 react-icecream-form(如 2 中所述)提供的 FormItem 感知到,因此在搭配使用 FormItem 与输入组件时(二者一一对应),可以省略将 FieldState 实例手动传递给 FormItem 的行为;即可以写为:

import { FormItem, TextInput } from 'react-icecream-form'

// 这里 `FormItem` 会自动去使用内容中仅有的那个输入组件(`TextInput`)所对应的 state
<FormItem>
  <TextInput state={state} />
</FormItem>

简单的字段输入场景

import React, { useEffect } from 'react'
import { reaction } from 'mobx'
import { observer } from 'mobx-react'
import { FieldState, Validator } from 'formstate-x'
import { TextInput, useFormstateX } from 'react-icecream-form'

export default observer(() => {
  const nameState = useFormstateX(() => new FieldState('').withValidator(notEmpty), [])

  useEffect(() => reaction(
    () => nameState.error,
    error => error && console.warn('Error:', error)
  ), [nameState])

  return (
    <>
      <p style={{ margin: '0 0 1em' }}>Plz input your name: </p>
      <TextInput state={nameState} />
      {nameState.value && <p>Hello {nameState.value}!</p>}
    </>
  )
})

function notEmpty(v: string) {
  return v.trim() === '' && 'empty input'
}

较复杂表单实现

import React, { useCallback } from 'react'
import { FormState, FieldState, ValueOf } from 'formstate-x'
import { observer } from 'mobx-react'
import { Form, FormItem, useFormstateX, TextInput, PasswordInput, RadioGroup, Radio, Checkbox, MultiSelect, SelectOption } from 'react-icecream-form'

type AccountType = 'default' | 'enterprise'

// 构造表单状态
function createFormState() {
  const accountTypeField = new FieldState<AccountType>('default')
  const usernameField = new FieldState('').withValidator(notEmpty)
  const passwordField = new FieldState('').withValidator(notEmpty)
  const password2Field = new FieldState('').withValidator(
    notEmpty,
    (v: string) => v !== passwordField.value && '密码不一致'
  )
  const productsField = new FieldState<string[]>([]).withValidator(
    products => productsRequired(accountTypeField.value) && products.length === 0 && '不可为空'
  )
  const agreedField = new FieldState(false).withValidator(
    agreed => !agreed && '请勾选'
  )
  return new FormState({
    accountType: accountTypeField,
    username: usernameField,
    password: passwordField,
    password2: password2Field,
    products: productsField,
    agreed: agreedField
  })
}

// 表单中值的类型
type Value = ValueOf<ReturnType<typeof createFormState>>

export default observer(() => {

  const state = useFormstateX(createFormState, [])

  const handleSubmit = useCallback((value: Value) => {
    console.log(`Submit with: ${JSON.stringify(value)}.`)
    return new Promise<void>(resolve => setTimeout(resolve, 2000))
  }, [])

  const handleCancel = useCallback(() => (
    console.log('Cancelled.')
  ), [])

  return (
    <Form
      state={state}
      layout="horizontal"
      labelWidth="4em"
      onSubmit={handleSubmit}
      onCancel={handleCancel}
    >
      <FormItem label="账户类型" labelVerticalAlign="text" tip="企业账号需要额外选择“开通产品”">
        <RadioGroup state={state.$.accountType}>
          <Radio value="default">普通账号</Radio>
          <Radio value="enterprise">企业账号</Radio>
        </RadioGroup>
      </FormItem>
      <FormItem label="用户名" required>
        <TextInput placeholder="请输入用户名" state={state.$.username} />
      </FormItem>
      <FormItem label="密码" required>
        <PasswordInput placeholder="请输入密码" state={state.$.password} />
      </FormItem>
      <FormItem label="确认密码" required tip="两次输入密码应当一致">
        <PasswordInput placeholder="请再次输入密码" state={state.$.password2} />
      </FormItem>
      {productsRequired(state.$.accountType.value) && (
        <FormItem required label="开通产品">
          <MultiSelect
            placeholder="请选择需要开通的产品"
            state={state.$.products}
            collapsed={false}
            style={{ width: '240px' }}
          >
            <SelectOption value="kodo">对象存储 Kodo</SelectOption>
            <SelectOption value="fusion">CDN</SelectOption>
            <SelectOption value="dora">智能多媒体服务 Dora</SelectOption>
            <SelectOption value="qvm">云主机服务 QVM</SelectOption>
            <SelectOption value="pandora">机器数据分析平台 Pandora</SelectOption>
          </MultiSelect>
        </FormItem>
      )}
      <FormItem label="">
        <label style={{ color: '#999999' }}>
          <Checkbox state={state.$.agreed} />&nbsp;
          <small>我已阅读并同意服务协议和隐私政策</small>
        </label>
      </FormItem>
    </Form>
  )
})

function productsRequired(accountType: AccountType) {
  return accountType === 'enterprise'
}

function notEmpty(v: string) {
  return v.trim() === '' && '不可为空'
}

列表输入与校验

import React, { useCallback } from 'react'
import { FormState, ArrayFormState, FieldState, ValueOf } from 'formstate-x'
import { observer } from 'mobx-react'
import { Tooltip } from 'react-icecream'
import { AddThinIcon, CloseThinIcon } from 'react-icecream/icons'
import { Form, FormItem, useFormstateX, TextInput } from 'react-icecream-form'

// 构造单条手机号对应的表单状态
function createNumberState(initialValue: string) {
  return new FieldState(initialValue).withValidator(validateNumber)
}

// 构造手机号列表对应的状态
function createNumbersState() {
  return new ArrayFormState([''], createNumberState).withValidator(
    numbers => numbers.length > 3 && '最多添加 3 个号码'
  )
}

// 构造表单状态
function createFormState() {
  return new FormState({
    numbers: createNumbersState()
  })
}

// 表单中值的类型
type Value = ValueOf<ReturnType<typeof createFormState>>

export default observer(() => {
  const state = useFormstateX(createFormState, [])
  const numbersState = state.$.numbers

  const handleAddNumber = () => numbersState.append('')
  const handleRemoveNumber = (i: number) => numbersState.remove(i)

  const handleSubmit = (value: Value) => {
    console.log(`Submit with: ${JSON.stringify(value)}.`)
    return new Promise<void>(resolve => setTimeout(resolve, 2000))
  }

  const numbersLabel = (
    <span style={{ display: 'flex', alignItems: 'center' }}>
      手机号
      <Tooltip title="点击添加">
        <AddThinIcon
          style={{ marginLeft: '.5em', cursor: 'pointer' }}
          onClick={handleAddNumber}
        />
      </Tooltip>
    </span>
  )

  return (
    <Form layout="horizontal" labelWidth="64px" state={state} onSubmit={handleSubmit}>
      <FormItem state={numbersState} label={numbersLabel}>
        {numbersState.$.map((numberState, i) => (
          <FormItem key={i}>
            <div style={{ display: 'flex', alignItems: 'center' }}>
              <TextInput placeholder="请输入中国大陆手机号" state={numberState} />
              {numbersState.$.length > 1 && (
                <Tooltip title="点击删除">
                  <CloseThinIcon
                    style={{ marginLeft: '.5em', padding: '5px', cursor: 'pointer' }}
                    onClick={() => handleRemoveNumber(i)}
                  />
                </Tooltip>
              )}
            </div>
          </FormItem>
        ))}
      </FormItem>
    </Form>
  )
})

function validateNumber(v: string) {
  if (!v) return '号码不可为空'
  if (!/^1\d{10}$/.test(v)) return '格式不正确'
}

支持新建或编辑的表单组件

import React, { useCallback, useState } from 'react'
import { FormState, FieldState } from 'formstate-x'
import { observer } from 'mobx-react'
import { Button } from 'react-icecream'
import { Form, FormItem, useFormstateX, TextInput } from 'react-icecream-form'

interface User {
  name: string
}

function createUserState(initialValue?: User) {
  return new FormState({
    name: new FieldState(initialValue?.name ?? '')
  })
}

interface UserFormProps {
  user?: User // 当前编辑的用户,如不传则代表新建
  onSubmit: (user: User) => void | Promise<void> // 表单提交的回调
}

// 用于新建或编辑用户信息的表单
function UserForm({ user, onSubmit }: UserFormProps) {
  const state = useFormstateX(createUserState, [user])

  return (
    <Form state={state} onSubmit={onSubmit}>
      <FormItem label="姓名">
        <TextInput placeholder="请输入姓名" state={state.$.name} />
      </FormItem>
    </Form>
  )
}

const motoko: User = {
  name: 'Motoko'
}

export default observer(() => {
  const [editing, setEditing] = useState(false)
  const editTarget = editing ? motoko : undefined
  const toggleEditing = useCallback(() => setEditing(v => !v), [])

  const handleSubmit = useCallback((user: User) => {
    const action = editing ? 'Update' : 'Create'
    alert(`${action}: ${JSON.stringify(user)}`)
  }, [editing])

  return (
    <>
      <div style={{ marginBottom: '1em', paddingBottom: '1em', borderBottom: '1px solid #E5E5E5' }}>
        <Button type="primary" onClick={toggleEditing}>
          {editing ? '切换为新建' : `切换为编辑 ${motoko.name}`}
        </Button>
      </div>
      <UserForm
        user={editTarget}
        onSubmit={handleSubmit}
      />
    </>
  )
})

在展示/编辑状态间切换的表单

import { FieldState, FormState } from 'formstate-x'
import React, { useState } from 'react'
import { Button } from 'react-icecream'
import { Form, FormItem, useFormstateX, TextArea } from 'react-icecream-form'

function createState(remark = '') {
  return new FormState({
    remark: new FieldState(remark).withValidator(v => !v && '不可为空')
  })
}

export default function Demo() {
  const remark = '请尽快发货'
  const [editing, setEditing] = useState(false)
  const state = useFormstateX(createState, [remark])

  const remarkEditingView = (
    <>
      <TextArea style={{ marginBottom: '12px' }} state={state.$.remark} />
      <Button onClick={() => setEditing(false)}>取消</Button>
    </>
  )

  const remarkDisplayView = (
    <div style={{ display: 'flex', alignItems: 'center' }}>
      {remark}&nbsp;
      <Button type="link" onClick={() => setEditing(true)}>编辑</Button>
    </div>
  )

  return (
    <Form
      layout="horizontal"
      labelWidth="6em"
      labelColor="light"
      state={state}
      footer={null}
    >
      <FormItem label="订单 ID" labelVerticalAlign="text">8901234567</FormItem>
      <FormItem label="订单备注">{editing ? remarkEditingView : remarkDisplayView}</FormItem>
    </Form>
  )
}

自定义输入组件

在项目中,往往会存在自定义的输入组件。如果希望这部分组件也像 react-icecream-form 提供的输入组件一样,被 FormItem 感知到,可以使用 react-icecream-form 提供的组件 InputWrapper 来实现:

import React from 'react'
import { FormState, FieldState } from 'formstate-x'
import { observer } from 'mobx-react'
import { AddThinIcon, RemoveThinIcon } from 'react-icecream/icons'
import { Form, FormItem, useFormstateX, InputGroup, InputGroupItem, InputWrapper } from 'react-icecream-form'

interface CounterProps {
  state: FieldState<number>
}

const Counter = observer(function _Counter({ state }: CounterProps) {

  const increase = () => state.onChange(state.value + 1)
  const decrease = () => state.onChange(state.value - 1)

  return (
    // `InputWrapper` registers Counter's state to outer FormItem
    <InputWrapper state={state}>
      <InputGroup style={{ width: '120px' }}>
        <InputGroupItem>
          <AddThinIcon style={{ height: '100%' }} onClick={increase} />
        </InputGroupItem>
        <InputGroupItem style={{ flex: '1 1 auto', textAlign: 'center' }}>
          {state.value}
        </InputGroupItem>
        <InputGroupItem>
          <RemoveThinIcon style={{ height: '100%' }} onClick={decrease} />
        </InputGroupItem>
      </InputGroup>
    </InputWrapper>
  )
})

export default observer(() => {

  const state = useFormstateX(() => new FormState({
    count: new FieldState(0).withValidator(v => v <= 0 && 'Positive required.')
  }), [])

  return (
    <Form state={state} onSubmit={v => alert(v.count)}>
      {/* So we don't need to pass `state.$.count` to `FormItem` here */}
      <FormItem label="Count">
        <Counter state={state.$.count} />
      </FormItem>
    </Form>
  )
})

注:这往往不是必须的,直接通过 FormItem 的 prop state 指定其对应的 state 实例也是常规的做法。

使用 ModalForm

react-icecream-form 也提供了 ModalForm & DrawerForm 与 formstate-x 的绑定,使用姿势跟 Form 类似:

import React, { useCallback, useState } from 'react'
import { FormState, FieldState, ValueOf } from 'formstate-x'
import { observer } from 'mobx-react'

import { Button } from 'react-icecream'
import { ModalForm, FormItem, useFormstateX, TextInput, PasswordInput } from 'react-icecream-form'

// 构造表单状态
function createFormState() {
  const usernameField = new FieldState('').validators(notEmpty)
  const passwordField = new FieldState('').validators(notEmpty)
  return new FormState({
    username: usernameField,
    password: passwordField
  })
}

// 表单中值的类型
type Value = ValueOf<ReturnType<typeof createFormState>>

export default observer(() => {

  const [visible, setVisible] = useState(false)
  const state = useFormstateX(createFormState, [])

  const handleSubmit = useCallback(async (value: Value) => {
    console.log(`Submit with: ${JSON.stringify(value)}.`)
    await new Promise<void>(resolve => setTimeout(resolve, 2000))
    setVisible(false)
  }, [])

  const handleCancel = useCallback(() => {
    console.log('Cancelled.')
    setVisible(false)
  }, [])

  return (
    <>
      <Button onClick={() => setVisible(true)}>打开弹框</Button>
      <ModalForm
        visible={visible}
        title="示例"
        state={state}
        layout="horizontal"
        labelWidth="4em"
        onSubmit={handleSubmit}
        onCancel={handleCancel}
      >
        <FormItem label="用户名" required>
          <TextInput placeholder="请输入用户名" state={state.$.username} />
        </FormItem>
        <FormItem label="密码" required>
          <PasswordInput placeholder="请输入密码" state={state.$.password} />
        </FormItem>
      </ModalForm>
    </>
  )
})

function notEmpty(v: string) {
  return v.trim() === '' && '不可为空'
}