imean-service-engine-htmx-plugin 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +211 -138
- package/dist/index.d.ts +211 -138
- package/dist/index.js +2320 -1140
- package/dist/index.mjs +2316 -1141
- package/docs/field-editor-best-practices.md +320 -0
- package/package.json +15 -13
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# FieldEditor 开发最佳实践
|
|
2
|
+
|
|
3
|
+
本文档旨在指导开发者如何正确开发和使用 FieldEditor 系列组件(如 `TagsEditor`、`StringArrayEditor`、`BannerEditor` 等)。
|
|
4
|
+
|
|
5
|
+
## 核心设计原则
|
|
6
|
+
|
|
7
|
+
### 1. 交互式输入控件不使用 `name` 属性
|
|
8
|
+
|
|
9
|
+
**原则**:所有用于组件内部交互的输入控件(如添加、编辑)**不应该**使用 `name` 属性。
|
|
10
|
+
|
|
11
|
+
**原因**:
|
|
12
|
+
- FieldEditor 组件作为表单字段的编辑器,嵌套在通用表单中
|
|
13
|
+
- 如果交互式输入控件设置了 `name`,会在表单提交时被后端接收
|
|
14
|
+
- 通用表单难以排除这些临时交互字段
|
|
15
|
+
- 使用 `hx-vals` 可以精确控制发送的参数,不影响表单提交
|
|
16
|
+
|
|
17
|
+
**正确示例**:
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
// ✅ 正确:使用 hx-vals 传递参数,不使用 name
|
|
21
|
+
<input
|
|
22
|
+
type="text"
|
|
23
|
+
id={inputId}
|
|
24
|
+
hx-vals="js:{tagValue:this.value}"
|
|
25
|
+
hx-post={ctx.url("addTag", { fieldName, index: Date.now() })}
|
|
26
|
+
hx-trigger="keydown[key=='Enter']"
|
|
27
|
+
/>
|
|
28
|
+
|
|
29
|
+
// ❌ 错误:交互式输入控件使用 name
|
|
30
|
+
<input
|
|
31
|
+
type="text"
|
|
32
|
+
name="tagValue" // 这会在表单提交时被发送
|
|
33
|
+
hx-post={ctx.url("addTag")}
|
|
34
|
+
/>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. 表单提交数据使用隐藏字段
|
|
38
|
+
|
|
39
|
+
**原则**:真正需要提交到后端的数据,使用 `hidden` 类型的 `input` 元素,并设置正确的 `name` 属性。
|
|
40
|
+
|
|
41
|
+
**数组字段命名规范**:
|
|
42
|
+
- 字符串数组:`${fieldName}[${index}]`
|
|
43
|
+
- 对象数组:`${fieldName}[${index}].propertyName`
|
|
44
|
+
- 参考 `form-data-processor.ts` 的解析规则
|
|
45
|
+
|
|
46
|
+
**正确示例**:
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
// ✅ 正确:使用 hidden input 存储表单数据
|
|
50
|
+
<div data-tag-item>
|
|
51
|
+
<span>{value}</span>
|
|
52
|
+
<input
|
|
53
|
+
type="hidden"
|
|
54
|
+
name={`${fieldName}[${index}]`}
|
|
55
|
+
value={value}
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
// ✅ 正确:对象数组字段
|
|
60
|
+
<div data-banner-item>
|
|
61
|
+
<input type="hidden" name={`${fieldName}[${index}].url`} value={banner.url} />
|
|
62
|
+
<input type="hidden" name={`${fieldName}[${index}].alt`} value={banner.alt} />
|
|
63
|
+
<input type="hidden" name={`${fieldName}[${index}].order`} value={banner.order} />
|
|
64
|
+
</div>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3. 索引管理策略
|
|
68
|
+
|
|
69
|
+
**原则**:
|
|
70
|
+
- **初始渲染**:使用真实索引(0, 1, 2, ...),便于回填和验证
|
|
71
|
+
- **动态添加**:使用时间戳作为索引(`Date.now()`),确保唯一且不连续
|
|
72
|
+
- **优势**:`form-data-processor` 会自动压缩不连续的数组索引
|
|
73
|
+
|
|
74
|
+
**实现示例**:
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// 初始渲染:使用真实索引
|
|
78
|
+
{tags.map((tag, index) => (
|
|
79
|
+
<div key={index}>
|
|
80
|
+
{this.renderTag(ctx, fieldName, tag, index, false)}
|
|
81
|
+
</div>
|
|
82
|
+
))}
|
|
83
|
+
|
|
84
|
+
// 动态添加:使用时间戳
|
|
85
|
+
hx-post={ctx.url("addTag", {
|
|
86
|
+
fieldName,
|
|
87
|
+
index: Date.now(), // 使用时间戳确保唯一
|
|
88
|
+
// ...
|
|
89
|
+
})}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 4. OOB 交换的正确用法
|
|
93
|
+
|
|
94
|
+
**原则**:
|
|
95
|
+
- 使用 `hx-swap-oob="morph"` 而不是 `hx-swap-oob="true"`
|
|
96
|
+
- 不要包裹多余的层级,保持 DOM 结构一致
|
|
97
|
+
- 在 OOB 交换时设置 `value=""` 来置空输入框
|
|
98
|
+
|
|
99
|
+
**正确示例**:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// ✅ 正确:直接返回 input 元素,使用 morph 交换
|
|
103
|
+
private renderAddInput(ctx, fieldName, inputId, tagsContainerId, isOob = false) {
|
|
104
|
+
return (
|
|
105
|
+
<input
|
|
106
|
+
type="text"
|
|
107
|
+
id={inputId}
|
|
108
|
+
value=""
|
|
109
|
+
hx-vals="js:{tagValue:this.value}"
|
|
110
|
+
{...(isOob ? { "hx-swap-oob": "morph" } : {})}
|
|
111
|
+
// ... 其他属性
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ❌ 错误:包裹多余的 div
|
|
117
|
+
<div hx-swap-oob="true" id={inputId}>
|
|
118
|
+
<input type="text" id={inputId} />
|
|
119
|
+
</div>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### 5. 纯 HTMX 实现,避免 JavaScript
|
|
123
|
+
|
|
124
|
+
**原则**:
|
|
125
|
+
- 尽量使用 HTMX 的原生功能(`hx-post`、`hx-get`、`hx-trigger` 等)
|
|
126
|
+
- 使用内联 JavaScript 表达式(`hx-vals="js:{...}"`、`hx-on:click="..."`)
|
|
127
|
+
- 避免使用 Alpine.js 和全局 script 代码块
|
|
128
|
+
- 仅在必要时使用少量 JavaScript(如空状态更新)
|
|
129
|
+
|
|
130
|
+
**正确示例**:
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
// ✅ 正确:使用 HTMX 原生功能
|
|
134
|
+
<input
|
|
135
|
+
hx-post={ctx.url("addTag", { fieldName, index: Date.now() })}
|
|
136
|
+
hx-target={`#${tagsContainerId}`}
|
|
137
|
+
hx-swap="beforeend"
|
|
138
|
+
hx-trigger="keydown[key=='Enter']"
|
|
139
|
+
hx-vals="js:{tagValue:this.value}"
|
|
140
|
+
/>
|
|
141
|
+
|
|
142
|
+
// ✅ 正确:使用内联 JavaScript 处理简单逻辑
|
|
143
|
+
<button
|
|
144
|
+
{...({
|
|
145
|
+
"hx-on:click": `htmx.closest(this, '[data-tag-item]').remove(); updateTagsEmptyState('${ctx.instanceId}')`,
|
|
146
|
+
} as any)}
|
|
147
|
+
>
|
|
148
|
+
删除
|
|
149
|
+
</button>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## 组件结构规范
|
|
153
|
+
|
|
154
|
+
### 方法职责划分
|
|
155
|
+
|
|
156
|
+
1. **`@Method` 装饰的方法**:
|
|
157
|
+
- 处理 HTMX 请求
|
|
158
|
+
- 验证输入数据
|
|
159
|
+
- 返回 HTML 片段(用于 HTMX 交换)
|
|
160
|
+
|
|
161
|
+
2. **`render*` 私有方法**:
|
|
162
|
+
- 渲染可复用的 UI 片段
|
|
163
|
+
- 不处理业务逻辑
|
|
164
|
+
- 接收 `RenderContext` 或 `ComponentContext`
|
|
165
|
+
|
|
166
|
+
3. **`render` 保护方法**:
|
|
167
|
+
- 组件的入口渲染方法
|
|
168
|
+
- 初始化组件结构
|
|
169
|
+
- 调用其他 `render*` 方法
|
|
170
|
+
|
|
171
|
+
### 错误处理
|
|
172
|
+
|
|
173
|
+
**原则**:错误应该通过 HTMX 响应返回,而不是静默失败。
|
|
174
|
+
|
|
175
|
+
**正确示例**:
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
@Method({ method: "post" })
|
|
179
|
+
async addTag(context: ComponentContext) {
|
|
180
|
+
const tagValue = String(body.tagValue || "").trim();
|
|
181
|
+
|
|
182
|
+
if (!tagValue) {
|
|
183
|
+
// ✅ 正确:返回错误提示(使用 OOB 交换)
|
|
184
|
+
return (
|
|
185
|
+
<div
|
|
186
|
+
className="text-red-600 text-sm p-2"
|
|
187
|
+
hx-swap-oob="true"
|
|
188
|
+
id={`${inputId}-error`}
|
|
189
|
+
>
|
|
190
|
+
标签不能为空
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 成功时返回新标签项
|
|
196
|
+
return this.renderTag(context, fieldName, tagValue, index, false);
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## 表单数据格式
|
|
201
|
+
|
|
202
|
+
### 数组字段格式
|
|
203
|
+
|
|
204
|
+
根据 `form-data-processor.ts` 的解析规则,数组字段应该使用以下格式:
|
|
205
|
+
|
|
206
|
+
1. **字符串数组**:
|
|
207
|
+
```
|
|
208
|
+
tags[0] = "标签1"
|
|
209
|
+
tags[1] = "标签2"
|
|
210
|
+
tags[100] = "标签3" // 不连续索引会被自动压缩
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
2. **对象数组**:
|
|
214
|
+
```
|
|
215
|
+
banners[0].url = "https://example.com/1.jpg"
|
|
216
|
+
banners[0].alt = "图片1"
|
|
217
|
+
banners[0].order = 0
|
|
218
|
+
banners[100].url = "https://example.com/2.jpg"
|
|
219
|
+
banners[100].alt = "图片2"
|
|
220
|
+
banners[100].order = 1
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Hidden 字段的位置
|
|
224
|
+
|
|
225
|
+
**原则**:Hidden 字段应该放在对应的数据项容器内,确保删除项时一起删除。
|
|
226
|
+
|
|
227
|
+
**正确示例**:
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
<div data-tag-item>
|
|
231
|
+
<span>{value}</span>
|
|
232
|
+
<input type="hidden" name={`${fieldName}[${index}]`} value={value} />
|
|
233
|
+
<button hx-on:click="...">删除</button>
|
|
234
|
+
</div>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## 代码审查清单
|
|
238
|
+
|
|
239
|
+
在开发或审查 FieldEditor 组件时,请检查:
|
|
240
|
+
|
|
241
|
+
- [ ] 交互式输入控件是否使用了 `hx-vals` 而不是 `name`?
|
|
242
|
+
- [ ] 表单提交数据是否使用 `hidden` input 并设置了正确的 `name`?
|
|
243
|
+
- [ ] 初始渲染是否使用真实索引,动态添加是否使用时间戳?
|
|
244
|
+
- [ ] OOB 交换是否使用 `morph` 而不是 `true`?
|
|
245
|
+
- [ ] OOB 交换是否没有包裹多余的层级?
|
|
246
|
+
- [ ] 是否尽量使用 HTMX 原生功能,避免 JavaScript?
|
|
247
|
+
- [ ] 错误处理是否通过 HTMX 响应返回?
|
|
248
|
+
- [ ] 删除操作后是否更新了空状态显示?
|
|
249
|
+
- [ ] Hidden 字段是否放在对应的数据项容器内?
|
|
250
|
+
|
|
251
|
+
## 参考实现
|
|
252
|
+
|
|
253
|
+
- `TagsEditor`:标签编辑器,字符串数组
|
|
254
|
+
- `StringArrayEditor`:字符串数组编辑器
|
|
255
|
+
- `BannerEditor`:Banner 编辑器,对象数组
|
|
256
|
+
|
|
257
|
+
## 常见错误
|
|
258
|
+
|
|
259
|
+
### ❌ 错误 1:交互式输入控件使用 name
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
// ❌ 错误
|
|
263
|
+
<input name="tagValue" hx-post={ctx.url("addTag")} />
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**问题**:会在表单提交时被发送,干扰表单数据。
|
|
267
|
+
|
|
268
|
+
**修复**:
|
|
269
|
+
```tsx
|
|
270
|
+
// ✅ 正确
|
|
271
|
+
<input hx-vals="js:{tagValue:this.value}" hx-post={ctx.url("addTag")} />
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### ❌ 错误 2:OOB 交换使用 true 并包裹 div
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
// ❌ 错误
|
|
278
|
+
<div hx-swap-oob="true" id={inputId}>
|
|
279
|
+
<input type="text" id={inputId} />
|
|
280
|
+
</div>
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**问题**:会导致焦点丢失,DOM 结构不一致。
|
|
284
|
+
|
|
285
|
+
**修复**:
|
|
286
|
+
```tsx
|
|
287
|
+
// ✅ 正确
|
|
288
|
+
<input
|
|
289
|
+
type="text"
|
|
290
|
+
id={inputId}
|
|
291
|
+
hx-swap-oob="morph"
|
|
292
|
+
value=""
|
|
293
|
+
/>
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### ❌ 错误 3:编辑输入框使用 name
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
// ❌ 错误
|
|
300
|
+
<input name="newValue" hx-post={ctx.url("updateTag")} />
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
**问题**:会在表单提交时被发送。
|
|
304
|
+
|
|
305
|
+
**修复**:
|
|
306
|
+
```tsx
|
|
307
|
+
// ✅ 正确
|
|
308
|
+
<input hx-vals="js:{newValue:this.value}" hx-post={ctx.url("updateTag")} />
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## 总结
|
|
312
|
+
|
|
313
|
+
FieldEditor 组件的核心是:
|
|
314
|
+
1. **交互式控件**:使用 `hx-vals`,不使用 `name`
|
|
315
|
+
2. **表单数据**:使用 `hidden` input,设置正确的 `name`
|
|
316
|
+
3. **索引管理**:初始用真实索引,动态添加用时间戳
|
|
317
|
+
4. **OOB 交换**:使用 `morph`,不包裹多余层级
|
|
318
|
+
5. **纯 HTMX**:尽量使用原生功能,避免 JavaScript
|
|
319
|
+
|
|
320
|
+
遵循这些原则可以确保组件既功能完整,又不会干扰表单提交。
|
package/package.json
CHANGED
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "imean-service-engine-htmx-plugin",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "HtmxAdminPlugin for IMean Service Engine",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsup",
|
|
9
|
+
"dev": "tsx examples-v2/index.ts",
|
|
10
|
+
"test": "vitest --run",
|
|
11
|
+
"test:ui": "vitest --ui",
|
|
12
|
+
"test:coverage": "vitest --run --coverage",
|
|
13
|
+
"test:unit": "vitest --run --project unit",
|
|
14
|
+
"test:e2e": "playwright test",
|
|
15
|
+
"test:e2e:ui": "playwright test --ui",
|
|
16
|
+
"test:e2e:headed": "playwright test --headed",
|
|
17
|
+
"dev:component": "tsx src/component-system/test.ts",
|
|
18
|
+
"prepublishOnly": "npm run build && npm run test"
|
|
19
|
+
},
|
|
7
20
|
"keywords": [
|
|
8
21
|
"microservice",
|
|
9
22
|
"hono",
|
|
@@ -38,16 +51,5 @@
|
|
|
38
51
|
"tsx": "^4.21.0",
|
|
39
52
|
"typescript": "^5.9.3",
|
|
40
53
|
"vitest": "^4.0.15"
|
|
41
|
-
},
|
|
42
|
-
"scripts": {
|
|
43
|
-
"build": "tsup",
|
|
44
|
-
"dev": "tsx examples-v2/index.ts",
|
|
45
|
-
"test": "vitest --run",
|
|
46
|
-
"test:ui": "vitest --ui",
|
|
47
|
-
"test:coverage": "vitest --run --coverage",
|
|
48
|
-
"test:unit": "vitest --run --project unit",
|
|
49
|
-
"test:e2e": "playwright test",
|
|
50
|
-
"test:e2e:ui": "playwright test --ui",
|
|
51
|
-
"test:e2e:headed": "playwright test --headed"
|
|
52
54
|
}
|
|
53
|
-
}
|
|
55
|
+
}
|