global is being global and i can select it and so on
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState, auth::GetAuthorizationRequest, definitions::common::Empty,
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
definitions::{common::Empty, table_definition::GetTableCatalogRequest},
|
||||
pages::table_scope,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
@@ -59,78 +62,60 @@ pub(crate) async fn load_page(
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner();
|
||||
|
||||
let effective_profile = draft.effective_profile_name();
|
||||
// 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`.
|
||||
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;
|
||||
|
||||
// A global table belongs to every profile, so it is a link target from
|
||||
// every scope. It is listed under each profile in the tree, so it is
|
||||
// collected once here and deduplicated by name.
|
||||
let global_tables = tree
|
||||
.profiles
|
||||
.iter()
|
||||
.flat_map(|profile| profile.tables.iter())
|
||||
.filter(|table| table.global)
|
||||
.map(|table| (table.name.clone(), table.table_kind.clone()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>()
|
||||
let effective_profile = draft.effective_profile_name();
|
||||
// A global table is a link target from every scope, and a global table
|
||||
// being created may only link to another global one.
|
||||
let tables = if draft.global {
|
||||
table_scope::global_tables(&catalog_tables)
|
||||
} else {
|
||||
table_scope::linkable_tables(&tree.profiles, &catalog_tables, &effective_profile)
|
||||
};
|
||||
|
||||
let table_options = tables
|
||||
.into_iter()
|
||||
.map(|(name, table_kind)| RelationTableOption {
|
||||
name,
|
||||
global: true,
|
||||
system: table_kind == "system",
|
||||
// The profile's ledger accounts are not a link target of their own: an
|
||||
// ACCOUNTING column is how a row is posted to one.
|
||||
.filter(|table| table.name != "accounts")
|
||||
.map(|table| RelationTableOption {
|
||||
name: table.name,
|
||||
global: table.global,
|
||||
system: table.table_kind == "system",
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if draft.global {
|
||||
draft.existing_profile_tables = tree
|
||||
.profiles
|
||||
// Every table the new one would sit beside holds its name — including the
|
||||
// shared ones, which exist in every profile, and including in a profile
|
||||
// that does not exist yet. A global table would land in every profile at
|
||||
// once, so for that scope every name in the deployment is taken.
|
||||
draft.existing_profile_tables = if draft.global {
|
||||
tree.profiles
|
||||
.iter()
|
||||
.flat_map(|profile| profile.tables.iter())
|
||||
.map(|table| table.name.clone())
|
||||
.chain(catalog_tables.iter().map(|table| table.name.clone()))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
draft.set_available_relation_table_options(global_tables);
|
||||
} else { match tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == effective_profile)
|
||||
{
|
||||
// An existing profile: its tables are the link targets, and their
|
||||
// names are reserved against duplicate table creation.
|
||||
Some(profile) => {
|
||||
let mut table_options = profile
|
||||
.tables
|
||||
.iter()
|
||||
.filter(|table| table.name != "accounts")
|
||||
.map(|table| RelationTableOption {
|
||||
name: table.name.clone(),
|
||||
global: table.global,
|
||||
system: table.table_kind == "system",
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// The shared tables belong here whether or not the tree repeated
|
||||
// them under this profile.
|
||||
for global in &global_tables {
|
||||
if !table_options.iter().any(|table| table.name == global.name) {
|
||||
table_options.push(global.clone());
|
||||
}
|
||||
}
|
||||
draft.existing_profile_tables = table_options
|
||||
.iter()
|
||||
.map(|table| table.name.clone())
|
||||
.collect();
|
||||
draft.set_available_relation_table_options(table_options);
|
||||
}
|
||||
// A brand-new (or not-yet-named) profile has no tables of its own,
|
||||
// but the shared ones are there to link to — and their names are
|
||||
// taken, so the new table may not reuse one either.
|
||||
None => {
|
||||
draft.existing_profile_tables = global_tables
|
||||
.iter()
|
||||
.map(|table| table.name.clone())
|
||||
.collect();
|
||||
draft.set_available_relation_table_options(global_tables);
|
||||
}
|
||||
}}
|
||||
.collect()
|
||||
} else {
|
||||
table_options
|
||||
.iter()
|
||||
.map(|table| table.name.clone())
|
||||
.collect()
|
||||
};
|
||||
draft.set_available_relation_table_options(table_options);
|
||||
|
||||
Ok(AddTablePageState {
|
||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
common::Empty, table_definition::GetTableCatalogRequest,
|
||||
table_structure::GetTableStructureRequest,
|
||||
},
|
||||
pages::GLOBAL_SCOPE,
|
||||
pages::{GLOBAL_SCOPE, table_scope},
|
||||
services::{AuthenticationError, authenticated_request},
|
||||
};
|
||||
|
||||
@@ -46,16 +46,19 @@ pub(crate) async fn load_admin_page(
|
||||
|
||||
// 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.
|
||||
let global_tables = definitions
|
||||
.get_table_catalog(
|
||||
authenticated_request(headers, GetTableCatalogRequest { profile_name: None })
|
||||
.map_err(authentication_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner()
|
||||
.tables;
|
||||
// profile shows only the tables it owns. The catalog is where every page
|
||||
// reads the global scope from — see `crate::pages::table_scope`.
|
||||
let global_tables = table_scope::global_tables(
|
||||
&definitions
|
||||
.get_table_catalog(
|
||||
authenticated_request(headers, GetTableCatalogRequest { profile_name: None })
|
||||
.map_err(authentication_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||
.into_inner()
|
||||
.tables,
|
||||
);
|
||||
|
||||
let mut profiles = vec![ProfileView {
|
||||
label: "Global".to_string(),
|
||||
@@ -72,49 +75,46 @@ 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.as_slice()),
|
||||
Some(name) => Some(
|
||||
profile_tree
|
||||
.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == name)
|
||||
.ok_or_else(|| LoadError::InvalidSelection(format!("Unknown profile '{name}'")))?
|
||||
.tables
|
||||
.as_slice(),
|
||||
),
|
||||
Some(GLOBAL_SCOPE) => Some(global_tables),
|
||||
Some(name) => {
|
||||
if !profile_tree.profiles.iter().any(|profile| profile.name == name) {
|
||||
return Err(LoadError::InvalidSelection(format!("Unknown profile '{name}'")));
|
||||
}
|
||||
Some(table_scope::profile_owned_tables(&profile_tree.profiles, name))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let scope_is_chosen = selected_tables.is_some();
|
||||
let tables = selected_tables
|
||||
.map(|tables| {
|
||||
tables
|
||||
.iter()
|
||||
.filter(|table| table.global == (selected_profile.as_deref() == Some(GLOBAL_SCOPE)))
|
||||
.into_iter()
|
||||
.map(|table| TableView {
|
||||
name: table.name.clone(),
|
||||
name: table.name,
|
||||
// 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
|
||||
.iter()
|
||||
.into_iter()
|
||||
.map(|dependency| {
|
||||
format!("{} ({})", dependency.table_name, dependency.column_name)
|
||||
})
|
||||
.collect(),
|
||||
row_display_columns: table.row_display_columns.clone(),
|
||||
table_kind: table.table_kind.clone(),
|
||||
row_display_columns: table.row_display_columns,
|
||||
table_kind: table.table_kind,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let selected_table = (!selection.table.is_empty()).then_some(selection.table);
|
||||
if let Some(table_name) = selected_table.as_deref() {
|
||||
if selected_tables.is_none() || !tables.iter().any(|table| table.name == table_name) {
|
||||
return Err(LoadError::InvalidSelection(format!(
|
||||
"Table '{table_name}' is not part of the selected profile"
|
||||
)));
|
||||
}
|
||||
if let Some(table_name) = selected_table.as_deref()
|
||||
&& (!scope_is_chosen || !tables.iter().any(|table| table.name == table_name))
|
||||
{
|
||||
return Err(LoadError::InvalidSelection(format!(
|
||||
"Table '{table_name}' is not part of the selected profile"
|
||||
)));
|
||||
}
|
||||
|
||||
let columns = match (selected_profile.as_deref(), selected_table.as_deref()) {
|
||||
|
||||
@@ -159,6 +159,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A global table is picked in the global scope, and the actions offered
|
||||
/// for it carry that scope. The page they lead to has to accept it — see
|
||||
/// `the_delete_page_accepts_a_global_table` for the other half of this.
|
||||
#[test]
|
||||
fn a_global_table_is_offered_the_same_actions() {
|
||||
use crate::pages::admin::admin::state::TableView;
|
||||
|
||||
let page = AdminPageState {
|
||||
nav: Nav::default(),
|
||||
profiles: Vec::new(),
|
||||
selected_profile: Some(crate::pages::GLOBAL_SCOPE.to_string()),
|
||||
tables: vec![TableView {
|
||||
name: "currencies".to_string(),
|
||||
depends_on: Vec::new(),
|
||||
row_display_columns: vec!["code".to_string()],
|
||||
table_kind: "dynamic".to_string(),
|
||||
}],
|
||||
selected_table: Some("currencies".to_string()),
|
||||
columns: Vec::new(),
|
||||
can_manage_tables: true,
|
||||
can_manage_scripts: true,
|
||||
can_manage_validations: true,
|
||||
can_export: true,
|
||||
can_ecb: true,
|
||||
};
|
||||
|
||||
let html = render_workspace(&page);
|
||||
assert!(
|
||||
html.contains("/admin/tables/delete?profile=__global&table=currencies"),
|
||||
"{html}"
|
||||
);
|
||||
assert!(html.contains("/admin/tables/new?global=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_columns_render_after_the_user_defined_ones() {
|
||||
let column = |name: &str, system: bool| crate::pages::admin::admin::state::ColumnView {
|
||||
|
||||
@@ -20,10 +20,11 @@ use crate::{
|
||||
definitions::{
|
||||
common::Empty,
|
||||
table_definition::{
|
||||
GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding,
|
||||
table_definition_client::TableDefinitionClient,
|
||||
GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, GetTableCatalogRequest,
|
||||
MoneyRounding, table_definition_client::TableDefinitionClient,
|
||||
},
|
||||
},
|
||||
pages::table_scope,
|
||||
schema::{ColumnCatalog, column_catalog},
|
||||
services::authenticated_request,
|
||||
};
|
||||
@@ -98,50 +99,68 @@ pub(crate) async fn load_page(
|
||||
.map(|profile| profile.name.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// A profile that no longer exists takes the table selection with it.
|
||||
if inputs.selection.profile != GLOBAL_SCOPE
|
||||
// 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.profile != GLOBAL_SCOPE
|
||||
&& !profiles.contains(&inputs.selection.profile.as_str())
|
||||
{
|
||||
inputs.selection.profile.clear();
|
||||
let missing = std::mem::take(&mut inputs.selection.profile);
|
||||
inputs.selection.table.clear();
|
||||
inputs
|
||||
.error
|
||||
.get_or_insert(format!("Profile `{missing}` no longer exists."));
|
||||
}
|
||||
|
||||
// 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 selected_tables = if inputs.selection.is_global() {
|
||||
tree.profiles.first().map(|profile| profile.tables.as_slice())
|
||||
table_scope::global_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,
|
||||
)
|
||||
} else {
|
||||
tree.profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == inputs.selection.profile)
|
||||
.map(|profile| profile.tables.as_slice())
|
||||
table_scope::profile_owned_tables(&tree.profiles, &inputs.selection.profile)
|
||||
};
|
||||
let tables = selected_tables
|
||||
.map(|tables| {
|
||||
tables
|
||||
.iter()
|
||||
.filter(|table| table.global == inputs.selection.is_global())
|
||||
.map(|table| TableSummary {
|
||||
name: table.name.clone(),
|
||||
table_kind: table.table_kind.clone(),
|
||||
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
|
||||
.iter()
|
||||
.map(|dependency| {
|
||||
format!("{} ({})", dependency.table_name, dependency.column_name)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.map(|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(),
|
||||
})
|
||||
.unwrap_or_default();
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !tables
|
||||
.iter()
|
||||
.any(|table| table.name == inputs.selection.table)
|
||||
// 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)
|
||||
{
|
||||
inputs.selection.table.clear();
|
||||
let missing = std::mem::take(&mut inputs.selection.table);
|
||||
let scope = inputs.selection.scope_label();
|
||||
inputs
|
||||
.error
|
||||
.get_or_insert(format!("`{missing}` is not a table of {scope}."));
|
||||
}
|
||||
|
||||
let detail = match inputs.selection.has_table() {
|
||||
|
||||
@@ -289,6 +289,50 @@ mod tests {
|
||||
assert!(!html.contains("/admin/profiles/copy?profile=billing\" method"));
|
||||
}
|
||||
|
||||
/// The other half of `a_global_table_is_offered_the_same_actions`: what the
|
||||
/// admin panel links to for a global table is a page that deletes it,
|
||||
/// rather than one that says no table was chosen.
|
||||
#[test]
|
||||
fn the_delete_page_accepts_a_global_table() {
|
||||
let mut state = page();
|
||||
state.selection = Selection {
|
||||
profile: crate::pages::GLOBAL_SCOPE.to_string(),
|
||||
table: "currencies".to_string(),
|
||||
};
|
||||
state.tables = vec![TableSummary {
|
||||
name: "currencies".to_string(),
|
||||
table_kind: "dynamic".to_string(),
|
||||
global: true,
|
||||
depends_on: Vec::new(),
|
||||
}];
|
||||
state.active = "delete";
|
||||
|
||||
let html = render_delete_page(&state);
|
||||
|
||||
assert!(!html.contains("Template error"), "{html}");
|
||||
assert!(html.contains("Type <code>currencies</code> to confirm"), "{html}");
|
||||
assert!(!html.contains("No table chosen"));
|
||||
// The sentinel is never shown as if it were a profile's name.
|
||||
assert!(html.contains("Global — all profiles"));
|
||||
}
|
||||
|
||||
/// And when the table really is gone, the page says which one rather than
|
||||
/// telling someone who just picked one to go and pick one. The loader is
|
||||
/// what clears the selection and sets the reason.
|
||||
#[test]
|
||||
fn a_dropped_selection_is_explained() {
|
||||
let mut state = page();
|
||||
state.selection.table = String::new();
|
||||
state.detail = None;
|
||||
state.error = Some("`invoice` is not a table of billing.".to_string());
|
||||
state.active = "delete";
|
||||
|
||||
let html = render_delete_page(&state);
|
||||
|
||||
assert!(html.contains("`invoice` is not a table of billing."), "{html}");
|
||||
assert!(html.contains("No table chosen"));
|
||||
}
|
||||
|
||||
/// A system table is the backend's own; every write is refused for it, so
|
||||
/// none of the write pages offer their form.
|
||||
#[test]
|
||||
|
||||
@@ -14,3 +14,4 @@ pub(crate) mod import_export;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod permissions;
|
||||
pub(crate) mod register;
|
||||
pub(crate) mod table_scope;
|
||||
|
||||
155
web/src/pages/table_scope.rs
Normal file
155
web/src/pages/table_scope.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Which tables a scope holds, decided in one place for every page that asks.
|
||||
//!
|
||||
//! Three pages used to answer this for themselves — the admin panel from the
|
||||
//! table catalog, the table-definition workspace and the Add-table builder from
|
||||
//! the profile tree — and they did not agree. The tree lists a global table
|
||||
//! under every profile, so a deployment whose tree has no profiles in it (or a
|
||||
//! profile the tree does not repeat them under) hid every global table from the
|
||||
//! two pages that read it, while the admin panel still listed them. Picking one
|
||||
//! there and acting on it then landed on "No table chosen", because the page
|
||||
//! being asked to act could not see the table the panel had just offered.
|
||||
//!
|
||||
//! So the global scope is the catalog's answer and nothing else, and a
|
||||
//! profile's own tables are the tree's. Neither is derived from the other.
|
||||
|
||||
use crate::definitions::table_definition::profile_tree_response::{Profile, Table};
|
||||
|
||||
/// The tables of the global scope: the catalog for `profile_name: None`, which
|
||||
/// is every shared table in the deployment, whether or not any profile exists
|
||||
/// to repeat it.
|
||||
pub(crate) fn global_tables(catalog: &[Table]) -> Vec<Table> {
|
||||
// The catalog for the global scope reports shared tables only, but the
|
||||
// filter is what makes that a property of this function rather than of the
|
||||
// call that happened to fill `catalog`.
|
||||
catalog.iter().filter(|table| table.global).cloned().collect()
|
||||
}
|
||||
|
||||
/// The tables a profile owns. Global tables are shared by every profile and
|
||||
/// are browsed under the global scope, so they are not a profile's own.
|
||||
pub(crate) fn profile_owned_tables(profiles: &[Profile], profile_name: &str) -> Vec<Table> {
|
||||
profiles
|
||||
.iter()
|
||||
.find(|profile| profile.name == profile_name)
|
||||
.map(|profile| {
|
||||
profile
|
||||
.tables
|
||||
.iter()
|
||||
.filter(|table| !table.global)
|
||||
.cloned()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Everything a table in `profile_name` may point a link at: the profile's own
|
||||
/// tables and the shared ones.
|
||||
///
|
||||
/// A profile that does not exist yet still gets the shared tables — they are
|
||||
/// there to be linked to before the profile holding the new table is created.
|
||||
pub(crate) fn linkable_tables(
|
||||
profiles: &[Profile],
|
||||
catalog: &[Table],
|
||||
profile_name: &str,
|
||||
) -> Vec<Table> {
|
||||
let mut tables = profile_owned_tables(profiles, profile_name);
|
||||
for global in global_tables(catalog) {
|
||||
if !tables.iter().any(|table| table.name == global.name) {
|
||||
tables.push(global);
|
||||
}
|
||||
}
|
||||
tables
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub(crate) fn table(name: &str, global: bool) -> Table {
|
||||
Table {
|
||||
id: 0,
|
||||
name: name.to_string(),
|
||||
depends_on: Vec::new(),
|
||||
row_display_columns: Vec::new(),
|
||||
table_kind: "dynamic".to_string(),
|
||||
global,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile(name: &str, tables: Vec<Table>) -> Profile {
|
||||
Profile {
|
||||
name: name.to_string(),
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression: the admin panel reads the global scope from the catalog,
|
||||
/// so every other page has to see the same tables there — including when
|
||||
/// the profile tree the other pages used to read has no profiles in it at
|
||||
/// all, which left the global scope looking empty and cleared the selection
|
||||
/// the panel had just made.
|
||||
#[test]
|
||||
fn the_global_scope_is_the_catalog_even_with_no_profiles_in_the_tree() {
|
||||
let catalog = vec![table("currencies", true), table("countries", true)];
|
||||
|
||||
assert_eq!(
|
||||
global_tables(&catalog)
|
||||
.iter()
|
||||
.map(|table| table.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["currencies", "countries"]
|
||||
);
|
||||
// And a profile-shaped question about an empty tree is empty, rather
|
||||
// than falling back to some other profile's tables.
|
||||
assert!(profile_owned_tables(&[], "billing").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_owns_its_own_tables_and_not_another_profiles() {
|
||||
let profiles = vec![
|
||||
profile(
|
||||
"billing",
|
||||
vec![table("invoice", false), table("currencies", true)],
|
||||
),
|
||||
profile("payroll", vec![table("employee", false)]),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
profile_owned_tables(&profiles, "billing")
|
||||
.iter()
|
||||
.map(|table| table.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["invoice"],
|
||||
"a global table is browsed under the global scope"
|
||||
);
|
||||
assert!(profile_owned_tables(&profiles, "unknown").is_empty());
|
||||
}
|
||||
|
||||
/// A link may point at a shared table, so the two lists are joined here —
|
||||
/// once, without repeating a table the tree already listed under the
|
||||
/// profile.
|
||||
#[test]
|
||||
fn link_targets_are_the_profiles_own_tables_and_the_shared_ones() {
|
||||
let profiles = vec![profile(
|
||||
"billing",
|
||||
vec![table("invoice", false), table("currencies", true)],
|
||||
)];
|
||||
let catalog = vec![table("currencies", true), table("countries", true)];
|
||||
|
||||
let names = |tables: Vec<Table>| {
|
||||
tables
|
||||
.iter()
|
||||
.map(|table| table.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
names(linkable_tables(&profiles, &catalog, "billing")),
|
||||
["invoice", "currencies", "countries"]
|
||||
);
|
||||
// A profile that does not exist yet can still link to the shared ones.
|
||||
assert_eq!(
|
||||
names(linkable_tables(&profiles, &catalog, "")),
|
||||
["currencies", "countries"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user