web routing is POG now
This commit is contained in:
@@ -9,10 +9,9 @@
|
||||
<div>
|
||||
<p class="eyebrow">Workspace</p>
|
||||
<h1>Admin panel</h1>
|
||||
<p>Browse profiles, tables, and their physical columns.</p>
|
||||
<p>Browse profiles, tables, and their physical columns. Pick a table to reach what can be done to it.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if page.can_manage_tables %}<a href="/admin/table-definition">Table definition</a>{% endif %}
|
||||
{% if page.can_manage_tables %}<a href="/admin/tables/new">Add table</a>{% endif %}
|
||||
{% if page.can_manage_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %}
|
||||
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}
|
||||
|
||||
@@ -50,9 +50,38 @@
|
||||
</small>
|
||||
</button>
|
||||
</form>
|
||||
{#
|
||||
The actions for the table you are pointed at, next to the table
|
||||
itself. Each one is a page of its own; this pane is where you
|
||||
choose what to act on, so it is also where you say what to do.
|
||||
#}
|
||||
{% if page.selected_table.as_deref() == Some(table.name.as_str()) %}
|
||||
<div class="browser-actions">
|
||||
{% if page.can_manage_tables && !table.is_system() %}
|
||||
<a href="/admin/tables/columns?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">Add / rename columns</a>
|
||||
<a class="danger-action" href="/admin/tables/delete?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">Delete table</a>
|
||||
{% endif %}
|
||||
<a href="/permissions/grants">Who may use it</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if page.selected_table.is_none() %}
|
||||
<p class="empty">Pick a table to add columns to it, delete it, or say who may use it.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if page.can_manage_tables && page.selected_profile.is_some() %}
|
||||
<div class="browser-actions pane-footer">
|
||||
{% if page.is_global() %}
|
||||
<a href="/admin/tables/new?global=true">+ New global table</a>
|
||||
{% else %}
|
||||
<a href="/admin/tables/new?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">+ New table</a>
|
||||
<a href="/admin/tables/from-template?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">From template</a>
|
||||
<a href="/admin/profiles/copy?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">Copy profile</a>
|
||||
<a href="/admin/profiles/history?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">Rename history</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="pane">
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</label>
|
||||
<label>Column type
|
||||
<select name="column_type_input"
|
||||
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
|
||||
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
|
||||
hx-trigger="change" hx-include="#column-form"
|
||||
hx-target="#column-panel" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "refresh"}'>
|
||||
@@ -134,7 +134,7 @@
|
||||
</div>
|
||||
|
||||
<button type="button" class="secondary"
|
||||
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
|
||||
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
|
||||
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "add-column"}'>Stage column</button>
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
there is no column of its own name to index. #}
|
||||
{% if page.columns.is_indexable(*loop.index0) %}
|
||||
<button type="button" class="toggle"
|
||||
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
|
||||
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
|
||||
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
|
||||
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
||||
@@ -168,7 +168,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="danger"
|
||||
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}"
|
||||
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}"
|
||||
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
||||
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
|
||||
</td>
|
||||
|
||||
23
web/templates/pages/admin/table_definition/columns.html
Normal file
23
web/templates/pages/admin/table_definition/columns.html
Normal file
@@ -0,0 +1,23 @@
|
||||
{# GET /admin/tables/columns — crate::pages::admin::table_definition::ui::ColumnsPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Columns{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
{% include "pages/admin/table_definition/context.html" %}
|
||||
|
||||
{#
|
||||
One swap target for the page's body. Both writes answer with this markup
|
||||
re-read from the backend, so the screen after a change is the definition
|
||||
as it now is, not the form that was submitted.
|
||||
#}
|
||||
<div id="table-panel" aria-live="polite">
|
||||
{% include "pages/admin/table_definition/columns_panel.html" %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<datalist id="currency-codes">
|
||||
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{#
|
||||
The columns page's body — crate::pages::admin::table_definition::ui::ColumnsFragment.
|
||||
Both writes on this page swap it, so it carries the outcome as well as the
|
||||
forms.
|
||||
#}
|
||||
{% include "pages/admin/table_definition/feedback.html" %}
|
||||
|
||||
{% if page.table_is_writable() %}
|
||||
{#
|
||||
Append columns. The panel stages columns without writing anything; the
|
||||
selection travels in the URL so the posted fields are exactly the ones the
|
||||
Add-table builder posts, and both are decoded by the same code.
|
||||
#}
|
||||
<section class="panel">
|
||||
<h2>Add columns to <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.</p>
|
||||
<form id="column-form"
|
||||
hx-post="/admin/tables/columns{{ page.selection.query() }}"
|
||||
hx-target="#table-panel" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div id="column-panel">
|
||||
{% include "pages/admin/table_definition/column_panel.html" %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if let Some(detail) = page.detail %}
|
||||
<section class="panel">
|
||||
<h2>Rename a column</h2>
|
||||
<p class="hint">Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows.</p>
|
||||
<form hx-post="/admin/tables/rename"
|
||||
hx-target="#table-panel" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<input type="hidden" name="table" value="{{ page.selection.table }}">
|
||||
<div class="form-grid">
|
||||
<label>Column
|
||||
<select name="old_column_name">
|
||||
<option value="">Choose a column</option>
|
||||
{% for column in detail.renameable_columns() %}
|
||||
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>New name
|
||||
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Rename column</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(detail) = page.detail %}
|
||||
<section class="panel">
|
||||
<h2>Columns it has now <span class="count">{{ detail.columns.len() }}</span></h2>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
{% for column in detail.columns %}
|
||||
<tr>
|
||||
<td><code>{{ column.name }}</code></td>
|
||||
<td>{{ column.field_type }}{% if !column.sql_type.is_empty() %} <small class="hint">{{ column.sql_type }}</small>{% endif %}</td>
|
||||
<td>
|
||||
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if !detail.scripts.is_empty() %}
|
||||
<h3 class="panel-subhead">Scripts</h3>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Target column</th><th>Type</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
{% for script in detail.scripts %}
|
||||
<tr>
|
||||
<td><code>{{ script.target_column }}</code></td>
|
||||
<td>{{ script.target_column_type }}</td>
|
||||
<td>
|
||||
{{ script.description }}
|
||||
<details><summary>source</summary><pre class="sql-preview">{{ script.script }}</pre></details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
76
web/templates/pages/admin/table_definition/context.html
Normal file
76
web/templates/pages/admin/table_definition/context.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{#
|
||||
The heading and action switcher shared by the five table-definition pages.
|
||||
Every one of them carries a `page` (crate::pages::admin::table_definition::
|
||||
state::TableDefinitionPageState), whose `active` says which is open.
|
||||
|
||||
These are deliberately separate pages, for the same reason the permission
|
||||
sections are: adding a column, dropping a table, copying a profile and
|
||||
reading its rename history are four different decisions, and stacking them
|
||||
on one screen was what made the old workspace impossible to find anything
|
||||
in. Which links appear follows how much has been selected — the table-wide
|
||||
ones need a table, the profile-wide ones only a profile.
|
||||
#}
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">
|
||||
{%- if page.selection.has_table() %}{{ page.selection.scope_label() }}{% else %}Table definition{% endif -%}
|
||||
</p>
|
||||
<h1>
|
||||
{%- if page.selection.has_table() %}{{ page.selection.table }}{% else %}{{ page.selection.scope_label() }}{% endif -%}
|
||||
</h1>
|
||||
<p>
|
||||
{%- if let Some(detail) = page.detail -%}
|
||||
{{ detail.columns.len() }} columns · identified by
|
||||
{%- if detail.row_display_columns.is_empty() %} its id
|
||||
{%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%}
|
||||
{%- if let Some(summary) = page.selected_table() -%}
|
||||
{%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%}
|
||||
{%- endif -%}
|
||||
.
|
||||
{%- else -%}
|
||||
Everything on this page acts on this profile.
|
||||
{%- endif -%}
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/admin{% if page.selection.has_table() %}{{ page.selection.query() }}{% else %}{{ page.selection.profile_query() }}{% endif %}">← Admin panel</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="tabs" aria-label="table actions">
|
||||
{% if page.selection.has_table() %}
|
||||
{% if page.table_is_writable() %}
|
||||
<a href="/admin/tables/columns{{ page.selection.query() }}" class="tab {% if page.is("columns") %}selected{% endif %}" {% if page.is("columns") %}aria-current="page"{% endif %}>
|
||||
<span>Columns</span><small>Append columns and rename them</small>
|
||||
</a>
|
||||
<a href="/admin/tables/delete{{ page.selection.query() }}" class="tab {% if page.is("delete") %}selected{% endif %}" {% if page.is("delete") %}aria-current="page"{% endif %}>
|
||||
<span>Delete</span><small>Drop this table</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/permissions/grants" class="tab">
|
||||
<span>Access</span><small>Which roles may read and write it</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if page.selection.has_profile() && !page.selection.is_global() %}
|
||||
<a href="/admin/profiles/copy{{ page.selection.profile_query() }}" class="tab {% if page.is("copy") %}selected{% endif %}" {% if page.is("copy") %}aria-current="page"{% endif %}>
|
||||
<span>Copy profile</span><small>Duplicate its structure</small>
|
||||
</a>
|
||||
<a href="/admin/tables/from-template{{ page.selection.profile_query() }}" class="tab {% if page.is("template") %}selected{% endif %}" {% if page.is("template") %}aria-current="page"{% endif %}>
|
||||
<span>From template</span><small>Create tables from a Typst invoice</small>
|
||||
</a>
|
||||
<a href="/admin/profiles/history{{ page.selection.profile_query() }}" class="tab {% if page.is("history") %}selected{% endif %}" {% if page.is("history") %}aria-current="page"{% endif %}>
|
||||
<span>Rename history</span><small>How columns got their names</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
{% if page.selection.has_table() && !page.table_is_writable() %}
|
||||
<section class="panel">
|
||||
<h2>Backend-managed</h2>
|
||||
<p class="hint">
|
||||
This table is the backend's own. Its definition is visible in the
|
||||
<a href="/admin{{ page.selection.query() }}">admin panel</a>, but it can only be
|
||||
changed through the backend's own APIs, so none of the write actions apply to it.
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
14
web/templates/pages/admin/table_definition/copy.html
Normal file
14
web/templates/pages/admin/table_definition/copy.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{# GET /admin/profiles/copy — crate::pages::admin::table_definition::ui::CopyPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Copy profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
{% include "pages/admin/table_definition/context.html" %}
|
||||
|
||||
<div id="table-panel" aria-live="polite">
|
||||
{% include "pages/admin/table_definition/copy_panel.html" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
33
web/templates/pages/admin/table_definition/copy_panel.html
Normal file
33
web/templates/pages/admin/table_definition/copy_panel.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{#
|
||||
The copy-profile page's body —
|
||||
crate::pages::admin::table_definition::ui::CopyFragment.
|
||||
#}
|
||||
{% include "pages/admin/table_definition/feedback.html" %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Copy <code>{{ page.selection.profile }}</code> into a new profile</h2>
|
||||
<p class="hint">Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile.</p>
|
||||
<form hx-post="/admin/profiles/copy"
|
||||
hx-target="#table-panel" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<div class="form-grid">
|
||||
<label>New profile name
|
||||
<input name="target_profile_name" value="{{ page.copy.target_profile_name }}" placeholder="billing_2027">
|
||||
</label>
|
||||
</div>
|
||||
{% if !page.copy_candidates().is_empty() %}
|
||||
<div class="check-group">
|
||||
{% for table in page.copy_candidates() %}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="table_names" value="{{ table.name }}"
|
||||
{% if page.copy_selected(table.name.as_str()) %}checked{% endif %}>
|
||||
{{ table.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit">Copy profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
14
web/templates/pages/admin/table_definition/delete.html
Normal file
14
web/templates/pages/admin/table_definition/delete.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{# GET /admin/tables/delete — crate::pages::admin::table_definition::ui::DeletePage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Delete table{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
{% include "pages/admin/table_definition/context.html" %}
|
||||
|
||||
<div id="table-panel" aria-live="polite">
|
||||
{% include "pages/admin/table_definition/delete_panel.html" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
39
web/templates/pages/admin/table_definition/delete_panel.html
Normal file
39
web/templates/pages/admin/table_definition/delete_panel.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{#
|
||||
The delete page's body — crate::pages::admin::table_definition::ui::DeleteFragment.
|
||||
Only a refused delete swaps this: a successful one redirects to the admin
|
||||
panel, because the table this page was about no longer exists.
|
||||
#}
|
||||
{% include "pages/admin/table_definition/feedback.html" %}
|
||||
|
||||
{% if page.table_is_writable() %}
|
||||
<section class="panel danger-panel">
|
||||
<h2>Delete <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">
|
||||
Drops the table and its definition, and the profile too when this was its
|
||||
last table. The backend refuses to delete a table that still has rows.
|
||||
</p>
|
||||
{% if let Some(summary) = page.selected_table() %}
|
||||
{% if !summary.depends_on.is_empty() %}
|
||||
<p class="hint">It links to {{ summary.depends_on|join(", ") }}.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<form hx-post="/admin/tables/delete"
|
||||
hx-target="#table-panel" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<input type="hidden" name="table" value="{{ page.selection.table }}">
|
||||
<div class="form-grid">
|
||||
<label class="wide">Type <code>{{ page.selection.table }}</code> to confirm
|
||||
<input name="confirm_table_name" value="" autocomplete="off" placeholder="{{ page.selection.table }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger-submit">Delete table</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% else if !page.selection.has_table() %}
|
||||
<section class="panel">
|
||||
<h2>No table chosen</h2>
|
||||
<p class="hint">Pick the table to delete in the <a href="/admin{{ page.selection.profile_query() }}">admin panel</a> first.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
24
web/templates/pages/admin/table_definition/feedback.html
Normal file
24
web/templates/pages/admin/table_definition/feedback.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{#
|
||||
The outcome of the last write, reported the way every page here reports it:
|
||||
inline as an alert, as a modal that has to be dismissed, and — on success —
|
||||
as a toast that outlives the swap.
|
||||
|
||||
Included at the top of each page's panel fragment, because the fragment is
|
||||
what a write swaps, so this is the markup that has to carry the reason.
|
||||
#}
|
||||
{% import "ui/alert.html" as feedback_alert %}
|
||||
{% import "ui/dialog.html" as feedback_dialog %}
|
||||
{% import "ui/toast.html" as feedback_toast %}
|
||||
|
||||
{%- if let Some(message) = page.error %}{% call feedback_alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
||||
{%- if let Some(message) = page.status %}{% call feedback_toast::success("Done", message) %}{% endcall %}{% endif -%}
|
||||
{%- if let Some(message) = page.error %}{% call feedback_dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
||||
|
||||
{% if let Some(sql) = page.sql %}
|
||||
{% if !sql.is_empty() %}
|
||||
<section class="panel">
|
||||
<h2>SQL the backend ran</h2>
|
||||
<pre class="sql-preview">{{ sql }}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,14 @@
|
||||
{# GET /admin/tables/from-template — crate::pages::admin::table_definition::ui::TemplatePage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Create from template{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
{% include "pages/admin/table_definition/context.html" %}
|
||||
|
||||
<div id="table-panel" aria-live="polite">
|
||||
{% include "pages/admin/table_definition/from_template_panel.html" %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,53 @@
|
||||
{#
|
||||
The invoice-template page's body —
|
||||
crate::pages::admin::table_definition::ui::TemplateFragment.
|
||||
#}
|
||||
{% include "pages/admin/table_definition/feedback.html" %}
|
||||
|
||||
{% if !page.generated.is_empty() %}
|
||||
<section class="panel">
|
||||
<h2>Tables created from the template</h2>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Table</th><th>Collection</th><th>Parent</th></tr></thead>
|
||||
<tbody>
|
||||
{% for generated in page.generated %}
|
||||
<tr>
|
||||
<td><code>{{ generated.table_name }}</code></td>
|
||||
<td>{% if generated.collection_path.is_empty() %}<span class="hint">root</span>{% else %}<code>{{ generated.collection_path }}</code>{% endif %}</td>
|
||||
<td>{% if generated.parent_table_name.is_empty() %}<span class="hint">—</span>{% else %}<code>{{ generated.parent_table_name }}</code>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Create tables from an invoice template</h2>
|
||||
<p class="hint">
|
||||
Reads the <code>#let komp_ac_fields = (…)</code> contract out of a Typst
|
||||
template. A path that matches an existing table and column becomes a
|
||||
reference to it; anything else becomes a TEXT column to refine later, and
|
||||
every <code>[]</code> becomes a child table.
|
||||
</p>
|
||||
<form hx-post="/admin/tables/from-template"
|
||||
hx-target="#table-panel" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<div class="form-grid">
|
||||
<label>Table name
|
||||
<input name="table_name" value="{{ page.invoice.table_name }}" placeholder="invoice">
|
||||
</label>
|
||||
<label>Row display columns
|
||||
<input name="row_display_columns" value="{{ page.invoice.row_display_columns }}" placeholder="number, issued_on">
|
||||
<small>Comma-separated, and only for the root table.</small>
|
||||
</label>
|
||||
<label class="wide">Template source
|
||||
<textarea name="typst_source" rows="10" placeholder="#let komp_ac_fields = ( "sidlo.nazov", "people[].name", )">{{ page.invoice.typst_source }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Create from template</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
39
web/templates/pages/admin/table_definition/history.html
Normal file
39
web/templates/pages/admin/table_definition/history.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{#
|
||||
GET /admin/profiles/history — crate::pages::admin::table_definition::ui::HistoryPage
|
||||
|
||||
Read-only, so there is nothing here to swap and no panel fragment beside it.
|
||||
A table in the selection narrows the history to that table; the loader is
|
||||
what applies that, by passing its id to the backend.
|
||||
#}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Rename history{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
{% include "pages/admin/table_definition/context.html" %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Column rename history</h2>
|
||||
{% if page.history.is_empty() %}
|
||||
<p class="hint">
|
||||
No column in {% if page.selection.has_table() %}<code>{{ page.selection.table }}</code>{% else %}this profile{% endif %} has been renamed.
|
||||
</p>
|
||||
{% else %}
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Table</th><th>Was</th><th>Is</th><th>When</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in page.history %}
|
||||
<tr>
|
||||
<td><code>{{ entry.table_name }}</code></td>
|
||||
<td><code>{{ entry.old_column_name }}</code></td>
|
||||
<td><code>{{ entry.new_column_name }}</code></td>
|
||||
<td>{{ entry.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -1,32 +0,0 @@
|
||||
{# GET /admin/table-definition — crate::pages::admin::table_definition::ui::TableDefinitionPage #}
|
||||
{% extends "ui/base.html" %}
|
||||
|
||||
{% block title %}Table definition{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="heading">
|
||||
<div>
|
||||
<p class="eyebrow">Table definition</p>
|
||||
<h1>Table definition</h1>
|
||||
<p>Choose Global to manage tables shared by every profile, or choose a profile to manage only its own tables.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="/admin">← Admin panel</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#
|
||||
One swap target for the whole workspace. Every write answers with this
|
||||
markup re-read from the backend, so the screen after a change is the
|
||||
definition as it now is, not the form that was submitted.
|
||||
#}
|
||||
<div id="table-definition-workspace" aria-live="polite">
|
||||
{% include "pages/admin/table_definition/workspace.html" %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<datalist id="currency-codes">
|
||||
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
{% endblock %}
|
||||
@@ -1,342 +0,0 @@
|
||||
{#
|
||||
The whole workspace — crate::pages::admin::table_definition::ui::WorkspaceFragment,
|
||||
and what table_definition.html embeds on first load.
|
||||
|
||||
Which panels appear follows how much has been selected: the copy and invoice
|
||||
template panels need a profile, everything else needs a table, and a
|
||||
backend-managed ("system") table gets none of the write panels at all,
|
||||
because the server refuses every one of them for it.
|
||||
#}
|
||||
{% import "ui/alert.html" as alert %}
|
||||
{% import "ui/dialog.html" as dialog %}
|
||||
{% import "ui/toast.html" as toast %}
|
||||
|
||||
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
||||
{%- if let Some(message) = page.status %}{% call toast::success("Done", message) %}{% endcall %}{% endif -%}
|
||||
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
|
||||
|
||||
{% if let Some(sql) = page.sql %}
|
||||
{% if !sql.is_empty() %}
|
||||
<section class="panel">
|
||||
<h2>SQL the backend ran</h2>
|
||||
<pre class="sql-preview">{{ sql }}</pre>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if !page.generated.is_empty() %}
|
||||
<section class="panel">
|
||||
<h2>Tables created from the template</h2>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Table</th><th>Collection</th><th>Parent</th></tr></thead>
|
||||
<tbody>
|
||||
{% for generated in page.generated %}
|
||||
<tr>
|
||||
<td><code>{{ generated.table_name }}</code></td>
|
||||
<td>{% if generated.collection_path.is_empty() %}<span class="hint">root</span>{% else %}<code>{{ generated.collection_path }}</code>{% endif %}</td>
|
||||
<td>{% if generated.parent_table_name.is_empty() %}<span class="hint">—</span>{% else %}<code>{{ generated.parent_table_name }}</code>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Selection</h2>
|
||||
<div class="form-grid">
|
||||
<label>Scope
|
||||
<select name="profile" hx-get="/admin/table-definition/workspace"
|
||||
hx-target="#table-definition-workspace" hx-swap="innerHTML">
|
||||
<option value="">Choose a scope</option>
|
||||
<option value="__global" {% if page.selection.is_global() %}selected{% endif %}>Global — all profiles</option>
|
||||
{% for profile in page.profiles %}
|
||||
<option value="{{ profile }}" {% if page.selection.profile == *profile %}selected{% endif %}>{{ profile }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{% if page.selection.has_profile() %}
|
||||
<label>Table
|
||||
<select name="table" hx-get="/admin/table-definition/workspace"
|
||||
hx-target="#table-definition-workspace" hx-swap="innerHTML"
|
||||
hx-include="[name='profile']">
|
||||
<option value="">Choose a table</option>
|
||||
{% for table in page.tables %}
|
||||
<option value="{{ table.name }}" {% if page.selection.table == table.name %}selected{% endif %}>
|
||||
{{ table.name }}{% if table.is_system() %} (system){% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if page.selection.has_profile() %}
|
||||
<div class="actions panel-actions">
|
||||
{% if page.selection.is_global() %}
|
||||
<a href="/admin/tables/new?global=true">+ Create a global table</a>
|
||||
{% else %}
|
||||
<a href="/admin/tables/new?profile={{ page.selection.profile }}">+ Create a table in this profile</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if page.tables.is_empty() %}
|
||||
<p class="hint">This profile has no tables yet.</p>
|
||||
{% else %}
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Table</th><th>Kind</th><th>Depends on</th><th>Row display</th></tr></thead>
|
||||
<tbody>
|
||||
{% for table in page.tables %}
|
||||
<tr>
|
||||
<td><code>{{ table.name }}</code></td>
|
||||
<td>{{ table.table_kind }}</td>
|
||||
<td>{% if table.depends_on.is_empty() %}<span class="hint">—</span>{% else %}{{ table.depends_on|join(", ") }}{% endif %}</td>
|
||||
<td>{% if table.row_display_columns.is_empty() %}<span class="hint">id</span>{% else %}{{ table.row_display_columns|join(", ") }}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="hint">Nothing is selected yet. Every panel below acts on one profile, and most of them on one table inside it.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if let Some(detail) = page.detail %}
|
||||
<section class="panel">
|
||||
<h2>{{ detail.name }} <span class="count">{{ detail.columns.len() }} columns</span></h2>
|
||||
<p class="hint">
|
||||
Identified by
|
||||
{%- if detail.row_display_columns.is_empty() %} its id
|
||||
{%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%}
|
||||
{%- if let Some(summary) = page.selected_table() -%}
|
||||
{%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%}
|
||||
{%- endif -%}
|
||||
.
|
||||
</p>
|
||||
|
||||
{% if detail.is_system() %}
|
||||
<p class="hint">This table is backend-managed. Its definition is shown here, but it can only be changed through the backend's own APIs.</p>
|
||||
{% endif %}
|
||||
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
{% for column in detail.columns %}
|
||||
<tr>
|
||||
<td><code>{{ column.name }}</code></td>
|
||||
<td>{{ column.field_type }}{% if !column.sql_type.is_empty() %} <small class="hint">{{ column.sql_type }}</small>{% endif %}</td>
|
||||
<td>
|
||||
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if !detail.scripts.is_empty() %}
|
||||
<h3 class="panel-subhead">Scripts</h3>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Target column</th><th>Type</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
{% for script in detail.scripts %}
|
||||
<tr>
|
||||
<td><code>{{ script.target_column }}</code></td>
|
||||
<td>{{ script.target_column_type }}</td>
|
||||
<td>
|
||||
{{ script.description }}
|
||||
<details><summary>source</summary><pre class="sql-preview">{{ script.script }}</pre></details>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if !page.permission_object.is_empty() %}
|
||||
<section class="panel">
|
||||
<h2>Data permissions for <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">
|
||||
These grants cover this table family and take effect on subsequent requests without replacing the
|
||||
current session. Wider access — a whole profile, or every table at once — is on the
|
||||
<a href="/permissions/grants">Permissions page</a>.
|
||||
</p>
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Role</th><th>Actions</th></tr></thead>
|
||||
<tbody>{% for role in page.role_permissions %}<tr>
|
||||
<td>{{ role.role }}</td>
|
||||
<td><div class="actions">{% for permission in role.actions %}
|
||||
{% if permission.direct %}
|
||||
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="revoke"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
|
||||
<button type="submit" class="danger">{{ permission.action }} ✓</button>
|
||||
</form>
|
||||
{% else if permission.effective %}
|
||||
<span class="tag">{{ permission.action }} · inherited</span>
|
||||
{% else %}
|
||||
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
|
||||
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="grant"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
|
||||
<button type="submit" class="secondary">Grant {{ permission.action }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}</div></td>
|
||||
</tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
<div id="permission-status" aria-live="polite"></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if page.table_is_writable() %}
|
||||
{#
|
||||
Append columns. The panel stages columns without writing anything; the
|
||||
selection travels in the URL so the posted fields are exactly the ones the
|
||||
Add-table builder posts, and both are decoded by the same code.
|
||||
#}
|
||||
<section class="panel">
|
||||
<h2>Add columns to <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.</p>
|
||||
<form id="column-form"
|
||||
hx-post="/admin/table-definition/columns{{ page.selection.query() }}"
|
||||
hx-target="#table-definition-workspace" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<div id="column-panel">
|
||||
{% include "pages/admin/table_definition/column_panel.html" %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if let Some(detail) = page.detail %}
|
||||
<section class="panel">
|
||||
<h2>Rename a column</h2>
|
||||
<p class="hint">Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows.</p>
|
||||
<form hx-post="/admin/table-definition/rename"
|
||||
hx-target="#table-definition-workspace" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<input type="hidden" name="table" value="{{ page.selection.table }}">
|
||||
<div class="form-grid">
|
||||
<label>Column
|
||||
<select name="old_column_name">
|
||||
<option value="">Choose a column</option>
|
||||
{% for column in detail.renameable_columns() %}
|
||||
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>New name
|
||||
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Rename column</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel danger-panel">
|
||||
<h2>Delete <code>{{ page.selection.table }}</code></h2>
|
||||
<p class="hint">
|
||||
Drops the table and its definition, and the profile too when this was its
|
||||
last table. The backend refuses to delete a table that still has rows.
|
||||
</p>
|
||||
<form hx-post="/admin/table-definition/delete"
|
||||
hx-target="#table-definition-workspace" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<input type="hidden" name="table" value="{{ page.selection.table }}">
|
||||
<div class="form-grid">
|
||||
<label class="wide">Type <code>{{ page.selection.table }}</code> to confirm
|
||||
<input name="confirm_table_name" value="" autocomplete="off" placeholder="{{ page.selection.table }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="danger-submit">Delete table</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if page.selection.has_profile() && !page.selection.is_global() %}
|
||||
<section class="panel">
|
||||
<h2>Copy <code>{{ page.selection.profile }}</code> into a new profile</h2>
|
||||
<p class="hint">Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile.</p>
|
||||
<form hx-post="/admin/table-definition/copy"
|
||||
hx-target="#table-definition-workspace" hx-swap="innerHTML">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<div class="form-grid">
|
||||
<label>New profile name
|
||||
<input name="target_profile_name" value="{{ page.copy.target_profile_name }}" placeholder="billing_2027">
|
||||
</label>
|
||||
</div>
|
||||
{% if !page.copy_candidates().is_empty() %}
|
||||
<div class="check-group">
|
||||
{% for table in page.copy_candidates() %}
|
||||
<label class="check">
|
||||
<input type="checkbox" name="table_names" value="{{ table.name }}"
|
||||
{% if page.copy_selected(table.name.as_str()) %}checked{% endif %}>
|
||||
{{ table.name }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit">Copy profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Create tables from an invoice template</h2>
|
||||
<p class="hint">
|
||||
Reads the <code>#let komp_ac_fields = (…)</code> contract out of a Typst
|
||||
template. A path that matches an existing table and column becomes a
|
||||
reference to it; anything else becomes a TEXT column to refine later, and
|
||||
every <code>[]</code> becomes a child table.
|
||||
</p>
|
||||
<form hx-post="/admin/table-definition/invoice-template"
|
||||
hx-target="#table-definition-workspace" hx-swap="innerHTML"
|
||||
hx-disabled-elt="button[type=submit]">
|
||||
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
|
||||
<div class="form-grid">
|
||||
<label>Table name
|
||||
<input name="table_name" value="{{ page.invoice.table_name }}" placeholder="invoice">
|
||||
</label>
|
||||
<label>Row display columns
|
||||
<input name="row_display_columns" value="{{ page.invoice.row_display_columns }}" placeholder="number, issued_on">
|
||||
<small>Comma-separated, and only for the root table.</small>
|
||||
</label>
|
||||
<label class="wide">Template source
|
||||
<textarea name="typst_source" rows="10" placeholder="#let komp_ac_fields = ( "sidlo.nazov", "people[].name", )">{{ page.invoice.typst_source }}</textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Create from template</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Column rename history</h2>
|
||||
{% if page.history.is_empty() %}
|
||||
<p class="hint">
|
||||
No column in {% if page.selection.has_table() %}<code>{{ page.selection.table }}</code>{% else %}this profile{% endif %} has been renamed.
|
||||
</p>
|
||||
{% else %}
|
||||
<table class="builder-table">
|
||||
<thead><tr><th>Table</th><th>Was</th><th>Is</th><th>When</th></tr></thead>
|
||||
<tbody>
|
||||
{% for entry in page.history %}
|
||||
<tr>
|
||||
<td><code>{{ entry.table_name }}</code></td>
|
||||
<td><code>{{ entry.old_column_name }}</code></td>
|
||||
<td><code>{{ entry.new_column_name }}</code></td>
|
||||
<td>{{ entry.created_at }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
Reference in New Issue
Block a user