match field adder default presentation to pg regtype

This commit is contained in:
Brent Schroeter 2025-10-07 06:23:50 +00:00
parent 6142c8cc40
commit d4de42365d
15 changed files with 458 additions and 146 deletions

View file

@ -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 {

View file

@ -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(),

View file

@ -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

View file

@ -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",

View file

@ -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()

View file

@ -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 {},
})
}

View file

@ -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>

View file

@ -149,6 +149,7 @@ button, input[type="submit"] {
}
}
// TODO: can this be removed?
.button-menu {
&__toggle-button {
@include globals.button-outline;

View file

@ -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;

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 };
}

View file

@ -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