diff --git a/Cargo.lock b/Cargo.lock index dd2d9042..2799e4dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,28 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-events" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-file" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7" +dependencies = [ + "gloo-events", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "gloo-storage" version = "0.2.2" @@ -136,6 +158,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "itoa" version = "1.0.6" @@ -198,6 +226,31 @@ dependencies = [ "kobold", ] +[[package]] +name = "kobold_invoice" +version = "0.1.0" +dependencies = [ + "compact_str", + "gloo-console", + "gloo-file", + "gloo-storage", + "gloo-utils", + "heck", + "js-sys", + "kobold", + "kobold_macros", + "kobold_qr", + "log", + "logos", + "serde", + "serde_json", + "take_mut", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-logger", + "web-sys", +] + [[package]] name = "kobold_list_example" version = "0.1.0" @@ -294,7 +347,7 @@ dependencies = [ "proc-macro2", "quote", "regex-syntax", - "syn", + "syn 1.0.109", ] [[package]] @@ -305,18 +358,18 @@ checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "proc-macro2" -version = "1.0.51" +version = "1.0.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" +checksum = "e472a104799c74b514a57226160104aa483546de37e839ec50e3c2e41dd87534" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.23" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8856d8364d252a14d474036ea1358d63c9e6965c8e5c1885c18f73d70bff9c7b" +checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" dependencies = [ "proc-macro2", ] @@ -353,29 +406,29 @@ checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041" [[package]] name = "serde" -version = "1.0.152" +version = "1.0.160" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb7d1f0d3021d347a83e556fc4683dea2ea09d87bccdf88ff5c12545d89d5efb" +checksum = "bb2f3770c8bce3bcda7e149193a069a0f4365bda1fa5cd88e03bca26afc1216c" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.152" +version = "1.0.160" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af487d118eecd09402d70a5d72551860e788df87b464af30e5ea6a38c75c541e" +checksum = "291a097c63d8497e00160b166a967a4a79c64f3facdd01cbd7502231688d77df" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.11", ] [[package]] name = "serde_json" -version = "1.0.94" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c533a59c9d8a93a09c6ab31f0fd5e5f4dd1b8fc9434804029839884765d04ea" +checksum = "d721eca97ac802aa7777b701877c8004d950fc142651367300d21c1cc0194744" dependencies = [ "itoa", "ryu", @@ -397,7 +450,7 @@ dependencies = [ "base64", "proc-macro2", "quote", - "syn", + "syn 1.0.109", "unicode-width", ] @@ -412,6 +465,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e3787bb71465627110e7d87ed4faaa36c1f61042ee67badb9e2ef173accc40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + [[package]] name = "thiserror" version = "1.0.39" @@ -429,7 +499,7 @@ checksum = "5420d42e90af0c38c3290abcca25b9b3bdf379fc9f55c528f53a269d9c9a267e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.109", ] [[package]] @@ -465,7 +535,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn", + "syn 1.0.109", "wasm-bindgen-shared", ] @@ -499,7 +569,7 @@ checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.109", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -510,6 +580,17 @@ version = "0.2.84" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" +[[package]] +name = "wasm-logger" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074649a66bb306c8f2068c9016395fa65d8e08d2affcbf95acf3c24c3ab19718" +dependencies = [ + "log", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.61" diff --git a/README.md b/README.md index d6d47d85..66f760e9 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Because the `view!` macro produces unique transient types, `if` and `match` expr the macro will naturally fail to compile. Using the `auto_branch` flag on the `#[component]` attribute -**Kobold** will scan the body of of your component render function, and make all `view!` macro invocations +**Kobold** will scan the body of your component render function, and make all `view!` macro invocations inside an `if` or `match` expression, and wrap them in an enum making them the same type: ```rust diff --git a/crates/kobold/src/stateful/hook.rs b/crates/kobold/src/stateful/hook.rs index 3b5c1b03..229dc486 100644 --- a/crates/kobold/src/stateful/hook.rs +++ b/crates/kobold/src/stateful/hook.rs @@ -18,7 +18,7 @@ use crate::View; /// A hook into some state `S`. A reference to `Hook` is obtained by using the [`stateful`](crate::stateful::stateful) /// function. /// -/// Hook can be read from though its `Deref` implementation, and it allows for mutations either by [`bind`ing](Hook::bind) +/// Hook can be read from through its `Deref` implementation, and it allows for mutations by [`bind`ing](Hook::bind) /// closures to it. #[repr(transparent)] pub struct Hook { diff --git a/crates/kobold_qr/src/lib.rs b/crates/kobold_qr/src/lib.rs index c3db6285..8a03ff7d 100644 --- a/crates/kobold_qr/src/lib.rs +++ b/crates/kobold_qr/src/lib.rs @@ -19,7 +19,7 @@ pub fn KoboldQR(data: &str, size: usize) -> impl View + '_ { Some( view! { - + } .on_render(move |canvas| { let ctx = match canvas.get_context("2d") { diff --git a/examples/invoice/Cargo.toml b/examples/invoice/Cargo.toml new file mode 100644 index 00000000..a1d7b8ec --- /dev/null +++ b/examples/invoice/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "kobold_invoice" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "main" +path = "src/main.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +kobold = { version = "0.8.0", path = "../../crates/kobold" } +kobold_macros = { version = "0.8.0", path = "../../crates/kobold_macros" } +kobold_qr = { version = "0.8.0", path = "../../crates/kobold_qr" } +compact_str = "0.7.0" +gloo-console = "0.2.3" +gloo-file = "0.2.3" +gloo-storage = "0.2.1" +gloo-utils = { version = "0.1.2", features = ["serde"] } +heck = "0.4.1" +js-sys = "0.3.61" +log = "0.4.17" +logos = "0.12.1" +serde = "1.0.159" +serde_json = "1.0.95" +take_mut = "0.2.2" +wasm-bindgen = "0.2.84" +wasm-bindgen-futures = "0.4.34" +wasm-logger = "0.2.0" + +[dependencies.web-sys] +version = "0.3.61" +features = [ + "console", + "Document", + "HtmlInputElement", + "File", + "FileList", +] diff --git a/examples/invoice/README.md b/examples/invoice/README.md new file mode 100644 index 00000000..be7a8484 --- /dev/null +++ b/examples/invoice/README.md @@ -0,0 +1,58 @@ +* Usage + * Run the following from the project root directory: + ``` + cd examples/invoice/ + cargo install --locked trunk + RUST_LOG=info trunk serve --address=127.0.0.1 --open + ``` + * Open in web browser http://localhost:8080 + * Upload, edit (saves in local storage), and download a backup to a CSV file for the "Main" table + * Create a text file similar to the example in folder ./data/main.csv and `mock_file_main` in state.rs, prefixed with `#main,`. + * Note: It looks like there is an additional column on the first row but that cell will be removed during the upload process and used to populate a `TableVariant` value in the state that is reflected in Local Storage + * Upload a file by clicking "Upload CSV file (Main) to upload it in the "Main" table + * View the file in the UI and serialised in browser Local Storage under key `kobald.invoice.main` + * Modify the table by double clicking cells and pressing escape or enter to save + * Add rows to the "Main" table if required by clicking the "+" icon above where you want to insert a new row + * Remove rows from the "Main" table if required by clicking the "X" icon on that row + * Save a backup of the file by clicking the associated "Save to CSV file" button + * Note: The downloaded file should be prefixed with `#main,` to indicate it uses the `TableVariant::Main` table + * Upload, edit (saves in local storage), and download a backup to a CSV file for the "Details" table + * Repeat steps used for the "Main" table, but similar to example in folder ./data/details.csv and `mock_file_details` in state.rs, and prefixed with `#details,` instead, and stored under `kobald.invoice.details` in Local Storage instead. + +* Contributing Guidelines + * Format `cargo fmt --all` before pushing commits + * Test with `cargo test` before pushing commits + +* Browser Compatibility: + * Brave Version 1.50.121 Chromium: 112.0.5615.138 (Official Build) (x86_64) + * Chrome Version 112.0.5615.137 (Official Build) (x86_64) + * Firefox Version 112.0.2 (64-bit) + +* Notes: + * Best Practice + * Use `&str` and avoid `String` + * Troubleshooting + * If you try to pass a function to a child component through props with `impl Listener>` or `&dyn Fn(&mut State, MouseEvent)` or `pub fn MyChildComponent)>(onfoobar_fn: F) -> impl View {` or by passing a bound closure `pub fn MyChildComponent>>(onfoobar_fn: F) -> impl View {` using kobold::event::Listener (https://docs.rs/kobold/latest/kobold/event/trait.Listener.html) that calls a function that is defined in a parent component and manipulates the state then you may encounter errors, which is not yet supported in Kobold. In the meantime just pass a `state: &Hook` prop to the child component and interact with the state in an `onclick` handler or similar directly by containing the function in the handler. This approach is used in the commit that updated this README comment. + * Closure (e.g. `state.update(|state| state.store())` has access to Signal of state + * `update` doesn't implement Deref so you can't access fields on it like you can with a Hook + * `update_silent` gives access to the actual state without triggering a render + * State + * Tables + * all `Table` cells should be populated with `Insitu` by default, the only exception is when you have escapes in the loaded CSV. e.g. if your CSV contains quotes in quotes, the parser needs to change escapes quotes into unescaped ones, so it will allocate a String to do it in. for a value in quotes it slices with +1/-1 to skip quotes, and then for escapes it also skips quotes and then replaces escaped quotes inside. if you put something like: `"hello ""world"""` in your CSV file, that will be `Text::Owned` + * the `Table` `source` property values should be read only + * if you edit a `Table` cell, just swap it from `Insitu` to `Owned` text + * you get an owned string from `.value()` so there is no point in trying to avoid it + * loading a file prefers `Insitu` since it can just borrow all unescaped values from the `source` without allocations + * it uses `fn parse_row` in csv.rs to magically know whether to store in `Insitu` instead of `Owned`, otherwise we explicitly tell it to use `Insitu` when setting the default value `Text::Insitu(0..0)` in this file and when we edit a field in the UI so it becomes `Owned("text")` (where text is what we enter) + * `Text` is used instead of just String to avoid unnecessary allocations that are expensive, since subslicing the `source` with an `Insitu` `range` is a const operation, so it's just fiddling with a pointer and the length - so it's not exactly free, but it's as close to free as you can get. even better would be for `Insitu` to contain `&str`, but internal borrowing is a bit of a pain + +* References: + * [kobold docs](https://docs.rs/kobold/latest/kobold/) + * [wasm-bindgen docs](https://rustwasm.github.io/docs/wasm-bindgen/introduction.html) + * [web-sys File](https://rustwasm.github.io/wasm-bindgen/api/web_sys/struct.File.html#) + * [js-sys](https://docs.rs/js-sys/latest/js_sys) + * Example: https://yew.rs/docs/0.18.0/concepts/wasm-bindgen/web-sys + * [std::fs::File::create](https://doc.rust-lang.org/std/fs/struct.File.html#method.create) + +* Credits: + * Maciej Hirsz diff --git a/examples/invoice/Trunk.toml b/examples/invoice/Trunk.toml new file mode 100644 index 00000000..0670414e --- /dev/null +++ b/examples/invoice/Trunk.toml @@ -0,0 +1,6 @@ +[tools] +wasm_bindgen = "0.2.84" + +[build] +filehash = false +pattern_script = "" diff --git a/examples/invoice/data/details.csv b/examples/invoice/data/details.csv new file mode 100644 index 00000000..1324cf9e --- /dev/null +++ b/examples/invoice/data/details.csv @@ -0,0 +1,2 @@ +#details,the invoice date,invoice number,name person from,organisation name from,organisation address from,email from,name person attention to,title to,organisation name to,email to +01.04.2023,0001,luke,clawbird,2 metaverse ave,test@test.com,recipient_name,director,nftverse,test2@test.com diff --git a/examples/invoice/data/main.csv b/examples/invoice/data/main.csv new file mode 100644 index 00000000..c5c3da84 --- /dev/null +++ b/examples/invoice/data/main.csv @@ -0,0 +1,3 @@ +#main,description,total,qr +task1,10,0x0000000000000000000000000000000000000000|h160 +task2,20,0x1000000000000000000000000000000000000000|h160 diff --git a/examples/invoice/index.html b/examples/invoice/index.html new file mode 100644 index 00000000..558bc357 --- /dev/null +++ b/examples/invoice/index.html @@ -0,0 +1,38 @@ + + + + + + Kobold • Invoice example + + + + + + + + + diff --git a/examples/invoice/koboldClassExample.js b/examples/invoice/koboldClassExample.js new file mode 100644 index 00000000..8d1f0a45 --- /dev/null +++ b/examples/invoice/koboldClassExample.js @@ -0,0 +1,21 @@ +export function name() { + return 'Rust'; +} + +export class MyClass { + constructor() { + this._number = 42; + } + + get number() { + return this._number; + } + + set number(n) { + return this._number = n; + } + + render() { + return `My number is: ${this.number}`; + } +} diff --git a/examples/invoice/koboldSaveFile.js b/examples/invoice/koboldSaveFile.js new file mode 100644 index 00000000..97dda286 --- /dev/null +++ b/examples/invoice/koboldSaveFile.js @@ -0,0 +1,19 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Generates an object url for a CSV file download by automatically generating a blob url +// (to download the file from) and associates that with a temporary hyperlink that is generated. +// It then clicks that hyperlink automatically to trigger the save file prompt for the user +// before removing the temporary hyperlink. +export function koboldSaveFile(filename, data) { + const blob = new Blob([data], { type: 'application/octet-stream' }); + console.log('created blob: ', blob); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = filename; + link.click(); + console.log('clicked link'); + window.URL.revokeObjectURL(link.href); + return true; +} diff --git a/examples/invoice/src/components/ButtonAddRow.rs b/examples/invoice/src/components/ButtonAddRow.rs new file mode 100644 index 00000000..276f6c8d --- /dev/null +++ b/examples/invoice/src/components/ButtonAddRow.rs @@ -0,0 +1,32 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use kobold::prelude::*; + +use web_sys::HtmlElement; + +use crate::state::State; + +#[component] +pub fn ButtonAddRow(row: usize, state: &Hook) -> impl View { + view! { + | { + let row = match event.target().get_attribute("data") { + Some(r) => r, + None => return, + }; + let row_usize = match row.parse::() { + Ok(r) => r, + Err(e) => return, + }; + // pass row index to insert at + state.add_row_main(row_usize); + }) + } + /> + } +} diff --git a/examples/invoice/src/components/ButtonDestroyRow.rs b/examples/invoice/src/components/ButtonDestroyRow.rs new file mode 100644 index 00000000..f186d826 --- /dev/null +++ b/examples/invoice/src/components/ButtonDestroyRow.rs @@ -0,0 +1,32 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use kobold::prelude::*; + +use web_sys::HtmlElement; + +use crate::state::State; + +#[component] +pub fn ButtonDestroyRow(row: usize, state: &Hook) -> impl View { + view! { + | { + let row = match event.target().get_attribute("data") { + Some(r) => r, + None => return, + }; + let row_usize = match row.parse::() { + Ok(r) => r, + Err(e) => return, + }; + + state.destroy_row_main(row_usize); + }) + } + /> + } +} diff --git a/examples/invoice/src/components/Cell.rs b/examples/invoice/src/components/Cell.rs new file mode 100644 index 00000000..32d27d57 --- /dev/null +++ b/examples/invoice/src/components/Cell.rs @@ -0,0 +1,123 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; +use web_sys::{EventTarget, HtmlElement, HtmlInputElement as InputElement, UiEvent}; + +use kobold::branching::Branch7; +use kobold::prelude::*; + +use crate::components::{ + ButtonAddRow::ButtonAddRow, ButtonDestroyRow::ButtonDestroyRow, QRForTask::QRForTask, +}; +use crate::js; +use crate::state::{Editing, State, Text}; + +#[component] +pub fn Cell(col: usize, row: usize, state: &Hook) -> impl View + '_ { + // debug!("Cell get_text source {:?} {:?}", col, row); + let value: &str; + let row_idx_below_current_row_idx = row + 1; + if col <= (state.details.table.columns.len() - 1) { + value = state + .main + .table + .source + .get_text(&state.main.table.rows[row][col]); + } else { + value = &""; + } + + if state.editing_main == (Editing::Cell { row, col }) { + let onchange = state.bind(move |state, e: Event| { + state.main.table.rows[row][col] = Text::Owned(e.target().value().into()); + state.store(); + state.editing_main = Editing::None; + }); + + let mut selected = false; + + let onmouseenter = move |e: MouseEvent| { + if !selected { + let input = e.target(); + input.focus(); + input.select(); + selected = true; + } + }; + + // only show remove row button after the last column + if col == (state.main.table.columns.len() - 1) { + Branch7::A(view! { + + { ref value } + + + + + + + + + }) + } else { + Branch7::B(view! { + + { ref value } + + + }) + } + // https://github.com/maciejhirsz/kobold/issues/51 + } else { + let ondblclick = state.bind(move |s, _| s.editing_main = Editing::Cell { row, col }); + + // TODO - should show the delete button regardless of whether the last column contains a QR code + if value.contains("0x") == true && (col == state.main.table.columns.len() - 1) { + Branch7::C(view! { + + + + + + + + + + }) + } else if value.contains("0x") == true && (col != state.main.table.columns.len() - 1) { + Branch7::D(view! { + + + + }) + } else if value.contains("0x") == false && (col == state.main.table.columns.len() - 1) { + Branch7::E(view! { + { ref value } + + + + + + + }) + } else if value.contains("0x") == false && (col != state.main.table.columns.len() - 1) { + Branch7::F(view! { + { ref value } + }) + } else { + Branch7::G(view! { + "error" + }) + } + } +} diff --git a/examples/invoice/src/components/CellDetails.rs b/examples/invoice/src/components/CellDetails.rs new file mode 100644 index 00000000..40206633 --- /dev/null +++ b/examples/invoice/src/components/CellDetails.rs @@ -0,0 +1,73 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; +use web_sys::HtmlInputElement as InputElement; + +use kobold::branching::Branch3; +use kobold::prelude::*; + +use crate::components::QRForTask::QRForTask; +use crate::state::{Editing, State, Text}; + +#[component] +pub fn CellDetails(col: usize, row: usize, state: &Hook) -> impl View + '_ { + // debug!("row/col: {:?}/{:?}", row, col); + // debug!("CellDetails get_text source {:?} {:?}", col, row); + let value: &str; + if col <= (state.details.table.columns.len() - 1) { + value = state + .details + .table + .source + .get_text(&state.details.table.rows[row][col]); + } else { + value = &""; + } + + if state.editing_details == (Editing::Cell { row, col }) { + let onchange = state.bind(move |state, e: Event| { + state.details.table.rows[row][col] = Text::Owned(e.target().value().into()); + state.store(); + state.editing_details = Editing::None; + }); + + let mut selected = false; + + let onmouseenter = move |e: MouseEvent| { + if !selected { + let input = e.target(); + input.focus(); + input.select(); + selected = true; + } + }; + + Branch3::A(view! { + + { ref value } + + + }) + // https://github.com/maciejhirsz/kobold/issues/51 + } else { + let ondblclick = state.bind(move |s, _| s.editing_details = Editing::Cell { row, col }); + + if value.contains("0x") { + Branch3::B(view! { + + + + }) + } else { + Branch3::C(view! { + { ref value } + }) + } + } +} diff --git a/examples/invoice/src/components/Editor.rs b/examples/invoice/src/components/Editor.rs new file mode 100644 index 00000000..8b344334 --- /dev/null +++ b/examples/invoice/src/components/Editor.rs @@ -0,0 +1,321 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; +use std::ops::Deref; +use web_sys::{ + EventTarget, File, FileList, HtmlElement, HtmlInputElement as InputElement, UiEvent, +}; + +use kobold::prelude::*; + +use crate::components::{ + Cell::Cell, CellDetails::CellDetails, Head::Head, HeadDetails::HeadDetails, Logo::Logo, +}; +use crate::csv; +use crate::helpers::logo_helpers::get_row_value_for_label_for_table; +use crate::js; +use crate::state::{Content, Editing, State, TableVariant}; + +// running `get(state)` returns either `state.main` or `state.details` +async fn onload_common( + table_variant: TableVariant, + get: impl Fn(&mut State) -> &mut Content, + state: Signal, + event: Event, +) { + debug!("onload_common"); + + let file = match event.target().files().and_then(|list| list.get(0)) { + Some(file) => file, + None => return, + }; + + event.target().set_value(""); + + // TODO - should only update filename if the upload in the next step was successful + state.update(|state| get(state).filename = file.name()); + + if let Ok(table) = csv::read_file(file).await { + debug!("table {:#?}", &table); + debug!("table_variant {:#?}", &table_variant); + + // https://docs.rs/kobold/latest/kobold/stateful/struct.Signal.html#method.update + state.update(move |state| { + get(state).table = table; + // we are already obtaining the table variant if it exists prefixed in the CSV file + // using in csv.rs `try_from` function, but if the table variant was not provided but + // the user uploaded the file by clicking the button that triggered `onload_details` that + // passed the relevant table variant to use as a parameter to this `onload_common` function + // then we'll use that instead, otherwise it will be unnecessary set to `TableVariant::Unknown` + if get(state).table.variant == TableVariant::Unknown { + get(state).table.variant = table_variant; + } + state.store(); // update local storage + }); + } +} + +// we don't need to pass type `TableVariant` as a parameter since anything we've uploaded to be saved +// should already have a variant associated with it, otherwise it'll be processed as `TableVariant::Unknown`. +// so we need to prefix the saved file's stringified value with its variant (e.g. `#details,\n...`), +// in the function `generate_csv_data_for_download` so it's ready to be processed if they re-uploaded it again later. +async fn onsave_common( + get: impl Fn(&mut State) -> &mut Content, + state: Signal, + event: MouseEvent, +) { + // closure required just to debug with access to state fields, since otherwise it'd trigger a render + state.update_silent(|state| debug!("onsave: {:?}", &get(state))); + + state.update(|state| { + // update local storage and state so that &state.details isn't + // `Content { filename: "\0\0\0\0\0\0\0", table: Table { source: TextSource + // { source: "\0" }, columns: [Insitu(0..0)], rows: [] } }` + state.store(); + + match csv::generate_csv_data_for_download(&get(state)) { + Ok(csv_data) => { + debug!("csv_data {:?}", csv_data); + // cast String into a byte slice + let csv_data_byte_slice: &[u8] = csv_data.as_bytes(); + js::browser_js::run_save_file(&get(state).filename, csv_data_byte_slice); + } + Err(err) => { + panic!("failed to generate csv data for download: {:?}", err); + } + }; + debug!("successfully generated csv data for download"); + }); +} + +fn get_files_for_file_list(file_list: web_sys::FileList) -> Vec { + debug!("file_list {:?}", file_list); + let mut no_more_files = false; + let mut files: Vec = vec![]; + let mut i: usize = 0; + + while no_more_files == false { + let iter_file = file_list.get(i.try_into().unwrap()); + match iter_file { + Some(file) => { + debug!("found file {:?}", file); + + files.push(file.clone()); + i = i + 1; + continue; + } + None => { + debug!("no more files found"); + no_more_files = true; + } + } + } + files +} + +// only support uploads where the user specifies the variant at the start of the file. +// for example the file for the Main table must be prefixed with `#main,` and the file +// for the Details table must be prefixed with `#details,`. +// to select multiple files, press CTRL or CMD during the process of selecting both files. +async fn onload_multiple_process(state: Signal, event: Event) { + let file_list: web_sys::FileList = match event.target().files() { + Some(f) => f, + None => return, + }; + let files: Vec = get_files_for_file_list(file_list); + debug!("files {:#?}", files); + debug!("files.len() {:#?}", files.len()); + + event.target().set_value(""); + + for (i, file) in files.iter().enumerate() { + if let Ok(table) = csv::read_file(file.clone()).await { + debug!("table {:#?}", &table); + // get the variant from the loaded file i.e. `#main` + debug!("table.variant {:#?}", &table.variant); + + // https://docs.rs/kobold/latest/kobold/stateful/struct.Signal.html#method.update + state.update(move |state| { + match table.variant { + TableVariant::Main => { + state.main.table = table; + state.main.filename = file.name(); + state.store(); // update local storage + } + TableVariant::Details => { + state.details.table = table; + state.details.filename = file.name(); + state.store(); // update local storage + } + TableVariant::Unknown => panic!("unsupported variant in file"), + _ => panic!("unsupported variant in file"), + } + }); + } + } +} + +#[component] +pub fn Editor() -> impl View { + stateful(State::default, |state| { + debug!("Editor()"); + + // "closure needs to return the future onload_common returns for async_bind to work" - Maciej + let onload_details = state.bind_async(|state, event: Event| { + debug!("onload_details"); + onload_common( + TableVariant::Details, + |state| &mut state.details, + state, + event, + ) + }); + + let onload_main = state.bind_async(|state, event: Event| { + onload_common(TableVariant::Main, |state| &mut state.main, state, event) + }); + + let onload_multiple = state + .bind_async(|state, event: Event| onload_multiple_process(state, event)); + + let onsave_details = state.bind_async(|state, event: MouseEvent| { + onsave_common(|state| &mut state.details, state, event) + }); + + let onsave_main = state.bind_async(|state, event: MouseEvent| { + onsave_common(|state| &mut state.main, state, event) + }); + + let label_to_search_for = "organisation name from".to_string(); + let process_row_value_for_label_for_table = + |label: &str| -> String { get_row_value_for_label_for_table(&label, &state) }; + + view! { +
+
+
+
+

