From 79fe78cefdb5742a2ee2354ee340cb760e5939f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 05:20:03 -0400 Subject: [PATCH] =?UTF-8?q?Misc=20=C2=B7=20Export=20and=20import:=20export?= =?UTF-8?q?,=20field=20mapping=20and=20a=20CSV=20importer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit misc::ExportDialog offers the owner's formats as cards and hands the chosen one over for the owner to write. misc::ImportDialog maps the file's columns to the app's fields, matched by name to start, over a preview, and asks for each required field's column. misc::CsvImporter reads CSV as RFC 4180, lets the first row name the columns or not, and maps it through the ImportDialog into records; text that does not read says why. --- examples/gallery/pages/misc/mod.rs | 12 +- examples/gallery/pages/misc/transfer.rs | 144 ++++++++++++ src/misc/csv.rs | 258 +++++++++++++++++++++ src/misc/export.rs | 147 ++++++++++++ src/misc/import.rs | 286 ++++++++++++++++++++++++ src/misc/mod.rs | 6 + src/misc/tests/mod.rs | 1 + src/misc/tests/transfer.rs | 167 ++++++++++++++ tasks/ch31-43.md | 6 +- 9 files changed, 1023 insertions(+), 4 deletions(-) create mode 100644 examples/gallery/pages/misc/transfer.rs create mode 100644 src/misc/csv.rs create mode 100644 src/misc/export.rs create mode 100644 src/misc/import.rs create mode 100644 src/misc/tests/transfer.rs diff --git a/examples/gallery/pages/misc/mod.rs b/examples/gallery/pages/misc/mod.rs index 524f765..91aceef 100644 --- a/examples/gallery/pages/misc/mod.rs +++ b/examples/gallery/pages/misc/mod.rs @@ -10,13 +10,14 @@ use crate::ui::{section, specimen, specimens}; mod asking; mod codes; mod notices; +mod transfer; mod web; pub const PAGE: Page = Page { number: 42, slug: "misc", title: "Misc", - summary: "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter, a QR code scanner, a captcha, a poll, a survey, a quiz, flashcards, licenses, terms, a cookie banner and web views.", + summary: "Small tools that stand alone: clocks, a stopwatch, a calculator, a unit converter, a QR code scanner, a captcha, a poll, a survey, a quiz, flashcards, licenses, terms, a cookie banner, data out and in, and web views.", render, script: &[ Step::DownAt("stopwatch-start", 30.0, 62.0), @@ -66,6 +67,14 @@ pub const PAGE: Page = Page { Step::UpAt("cookies", 230.0, 84.0), Step::HoverAt("cookies", 20.0, 20.0), Step::Shot("cookies-choosing"), + Step::DownAt("open-export", 20.0, 16.0), + Step::UpAt("open-export", 20.0, 16.0), + Step::Shot("export"), + Step::Key("escape"), + Step::DownAt("open-csv", 20.0, 16.0), + Step::UpAt("open-csv", 20.0, 16.0), + Step::Shot("csv-import"), + Step::Key("escape"), Step::DownAt("web", 240.0, 100.0), Step::UpAt("web", 240.0, 100.0), Step::DownAt("converter", 24.0, 60.0), @@ -89,6 +98,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(codes::render(window, cx)) .child(asking::render(window, cx)) .child(notices::render(window, cx)) + .child(transfer::render(window, cx)) .child(web::render(cx)) .into_any_element() } diff --git a/examples/gallery/pages/misc/transfer.rs b/examples/gallery/pages/misc/transfer.rs new file mode 100644 index 0000000..101071d --- /dev/null +++ b/examples/gallery/pages/misc/transfer.rs @@ -0,0 +1,144 @@ +use ely_gpui_component::{ + buttons::Button, + misc::{CsvImporter, ExportDialog, ExportFormat, ImportDialog, ImportField}, + theme::ActiveTheme, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div}; + +use crate::probe::probe; +use crate::ui::section; + +const PEOPLE: &str = "Name,Email,Team\nAda Lovelace,ada@example.com,Engines\n\"Lin, Wei\",lin@example.com,Maps\nGrace Hopper,grace@example.com,Compilers\n"; + +/// Which dialog is open. +#[derive(Clone, Copy, PartialEq)] +enum Open { + None, + Export, + Import, + Csv, +} + +fn fields() -> [ImportField; 3] { + [ + ImportField::new("name").required(), + ImportField::new("email").required(), + ImportField::new("phone"), + ] +} + +pub fn render(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = window.use_keyed_state("transfer-open", cx, |_, _| Open::None); + let said = window.use_keyed_state("transfer-said", cx, |_, _| SharedString::default()); + let shut = |words: String, + open: &gpui::Entity, + said: &gpui::Entity, + cx: &mut App| { + open.update(cx, |open, cx| { + *open = Open::None; + cx.notify(); + }); + said.update(cx, |said, cx| { + *said = words.into(); + cx.notify(); + }); + }; + let opener = |which: Open, label: &'static str, key: &'static str| { + let open = open.clone(); + probe( + key, + div().child(Button::new(key, label).on_click(move |_, _, cx| { + open.update(cx, |open, cx| { + *open = which; + cx.notify(); + }) + })), + ) + }; + let (o, s) = (open.clone(), said.clone()); + let answer = move |words: String, cx: &mut App| shut(words, &o, &s, cx); + let dialog = match *open.read(cx) { + Open::None => None, + Open::Export => { + let (done, cancel) = (answer.clone(), answer.clone()); + Some( + ExportDialog::new( + "export", + "Export the table", + [ + ExportFormat::Pdf, + ExportFormat::Png, + ExportFormat::Csv, + ExportFormat::Json, + ], + move |format, _, cx| done(format!("Export as .{}", format.extension()), cx), + move |_, cx| cancel("Export cancelled.".into(), cx), + ) + .into_any_element(), + ) + } + Open::Import => { + let (done, cancel) = (answer.clone(), answer.clone()); + Some( + ImportDialog::new( + "import", + "Import people", + ["Full name", "E-mail", "Team"], + fields(), + move |mapping, _, cx| done(format!("Columns by field: {mapping:?}"), cx), + move |_, cx| cancel("Import cancelled.".into(), cx), + ) + .preview([ + vec![ + "Ada Lovelace".into(), + "ada@example.com".into(), + "Engines".into(), + ], + vec![ + "Grace Hopper".into(), + "grace@example.com".into(), + "Compilers".into(), + ], + ]) + .into_any_element(), + ) + } + Open::Csv => { + let (done, cancel) = (answer.clone(), answer.clone()); + Some( + CsvImporter::new( + "csv", + "Import people.csv", + PEOPLE, + fields(), + move |records, _, cx| done(format!("{} people imported.", records.len()), cx), + move |_, cx| cancel("CSV import cancelled.".into(), cx), + ) + .into_any_element(), + ) + } + }; + div() + .child( + section( + "ExportDialog / ImportDialog / CSVImporter", + "Data out in a format the owner writes, and data in: columns mapped to the app's fields, matched by name to start, and a CSV file read into records through the same mapping.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_2() + .child(opener(Open::Export, "Export…", "open-export")) + .child(opener(Open::Import, "Import…", "open-import")) + .child(opener(Open::Csv, "Import people.csv…", "open-csv")), + ) + .child( + div() + .text_color(cx.theme().colors.fg_muted) + .child(said.read(cx).clone()), + ), + ) + .children(dialog) +} diff --git a/src/misc/csv.rs b/src/misc/csv.rs new file mode 100644 index 0000000..c27a8c6 --- /dev/null +++ b/src/misc/csv.rs @@ -0,0 +1,258 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + Window, div, +}; + +use super::{ImportDialog, ImportField}; +use crate::{ + buttons::{Button, ButtonVariant}, + feedback::InlineMessage, + forms::{Checkbox, Run}, + overlays::Dialog, + primitives::Severity, +}; + +type OnRecords = Rc], &mut Window, &mut App)>; + +/// Rows of fields from CSV text, as RFC 4180 has it: commas between fields, quotes around a field that holds a comma, a quote or a line break, a doubled quote for a quote inside, and CRLF or LF between rows. A last line break adds no row. Fails on a quote that never closes, text after a closing quote, or a row whose field count differs from the first's. +pub fn read_csv(text: &str) -> Result>, String> { + let (mut rows, mut row, mut field) = (Vec::new(), Vec::new(), String::new()); + let (mut quoted, mut closed, mut line) = (false, false, 1); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match (quoted, ch) { + (true, '"') if chars.peek() == Some(&'"') => { + chars.next(); + field.push('"'); + } + (true, '"') => (quoted, closed) = (false, true), + (true, ch) => { + line += usize::from(ch == '\n'); + field.push(ch); + } + (false, '"') if field.is_empty() && !closed => quoted = true, + (false, '"') => { + return Err(format!( + "line {line}: a quote inside a field it did not open" + )); + } + (false, ',') => { + row.push(std::mem::take(&mut field)); + closed = false; + } + (false, '\r') if chars.peek() == Some(&'\n') => {} + (false, '\n') => { + row.push(std::mem::take(&mut field)); + rows.push(std::mem::take(&mut row)); + (closed, line) = (false, line + 1); + } + (false, _) if closed => return Err(format!("line {line}: text after a closing quote")), + (false, ch) => field.push(ch), + } + } + if quoted { + return Err(format!("line {line}: a quote never closes")); + } + if !field.is_empty() || !row.is_empty() || closed { + row.push(field); + rows.push(row); + } + if let Some(first) = rows.first() { + let width = first.len(); + if let Some((ix, ragged)) = rows.iter().enumerate().find(|(_, row)| row.len() != width) { + return Err(format!( + "row {} has {} fields; the first has {width}", + ix + 1, + ragged.len() + )); + } + } + Ok(rows) +} + +/// What the importer read: the text, and its rows or why it does not read as CSV. +struct Reading { + text: SharedString, + rows: Result>, String>, +} + +/// CSV text into records: read as RFC 4180, its first row naming the columns unless the box says otherwise, then mapped to the owner's fields through an `ImportDialog`. Text that does not read as CSV, or holds no rows, says why, with only Cancel. Each way out answers once. Render it while open. +#[derive(IntoElement)] +pub struct CsvImporter { + id: ElementId, + title: SharedString, + text: SharedString, + fields: Vec, + on_import: OnRecords, + on_cancel: Run, +} + +impl CsvImporter { + /// `on_import` gets a record per row, its fields in field order, empty where a field was left empty. + pub fn new( + id: impl Into, + title: impl Into, + text: impl Into, + fields: impl IntoIterator, + on_import: impl Fn(&[Vec], &mut Window, &mut App) + 'static, + on_cancel: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + text: text.into(), + fields: fields.into_iter().collect(), + on_import: Rc::new(on_import), + on_cancel: Rc::new(on_cancel), + } + } +} + +impl RenderOnce for CsvImporter { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let reading = window.use_keyed_state((id.clone(), "reading"), cx, { + let text = self.text.clone(); + move |_, _| Reading { + rows: read_csv(&text), + text, + } + }); + if reading.read(cx).text != self.text { + let rows = read_csv(&self.text); + reading.update(cx, |reading, _| { + *reading = Reading { + text: self.text.clone(), + rows, + } + }); + } + let headed = window.use_keyed_state((id.clone(), "headed"), cx, |_, _| true); + let rows = match &reading.read(cx).rows { + Ok(rows) if rows.is_empty() => Err("it holds no rows".to_string()), + Ok(rows) => Ok(rows.clone()), + Err(why) => Err(why.clone()), + }; + let cancel = self.on_cancel; + let rows = match rows { + Ok(rows) => rows, + Err(why) => { + log::warn!("csv importer {id:?}: {why}"); + return Dialog::new(id.clone(), self.title, move |window, cx| cancel(window, cx)) + .child( + div() + .debug_selector(|| "csv-unread".into()) + .child(InlineMessage::new( + Severity::Danger, + format!("This file does not read as CSV: {why}."), + )), + ) + .action(move |close| { + Button::new((id, "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| close(window, cx)) + }) + .into_any_element(); + } + }; + let first_names = *headed.read(cx); + let width = rows[0].len(); + let (columns, data): (Vec, Vec>) = { + let shared = |row: &Vec| { + row.iter() + .map(|field| SharedString::from(field.clone())) + .collect::>() + }; + match first_names { + true => (shared(&rows[0]), rows[1..].iter().map(shared).collect()), + false => ( + (1..=width).map(|n| format!("Column {n}").into()).collect(), + rows.iter().map(shared).collect(), + ), + } + }; + let (import, turned) = (self.on_import, headed); + let records = Rc::new(data); + let given = records.clone(); + ImportDialog::new( + id.clone(), + self.title, + columns, + self.fields, + move |mapping, window, cx| { + let rows: Vec> = given + .iter() + .map(|row| { + mapping + .iter() + .map(|column| { + column.map(|column| row[column].clone()).unwrap_or_default() + }) + .collect() + }) + .collect(); + log::info!("csv importer: {} records", rows.len()); + import(&rows, window, cx) + }, + move |window, cx| cancel(window, cx), + ) + .preview(records.iter().cloned()) + .child( + Checkbox::new((id, "headed"), first_names) + .label("The first row names the columns") + .on_change(move |on, _, cx| { + turned.update(cx, |headed, cx| { + *headed = on; + cx.notify(); + }) + }), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::read_csv; + + #[test] + fn csv_reads_quotes_commas_and_line_breaks() { + let rows = read_csv("name,note\r\n\"Doe, Jane\",\"says \"\"hi\"\"\"\nAda,\"two\nlines\"\n") + .expect("valid CSV"); + assert_eq!( + rows, + [ + vec!["name".to_string(), "note".to_string()], + vec!["Doe, Jane".into(), "says \"hi\"".into()], + vec!["Ada".into(), "two\nlines".into()], + ] + ); + assert_eq!( + read_csv("a,,b").expect("empty middle"), + [vec!["a".to_string(), String::new(), "b".into()]] + ); + assert_eq!(read_csv("").expect("nothing"), Vec::>::new()); + } + + #[test] + fn csv_names_what_breaks_it() { + assert_eq!( + read_csv("a,\"open"), + Err("line 1: a quote never closes".into()) + ); + assert_eq!( + read_csv("\"a\"b,c"), + Err("line 1: text after a closing quote".into()) + ); + assert_eq!( + read_csv("ab\"c"), + Err("line 1: a quote inside a field it did not open".into()) + ); + assert_eq!( + read_csv("a,b\nc"), + Err("row 2 has 1 fields; the first has 2".into()) + ); + } +} diff --git a/src/misc/export.rs b/src/misc/export.rs new file mode 100644 index 0000000..527f476 --- /dev/null +++ b/src/misc/export.rs @@ -0,0 +1,147 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{RadioCard, Run}, + overlays::Dialog, + primitives::IconName, +}; + +type OnExport = Rc; + +/// A way to save data out. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExportFormat { + Pdf, + Png, + Csv, + Json, +} + +impl ExportFormat { + /// The file extension it takes, without the dot. + pub fn extension(self) -> &'static str { + match self { + Self::Pdf => "pdf", + Self::Png => "png", + Self::Csv => "csv", + Self::Json => "json", + } + } + + fn label(self) -> &'static str { + match self { + Self::Pdf => "PDF", + Self::Png => "PNG", + Self::Csv => "CSV", + Self::Json => "JSON", + } + } + + fn detail(self) -> &'static str { + match self { + Self::Pdf => "A document to print or share", + Self::Png => "A picture of what shows", + Self::Csv => "Rows for a spreadsheet", + Self::Json => "Data for other programs", + } + } + + fn icon(self) -> IconName { + match self { + Self::Pdf => IconName::FileText, + Self::Png => IconName::FileImage, + Self::Csv => IconName::FileSpreadsheet, + Self::Json => IconName::FileJson, + } + } +} + +/// Saves data out: the owner's formats as cards, the first chosen, then Export or Cancel. Each way out answers once: Export hands the chosen format to the owner, who writes the file; Cancel, Escape and a press on the scrim cancel. Render it while open. +#[derive(IntoElement)] +pub struct ExportDialog { + id: ElementId, + title: SharedString, + formats: Vec, + on_export: OnExport, + on_cancel: Run, +} + +impl ExportDialog { + pub fn new( + id: impl Into, + title: impl Into, + formats: impl IntoIterator, + on_export: impl Fn(ExportFormat, &mut Window, &mut App) + 'static, + on_cancel: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + formats: formats.into_iter().collect(), + on_export: Rc::new(on_export), + on_cancel: Rc::new(on_cancel), + } + } +} + +impl RenderOnce for ExportDialog { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let first = *self + .formats + .first() + .unwrap_or_else(|| panic!("export dialog {id:?} has no formats")); + let chosen = window.use_keyed_state((id.clone(), "chosen"), cx, move |_, _| first); + let format = *chosen.read(cx); + assert!( + self.formats.contains(&format), + "export dialog {id:?} lost its format {format:?}" + ); + let cards = self.formats.iter().map(|&each| { + let pick = chosen.clone(); + RadioCard::new((id.clone(), each.label()), each == format, each.label()) + .description(each.detail()) + .icon(each.icon()) + .on_select(move |_, cx| { + pick.update(cx, |chosen, cx| { + *chosen = each; + cx.notify(); + }) + }) + }); + let (cancel, export) = (self.on_cancel, self.on_export); + let (cancelled, exported) = (id.clone(), id.clone()); + let answered = Rc::new(Cell::new(false)); + let answer = answered.clone(); + Dialog::new(id.clone(), self.title, move |window, cx| { + if !answered.get() { + log::info!("export dialog {cancelled:?}: cancelled"); + cancel(window, cx) + } + }) + .child(div().flex().flex_col().gap_2().children(cards)) + .action({ + let id = id.clone(); + move |close| { + Button::new((id, "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| close(window, cx)) + } + }) + .action(move |close| { + Button::new((id, "export"), "Export") + .primary() + .on_click(move |_, window, cx| { + log::info!("export dialog {exported:?}: {format:?}"); + answer.set(true); + close(window, cx); + export(format, window, cx); + }) + }) + } +} diff --git a/src/misc/import.rs b/src/misc/import.rs new file mode 100644 index 0000000..5774afb --- /dev/null +++ b/src/misc/import.rs @@ -0,0 +1,286 @@ +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, prelude::*, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Choice, Run, Select}, + overlays::Dialog, + tables::Table, + theme::{ActiveTheme, TextSize}, +}; + +type OnMap = Rc], &mut Window, &mut App)>; + +/// A field the app keeps: its name, and whether an import must fill it. +#[derive(Clone, Debug, PartialEq)] +pub struct ImportField { + name: SharedString, + required: bool, +} + +impl ImportField { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + required: false, + } + } + + pub fn required(mut self) -> Self { + self.required = true; + self + } +} + +/// How far two names match: equal once case, spaces, dashes and underscores are set aside. +fn same(field: &str, column: &str) -> bool { + let plain = |name: &str| { + name.chars() + .filter(|ch| !matches!(ch, ' ' | '-' | '_')) + .flat_map(char::to_lowercase) + .collect::() + }; + plain(field) == plain(column) +} + +/// Each field's column to start: the first whose name matches, or none. +pub(crate) fn matched(fields: &[ImportField], columns: &[SharedString]) -> Vec> { + fields + .iter() + .map(|field| columns.iter().position(|column| same(&field.name, column))) + .collect() +} + +/// The mapping as the owner set it last, and the columns it was made for. +struct Mapping { + columns: Vec, + picked: Vec>, + nudged: bool, +} + +/// Columns from a file mapped to the fields the app keeps: a Select of the columns for each field, matched by name to start, over the first rows as they would come in. Import asks for a column for every required field, then hands the mapping over; each way out answers once, import or cancel. Render it while open. +#[derive(IntoElement)] +pub struct ImportDialog { + id: ElementId, + title: SharedString, + columns: Vec, + rows: Vec>, + fields: Vec, + body: SmallVec<[AnyElement; 1]>, + on_import: OnMap, + on_cancel: Run, +} + +impl ImportDialog { + /// `on_import` gets each field's column, in field order, or none for a field left empty. + pub fn new( + id: impl Into, + title: impl Into, + columns: impl IntoIterator>, + fields: impl IntoIterator, + on_import: impl Fn(&[Option], &mut Window, &mut App) + 'static, + on_cancel: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + columns: columns.into_iter().map(Into::into).collect(), + rows: Vec::new(), + fields: fields.into_iter().collect(), + body: SmallVec::new(), + on_import: Rc::new(on_import), + on_cancel: Rc::new(on_cancel), + } + } + + /// The first rows, by column, to show as they would come in. + pub fn preview(mut self, rows: impl IntoIterator>) -> Self { + self.rows = rows.into_iter().take(PREVIEW).collect(); + self + } +} + +impl ParentElement for ImportDialog { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +/// How many rows the preview shows. +const PREVIEW: usize = 3; + +impl RenderOnce for ImportDialog { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + assert!( + !self.fields.is_empty(), + "import dialog {id:?} has no fields" + ); + assert!( + !self.columns.is_empty(), + "import dialog {id:?} has no columns" + ); + let (fields, columns) = (Rc::new(self.fields), self.columns); + let mapping = window.use_keyed_state((id.clone(), "mapping"), cx, { + let (fields, columns) = (fields.clone(), columns.clone()); + move |_, _| Mapping { + picked: matched(&fields, &columns), + columns, + nudged: false, + } + }); + if mapping.read(cx).columns != columns { + let picked = matched(&fields, &columns); + mapping.update(cx, |mapping, _| { + *mapping = Mapping { + columns: columns.clone(), + picked, + nudged: false, + } + }); + } + let (picked, nudged) = (mapping.read(cx).picked.clone(), mapping.read(cx).nudged); + let missing: Vec = fields + .iter() + .zip(&picked) + .filter(|(field, column)| field.required && column.is_none()) + .map(|(field, _)| field.name.clone()) + .collect(); + let theme = cx.theme(); + let colors = &theme.colors; + let rows = fields.iter().enumerate().map(|(ix, field)| { + let skip = (!field.required).then(|| Choice::new("none", "Leave empty")); + let choices = skip.into_iter().chain( + columns + .iter() + .enumerate() + .map(|(column, name)| Choice::new(column.to_string(), name.clone())), + ); + let set = mapping.clone(); + let select = Select::new((id.clone(), format!("field-{ix}")), choices) + .placeholder("Choose a column") + .on_change(move |value, _, cx| { + let column = (value.as_ref() != "none") + .then(|| value.parse().expect("a column's place")); + set.update(cx, |mapping, cx| { + mapping.picked[ix] = column; + cx.notify(); + }) + }); + let select = match (picked[ix], field.required) { + (Some(column), _) => select.selected(column.to_string()), + (None, false) => select.selected("none"), + (None, true) => select, + }; + div() + .flex() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .text_color(colors.fg) + .child(match field.required { + true => format!("{} (required)", field.name), + false => field.name.to_string(), + }), + ) + .child(div().flex_1().min_w(theme.label_width()).child(select)) + }); + let preview = self.rows.iter().fold( + Table::new(fields.iter().map(|field| field.name.clone())), + |table, row| { + table.row(picked.iter().map(|column| { + column + .map(|column| row[column].clone()) + .unwrap_or_else(|| "—".into()) + })) + }, + ); + let (cancel, import) = (self.on_cancel, self.on_import); + let (cancelled, imported, asked) = (id.clone(), id.clone(), mapping.clone()); + let answered = Rc::new(Cell::new(false)); + let answer = answered.clone(); + Dialog::new(id.clone(), self.title, move |window, cx| { + if !answered.get() { + log::info!("import dialog {cancelled:?}: cancelled"); + cancel(window, cx) + } + }) + .children(self.body) + .child( + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows), + ) + .when(!self.rows.is_empty(), |dialog| { + dialog.child( + div() + .debug_selector(|| "import-preview".into()) + .child(preview), + ) + }) + .when(nudged && !missing.is_empty(), |dialog| { + dialog.child( + div() + .debug_selector(|| format!("import-missing-{}", missing.join(","))) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.danger) + .child(format!("Choose a column for {}.", missing.join(", "))), + ) + }) + .action({ + let id = id.clone(); + move |close| { + Button::new((id, "cancel"), "Cancel") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| close(window, cx)) + } + }) + .action(move |close| { + Button::new((id, "import"), "Import") + .primary() + .on_click(move |_, window, cx| { + if !missing.is_empty() { + asked.update(cx, |mapping, cx| { + mapping.nudged = true; + cx.notify(); + }); + return; + } + log::info!("import dialog {imported:?}: imported with {picked:?}"); + answer.set(true); + close(window, cx); + import(&picked, window, cx); + }) + }) + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::{ImportField, matched}; + + #[test] + fn fields_find_their_columns_by_name() { + let columns: Vec = ["E-mail", "Full name", "Age"].map(Into::into).into(); + let fields = [ + ImportField::new("full_name").required(), + ImportField::new("email"), + ImportField::new("phone"), + ]; + assert_eq!(matched(&fields, &columns), [Some(1), Some(0), None]); + } +} diff --git a/src/misc/mod.rs b/src/misc/mod.rs index 044b919..535e954 100644 --- a/src/misc/mod.rs +++ b/src/misc/mod.rs @@ -3,7 +3,10 @@ mod captcha; mod clock; mod consent; mod cookies; +mod csv; +mod export; mod flashcards; +mod import; mod licenses; mod poll; mod quiz; @@ -20,7 +23,10 @@ pub use captcha::{Captcha, CaptchaState}; pub use clock::{Clock, WorldClock}; pub use consent::ConsentDialog; pub use cookies::CookieBanner; +pub use csv::{CsvImporter, read_csv}; +pub use export::{ExportDialog, ExportFormat}; pub use flashcards::Flashcards; +pub use import::{ImportDialog, ImportField}; pub use licenses::{LicenseViewer, Package}; pub use poll::Poll; pub use quiz::{Quiz, QuizQuestion}; diff --git a/src/misc/tests/mod.rs b/src/misc/tests/mod.rs index a455732..28ee25c 100644 --- a/src/misc/tests/mod.rs +++ b/src/misc/tests/mod.rs @@ -17,6 +17,7 @@ mod asking; mod codes; mod notices; mod numbers; +mod transfer; struct Stage { root: FocusHandle, diff --git a/src/misc/tests/transfer.rs b/src/misc/tests/transfer.rs new file mode 100644 index 0000000..04abcee --- /dev/null +++ b/src/misc/tests/transfer.rs @@ -0,0 +1,167 @@ +use std::cell::{Cell, RefCell}; + +use gpui::{AnyElement, IntoElement, TestAppContext, div}; + +use super::{press, shown, stage}; +use crate::misc::{CsvImporter, ExportDialog, ExportFormat, ImportDialog, ImportField}; + +thread_local! { + static SAID: RefCell> = const { RefCell::new(Vec::new()) }; + static OPEN: Cell = const { Cell::new(true) }; + static TEXT: RefCell<&'static str> = const { RefCell::new("") }; +} + +fn said() -> Vec { + SAID.with(|said| said.borrow().clone()) +} + +/// Closes the dialog and notes the answer. +fn say(text: impl Into) { + OPEN.set(false); + SAID.with(|said| said.borrow_mut().push(text.into())); +} + +fn export() -> AnyElement { + match OPEN.get() { + true => ExportDialog::new( + "export", + "Export", + [ExportFormat::Pdf, ExportFormat::Png, ExportFormat::Csv], + |format, _, _| say(format!("{format:?}")), + |_, _| say("cancelled"), + ) + .into_any_element(), + false => div().into_any_element(), + } +} + +/// A card picks the format and Export hands it over, once. +#[gpui::test] +fn export_hands_over_the_chosen_format(cx: &mut TestAppContext) { + let (_, cx) = stage(export, cx); + press("tab", cx); + press("tab", cx); + press("space", cx); + for _ in 0..3 { + press("tab", cx); + } + press("enter", cx); + assert_eq!(said(), ["Png"]); +} + +/// Escape cancels, once. +#[gpui::test] +fn escape_cancels_the_export(cx: &mut TestAppContext) { + let (_, cx) = stage(export, cx); + press("tab", cx); + press("escape", cx); + assert_eq!(said(), ["cancelled"]); +} + +fn import() -> AnyElement { + match OPEN.get() { + true => ImportDialog::new( + "import", + "Import people", + ["Mail", "Who"], + [ + ImportField::new("name").required(), + ImportField::new("email"), + ], + |mapping, _, _| say(format!("{mapping:?}")), + |_, _| say("cancelled"), + ) + .preview([vec!["ada@x.io".into(), "Ada".into()]]) + .into_any_element(), + false => div().into_any_element(), + } +} + +/// Import asks for each required field's column, then hands the mapping over. +#[gpui::test] +fn import_asks_for_required_columns(cx: &mut TestAppContext) { + let (_, cx) = stage(import, cx); + assert!(shown("import-preview", cx)); + for _ in 0..4 { + press("tab", cx); + } + press("enter", cx); + assert!(said().is_empty() && shown("import-missing-name", cx)); + for _ in 0..3 { + press("shift-tab", cx); + } + for key in ["down", "down", "enter"] { + press(key, cx); + } + for _ in 0..3 { + press("tab", cx); + } + press("enter", cx); + assert_eq!(said(), ["[Some(1), None]"]); +} + +fn csv() -> AnyElement { + match OPEN.get() { + true => CsvImporter::new( + "csv", + "Import people", + TEXT.with(|text| *text.borrow()), + [ + ImportField::new("name").required(), + ImportField::new("email"), + ], + |records, _, _| say(format!("{records:?}")), + |_, _| say("cancelled"), + ) + .into_any_element(), + false => div().into_any_element(), + } +} + +/// Columns named by the first row match the fields, and Import hands over a record per row. +#[gpui::test] +fn a_csv_file_imports_as_records(cx: &mut TestAppContext) { + TEXT.with(|text| text.replace("Name,Email\nAda,ada@x.io\n\"Lin, Wei\",lin@x.io\n")); + let (_, cx) = stage(csv, cx); + for _ in 0..5 { + press("tab", cx); + } + press("enter", cx); + assert_eq!( + said(), + [r#"[["Ada", "ada@x.io"], ["Lin, Wei", "lin@x.io"]]"#] + ); +} + +/// Unticked, the first row is data, its columns go by number, and the required field waits for one. +#[gpui::test] +fn the_first_row_can_be_data(cx: &mut TestAppContext) { + TEXT.with(|text| text.replace("Name,Email\nAda,ada@x.io\n")); + let (_, cx) = stage(csv, cx); + press("tab", cx); + press("space", cx); + for _ in 0..4 { + press("tab", cx); + } + press("enter", cx); + assert!(said().is_empty() && shown("import-missing-name", cx)); +} + +/// Text that does not read as CSV says why, and Escape cancels. +#[gpui::test] +fn broken_csv_says_why(cx: &mut TestAppContext) { + TEXT.with(|text| text.replace("a,\"b")); + let (_, cx) = stage(csv, cx); + assert!(shown("csv-unread", cx)); + press("tab", cx); + press("escape", cx); + assert_eq!(said(), ["cancelled"]); +} + +/// Text with no rows says so. +#[gpui::test] +fn empty_csv_says_why(cx: &mut TestAppContext) { + TEXT.with(|text| text.replace("")); + let (_, cx) = stage(csv, cx); + assert!(shown("csv-unread", cx)); +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 08d16e1..b1be123 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -348,9 +348,9 @@ ## T42g Misc · Export and import -- [ ] ExportDialog — PDF, PNG, CSV, JSON -- [ ] ImportDialog — field mapping -- [ ] CSVImporter +- [x] ExportDialog — PDF, PNG, CSV, JSON → `misc::ExportDialog`: the owner's formats as cards, and Export hands the chosen one over for the owner to write; each way out answers once +- [x] ImportDialog — field mapping → `misc::ImportDialog`: a Select of the columns for each field, matched by name to start, over a preview of the first rows; Import asks for each required field's column +- [x] CSVImporter → `misc::CsvImporter`: CSV read as RFC 4180 (`misc::read_csv`), its first row naming the columns unless the box says otherwise, mapped through the ImportDialog into records; text that does not read says why ## T43 Library Tooling — `src/tooling`