From 2155ff0e171d8cbc7d1e4e86d15259dbe67d6332 Mon Sep 17 00:00:00 2001 From: wyc <1104749580@qq.com> Date: Sat, 8 Aug 2026 11:14:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E5=B1=82=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 149 ++++- backend/library/DESIGN.md | 330 +++++++--- backend/library/include/adminive/adminive.hpp | 4 + backend/library/include/adminive/amis.hpp | 582 ++++++++++++++---- .../library/include/adminive/collection.hpp | 94 ++- .../library/include/adminive/composition.hpp | 157 +++++ .../library/include/adminive/descriptor.hpp | 213 ++----- backend/library/include/adminive/http.hpp | 11 +- backend/library/include/adminive/json.hpp | 47 +- .../library/include/adminive/presentation.hpp | 44 ++ backend/library/include/adminive/view.hpp | 443 +++++++++++++ .../library/include/adminive/view_json.hpp | 213 +++++++ backend/library/tests/core_adapter_test.cpp | 10 +- backend/service/CMakeLists.txt | 4 + .../include/adminive/adapters/drogon.hpp | 28 +- .../include/adminive/adapters/httplib.hpp | 20 +- backend/service/src/device_tables.cpp | 11 +- backend/service/src/example_descriptors.hpp | 137 ++--- backend/service/src/main.cpp | 4 +- .../service/tests/advanced_adapter_test.cpp | 21 +- backend/service/tests/drogon_adapter_test.cpp | 24 +- backend/service/tests/managed_test.cpp | 2 +- backend/service/tests/safety_test.cpp | 4 +- backend/service/tests/test.cpp | 176 ++++-- backend/service/tests/view_schema_test.cpp | 125 ++++ 25 files changed, 2233 insertions(+), 620 deletions(-) create mode 100644 backend/library/include/adminive/composition.hpp create mode 100644 backend/library/include/adminive/presentation.hpp create mode 100644 backend/library/include/adminive/view.hpp create mode 100644 backend/library/include/adminive/view_json.hpp create mode 100644 backend/service/tests/view_schema_test.cpp diff --git a/README.md b/README.md index 7ee8eea..fa385f6 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,32 @@ # Adminive Backend-Driven Complete Example -Adminive 使用 C++20 模板、Concepts 和可替换适配器描述业务类型。同一份后端描述驱动 JSON 双向转换、事务式赋值、前端可编辑性、校验、amis CRUD、枚举列表列、树状配置、联动条件、日期控件、颜色控件和一次性提交。属性能力、Schema 与同步拓扑由 `third_party/Structive` 提供,Adminive 只叠加后台管理领域的描述、JSON、HTTP、AMIS 和事务适配。核心层不依赖具体 JSON 库、枚举反射库或结构体反射库。前端只读取 `/admin/amis` 并渲染后端返回的页面 JSON,不包含业务字段或业务判断。 +Adminive 使用 C++20 模板、Concepts 和可替换适配器,把后端已有的 C++ 业务结构投影成后台管理界面。同一份后端定义同时驱动对象结构 JSON、数据访问、字段控件、表单/卡片/流式/网格/Tabs/表格等语义组合、查询能力、HTTP 接口和 AMIS 页面。属性能力、Schema 与同步拓扑由 `third_party/Structive` 提供;Adminive 在其上分层描述 Field Presentation、View Schema、页面 Composition、JSON/HTTP/AMIS 和事务适配。前端不维护第二份业务字段清单,只消费后端生成的 `/descriptor`、`/view`、`/data` 与 `/amis`。 ## 设计理念 Adminive 的核心定位是:**让后端已有的 C++ 结构直接驱动后台管理能力,而不是再建立一套独立的数据模型。** -整体职责分为两层: +整体职责按语义分层: ```text -Structive - 负责 Property、Schema、intrinsic read/write 与 synchronization - -Adminive - 负责后台领域 metadata、JSON、HTTP、AMIS、managed transaction 与 frontend projection +Structive / Object Schema + Property、类型、intrinsic read/write、constraint、synchronization + │ + ▼ +Adminive Field Presentation + 单个字段的 label、description、control、options、visible_on + │ + ▼ +Adminive View Schema + Form/Table 的字段选择、顺序、分组和数据交互能力 + │ + ▼ +Adminive Composition View + Form/Table/Status 等组件之间的 card/grid/flow/tabs 等页面组合 + │ + ▼ +AMIS Adapter / Frontend + 翻译成具体 AMIS JSON,并负责最终视觉样式和响应式表现 ``` 当前设计遵守几个核心原则: @@ -21,14 +34,68 @@ Adminive - Structive 管“对象本身是什么”,Adminive 管“后台系统怎样消费它”。 - 字段默认 intrinsic read-only,只有 `.editable()`、`.creatable()` 或 `.read_write()` 显式进入 managed mutation path。 - `read_only` 是能够影响真实运行成本的结构信息:只读存储属性不为自身贡献 lock slot/mutex,managed read 走零锁路径。 -- `editable`、`sensitive`、`widget` 等是 Adminive 领域 metadata,不是通用 Property 访问控制。外部系统是否暴露字段由外部系统自己决定。 +- `editable`、`sensitive` 与 `Field_Presentation` 是 Adminive 领域 metadata;表格排序、搜索、筛选和页面组合属于独立 `View Schema`,都不是通用 Property 访问控制。 - Synchronization 只负责进程内 mutable consistency;`Resource_Transaction` 负责外部副作用的 prepare/commit/rollback;数据库事务继续由数据库负责。 - JSON、HTTP、枚举反射和结构体反射都通过 Adapter 接入,第三方依赖不进入 Adminive Core 协议。 -- 前端只消费后端描述,不维护第二份业务 Schema。 +- 后端拥有页面的语义结构;前端只消费后端描述并负责具体渲染、样式与响应式细节,不维护第二份业务 Schema。 - 静态可知的能力尽量在编译期表达,动态输入才进入 runtime adapter。 完整的设计理念、职责边界、同步原则、事务分层和 API 演进规则见 [backend/library/DESIGN.md](backend/library/DESIGN.md)。 +### 后端 UI 的三层描述与组合层 + +一个字段“是什么”、一个字段“怎么呈现”、一组字段“怎么组成业务视图”,以及多个业务视图“怎么组成页面”是四个不同问题。Adminive 不再把它们塞进同一个 Field Descriptor: + +```text +Object Schema 结构事实 +Field Presentation 单字段呈现 +View Schema Form/Table 语义组合 +Composition View 多组件页面组合 +``` + +后端保留完整的组合能力,包括 `vertical`、`horizontal`、`flow`、`grid`、`group`、`card`、`tabs` 和表格。区别只在于这些能力现在位于独立层:字段定义不再携带表格列顺序/排序等信息,AMIS Adapter 也不再承担业务组合决策。 + +例如一个配置对象可以只定义字段事实和单字段控件: + +```cpp +ADMINIVE_FIELD(T, host).editable().label("Host").text_input(); +ADMINIVE_FIELD(T, port).editable().label("Port").number_input(); +ADMINIVE_FIELD(T, color).editable().label("Color").color_input(); +``` + +同一个类型的编辑视图再独立决定组合: + +```cpp +return edit_form(vertical( + group("Network", horizontal(use<&T::host>(), use<&T::port>())), + card("Appearance", use<&T::color>()) +)); +``` + +如果同一个类型还需要列表,表格能力同样单独定义: + +```cpp +return table_view( + column<&T::host>("Host").search().fix(Table_Fixed::left), + column<&T::port>("Port").sort(), + column<&T::color>("Color") +).default_sort("port"); +``` + +其中 `.sort()`、`.search()`、`.filter()` 不只是前端提示:Collection Service 只接受后端 View Schema 明确声明的查询能力。前端不能自行把一个后端不支持的字段变成可排序或可搜索字段。 + +对于“上面一个配置表单、下面一个设备表格”这类跨组件页面,使用 `Composition_View`: + +```cpp +const auto page = composition_view("device_page", compose::vertical( + compose::heading("Devices"), + compose::slot("config"), + compose::slot("table") +)); +``` + +`slot` 引用已经生成的 Form/Table/Status 等组件,后端仍然决定语义组合,AMIS Adapter 只负责把组合翻译成具体 AMIS JSON。前端可以进一步决定 CSS、断点、列宽等纯视觉细节。 + ## 后端分层 `backend/library` 是库级协议层,只有头文件,不依赖也不链接任何具体 JSON、HTTP Server、枚举反射或结构体反射实现。它依赖项目内的 `third_party/Structive`,由 Structive 负责 intrinsic property capability、Schema 和 synchronization;Adminive 自身包含 descriptor、JSON 适配协议、AMIS schema、状态协议、managed adapter 和抽象 `Resource_Service`: @@ -358,23 +425,38 @@ Adminive/ `example.hpp` 位于 `backend/service/src`,只作为完整示例实现,不属于公共库头文件。 -## 列字段名称、显示名称和排序 +## 字段呈现与表格列是两层 + +Field Descriptor 只描述字段自身和单字段呈现: ```cpp ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") .creatable() .editable() .required() - .list_label("Mode") - .order(5) - .sortable(); + .select_input(); ``` -- `name` 来自成员名称,用于 JSON 字段和接口参数。 -- `label` 用于表单标签。 -- `list_label` 用于列表表头,未设置时回退到 `label`。 -- `order` 控制列表列顺序,未设置时按字段声明顺序生成默认值。 -- `sortable` 控制该列能否排序,后端只接受已声明为可排序的字段。 +表格列属于 `Table_View`,列顺序就是 `table_view()` 中的声明顺序,排序、搜索、筛选和固定列能力也在这里声明: + +```cpp +template <> +struct adminive::Type_View_Descriptor { + static adminive::Table_View table() { + using T = Radio_State; + return adminive::table_view( + adminive::column<&T::mode>("Mode").sort().filter(), + adminive::column<&T::port>("Port").sort().search().fix(adminive::Table_Fixed::left), + adminive::column<&T::buffer_count>("Buffers").sort() + ).default_sort("buffer_count"); + } +}; +``` + +- Field Descriptor 的 `name` 是稳定的数据字段名。 +- `Field_Presentation::label/control/options` 描述一个字段单独出现时如何呈现。 +- `Table_View` 决定哪些字段进入表格、列顺序、列标题、固定位置以及可排序/搜索/筛选能力。 +- Collection Service 只接受 `Table_View` 已声明的查询字段,因此 View Schema 同时是前端语义和后端能力契约。 ## 枚举列表项 @@ -395,16 +477,14 @@ struct Radio_State { }; ``` -描述中不需要手写选项: +描述中不需要手写枚举值集合,字段只声明自己的呈现;表格能力由 `Table_View` 另行决定: ```cpp ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") .creatable() .editable() .required() - .list_label("Mode") - .order(5) - .sortable(); + .select_input(); ``` 示例通过可选的 `magic_enum` 桥接生成;核心只依赖 `Enum_Adapter`: @@ -413,12 +493,16 @@ ADMINIVE_FIELD_LABEL(T, mode, "Operating Mode") { "name": "mode", "value_type": "enum", - "options": [ - {"label": "Receive", "value": "receive"}, - {"label": "Transmit", "value": "transmit"}, - {"label": "Duplex", "value": "duplex"}, - {"label": "Maintenance", "value": "maintenance"} - ] + "presentation": { + "label": "Operating Mode", + "control": "select", + "options": [ + {"label": "Receive", "value": "receive"}, + {"label": "Transmit", "value": "transmit"}, + {"label": "Duplex", "value": "duplex"}, + {"label": "Maintenance", "value": "maintenance"} + ] + } } ``` @@ -525,11 +609,11 @@ enum class -> select ADMINIVE_FIELD_LABEL(T, effective_date, "Effective Date") .editable() .required() - .widget("input-date"); + .date_input(); ADMINIVE_FIELD_LABEL(T, accent_color, "Accent Color") .editable() .required() - .widget("input-color"); + .color_input(); ``` 日期控件自动补充: @@ -610,6 +694,7 @@ resource.register_status<&Radio_State::status>(2000); GET /admin/amis GET /admin/status GET /admin/radio_states/descriptor +GET /admin/radio_states/view GET /admin/radio_states/status/descriptor GET /admin/radio_states/amis GET /admin/radio_states @@ -620,10 +705,12 @@ PUT /admin/radio_states/{id} PATCH /admin/radio_states/{id} DELETE /admin/radio_states/{id} GET /admin/config/radio/descriptor +GET /admin/config/radio/view GET /admin/config/radio/data GET /admin/config/radio/amis POST /admin/config/radio/data GET /admin/config/alerts/descriptor +GET /admin/config/alerts/view GET /admin/config/alerts/data GET /admin/config/alerts/amis POST /admin/config/alerts/data @@ -631,6 +718,8 @@ POST /admin/config/alerts/data ## 前端 +默认前端仍然只需要读取 `/admin/amis` 并渲染最终页面;`/descriptor`、`/view` 和 `/data` 是稳定的后端语义接口,供自定义前端、调试器或其他 Adapter 直接消费。业务字段名单、表格列和组合关系都不在前端硬编码。 + 创建外部 `node_modules` 相对符号链接: ```powershell diff --git a/backend/library/DESIGN.md b/backend/library/DESIGN.md index ebadb0a..c75b6f3 100644 --- a/backend/library/DESIGN.md +++ b/backend/library/DESIGN.md @@ -6,14 +6,23 @@ Adminive 的目标不是重新发明对象模型、反射系统或属性系统,而是把后端已经存在的 C++ 结构投影成后台管理能力。 -可以把两层职责概括为: +可以把职责分成结构层、字段呈现层、视图组合层和最终渲染层: ```text -Structive +Structive / Object Schema 描述并管理“这个 C++ 对象本身是什么” - -Adminive - 描述“后台管理系统应该怎样展示、编辑、传输和提交这个对象” + │ + ▼ +Field Presentation + 描述“单个字段应该以什么语义控件呈现” + │ + ▼ +View Schema / Composition + 描述“字段和业务组件如何组成表单、表格和页面” + │ + ▼ +AMIS Adapter / Frontend + 把语义结构翻译成具体 UI,并负责纯视觉与响应式细节 ``` 因此 Adminive 更接近一个 backend-driven administration projection layer,而不是一个新的 Property Core。 @@ -27,8 +36,10 @@ C++ business type Adminive descriptor │ ├── JSON encode / decode - ├── frontend schema - ├── AMIS CRUD / form / table + ├── field presentation + ├── form / table view schema + ├── page composition + ├── AMIS adapter ├── HTTP resource service ├── validation / error path ├── managed update @@ -98,16 +109,17 @@ managed read 走零锁路径 Adminive 负责后台管理领域信息和适配: ```text -字段名称与显示名称 +字段名称与 Field Presentation editable / creatable sensitive required -widget -visible / visible_on -list column metadata -排序信息 +control / visible_on +Form View / Table View +vertical / horizontal / flow / grid / group / card / tabs +表格列、排序、搜索、筛选、固定列与重排语义 +Composition View / Slot JSON 描述协议 -AMIS schema +AMIS adapter HTTP resource semantics frontend patch Object_Adapter @@ -145,17 +157,22 @@ Adminive 字段同时包含两类信息,但这两类信息不能混为一谈 字段 accessor ``` -第二类是后台领域元数据: +第二类是后台领域信息,但它本身还必须继续分层: ```text -是否允许前端编辑 -是否允许创建时输入 -是否在列表中展示 -是否敏感 -使用什么 widget -显示什么 label +Field Presentation + label / description / control / options / visible_on + +View Schema + 字段选择 / 顺序 / group / card / grid / flow / tabs + table columns / sort / search / filter / fixed / reorder + +Composition View + Form / Table / Status 等完整组件之间的页面组合 ``` +字段自身的呈现信息不能携带“它在某个表格里排第几列”这种视图语义;同一个 Property 可以同时出现在 Edit View、Detail View 和多个 Table View 中,而不需要重新定义字段本身。 + 例如: ```cpp @@ -181,7 +198,129 @@ managed writable 这条边界必须长期保持。 -## 5. 默认只读,显式声明可写 +## 5. 后端 UI 的分层模型 + +Adminive 的目标是让后端修改 C++ 结构和后台描述后,前端无需同步维护业务字段清单即可得到新的界面。因此后端必须拥有页面的**语义结构**,但不应该把所有语义都塞进 Field Descriptor,也不应该直接把 AMIS JSON 当成业务描述语言。 + +### 5.1 Object Schema:字段是什么 + +Object Schema 由 Structive 与 Adminive Descriptor 共同建立,负责字段名、真实 C++ 类型、intrinsic writable、constraint、managed synchronization 等结构事实。这一层不知道表格、Card、Tabs 或 AMIS。 + +### 5.2 Field Presentation:一个字段怎么呈现 + +`Field_Presentation` 只描述单字段呈现: + +```text +label +description +control +visible_on +enum labels/options +``` + +`control` 使用 `text / multiline_text / number / boolean / select / date / color` 等语义类型,而不是把 `input-text`、`input-date` 之类 AMIS 组件名写进结构描述。AMIS Adapter 再负责语义控件到 AMIS 组件的映射。 + +### 5.3 View Schema:这些字段怎么组成一个业务视图 + +Form View 可以组合: + +```text +all_fields +field +vertical +horizontal +flow +grid +group +card +tabs / tab +``` + +后端因此仍然可以完整决定“这十几个字段如何拼接”,但组合关系不再污染字段定义。一个类型可以分别拥有 Edit/Create/Detail View。 + +Table View 是一等视图,而不是普通 Field Presentation 的附属标记。它负责: + +```text +哪些字段成为列 +列顺序与标题 +fixed left/right +sortable +searchable +filterable +default sort +row reorder +create/edit form layout +``` + +这里的查询能力同时约束后端 Collection Service。前端不能因为自己画了一个搜索框,就让一个未声明为 searchable/filterable 的字段获得后端查询能力。 + +### 5.4 Composition View:完整业务组件怎么组成页面 + +当页面需要组合已经成型的 Form、Table、Status 或其他业务组件时,使用独立 `Composition_View`。它提供: + +```text +slot +heading +vertical +horizontal +flow +grid +group +card +tabs / tab +``` + +`slot` 只是引用一个已经生成的业务组件。例如“上方配置表单 + 下方设备表格”可以由后端描述为一个 vertical composition,而不需要业务代码直接拼 AMIS `grid/card/tpl` JSON。 + +### 5.5 后端与前端的最终边界 + +后端负责: + +```text +字段存在与否 +字段 intrinsic capability +单字段 presentation +字段属于哪个视图 +字段顺序与逻辑分组 +form/table/page 的语义组合 +表格排序、搜索、筛选等后端能力 +``` + +前端负责: + +```text +具体 renderer +CSS / theme +响应式断点 +最终列宽和像素级间距 +设备尺寸下的视觉降级 +``` + +因此核心原则是: + +> **后端拥有页面的语义结构,前端拥有页面的视觉实现。** + +这样既满足“删字段、加字段、改配置只改后端”,又避免后端被某个具体前端框架的 CSS/Grid 实现锁死。 + +### 5.6 AMIS 只是 Adapter + +依赖方向必须保持: + +```text +Object Schema + ↓ +Field Presentation + ↓ +View Schema / Composition + ↓ +AMIS Adapter + ↓ +AMIS JSON +``` + +不能反过来让 `Field_Descriptor` 直接保存任意 AMIS JSON;否则 Adminive 会从 backend-driven schema 退化成“在 C++ 里写前端 JSON”。 + +## 6. 默认只读,显式声明可写 Adminive 字段默认是 intrinsic read-only。 @@ -215,9 +354,9 @@ Adminive 字段默认是 intrinsic read-only。 同步、事务和适配器可以据此建立正确行为。 -## 6. 四种字段声明语义 +## 7. 四种字段声明语义 -### 6.1 普通字段 +### 7.1 普通字段 ```cpp ADMINIVE_FIELD(Config, id) @@ -233,7 +372,7 @@ intrinsic read-only 它仍然是普通 C++ 成员。直接修改 raw member 属于 raw C++ path,不属于 Adminive/Structive managed contract。 -### 6.2 `.editable()` +### 7.2 `.editable()` ```cpp ADMINIVE_FIELD(Config, worker_count).editable() @@ -249,7 +388,7 @@ frontend update editable 这是后台表单最常见的可编辑字段。 -### 6.3 `.creatable()` +### 7.3 `.creatable()` ```cpp ADMINIVE_FIELD(Device, address).creatable() @@ -265,7 +404,7 @@ intrinsic read-write 它表达创建流程的领域能力,不等同于普通 update editable。 -### 6.4 `.read_write()` +### 7.4 `.read_write()` ```cpp ADMINIVE_FIELD(Config, backend_state).read_write() @@ -281,7 +420,7 @@ intrinsic read-write 适合配置加载、后台代码、服务内部更新等受控路径。 -### 6.5 `.unsynchronized()` +### 7.5 `.unsynchronized()` ```cpp ADMINIVE_FIELD(Config, atomic_counter).read_write().unsynchronized() @@ -305,7 +444,7 @@ intrinsic writable `.unsynchronized()` 不是性能开关,更不是“我觉得这里应该没事”。它是对同步责任的显式转移。 -## 7. Adminive 不做访问控制系统 +## 8. Adminive 不做访问控制系统 Adminive 中的 `editable`、`readable`、`sensitive` 等信息服务于具体后台适配语义,但它们不构成一个通用安全边界。 @@ -343,7 +482,7 @@ persistence `sensitive()` 可以指导 Adminive JSON/HTTP 等适配器避免泄漏敏感内容,但不应被误解为 C++ 内存级权限机制。 -## 8. Managed path 与 raw C++ path +## 9. Managed path 与 raw C++ path Adminive 不试图取代普通 C++ 对象。 @@ -380,7 +519,7 @@ managed path 才承诺: > 增强原生 C++ 结构,而不是建立一堵无法绕过的对象墙。 -## 9. 为什么 Managed 回调不能泄漏引用 +## 10. 为什么 Managed 回调不能泄漏引用 Managed read/write 的锁只在回调作用域中有效。 @@ -412,7 +551,7 @@ ranges view 这不是 API 限制,而是锁生命周期本身决定的安全边界。 -## 10. 同步模型:只保护 mutable consistency +## 11. 同步模型:只保护 mutable consistency Structive synchronization 只解决一个问题: @@ -435,7 +574,7 @@ HTTP 权限 这种选择应该由一致性需求驱动,而不是为了“简单”把所有对象默认放到一把全局锁下。 -## 11. 嵌套结构的同步原则 +## 12. 嵌套结构的同步原则 嵌套结构不能简单采用“父对象只要有一个 writable 子字段,所有后代访问都锁父对象”的粗粒度规则。 @@ -456,7 +595,7 @@ unsynchronized writable leaf 同步边界来自真实 mutation possibility,而不是单纯来自对象树层级。 -## 12. Resource_Service 的职责 +## 13. Resource_Service 的职责 `Resource_Service` 是 Adminive 的应用事务编排层,不是新的 Property Core。 @@ -477,11 +616,11 @@ commit external side effect `Resource_Service` 不应该再创建一棵重复的 per-field mutex 系统。 -## 13. 三种“事务”不能混为一谈 +## 14. 三种“事务”不能混为一谈 Adminive 中至少存在三类不同一致性问题: -### 13.1 内存对象同步 +### 14.1 内存对象同步 由 Structive synchronization 负责。 @@ -491,7 +630,7 @@ lock slot synchronization topology ``` -### 13.2 Adminive 外部副作用事务 +### 14.2 Adminive 外部副作用事务 由 `Resource_Transaction` 负责。 @@ -503,7 +642,7 @@ rollback 它可以代表配置文件、设备、远端服务等外部动作。 -### 13.3 数据库事务 +### 14.3 数据库事务 由数据库自身负责。 @@ -511,7 +650,7 @@ rollback 三者可以在一次业务流程中协作,但职责必须保持独立。 -## 14. Adapter First +## 15. Adapter First Adminive Core 不应直接绑定具体第三方库。 @@ -538,7 +677,7 @@ HTTP adapter 如果未来接入新的 JSON/HTTP/Reflection 实现,应优先增加 Adapter,而不是修改 Adminive 的中心算法。 -## 15. Object_Adapter:运行时对象与配置模型分离 +## 16. Object_Adapter:运行时对象与配置模型分离 不是所有运行时对象都适合作为可复制配置对象。 @@ -575,7 +714,7 @@ frontend schema 这比要求所有业务类型变成“为了后台框架而设计的 DTO”更符合低侵入原则。 -## 16. Frontend 应保持被动 +## 17. Frontend 应保持被动 Adminive 是 backend-driven 系统,因此前端不应该重新维护: @@ -601,7 +740,7 @@ CRUD schema 前端可以拥有纯 UI 行为,但不应该成为第二份业务 Schema。 -## 17. 编译期与运行期的分工 +## 18. 编译期与运行期的分工 Adminive 建议遵守以下规则: @@ -626,7 +765,7 @@ Adminive 建议遵守以下规则: 更好的优化机会 ``` -## 18. Metadata 必须具有真实语义 +## 19. Metadata 必须具有真实语义 对 Adminive 来说,metadata 不应该只是“最终转成 JSON 的标签集合”。 @@ -649,13 +788,16 @@ sensitive required → 改变输入验证 -widget - → 改变前端控件 +Field Presentation control + → 改变单字段呈现控件 + +View Schema + → 改变字段组合、表格查询能力和页面语义结构 ``` 如果新增 metadata 没有明确消费者,应谨慎加入 Core descriptor,避免逐渐变成无边界的“万能标签袋”。 -## 19. Core 的非目标 +## 20. Core 的非目标 Adminive Core 不应该演化成: @@ -674,11 +816,11 @@ Structive 的替代品 保持非目标清晰,是长期保持 API 简洁的重要手段。 -## 20. 新功能应该放在哪里 +## 21. 新功能应该放在哪里 新增能力时,可以按下面的判断顺序决定归属。 -### 20.1 它描述 Property 本身吗? +### 21.1 它描述 Property 本身吗? 例如: @@ -690,21 +832,22 @@ generic constraint 优先考虑 Structive。 -### 20.2 它描述后台管理领域吗? +### 21.2 它描述后台管理领域吗? 例如: ```text editable -widget -list column -AMIS schema +Field Presentation +Form/Table View +Composition View +AMIS adapter frontend visible condition ``` 属于 Adminive。 -### 20.3 它绑定具体第三方库吗? +### 21.3 它绑定具体第三方库吗? 例如: @@ -718,7 +861,7 @@ Boost.PFR 放 Adapter/bridge 层。 -### 20.4 它属于具体业务项目吗? +### 21.4 它属于具体业务项目吗? 例如: @@ -730,7 +873,7 @@ Boost.PFR 留在业务层,不进入 Adminive Core。 -## 21. API 演进原则 +## 22. API 演进原则 后续修改 Adminive API 时建议遵守: @@ -745,13 +888,13 @@ Boost.PFR 9. 高级能力允许高级 API,但应与普通使用路径分层。 10. 对行为语义的修改必须同时补契约测试和文档。 -## 22. 测试原则 +## 23. 测试原则 Adminive 的测试不应只验证“JSON 长得对”。 -至少应该覆盖四类契约: +至少应该覆盖五类契约: -### 22.1 Descriptor contract +### 23.1 Descriptor contract ```text 字段 metadata @@ -761,7 +904,18 @@ nested descriptor schema validation ``` -### 22.2 Managed synchronization contract +### 23.2 View contract + +```text +Field Presentation 与结构事实分离 +Form View 的 group/card/grid/flow/tabs 组合 +Table View 的列选择与顺序 +sortable/searchable/filterable/fixed 的后端能力约束 +Composition View 的 slot 与跨组件组合 +/view JSON 与 AMIS Adapter 的语义一致性 +``` + +### 23.3 Managed synchronization contract ```text read-only zero-lock @@ -772,7 +926,7 @@ callback lifetime multi-thread blocking relationship ``` -### 22.3 Adapter contract +### 23.4 Adapter contract ```text JSON encode/decode @@ -780,9 +934,10 @@ Enum Adapter Reflection Adapter Object Adapter HTTP Adapter +AMIS Adapter ``` -### 22.4 Transaction contract +### 23.5 Transaction contract ```text prepare failure @@ -795,7 +950,7 @@ Config Store persistence 这些测试保护的是设计边界,而不仅是当前实现细节。 -## 23. 设计判断清单 +## 24. 设计判断清单 在提交一个新设计前,可以快速检查: @@ -803,6 +958,7 @@ Config Store persistence [ ] 这是 Structive 已经解决的问题吗? [ ] 这是 Adminive 后台领域真正需要的概念吗? [ ] 它是否把第三方库耦合进 Core? +[ ] 它是否把 Field Presentation、View Schema 和 AMIS Adapter 混成一层? [ ] 它是否让 metadata 真正改变行为? [ ] 它是否把访问控制错误塞进 Property Core? [ ] 它是否创建了第二套同步或事务机制? @@ -814,34 +970,44 @@ Config Store persistence 如果多数问题无法明确回答,通常意味着抽象边界还没有收敛。 -## 24. 最终架构图 +## 25. 最终架构图 ```text C++ Business Types │ ▼ - Adminive Descriptor - │ - ┌─────────────────┴─────────────────┐ - │ │ - ▼ ▼ - Adminive domain metadata Structive schema - editable/widget/sensitive/... capability/synchronization - │ │ - └─────────────────┬─────────────────┘ - ▼ - Managed object view - │ - ┌─────────────────────┼─────────────────────┐ - ▼ ▼ ▼ - JSON Adapter Resource Service AMIS Schema - │ │ │ - ▼ ▼ ▼ - serialization HTTP transaction Frontend + Structive / Object Schema + type / capability / synchronization │ ▼ - Resource_Transaction - prepare/commit/rollback + Adminive Field Descriptor + editable / required / sensitive + │ + ▼ + Field Presentation Layer + label / control / options / visible + │ + ┌──────────────┴──────────────┐ + ▼ ▼ + Form / Detail View Table View + group/card/grid/flow/tabs columns/sort/search/filter + │ │ + └──────────────┬──────────────┘ + ▼ + Composition View + Form/Table/Status slots + page layout + │ + ┌─────────────────┼─────────────────┐ + ▼ ▼ ▼ + View JSON Resource Service AMIS Adapter + │ + ▼ + Frontend + │ + renderer/theme/responsive layout + +JSON / HTTP / Enum / Reflection / AMIS 都位于消费或 Adapter 层; +Structive 和字段结构层不知道具体前端框架。 ``` 最重要的依赖方向始终是: diff --git a/backend/library/include/adminive/adminive.hpp b/backend/library/include/adminive/adminive.hpp index 0c37047..a325490 100644 --- a/backend/library/include/adminive/adminive.hpp +++ b/backend/library/include/adminive/adminive.hpp @@ -2,10 +2,14 @@ #include "adminive/adapter.hpp" #include "adminive/amis.hpp" #include "adminive/collection.hpp" +#include "adminive/composition.hpp" #include "adminive/concepts.hpp" #include "adminive/descriptor.hpp" #include "adminive/http.hpp" #include "adminive/json.hpp" #include "adminive/status.hpp" +#include "adminive/view.hpp" +#include "adminive/view_json.hpp" #include "adminive/managed.hpp" +#include "adminive/presentation.hpp" #include "adminive/value.hpp" diff --git a/backend/library/include/adminive/amis.hpp b/backend/library/include/adminive/amis.hpp index 1bb5d09..100e404 100644 --- a/backend/library/include/adminive/amis.hpp +++ b/backend/library/include/adminive/amis.hpp @@ -1,7 +1,9 @@ #pragma once #include "adminive/status.hpp" +#include "adminive/view_json.hpp" #include #include +#include #include #include #include @@ -47,9 +49,18 @@ inline std::string resolve_visible_on(std::string expression, std::string_view p return expression; } template +const Json& field_presentation(const Json& field) { + return Json_Adapter::at(field, "presentation"); +} +template +std::string field_label(const Json& field) { + return json_get(Json_Adapter::at(field_presentation(field), "label")); +} +template void apply_visible_on(Json& control, const Json& field, std::string_view prefix) { - if(Json_Adapter::contains(field, "visible_on")) { - json_set(control, "visibleOn", resolve_visible_on(json_get(Json_Adapter::at(field, "visible_on")), prefix)); + const auto& presentation = field_presentation(field); + if(Json_Adapter::contains(presentation, "visible_on")) { + json_set(control, "visibleOn", resolve_visible_on(json_get(Json_Adapter::at(presentation, "visible_on")), prefix)); } } template @@ -99,12 +110,12 @@ Json make_amis_polymorphic_control(const Json& field, std::string_view permissio } Json result = json_object(); json_set(result, "type", "fieldset"); - json_set(result, "title", Json_Adapter::at(field, "label")); + json_set(result, "title", Json_Adapter::at(field_presentation(field), "label")); json_set(result, "body", std::move(body)); json_set(result, "collapsable", true); json_set(result, "collapsed", false); - if(Json_Adapter::contains(field, "description")) { - json_set(result, "description", Json_Adapter::at(field, "description")); + if(Json_Adapter::contains(field_presentation(field), "description")) { + json_set(result, "description", Json_Adapter::at(field_presentation(field), "description")); } apply_visible_on(result, field, prefix); return result; @@ -138,16 +149,55 @@ Json make_typed_amis_polymorphic_control(const Json& field, std::string_view per }, Adapter::variants()); Json result = json_object(); json_set(result, "type", "fieldset"); - json_set(result, "title", Json_Adapter::at(field, "label")); + json_set(result, "title", Json_Adapter::at(field_presentation(field), "label")); json_set(result, "body", std::move(body)); json_set(result, "collapsable", true); json_set(result, "collapsed", false); - if(Json_Adapter::contains(field, "description")) { - json_set(result, "description", Json_Adapter::at(field, "description")); + if(Json_Adapter::contains(field_presentation(field), "description")) { + json_set(result, "description", Json_Adapter::at(field_presentation(field), "description")); } apply_visible_on(result, field, prefix); return result; } +inline std::string_view amis_control_type(std::string_view control, std::string_view value_type) { + if(control == "text") { + return "input-text"; + } + if(control == "multiline_text") { + return "textarea"; + } + if(control == "number") { + return "input-number"; + } + if(control == "boolean") { + return "switch"; + } + if(control == "select") { + return "select"; + } + if(control == "date") { + return "input-date"; + } + if(control == "color") { + return "input-color"; + } + if(control != "automatic") { + throw std::invalid_argument("unknown field control: " + std::string(control)); + } + if(value_type == "boolean") { + return "switch"; + } + if(value_type == "integer" || value_type == "number") { + return "input-number"; + } + if(value_type == "enum") { + return "select"; + } + if(value_type == "string") { + return "input-text"; + } + return {}; +} template Json make_default_amis_control(const Json& field, std::string_view permission, std::string_view prefix = {}) { const std::string name = json_get(Json_Adapter::at(field, "name")); @@ -158,12 +208,12 @@ Json make_default_amis_control(const Json& field, std::string_view permission, s json_set(descriptor, "fields", Json_Adapter::at(field, "children")); Json control = json_object(); json_set(control, "type", "fieldset"); - json_set(control, "title", Json_Adapter::at(field, "label")); + json_set(control, "title", Json_Adapter::at(field_presentation(field), "label")); json_set(control, "body", make_amis_form_body(descriptor, permission, full_name)); json_set(control, "collapsable", true); json_set(control, "collapsed", false); - if(Json_Adapter::contains(field, "description")) { - json_set(control, "description", Json_Adapter::at(field, "description")); + if(Json_Adapter::contains(field_presentation(field), "description")) { + json_set(control, "description", Json_Adapter::at(field_presentation(field), "description")); } apply_visible_on(control, field, prefix); return control; @@ -173,37 +223,29 @@ Json make_default_amis_control(const Json& field, std::string_view permission, s } Json control = json_object(); json_set(control, "name", full_name); - json_set(control, "label", Json_Adapter::at(field, "label")); - if(Json_Adapter::contains(field, "description")) { - json_set(control, "description", Json_Adapter::at(field, "description")); + json_set(control, "label", Json_Adapter::at(field_presentation(field), "label")); + if(Json_Adapter::contains(field_presentation(field), "description")) { + json_set(control, "description", Json_Adapter::at(field_presentation(field), "description")); } apply_visible_on(control, field, prefix); if(!json_get(Json_Adapter::at(field, permission))) { json_set(control, "type", "static"); return control; } - if(Json_Adapter::contains(field, "widget")) { - json_set(control, "type", Json_Adapter::at(field, "widget")); - } else if(value_type == "boolean") { - json_set(control, "type", "switch"); - } else if(value_type == "integer" || value_type == "number") { - json_set(control, "type", "input-number"); - } else if(value_type == "enum") { - json_set(control, "type", "select"); - } else if(value_type == "string") { - json_set(control, "type", "input-text"); - } else { - throw std::invalid_argument("field '" + full_name + "' requires an explicit widget or Control_Adapter"); + const auto control_type = amis_control_type(json_get(Json_Adapter::at(field_presentation(field), "control")), value_type); + if(control_type.empty()) { + throw std::invalid_argument("field '" + full_name + "' requires an explicit field control or Control_Adapter"); } - const std::string control_type = json_get(Json_Adapter::at(control, "type")); + json_set(control, "type", std::string(control_type)); + const std::string resolved_control_type = json_get(Json_Adapter::at(control, "type")); if(Json_Adapter::contains(field, "nullable") && json_get(Json_Adapter::at(field, "nullable"))) { json_set(control, "clearable", true); } - if(control_type == "input-date") { + if(resolved_control_type == "input-date") { json_set(control, "valueFormat", "YYYY-MM-DD"); json_set(control, "displayFormat", "YYYY-MM-DD"); json_set(control, "clearable", true); - } else if(control_type == "input-color") { + } else if(resolved_control_type == "input-color") { json_set(control, "clearable", true); } if(json_get(Json_Adapter::at(field, "required"))) { @@ -223,8 +265,8 @@ Json make_default_amis_control(const Json& field, std::string_view permission, s json_set(errors, "isInt", Json_Adapter::at(field, "validation_message")); json_set(control, "validationErrors", std::move(errors)); } - if(Json_Adapter::contains(field, "options")) { - json_set(control, "options", Json_Adapter::at(field, "options")); + if(Json_Adapter::contains(field_presentation(field), "options")) { + json_set(control, "options", Json_Adapter::at(field_presentation(field), "options")); } return control; } @@ -253,12 +295,12 @@ Json make_typed_amis_control(const Json& field, std::string_view permission, std const Json descriptor = to_descriptor_json(); Json control = json_object(); json_set(control, "type", "fieldset"); - json_set(control, "title", Json_Adapter::at(field, "label")); + json_set(control, "title", Json_Adapter::at(field_presentation(field), "label")); json_set(control, "body", make_typed_amis_form_body(descriptor, permission, full_name)); json_set(control, "collapsable", true); json_set(control, "collapsed", false); - if(Json_Adapter::contains(field, "description")) { - json_set(control, "description", Json_Adapter::at(field, "description")); + if(Json_Adapter::contains(field_presentation(field), "description")) { + json_set(control, "description", Json_Adapter::at(field_presentation(field), "description")); } apply_visible_on(control, field, prefix); return control; @@ -272,9 +314,7 @@ Json make_amis_form_body(const Json& descriptor, std::string_view permission, st const auto& fields = Json_Adapter::at(descriptor, "fields"); for(std::size_t index = 0; index < Json_Adapter::size(fields); ++index) { const auto& field = Json_Adapter::at(fields, index); - if(json_get(Json_Adapter::at(field, "visible"))) { - json_append(body, make_default_amis_control(field, permission, prefix)); - } + json_append(body, make_default_amis_control(field, permission, prefix)); } return body; } @@ -284,10 +324,8 @@ void append_typed_amis_form_fields(Json& body, const Json& fields, const Descrip if constexpr(Index < std::tuple_size_v) { const auto& item = std::get(descriptor.fields()); const auto& field = Json_Adapter::at(fields, Index); - if(json_get(Json_Adapter::at(field, "visible"))) { - using Field = std::remove_cvref_t; - json_append(body, make_typed_amis_control(field, permission, prefix)); - } + using Field = std::remove_cvref_t; + json_append(body, make_typed_amis_control(field, permission, prefix)); append_typed_amis_form_fields(body, fields, descriptor, permission, prefix); } } @@ -303,20 +341,14 @@ template Json make_default_amis_column(const Json& field) { Json column = json_object(); json_set(column, "name", Json_Adapter::at(field, "name")); - json_set(column, "label", Json_Adapter::at(field, "list_label")); - if(Json_Adapter::contains(field, "order")) { - json_set(column, "order", Json_Adapter::at(field, "order")); - } - if(Json_Adapter::contains(field, "sortable")) { - json_set(column, "sortable", Json_Adapter::at(field, "sortable")); - } + json_set(column, "label", Json_Adapter::at(field_presentation(field), "label")); const std::string value_type = json_get(Json_Adapter::at(field, "value_type")); if(value_type == "boolean") { json_set(column, "type", "status"); - } else if(value_type == "enum" && Json_Adapter::contains(field, "options")) { + } else if(value_type == "enum" && Json_Adapter::contains(field_presentation(field), "options")) { json_set(column, "type", "mapping"); Json map = json_object(); - const auto& options = Json_Adapter::at(field, "options"); + const auto& options = Json_Adapter::at(field_presentation(field), "options"); for(std::size_t index = 0; index < Json_Adapter::size(options); ++index) { const auto& option = Json_Adapter::at(options, index); json_set(map, json_get(Json_Adapter::at(option, "value")), Json_Adapter::at(option, "label")); @@ -340,31 +372,316 @@ Json make_typed_amis_column(const Json& field) { return make_default_amis_column(field); } } -template -struct Amis_Ordered_Column { - int order{}; - Json column; -}; -template -void append_typed_amis_columns(std::vector>& columns, const Json& fields, const Descriptor& descriptor) { +template +Json make_typed_amis_control_by_name(const Json& descriptor_json, const Descriptor& descriptor, std::string_view field_name, std::string_view permission, std::string_view prefix) { using Fields = std::remove_cvref_t; if constexpr(Index < std::tuple_size_v) { const auto& item = std::get(descriptor.fields()); - const auto& field = Json_Adapter::at(fields, Index); - using Field = std::remove_cvref_t; - using Member = typename Field::member_type; - using Storage = std::remove_cvref_t; - using Value = Adapted_Value_Type; - using Control_Value = Optional_Unwrapped_Type; - constexpr bool custom_column = Control_Adapter_With_Column || (!std::same_as && Control_Adapter_With_Column) || (!std::same_as && Control_Adapter_With_Column); - const std::string value_type = json_get(Json_Adapter::at(field, "value_type")); - const bool structural = value_type == "object" || value_type == "array" || value_type == "map" || value_type == "polymorphic"; - if(json_get(Json_Adapter::at(field, "readable")) && json_get(Json_Adapter::at(field, "list_visible")) && (!structural || custom_column)) { - columns.push_back(Amis_Ordered_Column{json_get(Json_Adapter::at(field, "order")), make_typed_amis_column(field)}); + if(item.name() == field_name) { + using Field = std::remove_cvref_t; + return make_typed_amis_control(Json_Adapter::at(Json_Adapter::at(descriptor_json, "fields"), Index), permission, prefix); } - append_typed_amis_columns(columns, fields, descriptor); + return make_typed_amis_control_by_name(descriptor_json, descriptor, field_name, permission, prefix); + } else { + throw std::invalid_argument("view field does not exist: " + std::string(field_name)); } } +template +Json make_typed_amis_column_by_name(const Json& descriptor_json, const Descriptor& descriptor, std::string_view field_name) { + using Fields = std::remove_cvref_t; + if constexpr(Index < std::tuple_size_v) { + const auto& item = std::get(descriptor.fields()); + if(item.name() == field_name) { + using Field = std::remove_cvref_t; + return make_typed_amis_column(Json_Adapter::at(Json_Adapter::at(descriptor_json, "fields"), Index)); + } + return make_typed_amis_column_by_name(descriptor_json, descriptor, field_name); + } else { + throw std::invalid_argument("table column field does not exist: " + std::string(field_name)); + } +} +inline std::string_view form_permission(Form_Mode mode) noexcept { + switch(mode) { + case Form_Mode::display: + return "readable"; + case Form_Mode::edit: + return "editable"; + case Form_Mode::create: + return "creatable"; + } + return "readable"; +} +template +void apply_view_node_options(Json& result, const View_Node& node, std::string_view prefix) { + if(!node.description.empty()) { + json_set(result, "description", node.description); + } + if(!node.visible_on.empty()) { + json_set(result, "visibleOn", resolve_visible_on(node.visible_on, prefix)); + } +} +template +Json make_amis_view_body(const View_Node& node, const Json& descriptor_json, std::string_view permission, std::string_view prefix); +template +Json make_amis_view_node(const View_Node& node, const Json& descriptor_json, std::string_view permission, std::string_view prefix) { + const auto descriptor = describe(); + if(node.kind == View_Node_Kind::field) { + return make_typed_amis_control_by_name(descriptor_json, descriptor, node.field, permission, prefix); + } + if(node.kind == View_Node_Kind::group) { + Json result = json_object(); + json_set(result, "type", "fieldset"); + json_set(result, "title", node.title); + json_set(result, "body", make_amis_view_body(View_Node{View_Node_Kind::vertical, {}, {}, {}, {}, 0, false, false, node.children}, descriptor_json, permission, prefix)); + if(node.collapsible) { + json_set(result, "collapsable", true); + json_set(result, "collapsed", node.collapsed); + } + apply_view_node_options(result, node, prefix); + return result; + } + if(node.kind == View_Node_Kind::card) { + Json result = json_object(); + json_set(result, "type", "card"); + if(!node.title.empty()) { + Json header = json_object(); + json_set(header, "title", node.title); + json_set(result, "header", std::move(header)); + } + json_set(result, "body", make_amis_view_body(View_Node{View_Node_Kind::vertical, {}, {}, {}, {}, 0, false, false, node.children}, descriptor_json, permission, prefix)); + apply_view_node_options(result, node, prefix); + return result; + } + if(node.kind == View_Node_Kind::horizontal || node.kind == View_Node_Kind::grid) { + Json columns = json_array(); + const std::size_t count = node.kind == View_Node_Kind::grid && node.columns != 0 ? node.columns : std::max(1, node.children.size()); + const std::size_t width = std::max(1, 12 / count); + for(const auto& child : node.children) { + Json column = json_object(); + json_set(column, "md", width); + json_set(column, "body", make_amis_view_body(child, descriptor_json, permission, prefix)); + json_append(columns, std::move(column)); + } + Json result = json_object(); + json_set(result, "type", "grid"); + json_set(result, "columns", std::move(columns)); + apply_view_node_options(result, node, prefix); + return result; + } + if(node.kind == View_Node_Kind::flow) { + Json items = json_array(); + for(const auto& child : node.children) { + Json item = json_object(); + json_set(item, "body", make_amis_view_body(child, descriptor_json, permission, prefix)); + json_append(items, std::move(item)); + } + Json result = json_object(); + json_set(result, "type", "flex"); + json_set(result, "items", std::move(items)); + apply_view_node_options(result, node, prefix); + return result; + } + if(node.kind == View_Node_Kind::tabs) { + Json tab_items = json_array(); + for(const auto& child : node.children) { + Json item = json_object(); + json_set(item, "title", child.title); + json_set(item, "body", make_amis_view_body(View_Node{View_Node_Kind::vertical, {}, {}, {}, {}, 0, false, false, child.children}, descriptor_json, permission, prefix)); + if(!child.visible_on.empty()) { + json_set(item, "visibleOn", resolve_visible_on(child.visible_on, prefix)); + } + json_append(tab_items, std::move(item)); + } + Json result = json_object(); + json_set(result, "type", "tabs"); + json_set(result, "tabs", std::move(tab_items)); + apply_view_node_options(result, node, prefix); + return result; + } + if(node.kind == View_Node_Kind::tab) { + throw std::invalid_argument("tab view can only be used inside tabs"); + } + Json result = json_object(); + json_set(result, "type", "container"); + json_set(result, "body", make_amis_view_body(View_Node{View_Node_Kind::vertical, {}, {}, {}, {}, 0, false, false, node.children}, descriptor_json, permission, prefix)); + apply_view_node_options(result, node, prefix); + return result; +} +template +void append_amis_view_node(Json& body, const View_Node& node, const Json& descriptor_json, std::string_view permission, std::string_view prefix) { + if(node.kind == View_Node_Kind::all_fields) { + Json fields = make_typed_amis_form_body(descriptor_json, permission, prefix); + for(std::size_t index = 0; index < Json_Adapter::size(fields); ++index) { + json_append(body, Json_Adapter::at(fields, index)); + } + return; + } + if(node.kind == View_Node_Kind::vertical) { + for(const auto& child : node.children) { + append_amis_view_node(body, child, descriptor_json, permission, prefix); + } + return; + } + json_append(body, make_amis_view_node(node, descriptor_json, permission, prefix)); +} +template +Json make_amis_view_body(const View_Node& node, const Json& descriptor_json, std::string_view permission, std::string_view prefix) { + Json result = json_array(); + append_amis_view_node(result, node, descriptor_json, permission, prefix); + return result; +} +template +void apply_composition_node_options(Json& result, const Composition_Node& node) { + if(!node.description.empty()) { + json_set(result, "description", node.description); + } + if(!node.visible_on.empty()) { + json_set(result, "visibleOn", node.visible_on); + } +} +template +Json make_amis_composition_body(const Composition_Node& node, const std::map& slots); +template +Json make_amis_composition_node(const Composition_Node& node, const std::map& slots) { + if(node.kind == Composition_Node_Kind::slot) { + const auto iterator = slots.find(node.slot); + if(iterator == slots.end()) { + throw std::invalid_argument("composition slot is not provided: " + node.slot); + } + return iterator->second; + } + if(node.kind == Composition_Node_Kind::heading) { + Json result = json_object(); + json_set(result, "type", "tpl"); + std::string html = "

