web routing is POG now
This commit is contained in:
2
server
2
server
Submodule server updated: 1a3c049329...0b0cb105be
@@ -286,8 +286,14 @@ mod tests {
|
|||||||
/// answers with the redirect to the login page rather than a 404 or a call
|
/// answers with the redirect to the login page rather than a 404 or a call
|
||||||
/// to the backend.
|
/// to the backend.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_table_definition_workspace_is_mounted_and_needs_a_session() {
|
async fn the_table_definition_pages_are_mounted_and_need_a_session() {
|
||||||
for path in ["/admin/table-definition", "/admin/table-definition/workspace"] {
|
for path in [
|
||||||
|
"/admin/tables/columns",
|
||||||
|
"/admin/tables/delete",
|
||||||
|
"/admin/profiles/copy",
|
||||||
|
"/admin/profiles/history",
|
||||||
|
"/admin/tables/from-template",
|
||||||
|
] {
|
||||||
let (status, _) = get(path).await;
|
let (status, _) = get(path).await;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status,
|
status,
|
||||||
@@ -296,13 +302,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The workspace these came out of is gone, and anything still pointing
|
||||||
|
// at it lands on the browser rather than on a 404.
|
||||||
|
let (status, _) = get("/admin/table-definition").await;
|
||||||
|
assert_eq!(status, axum::http::StatusCode::PERMANENT_REDIRECT);
|
||||||
|
|
||||||
for path in [
|
for path in [
|
||||||
"/admin/table-definition/columns",
|
"/admin/tables/columns",
|
||||||
"/admin/table-definition/columns/builder",
|
"/admin/tables/columns/builder",
|
||||||
"/admin/table-definition/rename",
|
"/admin/tables/rename",
|
||||||
"/admin/table-definition/delete",
|
"/admin/tables/delete",
|
||||||
"/admin/table-definition/copy",
|
"/admin/profiles/copy",
|
||||||
"/admin/table-definition/invoice-template",
|
"/admin/tables/from-template",
|
||||||
] {
|
] {
|
||||||
let response = test_router()
|
let response = test_router()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|||||||
@@ -108,8 +108,11 @@ pub(crate) async fn create_table(
|
|||||||
let result = definitions.post_table_definition(request).await;
|
let result = definitions.post_table_definition(request).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(response) if response.get_ref().success => {
|
Ok(response) if response.get_ref().success => {
|
||||||
|
// The admin panel, with the new table selected: it is the browser
|
||||||
|
// the actions on a table are reached from, so landing there is
|
||||||
|
// landing next to everything that can be done to it.
|
||||||
let location = format!(
|
let location = format!(
|
||||||
"/admin/table-definition?profile={profile_name}&table={}",
|
"/admin?profile={profile_name}&table={}",
|
||||||
page.draft.table_name
|
page.draft.table_name
|
||||||
);
|
);
|
||||||
let Ok(location) = HeaderValue::try_from(location) else {
|
let Ok(location) = HeaderValue::try_from(location) else {
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ pub(crate) async fn load_admin_page(
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
row_display_columns: table.row_display_columns.clone(),
|
row_display_columns: table.row_display_columns.clone(),
|
||||||
|
table_kind: table.table_kind.clone(),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ pub(crate) struct TableView {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub depends_on: Vec<String>,
|
pub depends_on: Vec<String>,
|
||||||
pub row_display_columns: Vec<String>,
|
pub row_display_columns: Vec<String>,
|
||||||
|
pub table_kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TableView {
|
||||||
|
/// System tables are backend-managed: the server refuses every structural
|
||||||
|
/// write on them, so the pane offers none of the action links for one.
|
||||||
|
pub(crate) fn is_system(&self) -> bool {
|
||||||
|
self.table_kind == "system"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let html = render_page(&page);
|
let html = render_page(&page);
|
||||||
for route in [
|
for route in [
|
||||||
"/admin/table-definition",
|
|
||||||
"/admin/tables/new",
|
"/admin/tables/new",
|
||||||
"/admin/logic/new",
|
"/admin/logic/new",
|
||||||
"/admin/validation/new",
|
"/admin/validation/new",
|
||||||
@@ -120,6 +119,42 @@ mod tests {
|
|||||||
assert!(html.contains("There are no global tables."));
|
assert!(html.contains("There are no global tables."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Selecting a table has to put what can be done to it on the screen. It
|
||||||
|
/// is the only way in: these pages take their selection from the URL, and
|
||||||
|
/// nobody is going to type one.
|
||||||
|
#[test]
|
||||||
|
fn selecting_a_table_shows_what_can_be_done_to_it() {
|
||||||
|
use crate::pages::admin::admin::state::TableView;
|
||||||
|
|
||||||
|
let page = AdminPageState {
|
||||||
|
nav: Nav::default(),
|
||||||
|
profiles: Vec::new(),
|
||||||
|
selected_profile: Some("books".to_string()),
|
||||||
|
tables: vec![TableView {
|
||||||
|
name: "invoice".to_string(),
|
||||||
|
depends_on: Vec::new(),
|
||||||
|
row_display_columns: vec!["number".to_string()],
|
||||||
|
table_kind: "dynamic".to_string(),
|
||||||
|
}],
|
||||||
|
selected_table: Some("invoice".to_string()),
|
||||||
|
columns: Vec::new(),
|
||||||
|
can_manage_tables: true,
|
||||||
|
can_manage_scripts: true,
|
||||||
|
can_manage_validations: true,
|
||||||
|
can_export: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let html = render_workspace(&page);
|
||||||
|
for route in [
|
||||||
|
"/admin/tables/columns?profile=books&table=invoice",
|
||||||
|
"/admin/tables/delete?profile=books&table=invoice",
|
||||||
|
"/admin/tables/new?profile=books",
|
||||||
|
"/admin/profiles/copy?profile=books",
|
||||||
|
] {
|
||||||
|
assert!(html.contains(route), "missing {route}\n{html}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn system_columns_render_after_the_user_defined_ones() {
|
fn system_columns_render_after_the_user_defined_ones() {
|
||||||
let column = |name: &str, system: bool| crate::pages::admin::admin::state::ColumnView {
|
let column = |name: &str, system: bool| crate::pages::admin::admin::state::ColumnView {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
//! Reads everything the workspace shows.
|
//! Reads the context every one of the five pages shows.
|
||||||
|
//!
|
||||||
|
//! One loader serves them all, because they all need the same thing: which
|
||||||
|
//! table is being worked on, and what it currently is. Four calls, in this
|
||||||
|
//! order — the column-type catalog is the vocabulary the append panel offers,
|
||||||
|
//! the profile tree names the tables, the profile details describe the
|
||||||
|
//! selected table's columns and scripts, and the rename history explains how
|
||||||
|
//! those columns got their names.
|
||||||
//!
|
//!
|
||||||
//! Four calls, in this order: the column-type catalog is the vocabulary the
|
|
||||||
//! append panel offers, the profile tree names the profiles and their tables,
|
|
||||||
//! the profile details describe the selected table's columns and scripts, and
|
|
||||||
//! the rename history explains how those columns got their names.
|
|
||||||
//! Nothing here trusts the posted selection — a profile or table that is gone
|
//! Nothing here trusts the posted selection — a profile or table that is gone
|
||||||
//! is dropped from the selection rather than reported as an error, because the
|
//! is dropped from the selection rather than reported as an error, because the
|
||||||
//! commonest way to get here with a stale one is having just deleted it.
|
//! commonest way to get here with a stale one is having just deleted it.
|
||||||
@@ -13,7 +16,7 @@ use tonic::transport::Channel;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
AppState,
|
AppState,
|
||||||
auth::{GetAuthorizationRequest, ListGrantableObjectsRequest, ListRolePermissionsRequest, ListRolesRequest},
|
auth::GetAuthorizationRequest,
|
||||||
definitions::{
|
definitions::{
|
||||||
common::Empty,
|
common::Empty,
|
||||||
table_definition::{
|
table_definition::{
|
||||||
@@ -26,8 +29,8 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
DetailColumn, GLOBAL_SCOPE, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState,
|
DetailColumn, GLOBAL_SCOPE, LoadError, PageInputs, RenameEntry, ScriptView,
|
||||||
TableDetailView, TablePermissionAction, TableRolePermissions, TableSummary,
|
TableDefinitionPageState, TableDetailView, TableSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Reads the column-type vocabulary on its own.
|
/// Reads the column-type vocabulary on its own.
|
||||||
@@ -92,11 +95,13 @@ pub(crate) async fn load_page(
|
|||||||
let profiles = tree
|
let profiles = tree
|
||||||
.profiles
|
.profiles
|
||||||
.iter()
|
.iter()
|
||||||
.map(|profile| profile.name.clone())
|
.map(|profile| profile.name.as_str())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
// A profile that no longer exists takes the table selection with it.
|
// A profile that no longer exists takes the table selection with it.
|
||||||
if inputs.selection.profile != GLOBAL_SCOPE && !profiles.contains(&inputs.selection.profile) {
|
if inputs.selection.profile != GLOBAL_SCOPE
|
||||||
|
&& !profiles.contains(&inputs.selection.profile.as_str())
|
||||||
|
{
|
||||||
inputs.selection.profile.clear();
|
inputs.selection.profile.clear();
|
||||||
inputs.selection.table.clear();
|
inputs.selection.table.clear();
|
||||||
}
|
}
|
||||||
@@ -127,7 +132,6 @@ pub(crate) async fn load_page(
|
|||||||
format!("{} ({})", dependency.table_name, dependency.column_name)
|
format!("{} ({})", dependency.table_name, dependency.column_name)
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
row_display_columns: table.row_display_columns.clone(),
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
@@ -195,8 +199,6 @@ pub(crate) async fn load_page(
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
row_display_columns: table.row_display_columns,
|
row_display_columns: table.row_display_columns,
|
||||||
table_kind: table.table_kind,
|
|
||||||
name: table.name,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
false => None,
|
false => None,
|
||||||
@@ -230,84 +232,8 @@ pub(crate) async fn load_page(
|
|||||||
false => Vec::new(),
|
false => Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut permission_object = String::new();
|
|
||||||
let mut role_permissions = Vec::new();
|
|
||||||
if inputs.selection.has_table()
|
|
||||||
&& crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE)
|
|
||||||
{
|
|
||||||
let expected_object = crate::authz::table_object(
|
|
||||||
&inputs.selection.profile,
|
|
||||||
&inputs.selection.table,
|
|
||||||
);
|
|
||||||
let roles = auth
|
|
||||||
.list_roles(
|
|
||||||
authenticated_request(headers, ListRolesRequest {})
|
|
||||||
.map_err(|_| LoadError::Unauthenticated)?,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
|
||||||
.into_inner()
|
|
||||||
.roles;
|
|
||||||
for role in roles.into_iter().filter(|role| role.kind == "data") {
|
|
||||||
let grantable = auth
|
|
||||||
.list_grantable_objects(
|
|
||||||
authenticated_request(
|
|
||||||
headers,
|
|
||||||
ListGrantableObjectsRequest {
|
|
||||||
target_role: role.name.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|_| LoadError::Unauthenticated)?,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
|
||||||
.into_inner()
|
|
||||||
.objects
|
|
||||||
.into_iter()
|
|
||||||
.find(|object| object.object == expected_object);
|
|
||||||
let Some(grantable) = grantable else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
permission_object = expected_object.clone();
|
|
||||||
let permissions = auth
|
|
||||||
.list_role_permissions(
|
|
||||||
authenticated_request(
|
|
||||||
headers,
|
|
||||||
ListRolePermissionsRequest {
|
|
||||||
role: role.name.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|_| LoadError::Unauthenticated)?,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|error| LoadError::Backend(error.message().to_string()))?
|
|
||||||
.into_inner();
|
|
||||||
role_permissions.push(TableRolePermissions {
|
|
||||||
role: role.name,
|
|
||||||
actions: grantable
|
|
||||||
.allowed_actions
|
|
||||||
.into_iter()
|
|
||||||
.map(|action| TablePermissionAction {
|
|
||||||
direct: crate::authz::is_direct_permission(
|
|
||||||
&permissions.permissions,
|
|
||||||
&expected_object,
|
|
||||||
&action,
|
|
||||||
),
|
|
||||||
effective: crate::authz::permissions_permit(
|
|
||||||
&permissions.effective_permissions,
|
|
||||||
&expected_object,
|
|
||||||
&action,
|
|
||||||
),
|
|
||||||
action,
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(TableDefinitionPageState {
|
Ok(TableDefinitionPageState {
|
||||||
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
|
||||||
profiles,
|
|
||||||
tables,
|
tables,
|
||||||
detail,
|
detail,
|
||||||
history,
|
history,
|
||||||
@@ -320,7 +246,8 @@ pub(crate) async fn load_page(
|
|||||||
error: inputs.error,
|
error: inputs.error,
|
||||||
sql: inputs.sql,
|
sql: inputs.sql,
|
||||||
generated: inputs.generated,
|
generated: inputs.generated,
|
||||||
permission_object,
|
// Overwritten by the handler, which is the only thing that knows
|
||||||
role_permissions,
|
// which of the pages it is answering for.
|
||||||
|
active: "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
//! The workspace's request handlers — one per `TableDefinition` write.
|
//! The request handlers behind the five table-definition pages.
|
||||||
//!
|
//!
|
||||||
//! Every write answers with the whole workspace, re-read from the backend, so
|
//! Each page has a GET that renders it and, except for the read-only history,
|
||||||
//! what the user sees after a change is the definition as it now is rather
|
//! a POST per write it offers. A write answers with its own page's body,
|
||||||
//! than the form they submitted. A refused write answers the same way but with
|
//! re-read from the backend, so what the user sees after a change is the
|
||||||
//! 422 and the backend's own message, which `ui/base.html` swaps in because a
|
//! definition as it now is rather than the form they submitted. A refused
|
||||||
//! 4xx still carries the explanation.
|
//! write answers the same way but with 422 and the backend's own message,
|
||||||
|
//! which `ui/base.html` swaps in because a 4xx still carries the explanation.
|
||||||
//!
|
//!
|
||||||
//! The column panel is the exception: staging a column changes nothing on the
|
//! Two exceptions. Staging a column changes nothing on the server, so those
|
||||||
//! server, so those interactions swap only the panel.
|
//! interactions swap only the column panel. And a successful delete leaves no
|
||||||
|
//! page to return to — the table it was about is gone — so it redirects to the
|
||||||
|
//! admin panel instead.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, HeaderValue, StatusCode},
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
use axum_extra::extract::Form;
|
use axum_extra::extract::Form;
|
||||||
@@ -30,36 +33,124 @@ use super::{
|
|||||||
loader::{self, load_page},
|
loader::{self, load_page},
|
||||||
state::{
|
state::{
|
||||||
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
|
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
|
||||||
RenameForm, Selection,
|
RenameForm, Selection, TableDefinitionPageState,
|
||||||
},
|
},
|
||||||
ui,
|
ui,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// GET /admin/table-definition
|
/// Which page a response belongs to: what the switcher marks as current, and
|
||||||
pub(crate) async fn page(
|
/// which fragment a write on it swaps back.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum Page {
|
||||||
|
Columns,
|
||||||
|
Delete,
|
||||||
|
Copy,
|
||||||
|
Template,
|
||||||
|
History,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Page {
|
||||||
|
fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Columns => "columns",
|
||||||
|
Self::Delete => "delete",
|
||||||
|
Self::Copy => "copy",
|
||||||
|
Self::Template => "template",
|
||||||
|
Self::History => "history",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_page(self, page: &TableDefinitionPageState) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Columns => ui::render_columns_page(page),
|
||||||
|
Self::Delete => ui::render_delete_page(page),
|
||||||
|
Self::Copy => ui::render_copy_page(page),
|
||||||
|
Self::Template => ui::render_template_page(page),
|
||||||
|
Self::History => ui::render_history_page(page),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `#table-panel` swap. History has no writes, so it never asks.
|
||||||
|
fn render_fragment(self, page: &TableDefinitionPageState) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Columns => ui::render_columns_fragment(page),
|
||||||
|
Self::Delete => ui::render_delete_fragment(page),
|
||||||
|
Self::Copy => ui::render_copy_fragment(page),
|
||||||
|
Self::Template => ui::render_template_fragment(page),
|
||||||
|
Self::History => ui::render_history_page(page),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The pages ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// GET /admin/tables/columns
|
||||||
|
pub(crate) async fn columns_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(selection): Query<Selection>,
|
Query(selection): Query<Selection>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match load_page(state, &headers, PageInputs::for_selection(selection)).await {
|
show(state, headers, PageInputs::for_selection(selection), Page::Columns).await
|
||||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
|
||||||
Err(error) => load_error_response(error),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /admin/table-definition/workspace — the swap when the selection changes.
|
/// GET /admin/tables/delete
|
||||||
pub(crate) async fn workspace(
|
pub(crate) async fn delete_page(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Query(selection): Query<Selection>,
|
Query(selection): Query<Selection>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match load_page(state, &headers, PageInputs::for_selection(selection)).await {
|
show(state, headers, PageInputs::for_selection(selection), Page::Delete).await
|
||||||
Ok(page) => Html(ui::render_workspace(&page)).into_response(),
|
}
|
||||||
|
|
||||||
|
/// GET /admin/profiles/copy
|
||||||
|
pub(crate) async fn copy_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(selection): Query<Selection>,
|
||||||
|
) -> Response {
|
||||||
|
show(state, headers, profile_inputs(selection), Page::Copy).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /admin/tables/from-template
|
||||||
|
pub(crate) async fn template_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(selection): Query<Selection>,
|
||||||
|
) -> Response {
|
||||||
|
show(state, headers, profile_inputs(selection), Page::Template).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /admin/profiles/history
|
||||||
|
pub(crate) async fn history_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(selection): Query<Selection>,
|
||||||
|
) -> Response {
|
||||||
|
show(state, headers, PageInputs::for_selection(selection), Page::History).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile-wide pages act on a profile, so a table in the query is
|
||||||
|
/// dropped rather than carried into a form that has no use for it.
|
||||||
|
fn profile_inputs(selection: Selection) -> PageInputs {
|
||||||
|
PageInputs::for_selection(Selection {
|
||||||
|
profile: selection.profile,
|
||||||
|
table: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Page) -> Response {
|
||||||
|
match load_page(state, &headers, inputs).await {
|
||||||
|
Ok(mut loaded) => {
|
||||||
|
loaded.active = page.name();
|
||||||
|
Html(page.render_page(&loaded)).into_response()
|
||||||
|
}
|
||||||
Err(error) => load_error_response(error),
|
Err(error) => load_error_response(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /admin/table-definition/columns/builder — staging a column to append.
|
// ── The writes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// POST /admin/tables/columns/builder — staging a column to append.
|
||||||
///
|
///
|
||||||
/// Nothing is written here; the panel is swapped back with the column added,
|
/// Nothing is written here; the panel is swapped back with the column added,
|
||||||
/// removed, or its index toggled, exactly as the Add-table builder works.
|
/// removed, or its index toggled, exactly as the Add-table builder works.
|
||||||
@@ -100,12 +191,15 @@ pub(crate) async fn update_columns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
match load_page(state, &headers, inputs).await {
|
match load_page(state, &headers, inputs).await {
|
||||||
Ok(page) => Html(ui::render_column_panel(&page)).into_response(),
|
Ok(mut loaded) => {
|
||||||
|
loaded.active = Page::Columns.name();
|
||||||
|
Html(ui::render_column_panel(&loaded)).into_response()
|
||||||
|
}
|
||||||
Err(error) => load_error_response(error),
|
Err(error) => load_error_response(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /admin/table-definition/columns — AddTableColumns.
|
/// POST /admin/tables/columns — AddTableColumns.
|
||||||
pub(crate) async fn add_columns(
|
pub(crate) async fn add_columns(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -128,13 +222,15 @@ pub(crate) async fn add_columns(
|
|||||||
inputs.columns = form.to_draft(catalog.clone(), false);
|
inputs.columns = form.to_draft(catalog.clone(), false);
|
||||||
|
|
||||||
if !inputs.selection.has_table() {
|
if !inputs.selection.has_table() {
|
||||||
return refuse(state, headers, inputs, "Select a table first.".to_string()).await;
|
return refuse(state, headers, inputs, Page::Columns, "Select a table first.".to_string())
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
if inputs.columns.is_empty() {
|
if inputs.columns.is_empty() {
|
||||||
return refuse(
|
return refuse(
|
||||||
state,
|
state,
|
||||||
headers,
|
headers,
|
||||||
inputs,
|
inputs,
|
||||||
|
Page::Columns,
|
||||||
"Describe at least one column before adding.".to_string(),
|
"Describe at least one column before adding.".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -142,7 +238,7 @@ pub(crate) async fn add_columns(
|
|||||||
// The same checks the server runs, applied to a draft that may have been
|
// The same checks the server runs, applied to a draft that may have been
|
||||||
// rebuilt from a posted form rather than through the panel.
|
// rebuilt from a posted form rather than through the panel.
|
||||||
if let Err(message) = inputs.columns.validate() {
|
if let Err(message) = inputs.columns.validate() {
|
||||||
return refuse(state, headers, inputs, message).await;
|
return refuse(state, headers, inputs, Page::Columns, message).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = AddTableColumnsRequest {
|
let request = AddTableColumnsRequest {
|
||||||
@@ -166,7 +262,7 @@ pub(crate) async fn add_columns(
|
|||||||
));
|
));
|
||||||
// The columns are the table's now, so the panel starts empty.
|
// The columns are the table's now, so the panel starts empty.
|
||||||
inputs.columns = crate::schema::ColumnDraft::for_append(catalog);
|
inputs.columns = crate::schema::ColumnDraft::for_append(catalog);
|
||||||
respond(state, headers, inputs, StatusCode::OK).await
|
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
|
||||||
}
|
}
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let message = response.into_inner().sql;
|
let message = response.into_inner().sql;
|
||||||
@@ -175,13 +271,15 @@ pub(crate) async fn add_columns(
|
|||||||
} else {
|
} else {
|
||||||
message
|
message
|
||||||
};
|
};
|
||||||
refuse(state, headers, inputs, message).await
|
refuse(state, headers, inputs, Page::Columns, message).await
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
|
||||||
}
|
}
|
||||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /admin/table-definition/rename — RenameColumnAlias.
|
/// POST /admin/tables/rename — RenameColumnAlias.
|
||||||
pub(crate) async fn rename_column(
|
pub(crate) async fn rename_column(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -202,6 +300,7 @@ pub(crate) async fn rename_column(
|
|||||||
state,
|
state,
|
||||||
headers,
|
headers,
|
||||||
inputs,
|
inputs,
|
||||||
|
Page::Columns,
|
||||||
"Choose a column and type its new name.".to_string(),
|
"Choose a column and type its new name.".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -226,7 +325,7 @@ pub(crate) async fn rename_column(
|
|||||||
table: form.table,
|
table: form.table,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
respond(state, headers, inputs, StatusCode::OK).await
|
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
|
||||||
}
|
}
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let message = response.into_inner().message;
|
let message = response.into_inner().message;
|
||||||
@@ -235,13 +334,18 @@ pub(crate) async fn rename_column(
|
|||||||
} else {
|
} else {
|
||||||
message
|
message
|
||||||
};
|
};
|
||||||
refuse(state, headers, inputs, message).await
|
refuse(state, headers, inputs, Page::Columns, message).await
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
|
||||||
}
|
}
|
||||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /admin/table-definition/delete — DeleteTable.
|
/// POST /admin/tables/delete — DeleteTable.
|
||||||
|
///
|
||||||
|
/// On success there is nothing left for this page to show, so it hands the
|
||||||
|
/// browser to the admin panel with the profile still selected.
|
||||||
pub(crate) async fn delete_table(
|
pub(crate) async fn delete_table(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -251,7 +355,7 @@ pub(crate) async fn delete_table(
|
|||||||
return rejection;
|
return rejection;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut inputs = PageInputs::for_selection(Selection {
|
let inputs = PageInputs::for_selection(Selection {
|
||||||
profile: form.profile.clone(),
|
profile: form.profile.clone(),
|
||||||
table: form.table.clone(),
|
table: form.table.clone(),
|
||||||
});
|
});
|
||||||
@@ -263,6 +367,7 @@ pub(crate) async fn delete_table(
|
|||||||
state,
|
state,
|
||||||
headers,
|
headers,
|
||||||
inputs,
|
inputs,
|
||||||
|
Page::Delete,
|
||||||
"Type the table's name exactly to confirm the deletion.".to_string(),
|
"Type the table's name exactly to confirm the deletion.".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -279,11 +384,18 @@ pub(crate) async fn delete_table(
|
|||||||
let mut definitions = state.definitions.clone();
|
let mut definitions = state.definitions.clone();
|
||||||
match definitions.delete_table(request).await {
|
match definitions.delete_table(request).await {
|
||||||
Ok(response) if response.get_ref().success => {
|
Ok(response) if response.get_ref().success => {
|
||||||
inputs.status = Some(response.into_inner().message);
|
// The profile may have gone with it, in which case the admin panel
|
||||||
// Whatever was selected is gone; the loader drops it, and this
|
// refuses the selection; landing on the bare panel is what the
|
||||||
// keeps the workspace from asking for it again.
|
// caller wants then anyway.
|
||||||
inputs.selection.table.clear();
|
let location = format!("/admin?profile={}", form.profile);
|
||||||
respond(state, headers, inputs, StatusCode::OK).await
|
match HeaderValue::try_from(location) {
|
||||||
|
Ok(location) => {
|
||||||
|
let mut response = Html(String::new()).into_response();
|
||||||
|
response.headers_mut().insert("hx-redirect", location);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let message = response.into_inner().message;
|
let message = response.into_inner().message;
|
||||||
@@ -292,13 +404,15 @@ pub(crate) async fn delete_table(
|
|||||||
} else {
|
} else {
|
||||||
message
|
message
|
||||||
};
|
};
|
||||||
refuse(state, headers, inputs, message).await
|
refuse(state, headers, inputs, Page::Delete, message).await
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
refuse(state, headers, inputs, Page::Delete, error.message().to_string()).await
|
||||||
}
|
}
|
||||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /admin/table-definition/copy — CopyProfile.
|
/// POST /admin/profiles/copy — CopyProfile.
|
||||||
pub(crate) async fn copy_profile(
|
pub(crate) async fn copy_profile(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -319,6 +433,7 @@ pub(crate) async fn copy_profile(
|
|||||||
state,
|
state,
|
||||||
headers,
|
headers,
|
||||||
inputs,
|
inputs,
|
||||||
|
Page::Copy,
|
||||||
"Name the profile to copy into.".to_string(),
|
"Name the profile to copy into.".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -345,7 +460,7 @@ pub(crate) async fn copy_profile(
|
|||||||
profile: form.profile,
|
profile: form.profile,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
respond(state, headers, inputs, StatusCode::OK).await
|
respond(state, headers, inputs, Page::Copy, StatusCode::OK).await
|
||||||
}
|
}
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
let message = response.into_inner().message;
|
let message = response.into_inner().message;
|
||||||
@@ -354,13 +469,13 @@ pub(crate) async fn copy_profile(
|
|||||||
} else {
|
} else {
|
||||||
message
|
message
|
||||||
};
|
};
|
||||||
refuse(state, headers, inputs, message).await
|
refuse(state, headers, inputs, Page::Copy, message).await
|
||||||
}
|
}
|
||||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
Err(error) => refuse(state, headers, inputs, Page::Copy, error.message().to_string()).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /admin/table-definition/invoice-template — CreateInvoiceTemplateTable.
|
/// POST /admin/tables/from-template — CreateInvoiceTemplateTable.
|
||||||
pub(crate) async fn create_from_invoice_template(
|
pub(crate) async fn create_from_invoice_template(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -381,6 +496,7 @@ pub(crate) async fn create_from_invoice_template(
|
|||||||
state,
|
state,
|
||||||
headers,
|
headers,
|
||||||
inputs,
|
inputs,
|
||||||
|
Page::Template,
|
||||||
"A table name and the template's source are both required.".to_string(),
|
"A table name and the template's source are both required.".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -417,43 +533,51 @@ pub(crate) async fn create_from_invoice_template(
|
|||||||
profile: form.profile,
|
profile: form.profile,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
respond(state, headers, inputs, StatusCode::OK).await
|
respond(state, headers, inputs, Page::Template, StatusCode::OK).await
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
refuse(
|
refuse(
|
||||||
state,
|
state,
|
||||||
headers,
|
headers,
|
||||||
inputs,
|
inputs,
|
||||||
|
Page::Template,
|
||||||
"The backend did not create the template's tables.".to_string(),
|
"The backend did not create the template's tables.".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
Err(error) => {
|
||||||
|
refuse(state, headers, inputs, Page::Template, error.message().to_string()).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-reads the workspace and answers with it.
|
/// Re-reads the page's body and answers with it.
|
||||||
async fn respond(
|
async fn respond(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
inputs: PageInputs,
|
inputs: PageInputs,
|
||||||
|
page: Page,
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
match load_page(state, &headers, inputs).await {
|
match load_page(state, &headers, inputs).await {
|
||||||
Ok(page) => (status, Html(ui::render_workspace(&page))).into_response(),
|
Ok(mut loaded) => {
|
||||||
|
loaded.active = page.name();
|
||||||
|
(status, Html(page.render_fragment(&loaded))).into_response()
|
||||||
|
}
|
||||||
Err(error) => load_error_response(error),
|
Err(error) => load_error_response(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Answers a refused write: the workspace as it still is, plus the reason.
|
/// Answers a refused write: the page as it still is, plus the reason.
|
||||||
async fn refuse(
|
async fn refuse(
|
||||||
state: AppState,
|
state: AppState,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
mut inputs: PageInputs,
|
mut inputs: PageInputs,
|
||||||
|
page: Page,
|
||||||
message: String,
|
message: String,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
inputs.error = Some(message);
|
inputs.error = Some(message);
|
||||||
respond(state, headers, inputs, StatusCode::UNPROCESSABLE_ENTITY).await
|
respond(state, headers, inputs, page, StatusCode::UNPROCESSABLE_ENTITY).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_error_response(error: LoadError) -> Response {
|
fn load_error_response(error: LoadError) -> Response {
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
//! The table-definition workspace: pick a profile and a table, then do
|
//! What can be done to a table once it exists, as one page per decision.
|
||||||
//! anything the `TableDefinition` service offers to it.
|
|
||||||
//!
|
//!
|
||||||
//! Creating a table is the one operation that lives elsewhere — it is a form
|
//! Adding columns, dropping the table, copying its profile, generating tables
|
||||||
//! long enough to want its own page, `pages/add_table` — and the workspace
|
//! from a template and reading the rename history are five different jobs, and
|
||||||
//! links to it with the chosen profile already filled in.
|
//! they used to be five panels stacked on one `/admin/table-definition`
|
||||||
|
//! workspace — which meant that after creating a table you landed on a screen
|
||||||
|
//! where finding the delete form meant scrolling past five other forms.
|
||||||
|
//!
|
||||||
|
//! They are now five routes, and the browsing they all needed — which profile,
|
||||||
|
//! which table — is done in the admin panel, which was already a three-pane
|
||||||
|
//! browser for exactly that. Every page here is entered with its selection in
|
||||||
|
//! the query string, and links back to the panel it came from.
|
||||||
|
//!
|
||||||
|
//! Creating a table is still elsewhere: it is a form long enough to want its
|
||||||
|
//! own page, `pages/add_table`.
|
||||||
|
|
||||||
mod loader;
|
mod loader;
|
||||||
mod logic;
|
mod logic;
|
||||||
@@ -12,6 +21,7 @@ mod ui;
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
Router,
|
||||||
|
response::Redirect,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,18 +29,28 @@ use crate::AppState;
|
|||||||
|
|
||||||
pub(crate) fn router() -> Router<AppState> {
|
pub(crate) fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/admin/table-definition", get(logic::page))
|
// Table-scoped.
|
||||||
.route("/admin/table-definition/workspace", get(logic::workspace))
|
.route("/admin/tables/columns", get(logic::columns_page))
|
||||||
|
.route("/admin/tables/columns", post(logic::add_columns))
|
||||||
.route(
|
.route(
|
||||||
"/admin/table-definition/columns/builder",
|
"/admin/tables/columns/builder",
|
||||||
post(logic::update_columns),
|
post(logic::update_columns),
|
||||||
)
|
)
|
||||||
.route("/admin/table-definition/columns", post(logic::add_columns))
|
.route("/admin/tables/rename", post(logic::rename_column))
|
||||||
.route("/admin/table-definition/rename", post(logic::rename_column))
|
.route("/admin/tables/delete", get(logic::delete_page))
|
||||||
.route("/admin/table-definition/delete", post(logic::delete_table))
|
.route("/admin/tables/delete", post(logic::delete_table))
|
||||||
.route("/admin/table-definition/copy", post(logic::copy_profile))
|
// Profile-scoped.
|
||||||
|
.route("/admin/profiles/copy", get(logic::copy_page))
|
||||||
|
.route("/admin/profiles/copy", post(logic::copy_profile))
|
||||||
|
.route("/admin/profiles/history", get(logic::history_page))
|
||||||
.route(
|
.route(
|
||||||
"/admin/table-definition/invoice-template",
|
"/admin/tables/from-template",
|
||||||
post(logic::create_from_invoice_template),
|
get(logic::template_page).post(logic::create_from_invoice_template),
|
||||||
|
)
|
||||||
|
// The workspace these came out of. Anything still pointing at it —
|
||||||
|
// a bookmark, an old link — lands on the browser instead.
|
||||||
|
.route(
|
||||||
|
"/admin/table-definition",
|
||||||
|
get(|| async { Redirect::permanent("/admin") }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
//! What the workspace renders, and the wire formats its panels post.
|
//! What the table-definition pages render, and the wire formats they post.
|
||||||
//!
|
//!
|
||||||
//! Every panel is a form of its own, and each one carries the selection it
|
//! One state type serves all five pages, because each of them needs the same
|
||||||
//! acts on in hidden fields, because the workspace is swapped whole on every
|
//! context: which profile and table is being worked on, and what that table
|
||||||
//! write: the response is rebuilt from the live profile tree rather than from
|
//! currently is. What differs is which panel the page renders, and `active`
|
||||||
|
//! is what says so.
|
||||||
|
//!
|
||||||
|
//! Every panel carries the selection it acts on in hidden fields, because a
|
||||||
|
//! write answers with its page re-read from the backend rather than with
|
||||||
//! whatever the browser still had on screen.
|
//! whatever the browser still had on screen.
|
||||||
|
|
||||||
use crate::schema::{ColumnCatalog, ColumnDraft};
|
use crate::schema::{ColumnCatalog, ColumnDraft};
|
||||||
@@ -38,6 +42,21 @@ impl Selection {
|
|||||||
pub(crate) fn query(&self) -> String {
|
pub(crate) fn query(&self) -> String {
|
||||||
format!("?profile={}&table={}", self.profile, self.table)
|
format!("?profile={}&table={}", self.profile, self.table)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same, for the profile-wide pages, which have no table to name.
|
||||||
|
pub(crate) fn profile_query(&self) -> String {
|
||||||
|
format!("?profile={}", self.profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the heading calls the scope, so the global one is not shown by its
|
||||||
|
/// sentinel name.
|
||||||
|
pub(crate) fn scope_label(&self) -> String {
|
||||||
|
if self.is_global() {
|
||||||
|
"Global — all profiles".to_string()
|
||||||
|
} else {
|
||||||
|
self.profile.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One table in the selected profile, from the profile tree.
|
/// One table in the selected profile, from the profile tree.
|
||||||
@@ -47,7 +66,6 @@ pub(crate) struct TableSummary {
|
|||||||
pub table_kind: String,
|
pub table_kind: String,
|
||||||
pub global: bool,
|
pub global: bool,
|
||||||
pub depends_on: Vec<String>,
|
pub depends_on: Vec<String>,
|
||||||
pub row_display_columns: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TableSummary {
|
impl TableSummary {
|
||||||
@@ -62,18 +80,12 @@ impl TableSummary {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct TableDetailView {
|
pub(crate) struct TableDetailView {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub name: String,
|
|
||||||
pub table_kind: String,
|
|
||||||
pub row_display_columns: Vec<String>,
|
pub row_display_columns: Vec<String>,
|
||||||
pub columns: Vec<DetailColumn>,
|
pub columns: Vec<DetailColumn>,
|
||||||
pub scripts: Vec<ScriptView>,
|
pub scripts: Vec<ScriptView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TableDetailView {
|
impl TableDetailView {
|
||||||
pub(crate) fn is_system(&self) -> bool {
|
|
||||||
self.table_kind == "system"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Columns a rename may target. Provenance and renameability are separate:
|
/// Columns a rename may target. Provenance and renameability are separate:
|
||||||
/// accounting companions remain renameable while protected generated
|
/// accounting companions remain renameable while protected generated
|
||||||
/// columns do not.
|
/// columns do not.
|
||||||
@@ -248,7 +260,6 @@ impl PageInputs {
|
|||||||
/// What the templates read.
|
/// What the templates read.
|
||||||
pub(crate) struct TableDefinitionPageState {
|
pub(crate) struct TableDefinitionPageState {
|
||||||
pub nav: crate::ui::Nav,
|
pub nav: crate::ui::Nav,
|
||||||
pub profiles: Vec<String>,
|
|
||||||
pub selection: Selection,
|
pub selection: Selection,
|
||||||
pub tables: Vec<TableSummary>,
|
pub tables: Vec<TableSummary>,
|
||||||
pub detail: Option<TableDetailView>,
|
pub detail: Option<TableDetailView>,
|
||||||
@@ -261,22 +272,17 @@ pub(crate) struct TableDefinitionPageState {
|
|||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub sql: Option<String>,
|
pub sql: Option<String>,
|
||||||
pub generated: Vec<GeneratedTableView>,
|
pub generated: Vec<GeneratedTableView>,
|
||||||
pub permission_object: String,
|
/// Which of the pages this is, for the action switcher they share. Set by
|
||||||
pub role_permissions: Vec<TableRolePermissions>,
|
/// the handler, because it is the one thing the loader cannot know.
|
||||||
}
|
pub active: &'static str,
|
||||||
|
|
||||||
pub(crate) struct TableRolePermissions {
|
|
||||||
pub role: String,
|
|
||||||
pub actions: Vec<TablePermissionAction>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) struct TablePermissionAction {
|
|
||||||
pub action: String,
|
|
||||||
pub direct: bool,
|
|
||||||
pub effective: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TableDefinitionPageState {
|
impl TableDefinitionPageState {
|
||||||
|
/// Whether `name` is the page being rendered. Read by context.html.
|
||||||
|
pub(crate) fn is(&self, name: &str) -> bool {
|
||||||
|
self.active == name
|
||||||
|
}
|
||||||
|
|
||||||
fn eligible_link_target(&self, table: &TableSummary) -> bool {
|
fn eligible_link_target(&self, table: &TableSummary) -> bool {
|
||||||
table.name != self.selection.table && table.name != "accounts"
|
table.name != self.selection.table && table.name != "accounts"
|
||||||
}
|
}
|
||||||
@@ -363,8 +369,6 @@ mod tests {
|
|||||||
fn provenance_and_renameability_are_independent() {
|
fn provenance_and_renameability_are_independent() {
|
||||||
let detail = TableDetailView {
|
let detail = TableDetailView {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: "contact".to_string(),
|
|
||||||
table_kind: "dynamic".to_string(),
|
|
||||||
row_display_columns: Vec::new(),
|
row_display_columns: Vec::new(),
|
||||||
scripts: Vec::new(),
|
scripts: Vec::new(),
|
||||||
columns: vec![
|
columns: vec![
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! One template struct per page, and one per swap target inside it.
|
||||||
|
//!
|
||||||
|
//! The pages share a state type and an action switcher, so what differs
|
||||||
|
//! between them here is only which panel they render. The column vocabulary
|
||||||
|
//! is threaded through the two that need it, because askama resolves those
|
||||||
|
//! fields on the struct rather than on `page`.
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -7,32 +14,31 @@ use crate::{
|
|||||||
|
|
||||||
use super::state::TableDefinitionPageState;
|
use super::state::TableDefinitionPageState;
|
||||||
|
|
||||||
/// GET /admin/table-definition — the shell around the workspace.
|
/// GET /admin/tables/columns
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "pages/admin/table_definition/table_definition.html")]
|
#[template(path = "pages/admin/table_definition/columns.html")]
|
||||||
struct TableDefinitionPage<'a> {
|
struct ColumnsPage<'a> {
|
||||||
nav: Nav,
|
nav: Nav,
|
||||||
page: &'a TableDefinitionPageState,
|
page: &'a TableDefinitionPageState,
|
||||||
column_types: Vec<String>,
|
column_types: Vec<String>,
|
||||||
temporal_types: Vec<String>,
|
temporal_types: Vec<String>,
|
||||||
gtin_types: Vec<String>,
|
gtin_types: Vec<String>,
|
||||||
currency_codes: &'static [&'static str],
|
currency_codes: &'static [&'static str],
|
||||||
/// False, as on the workspace fragment: the page embeds both, and the
|
/// False, as on the fragment: the page embeds both, and the outcome is
|
||||||
/// outcome is reported once, at the top.
|
/// reported once, at the top.
|
||||||
standalone_column_panel: bool,
|
standalone_column_panel: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `#table-definition-workspace` swap, which is the same markup the page
|
/// The `#table-panel` swap on the columns page.
|
||||||
/// embeds, so one template serves both.
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "pages/admin/table_definition/workspace.html")]
|
#[template(path = "pages/admin/table_definition/columns_panel.html")]
|
||||||
struct WorkspaceFragment<'a> {
|
struct ColumnsFragment<'a> {
|
||||||
page: &'a TableDefinitionPageState,
|
page: &'a TableDefinitionPageState,
|
||||||
column_types: Vec<String>,
|
column_types: Vec<String>,
|
||||||
temporal_types: Vec<String>,
|
temporal_types: Vec<String>,
|
||||||
gtin_types: Vec<String>,
|
gtin_types: Vec<String>,
|
||||||
/// False: the workspace shows the outcome of the last action itself, at
|
/// False: the fragment shows the outcome of the last action itself, at the
|
||||||
/// the top, so the panel it embeds must not repeat it.
|
/// top, so the panel it embeds must not repeat it.
|
||||||
standalone_column_panel: bool,
|
standalone_column_panel: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +55,58 @@ struct ColumnPanelFragment<'a> {
|
|||||||
standalone_column_panel: bool,
|
standalone_column_panel: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
|
/// GET /admin/tables/delete
|
||||||
render(&TableDefinitionPage {
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/admin/table_definition/delete.html")]
|
||||||
|
struct DeletePage<'a> {
|
||||||
|
nav: Nav,
|
||||||
|
page: &'a TableDefinitionPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/admin/table_definition/delete_panel.html")]
|
||||||
|
struct DeleteFragment<'a> {
|
||||||
|
page: &'a TableDefinitionPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /admin/profiles/copy
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/admin/table_definition/copy.html")]
|
||||||
|
struct CopyPage<'a> {
|
||||||
|
nav: Nav,
|
||||||
|
page: &'a TableDefinitionPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/admin/table_definition/copy_panel.html")]
|
||||||
|
struct CopyFragment<'a> {
|
||||||
|
page: &'a TableDefinitionPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /admin/tables/from-template
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/admin/table_definition/from_template.html")]
|
||||||
|
struct TemplatePage<'a> {
|
||||||
|
nav: Nav,
|
||||||
|
page: &'a TableDefinitionPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/admin/table_definition/from_template_panel.html")]
|
||||||
|
struct TemplateFragment<'a> {
|
||||||
|
page: &'a TableDefinitionPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /admin/profiles/history
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "pages/admin/table_definition/history.html")]
|
||||||
|
struct HistoryPage<'a> {
|
||||||
|
nav: Nav,
|
||||||
|
page: &'a TableDefinitionPageState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_columns_page(page: &TableDefinitionPageState) -> String {
|
||||||
|
render(&ColumnsPage {
|
||||||
nav: page.nav.clone(),
|
nav: page.nav.clone(),
|
||||||
page,
|
page,
|
||||||
// Asking the draft, so the picker can only ever offer what the draft
|
// Asking the draft, so the picker can only ever offer what the draft
|
||||||
@@ -63,11 +119,9 @@ pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
|
pub(crate) fn render_columns_fragment(page: &TableDefinitionPageState) -> String {
|
||||||
render(&WorkspaceFragment {
|
render(&ColumnsFragment {
|
||||||
page,
|
page,
|
||||||
// Asking the draft, so the picker can only ever offer what the draft
|
|
||||||
// would accept — the accounting rule is stated once.
|
|
||||||
column_types: page.columns.offered_types(),
|
column_types: page.columns.offered_types(),
|
||||||
temporal_types: page.columns.temporal_types(),
|
temporal_types: page.columns.temporal_types(),
|
||||||
gtin_types: page.columns.gtin_types(),
|
gtin_types: page.columns.gtin_types(),
|
||||||
@@ -78,8 +132,6 @@ pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
|
|||||||
pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
|
pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
|
||||||
render(&ColumnPanelFragment {
|
render(&ColumnPanelFragment {
|
||||||
page,
|
page,
|
||||||
// Asking the draft, so the picker can only ever offer what the draft
|
|
||||||
// would accept — the accounting rule is stated once.
|
|
||||||
column_types: page.columns.offered_types(),
|
column_types: page.columns.offered_types(),
|
||||||
temporal_types: page.columns.temporal_types(),
|
temporal_types: page.columns.temporal_types(),
|
||||||
gtin_types: page.columns.gtin_types(),
|
gtin_types: page.columns.gtin_types(),
|
||||||
@@ -87,8 +139,48 @@ pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Used when the workspace itself cannot be loaded. There is nothing left to
|
pub(crate) fn render_delete_page(page: &TableDefinitionPageState) -> String {
|
||||||
/// render, so the dialog is what tells the user why.
|
render(&DeletePage {
|
||||||
|
nav: page.nav.clone(),
|
||||||
|
page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_delete_fragment(page: &TableDefinitionPageState) -> String {
|
||||||
|
render(&DeleteFragment { page })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_copy_page(page: &TableDefinitionPageState) -> String {
|
||||||
|
render(&CopyPage {
|
||||||
|
nav: page.nav.clone(),
|
||||||
|
page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_copy_fragment(page: &TableDefinitionPageState) -> String {
|
||||||
|
render(&CopyFragment { page })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_template_page(page: &TableDefinitionPageState) -> String {
|
||||||
|
render(&TemplatePage {
|
||||||
|
nav: page.nav.clone(),
|
||||||
|
page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_template_fragment(page: &TableDefinitionPageState) -> String {
|
||||||
|
render(&TemplateFragment { page })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render_history_page(page: &TableDefinitionPageState) -> String {
|
||||||
|
render(&HistoryPage {
|
||||||
|
nav: page.nav.clone(),
|
||||||
|
page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Used when a page itself cannot be loaded. There is nothing left to render,
|
||||||
|
/// so the dialog is what tells the user why.
|
||||||
pub(crate) fn render_load_error(message: &str) -> String {
|
pub(crate) fn render_load_error(message: &str) -> String {
|
||||||
render(&Alert::error("Table definition unavailable", message))
|
render(&Alert::error("Table definition unavailable", message))
|
||||||
}
|
}
|
||||||
@@ -99,7 +191,7 @@ mod tests {
|
|||||||
use crate::{
|
use crate::{
|
||||||
pages::admin::table_definition::state::{
|
pages::admin::table_definition::state::{
|
||||||
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
|
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
|
||||||
TablePermissionAction, TableRolePermissions, TableSummary,
|
TableSummary,
|
||||||
},
|
},
|
||||||
schema::ColumnDraft,
|
schema::ColumnDraft,
|
||||||
};
|
};
|
||||||
@@ -110,28 +202,12 @@ mod tests {
|
|||||||
table_kind: kind.to_string(),
|
table_kind: kind.to_string(),
|
||||||
global: false,
|
global: false,
|
||||||
depends_on: Vec::new(),
|
depends_on: Vec::new(),
|
||||||
row_display_columns: vec!["number".to_string()],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn global_tables_have_their_own_scope() {
|
|
||||||
let mut state = page();
|
|
||||||
state.selection.profile = "__global".to_string();
|
|
||||||
|
|
||||||
let html = render_workspace(&state);
|
|
||||||
|
|
||||||
assert!(html.contains(r#"value="__global" selected"#));
|
|
||||||
assert!(html.contains("Global — all profiles"));
|
|
||||||
assert!(html.contains("/admin/tables/new?global=true"));
|
|
||||||
assert!(!html.contains("Copy <code>__global</code>"));
|
|
||||||
assert!(!html.contains("Create tables from an invoice template"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn page() -> TableDefinitionPageState {
|
fn page() -> TableDefinitionPageState {
|
||||||
TableDefinitionPageState {
|
TableDefinitionPageState {
|
||||||
nav: Nav::default(),
|
nav: Nav::default(),
|
||||||
profiles: vec!["billing".to_string(), "payroll".to_string()],
|
|
||||||
selection: Selection {
|
selection: Selection {
|
||||||
profile: "billing".to_string(),
|
profile: "billing".to_string(),
|
||||||
table: "invoice".to_string(),
|
table: "invoice".to_string(),
|
||||||
@@ -139,8 +215,6 @@ mod tests {
|
|||||||
tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
|
tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
|
||||||
detail: Some(TableDetailView {
|
detail: Some(TableDetailView {
|
||||||
id: 7,
|
id: 7,
|
||||||
name: "invoice".to_string(),
|
|
||||||
table_kind: "dynamic".to_string(),
|
|
||||||
row_display_columns: vec!["number".to_string()],
|
row_display_columns: vec!["number".to_string()],
|
||||||
scripts: Vec::new(),
|
scripts: Vec::new(),
|
||||||
columns: vec![DetailColumn {
|
columns: vec![DetailColumn {
|
||||||
@@ -165,29 +239,96 @@ mod tests {
|
|||||||
error: None,
|
error: None,
|
||||||
sql: None,
|
sql: None,
|
||||||
generated: Vec::new(),
|
generated: Vec::new(),
|
||||||
permission_object: String::new(),
|
active: "columns",
|
||||||
role_permissions: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The point of the page: every write the service offers is reachable
|
/// The point of the split: each write is its own page, and the switcher
|
||||||
/// from the one screen, for the one selection.
|
/// they share is how you get from one to the next.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_workspace_offers_every_table_definition_write() {
|
fn every_page_links_to_the_others() {
|
||||||
let html = render_workspace(&page());
|
let mut state = page();
|
||||||
|
|
||||||
|
for (active, html) in [
|
||||||
|
("columns", render_columns_page(&state)),
|
||||||
|
("delete", render_delete_page(&state)),
|
||||||
|
] {
|
||||||
|
state.active = active;
|
||||||
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = render_columns_page(&state);
|
||||||
|
for route in [
|
||||||
|
"/admin/tables/columns?profile=billing",
|
||||||
|
"/admin/tables/delete?profile=billing",
|
||||||
|
"/admin/profiles/copy?profile=billing",
|
||||||
|
"/admin/tables/from-template?profile=billing",
|
||||||
|
"/admin/profiles/history?profile=billing",
|
||||||
|
"/permissions/grants",
|
||||||
|
] {
|
||||||
|
// Escaped, because it is an attribute: `&` is what a browser
|
||||||
|
// reads back as the `&` separating the two parameters.
|
||||||
|
let route = route.replace('&', "&");
|
||||||
|
assert!(html.contains(&route), "missing the {route} link\n{html}");
|
||||||
|
}
|
||||||
|
// And back to where the table was picked.
|
||||||
|
assert!(html.contains(r#"href="/admin?profile=billing&table=invoice""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deleting is its own page, so the form is the only thing on it — no
|
||||||
|
/// scrolling past five other panels to reach it.
|
||||||
|
#[test]
|
||||||
|
fn the_delete_page_is_only_the_delete_form() {
|
||||||
|
let html = render_delete_page(&page());
|
||||||
|
|
||||||
assert!(!html.contains("Template error"), "{html}");
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
for route in [
|
assert!(html.contains("/admin/tables/delete"));
|
||||||
"/admin/table-definition/columns",
|
assert!(html.contains("Type <code>invoice</code> to confirm"));
|
||||||
"/admin/table-definition/rename",
|
// The other writes are links in the switcher, not forms on the page.
|
||||||
"/admin/table-definition/delete",
|
assert!(!html.contains("/admin/tables/rename"));
|
||||||
"/admin/table-definition/copy",
|
assert!(!html.contains("/admin/profiles/copy?profile=billing\" method"));
|
||||||
"/admin/table-definition/invoice-template",
|
|
||||||
] {
|
|
||||||
assert!(html.contains(route), "missing the {route} panel");
|
|
||||||
}
|
}
|
||||||
// And creating a table, which is the one write that has its own page.
|
|
||||||
assert!(html.contains("/admin/tables/new?profile=billing"));
|
/// A system table is the backend's own; every write is refused for it, so
|
||||||
|
/// none of the write pages offer their form.
|
||||||
|
#[test]
|
||||||
|
fn a_system_table_gets_no_write_form() {
|
||||||
|
let mut state = page();
|
||||||
|
state.selection.table = "accounts".to_string();
|
||||||
|
|
||||||
|
let html = render_delete_page(&state);
|
||||||
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
|
assert!(html.contains("backend's own"));
|
||||||
|
assert!(!html.contains(r#"name="confirm_table_name""#));
|
||||||
|
|
||||||
|
let html = render_columns_page(&state);
|
||||||
|
assert!(!html.contains("/admin/tables/rename"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile-wide pages need only a profile, and say so by still
|
||||||
|
/// rendering with no table selected.
|
||||||
|
#[test]
|
||||||
|
fn the_profile_pages_do_not_need_a_table() {
|
||||||
|
let mut state = page();
|
||||||
|
state.selection.table = String::new();
|
||||||
|
state.detail = None;
|
||||||
|
state.active = "copy";
|
||||||
|
|
||||||
|
let html = render_copy_page(&state);
|
||||||
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
|
assert!(html.contains("/admin/profiles/copy"));
|
||||||
|
// Table-scoped links are absent, because there is no table.
|
||||||
|
assert!(!html.contains("/admin/tables/delete"));
|
||||||
|
|
||||||
|
state.active = "template";
|
||||||
|
let html = render_template_page(&state);
|
||||||
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
|
assert!(html.contains("/admin/tables/from-template"));
|
||||||
|
|
||||||
|
state.active = "history";
|
||||||
|
let html = render_history_page(&state);
|
||||||
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
|
assert!(html.contains("has been renamed"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The append panel posts the selection in its URL, so the column fields
|
/// The append panel posts the selection in its URL, so the column fields
|
||||||
@@ -209,8 +350,7 @@ mod tests {
|
|||||||
let html = render_column_panel(&state);
|
let html = render_column_panel(&state);
|
||||||
|
|
||||||
assert!(!html.contains("Template error"), "{html}");
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
// Escaped, because it is an attribute: `&` is what a browser reads
|
assert!(html.contains("/admin/tables/columns/builder"));
|
||||||
// back as the `&` separating the two parameters.
|
|
||||||
// Escaped, because it is an attribute: `&` is what a browser reads
|
// Escaped, because it is an attribute: `&` is what a browser reads
|
||||||
// back as the `&` separating the two parameters.
|
// back as the `&` separating the two parameters.
|
||||||
assert!(html.contains("?profile=billing&table=invoice"));
|
assert!(html.contains("?profile=billing&table=invoice"));
|
||||||
@@ -254,58 +394,16 @@ mod tests {
|
|||||||
assert!(!html.contains(r#"<option value="invoice""#));
|
assert!(!html.contains(r#"<option value="invoice""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A system table is the backend's own; every write below is refused for
|
|
||||||
/// it, so the workspace shows the definition and stops there.
|
|
||||||
#[test]
|
|
||||||
fn a_system_table_is_readable_but_not_writable() {
|
|
||||||
let mut state = page();
|
|
||||||
state.selection.table = "accounts".to_string();
|
|
||||||
state.detail = state.detail.map(|mut detail| {
|
|
||||||
detail.name = "accounts".to_string();
|
|
||||||
detail.table_kind = "system".to_string();
|
|
||||||
detail
|
|
||||||
});
|
|
||||||
|
|
||||||
let html = render_workspace(&state);
|
|
||||||
|
|
||||||
assert!(!html.contains("Template error"), "{html}");
|
|
||||||
assert!(html.contains("backend-managed"));
|
|
||||||
assert!(!html.contains("/admin/table-definition/delete"));
|
|
||||||
assert!(!html.contains("/admin/table-definition/rename"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With only a profile chosen, the profile-wide panels are there and the
|
|
||||||
/// table-wide ones are not.
|
|
||||||
#[test]
|
|
||||||
fn the_panels_follow_how_much_has_been_selected() {
|
|
||||||
let mut state = page();
|
|
||||||
state.selection.table = String::new();
|
|
||||||
state.detail = None;
|
|
||||||
|
|
||||||
let html = render_workspace(&state);
|
|
||||||
|
|
||||||
assert!(html.contains("/admin/table-definition/copy"));
|
|
||||||
assert!(html.contains("/admin/table-definition/invoice-template"));
|
|
||||||
assert!(!html.contains("/admin/table-definition/delete"));
|
|
||||||
|
|
||||||
// With nothing chosen at all, only the profile picker is.
|
|
||||||
state.selection.profile = String::new();
|
|
||||||
state.tables.clear();
|
|
||||||
let html = render_workspace(&state);
|
|
||||||
assert!(!html.contains("/admin/table-definition/copy"));
|
|
||||||
assert!(html.contains("Choose a scope"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
|
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
|
||||||
let mut state = page();
|
let mut state = page();
|
||||||
assert!(!render_workspace(&state).contains(r#"role="dialog""#));
|
assert!(!render_columns_fragment(&state).contains(r#"role="dialog""#));
|
||||||
|
|
||||||
state.error = Some("That column already exists.".to_string());
|
state.error = Some("That column already exists.".to_string());
|
||||||
let html = render_workspace(&state);
|
let html = render_columns_fragment(&state);
|
||||||
assert!(html.contains(r#"role="dialog""#));
|
assert!(html.contains(r#"role="dialog""#));
|
||||||
// Once in the inline alert, once in the dialog — and not a third time
|
// Once in the inline alert, once in the dialog — and not a third time
|
||||||
// from the column panel the workspace embeds.
|
// from the column panel the fragment embeds.
|
||||||
assert_eq!(html.matches("That column already exists.").count(), 2);
|
assert_eq!(html.matches("That column already exists.").count(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,30 +429,25 @@ mod tests {
|
|||||||
state.status = Some("1 column added to `invoice`.".to_string());
|
state.status = Some("1 column added to `invoice`.".to_string());
|
||||||
state.sql = Some("ALTER TABLE \"billing\".\"invoice\" ADD COLUMN …".to_string());
|
state.sql = Some("ALTER TABLE \"billing\".\"invoice\" ADD COLUMN …".to_string());
|
||||||
|
|
||||||
let html = render_workspace(&state);
|
let html = render_columns_fragment(&state);
|
||||||
|
|
||||||
assert!(html.contains("ALTER TABLE"));
|
assert!(html.contains("ALTER TABLE"));
|
||||||
assert!(html.contains("1 column added"));
|
assert!(html.contains("1 column added"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selected_table_exposes_its_data_permission_actions() {
|
fn global_tables_have_their_own_scope() {
|
||||||
let mut page = page();
|
let mut state = page();
|
||||||
page.permission_object = "data:billing/invoice".to_string();
|
state.selection.profile = "__global".to_string();
|
||||||
page.role_permissions = vec![TableRolePermissions {
|
|
||||||
role: "bookkeeper".to_string(),
|
let html = render_columns_page(&state);
|
||||||
actions: vec![TablePermissionAction {
|
|
||||||
action: "read".to_string(),
|
|
||||||
direct: false,
|
|
||||||
effective: false,
|
|
||||||
}],
|
|
||||||
}];
|
|
||||||
|
|
||||||
let html = render_workspace(&page);
|
|
||||||
assert!(!html.contains("Template error"), "{html}");
|
assert!(!html.contains("Template error"), "{html}");
|
||||||
assert!(html.contains("Data permissions for"));
|
assert!(html.contains("Global — all profiles"));
|
||||||
assert!(html.contains("data:billing/invoice"));
|
// Copying and the invoice template are per profile, so the global
|
||||||
assert!(html.contains("Grant read"));
|
// scope offers neither.
|
||||||
|
assert!(!html.contains("/admin/profiles/copy"));
|
||||||
|
assert!(!html.contains("/admin/tables/from-template"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -58,6 +58,18 @@
|
|||||||
.column.system { color: #6b7686; background: #fafbfc; }
|
.column.system { color: #6b7686; background: #fafbfc; }
|
||||||
.column.system code { color: #67788e; }
|
.column.system code { color: #67788e; }
|
||||||
.group-label { margin: 14px 0 4px; padding: 0 11px; color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
|
.group-label { margin: 14px 0 4px; padding: 0 11px; color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
|
||||||
|
|
||||||
|
/* What can be done to whatever the pane is pointed at, rendered directly
|
||||||
|
under it: under the selected table, and under the tables list for the
|
||||||
|
profile-wide actions. Each link is a page of its own. */
|
||||||
|
.browser-actions { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 12px; }
|
||||||
|
.browser-actions a { padding: 6px 12px; border: 1px solid #a9c7f6; border-radius: 7px; color: #1d4ed8; background: #eaf2ff; font-size: 13px; font-weight: 600; text-decoration: none; }
|
||||||
|
.browser-actions a:hover { background: #d8e8ff; }
|
||||||
|
.browser-actions a.danger-action { border-color: #eccfcf; color: #a12b2b; background: #fdf3f3; }
|
||||||
|
.browser-actions a.danger-action:hover { background: #fbe6e6; }
|
||||||
|
.pane-footer { margin: 0 10px 10px; border-top: 1px solid #e5e8ed; padding-top: 12px; }
|
||||||
|
.pane-footer a { border-color: #d9dfe7; color: #33415c; background: white; font-weight: 500; }
|
||||||
|
.pane-footer a:hover { background: #f2f6fc; }
|
||||||
.empty { margin: 8px; color: #7c8796; }
|
.empty { margin: 8px; color: #7c8796; }
|
||||||
|
|
||||||
/* ---------- Forms (components/form_card.html) ---------- */
|
/* ---------- Forms (components/form_card.html) ---------- */
|
||||||
@@ -199,8 +211,11 @@
|
|||||||
|
|
||||||
/* ---------- Permissions (pages/permissions) ---------- */
|
/* ---------- Permissions (pages/permissions) ---------- */
|
||||||
|
|
||||||
/* The three sections — roles, people, access — are separate pages, and this
|
/* The switcher between pages that belong together but are deliberately
|
||||||
is the switcher between them, rendered by pages/permissions/tabs.html. */
|
apart: the three permission sections (pages/permissions/tabs.html), and
|
||||||
|
the table-definition actions (pages/admin/table_definition/context.html).
|
||||||
|
Both split one crowded screen into a page per decision, so both need the
|
||||||
|
same thing — a way back to the siblings. */
|
||||||
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
|
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
|
||||||
.tab { display: grid; gap: 2px; padding: 10px 16px; border: 1px solid #d9dfe7; border-radius: 9px; color: #33415c; background: white; text-decoration: none; }
|
.tab { display: grid; gap: 2px; padding: 10px 16px; border: 1px solid #d9dfe7; border-radius: 9px; color: #33415c; background: white; text-decoration: none; }
|
||||||
.tab small { color: #7c8796; font-size: 11px; }
|
.tab small { color: #7c8796; font-size: 11px; }
|
||||||
|
|||||||
@@ -9,10 +9,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Workspace</p>
|
<p class="eyebrow">Workspace</p>
|
||||||
<h1>Admin panel</h1>
|
<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>
|
||||||
<div class="actions">
|
<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_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_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %}
|
||||||
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}
|
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}
|
||||||
|
|||||||
@@ -50,9 +50,38 @@
|
|||||||
</small>
|
</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section class="pane">
|
<section class="pane">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>Column type
|
<label>Column type
|
||||||
<select name="column_type_input"
|
<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-trigger="change" hx-include="#column-form"
|
||||||
hx-target="#column-panel" hx-swap="innerHTML"
|
hx-target="#column-panel" hx-swap="innerHTML"
|
||||||
hx-vals='{"action": "refresh"}'>
|
hx-vals='{"action": "refresh"}'>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="secondary"
|
<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-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
||||||
hx-vals='{"action": "add-column"}'>Stage column</button>
|
hx-vals='{"action": "add-column"}'>Stage column</button>
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
there is no column of its own name to index. #}
|
there is no column of its own name to index. #}
|
||||||
{% if page.columns.is_indexable(*loop.index0) %}
|
{% if page.columns.is_indexable(*loop.index0) %}
|
||||||
<button type="button" class="toggle"
|
<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-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
||||||
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
|
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
|
||||||
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" class="danger"
|
<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-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
|
||||||
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
|
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
|
||||||
</td>
|
</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