export works from web
This commit is contained in:
@@ -566,8 +566,10 @@ analytics-result-meta-truncated = { $rows ->
|
|||||||
transfer-eyebrow = Přenos dat
|
transfer-eyebrow = Přenos dat
|
||||||
import-title = Import CSV
|
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-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-scope = Rozsah
|
||||||
import-target-tables = Cílové tabulky
|
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-file = Soubor CSV
|
||||||
import-csv-data = Data CSV
|
import-csv-data = Data CSV
|
||||||
import-error-title = CSV se nepodařilo importovat
|
import-error-title = CSV se nepodařilo importovat
|
||||||
@@ -586,11 +588,11 @@ import-success-message = Vloženo { $inserted ->
|
|||||||
[few] { $table_count } tabulkách
|
[few] { $table_count } tabulkách
|
||||||
*[other] { $table_count } tabulkách
|
*[other] { $table_count } tabulkách
|
||||||
}.
|
}.
|
||||||
import-err-select-profile = Vyberte profil.
|
import-err-select-profile = Vyberte rozsah.
|
||||||
import-err-tables-required = Zadejte alespoň jednu cílovou tabulku.
|
import-err-tables-required = Vyberte cílovou tabulku.
|
||||||
import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV.
|
import-err-csv-required = Vyberte soubor CSV nebo vložte data CSV.
|
||||||
import-err-unknown-profile = Neznámý profil.
|
import-err-unknown-profile = Neznámý rozsah.
|
||||||
import-err-tables-not-in-profile = Jedna nebo více cílových tabulek nepatří do profilu.
|
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-missing-structure = Backend vynechal požadovanou strukturu tabulky.
|
||||||
import-err-no-importable-columns = CSV nemá importovatelné sloupce pro tabulku '{ $table }'.
|
import-err-no-importable-columns = CSV nemá importovatelné sloupce pro tabulku '{ $table }'.
|
||||||
import-err-column-not-importable = Sloupec '{ $column }' není importovatelný 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-title = Export CSV
|
||||||
export-lead = Soubor se generuje z živých dat tabulek přes gRPC.
|
export-lead = Soubor se generuje z živých dat tabulek přes gRPC.
|
||||||
export-profile = Profil
|
export-scope = Rozsah
|
||||||
export-choose-profile = Vyberte profil
|
export-choose-scope = Vyberte globální nebo profil
|
||||||
export-tables = Tabulky
|
export-tables = Tabulka
|
||||||
export-comma-hint = Více tabulek oddělte čárkami.
|
export-choose-table = Vyberte tabulku
|
||||||
export-include-system = Zahrnout systémové sloupce
|
export-include-system = Zahrnout systémové sloupce
|
||||||
export-include-system-hint = Do souboru přidá id, deleted a row_revision. Import je ignoruje.
|
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-error-title = CSV se nepodařilo exportovat
|
||||||
export-download = Stáhnout CSV
|
export-download = Stáhnout CSV
|
||||||
export-err-select-profile = Vyberte profil.
|
export-err-select-profile = Vyberte rozsah.
|
||||||
export-err-tables-required = Zadejte alespoň jednu tabulku.
|
export-err-tables-required = Vyberte tabulku.
|
||||||
export-err-unknown-profile = Neznámý profil.
|
export-err-unknown-profile = Neznámý rozsah.
|
||||||
export-err-tables-not-in-profile = Jedna nebo více tabulek nepatří do vybraného profilu.
|
export-err-tables-not-in-profile = Tabulka nepatří do vybraného rozsahu.
|
||||||
export-err-missing-structure = Backend vynechal požadovanou strukturu tabulky.
|
export-err-missing-structure = Backend vynechal požadovanou strukturu tabulky.
|
||||||
export-err-negative-count = Backend vrátil záporný počet řádků.
|
export-err-negative-count = Backend vrátil záporný počet řádků.
|
||||||
export-err-range = Export přesahuje podporovaný rozsah pozic řádků.
|
export-err-range = Export přesahuje podporovaný rozsah pozic řádků.
|
||||||
|
|||||||
@@ -556,8 +556,10 @@ analytics-result-meta-truncated = { $rows ->
|
|||||||
transfer-eyebrow = Data transfer
|
transfer-eyebrow = Data transfer
|
||||||
import-title = Import CSV
|
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-lead = Choose a browser-local file or paste CSV. Rows are validated against live table structures before gRPC bulk insertion.
|
||||||
import-profile = Profile
|
import-scope = Scope
|
||||||
import-target-tables = Target tables
|
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-file = CSV file
|
||||||
import-csv-data = CSV data
|
import-csv-data = CSV data
|
||||||
import-error-title = Could not import CSV
|
import-error-title = Could not import CSV
|
||||||
@@ -573,11 +575,11 @@ import-success-message = Inserted { $inserted ->
|
|||||||
[one] { $table_count } table
|
[one] { $table_count } table
|
||||||
*[other] { $table_count } tables
|
*[other] { $table_count } tables
|
||||||
}.
|
}.
|
||||||
import-err-select-profile = Select a profile.
|
import-err-select-profile = Select a scope.
|
||||||
import-err-tables-required = Enter at least one target table.
|
import-err-tables-required = Select a target table.
|
||||||
import-err-csv-required = Choose a CSV file or paste CSV data.
|
import-err-csv-required = Choose a CSV file or paste CSV data.
|
||||||
import-err-unknown-profile = Unknown profile.
|
import-err-unknown-profile = Unknown scope.
|
||||||
import-err-tables-not-in-profile = One or more target tables do not belong to the profile.
|
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-missing-structure = The backend omitted a requested table structure.
|
||||||
import-err-no-importable-columns = CSV has no importable columns for table '{ $table }'.
|
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 }'.
|
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-title = Export CSV
|
||||||
export-lead = The download is generated from live table data through gRPC.
|
export-lead = The download is generated from live table data through gRPC.
|
||||||
export-profile = Profile
|
export-scope = Scope
|
||||||
export-choose-profile = Choose a profile
|
export-choose-scope = Choose global or a profile
|
||||||
export-tables = Tables
|
export-tables = Table
|
||||||
export-comma-hint = Separate multiple tables with commas.
|
export-choose-table = Choose a table
|
||||||
export-include-system = Include system columns
|
export-include-system = Include system columns
|
||||||
export-include-system-hint = Adds id, deleted and row_revision to the file. An import ignores them.
|
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-error-title = Could not export CSV
|
||||||
export-download = Download CSV
|
export-download = Download CSV
|
||||||
export-err-select-profile = Select a profile.
|
export-err-select-profile = Select a scope.
|
||||||
export-err-tables-required = Enter at least one table.
|
export-err-tables-required = Select a table.
|
||||||
export-err-unknown-profile = Unknown profile.
|
export-err-unknown-profile = Unknown scope.
|
||||||
export-err-tables-not-in-profile = One or more tables do not belong to the selected profile.
|
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-missing-structure = The backend omitted a requested table structure.
|
||||||
export-err-negative-count = The backend returned a negative row count.
|
export-err-negative-count = The backend returned a negative row count.
|
||||||
export-err-range = The export exceeds the supported row-position range.
|
export-err-range = The export exceeds the supported row-position range.
|
||||||
|
|||||||
@@ -566,8 +566,10 @@ analytics-result-meta-truncated = { $rows ->
|
|||||||
transfer-eyebrow = Prenos údajov
|
transfer-eyebrow = Prenos údajov
|
||||||
import-title = Import CSV
|
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-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-scope = Rozsah
|
||||||
import-target-tables = Cieľové tabuľky
|
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-file = Súbor CSV
|
||||||
import-csv-data = Údaje CSV
|
import-csv-data = Údaje CSV
|
||||||
import-error-title = CSV sa nepodarilo importovať
|
import-error-title = CSV sa nepodarilo importovať
|
||||||
@@ -586,11 +588,11 @@ import-success-message = Vložené { $inserted ->
|
|||||||
[few] { $table_count } tabuľkách
|
[few] { $table_count } tabuľkách
|
||||||
*[other] { $table_count } tabuľkách
|
*[other] { $table_count } tabuľkách
|
||||||
}.
|
}.
|
||||||
import-err-select-profile = Vyberte profil.
|
import-err-select-profile = Vyberte rozsah.
|
||||||
import-err-tables-required = Zadajte aspoň jednu cieľovú tabuľku.
|
import-err-tables-required = Vyberte cieľovú tabuľku.
|
||||||
import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV.
|
import-err-csv-required = Vyberte súbor CSV alebo vložte údaje CSV.
|
||||||
import-err-unknown-profile = Neznámy profil.
|
import-err-unknown-profile = Neznámy rozsah.
|
||||||
import-err-tables-not-in-profile = Jedna alebo viac cieľových tabuliek nepatrí do profilu.
|
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-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-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 }'.
|
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-title = Export CSV
|
||||||
export-lead = Súbor sa generuje z živých údajov tabuliek cez gRPC.
|
export-lead = Súbor sa generuje z živých údajov tabuliek cez gRPC.
|
||||||
export-profile = Profil
|
export-scope = Rozsah
|
||||||
export-choose-profile = Vyberte profil
|
export-choose-scope = Vyberte globálne alebo profil
|
||||||
export-tables = Tabuľky
|
export-tables = Tabuľka
|
||||||
export-comma-hint = Viacero tabuliek oddeľte čiarkami.
|
export-choose-table = Vyberte tabuľku
|
||||||
export-include-system = Zahrnúť systémové stĺpce
|
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-include-system-hint = Do súboru pridá id, deleted a row_revision. Import ich ignoruje.
|
||||||
export-error-title = CSV sa nepodarilo exportovať
|
export-error-title = CSV sa nepodarilo exportovať
|
||||||
export-download = Stiahnuť CSV
|
export-download = Stiahnuť CSV
|
||||||
export-err-select-profile = Vyberte profil.
|
export-err-select-profile = Vyberte rozsah.
|
||||||
export-err-tables-required = Zadajte aspoň jednu tabuľku.
|
export-err-tables-required = Vyberte tabuľku.
|
||||||
export-err-unknown-profile = Neznámy profil.
|
export-err-unknown-profile = Neznámy rozsah.
|
||||||
export-err-tables-not-in-profile = Jedna alebo viac tabuliek nepatrí do vybraného profilu.
|
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-missing-structure = Backend vynechal požadovanú štruktúru tabuľky.
|
||||||
export-err-negative-count = Backend vrátil záporný počet riadkov.
|
export-err-negative-count = Backend vrátil záporný počet riadkov.
|
||||||
export-err-range = Export presahuje podporovaný rozsah pozícií riadkov.
|
export-err-range = Export presahuje podporovaný rozsah pozícií riadkov.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ use axum::http::HeaderMap;
|
|||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::GetAuthorizationRequest,
|
auth::GetAuthorizationRequest,
|
||||||
definitions::common::Empty,
|
definitions::{common::Empty, table_definition::GetTableCatalogRequest},
|
||||||
|
i18n::Locale,
|
||||||
|
pages::{GLOBAL_SCOPE, table_scope},
|
||||||
services::authenticated_request,
|
services::authenticated_request,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -12,8 +14,14 @@ pub(crate) struct Catalog {
|
|||||||
pub authorization: crate::auth::AuthorizationSnapshot,
|
pub authorization: crate::auth::AuthorizationSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One entry of the scope selector: the global scope, or a profile.
|
||||||
pub(crate) struct 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,
|
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<String>,
|
pub tables: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,35 +53,67 @@ pub(crate) async fn load_catalog(
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
||||||
.into_inner();
|
.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::<Vec<_>>();
|
||||||
|
|
||||||
|
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()
|
.into_iter()
|
||||||
.filter_map(|profile| {
|
.filter(|table| !table.global)
|
||||||
let tables = profile
|
.filter(|table| {
|
||||||
.tables
|
crate::authz::permits_table(
|
||||||
.into_iter()
|
&authorization,
|
||||||
.filter(|table| {
|
&profile.name,
|
||||||
crate::authz::permits_table(
|
&table.name,
|
||||||
&authorization,
|
required_action,
|
||||||
&profile.name,
|
)
|
||||||
&table.name,
|
|
||||||
required_action,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map(|table| table.name)
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
(!tables.is_empty()).then_some(Profile {
|
|
||||||
name: profile.name,
|
|
||||||
tables,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
.map(|table| table.name)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
(!tables.is_empty()).then_some(Profile {
|
||||||
|
label: profile.name.clone(),
|
||||||
|
name: profile.name,
|
||||||
|
tables,
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
// An insert-only role is deliberately absent from GetProfileTree because
|
// An insert-only role is deliberately absent from GetProfileTree because
|
||||||
// that listing is filtered by read permission. Exact table grants still
|
// that listing is filtered by read permission. Exact table grants still
|
||||||
// carry enough information to offer their target here. Wildcard-only
|
// carry enough information to offer their target here. A wildcard-only
|
||||||
// insert roles can type a target manually and the backend remains the
|
// insert role has no table name to offer, so its scope stays empty and the
|
||||||
// authoritative permission check.
|
// backend remains the authoritative permission check.
|
||||||
for permission in authorization
|
for permission in authorization
|
||||||
.permissions
|
.permissions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -98,6 +138,7 @@ pub(crate) async fn load_catalog(
|
|||||||
} else {
|
} else {
|
||||||
profiles.push(Profile {
|
profiles.push(Profile {
|
||||||
name: profile_name.to_string(),
|
name: profile_name.to_string(),
|
||||||
|
label: scope_label(locale, profile_name),
|
||||||
tables: vec![table_name.to_string()],
|
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 {
|
pub(crate) enum LoadError {
|
||||||
Unauthenticated,
|
Unauthenticated,
|
||||||
Forbidden,
|
Forbidden,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{HeaderMap, HeaderValue, header},
|
http::{HeaderMap, HeaderValue, header},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
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::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ use crate::{i18n::Locale, tr};
|
|||||||
pub(crate) struct ExportForm {
|
pub(crate) struct ExportForm {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile_name: String,
|
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)]
|
#[serde(default)]
|
||||||
pub table_names: String,
|
pub table_names: Vec<String>,
|
||||||
/// An unchecked checkbox is not posted at all, so its absence is the
|
/// An unchecked checkbox is not posted at all, so its absence is the
|
||||||
/// `false` and any value it does carry is the `true`.
|
/// `false` and any value it does carry is the `true`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -25,8 +27,8 @@ impl ExportForm {
|
|||||||
}
|
}
|
||||||
let tables = self
|
let tables = self
|
||||||
.table_names
|
.table_names
|
||||||
.split(',')
|
.iter()
|
||||||
.map(str::trim)
|
.map(|table| table.trim())
|
||||||
.filter(|table| !table.is_empty())
|
.filter(|table| !table.is_empty())
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
|
||||||
extract::State,
|
extract::State,
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
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::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ use crate::{i18n::Locale, tr};
|
|||||||
pub(crate) struct ImportForm {
|
pub(crate) struct ImportForm {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profile_name: String,
|
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)]
|
#[serde(default)]
|
||||||
pub table_names: String,
|
pub table_names: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub csv_data: String,
|
pub csv_data: String,
|
||||||
}
|
}
|
||||||
@@ -25,8 +27,8 @@ impl ImportForm {
|
|||||||
}
|
}
|
||||||
let tables = self
|
let tables = self
|
||||||
.table_names
|
.table_names
|
||||||
.split(',')
|
.iter()
|
||||||
.map(str::trim)
|
.map(|table| table.trim())
|
||||||
.filter(|table| !table.is_empty())
|
.filter(|table| !table.is_empty())
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -10,17 +10,21 @@
|
|||||||
{# A real form post, not HTMX: the response is a file download. #}
|
{# A real form post, not HTMX: the response is a file download. #}
|
||||||
<form method="post" action="/admin/export.csv">
|
<form method="post" action="/admin/export.csv">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>{{ nav.tr("export-profile") }}
|
<label>{{ nav.tr("export-scope") }}
|
||||||
<select name="profile_name" required>
|
<select name="profile_name" data-profile-select required>
|
||||||
<option value="">{{ nav.tr("export-choose-profile") }}</option>
|
<option value="">{{ nav.tr("export-choose-scope") }}</option>
|
||||||
{% for profile in page.catalog.profiles %}
|
{% for profile in page.catalog.profiles %}
|
||||||
<option value="{{ profile.name }}">{{ profile.name }}</option>
|
<option value="{{ profile.name }}">{{ profile.label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>{{ nav.tr("export-tables") }}
|
<label>{{ nav.tr("export-tables") }}
|
||||||
<input name="table_names" list="export-tables" required placeholder="invoices, customers">
|
<select name="table_names" data-table-select required>
|
||||||
<small>{{ nav.tr("export-comma-hint") }}</small>
|
<option value="">{{ nav.tr("export-choose-table") }}</option>
|
||||||
|
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
|
||||||
|
<option value="{{ table }}" data-profile="{{ profile.name }}">{{ table }}</option>
|
||||||
|
{%- endfor %}{% endfor %}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="check">
|
<label class="check">
|
||||||
@@ -28,12 +32,10 @@
|
|||||||
{{ nav.tr("export-include-system") }}
|
{{ nav.tr("export-include-system") }}
|
||||||
</label>
|
</label>
|
||||||
<small>{{ nav.tr("export-include-system-hint") }}</small>
|
<small>{{ nav.tr("export-include-system-hint") }}</small>
|
||||||
<datalist id="export-tables">
|
|
||||||
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
|
|
||||||
</datalist>
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a href="/">{{ nav.tr("common-cancel") }}</a>
|
<a href="/">{{ nav.tr("common-cancel") }}</a>
|
||||||
<button type="submit">{{ nav.tr("export-download") }}</button>
|
<button type="submit">{{ nav.tr("export-download") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% include "pages/import_export/table_picker.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,22 +11,28 @@
|
|||||||
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
|
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
|
||||||
hx-disabled-elt="button[type=submit]">
|
hx-disabled-elt="button[type=submit]">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>{{ nav.tr("import-profile") }}
|
<label>{{ nav.tr("import-scope") }}
|
||||||
<input name="profile_name" list="import-profiles" value="{{ page.form.profile_name }}" required placeholder="billing">
|
<select name="profile_name" data-profile-select required>
|
||||||
|
<option value="">{{ nav.tr("import-choose-scope") }}</option>
|
||||||
|
{% for profile in page.catalog.profiles %}
|
||||||
|
<option value="{{ profile.name }}"{% if page.form.profile_name == profile.name %} selected{% endif %}>{{ profile.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>{{ nav.tr("import-target-tables") }}
|
||||||
|
<select name="table_names" data-table-select required>
|
||||||
|
<option value="">{{ nav.tr("import-choose-table") }}</option>
|
||||||
|
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}
|
||||||
|
<option value="{{ table }}" data-profile="{{ profile.name }}"{% if page.form.table_names.contains(table) %} selected{% endif %}>{{ table }}</option>
|
||||||
|
{%- endfor %}{% endfor %}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>{{ nav.tr("import-target-tables") }}<input name="table_names" list="import-tables" value="{{ page.form.table_names }}" required placeholder="invoices, customers"></label>
|
|
||||||
<label class="wide">{{ nav.tr("import-csv-file") }}
|
<label class="wide">{{ nav.tr("import-csv-file") }}
|
||||||
<input type="file" accept=".csv,text/csv"
|
<input type="file" accept=".csv,text/csv"
|
||||||
onchange="this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)">
|
onchange="this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)">
|
||||||
</label>
|
</label>
|
||||||
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
|
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="14" required>{{ page.form.csv_data }}</textarea></label>
|
||||||
</div>
|
</div>
|
||||||
<datalist id="import-profiles">
|
|
||||||
{% for profile in page.catalog.profiles %}<option value="{{ profile.name }}"></option>{% endfor %}
|
|
||||||
</datalist>
|
|
||||||
<datalist id="import-tables">
|
|
||||||
{%- for profile in page.catalog.profiles %}{% for table in profile.tables %}<option value="{{ table }}"></option>{% endfor %}{% endfor -%}
|
|
||||||
</datalist>
|
|
||||||
<div id="submission-status" aria-live="polite">
|
<div id="submission-status" aria-live="polite">
|
||||||
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
|
{%- if let Some(message) = page.error %}{% call alert::error(nav.locale, nav.tr("import-error-title"), message) %}{% endcall %}{% endif -%}
|
||||||
</div>
|
</div>
|
||||||
@@ -35,4 +41,5 @@
|
|||||||
<button type="submit">{{ nav.tr("import-import-rows") }}</button>
|
<button type="submit">{{ nav.tr("import-import-rows") }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% include "pages/import_export/table_picker.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
33
web/templates/pages/import_export/table_picker.html
Normal file
33
web/templates/pages/import_export/table_picker.html
Normal file
@@ -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 `<option>` is not
|
||||||
|
honoured everywhere, and a hidden option is still selectable with the
|
||||||
|
keyboard. With scripting off the dropdown lists every table it was rendered
|
||||||
|
with, and the server still refuses a table that is not in the profile.
|
||||||
|
#}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const profileSelect = document.querySelector("[data-profile-select]");
|
||||||
|
const tableSelect = document.querySelector("[data-table-select]");
|
||||||
|
if (!profileSelect || !tableSelect) return;
|
||||||
|
const placeholder = tableSelect.options[0];
|
||||||
|
const all = Array.from(tableSelect.options).slice(1);
|
||||||
|
function sync() {
|
||||||
|
const chosen = tableSelect.value;
|
||||||
|
tableSelect.replaceChildren(placeholder);
|
||||||
|
for (const option of all) {
|
||||||
|
if (option.dataset.profile === profileSelect.value) {
|
||||||
|
tableSelect.append(option);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tableSelect.value = Array.from(tableSelect.options).some(
|
||||||
|
(option) => option.value === chosen,
|
||||||
|
)
|
||||||
|
? chosen
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
profileSelect.addEventListener("change", sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user