global table profile name from server in web now

This commit is contained in:
Priec
2026-08-17 13:24:34 +02:00
parent efd9e2481b
commit f0d12584ef
15 changed files with 128 additions and 57 deletions

View File

@@ -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()

View File

@@ -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,

View File

@@ -192,6 +192,9 @@ impl BuilderForm {
pub(crate) struct AddTablePageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
/// 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<String>,
@@ -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,

View File

@@ -109,6 +109,7 @@ mod tests {
AddTablePageState {
nav: Nav::default(),
shared_profile: "__global".to_string(),
profiles: vec!["billing".to_string()],
draft,
status: None,

View File

@@ -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,

View File

@@ -10,6 +10,9 @@ pub(crate) struct AdminSelection {
pub(crate) struct AdminPageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<ProfileView>,
/// 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<String>,
pub tables: Vec<TableView>,
pub selected_table: Option<String>,
@@ -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.

View File

@@ -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()),

View File

@@ -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,

View File

@@ -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<TableSummary>,
@@ -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 {

View File

@@ -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);

View File

@@ -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::<Vec<_>>();
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()

View File

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

View File

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