export works from web

This commit is contained in:
Priec
2026-08-15 19:05:59 +02:00
parent 10d9784b9b
commit e3104a367e
11 changed files with 199 additions and 92 deletions

View File

@@ -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ů.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<_>>();

View File

@@ -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,

View File

@@ -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<_>>();

View File

@@ -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 %}

View File

@@ -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 =&gt; document.getElementById('csv-data').value = value)"> onchange="this.files[0]?.text().then(value =&gt; 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 %}

View 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>