diff --git a/web/locales/cs/main.ftl b/web/locales/cs/main.ftl index 3d86304a..95100fc9 100644 --- a/web/locales/cs/main.ftl +++ b/web/locales/cs/main.ftl @@ -566,8 +566,10 @@ analytics-result-meta-truncated = { $rows -> transfer-eyebrow = Přenos dat import-title = Import CSV import-lead = Vyberte soubor v prohlížeči nebo vložte CSV. Řádky se ověřují proti živým strukturám tabulek před hromadným vkládáním přes gRPC. -import-profile = Profil -import-target-tables = Cílové tabulky +import-scope = Rozsah +import-choose-scope = Vyberte globální nebo profil +import-target-tables = Cílová tabulka +import-choose-table = Vyberte tabulku import-csv-file = Soubor CSV import-csv-data = Data CSV import-error-title = CSV se nepodařilo importovat @@ -586,11 +588,11 @@ import-success-message = Vloženo { $inserted -> [few] { $table_count } tabulkách *[other] { $table_count } tabulkách }. -import-err-select-profile = Vyberte profil. -import-err-tables-required = Zadejte alespoň jednu cílovou tabulku. +import-err-select-profile = Vyberte rozsah. +import-err-tables-required = Vyberte cílovou tabulku. import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV. -import-err-unknown-profile = Neznámý profil. -import-err-tables-not-in-profile = Jedna nebo více cílových tabulek nepatří do profilu. +import-err-unknown-profile = Neznámý rozsah. +import-err-tables-not-in-profile = Cílová tabulka nepatří do vybraného rozsahu. import-err-missing-structure = Backend vynechal požadovanou strukturu tabulky. import-err-no-importable-columns = CSV nemá importovatelné sloupce pro tabulku '{ $table }'. import-err-column-not-importable = Sloupec '{ $column }' není importovatelný pro tabulku '{ $table }'. @@ -609,18 +611,18 @@ import-err-bad-integer = Neplatná celočíselná hodnota '{ $value }' export-title = Export CSV export-lead = Soubor se generuje z živých dat tabulek přes gRPC. -export-profile = Profil -export-choose-profile = Vyberte profil -export-tables = Tabulky -export-comma-hint = Více tabulek oddělte čárkami. +export-scope = Rozsah +export-choose-scope = Vyberte globální nebo profil +export-tables = Tabulka +export-choose-table = Vyberte tabulku export-include-system = Zahrnout systémové sloupce export-include-system-hint = Do souboru přidá id, deleted a row_revision. Import je ignoruje. export-error-title = CSV se nepodařilo exportovat export-download = Stáhnout CSV -export-err-select-profile = Vyberte profil. -export-err-tables-required = Zadejte alespoň jednu tabulku. -export-err-unknown-profile = Neznámý profil. -export-err-tables-not-in-profile = Jedna nebo více tabulek nepatří do vybraného profilu. +export-err-select-profile = Vyberte rozsah. +export-err-tables-required = Vyberte tabulku. +export-err-unknown-profile = Neznámý rozsah. +export-err-tables-not-in-profile = Tabulka nepatří do vybraného rozsahu. export-err-missing-structure = Backend vynechal požadovanou strukturu tabulky. export-err-negative-count = Backend vrátil záporný počet řádků. export-err-range = Export přesahuje podporovaný rozsah pozic řádků. diff --git a/web/locales/en/main.ftl b/web/locales/en/main.ftl index 915080b6..493aaad7 100644 --- a/web/locales/en/main.ftl +++ b/web/locales/en/main.ftl @@ -556,8 +556,10 @@ analytics-result-meta-truncated = { $rows -> transfer-eyebrow = Data transfer import-title = Import CSV import-lead = Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion. -import-profile = Profile -import-target-tables = Target tables +import-scope = Scope +import-choose-scope = Choose global or a profile +import-target-tables = Target table +import-choose-table = Choose a table import-csv-file = CSV file import-csv-data = CSV data import-error-title = Could not import CSV @@ -573,11 +575,11 @@ import-success-message = Inserted { $inserted -> [one] { $table_count } table *[other] { $table_count } tables }. -import-err-select-profile = Select a profile. -import-err-tables-required = Enter at least one target table. +import-err-select-profile = Select a scope. +import-err-tables-required = Select a target table. import-err-csv-required = Choose a CSV file or paste CSV data. -import-err-unknown-profile = Unknown profile. -import-err-tables-not-in-profile = One or more target tables do not belong to the profile. +import-err-unknown-profile = Unknown scope. +import-err-tables-not-in-profile = The target table does not belong to the selected scope. import-err-missing-structure = The backend omitted a requested table structure. import-err-no-importable-columns = CSV has no importable columns for table '{ $table }'. import-err-column-not-importable = Column '{ $column }' is not importable for table '{ $table }'. @@ -596,18 +598,18 @@ import-err-bad-integer = Invalid integer value '{ $value }' export-title = Export CSV export-lead = The download is generated from live table data through gRPC. -export-profile = Profile -export-choose-profile = Choose a profile -export-tables = Tables -export-comma-hint = Separate multiple tables with commas. +export-scope = Scope +export-choose-scope = Choose global or a profile +export-tables = Table +export-choose-table = Choose a table export-include-system = Include system columns export-include-system-hint = Adds id, deleted and row_revision to the file. An import ignores them. export-error-title = Could not export CSV export-download = Download CSV -export-err-select-profile = Select a profile. -export-err-tables-required = Enter at least one table. -export-err-unknown-profile = Unknown profile. -export-err-tables-not-in-profile = One or more tables do not belong to the selected profile. +export-err-select-profile = Select a scope. +export-err-tables-required = Select a table. +export-err-unknown-profile = Unknown scope. +export-err-tables-not-in-profile = The table does not belong to the selected scope. export-err-missing-structure = The backend omitted a requested table structure. export-err-negative-count = The backend returned a negative row count. export-err-range = The export exceeds the supported row-position range. diff --git a/web/locales/sk/main.ftl b/web/locales/sk/main.ftl index 75796f92..24a3cce5 100644 --- a/web/locales/sk/main.ftl +++ b/web/locales/sk/main.ftl @@ -566,8 +566,10 @@ analytics-result-meta-truncated = { $rows -> transfer-eyebrow = Prenos údajov import-title = Import CSV import-lead = Vyberte súbor v prehliadači alebo vložte CSV. Riadky sa overujú proti živým štruktúram tabuliek pred hromadným vkladaním cez gRPC. -import-profile = Profil -import-target-tables = Cieľové tabuľky +import-scope = Rozsah +import-choose-scope = Vyberte globálne alebo profil +import-target-tables = Cieľová tabuľka +import-choose-table = Vyberte tabuľku import-csv-file = Súbor CSV import-csv-data = Údaje CSV import-error-title = CSV sa nepodarilo importovať @@ -586,11 +588,11 @@ import-success-message = Vložené { $inserted -> [few] { $table_count } tabuľkách *[other] { $table_count } tabuľkách }. -import-err-select-profile = Vyberte profil. -import-err-tables-required = Zadajte aspoň jednu cieľovú tabuľku. +import-err-select-profile = Vyberte rozsah. +import-err-tables-required = Vyberte cieľovú tabuľku. import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV. -import-err-unknown-profile = Neznámy profil. -import-err-tables-not-in-profile = Jedna alebo viac cieľových tabuliek nepatrí do profilu. +import-err-unknown-profile = Neznámy rozsah. +import-err-tables-not-in-profile = Cieľová tabuľka nepatrí do vybraného rozsahu. import-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky. import-err-no-importable-columns = CSV nemá importovateľné stĺpce pre tabuľku '{ $table }'. import-err-column-not-importable = Stĺpec '{ $column }' nie je importovateľný pre tabuľku '{ $table }'. @@ -609,18 +611,18 @@ import-err-bad-integer = Neplatná celočíselná hodnota '{ $value }' export-title = Export CSV export-lead = Súbor sa generuje z živých údajov tabuliek cez gRPC. -export-profile = Profil -export-choose-profile = Vyberte profil -export-tables = Tabuľky -export-comma-hint = Viacero tabuliek oddeľte čiarkami. +export-scope = Rozsah +export-choose-scope = Vyberte globálne alebo profil +export-tables = Tabuľka +export-choose-table = Vyberte tabuľku export-include-system = Zahrnúť systémové stĺpce export-include-system-hint = Do súboru pridá id, deleted a row_revision. Import ich ignoruje. export-error-title = CSV sa nepodarilo exportovať export-download = Stiahnuť CSV -export-err-select-profile = Vyberte profil. -export-err-tables-required = Zadajte aspoň jednu tabuľku. -export-err-unknown-profile = Neznámy profil. -export-err-tables-not-in-profile = Jedna alebo viac tabuliek nepatrí do vybraného profilu. +export-err-select-profile = Vyberte rozsah. +export-err-tables-required = Vyberte tabuľku. +export-err-unknown-profile = Neznámy rozsah. +export-err-tables-not-in-profile = Tabuľka nepatrí do vybraného rozsahu. export-err-missing-structure = Backend vynechal požadovanú štruktúru tabuľky. export-err-negative-count = Backend vrátil záporný počet riadkov. export-err-range = Export presahuje podporovaný rozsah pozícií riadkov. diff --git a/web/src/pages/import_export/common/loader.rs b/web/src/pages/import_export/common/loader.rs index b5e8d06d..da856893 100644 --- a/web/src/pages/import_export/common/loader.rs +++ b/web/src/pages/import_export/common/loader.rs @@ -3,7 +3,9 @@ use axum::http::HeaderMap; use crate::{ AppState, auth::GetAuthorizationRequest, - definitions::common::Empty, + definitions::{common::Empty, table_definition::GetTableCatalogRequest}, + i18n::Locale, + pages::{GLOBAL_SCOPE, table_scope}, services::authenticated_request, }; @@ -12,8 +14,14 @@ pub(crate) struct Catalog { pub authorization: crate::auth::AuthorizationSnapshot, } +/// 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. 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. + pub label: String, pub tables: Vec, } @@ -45,35 +53,67 @@ pub(crate) async fn load_catalog( .await .map_err(|error| LoadError::Backend(error.message().to_string()))? .into_inner(); - let mut profiles = tree - .profiles + + // 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 + // `crate::pages::table_scope`. + let global = 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, + ) + .into_iter() + .map(|table| table.name) + .filter(|table| { + crate::authz::permits_table(&authorization, GLOBAL_SCOPE, table, required_action) + }) + .collect::>(); + + 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), + tables: global, + }); + } + profiles.extend(tree.profiles.into_iter().filter_map(|profile| { + let tables = profile + .tables .into_iter() - .filter_map(|profile| { - let tables = profile - .tables - .into_iter() - .filter(|table| { - crate::authz::permits_table( - &authorization, - &profile.name, - &table.name, - required_action, - ) - }) - .map(|table| table.name) - .collect::>(); - (!tables.is_empty()).then_some(Profile { - name: profile.name, - tables, - }) + .filter(|table| !table.global) + .filter(|table| { + crate::authz::permits_table( + &authorization, + &profile.name, + &table.name, + required_action, + ) }) + .map(|table| table.name) .collect::>(); + (!tables.is_empty()).then_some(Profile { + label: profile.name.clone(), + name: profile.name, + tables, + }) + })); // An insert-only role is deliberately absent from GetProfileTree because // that listing is filtered by read permission. Exact table grants still - // carry enough information to offer their target here. Wildcard-only - // insert roles can type a target manually and the backend remains the - // authoritative permission check. + // carry enough information to offer their target here. A wildcard-only + // insert role has no table name to offer, so its scope stays empty and the + // backend remains the authoritative permission check. for permission in authorization .permissions .iter() @@ -98,6 +138,7 @@ pub(crate) async fn load_catalog( } else { profiles.push(Profile { name: profile_name.to_string(), + label: scope_label(locale, profile_name), tables: vec![table_name.to_string()], }); } @@ -108,6 +149,16 @@ 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 { + crate::tr!(locale, "admin-global-label") + } else { + name.to_string() + } +} + pub(crate) enum LoadError { Unauthenticated, Forbidden, diff --git a/web/src/pages/import_export/export/logic.rs b/web/src/pages/import_export/export/logic.rs index 2f25ddbd..c39f65df 100644 --- a/web/src/pages/import_export/export/logic.rs +++ b/web/src/pages/import_export/export/logic.rs @@ -1,9 +1,11 @@ use axum::{ - Form, extract::State, http::{HeaderMap, HeaderValue, header}, response::{Html, IntoResponse, Redirect, Response}, }; +// The table checkboxes post `table_names` once per checked box, and +// `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`. +use axum_extra::extract::Form; use crate::{ AppState, diff --git a/web/src/pages/import_export/export/state.rs b/web/src/pages/import_export/export/state.rs index 070f1dd2..32482449 100644 --- a/web/src/pages/import_export/export/state.rs +++ b/web/src/pages/import_export/export/state.rs @@ -4,8 +4,10 @@ use crate::{i18n::Locale, tr}; pub(crate) struct ExportForm { #[serde(default)] pub profile_name: String, + /// One entry per checked table. The form posts the key once per checked + /// box, which only `axum_extra`'s `Form` decodes into a `Vec`. #[serde(default)] - pub table_names: String, + pub table_names: Vec, /// An unchecked checkbox is not posted at all, so its absence is the /// `false` and any value it does carry is the `true`. #[serde(default)] @@ -25,8 +27,8 @@ impl ExportForm { } let tables = self .table_names - .split(',') - .map(str::trim) + .iter() + .map(|table| table.trim()) .filter(|table| !table.is_empty()) .map(str::to_string) .collect::>(); diff --git a/web/src/pages/import_export/import/logic.rs b/web/src/pages/import_export/import/logic.rs index 26069db2..1ce6e67d 100644 --- a/web/src/pages/import_export/import/logic.rs +++ b/web/src/pages/import_export/import/logic.rs @@ -1,11 +1,13 @@ use std::collections::{HashMap, HashSet}; use axum::{ - Form, extract::State, http::HeaderMap, response::{Html, IntoResponse, Redirect, Response}, }; +// The table checkboxes post `table_names` once per checked box, and +// `axum::Form` (serde_urlencoded) cannot decode repeated keys into a `Vec`. +use axum_extra::extract::Form; use crate::{ AppState, diff --git a/web/src/pages/import_export/import/state.rs b/web/src/pages/import_export/import/state.rs index 5d22c625..dcfd6d90 100644 --- a/web/src/pages/import_export/import/state.rs +++ b/web/src/pages/import_export/import/state.rs @@ -4,8 +4,10 @@ use crate::{i18n::Locale, tr}; pub(crate) struct ImportForm { #[serde(default)] pub profile_name: String, + /// One entry per checked table. The form posts the key once per checked + /// box, which only `axum_extra`'s `Form` decodes into a `Vec`. #[serde(default)] - pub table_names: String, + pub table_names: Vec, #[serde(default)] pub csv_data: String, } @@ -25,8 +27,8 @@ impl ImportForm { } let tables = self .table_names - .split(',') - .map(str::trim) + .iter() + .map(|table| table.trim()) .filter(|table| !table.is_empty()) .map(str::to_string) .collect::>(); diff --git a/web/templates/pages/import_export/export/export.html b/web/templates/pages/import_export/export/export.html index 60374e6f..8d7092be 100644 --- a/web/templates/pages/import_export/export/export.html +++ b/web/templates/pages/import_export/export/export.html @@ -10,17 +10,21 @@ {# A real form post, not HTMX: the response is a file download. #}
-
{{ nav.tr("export-include-system-hint") }} - - {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%} -
{{ nav.tr("common-cancel") }}
+{% include "pages/import_export/table_picker.html" %} {% endblock %} diff --git a/web/templates/pages/import_export/import/import.html b/web/templates/pages/import_export/import/import.html index b31fd86c..cac36e6c 100644 --- a/web/templates/pages/import_export/import/import.html +++ b/web/templates/pages/import_export/import/import.html @@ -11,22 +11,28 @@
-
- - {% for profile in page.catalog.profiles %}{% endfor %} - - - {%- for profile in page.catalog.profiles %}{% for table in profile.tables %}{% endfor %}{% endfor -%} -
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
@@ -35,4 +41,5 @@
+{% include "pages/import_export/table_picker.html" %} {% endblock %} diff --git a/web/templates/pages/import_export/table_picker.html b/web/templates/pages/import_export/table_picker.html new file mode 100644 index 00000000..7d168bbe --- /dev/null +++ b/web/templates/pages/import_export/table_picker.html @@ -0,0 +1,33 @@ +{# + Leaves the table dropdown holding only the tables of the selected profile. + + The options are rebuilt rather than hidden: `hidden` on an `