Feature / Section / Component (cấu hình UI từ database)
3 bảng DB quyết định mọi màn hình. Bạn config ở đây — Core tự render.
Đây là cốt lõi của Core low-code. Hiểu được mục này = hiểu cách dùng Core.
Mô hình 3 lớp
Feature ← 1 màn hình logic (vd "Khách hàng")
└── ComponentGroup ← 1 vùng layout (vd "Header", "Body", "Tab thông tin")
└── Component ← 1 widget cụ thể (Textbox, Number, GridView, Button…)
Mỗi feature có nhiều section. Mỗi section chứa nhiều component. Section có thể lồng nhau (qua ParentId).
Bảng Feature
1 row = 1 màn hình. Field bạn thường set:
| Field | Mô tả |
|---|---|
Name | Định danh trong code (khớp nameof(...) khi viết class C#). |
Label | Tiêu đề hiển thị trên tab/popup. |
EntityName | Entity backend tương ứng (vd Customer). |
Icon | Icon path (cho menu + tab header). |
Active | Bật/tắt feature. |
IgnoreEncode | True → input string không URL-encode 6 ký tự đặc biệt (+ / ? # & ') khi save. Default false (encode). Xem String encoded vs raw. |
Trong code,
: base(nameof(Customer))của TabEditor/PopupEditor sẽ tìm row Feature cóName = "Customer".
Bảng ComponentGroup (Section)
1 row = 1 vùng layout. Field bạn thường set:
| Field | Mô tả |
|---|---|
Name | Tên section trong feature. |
FeatureId | Feature sở hữu. |
ParentId | Section cha (cây — null nếu là gốc). |
ElementType | Tag HTML (div, td, fieldset, …). |
ClassName | Class CSS gắn lên element. |
Order | Thứ tự giữa siblings. |
Active | Bật/tắt section. |
Column/XsCol/SmCol/LgCol/XlCol | Số cột grid theo screen width — chia cột responsive không cần viết CSS. |
Visibility | Expression evaluate trên entity → ẩn/hiện theo record. |
→ Cách dùng *Col để chia layout responsive: Section & chia cột.
Bảng Component (Widget)
1 row = 1 widget (input, button, grid, …). Field cấu hình tùy theo ComType.
Field cốt lõi (mọi widget đều cần)
| Field | Mô tả |
|---|---|
Name | Định danh trong feature (dùng để FindComponentByName<T>("Name")). |
ComponentGroupId | Section chứa widget này. |
ComType | Tên class renderer (vd Textbox, Number, GridView, Button…). |
FieldName | Property của entity để bind (hỗ trợ dotted path: "Customer.Address.City"). |
Label | Label hiển thị. |
ShowLabel | True → render <label> cạnh widget. |
Order | Thứ tự render trong section. |
Active | Bật/tắt. |
Editable | False → render readonly. |
Visibility | Expression ẩn/hiện theo record. |
Column | Span bao nhiêu ô trong grid section (default 2). |
Field tùy ComType
Mỗi ComType đọc 1 tập field khác nhau. Đặt field component không đọc cũng vô tác dụng.
→ Tham chiếu component liệt kê chính xác mỗi ComType dùng những field nào.
Ví dụ:
ComType = "Textbox"đọc thêm:UpperCase,FormatData, …ComType = "Number"đọc thêm:Precision.ComType = "GridView"đọc thêm:Reference,DataSourceFilter,CanAdd,CanSearch,IsRealtime, …ComType = "Dropdown"(alias) đọc thêm:Reference,RefName,DataSourceFilter,Template, …
Trong code Core, field này thường gọi là
ComponentType(Component.ComponentType). DB column cũng tênComponentType. Cùng 1 field.
ComponentType — alias mapping (ComponentFactory.GetComponent)
Field ComponentType không phải tên class trực tiếp — nó là alias được ComponentFactory.GetComponent(ui, form, canWrite) map sang class theo logic switch. Nhiều giá trị có variant tự pick.
Bảng sau từ source Core/Components/Extensions/ComponentFactory.cs:
ComponentType | Class C# instantiate | Ghi chú |
|---|---|---|
| (rỗng) | → Label → CellText | Default khi không set. |
Label | CellText | Hiển thị text formatted (read-only). |
Input | Textbox | Alias cho Textbox. |
Password | Textbox với Password = true | Textbox kiểu password. |
Textarea | Textarea | |
AutocompleteTextbox | AutocompleteTextbox | |
Timepicker | Timepicker | |
Pdf | Pdf | |
DocumentWrite | DocumentWrite | |
Link | Link | |
Image | ImageUploader (với CanWrite set) | Alias cho ImageUploader. |
Dropdown | SearchEntry nếu Precision < 2, MultipleSearchEntry nếu Precision >= 2 | Alias đa dụng cho picker. Dùng Precision để switch single/multi-select. |
DropdownString | SearchEntryString | Alias — bind bằng string code thay vì id. |
GridView | GridView nếu GroupBy rỗng, GroupGridView nếu set | Tự pick group variant dựa GroupBy. |
VirtualGrid | VirtualGrid nếu màn hình ≥ 768px; nhỏ hơn fallback GridView / GroupGridView | Tự fallback theo screen width. |
ListView | ListView nếu GroupBy rỗng, GroupListView nếu set | Tự pick group variant. |
| Khác | Reflection: Type.GetType("Core.Components." + ComponentType) | Vd "Datepicker" → Core.Components.Datepicker. Bất kỳ class kế thừa EditableComponent trong namespace Core.Components đều dùng được. |
Vài lưu ý khi cấu hình:
- Cell grid hiển thị dropdown picker →
ComponentType = "Dropdown"+RefName+FormatCell. Đừng dùng"SearchEntry"trực tiếp —Dropdownlà alias chuẩn. - Multi-select →
ComponentType = "Dropdown"+Precision >= 2. - Cell text read-only formatted (
{Customer.Name}) →ComponentType = "Label"(hoặc rỗng). - Group rows → set
GroupBy = "<FieldName>", giữComponentType = "GridView"hoặc"ListView"— Core auto pickGroupGridView/GroupListView. - Data rất lớn (>1000 row) →
ComponentType = "VirtualGrid"(Core tự fallbackGridViewtrên mobile). - Form input password →
ComponentType = "Password"(alias). - Custom widget bạn tự viết (vd
MyChartextendsEditableComponenttrongCore.Components) →ComponentType = "MyChart", Core tìm qua reflection.
Composited ComponentType — nhiều widget trên 1 cell
Khi ComponentType chứa ký tự không chữ cái (vd -, /, :), Core hiểu là composited:
- Tách
ComponentTypetheo non-alpha chars → list sub-type. - Tách
FieldNametheo,→ list field names. - Số sub-type phải = số field; ký tự non-alpha sẽ chèn giữa các widget.
Ví dụ:
# 2 textbox phân cách bởi "-"
ComType: Input-Input
FieldName: Code,Name
# Render: <Textbox Code> - <Textbox Name>
# 2 datepicker range, phân cách bởi "/"
ComType: Datepicker/Datepicker
FieldName: StartDate,EndDate
# Render: <Datepicker StartDate> / <Datepicker EndDate>
Cả 2 widget đều copy chung GuiInfo (Component cha) nhưng có FieldName riêng → 2 widget bind 2 field khác nhau.
DataSourceFilter — cú pháp OData
Field này là chuỗi OData querystring đầy đủ, bắt buộc bắt đầu bằng ? (vd ?$filter=Active eq true). Core ghép trực tiếp vào URL API khi load data cho widget (GridView, Select2, SearchEntry, …).
# ✅ Đúng — đủ "?$filter=":
DataSourceFilter: ?$filter=Active eq true
# ✅ Nhiều điều kiện AND/OR:
DataSourceFilter: ?$filter=Active eq true and CountryId eq 1
# ✅ Có thêm $expand / $orderby / $top:
DataSourceFilter: ?$expand=Address&$filter=Active eq true&$orderby=Name&$top=200
# ❌ Sai — thiếu "?$filter=", Core sẽ không nhận:
DataSourceFilter: Active eq true
OData operator hay dùng:
| Cú pháp | Ý nghĩa |
|---|---|
eq, ne | bằng / khác |
gt, ge, lt, le | lớn hơn / lớn-bằng / nhỏ hơn / nhỏ-bằng |
and, or, not | logic |
contains(Name,'foo') | string contains |
startswith(Code,'AB') | string startsWith |
Status/Code eq 'OPEN' | navigate qua FK |
null | so sánh với null: RegionId eq null |
Substitution động — khi cần filter theo entity hiện tại, dùng placeholder {<FieldName>} (curly braces):
DataSourceFilter: ?$filter=CustomerId eq {CustomerId}
Runtime sẽ thay {CustomerId} bằng entity.CustomerId lúc gọi API. Hữu ích cho picker cascade (chọn Customer trước → list Address chỉ load của Customer đó).
Có thể dùng nhiều placeholder + dotted path:
DataSourceFilter: ?$filter=CountryId eq {Customer.CountryId} and Active eq true
Xem thêm cú pháp đầy đủ tại Client (REST + OData).
FormatData / FormatEntity / FormatCell / FormatRow — template string format
Engine duy nhất: Utils.FormatEntity(template, source) parse placeholder {<expr>:<format>} và replace bằng giá trị từ source.
Quan trọng: GridPolicy (cột grid) lưu format ở field FormatCell / FormatRow. Khi render, GridPolicy.MapToComponent() map 1:1 sang Component:
res.FormatData = header.FormatCell; // FormatCell → FormatData
res.FormatEntity = header.FormatRow; // FormatRow → FormatEntity
Sau đó Utils.GetCellText(component, ...) dùng FormatData/FormatEntity y hệt component bình thường. Cú pháp giống hệt cho cả 4 field.
| Field | Bảng | Cú pháp tương đương | Áp dụng vào |
|---|---|---|---|
FormatData | Component | FormatData | Value đơn (Textbox, Number, Datepicker) hoặc ref entity (SearchEntry, Dropdown). |
FormatEntity | Component | FormatEntity | Toàn bộ entity đang bound. |
FormatCell | GridPolicy | = FormatData | (Mapped) cell value của row trong grid. |
FormatRow | GridPolicy | = FormatEntity | (Mapped) row entity trong grid. |
Cú pháp placeholder (chuẩn Utils.FormatEntity)
{<FieldName>} → entity.FieldName
{<FieldName>:<formatSpec>} → entity.FieldName được format với .NET format string
{<Path.To.Field>} → dotted path navigate qua FK
{<a * b>} → biểu thức tính toán (chỉ khi calc=true, vd template Word)
formatSpec là chuẩn .NET format string:
| Format | Ý nghĩa | Ví dụ output |
|---|---|---|
N0 | Số nguyên có dấu phẩy phân nhóm | 1,234,567 |
N2 | Số 2 chữ số thập phân | 1,234.57 |
N4 | Số 4 chữ số thập phân | 1,234.5678 |
dd/MM/yyyy | Date Việt Nam | 15/03/2026 |
dd/MM/yyyy HH:mm | Date + giờ phút | 15/03/2026 14:30 |
MM/yyyy | Tháng/năm | 03/2026 |
C0 | Currency | $1,234 |
Ví dụ FormatEntity (placeholder theo tên field)
# SearchEntry — sau khi pick Customer, hiển thị "ACME — Khách Sài Gòn, TP.HCM"
ComType: SearchEntry
FieldName: CustomerId
Reference: Customer
FormatEntity: "{Code} — {Name}, {City}"
# CellText hiển thị nested FK
ComType: CellText
FieldName: CustomerId
FormatEntity: "{Customer.Code} ({Customer.Phone})"
# Số hoá đơn + ngày
ComType: CellText
FormatEntity: "{InvoiceNo} ngày {InvoiceDate:dd/MM/yyyy}"
Ví dụ FormatData
# Datepicker (special case — Datepicker tự strip {0: và } để lấy format string)
ComType: Datepicker
FieldName: InvoiceDate
FormatData: "{0:dd/MM/yyyy}" # → "15/03/2026"
# Number — format theo field name
ComType: Number
FieldName: Amount
FormatData: "{Amount:N2}" # → "12,345.67"
# SearchEntry — FormatData applied lên matched entity
ComType: SearchEntry
FieldName: ProductId
Reference: Product
FormatData: "{Code}" # chỉ hiển thị Code sau khi pick
Lưu ý: Datepicker là trường hợp đặc biệt — code parse
{0:format}và lấyformatcho.toLocaleDateString(). Cho component khác (Number, Textbox, SearchEntry), nên dùng{FieldName:format}(theo tên field).
Ví dụ FormatCell (GridPolicy)
-- Cột FK hiển thị tên thay vì id
INSERT INTO GridPolicy (..., FieldName, ShortDesc, FormatCell, ...) VALUES
(..., 'CustomerId', N'Khách hàng', '{Customer.Name}', ...),
(..., 'CreatedDate', N'Ngày tạo', '{0:dd/MM/yyyy HH:mm}', ...),
(..., 'Total', N'Tổng', '{Total:N0}', ...),
(..., 'Status', N'Trạng thái', '{StatusName} ({StatusCode})', ...);
FormatCell được Utils.FormatEntity áp dụng lên row entity (1 record của grid). Có thể navigate FK ({Customer.Name}).
GridPolicy còn field FormatRow — fallback khi FormatCell rỗng.
Logic render cell trong grid (Utils.GetCellTextInternal)
Khi grid render 1 cell, Core gọi GetCellTextInternal(component, cellData, row, refData, ...). Logic theo ComponentType của cột:
ComponentType | Cách áp FormatData | Cú pháp placeholder |
|---|---|---|
Datepicker | string.Format(FormatData ?? "{0:dd/MM/yyyy}", dateValue) | {0:format} (positional) |
Dropdown / Select2 | Lookup ref entity từ refData → Utils.FormatEntity(FormatData, refEntity) | {Name} / {Code} (field name) |
MultipleSearchEntry | Split CSV ids, mỗi id lookup ref entity → apply FormatData | {Name} (field name) |
| Default (Textbox, Number, …) | string.Format(FormatData, cellData) → if FormatEntity set, override → Utils.FormatEntity(FormatEntity, row) | FormatData: {0:N2} (positional); FormatEntity: {FieldName} (field name) |
Quy tắc:
- Cell có
FormatEntityset → luôn overrideFormatData(logic default branch). - Cell
Dropdown/Select2/MultipleSearchEntrykhông dùngFormatEntity— chỉFormatDataáp lên ref entity. - Cell
DatepickerdùngFormatDatavới{0:format}. Nếu rỗng → fallback{0:dd/MM/yyyy}. - Cell mặc định không set gì → text =
cellData.ToString().
LoadMasterData — auto fetch reference data cho FK cells
Khi grid có cột FK (ComponentType = Dropdown/Select2/SearchEntry, RefName set), Core tự động gọi LoadMasterData(rows):
- Quét tất cả
HeadercóRefNameset (vdRefName = "Customer"). - Lấy distinct ids từ rows hiện tại (vd
CustomerId in (1,3,7)). - POST
Client.LoadById(...)để fetch các ref entity (Customer ids 1,3,7) — 1 request batch /RefName. - Cache vào
RefData[refName]. SyncMasterData(rows, headers)— set property nested object trên row (vdrow.Customer = customerEntity) để placeholder{Customer.Name}work.
→ Bạn không phải code gì — chỉ cần set RefName (hoặc Reference) trên GridPolicy của cột FK + FormatCell = "{Customer.Name}". Core lo phần fetch + bind.
Pattern bạn không nên làm:
# ❌ Sai — gọi API riêng để load Customer rồi tự ghép
FormatCell: "{0}" # rồi code C# query Customer riêng cho mỗi row
# ✅ Đúng — set RefName, Core tự LoadMasterData + sync
RefName: Customer
FormatCell: "{Customer.Name}"
Quy tắc ưu tiên (override)
- Component có cả
FormatData+FormatEntityset → cell render theo logicComponentType:- Default branch:
FormatEntityoverrideFormatData. - Dropdown/Select2/MultipleSearchEntry: chỉ dùng
FormatData. - Datepicker: chỉ dùng
FormatData.
- Default branch:
- GridPolicy
FormatCellđược map sangFormatData→ cùng quy tắc. - Không set gì → text thô.
Template (SearchEntry) khác FormatEntity?
| Field | Áp dụng khi nào |
|---|---|
Template | Trong dropdown suggestion (lúc user đang gõ). |
FormatEntity | Sau khi đã pick (hiển thị trong input). |
Có thể set khác nhau:
ComType: SearchEntry
FieldName: CustomerId
Reference: Customer
Template: "{Code} - {Name}" # dropdown
FormatEntity: "{Name} ({Code}, {Phone})" # sau pick
Bẫy hay gặp
- Dùng
{0:N2}cho FormatEntity → sai.{0}chỉ hoạt động khi source là single value (FormatData), không hoạt động với entity. Dùng{FieldName:N2}. - Dùng tên field viết thường → sai. C# property names thường PascalCase (
Code,Name), placeholder phải khớp. - Quên dấu
:giữa field và format →{Total N2}sai, đúng phải là{Total:N2}. - Không escape
{}trong template không phải placeholder → cần escape{{}}(rare).
Events — wire method C# vào component
Field Component.Events là chuỗi JSON map event-type → tên method trên class TabEditor / PopupEditor. Khi user tương tác với widget, Core dispatch event tương ứng và tìm method trên class C# để gọi.
⚠ Quy tắc đặt tên event trong JSON
| Loại event | Cách viết | Ví dụ |
|---|---|---|
| DOM event | lowercase (JS-style) | click, change, input, keydown, focusin, dblclick, contextmenu |
| CustomEventType | PascalCase (như enum) | BeforeCreated, AfterCreated, AfterChat, DateSearch, AfterDownload |
DOMContentLoaded | PascalCase đặc biệt | DOMContentLoaded — viết y hệt vì là tên chuẩn DOM. |
Sai chính tả case → method không bao giờ được gọi.
Cú pháp Events (DB là string, nội dung JSON)
{
"click": "OnRowDeleted",
"change": "OnQuantityChanged",
"AfterCreated": "OnRowAdded"
}
3 cặp trên có nghĩa:
- Khi component fire DOM event
click→ Core gọiform.OnRowDeleted(entity, ...). - Khi fire DOM event
change→ gọiform.OnQuantityChanged(entity, newValue, oldValue, ...). - Khi fire
AfterCreated(CustomEventType, lifecycle event của Core) → gọiform.OnRowAdded(rowData).
Class C# của bạn chỉ cần khai báo method tên y hệt:
public class InvoiceDetailBL : PopupEditor
{
public InvoiceDetailBL() : base(nameof(Invoice)) { Title = "Hóa đơn"; }
public void OnRowDeleted(object entity, object btn)
{
Toast.Success("Đã xóa dòng");
}
public async Task OnQuantityChanged(object entity, object newVal, object oldVal, object parent)
{
// tự tính lại tổng, refresh field khác, ...
}
public void OnRowAdded(object rowData)
{
// setup default value cho row mới
}
}
Method có thể void, async Task, async void đều được. Tham số đầu luôn là entity (record bound vào widget). Tham số tiếp theo tùy event (xem Tham chiếu component — phần Events của từng component).
Inline JavaScript thay vì method name
Value có thể là inline JS function (thay vì tên method):
{
"click": "function(form, com) { console.log('clicked', form, com); }"
}
Core sẽ .Call(form, form, com) — function nhận form (instance TabEditor/PopupEditor) và com (component fire event). Dùng cho logic ngắn không đáng viết hẳn 1 method C#.
Event types
2 nhóm event:
| Nhóm | Cách viết | Khi fire | Examples |
|---|---|---|---|
EventType (DOM) | lowercase | User tương tác trực tiếp (click, đổi value, focus…). | click, change, input, keydown, dblclick, contextmenu, focusin |
CustomEventType | PascalCase | Lifecycle / business event riêng của Core. | AfterCreated, BeforeDeleted, AfterPasted, RowFocusOut, AfterChat |
Mỗi component fire 1 tập riêng — xem Tham chiếu component cho list event của từng ComType + method signature args nhận được.
Button đặc biệt
Row có ComType = "Button" thì Name quyết định behavior:
Name | Tự động làm |
|---|---|
btnSave | Gọi Save(Entity) của TabEditor/PopupEditor hiện tại. |
btnCancel | Đóng tab/popup (prompt nếu dirty). |
btnPrint | In report bound. |
btnPreview | Print preview. |
| khác | Gọi method có tên trong Component.Events trên class C#. |
Field HotKey (vd "Ctrl+S") auto wire phím tắt.
Cách Core load 1 feature
Khi bạn mở 1 màn hình:
- Core lookup
FeaturetheoName. - Load tất cả
ComponentGroupthuộc feature, build cây quaParentId. - Với mỗi section, load
Componentcon. - Với mỗi component, instantiate class
EditableComponenttương ứngComType(qua reflection). - Bind tới
EntityquaFieldName. - Render.
Bạn không phải biết chi tiết — chỉ cần biết insert đủ row đúng thì màn hình hiện ra.
Sửa cấu hình ở đâu?
Không ở source code. Trong app đang chạy:
- Đăng nhập với account admin.
- Mở menu Quản lý Feature.
- Tạo / sửa row Feature.
- Click vào feature → mở Detail Feature → tab Section thêm
ComponentGroup, tab Component thêmComponent. - Save → F5 lại trang test → cấu hình mới có hiệu lực ngay.
Không cần rebuild C# / không cần restart backend.
Đánh đổi của low-code
✅ Lợi:
- Thêm field không cần biên dịch lại.
- Reorder field bằng UI.
- Variation per-tenant (cùng code, row khác nhau).
- Validation tập trung trong field config.
- Permission per-field qua
FeaturePolicy.
⚠ Phải nhớ:
- “Field này render ở đâu?” trả lời bằng query DB, không grep code.
- ComType mới (custom widget) vẫn cần build C#.
- Sửa schema entity (model) vẫn cần deploy.
Bẫy hay gặp khi config
| Triệu chứng | Kiểm tra |
|---|---|
| Tab mở trắng | Section thiếu hoặc all Active=false. |
| Field không hiện | Active=true? Visibility expression có ra true? |
| Console: “property X not found” | FieldName không có trên entity model. |
| Grid không có cột | Thiếu GridPolicy cho grid đó. |
| Button không làm gì | Tên method (case-sensitive) có khớp Component.Events? |
Đi tiếp
- Section & chia cột responsive — cách 1 section chia thành 2/3/4 cột tự động theo screen.
- Tham chiếu component — field nào cho ComType nào.
- Tạo 1 màn hình mới — config + viết class C# end-to-end.