Parcourir la source

♻️ refactor(goods-management): 重构子商品内联编辑表单,使用 react-hook-form 与 zod 进行表单管理

- 移除手动状态管理与验证逻辑,集成 react-hook-form 与 zod 进行声明式表单处理
- 定义基于 AdminUpdateGoodsDto 的 Zod 验证模式,确保类型安全与数据一致性
- 更新表单字段组件,使用 FormField 与 FormControl 进行统一管理
- 优化数值输入处理,确保空值转换为 undefined 以符合 API 期望
- 更新相关类型定义,统一使用从 goodsClient 推断的 UpdateRequest 类型

✅ test(goods-management): 更新子商品内联编辑表单的单元测试

- 调整测试用例以适配新的 react-hook-form 实现
- 更新状态选择器的查询方式,使用更具体的角色选择器
- 修正数值输入框的值断言,反映 react-hook-form 的数值类型处理
- 确保验证错误信息的正确显示与断言
yourname il y a 1 mois
Parent
commit
b480dd066a

+ 195 - 214
packages/goods-management-ui-mt/src/components/ChildGoodsInlineEditForm.tsx

@@ -1,10 +1,15 @@
-import React, { useState } from 'react';
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
 import { Save, X } from 'lucide-react';
 
 import { Button } from '@d8d/shared-ui-components/components/ui/button';
 import { Input } from '@d8d/shared-ui-components/components/ui/input';
-import { Label } from '@d8d/shared-ui-components/components/ui/label';
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@d8d/shared-ui-components/components/ui/form';
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@d8d/shared-ui-components/components/ui/select';
+import { AdminUpdateGoodsDto } from '@d8d/goods-module-mt/schemas';
+import { UpdateRequest } from '../types/goods';
+import { z } from 'zod';
 
 interface ChildGoodsInlineEditFormProps {
   // 子商品数据
@@ -19,251 +24,227 @@ interface ChildGoodsInlineEditFormProps {
   };
 
   // 回调函数
-  onSave: (childId: number, updateData: {
-    name: string;
-    price: number;
-    costPrice?: number;
-    stock: number;
-    sort: number;
-    state: number;
-  }) => Promise<void>;
+  onSave: (childId: number, updateData: UpdateRequest) => Promise<void>;
   onCancel: () => void;
 
   // 加载状态
   isLoading?: boolean;
 }
 
