import improvements2
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{#
|
||||
Two things the mapping form does in the browser, both of them conveniences
|
||||
Two things the mapping table 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
|
||||
@@ -7,38 +7,28 @@
|
||||
#}
|
||||
<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]"));
|
||||
function sync() {
|
||||
const selects = Array.from(document.querySelectorAll("[data-source-select]"));
|
||||
// A source column can fill one destination only. The mapping refuses a
|
||||
// repeat outright; this stops it being offered, so the refusal is not
|
||||
// how the user finds out.
|
||||
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);
|
||||
}
|
||||
// The example beside the row is the first data row's value at the
|
||||
// chosen position, so the row shows what it is actually going to
|
||||
// import rather than only which column it named.
|
||||
const cell = select.closest("tr")?.querySelector("[data-example-cell]");
|
||||
if (cell) cell.textContent = select.selectedOptions[0]?.dataset.example ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("change", function (event) {
|
||||
if (event.target.matches("[data-source-mode]")) syncSourceFields();
|
||||
if (event.target.matches("[data-destination]")) syncDestinations();
|
||||
if (event.target.matches("[data-source-select]")) sync();
|
||||
});
|
||||
document.body.addEventListener("htmx:afterSwap", function () {
|
||||
syncSourceFields();
|
||||
syncDestinations();
|
||||
});
|
||||
syncSourceFields();
|
||||
syncDestinations();
|
||||
document.body.addEventListener("htmx:afterSwap", sync);
|
||||
sync();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
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.
|
||||
form is the whole state of the preparation, and the destination and the file
|
||||
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 }}">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{% match page.step %}
|
||||
|
||||
{#- ------------------------------------------------------------------
|
||||
Step 1 — where the data is going, and how the source is laid out.
|
||||
Step 1 — where the data is going, and the file it comes from.
|
||||
------------------------------------------------------------------ -#}
|
||||
{% when Step::Source %}
|
||||
<div class="form-grid">
|
||||
@@ -31,33 +31,12 @@
|
||||
{%- 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>
|
||||
<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 class="form-actions">
|
||||
<a href="/">{{ nav.tr("common-cancel") }}</a>
|
||||
@@ -65,7 +44,9 @@
|
||||
</div>
|
||||
|
||||
{#- ------------------------------------------------------------------
|
||||
Step 2a — one row per source position, pointed wherever the user says.
|
||||
Step 2 — one row per destination column, asking where its value comes
|
||||
from. Read this way round, a destination cannot be filled twice: it
|
||||
appears exactly once.
|
||||
------------------------------------------------------------------ -#}
|
||||
{% when Step::Mapping with (step) %}
|
||||
{% include "pages/import_export/import/carried.html" %}
|
||||
@@ -76,26 +57,32 @@
|
||||
<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>
|
||||
<th>{{ nav.tr("import-th-required") }}</th>
|
||||
<th>{{ nav.tr("import-th-source") }}</th>
|
||||
<th>{{ nav.tr("import-th-example") }}</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><code>{{ row.name }}</code></td>
|
||||
<td>{% if row.required %}{{ nav.tr("common-yes") }}{% else %}<span class="hint">{{ nav.tr("common-no") }}</span>{% endif %}</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>
|
||||
{#
|
||||
The destination travels as its stable identity, so a rename
|
||||
between here and Import moves the mapping with the column
|
||||
instead of leaving it pointing at a name.
|
||||
#}
|
||||
<input type="hidden" name="destination" value="{{ row.key }}">
|
||||
<select name="source_position" data-source-select>
|
||||
<option value="">{{ nav.tr("import-source-none") }}</option>
|
||||
{% for option in step.sources %}
|
||||
<option value="{{ option.position }}" data-example="{{ option.example }}"{% if row.takes(option) %} selected{% endif %}>{{ option.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="example" data-example-cell>{{ row.example }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -106,69 +93,17 @@
|
||||
<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 %}
|
||||
{#
|
||||
The mapping travels with the preview, so Import and Download prepare the
|
||||
identical file from the identical answers.
|
||||
#}
|
||||
{% for key in page.form.destination %}<input type="hidden" name="destination" value="{{ key }}">{% endfor %}
|
||||
{% for position in page.form.source_position %}<input type="hidden" name="source_position" value="{{ position }}">{% endfor %}
|
||||
<div class="builder-section">
|
||||
<h2>{{ nav.tr_args("import-preview-heading", [("table", step.table_name.clone())]) }}</h2>
|
||||
<dl class="postable-grid">
|
||||
|
||||
Reference in New Issue
Block a user