"Invoice"

+
+ +
+
+
+
+
+
+ + + + // +
+
"Instructions: Upload two table files at once by holding down CMD or CTRL. One file prefixed with '#main,' and a second prefixed with '#details,'."
+
+

"Details table"

+
+ // https://stackoverflow.com/a/48499451/3208553 + + + + +
+

+ | { + if matches!(event.key().as_str(), "Esc" | "Escape") { + state.editing_details = Editing::None; + + Then::Render + } else { + Then::Stop + } + }) + } + > + + + { + for state.details.table.columns().map(|col| view! { + + }) + } + + + + + { + for state.details.table.columns().map(|col| view! { + + }) + } + + +
+
+
+

"Main table"

+
+ + + + +
+

+ | { + if matches!(event.key().as_str(), "Esc" | "Escape") { + state.editing_main = Editing::None; + + Then::Render + } else { + Then::Stop + } + }) + } + > + + + { + for state.main.table.columns().map(|col| view! { + + }) + } + + + + { + for state.main.table.rows().map(move |row| view! { + + { + for state.main.table.columns().map(move |col| view! { + + }) + } + + }) + } + +
+
+
+
+ { + ( + state.editing_main == Editing::None && + state.editing_details == Editing::None + ).then(|| view! { +

"Hint: Double-click to edit an invoice field"

+ }) + } +
+
+ } + }) +} diff --git a/examples/invoice/src/components/Head.rs b/examples/invoice/src/components/Head.rs new file mode 100644 index 00000000..1e4a36a6 --- /dev/null +++ b/examples/invoice/src/components/Head.rs @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; +use web_sys::{HtmlElement, HtmlInputElement as InputElement}; + +use kobold::branching::Branch3; +use kobold::prelude::*; + +use crate::components::ButtonAddRow::ButtonAddRow; +use crate::state::{Editing, State, Text}; + +#[component] +pub fn Head(col: usize, row: usize, state: &Hook) -> impl View + '_ { + // debug!("Head get_text source {:?} {:?}", col, row); + let value: &str; + if col <= (state.details.table.columns.len() - 1) { + value = state + .main + .table + .source + .get_text(&state.main.table.columns[col]); + } else { + value = &""; + } + + if state.editing_main == (Editing::Column { col }) { + let onchange = state.bind(move |state, e: Event| { + state.main.table.columns[col] = Text::Owned(e.target().value().into()); + state.store(); + state.editing_main = Editing::None; + }); + + Branch3::A(view! { + + { ref value } + + + }) + } else { + let ondblclick = state.bind(move |s, _| s.editing_main = Editing::Column { col }); + + if col == (state.main.table.columns.len() - 1) { + Branch3::B(view! { + { ref value } + // for the add row button column + + + + // for the destroy row button column + + }) + } else { + Branch3::C(view! { + { ref value } + }) + } + } +} diff --git a/examples/invoice/src/components/HeadDetails.rs b/examples/invoice/src/components/HeadDetails.rs new file mode 100644 index 00000000..07d012e3 --- /dev/null +++ b/examples/invoice/src/components/HeadDetails.rs @@ -0,0 +1,48 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; +use web_sys::HtmlInputElement as InputElement; + +use kobold::prelude::*; + +use crate::state::{Editing, State, Text}; + +#[component(auto_branch)] +pub fn HeadDetails(col: usize, row: usize, state: &Hook) -> impl View + '_ { + // debug!("row/col: {:?}/{:?}", row, col); + // debug!("HeadDetails get_text source {:?} {:?}", col, row); + let value: &str; + if col <= (state.details.table.columns.len() - 1) { + value = &state + .details + .table + .source + .get_text(&state.details.table.columns[col]); + } else { + value = &""; + } + + if state.editing_details == (Editing::Column { col }) { + let onchange = state.bind(move |state, e: Event| { + state.details.table.columns[col] = Text::Owned(e.target().value().into()); + state.store(); + state.editing_details = Editing::None; + }); + + view! { + + { ref value } + + + } + } else { + let ondblclick = state.bind(move |s, _| s.editing_details = Editing::Column { col }); + + view! { { ref value } } + } +} diff --git a/examples/invoice/src/components/Logo.rs b/examples/invoice/src/components/Logo.rs new file mode 100644 index 00000000..6873b053 --- /dev/null +++ b/examples/invoice/src/components/Logo.rs @@ -0,0 +1,29 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use kobold::prelude::*; + +#[component( + // using `?` makes the property an optional parameter and falls back to the default + alt?: "logo".to_string(), + caption?: "Kobold".to_string(), + // TODO - handle SVG images + // image_url?: "https://raw.githubusercontent.com/maciejhirsz/kobold/master/kobold.svg", + image_url?: "https://github.githubassets.com/images/mona-loading-default.gif", + width?: "70px", + height?: "100px", +)] +pub fn Logo<'a>( + alt: String, + caption: String, + height: &'a str, + width: &'a str, + image_url: &'a str, +) -> impl View + 'a { + view! { +
+ {alt.to_string()} + { caption.to_string() } + } +} diff --git a/examples/invoice/src/components/QRForTask.rs b/examples/invoice/src/components/QRForTask.rs new file mode 100644 index 00000000..31c30dc6 --- /dev/null +++ b/examples/invoice/src/components/QRForTask.rs @@ -0,0 +1,27 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; + +use kobold::prelude::*; +use kobold_qr::KoboldQR; + +use crate::components::util::sword::sword; + +#[component] +pub fn QRForTask(value: &str) -> impl View + '_ { + let (left, right): (&str, &str) = sword(value); + // assert_eq!(&v, &Vec::from(["0x100", "h160"])); + debug!("{:#?} {:#?}", &left, &right); + let data: &str = left; + let format: &str = right; + + view! { + + +
{data}
+
{format}
+
+ } +} diff --git a/examples/invoice/src/components/mod.rs b/examples/invoice/src/components/mod.rs new file mode 100644 index 00000000..56d420ce --- /dev/null +++ b/examples/invoice/src/components/mod.rs @@ -0,0 +1,11 @@ +pub mod ButtonAddRow; +pub mod ButtonDestroyRow; +pub mod Cell; +pub mod CellDetails; +pub mod Editor; +pub mod Head; +pub mod HeadDetails; +pub mod Logo; +pub mod QRForTask; + +pub mod util; diff --git a/examples/invoice/src/components/util/mod.rs b/examples/invoice/src/components/util/mod.rs new file mode 100644 index 00000000..00df1c89 --- /dev/null +++ b/examples/invoice/src/components/util/mod.rs @@ -0,0 +1 @@ +pub mod sword; diff --git a/examples/invoice/src/components/util/sword.rs b/examples/invoice/src/components/util/sword.rs new file mode 100644 index 00000000..0171ef1f --- /dev/null +++ b/examples/invoice/src/components/util/sword.rs @@ -0,0 +1,9 @@ +// Credit: maciejhirsz +pub fn sword(input: &str) -> (&str, &str) { + let (left, right) = match input.split_once('|') { + Some(res) => res, + None => panic!("unable to sword"), + }; + + (left.trim(), right.trim()) +} diff --git a/examples/invoice/src/csv.rs b/examples/invoice/src/csv.rs new file mode 100644 index 00000000..48050966 --- /dev/null +++ b/examples/invoice/src/csv.rs @@ -0,0 +1,459 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::{debug, error}; +use logos::{Lexer, Logos}; +use std::str::FromStr; +use take_mut::take; +use wasm_bindgen_futures::JsFuture; +use web_sys::{File, Url}; + +use crate::helpers::csv_helpers; +use crate::state::{Content, Table, TableVariant, Text, TextSource}; + +#[derive(Logos, Debug, PartialEq)] +enum Token { + #[error] + Err, + // If we use `#` as the character before the TableVariant is mentioned + // then we cannot use that character elsewhere in the Table `source` value + #[regex(r#"[#].+"#, priority = 7)] + Hash, + #[token(",")] + Comma, + #[regex(r"[\n\r]+")] + Newline, + #[regex(r#"[^"\n\r,#]+"#)] + Value, + #[regex(r#""[^"]+""#, priority = 6)] + QuotedValue, + #[regex(r#""([^"]|"")+""#)] + EscapedValue, +} + +#[derive(Debug)] +pub enum Error { + NoData, + FailedToBufferFile, + FailedToReadFile, + FailedToWriteFile, + FailedToLoadMetadata, + InvalidRowLength, + MustBeAtLeastOneRowData, + MustBeAtLeastOneColumnData, + MustBeSameColumnLengthOnAllRows, + MustBeTwoRowsIncludingLabelsRowDataRow, + TableVariantUnsupported, +} + +fn parse_table_variant(lex: &mut Lexer, columns: usize) -> Result { + let mut table_variant = TableVariant::Unknown; + + while let Some(token) = lex.next() { + match token { + Token::Hash => { + let mut slice = lex.slice(); + // lookahead and lookbehind are not supported `.+?(?=,)` or `.+?(,)` + // so manually have to get value between @ and next comma + + // https://stackoverflow.com/a/37784410/3208553 + let start_bytes = slice.find("#").unwrap_or(0); + let end_bytes = slice.find(",").unwrap_or(slice.len()); + let result = &slice[(start_bytes + 1)..end_bytes]; + debug!("parse_row result {:?}", result); + table_variant = match result { + "main" => TableVariant::Main, + "details" => TableVariant::Details, + _ => return Err(Error::TableVariantUnsupported), + }; + } + Token::Value => continue, + Token::QuotedValue => break, + Token::EscapedValue => break, + Token::Comma => break, + Token::Newline => break, + Token::Err => break, + // allow users to not bother using a table variant + _ => debug!("no table variant"), + } + + if token == Token::Comma { + break; + } + } + Ok(table_variant) +} + +fn parse_row(lex: &mut Lexer, columns: usize) -> Result>, Error> { + let mut row = Vec::with_capacity(columns); + let mut value = None; + + while let Some(token) = lex.next() { + let slice = lex.slice(); + debug!("slice {:?}", slice); + + value = match token { + Token::Value => Some(Text::Insitu(lex.span())), + Token::QuotedValue => { + let mut span = lex.span(); + + span.start += 1; + span.end -= 1; + + Some(Text::Insitu(span)) + } + Token::EscapedValue => { + let mut slice = lex.slice(); + + slice = &slice[1..slice.len() - 1]; + + Some(Text::Owned(slice.replace("\"\"", "\"").into())) + } + Token::Comma => { + row.push(value.take().unwrap_or_default()); + continue; + } + Token::Newline => { + row.push(value.take().unwrap_or_default()); + break; + } + Token::Err => break, + _ => break, + } + } + + if let Some(value) = value { + row.push(value); + } + + debug!( + "match columns row.len(), \n{:?}, \n{:?}", + columns, + row.len() + ); + + match (columns, row.len()) { + (_, 0) => Ok(None), + (0, _) => Ok(Some(row)), + (n, r) => { + if n > r { + row.resize_with(n, Text::default); + } + + if r > n { + Err(Error::InvalidRowLength) + } else { + Ok(Some(row)) + } + } + } +} + +impl TryFrom for Table { + type Error = Error; + + fn try_from(source: String) -> Result { + let mut table_variant = TableVariant::Unknown; + let mut lex_just_to_get_variant = Token::lexer(&source); + table_variant = match parse_table_variant(&mut lex_just_to_get_variant, 0) { + Ok(variant) => variant, + // FIXME - if i upload a file with prefix `#blah,...` it does not propagate and + // show the error in the browser console + // from parse_table_variant for some reason, it just stops execution + // Err(err) => return Err(Error::TableVariantUnsupported), + Err(err) => panic!("table variant unsupported"), + }; + + let mut trunc_source = source.clone(); + if table_variant != TableVariant::Unknown { + debug!("table_variant {:?}", &table_variant); + // we've obtained the `table_variant` from the file that's being uploaded + // and we'll store that in the `Table` state, but if it + // was not `TableVariant::Unknown` and it was a valid variant then we need + // to remove it from the `source` so we'll create another version of it removed so + // it doesn't interfere with processing the rest of the source + + let binding = trunc_source.find(","); + let first_comma_index = match &binding { + Some(idx) => idx, + None => panic!("must be a comma after the table variant in source for it to exist"), + }; + debug!("first_comma_index {:?}", &first_comma_index); + trunc_source = (&trunc_source[first_comma_index + 1..]).to_string(); + debug!("trunc_source {:?}", &trunc_source); + } + + // process without the truncated source + let mut lex = Token::lexer(&trunc_source); + + let columns = parse_row(&mut lex, 0)?.ok_or(Error::NoData)?; + debug!("columns {:?}", &columns); + + let mut rows = Vec::new(); + + while let Some(row) = parse_row(&mut lex, columns.len())? { + debug!("row {:?}", &row); + rows.push(row); + } + debug!("rows {:?}", &rows); + + Ok(Table { + variant: table_variant, + source: TextSource::from(trunc_source), + columns, + rows, + }) + } +} + +impl FromStr for Table { + type Err = Error; + + fn from_str(s: &str) -> Result { + s.to_owned().try_into() + } +} + +pub async fn read_file(file: File) -> Result { + let text = JsFuture::from(file.text()) + .await + .map_err(|_| Error::FailedToReadFile)? + .as_string() + .ok_or(Error::FailedToReadFile)?; + + text.parse() +} + +fn validate_same_columns_length_all_rows( + new_csv: &Vec>, + new_csv_lens: &Vec, +) -> Result<(), Error> { + let is_not_all_same = + |new_csv: &[usize]| -> bool { new_csv.iter().min() != new_csv.iter().max() }; + debug!("is_not_all_same {:?}", is_not_all_same(&new_csv_lens)); + if is_not_all_same(&new_csv_lens) == true { + return Err(Error::MustBeSameColumnLengthOnAllRows); + } + Ok(()) +} + +// the TableVariant::Main has a single label row, and then multiple data rows under it in the CSV file. +// +// it needs to be processed differently from TableVariant::Details that has only a single label row, +// a single data row. +pub fn generate_csv_data_for_download(content: &Content) -> Result { + // generate CSV file format from object Url in state + // https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=f911a069c22a7f4cf4b5e8a9aa05e65e + let table_variant = &content.table.variant; + + match table_variant { + TableVariant::Main => { + let binding_source = &content.table.source.source; + let original_csv: Vec<&str> = binding_source.split(&['\n'][..]).collect(); + debug!("original_csv {:?}", original_csv); + + let mut new_csv: Vec> = vec![]; + let mut new_csv_lens: Vec = vec![]; + let padding = "".to_string(); + csv_helpers::pad_csv_data(&original_csv, &mut new_csv, &mut new_csv_lens, &padding); + + debug!( + "content.table.columns.len() {:?}", + content.table.columns.len() + ); + // validate qty of columns + if content.table.columns.len() < 1 { + return Err(Error::MustBeAtLeastOneColumnData); + } + debug!("new_csv_lens {:?}", new_csv_lens); + + validate_same_columns_length_all_rows(&new_csv, &new_csv_lens)?; + + let mut arr = vec![]; + let prefix = "#".to_string(); + let table_variant_str = "main".to_string(); + let prefix_table_variant: String = format!("{prefix}{table_variant_str},"); + + // only one column so we'll process that first before the rows + let new_csv_labels_stringified: String = + update_csv_row_for_modified_table_cells(&content.table.columns, &mut new_csv[0]); // labels + + let combined = format!("{}{}", prefix_table_variant, new_csv_labels_stringified); + arr.push(combined); + debug!("arr combined: {:?}", arr); + + let content_table_rows = content.table.rows.clone(); + + debug!("content_table_rows {:?}", content_table_rows); + debug!("content_table_rows.len() {:?}", content_table_rows.len()); + // validate qty of rows + if content_table_rows.len() < 1 { + error!("{:?}", Error::MustBeAtLeastOneRowData); + return Ok(arr.join("\n")); // Err(Error::MustBeAtLeastOneRowData); + } + + // if the user add any additional rows to `content_table_rows`, then we need to add a + // new row in the same index to new_csv. + // since new_csv contains the label row, we subtract 1 from new_csv since we don't + // want to include the label row in the length, since `content_table_rows` doesn't have + // the labels row. + let diff = match content_table_rows.len().checked_sub(new_csv.len() - 1) { + Some(d) => d, + None => 0, + }; + if diff > 0 { + for (i, row) in content_table_rows.iter().enumerate() { + // count how many elements in the row equal Owned(""). + // if they're all `Owned("")` then that index was a row added by the user + let mut count_same: i32 = 0; + row.into_iter().enumerate().for_each(|(j, element)| { + match element { + Text::Owned(s) => { + if s.to_string() == "".to_string() { + count_same += 1; + } else { + // TODO - how to `continue` to next iteration in closure? + // using something like `debug!("")` is dumb + debug!(""); // continue + } + } + _ => debug!(""), // continue + } + }); + // if they're all the same value of Owned("") on this row, + // then it was a row added to this index i by the user + if row.len() == count_same as usize { + if new_csv.get(i + 1).is_some() { + new_csv.insert(i + 1, vec!["", "", ""]); + } else { + // for rows added by the user to the end of + // `content_table_rows` that don't exist + // in `new_csv` then push them to the end + new_csv.push(vec!["", "", ""]); + } + } + } + } + + // debug!("new_csv {:?}", new_csv); + // debug!("new_csv_lens {:?}", new_csv_lens); + // debug!("content.table.rows {:?}", content.table.rows); + + // multiple rows so we'll push each of them now + content_table_rows + .into_iter() + .enumerate() + .for_each(|(i, row_data)| { + let new_csv_data_stringified: String = update_csv_row_for_modified_table_cells( + &content.table.rows[i], + // start with `+ 1` since `new_csv[0]` are its labels but we're only doing the rows + &mut new_csv[i + 1], + ); // values row 1 + arr.push(new_csv_data_stringified); + }); + + let content_serialized: String = arr.join("\n"); + debug!("content_serialized {:?}", content_serialized); + + return Ok(content_serialized); + } + TableVariant::Details => { + let binding_source = &content.table.source.source; + let original_csv: Vec<&str> = binding_source.split(&['\n'][..]).collect(); + debug!("original_csv {:?}", original_csv); + + let mut new_csv: Vec> = vec![]; + let mut new_csv_lens: Vec = vec![]; + let padding = "".to_string(); + csv_helpers::pad_csv_data(&original_csv, &mut new_csv, &mut new_csv_lens, &padding); + + debug!( + "content cols rows {:?} {:?}", + content.table.columns.len(), + content.table.rows.len() + ); + // validate qty of rows + if content.table.columns.len() != (1 as usize) + && content.table.rows.len() != (1 as usize) + { + return Err(Error::MustBeTwoRowsIncludingLabelsRowDataRow); + } + + debug!("new_csv_lens {:?}", new_csv_lens); + + validate_same_columns_length_all_rows(&new_csv, &new_csv_lens)?; + + let mut arr = vec![]; + let prefix = "#".to_string(); + let table_variant_str = "details".to_string(); + let prefix_table_variant: String = format!("{prefix}{table_variant_str},"); + + let new_csv_labels_stringified: String = + update_csv_row_for_modified_table_cells(&content.table.columns, &mut new_csv[0]); + let combined = format!("{}{}", prefix_table_variant, new_csv_labels_stringified); + arr.push(combined); + debug!("arr combined: {:?}", arr); + + let new_csv_values_stringified: String = + update_csv_row_for_modified_table_cells(&content.table.rows[0], &mut new_csv[1]); + arr.push(new_csv_values_stringified); + + let content_serialized: String = arr.join("\n"); + debug!("content_serialized {:?}", content_serialized); + + return Ok(content_serialized); + } + TableVariant::Unknown => { + return Err(Error::TableVariantUnsupported); + } + _ => { + return Err(Error::TableVariantUnsupported); + } + }; +} + +pub fn update_csv_row_for_modified_table_cells<'a>( + cells: &'a Vec, + csv_row: &mut Vec<&'a str>, +) -> String { + let _ = &cells.into_iter().enumerate().for_each(|(i, el)| { + match el { + Text::Insitu(r) => {} + Text::Owned(s) => { + // let len = csv_row.len() - 1; + // https://users.rust-lang.org/t/replacing-element-of-vector/57258/3 + // use `take` so we have a closure that must return a valid T otherwise + // the closure panics and program aborts incase it panics before we've + // finished the process of swapping for the new value + take(csv_row, |mut cr| { + // Note: Do not need this lengthy approach. Possibly don't need + // `take_mut` either. + // // removes elem at index i and swaps last elem into old index i + // let old_cell_data = &cr.swap_remove(i); + // cr.push(s); // push new elem to end of vector + // cr.swap(i, len); // swap new elem into index i + // debug!("replaced {:?} with {:?}", old_cell_data, s); + + // Note: This is a simpler approach to replacing the value + core::mem::replace(&mut cr[i], s); + cr // must return valid T or it panics + }); + } + } + }); + // debug!("{:?}", csv_row); + let mut c = 0; + let new_csv_data_stringified: String = csv_row + .iter() + .map(|text| { + if c == csv_row.len() - 1 { + c += 1; + return text.to_string(); + } + c += 1; + return text.to_string() + ","; + }) + .collect::(); + new_csv_data_stringified +} diff --git a/examples/invoice/src/helpers/csv_helpers.rs b/examples/invoice/src/helpers/csv_helpers.rs new file mode 100644 index 00000000..384f2824 --- /dev/null +++ b/examples/invoice/src/helpers/csv_helpers.rs @@ -0,0 +1,54 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; + +// incase there are more columns in some rows than others we will pad them for processing +pub fn pad_csv_data<'a>( + original_csv: &Vec<&'a str>, + new_csv: &mut Vec>, + new_csv_lens: &mut Vec, + padding: &'a str, +) { + let mut old_csv: Vec> = vec![]; + let mut old_csv_lens: Vec = vec![]; + original_csv + .into_iter() + .enumerate() + .for_each(|(i, row_data)| { + let data: Vec<&str> = row_data.split(",").collect(); + old_csv.push(data.clone()); + old_csv_lens.push(data.len()); + }); + + let old_csv_lens_most_columns = old_csv_lens.iter().max().unwrap(); + debug!("old_csv {:?}", old_csv); + debug!("old_csv_lens_most_columns {:?}", old_csv_lens_most_columns); + + old_csv.into_iter().enumerate().for_each(|(i, row_data)| { + debug!("row_data {:?}", row_data); + let mut data = row_data.clone(); + let mut data_len = &data.len(); + + // incase the uploaded data has an extra column on the right with only + // a label with cell data but no data for the other rows in that column, + // e.g. "description,total,qr,aaa\neat,1,0x0,\nsleep,2,0x1," + // then we need to manually add the extra row values here so we don't + // get index out of bounds error when swapping values in + // function `update_csv_row_for_modified_table_cells` + if &data_len < &old_csv_lens_most_columns { + // resize to add padding to this row_data with empty string "" so + // has the same as the longest length + data.resize(*old_csv_lens_most_columns, &padding); + } + // create longer lived data length value + let mut data_len = &data.len(); // update after resize + debug!("data {:?}", &data); + + new_csv.push(data); + new_csv_lens.push(*data_len); + }); + debug!("new_csv {:?}", new_csv); + debug!("new_csv_lens {:?}", new_csv_lens); +} diff --git a/examples/invoice/src/helpers/logo_helpers.rs b/examples/invoice/src/helpers/logo_helpers.rs new file mode 100644 index 00000000..34ed74bf --- /dev/null +++ b/examples/invoice/src/helpers/logo_helpers.rs @@ -0,0 +1,48 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use heck::ToTitleCase; +use log::{debug, error}; + +use kobold::prelude::*; + +use crate::state::State; + +// fetch data from the first row of data for a table +pub fn get_row_value_for_label_for_table(label_to_search_for: &str, state: &State) -> String { + let binding_source = &state.details.table.source.source; + let binding = binding_source.find("\n"); + let first_newline_index = match &binding { + Some(i) => i, + None => panic!("must be a newline after the labels row in source"), + }; + let labels_row_str = match binding_source.get(0..*first_newline_index) { + Some(s) => s.to_string(), + None => panic!("unable to get labels row from source"), + }; + let labels_row_vec: Vec<&str> = labels_row_str.split(&[','][..]).collect(); + + let col_org_name_idx = match labels_row_vec + .iter() + .position(|&label| label == label_to_search_for) + { + Some(i) => i, + None => { + error!("unable to find index of org_name label in labels"); + return "unknown".to_string(); + } + }; + // debug!("get_row_value_for_label_for_table get_text source {:?}", col_org_name_idx); + let mut org_name = "".to_string(); + if col_org_name_idx <= (state.details.table.columns.len() - 1) { + org_name = state + .details + .table + .source + .get_text(&state.details.table.rows[0][col_org_name_idx]) + .to_string(); + } + let org_name_caption: String = format!("{}", org_name.to_title_case()); + org_name_caption +} diff --git a/examples/invoice/src/helpers/mod.rs b/examples/invoice/src/helpers/mod.rs new file mode 100644 index 00000000..2d68821a --- /dev/null +++ b/examples/invoice/src/helpers/mod.rs @@ -0,0 +1,2 @@ +pub mod csv_helpers; +pub mod logo_helpers; diff --git a/examples/invoice/src/js/browser_js.rs b/examples/invoice/src/js/browser_js.rs new file mode 100644 index 00000000..b4607f2f --- /dev/null +++ b/examples/invoice/src/js/browser_js.rs @@ -0,0 +1,57 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use wasm_bindgen::prelude::*; + +// interfaces +use crate::js::interfaces::browser_js_spec_alert; +use crate::js::interfaces::non_browser_js_spec_class_example as class_example; +use crate::js::interfaces::non_browser_js_spec_save_file as save_file; + +#[macro_use] +use crate::js::interfaces::browser_js_spec_macros; + +// https://rustwasm.github.io/docs/wasm-bindgen/examples/without-a-bundler.html +#[wasm_bindgen] +pub fn run() -> Result<(), JsValue> { + check_window(); + greet("hi".to_string().as_str()); + + run_non_browser_js(); + + Ok(()) +} + +#[wasm_bindgen] +pub fn greet(name: &str) -> JsValue { + let age: JsValue = 4.into(); + // browser_js_spec_alert::alert(&format!("Hello, {:?}! {:?}", name, &age)); + return age; +} + +#[wasm_bindgen] +pub fn check_window() { + let window = web_sys::window().expect("no global `window` exists"); + let document = window.document().expect("should have a document on window"); + let body = document.body().expect("document should have a body"); +} + +#[wasm_bindgen] +pub fn run_non_browser_js() { + // https://rustwasm.github.io/docs/wasm-bindgen/examples/console-log.html + // crate::console_log!("class_example::name {:?}", class_example::name()); + let x = class_example::MyClass::new(); + assert_eq!(x.number(), 42); + x.set_number(10); + // crate::console_log!("class_example::MyClass::render() {:?}", &x.render()); +} + +#[wasm_bindgen] +pub fn run_save_file(filename: &str, data: &[u8]) { + let has_saved_file = save_file::kobold_save_file(filename, data); + crate::console_log!( + "save_file::kobold_save_file(filename, data) {:?}", + has_saved_file + ); +} diff --git a/examples/invoice/src/js/interfaces/browser_js_spec_alert.rs b/examples/invoice/src/js/interfaces/browser_js_spec_alert.rs new file mode 100644 index 00000000..7be7b6cc --- /dev/null +++ b/examples/invoice/src/js/interfaces/browser_js_spec_alert.rs @@ -0,0 +1,6 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + pub fn alert(s: &str); +} diff --git a/examples/invoice/src/js/interfaces/browser_js_spec_macros.rs b/examples/invoice/src/js/interfaces/browser_js_spec_macros.rs new file mode 100644 index 00000000..79b54dc2 --- /dev/null +++ b/examples/invoice/src/js/interfaces/browser_js_spec_macros.rs @@ -0,0 +1,15 @@ +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console)] + pub fn log(s: &str); +} + +#[macro_export] +macro_rules! console_log { + // uses the `log` function + ($($t:tt)*) => { + ($crate::js::interfaces::browser_js_spec_macros::log(&format_args!($($t)*).to_string())) + } +} diff --git a/examples/invoice/src/js/interfaces/mod.rs b/examples/invoice/src/js/interfaces/mod.rs new file mode 100644 index 00000000..55aed779 --- /dev/null +++ b/examples/invoice/src/js/interfaces/mod.rs @@ -0,0 +1,7 @@ +pub mod browser_js_spec_alert; + +pub mod non_browser_js_spec_class_example; +pub mod non_browser_js_spec_save_file; + +#[macro_use] +pub mod browser_js_spec_macros; diff --git a/examples/invoice/src/js/interfaces/non_browser_js_spec_class_example.rs b/examples/invoice/src/js/interfaces/non_browser_js_spec_class_example.rs new file mode 100644 index 00000000..eb0d923d --- /dev/null +++ b/examples/invoice/src/js/interfaces/non_browser_js_spec_class_example.rs @@ -0,0 +1,26 @@ +use wasm_bindgen::prelude::*; + +// interface +// +// Note: currently only supports loading .js files located in the root of the Rust project directory. +// See https://github.com/rustwasm/wasm-bindgen/tree/main/examples/import_js/crate +#[wasm_bindgen(module = "/koboldClassExample.js")] +extern "C" { + // function + pub fn name() -> String; + + // class + pub type MyClass; + + #[wasm_bindgen(constructor)] + pub fn new() -> MyClass; + + #[wasm_bindgen(method, getter)] + pub fn number(this: &MyClass) -> u32; + + #[wasm_bindgen(method, setter)] + pub fn set_number(this: &MyClass, number: u32) -> MyClass; + + #[wasm_bindgen(method)] + pub fn render(this: &MyClass) -> String; +} diff --git a/examples/invoice/src/js/interfaces/non_browser_js_spec_save_file.rs b/examples/invoice/src/js/interfaces/non_browser_js_spec_save_file.rs new file mode 100644 index 00000000..0b8ef05b --- /dev/null +++ b/examples/invoice/src/js/interfaces/non_browser_js_spec_save_file.rs @@ -0,0 +1,16 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use wasm_bindgen::prelude::*; + +// interface +// +// Note: currently only supports loading .js files located in the root of the Rust project directory. +// See https://github.com/rustwasm/wasm-bindgen/tree/main/examples/import_js/crate +#[wasm_bindgen(module = "/koboldSaveFile.js")] +extern "C" { + // function + #[wasm_bindgen(js_name = "koboldSaveFile")] + pub fn kobold_save_file(filename: &str, data: &[u8]) -> bool; +} diff --git a/examples/invoice/src/js/mod.rs b/examples/invoice/src/js/mod.rs new file mode 100644 index 00000000..8aa09a79 --- /dev/null +++ b/examples/invoice/src/js/mod.rs @@ -0,0 +1,2 @@ +pub mod browser_js; +pub mod interfaces; diff --git a/examples/invoice/src/main.rs b/examples/invoice/src/main.rs new file mode 100644 index 00000000..b0fc71bd --- /dev/null +++ b/examples/invoice/src/main.rs @@ -0,0 +1,30 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use log::debug; +use web_sys::{EventTarget, HtmlElement, HtmlInputElement as InputElement}; + +use kobold::prelude::*; + +mod components; +mod csv; +mod helpers; +mod js; +mod state; +#[cfg(test)] +mod tests; +use components::{ + Cell::Cell, CellDetails::CellDetails, Editor::Editor, Head::Head, HeadDetails::HeadDetails, +}; +use state::{Editing, State}; + +fn main() { + // Demonstrate use of Rust `wasm-bindgen` https://rustwasm.github.io/docs/wasm-bindgen + js::browser_js::run(); + + wasm_logger::init(wasm_logger::Config::default()); + kobold::start(view! { + + }); +} diff --git a/examples/invoice/src/state.rs b/examples/invoice/src/state.rs new file mode 100644 index 00000000..f82fc6ea --- /dev/null +++ b/examples/invoice/src/state.rs @@ -0,0 +1,209 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use gloo_storage::{LocalStorage, Storage}; +use log::{debug, error, info, warn}; +use serde::{Deserialize, Serialize}; +use std::convert::TryInto; +use std::ops::{Deref, DerefMut, Range}; +use wasm_bindgen::UnwrapThrowExt; + +const KEY_MAIN: &str = "kobold.invoice.main"; +const KEY_DETAILS: &str = "kobold.invoice.details"; + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub enum TableVariant { + Main, + Details, + Unknown, +} + +#[derive(Deserialize, Debug)] +pub enum Error { + StorageError, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Debug)] +pub enum Editing { + None, + Column { col: usize }, + Cell { col: usize, row: usize }, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Content { + pub filename: String, + pub table: Table, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct State { + pub editing_main: Editing, + pub editing_details: Editing, + pub main: Content, + pub details: Content, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Table { + pub variant: TableVariant, + pub source: TextSource, + pub columns: Vec, + pub rows: Vec>, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum Text { + Insitu(Range), + Owned(Box), +} + +impl Default for Text { + fn default() -> Self { + Text::Insitu(0..0) + } +} + +impl Default for State { + fn default() -> Self { + let main_local_storage: Table = match LocalStorage::get(KEY_MAIN) { + Ok(local_storage) => local_storage, + Err(err) => Table::mock_file_main(), + }; + let details_local_storage: Table = match LocalStorage::get(KEY_DETAILS) { + Ok(local_storage) => local_storage, + // TODO - check that this actually converts to Table type + Err(err) => Table::mock_file_details(), + }; + debug!( + "loading local storage: {:?}\n\n{:?}", + main_local_storage, details_local_storage + ); + + State { + editing_main: Editing::None, + editing_details: Editing::None, + main: Content { + filename: "main.csv".to_owned(), + table: main_local_storage, + }, + details: Content { + filename: "details.csv".to_owned(), + table: details_local_storage, + }, + } + } +} + +impl State { + pub fn mock() -> Self { + State { + editing_main: Editing::None, + editing_details: Editing::None, + main: Content { + filename: "main.csv".to_owned(), + table: Table::mock_file_main(), + }, + details: Content { + filename: "details.csv".to_owned(), + table: Table::mock_file_details(), + }, + } + } + + #[inline(never)] + // store the updated state in web browser local storage + pub fn store(&self) { + debug!( + "updating store: {:?}\n\n{:?}", + &self.main.table, &self.details.table + ); + LocalStorage::set(KEY_MAIN, &self.main.table).unwrap_throw(); + LocalStorage::set(KEY_DETAILS, &self.details.table).unwrap_throw(); + } + + // remove a row from the 'main' table that has multiple rows + // + // Note: The code in this draft was not necessary + // https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=53e5b5c0c241be2f5b37815a685e7da6 + pub fn destroy_row_main(&mut self, row_idx_destroy: usize) { + // WARNING: do not remove the `source`, it must remain read-only + + self.main.table.rows.remove(row_idx_destroy); // remove from `rows` + + self.store(); + } + + pub fn add_row_main(&mut self, row_idx_to_insert_at: usize) { + let mut new_row: Vec = vec![]; + for i in 0..self.main.table.columns.len() { + new_row.push(Text::Owned("".into())); + } + self.main.table.rows.insert(row_idx_to_insert_at, new_row); // add to `rows` + + debug!("self.main.table.rows: {:?}", self.main.table.rows); + self.store(); + } +} + +impl Deref for Content { + type Target = Table; + + fn deref(&self) -> &Table { + &self.table + } +} + +impl DerefMut for Content { + fn deref_mut(&mut self) -> &mut Table { + &mut self.table + } +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct TextSource { + pub source: String, +} + +impl From for TextSource { + fn from(source: String) -> Self { + TextSource { source } + } +} + +impl TextSource { + pub fn get_text<'a>(&'a self, text: &'a Text) -> &'a str { + // debug!("get_text source {:?}", self.source); + match text { + Text::Insitu(span) => &self.source[span.clone()], + Text::Owned(string) => string, + } + } +} + +impl Table { + fn mock_file_main() -> Self { + "#main,description,total,qr\ntask1,10,0x000|h160\ntask2,20,0x100|h160" + .parse() + .expect_throw("unable to parse mock file main") + } + + // `#details,` is not a column, it is only to identify the table variant. if it was this value it would be stored + // in `Table`'s `variant` property as `TableVariant::Details` if that was the configured mapping supported. + // it is removed from the source during the upload process using `parse_table_variant` in csv.rs. + // if it is not specified then a value of `TableVariant::Unknown` is assigned. + fn mock_file_details() -> Self { + "#details,invoice date,invoice number,name person from,organisation name from,organisation address from,email from,name person attention to,title to,organisation name to,email to\n01.04.2023,0001,luke,clawbird,1 metaverse ave,test@test.com,recipient_name,director,nftverse,test2@test.com" + .parse() + .expect_throw("unable to parse mock file details") + } + + pub fn rows(&self) -> Range { + 0..self.rows.len() + } + + pub fn columns(&self) -> Range { + 0..self.columns.len() + } +} diff --git a/examples/invoice/src/tests.rs b/examples/invoice/src/tests.rs new file mode 100644 index 00000000..376c536e --- /dev/null +++ b/examples/invoice/src/tests.rs @@ -0,0 +1,54 @@ +use crate::csv::update_csv_row_for_modified_table_cells; +use crate::helpers::csv_helpers; +use crate::state::Text; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_csv_row_for_modified_table_cells() -> Result<(), String> { + let cells: Vec = vec![ + Text::Owned("11task1".into()), + Text::Insitu(27..29), + Text::Insitu(30..40), + ]; + let bindings = vec![ + "task1".to_string(), + "10".to_string(), + "0x000|h160".to_string(), + ]; + let mut csv_row: Vec<&str> = vec![&bindings[0], &bindings[1], &bindings[2]]; + let actual: String = update_csv_row_for_modified_table_cells(&cells, &mut csv_row); + let expected = "11task1,10,0x000|h160".to_string(); + + assert_eq!(actual, expected); + Ok(()) + } + + #[test] + fn test_pad_csv_data() -> Result<(), String> { + // label row has 5 columns, but the data rows only have 4 columns of data + let label_row = "description,total,qr,aaa".to_string(); + let row_1 = "task1,10,0x0|h160".to_string(); + let row_2 = "task2,20,0x1|h160".to_string(); + let original_csv = vec![label_row.as_str(), row_1.as_str(), row_2.as_str()]; + let mut new_csv: Vec> = vec![]; + let mut new_csv_lens: Vec = vec![]; + // add padding "" to a 5th column for the data rows so all rows have same qty of columns + let padding = "".to_string(); + csv_helpers::pad_csv_data(&original_csv, &mut new_csv, &mut new_csv_lens, &padding); + + let label_row_vec: Vec<&str> = label_row.split(&[','][..]).collect(); + let mut row_1_vec: Vec<&str> = row_1.split(&[','][..]).collect(); + let mut row_2_vec: Vec<&str> = row_2.split(&[','][..]).collect(); + row_1_vec.push(&padding); + row_2_vec.push(&padding); + + let actual: Vec> = new_csv.clone(); + let expected = vec![label_row_vec, row_1_vec, row_2_vec]; + + assert_eq!(actual, expected); + Ok(()) + } +} diff --git a/examples/invoice/styles.css b/examples/invoice/styles.css new file mode 100644 index 00000000..8fc5a092 --- /dev/null +++ b/examples/invoice/styles.css @@ -0,0 +1,227 @@ +canvas { + display: inline-block; + margin: 10px; + vertical-align: middle; + width: 50px; + height: 50px; +} + +html, body { + font-family: sans-serif; +} + +table { + border-collapse: collapse; + table-layout: fixed; +} + +th { + background: #eee; + position: sticky; + top: 0; /* this is required for the stickiness of the table header */ + box-shadow: 0 2px 2px -1px rgba(0, 0, 0, 0.4); +} + +td, th, input.edit { + padding: 4px 10px; + font-size: 12px; + border: 1px solid #ccc; +} + +thead.details { + float: left; +} + +thead.details th { + display: block; + text-align: left; +} + +tbody.details { + float: right; +} + +tbody.details td { + display: block; + text-align: center; +} + +div.edit, td.edit, th.edit { + position: relative; +} + +input.edit { + font-family: sans-serif; + border: 0; + background: #ffa; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + max-width: 100%; + box-sizing: border-box; + outline: none; +} + +input.edit-head { + font-weight: bold; + text-align: center; +} + +input[type="file"] { + padding: 15px; +} + +/* https://stackoverflow.com/questions/74115573/styling-an-input-type-file-text-next-to-the-button-not-the-button-itself-us */ +.container { + border: 1px solid lightgrey; + border-radius: 4px; + display: inline-flex; +} + +#multi-upload-container { + border: 1px solid lightgrey; + border-radius: 4px; + display: inline-block; +} + +#file-input-multiple-modern { + /* display: inline-block; */ + background-color: #999999; + border: 2px solid #000000; + border-radius: 4px; + padding: .5em 2em; + width: 250px; + color: #ffffff; + text-align: center; + /* margin-left: 20px; */ + font-size: 1.0em; + font-weight: 600; +} + +#header-container { + display: grid; + grid-template-columns: 1fr 0fr; + grid-row-gap: 0px; +} + +input[type=button] { + background-color: lightgray; + border: 0; + border-radius: 4px; + padding: .5em 2em; +} + +/* Hide traditional file upload button */ +.file-input-hidden { + display: none; +} + +.label { + display: block; + margin-top: .5em; + margin-left: 10px; +} + +#button-file-save { + display: inline-block; + background-color: #11cc11; + border: 2px solid #11aa11; + border-radius: 4px; + padding: .5em 2em; + width: 200px; + color: #ffffff; + text-align: center; + margin-left: 20px; + font-size: 1.0em; + font-weight: 600; +} + +textarea { + display: block; + margin: 10px; + width: 35%; + height: 100%; + min-width: 150px; + min-height: 30px; + vertical-align: top; +} + +div.qr > div { + display: inline-flex; + vertical-align: middle; +} + +/* override todo-app-css */ +.new-todo, .edit { + font-size: 11.5px !important; +} +/* override todo-app-css */ +.main { + margin-top: 20px !important; +} + +.add-container { + border: 1px solid transparent; + background-color: transparent; +} + +.add { + display: contents !important; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + font-weight: 100; + color: #949494; + transition: color 0.2s ease-out; +} + +.add:hover, +.add:focus { + color: #11cc11; +} + +.add:after { + content: '+'; /* U+002B */ + display: block; + height: 100%; + line-height: 1.1; +} + +.destroy-container { + border: 1px solid transparent; + background-color: transparent; +} + +.destroy { + /* display: none; */ + display: contents !important; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #949494; + /* transition: color 0.2s ease-out; */ +} + +.destroy:hover, +.destroy:focus { + color: #C18585; +} + +.destroy:after { + content: '×'; + display: block; + height: 100%; + line-height: 1.1; +} diff --git a/examples/qrcode/index.html b/examples/qrcode/index.html index e64373cc..1b6b73b7 100644 --- a/examples/qrcode/index.html +++ b/examples/qrcode/index.html @@ -15,8 +15,8 @@ margin: 10px; width: 70%; height: 50%; - min-width: 200px; - min-height: 200px; + min-width: 50px; + min-height: 50px; vertical-align: top; }