Màn hình chung từ mô tả #
Hướng dẫn dựng MỘT màn hình dùng chung vẽ được mọi màn hình module có mô tả
(listMenu() → screen khác null). Làm một lần: module mới phát hành dùng lại
khuôn có sẵn là CMS có màn hình, không code, không build lại.
Khung gọi màn hình này từ route /s/:key
(Dựng khung CMS §7). Hợp đồng
của mô tả (field, mã lỗi, luật quyền):
CMS §3.1. Mọi màn hình có
ở bản server này: Màn hình dựng sẵn.
1. Luồng #
/s/brand.brands
│ listMenu() đã có: { key, screen: { type: "crud", revision: "320da6…" } }
▼
cache (key, revision, ngôn ngữ) có? ── không ─► getMenuScreen({ params: { key } })
│
▼
schemaVersion > 1 hoặc type lạ ─► "Chưa hỗ trợ màn hình này"
│
▼ type = "crud"
Danh sách (§4): cột · tìm · lọc · sắp · trang · nút tạo / sửa / xoá / thùng rác
⇅
Form tạo / sửa (§5): ô theo widget, widget lạ → theo schema; lưu = create / update
│ mọi lời gọi: client.call(operations[id], { params, query, body, form })
Mô tả không có code, không biểu thức: mọi thứ màn hình cần (đường, quyền,
schema, nhãn đã dịch) nằm trong response của getMenuScreen.
2. Tải và cache mô tả #
Mô tả TĨNH theo bản server: giống nhau cho mọi người dùng, mọi store. Khoá cache
= (key, revision, ngôn ngữ). revision đổi khi mô tả đổi (module đổi màn
hình, route đổi schema, server lên bản mới) — kể cả khi tập key của menu không
đổi.
import { isApiError } from "./sdk/api.ts";
import type { XforgeClient } from "./sdk/api.ts";
export type Screen = Awaited<ReturnType<XforgeClient["getMenuScreen"]>>["data"];
const SUPPORTED_SCHEMA = 1;
const SUPPORTED_TYPES = new Set(["crud"]);
const mem = new Map<string, Screen>(); // có thể thay bằng IndexedDB
export const screenCache = {
async get(key: string, revision: string): Promise<Screen | "unsupported"> {
const id = `${key}|${revision}|${state.lang ?? ""}`;
let s = mem.get(id);
if (!s) {
try {
s = (await api.getMenuScreen({ params: { key } })).data;
} catch (e) {
// Mục vừa rời menu (quyền, module, gói) — đọc lại menu, về trang chủ.
if (isApiError(e, "menu_entry_not_found")) throw e;
if (isApiError(e, "menu_screen_not_declared")) return "unsupported";
throw e;
}
mem.set(id, s);
}
if (s.schemaVersion > SUPPORTED_SCHEMA || !SUPPORTED_TYPES.has(s.type)) {
return "unsupported";
}
return s;
},
dropLanguage: () => mem.clear(),
clearMembership: () => {/* mô tả không theo store — giữ nguyên */},
clear: () => mem.clear(),
};
revisionđến từlistMenu(). Menu được đọc lại khi cócore.menu_changedvà ở mỗireadycủa luồng realtime — server lên bản mới thì luồng đứt, nối lại,ready, menu mới mangrevisionmới. Không cần thêm cơ chế nào.- Đừng dùng tên +
versioncủa module (listModules()) làm khoá: mô tả có thể đổi màversiongiữ nguyên. - Chỉ mở màn hình khi
keyCÓ tronglistMenu()của store hiện tại — bản cache dùng chung giữa các store, quyền thì không. - Gặp
typelạ hayschemaVersionlớn hơn bản bạn biết: hiện "chưa hỗ trợ" ở vùng nội dung, menu và phần còn lại của trang vẫn chạy. Bỏ qua field lạ ở MỌI cấp của mô tả — server chỉ thêm, không bớt.
3. Gọi operation #
operations chứa MỌI operation màn hình dùng, kể cả của module khác (ô chọn nhà
cung cấp, tải logo qua module files): method, path
(/api/v1/brands/{id}), permissions, conditional, features, idempotent,
status, bodyContentType, rawResponse, và JSON Schema params / query /
body / response / meta.
Gọi bằng call() của SDK — cùng đường refresh / đổi store / Idempotency-Key
với method sinh ra:
type Op = Screen["operations"][string];
/** Tham số đường dẫn `{x}` lấy từ field CÙNG TÊN của bản ghi. */
function pathParams(op: Op, record: Record<string, unknown> = {}) {
return Object.fromEntries(
[...op.path.matchAll(/\{(\w+)\}/g)].map(([, k]) => [k, record[k]]),
);
}
export function run<
T = { data: unknown; meta?: { nextCursor: string | null } },
>(
screen: Screen,
id: string,
input: {
record?: Record<string, unknown>;
query?: Record<string, unknown>;
body?: unknown;
form?: FormData;
idempotencyKey?: string;
} = {},
): Promise<T> {
const op = screen.operations[id];
return api.call<T>(op, {
params: pathParams(op, input.record),
query: input.query,
body: input.body,
form: input.form,
idempotencyKey: input.idempotencyKey,
});
}
call()chỉ nhận đường dưới/api/v1/và từ chối tham số./..— đừng dựngpathtay.rawResponsekhác null (vd tải file):call()trảResponse— đọcblob().- Form tạo: sinh
idempotencyKeyMỘT lần khi mở form, dùng lại cho mọi lần bấm "Lưu" / "Thử lại" của form đó.
4. Danh sách #
layout của crud:
ops: { list, get, create, update, delete } operationId | null (không có → ẩn nút)
trash: { list, restore, columns } | null
search: true → ô tìm (tham số q)
columns: [{ field, label, widget, sort, options, currencyField, download, schema }]
filters: [{ param, label, widget, options, schema }]
form: [{ field, label, widget, on, required, options, display, upload, download,
currencyField, schema, fields }]
enums: { <tên>: { <giá trị>: { label, tone } } }
4.1. Tham số danh sách #
interface ListState {
q: string;
sort: string | null; // giá trị `sort` của cột, thêm "-" để giảm dần
filters: Record<string, string>; // param → giá trị CHUỖI
cursor: string | null;
}
function listQuery(s: ListState) {
const q = s.q.trim(); // chỉ gửi khi đủ 3 ký tự (xem dưới)
return {
limit: "50",
...(q.length >= 3 ? { q } : {}),
...(s.sort ? { sort: s.sort } : {}),
...s.filters,
...(s.cursor ? { cursor: s.cursor } : {}),
};
}
async function loadPage(screen: Screen, s: ListState) {
const r = await run<
{ data: Record<string, unknown>[]; meta: { nextCursor: string | null } }
>(
screen,
opsOf(screen).list!,
{ query: listQuery(s) },
);
return { rows: r.data, next: r.meta.nextCursor };
}
- Đổi
q/sort/ bộ lọc → đặtcursor = null(con trỏ của kiểu sắp khác bị 400invalid_cursor). "Tải thêm" / trang sau: gửimeta.nextCursor;null= hết (Phần chung §2). - Ô tìm: gõ xong ~300 ms mới gọi; tìm không phân biệt dấu. CHỈ gửi
qkhi có từ 3 ký tự (sau khi bỏ khoảng trắng hai đầu) — chỉ mục tìm kiếm là trigram, chuỗi 1–2 ký tự không dùng được chỉ mục nên server phải quét cả bảng; ít hơn 3 ký tự thì coi như ô trống (không gửiq). - Cột có
sortmới bấm sắp được; mặc định để server tự sắp (không gửisort). - Tham số query luôn là CHUỖI (
"true","50").
4.2. Ô của cột #
Lấy giá trị theo đường field (có thể lồng: supplierInfo.name):
const valueAt = (row: unknown, path: string) =>
path.split(".").reduce<unknown>(
(v, k) => (v && typeof v === "object"
? (v as Record<string, unknown>)[k]
: undefined),
row,
);
widget | Hiện | Ghi chú |
|---|---|---|
null | theo schema (bảng §7) | chuỗi / số in thẳng, boolean = dấu tích |
text, textarea, url, email | chuỗi; url thành link mở tab mới | textarea cắt ngắn trong bảng |
number, qty | số; qty là CHUỖI thập phân — giữ nguyên chữ số | đừng parseFloat để tính |
money | Intl.NumberFormat(locale, { style: "currency", currency }) | currency = giá trị của field currencyField trên cùng dòng |
date, datetime | date là YYYY-MM-DD; datetime là ISO UTC → giờ địa phương | khoảng ngày theo timezone của store |
switch | dấu tích / công tắc chỉ đọc | |
badge | nhãn + màu từ enums[options.enum][giá trị] | tone: neutral / info / success / warning / danger; lạ → trung tính |
image | ảnh nhỏ: tải nội dung qua operations[download] | xem §4.3 |
user | name của object { id, login, name } | null → "—" |
Giá trị null → "—". Widget bạn chưa làm → vẽ theo schema (§7), đừng bỏ cột.
4.3. Ảnh / file trong bảng #
async function thumbnail(
screen: Screen,
opId: string,
info: { id: string } | null,
) {
if (!info) return null;
const res = await run<Response>(screen, opId, { record: info }); // {id} ← info.id
return URL.createObjectURL(await res.blob()); // nhớ revokeObjectURL khi bỏ
}
Người dùng thiếu quyền của operation tải (files.read…) → không gọi, hiện ô
trống (xem canOperation, §8).
4.4. Bộ lọc #
widget | Ô lọc | Gửi |
|---|---|---|
boolean | Có / Không / Tất cả | "true" / "false" / không gửi |
select | chọn một trong options | giá trị chuỗi |
picker | ô chọn có tìm (§5.4) | id |
text, email, date, datetime | ô nhập | chuỗi (ngày YYYY-MM-DD) |
null / lạ | ô chữ | chuỗi |
schema của tham số lọc thường chỉ là string kèm description (vd
"eq: bool") — dựa vào widget, đừng suy từ schema.
4.5. Nút của danh sách #
- Tạo — có
ops.createvàcanOperation(operations[ops.create]). - Sửa (bấm dòng) — có
ops.update; đọc bản ghi bằngops.getnếu có, không thì dùng chính dòng của danh sách. - Xoá — có
ops.delete; hỏi xác nhận,run(screen, ops.delete, { record: row })(204), rồi tải lại trang hiện tại. - Thùng rác —
trashkhác null: tab riêng, danh sáchtrash.listvớitrash.columns, nút Khôi phục =run(screen, trash.restore, { record: row }).
Quyền xem từng nút: §8.
5. Form tạo / sửa #
5.1. Ô nào ở form nào #
Mỗi ô có on (["create"], ["update"], hoặc cả hai) và required (form nào
bắt buộc). Form tạo vẽ ô có on ∋ "create"; form sửa vẽ ô có on ∋ "update".
layout trong SDK là object mở (thêm type mới không phá kiểu) — khai kiểu của
phần bạn đọc:
interface Field {
field: string;
label: string;
widget: string | null;
on: ("create" | "update")[];
required: ("create" | "update")[];
options: Record<string, unknown> | null;
display: string | null;
upload: string | null;
download: string | null;
currencyField: string | null;
schema: Record<string, unknown>;
fields: Field[] | null;
}
const opsOf = (s: Screen) => s.layout.ops as Record<string, string | null>;
const fieldsFor = (screen: Screen, mode: "create" | "update") =>
(screen.layout.form as Field[]).filter((f) => f.on.includes(mode));
const isRequired = (f: Field, mode: "create" | "update") =>
f.required.includes(mode);
5.2. Giá trị ban đầu và gửi lên #
- Tạo: giá trị mặc định lấy từ
schema.defaultnếu có (vdactivemặc địnhtrue), không thì rỗng. Ô không bắt buộc mà để trống → KHÔNG gửi khoá đó. - Sửa: giá trị ban đầu = field CÙNG TÊN của bản ghi (
ops.get). Update là PATCH: chỉ gửi ô đã đổi. Xoá giá trị của ô cho phépnull(schema có nhánhnull) → gửinull. bodychỉ chứa khoá có trong form — server từ chối khoá lạ (400validation_failed,rule: "unknown_key").
async function save(
screen: Screen,
mode: "create" | "update",
values: Record<string, unknown>,
record: Record<string, unknown> | null,
key: string,
) {
const ops = opsOf(screen);
if (mode === "create") {
return await run(screen, ops.create!, {
body: values,
idempotencyKey: key,
});
}
const changed = Object.fromEntries(
Object.entries(values).filter(([k, v]) =>
JSON.stringify(v) !== JSON.stringify(record![k])
),
);
return await run(screen, ops.update!, {
record: record!,
body: changed,
idempotencyKey: key,
});
}
5.3. Ô theo widget #
widget | Ô | Gửi | Ghi chú |
|---|---|---|---|
text, email, url | ô chữ (type tương ứng) | chuỗi | schema.maxLength, pattern để báo sớm |
textarea | ô nhiều dòng | chuỗi | |
number | ô số | số | schema.type integer → không cho phần thập phân |
qty | ô số dạng chuỗi | CHUỖI ("1.5") | đừng đổi sang float |
money | ô tiền | CHUỖI ("125000") | tiền tệ: field currencyField của bản ghi / form; không có thì currency của store |
date | chọn ngày | "YYYY-MM-DD" | |
datetime | chọn ngày giờ | ISO có múi giờ | |
switch | công tắc | boolean | |
select | chọn một | giá trị | lựa chọn từ options (§5.4) hoặc schema.enum |
picker | chọn một, có tìm | id | §5.4; hiện giá trị đang có từ display |
multipicker | chọn nhiều, có tìm | mảng id | §5.4 |
file | chọn file → tải lên ngay | id file | §5.5 |
translations | một nhóm ô cho mỗi ngôn ngữ | mảng { lang, … } | §5.6 |
Ô có required ở form đang mở: đánh dấu *, chặn gửi khi trống. Server vẫn
kiểm — lỗi của server thắng (§6).
5.4. Lựa chọn (options) #
Hai dạng:
{ enum: "status" } → screen.layout.enums.status: { draft: { label, tone } }
{ op, value, label, hint, query } → gọi operations[op] lấy danh sách
async function loadOptions(
screen: Screen,
o: {
op: string;
value: string;
label: string;
hint: string | null;
query: Record<string, string>;
},
q: string,
) {
if (!canOperation(screen.operations[o.op])) return null; // → ô nhập thường (§8)
const r = await run<{ data: unknown[] }>(screen, o.op, {
// `q` chỉ gửi khi đủ 3 ký tự (chỉ mục trigram, §4.1).
query: {
...o.query,
...(q.trim().length >= 3 ? { q: q.trim() } : {}),
limit: "20",
},
});
return r.data.map((it) => ({
value: valueAt(it, o.value),
label: valueAt(it, o.label),
hint: o.hint ? valueAt(it, o.hint) : null,
}));
}
querylà tham số CỐ ĐỊNH (vd chỉ nhà cung cấp đang hoạt động) — luôn gửi kèm.- Ô chọn có tìm: cùng luật với ô tìm của danh sách — chờ ~300 ms sau lần gõ
cuối, chỉ gửi
qkhi có từ 3 ký tự; gõ 1–2 ký tự thì giữ danh sách đầu (khôngq). - Ô sửa: hiện nhãn của giá trị đang có từ
display(đường trong bản ghi, vdsupplierInfo.name) — không cần gọi danh sách để biết tên. - Danh sách dài: tải theo trang (
meta.nextCursor) khi cuộn.
5.5. Tải file (file) #
async function uploadInto(screen: Screen, field: Field, file: File) {
const form = new FormData();
form.append("file", file, file.name);
const r = await run<{ data: { id: string } }>(screen, field.upload!, {
form,
});
return r.data.id; // gán vào giá trị của ô
}
- Tải lên ngay khi chọn file; ô giữ
id, form gửiidkhi lưu. - File lớn hơn trần upload một lần (mặc định 25 MB) → upload theo phần của
module
files(xem trang module files). - Hiện file đang có:
display(vdlogoFileInfo:{ id, name, contentType, size }); xem / tải về quadownload(§4.3). - Bỏ file khỏi ô (schema cho
null) → gửinull.
5.6. Bản dịch (translations) #
Field dịch được (tên, mô tả, slug…) gửi thành MỘT mảng, mỗi phần tử một ngôn ngữ (Phần chung §3.3):
translations: [
{ lang: "vi", name: "Thương hiệu A", slug: "", description: null },
{ lang: "en", name: "Brand A", … },
]
- Ngôn ngữ:
languagescủa store (getMe()), ngôn ngữ mặc định (defaultLang) lên đầu.langdo widget quản — không phải một ô. - Ô của mỗi nhóm:
fields(đã có nhãn, widget,requiredtheo phần tử). - Bản ghi đang sửa: lấy từ field
translationscủa bản ghi (MỌI bản dịch). - Gửi
translationskhi sửa = THAY cả bộ: ngôn ngữ bỏ trống hết thì không gửi phần tử đó (server xoá bản dịch ngôn ngữ đó). Không đổi bản dịch nào → đừng gửi khoátranslations. - Ngôn ngữ store không bật → 400
unsupported_language.
6. Lỗi của form #
| Lỗi | Gắn vào |
|---|---|
validation_failed | details[]: mỗi phần tử có path (code, translations.1.name) → ô tương ứng; message đã dịch |
duplicate_value | details.fields (vd ["code"]) → các ô đó, câu message |
*_exists (vd brand_code_exists) | câu chung đầu form (mã riêng của module) |
file_not_attachable | ô file |
có error.at (lines.3) | dòng / phần tử đó của mảng |
version_conflict | hỏi tải lại bản mới |
| còn lại | khung xử lý (Dựng khung CMS §9) |
function fieldErrors(e: unknown): Map<string, string> {
const out = new Map<string, string>();
if (isApiError(e, "validation_failed")) {
for (const i of e.details as { path: string | null; message: string }[]) {
out.set(i.path ?? "", i.message);
}
} else if (isApiError(e, "duplicate_value")) {
for (const f of e.details.fields) out.set(f, e.message);
}
return out;
}
path của bản dịch theo CHỈ SỐ trong mảng bạn gửi (translations.1.name = phần
tử thứ hai) — giữ thứ tự gửi để gắn đúng nhóm ngôn ngữ.
7. Widget chưa hỗ trợ: vẽ theo schema #
Mỗi cột / ô / lọc kèm schema (JSON Schema của field, lấy từ route). Gặp
widget bạn chưa làm — hoặc widget: null — vẽ theo kiểu:
schema | Ô mặc định |
|---|---|
anyOf có { type: "null" } | như nhánh còn lại, cho phép để trống / xoá |
enum | chọn một trong các giá trị (nhãn = chính giá trị) |
type: "boolean" | công tắc |
type: "integer" / "number" | ô số (minimum / maximum) |
type: "string", format: "date" | chọn ngày |
type: "string", format: "date-time" | chọn ngày giờ |
type: "string", format: "email" / "uri" | ô email / link |
type: "string" | ô chữ (maxLength, pattern) |
type: "array" các phần tử object | bảng con, mỗi dòng các ô theo items.properties |
| không suy được | "Ô này chưa hỗ trợ" — vẫn cho lưu các ô khác |
Danh mục widget sẽ có thêm tên mới. Kiểm CMS đã hỗ trợ đủ widget của bản server
đang chạy: Màn hình dựng sẵn (mục "Widget đang dùng"),
hoặc tự động từ sdk/screens.json (§10).
8. Quyền và gói #
Mô tả KHÔNG theo người dùng — bạn tự quyết hiện nút nào bằng permissions /
features của từng operation, cùng luật với màn tay
(Dựng khung CMS §12):
canOperation(screen.operations[opId]); // superuser || mọi quyền (kể cả có điều kiện) + gói có tính năng
- Không có quyền
ops.create/ops.update/ops.delete/trash.restore→ ẩn nút tương ứng. Không có quyềnops.listthì mục đã không có trong menu. conditional: true→ hiện nút; server có thể trả 403condition_not_metcho bản ghi cụ thể — báo lỗi, không ẩn nút trước.- Thiếu quyền của operation trong
options(ô chọn) → ô nhập thường theoschema(vẫn hợp lệ, server kiểm). Thiếu quyền tải ảnh / tải file → ẩn ảnh / nút tải lên. - Ẩn nút chỉ là giao diện; server luôn kiểm (403
forbidden,details.missing).
9. Realtime trên màn hình đang mở #
- Mỗi
readycủa luồng realtime: tải lại trang danh sách hiện tại (sự kiện có thể đã lỡ). - Sự kiện
<module>.<topic>của module màn hình đang mở:datachỉ là tham chiếu ({ id }) — đọc lại dòng / bản ghi đó, đừng dùngdatalàm nội dung. - Mô tả đổi
revisionkhi đang mở: tải mô tả mới, giữ bộ lọc / trang hiện tại nếu tham số còn hợp lệ, không thì đặt lại.
10. Kiểm renderer bằng sdk/screens.json #
sdk/screens.json (tải ở Tải SDK) chứa mô tả của MỌI màn
hình ở bản server, mọi ngôn ngữ:
{ "items": [ { "key": "brand.brands", "module": "brand", "type": "crud",
"revision": "…", "descriptors": { "en": { … }, "vi": { … } } } ] }
Viết một test trong CI của CMS:
import catalog from "./sdk/screens.json" with { type: "json" };
const SUPPORTED_WIDGETS = new Set([
"text",
"textarea",
"number",
"switch", /* … */
]);
for (const it of catalog.items) {
const d = it.descriptors.en;
// 1. Loại + phiên bản định dạng renderer biết.
assert(
SUPPORTED_TYPES.has(d.type) || d.type === "custom",
`${it.key}: type ${d.type}`,
);
assert(d.schemaVersion <= SUPPORTED_SCHEMA);
// 2. Mọi widget đã có (hoặc bạn chấp nhận vẽ theo schema).
for (const w of widgetsOf(d.layout)) {
assert(SUPPORTED_WIDGETS.has(w), `${it.key}: widget ${w}`);
}
// 3. Renderer dựng được mà không ném lỗi (render vào DOM giả / snapshot).
renderScreenForTest(d);
}
Chạy lại mỗi lần lên bản server mới: màn hình mới / widget mới lộ ra trước khi người dùng thấy "chưa hỗ trợ".
11. Màn hình chung chưa làm được gì #
Theo thiết kế bản đầu (ADR-0116 của Core) — dùng màn tay theo key nếu cần:
- Thao tác ngoài CRUD trên một bản ghi (đặt hàng, duyệt, nhận hàng…) và thao tác cấp danh sách (nhập mẫu, đồng bộ…).
- Bảng con của bản ghi (thuế suất của nhóm thuế, giá theo sản phẩm của bảng giá…).
- Ô phụ thuộc ô khác (đơn vị theo sản phẩm đã chọn), form điền sẵn từ bản ghi.
- Xoá vĩnh viễn và những quyền server kiểm bên trong route (không có trong
permissionscủa operation) — xem trang của module.
Trang của từng module (mục Module ở thanh bên) ghi màn hình chung của module đó có và thiếu gì.
12. Checklist #
- Cache mô tả theo (
key,revision, ngôn ngữ); đọc lại khirevisiontrong menu đổi. -
typelạ /schemaVersionlớn hơn → "chưa hỗ trợ", trang vẫn chạy; bỏ qua field lạ. - Mọi lời gọi qua
client.call(operations[id], …); tham số đường dẫn từ field cùng tên của bản ghi. - Danh sách: tìm, lọc, sắp theo cột có
sort, con trỏ, đặt lại con trỏ khi đổi điều kiện. - Form:
on/requiredtheo form; PATCH chỉ gửi ô đã đổi;nullđể xoá. - Widget: đủ danh mục của bản server; widget lạ → theo
schema. - Ô chọn:
options(enum / op + query), hiện giá trị đang có quadisplay. - File: tải lên ngay, giữ id; ảnh qua operation tải về.
- Bản dịch: theo
languagescủa store, gửi cả bộ. - Lỗi theo ô (
details[].path,duplicate_value.fields,error.at). - Nút theo
canOperation(quyền + gói);conditionalvẫn hiện. - Test CI trên
sdk/screens.json.