svelte
This commit is contained in:
parent
0f3eecceea
commit
0b4b7db0be
46 changed files with 3157 additions and 64 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -1228,11 +1228,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "headers"
|
name = "headers"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9"
|
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"headers-core",
|
"headers-core",
|
||||||
"http 1.3.1",
|
"http 1.3.1",
|
||||||
|
|
@ -1676,6 +1676,7 @@ dependencies = [
|
||||||
"derive_builder",
|
"derive_builder",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
"headers",
|
||||||
"interim-models",
|
"interim-models",
|
||||||
"interim-pgtypes",
|
"interim-pgtypes",
|
||||||
"oauth2",
|
"oauth2",
|
||||||
|
|
|
||||||
34
dev-services/docker-compose.yaml
Normal file
34
dev-services/docker-compose.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
name: interim
|
||||||
|
|
||||||
|
services:
|
||||||
|
pg:
|
||||||
|
image: postgres:17
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: guest
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- "./docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d:ro"
|
||||||
|
- "./pgdata:/var/lib/postgresql/data"
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
depends_on: [pg]
|
||||||
|
restart: always
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: keycloak.dockerfile
|
||||||
|
environment:
|
||||||
|
KC_DB: postgres
|
||||||
|
KC_DB_URL: jdbc:postgresql://pg:5432/keycloak
|
||||||
|
KC_DB_USERNAME: keycloak
|
||||||
|
KC_DB_PASSWORD: guest
|
||||||
|
KC_HOSTNAME: 0.0.0.0
|
||||||
|
KEYCLOAK_ADMIN: admin
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD: guest
|
||||||
|
command: [start, --optimized]
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9000:9000"
|
||||||
|
- "127.0.0.1:8443:8443"
|
||||||
|
|
||||||
3
dev-services/docker-entrypoint-initdb.d/init-app.sql
Normal file
3
dev-services/docker-entrypoint-initdb.d/init-app.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
CREATE USER interim_app WITH ENCRYPTED PASSWORD 'guest';
|
||||||
|
CREATE DATABASE interim_app;
|
||||||
|
ALTER DATABASE interim_app OWNER TO interim_app;
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
CREATE USER keycloak WITH ENCRYPTED PASSWORD 'guest';
|
||||||
|
CREATE DATABASE keycloak;
|
||||||
|
ALTER DATABASE keycloak OWNER TO keycloak;
|
||||||
18
dev-services/keycloak.dockerfile
Normal file
18
dev-services/keycloak.dockerfile
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM quay.io/keycloak/keycloak:26.3.2 as builder
|
||||||
|
|
||||||
|
# Enable health and metrics support
|
||||||
|
ENV KC_HEALTH_ENABLED=true
|
||||||
|
ENV KC_METRICS_ENABLED=true
|
||||||
|
|
||||||
|
# Configure a database vendor
|
||||||
|
ENV KC_DB=postgres
|
||||||
|
|
||||||
|
WORKDIR /opt/keycloak
|
||||||
|
# for demonstration purposes only, please make sure to use proper certificates in production instead
|
||||||
|
RUN keytool -genkeypair -storepass password -storetype PKCS12 -keyalg RSA -keysize 2048 -dname "CN=server" -alias server -ext "SAN:c=DNS:localhost,IP:127.0.0.1" -keystore conf/server.keystore
|
||||||
|
RUN /opt/keycloak/bin/kc.sh build
|
||||||
|
|
||||||
|
FROM quay.io/keycloak/keycloak:26.3.2
|
||||||
|
COPY --from=builder /opt/keycloak/ /opt/keycloak/
|
||||||
|
|
||||||
|
ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
|
||||||
|
|
@ -36,17 +36,21 @@ impl Field {
|
||||||
|
|
||||||
pub fn webc_tag(&self) -> &str {
|
pub fn webc_tag(&self) -> &str {
|
||||||
match self.field_type.0 {
|
match self.field_type.0 {
|
||||||
FieldType::Integer => "cell-integer",
|
FieldType::InterimUser {} => "cell-interim-user",
|
||||||
FieldType::InterimUser => "cell-interim-user",
|
FieldType::Text {} => "cell-text",
|
||||||
FieldType::Text => "cell-text",
|
|
||||||
FieldType::Timestamp { .. } => "cell-timestamp",
|
FieldType::Timestamp { .. } => "cell-timestamp",
|
||||||
FieldType::Uuid => "cell-uuid",
|
FieldType::Uuid { .. } => "cell-uuid",
|
||||||
FieldType::Unknown => "cell-unknown",
|
FieldType::Unknown => "cell-unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn webc_custom_attrs(&self) -> Vec<(String, String)> {
|
pub fn webc_custom_attrs(&self) -> Vec<(String, String)> {
|
||||||
vec![]
|
match self.field_type.clone() {
|
||||||
|
sqlx::types::Json(FieldType::Uuid {
|
||||||
|
default_with_version: Some(_),
|
||||||
|
}) => vec![("has_default".to_owned(), "true".to_owned())],
|
||||||
|
_ => vec![],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
|
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
|
||||||
|
|
@ -56,9 +60,6 @@ impl Field {
|
||||||
let type_info = value_ref.type_info();
|
let type_info = value_ref.type_info();
|
||||||
let ty = type_info.name();
|
let ty = type_info.name();
|
||||||
Ok(match ty {
|
Ok(match ty {
|
||||||
"INT" | "INT4" => {
|
|
||||||
Encodable::Integer(<Option<i32> as Decode<Postgres>>::decode(value_ref).unwrap())
|
|
||||||
}
|
|
||||||
"TEXT" | "VARCHAR" => {
|
"TEXT" | "VARCHAR" => {
|
||||||
Encodable::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
|
Encodable::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
|
||||||
}
|
}
|
||||||
|
|
@ -103,13 +104,14 @@ where lens_id = $1
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(tag = "t", content = "c")]
|
#[serde(tag = "t", content = "c")]
|
||||||
pub enum FieldType {
|
pub enum FieldType {
|
||||||
Integer,
|
InterimUser {},
|
||||||
InterimUser,
|
Text {},
|
||||||
Text,
|
|
||||||
Timestamp {
|
Timestamp {
|
||||||
format: String,
|
format: String,
|
||||||
},
|
},
|
||||||
Uuid,
|
Uuid {
|
||||||
|
default_with_version: Option<String>,
|
||||||
|
},
|
||||||
/// A special variant for when the field type is not specified and cannot be
|
/// A special variant for when the field type is not specified and cannot be
|
||||||
/// inferred. This isn't represented as an error, because we still want to
|
/// inferred. This isn't represented as an error, because we still want to
|
||||||
/// be able to define display behavior via the .render() method.
|
/// be able to define display behavior via the .render() method.
|
||||||
|
|
@ -119,12 +121,13 @@ pub enum FieldType {
|
||||||
impl FieldType {
|
impl FieldType {
|
||||||
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
||||||
match attr.regtype.as_str() {
|
match attr.regtype.as_str() {
|
||||||
"integer" => Self::Integer,
|
"text" => Self::Text {},
|
||||||
"text" => Self::Text,
|
|
||||||
"timestamp" => Self::Timestamp {
|
"timestamp" => Self::Timestamp {
|
||||||
format: RFC_3339_S.to_owned(),
|
format: RFC_3339_S.to_owned(),
|
||||||
},
|
},
|
||||||
"uuid" => Self::Uuid,
|
"uuid" => Self::Uuid {
|
||||||
|
default_with_version: None,
|
||||||
|
},
|
||||||
_ => Self::Unknown,
|
_ => Self::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,14 +137,27 @@ impl FieldType {
|
||||||
/// None if the field type is Unknown.
|
/// None if the field type is Unknown.
|
||||||
pub fn attr_data_type_fragment(&self) -> Option<&'static str> {
|
pub fn attr_data_type_fragment(&self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::Integer => Some("integer"),
|
Self::InterimUser {} | Self::Text {} => Some("text"),
|
||||||
Self::InterimUser | Self::Text => Some("text"),
|
|
||||||
Self::Timestamp { .. } => Some("timestamptz"),
|
Self::Timestamp { .. } => Some("timestamptz"),
|
||||||
Self::Uuid => Some("uuid"),
|
Self::Uuid { .. } => Some("uuid"),
|
||||||
Self::Unknown => None,
|
Self::Unknown => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn default_for_insert(&self) -> Result<Encodable, FieldTypeUnknownError> {
|
||||||
|
match self {
|
||||||
|
Self::InterimUser {} => Ok(Encodable::Text(None)),
|
||||||
|
Self::Text {} => Ok(Encodable::Text(None)),
|
||||||
|
Self::Timestamp { .. } => Ok(Encodable::Timestamp(None)),
|
||||||
|
Self::Uuid { .. } => Ok(Encodable::Uuid(None)),
|
||||||
|
Self::Unknown => Err(FieldTypeUnknownError {}),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error)]
|
||||||
|
#[error("field type is unknown")]
|
||||||
|
pub struct FieldTypeUnknownError {}
|
||||||
|
|
||||||
// -------- Insertable --------
|
// -------- Insertable --------
|
||||||
|
|
||||||
|
|
@ -212,9 +228,8 @@ pub enum ParseError {
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
#[serde(tag = "t", content = "c")]
|
#[serde(tag = "t", content = "c")]
|
||||||
pub enum Encodable {
|
pub enum Encodable {
|
||||||
Integer(Option<i32>),
|
|
||||||
Text(Option<String>),
|
Text(Option<String>),
|
||||||
Timestamptz(Option<DateTime<Utc>>),
|
Timestamp(Option<DateTime<Utc>>),
|
||||||
Uuid(Option<Uuid>),
|
Uuid(Option<Uuid>),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,9 +239,8 @@ impl Encodable {
|
||||||
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::Integer(value) => query.bind(value),
|
|
||||||
Self::Text(value) => query.bind(value),
|
Self::Text(value) => query.bind(value),
|
||||||
Self::Timestamptz(value) => query.bind(value),
|
Self::Timestamp(value) => query.bind(value),
|
||||||
Self::Uuid(value) => query.bind(value),
|
Self::Uuid(value) => query.bind(value),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ config = "0.14.1"
|
||||||
derive_builder = { workspace = true }
|
derive_builder = { workspace = true }
|
||||||
dotenvy = "0.15.7"
|
dotenvy = "0.15.7"
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
|
headers = "0.4.1"
|
||||||
interim-models = { workspace = true }
|
interim-models = { workspace = true }
|
||||||
interim-pgtypes = { workspace = true }
|
interim-pgtypes = { workspace = true }
|
||||||
oauth2 = "4.4.2"
|
oauth2 = "4.4.2"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_session::{Session, SessionStore};
|
use async_session::{Session, SessionStore};
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Router,
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
response::{IntoResponse, Redirect},
|
response::{IntoResponse, Redirect},
|
||||||
routing::get,
|
routing::get,
|
||||||
Router,
|
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use oauth2::{
|
use oauth2::{
|
||||||
basic::BasicClient, reqwest::async_http_client, AuthUrl, AuthorizationCode, ClientId,
|
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, RedirectUrl, RefreshToken,
|
||||||
ClientSecret, CsrfToken, RedirectUrl, RefreshToken, TokenResponse, TokenUrl,
|
TokenResponse, TokenUrl, basic::BasicClient, reqwest::async_http_client,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ async fn start_login(
|
||||||
|
|
||||||
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
|
if session.get::<AuthInfo>(SESSION_KEY_AUTH_INFO).is_some() {
|
||||||
tracing::debug!("already logged in, redirecting...");
|
tracing::debug!("already logged in, redirecting...");
|
||||||
return Ok(Redirect::to(&format!("{}/", root_path)).into_response());
|
return Ok(Redirect::to(&format!("{root_path}/")).into_response());
|
||||||
}
|
}
|
||||||
assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none());
|
assert!(session.get_raw(SESSION_KEY_AUTH_REFRESH_TOKEN).is_none());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,10 @@ pub fn new_router(state: AppState) -> Router<()> {
|
||||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
|
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
|
||||||
get(routes::lenses::lens_page),
|
get(routes::lenses::lens_page),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data",
|
||||||
|
get(routes::lenses::get_data_page_get),
|
||||||
|
)
|
||||||
// .route(
|
// .route(
|
||||||
// "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
|
// "/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
|
||||||
// post(routes::lenses::add_selection_page_post),
|
// post(routes::lenses::add_selection_page_post),
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ use interim_models::{
|
||||||
lens::{Lens, LensDisplayType},
|
lens::{Lens, LensDisplayType},
|
||||||
};
|
};
|
||||||
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
|
use interim_pgtypes::{escape_identifier, pg_attribute::PgAttribute, pg_class::PgClass};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
postgres::{PgRow, types::Oid},
|
postgres::{PgRow, types::Oid},
|
||||||
|
|
@ -205,7 +205,7 @@ pub async fn lens_page(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "lens.html")]
|
#[template(path = "lens0_2.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
fields: Vec<Field>,
|
fields: Vec<Field>,
|
||||||
all_columns: Vec<PgAttribute>,
|
all_columns: Vec<PgAttribute>,
|
||||||
|
|
@ -237,6 +237,94 @@ pub async fn lens_page(
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_data_page_get(
|
||||||
|
State(settings): State<Settings>,
|
||||||
|
State(mut base_pooler): State<BasePooler>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Path(LensPagePath {
|
||||||
|
lens_id,
|
||||||
|
base_id,
|
||||||
|
class_oid,
|
||||||
|
}): Path<LensPagePath>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// FIXME auth
|
||||||
|
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
|
||||||
|
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
|
||||||
|
|
||||||
|
let mut base_client = base_pooler
|
||||||
|
.acquire_for(lens.base_id, RoleAssignment::User(current_user.id))
|
||||||
|
.await?;
|
||||||
|
let rel = PgClass::with_oid(lens.class_oid)
|
||||||
|
.fetch_one(&mut base_client)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let attrs = PgAttribute::all_for_rel(lens.class_oid)
|
||||||
|
.fetch_all(&mut base_client)
|
||||||
|
.await?;
|
||||||
|
let fields = Field::belonging_to_lens(lens.id)
|
||||||
|
.fetch_all(&mut app_db)
|
||||||
|
.await?;
|
||||||
|
let pkey_attrs = PgAttribute::pkeys_for_rel(lens.class_oid)
|
||||||
|
.fetch_all(&mut base_client)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
||||||
|
let rows: Vec<PgRow> = query(&format!(
|
||||||
|
"select {0} from {1}.{2} limit $1",
|
||||||
|
pkey_attrs
|
||||||
|
.iter()
|
||||||
|
.chain(attrs.iter())
|
||||||
|
.map(|attr| escape_identifier(&attr.attname))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
escape_identifier(&rel.regnamespace),
|
||||||
|
escape_identifier(&rel.relname),
|
||||||
|
))
|
||||||
|
.bind(FRONTEND_ROW_LIMIT)
|
||||||
|
.fetch_all(base_client.get_conn())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct DataRow {
|
||||||
|
pkey: String,
|
||||||
|
data: Vec<Encodable>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data: Vec<DataRow> = vec![];
|
||||||
|
let mut pkeys: Vec<String> = vec![];
|
||||||
|
for row in rows.iter() {
|
||||||
|
let mut pkey_values: HashMap<String, Encodable> = HashMap::new();
|
||||||
|
for attr in pkey_attrs.clone() {
|
||||||
|
let field = Field::default_from_attr(&attr);
|
||||||
|
pkey_values.insert(field.name.clone(), field.get_value_encodable(row)?);
|
||||||
|
}
|
||||||
|
let pkey = serde_json::to_string(&pkey_values)?;
|
||||||
|
pkeys.push(pkey.clone());
|
||||||
|
let mut row_data: Vec<Encodable> = vec![];
|
||||||
|
for field in fields.iter() {
|
||||||
|
row_data.push(field.get_value_encodable(row)?);
|
||||||
|
}
|
||||||
|
data.push(DataRow {
|
||||||
|
pkey,
|
||||||
|
data: row_data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ResponseBody {
|
||||||
|
pkeys: Vec<String>,
|
||||||
|
data: Vec<DataRow>,
|
||||||
|
fields: Vec<Field>,
|
||||||
|
}
|
||||||
|
Ok(Json(ResponseBody {
|
||||||
|
fields,
|
||||||
|
pkeys,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct AddColumnPageForm {
|
pub struct AddColumnPageForm {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
<th class="viewer-table__column-header" width="{{ field.width_px }}">
|
<th class="viewer-table__column-header" width="{{ field.width_px }}" scope="col">
|
||||||
{{ field.label.clone().unwrap_or(field.name.clone()) }}
|
{{ field.label.clone().unwrap_or(field.name.clone()) }}
|
||||||
</th>
|
</th>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
@ -52,6 +52,23 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<tr class="viewer-table__insertable-row">
|
||||||
|
{% for (i, field) in fields.iter().enumerate() %}
|
||||||
|
<td class="viewer-table__td viewer-table__td--insertable">
|
||||||
|
<{{ field.webc_tag() | safe }}
|
||||||
|
{% for (k, v) in field.webc_custom_attrs() %}
|
||||||
|
{{ k }}="{{ v }}"
|
||||||
|
{% endfor %}
|
||||||
|
row="{{ pkeys.len() }}"
|
||||||
|
column="{{ i }}"
|
||||||
|
class="cell"
|
||||||
|
insertable="true"
|
||||||
|
value="{{ field.field_type.default_for_insert()? | json }}"
|
||||||
|
>
|
||||||
|
</{{ field.webc_tag() | safe }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<viewer-hoverbar root-path="{{ settings.root_path }}"></viewer-hoverbar>
|
<viewer-hoverbar root-path="{{ settings.root_path }}"></viewer-hoverbar>
|
||||||
|
|
|
||||||
16
interim-server/templates/lens0_2.html
Normal file
16
interim-server/templates/lens0_2.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
|
||||||
|
<div class="page-grid">
|
||||||
|
<div class="page-grid__toolbar"></div>
|
||||||
|
<div class="page-grid__sidebar">
|
||||||
|
{{ navbar | safe }}
|
||||||
|
</div>
|
||||||
|
<main class="page-grid__main">
|
||||||
|
<table-viewer root-path="{{ settings.root_path }}"></table-viewer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="{{ settings.root_path }}/js_dist/TableViewer.js"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
12
mise.toml
12
mise.toml
|
|
@ -8,15 +8,21 @@ rust = { version = "1.88.0", components = "rust-analyzer,clippy" }
|
||||||
watchexec = "latest"
|
watchexec = "latest"
|
||||||
"github:sass/dart-sass" = "1.89.2"
|
"github:sass/dart-sass" = "1.89.2"
|
||||||
|
|
||||||
[tasks.postgres]
|
[tasks.dev-services]
|
||||||
run = "docker run --rm -it -e POSTGRES_PASSWORD=guest -v './pgdata:/var/lib/postgresql/data' -p 127.0.0.1:5432:5432 postgres:17"
|
run = "docker compose up"
|
||||||
|
dir = "./dev-services"
|
||||||
|
|
||||||
[tasks.server]
|
[tasks.server]
|
||||||
run = "cargo run serve"
|
run = "cargo run serve"
|
||||||
description = "Run the server. For development: `mise watch --restart serve`."
|
description = "Run the server. For development: `mise watch --restart serve`."
|
||||||
sources = ["**/*.rs", "**/*.html"]
|
sources = ["**/*.rs", "**/*.html"]
|
||||||
|
|
||||||
[tasks.build-js]
|
[tasks.build-svelte]
|
||||||
|
run = "deno run -A npm:vite build"
|
||||||
|
dir = "./svelte"
|
||||||
|
sources = ["svelte/src/**/*.ts", "svelte/src/**/*.svelte"]
|
||||||
|
|
||||||
|
[tasks.build-gleam]
|
||||||
run = "sh build.sh"
|
run = "sh build.sh"
|
||||||
dir = "./webc"
|
dir = "./webc"
|
||||||
sources = ["webc/src/**/*.gleam", "webc/src/**/*.mjs"]
|
sources = ["webc/src/**/*.gleam", "webc/src/**/*.mjs"]
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ $notice-color-info: #39d;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin rounded-sm {
|
@mixin rounded-sm {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ button, input[type="submit"] {
|
||||||
|
|
||||||
&__main {
|
&__main {
|
||||||
grid-area: main;
|
grid-area: main;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
129
sass/viewer.scss
129
sass/viewer.scss
|
|
@ -1,34 +1,135 @@
|
||||||
.viewer-table {
|
@use 'globals';
|
||||||
border-collapse: collapse;
|
@use 'sass:color';
|
||||||
height: 1px; // css hack to make percentage based cell heights work
|
|
||||||
|
|
||||||
&__column-header {
|
$table-border-color: #ccc;
|
||||||
|
|
||||||
|
.lens-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template:
|
||||||
|
'table' 1fr
|
||||||
|
'editor' max-content;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lens-table {
|
||||||
|
display: grid;
|
||||||
|
grid-area: table;
|
||||||
|
grid-template:
|
||||||
|
'headers' max-content
|
||||||
|
'main' 1fr
|
||||||
|
'inserter' max-content;
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&__headers {
|
||||||
|
display: flex;
|
||||||
|
grid-area: headers;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
border: solid 1px #ccc;
|
border: solid 1px #ccc;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
|
border-left: none;
|
||||||
font-family: "Funnel Sans";
|
font-family: "Funnel Sans";
|
||||||
background: #0001;
|
background: #0001;
|
||||||
height: 100%; // css hack to make percentage based cell heights work
|
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions-header {
|
&__header-actions {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__td {
|
&__main {
|
||||||
border: solid 1px #ccc;
|
grid-area: main;
|
||||||
height: 100%; // css hack to make percentage based cell heights work
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cell {
|
||||||
|
flex: none;
|
||||||
|
border: solid 1px $table-border-color;
|
||||||
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
&:first-child {
|
&--insertable {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
.lens-table__cell-content {
|
||||||
|
outline: 3px solid #37f;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cell-content {
|
||||||
|
font-family: globals.$font-family-data;
|
||||||
|
|
||||||
|
&--null {
|
||||||
|
color: color.scale(#000, $lightness: 50%, $space: hsl);
|
||||||
|
font-style: oblique;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--uuid {
|
||||||
|
font-family: globals.$font-family-mono;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__inserter {
|
||||||
|
grid-area: inserter;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.lens-table__cell {
|
||||||
|
border: dashed 1px $table-border-color;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lens-table__row:first-child .lens-table__cell {
|
||||||
|
border-top: dashed 1px $table-border-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lens-editor {
|
||||||
|
align-items: stretch;
|
||||||
|
border-top: globals.$default-border;
|
||||||
|
display: flex;
|
||||||
|
grid-area: editor;
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
@include globals.reset_input;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-family: globals.$font-family-data;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
24
svelte/.gitignore
vendored
Normal file
24
svelte/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
1
svelte/.npmrc
Normal file
1
svelte/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@jsr:registry=https://npm.jsr.io
|
||||||
47
svelte/README.md
Normal file
47
svelte/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Svelte + TS + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Svelte and TypeScript in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|
||||||
|
## Need an official Svelte framework?
|
||||||
|
|
||||||
|
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
|
||||||
|
|
||||||
|
## Technical considerations
|
||||||
|
|
||||||
|
**Why use this over SvelteKit?**
|
||||||
|
|
||||||
|
- It brings its own routing solution which might not be preferable for some users.
|
||||||
|
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
|
||||||
|
|
||||||
|
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
|
||||||
|
|
||||||
|
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
|
||||||
|
|
||||||
|
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
|
||||||
|
|
||||||
|
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
|
||||||
|
|
||||||
|
**Why include `.vscode/extensions.json`?**
|
||||||
|
|
||||||
|
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
|
||||||
|
|
||||||
|
**Why enable `allowJs` in the TS template?**
|
||||||
|
|
||||||
|
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
|
||||||
|
|
||||||
|
**Why is HMR not preserving my local component state?**
|
||||||
|
|
||||||
|
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
|
||||||
|
|
||||||
|
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// store.ts
|
||||||
|
// An extremely simple external store
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
export default writable(0)
|
||||||
|
```
|
||||||
11
svelte/deno.json
Normal file
11
svelte/deno.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --watch main.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@std/assert": "jsr:@std/assert@1",
|
||||||
|
"@std/path": "jsr:@std/path@^1.1.1",
|
||||||
|
"@std/uuid": "jsr:@std/uuid@^1.0.9",
|
||||||
|
},
|
||||||
|
"unstable": ["fmt-component"]
|
||||||
|
}
|
||||||
1012
svelte/deno.lock
generated
Normal file
1012
svelte/deno.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
13
svelte/index.html
Normal file
13
svelte/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + Svelte + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
svelte/main.ts
Normal file
8
svelte/main.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export function add(a: number, b: number): number {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Learn more at https://docs.deno.com/runtime/manual/examples/module_metadata#concepts
|
||||||
|
if (import.meta.main) {
|
||||||
|
console.log("Add 2 + 3 =", add(2, 3));
|
||||||
|
}
|
||||||
6
svelte/main_test.ts
Normal file
6
svelte/main_test.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { add } from "./main.ts";
|
||||||
|
|
||||||
|
Deno.test(function addTest() {
|
||||||
|
assertEquals(add(2, 3), 5);
|
||||||
|
});
|
||||||
27
svelte/package.json
Normal file
27
svelte/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"name": "vite-project",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@deno/vite-plugin": "^1.0.5",
|
||||||
|
"@jsr/std__uuid": "^1.0.9",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.1.1",
|
||||||
|
"svelte-language-server": "^0.17.19",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"vite": "^7.1.1",
|
||||||
|
"zod": "^4.0.17"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
|
"svelte": "^5.37.3",
|
||||||
|
"svelte-check": "^4.3.1",
|
||||||
|
"typescript": "~5.8.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
svelte/public/vite.svg
Normal file
1
svelte/public/vite.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
47
svelte/src/App.svelte
Normal file
47
svelte/src/App.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import svelteLogo from './assets/svelte.svg'
|
||||||
|
import viteLogo from '/vite.svg'
|
||||||
|
import Counter from './lib/Counter.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div>
|
||||||
|
<a href="https://vite.dev" target="_blank" rel="noreferrer">
|
||||||
|
<img src={viteLogo} class="logo" alt="Vite Logo" />
|
||||||
|
</a>
|
||||||
|
<a href="https://svelte.dev" target="_blank" rel="noreferrer">
|
||||||
|
<img src={svelteLogo} class="logo svelte" alt="Svelte Logo" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h1>Vite + Svelte</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<Counter />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out <a href="https://github.com/sveltejs/kit#readme" target="_blank" rel="noreferrer">SvelteKit</a>, the official Svelte app framework powered by Vite!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="read-the-docs">
|
||||||
|
Click on the Vite and Svelte logos to learn more
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.svelte:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #ff3e00aa);
|
||||||
|
}
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
533
svelte/src/TableViewer.svelte
Normal file
533
svelte/src/TableViewer.svelte
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
<svelte:options customElement={{ tag: "table-viewer", shadow: "none" }} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import * as uuid from "uuid";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Coords,
|
||||||
|
type Encodable,
|
||||||
|
type Field,
|
||||||
|
type Row,
|
||||||
|
type FieldType,
|
||||||
|
coords_eq,
|
||||||
|
} from "./field.svelte";
|
||||||
|
|
||||||
|
type CommittedChange = {
|
||||||
|
coords_initial: Coords;
|
||||||
|
// This will be identical to coords_initial, unless the change altered a
|
||||||
|
// primary key.
|
||||||
|
coords_updated: Coords;
|
||||||
|
value_initial: Encodable;
|
||||||
|
value_updated: Encodable;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LazyData = {
|
||||||
|
rows: Row[];
|
||||||
|
fields: Field[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Selection = {
|
||||||
|
region: "main" | "inserter";
|
||||||
|
coords: Coords;
|
||||||
|
original_value: Encodable;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedPkey = Record<string, Encodable>;
|
||||||
|
|
||||||
|
let selections = $state<Selection[]>([]);
|
||||||
|
let editing = $state(false);
|
||||||
|
let editor_input_value = $state("");
|
||||||
|
let committed_changes = $state<CommittedChange[][]>([]);
|
||||||
|
let reverted_changes = $state<CommittedChange[][]>([]);
|
||||||
|
let editor_input_element = $state<HTMLInputElement | undefined>();
|
||||||
|
let table_element = $state<HTMLDivElement | undefined>();
|
||||||
|
let inserter_rows = $state<Row[]>([]);
|
||||||
|
let lazy_data = $state<LazyData | undefined>();
|
||||||
|
|
||||||
|
// -------- Helper Functions -------- //
|
||||||
|
|
||||||
|
function arrow_key_direction(
|
||||||
|
key: string,
|
||||||
|
): "Down" | "Left" | "Right" | "Up" | undefined {
|
||||||
|
if (key === "ArrowDown") {
|
||||||
|
return "Down";
|
||||||
|
} else if (key === "ArrowLeft") {
|
||||||
|
return "Left";
|
||||||
|
} else if (key === "ArrowRight") {
|
||||||
|
return "Right";
|
||||||
|
} else if (key === "ArrowUp") {
|
||||||
|
return "Up";
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function try_parse_editor_value(
|
||||||
|
field_type: FieldType,
|
||||||
|
): Encodable | undefined {
|
||||||
|
if (field_type.t === "Text") {
|
||||||
|
return {
|
||||||
|
t: "Text",
|
||||||
|
c: editor_input_value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (field_type.t === "Uuid") {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
t: "Uuid",
|
||||||
|
c: uuid.stringify(uuid.parse(editor_input_value)),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// uuid.parse() throws a TypeError if unsuccessful.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unknown field type");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Updates and Effects -------- //
|
||||||
|
|
||||||
|
function set_selections(arr: Omit<Selection, "original_value">[]) {
|
||||||
|
selections = arr.map((sel) => {
|
||||||
|
let cell_data: Encodable | undefined;
|
||||||
|
if (sel.region === "main") {
|
||||||
|
cell_data = lazy_data?.rows[sel.coords[0]].data[sel.coords[1]];
|
||||||
|
} else if (sel.region === "inserter") {
|
||||||
|
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
|
||||||
|
} else {
|
||||||
|
throw new Error("invalid region");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...sel,
|
||||||
|
original_value: cell_data!,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (arr.length === 1) {
|
||||||
|
const [sel] = arr;
|
||||||
|
let cell_data: Encodable | undefined;
|
||||||
|
if (sel.region === "main") {
|
||||||
|
cell_data = lazy_data?.rows[sel.coords[0]].data[sel.coords[1]];
|
||||||
|
} else if (sel.region === "inserter") {
|
||||||
|
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
|
||||||
|
}
|
||||||
|
if (cell_data?.t === "Text" || cell_data?.t === "Uuid") {
|
||||||
|
editor_input_value = cell_data.c ?? "";
|
||||||
|
} else {
|
||||||
|
editor_input_value = "";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editor_input_value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function try_move_selection(direction: "Down" | "Left" | "Right" | "Up") {
|
||||||
|
if (lazy_data && !editing && selections.length > 0) {
|
||||||
|
const last_selection = selections[selections.length - 1];
|
||||||
|
if (
|
||||||
|
direction === "Right" &&
|
||||||
|
last_selection.coords[1] < lazy_data.fields.length - 1
|
||||||
|
) {
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: last_selection.region,
|
||||||
|
coords: [last_selection.coords[0], last_selection.coords[1] + 1],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else if (direction === "Left" && last_selection.coords[1] > 0) {
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: last_selection.region,
|
||||||
|
coords: [last_selection.coords[0], last_selection.coords[1] - 1],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else if (direction === "Down") {
|
||||||
|
if (last_selection.region === "main") {
|
||||||
|
if (last_selection.coords[0] < lazy_data.rows.length - 1) {
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: "main",
|
||||||
|
coords: [
|
||||||
|
last_selection.coords[0] + 1,
|
||||||
|
last_selection.coords[1],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// At bottom of main table.
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: "inserter",
|
||||||
|
coords: [0, last_selection.coords[1]],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if (last_selection.region === "inserter") {
|
||||||
|
if (last_selection.coords[0] < inserter_rows.length - 1) {
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: "inserter",
|
||||||
|
coords: [
|
||||||
|
last_selection.coords[0] + 1,
|
||||||
|
last_selection.coords[1],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (direction === "Up") {
|
||||||
|
if (last_selection.region === "main") {
|
||||||
|
if (last_selection.coords[0] > 0) {
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: "main",
|
||||||
|
coords: [
|
||||||
|
last_selection.coords[0] - 1,
|
||||||
|
last_selection.coords[1],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if (last_selection.region === "inserter") {
|
||||||
|
if (last_selection.coords[0] > 0) {
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: "inserter",
|
||||||
|
coords: [
|
||||||
|
last_selection.coords[0] - 1,
|
||||||
|
last_selection.coords[1],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// At top of inserter table.
|
||||||
|
set_selections([
|
||||||
|
{
|
||||||
|
region: "main",
|
||||||
|
coords: [lazy_data.rows.length - 1, last_selection.coords[1]],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function try_sync_edit_to_cells() {
|
||||||
|
if (lazy_data && editing && selections.length === 1) {
|
||||||
|
const [sel] = selections;
|
||||||
|
const parsed = try_parse_editor_value(
|
||||||
|
lazy_data.fields[sel.coords[1]].field_type,
|
||||||
|
);
|
||||||
|
if (parsed !== undefined) {
|
||||||
|
if (sel.region === "main") {
|
||||||
|
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = parsed;
|
||||||
|
} else if (sel.region === "inserter") {
|
||||||
|
inserter_rows[sel.coords[0]].data[sel.coords[1]] = parsed;
|
||||||
|
} else {
|
||||||
|
throw new Error("Unknown region");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function try_start_edit() {
|
||||||
|
if (!editing) {
|
||||||
|
editing = true;
|
||||||
|
editor_input_element?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function try_commit_edit() {
|
||||||
|
(async function () {
|
||||||
|
if (lazy_data && editing && selections.length === 1) {
|
||||||
|
const [sel] = selections;
|
||||||
|
const field = lazy_data.fields[sel.coords[1]];
|
||||||
|
const parsed = try_parse_editor_value(field.field_type);
|
||||||
|
if (parsed !== undefined) {
|
||||||
|
if (sel.region === "main") {
|
||||||
|
const pkey = JSON.parse(
|
||||||
|
lazy_data.rows[sel.coords[0]].key as string,
|
||||||
|
) as ParsedPkey;
|
||||||
|
const resp = await fetch("update-value", {
|
||||||
|
method: "post",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
column: field.name,
|
||||||
|
pkeys: pkey,
|
||||||
|
value: parsed,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.status >= 200 && resp.status < 300) {
|
||||||
|
committed_changes.push([
|
||||||
|
{
|
||||||
|
coords_initial: sel.coords,
|
||||||
|
coords_updated: sel.coords, // TODO: this assumes no inserted/deleted rows
|
||||||
|
value_initial: sel.original_value,
|
||||||
|
value_updated: parsed,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
editing = false;
|
||||||
|
selections = [{ ...sel, original_value: parsed }];
|
||||||
|
table_element?.focus();
|
||||||
|
} else {
|
||||||
|
// TODO display feedback to user
|
||||||
|
console.error(resp);
|
||||||
|
console.error(await resp.text());
|
||||||
|
}
|
||||||
|
} else if (sel.region === "inserter") {
|
||||||
|
table_element?.focus();
|
||||||
|
editing = false;
|
||||||
|
selections = [{ ...sel, original_value: parsed }];
|
||||||
|
} else {
|
||||||
|
throw new Error("Unknown region");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel_edit() {
|
||||||
|
selections.forEach(({ coords, original_value, region }) => {
|
||||||
|
if (region === "main") {
|
||||||
|
if (lazy_data) {
|
||||||
|
lazy_data.rows[coords[0]].data[coords[1]] = original_value;
|
||||||
|
}
|
||||||
|
} else if (region === "inserter") {
|
||||||
|
inserter_rows[coords[0]].data[coords[1]] = original_value;
|
||||||
|
} else {
|
||||||
|
throw new Error("Unknown region");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Reset editor input value
|
||||||
|
set_selections(selections);
|
||||||
|
editing = false;
|
||||||
|
table_element?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Event Handlers: Both Tables -------- //
|
||||||
|
|
||||||
|
function handle_table_keydown(ev: KeyboardEvent) {
|
||||||
|
const arrow_direction = arrow_key_direction(ev.key);
|
||||||
|
if (arrow_direction) {
|
||||||
|
try_move_selection(arrow_direction);
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
try_start_edit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_table_cell_dblclick(_: Coords) {
|
||||||
|
try_start_edit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_table_focus() {
|
||||||
|
if (selections.length === 0 && (lazy_data?.rows[0]?.data.length ?? 0) > 0) {
|
||||||
|
set_selections([{ region: "main", coords: [0, 0] }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Event Handlers: Main Table -------- //
|
||||||
|
|
||||||
|
function handle_main_cell_click(ev: MouseEvent, coords: Coords) {
|
||||||
|
if (!editing) {
|
||||||
|
if (ev.metaKey || ev.ctrlKey) {
|
||||||
|
// TODO
|
||||||
|
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||||
|
// editor_input_value = "";
|
||||||
|
} else {
|
||||||
|
set_selections([{ region: "main", coords }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Event Handlers: Inserter Table -------- //
|
||||||
|
|
||||||
|
function handle_inserter_cell_click(ev: MouseEvent, coords: Coords) {
|
||||||
|
if (!editing) {
|
||||||
|
if (ev.metaKey || ev.ctrlKey) {
|
||||||
|
// TODO
|
||||||
|
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||||
|
// editor_input_value = "";
|
||||||
|
} else {
|
||||||
|
set_selections([{ region: "inserter", coords }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Event Handlers: Editor -------- //
|
||||||
|
|
||||||
|
function handle_editor_blur() {
|
||||||
|
try_commit_edit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_editor_focus() {
|
||||||
|
try_start_edit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_editor_input() {
|
||||||
|
try_sync_edit_to_cells();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_editor_keydown(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
try_commit_edit();
|
||||||
|
} else if (ev.key === "Escape") {
|
||||||
|
cancel_edit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Initial API Fetch -------- //
|
||||||
|
|
||||||
|
(async function () {
|
||||||
|
interface GetDataResponse {
|
||||||
|
data: {
|
||||||
|
pkey: string;
|
||||||
|
data: Encodable[];
|
||||||
|
}[];
|
||||||
|
fields: Field[];
|
||||||
|
}
|
||||||
|
const resp = await fetch("get-data");
|
||||||
|
const body: GetDataResponse = await resp.json();
|
||||||
|
lazy_data = {
|
||||||
|
fields: body.fields,
|
||||||
|
rows: body.data.map(({ data, pkey }) => ({ data, key: pkey })),
|
||||||
|
};
|
||||||
|
inserter_rows = [
|
||||||
|
{
|
||||||
|
key: 0,
|
||||||
|
data: body.fields.map(() => ({ t: "Text", c: undefined })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
})().catch(console.error);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet table_region({
|
||||||
|
region_name,
|
||||||
|
rows,
|
||||||
|
on_cell_click,
|
||||||
|
}: {
|
||||||
|
region_name: string;
|
||||||
|
rows: Row[];
|
||||||
|
on_cell_click(ev: MouseEvent, coords: Coords): void;
|
||||||
|
})}
|
||||||
|
{#if lazy_data}
|
||||||
|
{#each rows as row, row_index}
|
||||||
|
<div class="lens-table__row" role="row">
|
||||||
|
{#each lazy_data.fields as field, field_index}
|
||||||
|
{@const cell_data = row.data[field_index]}
|
||||||
|
{@const cell_coords: Coords = [row_index, field_index]}
|
||||||
|
{@const cell_selected = selections.some(
|
||||||
|
(sel) =>
|
||||||
|
sel.region === region_name && coords_eq(sel.coords, cell_coords),
|
||||||
|
)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
aria-colindex={field_index}
|
||||||
|
aria-rowindex={row_index}
|
||||||
|
aria-selected={cell_selected}
|
||||||
|
class={[
|
||||||
|
"lens-table__cell",
|
||||||
|
cell_selected && "lens-table__cell--selected",
|
||||||
|
]}
|
||||||
|
onmousedown={(ev) => on_cell_click(ev, cell_coords)}
|
||||||
|
ondblclick={() => handle_table_cell_dblclick(cell_coords)}
|
||||||
|
role="gridcell"
|
||||||
|
style:width={`${field.width_px}px`}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if cell_data.t === "Text"}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"lens-table__cell-content",
|
||||||
|
"lens-table__cell-content--text",
|
||||||
|
(cell_data.c ?? undefined) === undefined &&
|
||||||
|
"lens-table__cell-content--null",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cell_data.c ?? "Null"}
|
||||||
|
</div>
|
||||||
|
{:else if cell_data.t === "Uuid"}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"lens-table__cell-content",
|
||||||
|
"lens-table__cell-content--uuid",
|
||||||
|
(cell_data.c ?? undefined) === undefined &&
|
||||||
|
"lens-table__cell-content--null",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cell_data.c ?? "Null"}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"lens-table__cell-content",
|
||||||
|
"lens-table__cell-content--unknown",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
UNKNOWN
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="lens-grid">
|
||||||
|
{#if lazy_data}
|
||||||
|
<div
|
||||||
|
bind:this={table_element}
|
||||||
|
class="lens-table"
|
||||||
|
onfocus={handle_table_focus}
|
||||||
|
onkeydown={handle_table_keydown}
|
||||||
|
role="grid"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class={["lens-table__headers"]}>
|
||||||
|
{#each lazy_data.fields as field, field_index}
|
||||||
|
<div
|
||||||
|
aria-colindex={field_index}
|
||||||
|
class="lens-table__header"
|
||||||
|
role="columnheader"
|
||||||
|
style:width={`${field.width_px}px`}
|
||||||
|
>
|
||||||
|
<div>{field.label ?? field.name}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="lens-table__header-actions">TODO</div>
|
||||||
|
</div>
|
||||||
|
<div class="lens-table__main">
|
||||||
|
{@render table_region({
|
||||||
|
region_name: "main",
|
||||||
|
rows: lazy_data.rows,
|
||||||
|
on_cell_click: handle_main_cell_click,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div class="lens-table__inserter">
|
||||||
|
{@render table_region({
|
||||||
|
region_name: "inserter",
|
||||||
|
rows: inserter_rows,
|
||||||
|
on_cell_click: handle_inserter_cell_click,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lens-editor">
|
||||||
|
<input
|
||||||
|
bind:this={editor_input_element}
|
||||||
|
bind:value={editor_input_value}
|
||||||
|
class={[
|
||||||
|
"lens-editor__input",
|
||||||
|
selections.length !== 1 && "lens-editor__input--hidden",
|
||||||
|
]}
|
||||||
|
onblur={handle_editor_blur}
|
||||||
|
onfocus={handle_editor_focus}
|
||||||
|
oninput={handle_editor_input}
|
||||||
|
onkeydown={handle_editor_keydown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
79
svelte/src/app.css
Normal file
79
svelte/src/app.css
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
svelte/src/assets/svelte.svg
Normal file
1
svelte/src/assets/svelte.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
75
svelte/src/field.svelte.ts
Normal file
75
svelte/src/field.svelte.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// -------- Encodable -------- //
|
||||||
|
|
||||||
|
const encodable_text_schema = z.object({
|
||||||
|
t: z.literal("Text"),
|
||||||
|
c: z.string().nullish().transform((x) => x ?? undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodable_timestamp_schema = z.object({
|
||||||
|
t: z.literal("Timestamp"),
|
||||||
|
c: z.coerce.date().nullish().transform((x) => x ?? undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodable_uuid_schema = z.object({
|
||||||
|
t: z.literal("Uuid"),
|
||||||
|
c: z.string().nullish().transform((x) => x ?? undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
const encodable_schema = z.union([
|
||||||
|
encodable_text_schema,
|
||||||
|
encodable_timestamp_schema,
|
||||||
|
encodable_uuid_schema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type Encodable = z.infer<typeof encodable_schema>;
|
||||||
|
|
||||||
|
// -------- FieldType -------- //
|
||||||
|
|
||||||
|
const integer_field_type_schema = z.object({
|
||||||
|
t: z.literal("Integer"),
|
||||||
|
c: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text_field_type_schema = z.object({
|
||||||
|
t: "Text",
|
||||||
|
c: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uuid_field_type_schema = z.object({
|
||||||
|
t: "Uuid",
|
||||||
|
c: z.unknown(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const field_type_schema = z.union([
|
||||||
|
integer_field_type_schema,
|
||||||
|
text_field_type_schema,
|
||||||
|
uuid_field_type_schema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type FieldType = z.infer<typeof field_type_schema>;
|
||||||
|
|
||||||
|
// -------- Field -------- //
|
||||||
|
|
||||||
|
export type Field = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
field_type: FieldType;
|
||||||
|
width_px: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------- Table Utils -------- //
|
||||||
|
// TODO move this to its own module
|
||||||
|
|
||||||
|
export type Coords = [number, number];
|
||||||
|
|
||||||
|
export type Row = {
|
||||||
|
key: string | number;
|
||||||
|
data: Encodable[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function coords_eq(a: Coords, b: Coords): boolean {
|
||||||
|
return a[0] === b[0] && a[1] === b[1];
|
||||||
|
}
|
||||||
122
svelte/src/interactive-table.svelte
Normal file
122
svelte/src/interactive-table.svelte
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
type Coords,
|
||||||
|
type Field,
|
||||||
|
type Row,
|
||||||
|
coords_eq,
|
||||||
|
} from "./field.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fields: Field[];
|
||||||
|
header_classes?: (string | boolean | undefined)[];
|
||||||
|
on_cell_click?(ev: MouseEvent, coords: Coords): void;
|
||||||
|
on_cell_dblclick?(coords: Coords): void;
|
||||||
|
on_focus?(): void;
|
||||||
|
on_keydown?(ev: KeyboardEvent): void;
|
||||||
|
rows: Row[];
|
||||||
|
root_element?: HTMLDivElement;
|
||||||
|
selections: Coords[];
|
||||||
|
show_headers?: boolean;
|
||||||
|
tab_index?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
fields,
|
||||||
|
header_classes = [],
|
||||||
|
on_cell_click,
|
||||||
|
on_cell_dblclick,
|
||||||
|
on_focus,
|
||||||
|
on_keydown,
|
||||||
|
root_element = $bindable(),
|
||||||
|
rows,
|
||||||
|
selections,
|
||||||
|
show_headers = true,
|
||||||
|
tab_index = 0,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={root_element}
|
||||||
|
class="viewer-table"
|
||||||
|
onfocus={on_focus}
|
||||||
|
onkeydown={on_keydown}
|
||||||
|
role="grid"
|
||||||
|
tabindex={tab_index}
|
||||||
|
>
|
||||||
|
{#if show_headers}
|
||||||
|
<div class={["viewer-table__headers", ...header_classes]}>
|
||||||
|
{#each fields as field, field_index}
|
||||||
|
<div
|
||||||
|
aria-colindex={field_index}
|
||||||
|
class="viewer-header"
|
||||||
|
role="columnheader"
|
||||||
|
style:width={`${field.width_px}px`}
|
||||||
|
>
|
||||||
|
<div>{field.label ?? field.name}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="viewer-header-actions">
|
||||||
|
TODO
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="viewer-table__body">
|
||||||
|
{#each rows as row, row_index}
|
||||||
|
<div aria-rowindex={row_index} class="viewer-row" role="row">
|
||||||
|
{#each fields as field, field_index}
|
||||||
|
{@const cell_data = row.data[field_index]}
|
||||||
|
{@const cell_coords: Coords = [row_index, field_index]}
|
||||||
|
{@const cell_selected = selections.some(
|
||||||
|
(sel_coords) => coords_eq(sel_coords, cell_coords),
|
||||||
|
)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
aria-colindex={field_index}
|
||||||
|
aria-rowindex={row_index}
|
||||||
|
aria-selected={cell_selected}
|
||||||
|
class={[
|
||||||
|
"viewer-cell",
|
||||||
|
cell_selected && "viewer-cell--selected",
|
||||||
|
]}
|
||||||
|
onclick={(ev) => on_cell_click?.(ev, cell_coords)}
|
||||||
|
ondblclick={() => on_cell_dblclick?.(cell_coords)}
|
||||||
|
role="gridcell"
|
||||||
|
style:width={`${field.width_px}px`}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if cell_data.t === "Text"}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"viewer-cell__content",
|
||||||
|
"viewer-cell__content--text",
|
||||||
|
cell_data.c === null && "viewer-cell__content--null",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cell_data.c}
|
||||||
|
</div>
|
||||||
|
{:else if cell_data.t === "Uuid"}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"viewer-cell__content",
|
||||||
|
"viewer-cell__content--uuid",
|
||||||
|
cell_data.c === null && "viewer-cell__content--null",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cell_data.c}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"viewer-cell__content",
|
||||||
|
"viewer-cell__content--unknown",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
UNKNOWN
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
10
svelte/src/lib/Counter.svelte
Normal file
10
svelte/src/lib/Counter.svelte
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<script lang="ts">
|
||||||
|
let count: number = $state(0)
|
||||||
|
const increment = () => {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick={increment}>
|
||||||
|
count is {count}
|
||||||
|
</button>
|
||||||
9
svelte/src/main.ts
Normal file
9
svelte/src/main.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { mount } from 'svelte'
|
||||||
|
import './app.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = mount(App, {
|
||||||
|
target: document.getElementById('app')!,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
168
svelte/src/table-viewer/loaded.svelte
Normal file
168
svelte/src/table-viewer/loaded.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
<svelte:options customElement={{ tag: "table-viewer", shadow: "none" }} />
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { type Encodable, type Field } from "./field.svelte";
|
||||||
|
|
||||||
|
type Coords = [string, string];
|
||||||
|
interface CommittedChange {
|
||||||
|
coords_initial: Coords;
|
||||||
|
// This will be identical to coords_initial, unless the change altered a
|
||||||
|
// primary key.
|
||||||
|
coords_updated: Coords;
|
||||||
|
value_initial: Encodable;
|
||||||
|
value_updated: Encodable;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selections = $state<Coords[]>([]);
|
||||||
|
let table_focused = $state(false);
|
||||||
|
let editing = $state(false);
|
||||||
|
let editor_input_value = $state("");
|
||||||
|
let committed_changes = $state<CommittedChange[][]>([]);
|
||||||
|
let reverted_changes = $state<CommittedChange[][]>([]);
|
||||||
|
let editor_input_element = $state<HTMLInputElement | undefined>();
|
||||||
|
|
||||||
|
interface GetDataResult {
|
||||||
|
pkeys: string[];
|
||||||
|
data: Record<string, Record<string, Encodable>>;
|
||||||
|
fields: Record<string, Field>;
|
||||||
|
field_names: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function coords_eq(a: Coords, b: Coords): boolean {
|
||||||
|
return a[0] === b[0] && a[1] === b[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_click(ev: MouseEvent, coords: Coords, cell_data: Encodable) {
|
||||||
|
if (!editing) {
|
||||||
|
if (ev.metaKey || ev.ctrlKey) {
|
||||||
|
// TODO
|
||||||
|
// selections = [...selections.filter((prev) => !coords_eq(prev, coords)), coords];
|
||||||
|
// editor_input_value = "";
|
||||||
|
} else {
|
||||||
|
selections = [coords];
|
||||||
|
if (cell_data.t === "Text" || cell_data.t === "Uuid") {
|
||||||
|
editor_input_value = cell_data.c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_dblclick(_coords: Coords) {
|
||||||
|
if (!editing) {
|
||||||
|
editing = true;
|
||||||
|
editor_input_element?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_table_focusin() {
|
||||||
|
table_focused = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_table_focusout() {
|
||||||
|
table_focused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handle_keydown(ev: KeyboardEvent) {
|
||||||
|
if (table_focused && ev.key === "ArrowDown") {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
document.addEventListener("keydown", handle_keydown);
|
||||||
|
return () => document.removeEventListener("keydown", handle_keydown);
|
||||||
|
});
|
||||||
|
|
||||||
|
const data_promise: Promise<GetDataResult> = fetch("get-data")
|
||||||
|
.then((resp) => resp.json())
|
||||||
|
.then((body) => ({
|
||||||
|
...body,
|
||||||
|
data: Object.fromEntries(body.data.map(({ pkey, data }) => [
|
||||||
|
pkey,
|
||||||
|
Object.fromEntries(body.fields.map(({ name }, i) => [name, data[i]])),
|
||||||
|
])),
|
||||||
|
field_names: body.fields.map(({ name }) => name),
|
||||||
|
fields: Object.fromEntries(body.fields.map((field) => [field.name, field])),
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#await data_promise}
|
||||||
|
<div class="loading-indicator">Loading...</div>
|
||||||
|
{:then data}
|
||||||
|
<div
|
||||||
|
class="viewer-table"
|
||||||
|
onfocusin={handle_table_focusin}
|
||||||
|
onfocusout={handle_table_focusout}
|
||||||
|
role="grid"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="viewer-table__headers">
|
||||||
|
{#each data.field_names as field_name, field_index}
|
||||||
|
<div
|
||||||
|
aria-colindex={field_index}
|
||||||
|
class="viewer-header"
|
||||||
|
role="columnheader"
|
||||||
|
style:width={`${data.fields[field_name].width_px}px`}
|
||||||
|
>
|
||||||
|
<div>{data.fields[field_name].label ?? field_name}</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="viewer-header-actions">
|
||||||
|
TODO
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="viewer-table__body">
|
||||||
|
{#each data.pkeys as pkey, row_index}
|
||||||
|
<div aria-rowindex={row_index} class="viewer-row" role="row">
|
||||||
|
{#each data.field_names as field_name, field_index}
|
||||||
|
{@const cell_data = data.data[pkey][field_name]}
|
||||||
|
{@const selected = selections.some(
|
||||||
|
([sel_pkey, sel_field]) => sel_pkey === pkey && sel_field === field_name,
|
||||||
|
)}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
aria-colindex={field_index}
|
||||||
|
aria-rowindex={row_index}
|
||||||
|
aria-selected={selected}
|
||||||
|
class="viewer-cell"
|
||||||
|
onclick={(ev) => handle_click(ev, [pkey, field_name], cell_data)}
|
||||||
|
ondblclick={() => handle_dblclick([pkey, field_name])}
|
||||||
|
role="gridcell"
|
||||||
|
style:width={`${data.fields[field_name].width_px}px`}
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
{#if cell_data.t === "Text"}
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"viewer-cell__content",
|
||||||
|
"viewer-cell__content--text",
|
||||||
|
selections.some((coords) => coords_eq(coords, [pkey, field_name])) && "viewer-cell__content--selected",
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cell_data.c}
|
||||||
|
</div>
|
||||||
|
{:else if cell_data.t === "Uuid"}
|
||||||
|
<div class="viewer-cell__content viewer-cell__content--uuid">{cell_data.c}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="viewer-cell__content viewer-cell__content--unknown">Unknown type</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor">
|
||||||
|
<input
|
||||||
|
class={["editor__input", selections.length !== 1 && "editor__input--hidden"]}
|
||||||
|
onfocus={() => {
|
||||||
|
editing = true;
|
||||||
|
}}
|
||||||
|
role="combobox"
|
||||||
|
bind:this={editor_input_element}
|
||||||
|
bind:value={editor_input_value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:catch err}
|
||||||
|
Error {err}
|
||||||
|
{/await}
|
||||||
2
svelte/src/vite-env.d.ts
vendored
Normal file
2
svelte/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
8
svelte/svelte.config.js
Normal file
8
svelte/svelte.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
compilerOptions: { customElement: true },
|
||||||
|
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
20
svelte/tsconfig.app.json
Normal file
20
svelte/tsconfig.app.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
7
svelte/tsconfig.json
Normal file
7
svelte/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
svelte/tsconfig.node.json
Normal file
25
svelte/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
21
svelte/vite.config.ts
Normal file
21
svelte/vite.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import * as path from "@std/path";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: path.fromFileUrl(
|
||||||
|
new URL("./src/TableViewer.svelte", import.meta.url),
|
||||||
|
),
|
||||||
|
output: {
|
||||||
|
dir: path.fromFileUrl(new URL("../js_dist", import.meta.url)),
|
||||||
|
entryFileNames: "[name].js",
|
||||||
|
chunkFileNames: "[name].js",
|
||||||
|
assetFileNames: "[name].[ext]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -27,11 +27,23 @@ pub fn options_common(
|
||||||
component.on_attribute_change("selected", fn(value) {
|
component.on_attribute_change("selected", fn(value) {
|
||||||
ParentChangedSelected(value != "") |> CommonMsg |> map_msg |> Ok
|
ParentChangedSelected(value != "") |> CommonMsg |> map_msg |> Ok
|
||||||
}),
|
}),
|
||||||
|
component.on_attribute_change("insertable", fn(value) {
|
||||||
|
ParentChangedInsertable(value != "") |> CommonMsg |> map_msg |> Ok
|
||||||
|
}),
|
||||||
|
component.on_attribute_change("has-default", fn(value) {
|
||||||
|
ParentChangedInsertable(value != "") |> CommonMsg |> map_msg |> Ok
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type ModelCommon {
|
pub type ModelCommon {
|
||||||
ModelCommon(root_path: String, row: Int, column: Int, selected: Bool)
|
ModelCommon(
|
||||||
|
root_path: String,
|
||||||
|
row: Int,
|
||||||
|
column: Int,
|
||||||
|
selected: Bool,
|
||||||
|
insertable: Bool,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Msg {
|
pub type Msg {
|
||||||
|
|
@ -39,13 +51,20 @@ pub type Msg {
|
||||||
ParentChangedRow(Int)
|
ParentChangedRow(Int)
|
||||||
ParentChangedColumn(Int)
|
ParentChangedColumn(Int)
|
||||||
ParentChangedSelected(Bool)
|
ParentChangedSelected(Bool)
|
||||||
|
ParentChangedInsertable(Bool)
|
||||||
UserClickedCell
|
UserClickedCell
|
||||||
UserDoubleClickedCell
|
UserDoubleClickedCell
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init(_) -> #(ModelCommon, Effect(CommonMsg)) {
|
pub fn init(_) -> #(ModelCommon, Effect(CommonMsg)) {
|
||||||
#(
|
#(
|
||||||
ModelCommon(root_path: "", selected: False, row: -1, column: -1),
|
ModelCommon(
|
||||||
|
root_path: "",
|
||||||
|
selected: False,
|
||||||
|
row: -1,
|
||||||
|
column: -1,
|
||||||
|
insertable: False,
|
||||||
|
),
|
||||||
context.request_context(
|
context.request_context(
|
||||||
context: dynamic.string("root_path"),
|
context: dynamic.string("root_path"),
|
||||||
subscribe: False,
|
subscribe: False,
|
||||||
|
|
@ -79,6 +98,10 @@ pub fn update(
|
||||||
ModelCommon(..model, selected:),
|
ModelCommon(..model, selected:),
|
||||||
effect.none(),
|
effect.none(),
|
||||||
)
|
)
|
||||||
|
ParentChangedInsertable(insertable) -> #(
|
||||||
|
ModelCommon(..model, insertable:),
|
||||||
|
effect.none(),
|
||||||
|
)
|
||||||
UserClickedCell -> #(
|
UserClickedCell -> #(
|
||||||
model,
|
model,
|
||||||
event.emit(
|
event.emit(
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,13 @@ pub fn component() -> App(Nil, Model, Msg) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Model {
|
pub type Model {
|
||||||
Model(common: ModelCommon, value: Option(String))
|
Model(common: ModelCommon, value: Option(String), has_default: Bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init(_) -> #(Model, Effect(Msg)) {
|
fn init(_) -> #(Model, Effect(Msg)) {
|
||||||
let #(model_common, effect_common) = cell_common.init(Nil)
|
let #(model_common, effect_common) = cell_common.init(Nil)
|
||||||
#(
|
#(
|
||||||
Model(common: model_common, value: option.None),
|
Model(common: model_common, value: None, has_default: False),
|
||||||
effect_common
|
effect_common
|
||||||
|> effect.map(fn(effect_common) { Common(effect_common) }),
|
|> effect.map(fn(effect_common) { Common(effect_common) }),
|
||||||
)
|
)
|
||||||
|
|
@ -71,7 +71,17 @@ fn view(model: Model) -> Element(Msg) {
|
||||||
False -> attr.class("cell__content--uuid")
|
False -> attr.class("cell__content--uuid")
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[html.text(model.value |> option.unwrap("Null"))],
|
[
|
||||||
|
html.text(
|
||||||
|
model.value
|
||||||
|
|> option.unwrap(
|
||||||
|
case model.common.insertable && model.common.has_default {
|
||||||
|
True -> "Default"
|
||||||
|
False -> "Null"
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
webc/src/history.gleam
Normal file
1
webc/src/history.gleam
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
465
webc/src/table_viewer_component.gleam
Normal file
465
webc/src/table_viewer_component.gleam
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
import gleam/dict.{type Dict}
|
||||||
|
import gleam/dynamic.{type Dynamic}
|
||||||
|
import gleam/dynamic/decode
|
||||||
|
import gleam/http/response.{type Response}
|
||||||
|
import gleam/io
|
||||||
|
import gleam/json
|
||||||
|
import gleam/list
|
||||||
|
import gleam/option.{None, Some}
|
||||||
|
import gleam/pair
|
||||||
|
import gleam/regexp
|
||||||
|
import gleam/result
|
||||||
|
import gleam/set.{type Set}
|
||||||
|
import lustre.{type App}
|
||||||
|
import lustre/component
|
||||||
|
import lustre/effect.{type Effect}
|
||||||
|
import lustre/element.{type Element}
|
||||||
|
import lustre/element/html
|
||||||
|
import lustre/event
|
||||||
|
import plinth/browser/document
|
||||||
|
import plinth/browser/event as plinth_event
|
||||||
|
import rsvp
|
||||||
|
|
||||||
|
import context
|
||||||
|
import encodable.{type Encodable}
|
||||||
|
import field.{type Field}
|
||||||
|
import field_type.{type FieldType}
|
||||||
|
|
||||||
|
pub const name: String = "table-viewer"
|
||||||
|
|
||||||
|
pub fn component() -> App(Nil, Model, Msg) {
|
||||||
|
lustre.component(init, update, view, [
|
||||||
|
component.on_attribute_change("root-path", fn(value) {
|
||||||
|
ParentChangedRootPath(value) |> Ok
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Model -------- //
|
||||||
|
|
||||||
|
type Coord {
|
||||||
|
Coord(pkey: String, field: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cell {
|
||||||
|
Cell(value_committed: Encodable, value_current: Encodable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Model {
|
||||||
|
Model(
|
||||||
|
root_path: String,
|
||||||
|
selections: List(Coord),
|
||||||
|
editing: Bool,
|
||||||
|
fields: Dict(String, Field),
|
||||||
|
field_names: List(String),
|
||||||
|
history: List(HistoryEvent),
|
||||||
|
pkeys: List(String),
|
||||||
|
data: Dict(Coord, Encodable),
|
||||||
|
// Whether OS, ctrl, or alt keys are being held. If so, simultaneously
|
||||||
|
// pressing a typing key should not trigger an overwrite.
|
||||||
|
modifiers_held: Set(String),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type HistoryEvent {
|
||||||
|
CommitCellValue(from: Encodable, to: Encodable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_history(ev: HistoryEvent) -> Effect(Msg) {
|
||||||
|
case ev {
|
||||||
|
CommitCellValue -> {
|
||||||
|
todo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init(_) -> #(Model, Effect(Msg)) {
|
||||||
|
#(
|
||||||
|
Model(
|
||||||
|
root_path: "",
|
||||||
|
selections: [],
|
||||||
|
editing: False,
|
||||||
|
fields: dict.new(),
|
||||||
|
field_names: [],
|
||||||
|
pkeys: [],
|
||||||
|
values: dict.new(),
|
||||||
|
modifiers_held: set.new(),
|
||||||
|
),
|
||||||
|
effect.before_paint(fn(dispatch, _) -> Nil {
|
||||||
|
document.add_event_listener("keydown", fn(ev) -> Nil {
|
||||||
|
let key = plinth_event.key(ev)
|
||||||
|
case key {
|
||||||
|
"ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" ->
|
||||||
|
dispatch(UserPressedDirectionalKey(key))
|
||||||
|
"Enter" -> dispatch(UserPressedEnterKey)
|
||||||
|
"Shift", "Meta" | "Alt" | "Control" ->
|
||||||
|
dispatch(UserPressedModifierKey(key))
|
||||||
|
_ ->
|
||||||
|
case is_typing_key(key) {
|
||||||
|
True -> dispatch(UserPressedTypingKey(key))
|
||||||
|
False -> Nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
document.add_event_listener("keyup", fn(ev) -> Nil {
|
||||||
|
let key = plinth_event.key(ev)
|
||||||
|
case key {
|
||||||
|
"Meta" | "Alt" | "Control" -> dispatch(UserReleasedModifierKey(key))
|
||||||
|
_ -> Nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_typing_key(key: String) -> Bool {
|
||||||
|
let assert Ok(re) =
|
||||||
|
regexp.from_string("^[a-zA-Z0-9!@#$%^&*._/?<>{}[\\]'\"~`-]$")
|
||||||
|
regexp.check(key, with: re)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Update -------- //
|
||||||
|
|
||||||
|
pub type EditorMsg {
|
||||||
|
EditStarted
|
||||||
|
EditStateChanged(EditState)
|
||||||
|
EditFinished(Encodable)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ApiMsg {
|
||||||
|
CellValueCommitted(Coord)
|
||||||
|
RowInserted
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type MouseMsg {
|
||||||
|
UserClickedCell(Coord)
|
||||||
|
UserDoubleClickedCell(Coord)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type KeyboardMsg {
|
||||||
|
UserPressedDirectionalKey(String)
|
||||||
|
UserPressedEnterKey
|
||||||
|
UserPressedTypingKey(String)
|
||||||
|
UserPressedModifierKey(String)
|
||||||
|
UserReleasedModifierKey(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Msg {
|
||||||
|
ParentChangedRootPath(String)
|
||||||
|
EditorMessage(EditorMsg)
|
||||||
|
ApiMessage(ApiMsg)
|
||||||
|
MouseMessage(MouseMsg)
|
||||||
|
KeyboardMessage(KeyboardMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
||||||
|
case msg {
|
||||||
|
ParentChangedRootPath(root_path) -> #(
|
||||||
|
Model(..model, root_path:),
|
||||||
|
context.update_consumers(
|
||||||
|
model.root_path_consumers,
|
||||||
|
dynamic.string(root_path),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
EditorMessage(sub_msg) -> editor_update(model, sub_msg)
|
||||||
|
MouseEvent(sub_msg) -> mouse_update(model, sub_msg)
|
||||||
|
KeyboardMessage(sub_msg) -> keyboard_update(model, sub_msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_update(model: Model, msg: EditorMsg) -> #(Model, Effect(Msg)) {
|
||||||
|
case msg {
|
||||||
|
EditStarted -> #(Model(..model, editing: True), effect.none())
|
||||||
|
EditStateChanged(_) -> todo
|
||||||
|
EditFinished -> todo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_update(model: Model, msg: ApiMsg) -> #(Model, Effect(Msg)) {
|
||||||
|
case msg {
|
||||||
|
CellValueCommitted(response) -> {
|
||||||
|
case response {
|
||||||
|
Ok(_) -> #(Model(..model, editing: False), blur_hoverbar())
|
||||||
|
Error(rsvp.HttpError(response)) -> {
|
||||||
|
io.println_error("HTTP error while updating value: " <> response.body)
|
||||||
|
#(model, effect.none())
|
||||||
|
}
|
||||||
|
Error(err) -> {
|
||||||
|
echo err
|
||||||
|
#(model, effect.none())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_update(model: Model, msg: ApiMsg) -> #(Model, Effect(Msg)) {
|
||||||
|
case msg {
|
||||||
|
UserClickedCell(row, column) -> assign_selection(model:, to: #(row, column))
|
||||||
|
// TODO
|
||||||
|
UserDoubleClickedCell(_row, _column) -> #(model, case model.editing {
|
||||||
|
True -> effect.none()
|
||||||
|
False -> focus_hoverbar()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn keyboard_update(model: Model, msg: KeyboardMsg) -> #(Model, Effect(Msg)) {
|
||||||
|
case msg {
|
||||||
|
UserPressedDirectionalKey(key) -> {
|
||||||
|
case model.editing, model.selections {
|
||||||
|
False, [#(selected_row, selected_col)] -> {
|
||||||
|
let first_row_selected = selected_row == 0
|
||||||
|
// No need to subtract 1 from pkeys size, because there's another
|
||||||
|
// row for "insert".
|
||||||
|
let last_row_selected = selected_row == dict.size(model.pkeys)
|
||||||
|
let first_col_selected = selected_col == 0
|
||||||
|
let last_col_selected = selected_col == dict.size(model.fields) - 1
|
||||||
|
case
|
||||||
|
key,
|
||||||
|
first_row_selected,
|
||||||
|
last_row_selected,
|
||||||
|
first_col_selected,
|
||||||
|
last_col_selected
|
||||||
|
{
|
||||||
|
"ArrowLeft", _, _, False, _ ->
|
||||||
|
assign_selection(model:, to: #(selected_row, selected_col - 1))
|
||||||
|
"ArrowRight", _, _, _, False ->
|
||||||
|
assign_selection(model:, to: #(selected_row, selected_col + 1))
|
||||||
|
"ArrowUp", False, _, _, _ ->
|
||||||
|
assign_selection(model:, to: #(selected_row - 1, selected_col))
|
||||||
|
"ArrowDown", _, False, _, _ ->
|
||||||
|
assign_selection(model:, to: #(selected_row + 1, selected_col))
|
||||||
|
_, _, _, _, _ -> #(model, effect.none())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
False, [] ->
|
||||||
|
case dict.size(model.pkeys) > 0 && dict.size(model.fields) > 0 {
|
||||||
|
True -> assign_selection(model:, to: #(0, 0))
|
||||||
|
False -> #(model, effect.none())
|
||||||
|
}
|
||||||
|
_, _ -> #(model, effect.none())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UserPressedEnterKey -> #(model, case model.editing {
|
||||||
|
True -> effect.none()
|
||||||
|
False -> focus_hoverbar()
|
||||||
|
})
|
||||||
|
UserPressedTypingKey(key) ->
|
||||||
|
case model.editing, model.selections, set.is_empty(model.modifiers_held) {
|
||||||
|
False, [_], True -> #(model, overwrite_in_hoverbar(value: key))
|
||||||
|
_, _, _ -> #(model, effect.none())
|
||||||
|
}
|
||||||
|
UserPressedModifierKey(key) -> #(
|
||||||
|
Model(..model, modifiers_held: model.modifiers_held |> set.insert(key)),
|
||||||
|
effect.none(),
|
||||||
|
)
|
||||||
|
UserReleasedModifierKey(key) -> #(
|
||||||
|
Model(..model, modifiers_held: model.modifiers_held |> set.delete(key)),
|
||||||
|
effect.none(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overwrite_in_hoverbar(value value: String) -> Effect(Msg) {
|
||||||
|
use _, _ <- effect.before_paint()
|
||||||
|
do_overwrite_in_hoverbar(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./viewer_controller_component.ffi.mjs", "overwriteInHoverbar")
|
||||||
|
fn do_overwrite_in_hoverbar(value _value: String) -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commit_change(model model: Model, value value: Encodable) -> Effect(Msg) {
|
||||||
|
case model.selections {
|
||||||
|
[#(row, col)] ->
|
||||||
|
rsvp.post(
|
||||||
|
"./update-value",
|
||||||
|
json.object([
|
||||||
|
#(
|
||||||
|
"column",
|
||||||
|
model.fields
|
||||||
|
|> dict.get(col)
|
||||||
|
|> result.map(fn(field) { field.name })
|
||||||
|
|> result.unwrap("")
|
||||||
|
|> json.string(),
|
||||||
|
),
|
||||||
|
#(
|
||||||
|
"pkeys",
|
||||||
|
model.pkeys
|
||||||
|
|> dict.get(row)
|
||||||
|
|> result.unwrap(dict.new())
|
||||||
|
|> json.dict(fn(x) { x }, encodable.to_json),
|
||||||
|
),
|
||||||
|
#("value", encodable.to_json(value)),
|
||||||
|
]),
|
||||||
|
rsvp.expect_ok_response(ServerUpdateValuePost),
|
||||||
|
)
|
||||||
|
_ -> todo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updater for when selection is being assigned to a single cell.
|
||||||
|
fn assign_selection(
|
||||||
|
model model: Model,
|
||||||
|
to coords: #(Int, Int),
|
||||||
|
) -> #(Model, Effect(Msg)) {
|
||||||
|
// Multiple sets of conditions fall back to the "selection actually changed"
|
||||||
|
// scenario, so this is a little awkward to write without a return keyword.
|
||||||
|
let early_return = case model.selections {
|
||||||
|
[prev_coords] ->
|
||||||
|
case prev_coords == coords {
|
||||||
|
True -> Some(#(model, effect.none()))
|
||||||
|
False -> None
|
||||||
|
}
|
||||||
|
_ -> None
|
||||||
|
}
|
||||||
|
case early_return {
|
||||||
|
Some(value) -> value
|
||||||
|
None -> #(
|
||||||
|
Model(..model, selections: [coords]),
|
||||||
|
effect.batch([
|
||||||
|
sync_cell_value_to_hoverbar(
|
||||||
|
coords,
|
||||||
|
model.fields
|
||||||
|
|> dict.get(pair.second(coords))
|
||||||
|
|> result.map(fn(f) { f.field_type })
|
||||||
|
|> result.unwrap(field_type.Unknown),
|
||||||
|
),
|
||||||
|
change_selected_attrs([coords]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Effects -------- //
|
||||||
|
|
||||||
|
fn focus_hoverbar() -> Effect(Msg) {
|
||||||
|
use _dispatch, _root <- effect.before_paint()
|
||||||
|
do_focus_hoverbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./viewer_controller_component.ffi.mjs", "focusHoverbar")
|
||||||
|
fn do_focus_hoverbar() -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blur_hoverbar() -> Effect(Msg) {
|
||||||
|
use _dispatch, _root <- effect.before_paint()
|
||||||
|
do_blur_hoverbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./viewer_controller_component.ffi.mjs", "blurHoverbar")
|
||||||
|
fn do_blur_hoverbar() -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effect that changes [selected=] attributes on assigned children. Should only
|
||||||
|
/// be called when the new selection is different than the previous one (that
|
||||||
|
/// is, not when selection is "moving" from a cell to the same cell).
|
||||||
|
fn change_selected_attrs(to_coords coords: List(#(Int, Int))) -> Effect(msg) {
|
||||||
|
use _, _ <- effect.before_paint()
|
||||||
|
do_clear_selected_attrs()
|
||||||
|
coords
|
||||||
|
|> list.map(fn(coords) {
|
||||||
|
let #(row, column) = coords
|
||||||
|
do_set_cell_attr(row:, column:, key: "selected", value: "true")
|
||||||
|
})
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_cell_value(coords coords: #(Int, Int), value value: Encodable) {
|
||||||
|
use _, _ <- effect.before_paint()
|
||||||
|
let #(row, col) = coords
|
||||||
|
do_set_cell_attr(
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
"value",
|
||||||
|
value |> encodable.to_json() |> json.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./viewer_controller_component.ffi.mjs", "clearSelectedAttrs")
|
||||||
|
fn do_clear_selected_attrs() -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./viewer_controller_component.ffi.mjs", "setCellAttr")
|
||||||
|
fn do_set_cell_attr(
|
||||||
|
row _row: Int,
|
||||||
|
column _column: Int,
|
||||||
|
key _key: String,
|
||||||
|
value _value: String,
|
||||||
|
) -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_cell_value_to_hoverbar(
|
||||||
|
coords coords: #(Int, Int),
|
||||||
|
field_type f_type: FieldType,
|
||||||
|
) -> Effect(Msg) {
|
||||||
|
use _, _root <- effect.before_paint()
|
||||||
|
let #(row, column) = coords
|
||||||
|
do_sync_cell_value_to_hoverbar(
|
||||||
|
row:,
|
||||||
|
column:,
|
||||||
|
field_type: f_type |> field_type.to_json() |> json.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@external(javascript, "./viewer_controller_component.ffi.mjs", "syncCellValueToHoverbar")
|
||||||
|
fn do_sync_cell_value_to_hoverbar(
|
||||||
|
row _row: Int,
|
||||||
|
column _column: Int,
|
||||||
|
field_type _field_type: String,
|
||||||
|
) -> Nil {
|
||||||
|
Nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- View -------- //
|
||||||
|
|
||||||
|
fn view(_: Model) -> Element(Msg) {
|
||||||
|
html.div(
|
||||||
|
[
|
||||||
|
context.on_context_request(
|
||||||
|
dynamic.string("root_path"),
|
||||||
|
ChildRequestedRootPath,
|
||||||
|
),
|
||||||
|
event.on("cell-click", {
|
||||||
|
use msg <- decode.field("detail", {
|
||||||
|
use row <- decode.field("row", decode.int)
|
||||||
|
use column <- decode.field("column", decode.int)
|
||||||
|
decode.success(UserClickedCell(row, column))
|
||||||
|
})
|
||||||
|
decode.success(msg)
|
||||||
|
}),
|
||||||
|
event.on("cell-dblclick", {
|
||||||
|
use msg <- decode.field("detail", {
|
||||||
|
use row <- decode.field("row", decode.int)
|
||||||
|
use column <- decode.field("column", decode.int)
|
||||||
|
decode.success(UserDoubleClickedCell(row, column))
|
||||||
|
})
|
||||||
|
decode.success(msg)
|
||||||
|
}),
|
||||||
|
event.on("edit-start", decode.success(ChildEmittedEditStartEvent)),
|
||||||
|
event.on("edit-update", {
|
||||||
|
use original <- decode.subfield(
|
||||||
|
["detail", "original"],
|
||||||
|
encodable.decoder(),
|
||||||
|
)
|
||||||
|
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
|
||||||
|
decode.success(ChildEmittedEditUpdateEvent(original:, edited:))
|
||||||
|
}),
|
||||||
|
event.on("edit-end", {
|
||||||
|
use original <- decode.subfield(
|
||||||
|
["detail", "original"],
|
||||||
|
encodable.decoder(),
|
||||||
|
)
|
||||||
|
use edited <- decode.subfield(["detail", "edited"], encodable.decoder())
|
||||||
|
decode.success(ChildEmittedEditEndEvent(original:, edited:))
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[component.default_slot([], [])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -160,10 +160,17 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
||||||
_ -> todo
|
_ -> todo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ChildEmittedEditEndEvent(_original, edited) -> #(
|
ChildEmittedEditEndEvent(_original, edited) ->
|
||||||
model,
|
case model.selections {
|
||||||
commit_change(model:, value: edited),
|
[#(selected_row, _)] ->
|
||||||
)
|
case selected_row == dict.size(model.pkeys) {
|
||||||
|
// Cell under edit is in "insert" row
|
||||||
|
True -> #(Model(..model, editing: False), effect.none())
|
||||||
|
False -> #(model, commit_change(model:, value: edited))
|
||||||
|
}
|
||||||
|
_ -> todo
|
||||||
|
}
|
||||||
|
|
||||||
ChildRequestedRootPath(callback, subscribe) -> #(
|
ChildRequestedRootPath(callback, subscribe) -> #(
|
||||||
case subscribe {
|
case subscribe {
|
||||||
True ->
|
True ->
|
||||||
|
|
@ -198,7 +205,9 @@ fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
|
||||||
case model.editing, model.selections {
|
case model.editing, model.selections {
|
||||||
False, [#(selected_row, selected_col)] -> {
|
False, [#(selected_row, selected_col)] -> {
|
||||||
let first_row_selected = selected_row == 0
|
let first_row_selected = selected_row == 0
|
||||||
let last_row_selected = selected_row == dict.size(model.pkeys) - 1
|
// No need to subtract 1 from pkeys size, because there's another
|
||||||
|
// row for "insert".
|
||||||
|
let last_row_selected = selected_row == dict.size(model.pkeys)
|
||||||
let first_col_selected = selected_col == 0
|
let first_col_selected = selected_col == 0
|
||||||
let last_col_selected = selected_col == dict.size(model.fields) - 1
|
let last_col_selected = selected_col == dict.size(model.fields) - 1
|
||||||
case
|
case
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue