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()
|
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
|
/// Generate a default field config based on an existing column's name and
|
||||||
/// type.
|
/// type.
|
||||||
pub fn default_from_attr(attr: &PgAttribute) -> Option<Self> {
|
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
|
/// Error when parsing a sqlx value to JSON
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ParseError {
|
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)]
|
#[derive(Clone, Debug, Deserialize, EnumIter, EnumString, PartialEq, Serialize, strum::Display)]
|
||||||
#[serde(tag = "t", content = "c")]
|
#[serde(tag = "t", content = "c")]
|
||||||
pub enum Presentation {
|
pub enum Presentation {
|
||||||
Array { inner: Box<Presentation> },
|
|
||||||
Dropdown { allow_custom: bool },
|
Dropdown { allow_custom: bool },
|
||||||
Text { input_mode: TextInputMode },
|
Text { input_mode: TextInputMode },
|
||||||
Timestamp { format: String },
|
Timestamp { format: String },
|
||||||
|
|
@ -20,7 +19,6 @@ impl Presentation {
|
||||||
/// altering a backing column, such as "integer", or "timestamptz".
|
/// altering a backing column, such as "integer", or "timestamptz".
|
||||||
pub fn attr_data_type_fragment(&self) -> String {
|
pub fn attr_data_type_fragment(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Array { inner } => format!("{0}[]", inner.attr_data_type_fragment()),
|
|
||||||
Self::Dropdown { .. } | Self::Text { .. } => "text".to_owned(),
|
Self::Dropdown { .. } | Self::Text { .. } => "text".to_owned(),
|
||||||
Self::Timestamp { .. } => "timestamptz".to_owned(),
|
Self::Timestamp { .. } => "timestamptz".to_owned(),
|
||||||
Self::Uuid { .. } => "uuid".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.
|
/// Bet the web component tag name to use for rendering a UI cell.
|
||||||
pub fn cell_webc_tag(&self) -> String {
|
pub fn cell_webc_tag(&self) -> String {
|
||||||
match self {
|
match self {
|
||||||
Self::Array { .. } => todo!(),
|
|
||||||
Self::Dropdown { .. } => "cell-dropdown".to_owned(),
|
Self::Dropdown { .. } => "cell-dropdown".to_owned(),
|
||||||
Self::Text { .. } => "cell-text".to_owned(),
|
Self::Text { .. } => "cell-text".to_owned(),
|
||||||
Self::Timestamp { .. } => "cell-timestamp".to_owned(),
|
Self::Timestamp { .. } => "cell-timestamp".to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ pub(super) struct PathParams {
|
||||||
workspace_id: Uuid,
|
workspace_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: validate name, prevent leading underscore
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct FormBody {
|
pub(super) struct FormBody {
|
||||||
name: String,
|
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.
|
// `MyVariant { .. }` pattern to pay attention to only the tag.
|
||||||
let presentation_default = Presentation::try_from(form.presentation_tag.as_str())?;
|
let presentation_default = Presentation::try_from(form.presentation_tag.as_str())?;
|
||||||
Ok(match presentation_default {
|
Ok(match presentation_default {
|
||||||
Presentation::Array { .. } => todo!(),
|
|
||||||
Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true },
|
Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true },
|
||||||
Presentation::Text { .. } => Presentation::Text {
|
Presentation::Text { .. } => Presentation::Text {
|
||||||
input_mode: form
|
input_mode: form
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ mod portal_settings_handler;
|
||||||
mod set_filter_handler;
|
mod set_filter_handler;
|
||||||
mod settings_handler;
|
mod settings_handler;
|
||||||
mod settings_invite_handler;
|
mod settings_invite_handler;
|
||||||
|
mod update_field_handler;
|
||||||
mod update_form_transitions_handler;
|
mod update_form_transitions_handler;
|
||||||
mod update_portal_name_handler;
|
mod update_portal_name_handler;
|
||||||
mod update_prompts_handler;
|
mod update_prompts_handler;
|
||||||
|
|
@ -39,6 +40,10 @@ pub(super) fn new_router() -> Router<App> {
|
||||||
post(update_portal_name_handler::post),
|
post(update_portal_name_handler::post),
|
||||||
)
|
)
|
||||||
.route("/p/{portal_id}/add-field", post(add_field_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}/insert", post(insert_handler::post))
|
||||||
.route(
|
.route(
|
||||||
"/p/{portal_id}/update-value",
|
"/p/{portal_id}/update-value",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use interim_models::{expression::PgExpressionAny, portal::Portal, workspace::Workspace};
|
use interim_models::{expression::PgExpressionAny, portal::Portal, workspace::Workspace};
|
||||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::postgres::types::Oid;
|
use sqlx::postgres::types::Oid;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -57,10 +57,23 @@ pub(super) async fn get(
|
||||||
.fetch_all(&mut workspace_client)
|
.fetch_all(&mut workspace_client)
|
||||||
.await?;
|
.await?;
|
||||||
let attr_names: Vec<String> = attrs.iter().map(|attr| attr.attname.clone()).collect();
|
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)]
|
#[derive(Template)]
|
||||||
#[template(path = "portal_table.html")]
|
#[template(path = "portal_table.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
|
columns: Vec<ColumnInfo>,
|
||||||
attr_names: Vec<String>,
|
attr_names: Vec<String>,
|
||||||
filter: Option<PgExpressionAny>,
|
filter: Option<PgExpressionAny>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|
@ -68,6 +81,7 @@ pub(super) async fn get(
|
||||||
}
|
}
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
ResponseTemplate {
|
ResponseTemplate {
|
||||||
|
columns,
|
||||||
attr_names,
|
attr_names,
|
||||||
filter: portal.table_filter.0,
|
filter: portal.table_filter.0,
|
||||||
navbar: WorkspaceNav::builder()
|
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
|
Portal Settings
|
||||||
</button>
|
</button>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
<div class="page-grid__sidebar">
|
<div class="page-grid__sidebar">
|
||||||
|
|
@ -19,7 +22,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="page-grid__main">
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
|
<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 {
|
.button-menu {
|
||||||
&__toggle-button {
|
&__toggle-button {
|
||||||
@include globals.button-outline;
|
@include globals.button-outline;
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,10 @@ $table-border-color: #ccc;
|
||||||
&__popover {
|
&__popover {
|
||||||
&:popover-open {
|
&:popover-open {
|
||||||
@include globals.popover;
|
@include globals.popover;
|
||||||
|
left: anchor(left);
|
||||||
|
top: anchor(bottom);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
position: absolute;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,11 +203,12 @@ $table-border-color: #ccc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.lens-editor {
|
.datum-editor {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border-top: globals.$default-border;
|
border-top: globals.$default-border;
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-area: editor;
|
grid-area: editor;
|
||||||
|
height: 12rem;
|
||||||
|
|
||||||
&__input {
|
&__input {
|
||||||
@include globals.reset_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?(
|
on_name_input?(
|
||||||
ev: Event & { currentTarget: EventTarget & HTMLInputElement },
|
ev: Event & { currentTarget: EventTarget & HTMLInputElement },
|
||||||
): void;
|
): void;
|
||||||
|
on_presentation_input?(presentation: Presentation): void;
|
||||||
presentation?: Presentation;
|
presentation?: Presentation;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ field. This is typically rendered within a popover component, and within an HTML
|
||||||
name_value = $bindable(),
|
name_value = $bindable(),
|
||||||
label_value = $bindable(),
|
label_value = $bindable(),
|
||||||
on_name_input,
|
on_name_input,
|
||||||
|
on_presentation_input,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handle_presentation_tag_change(
|
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
|
const tag = ev.currentTarget
|
||||||
.value as (typeof all_presentation_tags)[number];
|
.value as (typeof all_presentation_tags)[number];
|
||||||
presentation = get_empty_presentation(tag);
|
presentation = get_empty_presentation(tag);
|
||||||
|
on_presentation_input?.(presentation);
|
||||||
}
|
}
|
||||||
|
|
||||||
function get_empty_presentation(
|
function get_empty_presentation(
|
||||||
tag: (typeof all_presentation_tags)[number],
|
tag: (typeof all_presentation_tags)[number],
|
||||||
): Presentation {
|
): Presentation {
|
||||||
|
if (tag === "Dropdown") {
|
||||||
|
return { t: "Dropdown", c: { allow_custom: true } };
|
||||||
|
}
|
||||||
if (tag === "Text") {
|
if (tag === "Text") {
|
||||||
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
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>;
|
type _ = Assert<typeof tag extends never ? true : false>;
|
||||||
throw new Error("this should be unreachable");
|
throw new Error("this should be unreachable");
|
||||||
}
|
}
|
||||||
|
on_presentation_input?.(presentation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import calendar_days_icon from "../assets/heroicons/20/solid/calendar-days.svg?raw";
|
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 document_text_icon from "../assets/heroicons/20/solid/document-text.svg?raw";
|
||||||
import identification_icon from "../assets/heroicons/20/solid/identification.svg?raw";
|
import identification_icon from "../assets/heroicons/20/solid/identification.svg?raw";
|
||||||
import { type FieldInfo } from "./field.svelte";
|
import { type FieldInfo } from "./field.svelte";
|
||||||
|
|
@ -18,6 +19,9 @@
|
||||||
let popover_element = $state<HTMLDivElement | undefined>();
|
let popover_element = $state<HTMLDivElement | undefined>();
|
||||||
let name_value = $state(field.field.name);
|
let name_value = $state(field.field.name);
|
||||||
let label_value = $state(field.field.table_label ?? "");
|
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(() => {
|
$effect(() => {
|
||||||
field.field.table_label = label_value === "" ? undefined : label_value;
|
field.field.table_label = label_value === "" ? undefined : label_value;
|
||||||
|
|
@ -57,8 +61,11 @@
|
||||||
bind:this={type_indicator_element}
|
bind:this={type_indicator_element}
|
||||||
class="field-header__type-indicator"
|
class="field-header__type-indicator"
|
||||||
onclick={handle_type_indicator_element_click}
|
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}
|
{@html document_text_icon}
|
||||||
{:else if field.field.presentation.t === "Timestamp"}
|
{:else if field.field.presentation.t === "Timestamp"}
|
||||||
{@html calendar_days_icon}
|
{@html calendar_days_icon}
|
||||||
|
|
@ -70,8 +77,12 @@
|
||||||
bind:this={popover_element}
|
bind:this={popover_element}
|
||||||
class="field-header__popover"
|
class="field-header__popover"
|
||||||
popover="auto"
|
popover="auto"
|
||||||
|
style:position-anchor={anchor_name}
|
||||||
>
|
>
|
||||||
<FieldDetails bind:name_value bind:label_value name_input_disabled />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,21 @@ import { type Datum } from "./datum.svelte.ts";
|
||||||
type Assert<_T extends true> = void;
|
type Assert<_T extends true> = void;
|
||||||
|
|
||||||
export const all_presentation_tags = [
|
export const all_presentation_tags = [
|
||||||
|
"Dropdown",
|
||||||
"Text",
|
"Text",
|
||||||
"Timestamp",
|
"Timestamp",
|
||||||
"Uuid",
|
"Uuid",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Type checking to ensure that all valid enum tags are included.
|
// 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
|
Presentation["t"] extends (typeof all_presentation_tags)[number] ? true
|
||||||
: false
|
: false
|
||||||
>;
|
>;
|
||||||
|
type _PresentationTagsAssertionB = Assert<
|
||||||
|
(typeof all_presentation_tags)[number] extends Presentation["t"] ? true
|
||||||
|
: false
|
||||||
|
>;
|
||||||
|
|
||||||
export const all_text_input_modes = [
|
export const all_text_input_modes = [
|
||||||
"SingleLine",
|
"SingleLine",
|
||||||
|
|
@ -22,10 +27,14 @@ export const all_text_input_modes = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Type checking to ensure that all valid enum tags are included.
|
// 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
|
TextInputMode["t"] extends (typeof all_text_input_modes)[number] ? true
|
||||||
: false
|
: false
|
||||||
>;
|
>;
|
||||||
|
type _TextInputModesAssertionB = Assert<
|
||||||
|
(typeof all_text_input_modes)[number] extends TextInputMode["t"] ? true
|
||||||
|
: false
|
||||||
|
>;
|
||||||
|
|
||||||
const text_input_mode_schema = z.union([
|
const text_input_mode_schema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -40,6 +49,15 @@ const text_input_mode_schema = z.union([
|
||||||
|
|
||||||
export type TextInputMode = z.infer<typeof text_input_mode_schema>;
|
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({
|
const presentation_text_schema = z.object({
|
||||||
t: z.literal("Text"),
|
t: z.literal("Text"),
|
||||||
c: z.object({
|
c: z.object({
|
||||||
|
|
@ -66,6 +84,7 @@ const presentation_uuid_schema = z.object({
|
||||||
export type PresentationUuid = z.infer<typeof presentation_uuid_schema>;
|
export type PresentationUuid = z.infer<typeof presentation_uuid_schema>;
|
||||||
|
|
||||||
export const presentation_schema = z.union([
|
export const presentation_schema = z.union([
|
||||||
|
presentation_dropdown_schema,
|
||||||
presentation_text_schema,
|
presentation_text_schema,
|
||||||
presentation_timestamp_schema,
|
presentation_timestamp_schema,
|
||||||
presentation_uuid_schema,
|
presentation_uuid_schema,
|
||||||
|
|
@ -74,12 +93,15 @@ export const presentation_schema = z.union([
|
||||||
export type Presentation = z.infer<typeof presentation_schema>;
|
export type Presentation = z.infer<typeof presentation_schema>;
|
||||||
|
|
||||||
export function get_empty_datum_for(presentation: Presentation): Datum {
|
export function get_empty_datum_for(presentation: Presentation): Datum {
|
||||||
if (presentation.t === "Timestamp") {
|
if (presentation.t === "Dropdown") {
|
||||||
return { t: "Timestamp", c: undefined };
|
return { t: "Text", c: undefined };
|
||||||
}
|
}
|
||||||
if (presentation.t === "Text") {
|
if (presentation.t === "Text") {
|
||||||
return { t: "Text", c: undefined };
|
return { t: "Text", c: undefined };
|
||||||
}
|
}
|
||||||
|
if (presentation.t === "Timestamp") {
|
||||||
|
return { t: "Timestamp", c: undefined };
|
||||||
|
}
|
||||||
if (presentation.t === "Uuid") {
|
if (presentation.t === "Uuid") {
|
||||||
return { t: "Uuid", c: undefined };
|
return { t: "Uuid", c: undefined };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,15 @@
|
||||||
coords_eq,
|
coords_eq,
|
||||||
field_info_schema,
|
field_info_schema,
|
||||||
} from "./field.svelte";
|
} from "./field.svelte";
|
||||||
|
import FieldAdder from "./field-adder.svelte";
|
||||||
import FieldHeader from "./field-header.svelte";
|
import FieldHeader from "./field-header.svelte";
|
||||||
import { get_empty_datum_for } from "./presentation.svelte";
|
import { get_empty_datum_for } from "./presentation.svelte";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
columns?: string[];
|
columns?: {
|
||||||
|
name: string;
|
||||||
|
regtype: string;
|
||||||
|
}[];
|
||||||
};
|
};
|
||||||
|
|
||||||
let { columns = [] }: Props = $props();
|
let { columns = [] }: Props = $props();
|
||||||
|
|
@ -547,7 +551,7 @@
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
<div class="lens-table__header-actions">
|
<div class="lens-table__header-actions">
|
||||||
<field-adder {columns}></field-adder>
|
<FieldAdder {columns}></FieldAdder>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lens-table__main">
|
<div class="lens-table__main">
|
||||||
|
|
@ -589,7 +593,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="lens-editor">
|
<div class="datum-editor">
|
||||||
{#if selections.length === 1 && editor_state}
|
{#if selections.length === 1 && editor_state}
|
||||||
<DatumEditor
|
<DatumEditor
|
||||||
bind:editor_state
|
bind:editor_state
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue