xforge · tài liệu FE

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(),
};

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,
  });
}

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 };
}

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,
  );
widgetHiệnGhi chú
nulltheo schema (bảng §7)chuỗi / số in thẳng, boolean = dấu tích
text, textarea, url, emailchuỗi; url thành link mở tab mớitextarea cắt ngắn trong bảng
number, qtysố; qty là CHUỖI thập phân — giữ nguyên chữ sốđừng parseFloat để tính
moneyIntl.NumberFormat(locale, { style: "currency", currency })currency = giá trị của field currencyField trên cùng dòng
date, datetimedate là YYYY-MM-DD; datetime là ISO UTC → giờ địa phươngkhoảng ngày theo timezone của store
switchdấu tích / công tắc chỉ đọc
badgenhã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
username 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ọcGửi
booleanCó / Không / Tất cả"true" / "false" / không gửi
selectchọn một trong optionsgiá trị chuỗi
pickerô chọn có tìm (§5.4)id
text, email, date, datetimeô nhậpchuỗ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 #

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 #

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ửiGhi chú
text, email, urlô chữ (type tương ứng)chuỗischema.maxLength, pattern để báo sớm
textareaô nhiều dòngchuỗi
numberô sốsốschema.type integer → không cho phần thập phân
qtyô số dạng chuỗiCHUỖI ("1.5")đừng đổi sang float
moneyô tiềnCHUỖI ("125000")tiền tệ: field currencyField của bản ghi / form; không có thì currency của store
datechọn ngày"YYYY-MM-DD"
datetimechọn ngày giờISO có múi giờ
switchcông tắcboolean
selectchọn mộtgiá trịlựa chọn từ options (§5.4) hoặc schema.enum
pickerchọn một, có tìmid§5.4; hiện giá trị đang có từ display
multipickerchọn nhiều, có tìmmảng id§5.4
filechọn file → tải lên ngayid file§5.5
translationsmộ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,
  }));
}

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 ô
}

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", … },
]

6. Lỗi của form #

LỗiGắn vào
validation_faileddetails[]: mỗi phần tử có path (code, translations.1.name) → ô tương ứng; message đã dịch
duplicate_valuedetails.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_conflicthỏi tải lại bản mới
còn lạikhung 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á
enumchọ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ử objectbả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

9. Realtime trên màn hình đang mở #

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:

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 #

Nguồn: content/guides/cms-screens.md