" + node.title + "

"; + json_set(result, "tpl", std::move(html)); + apply_composition_node_options(result, node); + return result; + } + if(node.kind == Composition_Node_Kind::group) { + Json result = json_object(); + json_set(result, "type", "panel"); + json_set(result, "title", node.title); + json_set(result, "body", make_amis_composition_body(Composition_Node{Composition_Node_Kind::vertical, {}, {}, {}, {}, {}, 0, false, false, node.children}, slots)); + if(node.collapsible) { + json_set(result, "collapsable", true); + json_set(result, "collapsed", node.collapsed); + } + apply_composition_node_options(result, node); + return result; + } + if(node.kind == Composition_Node_Kind::card) { + Json result = json_object(); + json_set(result, "type", "card"); + if(!node.title.empty()) { + Json header = json_object(); + json_set(header, "title", node.title); + json_set(result, "header", std::move(header)); + } + json_set(result, "body", make_amis_composition_body(Composition_Node{Composition_Node_Kind::vertical, {}, {}, {}, {}, {}, 0, false, false, node.children}, slots)); + apply_composition_node_options(result, node); + return result; + } + if(node.kind == Composition_Node_Kind::horizontal || node.kind == Composition_Node_Kind::grid) { + Json columns = json_array(); + const std::size_t count = node.kind == Composition_Node_Kind::grid && node.columns != 0 ? node.columns : std::max(1, node.children.size()); + const std::size_t width = std::max(1, 12 / count); + for(const auto& child : node.children) { + Json column = json_object(); + json_set(column, "md", width); + json_set(column, "body", make_amis_composition_body(child, slots)); + json_append(columns, std::move(column)); + } + Json result = json_object(); + json_set(result, "type", "grid"); + json_set(result, "columns", std::move(columns)); + apply_composition_node_options(result, node); + return result; + } + if(node.kind == Composition_Node_Kind::flow) { + Json items = json_array(); + for(const auto& child : node.children) { + Json item = json_object(); + json_set(item, "body", make_amis_composition_body(child, slots)); + json_append(items, std::move(item)); + } + Json result = json_object(); + json_set(result, "type", "flex"); + json_set(result, "items", std::move(items)); + apply_composition_node_options(result, node); + return result; + } + if(node.kind == Composition_Node_Kind::tabs) { + Json tab_items = json_array(); + for(const auto& child : node.children) { + Json item = json_object(); + json_set(item, "title", child.title); + json_set(item, "body", make_amis_composition_body(Composition_Node{Composition_Node_Kind::vertical, {}, {}, {}, {}, {}, 0, false, false, child.children}, slots)); + if(!child.visible_on.empty()) { + json_set(item, "visibleOn", child.visible_on); + } + json_append(tab_items, std::move(item)); + } + Json result = json_object(); + json_set(result, "type", "tabs"); + json_set(result, "tabs", std::move(tab_items)); + apply_composition_node_options(result, node); + return result; + } + if(node.kind == Composition_Node_Kind::tab) { + throw std::invalid_argument("composition tab can only be used inside tabs"); + } + Json result = json_object(); + json_set(result, "type", "container"); + json_set(result, "body", make_amis_composition_body(Composition_Node{Composition_Node_Kind::vertical, {}, {}, {}, {}, {}, 0, false, false, node.children}, slots)); + apply_composition_node_options(result, node); + return result; +} +template +void append_amis_composition_node(Json& body, const Composition_Node& node, const std::map& slots) { + if(node.kind == Composition_Node_Kind::vertical) { + for(const auto& child : node.children) { + append_amis_composition_node(body, child, slots); + } + return; + } + if(node.kind == Composition_Node_Kind::slot) { + const auto iterator = slots.find(node.slot); + if(iterator == slots.end()) { + throw std::invalid_argument("composition slot is not provided: " + node.slot); + } + if(Json_Adapter::is_array(iterator->second)) { + for(std::size_t index = 0; index < Json_Adapter::size(iterator->second); ++index) { + json_append(body, Json_Adapter::at(iterator->second, index)); + } + } else { + json_append(body, iterator->second); + } + return; + } + json_append(body, make_amis_composition_node(node, slots)); +} +template +Json make_amis_composition_body(const Composition_Node& node, const std::map& slots) { + Json result = json_array(); + append_amis_composition_node(result, node, slots); + return result; +} +template +Json to_amis_composition_schema(const Composition_View& view, const std::map& slots) { + validate_composition_view(view); + Json result = json_object(); + json_set(result, "type", view.title.empty() ? "container" : "panel"); + if(!view.title.empty()) { + json_set(result, "title", view.title); + } + json_set(result, "body", make_amis_composition_body(view.body, slots)); + return result; +} template Json make_amis_status_body(const Json& descriptor) { Json body = json_array(); @@ -441,64 +758,75 @@ Json make_amis_object_status_column(const Json& descriptor, std::string status_a } template requires Described_Type> || Snapshot_Adapted_Object -Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::string submit_label = "Apply") { +Json to_amis_form_schema(const T& value, const Form_View& view, std::string submit_api = {}) { using Model = Object_Model_Type; + validate_form_view(view); const Json descriptor = to_descriptor_json(); Json result = json_object(); json_set(result, "type", "form"); - json_set(result, "title", Json_Adapter::at(descriptor, "label")); + json_set(result, "title", view.title); json_set(result, "data", to_frontend_json(value)); - json_set(result, "body", make_typed_amis_form_body(descriptor, "editable")); - json_set(result, "actions", make_amis_form_actions(std::move(submit_label))); - json_set(result, "affixFooter", true); - if(!submit_api.empty()) { - Json api = json_object(); - json_set(api, "method", "post"); - json_set(api, "url", std::move(submit_api)); - json_set(result, "api", std::move(api)); + json_set(result, "body", make_amis_view_body(view.body, descriptor, form_permission(view.mode), {})); + if(view.mode != Form_Mode::display) { + json_set(result, "actions", make_amis_form_actions(view.submit_label)); + json_set(result, "affixFooter", view.affix_footer); + if(!submit_api.empty()) { + Json api = json_object(); + json_set(api, "method", "post"); + json_set(api, "url", std::move(submit_api)); + json_set(result, "api", std::move(api)); + } } return result; } template -Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::string submit_label = "Apply") { +Json to_amis_form_schema(const T& value, const Form_View& view, std::string submit_api = {}) { return value.read([&](const auto& item) { - return adminive::to_amis_form_schema(item, std::move(submit_api), std::move(submit_label)); + return adminive::to_amis_form_schema(item, view, std::move(submit_api)); }); } template -Json to_amis_form_schema(const T& value, std::string submit_api = {}, std::string submit_label = "Apply") { +Json to_amis_form_schema(const T& value, const Form_View& view, std::string submit_api = {}) { return value.read([&](const auto& item) { - return adminive::to_amis_form_schema(item, std::move(submit_api), std::move(submit_label)); + return adminive::to_amis_form_schema(item, view, std::move(submit_api)); }); } template requires Described_Type> -Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = nullptr, std::uint64_t status_interval = 2000) { +Json to_amis_table_schema(std::string base_api, const Table_View& view, const Json* item_status_descriptor = nullptr, std::uint64_t item_status_interval = 2000, const Json* overview_status_descriptor = nullptr, std::string overview_status_api = {}, std::uint64_t overview_status_interval = 2000) { using Model = Object_Model_Type; + validate_table_view(view); const Json descriptor = to_descriptor_json(); - const Json& list = Json_Adapter::at(descriptor, "list"); - const std::string crud_id = json_get(Json_Adapter::at(descriptor, "name")) + "_crud"; + const auto object_descriptor = describe(); + const std::string crud_id = view.name + "_crud"; Json columns = json_array(); Json id_column = json_object(); json_set(id_column, "name", "id"); - json_set(id_column, "label", Json_Adapter::at(list, "id_label")); + json_set(id_column, "label", view.id_label); json_set(id_column, "sortable", true); json_append(columns, std::move(id_column)); - std::vector> ordered_columns; - const auto& fields = Json_Adapter::at(descriptor, "fields"); - const auto object_descriptor = describe(); - append_typed_amis_columns<0>(ordered_columns, fields, object_descriptor); - std::stable_sort(ordered_columns.begin(), ordered_columns.end(), [](const Amis_Ordered_Column& left, const Amis_Ordered_Column& right) { - return left.order < right.order; - }); - for(auto& column : ordered_columns) { - json_append(columns, std::move(column.column)); + for(const auto& definition : view.columns) { + Json column_json = make_typed_amis_column_by_name(descriptor, object_descriptor, definition.field); + if(!definition.label.empty()) { + json_set(column_json, "label", definition.label); + } + json_set(column_json, "sortable", definition.sortable); + if(definition.searchable) { + json_set(column_json, "searchable", true); + } + if(definition.filterable) { + json_set(column_json, "filterable", true); + } + if(definition.fixed == Table_Fixed::left) { + json_set(column_json, "fixed", "left"); + } else if(definition.fixed == Table_Fixed::right) { + json_set(column_json, "fixed", "right"); + } + json_append(columns, std::move(column_json)); } - if(status_descriptor) { - json_append(columns, make_amis_object_status_column(*status_descriptor, base_api + "/${id}/status", status_interval, json_get(Json_Adapter::at(list, "view_status_label")))); + if(item_status_descriptor) { + json_append(columns, make_amis_object_status_column(*item_status_descriptor, base_api + "/${id}/status", item_status_interval, view.view_status_label)); } - const std::string create_label = json_get(Json_Adapter::at(list, "create_label")); - const std::string edit_label = json_get(Json_Adapter::at(list, "edit_label")); Json create_api = json_object(); json_set(create_api, "method", "post"); json_set(create_api, "url", base_api); @@ -506,8 +834,8 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(create_form, "type", "form"); json_set(create_form, "api", std::move(create_api)); json_set(create_form, "reload", crud_id); - json_set(create_form, "body", make_typed_amis_form_body(descriptor, "creatable")); - json_set(create_form, "actions", make_amis_form_actions(create_label)); + json_set(create_form, "body", make_amis_view_body(view.create_body, descriptor, "creatable", {})); + json_set(create_form, "actions", make_amis_form_actions(view.create_label)); Json edit_api = json_object(); json_set(edit_api, "method", "put"); json_set(edit_api, "url", base_api + "/${id}"); @@ -515,23 +843,23 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(edit_form, "type", "form"); json_set(edit_form, "api", std::move(edit_api)); json_set(edit_form, "reload", crud_id); - json_set(edit_form, "body", make_typed_amis_form_body(descriptor, "editable")); - json_set(edit_form, "actions", make_amis_form_actions(json_get(Json_Adapter::at(list, "confirm_label")))); + json_set(edit_form, "body", make_amis_view_body(view.edit_body, descriptor, "editable", {})); + json_set(edit_form, "actions", make_amis_form_actions(view.confirm_label)); Json create_dialog = json_object(); - json_set(create_dialog, "title", create_label + " " + json_get(Json_Adapter::at(descriptor, "label"))); + json_set(create_dialog, "title", view.create_label + " " + json_get(Json_Adapter::at(descriptor, "label"))); json_set(create_dialog, "body", std::move(create_form)); Json create_button = json_object(); json_set(create_button, "type", "button"); - json_set(create_button, "label", create_label); + json_set(create_button, "label", view.create_label); json_set(create_button, "level", "primary"); json_set(create_button, "actionType", "dialog"); json_set(create_button, "dialog", std::move(create_dialog)); Json edit_dialog = json_object(); - json_set(edit_dialog, "title", edit_label + " " + json_get(Json_Adapter::at(descriptor, "label"))); + json_set(edit_dialog, "title", view.edit_label + " " + json_get(Json_Adapter::at(descriptor, "label"))); json_set(edit_dialog, "body", std::move(edit_form)); Json edit_button = json_object(); json_set(edit_button, "type", "button"); - json_set(edit_button, "label", edit_label); + json_set(edit_button, "label", view.edit_label); json_set(edit_button, "level", "link"); json_set(edit_button, "actionType", "dialog"); json_set(edit_button, "dialog", std::move(edit_dialog)); @@ -540,11 +868,11 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(delete_api, "url", base_api + "/${id}"); Json delete_button = json_object(); json_set(delete_button, "type", "button"); - json_set(delete_button, "label", Json_Adapter::at(list, "delete_label")); + json_set(delete_button, "label", view.delete_label); json_set(delete_button, "level", "link"); json_set(delete_button, "className", "text-danger"); json_set(delete_button, "actionType", "ajax"); - json_set(delete_button, "confirmText", Json_Adapter::at(list, "delete_confirm")); + json_set(delete_button, "confirmText", view.delete_confirm); json_set(delete_button, "api", std::move(delete_api)); json_set(delete_button, "reload", crud_id); Json buttons = json_array(); @@ -552,12 +880,12 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_append(buttons, std::move(delete_button)); Json operation = json_object(); json_set(operation, "type", "operation"); - json_set(operation, "label", Json_Adapter::at(list, "actions_label")); + json_set(operation, "label", view.actions_label); json_set(operation, "buttons", std::move(buttons)); json_append(columns, std::move(operation)); Json toggler = json_object(); json_set(toggler, "type", "columns-toggler"); - json_set(toggler, "draggable", Json_Adapter::at(list, "column_reorderable")); + json_set(toggler, "draggable", view.column_reorderable); Json toolbar = json_array(); json_append(toolbar, std::move(toggler)); json_append(toolbar, "reload"); @@ -573,35 +901,29 @@ Json to_amis_crud_schema(std::string base_api, const Json* status_descriptor = n json_set(crud, "primaryField", "id"); json_set(crud, "headerToolbar", std::move(toolbar)); json_set(crud, "columns", std::move(columns)); - const std::string default_order_by = json_get(Json_Adapter::at(list, "default_order_by")); - if(!default_order_by.empty()) { + if(!view.default_order_by.empty()) { Json params = json_object(); - json_set(params, "orderBy", default_order_by); - json_set(params, "orderDir", Json_Adapter::at(list, "default_order_dir")); + json_set(params, "orderBy", view.default_order_by); + json_set(params, "orderDir", view.default_order_dir); json_set(crud, "defaultParams", std::move(params)); } - if(json_get(Json_Adapter::at(list, "user_reorderable"))) { + if(view.user_reorderable) { json_set(crud, "draggable", true); Json order_api = json_object(); json_set(order_api, "method", "post"); json_set(order_api, "url", base_api + "/order"); json_set(crud, "saveOrderApi", std::move(order_api)); } + Json page_body = json_array(); + if(overview_status_descriptor && !overview_status_api.empty()) { + json_append(page_body, make_amis_status_service(*overview_status_descriptor, std::move(overview_status_api), overview_status_interval)); + } + json_append(page_body, std::move(crud)); Json result = json_object(); json_set(result, "type", "page"); - json_set(result, "title", Json_Adapter::at(list, "management_label")); - json_set(result, "body", std::move(crud)); - return result; -} -template -requires Described_Type> -Json to_amis_crud_status_schema(std::string base_api, std::string status_api, const Json& status_descriptor, std::uint64_t status_interval = 2000, const Json* object_status_descriptor = nullptr, std::uint64_t object_status_interval = 2000) { - Json result = to_amis_crud_schema(std::move(base_api), object_status_descriptor, object_status_interval); - Json crud = std::move(Json_Adapter::at(result, "body")); - Json body = json_array(); - json_append(body, make_amis_status_service(status_descriptor, std::move(status_api), status_interval)); - json_append(body, std::move(crud)); - json_set(result, "body", std::move(body)); + json_set(result, "title", view.title); + json_set(result, "body", std::move(page_body)); return result; } + } diff --git a/backend/library/include/adminive/collection.hpp b/backend/library/include/adminive/collection.hpp index 4688da9..271fd31 100644 --- a/backend/library/include/adminive/collection.hpp +++ b/backend/library/include/adminive/collection.hpp @@ -2,9 +2,12 @@ #include "adminive/http.hpp" #include "adminive/status.hpp" #include +#include +#include #include #include #include +#include #include #include #include @@ -17,9 +20,10 @@ struct Collection_Query { std::size_t per_page{20}; std::string order_by; std::string order_dir; + std::map fields; }; template -requires std::default_initializable && std::copy_constructible && std::assignable_from +requires Table_View_Described_Type && std::default_initializable && std::copy_constructible && std::assignable_from class Collection_Service { public: explicit Collection_Service(std::string path, std::vector initial = {}) : path_(std::move(path)) { @@ -53,7 +57,7 @@ public: return static_cast(status_reader_); } bool user_reorderable() const { - return describe().list_options().user_reorderable_; + return describe_table_view().user_reorderable; } std::size_t size() const { std::scoped_lock lock(mutex_); @@ -67,18 +71,24 @@ public: } return items; } + Json view_schema() const { + return to_view_json(describe_table_view()); + } Json amis_schema() const { - const Json* descriptor = status_reader_ ? &status_descriptor_ : nullptr; - if(overview_status_api_.empty()) { - return to_amis_crud_schema(path_, descriptor, status_interval_); - } - return to_amis_crud_status_schema(path_, overview_status_api_, overview_status_descriptor_, overview_status_interval_, descriptor, status_interval_); + const Json* item_status = status_reader_ ? &status_descriptor_ : nullptr; + const Json* overview_status = overview_status_api_.empty() ? nullptr : &overview_status_descriptor_; + return to_amis_table_schema(path_, describe_table_view(), item_status, status_interval_, overview_status, overview_status_api_, overview_status_interval_); } Http_Response descriptor_response() const noexcept { return safe_response([&] { return make_http_success(to_descriptor_json()); }); } + Http_Response view_response() const noexcept { + return safe_response([&] { + return make_http_success(view_schema()); + }); + } Http_Response amis_response() const noexcept { return safe_response([&] { return make_http_success(amis_schema()); @@ -113,6 +123,7 @@ public: for(const auto& entry : entries_) { ordered_entries.push_back(&entry); } + apply_filters(query, ordered_entries); apply_sort(query, ordered_entries); const std::size_t page = query.page == 0 ? 1 : query.page; const std::size_t per_page = query.per_page == 0 ? 20 : query.per_page; @@ -230,15 +241,67 @@ private: const std::string right_value = dump_json(right); return left_value < right_value ? -1 : left_value > right_value ? 1 : 0; } + static const Table_Column* find_table_column(const Table_View& view, std::string_view name) { + const auto iterator = std::find_if(view.columns.begin(), view.columns.end(), [&](const Table_Column& column) { + return column.field == name; + }); + return iterator == view.columns.end() ? nullptr : &*iterator; + } static bool is_sortable_field(const std::string& name) { if(name == "id") { return true; } - bool sortable{}; - std::apply([&](const auto&... field) { - ((field.name() == name ? sortable = field.is_sortable() : false), ...); - }, describe().fields()); - return sortable; + const auto view = describe_table_view(); + const auto* column = find_table_column(view, name); + return column && column->sortable; + } + static std::string query_text(const Json& value) { + if(Json_Adapter::is_string(value)) { + return json_get(value); + } + if(Json_Adapter::is_boolean(value)) { + return json_get(value) ? "true" : "false"; + } + if(Json_Adapter::is_number(value)) { + char buffer[64]; + const auto [end, error] = std::to_chars(buffer, buffer + sizeof(buffer), Json_Adapter::number(value), std::chars_format::general); + if(error == std::errc{}) { + return std::string(buffer, end); + } + } + return dump_json(value); + } + static std::string lowercase(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; + } + static bool matches_query(const Json& value, std::string_view query, const Table_Column& column) { + const std::string text = query_text(value); + if(column.searchable) { + return lowercase(text).find(lowercase(std::string(query))) != std::string::npos; + } + return text == query; + } + static void apply_filters(const Collection_Query& query, std::vector& entries) { + if(query.fields.empty()) { + return; + } + const auto view = describe_table_view(); + for(const auto& [name, value] : query.fields) { + const auto* column = find_table_column(view, name); + if(!column || (!column->searchable && !column->filterable)) { + throw std::invalid_argument("table query field is not searchable or filterable: " + name); + } + entries.erase(std::remove_if(entries.begin(), entries.end(), [&](const Entry* entry) { + const Json encoded = to_frontend_json(entry->value); + if(!Json_Adapter::contains(encoded, name)) { + return true; + } + return !matches_query(Json_Adapter::at(encoded, name), value, *column); + }), entries.end()); + } } static std::vector read_order_ids(const Json& value) { std::vector result; @@ -262,10 +325,9 @@ private: return result; } void apply_sort(const Collection_Query& query, std::vector& entries) const { - const auto descriptor = describe(); - const auto& options = descriptor.list_options(); - const std::string order_by = query.order_by.empty() ? options.default_order_by : query.order_by; - const std::string order_dir = query.order_by.empty() ? options.default_order_dir : query.order_dir.empty() ? "asc" : query.order_dir; + const auto view = describe_table_view(); + const std::string order_by = query.order_by.empty() ? view.default_order_by : query.order_by; + const std::string order_dir = query.order_by.empty() ? view.default_order_dir : query.order_dir.empty() ? "asc" : query.order_dir; if(order_by.empty() || !is_sortable_field(order_by)) { return; } diff --git a/backend/library/include/adminive/composition.hpp b/backend/library/include/adminive/composition.hpp new file mode 100644 index 0000000..6d5c93a --- /dev/null +++ b/backend/library/include/adminive/composition.hpp @@ -0,0 +1,157 @@ +#pragma once +#include +#include +#include +#include +#include +namespace adminive { +enum class Composition_Node_Kind { + slot, + heading, + vertical, + horizontal, + flow, + grid, + group, + card, + tabs, + tab +}; +struct Composition_Node { + Composition_Node_Kind kind{Composition_Node_Kind::vertical}; + std::string slot; + std::string title; + std::string description; + std::string visible_on; + std::string accent_color; + std::size_t columns{}; + bool collapsible{}; + bool collapsed{}; + std::vector children; + Composition_Node titled(std::string value) const { + auto result = *this; + result.title = std::move(value); + return result; + } + Composition_Node described(std::string value) const { + auto result = *this; + result.description = std::move(value); + return result; + } + Composition_Node visible_when(std::string value) const { + auto result = *this; + result.visible_on = std::move(value); + return result; + } + Composition_Node accent(std::string value) const { + auto result = *this; + result.accent_color = std::move(value); + return result; + } + Composition_Node collapsible_when(bool value = true, bool initially_collapsed = false) const { + auto result = *this; + result.collapsible = value; + result.collapsed = value && initially_collapsed; + return result; + } +}; +namespace compose { +inline Composition_Node slot(std::string name) { + Composition_Node result; + result.kind = Composition_Node_Kind::slot; + result.slot = std::move(name); + return result; +} +inline Composition_Node heading(std::string title) { + Composition_Node result; + result.kind = Composition_Node_Kind::heading; + result.title = std::move(title); + return result; +} +template +Composition_Node make_container(Composition_Node_Kind kind, Children&&... children) { + Composition_Node result; + result.kind = kind; + result.children.reserve(sizeof...(Children)); + (result.children.push_back(std::forward(children)), ...); + return result; +} +template +Composition_Node vertical(Children&&... children) { + return make_container(Composition_Node_Kind::vertical, std::forward(children)...); +} +template +Composition_Node horizontal(Children&&... children) { + return make_container(Composition_Node_Kind::horizontal, std::forward(children)...); +} +template +Composition_Node flow(Children&&... children) { + return make_container(Composition_Node_Kind::flow, std::forward(children)...); +} +template +Composition_Node grid(std::size_t columns, Children&&... children) { + auto result = make_container(Composition_Node_Kind::grid, std::forward(children)...); + result.columns = columns; + return result; +} +template +Composition_Node group(std::string title, Children&&... children) { + auto result = make_container(Composition_Node_Kind::group, std::forward(children)...); + result.title = std::move(title); + return result; +} +template +Composition_Node card(std::string title, Children&&... children) { + auto result = make_container(Composition_Node_Kind::card, std::forward(children)...); + result.title = std::move(title); + return result; +} +template +Composition_Node tab(std::string title, Children&&... children) { + auto result = make_container(Composition_Node_Kind::tab, std::forward(children)...); + result.title = std::move(title); + return result; +} +template +Composition_Node tabs(Children&&... children) { + return make_container(Composition_Node_Kind::tabs, std::forward(children)...); +} +} +struct Composition_View { + std::string name; + std::string title; + Composition_Node body; + Composition_View titled(std::string value) const { + auto result = *this; + result.title = std::move(value); + return result; + } +}; +inline Composition_View composition_view(std::string name, Composition_Node body) { + return Composition_View{std::move(name), {}, std::move(body)}; +} +inline void validate_composition_node(const Composition_Node& node) { + if(node.kind == Composition_Node_Kind::slot && node.slot.empty()) { + throw std::invalid_argument("composition slot name must not be empty"); + } + if(node.kind == Composition_Node_Kind::grid && node.columns == 0) { + throw std::invalid_argument("composition grid requires at least one column"); + } + if(node.kind == Composition_Node_Kind::tabs) { + for(const auto& child : node.children) { + if(child.kind != Composition_Node_Kind::tab) { + throw std::invalid_argument("composition tabs accepts only tab children"); + } + } + } + for(const auto& child : node.children) { + validate_composition_node(child); + } +} +inline void validate_composition_view(const Composition_View& view) { + if(view.name.empty()) { + throw std::invalid_argument("composition view name must not be empty"); + } + validate_composition_node(view.body); +} +} diff --git a/backend/library/include/adminive/descriptor.hpp b/backend/library/include/adminive/descriptor.hpp index fac3721..0a0a62f 100644 --- a/backend/library/include/adminive/descriptor.hpp +++ b/backend/library/include/adminive/descriptor.hpp @@ -1,6 +1,7 @@ #pragma once #include "adminive/adapter.hpp" #include "adminive/concepts.hpp" +#include "adminive/presentation.hpp" #include #include #include @@ -93,22 +94,13 @@ inline std::string make_label(std::string_view name) { } struct Field_Metadata { std::string name; - std::string label; - std::string list_label; - std::string description; - std::string widget; - std::string visible_on; - std::map enum_labels; + Field_Presentation presentation; bool editable{}; bool creatable{}; bool readable{true}; bool sensitive{}; bool include_default{true}; - bool visible{true}; bool required{}; - bool list_visible{true}; - int order{-1}; - bool sortable{}; }; template class Field_Descriptor { @@ -121,11 +113,11 @@ public: static constexpr bool synchronized = Synchronized; explicit Field_Descriptor(std::string name) { metadata_.name = std::move(name); - metadata_.label = make_label(metadata_.name); + metadata_.presentation.label = make_label(metadata_.name); } Field_Descriptor(std::string name, std::string label) { metadata_.name = std::move(name); - metadata_.label = std::move(label); + metadata_.presentation.label = std::move(label); } explicit Field_Descriptor(Field_Metadata metadata) : metadata_(std::move(metadata)) {} auto editable() const { @@ -163,54 +155,50 @@ public: result.metadata_.include_default = value; return result; } - Field_Descriptor visible(bool value = true) const { - auto result = *this; - result.metadata_.visible = value; - return result; - } Field_Descriptor required(bool value = true) const { auto result = *this; result.metadata_.required = value; return result; } - Field_Descriptor list_visible(bool value = true) const { - auto result = *this; - result.metadata_.list_visible = value; - return result; - } Field_Descriptor label(std::string value) const { auto result = *this; - result.metadata_.label = std::move(value); + result.metadata_.presentation.label = std::move(value); return result; } Field_Descriptor description(std::string value) const { auto result = *this; - result.metadata_.description = std::move(value); + result.metadata_.presentation.description = std::move(value); return result; } - Field_Descriptor widget(std::string value) const { + Field_Descriptor control(Field_Control value) const { auto result = *this; - result.metadata_.widget = std::move(value); + result.metadata_.presentation.control = value; return result; } + Field_Descriptor text_input() const { + return control(Field_Control::text); + } + Field_Descriptor multiline_text() const { + return control(Field_Control::multiline_text); + } + Field_Descriptor number_input() const { + return control(Field_Control::number); + } + Field_Descriptor boolean_input() const { + return control(Field_Control::boolean); + } + Field_Descriptor select_input() const { + return control(Field_Control::select); + } + Field_Descriptor date_input() const { + return control(Field_Control::date); + } + Field_Descriptor color_input() const { + return control(Field_Control::color); + } Field_Descriptor visible_on(std::string value) const { auto result = *this; - result.metadata_.visible_on = std::move(value); - return result; - } - Field_Descriptor list_label(std::string value) const { - auto result = *this; - result.metadata_.list_label = std::move(value); - return result; - } - Field_Descriptor order(int value) const { - auto result = *this; - result.metadata_.order = value; - return result; - } - Field_Descriptor sortable(bool value = true) const { - auto result = *this; - result.metadata_.sortable = value; + result.metadata_.presentation.visible_on = std::move(value); return result; } template @@ -221,12 +209,12 @@ public: throw std::invalid_argument("enum adapter returned an empty value name"); } auto result = *this; - result.metadata_.enum_labels.insert_or_assign(std::string(name), std::move(label)); + result.metadata_.presentation.enum_labels.insert_or_assign(std::string(name), std::move(label)); return result; } Field_Descriptor enum_label(std::string value_name, std::string label) const { auto result = *this; - result.metadata_.enum_labels.insert_or_assign(std::move(value_name), std::move(label)); + result.metadata_.presentation.enum_labels.insert_or_assign(std::move(value_name), std::move(label)); return result; } template @@ -242,23 +230,23 @@ public: const std::string& name() const noexcept { return metadata_.name; } - const std::string& label() const noexcept { - return metadata_.label; + const Field_Presentation& presentation() const noexcept { + return metadata_.presentation; } - const std::string& list_label() const noexcept { - return metadata_.list_label.empty() ? metadata_.label : metadata_.list_label; + const std::string& label() const noexcept { + return metadata_.presentation.label; } const std::string& description() const noexcept { - return metadata_.description; + return metadata_.presentation.description; } - const std::string& widget() const noexcept { - return metadata_.widget; + Field_Control control() const noexcept { + return metadata_.presentation.control; } const std::string& visible_on() const noexcept { - return metadata_.visible_on; + return metadata_.presentation.visible_on; } const std::map& enum_labels() const noexcept { - return metadata_.enum_labels; + return metadata_.presentation.enum_labels; } bool is_editable() const noexcept { return metadata_.editable; @@ -275,21 +263,9 @@ public: bool includes_default() const noexcept { return metadata_.include_default; } - bool is_visible() const noexcept { - return metadata_.visible; - } bool is_required() const noexcept { return metadata_.required; } - int order() const noexcept { - return metadata_.order; - } - bool is_sortable() const noexcept { - return metadata_.sortable; - } - bool is_list_visible() const noexcept { - return metadata_.list_visible; - } private: template auto rebind() const { @@ -338,98 +314,22 @@ struct No_Object_Validator { template void operator()(const T&) const noexcept {} }; -struct Object_List_Options { - std::string id_label{"ID"}; - std::string create_label{"Create"}; - std::string edit_label{"Edit"}; - std::string delete_label{"Delete"}; - std::string actions_label{"Actions"}; - std::string confirm_label{"Confirm Changes"}; - std::string delete_confirm{"Delete this record?"}; - std::string view_status_label{"View status"}; - std::string management_label; - std::string default_order_by; - std::string default_order_dir{"asc"}; - bool user_reorderable_{}; - bool column_reorderable_{true}; -}; template class Object_Descriptor { public: using object_type = T; using validator_type = Validator; using fields_type = std::tuple; - Object_Descriptor(std::string name, std::string label, Validator validator, std::tuple fields, Object_List_Options list_options = {}) : name_(std::move(name)), label_(std::move(label)), validator_(std::move(validator)), fields_(std::move(fields)), list_options_(std::move(list_options)) {} + Object_Descriptor(std::string name, std::string label, Validator validator, std::tuple fields) : name_(std::move(name)), label_(std::move(label)), validator_(std::move(validator)), fields_(std::move(fields)) {} template auto validator(New_Validator value) const { - return Object_Descriptor(name_, label_, std::move(value), fields_, list_options_); + return Object_Descriptor(name_, label_, std::move(value), fields_); } Object_Descriptor label(std::string value) const { auto result = *this; result.label_ = std::move(value); return result; } - Object_Descriptor id_label(std::string value) const { - auto result = *this; - result.list_options_.id_label = std::move(value); - return result; - } - Object_Descriptor create_label(std::string value) const { - auto result = *this; - result.list_options_.create_label = std::move(value); - return result; - } - Object_Descriptor edit_label(std::string value) const { - auto result = *this; - result.list_options_.edit_label = std::move(value); - return result; - } - Object_Descriptor delete_label(std::string value) const { - auto result = *this; - result.list_options_.delete_label = std::move(value); - return result; - } - Object_Descriptor actions_label(std::string value) const { - auto result = *this; - result.list_options_.actions_label = std::move(value); - return result; - } - Object_Descriptor confirm_label(std::string value) const { - auto result = *this; - result.list_options_.confirm_label = std::move(value); - return result; - } - Object_Descriptor delete_confirm(std::string value) const { - auto result = *this; - result.list_options_.delete_confirm = std::move(value); - return result; - } - Object_Descriptor view_status_label(std::string value) const { - auto result = *this; - result.list_options_.view_status_label = std::move(value); - return result; - } - Object_Descriptor management_label(std::string value) const { - auto result = *this; - result.list_options_.management_label = std::move(value); - return result; - } - Object_Descriptor default_sort(std::string field_name, bool descending = false) const { - auto result = *this; - result.list_options_.default_order_by = std::move(field_name); - result.list_options_.default_order_dir = descending ? "desc" : "asc"; - return result; - } - Object_Descriptor user_reorderable(bool value = true) const { - auto result = *this; - result.list_options_.user_reorderable_ = value; - return result; - } - Object_Descriptor column_reorderable(bool value = true) const { - auto result = *this; - result.list_options_.column_reorderable_ = value; - return result; - } const std::string& name() const noexcept { return name_; } @@ -442,15 +342,11 @@ public: const std::tuple& fields() const noexcept { return fields_; } - const Object_List_Options& list_options() const noexcept { - return list_options_; - } private: std::string name_; std::string label_; Validator validator_; std::tuple fields_; - Object_List_Options list_options_; }; template void validate_descriptor(const Descriptor& descriptor) { @@ -468,29 +364,6 @@ void validate_descriptor(const Descriptor& descriptor) { } }(), ...); }, descriptor.fields()); - const auto& options = descriptor.list_options(); - if(options.default_order_dir != "asc" && options.default_order_dir != "desc") { - throw std::invalid_argument("default sort direction must be 'asc' or 'desc'"); - } - if(options.default_order_by.empty()) { - return; - } - bool found{}; - bool sortable{}; - std::apply([&](const auto&... field) { - ([&] { - if(field.name() == options.default_order_by) { - found = true; - sortable = field.is_sortable(); - } - }(), ...); - }, descriptor.fields()); - if(!found) { - throw std::invalid_argument("default sort field does not exist: " + options.default_order_by); - } - if(!sortable) { - throw std::invalid_argument("default sort field is not sortable: " + options.default_order_by); - } } template auto object(std::string name, Fields... fields) { diff --git a/backend/library/include/adminive/http.hpp b/backend/library/include/adminive/http.hpp index db64dad..e3e3aa1 100644 --- a/backend/library/include/adminive/http.hpp +++ b/backend/library/include/adminive/http.hpp @@ -114,10 +114,14 @@ public: const std::string& path() const noexcept { return path_; } + Json view_schema() const { + return to_view_json(describe_edit_view()); + } Json amis_schema() const { Json result; + const auto view = describe_edit_view(); read_access_([&](const Object& value) { - result = to_amis_form_schema(value, path_ + "/data", describe().list_options().confirm_label); + result = to_amis_form_schema(value, view, path_ + "/data"); }); return result; } @@ -126,6 +130,11 @@ public: return make_http_success(to_descriptor_json()); }); } + Http_Response view_response() const noexcept { + return safe_response([&] { + return make_http_success(view_schema()); + }); + } Http_Response data_response() const noexcept { return safe_response([&] { Http_Response response; diff --git a/backend/library/include/adminive/json.hpp b/backend/library/include/adminive/json.hpp index 196fc85..cc6c498 100644 --- a/backend/library/include/adminive/json.hpp +++ b/backend/library/include/adminive/json.hpp @@ -395,7 +395,6 @@ requires Described_Type Json model_descriptor_json(const Model* defaults = nullptr) { const auto descriptor = describe(); Json fields = json_array(); - int field_index{}; std::apply([&](const auto&... item) { ([&] { using Field = std::remove_cvref_t; @@ -403,70 +402,46 @@ Json model_descriptor_json(const Model* defaults = nullptr) { using Value = Adapted_Value_Type; using Descriptor_Value = Optional_Unwrapped_Type; Json field_json = json_object(); - const int order = item.order() < 0 ? field_index : item.order(); - ++field_index; json_set(field_json, "name", item.name()); - json_set(field_json, "label", item.label()); - json_set(field_json, "list_label", item.list_label()); json_set(field_json, "value_type", value_type_name()); json_set(field_json, "nullable", Optional_Type); json_set(field_json, "readable", item.is_readable()); json_set(field_json, "sensitive", item.is_sensitive()); json_set(field_json, "editable", item.is_editable()); - using Field = std::remove_cvref_t; json_set(field_json, "writable", Field::managed_writable); json_set(field_json, "synchronized", Field::managed_writable && Field::synchronized); json_set(field_json, "creatable", item.is_creatable()); - json_set(field_json, "visible", item.is_visible()); json_set(field_json, "required", item.is_required()); - json_set(field_json, "list_visible", item.is_list_visible()); - json_set(field_json, "order", order); - json_set(field_json, "sortable", item.is_sortable()); if(defaults && item.is_readable() && !item.is_sensitive() && item.includes_default()) { json_set(field_json, "default", encode_json_value(item.get(*defaults))); } + Json presentation = json_object(); + json_set(presentation, "label", item.label()); + json_set(presentation, "control", std::string(field_control_name(item.control()))); if(!item.description().empty()) { - json_set(field_json, "description", item.description()); - } - if(!item.widget().empty()) { - json_set(field_json, "widget", item.widget()); + json_set(presentation, "description", item.description()); } if(!item.visible_on().empty()) { - json_set(field_json, "visible_on", item.visible_on()); + json_set(presentation, "visible_on", item.visible_on()); } + auto options = enum_options(item.enum_labels()); + if(Json_Adapter::size(options) != 0) { + json_set(presentation, "options", std::move(options)); + } + json_set(field_json, "presentation", std::move(presentation)); if constexpr(Described_Type || Snapshot_Adapted_Object) { json_set(field_json, "children", Json_Adapter::at(to_descriptor_json(), "fields")); } append_constraints(field_json); - auto options = enum_options(item.enum_labels()); - if(Json_Adapter::size(options) != 0) { - json_set(field_json, "options", std::move(options)); - } json_append(fields, std::move(field_json)); }(), ...); }, descriptor.fields()); - const auto& options = descriptor.list_options(); - Json list = json_object(); - json_set(list, "id_label", options.id_label); - json_set(list, "create_label", options.create_label); - json_set(list, "edit_label", options.edit_label); - json_set(list, "delete_label", options.delete_label); - json_set(list, "actions_label", options.actions_label); - json_set(list, "confirm_label", options.confirm_label); - json_set(list, "delete_confirm", options.delete_confirm); - json_set(list, "view_status_label", options.view_status_label); - json_set(list, "management_label", options.management_label.empty() ? descriptor.label() + " Management" : options.management_label); - json_set(list, "default_order_by", options.default_order_by); - json_set(list, "default_order_dir", options.default_order_dir); - json_set(list, "user_reorderable", options.user_reorderable_); - json_set(list, "column_reorderable", options.column_reorderable_); Json result = json_object(); json_set(result, "protocol", "adminive.resource"); - json_set(result, "protocol_version", 4); + json_set(result, "protocol_version", 5); json_set(result, "name", descriptor.name()); json_set(result, "label", descriptor.label()); json_set(result, "value_type", "object"); - json_set(result, "list", std::move(list)); json_set(result, "fields", std::move(fields)); return result; } diff --git a/backend/library/include/adminive/presentation.hpp b/backend/library/include/adminive/presentation.hpp new file mode 100644 index 0000000..e37a539 --- /dev/null +++ b/backend/library/include/adminive/presentation.hpp @@ -0,0 +1,44 @@ +#pragma once +#include +#include +#include +namespace adminive { +enum class Field_Control { + automatic, + text, + multiline_text, + number, + boolean, + select, + date, + color +}; +inline std::string_view field_control_name(Field_Control value) noexcept { + switch(value) { + case Field_Control::automatic: + return "automatic"; + case Field_Control::text: + return "text"; + case Field_Control::multiline_text: + return "multiline_text"; + case Field_Control::number: + return "number"; + case Field_Control::boolean: + return "boolean"; + case Field_Control::select: + return "select"; + case Field_Control::date: + return "date"; + case Field_Control::color: + return "color"; + } + return "automatic"; +} +struct Field_Presentation { + std::string label; + std::string description; + Field_Control control{Field_Control::automatic}; + std::string visible_on; + std::map enum_labels; +}; +} diff --git a/backend/library/include/adminive/view.hpp b/backend/library/include/adminive/view.hpp new file mode 100644 index 0000000..60228a7 --- /dev/null +++ b/backend/library/include/adminive/view.hpp @@ -0,0 +1,443 @@ +#pragma once +#include "adminive/descriptor.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +namespace adminive { +enum class View_Node_Kind { + all_fields, + field, + vertical, + horizontal, + flow, + grid, + group, + card, + tabs, + tab +}; +struct View_Node { + View_Node_Kind kind{View_Node_Kind::vertical}; + std::string field; + std::string title; + std::string description; + std::string visible_on; + std::size_t columns{}; + bool collapsible{}; + bool collapsed{}; + std::vector children; + View_Node titled(std::string value) const { + auto result = *this; + result.title = std::move(value); + return result; + } + View_Node described(std::string value) const { + auto result = *this; + result.description = std::move(value); + return result; + } + View_Node visible_when(std::string value) const { + auto result = *this; + result.visible_on = std::move(value); + return result; + } + View_Node collapsible_when(bool value = true, bool initially_collapsed = false) const { + auto result = *this; + result.collapsible = value; + result.collapsed = value && initially_collapsed; + return result; + } +}; +inline View_Node all_fields() { + View_Node result; + result.kind = View_Node_Kind::all_fields; + return result; +} +inline View_Node view_field(std::string name) { + View_Node result; + result.kind = View_Node_Kind::field; + result.field = std::move(name); + return result; +} +template +std::string described_member_name() { + using Owner = typename Member_Pointer_Traits::owner_type; + const auto descriptor = describe(); + std::string result; + std::apply([&](const auto&... item) { + ([&] { + using Field = std::remove_cvref_t; + using Accessor = typename Field::accessor_type; + if constexpr(requires { Accessor::member; }) { + if constexpr(std::same_as, std::remove_cv_t>) { + if constexpr(Accessor::member == Member) { + result = item.name(); + } + } + } + }(), ...); + }, descriptor.fields()); + if(result.empty()) { + throw std::invalid_argument("view member is not registered in the object descriptor"); + } + return result; +} +template +View_Node use() { + return view_field(described_member_name()); +} +template +View_Node use_index() { + static_assert(Index < static_cast(Reflection_Adapter::field_count)); + const auto descriptor = describe(); + const auto& field = std::get(descriptor.fields()); + return view_field(field.name()); +} +template +View_Node make_view_container(View_Node_Kind kind, Children&&... children) { + View_Node result; + result.kind = kind; + result.children.reserve(sizeof...(Children)); + (result.children.push_back(std::forward(children)), ...); + return result; +} +template +View_Node vertical(Children&&... children) { + return make_view_container(View_Node_Kind::vertical, std::forward(children)...); +} +template +View_Node horizontal(Children&&... children) { + return make_view_container(View_Node_Kind::horizontal, std::forward(children)...); +} +template +View_Node flow(Children&&... children) { + return make_view_container(View_Node_Kind::flow, std::forward(children)...); +} +template +View_Node grid(std::size_t columns, Children&&... children) { + auto result = make_view_container(View_Node_Kind::grid, std::forward(children)...); + result.columns = columns; + return result; +} +template +View_Node group(std::string title, Children&&... children) { + auto result = make_view_container(View_Node_Kind::group, std::forward(children)...); + result.title = std::move(title); + return result; +} +template +View_Node card(std::string title, Children&&... children) { + auto result = make_view_container(View_Node_Kind::card, std::forward(children)...); + result.title = std::move(title); + return result; +} +template +View_Node tab(std::string title, Children&&... children) { + auto result = make_view_container(View_Node_Kind::tab, std::forward(children)...); + result.title = std::move(title); + return result; +} +template +View_Node tabs(Children&&... children) { + return make_view_container(View_Node_Kind::tabs, std::forward(children)...); +} +enum class Form_Mode { + display, + edit, + create +}; +struct Form_View { + std::string name; + std::string title; + std::string submit_label{"Apply"}; + Form_Mode mode{Form_Mode::edit}; + View_Node body{all_fields()}; + bool affix_footer{true}; + Form_View titled(std::string value) const { + auto result = *this; + result.title = std::move(value); + return result; + } + Form_View submit(std::string value) const { + auto result = *this; + result.submit_label = std::move(value); + return result; + } + Form_View affix(bool value = true) const { + auto result = *this; + result.affix_footer = value; + return result; + } +}; +template +Form_View edit_form(View_Node body = all_fields()) { + const auto descriptor = describe(); + return Form_View{descriptor.name() + "_edit", descriptor.label(), "Apply", Form_Mode::edit, std::move(body), true}; +} +template +Form_View create_form(View_Node body = all_fields()) { + const auto descriptor = describe(); + return Form_View{descriptor.name() + "_create", descriptor.label(), "Create", Form_Mode::create, std::move(body), true}; +} +template +Form_View detail_view(View_Node body = all_fields()) { + const auto descriptor = describe(); + return Form_View{descriptor.name() + "_detail", descriptor.label(), {}, Form_Mode::display, std::move(body), false}; +} +enum class Table_Fixed { + none, + left, + right +}; +struct Table_Column { + std::string field; + std::string label; + bool sortable{}; + bool searchable{}; + bool filterable{}; + Table_Fixed fixed{Table_Fixed::none}; + Table_Column titled(std::string value) const { + auto result = *this; + result.label = std::move(value); + return result; + } + Table_Column sort(bool value = true) const { + auto result = *this; + result.sortable = value; + return result; + } + Table_Column search(bool value = true) const { + auto result = *this; + result.searchable = value; + return result; + } + Table_Column filter(bool value = true) const { + auto result = *this; + result.filterable = value; + return result; + } + Table_Column fix(Table_Fixed value) const { + auto result = *this; + result.fixed = value; + return result; + } +}; +template +Table_Column column(std::string label = {}) { + return Table_Column{described_member_name(), std::move(label)}; +} +template +Table_Column column_index(std::string label = {}) { + const auto descriptor = describe(); + const auto& field = std::get(descriptor.fields()); + return Table_Column{field.name(), std::move(label)}; +} +struct Table_View { + std::string name; + std::string title; + std::string id_label{"ID"}; + std::string create_label{"Create"}; + std::string edit_label{"Edit"}; + std::string delete_label{"Delete"}; + std::string actions_label{"Actions"}; + std::string confirm_label{"Confirm Changes"}; + std::string delete_confirm{"Delete this record?"}; + std::string view_status_label{"View status"}; + std::string default_order_by; + std::string default_order_dir{"asc"}; + bool user_reorderable{}; + bool column_reorderable{true}; + std::vector columns; + View_Node create_body{all_fields()}; + View_Node edit_body{all_fields()}; + Table_View titled(std::string value) const { + auto result = *this; + result.title = std::move(value); + return result; + } + Table_View id_title(std::string value) const { + auto result = *this; + result.id_label = std::move(value); + return result; + } + Table_View create_title(std::string value) const { + auto result = *this; + result.create_label = std::move(value); + return result; + } + Table_View edit_title(std::string value) const { + auto result = *this; + result.edit_label = std::move(value); + return result; + } + Table_View delete_title(std::string value) const { + auto result = *this; + result.delete_label = std::move(value); + return result; + } + Table_View actions_title(std::string value) const { + auto result = *this; + result.actions_label = std::move(value); + return result; + } + Table_View confirm_title(std::string value) const { + auto result = *this; + result.confirm_label = std::move(value); + return result; + } + Table_View delete_confirmation(std::string value) const { + auto result = *this; + result.delete_confirm = std::move(value); + return result; + } + Table_View status_title(std::string value) const { + auto result = *this; + result.view_status_label = std::move(value); + return result; + } + Table_View default_sort(std::string field_name, bool descending = false) const { + auto result = *this; + result.default_order_by = std::move(field_name); + result.default_order_dir = descending ? "desc" : "asc"; + return result; + } + Table_View reorderable(bool value = true) const { + auto result = *this; + result.user_reorderable = value; + return result; + } + Table_View reorderable_columns(bool value = true) const { + auto result = *this; + result.column_reorderable = value; + return result; + } + Table_View create_layout(View_Node value) const { + auto result = *this; + result.create_body = std::move(value); + return result; + } + Table_View edit_layout(View_Node value) const { + auto result = *this; + result.edit_body = std::move(value); + return result; + } +}; +template +Table_View table_view(Columns&&... columns) { + const auto descriptor = describe(); + Table_View result; + result.name = descriptor.name() + "_table"; + result.title = descriptor.label() + " Management"; + result.columns.reserve(sizeof...(Columns)); + (result.columns.push_back(std::forward(columns)), ...); + return result; +} +template +struct Type_View_Descriptor; +template +Form_View describe_edit_view() { + if constexpr(requires { { Type_View_Descriptor::edit() } -> std::same_as; }) { + return Type_View_Descriptor::edit(); + } else { + return edit_form(); + } +} +template +Form_View describe_create_view() { + if constexpr(requires { { Type_View_Descriptor::create() } -> std::same_as; }) { + return Type_View_Descriptor::create(); + } else { + return create_form(); + } +} +template +Form_View describe_detail_view() { + if constexpr(requires { { Type_View_Descriptor::detail() } -> std::same_as; }) { + return Type_View_Descriptor::detail(); + } else { + return detail_view(); + } +} +template +concept Table_View_Described_Type = Described_Type && requires { + { Type_View_Descriptor::table() } -> std::same_as; +}; +template +Table_View describe_table_view() requires Table_View_Described_Type { + return Type_View_Descriptor::table(); +} +template +bool descriptor_has_field(const Descriptor& descriptor, std::string_view name) { + bool found{}; + std::apply([&](const auto&... field) { + ((found = found || field.name() == name), ...); + }, descriptor.fields()); + return found; +} +template +void validate_view_node(const View_Node& node) { + const auto descriptor = describe(); + if(node.kind == View_Node_Kind::field && !descriptor_has_field(descriptor, node.field)) { + throw std::invalid_argument("view field does not exist: " + node.field); + } + if(node.kind == View_Node_Kind::grid && node.columns == 0) { + throw std::invalid_argument("grid view requires at least one column"); + } + if(node.kind == View_Node_Kind::tabs) { + for(const auto& child : node.children) { + if(child.kind != View_Node_Kind::tab) { + throw std::invalid_argument("tabs view accepts only tab children"); + } + } + } + for(const auto& child : node.children) { + validate_view_node(child); + } +} +template +void validate_form_view(const Form_View& view) { + if(view.name.empty()) { + throw std::invalid_argument("form view name must not be empty"); + } + validate_view_node(view.body); +} +template +void validate_table_view(const Table_View& view) { + if(view.name.empty()) { + throw std::invalid_argument("table view name must not be empty"); + } + const auto descriptor = describe(); + std::vector names; + names.reserve(view.columns.size()); + for(const auto& column : view.columns) { + if(!descriptor_has_field(descriptor, column.field)) { + throw std::invalid_argument("table column field does not exist: " + column.field); + } + if(std::find(names.begin(), names.end(), column.field) != names.end()) { + throw std::invalid_argument("duplicate table column field: " + column.field); + } + names.push_back(column.field); + } + if(!view.default_order_by.empty()) { + const auto iterator = std::find_if(view.columns.begin(), view.columns.end(), [&](const Table_Column& column) { + return column.field == view.default_order_by; + }); + if(iterator == view.columns.end()) { + throw std::invalid_argument("default sort field is not a table column: " + view.default_order_by); + } + if(!iterator->sortable) { + throw std::invalid_argument("default sort field is not sortable: " + view.default_order_by); + } + } + if(view.default_order_dir != "asc" && view.default_order_dir != "desc") { + throw std::invalid_argument("default sort direction must be 'asc' or 'desc'"); + } + validate_view_node(view.create_body); + validate_view_node(view.edit_body); +} +} diff --git a/backend/library/include/adminive/view_json.hpp b/backend/library/include/adminive/view_json.hpp new file mode 100644 index 0000000..752f3af --- /dev/null +++ b/backend/library/include/adminive/view_json.hpp @@ -0,0 +1,213 @@ +#pragma once +#include "adminive/composition.hpp" +#include "adminive/json.hpp" +#include "adminive/view.hpp" +namespace adminive { +inline std::string_view composition_node_kind_name(Composition_Node_Kind value) noexcept { + switch(value) { + case Composition_Node_Kind::slot: + return "slot"; + case Composition_Node_Kind::heading: + return "heading"; + case Composition_Node_Kind::vertical: + return "vertical"; + case Composition_Node_Kind::horizontal: + return "horizontal"; + case Composition_Node_Kind::flow: + return "flow"; + case Composition_Node_Kind::grid: + return "grid"; + case Composition_Node_Kind::group: + return "group"; + case Composition_Node_Kind::card: + return "card"; + case Composition_Node_Kind::tabs: + return "tabs"; + case Composition_Node_Kind::tab: + return "tab"; + } + return "vertical"; +} +template +Json composition_node_to_json(const Composition_Node& node) { + Json result = json_object(); + json_set(result, "kind", std::string(composition_node_kind_name(node.kind))); + if(!node.slot.empty()) { + json_set(result, "slot", node.slot); + } + if(!node.title.empty()) { + json_set(result, "title", node.title); + } + if(!node.description.empty()) { + json_set(result, "description", node.description); + } + if(!node.visible_on.empty()) { + json_set(result, "visible_on", node.visible_on); + } + if(!node.accent_color.empty()) { + json_set(result, "accent_color", node.accent_color); + } + if(node.columns != 0) { + json_set(result, "columns", node.columns); + } + if(node.collapsible) { + json_set(result, "collapsible", true); + json_set(result, "collapsed", node.collapsed); + } + if(!node.children.empty()) { + Json children = json_array(); + for(const auto& child : node.children) { + json_append(children, composition_node_to_json(child)); + } + json_set(result, "children", std::move(children)); + } + return result; +} +template +Json to_view_json(const Composition_View& view) { + validate_composition_view(view); + Json result = json_object(); + json_set(result, "protocol", "adminive.view"); + json_set(result, "protocol_version", 1); + json_set(result, "kind", "composition"); + json_set(result, "name", view.name); + if(!view.title.empty()) { + json_set(result, "title", view.title); + } + json_set(result, "body", composition_node_to_json(view.body)); + return result; +} +inline std::string_view view_node_kind_name(View_Node_Kind value) noexcept { + switch(value) { + case View_Node_Kind::all_fields: + return "all_fields"; + case View_Node_Kind::field: + return "field"; + case View_Node_Kind::vertical: + return "vertical"; + case View_Node_Kind::horizontal: + return "horizontal"; + case View_Node_Kind::flow: + return "flow"; + case View_Node_Kind::grid: + return "grid"; + case View_Node_Kind::group: + return "group"; + case View_Node_Kind::card: + return "card"; + case View_Node_Kind::tabs: + return "tabs"; + case View_Node_Kind::tab: + return "tab"; + } + return "vertical"; +} +inline std::string_view form_mode_name(Form_Mode value) noexcept { + switch(value) { + case Form_Mode::display: + return "display"; + case Form_Mode::edit: + return "edit"; + case Form_Mode::create: + return "create"; + } + return "display"; +} +inline std::string_view table_fixed_name(Table_Fixed value) noexcept { + switch(value) { + case Table_Fixed::none: + return "none"; + case Table_Fixed::left: + return "left"; + case Table_Fixed::right: + return "right"; + } + return "none"; +} +template +Json view_node_to_json(const View_Node& node) { + Json result = json_object(); + json_set(result, "kind", std::string(view_node_kind_name(node.kind))); + if(!node.field.empty()) { + json_set(result, "field", node.field); + } + if(!node.title.empty()) { + json_set(result, "title", node.title); + } + if(!node.description.empty()) { + json_set(result, "description", node.description); + } + if(!node.visible_on.empty()) { + json_set(result, "visible_on", node.visible_on); + } + if(node.columns != 0) { + json_set(result, "columns", node.columns); + } + if(node.collapsible) { + json_set(result, "collapsible", true); + json_set(result, "collapsed", node.collapsed); + } + if(!node.children.empty()) { + Json children = json_array(); + for(const auto& child : node.children) { + json_append(children, view_node_to_json(child)); + } + json_set(result, "children", std::move(children)); + } + return result; +} +template +Json to_view_json(const Form_View& view) { + validate_form_view(view); + Json result = json_object(); + json_set(result, "protocol", "adminive.view"); + json_set(result, "protocol_version", 1); + json_set(result, "kind", "form"); + json_set(result, "name", view.name); + json_set(result, "title", view.title); + json_set(result, "mode", std::string(form_mode_name(view.mode))); + json_set(result, "submit_label", view.submit_label); + json_set(result, "affix_footer", view.affix_footer); + json_set(result, "body", view_node_to_json(view.body)); + return result; +} +template +Json to_view_json(const Table_View& view) { + validate_table_view(view); + Json result = json_object(); + json_set(result, "protocol", "adminive.view"); + json_set(result, "protocol_version", 1); + json_set(result, "kind", "table"); + json_set(result, "name", view.name); + json_set(result, "title", view.title); + json_set(result, "id_label", view.id_label); + json_set(result, "create_label", view.create_label); + json_set(result, "edit_label", view.edit_label); + json_set(result, "delete_label", view.delete_label); + json_set(result, "actions_label", view.actions_label); + json_set(result, "confirm_label", view.confirm_label); + json_set(result, "delete_confirm", view.delete_confirm); + json_set(result, "view_status_label", view.view_status_label); + json_set(result, "default_order_by", view.default_order_by); + json_set(result, "default_order_dir", view.default_order_dir); + json_set(result, "user_reorderable", view.user_reorderable); + json_set(result, "column_reorderable", view.column_reorderable); + Json columns = json_array(); + for(const auto& column : view.columns) { + Json item = json_object(); + json_set(item, "field", column.field); + if(!column.label.empty()) { + json_set(item, "label", column.label); + } + json_set(item, "sortable", column.sortable); + json_set(item, "searchable", column.searchable); + json_set(item, "filterable", column.filterable); + json_set(item, "fixed", std::string(table_fixed_name(column.fixed))); + json_append(columns, std::move(item)); + } + json_set(result, "columns", std::move(columns)); + json_set(result, "create_body", view_node_to_json(view.create_body)); + json_set(result, "edit_body", view_node_to_json(view.edit_body)); + return result; +} +} diff --git a/backend/library/tests/core_adapter_test.cpp b/backend/library/tests/core_adapter_test.cpp index e9bf564..34827e7 100644 --- a/backend/library/tests/core_adapter_test.cpp +++ b/backend/library/tests/core_adapter_test.cpp @@ -262,6 +262,13 @@ struct Type_Descriptor { }); } }; +template <> +struct Type_View_Descriptor { + static Table_View table() { + using T = adapter_test::Config; + return table_view(column_index("线程数").sort(), column_index("模式").sort().filter(), column_index("名称").search()); + } +}; } int main() { using Json = adapter_test::Mini_Json; @@ -270,7 +277,8 @@ int main() { const auto& fields = adminive::Json_Adapter::at(descriptor, "fields"); assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 0), "name")) == "worker_count")); assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 0), "default")) == 4)); - const auto& options = adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 1), "options"); + const auto& presentation = adminive::Json_Adapter::at(adminive::Json_Adapter::at(fields, 1), "presentation"); + const auto& options = adminive::Json_Adapter::at(presentation, "options"); assert((adminive::json_get(adminive::Json_Adapter::at(adminive::Json_Adapter::at(options, 0), "label")) == "主用")); Config config; Json patch = adminive::json_object(); diff --git a/backend/service/CMakeLists.txt b/backend/service/CMakeLists.txt index 7a97d32..7ff6e22 100644 --- a/backend/service/CMakeLists.txt +++ b/backend/service/CMakeLists.txt @@ -68,6 +68,9 @@ if(BUILD_TESTING) add_executable(Adminive_Managed_Test "${CMAKE_CURRENT_LIST_DIR}/tests/managed_test.cpp") target_link_libraries(Adminive_Managed_Test PRIVATE Adminive::Default) add_test(NAME Adminive_Managed_Test COMMAND Adminive_Managed_Test) + add_executable(Adminive_View_Schema_Test "${CMAKE_CURRENT_LIST_DIR}/tests/view_schema_test.cpp") + target_link_libraries(Adminive_View_Schema_Test PRIVATE Adminive::Default) + add_test(NAME Adminive_View_Schema_Test COMMAND Adminive_View_Schema_Test) add_executable(Adminive_Drogon_Adapter_Test "${CMAKE_CURRENT_LIST_DIR}/tests/drogon_adapter_test.cpp") target_include_directories(Adminive_Drogon_Adapter_Test BEFORE PRIVATE "${CMAKE_CURRENT_LIST_DIR}/tests/fake_drogon") target_link_libraries(Adminive_Drogon_Adapter_Test PRIVATE Adminive::Default) @@ -79,6 +82,7 @@ if(BUILD_TESTING) target_compile_options(Adminive_Safety_Test PRIVATE /permissive-) target_compile_options(Adminive_Config_Store_Test PRIVATE /permissive-) target_compile_options(Adminive_Managed_Test PRIVATE /permissive-) + target_compile_options(Adminive_View_Schema_Test PRIVATE /permissive-) endif() endif() install(TARGETS Adminive_Service Adminive_Nlohmann Adminive_MagicEnum Adminive_BoostPfr Adminive_Httplib Adminive_Default EXPORT AdminiveServiceTargets) diff --git a/backend/service/include/adminive/adapters/drogon.hpp b/backend/service/include/adminive/adapters/drogon.hpp index 650a555..2374373 100644 --- a/backend/service/include/adminive/adapters/drogon.hpp +++ b/backend/service/include/adminive/adapters/drogon.hpp @@ -87,6 +87,9 @@ public: template requires (!std::is_const_v) && std::same_as::value_type, std::remove_cvref_t> explicit Drogon_Resource(Managed_Field value, std::string path, Transaction transaction = {}) : service_(std::make_shared(value, std::move(path), std::move(transaction))) {} + Json view_schema() const { + return service_->view_schema(); + } Json amis_schema() const { return service_->amis_schema(); } @@ -99,6 +102,11 @@ public: return service->descriptor_response(); }); }, get_constraints); + app.registerHandler(service->path() + "/view", [service](const drogon::HttpRequestPtr&, std::function&& callback) { + complete_drogon_request(callback, [service] { + return service->view_response(); + }); + }, get_constraints); app.registerHandler(service->path() + "/data", [service](const drogon::HttpRequestPtr&, std::function&& callback) { complete_drogon_request(callback, [service] { return service->data_response(); @@ -126,7 +134,7 @@ private: std::shared_ptr service_; }; template -requires std::default_initializable && std::copy_constructible && std::assignable_from +requires Table_View_Described_Type && std::default_initializable && std::copy_constructible && std::assignable_from class Drogon_Collection_Resource { public: using Service = Collection_Service; @@ -145,6 +153,9 @@ public: Json items_json() const { return service_->items_json(); } + Json view_schema() const { + return service_->view_schema(); + } Json amis_schema() const { return service_->amis_schema(); } @@ -160,6 +171,11 @@ public: return service->descriptor_response(); }); }, get_constraints); + app.registerHandler(service->path() + "/view", [service](const drogon::HttpRequestPtr&, std::function&& callback) { + complete_drogon_request(callback, [service] { + return service->view_response(); + }); + }, get_constraints); app.registerHandler(service->path() + "/amis", [service](const drogon::HttpRequestPtr&, std::function&& callback) { complete_drogon_request(callback, [service] { return service->amis_response(); @@ -183,6 +199,16 @@ public: query.per_page = read_drogon_size_parameter(request, "perPage", 20); query.order_by = request->getParameter("orderBy"); query.order_dir = request->getParameter("orderDir"); + const auto view = describe_table_view(); + for(const auto& column : view.columns) { + if(!column.searchable && !column.filterable) { + continue; + } + const std::string value = request->getParameter(column.field); + if(!value.empty()) { + query.fields.insert_or_assign(column.field, value); + } + } complete_drogon_request(callback, [service, query = std::move(query)]() mutable { return service->list_response(std::move(query)); }); diff --git a/backend/service/include/adminive/adapters/httplib.hpp b/backend/service/include/adminive/adapters/httplib.hpp index a02c441..002728f 100644 --- a/backend/service/include/adminive/adapters/httplib.hpp +++ b/backend/service/include/adminive/adapters/httplib.hpp @@ -41,6 +41,9 @@ public: template requires (!std::is_const_v) && std::same_as::value_type, std::remove_cvref_t> explicit Http_Resource(Managed_Field value, std::string path, Transaction transaction = {}) : service_(std::make_shared(value, std::move(path), std::move(transaction))) {} + Json view_schema() const { + return service_->view_schema(); + } Json amis_schema() const { return service_->amis_schema(); } @@ -49,6 +52,9 @@ public: server.Get(service->path() + "/descriptor", [service](const httplib::Request&, httplib::Response& response) { write_http_response(response, service->descriptor_response()); }); + server.Get(service->path() + "/view", [service](const httplib::Request&, httplib::Response& response) { + write_http_response(response, service->view_response()); + }); server.Get(service->path() + "/data", [service](const httplib::Request&, httplib::Response& response) { write_http_response(response, service->data_response()); }); @@ -63,7 +69,7 @@ private: std::shared_ptr service_; }; template -requires std::default_initializable && std::copy_constructible && std::assignable_from +requires Table_View_Described_Type && std::default_initializable && std::copy_constructible && std::assignable_from class Http_Collection_Resource { public: using Service = Collection_Service; @@ -82,6 +88,9 @@ public: Json items_json() const { return service_->items_json(); } + Json view_schema() const { + return service_->view_schema(); + } Json amis_schema() const { return service_->amis_schema(); } @@ -90,6 +99,9 @@ public: server.Get(service->path() + "/descriptor", [service](const httplib::Request&, httplib::Response& response) { write_http_response(response, service->descriptor_response()); }); + server.Get(service->path() + "/view", [service](const httplib::Request&, httplib::Response& response) { + write_http_response(response, service->view_response()); + }); server.Get(service->path() + "/amis", [service](const httplib::Request&, httplib::Response& response) { write_http_response(response, service->amis_response()); }); @@ -109,6 +121,12 @@ public: query.order_by = request.get_param_value("orderBy"); query.order_dir = request.has_param("orderDir") ? request.get_param_value("orderDir") : "asc"; } + const auto view = describe_table_view(); + for(const auto& column : view.columns) { + if((column.searchable || column.filterable) && request.has_param(column.field)) { + query.fields.insert_or_assign(column.field, request.get_param_value(column.field)); + } + } write_http_response(response, service->list_response(std::move(query))); }); server.Get(item_pattern(service->path()), [service](const httplib::Request& request, httplib::Response& response) { diff --git a/backend/service/src/device_tables.cpp b/backend/service/src/device_tables.cpp index 03196f6..7f42e73 100644 --- a/backend/service/src/device_tables.cpp +++ b/backend/service/src/device_tables.cpp @@ -18,8 +18,11 @@ std::vector make_network_rows() { }; } Json make_table_page(std::string_view title, std::string_view color, Json config_form, Json table_page) { - Json heading{{"type", "tpl"}, {"tpl", "

