diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 87f47369..9e3fef48 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -23,6 +23,7 @@ every risk-retirement spike. This index maps each spike to its record. | [Windows support](./windows-support.md) | none. Windows-only failures in Rust tests and the NAPI test bridge | accepted, pending Windows CI (no run has reached a runner yet) | | [Action queries](./action-queries.md) | none. Saved queries that change rows: DuckDB SQL through a writable attach, per-row triggers | accepted | | [Access import](./access-import.md) | none. One-way import of Access databases and templates. Format spec in [`docs/access-format.md`](../access-format.md) | accepted | +| [Field formats](./field-formats.md) | none. Rich text, attachment, and multi-select fields and input masks (PRD Phase 6) | accepted | | [Data export](./data-export.md) | none. CSV, XLSX and JSON export streamed from DuckDB (PRD Phase 6) | accepted | | [Datasheet tools](./datasheet-tools.md) | none. Filter by selection, find and replace, frozen and hidden columns, totals row, spreadsheet paste (PRD Phase 6) | accepted | diff --git a/docs/decisions/datasheet-tools.md b/docs/decisions/datasheet-tools.md index 2ca11664..dffce392 100644 --- a/docs/decisions/datasheet-tools.md +++ b/docs/decisions/datasheet-tools.md @@ -23,7 +23,7 @@ The Data mode grid moved from `DatabaseWorkbench` into `src/data/sheet/`. - **Sort.** Clicking a header sorts by it. Shift-click adds the column as the next sort key (`sorts.ts`). The column menu also sorts. - **Find and replace** (`FindReplace.tsx`, `find.ts`) matches the text a cell - shows, skipping nulls and blobs. Find next walks the filtered, sorted table + shows (masked or formatted by its field settings), skipping nulls and blobs. Find next walks the filtered, sorted table one page at a time, wraps once, and moves the grid to the match. Replace all reads every matching row, asks for confirmation, and sends one `writeRecordBatch` with `expected` values. @@ -39,7 +39,8 @@ The Data mode grid moved from `DatabaseWorkbench` into `src/data/sheet/`. average, standard deviation and variance need a numeric column. - **Paste** (`paste.ts`) takes over a paste that spans more than one cell. It parses tab-separated clipboard text with quoted fields, maps it onto the - visible editable columns from the focused cell, and sends one + visible editable columns (not generated, blob, or formatted fields) from the + focused cell, applies input masks, and sends one `writeRecordBatch`. Rows that land on existing records update them (an empty cell becomes null). Rows past the last record, or pasted into the new-record row, are inserted (an empty cell keeps the default). A cell that does not diff --git a/docs/decisions/field-formats.md b/docs/decisions/field-formats.md new file mode 100644 index 00000000..3deb3aed --- /dev/null +++ b/docs/decisions/field-formats.md @@ -0,0 +1,118 @@ +# Field formats: rich text, attachments, multiple choices, and input masks + +Status: accepted. Implements the PRD Phase 6 item "attachment, rich-text memo, +multi-select fields, and input masks". Extends the +[type matrix](./recordstore-type-matrix.md) without adding logical types, and +narrows PRD §18's "record-linked attachments are deferred". + +## Context + +Access users expect four field kinds that ixtable did not have: a memo that +keeps bold, italics, and lists; an attachment field that holds files per +record; a field that holds several choices from a list; and an input mask +such as `(000) 000-0000` that shapes what is typed. + +Logical types are the contract between the RecordStore, DuckDB, and the +designer. A new logical type would need a physical type on both stores, a +DuckDB read mapping, a CHECK constraint on SQLite, and a table rebuild to +change. None of the four kinds needs new storage semantics: they are text or +JSON with rules about entry and display. Attachment bytes are the exception, +and they must follow the same write and read paths as records. + +## Decision + +**Field settings sit on the entity, keyed by column.** `EntitySettings.fields` +(`src-tauri/src/recordstore/model.rs`, `src/fields/types.ts`) holds one +`FieldSettings` per column: a stable `id`, the `column`, an optional `format` +(`richText`, `attachment`, `multiSelect`), an optional `inputMask`, and the +`options` of a multi-select. The table designer edits them in its Field +settings section (`src/fields/FieldSettingsEditor.tsx`) as ordinary definition +edits; they never change the column. `commands::alter_table` renames a field's +column with the column and drops it with the column (`follow_columns`). +`recordstore::validate` flags a duplicate column, an unknown format, and a +multi-select without choices. + +| Format | Column logical type | Stored value | +|---|---|---| +| `richText` | `text` | sanitized HTML subset | +| `attachment` | `text` or `json` | JSON array of `{id, name, mime, size, sha256}` | +| `multiSelect` | `text` or `json` | JSON array of chosen strings | +| input mask | `text` | typed characters only, or with literals when the mask's second section is `0` | + +**Forms inherit from fields.** Three control kinds, `richText`, `attachment`, +and `multiSelect`, join `ControlKind` in Rust and TypeScript, and a control has +an optional `inputMask`. The generator picks the kind and mask from the field +settings. At run time `withFieldDefaults` (`src/fields/defaults.ts`) upgrades a +plain text control bound to a formatted field to that kind, fills a text +control's mask from the field when it has none, and fills a multi-select's +choices. A mask set on the control wins. + +**Rich text is sanitized twice.** `sanitizeRichText` (`src/fields/richtext.ts`) +keeps `p`, `br`, `div`, `b`, `strong`, `i`, `em`, `u`, `s`, `ul`, `ol`, `li`, +`h1` to `h3`, `blockquote`, and `a` with an `http`, `https`, or `mailto` link. +It drops every other attribute, and drops `script`, `style`, and embedded +content with their text. The editor sanitizes what it emits, and the read-only +view sanitizes again before rendering, because a value can also come from an +import, an action, or another PostgreSQL client. Lists, the datasheet, +reports, and the required check use `richTextPlain`. + +**Attachment bytes live in the record store.** `record_attachments.rs` writes +each file through the session's `RecordStore` into the hidden +`_ixtable_attachments` table (BLOB on SQLite, BYTEA on PostgreSQL), created on +first use with `execute_internal`. Binds are text on both stores, so content +travels as hex (`unhex` on SQLite, `decode(..., 'hex')` on PostgreSQL). Reads +go through DuckDB like every other read and check the SHA-256 recorded at +upload. The commands are: + +- `upload_record_attachment`: needs create or update permission on the table, + refuses a column that is not an attachment field, caps a file at 20 MB, and + cleans the file name with `assets::safe_file_name`. +- `read_record_attachment` and `save_record_attachment`: need read permission + on the table the file was uploaded for. +- `remove_unused_record_attachments`: Studio only. Deletes files no attachment + column refers to, keeping files younger than an hour because an open form may + not have saved them yet. + +**Masks follow Access syntax.** `src/fields/mask.ts` parses the three +sections (pattern, store literals, placeholder) and the slot characters +`0 9 # L ? A a & C`, case markers `> <`, `\` escapes, and quoted literals. It +skips `!` and does not support the `Password` mask. Like Access, a mask +governs entry only: forms check it in `validateControl`, and the datasheet +checks it before the logical type (`datasheetValue`). Writes from actions, +imports, and other clients are not checked. + +## Consequences + +- No DDL, table rebuild, or new DuckDB mapping is needed to adopt a format, and + an older build that does not know `fields` drops them without breaking the + table. +- Attachments travel with embedded SQLite data, so a runtime installation's + files stay with its records through definition updates, and PostgreSQL + users share them. They count toward the `.ixt` archive size for SQLite apps. +- A file uploaded from a form that is then cancelled stays stored until + cleanup runs. Cleanup is a manual Studio action. +- The datasheet shows formatted fields as text and edits them only in forms. + Its edits, inserts, multi-cell paste and find and replace all parse through + `datasheetValue`, so a mask applies to each of them, and find matches the + masked or formatted text a cell shows (`src/data/sheet/rows.ts`, `find.ts`). +- A query-sourced report sees the stored HTML or JSON. Only a table-sourced + report turns formatted fields into text (`fieldTextRows`). +- Expressions see the stored value: HTML for rich text and JSON text for + attachments and multiple choices. + +## Evidence + +- `src-tauri/src/record_attachments_tests.rs`: hex and checksum, the stored + bytes on SQLite, and store, read, checksum, and unused-file detection through + DuckDB on both stores (`postgres::` variants run in the PostgreSQL job). +- `src-tauri/src/recordstore/fields_tests.rs`: serde shape, column renames and + drops, and validation. +- `tests/unit/field-mask.test.ts`, `tests/unit/field-formats.test.ts`, + `tests/unit/field-inputs.test.tsx`: masks, sanitizing, value parsing, + inheritance, generation, validation, and the inputs. +- `tests/integration/field-formats.test.tsx`: upload and read through the + bridge, setting formats in the table designer, entering each kind in a + generated form, the datasheet mask check, and masked and formatted cells in + the datasheet. +- `tests/unit/datasheet-fields.test.ts`: masks and formats in datasheet + edits, paste, and find. diff --git a/docs/decisions/recordstore-type-matrix.md b/docs/decisions/recordstore-type-matrix.md index 67ef8341..079710a4 100644 --- a/docs/decisions/recordstore-type-matrix.md +++ b/docs/decisions/recordstore-type-matrix.md @@ -90,5 +90,8 @@ prebuilt libduckdb (see `duckdb-read-path.md`). ## Audit log +- 2026-10-10: field formats (rich text, attachments, multiple choices, input + masks) layer on `text` and `json` columns without new logical types; see + [field formats](./field-formats.md). - 2026-10-05: Proof now names `conformance_more.rs`, which holds the optimistic, schema change, and migration scenarios. diff --git a/scripts/screenshot/specs/fields.spec.tsx b/scripts/screenshot/specs/fields.spec.tsx new file mode 100644 index 00000000..048a6eef --- /dev/null +++ b/scripts/screenshot/specs/fields.spec.tsx @@ -0,0 +1,95 @@ +import { screen, waitFor, within } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { expect, it } from "vitest"; +import { captureDocument } from "../capture"; +import { createTable, LONG, openMode, refreshDatabase, renderNewDocument } from "./fixtures"; + +type Config = { entities: Array<{ table: string; fields?: Array<{ column: string }> }> }; +const fieldCount = async () => + (await invoke("read_document_config", { windowLabel: "main" })).entities.find( + (e) => e.table === "contacts", + )?.fields?.length ?? 0; + +it("sets field formats in the table designer and enters each kind in the Runtime", async () => { + const user = await renderNewDocument(); + await createTable("contacts", [ + { name: "id", declaredType: "INTEGER", primaryKeyPosition: 1 }, + { name: "name", declaredType: "TEXT" }, + { name: "phone", declaredType: "TEXT" }, + { name: "notes", declaredType: "TEXT" }, + { name: "interests", declaredType: "TEXT" }, + { name: "documents", declaredType: "TEXT" }, + ]); + await refreshDatabase(); + await user.click(await screen.findByRole("button", { name: /^contacts\b/ }, LONG)); + await user.click(await screen.findByRole("button", { name: "Design table" }, LONG)); + const fields = await screen.findByRole("region", { name: "Field settings" }, LONG); + await user.type( + within(fields).getByRole("textbox", { name: /phone input mask/ }), + "(000) 000-0000", + ); + await user.selectOptions( + within(fields).getByRole("combobox", { name: "notes format" }), + "richText", + ); + await user.selectOptions( + within(fields).getByRole("combobox", { name: "interests format" }), + "multiSelect", + ); + await user.type( + await within(fields).findByRole("textbox", { name: "interests choices" }), + "Pricing{Enter}Support{Enter}Training", + ); + await user.selectOptions( + within(fields).getByRole("combobox", { name: "documents format" }), + "attachment", + ); + await waitFor(async () => expect(await fieldCount()).toBe(4), LONG); + fields.scrollIntoView(); + await captureDocument(document, { + name: "fields-01-table-designer", + expand: ".table-designer", + expectations: [ + "The Field settings section lists the text columns with a Format picker each.", + "phone shows the input mask (000) 000-0000 and its preview (___) ___-____.", + "notes is Rich text, interests is Multiple choices with three choices, documents is Attachments.", + ], + }); + + await openMode(user, "Design"); + await screen.findByRole("region", { name: "Form builder" }, LONG); + await user.selectOptions( + screen.getByRole("combobox", { name: "Table to generate from" }), + "contacts", + ); + await user.click(screen.getByRole("button", { name: "Generate form from table" })); + const forms = screen.getByRole("region", { name: "Forms" }); + await within(forms).findByRole("button", { name: "Contacts list" }, LONG); + await openMode(user, "Runtime"); + const page = await screen.findByRole("region", { name: "Application page" }, LONG); + const nav = screen.getByRole("navigation", { name: "Application navigation" }); + await user.click(within(nav).getByRole("button", { name: "Contacts" })); + await user.click(await within(page).findByRole("button", { name: "New contacts" }, LONG)); + const create = await screen.findByRole("form", { name: "New Contacts" }, LONG); + await user.type(within(create).getByRole("textbox", { name: "Name" }), "Ada Byrne"); + await user.type(within(create).getByRole("textbox", { name: "Phone" }), "5551234567"); + await user.type( + within(create).getByRole("textbox", { name: "Notes" }), + "Asked about the annual plan.", + ); + await user.click(within(create).getByRole("checkbox", { name: "Support" })); + await user.upload( + within(create).getByLabelText(/Add files/), + new File(["%PDF-1.4 quote"], "quote.pdf", { type: "application/pdf" }), + ); + await within(create).findByText("quote.pdf", {}, LONG); + expect(within(create).getByRole("textbox", { name: "Phone" })).toHaveValue("(555) 123-4567"); + await captureDocument(document, { + name: "fields-02-runtime-entry", + expectations: [ + "The New Contacts form shows Phone formatted as (555) 123-4567.", + "Notes is a rich-text box with a formatting toolbar, and Interests shows three checkboxes with Support ticked.", + "Documents lists quote.pdf with its size and Save and Remove buttons, plus an Add files button.", + ], + }); +}); diff --git a/src-tauri/src/data/read.rs b/src-tauri/src/data/read.rs index ab193558..1d1b5153 100644 --- a/src-tauri/src/data/read.rs +++ b/src-tauri/src/data/read.rs @@ -163,6 +163,10 @@ impl ReadRuntime { pub(super) fn from(&self, table: &str) -> String { format!("data.{}.{}", q(self.schema_name()), q(table)) } + /// The read path of any table, including internal `_ixtable_` tables. + pub fn qualified(&self, table: &str) -> String { + self.from(table) + } pub fn objects(&self) -> Result, String> { self.ready()?; diff --git a/src-tauri/src/design/checks.rs b/src-tauri/src/design/checks.rs index 5cfa5ac8..7d1791df 100644 --- a/src-tauri/src/design/checks.rs +++ b/src-tauri/src/design/checks.rs @@ -266,6 +266,9 @@ fn check_control(config: &DocumentConfig, form: &Form, control: &Control, out: & | ControlKind::Datetime | ControlKind::Select | ControlKind::Relationship + | ControlKind::RichText + | ControlKind::Attachment + | ControlKind::MultiSelect ); if needs_binding && control.binding.as_ref().is_none_or(|b| b.column.is_empty()) diff --git a/src-tauri/src/design/mod.rs b/src-tauri/src/design/mod.rs index 32f5da97..fcf947fa 100644 --- a/src-tauri/src/design/mod.rs +++ b/src-tauri/src/design/mod.rs @@ -153,6 +153,12 @@ pub enum ControlKind { Tabs, RelatedList, Image, + /// Formatted text stored as sanitized HTML. + RichText, + /// Files of an attachment field. + Attachment, + /// Several choices stored as a JSON array. + MultiSelect, } impl ControlKind { pub fn is_container(self) -> bool { @@ -208,6 +214,9 @@ pub struct Control { pub asset_id: Option, #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub read_only: bool, + /// Access-style input mask for text entry (`src/fields/mask.ts`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_mask: Option, /// Presentation variant, e.g. "toggle" for booleans. #[serde(default, skip_serializing_if = "Option::is_none")] pub variant: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c455d8e3..dc0d3cfc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,6 +24,7 @@ pub mod migrations; pub mod paths; pub mod postgres; pub mod queries; +pub mod record_attachments; pub mod recordstore; pub mod recovery; mod report_pdf; @@ -441,6 +442,11 @@ pub fn run() { recordstore::runtime_login::runtime_datasource_login_status, recordstore::runtime_login::set_runtime_datasource_login, recordstore::runtime_login::clear_runtime_datasource_login, + // record attachment commands + record_attachments::upload_record_attachment, + record_attachments::read_record_attachment, + record_attachments::save_record_attachment, + record_attachments::remove_unused_record_attachments, // roles commands authz::set_runtime_role_preview, // design commands diff --git a/src-tauri/src/record_attachments.rs b/src-tauri/src/record_attachments.rs new file mode 100644 index 00000000..043ff2f4 --- /dev/null +++ b/src-tauri/src/record_attachments.rs @@ -0,0 +1,332 @@ +//! Files of attachment fields (`docs/decisions/field-formats.md`). The field's column +//! holds a JSON array of `AttachmentRef`; the bytes live in the record store's hidden +//! `_ixtable_attachments` table, written through the RecordStore and read through DuckDB, +//! so they travel with embedded SQLite data and are shared on PostgreSQL. +use crate::{ + authz::Op, + data::{DataValue, ReadRuntime}, + manager::AppError, + recordstore::{with_store, RecordStore, StoreError}, +}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; + +pub const TABLE: &str = "_ixtable_attachments"; +/// Largest file one attachment can hold (decimal megabytes). +pub const MAX_BYTES: usize = 20_000_000; +/// Files younger than this are kept by cleanup: an open form may not have saved them yet. +const CLEANUP_GRACE: chrono::Duration = chrono::Duration::hours(1); + +const SQLITE_DDL: &str = "CREATE TABLE IF NOT EXISTS _ixtable_attachments(id TEXT PRIMARY KEY, table_name TEXT NOT NULL, column_name TEXT NOT NULL, name TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL, sha256 TEXT NOT NULL, created_at TEXT NOT NULL, content BLOB NOT NULL)"; +const POSTGRES_DDL: &str = "CREATE TABLE IF NOT EXISTS _ixtable_attachments(id TEXT PRIMARY KEY, table_name TEXT NOT NULL, column_name TEXT NOT NULL, name TEXT NOT NULL, mime TEXT NOT NULL, size BIGINT NOT NULL, sha256 TEXT NOT NULL, created_at TEXT NOT NULL, content BYTEA NOT NULL)"; +// Binds are text on both stores, so the content travels as hex. +const SQLITE_INSERT: &str = + "INSERT INTO _ixtable_attachments VALUES (?, ?, ?, ?, ?, CAST(? AS INTEGER), ?, ?, unhex(?))"; +const POSTGRES_INSERT: &str = "INSERT INTO _ixtable_attachments VALUES (?, ?, ?, ?, ?, CAST(? AS TEXT)::bigint, ?, ?, decode(CAST(? AS TEXT), 'hex'))"; + +/// One file of an attachment field, as stored in the field's JSON array. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentRef { + pub id: String, + pub name: String, + pub mime: String, + pub size: u64, + pub sha256: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AttachmentContent { + #[serde(flatten)] + pub file: AttachmentRef, + pub content_base64: String, +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push(DIGITS[(b >> 4) as usize] as char); + out.push(DIGITS[(b & 15) as usize] as char); + } + out +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex(&Sha256::digest(bytes)) +} + +/// The ids an attachment column value refers to; anything but a JSON array of files is none. +pub fn referenced_ids(value: &str) -> Vec { + serde_json::from_str::>(value) + .unwrap_or_default() + .iter() + .filter_map(|file| file.get("id")?.as_str().map(str::to_string)) + .collect() +} + +/// Writes one file through the record store, creating the hidden table on first use. +pub fn store_file( + store: &mut dyn RecordStore, + table: &str, + column: &str, + name: &str, + mime: &str, + bytes: &[u8], +) -> Result { + let postgres = store.kind() == "postgres"; + store.execute_internal(if postgres { POSTGRES_DDL } else { SQLITE_DDL }, &[])?; + let file = AttachmentRef { + id: uuid::Uuid::now_v7().to_string(), + name: crate::assets::safe_file_name(name), + mime: mime.to_string(), + size: bytes.len() as u64, + sha256: sha256_hex(bytes), + }; + let binds = [ + file.id.clone(), + table.to_string(), + column.to_string(), + file.name.clone(), + file.mime.clone(), + file.size.to_string(), + file.sha256.clone(), + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + hex(bytes), + ]; + let insert = if postgres { + POSTGRES_INSERT + } else { + SQLITE_INSERT + }; + store.execute_internal(insert, &binds)?; + Ok(file) +} + +fn text(value: &DataValue) -> String { + match value { + DataValue::Text(s) | DataValue::Timestamp(s) => s.clone(), + DataValue::Integer(i) => i.to_string(), + _ => String::new(), + } +} + +fn table_exists(reader: &ReadRuntime) -> bool { + reader.row_count(TABLE).is_ok() +} + +/// Reads one file through DuckDB and checks its checksum. Returns its table and column too. +pub fn read_file( + reader: &ReadRuntime, + id: &str, +) -> Result, String> { + let id = uuid::Uuid::parse_str(id).map_err(|_| "Not an attachment id".to_string())?; + if !table_exists(reader) { + return Ok(None); + } + let sql = format!( + "SELECT table_name, column_name, name, mime, size, sha256, content FROM {} WHERE id = '{id}'", + reader.qualified(TABLE) + ); + let Some(row) = reader.query(&sql)?.rows.into_iter().next() else { + return Ok(None); + }; + let DataValue::Blob(content_base64) = &row[6] else { + return Err("The attachment has no content".into()); + }; + let bytes = STANDARD + .decode(content_base64) + .map_err(|e| format!("The attachment content is not readable: {e}"))?; + let file = AttachmentRef { + id: id.to_string(), + name: text(&row[2]), + mime: text(&row[3]), + size: bytes.len() as u64, + sha256: text(&row[5]), + }; + if sha256_hex(&bytes) != file.sha256 { + return Err(format!("{} failed its checksum check", file.name)); + } + Ok(Some(( + text(&row[0]), + text(&row[1]), + AttachmentContent { + file, + content_base64: content_base64.clone(), + }, + ))) +} + +/// Stored files no record refers to that are older than the cleanup grace period. +pub fn unused_files( + reader: &ReadRuntime, + fields: &[(String, String)], + now: chrono::DateTime, +) -> Result, String> { + if !table_exists(reader) { + return Ok(vec![]); + } + let mut used = HashSet::new(); + for (table, column) in fields { + let sql = format!( + "SELECT CAST({} AS VARCHAR) FROM {} WHERE {} IS NOT NULL", + crate::data::q(column), + reader.qualified(table), + crate::data::q(column) + ); + // A table or column that no longer exists refers to nothing. + let Ok(result) = reader.query(&sql) else { + continue; + }; + for row in result.rows { + used.extend(referenced_ids(&text(&row[0]))); + } + } + let cutoff = (now - CLEANUP_GRACE).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let sql = format!( + "SELECT id FROM {} WHERE created_at < '{cutoff}' ORDER BY id", + reader.qualified(TABLE) + ); + Ok(reader + .query(&sql)? + .rows + .iter() + .map(|row| text(&row[0])) + .filter(|id| !used.contains(id)) + .collect()) +} + +fn attachment_field(window: &str, table: &str, column: &str) -> Result { + let config = crate::manager()?.config(window)?; + Ok(config.entities.iter().any(|e| { + e.table == table + && e.fields + .iter() + .any(|f| f.column == column && f.format.as_deref() == Some("attachment")) + })) +} + +/// Stores a file for an attachment field and returns the entry the form adds to the +/// field's JSON array. Needs permission to create or update records of the table. +#[tauri::command] +pub fn upload_record_attachment( + window_label: String, + table: String, + column: String, + name: String, + mime: Option, + content_base64: String, +) -> Result { + let window = window_label.as_str(); + let may_create = crate::authz::check(window, "table", &table, Op::Create); + if may_create.is_err() { + crate::authz::check(window, "table", &table, Op::Update)?; + } + if !attachment_field(window, &table, &column)? { + return Err(AppError::new( + "VALIDATION", + format!("{table}.{column} is not an attachment field"), + )); + } + let bytes = STANDARD + .decode(content_base64.as_bytes()) + .map_err(|e| AppError::new("VALIDATION", format!("The file is not valid base64: {e}")))?; + if bytes.len() > MAX_BYTES { + return Err(AppError::new( + "TOO_LARGE", + format!( + "{name} is {} MB; attachments can be at most {} MB", + bytes.len().div_ceil(1_000_000), + MAX_BYTES / 1_000_000 + ), + )); + } + let mime = mime + .filter(|m| !m.trim().is_empty()) + .unwrap_or_else(|| crate::assets::guess_media_type(&name).to_string()); + let file = with_store(window, |s| { + store_file(s, &table, &column, &name, &mime, &bytes) + })?; + crate::recordstore::after_write(window)?; + Ok(file) +} + +/// Reads one attachment's content, for a role that can read the record's table. +#[tauri::command] +pub fn read_record_attachment( + window_label: String, + id: String, +) -> Result { + let m = crate::manager()?; + let found = m.with_session(&window_label, |s| { + read_file(&s.reader, &id).map_err(|e| AppError::new("IO_ERROR", e)) + })?; + let Some((table, _, content)) = found else { + return Err(AppError::new( + "NOT_FOUND", + "The attachment no longer exists", + )); + }; + crate::authz::check(&window_label, "table", &table, Op::Read)?; + Ok(content) +} + +/// Writes one attachment to a path the user chose in a save dialog. +#[tauri::command] +pub fn save_record_attachment( + window_label: String, + id: String, + path: String, +) -> Result<(), AppError> { + let content = read_record_attachment(window_label, id)?; + let bytes = STANDARD + .decode(content.content_base64.as_bytes()) + .map_err(|e| AppError::new("IO_ERROR", e.to_string()))?; + std::fs::write(&path, bytes).map_err(|e| AppError::new("IO_ERROR", e)) +} + +/// Deletes stored files no record refers to any more (Studio only). Returns how many. +#[tauri::command] +pub fn remove_unused_record_attachments(window_label: String) -> Result { + let window = window_label.as_str(); + crate::authz::require_unrestricted(window, "remove unused attachments")?; + let m = crate::manager()?; + let config = m.config(window)?; + let fields: Vec<(String, String)> = config + .entities + .iter() + .flat_map(|e| { + e.fields + .iter() + .filter(|f| f.format.as_deref() == Some("attachment")) + .map(|f| (e.table.clone(), f.column.clone())) + }) + .collect(); + let unused = m.with_session(window, |s| { + unused_files(&s.reader, &fields, chrono::Utc::now()) + .map_err(|e| AppError::new("IO_ERROR", e)) + })?; + if unused.is_empty() { + return Ok(0); + } + let count = unused.len(); + with_store(window, |s| { + for id in &unused { + s.execute_internal( + "DELETE FROM _ixtable_attachments WHERE id = ?", + std::slice::from_ref(id), + )?; + } + Ok(()) + })?; + crate::recordstore::after_write(window)?; + Ok(count) +} + +#[cfg(test)] +mod tests { + include!("record_attachments_tests.rs"); +} diff --git a/src-tauri/src/record_attachments_tests.rs b/src-tauri/src/record_attachments_tests.rs new file mode 100644 index 00000000..4804b80f --- /dev/null +++ b/src-tauri/src/record_attachments_tests.rs @@ -0,0 +1,91 @@ +// Attachment field storage tests (included from record_attachments.rs). +use super::*; +use crate::recordstore::conformance::{each_store, per_store, Harness, Store}; + +#[test] +fn hex_and_checksum_match_known_values() { + assert_eq!(hex(&[0, 15, 16, 255]), "000f10ff"); + assert_eq!( + sha256_hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); +} + +#[test] +fn referenced_ids_reads_only_file_arrays() { + let value = r#"[{"id":"a","name":"x.pdf"},{"name":"no id"},{"id":"b"}]"#; + assert_eq!(referenced_ids(value), vec!["a", "b"]); + assert!(referenced_ids("not json").is_empty()); + assert!(referenced_ids(r#"{"id":"a"}"#).is_empty()); +} + +fn stores_reads_and_finds_unused_files(store: Store) { + each_store(store, |h: &mut Harness| { + let bytes: Vec = (0..=255u8).cycle().take(70_000).collect(); + let a = store_file(h.store.as_mut(), "docs", "files", "../a/report.pdf", "application/pdf", &bytes) + .unwrap(); + let b = store_file(h.store.as_mut(), "docs", "files", "b.txt", "text/plain", b"hello").unwrap(); + assert_eq!(a.name, "report.pdf"); + assert_eq!(a.size, 70_000); + h.reader.refresh().unwrap(); + + let (table, column, content) = read_file(&h.reader, &a.id).unwrap().unwrap(); + assert_eq!((table.as_str(), column.as_str()), ("docs", "files")); + assert_eq!(content.file, a); + assert_eq!(STANDARD.decode(content.content_base64).unwrap(), bytes); + let missing = uuid::Uuid::now_v7().to_string(); + assert!(read_file(&h.reader, &missing).unwrap().is_none()); + assert!(read_file(&h.reader, "x' OR '1'='1").is_err()); + + h.store + .execute_internal("CREATE TABLE docs(id INTEGER PRIMARY KEY, files TEXT)", &[]) + .unwrap(); + let refs = serde_json::to_string(&[&a]).unwrap(); + h.store + .execute_internal("INSERT INTO docs(id, files) VALUES (1, ?)", &[refs]) + .unwrap(); + h.reader.refresh().unwrap(); + let fields = [("docs".to_string(), "files".to_string())]; + let later = chrono::Utc::now() + chrono::Duration::hours(2); + // Inside the grace period nothing is unused; after it, only the unreferenced file. + assert!(unused_files(&h.reader, &fields, chrono::Utc::now()).unwrap().is_empty()); + assert_eq!(unused_files(&h.reader, &fields, later).unwrap(), vec![b.id.clone()]); + }); +} + +fn a_store_without_attachments_reads_nothing(store: Store) { + each_store(store, |h: &mut Harness| { + h.reader.refresh().unwrap(); + let id = uuid::Uuid::now_v7().to_string(); + assert!(read_file(&h.reader, &id).unwrap().is_none()); + assert!(unused_files(&h.reader, &[], chrono::Utc::now()).unwrap().is_empty()); + }); +} + +per_store!( + stores_reads_and_finds_unused_files, + a_store_without_attachments_reads_nothing, +); + +#[test] +fn sqlite_store_keeps_the_exact_bytes() { + let dir = std::env::temp_dir().join(format!("ixtable-attach-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("data.db"); + rusqlite::Connection::open(&path).unwrap(); + let mut store = crate::recordstore::sqlite::SqliteRecordStore::new(path.clone()); + let bytes = [0u8, 1, 127, 128, 254, 255]; + let file = store_file(&mut store, "t", "c", "a.bin", "application/octet-stream", &bytes).unwrap(); + let c = rusqlite::Connection::open(&path).unwrap(); + let (content, size): (Vec, i64) = c + .query_row( + "SELECT content, size FROM _ixtable_attachments WHERE id = ?", + [&file.id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(content, bytes); + assert_eq!(size, 6); + drop(c); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/src-tauri/src/recordstore/commands.rs b/src-tauri/src/recordstore/commands.rs index 6e79472d..f304939f 100644 --- a/src-tauri/src/recordstore/commands.rs +++ b/src-tauri/src/recordstore/commands.rs @@ -3,7 +3,7 @@ //! reader is refreshed, so the next read sees it (read-your-writes). use super::{ entity_policy, read_target, secrets, with_store, ChangePlan, Dependent, EntitySettings, - StoreCapabilities, TableImpact, WriteOp, WriteOutcome, + FieldSettings, StoreCapabilities, TableImpact, WriteOp, WriteOutcome, }; use crate::archive::DocumentConfig; use crate::data::{AlterTable, CreateTable, IndexDef, NamedValue}; @@ -105,6 +105,11 @@ pub fn alter_table( ) -> Result { guard_definition(window)?; with_store(window, |s| s.alter_table(table, operations))?; + update_entities(window, |entities| { + for e in entities.iter_mut().filter(|e| e.table == table) { + follow_columns(&mut e.fields, operations); + } + })?; if let Some(new_name) = operations.iter().rev().find_map(|op| match op { AlterTable::RenameTable { new_name } => Some(new_name.clone()), _ => None, @@ -118,6 +123,20 @@ pub fn alter_table( after_write(window) } +/// Field settings follow column renames and drop with their column. +pub fn follow_columns(fields: &mut Vec, operations: &[AlterTable]) { + for op in operations { + match op { + AlterTable::RenameColumn { column, new_name } => fields + .iter_mut() + .filter(|f| &f.column == column) + .for_each(|f| f.column = new_name.clone()), + AlterTable::DropColumn { column } => fields.retain(|f| &f.column != column), + _ => {} + } + } +} + /// Saved queries, forms, reports, dashboards, and actions that mention a table. pub fn config_dependents(config: &DocumentConfig, table: &str) -> Vec { fn hit(v: &Value, table: &str) -> bool { diff --git a/src-tauri/src/recordstore/fields_tests.rs b/src-tauri/src/recordstore/fields_tests.rs new file mode 100644 index 00000000..a6cda2ea --- /dev/null +++ b/src-tauri/src/recordstore/fields_tests.rs @@ -0,0 +1,79 @@ +//! Field settings: serde shape, validation, and following column renames and drops. +use super::*; +use crate::archive::DocumentConfig; +use crate::data::AlterTable; + +fn field(column: &str, format: Option<&str>) -> FieldSettings { + FieldSettings { + id: format!("f-{column}"), + column: column.into(), + format: format.map(str::to_string), + ..Default::default() + } +} + +#[test] +fn entity_fields_round_trip_and_stay_out_of_older_json() { + let plain: EntitySettings = + serde_json::from_str(r#"{"id":"e","table":"t","concurrency":"optimistic"}"#).unwrap(); + assert!(plain.fields.is_empty()); + assert!(!serde_json::to_string(&plain).unwrap().contains("fields")); + let json = r#"{"id":"e","table":"t","concurrency":"optimistic","actionId":null,"fields":[{"id":"f","column":"phone","inputMask":"(000) 000-0000"},{"id":"g","column":"tags","format":"multiSelect","options":["a","b"]}]}"#; + let parsed: EntitySettings = serde_json::from_str(json).unwrap(); + assert_eq!( + parsed.fields[0].input_mask.as_deref(), + Some("(000) 000-0000") + ); + assert_eq!(parsed.fields[1].options, vec!["a", "b"]); + let back: EntitySettings = + serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap(); + assert_eq!(back, parsed); +} + +#[test] +fn field_settings_follow_column_renames_and_drops() { + let mut fields = vec![ + field("notes", Some("richText")), + field("files", Some("attachment")), + ]; + follow_columns( + &mut fields, + &[ + AlterTable::RenameColumn { + column: "notes".into(), + new_name: "details".into(), + }, + AlterTable::DropColumn { + column: "files".into(), + }, + ], + ); + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].column, "details"); +} + +#[test] +fn validation_flags_duplicate_unknown_and_empty_multi_select_fields() { + let mut config = DocumentConfig::default(); + config.entities.push(EntitySettings { + id: "e".into(), + table: "t".into(), + fields: vec![ + field("a", Some("richText")), + field("a", None), + field("b", Some("hologram")), + field("c", Some("multiSelect")), + ], + ..Default::default() + }); + let messages: Vec = validate(&config).into_iter().map(|i| i.message).collect(); + assert!(messages + .iter() + .any(|m| m == "t.a has more than one field setting")); + assert!(messages + .iter() + .any(|m| m == "t.b uses an unknown field format hologram")); + assert!(messages + .iter() + .any(|m| m == "t.c is a multi-select field with no choices")); +} diff --git a/src-tauri/src/recordstore/mod.rs b/src-tauri/src/recordstore/mod.rs index d2609d6c..2be706c0 100644 --- a/src-tauri/src/recordstore/mod.rs +++ b/src-tauri/src/recordstore/mod.rs @@ -8,6 +8,8 @@ pub mod commands; pub(crate) mod conformance; #[cfg(test)] mod conformance_more; +#[cfg(test)] +mod fields_tests; pub mod login; pub mod model; pub mod plan; @@ -279,9 +281,40 @@ pub fn validate(config: &DocumentConfig) -> Vec { )); } } + for e in &config.entities { + field_issues(e, &mut issues); + } issues } +/// Field settings: one per column, a known format, and choices for a multi-select. +fn field_issues(entity: &EntitySettings, issues: &mut Vec) { + let mut seen = std::collections::HashSet::new(); + for f in &entity.fields { + let name = format!("{}.{}", entity.table, f.column); + if f.column.is_empty() || !seen.insert(f.column.as_str()) { + issues.push(Issue::error( + "entity", + &entity.id, + format!("{name} has more than one field setting"), + )); + } + match f.format.as_deref() { + Some(format) if !FIELD_FORMATS.contains(&format) => issues.push(Issue::warning( + "entity", + &entity.id, + format!("{name} uses an unknown field format {format}"), + )), + Some("multiSelect") if f.options.is_empty() => issues.push(Issue::warning( + "entity", + &entity.id, + format!("{name} is a multi-select field with no choices"), + )), + _ => {} + } + } +} + /// Session-aware checks: tables in the store without a concurrency policy. pub fn table_issues(window: &str, config: &DocumentConfig) -> Vec { let tables: Vec = crate::manager() diff --git a/src-tauri/src/recordstore/model.rs b/src-tauri/src/recordstore/model.rs index 80a4fbc3..557f2e89 100644 --- a/src-tauri/src/recordstore/model.rs +++ b/src-tauri/src/recordstore/model.rs @@ -102,7 +102,32 @@ pub struct EntitySettings { pub concurrency: String, #[serde(default)] pub action_id: Option, + /// Per-column field settings (rich text, attachments, multi-select, input masks). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, } + +/// How a column is presented and entered, layered on its logical type +/// (`docs/decisions/field-formats.md`). Keyed by column name: table renames and +/// drops of the column follow it (`commands::alter_table`). +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct FieldSettings { + pub id: String, + pub column: String, + /// One of `FIELD_FORMATS`; none is a plain field of the column's logical type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + /// Access-style input mask (`src/fields/mask.ts`); applies to entry only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_mask: Option, + /// Choices of a multi-select field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, +} +/// `richText` holds sanitized HTML in a text column; `attachment` and `multiSelect` +/// hold a JSON array in a text or json column. +pub const FIELD_FORMATS: &[&str] = &["richText", "attachment", "multiSelect"]; fn default_policy() -> String { "optimistic".into() } @@ -113,6 +138,7 @@ impl Default for EntitySettings { table: String::new(), concurrency: default_policy(), action_id: None, + fields: vec![], } } } diff --git a/src-tauri/src/trigger_auth_tests.rs b/src-tauri/src/trigger_auth_tests.rs index 904d6f12..4813f239 100644 --- a/src-tauri/src/trigger_auth_tests.rs +++ b/src-tauri/src/trigger_auth_tests.rs @@ -279,6 +279,7 @@ fn updates_of_custom_action_entities_declare_the_routed_action() { table: "inventory".into(), concurrency: "customAction".into(), action_id: Some("a-guard".into()), + ..Default::default() }); assert!(check(&c).is_ok()); let needed = needed_grants(&c, &c.triggers[1]); diff --git a/src/data/TableSchemaDesigner.tsx b/src/data/TableSchemaDesigner.tsx index 33551685..e2f8854f 100644 --- a/src/data/TableSchemaDesigner.tsx +++ b/src/data/TableSchemaDesigner.tsx @@ -1,5 +1,6 @@ import { Plus, Trash2 } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { FieldSettingsEditor } from "../fields/FieldSettingsEditor"; import { asTauriError } from "../lib/api"; import { useDocumentConfig } from "../lib/config-store"; import type { AlterTableOperation, TableSchema } from "../lib/types"; @@ -266,6 +267,7 @@ export function TableSchemaDesigner({ +

