diff --git a/server b/server index 20f226f5..22dcc2e0 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 20f226f5d85e5df93691b8a834588bac54969e8c +Subproject commit 22dcc2e032e53e879deca0eddf2d46dc224ec99d diff --git a/web/src/pages/add_table/draft.rs b/web/src/pages/add_table/draft.rs index 84320c73..2af5002f 100644 --- a/web/src/pages/add_table/draft.rs +++ b/web/src/pages/add_table/draft.rs @@ -27,6 +27,13 @@ pub(crate) struct PreviewRow { pub source: String, } +#[derive(Clone, Debug)] +pub(crate) struct RelationTableOption { + pub name: String, + pub global: bool, + pub system: bool, +} + /// The whole Add-table page state, minus presentation. #[derive(Clone, Debug, Default)] pub(crate) struct TableDraft { @@ -46,6 +53,7 @@ pub(crate) struct TableDraft { /// Tables in the target profile, offered as `link(...)` targets by the /// column picker. pub relation_tables: Vec, + pub relation_table_options: Vec, /// Columns identifying a row to users, in the order they are shown. /// Empty means rows are identified by their id alone. pub row_display_columns: Vec, @@ -125,11 +133,57 @@ impl TableDraft { /// Records the tables the target profile offers as link targets. A table /// cannot link to itself, so its own name is never among them. + #[cfg(test)] pub(crate) fn set_available_relation_tables(&mut self, table_names: Vec) { - self.relation_tables = table_names + self.set_available_relation_table_options( + table_names + .into_iter() + .map(|name| RelationTableOption { + name, + global: false, + system: false, + }) + .collect(), + ); + } + + pub(crate) fn set_available_relation_table_options( + &mut self, + options: Vec, + ) { + self.relation_table_options = options .into_iter() - .filter(|table_name| table_name != &self.table_name) + .filter(|option| option.name != self.table_name) .collect(); + self.relation_tables = self + .relation_table_options + .iter() + .map(|option| option.name.clone()) + .collect(); + } + + pub(crate) fn global_relation_tables(&self) -> Vec<&str> { + self.relation_table_options + .iter() + .filter(|option| option.global) + .map(|option| option.name.as_str()) + .collect() + } + + pub(crate) fn user_relation_tables(&self) -> Vec<&str> { + self.relation_table_options + .iter() + .filter(|option| !option.global && !option.system) + .map(|option| option.name.as_str()) + .collect() + } + + pub(crate) fn system_relation_tables(&self) -> Vec<&str> { + self.relation_table_options + .iter() + .filter(|option| !option.global && option.system) + .map(|option| option.name.as_str()) + .collect() } // ---- derived state --------------------------------------------------- diff --git a/web/src/pages/add_table/loader.rs b/web/src/pages/add_table/loader.rs index 5d348f50..502135a2 100644 --- a/web/src/pages/add_table/loader.rs +++ b/web/src/pages/add_table/loader.rs @@ -5,7 +5,7 @@ use crate::{ services::authenticated_request, }; -use super::{draft::TableDraft, state::AddTablePageState}; +use super::{draft::{RelationTableOption, TableDraft}, state::AddTablePageState}; /// Loads everything the builder needs around the draft: the column-type /// vocabulary, the profiles that can be picked, and — for whichever profile the @@ -66,9 +66,14 @@ pub(crate) async fn load_page( .iter() .flat_map(|profile| profile.tables.iter()) .filter(|table| table.global) - .map(|table| table.name.clone()) - .collect::>() + .map(|table| (table.name.clone(), table.table_kind.clone())) + .collect::>() .into_iter() + .map(|(name, table_kind)| RelationTableOption { + name, + global: true, + system: table_kind == "system", + }) .collect::>(); draft.existing_profile_tables = tree .profiles @@ -78,7 +83,7 @@ pub(crate) async fn load_page( .collect::>() .into_iter() .collect(); - draft.set_available_relation_tables(global_tables); + draft.set_available_relation_table_options(global_tables); } else { match tree .profiles .iter() @@ -87,19 +92,27 @@ pub(crate) async fn load_page( // An existing profile: its tables are the link targets, and their // names are reserved against duplicate table creation. Some(profile) => { - let table_names = profile + let table_options = profile .tables .iter() .filter(|table| table.name != "accounts") - .map(|table| table.name.clone()) + .map(|table| RelationTableOption { + name: table.name.clone(), + global: table.global, + system: table.table_kind == "system", + }) .collect::>(); - draft.existing_profile_tables = table_names.clone(); - draft.set_available_relation_tables(table_names); + 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 nothing to link to. None => { draft.existing_profile_tables.clear(); draft.relation_tables.clear(); + draft.relation_table_options.clear(); } }} diff --git a/web/src/pages/add_table/state.rs b/web/src/pages/add_table/state.rs index ebb8fdb7..c7131b7e 100644 --- a/web/src/pages/add_table/state.rs +++ b/web/src/pages/add_table/state.rs @@ -143,6 +143,7 @@ impl BuilderForm { table_name: self.table_name.clone(), columns, relation_tables: self.relation_tables.clone(), + relation_table_options: Vec::new(), row_display_columns, // Filled in by the loader from the live profile tree, never by the // client: it is what duplicate table names are checked against. diff --git a/web/src/pages/add_table/ui.rs b/web/src/pages/add_table/ui.rs index e3f374d0..c854d422 100644 --- a/web/src/pages/add_table/ui.rs +++ b/web/src/pages/add_table/ui.rs @@ -63,7 +63,7 @@ pub(crate) fn render_submission_error(message: &str) -> String { mod tests { use super::*; use crate::{ - pages::add_table::draft::TableDraft, + pages::add_table::draft::{RelationTableOption, TableDraft}, schema::{ColumnDefinition, MoneyMode}, }; @@ -80,7 +80,23 @@ mod tests { money_mode: MoneyMode::Exact, currency: String::new(), }); - draft.set_available_relation_tables(vec!["customer".to_string()]); + draft.set_available_relation_table_options(vec![ + RelationTableOption { + name: "customer".to_string(), + global: false, + system: false, + }, + RelationTableOption { + name: "currencies".to_string(), + global: true, + system: false, + }, + RelationTableOption { + name: "audit_log".to_string(), + global: false, + system: true, + }, + ]); draft.toggle_row_display_candidate(1); AddTablePageState { @@ -172,9 +188,13 @@ mod tests { state.draft.columns.type_input = "link".to_string(); let html = render_builder(&state); + assert!(html.contains(r#">FKlink"#)); assert!(html.contains("Link alias")); assert!(html.contains(r#"name="link_table_input""#)); assert!(html.contains(r#""#)); + assert!(html.contains(r#""#)); } #[test] diff --git a/web/src/pages/admin/table_definition/loader.rs b/web/src/pages/admin/table_definition/loader.rs index d3a80653..542cd8bc 100644 --- a/web/src/pages/admin/table_definition/loader.rs +++ b/web/src/pages/admin/table_definition/loader.rs @@ -117,6 +117,7 @@ pub(crate) async fn load_page( .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 diff --git a/web/src/pages/admin/table_definition/state.rs b/web/src/pages/admin/table_definition/state.rs index 0b1e540f..1145bce2 100644 --- a/web/src/pages/admin/table_definition/state.rs +++ b/web/src/pages/admin/table_definition/state.rs @@ -45,6 +45,7 @@ impl Selection { pub(crate) struct TableSummary { pub name: String, pub table_kind: String, + pub global: bool, pub depends_on: Vec, pub row_display_columns: Vec, } @@ -277,10 +278,34 @@ pub(crate) struct TablePermissionAction { } impl TableDefinitionPageState { - pub(crate) fn link_target_tables(&self) -> Vec<&str> { + fn eligible_link_target(&self, table: &TableSummary) -> bool { + table.name != self.selection.table && table.name != "accounts" + } + + pub(crate) fn global_link_target_tables(&self) -> Vec<&str> { self.tables .iter() - .filter(|table| table.name != self.selection.table && table.name != "accounts") + .filter(|table| self.eligible_link_target(table) && table.global) + .map(|table| table.name.as_str()) + .collect() + } + + pub(crate) fn user_link_target_tables(&self) -> Vec<&str> { + self.tables + .iter() + .filter(|table| { + self.eligible_link_target(table) && !table.global && !table.is_system() + }) + .map(|table| table.name.as_str()) + .collect() + } + + pub(crate) fn system_link_target_tables(&self) -> Vec<&str> { + self.tables + .iter() + .filter(|table| { + self.eligible_link_target(table) && !table.global && table.is_system() + }) .map(|table| table.name.as_str()) .collect() } diff --git a/web/src/pages/admin/table_definition/ui.rs b/web/src/pages/admin/table_definition/ui.rs index 667ab660..7e15b0be 100644 --- a/web/src/pages/admin/table_definition/ui.rs +++ b/web/src/pages/admin/table_definition/ui.rs @@ -108,6 +108,7 @@ mod tests { TableSummary { name: name.to_string(), table_kind: kind.to_string(), + global: false, depends_on: Vec::new(), row_display_columns: vec!["number".to_string()], } @@ -234,12 +235,21 @@ mod tests { let mut state = page(); state.columns.type_input = "link".to_string(); state.tables.push(table("customer", "dynamic")); + state.tables.push(TableSummary { + global: true, + ..table("currencies", "dynamic") + }); + state.tables.push(table("audit_log", "system")); let html = render_column_panel(&state); + assert!(html.contains(r#">FKlink"#)); assert!(html.contains("Link alias")); assert!(html.contains(r#"name="link_table_input""#)); assert!(html.contains(r#""#)); + assert!(html.contains(r#""#)); assert!(!html.contains(r#" {% for column_type in column_types %} - + {% endfor %} @@ -152,9 +152,27 @@ diff --git a/web/templates/pages/admin/table_definition/column_panel.html b/web/templates/pages/admin/table_definition/column_panel.html index 85758ebc..72074d63 100644 --- a/web/templates/pages/admin/table_definition/column_panel.html +++ b/web/templates/pages/admin/table_definition/column_panel.html @@ -35,7 +35,7 @@ hx-vals='{"action": "refresh"}'> {% for column_type in column_types %} - + {% endfor %} @@ -79,11 +79,29 @@ {% endif %}