add support for numeric fields

This commit is contained in:
Brent Schroeter 2025-11-11 01:26:48 +00:00
parent 95a4165163
commit 68ca114d5e
19 changed files with 171 additions and 69 deletions

31
Cargo.lock generated
View file

@ -397,6 +397,21 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bigdecimal"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "560f42649de9fa436b73517378a147ec21f6c997a546581df4b4b31677828934"
dependencies = [
"autocfg",
"libm",
"num-bigint",
"num-integer",
"num-traits",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "bincode" name = "bincode"
version = "1.3.3" version = "1.3.3"
@ -1739,6 +1754,7 @@ dependencies = [
name = "interim-models" name = "interim-models"
version = "0.0.1" version = "0.0.1"
dependencies = [ dependencies = [
"bigdecimal",
"chrono", "chrono",
"derive_builder", "derive_builder",
"interim-pgtypes", "interim-pgtypes",
@ -1786,6 +1802,7 @@ dependencies = [
"async-session", "async-session",
"axum", "axum",
"axum-extra", "axum-extra",
"bigdecimal",
"chrono", "chrono",
"clap", "clap",
"config", "config",
@ -2079,6 +2096,16 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.4" version = "0.8.4"
@ -3203,6 +3230,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bigdecimal",
"bytes", "bytes",
"chrono", "chrono",
"crc", "crc",
@ -3280,6 +3308,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.22.1", "base64 0.22.1",
"bigdecimal",
"bitflags 2.9.4", "bitflags 2.9.4",
"byteorder", "byteorder",
"bytes", "bytes",
@ -3324,6 +3353,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
dependencies = [ dependencies = [
"atoi", "atoi",
"base64 0.22.1", "base64 0.22.1",
"bigdecimal",
"bitflags 2.9.4", "bitflags 2.9.4",
"byteorder", "byteorder",
"chrono", "chrono",
@ -3341,6 +3371,7 @@ dependencies = [
"log", "log",
"md-5", "md-5",
"memchr", "memchr",
"num-bigint",
"once_cell", "once_cell",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",

View file

@ -10,6 +10,7 @@ edition = "2024"
[workspace.dependencies] [workspace.dependencies]
anyhow = { version = "1.0.91", features = ["backtrace"] } anyhow = { version = "1.0.91", features = ["backtrace"] }
bigdecimal = { version = "0.4.9", features = ["serde-json"] }
chrono = { version = "0.4.41", features = ["serde"] } chrono = { version = "0.4.41", features = ["serde"] }
derive_builder = "0.20.2" derive_builder = "0.20.2"
futures = "0.3.31" futures = "0.3.31"
@ -22,7 +23,7 @@ regex = "1.11.1"
reqwest = { version = "0.12.8", features = ["json"] } reqwest = { version = "0.12.8", features = ["json"] }
serde = { version = "1.0.213", features = ["derive"] } serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.132" serde_json = "1.0.132"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] } sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros", "bigdecimal"] }
strum = { version = "0.27.2", features = ["derive"] } strum = { version = "0.27.2", features = ["derive"] }
thiserror = "2.0.12" thiserror = "2.0.12"
tokio = { version = "1.42.0", features = ["full"] } tokio = { version = "1.42.0", features = ["full"] }

View file

@ -4,6 +4,7 @@ edition.workspace = true
version.workspace = true version.workspace = true
[dependencies] [dependencies]
bigdecimal = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
derive_builder = { workspace = true } derive_builder = { workspace = true }
interim-pgtypes = { path = "../interim-pgtypes" } interim-pgtypes = { path = "../interim-pgtypes" }

View file

@ -1,3 +1,4 @@
use bigdecimal::BigDecimal;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::Postgres; use sqlx::Postgres;
@ -6,6 +7,14 @@ use uuid::Uuid;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "t", content = "c")] #[serde(tag = "t", content = "c")]
pub enum Datum { pub enum Datum {
// BigDecimal is used because a user may insert a value directly via SQL
// which overflows the representational space of `rust_decimal::Decimal`.
// Note that by default, [`BigDecimal`] serializes to JSON as a string. This
// behavior can be modified, but it's a pain when paired with the [`Option`]
// type. String representation should be acceptable for the UI, as [`Datum`]
// values should always be parsed through Zod, which can coerce the value to
// a number transparently.
Numeric(Option<BigDecimal>),
Text(Option<String>), Text(Option<String>),
Timestamp(Option<DateTime<Utc>>), Timestamp(Option<DateTime<Utc>>),
Uuid(Option<Uuid>), Uuid(Option<Uuid>),
@ -19,6 +28,7 @@ impl Datum {
query: sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>>, query: sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>>,
) -> sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>> { ) -> sqlx::query::Query<'a, Postgres, <sqlx::Postgres as sqlx::Database>::Arguments<'a>> {
match self { match self {
Self::Numeric(value) => query.bind(value),
Self::Text(value) => query.bind(value), Self::Text(value) => query.bind(value),
Self::Timestamp(value) => query.bind(value), Self::Timestamp(value) => query.bind(value),
Self::Uuid(value) => query.bind(value), Self::Uuid(value) => query.bind(value),
@ -38,8 +48,10 @@ impl Datum {
pub fn is_none(&self) -> bool { pub fn is_none(&self) -> bool {
match self { match self {
Self::Text(None) | Self::Timestamp(None) | Self::Uuid(None) => true, Self::Numeric(None) | Self::Text(None) | Self::Timestamp(None) | Self::Uuid(None) => {
Self::Text(_) | Self::Timestamp(_) | Self::Uuid(_) => false, true
}
Self::Numeric(_) | Self::Text(_) | Self::Timestamp(_) | Self::Uuid(_) => false,
} }
} }
} }

View file

@ -1,3 +1,4 @@
use bigdecimal::BigDecimal;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use derive_builder::Builder; use derive_builder::Builder;
use interim_pgtypes::pg_attribute::PgAttribute; use interim_pgtypes::pg_attribute::PgAttribute;
@ -72,6 +73,9 @@ impl Field {
let ty = type_info.name(); let ty = type_info.name();
dbg!(&ty); dbg!(&ty);
Ok(match ty { Ok(match ty {
"NUMERIC" => {
Datum::Numeric(<Option<BigDecimal> as Decode<Postgres>>::decode(value_ref).unwrap())
}
"TEXT" | "VARCHAR" => { "TEXT" | "VARCHAR" => {
Datum::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap()) Datum::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
} }

View file

@ -12,6 +12,7 @@ pub enum Presentation {
allow_custom: bool, allow_custom: bool,
options: Vec<DropdownOption>, options: Vec<DropdownOption>,
}, },
Numeric {},
Text { Text {
input_mode: TextInputMode, input_mode: TextInputMode,
}, },
@ -30,11 +31,12 @@ pub struct DropdownOption {
impl Presentation { impl Presentation {
/// Returns a SQL fragment for the default data type for creating or /// Returns a SQL fragment for the default data type for creating or
/// 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) -> &'static str {
match self { match self {
Self::Dropdown { .. } | Self::Text { .. } => "text".to_owned(), Self::Dropdown { .. } | Self::Text { .. } => "text",
Self::Timestamp { .. } => "timestamptz".to_owned(), Self::Numeric { .. } => "numeric",
Self::Uuid { .. } => "uuid".to_owned(), Self::Timestamp { .. } => "timestamptz",
Self::Uuid { .. } => "uuid",
} }
} }
@ -42,6 +44,7 @@ impl Presentation {
/// Returns None if no default presentation exists. /// Returns None if no default presentation exists.
pub fn default_from_attr(attr: &PgAttribute) -> Option<Self> { pub fn default_from_attr(attr: &PgAttribute) -> Option<Self> {
match attr.regtype.to_lowercase().as_str() { match attr.regtype.to_lowercase().as_str() {
"numeric" => Some(Self::Numeric {}),
"text" => Some(Self::Text { "text" => Some(Self::Text {
input_mode: TextInputMode::MultiLine {}, input_mode: TextInputMode::MultiLine {},
}), }),
@ -52,16 +55,6 @@ impl Presentation {
_ => None, _ => None,
} }
} }
/// Bet the web component tag name to use for rendering a UI cell.
pub fn cell_webc_tag(&self) -> String {
match self {
Self::Dropdown { .. } => "cell-dropdown".to_owned(),
Self::Text { .. } => "cell-text".to_owned(),
Self::Timestamp { .. } => "cell-timestamp".to_owned(),
Self::Uuid {} => "cell-uuid".to_owned(),
}
}
} }
impl Default for Presentation { impl Default for Presentation {

View file

@ -9,6 +9,7 @@ askama = { version = "0.14.0", features = ["serde_json", "urlencode"] }
async-session = "3.0.0" async-session = "3.0.0"
axum = { version = "0.8.1", features = ["macros", "ws"] } axum = { version = "0.8.1", features = ["macros", "ws"] }
axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] } axum-extra = { version = "0.10.0", features = ["cookie", "form", "typed-header"] }
bigdecimal = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
clap = { version = "4.5.31", features = ["derive"] } clap = { version = "4.5.31", features = ["derive"] }
config = "0.14.1" config = "0.14.1"

View file

@ -29,21 +29,21 @@ pub(crate) struct Navigator {
} }
impl Navigator { impl Navigator {
pub(crate) fn workspace_page(&self) -> WorkspacePageBuilder { pub(crate) fn workspace_page(&'_ self) -> WorkspacePageBuilder<'_> {
WorkspacePageBuilder { WorkspacePageBuilder {
root_path: Some(&self.root_path), root_path: Some(&self.root_path),
..Default::default() ..Default::default()
} }
} }
pub(crate) fn portal_page(&self) -> PortalPageBuilder { pub(crate) fn portal_page(&'_ self) -> PortalPageBuilder<'_> {
PortalPageBuilder { PortalPageBuilder {
root_path: Some(&self.root_path), root_path: Some(&self.root_path),
..Default::default() ..Default::default()
} }
} }
pub(crate) fn form_page(&self, portal_id: Uuid) -> FormPageBuilder { pub(crate) fn form_page(&'_ self, portal_id: Uuid) -> FormPageBuilder<'_> {
FormPageBuilder { FormPageBuilder {
root_path: Some(&self.root_path), root_path: Some(&self.root_path),
portal_id: Some(portal_id), portal_id: Some(portal_id),
@ -52,7 +52,7 @@ impl Navigator {
/// Returns a [`NavigatorPage`] builder for navigating to a relation's /// Returns a [`NavigatorPage`] builder for navigating to a relation's
/// "settings" page. /// "settings" page.
pub(crate) fn rel_page(&self) -> RelPageBuilder { pub(crate) fn rel_page(&'_ self) -> RelPageBuilder<'_> {
RelPageBuilder { RelPageBuilder {
root_path: Some(&self.root_path), root_path: Some(&self.root_path),
..Default::default() ..Default::default()

View file

@ -42,6 +42,7 @@ impl TryFrom<PresentationForm> for Presentation {
.map(|(color, value)| DropdownOption { color, value }) .map(|(color, value)| DropdownOption { color, value })
.collect(), .collect(),
}, },
Presentation::Numeric { .. } => Presentation::Numeric {},
Presentation::Text { .. } => Presentation::Text { Presentation::Text { .. } => Presentation::Text {
input_mode: TextInputMode::try_from(form_value.text_input_mode.as_str())?, input_mode: TextInputMode::try_from(form_value.text_input_mode.as_str())?,
}, },

View file

@ -58,14 +58,6 @@ fn default_host() -> String {
"127.0.0.1".to_owned() "127.0.0.1".to_owned()
} }
fn default_db_role_prefix() -> String {
"__phono__".to_owned()
}
fn default_phono_table_namespace() -> String {
"phono".to_owned()
}
#[derive(Clone, Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub(crate) struct AuthSettings { pub(crate) struct AuthSettings {
pub(crate) client_id: String, pub(crate) client_id: String,

View file

@ -88,6 +88,14 @@ $table-border-color: #ccc;
padding: 0 8px; padding: 0 8px;
} }
&--numeric {
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 8px;
}
&--text { &--text {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View file

@ -184,7 +184,7 @@
<i class="ti ti-cube"></i> <i class="ti ti-cube"></i>
{/if} {/if}
</button> </button>
{#if field_info.field.presentation.t === "Dropdown" || field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"} {#if ["Dropdown", "Numeric", "Text", "Uuid"].includes(field_info.field.presentation.t)}
<input <input
{...interactive_handlers} {...interactive_handlers}
bind:this={text_input_element} bind:this={text_input_element}

View file

@ -1,18 +1,37 @@
import * as uuid from "uuid";
import { z } from "zod"; import { z } from "zod";
import { get_empty_datum_for, Presentation } from "./presentation.svelte.ts";
import {
get_empty_datum_for,
type Presentation,
} from "./presentation.svelte.ts";
type Assert<_T extends true> = void; type Assert<_T extends true> = void;
export const all_datum_tags = [ export const all_datum_tags = [
"Numeric",
"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 _ = Assert< type _DatumTagsAssertionA = Assert<
Datum["t"] extends (typeof all_datum_tags)[number] ? true : false Datum["t"] extends (typeof all_datum_tags)[number] ? true : false
>; >;
type _DatumTagsAssertionB = Assert<
(typeof all_datum_tags)[number] extends Datum["t"] ? true : false
>;
const NUMERIC_REGEX = /^-?[0-9]+(\.[0-9]+)?$/;
const datum_numeric_schema = z.object({
t: z.literal("Numeric"),
// The `bigdecimal` Rust library serializes values to strings by default.
// Doing the same here helps to avoid weird floating point conversion errors
// when displaying.
c: z.string().regex(NUMERIC_REGEX).nullish().transform((x) => x ?? undefined),
});
const datum_text_schema = z.object({ const datum_text_schema = z.object({
t: z.literal("Text"), t: z.literal("Text"),
@ -30,6 +49,7 @@ const datum_uuid_schema = z.object({
}); });
export const datum_schema = z.union([ export const datum_schema = z.union([
datum_numeric_schema,
datum_text_schema, datum_text_schema,
datum_timestamp_schema, datum_timestamp_schema,
datum_uuid_schema, datum_uuid_schema,
@ -37,7 +57,11 @@ export const datum_schema = z.union([
export type Datum = z.infer<typeof datum_schema>; export type Datum = z.infer<typeof datum_schema>;
export function parse_clipboard_value( /**
* Attempt to parse a Datum from a textual representation, for example when
* pasting values from the clipboard. If unsuccessful, returns an empty Datum.
*/
export function parse_datum_from_text(
presentation: Presentation, presentation: Presentation,
input: string, input: string,
): Datum { ): Datum {
@ -50,6 +74,22 @@ export function parse_clipboard_value(
} else { } else {
return get_empty_datum_for(presentation); return get_empty_datum_for(presentation);
} }
} else if (presentation.t === "Numeric") {
// For now, commas and spaces are accepted as thousands-separators, but full
// stops are not. Only full stops are allowed as the decimal separator. This
// is about as flexible as parsing can be with regards to international
// standards from an English-centric perspective, without resorting to
// troublesome heuristics to disambiguate between thousands-separators and
// decimal separators.
const input_cleaned = input.trim().replace(/^\+/, "").replace(
/(?:,|\s+)([0-9]{3})(,|.|\s|$)/g,
"$1$2",
);
console.log(NUMERIC_REGEX.test(input_cleaned));
return {
t: "Numeric",
c: NUMERIC_REGEX.test(input_cleaned) ? input_cleaned : undefined,
};
} else if (presentation.t === "Text") { } else if (presentation.t === "Text") {
return { t: "Text", c: input }; return { t: "Text", c: input };
} else if (presentation.t === "Timestamp") { } else if (presentation.t === "Timestamp") {
@ -57,9 +97,15 @@ export function parse_clipboard_value(
console.warn("parsing timestamps from clipboard is not yet supported"); console.warn("parsing timestamps from clipboard is not yet supported");
return get_empty_datum_for(presentation); return get_empty_datum_for(presentation);
} else if (presentation.t === "Uuid") { } else if (presentation.t === "Uuid") {
// TODO: implement try {
console.warn("parsing uuids from clipboard is not yet supported"); return {
return get_empty_datum_for(presentation); t: "Uuid",
c: uuid.stringify(uuid.parse(input)),
};
} catch {
// uuid.parse() throws a TypeError if unsuccessful.
return get_empty_datum_for(presentation);
}
} }
type _ = Assert<typeof presentation["t"] extends never ? true : false>; type _ = Assert<typeof presentation["t"] extends never ? true : false>;
throw new Error("should be unreachable"); throw new Error("should be unreachable");

View file

@ -1,7 +1,10 @@
import * as uuid from "uuid"; import * as uuid from "uuid";
import { type Datum } from "./datum.svelte.ts"; import { type Datum, parse_datum_from_text } from "./datum.svelte.ts";
import { type Presentation } from "./presentation.svelte.ts"; import {
get_empty_datum_for,
type Presentation,
} from "./presentation.svelte.ts";
type Assert<_T extends true> = void; type Assert<_T extends true> = void;
@ -23,7 +26,7 @@ export const DEFAULT_EDITOR_STATE: EditorState = {
}; };
export function editor_state_from_datum(value: Datum): EditorState { export function editor_state_from_datum(value: Datum): EditorState {
if (value.t === "Text") { if (value.t === "Numeric" || value.t === "Text") {
return { return {
...DEFAULT_EDITOR_STATE, ...DEFAULT_EDITOR_STATE,
text_value: value.c ?? "", text_value: value.c ?? "",
@ -57,43 +60,25 @@ export function datum_from_editor_state(
value: EditorState, value: EditorState,
presentation: Presentation, presentation: Presentation,
): Datum | undefined { ): Datum | undefined {
if (presentation.t === "Dropdown") { if (
presentation.t === "Dropdown" || presentation.t === "Numeric" ||
presentation.t === "Text" || presentation.t === "Uuid"
) {
// Presentations which derive values directly from the editor's text input
// can all be handled with the same logic.
if (value.is_null) { if (value.is_null) {
return { t: "Text", c: undefined }; return get_empty_datum_for(presentation);
} }
if ( const parsed = parse_datum_from_text(presentation, value.text_value);
!presentation.c.allow_custom && if (parsed.c === undefined) {
presentation.c.options.every((option) =>
option.value !== value.text_value
)
) {
return undefined; return undefined;
} }
return { return parsed;
t: "Text",
c: value.text_value,
};
}
if (presentation.t === "Text") {
return { t: "Text", c: value.is_null ? undefined : value.text_value };
} }
if (presentation.t === "Timestamp") { if (presentation.t === "Timestamp") {
// FIXME // FIXME
throw new Error("not yet implemented"); throw new Error("not yet implemented");
} }
if (presentation.t === "Uuid") {
try {
return {
t: "Uuid",
c: value.is_null
? undefined
: uuid.stringify(uuid.parse(value.text_value)),
};
} catch {
// uuid.parse() throws a TypeError if unsuccessful.
return undefined;
}
}
type _ = Assert<typeof presentation extends never ? true : false>; type _ = Assert<typeof presentation extends never ? true : false>;
throw new Error("this should be unreachable"); throw new Error("this should be unreachable");
} }

View file

@ -59,6 +59,9 @@ field. This is typically rendered within a popover component, and within an HTML
if (tag === "Dropdown") { if (tag === "Dropdown") {
return { t: "Dropdown", c: { allow_custom: false, options: [] } }; return { t: "Dropdown", c: { allow_custom: false, options: [] } };
} }
if (tag === "Numeric") {
return { t: "Numeric", c: {} };
}
if (tag === "Text") { if (tag === "Text") {
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } }; return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
} }
@ -153,6 +156,7 @@ field. This is typically rendered within a popover component, and within an HTML
value={option.value} value={option.value}
/> />
<button <button
aria-label="Remove option"
class="button--clear" class="button--clear"
onclick={() => { onclick={() => {
if (presentation?.t !== "Dropdown") { if (presentation?.t !== "Dropdown") {

View file

@ -65,6 +65,8 @@
<span slot="button-contents"> <span slot="button-contents">
{#if field.field.presentation.t === "Dropdown"} {#if field.field.presentation.t === "Dropdown"}
<i class="ti ti-pointer"></i> <i class="ti ti-pointer"></i>
{:else if field.field.presentation.t === "Numeric"}
<i class="ti ti-decimal"></i>
{:else if field.field.presentation.t === "Text"} {:else if field.field.presentation.t === "Text"}
<i class="ti ti-file-text"></i> <i class="ti ti-file-text"></i>
{:else if field.field.presentation.t === "Timestamp"} {:else if field.field.presentation.t === "Timestamp"}

View file

@ -6,6 +6,7 @@ type Assert<_T extends true> = void;
export const all_presentation_tags = [ export const all_presentation_tags = [
"Dropdown", "Dropdown",
"Numeric",
"Text", "Text",
"Timestamp", "Timestamp",
"Uuid", "Uuid",
@ -36,6 +37,11 @@ type _TextInputModesAssertionB = Assert<
: false : false
>; >;
const presentation_numeric_schema = z.object({
t: z.literal("Numeric"),
c: z.object({}),
});
const text_input_mode_schema = z.union([ const text_input_mode_schema = z.union([
z.object({ z.object({
t: z.literal("SingleLine"), t: z.literal("SingleLine"),
@ -91,6 +97,7 @@ 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_dropdown_schema,
presentation_numeric_schema,
presentation_text_schema, presentation_text_schema,
presentation_timestamp_schema, presentation_timestamp_schema,
presentation_uuid_schema, presentation_uuid_schema,
@ -102,6 +109,9 @@ export function get_empty_datum_for(presentation: Presentation): Datum {
if (presentation.t === "Dropdown") { if (presentation.t === "Dropdown") {
return { t: "Text", c: undefined }; return { t: "Text", c: undefined };
} }
if (presentation.t === "Numeric") {
return { t: "Numeric", c: undefined };
}
if (presentation.t === "Text") { if (presentation.t === "Text") {
return { t: "Text", c: undefined }; return { t: "Text", c: undefined };
} }

View file

@ -102,6 +102,17 @@
{:else} {:else}
UNKNOWN UNKNOWN
{/if} {/if}
{:else if field.field.presentation.t === "Numeric"}
<div
class="lens-cell__content lens-cell__content--numeric"
class:lens-cell__content--null={value.c === undefined}
>
{#if value.c === undefined}
<i class={["ti", null_value_class]}></i>
{:else}
{value.c}
{/if}
</div>
{:else if field.field.presentation.t === "Text"} {:else if field.field.presentation.t === "Text"}
<div <div
class="lens-cell__content lens-cell__content--text" class="lens-cell__content lens-cell__content--text"

View file

@ -20,7 +20,7 @@
import { import {
type Datum, type Datum,
datum_schema, datum_schema,
parse_clipboard_value, parse_datum_from_text,
} from "./datum.svelte"; } from "./datum.svelte";
import DatumEditor from "./datum-editor.svelte"; import DatumEditor from "./datum-editor.svelte";
import { type Row, type FieldInfo, field_info_schema } from "./field.svelte"; import { type Row, type FieldInfo, field_info_schema } from "./field.svelte";
@ -455,7 +455,7 @@
const parsed_tsv: Datum[][] = paste_text.split("\n").map((line) => { const parsed_tsv: Datum[][] = paste_text.split("\n").map((line) => {
const raw_values = line.split("\t"); const raw_values = line.split("\t");
return fields.map(({ field: { presentation } }, i) => return fields.map(({ field: { presentation } }, i) =>
parse_clipboard_value(presentation, raw_values[i] ?? ""), parse_datum_from_text(presentation, raw_values[i] ?? ""),
); );
}); });
if (selections.length === 1) { if (selections.length === 1) {