" + std::string(title) + "

"}}; - return Json{{"type", "container"}, {"body", Json::array({std::move(heading), std::move(config_form), std::move(table_page.at("body"))})}}; + const auto view = composition_view("device_table", compose::vertical(compose::heading(std::string(title)).accent(std::string(color)), compose::slot("config"), compose::slot("table"))); + std::map slots; + slots.insert_or_assign("config", std::move(config_form)); + slots.insert_or_assign("table", std::move(table_page.at("body"))); + return to_amis_composition_schema(view, slots); } } Serial_Device_Table::Serial_Device_Table(Config_Store& store) : store_(store), config_resource_(store.serial_table(), "/admin/device_tables/serial/config", store.serial_table_transaction()), rows_("/admin/device_tables/serial/items", make_serial_rows()) {} @@ -34,7 +37,7 @@ std::string_view Serial_Device_Table::type_label() const noexcept { } Json Serial_Device_Table::amis_schema() const { const Serial_Table_Config config = store_.serial_table().snapshot(); - return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/serial/config/data", "确认修改"), rows_.amis_schema()); + return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, describe_edit_view(), "/admin/device_tables/serial/config/data"), rows_.amis_schema()); } void Serial_Device_Table::bind(httplib::Server& server) { config_resource_.bind(server); @@ -52,7 +55,7 @@ std::string_view Network_Device_Table::type_label() const noexcept { } Json Network_Device_Table::amis_schema() const { const Network_Table_Config config = store_.network_table().snapshot(); - return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, "/admin/device_tables/network/config/data", "确认修改"), rows_.amis_schema()); + return make_table_page(config.group_name, config.accent_color, to_amis_form_schema(config, describe_edit_view(), "/admin/device_tables/network/config/data"), rows_.amis_schema()); } void Network_Device_Table::bind(httplib::Server& server) { config_resource_.bind(server); diff --git a/backend/service/src/example_descriptors.hpp b/backend/service/src/example_descriptors.hpp index 6cde774..7a1ecd9 100644 --- a/backend/service/src/example_descriptors.hpp +++ b/backend/service/src/example_descriptors.hpp @@ -19,138 +19,119 @@ template <> struct Type_Descriptor { static auto get() { using T = example::Endpoint_Config; - return object( - "endpoint_config", - ADMINIVE_FIELD_LABEL(T, enabled, "启用端点").editable().creatable().description("控制当前端点是否参与运行"), - ADMINIVE_FIELD_LABEL(T, host, "主机地址").editable().creatable().required().description("字符串输入示例"), - ADMINIVE_FIELD_LABEL(T, port, "端口").editable().creatable().required().description("整数输入示例") - ).label("端点配置"); + return object("endpoint_config", ADMINIVE_FIELD_LABEL(T, enabled, "启用端点").editable().creatable().description("控制当前端点是否参与运行"), ADMINIVE_FIELD_LABEL(T, host, "主机地址").editable().creatable().required().description("字符串输入示例"), ADMINIVE_FIELD_LABEL(T, port, "端口").editable().creatable().required().description("整数输入示例")).label("端点配置"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Appearance_Config; - return object( - "appearance_config", - ADMINIVE_FIELD_LABEL(T, panel_title, "面板标题").editable().creatable().required(), - ADMINIVE_FIELD_LABEL(T, effective_date, "生效日期").editable().creatable().required().widget("input-date"), - ADMINIVE_FIELD_LABEL(T, accent_color, "主题颜色").editable().creatable().required().widget("input-color") - ).label("界面配置"); + return object("appearance_config", ADMINIVE_FIELD_LABEL(T, panel_title, "面板标题").editable().creatable().required(), ADMINIVE_FIELD_LABEL(T, effective_date, "生效日期").editable().creatable().required().date_input(), ADMINIVE_FIELD_LABEL(T, accent_color, "主题颜色").editable().creatable().required().color_input()).label("界面配置"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Radio_Service_Config; - return object( - "radio_service_config", - ADMINIVE_FIELD_LABEL(T, profile_name, "配置名称").editable().required(), - ADMINIVE_FIELD_LABEL(T, mode, "运行模式").editable().required().enum_label("接收").enum_label("发送").enum_label("双工").enum_label("维护"), - ADMINIVE_FIELD_LABEL(T, worker_count, "工作线程数").editable().required(), - ADMINIVE_FIELD_LABEL(T, receive_gain, "接收增益").editable().required(), - ADMINIVE_FIELD_LABEL(T, primary_endpoint, "主端点").editable(), - ADMINIVE_FIELD_LABEL(T, backup_endpoint, "备用端点").editable().visible_on("${$self.mode == 'duplex'}"), - ADMINIVE_FIELD_LABEL(T, appearance, "界面显示").editable() - ).label("无线电服务配置").confirm_label("确认修改"); + return object("radio_service_config", ADMINIVE_FIELD_LABEL(T, profile_name, "配置名称").editable().required(), ADMINIVE_FIELD_LABEL(T, mode, "运行模式").editable().required().enum_label("接收").enum_label("发送").enum_label("双工").enum_label("维护"), ADMINIVE_FIELD_LABEL(T, worker_count, "工作线程数").editable().required(), ADMINIVE_FIELD_LABEL(T, receive_gain, "接收增益").editable().required(), ADMINIVE_FIELD_LABEL(T, primary_endpoint, "主端点").editable(), ADMINIVE_FIELD_LABEL(T, backup_endpoint, "备用端点").editable().visible_on("${$self.mode == 'duplex'}"), ADMINIVE_FIELD_LABEL(T, appearance, "界面显示").editable()).label("无线电服务配置"); + } +}; +template <> +struct Type_View_Descriptor { + static Form_View edit() { + using T = example::Radio_Service_Config; + return edit_form(tabs(tab("基础", group("运行参数", use<&T::profile_name>(), use<&T::mode>(), horizontal(use<&T::worker_count>(), use<&T::receive_gain>()))), tab("端点", horizontal(card("主端点", use<&T::primary_endpoint>()), card("备用端点", use<&T::backup_endpoint>()))), tab("界面", card("显示设置", use<&T::appearance>())))).submit("确认修改"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Alert_Config; - return object( - "alert_config", - ADMINIVE_FIELD_LABEL(T, enabled, "启用告警").editable(), - ADMINIVE_FIELD_LABEL(T, rule_name, "规则名称").editable().required(), - ADMINIVE_FIELD_LABEL(T, channel, "发送通道").editable().required().enum_label("控制台").enum_label("邮件").enum_label("Webhook"), - ADMINIVE_FIELD_LABEL(T, minimum_level, "最低日志级别").editable().required().enum_label("跟踪").enum_label("调试").enum_label("信息").enum_label("警告").enum_label("错误"), - ADMINIVE_FIELD_LABEL(T, repeat_minutes, "重复间隔(分钟)").editable().required(), - ADMINIVE_FIELD_LABEL(T, effective_date, "生效日期").editable().required().widget("input-date"), - ADMINIVE_FIELD_LABEL(T, highlight_color, "高亮颜色").editable().required().widget("input-color"), - ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook 端点").editable().visible_on("${$self.enabled && $self.channel == 'webhook'}") - ).label("告警配置").confirm_label("确认修改"); + return object("alert_config", ADMINIVE_FIELD_LABEL(T, enabled, "启用告警").editable(), ADMINIVE_FIELD_LABEL(T, rule_name, "规则名称").editable().required(), ADMINIVE_FIELD_LABEL(T, channel, "发送通道").editable().required().enum_label("控制台").enum_label("邮件").enum_label("Webhook"), ADMINIVE_FIELD_LABEL(T, minimum_level, "最低日志级别").editable().required().enum_label("跟踪").enum_label("调试").enum_label("信息").enum_label("警告").enum_label("错误"), ADMINIVE_FIELD_LABEL(T, repeat_minutes, "重复间隔(分钟)").editable().required(), ADMINIVE_FIELD_LABEL(T, effective_date, "生效日期").editable().required().date_input(), ADMINIVE_FIELD_LABEL(T, highlight_color, "高亮颜色").editable().required().color_input(), ADMINIVE_FIELD_LABEL(T, webhook_endpoint, "Webhook 端点").editable().visible_on("${$self.enabled && $self.channel == 'webhook'}")).label("告警配置"); + } +}; +template <> +struct Type_View_Descriptor { + static Form_View edit() { + using T = example::Alert_Config; + return edit_form(vertical(group("告警规则", horizontal(use<&T::enabled>(), use<&T::rule_name>()), grid(3, use<&T::channel>(), use<&T::minimum_level>(), use<&T::repeat_minutes>())), flow(card("生效日期", use<&T::effective_date>()), card("高亮颜色", use<&T::highlight_color>())), group("Webhook", use<&T::webhook_endpoint>()).collapsible_when())).submit("确认修改"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Serial_Table_Config; - return object( - "serial_table_config", - ADMINIVE_FIELD_LABEL(T, group_name, "分组名称").editable().required(), - ADMINIVE_FIELD_LABEL(T, default_baud_rate, "默认波特率").editable().required(), - ADMINIVE_FIELD_LABEL(T, default_parity, "默认校验位").editable().required().enum_label("无校验").enum_label("奇校验").enum_label("偶校验"), - ADMINIVE_FIELD_LABEL(T, accent_color, "界面颜色").editable().required().widget("input-color") - ).label("串口表格配置").confirm_label("确认修改"); + return object("serial_table_config", ADMINIVE_FIELD_LABEL(T, group_name, "分组名称").editable().required(), ADMINIVE_FIELD_LABEL(T, default_baud_rate, "默认波特率").editable().required(), ADMINIVE_FIELD_LABEL(T, default_parity, "默认校验位").editable().required().enum_label("无校验").enum_label("奇校验").enum_label("偶校验"), ADMINIVE_FIELD_LABEL(T, accent_color, "界面颜色").editable().required().color_input()).label("串口表格配置"); + } +}; +template <> +struct Type_View_Descriptor { + static Form_View edit() { + using T = example::Serial_Table_Config; + return edit_form(horizontal(group("默认参数", use<&T::group_name>(), use<&T::default_baud_rate>(), use<&T::default_parity>()), card("显示", use<&T::accent_color>()))).submit("确认修改"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Network_Table_Config; - return object( - "network_table_config", - ADMINIVE_FIELD_LABEL(T, group_name, "分组名称").editable().required(), - ADMINIVE_FIELD_LABEL(T, default_protocol, "默认协议").editable().required().enum_label("TCP").enum_label("UDP").enum_label("WebSocket"), - ADMINIVE_FIELD_LABEL(T, timeout_ms, "超时时间(毫秒)").editable().required(), - ADMINIVE_FIELD_LABEL(T, tls_enabled, "默认启用 TLS").editable(), - ADMINIVE_FIELD_LABEL(T, accent_color, "界面颜色").editable().required().widget("input-color") - ).label("网络表格配置").confirm_label("确认修改"); + return object("network_table_config", ADMINIVE_FIELD_LABEL(T, group_name, "分组名称").editable().required(), ADMINIVE_FIELD_LABEL(T, default_protocol, "默认协议").editable().required().enum_label("TCP").enum_label("UDP").enum_label("WebSocket"), ADMINIVE_FIELD_LABEL(T, timeout_ms, "超时时间(毫秒)").editable().required(), ADMINIVE_FIELD_LABEL(T, tls_enabled, "默认启用 TLS").editable(), ADMINIVE_FIELD_LABEL(T, accent_color, "界面颜色").editable().required().color_input()).label("网络表格配置"); + } +}; +template <> +struct Type_View_Descriptor { + static Form_View edit() { + using T = example::Network_Table_Config; + return edit_form(grid(2, group("网络默认值", use<&T::group_name>(), use<&T::default_protocol>(), use<&T::timeout_ms>(), use<&T::tls_enabled>()), card("显示", use<&T::accent_color>()))).submit("确认修改"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Application_Config; - return object( - "application_config", - ADMINIVE_FIELD_LABEL(T, default_device_type, "默认设备类型").read_write(), - ADMINIVE_FIELD_LABEL(T, radio_service, "无线电服务").read_write(), - ADMINIVE_FIELD_LABEL(T, alerts, "告警").read_write(), - ADMINIVE_FIELD_LABEL(T, serial_table, "串口表格").read_write(), - ADMINIVE_FIELD_LABEL(T, network_table, "网络表格").read_write() - ).label("Adminive 配置"); + return object("application_config", ADMINIVE_FIELD_LABEL(T, default_device_type, "默认设备类型").read_write(), ADMINIVE_FIELD_LABEL(T, radio_service, "无线电服务").read_write(), ADMINIVE_FIELD_LABEL(T, alerts, "告警").read_write(), ADMINIVE_FIELD_LABEL(T, serial_table, "串口表格").read_write(), ADMINIVE_FIELD_LABEL(T, network_table, "网络表格").read_write()).label("Adminive 配置"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Radio_State; - return object( - "radio_state", - ADMINIVE_FIELD_LABEL(T, mode, "运行模式").creatable().editable().required().list_label("模式").order(5).sortable().enum_label("接收").enum_label("发送").enum_label("双工").enum_label("维护"), - ADMINIVE_FIELD_LABEL(T, port, "监听端口").creatable().editable().required().list_label("监听端口").order(20).sortable(), - ADMINIVE_FIELD_LABEL(T, buffer_count, "缓冲区数量").creatable().editable().required().list_label("缓冲区").order(10).sortable(), - ADMINIVE_FIELD_LABEL(T, low_watermark, "低水位").creatable().editable().list_label("低水位").order(30).sortable(), - ADMINIVE_FIELD_LABEL(T, high_watermark, "高水位").creatable().editable().list_label("高水位").order(40).sortable() - ).label("无线电状态").validator(T::Watermark_Validator{}).id_label("编号").create_label("新增").edit_label("编辑").delete_label("删除").actions_label("操作").confirm_label("确认修改").delete_confirm("确定删除这条无线电状态吗?").view_status_label("查看状态 ▾").management_label("无线电状态列表").default_sort("buffer_count"); + return object("radio_state", ADMINIVE_FIELD_LABEL(T, mode, "运行模式").creatable().editable().required().enum_label("接收").enum_label("发送").enum_label("双工").enum_label("维护"), ADMINIVE_FIELD_LABEL(T, port, "监听端口").creatable().editable().required(), ADMINIVE_FIELD_LABEL(T, buffer_count, "缓冲区数量").creatable().editable().required(), ADMINIVE_FIELD_LABEL(T, low_watermark, "低水位").creatable().editable(), ADMINIVE_FIELD_LABEL(T, high_watermark, "高水位").creatable().editable()).label("无线电状态").validator(T::Watermark_Validator{}); + } +}; +template <> +struct Type_View_Descriptor { + static Table_View table() { + using T = example::Radio_State; + return table_view(column<&T::mode>("模式").sort().filter(), column<&T::buffer_count>("缓冲区").sort(), column<&T::port>("监听端口").sort().search(), column<&T::low_watermark>("低水位").sort(), column<&T::high_watermark>("高水位").sort()).titled("无线电状态列表").id_title("编号").create_title("新增").edit_title("编辑").delete_title("删除").actions_title("操作").confirm_title("确认修改").delete_confirmation("确定删除这条无线电状态吗?").status_title("查看状态 ▾").default_sort("buffer_count"); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Serial_Device_Row; - return object( - "serial_device", - ADMINIVE_FIELD_LABEL(T, port_name, "串口名称").creatable().editable().required().list_label("串口").order(10).sortable(), - ADMINIVE_FIELD_LABEL(T, baud_rate, "波特率").creatable().editable().required().list_label("波特率").order(20).sortable(), - ADMINIVE_FIELD_LABEL(T, parity, "校验位").creatable().editable().required().list_label("校验位").order(30).sortable().enum_label("无校验").enum_label("奇校验").enum_label("偶校验"), - ADMINIVE_FIELD_LABEL(T, enabled, "启用").creatable().editable().list_label("启用").order(40).sortable() - ).label("串口设备").id_label("编号").create_label("新增串口").edit_label("编辑").delete_label("删除").actions_label("操作").confirm_label("确认修改").delete_confirm("确定删除这个串口吗?").management_label("串口设备列表").user_reorderable(); + return object("serial_device", ADMINIVE_FIELD_LABEL(T, port_name, "串口名称").creatable().editable().required(), ADMINIVE_FIELD_LABEL(T, baud_rate, "波特率").creatable().editable().required(), ADMINIVE_FIELD_LABEL(T, parity, "校验位").creatable().editable().required().enum_label("无校验").enum_label("奇校验").enum_label("偶校验"), ADMINIVE_FIELD_LABEL(T, enabled, "启用").creatable().editable()).label("串口设备"); + } +}; +template <> +struct Type_View_Descriptor { + static Table_View table() { + using T = example::Serial_Device_Row; + return table_view(column<&T::port_name>("串口").sort().search().fix(Table_Fixed::left), column<&T::baud_rate>("波特率").sort(), column<&T::parity>("校验位").sort().filter(), column<&T::enabled>("启用").sort().filter()).titled("串口设备列表").id_title("编号").create_title("新增串口").edit_title("编辑").delete_title("删除").actions_title("操作").confirm_title("确认修改").delete_confirmation("确定删除这个串口吗?").reorderable(); } }; template <> struct Type_Descriptor { static auto get() { using T = example::Network_Device_Row; - return object( - "network_device", - ADMINIVE_FIELD_LABEL(T, endpoint, "网络地址").creatable().editable().required().list_label("地址").order(10).sortable(), - ADMINIVE_FIELD_LABEL(T, protocol, "协议").creatable().editable().required().list_label("协议").order(20).sortable().enum_label("TCP").enum_label("UDP").enum_label("WebSocket"), - ADMINIVE_FIELD_LABEL(T, timeout_ms, "超时时间").creatable().editable().required().list_label("超时(毫秒)").order(30).sortable(), - ADMINIVE_FIELD_LABEL(T, tls_enabled, "启用 TLS").creatable().editable().list_label("TLS").order(40).sortable() - ).label("网络设备").id_label("编号").create_label("新增网络设备").edit_label("编辑").delete_label("删除").actions_label("操作").confirm_label("确认修改").delete_confirm("确定删除这个网络设备吗?").management_label("网络设备列表").user_reorderable(); + return object("network_device", ADMINIVE_FIELD_LABEL(T, endpoint, "网络地址").creatable().editable().required(), ADMINIVE_FIELD_LABEL(T, protocol, "协议").creatable().editable().required().enum_label("TCP").enum_label("UDP").enum_label("WebSocket"), ADMINIVE_FIELD_LABEL(T, timeout_ms, "超时时间").creatable().editable().required(), ADMINIVE_FIELD_LABEL(T, tls_enabled, "启用 TLS").creatable().editable()).label("网络设备"); + } +}; +template <> +struct Type_View_Descriptor { + static Table_View table() { + using T = example::Network_Device_Row; + return table_view(column<&T::endpoint>("地址").sort().search().fix(Table_Fixed::left), column<&T::protocol>("协议").sort().filter(), column<&T::timeout_ms>("超时(毫秒)").sort(), column<&T::tls_enabled>("TLS").sort().filter()).titled("网络设备列表").id_title("编号").create_title("新增网络设备").edit_title("编辑").delete_title("删除").actions_title("操作").confirm_title("确认修改").delete_confirmation("确定删除这个网络设备吗?").reorderable(); } }; } diff --git a/backend/service/src/main.cpp b/backend/service/src/main.cpp index 95c8b3a..a46521d 100644 --- a/backend/service/src/main.cpp +++ b/backend/service/src/main.cpp @@ -30,9 +30,9 @@ void describe_example() { std::cout << adminive::to_descriptor_json().dump(2) << '\n'; std::cout << adminive::to_json(state).dump(2) << '\n'; std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_amis_form_schema(service, "/admin/config/radio/data", "确认修改").dump(2) << '\n'; + std::cout << adminive::to_amis_form_schema(service, adminive::describe_edit_view(), "/admin/config/radio/data").dump(2) << '\n'; std::cout << adminive::to_descriptor_json().dump(2) << '\n'; - std::cout << adminive::to_amis_form_schema(alerts, "/admin/config/alerts/data", "确认修改").dump(2) << '\n'; + std::cout << adminive::to_amis_form_schema(alerts, adminive::describe_edit_view(), "/admin/config/alerts/data").dump(2) << '\n'; } } int main(int argc, char** argv) { diff --git a/backend/service/tests/advanced_adapter_test.cpp b/backend/service/tests/advanced_adapter_test.cpp index 5634b9e..83ae4f0 100644 --- a/backend/service/tests/advanced_adapter_test.cpp +++ b/backend/service/tests/advanced_adapter_test.cpp @@ -134,12 +134,12 @@ struct Control_Adapter { advanced_test::Json result; result["type"] = "input-color"; result["name"] = make_field_name(context.prefix, field.at("name").get()); - result["label"] = field.at("label"); + result["label"] = field.at("presentation").at("label"); result["clearable"] = true; return result; } static advanced_test::Json make_column(const advanced_test::Json& field) { - return advanced_test::Json{{"type", "tpl"}, {"name", field.at("name")}, {"label", field.at("list_label")}, {"tpl", "${color}"}}; + return advanced_test::Json{{"type", "tpl"}, {"name", field.at("name")}, {"label", field.at("presentation").at("label")}, {"tpl", "${color}"}}; } }; template <> @@ -171,7 +171,7 @@ template <> struct Type_Descriptor { static auto get() { using T = advanced_test::Control_Config; - return object("control", "Control", ADMINIVE_FIELD(T, color).editable().list_label("颜色")); + return object("control", "Control", ADMINIVE_FIELD(T, color).editable().label("颜色")); } }; template <> @@ -255,7 +255,14 @@ template <> struct Type_Descriptor { static auto get() { using T = advanced_test::Invalid_Sort_Config; - return object("invalid_sort", "Invalid Sort", ADMINIVE_FIELD(T, value)).default_sort("value"); + return object("invalid_sort", "Invalid Sort", ADMINIVE_FIELD(T, value)); + } +}; +template <> +struct Type_View_Descriptor { + static Table_View table() { + using T = advanced_test::Invalid_Sort_Config; + return table_view(column<&T::value>()).default_sort("value"); } }; } @@ -299,10 +306,10 @@ int main() { assert(managed_inherited.field<0>().snapshot() == 12); assert(managed_inherited.field<1>().snapshot() == "changed"); Control_Config controls; - const auto control_form = adminive::to_amis_form_schema(controls); + const auto control_form = adminive::to_amis_form_schema(controls, adminive::describe_edit_view()); assert(control_form.at("body").at(0).at("type") == "input-color"); Device_Config devices; - const auto device_form = adminive::to_amis_form_schema(devices); + const auto device_form = adminive::to_amis_form_schema(devices, adminive::describe_edit_view()); assert(device_form.at("body").at(0).at("type") == "fieldset"); assert(device_form.at("body").at(0).at("body").at(0).at("type") == "select"); assert(device_form.at("body").at(0).at("body").at(1).at("body").at(2).at("type") == "input-color"); @@ -333,7 +340,7 @@ int main() { assert(duplicate_rejected); bool invalid_sort_rejected{}; try { - static_cast(adminive::to_descriptor_json()); + adminive::validate_table_view(adminive::describe_table_view()); } catch(const std::invalid_argument&) { invalid_sort_rejected = true; } diff --git a/backend/service/tests/drogon_adapter_test.cpp b/backend/service/tests/drogon_adapter_test.cpp index 29d19b2..d14c6db 100644 --- a/backend/service/tests/drogon_adapter_test.cpp +++ b/backend/service/tests/drogon_adapter_test.cpp @@ -22,6 +22,13 @@ struct Type_Descriptor { } }; template <> +struct Type_View_Descriptor { + static Table_View table() { + using T = drogon_test::Config; + return table_view(column<&T::name>("Name").sort().search()); + } +}; +template <> struct Type_Descriptor { static auto get() { using T = drogon_test::Status; @@ -63,9 +70,15 @@ int main() { assert(app.filters("/config/data", drogon::Post).size() == 2); assert(app.filters("/status/data", drogon::Get).size() == 2); auto request = std::make_shared(); + drogon::HttpResponsePtr response; + app.handle("/config/view", drogon::Get, request, [&response](const drogon::HttpResponsePtr& value) { + response = value; + }); + assert(response->status == drogon::k200OK); + assert(Json::parse(response->body).at("data").at("kind") == "form"); request->body = R"({"name":"updated"})"; request->user = "wyc"; - drogon::HttpResponsePtr response; + response.reset(); app.handle("/config/data", drogon::Post, request, [&response](const drogon::HttpResponsePtr& value) { response = value; }); @@ -102,7 +115,14 @@ int main() { assert(response->status == drogon::k200OK); adminive::Drogon_Collection_Resource collection("/configs", {drogon_test::Config{"first"}}); collection.bind(app, options); - request->parameters = {{"page", "1"}, {"perPage", "10"}}; + response.reset(); + app.handle("/configs/view", drogon::Get, request, [&response](const drogon::HttpResponsePtr& value) { + response = value; + }); + assert(response->status == drogon::k200OK); + assert(Json::parse(response->body).at("data").at("kind") == "table"); + assert(Json::parse(response->body).at("data").at("columns").at(0).at("searchable") == true); + request->parameters = {{"page", "1"}, {"perPage", "10"}, {"name", "fir"}}; response.reset(); app.handle("/configs", drogon::Get, request, [&response](const drogon::HttpResponsePtr& value) { response = value; diff --git a/backend/service/tests/managed_test.cpp b/backend/service/tests/managed_test.cpp index 8890500..f55f122 100644 --- a/backend/service/tests/managed_test.cpp +++ b/backend/service/tests/managed_test.cpp @@ -115,7 +115,7 @@ int main() { assert(Probe_Lock::exclusive_locks.load(std::memory_order_relaxed) == 0); assert(Probe_Lock::shared_locks.load(std::memory_order_relaxed) == 0); const auto descriptor = adminive::to_descriptor_json(); - assert(descriptor.at("protocol_version") == 4); + assert(descriptor.at("protocol_version") == 5); assert(!descriptor.at("fields").at(0).contains("lock_mode")); assert(descriptor.at("fields").at(0).at("writable") == true); assert(descriptor.at("fields").at(0).at("synchronized") == true); diff --git a/backend/service/tests/safety_test.cpp b/backend/service/tests/safety_test.cpp index 0f8bd5e..dbf91fb 100644 --- a/backend/service/tests/safety_test.cpp +++ b/backend/service/tests/safety_test.cpp @@ -276,7 +276,7 @@ int main() { update = adminive::apply_frontend_create(created, Json{{"child", Json{{"editable_value", 5}}}}); assert(!update.success); assert(update.field_errors.contains("child.required_value")); - const auto root_form = adminive::to_amis_form_schema(root); + const auto root_form = adminive::to_amis_form_schema(root, adminive::describe_edit_view()); assert(root_form.at("body").at(0).at("body").at(2).at("visibleOn") == "${child.editable_value > 0}"); Numeric_Config numeric; update = adminive::apply_frontend_patch(numeric, Json{{"signed_value", 1.5}}); @@ -286,7 +286,7 @@ int main() { update = adminive::apply_frontend_patch(numeric, Json{{"signed_value", std::numeric_limits::max()}}); assert(!update.success); Optional_Config optional; - const auto optional_form = adminive::to_amis_form_schema(optional); + const auto optional_form = adminive::to_amis_form_schema(optional, adminive::describe_edit_view()); assert(optional_form.at("body").at(0).at("clearable") == true); assert(optional_form.at("body").at(1).at("clearable") == true); update = adminive::apply_frontend_patch(optional, Json{{"count", nullptr}, {"note", nullptr}}); diff --git a/backend/service/tests/test.cpp b/backend/service/tests/test.cpp index c408f99..07be628 100644 --- a/backend/service/tests/test.cpp +++ b/backend/service/tests/test.cpp @@ -1,33 +1,91 @@ #include "example_descriptors.hpp" #include #include +#include #include +namespace { +using Json = adminive::example::Json; +const Json* find_named_node(const Json& value, std::string_view name) { + if(value.is_object()) { + if(value.contains("name") && value.at("name").is_string() && value.at("name").get() == name) { + return &value; + } + for(const auto& [key, child] : value.items()) { + static_cast(key); + if(const Json* result = find_named_node(child, name)) { + return result; + } + } + } else if(value.is_array()) { + for(const auto& child : value) { + if(const Json* result = find_named_node(child, name)) { + return result; + } + } + } + return nullptr; +} +const Json* find_titled_node(const Json& value, std::string_view title) { + if(value.is_object()) { + if(value.contains("title") && value.at("title").is_string() && value.at("title").get() == title) { + return &value; + } + for(const auto& [key, child] : value.items()) { + static_cast(key); + if(const Json* result = find_titled_node(child, title)) { + return result; + } + } + } else if(value.is_array()) { + for(const auto& child : value) { + if(const Json* result = find_titled_node(child, title)) { + return result; + } + } + } + return nullptr; +} +} int main() { using adminive::example::Alert_Config; using adminive::example::Radio_Item_Status; using adminive::example::Radio_Mode; using adminive::example::Radio_Service_Config; using adminive::example::Radio_State; - using Json = adminive::example::Json; Radio_State state; const auto descriptor = adminive::to_descriptor_json(); + assert(descriptor.at("protocol") == "adminive.resource"); + assert(descriptor.at("protocol_version") == 5); assert(descriptor.at("name") == "radio_state"); assert(descriptor.at("fields").size() == 5); - assert(descriptor.at("fields").at(0).at("name") == "mode"); - assert(descriptor.at("fields").at(0).at("label") == "运行模式"); - assert(descriptor.at("fields").at(0).at("list_label") == "模式"); - assert(descriptor.at("fields").at(0).at("value_type") == "enum"); - assert(descriptor.at("fields").at(0).at("options").size() == 4); - assert(descriptor.at("fields").at(0).at("options").at(0).at("value") == "receive"); - assert(descriptor.at("fields").at(0).at("options").at(2).at("value") == "duplex"); - assert(descriptor.at("fields").at(0).at("order") == 5); - assert(descriptor.at("fields").at(0).at("sortable") == true); - assert(descriptor.at("fields").at(1).at("name") == "port"); - assert(descriptor.at("fields").at(1).at("list_label") == "监听端口"); - assert(descriptor.at("fields").at(1).at("minimum") == 1); - assert(descriptor.at("fields").at(1).at("maximum") == 65535); - assert(descriptor.at("fields").at(2).at("name") == "buffer_count"); + const auto& mode_field = descriptor.at("fields").at(0); + assert(mode_field.at("name") == "mode"); + assert(mode_field.at("presentation").at("label") == "运行模式"); + assert(mode_field.at("value_type") == "enum"); + assert(mode_field.at("presentation").at("options").size() == 4); + assert(mode_field.at("presentation").at("options").at(0).at("value") == "receive"); + assert(mode_field.at("presentation").at("options").at(2).at("value") == "duplex"); + const auto& port_field = descriptor.at("fields").at(1); + assert(port_field.at("name") == "port"); + assert(port_field.at("presentation").at("label") == "监听端口"); + assert(port_field.at("minimum") == 1); + assert(port_field.at("maximum") == 65535); assert(descriptor.at("fields").at(2).at("multiple_of") == 2); + assert(!descriptor.contains("list")); + const auto table_view = adminive::describe_table_view(); + assert(table_view.columns.size() == 5); + assert(table_view.columns.at(0).field == "mode"); + assert(table_view.columns.at(0).label == "模式"); + assert(table_view.columns.at(0).sortable); + assert(table_view.columns.at(0).filterable); + assert(table_view.columns.at(2).field == "port"); + assert(table_view.columns.at(2).searchable); + assert(table_view.default_order_by == "buffer_count"); + const auto table_view_json = adminive::to_view_json(table_view); + assert(table_view_json.at("protocol") == "adminive.view"); + assert(table_view_json.at("kind") == "table"); + assert(table_view_json.at("columns").at(0).at("field") == "mode"); + assert(table_view_json.at("columns").at(0).at("filterable") == true); const auto initial = adminive::to_json(state); assert(initial.at("mode") == "receive"); assert(initial.at("port") == 9999); @@ -57,29 +115,27 @@ int main() { assert(!result.success); assert(result.field_errors.contains("mode")); assert(result.field_errors.contains("buffer_count")); - const auto form = adminive::to_amis_form_schema(state, "/admin/radio_state", "Confirm Changes"); + const auto form_view = adminive::edit_form().submit("Confirm Changes"); + const auto form = adminive::to_amis_form_schema(state, form_view, "/admin/radio_state"); assert(form.at("type") == "form"); assert(form.at("body").size() == 5); assert(form.at("body").at(0).at("type") == "select"); assert(form.at("body").at(0).at("options").size() == 4); assert(form.at("body").at(1).at("type") == "input-number"); assert(form.at("body").at(2).at("step") == 2); - assert(form.at("actions").size() == 2); - assert(form.at("actions").at(0).at("type") == "reset"); - assert(form.at("actions").at(1).at("type") == "submit"); assert(form.at("actions").at(1).at("label") == "Confirm Changes"); - const auto crud = adminive::to_amis_crud_schema("/admin/radio_states"); + const auto crud = adminive::to_amis_table_schema("/admin/radio_states", table_view); assert(crud.at("type") == "page"); - assert(crud.at("body").at("type") == "crud"); - assert(crud.at("body").at("columns").size() == 7); - assert(crud.at("body").at("columns").at(1).at("name") == "mode"); - assert(crud.at("body").at("columns").at(1).at("type") == "mapping"); - assert(crud.at("body").at("columns").at(1).at("map").at("duplex") == "双工"); - assert(crud.at("body").at("columns").at(2).at("name") == "buffer_count"); - assert(crud.at("body").at("columns").at(3).at("name") == "port"); - assert(!crud.at("body").contains("interval")); - const auto& create_form = crud.at("body").at("headerToolbar").at(2).at("dialog").at("body"); - assert(create_form.at("actions").at(1).at("type") == "submit"); + const auto& crud_body = crud.at("body").at(0); + assert(crud_body.at("type") == "crud"); + assert(crud_body.at("columns").size() == 7); + assert(crud_body.at("columns").at(1).at("name") == "mode"); + assert(crud_body.at("columns").at(1).at("type") == "mapping"); + assert(crud_body.at("columns").at(1).at("map").at("duplex") == "双工"); + assert(crud_body.at("columns").at(1).at("filterable") == true); + assert(crud_body.at("columns").at(3).at("name") == "port"); + assert(crud_body.at("columns").at(3).at("searchable") == true); + const auto& create_form = crud_body.at("headerToolbar").at(2).at("dialog").at("body"); assert(create_form.at("actions").at(1).at("label") == "新增"); const auto status_descriptor = adminive::to_status_descriptor_json(); assert(status_descriptor.at("protocol") == "adminive.status"); @@ -94,7 +150,7 @@ int main() { assert(first_status.at("refresh_sequence").at("value") == 1); assert(second_status.at("refresh_sequence").at("value") == 2); const auto overview_descriptor = adminive::to_status_descriptor_json(); - const auto status_crud = adminive::to_amis_crud_status_schema("/admin/radio_states", "/admin/status", overview_descriptor, 2000, &status_descriptor); + const auto status_crud = adminive::to_amis_table_schema("/admin/radio_states", table_view, &status_descriptor, 2000, &overview_descriptor, "/admin/status", 2000); assert(status_crud.at("body").is_array()); assert(status_crud.at("body").at(0).at("type") == "service"); assert(status_crud.at("body").at(1).at("columns").size() == 8); @@ -117,38 +173,46 @@ int main() { assert(service_descriptor.at("fields").at(3).at("value_type") == "number"); assert(service_descriptor.at("fields").at(4).at("value_type") == "object"); assert(service_descriptor.at("fields").at(4).at("children").size() == 3); - assert(service_descriptor.at("fields").at(5).at("visible_on") == "${$self.mode == 'duplex'}"); - assert(service_descriptor.at("fields").at(6).at("children").at(1).at("widget") == "input-date"); - assert(service_descriptor.at("fields").at(6).at("children").at(2).at("widget") == "input-color"); - const auto service_form = adminive::to_amis_form_schema(service_config, "/admin/config/radio/data", "Confirm Changes"); - assert(service_form.at("body").at(0).at("type") == "input-text"); - assert(service_form.at("body").at(1).at("type") == "select"); - assert(service_form.at("body").at(2).at("type") == "input-number"); - assert(service_form.at("body").at(3).at("type") == "input-number"); - assert(service_form.at("body").at(4).at("type") == "fieldset"); - assert(service_form.at("body").at(4).at("body").at(1).at("name") == "primary_endpoint.host"); - assert(service_form.at("body").at(5).at("visibleOn") == "${mode == 'duplex'}"); - assert(service_form.at("body").at(6).at("body").at(1).at("type") == "input-date"); - assert(service_form.at("body").at(6).at("body").at(1).at("valueFormat") == "YYYY-MM-DD"); - assert(service_form.at("body").at(6).at("body").at(2).at("type") == "input-color"); - assert(service_form.at("actions").at(1).at("type") == "submit"); + assert(service_descriptor.at("fields").at(5).at("presentation").at("visible_on") == "${$self.mode == 'duplex'}"); + assert(service_descriptor.at("fields").at(6).at("children").at(1).at("presentation").at("control") == "date"); + assert(service_descriptor.at("fields").at(6).at("children").at(2).at("presentation").at("control") == "color"); + const auto service_view = adminive::describe_edit_view(); + const auto service_view_json = adminive::to_view_json(service_view); + assert(service_view_json.at("body").at("kind") == "tabs"); + assert(service_view_json.at("body").at("children").size() == 3); + const auto service_form = adminive::to_amis_form_schema(service_config, service_view, "/admin/config/radio/data"); + assert(service_form.at("body").at(0).at("type") == "tabs"); + const Json* profile_control = find_named_node(service_form, "profile_name"); + const Json* mode_control = find_named_node(service_form, "mode"); + const Json* worker_control = find_named_node(service_form, "worker_count"); + const Json* date_control = find_named_node(service_form, "appearance.effective_date"); + const Json* color_control = find_named_node(service_form, "appearance.accent_color"); + assert(profile_control && profile_control->at("type") == "input-text"); + assert(mode_control && mode_control->at("type") == "select"); + assert(worker_control && worker_control->at("type") == "input-number"); + assert(date_control && date_control->at("type") == "input-date"); + assert(color_control && color_control->at("type") == "input-color"); + assert(service_form.at("actions").at(1).at("label") == "确认修改"); result = adminive::apply_frontend_patch(service_config, Json{{"profile_name", "Duplex Profile"}, {"mode", "duplex"}, {"worker_count", 8}, {"receive_gain", 2.5}, {"primary_endpoint", Json{{"enabled", true}, {"host", "10.0.0.1"}, {"port", 9200}}}, {"backup_endpoint", Json{{"enabled", true}, {"host", "10.0.0.2"}, {"port", 9201}}}, {"appearance", Json{{"panel_title", "Duplex Radio"}, {"effective_date", "2026-08-07"}, {"accent_color", "#16a34a"}}}}); assert(result.success); assert(service_config.mode == Radio_Mode::duplex); assert(service_config.primary_endpoint.host == "10.0.0.1"); assert(service_config.appearance.accent_color == "#16a34a"); Alert_Config alert_config; - const auto alert_form = adminive::to_amis_form_schema(alert_config, "/admin/config/alerts/data", "Confirm Changes"); - assert(alert_form.at("body").size() == 8); - assert(alert_form.at("body").at(1).at("type") == "input-text"); - assert(alert_form.at("body").at(2).at("type") == "select"); - assert(alert_form.at("body").at(3).at("type") == "select"); - assert(alert_form.at("body").at(4).at("type") == "input-number"); - assert(alert_form.at("body").at(5).at("type") == "input-date"); - assert(alert_form.at("body").at(6).at("type") == "input-color"); - assert(alert_form.at("body").at(7).at("type") == "fieldset"); - assert(alert_form.at("body").at(7).at("visibleOn") == "${enabled && channel == 'webhook'}"); + const auto alert_view = adminive::describe_edit_view(); + const auto alert_view_json = adminive::to_view_json(alert_view); + assert(alert_view_json.at("body").at("kind") == "vertical"); + const auto alert_form = adminive::to_amis_form_schema(alert_config, alert_view, "/admin/config/alerts/data"); + assert(find_named_node(alert_form, "rule_name")); + assert(find_named_node(alert_form, "effective_date")->at("type") == "input-date"); + assert(find_named_node(alert_form, "highlight_color")->at("type") == "input-color"); + const Json* webhook_group = find_titled_node(alert_form, "Webhook 端点"); + assert(webhook_group); + assert(webhook_group->at("visibleOn") == "${enabled && channel == 'webhook'}"); adminive::example::Http_Resource service_resource(service_config, "/admin/config/radio"); + const auto resource_view = service_resource.view_schema(); + assert(resource_view.at("protocol") == "adminive.view"); + assert(resource_view.at("kind") == "form"); const auto resource_form = service_resource.amis_schema(); assert(resource_form.at("api").at("method") == "post"); assert(resource_form.at("api").at("url") == "/admin/config/radio/data"); diff --git a/backend/service/tests/view_schema_test.cpp b/backend/service/tests/view_schema_test.cpp new file mode 100644 index 0000000..308f6fd --- /dev/null +++ b/backend/service/tests/view_schema_test.cpp @@ -0,0 +1,125 @@ +#include "adminive/adminive.hpp" +#include "adminive/adapters/nlohmann_json.hpp" +#include +#include +#include +namespace view_test { +using Json = nlohmann::json; +struct Config { + std::string name{"Alpha"}; + int count{3}; + bool enabled{true}; + std::string color{"#336699"}; +}; +} +namespace adminive { +template <> +struct Type_Descriptor { + static auto get() { + using T = view_test::Config; + return object("view_config", "View Config", ADMINIVE_FIELD(T, name).editable().creatable().label("Name").text_input(), ADMINIVE_FIELD(T, count).editable().creatable().label("Count").number_input(), ADMINIVE_FIELD(T, enabled).editable().creatable().label("Enabled").boolean_input(), ADMINIVE_FIELD(T, color).editable().creatable().label("Color").color_input()); + } +}; +template <> +struct Type_View_Descriptor { + static Form_View edit() { + using T = view_test::Config; + return edit_form(vertical(group("Identity", horizontal(use<&T::name>(), use<&T::enabled>())), grid(2, use<&T::count>(), card("Appearance", use<&T::color>())), flow(card("Name Card", use<&T::name>()), card("Count Card", use<&T::count>())), tabs(tab("General", use<&T::name>(), use<&T::count>()), tab("Visual", use<&T::color>())))).submit("Save"); + } + static Table_View table() { + using T = view_test::Config; + return table_view(column<&T::name>("Name").search().fix(Table_Fixed::left), column<&T::count>("Count").sort(), column<&T::enabled>("Enabled").filter(), column<&T::color>("Color")).titled("Configs").default_sort("count", true).create_layout(grid(2, use<&T::name>(), use<&T::count>(), use<&T::enabled>(), use<&T::color>())).edit_layout(vertical(use<&T::name>(), horizontal(use<&T::count>(), use<&T::enabled>()), use<&T::color>())); + } +}; +} +int main() { + using view_test::Config; + using view_test::Json; + const auto descriptor = adminive::to_descriptor_json(); + assert(descriptor.at("fields").at(0).at("presentation").at("label") == "Name"); + assert(descriptor.at("fields").at(0).at("presentation").at("control") == "text"); + assert(!descriptor.at("fields").at(0).contains("sortable")); + const auto form_view = adminive::describe_edit_view(); + adminive::validate_form_view(form_view); + const auto form_json = adminive::to_view_json(form_view); + assert(form_json.at("kind") == "form"); + assert(form_json.at("body").at("kind") == "vertical"); + assert(form_json.at("body").at("children").at(0).at("kind") == "group"); + assert(form_json.at("body").at("children").at(1).at("kind") == "grid"); + assert(form_json.at("body").at("children").at(2).at("kind") == "flow"); + assert(form_json.at("body").at("children").at(3).at("kind") == "tabs"); + Config config; + const auto form_amis = adminive::to_amis_form_schema(config, form_view, "/config/data"); + assert(form_amis.at("type") == "form"); + assert(form_amis.at("actions").at(1).at("label") == "Save"); + assert(form_amis.at("body").at(0).at("type") == "fieldset"); + assert(form_amis.at("body").at(1).at("type") == "grid"); + assert(form_amis.at("body").at(2).at("type") == "flex"); + assert(form_amis.at("body").at(3).at("type") == "tabs"); + const auto table_view = adminive::describe_table_view(); + adminive::validate_table_view(table_view); + const auto table_json = adminive::to_view_json(table_view); + assert(table_json.at("kind") == "table"); + assert(table_json.at("columns").at(0).at("searchable") == true); + assert(table_json.at("columns").at(0).at("fixed") == "left"); + assert(table_json.at("columns").at(1).at("sortable") == true); + assert(table_json.at("columns").at(2).at("filterable") == true); + assert(table_json.at("default_order_by") == "count"); + assert(table_json.at("default_order_dir") == "desc"); + const auto composition = adminive::composition_view("config_page", adminive::compose::vertical(adminive::compose::heading("Config Page").accent("#123456"), adminive::compose::horizontal(adminive::compose::slot("form"), adminive::compose::slot("table")))); + const auto composition_json = adminive::to_view_json(composition); + assert(composition_json.at("kind") == "composition"); + assert(composition_json.at("body").at("children").at(0).at("kind") == "heading"); + const auto composition_amis = adminive::to_amis_composition_schema(composition, {{"form", form_amis}, {"table", Json{{"type", "tpl"}, {"tpl", "table"}}}}); + assert(composition_amis.at("body").at(0).at("type") == "tpl"); + assert(composition_amis.at("body").at(1).at("type") == "grid"); + const auto table_amis = adminive::to_amis_table_schema("/configs", table_view); + assert(table_amis.at("type") == "page"); + assert(table_amis.at("body").at(0).at("columns").at(1).at("searchable") == true); + assert(table_amis.at("body").at(0).at("columns").at(2).at("sortable") == true); + assert(table_amis.at("body").at(0).at("columns").at(3).at("filterable") == true); + adminive::Collection_Service collection("/configs", {Config{"Alpha", 3, true, "#111111"}, Config{"Beta", 7, false, "#222222"}, Config{"Alphabet", 5, true, "#333333"}}); + adminive::Collection_Query query; + query.fields["name"] = "alpha"; + auto response = collection.list_response(query); + assert(response.status == 200); + assert(response.body.at("data").at("total") == 2); + assert(response.body.at("data").at("items").at(0).at("count") == 5); + query = {}; + query.fields["enabled"] = "true"; + response = collection.list_response(query); + assert(response.status == 200); + assert(response.body.at("data").at("total") == 2); + query = {}; + query.order_by = "count"; + query.order_dir = "asc"; + response = collection.list_response(query); + assert(response.status == 200); + assert(response.body.at("data").at("items").at(0).at("count") == 3); + query = {}; + query.fields["color"] = "#111111"; + response = collection.list_response(query); + assert(response.status == 400); + bool invalid_grid{}; + try { + adminive::validate_form_view(adminive::edit_form(adminive::grid(0, adminive::use<&Config::name>()))); + } catch(const std::invalid_argument&) { + invalid_grid = true; + } + assert(invalid_grid); + bool invalid_tabs{}; + try { + adminive::validate_form_view(adminive::edit_form(adminive::tabs(adminive::use<&Config::name>()))); + } catch(const std::invalid_argument&) { + invalid_tabs = true; + } + assert(invalid_tabs); + bool invalid_sort{}; + try { + adminive::validate_table_view(adminive::table_view(adminive::column<&Config::name>()).default_sort("name")); + } catch(const std::invalid_argument&) { + invalid_sort = true; + } + assert(invalid_sort); + return 0; +}