add support for numeric fields
This commit is contained in:
parent
95a4165163
commit
68ca114d5e
19 changed files with 171 additions and 69 deletions
31
Cargo.lock
generated
31
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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())?,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue