跳至主要内容

表單元件

:::info 內容來源 本頁內容源自 appfuse-web/lib/form/README.md,如有差異以 README 為準。 :::

AppFuse Web 提供完整的表單元件庫,整合 react-hook-form 進行表單狀態管理和驗證。

設計理念

  • Modifiers(修飾符):使用者傳入的配置參數(如 onChangetypemultiple
  • Hook:自定義 Hook,使用 useController 處理表單邏輯
  • Hints(提示):傳遞給基礎 UI 組件的計算屬性(如翻譯後的 labelerror
  • Component:基礎 UI 元件渲染

基本用法

建立表單

import { useForm } from 'react-hook-form';
import { Input, Select, Checkbox } from '@appfuse/appfuse-web/form';
import { schema, validatorResolver } from '@appfuse/appfuse-web/form/validator';

// 1. 定義 Schema(使用框架內建的輕量驗證器)
const userSchema = schema.object({
name: schema.string().required(),
email: schema.string().required(),
role: schema.string().required(),
active: schema.boolean(),
});

// 2. 建立表單
function UserForm() {
const { control, handleSubmit } = useForm({
resolver: validatorResolver(userSchema),
defaultValues: {
name: null,
email: null,
role: null,
active: true,
},
});

const onSubmit = (data) => {
console.log(data);
};

return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input name="name" control={control} label="Name" />
<Input name="email" control={control} label="Email" type="email" />
<Select
name="role"
control={control}
label="Role"
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
]}
/>
<Checkbox name="active" control={control} label="Active" />
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
);
}

:::warning 驗證策略 AppFuse Web 採用「前端輕量、後端完整」的驗證策略。前端僅驗證類型和必填,格式驗證(Email、長度等)由後端處理。禁止使用 Zod,請使用框架內建的 schema 驗證器(~5KB vs Zod ~60KB)。 :::

表單元件一覽

文字輸入

元件用途特性
Input單行文字支援 text, number, date, email, password
Textarea多行文字自動調整高度、字數統計
TagInput標籤輸入多值輸入、可創建新標籤
RichTextEditor富文本基於 Slate.js,支援格式化

選擇輸入

元件用途特性
Select下拉選單單選/多選、依賴欄位聯動過濾
Checkbox核取方塊布林/單選/多選模式
CheckboxGroup核取群組宣告式多選
Radio單選按鈕單一選擇
RadioGroup單選群組宣告式單選
Switch開關布林/單選/多選模式

檔案輸入

元件用途特性
FileInput檔案上傳拖放、多檔、驗證
MediaInput媒體上傳圖片/影片預覽、裁切、旋轉

元件屬性

共用屬性(FormFieldProps)

所有表單元件都接收以下屬性:

interface FormFieldProps {
name: string; // 欄位名稱(必填)
control: Control; // react-hook-form control(必填)
label?: string; // 標籤(自動翻譯)
}

:::tip 基礎 UI 屬性 variantdensitycolordisabledreadOnly 等外觀屬性由基礎 UI 元件提供,透過 ...rest 傳遞。所有表單元件都支援對應基礎元件的全部屬性。 :::

Input 屬性

<Input
name="price"
control={control}
label="Price"
type="number" // text | number | date | email | password | tel | url | datetime-local
min={0} // 最小值(number 類型)
max={9999} // 最大值(number 類型)
step={0.01} // 步進值(number 類型)
onChange={(value, event) => {
console.log('新值:', value) // 已轉換的類型(number | null)
}}
/>

Select 屬性

<Select
name="category"
control={control}
label="Category"
options={categories}
multiple={false} // 是否多選
creatable={false} // 是否可創建新選項(繼承自基礎元件)
dependency="parentField" // 依賴欄位(連動過濾)
filter={(option, dependencyValue) => option.parentId === dependencyValue}
onChange={(value) => {
console.log('選擇的值:', value)
}}
/>

:::info 搜尋功能 Select 的搜尋功能由基礎元件自動控制,當選項數量超過 searchThreshold(預設 10)時自動啟用,無需手動設定。 :::

Checkbox 屬性

// 布林模式(預設)
<Checkbox
name="agree"
control={control}
label="I agree to terms"
/>

// 單選模式(有 value,可取消選擇)
<Checkbox
name="role"
control={control}
label="Admin"
value="admin"
/>

// 多選模式(有 value + multiple)
<Checkbox name="features" control={control} label="WiFi" value="wifi" multiple />
<Checkbox name="features" control={control} label="Parking" value="parking" multiple />

標籤自動翻譯

所有表單元件會自動翻譯以下 props,傳入 i18n key(英文)即可:

Prop適用元件說明
label所有表單元件欄位標籤
aria-labelInput, Select, TagInput 等無障礙標籤
placeholderTagInput, Select(搜尋框)提示文字
// ✅ 正確:傳入 i18n key
<Input name="email" control={control} label="Email" />
<Select name="role" control={control} label="Role" options={roles} />

// ❌ 錯誤:不要先翻譯再傳入(會造成雙重翻譯)
<Input name="email" control={control} label={t('Email')} />

:::info Input / Textarea 的 placeholder InputTextarea 為了實現浮動標籤效果,placeholder 被硬編碼為 " ",傳入的 placeholder prop 不會顯示。 :::

表單值設計

空值處理

AppFuse Web 使用 null 表示空值(非空字串 ''):

