diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 72ee4d78..f1ec3110 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -12,6 +12,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [Unreleased] + +### Changed + +- **The shared profile is named by the backend** — `ProfileTreeResponse` + gained `shared_profile_name`, and every page that browses the shared tables + now reads the name from there instead of spelling `__global` itself. The + admin panel, the table-definition workspace, the add-table builder and the + transfer pages each pass that name as `GetTableCatalogRequest.profile_name`, + which is no longer optional: the shared profile is asked for by name like + any other. + +--- + ## [v0.8.46] — 2026-08-16 ### Added diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index b1411b3a..6d157d93 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -70,11 +70,13 @@ pub(crate) async fn load_page( // The global scope is the catalog's answer, here as everywhere else, so a // shared table the admin panel lists is a shared table this page can link - // to — see `crate::pages::table_scope`. + // to — see `crate::pages::table_scope`. Which profile holds the shared + // tables is the server's to say, and the tree above says it. + let shared_profile = tree.shared_profile_name.clone(); let catalog_tables = definitions .get_table_catalog( authenticated_request(headers, GetTableCatalogRequest { - profile_name: crate::pages::GLOBAL_SCOPE.to_string(), + profile_name: shared_profile.clone(), }) .map_err(|_| LoadError::Unauthenticated)?, ) @@ -127,6 +129,7 @@ pub(crate) async fn load_page( Ok(AddTablePageState { nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization), + shared_profile, profiles: tree .profiles .into_iter() diff --git a/web/src/pages/add_table/logic.rs b/web/src/pages/add_table/logic.rs index c1ea4915..2564bf0d 100644 --- a/web/src/pages/add_table/logic.rs +++ b/web/src/pages/add_table/logic.rs @@ -95,8 +95,10 @@ pub(crate) async fn create_table( } }; + // A global table is stored in the shared profile, which the loader read + // off the profile tree; the redirect below browses it under that name. let profile_name = if request.global { - "__global".to_string() + page.shared_profile.clone() } else { request.profile_name.clone() }; @@ -245,6 +247,7 @@ mod tests { }); AddTablePageState { nav: crate::ui::Nav::default(), + shared_profile: "__global".to_string(), profiles: vec!["billing".to_string()], draft, status: None, diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index 291fa328..acfee64e 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -192,6 +192,9 @@ impl BuilderForm { pub(crate) struct AddTablePageState { pub nav: crate::ui::Nav, pub profiles: Vec, + /// The profile a global table lands in, as the profile tree reports it. + /// The builder posts it as the created table's scope. + pub shared_profile: String, pub draft: TableDraft, /// The outcome of the last builder action, if any. pub status: Option, @@ -492,6 +495,7 @@ mod tests { fn the_column_list_shows_what_a_definition_row_expands_into() { let mut page = AddTablePageState { nav: crate::ui::Nav::default(), + shared_profile: "__global".to_string(), profiles: Vec::new(), draft: posted_form().to_draft(), status: None, @@ -567,6 +571,7 @@ mod tests { let page = AddTablePageState { nav: crate::ui::Nav::default(), + shared_profile: "__global".to_string(), profiles: Vec::new(), draft, status: None, diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index b61c1128..a64475f5 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -109,6 +109,7 @@ mod tests { AddTablePageState { nav: Nav::default(), + shared_profile: "__global".to_string(), profiles: vec!["billing".to_string()], draft, status: None, diff --git a/web/src/pages/admin/admin/loader.rs b/web/src/pages/admin/admin/loader.rs index cb27655c..724e4e13 100644 --- a/web/src/pages/admin/admin/loader.rs +++ b/web/src/pages/admin/admin/loader.rs @@ -7,7 +7,7 @@ use crate::{ common::Empty, table_definition::GetTableCatalogRequest, table_structure::GetTableStructureRequest, }, - pages::{GLOBAL_SCOPE, table_scope}, + pages::table_scope, services::{AuthenticationError, authenticated_request}, }; @@ -45,14 +45,16 @@ pub(crate) async fn load_admin_page( .into_inner(); // Global tables belong to every profile, so the tree repeats them under - // each one. They are browsed under the global scope instead, and a - // profile shows only the tables it owns. The catalog is where every page - // reads the global scope from — see `crate::pages::table_scope`. + // each one. They are browsed under the shared profile instead, whose name + // only the server knows, and a profile shows only the tables it owns. The + // catalog is where every page reads the global scope from — see + // `crate::pages::table_scope`. + let shared_profile = profile_tree.shared_profile_name.clone(); let global_tables = table_scope::global_tables( &definitions .get_table_catalog( authenticated_request(headers, GetTableCatalogRequest { - profile_name: crate::pages::GLOBAL_SCOPE.to_string(), + profile_name: shared_profile.clone(), }) .map_err(authentication_error)?, ) @@ -67,7 +69,7 @@ pub(crate) async fn load_admin_page( crate::i18n::Locale::from_headers(headers), "admin-global-label" ), - scope: GLOBAL_SCOPE.to_string(), + scope: shared_profile.clone(), table_count: global_tables.len(), global: true, }]; @@ -80,7 +82,7 @@ pub(crate) async fn load_admin_page( let selected_profile = (!selection.profile.is_empty()).then_some(selection.profile); let selected_tables = match selected_profile.as_deref() { - Some(GLOBAL_SCOPE) => Some(global_tables), + Some(name) if name == shared_profile => Some(global_tables), Some(name) => { if !profile_tree.profiles.iter().any(|profile| profile.name == name) { return Err(LoadError::InvalidSelection(crate::tr!( @@ -170,6 +172,7 @@ pub(crate) async fn load_admin_page( Ok(AdminPageState { nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization), profiles, + shared_profile, selected_profile, tables, selected_table, diff --git a/web/src/pages/admin/admin/state.rs b/web/src/pages/admin/admin/state.rs index cd4452be..04d50ac2 100644 --- a/web/src/pages/admin/admin/state.rs +++ b/web/src/pages/admin/admin/state.rs @@ -10,6 +10,9 @@ pub(crate) struct AdminSelection { pub(crate) struct AdminPageState { pub nav: crate::ui::Nav, pub profiles: Vec, + /// The profile the shared tables are stored in, as the profile tree + /// reports it. The page never spells the name itself. + pub shared_profile: String, pub selected_profile: Option, pub tables: Vec, pub selected_table: Option, @@ -31,7 +34,7 @@ pub(crate) struct AdminPageState { impl AdminPageState { /// Whether the pane is pointed at the global scope rather than a profile. pub(crate) fn is_global(&self) -> bool { - self.selected_profile.as_deref() == Some(crate::pages::GLOBAL_SCOPE) + self.selected_profile.as_deref() == Some(self.shared_profile.as_str()) } /// The columns the user defined, in the order the backend reported them. diff --git a/web/src/pages/admin/admin/ui.rs b/web/src/pages/admin/admin/ui.rs index 2b10b98a..b3812080 100644 --- a/web/src/pages/admin/admin/ui.rs +++ b/web/src/pages/admin/admin/ui.rs @@ -47,6 +47,11 @@ pub(crate) fn render_error(locale: Locale, message: &str) -> String { mod tests { use super::*; + /// What this deployment's server happens to call the shared profile. The + /// page is handed the name rather than knowing it, so the tests spell it + /// once here. + const SHARED_PROFILE: &str = "__global"; + #[test] fn dashboard_links_every_admin_action() { // The admin page is only reachable with a session, so the navbar @@ -65,6 +70,7 @@ mod tests { let page = AdminPageState { nav, profiles: Vec::new(), + shared_profile: SHARED_PROFILE.to_string(), selected_profile: None, tables: Vec::new(), selected_table: None, @@ -99,7 +105,7 @@ mod tests { profiles: vec![ ProfileView { label: "Global".to_string(), - scope: crate::pages::GLOBAL_SCOPE.to_string(), + scope: SHARED_PROFILE.to_string(), table_count: 2, global: true, }, @@ -110,7 +116,8 @@ mod tests { global: false, }, ], - selected_profile: Some(crate::pages::GLOBAL_SCOPE.to_string()), + shared_profile: SHARED_PROFILE.to_string(), + selected_profile: Some(SHARED_PROFILE.to_string()), tables: Vec::new(), selected_table: None, columns: Vec::new(), @@ -141,6 +148,7 @@ mod tests { let page = AdminPageState { nav: Nav::default(), profiles: Vec::new(), + shared_profile: SHARED_PROFILE.to_string(), selected_profile: Some("books".to_string()), tables: vec![TableView { name: "invoice".to_string(), @@ -180,7 +188,8 @@ mod tests { let page = AdminPageState { nav: Nav::default(), profiles: Vec::new(), - selected_profile: Some(crate::pages::GLOBAL_SCOPE.to_string()), + shared_profile: SHARED_PROFILE.to_string(), + selected_profile: Some(SHARED_PROFILE.to_string()), tables: vec![TableView { name: "currencies".to_string(), depends_on: Vec::new(), @@ -218,6 +227,7 @@ mod tests { let page = AdminPageState { nav: Nav::default(), profiles: Vec::new(), + shared_profile: SHARED_PROFILE.to_string(), selected_profile: Some("books".to_string()), tables: Vec::new(), selected_table: Some("invoice".to_string()), diff --git a/web/src/pages/admin/table_definition/loader.rs b/web/src/pages/admin/table_definition/loader.rs index ebd7170f..d700cc5a 100644 --- a/web/src/pages/admin/table_definition/loader.rs +++ b/web/src/pages/admin/table_definition/loader.rs @@ -30,7 +30,7 @@ use crate::{ }; use super::state::{ - DetailColumn, GLOBAL_SCOPE, LoadError, PageInputs, RenameEntry, ScriptView, + DetailColumn, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState, TableDetailView, TableSummary, }; @@ -93,6 +93,9 @@ pub(crate) async fn load_page( .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); + // The profile the shared tables are stored in is the server's to name, so + // it is read off the tree rather than spelled here. + let shared_profile = tree.shared_profile_name.clone(); let profiles = tree .profiles .iter() @@ -103,7 +106,7 @@ pub(crate) async fn load_page( // says so — the commonest way to get here with a stale one is having just // deleted its last table, which is worth being told. if inputs.selection.has_profile() - && inputs.selection.profile != GLOBAL_SCOPE + && !inputs.selection.is_shared_profile(&shared_profile) && !profiles.contains(&inputs.selection.profile.as_str()) { let missing = std::mem::take(&mut inputs.selection.profile); @@ -122,7 +125,7 @@ pub(crate) async fn load_page( let catalog_tables = definitions .get_table_catalog( authenticated_request(headers, GetTableCatalogRequest { - profile_name: crate::pages::GLOBAL_SCOPE.to_string(), + profile_name: shared_profile.clone(), }) .map_err(|_| LoadError::Unauthenticated)?, ) @@ -130,7 +133,7 @@ pub(crate) async fn load_page( .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner() .tables; - let selected_tables = if inputs.selection.is_global() { + let selected_tables = if inputs.selection.is_shared_profile(&shared_profile) { table_scope::global_tables(&catalog_tables) } else { table_scope::profile_owned_tables(&tree.profiles, &inputs.selection.profile) @@ -138,7 +141,7 @@ pub(crate) async fn load_page( // A shared table is a link target from every scope, which is why this is // not the list above: browsing a profile shows the profile's own tables, // and linking from one may reach the shared ones too. - let link_targets = if inputs.selection.is_global() { + let link_targets = if inputs.selection.is_shared_profile(&shared_profile) { table_scope::global_tables(&catalog_tables) } else { table_scope::linkable_tables(&tree.profiles, &catalog_tables, &inputs.selection.profile) @@ -167,7 +170,9 @@ pub(crate) async fn load_page( .any(|table| table.name == inputs.selection.table) { let missing = std::mem::take(&mut inputs.selection.table); - let scope = inputs.selection.scope_label(&crate::i18n::Locale::from_headers(headers)); + let scope = inputs + .selection + .scope_label(&crate::i18n::Locale::from_headers(headers), &shared_profile); inputs.error.get_or_insert(crate::tr!( crate::i18n::Locale::from_headers(headers), "td-err-not-a-table", @@ -269,7 +274,7 @@ pub(crate) async fn load_page( // The append panel is held to the rules of the table it is appending to: a // shared table keeps no quantity ledger, and no link may point at the table // itself. - inputs.columns.global = inputs.selection.is_global() + inputs.columns.global = inputs.selection.is_shared_profile(&shared_profile) || tables .iter() .any(|table| table.name == inputs.selection.table && table.global); @@ -277,6 +282,7 @@ pub(crate) async fn load_page( Ok(TableDefinitionPageState { nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization), + shared_profile, tables, link_targets, detail, diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index abbdcc43..668aac7b 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -11,8 +11,6 @@ use crate::schema::{ColumnCatalog, ColumnDraft}; -pub(crate) use crate::pages::GLOBAL_SCOPE; - /// The profile and table the workspace is pointed at. Arrives as a query /// string on the selector and on the column-panel endpoints, and as hidden /// fields on the panels that write. @@ -33,8 +31,10 @@ impl Selection { !self.table.is_empty() } - pub(crate) fn is_global(&self) -> bool { - self.profile == GLOBAL_SCOPE + /// Whether the selection is the shared profile, whose name the caller has + /// from the profile tree — the page does not spell it itself. + pub(crate) fn is_shared_profile(&self, shared_profile: &str) -> bool { + self.profile == shared_profile } /// The query string the column panel posts back to, so the panel's own @@ -48,10 +48,14 @@ impl Selection { format!("?profile={}", self.profile) } - /// What the heading calls the scope, so the global one is not shown by its - /// sentinel name. - pub(crate) fn scope_label(&self, locale: &crate::i18n::Locale) -> String { - if self.is_global() { + /// What the heading calls the scope, so the shared profile is not shown by + /// its schema name. + pub(crate) fn scope_label( + &self, + locale: &crate::i18n::Locale, + shared_profile: &str, + ) -> String { + if self.is_shared_profile(shared_profile) { crate::tr!(*locale, "td-global-label") } else { self.profile.clone() @@ -300,6 +304,10 @@ impl PageInputs { pub(crate) struct TableDefinitionPageState { pub nav: crate::ui::Nav, pub selection: Selection, + /// The profile the shared tables are stored in, as the profile tree + /// reports it. Everything that has to recognise the shared scope reads it + /// from here rather than knowing the name. + pub shared_profile: String, /// The tables of the selected scope — what the workspace browses and acts /// on. pub tables: Vec, @@ -327,6 +335,17 @@ impl TableDefinitionPageState { self.active == name } + /// Whether the workspace is pointed at the shared profile rather than a + /// profile of its own. Read by context.html. + pub(crate) fn is_global(&self) -> bool { + self.selection.is_shared_profile(&self.shared_profile) + } + + /// The heading's name for the selected scope. Read by context.html. + pub(crate) fn scope_label(&self, locale: &crate::i18n::Locale) -> String { + self.selection.scope_label(locale, &self.shared_profile) + } + /// A table cannot link to itself, and the chart of accounts is reached /// through an ACCOUNTING row rather than through a link of one's own. fn eligible_link_target(&self, table: &TableSummary) -> bool { diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index 3558e731..a7656f22 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -256,9 +256,14 @@ mod tests { } } + /// What this deployment's server happens to call the shared profile. The + /// page is handed the name rather than knowing it. + const SHARED_PROFILE: &str = "__global"; + fn page() -> TableDefinitionPageState { TableDefinitionPageState { nav: Nav::default(), + shared_profile: SHARED_PROFILE.to_string(), selection: Selection { profile: "billing".to_string(), table: "invoice".to_string(), @@ -351,7 +356,7 @@ mod tests { fn the_delete_page_accepts_a_global_table() { let mut state = page(); state.selection = Selection { - profile: crate::pages::GLOBAL_SCOPE.to_string(), + profile: SHARED_PROFILE.to_string(), table: "currencies".to_string(), }; state.tables = vec![TableSummary { @@ -661,7 +666,7 @@ mod tests { #[test] fn global_tables_have_their_own_scope() { let mut state = page(); - state.selection.profile = "__global".to_string(); + state.selection.profile = SHARED_PROFILE.to_string(); let html = render_presentation_page(&state); diff --git a/web/src/pages/import_export/common/loader.rs b/web/src/pages/import_export/common/loader.rs index a88d6664..1d6c6479 100644 --- a/web/src/pages/import_export/common/loader.rs +++ b/web/src/pages/import_export/common/loader.rs @@ -5,7 +5,7 @@ use crate::{ auth::GetAuthorizationRequest, definitions::{common::Empty, table_definition::GetTableCatalogRequest}, i18n::Locale, - pages::{GLOBAL_SCOPE, table_scope}, + pages::table_scope, services::authenticated_request, }; @@ -16,8 +16,9 @@ pub(crate) struct Catalog { /// One entry of the scope selector: the global scope, or a profile. pub(crate) struct Profile { - /// What the form posts, and what the backend resolves the table in — - /// `__global` for the shared tables, the schema name otherwise. + /// What the form posts, and what the backend resolves the table in — the + /// schema name, which for the shared tables is the profile the server + /// reports as holding them. pub name: String, /// What the selector shows. The global scope is not a schema a user names, /// so it is labelled in their language instead. @@ -69,14 +70,16 @@ pub(crate) async fn load_catalog( // A global table is shared by every profile, so the tree repeats it under // each one. Listing it there would offer the same table once per profile // and make "the tables of this profile" mean two different things; it is - // offered under the global scope instead, which — like every other page — - // is read from the catalog rather than derived from the tree. See + // offered under the shared profile instead — named by the tree, since only + // the server knows it — whose tables, like on every other page, are read + // from the catalog rather than derived from the tree. See // `crate::pages::table_scope`. + let shared_profile = tree.shared_profile_name.clone(); let global = table_scope::global_tables( &definitions .get_table_catalog( authenticated_request(headers, GetTableCatalogRequest { - profile_name: crate::pages::GLOBAL_SCOPE.to_string(), + profile_name: shared_profile.clone(), }) .map_err(|_| LoadError::Unauthenticated)?, ) @@ -87,15 +90,15 @@ pub(crate) async fn load_catalog( ) .into_iter() .map(|table| table.name) - .filter(|table| permits(GLOBAL_SCOPE, table)) + .filter(|table| permits(&shared_profile, table)) .collect::>(); let locale = Locale::from_headers(headers); let mut profiles = Vec::new(); if !global.is_empty() { profiles.push(Profile { - name: GLOBAL_SCOPE.to_string(), - label: scope_label(locale, GLOBAL_SCOPE), + label: scope_label(locale, &shared_profile, &shared_profile), + name: shared_profile.clone(), tables: global, }); } @@ -146,7 +149,7 @@ pub(crate) async fn load_catalog( } else { profiles.push(Profile { name: profile_name.to_string(), - label: scope_label(locale, profile_name), + label: scope_label(locale, profile_name, &shared_profile), tables: vec![table_name.to_string()], }); } @@ -157,10 +160,10 @@ pub(crate) async fn load_catalog( }) } -/// A profile shows its schema name; the global scope is not one a user named, -/// so it is labelled in their language. -fn scope_label(locale: Locale, name: &str) -> String { - if name == GLOBAL_SCOPE { +/// A profile shows its schema name; the shared profile is not one a user +/// named, so it is labelled in their language. +fn scope_label(locale: Locale, name: &str, shared_profile: &str) -> String { + if name == shared_profile { crate::tr!(locale, "admin-global-label") } else { name.to_string() diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs index 9236b41e..c90491c4 100644 --- a/web/src/pages/mod.rs +++ b/web/src/pages/mod.rs @@ -1,10 +1,3 @@ -/// The pseudo-profile a page posts when the selection is the global scope. -/// -/// Global tables belong to every profile, so the backend keeps them in a -/// schema of their own rather than in any one profile's. A selector that lists -/// profiles offers this alongside them, and the backend recognises the name. -pub(crate) const GLOBAL_SCOPE: &str = "__global"; - pub(crate) mod add_logic; pub(crate) mod add_table; pub(crate) mod add_validation; diff --git a/web/src/pages/table_scope.rs b/web/src/pages/table_scope.rs index bd48c772..74c46081 100644 --- a/web/src/pages/table_scope.rs +++ b/web/src/pages/table_scope.rs @@ -63,7 +63,10 @@ pub(crate) fn linkable_tables( #[cfg(test)] pub(crate) mod tests { use super::*; - use crate::pages::GLOBAL_SCOPE; + + /// What this deployment's server happens to call the shared profile; the + /// tables carry it, and nothing here composes it. + const SHARED_PROFILE: &str = "__global"; pub(crate) fn table(name: &str, global: bool) -> Table { Table { @@ -74,7 +77,7 @@ pub(crate) mod tests { table_kind: "dynamic".to_string(), global, profile_name: if global { - GLOBAL_SCOPE.to_string() + SHARED_PROFILE.to_string() } else { "test_profile".to_string() }, diff --git a/web/templates/pages/admin/table_definition/context.html b/web/templates/pages/admin/table_definition/context.html index 0a5b5ee7..da54e6d8 100644 --- a/web/templates/pages/admin/table_definition/context.html +++ b/web/templates/pages/admin/table_definition/context.html @@ -12,10 +12,10 @@

- {%- if page.selection.has_table() %}{{ page.selection.scope_label(nav.locale) }}{% else %}{{ nav.tr("td-eyebrow-table-definition") }}{% endif -%} + {%- if page.selection.has_table() %}{{ page.scope_label(nav.locale) }}{% else %}{{ nav.tr("td-eyebrow-table-definition") }}{% endif -%}

- {%- if page.selection.has_table() %}{{ page.selection.table }}{% else %}{{ page.selection.scope_label(nav.locale) }}{% endif -%} + {%- if page.selection.has_table() %}{{ page.selection.table }}{% else %}{{ page.scope_label(nav.locale) }}{% endif -%}

{%- if let Some(detail) = page.detail -%} @@ -53,7 +53,7 @@ {{ nav.tr("td-tab-access") }}{{ nav.tr("td-tab-access-sub") }} {% endif %} - {% if page.selection.has_profile() && !page.selection.is_global() %} + {% if page.selection.has_profile() && !page.is_global() %} {{ nav.tr("td-tab-copy") }}{{ nav.tr("td-tab-copy-sub") }}