Primary key

void; }) { const [layout, saveLayout] = useSheetLayout(table, onError); + const { config } = useDocumentConfig(); + // Input masks and formats (rich text, attachments, multi-select) per column index. + const fields: SheetFields = useMemo( + () => page.columns.map((c) => fieldSettingsFor(config, table, c.name)), + [config, table, page.columns], + ); + const shown = (j: number, v: DataValue) => datasheetText(v, fields[j]) ?? showValue(v); const [active, setActive] = useState(null); const [finding, setFinding] = useState(false); const [pending, setPending] = useState<{ offset: number; cell: CellAt } | null>(null); @@ -72,7 +82,7 @@ export function Datasheet({ const visible = visibleColumns(page.columns, layout); const frozen = visible.slice(0, layout.frozen); const frozenKey = frozen.join(","); - const editable = (j: number) => !readOnly && isEditable(page.columns[j]); + const editable = (j: number) => !readOnly && isEditable(page.columns[j], fields[j]); const editableVisible = visible.filter(editable); // Frozen columns stick at the summed widths of the row number and earlier frozen columns. @@ -118,7 +128,7 @@ export function Datasheet({ const commit = async (row: number, column: number, text: string) => { const meta = page.columns[column]; try { - const value = valueFromText(text, meta.name, logicalOf(meta)); + const value = datasheetValue(text, meta, fields[column]); await updateRecord(table, [{ column: meta.name, value }], page.identities[row], { expected: originalValues(page.columns, page.rows[row]), }); @@ -144,7 +154,7 @@ export function Datasheet({ setDraftError(""); try { const cells = new Map(Object.entries(draft).map(([j, text]) => [Number(j), text])); - await insertRecord(table, typedValues(page.columns, cells, false)); + await insertRecord(table, typedValues(page.columns, cells, false, fields)); setDraft({}); onWritten(); } catch (e) { @@ -166,7 +176,7 @@ export function Datasheet({ return; } try { - const writes = pasteWrites(table, page, plan); + const writes = pasteWrites(table, page, plan, fields); if (!writes.length) return; await writeRecordBatch(writes); setDraft({}); @@ -221,6 +231,7 @@ export function Datasheet({ readOnly={readOnly} read={{ sorts, filters: readFilters }} visible={visible} + fields={fields} editable={editable} active={active} onFound={(at, cell) => { @@ -339,7 +350,7 @@ export function Datasheet({ setActive({ row: i, column: j })} onPaste={(e) => void paste(e, { row: i, column: j })} @@ -347,7 +358,7 @@ export function Datasheet({ if (e.key === "Enter") e.currentTarget.blur(); }} onBlur={(e) => { - if (e.currentTarget.value !== showValue(v)) + if (e.currentTarget.value !== shown(j, v)) void commit(i, j, e.currentTarget.value); }} /> @@ -360,7 +371,7 @@ export function Datasheet({ aria-label={label} onFocus={() => setActive({ row: i, column: j })} > - {showValue(v)} + {shown(j, v)} )} @@ -388,8 +399,8 @@ export function Datasheet({ const props = cellProps(j); return ( - {column.generated ? ( - Generated + {column.generated || fields[j]?.format ? ( + {column.generated ? "Generated" : "Use a form"} ) : ( boolean; active: CellAt | null; onFound: (offset: number, cell: CellAt) => void; @@ -73,7 +76,7 @@ export function FindReplace({ }; const locate = async () => { - const here = nextMatch(page.rows, columns, options, active); + const here = nextMatch(page.rows, columns, options, active, fields); if (here) return onFound(page.offset, here); const pages = Math.ceil(page.total / pageSize); const current = Math.floor(page.offset / pageSize); @@ -81,7 +84,7 @@ export function FindReplace({ for (let k = 1; k <= pages; k++) { const offset = ((current + k) % pages) * pageSize; const rows = offset === page.offset ? page.rows : (await readPage(offset, pageSize)).rows; - const hit = nextMatch(rows, columns, options, null); + const hit = nextMatch(rows, columns, options, null, fields); if (hit) return onFound(offset, hit); } setStatus(`No matches for “${find}”.`); @@ -92,7 +95,7 @@ export function FindReplace({ const meta = page.columns[column]; return { column: meta.name, - value: valueFromText(replaceText(text, replacement, options), meta.name, logicalOf(meta)), + value: datasheetValue(replaceText(text, replacement, options), meta, fields[column]), identity, expected: originalValues(page.columns, row), }; @@ -101,17 +104,27 @@ export function FindReplace({ const replace = () => run(async () => { const value = active && page.rows[active.row]?.[active.column]; - if (!active || !value || !editable(active.column) || !cellMatches(value, options)) { + if ( + !active || + !value || + !editable(active.column) || + !cellMatches(value, options, fields[active.column]) + ) { await locate(); return; } const row = page.rows[active.row]; - const change = write(row, page.identities[active.row], active.column, cellText(value) ?? ""); + const change = write( + row, + page.identities[active.row], + active.column, + cellText(value, fields[active.column]) ?? "", + ); await updateRecord(table, [{ column: change.column, value: change.value }], change.identity, { expected: change.expected, }); onWritten(); - const next = nextMatch(page.rows, columns, options, active); + const next = nextMatch(page.rows, columns, options, active, fields); if (next) onFound(page.offset, next); }); @@ -124,8 +137,8 @@ export function FindReplace({ const chunk = await readPage(offset, SCAN_PAGE); chunk.rows.forEach((row, i) => { const values = targets - .filter((j) => cellMatches(row[j], options)) - .map((j) => write(row, chunk.identities[i], j, cellText(row[j]) ?? "")); + .filter((j) => cellMatches(row[j], options, fields[j])) + .map((j) => write(row, chunk.identities[i], j, cellText(row[j], fields[j]) ?? "")); if (!values.length) return; cells += values.length; writes.push({ diff --git a/src/data/sheet/find.ts b/src/data/sheet/find.ts index 98440967..186e7839 100644 --- a/src/data/sheet/find.ts +++ b/src/data/sheet/find.ts @@ -1,3 +1,5 @@ +import { datasheetText } from "../../fields/datasheet"; +import type { FieldSettings } from "../../fields/types"; import type { DataValue } from "../../lib/types"; import { showValue } from "../format"; @@ -18,12 +20,21 @@ function pattern(options: FindOptions, global: boolean): RegExp { ); } -/** The text a datasheet cell shows, or null for cells find skips (nulls and blobs). */ -export const cellText = (value: DataValue): string | null => - value.type === "null" || value.type === "blob" ? null : showValue(value); +/** + * The text a datasheet cell shows (masked or formatted by its field), or null for + * cells find skips (nulls and blobs). + */ +export const cellText = (value: DataValue, field?: FieldSettings): string | null => + value.type === "null" || value.type === "blob" + ? null + : (datasheetText(value, field) ?? showValue(value)); -export function cellMatches(value: DataValue, options: FindOptions): boolean { - const text = cellText(value); +export function cellMatches( + value: DataValue, + options: FindOptions, + field?: FieldSettings, +): boolean { + const text = cellText(value, field); return !!options.find && text !== null && pattern(options, false).test(text); } @@ -42,13 +53,14 @@ export function nextMatch( columns: number[], options: FindOptions, after: CellAt | null, + fields: (FieldSettings | undefined)[] = [], ): CellAt | null { const startRow = after?.row ?? 0; for (let row = startRow; row < rows.length; row++) { for (const column of columns) { if (row === startRow && after && columns.indexOf(column) <= columns.indexOf(after.column)) continue; - if (cellMatches(rows[row][column], options)) return { row, column }; + if (cellMatches(rows[row][column], options, fields[column])) return { row, column }; } } return null; diff --git a/src/data/sheet/paste.ts b/src/data/sheet/paste.ts index 5148c6a2..05556f2a 100644 --- a/src/data/sheet/paste.ts +++ b/src/data/sheet/paste.ts @@ -1,6 +1,6 @@ import type { RecordWrite } from "../../lib/records"; import type { DbPage } from "../../lib/types"; -import { originalValues, typedValues } from "./rows"; +import { originalValues, type SheetFields, typedValues } from "./rows"; /** * Parses spreadsheet clipboard text (Excel, Numbers, Google Sheets: tab-separated @@ -92,7 +92,12 @@ export function planPaste( * rows leave empty cells to their defaults. A cell that does not parse for its * column throws, naming the pasted row. */ -export function pasteWrites(table: string, page: DbPage, plan: PastePlan): RecordWrite[] { +export function pasteWrites( + table: string, + page: DbPage, + plan: PastePlan, + fields: SheetFields = [], +): RecordWrite[] { const at = (k: number, build: () => RecordWrite) => { try { return build(); @@ -107,7 +112,7 @@ export function pasteWrites(table: string, page: DbPage, plan: PastePlan): Recor operation: "update" as const, table, identity: page.identities[row], - values: typedValues(page.columns, cells, true), + values: typedValues(page.columns, cells, true, fields), meta: { expected: originalValues(page.columns, page.rows[row]) }, })), ), @@ -116,7 +121,7 @@ export function pasteWrites(table: string, page: DbPage, plan: PastePlan): Recor operation: "insert" as const, table, identity: null, - values: typedValues(page.columns, cells, false), + values: typedValues(page.columns, cells, false, fields), })), ), ]; diff --git a/src/data/sheet/rows.ts b/src/data/sheet/rows.ts index 5db619ca..9e103688 100644 --- a/src/data/sheet/rows.ts +++ b/src/data/sheet/rows.ts @@ -1,5 +1,10 @@ import type { DataValue, DbColumn, NamedValue } from "../../lib/types"; -import { logicalOf, valueFromText } from "../../schema/logical"; +import { datasheetValue } from "../../fields/datasheet"; +import type { FieldSettings } from "../../fields/types"; +import { logicalOf } from "../../schema/logical"; + +/** Field settings per column index of a page (masks and formats), from the document config. */ +export type SheetFields = (FieldSettings | undefined)[]; /** * The row as read, sent back as `expected` so optimistic entities detect @@ -10,21 +15,26 @@ export const originalValues = (columns: DbColumn[], row: DataValue[]): NamedValu .map((c, j) => ({ column: c.name, value: row[j] })) .filter((_, j) => !columns[j].generated && logicalOf(columns[j]) !== "blob"); -/** Whether the datasheet lets the user type into `column`. */ -export const isEditable = (column: DbColumn) => !column.generated && logicalOf(column) !== "blob"; +/** + * Whether the datasheet lets the user type into `column`. Formatted fields (rich + * text, attachments, multi-select) are edited in a form. + */ +export const isEditable = (column: DbColumn, field?: FieldSettings) => + !column.generated && logicalOf(column) !== "blob" && !field?.format; /** * Typed values for `cells` (column index to text). Empty text becomes null when * `emptyAsNull` (pasting over a cell), and is left out otherwise (a new record - * keeps the column default). + * keeps the column default). A column's input mask applies before its type. */ export const typedValues = ( columns: DbColumn[], cells: Map, emptyAsNull: boolean, + fields: SheetFields = [], ): NamedValue[] => [...cells].flatMap(([j, text]): NamedValue[] => { const column = columns[j]; if (text === "") return emptyAsNull ? [{ column: column.name, value: { type: "null" } }] : []; - return [{ column: column.name, value: valueFromText(text, column.name, logicalOf(column)) }]; + return [{ column: column.name, value: datasheetValue(text, column, fields[j]) }]; }); diff --git a/src/design/ControlProperties.tsx b/src/design/ControlProperties.tsx index 213137fd..67cf5136 100644 --- a/src/design/ControlProperties.tsx +++ b/src/design/ControlProperties.tsx @@ -8,6 +8,7 @@ import { knownNames } from "../runtime/formState"; import { AssetPicker } from "./AssetPicker"; import { controlConstraints } from "./constraints"; import { DraftInput } from "./DraftInput"; +import { MaskField } from "../fields/MaskField"; import { ExpressionField } from "./ExpressionField"; import { clampPlacement, moveToContainer, removeControl } from "./operations"; import { @@ -174,7 +175,7 @@ export function ControlProperties({ placement={control.placement} onChange={(placement) => change({ placement }, "Place control")} /> - {control.kind === "select" && ( + {(control.kind === "select" || control.kind === "multiSelect") && ( <> - + {control.kind === "select" && ( + + )} )} {control.kind === "relationship" && ( @@ -301,6 +304,12 @@ export function ControlProperties({ )} + {control.kind === "text" && ( + change({ inputMask }, "Set input mask")} + /> + )} {(control.kind === "text" || control.kind === "multiline") && (