// ✅ 正確的預設值
const defaultValues = {
name: null, // 字串欄位用 null
category: null, // 選擇欄位用 null
price: null, // 數字欄位用 null
date: null, // 日期欄位用 null
agree: false, // Boolean 永遠有值
tags: [], // 陣列使用 []
};

// ❌ 錯誤
const defaultValues = {
category: '', // Select 不應使用空字串
price: 0, // 0 是有意義的值,不是空值
};

為什麼用 null 而非 ''

  1. 與 API 一致:RESTful API 回傳 null 表示缺失值,使用 null 可直接對應
  2. 語義清晰null = 未填寫,'' = 刻意填寫空白
  3. 型別安全string | null 明確表達可為空,string 則語義不清
  4. 自動轉換:表單元件自動處理 null'' 的轉換,開發者無需關心

型別轉換

表單元件會自動處理型別轉換:

元件HTML 值表單值
Input (text)''''
Input (number)''null
Input (number)'123'123
Input (date)''null
Selectundefinednull

驗證

使用框架內建驗證器

import { schema, validatorResolver } from '@appfuse/appfuse-web/form/validator';

const customerSchema = schema.object({
// 必填字串
name: schema.string().required(),

// 必填選擇
category: schema.string().required(),

// 必填數字
price: schema.number().required(),

// 選填
note: schema.string(),

// 選填數字(nullable)
discount: schema.number().nullable(),
});

// 搭配 react-hook-form 使用
const { control, handleSubmit } = useForm({
resolver: validatorResolver(customerSchema),
});

:::tip 前端驗證範圍 前端只驗證類型必填。格式驗證(Email 格式、字串長度等)由後端 @Email@Length 等 Bean Validation 處理。 :::

後端違例處理

後端回傳的驗證錯誤可自動映射到表單欄位:

import { mapViolationsToFormErrors, createServerErrorHandler } from '@appfuse/appfuse-web/form/validator';

const onSubmit = async (data) => {
try {
await api.post('/customers', data);
prompt.success('Customer created');
} catch (error) {
// 自動將後端 violations 映射到 react-hook-form 錯誤
const handler = createServerErrorHandler(setError);
handler(error);
}
};

錯誤訊息翻譯

錯誤訊息會自動翻譯,並替換 ${field} 為欄位的翻譯後標籤:

驗證器生成錯誤 → 訊息模板 + 參數 → Hook 翻譯 → 顯示本地化錯誤

src/nls/message/ 中定義錯誤訊息翻譯:

// src/nls/message/zh-TW.ts
export default {
'${field} is required': '${field}為必填',
'${field} must be ${type}': '${field}必須為${type}類型',
'${field} must be an array': '${field}必須為陣列',
'${field} must not be empty': '${field}不可為空',
}

效果:假設 label="Name" 且 i18n 定義 Name姓名,驗證失敗時顯示「姓名為必填」。

後端回傳的 violation 錯誤也使用相同格式,前端自動處理:

{
"violations": [{
"format": "${field} is already taken",
"params": { "field": "email" }
}]
}

進階用法

連動選單

// 縣市 → 區域連動
<Select
name="city"
control={control}
label="City"
options={cities}
/>
<Select
name="district"
control={control}
label="District"
options={districts}
dependency="city"
filter={(option, cityValue) => option.cityId === cityValue}
/>

:::info 連動行為 當 dependency 指定的欄位值改變時,Select 會自動重設當前欄位值為 null,並根據 filter 函式過濾可選選項。 :::

動態表單

import { useFieldArray } from 'react-hook-form';

function OrderForm() {
const { control } = useForm();
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});

return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<Input
name={`items.${index}.name`}
control={control}
label="Item Name"
/>
<Input
name={`items.${index}.quantity`}
control={control}
label="Quantity"
type="number"
/>
<button onClick={() => remove(index)}>Remove</button>
</div>
))}
<button onClick={() => append({ name: '', quantity: 1 })}>
Add Item
</button>
</div>
);
}

唯讀模式

// 單一欄位
<Input name="id" control={control} label="ID" readOnly />

// 整個表單
function ViewForm({ data }) {
const { control } = useForm({ defaultValues: data });

return (
<fieldset disabled>
<Input name="name" control={control} label="Name" readOnly />
<Select name="status" control={control} label="Status" options={statuses} readOnly />
</fieldset>
);
}

外觀變體

Variant(外框樣式)

// Bordered(預設)- 有邊框
<Input name="name" control={control} variant="bordered" />

// Ghost - 無邊框,僅底線
<Input name="name" control={control} variant="ghost" />

Density(密度)

// Comfortable(預設)- 標準間距
<Input name="name" control={control} density="comfortable" />

// Compact - 緊湊間距
<Input name="name" control={control} density="compact" />

Color(顏色)

<Input name="name" control={control} color="primary" />
<Input name="name" control={control} color="error" />

最佳實踐

  1. 使用框架內建驗證器 - 使用 schema + validatorResolver,禁止使用 Zod
  2. 前端輕量驗證 - 只驗證類型和必填,格式驗證交給後端
  3. 正確的預設值 - 選擇和數字欄位使用 null,布林欄位使用 false,陣列使用 []
  4. 標籤自動翻譯 - label 傳入英文 key,框架自動翻譯
  5. 保持一致性 - 同一表單使用相同的 variant 和 density

下一步