跳至主要内容

表單元件

內容來源

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

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

設計理念

  • Modifiers: 前處理修飾器(如 trim、轉型)
  • Hook: react-hook-form Controller
  • Hints: 提示訊息(helper text、error message)
  • 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: '',
email: '',
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>
);
}
驗證策略

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媒體上傳圖片/影片預覽、裁切、旋轉

元件屬性

共用屬性

所有表單元件都支援以下屬性:

interface CommonProps {
name: string; // 欄位名稱(必填)
control: Control; // react-hook-form control(必填)
label?: string; // 標籤(自動翻譯)
hint?: string; // 提示文字
readOnly?: boolean; // 唯讀模式
disabled?: boolean; // 停用
variant?: 'bordered' | 'ghost'; // 外觀變體
density?: 'compact' | 'comfortable'; // 密度
color?: 'default' | 'primary' | 'secondary' | 'accent' | 'info' | 'success' | 'warning' | 'error';
}

Input 屬性

<Input
name="price"
control={control}
label="Price"
type="number" // text | number | date | email | password
min={0} // 最小值(number 類型)
max={9999} // 最大值(number 類型)
step={0.01} // 步進值(number 類型)
placeholder="Enter price"
/>

Select 屬性

<Select
name="category"
control={control}
label="Category"
options={categories}
isMulti={false} // 是否多選
isSearchable={true} // 是否可搜尋
isCreatable={false} // 是否可創建新選項
isClearable={true} // 是否可清除
dependsOn="parentField" // 依賴欄位(連動過濾)
filterBy="parentId" // 過濾欄位
/>

Checkbox 屬性

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

// 多選模式
<Checkbox
name="features"
control={control}
label="Features"
options={[
{ value: 'wifi', label: 'WiFi' },
{ value: 'parking', label: 'Parking' },
]}
/>

標籤自動翻譯

所有表單元件會自動翻譯以下 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')} />
Input / Textarea 的 placeholder

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

表單值設計

空值處理

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

// ✅ 正確的預設值
const defaultValues = {
name: '', // 字串欄位用空字串
category: null, // 選擇欄位用 null
price: null, // 數字欄位用 null
date: null, // 日期欄位用 null
};

// ❌ 錯誤
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),
});
前端驗證範圍

前端只驗證類型必填。格式驗證(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}
dependsOn="city" // 依賴 city 欄位
filterBy="cityId" // 用 cityId 欄位過濾
/>

動態表單

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" 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,字串使用 ''
  4. 標籤自動翻譯 - label 傳入英文 key,框架自動翻譯
  5. 保持一致性 - 同一表單使用相同的 variant 和 density

下一步