313 lines
13 KiB
Rust
313 lines
13 KiB
Rust
//! 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<Channel>,
|
|
headers: &HeaderMap,
|
|
) -> Result<ColumnCatalog, LoadError> {
|
|
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<TableDefinitionPageState, LoadError> {
|
|
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::<Vec<_>>();
|
|
|
|
// 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::<Vec<_>>();
|
|
let link_targets = link_targets.into_iter().map(summary).collect::<Vec<_>>();
|
|
|
|
// 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: "",
|
|
})
|
|
}
|