import improvements
This commit is contained in:
44
web/templates/pages/import_export/import/behaviour.html
Normal file
44
web/templates/pages/import_export/import/behaviour.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{#
|
||||
Two things the mapping form does in the browser, both of them conveniences
|
||||
over rules the server enforces anyway.
|
||||
|
||||
Everything is delegated from `document`, because each step swaps the whole
|
||||
block: a listener bound to a select would be thrown away with it.
|
||||
#}
|
||||
<script>
|
||||
(function () {
|
||||
// A template is generated from the table, so there is no file to give.
|
||||
// Hiding the fields is not the check — the server refuses a file import
|
||||
// with no CSV either way.
|
||||
function syncSourceFields() {
|
||||
const mode = document.querySelector("[data-source-mode]:checked");
|
||||
const fields = document.querySelector("[data-source-fields]");
|
||||
if (!mode || !fields) return;
|
||||
fields.hidden = mode.value === "template";
|
||||
}
|
||||
|
||||
// A destination column can be filled from one source position only. The
|
||||
// mapping step refuses a repeat outright; this stops it being offered, so
|
||||
// the refusal is not how the user finds out.
|
||||
function syncDestinations() {
|
||||
const selects = Array.from(document.querySelectorAll("[data-destination]"));
|
||||
const taken = new Set(selects.map((select) => select.value).filter(Boolean));
|
||||
for (const select of selects) {
|
||||
for (const option of select.options) {
|
||||
option.disabled = option.value !== "" && option.value !== select.value && taken.has(option.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("change", function (event) {
|
||||
if (event.target.matches("[data-source-mode]")) syncSourceFields();
|
||||
if (event.target.matches("[data-destination]")) syncDestinations();
|
||||
});
|
||||
document.body.addEventListener("htmx:afterSwap", function () {
|
||||
syncSourceFields();
|
||||
syncDestinations();
|
||||
});
|
||||
syncSourceFields();
|
||||
syncDestinations();
|
||||
})();
|
||||
</script>
|
||||
13
web/templates/pages/import_export/import/carried.html
Normal file
13
web/templates/pages/import_export/import/carried.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{#
|
||||
What the first step answered, carried forward.
|
||||
|
||||
Steps 2 and 3 replace the fields that hold these, so they re-state them: the
|
||||
form is the whole state of the preparation, and the destination and the source
|
||||
text have to survive every swap. The summary line above them is so the user
|
||||
can still see what they are preparing after the selects are gone.
|
||||
#}
|
||||
<p class="hint">{{ nav.tr_args("import-carried", [("scope", page.form.profile_name.clone()), ("table", page.form.table_name.clone())]) }}</p>
|
||||
<input type="hidden" name="profile_name" value="{{ page.form.profile_name }}">
|
||||
<input type="hidden" name="table_name" value="{{ page.form.table_name }}">
|
||||
<input type="hidden" name="source_mode" value="{{ page.form.source_mode }}">
|
||||
<input type="hidden" name="csv_data" value="{{ page.form.csv_data }}">
|
||||
@@ -1,40 +0,0 @@
|
||||
{#
|
||||
The form's fields, on their own so that "Normalize headers" can hand them
|
||||
back with the rewritten CSV in the textarea — see
|
||||
crate::pages::import_export::import::ui::ImportFields. `changed` is `None`
|
||||
when the page first renders and `Some` once a rewrite has run.
|
||||
#}
|
||||
<div id="import-fields">
|
||||
<div class="form-grid">
|
||||
<label>{{ nav.tr("import-scope") }}
|
||||
<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 class="wide">{{ nav.tr("import-csv-file") }}
|
||||
<input type="file" accept=".csv,text/csv"
|
||||
onchange="this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)">
|
||||
</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>
|
||||
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="import_system_columns" value="true"{% if page.form.import_system_columns() %} checked{% endif %}>
|
||||
{{ nav.tr("import-system-columns") }}
|
||||
</label>
|
||||
<small>{{ nav.tr("import-system-columns-hint") }}</small>
|
||||
{%- if let Some(changed) = changed %}
|
||||
<p class="hint" role="status">{% if changed %}{{ nav.tr("import-normalized-message") }}{% else %}{{ nav.tr("import-normalized-unchanged") }}{% endif %}</p>
|
||||
{%- endif %}
|
||||
</div>
|
||||
@@ -1,6 +1,5 @@
|
||||
{# GET /admin/import — crate::pages::import_export::import::ui::ImportPage #}
|
||||
{% extends "ui/form_page.html" %}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
|
||||
{% block title %}{{ nav.tr("import-title") }}{% endblock %}
|
||||
{% block eyebrow %}{{ nav.tr("transfer-eyebrow") }}{% endblock %}
|
||||
@@ -8,22 +7,33 @@
|
||||
{% block lead %}<p>{{ nav.tr("import-lead") }}</p>{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form hx-post="/admin/import" hx-target="#submission-status" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
{% include "pages/import_export/import/fields.html" %}
|
||||
<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 -%}
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/">{{ nav.tr("common-cancel") }}</a>
|
||||
{# Rewrites the header rows and puts the result back in the textarea. It
|
||||
imports nothing: the file that gets imported is the one the user can see
|
||||
and has had the chance to read. #}
|
||||
<button type="button" hx-post="/admin/import/normalize" hx-include="closest form"
|
||||
hx-target="#import-fields" hx-swap="outerHTML"
|
||||
title="{{ nav.tr("import-normalize-hint") }}">{{ nav.tr("import-normalize") }}</button>
|
||||
<button type="submit">{{ nav.tr("import-import-rows") }}</button>
|
||||
</div>
|
||||
{#
|
||||
One form for the whole preparation. The steps swap the block inside it rather
|
||||
than the form itself, so the scope, the table and the CSV the user chose stay
|
||||
where they are while they move back and forth.
|
||||
|
||||
Its own `action` is the prepared-CSV download: a file has to come back from a
|
||||
real browser submit, so the plain submit button is the one that downloads and
|
||||
every other button carries an `hx-post` of its own.
|
||||
#}
|
||||
<form id="import-form" method="post" action="/admin/import/prepared.csv"
|
||||
hx-target="#import-step" hx-swap="outerHTML" hx-disabled-elt="this">
|
||||
{% include "pages/import_export/import/step.html" %}
|
||||
</form>
|
||||
{% include "pages/import_export/table_picker.html" %}
|
||||
{% include "pages/import_export/import/behaviour.html" %}
|
||||
{#
|
||||
A refusal is not a step. Without this the alert answering "Continue" would be
|
||||
swapped in place of the step it was refusing, and the user would lose the
|
||||
mapping they were being told to fix.
|
||||
#}
|
||||
<script>
|
||||
document.addEventListener("htmx:beforeSwap", function (event) {
|
||||
const form = event.detail.elt.closest && event.detail.elt.closest("#import-form");
|
||||
const status = document.getElementById("submission-status");
|
||||
if (!form || !status || event.detail.xhr.status < 400) return;
|
||||
event.detail.target = status;
|
||||
event.detail.swapOverride = "innerHTML";
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
226
web/templates/pages/import_export/import/step.html
Normal file
226
web/templates/pages/import_export/import/step.html
Normal file
@@ -0,0 +1,226 @@
|
||||
{#
|
||||
The preparation, one step at a time —
|
||||
crate::pages::import_export::import::ui::ImportStep.
|
||||
|
||||
Every button that moves between steps swaps this whole block, so each step
|
||||
re-states the answers the next one needs as hidden fields. The page keeps no
|
||||
server-side draft: what the form carries is the entire state of the
|
||||
preparation.
|
||||
#}
|
||||
<div id="import-step">
|
||||
{% match page.step %}
|
||||
|
||||
{#- ------------------------------------------------------------------
|
||||
Step 1 — where the data is going, and how the source is laid out.
|
||||
------------------------------------------------------------------ -#}
|
||||
{% when Step::Source %}
|
||||
<div class="form-grid">
|
||||
<label>{{ nav.tr("import-scope") }}
|
||||
<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_name" 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.is_table(table) %} selected{% endif %}>{{ table }}</option>
|
||||
{%- endfor %}{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#
|
||||
Always asked, never guessed. A data row can hold words that read exactly
|
||||
like column names, so deciding for the user is how a real row gets eaten
|
||||
as a header.
|
||||
#}
|
||||
<fieldset class="wide source-mode">
|
||||
<legend>{{ nav.tr("import-source-mode") }}</legend>
|
||||
<label class="check"><input type="radio" name="source_mode" value="header" data-source-mode
|
||||
{% if page.form.is_mode("header") %} checked{% endif %}>{{ nav.tr("import-source-header") }}</label>
|
||||
<label class="check"><input type="radio" name="source_mode" value="data" data-source-mode
|
||||
{% if page.form.is_mode("data") %} checked{% endif %}>{{ nav.tr("import-source-data") }}</label>
|
||||
<label class="check"><input type="radio" name="source_mode" value="template" data-source-mode
|
||||
{% if page.form.is_mode("template") %} checked{% endif %}>{{ nav.tr("import-source-template") }}</label>
|
||||
<p class="hint">{{ nav.tr("import-source-mode-hint") }}</p>
|
||||
</fieldset>
|
||||
|
||||
<div class="wide" data-source-fields>
|
||||
<div class="form-grid">
|
||||
<label class="wide">{{ nav.tr("import-csv-file") }}
|
||||
<input type="file" accept=".csv,text/csv"
|
||||
onchange="this.files[0]?.text().then(value => document.getElementById('csv-data').value = value)">
|
||||
</label>
|
||||
<label class="wide">{{ nav.tr("import-csv-data") }}<textarea id="csv-data" name="csv_data" rows="12">{{ page.form.csv_data }}</textarea></label>
|
||||
<small class="wide">{{ nav.tr("import-csv-format-hint") }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<a href="/">{{ nav.tr("common-cancel") }}</a>
|
||||
<button type="button" hx-post="/admin/import/prepare" hx-include="closest form">{{ nav.tr("import-continue") }}</button>
|
||||
</div>
|
||||
|
||||
{#- ------------------------------------------------------------------
|
||||
Step 2a — one row per source position, pointed wherever the user says.
|
||||
------------------------------------------------------------------ -#}
|
||||
{% when Step::Mapping with (step) %}
|
||||
{% include "pages/import_export/import/carried.html" %}
|
||||
<div class="builder-section">
|
||||
<h2>{{ nav.tr_args("import-mapping-heading", [("table", step.table_name.clone())]) }}</h2>
|
||||
<p class="hint">{{ nav.tr("import-mapping-hint") }}</p>
|
||||
<p class="hint">{{ nav.tr_args("import-mapping-rows", [("rows", step.source_rows.to_string())]) }}</p>
|
||||
<table class="builder-table mapping-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ nav.tr("import-th-position") }}</th>
|
||||
{% if step.has_source_names %}<th>{{ nav.tr("import-th-source-name") }}</th>{% endif %}
|
||||
<th>{{ nav.tr("import-th-example") }}</th>
|
||||
<th>{{ nav.tr("import-th-destination") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in step.rows %}
|
||||
<tr>
|
||||
<td class="mark">{{ row.position }}</td>
|
||||
{% if step.has_source_names %}<td class="source-name">{% if let Some(name) = row.source_name %}{{ name }}{% endif %}</td>{% endif %}
|
||||
<td class="example">{{ row.example }}</td>
|
||||
<td>
|
||||
<select name="mapping" data-destination>
|
||||
<option value="">{{ nav.tr("import-destination-ignore") }}</option>
|
||||
{% for column in step.columns %}
|
||||
<option value="{{ column }}"{% if row.targets(column) %} selected{% endif %}>{{ column }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" hx-post="/admin/import/source" hx-include="closest form">{{ nav.tr("import-back-to-source") }}</button>
|
||||
<button type="button" hx-post="/admin/import/preview" hx-include="closest form">{{ nav.tr("import-to-preview") }}</button>
|
||||
</div>
|
||||
|
||||
{#- ------------------------------------------------------------------
|
||||
Step 2b — the template generator: pick the columns, arrange them, take
|
||||
away the header.
|
||||
------------------------------------------------------------------ -#}
|
||||
{% when Step::Template with (step) %}
|
||||
{% include "pages/import_export/import/carried.html" %}
|
||||
<div class="builder-section">
|
||||
<h2>{{ nav.tr_args("import-template-heading", [("table", step.table_name.clone())]) }}</h2>
|
||||
<p class="hint">{{ nav.tr("import-template-hint") }}</p>
|
||||
<table class="builder-table template-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ nav.tr("import-th-order") }}</th>
|
||||
<th>{{ nav.tr("import-th-include") }}</th>
|
||||
<th>{{ nav.tr("import-th-destination") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in step.rows %}
|
||||
<tr>
|
||||
<td class="order">
|
||||
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-up", [("name", row.name.clone())]) }}"
|
||||
{% if row.first %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
|
||||
hx-vals='{"action": "up", "index": "{{ row.index }}"}'{% endif %}>↑</button>
|
||||
<button type="button" class="toggle move" aria-label="{{ nav.tr_args("import-move-down", [("name", row.name.clone())]) }}"
|
||||
{% if row.last %}disabled{% else %}hx-post="/admin/import/prepare" hx-include="closest form"
|
||||
hx-vals='{"action": "down", "index": "{{ row.index }}"}'{% endif %}>↓</button>
|
||||
</td>
|
||||
<td>
|
||||
{#
|
||||
Both fields post in document order, so moving a row moves the
|
||||
generated header with it: `template_order` remembers where the
|
||||
unticked ones sit, `template_columns` is the header itself.
|
||||
#}
|
||||
<input type="hidden" name="template_order" value="{{ row.name }}">
|
||||
<label class="check"><input type="checkbox" name="template_columns" value="{{ row.name }}"{% if row.chosen %} checked{% endif %}
|
||||
hx-post="/admin/import/prepare" hx-include="closest form"></label>
|
||||
</td>
|
||||
<td><code>{{ row.name }}</code>{% if row.required %} <span class="hint">{{ nav.tr("import-required-column") }}</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if step.chosen > 0 %}
|
||||
<p class="hint">{{ nav.tr("import-template-generated") }}</p>
|
||||
<pre class="sql-preview">{{ step.header }}</pre>
|
||||
{% else %}
|
||||
<p class="hint">{{ nav.tr("import-template-nothing-chosen") }}</p>
|
||||
{% endif %}
|
||||
<p class="hint">{{ nav.tr("import-template-round-trip") }}</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" hx-post="/admin/import/source" hx-include="closest form">{{ nav.tr("import-back-to-source") }}</button>
|
||||
{# A real submit: the answer is a file. #}
|
||||
<button type="submit" formaction="/admin/import/template.csv" formnovalidate>{{ nav.tr("import-download-template") }}</button>
|
||||
</div>
|
||||
|
||||
{#- ------------------------------------------------------------------
|
||||
Step 3 — the prepared import, exactly as it will be sent.
|
||||
------------------------------------------------------------------ -#}
|
||||
{% when Step::Preview with (step) %}
|
||||
{% include "pages/import_export/import/carried.html" %}
|
||||
{% for value in page.form.mapping %}<input type="hidden" name="mapping" value="{{ value }}">{% endfor %}
|
||||
<div class="builder-section">
|
||||
<h2>{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}</h2>
|
||||
<dl class="postable-grid">
|
||||
<div>
|
||||
<dt>{{ nav.tr("import-summary-rows") }}</dt>
|
||||
<dd><strong>{{ step.source_rows }}</strong></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ nav.tr("import-summary-columns-used") }}</dt>
|
||||
<dd>{{ step.columns|join(", ") }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ nav.tr("import-summary-ignored") }}</dt>
|
||||
<dd>{% if step.ignored.is_empty() %}<span class="hint">—</span>{% else %}{{ step.ignored|join(", ") }}{% endif %}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ nav.tr("import-summary-omitted") }}</dt>
|
||||
<dd>{% if step.omitted.is_empty() %}<span class="hint">—</span>{% else %}{{ step.omitted|join(", ") }}{% endif %}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{% if !step.missing_required.is_empty() %}
|
||||
<p class="error">{{ nav.tr_args("import-summary-missing-required", [("columns", step.missing_required.join(", "))]) }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="builder-table preview">
|
||||
<thead><tr>{% for column in step.columns %}<th>{{ column }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>
|
||||
{% for row in step.rows %}
|
||||
<tr>{% for value in row %}<td>{{ value }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if step.hidden_rows > 0 %}
|
||||
<p class="hint">{{ nav.tr_args("import-preview-more-rows", [("rows", step.hidden_rows.to_string())]) }}</p>
|
||||
{% endif %}
|
||||
|
||||
<details class="rename-generated">
|
||||
<summary>{{ nav.tr("import-prepared-csv") }}</summary>
|
||||
<p class="hint">{{ nav.tr("import-prepared-csv-hint") }}</p>
|
||||
<pre class="sql-preview">{{ step.csv }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" hx-post="/admin/import/prepare" hx-include="closest form">{{ nav.tr("import-back-to-mapping") }}</button>
|
||||
{# A real submit: the answer is a file. #}
|
||||
<button type="submit" formnovalidate>{{ nav.tr("import-download-prepared") }}</button>
|
||||
<button type="button" hx-post="/admin/import" hx-include="closest form"
|
||||
hx-target="#submission-status" hx-swap="innerHTML">{{ nav.tr("import-import-rows") }}</button>
|
||||
</div>
|
||||
{% endmatch %}
|
||||
|
||||
<div id="submission-status" aria-live="polite"></div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user