//! Reads the context every table-definition page shows. //! //! One loader serves them all, because they all need the same thing: which //! table is being worked on, and what it currently is. Four calls, in this //! order — the column-type catalog is the vocabulary the append panel offers, //! the profile tree names the tables, the profile details describe the //! selected table's columns and scripts, and the rename history explains how //! those columns got their names. //! //! Nothing here trusts the posted selection — a profile or table that is gone //! is dropped from the selection rather than reported as an error, because the //! commonest way to get here with a stale one is having just deleted it. use axum::http::HeaderMap; use tonic::transport::Channel; use crate::{ AppState, auth::GetAuthorizationRequest, definitions::{ common::Empty, table_definition::{ AliasChangeKind, GetAliasChangeHistoryRequest, GetProfileDetailsRequest, GetTableCatalogRequest, MoneyRounding, table_definition_client::TableDefinitionClient, }, }, pages::table_scope, schema::{ColumnCatalog, column_catalog}, services::authenticated_request, }; use super::state::{ DetailColumn, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState, TableDetailView, TableSummary, }; /// Reads the column-type vocabulary on its own. /// /// The panel's handlers stage and validate a column before the rest of the /// workspace is read, and they cannot do either without the vocabulary, so /// they fetch it with this and hand it to [`load_page`] on the draft. pub(crate) async fn load_column_catalog( definitions: &mut TableDefinitionClient, headers: &HeaderMap, ) -> Result { let request = authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?; let response = definitions .list_column_types(request) .await .map_err(|error| match error.code() { tonic::Code::Unauthenticated => LoadError::Unauthenticated, _ => LoadError::Backend(error.message().to_string()), })?; Ok(column_catalog(response.into_inner().column_types)) } pub(crate) async fn load_page( state: AppState, headers: &HeaderMap, mut inputs: PageInputs, ) -> Result { let authorization_request = authenticated_request(headers, GetAuthorizationRequest {}) .map_err(|_| LoadError::Unauthenticated)?; let mut auth = state.auth; let authorization = auth .get_authorization(authorization_request) .await .map_err(|error| match error.code() { tonic::Code::Unauthenticated => LoadError::Unauthenticated, _ => LoadError::Backend(error.message().to_string()), })? .into_inner(); if !crate::authz::can_manage(&authorization, crate::authz::STRUCT_TABLE) { return Err(LoadError::Forbidden); } let mut definitions = state.definitions; // A handler that had to stage or validate a column before it got here has // already read the vocabulary; anything else reads it now. if !inputs.columns.catalog.is_loaded() { inputs.columns.catalog = load_column_catalog(&mut definitions, headers).await?; } // Also what the definition below is read through: the catalog describes // the server-generated companion types as well as the declarable ones. let catalog = inputs.columns.catalog.clone(); let tree = definitions .get_profile_tree( authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?, ) .await .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() .map(|profile| profile.name.as_str()) .collect::>(); // A profile that no longer exists takes the table selection with it, and // 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.is_shared_profile(&shared_profile) && !profiles.contains(&inputs.selection.profile.as_str()) { let missing = std::mem::take(&mut inputs.selection.profile); inputs.selection.table.clear(); inputs.error.get_or_insert(crate::tr!( crate::i18n::Locale::from_headers(headers), "td-err-profile-gone", "missing" => missing, )); } // The same two sources the admin panel browses by, so a table it offers is // a table this page can act on. Reading the global scope out of the tree // instead is what used to answer "No table chosen" for a table the panel // had just listed. let catalog_tables = definitions .get_table_catalog( authenticated_request(headers, GetTableCatalogRequest { profile_name: None, }) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner() .tables; 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) }; // 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_shared_profile(&shared_profile) { table_scope::global_tables(&catalog_tables) } else { table_scope::linkable_tables(&tree.profiles, &catalog_tables, &inputs.selection.profile) }; let summary = |table: crate::definitions::table_definition::profile_tree_response::Table| TableSummary { name: table.name, table_kind: table.table_kind, global: table.global, // One entry per link, named by the column carrying it, so a table // pointing at one target twice reads as two links. depends_on: table .depends_on .into_iter() .map(|dependency| format!("{} ({})", dependency.table_name, dependency.column_name)) .collect(), }; let tables = selected_tables.into_iter().map(summary).collect::>(); let link_targets = link_targets.into_iter().map(summary).collect::>(); // A table the scope does not hold is dropped rather than acted on. It is // said out loud, though: dropping it in silence is what left the panel // telling someone who had just picked a table to go and pick one. if inputs.selection.has_table() && !tables .iter() .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), &shared_profile); inputs.error.get_or_insert(crate::tr!( crate::i18n::Locale::from_headers(headers), "td-err-not-a-table", "missing" => missing, "scope" => scope, )); } let detail = match inputs.selection.has_table() { true => { let request = GetProfileDetailsRequest { profile_name: inputs.selection.profile.clone(), }; let details = definitions .get_profile_details( authenticated_request(headers, request) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); details .tables .into_iter() .find(|table| table.name == inputs.selection.table) .map(|table| TableDetailView { id: table.id, row_version: table.row_version, has_data: table.has_data, columns: table .columns .iter() .map(|column| { let behavior = table.column_behaviors.get(&column.name); DetailColumn { column_id: behavior.map(|behavior| behavior.column_id).unwrap_or_default(), name: column.name.clone(), sql_type: catalog.sql_type(&column.field_type), field_type: column.field_type.clone(), currency: column.currency.clone(), quantity_ledger: column.quantity_ledger, rounded: column.rounding == i32::from(MoneyRounding::HalfUp), generated: behavior.is_some_and(|behavior| behavior.generated), read_only: behavior.is_some_and(|behavior| behavior.read_only), generated_from: behavior .map(|behavior| behavior.generated_from.clone()) .unwrap_or_default(), // Renaming changes shared table metadata, so an // incomplete server capability response does // not grant permission by omission. renameable: behavior.is_some_and(|behavior| behavior.renameable), hidden_from_forms: behavior .is_some_and(|behavior| behavior.hidden_from_forms), } }) .collect(), scripts: table .scripts .into_iter() .map(|script| ScriptView { target_column: script.target_column, target_column_type: script.target_column_type, description: script.description, script: script.script, }) .collect(), row_display_columns: table.row_display_columns, }) } false => None, }; // The history is per profile; a selected table narrows it to that table. let history = match inputs.selection.has_profile() { true => { let request = GetAliasChangeHistoryRequest { profile_name: inputs.selection.profile.clone(), table_definition_id: detail.as_ref().map(|detail| detail.id), column_id: None, alias_kind: AliasChangeKind::Column as i32, option_value: None, limit: 0, }; definitions .get_alias_change_history( authenticated_request(headers, request) .map_err(|_| LoadError::Unauthenticated)?, ) .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner() .entries .into_iter() .map(|entry| RenameEntry { table_name: entry.table_name, old_column_name: entry.old_alias.unwrap_or_default(), new_column_name: entry.new_alias.unwrap_or_default(), created_at: entry.changed_at, }) .collect() } false => Vec::new(), }; // 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_shared_profile(&shared_profile) || tables .iter() .any(|table| table.name == inputs.selection.table && table.global); if inputs.columns.table_name.is_empty() { inputs.columns.table_name = inputs.selection.table.clone(); } Ok(TableDefinitionPageState { nav: crate::ui::Nav::from_authorization(headers, "admin", &authorization), shared_profile, tables, link_targets, detail, history, selection: inputs.selection, columns: inputs.columns, remove_column_ids: inputs.remove_column_ids, copy: inputs.copy, invoice: inputs.invoice, status: inputs.status, error: inputs.error, sql: inputs.sql, generated: inputs.generated, // Overwritten by the handler, which is the only thing that knows // which of the pages it is answering for. active: "", }) }