match field adder default presentation to pg regtype
This commit is contained in:
parent
6142c8cc40
commit
d4de42365d
15 changed files with 458 additions and 146 deletions
|
|
@ -39,6 +39,11 @@ impl Field {
|
|||
InsertableFieldBuilder::default()
|
||||
}
|
||||
|
||||
/// Construct an update to an existing field.
|
||||
pub fn update() -> UpdateBuilder {
|
||||
UpdateBuilder::default()
|
||||
}
|
||||
|
||||
/// Generate a default field config based on an existing column's name and
|
||||
/// type.
|
||||
pub fn default_from_attr(attr: &PgAttribute) -> Option<Self> {
|
||||
|
|
@ -148,6 +153,24 @@ impl InsertableFieldBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Builder, Clone, Debug)]
|
||||
pub struct Update {
|
||||
id: Uuid,
|
||||
#[builder(default, setter(strip_option))]
|
||||
table_label: Option<Option<String>>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
presentation: Option<Presentation>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
table_width_px: i32,
|
||||
}
|
||||
|
||||
impl Update {
|
||||
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
|
||||
// TODO: consolidate
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
|
||||
/// Error when parsing a sqlx value to JSON
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseError {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ pub const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
|
|||
#[derive(Clone, Debug, Deserialize, EnumIter, EnumString, PartialEq, Serialize, strum::Display)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum Presentation {
|
||||
Array { inner: Box<Presentation> },
|
||||
Dropdown { allow_custom: bool },
|
||||
Text { input_mode: TextInputMode },
|
||||
Timestamp { format: String },
|
||||
|
|
@ -20,7 +19,6 @@ impl Presentation {
|
|||
/// altering a backing column, such as "integer", or "timestamptz".
|
||||
pub fn attr_data_type_fragment(&self) -> String {
|
||||
match self {
|
||||
Self::Array { inner } => format!("{0}[]", inner.attr_data_type_fragment()),
|
||||
Self::Dropdown { .. } | Self::Text { .. } => "text".to_owned(),
|
||||
Self::Timestamp { .. } => "timestamptz".to_owned(),
|
||||
Self::Uuid { .. } => "uuid".to_owned(),
|
||||
|
|
@ -45,7 +43,6 @@ impl Presentation {
|
|||
/// Bet the web component tag name to use for rendering a UI cell.
|
||||
pub fn cell_webc_tag(&self) -> String {
|
||||
match self {
|
||||
Self::Array { .. } => todo!(),
|
||||
Self::Dropdown { .. } => "cell-dropdown".to_owned(),
|
||||
Self::Text { .. } => "cell-text".to_owned(),
|
||||
Self::Timestamp { .. } => "cell-timestamp".to_owned(),
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ pub(super) struct PathParams {
|
|||
workspace_id: Uuid,
|
||||
}
|
||||
|
||||
// FIXME: validate name, prevent leading underscore
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct FormBody {
|
||||
name: String,
|
||||
|
|
@ -126,7 +127,6 @@ fn try_presentation_from_form(form: &FormBody) -> Result<Presentation, AppError>
|
|||
// `MyVariant { .. }` pattern to pay attention to only the tag.
|
||||
let presentation_default = Presentation::try_from(form.presentation_tag.as_str())?;
|
||||
Ok(match presentation_default {
|
||||
Presentation::Array { .. } => todo!(),
|
||||
Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true },
|
||||
Presentation::Text { .. } => Presentation::Text {
|
||||
input_mode: form
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ mod portal_settings_handler;
|
|||
mod set_filter_handler;
|
||||
mod settings_handler;
|
||||
mod settings_invite_handler;
|
||||
mod update_field_handler;
|
||||
mod update_form_transitions_handler;
|
||||
mod update_portal_name_handler;
|
||||
mod update_prompts_handler;
|
||||
|
|
@ -39,6 +40,10 @@ pub(super) fn new_router() -> Router<App> {
|
|||
post(update_portal_name_handler::post),
|
||||
)
|
||||
.route("/p/{portal_id}/add-field", post(add_field_handler::post))
|
||||
.route(
|
||||
"/p/{portal_id}/update-field",
|
||||
post(update_field_handler::post),
|
||||
)
|
||||
.route("/p/{portal_id}/insert", post(insert_handler::post))
|
||||
.route(
|
||||
"/p/{portal_id}/update-value",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use axum::{
|
|||
};
|
||||
use interim_models::{expression::PgExpressionAny, portal::Portal, workspace::Workspace};
|
||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
|
@ -57,10 +57,23 @@ pub(super) async fn get(
|
|||
.fetch_all(&mut workspace_client)
|
||||
.await?;
|
||||
let attr_names: Vec<String> = attrs.iter().map(|attr| attr.attname.clone()).collect();
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct ColumnInfo {
|
||||
name: String,
|
||||
regtype: String,
|
||||
}
|
||||
let columns: Vec<ColumnInfo> = attrs
|
||||
.iter()
|
||||
.map(|attr| ColumnInfo {
|
||||
name: attr.attname.clone(),
|
||||
regtype: attr.regtype.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "portal_table.html")]
|
||||
struct ResponseTemplate {
|
||||
columns: Vec<ColumnInfo>,
|
||||
attr_names: Vec<String>,
|
||||
filter: Option<PgExpressionAny>,
|
||||
settings: Settings,
|
||||
|
|
@ -68,6 +81,7 @@ pub(super) async fn get(
|
|||
}
|
||||
Ok(Html(
|
||||
ResponseTemplate {
|
||||
columns,
|
||||
attr_names,
|
||||
filter: portal.table_filter.0,
|
||||
navbar: WorkspaceNav::builder()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
use axum::{
|
||||
debug_handler,
|
||||
extract::{Path, State},
|
||||
response::Response,
|
||||
};
|
||||
// [`axum_extra`]'s form extractor is preferred:
|
||||
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
|
||||
use axum_extra::extract::Form;
|
||||
use interim_models::{
|
||||
field::Field,
|
||||
portal::Portal,
|
||||
presentation::{Presentation, RFC_3339_S, TextInputMode},
|
||||
workspace::Workspace,
|
||||
workspace_user_perm::{self, WorkspaceUserPerm},
|
||||
};
|
||||
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{postgres::types::Oid, query};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
app::{App, AppDbConn},
|
||||
errors::{AppError, forbidden},
|
||||
navigator::{Navigator, NavigatorPage},
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathParams {
|
||||
portal_id: Uuid,
|
||||
rel_oid: u32,
|
||||
workspace_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct FormBody {
|
||||
name: String,
|
||||
label: String,
|
||||
presentation_tag: String,
|
||||
dropdown_allow_custom: Option<bool>,
|
||||
text_input_mode: Option<String>,
|
||||
timestamp_format: Option<String>,
|
||||
}
|
||||
|
||||
/// HTTP POST handler for adding a [`Field`] to a [`Portal`]. If the field name
|
||||
/// does not match a column in the backing database, a new column is created
|
||||
/// with a compatible type.
|
||||
///
|
||||
/// This handler expects 3 path parameters with the structure described by
|
||||
/// [`PathParams`].
|
||||
#[debug_handler(state = App)]
|
||||
pub(super) async fn post(
|
||||
State(mut workspace_pooler): State<WorkspacePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(user): CurrentUser,
|
||||
navigator: Navigator,
|
||||
Path(PathParams {
|
||||
portal_id,
|
||||
rel_oid,
|
||||
workspace_id,
|
||||
}): Path<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// Check workspace authorization.
|
||||
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
|
||||
.fetch_all(&mut app_db)
|
||||
.await?;
|
||||
if workspace_perms.iter().all(|p| {
|
||||
p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect
|
||||
}) {
|
||||
return Err(forbidden!("access denied to workspace"));
|
||||
}
|
||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||
// permission to access/alter both as needed.
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
.await?;
|
||||
|
||||
let class = PgClass::with_oid(portal.class_oid)
|
||||
.fetch_one(&mut workspace_client)
|
||||
.await?;
|
||||
|
||||
let presentation = try_presentation_from_form(&form)?;
|
||||
|
||||
query(&format!(
|
||||
"alter table {ident} add column if not exists {col} {typ}",
|
||||
ident = class.get_identifier(),
|
||||
col = escape_identifier(&form.name),
|
||||
typ = presentation.attr_data_type_fragment(),
|
||||
))
|
||||
.execute(workspace_client.get_conn())
|
||||
.await?;
|
||||
|
||||
Field::insert()
|
||||
.portal_id(portal.id)
|
||||
.name(form.name)
|
||||
.table_label(if form.label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.label)
|
||||
})
|
||||
.presentation(presentation)
|
||||
.build()?
|
||||
.insert(&mut app_db)
|
||||
.await?;
|
||||
|
||||
Ok(navigator
|
||||
.portal_page()
|
||||
.workspace_id(workspace_id)
|
||||
.rel_oid(Oid(rel_oid))
|
||||
.portal_id(portal_id)
|
||||
.build()?
|
||||
.redirect_to())
|
||||
}
|
||||
|
||||
fn try_presentation_from_form(form: &FormBody) -> Result<Presentation, AppError> {
|
||||
// Parses the presentation tag into the correct enum variant, but without
|
||||
// meaningful inner value(s). Match arms should all use the
|
||||
// `MyVariant { .. }` pattern to pay attention to only the tag.
|
||||
let presentation_default = Presentation::try_from(form.presentation_tag.as_str())?;
|
||||
Ok(match presentation_default {
|
||||
Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true },
|
||||
Presentation::Text { .. } => Presentation::Text {
|
||||
input_mode: form
|
||||
.text_input_mode
|
||||
.clone()
|
||||
.map(|value| TextInputMode::try_from(value.as_str()))
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
Presentation::Timestamp { .. } => Presentation::Timestamp {
|
||||
format: form
|
||||
.timestamp_format
|
||||
.clone()
|
||||
.unwrap_or(RFC_3339_S.to_owned()),
|
||||
},
|
||||
Presentation::Uuid { .. } => Presentation::Uuid {},
|
||||
})
|
||||
}
|
||||
|
|
@ -10,7 +10,10 @@
|
|||
Portal Settings
|
||||
</button>
|
||||
</a>
|
||||
<filter-menu identifier-hints="{{ attr_names | json }}" initial-value="{{ filter | json }}"></filter-menu>
|
||||
<filter-menu
|
||||
identifier-hints="{{ attr_names | json }}"
|
||||
initial-value="{{ filter | json }}"
|
||||
></filter-menu>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-grid__sidebar">
|
||||
|
|
@ -19,7 +22,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<main class="page-grid__main">
|
||||
<table-viewer columns="{{ attr_names | json }}" root-path="{{ settings.root_path }}"></table-viewer>
|
||||
<table-viewer columns="{{ columns | json }}"></table-viewer>
|
||||
</main>
|
||||
</div>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ button, input[type="submit"] {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO: can this be removed?
|
||||
.button-menu {
|
||||
&__toggle-button {
|
||||
@include globals.button-outline;
|
||||
|
|
|
|||
|
|
@ -161,7 +161,10 @@ $table-border-color: #ccc;
|
|||
&__popover {
|
||||
&:popover-open {
|
||||
@include globals.popover;
|
||||
left: anchor(left);
|
||||
top: anchor(bottom);
|
||||
padding: 1rem;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -200,11 +203,12 @@ $table-border-color: #ccc;
|
|||
}
|
||||
}
|
||||
|
||||
.lens-editor {
|
||||
.datum-editor {
|
||||
align-items: stretch;
|
||||
border-top: globals.$default-border;
|
||||
display: flex;
|
||||
grid-area: editor;
|
||||
height: 12rem;
|
||||
|
||||
&__input {
|
||||
@include globals.reset_input;
|
||||
|
|
|
|||
204
svelte/src/field-adder.svelte
Normal file
204
svelte/src/field-adder.svelte
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<!--
|
||||
@component
|
||||
An interactive UI that sits in the header row of a table and allows the user to
|
||||
quickly add a new field based on an existing column or configure a field backed
|
||||
by a new column to be added to the relation in the database.
|
||||
|
||||
Note: The form interface is implemented with a literal HTML <form> element with
|
||||
method="post", meaning that the parent page is reloaded upon field creation. It
|
||||
is not necessary to update the table DOM dynamically upon successful form
|
||||
submission.
|
||||
-->
|
||||
|
||||
<!--TODO: disable new column creation if the relation is a Postgres view.-->
|
||||
<!--TODO: display a warning if column name already exists and its type is
|
||||
incompatible with the current presentation configuration.-->
|
||||
|
||||
<script lang="ts">
|
||||
import icon_ellipsis_vertical from "../assets/heroicons/20/solid/ellipsis-vertical.svg?raw";
|
||||
import icon_plus from "../assets/heroicons/20/solid/plus.svg?raw";
|
||||
import icon_x_mark from "../assets/heroicons/20/solid/x-mark.svg?raw";
|
||||
import Combobox from "./combobox.svelte";
|
||||
import FieldDetails from "./field-details.svelte";
|
||||
import {
|
||||
all_presentation_tags,
|
||||
type Presentation,
|
||||
} from "./presentation.svelte";
|
||||
|
||||
type Assert<_T extends true> = void;
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* An array of all existing column names visible in the current relation,
|
||||
* to the current user. This is used to populate the autocomplete combobox.
|
||||
*/
|
||||
columns?: {
|
||||
name: string;
|
||||
regtype: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
let { columns = [] }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let name_customized = $state(false);
|
||||
let name_value = $state("");
|
||||
let label_value = $state("");
|
||||
let presentation_customized = $state(false);
|
||||
let presentation_value = $state<Presentation | undefined>(
|
||||
get_empty_presentation("Text"),
|
||||
);
|
||||
let popover_element = $state<HTMLDivElement | undefined>();
|
||||
let search_input_element = $state<HTMLInputElement | undefined>();
|
||||
// Hacky workaround because as of September 2025 implicit anchor association
|
||||
// is still pretty broken, at least in Firefox.
|
||||
let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`);
|
||||
|
||||
// If the database-friendly column name has not been explicitly set, keep it
|
||||
// synchronized with the human-friendly field label as typed in the table
|
||||
// header cell.
|
||||
$effect(() => {
|
||||
if (!name_customized) {
|
||||
// FIXME: apply transformations to make SQL-friendly
|
||||
name_value = label_value
|
||||
.toLocaleLowerCase("en-US")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/^[^a-z]/, "_")
|
||||
.replace(/[^a-z0-9]/g, "_");
|
||||
}
|
||||
});
|
||||
|
||||
// When the field name as entered corresponds to an existing column's name,
|
||||
// the presentation state is updated to the default for that column's Postgres
|
||||
// type, *unless* the presentation has already been customized by the user.
|
||||
$effect(() => {
|
||||
if (!presentation_customized) {
|
||||
// TODO: Should this normalize the comparison to lowercase?
|
||||
const regtype =
|
||||
columns.find(({ name }) => name === name_value)?.regtype ?? "";
|
||||
const presentation_tag = presentation_tags_by_regtype[regtype]?.[0];
|
||||
console.info(`Determining default presentation tag for ${regtype}.`);
|
||||
if (presentation_tag) {
|
||||
presentation_value = get_empty_presentation(presentation_tag);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Move to presentation.svelte.rs
|
||||
// Elements of array are compatible presentation tags; first element is the
|
||||
// default.
|
||||
const presentation_tags_by_regtype: Readonly<
|
||||
Record<string, ReadonlyArray<(typeof all_presentation_tags)[number]>>
|
||||
> = {
|
||||
text: ["Text", "Dropdown"],
|
||||
"timestamp with time zone": ["Timestamp"],
|
||||
uuid: ["Uuid"],
|
||||
};
|
||||
|
||||
function get_empty_presentation(
|
||||
tag: (typeof all_presentation_tags)[number],
|
||||
): Presentation {
|
||||
if (tag === "Dropdown") {
|
||||
return { t: "Dropdown", c: { allow_custom: true } };
|
||||
}
|
||||
if (tag === "Text") {
|
||||
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
||||
}
|
||||
if (tag === "Timestamp") {
|
||||
return { t: "Timestamp", c: {} };
|
||||
}
|
||||
if (tag === "Uuid") {
|
||||
return { t: "Uuid", c: {} };
|
||||
}
|
||||
type _ = Assert<typeof tag extends never ? true : false>;
|
||||
throw new Error("this should be unreachable");
|
||||
}
|
||||
|
||||
function handle_presentation_input() {
|
||||
presentation_customized = true;
|
||||
}
|
||||
|
||||
function handle_summary_toggle_button_click() {
|
||||
expanded = !expanded;
|
||||
if (expanded) {
|
||||
setTimeout(() => {
|
||||
search_input_element?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handle_field_options_button_click() {
|
||||
popover_element?.showPopover();
|
||||
}
|
||||
|
||||
function handle_name_input() {
|
||||
name_customized = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="post" action="add-field">
|
||||
<div class="field-adder__container">
|
||||
<div
|
||||
class="field-adder__header-lookalike"
|
||||
style:display={expanded ? "block" : "none"}
|
||||
>
|
||||
<Combobox
|
||||
bind:search_value={label_value}
|
||||
bind:search_input_element
|
||||
bind:value={label_value}
|
||||
completions={columns
|
||||
.map(({ name }) => name)
|
||||
.filter((name) =>
|
||||
name
|
||||
.toLocaleLowerCase("en-US")
|
||||
.includes(label_value.toLocaleLowerCase("en-US")),
|
||||
)}
|
||||
search_input_class="field-adder__label-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-adder__summary-buttons">
|
||||
<button
|
||||
aria-label="more field options"
|
||||
class="button--clear"
|
||||
onclick={handle_field_options_button_click}
|
||||
style:anchor-name={anchor_name}
|
||||
style:display={expanded ? "block" : "none"}
|
||||
type="button"
|
||||
>
|
||||
{@html icon_ellipsis_vertical}
|
||||
</button>
|
||||
<button
|
||||
aria-label="toggle field adder"
|
||||
class="button--clear"
|
||||
onclick={handle_summary_toggle_button_click}
|
||||
type="button"
|
||||
>
|
||||
{@html expanded ? icon_x_mark : icon_plus}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
bind:this={popover_element}
|
||||
class="field-adder__popover"
|
||||
popover="auto"
|
||||
style:position-anchor={anchor_name}
|
||||
>
|
||||
<!--
|
||||
The "advanced" details for creating a new column or customizing the behavior
|
||||
of a field backed by an existing column overlap with the controls exposed when
|
||||
editing the configuration of an existing field.
|
||||
-->
|
||||
{#if presentation_value}
|
||||
<FieldDetails
|
||||
bind:name_value
|
||||
bind:label_value
|
||||
bind:presentation={presentation_value}
|
||||
on_name_input={handle_name_input}
|
||||
on_presentation_input={handle_presentation_input}
|
||||
/>
|
||||
{/if}
|
||||
<button class="button--primary" type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
<svelte:options
|
||||
customElement={{
|
||||
props: {
|
||||
columns: { type: "Array" },
|
||||
},
|
||||
shadow: "none",
|
||||
tag: "field-adder",
|
||||
}}
|
||||
/>
|
||||
|
||||
<!--
|
||||
@component
|
||||
An interactive UI that sits in the header row of a table and allows the user to
|
||||
quickly add a new field based on an existing column or configure a field backed
|
||||
by a new column to be added to the relation in the database.
|
||||
|
||||
Note: The form interface is implemented with a literal HTML <form> element with
|
||||
method="post", meaning that the parent page is reloaded upon field creation. It
|
||||
is not necessary to update the table DOM dynamically upon successful form
|
||||
submission.
|
||||
-->
|
||||
|
||||
<!--TODO: disable new column creation if the relation is a Postgres view.-->
|
||||
|
||||
<script lang="ts">
|
||||
import icon_ellipsis_vertical from "../assets/heroicons/20/solid/ellipsis-vertical.svg?raw";
|
||||
import icon_plus from "../assets/heroicons/20/solid/plus.svg?raw";
|
||||
import icon_x_mark from "../assets/heroicons/20/solid/x-mark.svg?raw";
|
||||
import Combobox from "./combobox.svelte";
|
||||
import FieldDetails from "./field-details.svelte";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* An array of all existing column names visible in the current relation,
|
||||
* to the current user. This is used to populate the autocomplete combobox.
|
||||
*/
|
||||
columns?: string[];
|
||||
};
|
||||
|
||||
let { columns = [] }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let name_value = $state("");
|
||||
let label_value = $state("");
|
||||
let name_customized = $state(false);
|
||||
let popover_element = $state<HTMLDivElement | undefined>();
|
||||
let search_input_element = $state<HTMLInputElement | undefined>();
|
||||
|
||||
// If the database-friendly column name has not been explicitly set, keep it
|
||||
// synchronized with the human-friendly field label as typed in the table
|
||||
// header cell.
|
||||
$effect(() => {
|
||||
if (!name_customized) {
|
||||
name_value = label_value;
|
||||
}
|
||||
});
|
||||
|
||||
function handle_summary_toggle_button_click() {
|
||||
expanded = !expanded;
|
||||
if (expanded) {
|
||||
setTimeout(() => {
|
||||
search_input_element?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handle_field_options_button_click() {
|
||||
popover_element?.showPopover();
|
||||
}
|
||||
|
||||
function handle_name_input() {
|
||||
name_customized = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="post" action="add-field">
|
||||
<div class="field-adder__container">
|
||||
<div
|
||||
class="field-adder__header-lookalike"
|
||||
style:display={expanded ? "block" : "none"}
|
||||
>
|
||||
<Combobox
|
||||
bind:search_value={label_value}
|
||||
bind:search_input_element
|
||||
bind:value={label_value}
|
||||
completions={columns.filter((col) =>
|
||||
col
|
||||
.toLocaleLowerCase("en-US")
|
||||
.includes(label_value.toLocaleLowerCase("en-US")),
|
||||
)}
|
||||
search_input_class="field-adder__label-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-adder__summary-buttons">
|
||||
<button
|
||||
aria-label="more field options"
|
||||
class="button--clear"
|
||||
onclick={handle_field_options_button_click}
|
||||
style:display={expanded ? "block" : "none"}
|
||||
type="button"
|
||||
>
|
||||
{@html icon_ellipsis_vertical}
|
||||
</button>
|
||||
<button
|
||||
aria-label="toggle field adder"
|
||||
class="button--clear"
|
||||
onclick={handle_summary_toggle_button_click}
|
||||
type="button"
|
||||
>
|
||||
{@html expanded ? icon_x_mark : icon_plus}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div bind:this={popover_element} class="field-adder__popover" popover="auto">
|
||||
<!--
|
||||
The "advanced" details for creating a new column or customizing the behavior
|
||||
of a field backed by an existing column overlap with the controls exposed when
|
||||
editing the configuration of an existing field.
|
||||
-->
|
||||
<FieldDetails
|
||||
bind:name_value
|
||||
bind:label_value
|
||||
on_name_input={handle_name_input}
|
||||
/>
|
||||
<button class="button--primary" type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -21,6 +21,7 @@ field. This is typically rendered within a popover component, and within an HTML
|
|||
on_name_input?(
|
||||
ev: Event & { currentTarget: EventTarget & HTMLInputElement },
|
||||
): void;
|
||||
on_presentation_input?(presentation: Presentation): void;
|
||||
presentation?: Presentation;
|
||||
};
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ field. This is typically rendered within a popover component, and within an HTML
|
|||
name_value = $bindable(),
|
||||
label_value = $bindable(),
|
||||
on_name_input,
|
||||
on_presentation_input,
|
||||
}: Props = $props();
|
||||
|
||||
function handle_presentation_tag_change(
|
||||
|
|
@ -38,11 +40,15 @@ field. This is typically rendered within a popover component, and within an HTML
|
|||
const tag = ev.currentTarget
|
||||
.value as (typeof all_presentation_tags)[number];
|
||||
presentation = get_empty_presentation(tag);
|
||||
on_presentation_input?.(presentation);
|
||||
}
|
||||
|
||||
function get_empty_presentation(
|
||||
tag: (typeof all_presentation_tags)[number],
|
||||
): Presentation {
|
||||
if (tag === "Dropdown") {
|
||||
return { t: "Dropdown", c: { allow_custom: true } };
|
||||
}
|
||||
if (tag === "Text") {
|
||||
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
||||
}
|
||||
|
|
@ -76,6 +82,7 @@ field. This is typically rendered within a popover component, and within an HTML
|
|||
type _ = Assert<typeof tag extends never ? true : false>;
|
||||
throw new Error("this should be unreachable");
|
||||
}
|
||||
on_presentation_input?.(presentation);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import calendar_days_icon from "../assets/heroicons/20/solid/calendar-days.svg?raw";
|
||||
import cursor_arrow_rays_icon from "../assets/heroicons/20/solid/cursor-arrow-rays.svg?raw";
|
||||
import document_text_icon from "../assets/heroicons/20/solid/document-text.svg?raw";
|
||||
import identification_icon from "../assets/heroicons/20/solid/identification.svg?raw";
|
||||
import { type FieldInfo } from "./field.svelte";
|
||||
|
|
@ -18,6 +19,9 @@
|
|||
let popover_element = $state<HTMLDivElement | undefined>();
|
||||
let name_value = $state(field.field.name);
|
||||
let label_value = $state(field.field.table_label ?? "");
|
||||
// Hacky workaround because as of September 2025 implicit anchor association
|
||||
// is still pretty broken, at least in Firefox.
|
||||
let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`);
|
||||
|
||||
$effect(() => {
|
||||
field.field.table_label = label_value === "" ? undefined : label_value;
|
||||
|
|
@ -57,8 +61,11 @@
|
|||
bind:this={type_indicator_element}
|
||||
class="field-header__type-indicator"
|
||||
onclick={handle_type_indicator_element_click}
|
||||
style:anchor-name={anchor_name}
|
||||
>
|
||||
{#if field.field.presentation.t === "Text"}
|
||||
{#if field.field.presentation.t === "Dropdown"}
|
||||
{@html cursor_arrow_rays_icon}
|
||||
{:else if field.field.presentation.t === "Text"}
|
||||
{@html document_text_icon}
|
||||
{:else if field.field.presentation.t === "Timestamp"}
|
||||
{@html calendar_days_icon}
|
||||
|
|
@ -70,8 +77,12 @@
|
|||
bind:this={popover_element}
|
||||
class="field-header__popover"
|
||||
popover="auto"
|
||||
style:position-anchor={anchor_name}
|
||||
>
|
||||
<form method="post" action="update-field">
|
||||
<FieldDetails bind:name_value bind:label_value name_input_disabled />
|
||||
<button class="button--primary" type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,16 +5,21 @@ import { type Datum } from "./datum.svelte.ts";
|
|||
type Assert<_T extends true> = void;
|
||||
|
||||
export const all_presentation_tags = [
|
||||
"Dropdown",
|
||||
"Text",
|
||||
"Timestamp",
|
||||
"Uuid",
|
||||
] as const;
|
||||
|
||||
// Type checking to ensure that all valid enum tags are included.
|
||||
type _PresentationTagsAssertion = Assert<
|
||||
type _PresentationTagsAssertionA = Assert<
|
||||
Presentation["t"] extends (typeof all_presentation_tags)[number] ? true
|
||||
: false
|
||||
>;
|
||||
type _PresentationTagsAssertionB = Assert<
|
||||
(typeof all_presentation_tags)[number] extends Presentation["t"] ? true
|
||||
: false
|
||||
>;
|
||||
|
||||
export const all_text_input_modes = [
|
||||
"SingleLine",
|
||||
|
|
@ -22,10 +27,14 @@ export const all_text_input_modes = [
|
|||
] as const;
|
||||
|
||||
// Type checking to ensure that all valid enum tags are included.
|
||||
type _TextInputModesAssertion = Assert<
|
||||
type _TextInputModesAssertionA = Assert<
|
||||
TextInputMode["t"] extends (typeof all_text_input_modes)[number] ? true
|
||||
: false
|
||||
>;
|
||||
type _TextInputModesAssertionB = Assert<
|
||||
(typeof all_text_input_modes)[number] extends TextInputMode["t"] ? true
|
||||
: false
|
||||
>;
|
||||
|
||||
const text_input_mode_schema = z.union([
|
||||
z.object({
|
||||
|
|
@ -40,6 +49,15 @@ const text_input_mode_schema = z.union([
|
|||
|
||||
export type TextInputMode = z.infer<typeof text_input_mode_schema>;
|
||||
|
||||
const presentation_dropdown_schema = z.object({
|
||||
t: z.literal("Dropdown"),
|
||||
c: z.object({
|
||||
allow_custom: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PresentationDropdown = z.infer<typeof presentation_dropdown_schema>;
|
||||
|
||||
const presentation_text_schema = z.object({
|
||||
t: z.literal("Text"),
|
||||
c: z.object({
|
||||
|
|
@ -66,6 +84,7 @@ const presentation_uuid_schema = z.object({
|
|||
export type PresentationUuid = z.infer<typeof presentation_uuid_schema>;
|
||||
|
||||
export const presentation_schema = z.union([
|
||||
presentation_dropdown_schema,
|
||||
presentation_text_schema,
|
||||
presentation_timestamp_schema,
|
||||
presentation_uuid_schema,
|
||||
|
|
@ -74,12 +93,15 @@ export const presentation_schema = z.union([
|
|||
export type Presentation = z.infer<typeof presentation_schema>;
|
||||
|
||||
export function get_empty_datum_for(presentation: Presentation): Datum {
|
||||
if (presentation.t === "Timestamp") {
|
||||
return { t: "Timestamp", c: undefined };
|
||||
if (presentation.t === "Dropdown") {
|
||||
return { t: "Text", c: undefined };
|
||||
}
|
||||
if (presentation.t === "Text") {
|
||||
return { t: "Text", c: undefined };
|
||||
}
|
||||
if (presentation.t === "Timestamp") {
|
||||
return { t: "Timestamp", c: undefined };
|
||||
}
|
||||
if (presentation.t === "Uuid") {
|
||||
return { t: "Uuid", c: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,15 @@
|
|||
coords_eq,
|
||||
field_info_schema,
|
||||
} from "./field.svelte";
|
||||
import FieldAdder from "./field-adder.svelte";
|
||||
import FieldHeader from "./field-header.svelte";
|
||||
import { get_empty_datum_for } from "./presentation.svelte";
|
||||
|
||||
type Props = {
|
||||
columns?: string[];
|
||||
columns?: {
|
||||
name: string;
|
||||
regtype: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
let { columns = [] }: Props = $props();
|
||||
|
|
@ -547,7 +551,7 @@
|
|||
/>
|
||||
{/each}
|
||||
<div class="lens-table__header-actions">
|
||||
<field-adder {columns}></field-adder>
|
||||
<FieldAdder {columns}></FieldAdder>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lens-table__main">
|
||||
|
|
@ -589,7 +593,7 @@
|
|||
{/each}
|
||||
</form>
|
||||
</div>
|
||||
<div class="lens-editor">
|
||||
<div class="datum-editor">
|
||||
{#if selections.length === 1 && editor_state}
|
||||
<DatumEditor
|
||||
bind:editor_state
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue