Lưu (save) entity
Insert vs update, navigation property, optimistic concurrency, dirty tracking.
Save form nghe đơn giản — await client.PostAsync(entity) — nhưng trong Core có vài cạnh sắc quanh navigation property, reference, và khác biệt PATCH/POST. Đây là playbook.
Insert vs update
Cả hai đi qua cùng 1 call (base.Save(entity)). Framework quyết định:
entity.Id == 0→ insert (POST /api/Entity).entity.Id > 0→ update (PATCH /api/Entity/{id}với 1PatchUpdate).
EditForm.IsEditMode => Entity != null && Entity[IdField].As<int>() > 0 là cùng check, expose cho code của bạn.
Strip navigation property
Nếu entity mang collection (feature.ComponentGroup, feature.Component, feature.GridPolicy), null hoá trước khi POST trừ khi thực sự muốn nested upsert:
private async Task<bool> SaveFeatureInternal(Feature feature)
{
feature.ClearReferences();
feature.Component = null;
feature.ComponentGroup = null;
feature.FeaturePolicy = null;
feature.GridPolicy = null;
return await base.Save(feature);
}
Tại sao:
- Navigation collection load qua
$expandđể hiển thị. POST lại bắt server reconcile — chậm trong best case, destructive trong worst case. ClearReferences()(trongCore.Extensions) walk graph và null back-reference, tránh cycle khiNewtonsoft.Jsonserialize.
PostAsync có cờ allowNested cùng tác dụng:
public Task<T> PostAsync<T>(object value, string subUrl = "",
bool annonymous = false, bool allowNested = false, string finalUrl = null);
Mặc định false strip nested. Pass true chỉ khi controller được build cho upsert children.
Patch update — chỉ field đã đổi
PatchUpdate (trong Core.ViewModels) gửi list (field, old, new) thay vì cả entity:
var patch = new PatchUpdate
{
Table = nameof(Customer),
Key = customer.Id.ToString(),
Changes = new List<PatchVM>
{
new() { Field = nameof(Customer.Name), Value = customer.Name, OldValue = oldCustomer.Name },
new() { Field = nameof(Customer.Phone), Value = customer.Phone, OldValue = oldCustomer.Phone },
},
};
await new Client(nameof(Customer)).PatchAsync<bool>(patch);
Lợi:
- Payload nhỏ.
- Server detect concurrent edit (so
OldValuevới DB hiện tại) và reject sạch. - Bảng
Historylog chính xác ai đổi gì.
Phần lớn flow save form dùng PATCH tự động khi entity ở edit mode — bạn chỉ build PatchUpdate thủ công cho mutation ngoài form (toggle cờ từ row grid, …).
Dirty tracking
EditForm track dirty state qua EditableComponent.PopulateDirty. Input flip cờ khi value đổi; lúc save, framework duyệt input dirty và build PatchUpdate từ chúng. Bạn không phải track tay.
PopulateDirty = false (form admin của framework dùng) tắt dirty tracking — toàn bộ entity được save mỗi lần. Dùng khi thay đổi rải rác nhưng đi cùng một bộ.
Bulk save
Cho list entity (save nhiều row từ grid 1 lần):
var saved = await new Client(nameof(Item)).BulkUpdateAsync(items);
Server insert row Id = 0 và update phần còn lại. Trả list đã save với id mới gán.
PatchAsync<T>(List<PatchUpdate>) là bulk PATCH — dùng khi mỗi row chỉ vài field đổi.
Validation trước save
Cách Core khuyên dùng: set rule vào Component.Validation (DB) → trước submit gọi await this.IsFormValid().
public override async Task<bool> Save(object entity)
{
// 1) Check toàn bộ rule cấu hình trong Component.Validation
// (Required, regex, minLength, range, custom expression, ...)
if (!await this.IsFormValid()) return false;
// 2) Business check riêng (unique, cross-field, server-side)
var c = (Customer)entity;
if (!await ValidationExtensions.IsUnique(c, nameof(Customer.Code)))
{
Toast.Warning("Mã đã tồn tại");
return false;
}
return await base.Save(c);
}
IsFormValid() tự duyệt mọi widget visible, gọi ValidateAsync() trên từng cái → tự focus field đầu tiên sai + show toast lỗi → return false. Bạn không cần handle UI.
Đừng tự check
Required/ regex / minLength / max trong code C#. Set vàoComponent.Validationcủa row DB rồi để Core lo. Code C# chỉ cho business rule không cấu hình được (vd unique check từ server, cross-field validation).
AfterSaved — hook sau khi save
form.AfterSaved = ok =>
{
if (ok) Toast.Success("Đã lưu");
else Toast.Warning("Lưu thất bại");
};
Cho flow phức tạp hơn — override Save thay vì hook.
Concurrency: lỗi “data đã đổi”
Server check OldValue mà có người khác sửa giữa load và save → PATCH fail. 2 lựa chọn:
- Reload và retry.
await form.LoadEntity()— show diff cho user. - Force overwrite. Gửi patch với
OldValue = nullđể skip check (chỉ khi biết rõ).
Không có retry loop sẵn — design UX explicit.
Read-only / lock
EditForm.IsLock phản ánh lock server-side (vd record đang được user khác edit). Nếu true:
- Input render
disabled. - Save bị suppress.
- Icon lock hiện trên header form.
Lock đến từ collection Locked trên entity load. Release lock thường khi đóng tab — Disposed handler fire Client.PostAsync để release.
Flow validation
Theo thứ tự:
- Cờ per-input —
Component.Validation(rule JSON),MaxLength, … render border đỏ + tooltip khi blur. await this.IsFormValid()— duyệt tất cả widget, gọiValidateAsync()→ focus field sai đầu tiên + toast lỗi.Saveoverride — business validation thêm (unique, cross-field). Trảfalseđể abort.- Validation server — API có thể reject. Message HttpException trở thành toast.
Luôn Toast trên path abort, nếu không user thấy không có gì xảy ra.
ClearReferences làm gì?
feature.ClearReferences();
Walk graph và set back-reference (parent pointer) về null. Mục đích: tránh cycle khi Newtonsoft serialize. Sau khi gọi xong, không thể navigate ngược từ child → parent — nhưng bạn sắp send qua wire nên không cần.
Vô tình gọi rồi tiếp tục dùng entity → null deref khi navigate. Hoặc reload từ server, hoặc giữ copy riêng.
String encoded vs raw — EncodeSpecialChar
Textbox (và các input string khác) URL-encode 6 ký tự đặc biệt trước khi lưu vào entity, decode khi đọc ra. Đây KHÔNG phải HTML encode — chỉ là URL-style encoding tránh các ký tự phá OData querystring.
Bảng encode trong Utils.SpecialChar:
| Ký tự gốc | Encode thành |
|---|---|
+ | %2B |
/ | %2F |
? | %3F |
# | %23 |
& | %26 |
' | %27 |
Lý do: nếu value chứa &, OData query ?$filter=Code eq 'AB&CD' sẽ bị parse sai (&CD thành parameter mới).
Vd: user nhập "AB & CD" → entity lưu "AB %26 CD" → DB lưu "AB %26 CD" → đọc ra → decode lại thành "AB & CD" hiển thị.
Tắt encode: Feature.IgnoreEncode = true (hoặc Component.PlainText = true cho widget cụ thể).
Cẩn thận: bypass Textbox (set entity.Name = "AB & CD" thẳng từ JS callback) → giá trị không qua encode → khi save, OData filter có thể fail. Nhất quán: hoặc luôn-encoded (qua EncodeSpecialChar()) hoặc luôn-raw (set IgnoreEncode = true).
Tip
- Luôn handle
falsetừ Save. Toast lỗi, đừng silent. - Wrap async save trong
try/catch HttpException. Network error đừng crash UI. - Refresh related view khi success.
ShouldUpdateParentForm = truehoặcAfterSavedthủ công. - Đừng
awaituser input. Build patch sync từ state hiện tại, rồiawaitnetwork call.