implement basic record editing
This commit is contained in:
parent
1116e40590
commit
9c1c11a277
29 changed files with 733 additions and 130 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -1636,11 +1636,14 @@ dependencies = [
|
|||
name = "interim-models"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"derive_builder",
|
||||
"interim-pgtypes",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.12",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
@ -1648,10 +1651,13 @@ dependencies = [
|
|||
name = "interim-pgtypes"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"derive_builder",
|
||||
"nom 8.0.0",
|
||||
"regex",
|
||||
"serde",
|
||||
"sqlx",
|
||||
"thiserror 2.0.12",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
@ -1672,7 +1678,6 @@ dependencies = [
|
|||
"futures",
|
||||
"interim-models",
|
||||
"interim-pgtypes",
|
||||
"nom 8.0.0",
|
||||
"oauth2",
|
||||
"percent-encoding",
|
||||
"rand 0.8.5",
|
||||
|
|
|
|||
|
|
@ -1,28 +1,128 @@
|
|||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@customElement("cell-text")
|
||||
export class CellText extends LitElement {
|
||||
@property({ attribute: true, type: String, reflect: true })
|
||||
/**
|
||||
* When present, a JSON object with key "c" mapped to cell contents.
|
||||
*/
|
||||
@property({ attribute: true, reflect: true })
|
||||
value = "null";
|
||||
|
||||
@property({ attribute: "editable", type: Boolean, reflect: true })
|
||||
@property({ attribute: true, type: Boolean })
|
||||
editable = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
editing = false;
|
||||
@property({ attribute: true })
|
||||
column = "";
|
||||
|
||||
@property()
|
||||
pkeyJson = "";
|
||||
|
||||
@state()
|
||||
private _editing = false;
|
||||
|
||||
@state()
|
||||
private _updating = false;
|
||||
|
||||
private _inputRef = createRef<HTMLTextAreaElement>();
|
||||
|
||||
static override styles = css`
|
||||
.outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: "Funnel Sans";
|
||||
}
|
||||
`;
|
||||
|
||||
protected override updated() {
|
||||
if (this._editing && this._inputRef.value) {
|
||||
this._inputRef.value.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private get _contents(): string | undefined {
|
||||
return JSON.parse(this.value)?.c ?? undefined;
|
||||
}
|
||||
|
||||
startEdit() {
|
||||
if (!this._updating) {
|
||||
this._editing = true;
|
||||
}
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this._editing = false;
|
||||
}
|
||||
|
||||
confirmEdit(value: string) {
|
||||
// TODO how to handle null vs empty string
|
||||
(async () => {
|
||||
this._editing = false;
|
||||
this._updating = true;
|
||||
const response = await fetch("update-value", {
|
||||
method: "post",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pkeys: JSON.parse(this.pkeyJson),
|
||||
column: this.column,
|
||||
value: { t: "Text", c: value },
|
||||
}),
|
||||
});
|
||||
// TODO retry logic
|
||||
if (response.ok) {
|
||||
this.value = JSON.stringify({ t: "Text", c: value });
|
||||
this._updating = false;
|
||||
}
|
||||
})()
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
private _handleClick() {
|
||||
if (!this._editing && !this._updating) {
|
||||
this.startEdit();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBlur(ev: Event) {
|
||||
this.confirmEdit((ev.target as HTMLTextAreaElement).value);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const parsed = JSON.parse(this.value);
|
||||
if (parsed === null) {
|
||||
return html`
|
||||
<code>---</code>
|
||||
let inner: TemplateResult = html`
|
||||
|
||||
`;
|
||||
|
||||
if (this._updating) {
|
||||
inner = html`
|
||||
Loading...
|
||||
`;
|
||||
}
|
||||
|
||||
if (this._editing) {
|
||||
inner = html`
|
||||
<div>
|
||||
<textarea ${ref(this._inputRef)} @blur="${this
|
||||
._handleBlur}">${this._contents ??
|
||||
""}</textarea>
|
||||
<button type="button" @click="${this.cancelEdit}">
|
||||
<custom-icon name="x-mark" alt="cancel"></custom-icon>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else if (this._contents === null) {
|
||||
inner = html`
|
||||
<code>NULL</code>
|
||||
`;
|
||||
} else {
|
||||
inner = html`
|
||||
${this._contents}
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div>
|
||||
${parsed}
|
||||
<div class="outer" @click="${this._handleClick}">
|
||||
${inner}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,128 @@
|
|||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@customElement("cell-uuid")
|
||||
export class CellUuid extends LitElement {
|
||||
@property({ attribute: "is-null", type: Boolean, reflect: true })
|
||||
isNull = false;
|
||||
/**
|
||||
* When present, a JSON object with key "c" mapped to cell contents.
|
||||
*/
|
||||
@property({ attribute: true, reflect: true })
|
||||
value = "null";
|
||||
|
||||
@property({ attribute: true })
|
||||
column = "";
|
||||
|
||||
@property()
|
||||
pkeyJson = "";
|
||||
|
||||
@state()
|
||||
private _editing = false;
|
||||
|
||||
@state()
|
||||
private _updating = false;
|
||||
|
||||
private _inputRef = createRef<HTMLTextAreaElement>();
|
||||
|
||||
static override styles = css`
|
||||
.outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: "Funnel Sans";
|
||||
}
|
||||
`;
|
||||
|
||||
protected override updated() {
|
||||
if (this._editing && this._inputRef.value) {
|
||||
this._inputRef.value.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private get _contents(): string | undefined {
|
||||
return JSON.parse(this.value)?.c ?? undefined;
|
||||
}
|
||||
|
||||
startEdit() {
|
||||
if (!this._updating) {
|
||||
this._editing = true;
|
||||
}
|
||||
}
|
||||
|
||||
cancelEdit() {
|
||||
this._editing = false;
|
||||
}
|
||||
|
||||
confirmEdit(value: string) {
|
||||
// TODO how to handle null vs empty string
|
||||
(async () => {
|
||||
this._editing = false;
|
||||
this._updating = true;
|
||||
const response = await fetch("update-value", {
|
||||
method: "post",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
pkeys: JSON.parse(this.pkeyJson),
|
||||
column: this.column,
|
||||
value: { t: "Uuid", c: value },
|
||||
}),
|
||||
});
|
||||
// TODO retry logic
|
||||
if (response.ok) {
|
||||
// FIXME If this is a primary key, need to freeze the whole row until
|
||||
// edit is accepted by the server, and then update the pkeys value
|
||||
// locally.
|
||||
this.value = JSON.stringify({ t: "Uuid", c: value });
|
||||
this._updating = false;
|
||||
}
|
||||
})()
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
private _handleClick() {
|
||||
if (!this._editing && !this._updating) {
|
||||
this.startEdit();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBlur(ev: Event) {
|
||||
this.confirmEdit((ev.target as HTMLTextAreaElement).value);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
if (this.isNull) {
|
||||
return html`
|
||||
let inner: TemplateResult = html`
|
||||
|
||||
`;
|
||||
|
||||
if (this._updating) {
|
||||
inner = html`
|
||||
Loading...
|
||||
`;
|
||||
}
|
||||
|
||||
if (this._editing) {
|
||||
inner = html`
|
||||
<div>
|
||||
<textarea ${ref(this._inputRef)} @blur="${this
|
||||
._handleBlur}">${this._contents ?? ""}</textarea>
|
||||
<button type="button" @click="${this.cancelEdit}">
|
||||
<custom-icon name="x-mark" alt="cancel"></custom-icon>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
} else if (this._contents === null) {
|
||||
inner = html`
|
||||
<code>NULL</code>
|
||||
`;
|
||||
} else {
|
||||
inner = html`
|
||||
<code>${this._contents}</code>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<code><slot></slot></code>
|
||||
<div class="outer" @click="${this._handleClick}">
|
||||
${inner}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
components/src/entrypoints/viewer-controller.ts
Normal file
11
components/src/entrypoints/viewer-controller.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("table.viewer > tbody > tr").forEach((tr) => {
|
||||
const pkeyJson = tr.getAttribute("data-pkey");
|
||||
if (pkeyJson !== null) {
|
||||
tr.querySelectorAll(".cell").forEach((node) => {
|
||||
console.log(node);
|
||||
(node as unknown as { pkeyJson: string }).pkeyJson = pkeyJson;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -4,9 +4,12 @@ edition.workspace = true
|
|||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
derive_builder = { workspace = true }
|
||||
interim-pgtypes = { path = "../interim-pgtypes" }
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ create table if not exists lens_selections (
|
|||
lens_id uuid not null references lenses(id) on delete cascade,
|
||||
attr_filters jsonb not null default '[]'::jsonb,
|
||||
label text,
|
||||
display_type text,
|
||||
visible boolean not null default true
|
||||
field_type jsonb,
|
||||
visible boolean not null default true,
|
||||
width_px int not null default 200
|
||||
);
|
||||
|
|
|
|||
12
interim-models/sqlizable.rs
Normal file
12
interim-models/sqlizable.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{Encode, postgres::Postgres};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum Sqlizable {
|
||||
Integer(i32),
|
||||
Text(String),
|
||||
Timestamptz(DateTime<Utc>),
|
||||
Uuid(Uuid),
|
||||
}
|
||||
|
||||
impl Encode<'a, Postgres> for Sqlizable {}
|
||||
172
interim-models/src/field.rs
Normal file
172
interim-models/src/field.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
|
||||
|
||||
/// A single column which can be passed to a front-end viewer. A Selection may
|
||||
/// resolve to zero or more Fields.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Field {
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub field_type: sqlx::types::Json<FieldType>,
|
||||
pub width_px: i32,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
||||
Self {
|
||||
name: attr.attname.clone(),
|
||||
label: None,
|
||||
field_type: sqlx::types::Json(FieldType::default_from_attr(attr)),
|
||||
width_px: 200,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webc_tag(&self) -> &str {
|
||||
match self.field_type.0 {
|
||||
FieldType::Integer => "cell-integer",
|
||||
FieldType::InterimUser => "cell-interim-user",
|
||||
FieldType::Text => "cell-text",
|
||||
FieldType::Timestamp { .. } => "cell-timestamp",
|
||||
FieldType::Uuid => "cell-uuid",
|
||||
FieldType::Unknown => "cell-unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webc_custom_attrs(&self) -> Vec<(String, String)> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
pub fn render(&self, value: &Encodable) -> String {
|
||||
match (self.field_type.0.clone(), value) {
|
||||
(FieldType::Integer, Encodable::Integer(Some(value))) => value.to_string(),
|
||||
(FieldType::Integer, Encodable::Integer(None)) => "".to_owned(),
|
||||
(FieldType::Integer, _) => "###".to_owned(),
|
||||
(FieldType::InterimUser, Encodable::Text(value)) => todo!(),
|
||||
(FieldType::InterimUser, _) => "###".to_owned(),
|
||||
(FieldType::Text, Encodable::Text(Some(value))) => value.clone(),
|
||||
(FieldType::Text, Encodable::Text(None)) => "".to_owned(),
|
||||
(FieldType::Text, _) => "###".to_owned(),
|
||||
(FieldType::Timestamp { format }, Encodable::Timestamptz(value)) => value
|
||||
.map(|value| value.format(&format).to_string())
|
||||
.unwrap_or("".to_owned()),
|
||||
(FieldType::Timestamp { .. }, _) => "###".to_owned(),
|
||||
(FieldType::Uuid, Encodable::Uuid(Some(value))) => value.hyphenated().to_string(),
|
||||
(FieldType::Uuid, Encodable::Uuid(None)) => "".to_owned(),
|
||||
(FieldType::Uuid, _) => "###".to_owned(),
|
||||
(FieldType::Unknown, _) => "###".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
|
||||
let value_ref = row
|
||||
.try_get_raw(self.name.as_str())
|
||||
.or(Err(ParseError::FieldNotFound))?;
|
||||
let type_info = value_ref.type_info();
|
||||
let ty = type_info.name();
|
||||
Ok(match ty {
|
||||
"INT" | "INT4" => {
|
||||
Encodable::Integer(<Option<i32> as Decode<Postgres>>::decode(value_ref).unwrap())
|
||||
}
|
||||
"TEXT" | "VARCHAR" => {
|
||||
Encodable::Text(<Option<String> as Decode<Postgres>>::decode(value_ref).unwrap())
|
||||
}
|
||||
"UUID" => {
|
||||
Encodable::Uuid(<Option<Uuid> as Decode<Postgres>>::decode(value_ref).unwrap())
|
||||
}
|
||||
_ => return Err(ParseError::UnknownType),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum FieldType {
|
||||
Integer,
|
||||
InterimUser,
|
||||
Text,
|
||||
Timestamp {
|
||||
format: String,
|
||||
},
|
||||
Uuid,
|
||||
/// 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
|
||||
/// be able to define display behavior via the .render() method.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl FieldType {
|
||||
pub fn default_from_attr(attr: &PgAttribute) -> Self {
|
||||
match attr.regtype.as_str() {
|
||||
"integer" => Self::Integer,
|
||||
"text" => Self::Text,
|
||||
"timestamp" => Self::Timestamp {
|
||||
format: RFC_3339_S.to_owned(),
|
||||
},
|
||||
"uuid" => Self::Uuid,
|
||||
_ => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error when parsing a sqlx value to JSON
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseError {
|
||||
#[error("incompatible json type")]
|
||||
BadJsonType,
|
||||
#[error("field not found in row")]
|
||||
FieldNotFound,
|
||||
#[error("unknown postgres type")]
|
||||
UnknownType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum Encodable {
|
||||
Integer(Option<i32>),
|
||||
Text(Option<String>),
|
||||
Timestamptz(Option<DateTime<Utc>>),
|
||||
Uuid(Option<Uuid>),
|
||||
}
|
||||
|
||||
impl Encodable {
|
||||
pub fn bind_onto<'a>(
|
||||
&'a self,
|
||||
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>> {
|
||||
match self {
|
||||
Self::Integer(value) => query.bind(value),
|
||||
Self::Text(value) => query.bind(value),
|
||||
Self::Timestamptz(value) => query.bind(value),
|
||||
Self::Uuid(value) => query.bind(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// impl<'a> Encode<'a, Postgres> for Encodable {
|
||||
// fn encode_by_ref(
|
||||
// &self,
|
||||
// buf: &mut <Postgres as sqlx::Database>::ArgumentBuffer<'a>,
|
||||
// ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
|
||||
// match self {
|
||||
// Self::Integer(value) => Encode::<'a, Postgres>::encode(value, buf),
|
||||
// Self::Text(value) => Encode::<'a, Postgres>::encode(value, buf),
|
||||
// Self::Timestamptz(value) => Encode::<'a, Postgres>::encode(value, buf),
|
||||
// Self::Uuid(value) => Encode::<'a, Postgres>::encode(value, buf),
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// impl<'a> Decode<'a, Postgres> for FieldType {
|
||||
// fn decode(
|
||||
// value: <Postgres as sqlx::Database>::ValueRef<'a>,
|
||||
// ) -> Result<Self, sqlx::error::BoxDynError> {
|
||||
// let value: String = Decode::<'a, Postgres>::decode(value)?;
|
||||
// Ok(serde_json::from_str(&value)?)
|
||||
// }
|
||||
// }
|
||||
|
|
@ -3,7 +3,10 @@ use serde::Serialize;
|
|||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::selection::{AttrFilter, Selection, SelectionDisplayType};
|
||||
use crate::{
|
||||
field::FieldType,
|
||||
selection::{AttrFilter, Selection},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Lens {
|
||||
|
|
@ -76,8 +79,9 @@ select
|
|||
id,
|
||||
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
|
||||
label,
|
||||
display_type as "display_type: SelectionDisplayType",
|
||||
visible
|
||||
field_type as "field_type: sqlx::types::Json<FieldType>",
|
||||
visible,
|
||||
width_px
|
||||
from lens_selections
|
||||
where lens_id = $1
|
||||
"#,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod field;
|
||||
pub mod lens;
|
||||
pub mod selection;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,13 +5,16 @@ use serde::{Deserialize, Serialize};
|
|||
use sqlx::{PgExecutor, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::field::{Field, FieldType};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Selection {
|
||||
pub id: Uuid,
|
||||
pub attr_filters: sqlx::types::Json<Vec<AttrFilter>>,
|
||||
pub label: Option<String>,
|
||||
pub display_type: Option<SelectionDisplayType>,
|
||||
pub field_type: Option<sqlx::types::Json<FieldType>>,
|
||||
pub visible: bool,
|
||||
pub width_px: i32,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
|
|
@ -30,7 +33,11 @@ impl Selection {
|
|||
.map(|attr| Field {
|
||||
name: attr.attname.clone(),
|
||||
label: self.label.clone(),
|
||||
display_type: self.display_type.clone(),
|
||||
field_type: self
|
||||
.field_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| sqlx::types::Json(FieldType::default_from_attr(&attr))),
|
||||
width_px: self.width_px,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
|
|
@ -39,15 +46,6 @@ impl Selection {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, sqlx::Type)]
|
||||
#[sqlx(type_name = "TEXT")]
|
||||
#[sqlx(rename_all = "lowercase")]
|
||||
pub enum SelectionDisplayType {
|
||||
Text,
|
||||
InterimUser,
|
||||
Timestamp,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub enum AttrFilter {
|
||||
NameEq(String),
|
||||
|
|
@ -67,15 +65,6 @@ impl AttrFilter {
|
|||
}
|
||||
}
|
||||
|
||||
/// A single column which can be passed to a front-end viewer. A Selection may
|
||||
/// resolve to zero or more Fields.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Field {
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub display_type: Option<SelectionDisplayType>,
|
||||
}
|
||||
|
||||
#[derive(Builder, Clone, Debug)]
|
||||
pub struct InsertableSelection {
|
||||
lens_id: Uuid,
|
||||
|
|
@ -83,7 +72,7 @@ pub struct InsertableSelection {
|
|||
#[builder(default, setter(strip_option))]
|
||||
label: Option<String>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
display_type: Option<SelectionDisplayType>,
|
||||
field_type: Option<FieldType>,
|
||||
#[builder(default = true)]
|
||||
visible: bool,
|
||||
}
|
||||
|
|
@ -94,20 +83,22 @@ impl InsertableSelection {
|
|||
Selection,
|
||||
r#"
|
||||
insert into lens_selections
|
||||
(id, lens_id, attr_filters, label, display_type, visible)
|
||||
(id, lens_id, attr_filters, label, field_type, visible)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
returning
|
||||
id,
|
||||
attr_filters as "attr_filters: sqlx::types::Json<Vec<AttrFilter>>",
|
||||
label,
|
||||
display_type as "display_type: SelectionDisplayType",
|
||||
visible
|
||||
field_type as "field_type?: sqlx::types::Json<FieldType>",
|
||||
visible,
|
||||
width_px
|
||||
"#,
|
||||
Uuid::now_v7(),
|
||||
self.lens_id,
|
||||
sqlx::types::Json::<_>(self.attr_filters) as sqlx::types::Json<Vec<AttrFilter>>,
|
||||
self.label,
|
||||
self.display_type as Option<SelectionDisplayType>,
|
||||
self.field_type.map(|value| sqlx::types::Json::<_>(value))
|
||||
as Option<sqlx::types::Json<FieldType>>,
|
||||
self.visible,
|
||||
)
|
||||
.fetch_one(app_db)
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ edition.workspace = true
|
|||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { workspace = true }
|
||||
derive_builder = { workspace = true }
|
||||
nom = "8.0.0"
|
||||
regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1 +1,6 @@
|
|||
pub mod pg_acl;
|
||||
pub mod pg_attribute;
|
||||
pub mod pg_class;
|
||||
pub mod pg_database;
|
||||
pub mod pg_namespace;
|
||||
pub mod pg_role;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use nom::{
|
||||
AsChar as _, IResult, Parser,
|
||||
branch::alt,
|
||||
bytes::complete::{is_not, tag, take_till},
|
||||
character::char,
|
||||
|
|
@ -6,12 +7,11 @@ use nom::{
|
|||
error::ParseError,
|
||||
multi::{many0, many1},
|
||||
sequence::delimited,
|
||||
AsChar as _, IResult, Parser,
|
||||
};
|
||||
use sqlx::{
|
||||
Decode, Postgres,
|
||||
error::BoxDynError,
|
||||
postgres::{PgHasArrayType, PgTypeInfo, PgValueRef},
|
||||
Decode, Postgres,
|
||||
};
|
||||
|
||||
/// This type will automatically decode Postgres "aclitem" values, provided that
|
||||
|
|
@ -9,6 +9,8 @@ pub struct PgAttribute {
|
|||
pub attname: String,
|
||||
/// The data type of this column (zero for a dropped column)
|
||||
pub atttypid: Oid,
|
||||
/// SYNTHESIZED: textual representation of attribute type
|
||||
pub regtype: String,
|
||||
/// A copy of pg_type.typlen of this column's type
|
||||
pub attlen: i16,
|
||||
/// The number of the column. Ordinary columns are numbered from 1 up. System columns, such as ctid, have (arbitrary) negative numbers.
|
||||
|
|
@ -46,6 +48,7 @@ select
|
|||
attrelid,
|
||||
attname,
|
||||
atttypid,
|
||||
atttypid::regtype::text as "regtype!",
|
||||
attlen,
|
||||
attnum,
|
||||
attnotnull as "attnotnull?",
|
||||
|
|
@ -65,3 +68,38 @@ where attrelid = $1 and attnum > 0 and not attisdropped
|
|||
.fetch_all(client)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_primary_keys_for_rel<'a, E: PgExecutor<'a>>(
|
||||
oid: Oid,
|
||||
client: E,
|
||||
) -> Result<Vec<PgAttribute>, sqlx::Error> {
|
||||
query_as!(
|
||||
PgAttribute,
|
||||
r#"
|
||||
select
|
||||
a.attrelid as attrelid,
|
||||
a.attname as attname,
|
||||
a.atttypid as atttypid,
|
||||
atttypid::regtype::text as "regtype!",
|
||||
a.attlen as attlen,
|
||||
a.attnum as attnum,
|
||||
a.attnotnull as "attnotnull?",
|
||||
a.atthasdef as atthasdef,
|
||||
a.atthasmissing as atthasmissing,
|
||||
a.attidentity as attidentity,
|
||||
a.attgenerated as attgenerated,
|
||||
a.attisdropped as attisdropped,
|
||||
a.attislocal as attislocal,
|
||||
a.attoptions as attoptions,
|
||||
a.attfdwoptions as attfdwoptions
|
||||
from pg_attribute a
|
||||
join pg_index i
|
||||
on a.attrelid = i.indrelid
|
||||
and a.attnum = any(i.indkey)
|
||||
where i.indrelid = $1 and i.indisprimary;
|
||||
"#,
|
||||
&oid
|
||||
)
|
||||
.fetch_all(client)
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use sqlx::{postgres::types::Oid, query_as, PgExecutor};
|
||||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
|
||||
use crate::pg_acls::PgAclItem;
|
||||
use crate::{pg_acl::PgAclItem, pg_namespace::PgNamespace};
|
||||
|
||||
pub struct PgClass {
|
||||
/// Row identifier
|
||||
|
|
@ -112,6 +112,17 @@ where
|
|||
.fetch_all(client)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fetch_namespace<'a, E: PgExecutor<'a>>(
|
||||
&self,
|
||||
client: E,
|
||||
) -> Result<PgNamespace, sqlx::Error> {
|
||||
PgNamespace::fetch_by_oid(self.relnamespace, client)
|
||||
.await?
|
||||
// If client has access to the class, it would expect to have access
|
||||
// to the namespace that contains it. If not, that's an error.
|
||||
.ok_or(sqlx::Error::RowNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PgRelKind {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
use sqlx::{postgres::types::Oid, query_as, PgExecutor};
|
||||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
|
||||
use crate::pg_acls::PgAclItem;
|
||||
use crate::pg_acl::PgAclItem;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PgDatabase {
|
||||
1
interim-pgtypes/src/pg_index.rs
Normal file
1
interim-pgtypes/src/pg_index.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
use sqlx::query_as;
|
||||
39
interim-pgtypes/src/pg_namespace.rs
Normal file
39
interim-pgtypes/src/pg_namespace.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
|
||||
use crate::pg_acl::PgAclItem;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PgNamespace {
|
||||
/// Row identifier
|
||||
pub oid: Oid,
|
||||
/// Name of the namespace
|
||||
pub nspname: String,
|
||||
/// Owner of the namespace
|
||||
pub nspowner: Oid,
|
||||
/// Access privileges; see Section 5.8 for details
|
||||
pub nspacl: Option<Vec<PgAclItem>>,
|
||||
}
|
||||
|
||||
impl PgNamespace {
|
||||
pub async fn fetch_by_oid<'a, E: PgExecutor<'a>>(
|
||||
oid: Oid,
|
||||
client: E,
|
||||
) -> Result<Option<PgNamespace>, sqlx::Error> {
|
||||
query_as!(
|
||||
PgNamespace,
|
||||
r#"
|
||||
select
|
||||
oid,
|
||||
nspname,
|
||||
nspowner,
|
||||
nspacl::text[] as "nspacl: Vec<PgAclItem>"
|
||||
from pg_namespace
|
||||
where
|
||||
oid = $1
|
||||
"#,
|
||||
oid,
|
||||
)
|
||||
.fetch_optional(client)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,6 @@ dotenvy = "0.15.7"
|
|||
futures = { workspace = true }
|
||||
interim-models = { workspace = true }
|
||||
interim-pgtypes = { workspace = true }
|
||||
nom = "8.0.0"
|
||||
oauth2 = "4.4.2"
|
||||
percent-encoding = "2.3.1"
|
||||
rand = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use sqlx::{query, PgConnection};
|
||||
use interim_pgtypes::{
|
||||
pg_acl::PgPrivilegeType,
|
||||
pg_database::PgDatabase,
|
||||
pg_role::{PgRole, RoleTree, user_id_from_rolname},
|
||||
};
|
||||
use sqlx::{PgConnection, query};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
bases::Base,
|
||||
pg_acls::PgPrivilegeType,
|
||||
pg_databases::PgDatabase,
|
||||
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
|
||||
};
|
||||
use crate::bases::Base;
|
||||
|
||||
pub struct BaseUserPerm {
|
||||
pub id: Uuid,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ use std::fmt::Display;
|
|||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use interim_models::selection::SelectionDisplayType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{
|
||||
ColumnIndex, Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
|
||||
ColumnIndex, Decode, Encode, Postgres, Row as _, TypeInfo as _, ValueRef as _,
|
||||
error::BoxDynError,
|
||||
postgres::{PgRow, PgTypeInfo, PgValueRef},
|
||||
};
|
||||
|
|
@ -12,6 +13,8 @@ use uuid::Uuid;
|
|||
|
||||
const DEFAULT_TIMESTAMP_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f%:z";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum Value {
|
||||
Text(Option<String>),
|
||||
Integer(Option<i32>),
|
||||
|
|
@ -53,37 +56,34 @@ impl Value {
|
|||
let value_ref = row.try_get_raw(idx)?;
|
||||
Self::decode(value_ref)
|
||||
}
|
||||
|
||||
pub fn webc_tag(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Text(_) => "cell-text",
|
||||
Self::Integer(_) => todo!(),
|
||||
Self::Timestamptz(_) => todo!(),
|
||||
Self::Uuid(_) => "cell-uuid",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_json(&self) -> Result<String, serde_json::Error> {
|
||||
match self {
|
||||
Self::Text(value) => serde_json::to_string(&value),
|
||||
Self::Integer(value) => serde_json::to_string(&value),
|
||||
Self::Timestamptz(value) => serde_json::to_string(&value),
|
||||
Self::Uuid(value) => serde_json::to_string(&value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToHtmlString for Value {
|
||||
fn to_html_string(&self, display_type: &Option<SelectionDisplayType>) -> String {
|
||||
macro_rules! cell_html {
|
||||
($component:expr, $value:expr$(, $attr_name:expr => $attr_val:expr)*) => {
|
||||
{
|
||||
let value = $value.clone();
|
||||
let attrs: Vec<String> = vec![
|
||||
format!("value=\"{}\"", serde_json::to_string(&value).unwrap().replace('"', "\\\"")),
|
||||
$(format!("{}=\"{}\"", $attr_name, $attr_val.replace('"', "\\\"")),)*
|
||||
];
|
||||
format!(
|
||||
"<{} {}>{}</{}>",
|
||||
$component,
|
||||
attrs.join(" "),
|
||||
value.map(|value| value.to_string()).unwrap_or("-".to_owned()),
|
||||
$component,
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
match self {
|
||||
Self::Text(value) => cell_html!("cell-text", value),
|
||||
Self::Integer(value) => cell_html!("cell-integer", value),
|
||||
Self::Timestamptz(value) => cell_html!(
|
||||
"cell-timestamptz",
|
||||
value,
|
||||
"format" => DEFAULT_TIMESTAMP_FORMAT
|
||||
),
|
||||
Self::Uuid(value) => cell_html!("cell-uuid", value),
|
||||
Self::Text(Some(value)) => value.clone(),
|
||||
Self::Integer(Some(value)) => format!("{value}"),
|
||||
Self::Timestamptz(_) => todo!(),
|
||||
Self::Uuid(Some(value)) => value.to_string(),
|
||||
_ => "-".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -126,3 +126,17 @@ impl<'a> Decode<'a, Postgres> for Value {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Encode<'a, Postgres> for Value {
|
||||
fn encode_by_ref(
|
||||
&self,
|
||||
buf: &mut <Postgres as sqlx::Database>::ArgumentBuffer<'a>,
|
||||
) -> std::result::Result<sqlx::encode::IsNull, BoxDynError> {
|
||||
match self {
|
||||
Self::Text(value) => Encode::<'a, Postgres>::encode_by_ref(&value, buf),
|
||||
Self::Integer(value) => Encode::<'a, Postgres>::encode_by_ref(&value, buf),
|
||||
Self::Timestamptz(value) => value.encode_by_ref(buf),
|
||||
Self::Uuid(value) => value.encode_by_ref(buf),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,14 +16,9 @@ mod base_pooler;
|
|||
mod base_user_perms;
|
||||
mod bases;
|
||||
mod cli;
|
||||
mod data_layer;
|
||||
mod db_conns;
|
||||
mod lenses;
|
||||
mod middleware;
|
||||
mod pg_acls;
|
||||
mod pg_classes;
|
||||
mod pg_databases;
|
||||
mod pg_roles;
|
||||
mod rel_invitations;
|
||||
mod router;
|
||||
mod routes;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use chrono::{DateTime, Utc};
|
||||
use derive_builder::Builder;
|
||||
use sqlx::{postgres::types::Oid, query_as, PgExecutor};
|
||||
use interim_pgtypes::pg_acl::PgPrivilegeType;
|
||||
use sqlx::{PgExecutor, postgres::types::Oid, query_as};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::pg_acls::PgPrivilegeType;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RelInvitation {
|
||||
pub id: Uuid,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
extract::{ws::WebSocket, ConnectInfo, WebSocketUpgrade},
|
||||
http::{header::CACHE_CONTROL, HeaderValue},
|
||||
Router,
|
||||
extract::{ConnectInfo, WebSocketUpgrade, ws::WebSocket},
|
||||
http::{HeaderValue, header::CACHE_CONTROL},
|
||||
response::Response,
|
||||
routing::{any, get, post},
|
||||
Router,
|
||||
};
|
||||
use axum_extra::routing::RouterExt as _;
|
||||
use tower::ServiceBuilder;
|
||||
|
|
@ -58,7 +58,7 @@ pub fn new_router(state: AppState) -> Router<()> {
|
|||
get(routes::lenses::add_lens_page_get),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/lenses/add",
|
||||
"/d/{base_id}/r/{class_oid}/lenses/add/",
|
||||
post(routes::lenses::add_lens_page_post),
|
||||
)
|
||||
.route_with_tsr(
|
||||
|
|
@ -73,6 +73,10 @@ pub fn new_router(state: AppState) -> Router<()> {
|
|||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/add-selection",
|
||||
post(routes::lenses::add_selection_page_post),
|
||||
)
|
||||
.route(
|
||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value",
|
||||
post(routes::lenses::update_value_page_post),
|
||||
)
|
||||
.route_with_tsr(
|
||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/viewer/",
|
||||
get(routes::lenses::viewer_page),
|
||||
|
|
|
|||
|
|
@ -2,16 +2,22 @@ use std::collections::HashMap;
|
|||
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
response::{Html, IntoResponse as _, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
use interim_models::{
|
||||
field::{Encodable, Field},
|
||||
lens::{Lens, LensDisplayType},
|
||||
selection::{AttrFilter, Field, Selection},
|
||||
selection::{AttrFilter, Selection},
|
||||
};
|
||||
use interim_pgtypes::{
|
||||
pg_attribute::{PgAttribute, fetch_attributes_for_rel, fetch_primary_keys_for_rel},
|
||||
pg_class::PgClass,
|
||||
};
|
||||
use interim_pgtypes::pg_attribute::{PgAttribute, fetch_attributes_for_rel};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::{
|
||||
postgres::{PgRow, types::Oid},
|
||||
query,
|
||||
|
|
@ -23,7 +29,6 @@ use crate::{
|
|||
app_state::AppDbConn,
|
||||
base_pooler::BasePooler,
|
||||
bases::Base,
|
||||
data_layer::{ToHtmlString as _, Value},
|
||||
db_conns::{escape_identifier, init_role},
|
||||
settings::Settings,
|
||||
users::CurrentUser,
|
||||
|
|
@ -149,15 +154,12 @@ pub async fn lens_page(
|
|||
|
||||
// FIXME auth
|
||||
|
||||
let class = query!(
|
||||
"select relname from pg_class where oid = $1",
|
||||
Oid(class_oid)
|
||||
)
|
||||
.fetch_optional(&mut *client)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound(
|
||||
"no relation found with that oid".to_owned(),
|
||||
))?;
|
||||
let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound(
|
||||
"no relation found with that oid".to_owned(),
|
||||
))?;
|
||||
let namespace = class.fetch_namespace(&mut *client).await?;
|
||||
|
||||
let lens = Lens::fetch_by_id(lens_id, &mut *app_db)
|
||||
.await?
|
||||
|
|
@ -171,25 +173,47 @@ pub async fn lens_page(
|
|||
fields.append(&mut selection.resolve_fields_from_attrs(&attrs));
|
||||
}
|
||||
|
||||
let pkey_attrs = fetch_primary_keys_for_rel(Oid(class_oid), &mut *client).await?;
|
||||
|
||||
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
||||
let rows = query(&format!(
|
||||
"select {} from {} limit $1",
|
||||
attrs
|
||||
struct Row {
|
||||
pkeys: HashMap<String, Encodable>,
|
||||
data: PgRow,
|
||||
}
|
||||
let rows: Vec<Row> = query(&format!(
|
||||
"select {0} from {1}.{2} limit $1",
|
||||
pkey_attrs
|
||||
.iter()
|
||||
.map(|attr| attr.attname.clone())
|
||||
.chain(attrs.iter())
|
||||
.map(|attr| escape_identifier(&attr.attname))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
escape_identifier(&namespace.nspname),
|
||||
escape_identifier(&class.relname),
|
||||
))
|
||||
.bind(FRONTEND_ROW_LIMIT)
|
||||
.fetch_all(&mut *client)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
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).unwrap());
|
||||
}
|
||||
Row {
|
||||
pkeys: pkey_values,
|
||||
data: row,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "lens.html")]
|
||||
struct ResponseTemplate {
|
||||
fields: Vec<Field>,
|
||||
all_columns: Vec<PgAttribute>,
|
||||
rows: Vec<PgRow>,
|
||||
rows: Vec<Row>,
|
||||
selections_json: String,
|
||||
settings: Settings,
|
||||
}
|
||||
|
|
@ -265,6 +289,57 @@ pub async fn update_lens_page_post(
|
|||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateValuePageForm {
|
||||
column: String,
|
||||
pkeys: HashMap<String, Encodable>,
|
||||
value: Encodable,
|
||||
}
|
||||
|
||||
pub async fn update_value_page_post(
|
||||
State(settings): State<Settings>,
|
||||
State(mut base_pooler): State<BasePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(current_user): CurrentUser,
|
||||
Path(LensPagePath {
|
||||
base_id,
|
||||
class_oid,
|
||||
lens_id: _,
|
||||
}): Path<LensPagePath>,
|
||||
Json(body): Json<UpdateValuePageForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME auth
|
||||
// FIXME csrf
|
||||
|
||||
let base = Base::fetch_by_id(base_id, &mut *app_db)
|
||||
.await?
|
||||
.ok_or(not_found!("no base found with that id"))?;
|
||||
|
||||
let mut client = base_pooler.acquire_for(base_id).await?;
|
||||
|
||||
let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
|
||||
.await?
|
||||
.ok_or(not_found!("unable to load table"))?;
|
||||
let namespace = class.fetch_namespace(&mut *client).await?;
|
||||
|
||||
let pkey_attrs = fetch_primary_keys_for_rel(Oid(class_oid), &mut *client).await?;
|
||||
|
||||
body.pkeys
|
||||
.get(&pkey_attrs.first().unwrap().attname)
|
||||
.unwrap()
|
||||
.bind_onto(body.value.bind_onto(query(&format!(
|
||||
r#"update {0}.{1} set {2} = $1 where {3} = $2"#,
|
||||
escape_identifier(&namespace.nspname),
|
||||
escape_identifier(&class.relname),
|
||||
escape_identifier(&body.column),
|
||||
escape_identifier(&pkey_attrs.first().unwrap().attname),
|
||||
))))
|
||||
.execute(&mut *client)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "ok": true })).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ViewerPagePath {
|
||||
base_id: Uuid,
|
||||
|
|
|
|||
|
|
@ -7,19 +7,21 @@ use axum::{
|
|||
response::{Html, IntoResponse as _, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
use interim_pgtypes::{
|
||||
pg_acl::PgPrivilegeType,
|
||||
pg_class::{PgClass, PgRelKind},
|
||||
pg_role::{PgRole, RoleTree, user_id_from_rolname},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
app_error::{not_found, AppError},
|
||||
app_error::{AppError, not_found},
|
||||
app_state::AppDbConn,
|
||||
base_pooler::BasePooler,
|
||||
bases::Base,
|
||||
db_conns::init_role,
|
||||
pg_acls::PgPrivilegeType,
|
||||
pg_classes::{PgClass, PgRelKind},
|
||||
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
|
||||
rel_invitations::RelInvitation,
|
||||
settings::Settings,
|
||||
users::{CurrentUser, User},
|
||||
|
|
|
|||
|
|
@ -4,12 +4,13 @@
|
|||
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-components.mjs"></script>
|
||||
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-controller.mjs"></script>
|
||||
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
|
||||
<table class="viewer">
|
||||
<thead>
|
||||
<tr>
|
||||
{% for field in fields %}
|
||||
<th>
|
||||
<th width="{{ field.width_px }}">
|
||||
<div class="padded-cell">{{ field.label.clone().unwrap_or(field.name.clone()) }}</div>
|
||||
</th>
|
||||
{% endfor %}
|
||||
|
|
@ -20,12 +21,21 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr>
|
||||
<tr data-pkey="{{ row.pkeys | json }}">
|
||||
{% for field in fields %}
|
||||
<td>
|
||||
{% match Value::get_from_row(row, field.name.as_str()) %}
|
||||
{% when Ok with (value) %}
|
||||
{{ value.to_html_string(field.display_type) | safe }}
|
||||
{% match field.get_value_encodable(row.data) %}
|
||||
{% when Ok with (encodable) %}
|
||||
<{{ field.webc_tag() | safe }}
|
||||
{% for (k, v) in field.webc_custom_attrs() %}
|
||||
{{ k }}="{{ v }}"
|
||||
{% endfor %}
|
||||
column="{{ field.name }}"
|
||||
value="{{ encodable | json }}"
|
||||
class="cell"
|
||||
>
|
||||
{{ field.render(encodable) | safe }}
|
||||
</{{ field.webc_tag() | safe }}
|
||||
{% when Err with (err) %}
|
||||
<span class="pg-value-error">{{ err }}</span>
|
||||
{% endmatch %}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue