diff --git a/client b/client index 819a4b63..e17321c9 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit 819a4b6300c34e749efc14a25eca92e2c5c61e78 +Subproject commit e17321c951fd3b1d18d2b10ecd0966742091abcc diff --git a/server b/server index 32b38e5a..8b7a88ec 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 32b38e5a5bb6a3e1a794ecb7fdcf426cbb2dec27 +Subproject commit 8b7a88ec3ee3095e5a25f305d68b477696b57939 diff --git a/web/src/lib.rs b/web/src/lib.rs index 8af98615..de3401ac 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -12,6 +12,12 @@ mod schema; mod services; mod ui; mod authz; +// The server's system column vocabulary, read out of `common` the same way the +// generated protos are: this crate compiles that source tree directly instead +// of depending on the crate. Only `is_system_column` is used here. +#[path = "../../common/src/system_column.rs"] +#[allow(dead_code)] +mod system_column; mod analytics { include!(concat!( env!("CARGO_MANIFEST_DIR"), diff --git a/web/src/pages/admin/admin/loader.rs b/web/src/pages/admin/admin/loader.rs index de47f8dd..f991b446 100644 --- a/web/src/pages/admin/admin/loader.rs +++ b/web/src/pages/admin/admin/loader.rs @@ -122,6 +122,7 @@ pub(crate) async fn load_admin_page( nullable: column.is_nullable, primary_key: column.is_primary_key, quantity_ledger: column.quantity_ledger, + system: crate::system_column::is_system_column(&column.name), }) .collect() } diff --git a/web/src/pages/admin/admin/state.rs b/web/src/pages/admin/admin/state.rs index 7f4b2596..aa8e9548 100644 --- a/web/src/pages/admin/admin/state.rs +++ b/web/src/pages/admin/admin/state.rs @@ -20,6 +20,20 @@ pub(crate) struct AdminPageState { pub can_export: bool, } +impl AdminPageState { + /// The columns the user defined, in the order the backend reported them. + /// The columns pane renders these first. + pub(crate) fn user_columns(&self) -> Vec<&ColumnView> { + self.columns.iter().filter(|column| !column.system).collect() + } + + /// The columns the server puts on every managed table. They are hidden + /// everywhere else, so they trail the user's own here. + pub(crate) fn system_columns(&self) -> Vec<&ColumnView> { + self.columns.iter().filter(|column| column.system).collect() + } +} + #[derive(Debug)] pub(crate) struct ProfileView { pub name: String, @@ -40,12 +54,18 @@ pub(crate) struct ColumnView { pub nullable: bool, pub primary_key: bool, pub quantity_ledger: bool, + /// The server named this column, not the user. Ordinary screens never show + /// it, so the admin panel is the only place it is visible. + pub system: bool, } impl ColumnView { /// The badge list rendered under each column name. pub(crate) fn flags(&self) -> Vec<&'static str> { let mut flags = Vec::new(); + if self.system { + flags.push("system"); + } if self.primary_key { flags.push("primary key"); } diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index 24b81874..549d6d37 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -80,4 +80,41 @@ mod tests { assert!(html.contains(route), "missing admin action route {route}"); } } + + #[test] + fn system_columns_render_after_the_user_defined_ones() { + let column = |name: &str, system: bool| crate::pages::admin::admin::state::ColumnView { + name: name.to_string(), + data_type: "TEXT".to_string(), + nullable: true, + primary_key: false, + quantity_ledger: false, + system, + }; + let page = AdminPageState { + nav: Nav::default(), + profiles: Vec::new(), + selected_profile: Some("books".to_string()), + tables: Vec::new(), + selected_table: Some("invoice".to_string()), + // The backend reports the physical order, system columns first. + columns: vec![ + column("id", true), + column("title", false), + column("created_at", true), + ], + can_manage_tables: true, + can_manage_scripts: true, + can_manage_validations: true, + can_export: true, + }; + + let html = render_workspace(&page); + let title = html.find(">title<").expect("the user column is rendered"); + let label = html + .find("System columns") + .expect("the system group is labelled"); + let id = html.find(">id<").expect("the system column is rendered"); + assert!(title < label && label < id, "system columns must trail"); + } } diff --git a/web/static/app.css b/web/static/app.css index 74b6b4e8..7a777cbc 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -55,6 +55,9 @@ .column { display: grid; grid-template-columns: minmax(100px, 1fr) auto; gap: 3px 12px; padding: 10px 11px; border-bottom: 1px solid #edf0f3; } .column code { color: #315078; font-size: 12px; } .column small { grid-column: 1 / -1; } + .column.system { color: #6b7686; background: #fafbfc; } + .column.system code { color: #67788e; } + .group-label { margin: 14px 0 4px; padding: 0 11px; color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; } .empty { margin: 8px; color: #7c8796; } /* ---------- Forms (components/form_card.html) ---------- */ diff --git a/web/templates/pages/admin/admin/workspace.html b/web/templates/pages/admin/admin/workspace.html index 9db81f7d..c564e97d 100644 --- a/web/templates/pages/admin/admin/workspace.html +++ b/web/templates/pages/admin/admin/workspace.html @@ -49,20 +49,34 @@
-

Columns

{{ page.columns.len() }}
+
+

Columns

+ {{ page.user_columns().len() }} user · {{ page.system_columns().len() }} system +
{% if page.selected_table.is_none() %}

Select a table to inspect its columns.

{% else if page.columns.is_empty() %}

This table has no visible columns.

{% else %} - {% for column in page.columns %} + {% for column in page.user_columns() %}
{{ column.name }} {{ column.data_type }} {{ column.flags()|join(" · ") }}
{% endfor %} + {% let system_columns = page.system_columns() %} + {% if !system_columns.is_empty() %} +

System columns · hidden from users

+ {% for column in system_columns %} +
+ {{ column.name }} + {{ column.data_type }} + {{ column.flags()|join(" · ") }} +
+ {% endfor %} + {% endif %} {% endif %}