+// 从AdminUpdateGoodsDto提取我们需要的字段,并设置为必需
+const childGoodsUpdateSchema = AdminUpdateGoodsDto.pick({
+  name: true,
+  price: true,
+  costPrice: true,
+  stock: true,
+  sort: true,
+  state: true
+}).extend({
+  name: z.string().min(1, '商品名称不能为空'),
+  price: z.coerce.number<number>().multipleOf(0.01, '价格最多保留两位小数').min(0, '价格不能为负数'),
+  costPrice: z.coerce.number<number>().multipleOf(0.01, '成本价最多保留两位小数').min(0, '成本价不能为负数').optional(),
+  stock: z.coerce.number<number>().int().nonnegative('库存必须为非负数'),
+  sort: z.coerce.number<number>().int().nonnegative('排序值必须为非负数'),
+  state: z.number().int().min(1).max(2)
+});
+
+// 表单数据类型
+type ChildGoodsFormData = z.infer<typeof childGoodsUpdateSchema>;
+
 export const ChildGoodsInlineEditForm: React.FC<ChildGoodsInlineEditFormProps> = ({
   child,
   onSave,
   onCancel,
   isLoading = false
 }) => {
-  // 表单状态
-  const [formData, setFormData] = useState({
-    name: child.name,
-    price: child.price.toString(),
-    costPrice: child.costPrice?.toString() || '',
-    stock: child.stock.toString(),
-    sort: child.sort.toString(),
-    state: child.state.toString()
-  });
-
-  // 表单验证状态
-  const [errors, setErrors] = useState<Record<string, string>>({});
-
-  // 处理输入变化
-  const handleInputChange = (field: string, value: string) => {
-    setFormData(prev => ({
-      ...prev,
-      [field]: value
-    }));
-
-    // 清除该字段的错误
-    if (errors[field]) {
-      setErrors(prev => {
-        const newErrors = { ...prev };
-        delete newErrors[field];
-        return newErrors;
-      });
-    }
-  };
-
-  // 验证表单
-  const validateForm = (): boolean => {
-    const newErrors: Record<string, string> = {};
-
-    // 验证商品名称
-    if (!formData.name.trim()) {
-      newErrors.name = '商品名称不能为空';
-    } else if (formData.name.length > 255) {
-      newErrors.name = '商品名称不能超过255个字符';
-    }
-
-    // 验证价格
-    const price = parseFloat(formData.price);
-    if (isNaN(price) || price < 0) {
-      newErrors.price = '价格必须是非负数';
-    }
-
-    // 验证成本价(可选)
-    if (formData.costPrice) {
-      const costPrice = parseFloat(formData.costPrice);
-      if (isNaN(costPrice) || costPrice < 0) {
-        newErrors.costPrice = '成本价必须是非负数';
-      }
-    }
-
-    // 验证库存
-    const stock = parseInt(formData.stock);
-    if (isNaN(stock) || stock < 0) {
-      newErrors.stock = '库存必须是非负整数';
-    }
-
-    // 验证排序
-    const sort = parseInt(formData.sort);
-    if (isNaN(sort) || sort < 0) {
-      newErrors.sort = '排序值必须是非负整数';
-    }
-
-    // 验证状态
-    const state = parseInt(formData.state);
-    if (isNaN(state) || (state !== 1 && state !== 2)) {
-      newErrors.state = '状态值必须是1(可用)或2(不可用)';
+  // 初始化表单
+  const form = useForm<ChildGoodsFormData>({
+    resolver: zodResolver(childGoodsUpdateSchema),
+    defaultValues: {
+      name: child.name,
+      price: child.price,
+      costPrice: child.costPrice,
+      stock: child.stock,
+      sort: child.sort,
+      state: child.state
     }
-
-    setErrors(newErrors);
-    return Object.keys(newErrors).length === 0;
-  };
+  });
 
   // 处理表单提交
-  const handleSubmit = async (e: React.FormEvent) => {
-    e.preventDefault();
-
-    if (!validateForm()) {
-      return;
-    }
-
-    // 准备更新数据
+  const handleSubmit = form.handleSubmit(async (data) => {
+    // 准备更新数据 - 直接使用验证后的数据
     const updateData = {
-      name: formData.name.trim(),
-      price: parseFloat(formData.price),
-      costPrice: formData.costPrice ? parseFloat(formData.costPrice) : undefined,
-      stock: parseInt(formData.stock),
-      sort: parseInt(formData.sort),
-      state: parseInt(formData.state)
+      name: data.name,
+      price: data.price,
+      costPrice: data.costPrice,
+      stock: data.stock,
+      sort: data.sort,
+      state: data.state
     };
 
     await onSave(child.id, updateData);
-  };
+  }, (errors) => {
+    console.debug('表单验证错误:', errors);
+  });
 
   return (
-    <form onSubmit={handleSubmit} className="space-y-4">
-      <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-        {/* 商品名称 */}
-        <div className="space-y-2">
-          <Label htmlFor="name">商品名称</Label>
-          <Input
-            id="name"
-            value={formData.name}
-            onChange={(e) => handleInputChange('name', e.target.value)}
-            placeholder="请输入商品名称"
-            className={errors.name ? 'border-destructive' : ''}
+    <Form {...form}>
+      <form onSubmit={handleSubmit} className="space-y-4">
+        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+          {/* 商品名称 */}
+          <FormField
+            control={form.control}
+            name="name"
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>商品名称</FormLabel>
+                <FormControl>
+                  <Input
+                    placeholder="请输入商品名称"
+                    {...field}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
           />
-          {errors.name && (
-            <p className="text-sm text-destructive">{errors.name}</p>
-          )}
-        </div>
 
-        {/* 价格 */}
-        <div className="space-y-2">
-          <Label htmlFor="price">价格</Label>
-          <Input
-            id="price"
-            type="number"
-            step="0.01"
-            min="0"
-            value={formData.price}
-            onChange={(e) => handleInputChange('price', e.target.value)}
-            placeholder="0.00"
-            className={errors.price ? 'border-destructive' : ''}
+          {/* 价格 */}
+          <FormField
+            control={form.control}
+            name="price"
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>价格</FormLabel>
+                <FormControl>
+                  <Input
+                    type="number"
+                    step="0.01"
+                    min="0"
+                    placeholder="0.00"
+                    {...field}
+                    value={field.value ?? ''}
+                    onChange={(e) => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
           />
-          {errors.price && (
-            <p className="text-sm text-destructive">{errors.price}</p>
-          )}
-        </div>
 
-        {/* 成本价 */}
-        <div className="space-y-2">
-          <Label htmlFor="costPrice">成本价</Label>
-          <Input
-            id="costPrice"
-            type="number"
-            step="0.01"
-            min="0"
-            value={formData.costPrice}
-            onChange={(e) => handleInputChange('costPrice', e.target.value)}
-            placeholder="0.00"
-            className={errors.costPrice ? 'border-destructive' : ''}
+          {/* 成本价 */}
+          <FormField
+            control={form.control}
+            name="costPrice"
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>成本价</FormLabel>
+                <FormControl>
+                  <Input
+                    type="number"
+                    step="0.01"
+                    min="0"
+                    placeholder="0.00"
+                    {...field}
+                    value={field.value ?? ''}
+                    onChange={(e) => field.onChange(e.target.value === '' ? undefined : parseFloat(e.target.value))}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
           />
-          {errors.costPrice && (
-            <p className="text-sm text-destructive">{errors.costPrice}</p>
-          )}
-        </div>
 
-        {/* 库存 */}
-        <div className="space-y-2">
-          <Label htmlFor="stock">库存</Label>
-          <Input
-            id="stock"
-            type="number"
-            min="0"
-            step="1"
-            value={formData.stock}
-            onChange={(e) => handleInputChange('stock', e.target.value)}
-            placeholder="0"
-            className={errors.stock ? 'border-destructive' : ''}
+          {/* 库存 */}
+          <FormField
+            control={form.control}
+            name="stock"
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>库存</FormLabel>
+                <FormControl>
+                  <Input
+                    type="number"
+                    min="0"
+                    step="1"
+                    placeholder="0"
+                    {...field}
+                    value={field.value ?? ''}
+                    onChange={(e) => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
           />
-          {errors.stock && (
-            <p className="text-sm text-destructive">{errors.stock}</p>
-          )}
-        </div>
 
-        {/* 排序 */}
-        <div className="space-y-2">
-          <Label htmlFor="sort">排序</Label>
-          <Input
-            id="sort"
-            type="number"
-            min="0"
-            step="1"
-            value={formData.sort}
-            onChange={(e) => handleInputChange('sort', e.target.value)}
-            placeholder="0"
-            className={errors.sort ? 'border-destructive' : ''}
+          {/* 排序 */}
+          <FormField
+            control={form.control}
+            name="sort"
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>排序</FormLabel>
+                <FormControl>
+                  <Input
+                    type="number"
+                    min="0"
+                    step="1"
+                    placeholder="0"
+                    {...field}
+                    value={field.value ?? ''}
+                    onChange={(e) => field.onChange(e.target.value === '' ? undefined : parseInt(e.target.value))}
+                  />
+                </FormControl>
+                <FormMessage />
+              </FormItem>
+            )}
+          />
+
+          {/* 状态 */}
+          <FormField
+            control={form.control}
+            name="state"
+            render={({ field }) => (
+              <FormItem>
+                <FormLabel>状态</FormLabel>
+                <Select
+                  value={field.value?.toString()}
+                  onValueChange={(value) => field.onChange(parseInt(value))}
+                >
+                  <FormControl>
+                    <SelectTrigger>
+                      <SelectValue placeholder="选择状态" />
+                    </SelectTrigger>
+                  </FormControl>
+                  <SelectContent>
+                    <SelectItem value="1">可用</SelectItem>
+                    <SelectItem value="2">不可用</SelectItem>
+                  </SelectContent>
+                </Select>
+                <FormMessage />
+              </FormItem>
+            )}
           />
-          {errors.sort && (
-            <p className="text-sm text-destructive">{errors.sort}</p>
-          )}
         </div>
 
-        {/* 状态 */}
-        <div className="space-y-2">
-          <Label htmlFor="state">状态</Label>
-          <Select
-            value={formData.state}
-            onValueChange={(value) => handleInputChange('state', value)}
+        {/* 操作按钮 */}
+        <div className="flex justify-end gap-2 pt-4">
+          <Button
+            type="button"
+            variant="outline"
+            onClick={onCancel}
+            disabled={isLoading}
           >
-            <SelectTrigger className={errors.state ? 'border-destructive' : ''}>
-              <SelectValue placeholder="选择状态" />
-            </SelectTrigger>
-            <SelectContent>
-              <SelectItem value="1">可用</SelectItem>
-              <SelectItem value="2">不可用</SelectItem>
-            </SelectContent>
-          </Select>
-          {errors.state && (
-            <p className="text-sm text-destructive">{errors.state}</p>
-          )}
+            <X className="h-4 w-4 mr-2" />
+            取消
+          </Button>
+          <Button type="submit" disabled={isLoading}>
+            <Save className="h-4 w-4 mr-2" />
+            {isLoading ? '保存中...' : '保存'}
+          </Button>
         </div>
-      </div>
-
-      {/* 操作按钮 */}
-      <div className="flex justify-end gap-2 pt-4">
-        <Button
-          type="button"
-          variant="outline"
-          onClick={onCancel}
-          disabled={isLoading}
-        >
-          <X className="h-4 w-4 mr-2" />
-          取消
-        </Button>
-        <Button type="submit" disabled={isLoading}>
-          <Save className="h-4 w-4 mr-2" />
-          {isLoading ? '保存中...' : '保存'}
-        </Button>
-      </div>
-    </form>
+      </form>
+    </Form>
   );
 };

+ 2 - 8
packages/goods-management-ui-mt/src/components/ChildGoodsList.tsx

@@ -10,6 +10,7 @@ import { Badge } from '@d8d/shared-ui-components/components/ui/badge';
 import { Skeleton } from '@d8d/shared-ui-components/components/ui/skeleton';
 import { goodsClientManager } from '../api/goodsClient';
 import { ChildGoodsInlineEditForm } from './ChildGoodsInlineEditForm';
+import { UpdateRequest } from '../types/goods';
 
 interface ChildGoods {
   id: number;
@@ -99,14 +100,7 @@ export const ChildGoodsList: React.FC<ChildGoodsListProps> = ({
     setEditingChildId(null);
   };
 
-  const handleSaveEdit = async (childId: number, updateData: {
-    name: string;
-    price: number;
-    costPrice?: number;
-    stock: number;
-    sort: number;
-    state: number;
-  }) => {
+  const handleSaveEdit = async (childId: number, updateData: UpdateRequest) => {
     setIsSaving(true);
     try {
       const client = goodsClientManager.get();

+ 4 - 4
packages/goods-management-ui-mt/src/types/goods.ts

@@ -1,9 +1,9 @@
 import type { InferRequestType, InferResponseType } from 'hono/client';
-import type { adminGoodsRoutes } from '@d8d/goods-module';
+import { goodsClient } from '../api/index';
 
-export type CreateRequest = InferRequestType<typeof adminGoodsRoutes.$post>['json'];
-export type UpdateRequest = InferRequestType<typeof adminGoodsRoutes[':id']['$put']>['json'];
-export type GoodsResponse = InferResponseType<typeof adminGoodsRoutes.$get, 200>['data'][0];
+export type CreateRequest = InferRequestType<typeof goodsClient.index.$post>['json'];
+export type UpdateRequest = InferRequestType<typeof goodsClient[':id']['$put']>['json'];
+export type GoodsResponse = InferResponseType<typeof goodsClient.index.$get, 200>['data'][0];
 
 export interface Goods {
   id: number;

+ 16 - 13
packages/goods-management-ui-mt/tests/unit/ChildGoodsInlineEditForm.test.tsx

@@ -39,11 +39,13 @@ describe('ChildGoodsInlineEditForm', () => {
 
     // 检查所有字段
     expect(screen.getByLabelText('商品名称')).toHaveValue('测试商品');
-    expect(screen.getByLabelText('价格')).toHaveValue('100');
-    expect(screen.getByLabelText('成本价')).toHaveValue('80');
-    expect(screen.getByLabelText('库存')).toHaveValue('10');
-    expect(screen.getByLabelText('排序')).toHaveValue('1');
-    expect(screen.getByText('可用')).toBeInTheDocument(); // 状态选择器
+    expect(screen.getByLabelText('价格')).toHaveValue(100);
+    expect(screen.getByLabelText('成本价')).toHaveValue(80);
+    expect(screen.getByLabelText('库存')).toHaveValue(10);
+    expect(screen.getByLabelText('排序')).toHaveValue(1);
+    // 状态选择器 - 使用更具体的查询
+    const stateSelect = screen.getByRole('combobox', { name: /状态/i });
+    expect(stateSelect).toBeInTheDocument();
 
     // 检查按钮
     expect(screen.getByText('保存')).toBeInTheDocument();
@@ -66,22 +68,22 @@ describe('ChildGoodsInlineEditForm', () => {
     await user.clear(priceInput);
     await user.type(priceInput, '150.50');
 
-    expect(priceInput).toHaveValue('150.5');
+    expect(priceInput).toHaveValue(150.5);
 
     // 修改库存
     const stockInput = screen.getByLabelText('库存');
     await user.clear(stockInput);
     await user.type(stockInput, '20');
 
-    expect(stockInput).toHaveValue('20');
+    expect(stockInput).toHaveValue(20);
   });
 
   it('应该处理状态选择', async () => {
     const user = userEvent.setup();
     renderComponent();
 
-    // 打开选择器
-    const stateTrigger = screen.getByText('可用');
+    // 打开选择器 - 使用更具体的查询
+    const stateTrigger = screen.getByRole('combobox', { name: /状态/i });
     await user.click(stateTrigger);
 
     // 选择"不可用"
@@ -155,9 +157,9 @@ describe('ChildGoodsInlineEditForm', () => {
     // 应该显示验证错误
     await waitFor(() => {
       expect(screen.getByText('商品名称不能为空')).toBeInTheDocument();
-      expect(screen.getByText('价格必须是非负数')).toBeInTheDocument();
-      expect(screen.getByText('库存必须是非负整数')).toBeInTheDocument();
     });
+    expect(screen.getByText('价格必须是非负数')).toBeInTheDocument();
+    expect(screen.getByText('库存必须是非负整数')).toBeInTheDocument();
 
     // 不应该调用onSave
     expect(mockOnSave).not.toHaveBeenCalled();
@@ -239,7 +241,8 @@ describe('ChildGoodsInlineEditForm', () => {
 
     renderComponent({ child: childWithoutCostPrice });
 
-    // 成本价输入框应该为空
-    expect(screen.getByLabelText('成本价')).toHaveValue('');
+    // 成本价输入框应该为空(或undefined)
+    const costPriceInput = screen.getByLabelText('成本价');
+    expect(costPriceInput).toHaveValue('');
   });
 });