diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 144aa8c..1663ec7 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -7,6 +7,8 @@ on: env: CARGO_INCREMENTAL: 0 CARGO_TERM_COLOR: always + CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" RUSTC_WRAPPER: "sccache" RUSTFLAGS: -D warnings SCCACHE_GHA_ENABLED: "true" @@ -17,55 +19,32 @@ jobs: name: Nightly clippy (wasm32) runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - # Install caches - - name: Install sccache - uses: mozilla-actions/sccache-action@v0.0.6 - - name: Cache cargo registry and index - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/index - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - name: Cache cargo target - uses: actions/cache@v4 + - uses: actions/checkout@v6 + - uses: mozilla-actions/sccache-action@v0.0.9 + - uses: Swatinem/rust-cache@v2 with: - path: target - key: ${{ runner.os }}-cargo-target-${{ hashFiles('**/Cargo.lock') }} - - # Install toolchain - - name: Install toolchain - uses: actions-rs/toolchain@v1 + shared-key: "deps-cache-stable" + cache-targets: "false" # IMPORTANT: Let sccache handle target artifacts + - uses: dtolnay/rust-toolchain@stable with: toolchain: nightly target: wasm32-unknown-unknown components: clippy, rustfmt - override: true # Formatting check - name: Check formatting run: cargo fmt -- --check # Clippy checks - - name: Vertigo-forms Clippy - uses: actions-rs/clippy-check@v1 + - name: Clippy (stable) + uses: giraffate/clippy-action@v1 with: - token: ${{ secrets.GITHUB_TOKEN }} - args: -p vertigo-forms --all-features --target wasm32-unknown-unknown -- -Dwarnings - name: Vertigo-forms Clippy Output - - name: Examples Clippy - uses: actions-rs/clippy-check@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: -p vertigo-forms-storybook -p vertigo-forms-example-manual-form -p vertigo-forms-example-model-form --all-features --target wasm32-unknown-unknown -- -Dwarnings - name: Storybook/Examples Clippy Output + tool_name: "Clippy (stable)" + reporter: github-pr-review + filter_mode: added + github_token: ${{ secrets.GITHUB_TOKEN }} + clippy_flags: --all --all-features --tests --locked # Unit tests - name: Unit tests - uses: actions-rs/cargo@v1 - with: - command: test - args: --all-features + run: cargo test --all --all-features --locked diff --git a/CHANGES.md b/CHANGES.md index e8f9475..d5dacb5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,11 +1,16 @@ -## Unreleased +## 0.2.0 - Unreleased + +### Added + +* `ResourceTable` ### Fixed -* Fixed positioning of `SelectSearch`, `Popup` and `MultiDropDown` popups +* Positioning of `SelectSearch`, `Popup` and `MultiDropDown` popups +* `SelectSearch` sorting ## 0.1.3 - 2026-03-05 diff --git a/Cargo.lock b/Cargo.lock index d39658f..2ccc45c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "shlex", @@ -53,9 +53,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crypto-common" @@ -178,9 +178,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "log" @@ -488,9 +488,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "ucd-trie" @@ -518,9 +518,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vertigo" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa34d8d831b3cdb14fa67530a53d3741e055e363a19d6bac48288b3c0740f28" +checksum = "7ad8484c79e7f9b7a44b5f3738aa7e7dabdf707a5b8ea944b1f667f786a5ab49" dependencies = [ "base64", "log", @@ -529,7 +529,7 @@ dependencies = [ [[package]] name = "vertigo-forms" -version = "0.1.3" +version = "0.2.0" dependencies = [ "base64", "either", @@ -538,7 +538,7 @@ dependencies = [ [[package]] name = "vertigo-forms-example-manual-form" -version = "0.1.3" +version = "0.2.0" dependencies = [ "vertigo", "vertigo-forms", @@ -546,7 +546,7 @@ dependencies = [ [[package]] name = "vertigo-forms-example-model-form" -version = "0.1.3" +version = "0.2.0" dependencies = [ "vertigo", "vertigo-forms", @@ -554,7 +554,7 @@ dependencies = [ [[package]] name = "vertigo-forms-storybook" -version = "0.1.3" +version = "0.2.0" dependencies = [ "vertigo", "vertigo-forms", @@ -562,9 +562,9 @@ dependencies = [ [[package]] name = "vertigo-macro" -version = "0.11.2" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765b27859ad0eb9ca69d6ef10129400d4ff229fdd1ec4a33ad291bd703f4d41a" +checksum = "5b05ec1f45b26c2d5e8cbae596b6971b3992d878a13f73aedc7c5798fd1f869d" dependencies = [ "base64", "crc", diff --git a/Cargo.toml b/Cargo.toml index c875426..33e39bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["storybook", "examples/manual_form", "examples/model_form"] [package] name = "vertigo-forms" -version = "0.1.3" +version = "0.2.0" authors = ["Michał Pokrywka "] description = "Building block for forms in vertigo" readme = "README.md" diff --git a/examples/manual_form/Cargo.toml b/examples/manual_form/Cargo.toml index 9c92bbf..020025c 100644 --- a/examples/manual_form/Cargo.toml +++ b/examples/manual_form/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vertigo-forms-example-manual-form" -version = "0.1.3" +version = "0.2.0" authors = ["Michał Pokrywka "] edition = "2024" diff --git a/examples/model_form/Cargo.toml b/examples/model_form/Cargo.toml index 22585ab..326c016 100644 --- a/examples/model_form/Cargo.toml +++ b/examples/model_form/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vertigo-forms-example-model-form" -version = "0.1.3" +version = "0.2.0" authors = ["Michał Pokrywka "] edition = "2024" diff --git a/src/button.rs b/src/button.rs new file mode 100644 index 0000000..98e3d00 --- /dev/null +++ b/src/button.rs @@ -0,0 +1,118 @@ +use std::rc::Rc; +use vertigo::{component, css, dom}; + +#[derive(Clone, Copy, PartialEq)] +pub enum ButtonColor { + Primary, + Success, + Danger, + Secondary, +} + +#[derive(Clone, Copy, PartialEq)] +pub enum ButtonVariant { + Text, + Outline, +} + +#[component] +pub fn Button( + label: String, + on_click: Rc, + color: ButtonColor, + variant: ButtonVariant, +) { + let style = match (variant, color) { + (ButtonVariant::Text, ButtonColor::Primary) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 600; + color: #007bff; + :hover { text-decoration: underline; } + " + ), + (ButtonVariant::Text, ButtonColor::Success) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 600; + color: #28a745; + :hover { text-decoration: underline; } + " + ), + (ButtonVariant::Text, ButtonColor::Danger) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 600; + color: #dc3545; + :hover { text-decoration: underline; } + " + ), + (ButtonVariant::Text, ButtonColor::Secondary) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 600; + color: #6c757d; + :hover { text-decoration: underline; } + " + ), + (ButtonVariant::Outline, ButtonColor::Danger) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 700; + background: #fff; padding: 4px 12px; border-radius: 4px; + border: 1px solid #dc3545; color: #dc3545; + :hover { background: #dc3545; color: #fff; } + " + ), + (ButtonVariant::Outline, ButtonColor::Primary) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 700; + background: #fff; padding: 4px 12px; border-radius: 4px; + border: 1px solid #007bff; color: #007bff; + :hover { background: #007bff; color: #fff; } + " + ), + (ButtonVariant::Outline, ButtonColor::Success) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 700; + background: #fff; padding: 4px 12px; border-radius: 4px; + border: 1px solid #28a745; color: #28a745; + :hover { background: #28a745; color: #fff; } + " + ), + (ButtonVariant::Outline, ButtonColor::Secondary) => css!( + " + font-size: 0.8rem; cursor: pointer; font-weight: 700; + background: #fff; padding: 4px 12px; border-radius: 4px; + border: 1px solid #6c757d; color: #6c757d; + :hover { background: #6c757d; color: #fff; } + " + ), + }; + + dom! { +
+ {label} +
+ } +} + +#[component] +pub fn TableButton(label: String, on_click: Rc) { + dom! { +
+ { label } +
+ } +} diff --git a/src/form/data/form_data.rs b/src/form/data/form_data.rs index 43afe91..0ead839 100644 --- a/src/form/data/form_data.rs +++ b/src/form/data/form_data.rs @@ -131,6 +131,7 @@ pub enum FieldsetStyle { /// A section of form with label and a field (or field set). #[derive(Default)] +// #[derive(Clone)] pub struct DataSection { pub label: String, pub fields: Vec, diff --git a/src/lib.rs b/src/lib.rs index cb22d0e..e192524 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,12 @@ use std::{collections::HashMap, fmt::Display, str::FromStr}; +pub mod button; mod drop_image_file; pub mod form; mod input; pub mod login; mod popup; +pub mod resource_table; mod search_panel; mod select; mod select_search; @@ -14,6 +16,7 @@ mod tabs; mod with_loader; pub use { + button::{Button, ButtonColor, ButtonVariant, TableButton}, drop_image_file::{DropImageFile, DropImageFileParams, image_as_uri, name_to_mime}, input::{Input, InputWithButton, InputWithButtonParams, ListInput}, popup::{Popup, PopupOnHover, PopupParams}, diff --git a/src/resource_table/mod.rs b/src/resource_table/mod.rs new file mode 100644 index 0000000..db5f81e --- /dev/null +++ b/src/resource_table/mod.rs @@ -0,0 +1,276 @@ +use std::rc::Rc; +use vertigo::{Computed, Css, DomNode, Resource, Value, bind_rc, css, dom}; + +use crate::button::TableButton; + +pub mod row_from_data_section; +pub use row_from_data_section::row_from_data_section; +pub mod row_form; + +pub use row_form::{ + AsyncResult, CancelCallback, CreateFn, ProcessCallback, ResourceTableLabels, RowResult, + RowState, row_form, +}; + +type ComputedListItem = Computed>; +type ComputedList = Computed>>>>; + +/// A generic CRUD table component with inline add/edit forms. +/// +/// `Model` is the read-only row type; `ModelForm` is the editable form state created from it via +/// `create_form_model`. +/// +/// ## Feeding `list` from `LazyListCache` +/// +/// [`vertigo::LazyListCache::granular`] returns `Resource>>>>`, +/// which matches `list`'s inner type. Wrap it in [`Computed::from`] to get the right shape: +/// +/// ```rust,ignore +/// let list = { +/// let cache = my_cache.clone(); +/// Computed::from(move |ctx| cache.granular(ctx, None as Option bool>)) +/// }; +/// +/// ResourceTable { +/// list, +/// // ... +/// } +/// ``` +#[derive(Clone)] +pub struct ResourceTable { + pub list: ComputedList, + pub title: String, + pub add_label: String, + pub table_css: Css, + pub render_header: fn() -> DomNode, + pub render_filters: Option DomNode>, + pub create_new_model: Rc Model>, + pub create_form_model: fn(&Model) -> ModelForm, + pub update_model: fn(&Model, &ModelForm, &vertigo::Context) -> RowResult, + pub render_row_view: fn(&Model, create_buttons: CreateFn, alert: Computed) -> DomNode, + pub render_row_form: fn(&ModelForm, buttons: DomNode) -> DomNode, + pub on_create: ProcessCallback, + pub on_update: ProcessCallback, + pub on_delete: Option>, + pub labels: ResourceTableLabels, +} + +impl + ResourceTable +{ + pub fn into_component(self) -> ResourceTable { + self + } + pub fn mount(self) -> DomNode { + let props = Rc::new(self); + + let props2 = props.clone(); + let list = props.list.render_value(move |list| { + let mut rows = Vec::new(); + let props = props2.clone(); + match list { + Resource::Ready(list) => { + for item in &*list { + let props = props.clone(); + let row = item.render_value_option(move |item| { + item.map(|item| { + row_form( + RowState::View { + confirm_delete: false, + }, + &item, + props.create_form_model, + props.update_model, + props.render_row_view, + props.render_row_form, + props.labels.save.clone(), + props.on_update.clone(), + props.labels.cancel.clone(), + Rc::new(|| Box::pin(async { None })), + props.on_delete.clone(), + props.labels.clone(), + ) + }) + }); + + rows.push(row); + } + + dom! { +
+ { ..rows } +
+ } + } + Resource::Loading => { + dom! {
"Loading..."
} + } + Resource::Error(err) => { + dom! {
{err}
} + } + } + }); + + let is_adding = Value::new(None); + + let table_css = css! {" + width: 100%; + max-width: 1600px; + background: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + overflow: hidden; + margin: 0 auto; + "} + &props.table_css; + + dom! { +
+
+
+

{props.title.clone()}

+ + {..props.render_filters.map(|render_filters| render_filters())} + + + +
+ + { (props.render_header)() } + +
+ { render_add(props.clone(), is_adding) } + { list } +
+
+
+ } + } +} + +fn render_add( + props: Rc>, + is_adding: Value>, +) -> DomNode { + is_adding.clone().render_value({ + let is_adding = is_adding.clone(); + let props = props.clone(); + + move |current_is_adding| { + if let Some(item) = current_is_adding { + let is_adding = is_adding.clone(); + let props = props.clone(); + + row_form( + RowState::Edit, + &item, + props.create_form_model, + props.update_model, + |_, _, _| dom! {
}, + props.render_row_form, + props.add_label.clone(), + Rc::new({ + let is_adding = is_adding.clone(); + let on_create = props.on_create.clone(); + + move |item| { + let is_adding = is_adding.clone(); + let on_create = on_create.clone(); + + Box::pin(async move { + let result = on_create(item).await; + if result.is_none() { + is_adding.set(None); + } + result + }) + } + }), + props.labels.cancel.clone(), + Rc::new({ + let is_adding = is_adding.clone(); + move || { + is_adding.set(None); + Box::pin(async move { None }) + } + }), + None, + props.labels.clone(), + ) + } else { + dom! {
} + } + } + }) +} + +pub fn base_header_css() -> Css { + css! {" + display: grid; + background: #f8f9fa; + padding: 12px 20px; + align-items: center; + + font-size: 0.85rem; + font-weight: 700; + color: #666; + border-bottom: 1px solid #eee; + "} +} + +pub fn base_row_css() -> Css { + css! {" + display: grid; + padding: 2px 10px; + align-items: center; + gap: 5px; + + transition: background 0.2s; + :hover { background: #f8f8f8; } + "} +} + +pub fn edit_input_css() -> Css { + css! {" + width: 90%; padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; + "} +} + +pub fn read_only_col_css() -> Css { + css! {" + font-family: monospace; color: #888; + "} +} + +pub fn main_col_css() -> Css { + css! {" + font-weight: 500; color: #333; + "} +} + +pub fn normal_col_css() -> Css { + css! {" + color: #333; font-size: 0.9rem; + "} +} + +pub fn low_prio_col_css() -> Css { + css! {" + color: #666; font-size: 0.9rem; + "} +} diff --git a/src/resource_table/row_form.rs b/src/resource_table/row_form.rs new file mode 100644 index 0000000..2ae210b --- /dev/null +++ b/src/resource_table/row_form.rs @@ -0,0 +1,298 @@ +use std::{future::Future, pin::Pin, rc::Rc}; +use vertigo::{Computed, DomComment, DomNode, Value, bind_rc, bind_spawn, css, dom, transaction}; + +use crate::button::{Button, ButtonColor, ButtonVariant}; + +pub type AsyncResult = Pin>>; +pub type ProcessCallback = Rc AsyncResult>>; +pub type CancelCallback = Rc AsyncResult>>; +pub type CreateFn = Rc DomNode>; +pub type RowResult = Result>; + +#[derive(Clone, PartialEq)] +pub enum RowState { + View { confirm_delete: bool }, + Edit, + Processing, +} + +#[derive(Clone)] +pub struct ResourceTableLabels { + pub edit: String, + pub delete: String, + pub confirm_delete: String, + pub cancel: String, + pub confirm_question: String, + pub processing: String, + pub save: String, +} + +impl Default for ResourceTableLabels { + fn default() -> Self { + Self { + edit: "Edit".to_string(), + delete: "Delete".to_string(), + confirm_delete: "Confirm delete".to_string(), + cancel: "Cancel".to_string(), + confirm_question: "Are you sure you want to delete this record?".to_string(), + processing: "Processing...".to_string(), + save: "Save".to_string(), + } + } +} + +#[allow(clippy::too_many_arguments)] +pub fn row_form( + initial_state: RowState, + item: &Model, + create_form_model: fn(&Model) -> FormModel, + update_model: fn(&Model, &FormModel, &vertigo::Context) -> RowResult, + render_view: fn(&Model, create_buttons: CreateFn, alert: Computed) -> DomNode, + render_form: fn(&FormModel, buttons: DomNode) -> DomNode, + + process_label: String, + process: ProcessCallback, + + cancel_label: String, + cancel: CancelCallback, + + delete: Option>, + + labels: ResourceTableLabels, +) -> DomNode { + let state = Value::new(initial_state); + let error = Value::new(None::); + let form_model = create_form_model(item); + let optimistic_item: Value> = Value::new(None); + + let alert_view = state.map(|value| { + if let RowState::View { confirm_delete } = value + && !confirm_delete + { + false + } else { + true + } + }); + + let item = item.clone(); + state.clone().render_value(move |current_state| { + let error_view = error.clone().render_value(|err| { + if let Some(err) = err { + dom! {
{err}
} + } else { + dom! {
} + } + }); + + match current_state { + RowState::View { confirm_delete } => { + let confirm_view = if confirm_delete { + let confirm = bind_rc!(state, error, delete, item, || { + if let Some(delete_fn) = &delete { + state.set(RowState::Processing); + + bind_spawn!(state, error, delete_fn, item, async move { + let result = delete_fn(item).await; + if let Some(err) = result { + error.set(Some(err)); + state.set(RowState::View { confirm_delete: false }); + } else { + error.set(None); + state.set(RowState::View { confirm_delete: false }); + } + })(); + } + }); + + confirm_delete_view(state.clone(), confirm, &labels) + } else { + dom! { +
+ } + }; + + let state_for_buttons = state.clone(); + let delete_for_buttons = delete.clone(); + let edit_label = labels.edit.clone(); + let delete_label = labels.delete.clone(); + + let create_buttons = move || { + let delete_view_inner = if delete_for_buttons.is_some() { + let delete_click = bind_rc!(state_for_buttons, || { + state_for_buttons.set(RowState::View { confirm_delete: true }); + }); + + dom! { +
+ } + }; + + DomComment::dom_fragment(vec![ + render_view(&item, Rc::new(create_buttons), alert_view.clone()), + dom! { +
+ { confirm_view } +
+ }, + ]) + .into() + } + RowState::Edit => { + let buttons = dom! { +
+
+ }; + + let rendered_form = render_form(&form_model, buttons); + + dom! { +
+ { rendered_form } + { error_view } +
+ } + } + RowState::Processing => { + let processing_label = labels.processing.clone(); + optimistic_item.clone().render_value(move |opt_item| { + if let Some(opt_item) = opt_item { + let no_alert = Computed::from(|_ctx| false); + render_view(&opt_item, Rc::new(|| dom! {
}), no_alert) + } else { + let label = processing_label.clone(); + dom! { +
+ "↻" + {label} +
+ } + } + }) + } + } + }) +} + +fn confirm_delete_view( + state: Value, + on_confirm: Rc, + labels: &ResourceTableLabels, +) -> DomNode { + let confirm_question = labels.confirm_question.clone(); + let confirm_delete_label = labels.confirm_delete.clone(); + let cancel_label = labels.cancel.clone(); + + dom! { +
+
+ {confirm_question} +
+ +
+
+
+ } +} diff --git a/src/resource_table/row_from_data_section.rs b/src/resource_table/row_from_data_section.rs new file mode 100644 index 0000000..67e3b4e --- /dev/null +++ b/src/resource_table/row_from_data_section.rs @@ -0,0 +1,52 @@ +use vertigo::{DomNode, css, dom}; + +use crate::{ + form::{DataSection, Field}, + resource_table::{base_row_css, normal_col_css}, +}; + +/// Renders a single row in a `ResourceTable` using a `DataSection`. +/// +/// This function simplifies building editable table rows by leveraging `vertigo_forms::form::DataSection` +/// to automatically render form fields based on the section's definition. +/// +/// # Arguments +/// +/// * `section` - A reference to the `DataSection` containing the row's form fields. +/// * `buttons` - A `DomNode` containing the action buttons (e.g., Save, Cancel) to render in the last column. +/// * `grid_template_columns` - A string specifying the CSS `grid-template-columns` property to align the +/// row's cells with the table header (e.g., `"50px 1fr 100px 150px"`). +/// +/// # Example +/// +/// ```rust,ignore +/// render_row_form: |form: &Rc, buttons| { +/// row_from_data_section(form, buttons, "50px 1fr 100px 150px") +/// } +/// ``` +pub fn row_from_data_section( + section: &DataSection, + buttons: DomNode, + grid_template_columns: &str, +) -> DomNode { + let fields: Vec = section + .fields + .iter() + .map(|field| { + dom! { } + }) + .collect(); + + let error_node = section.error.as_ref().map(|err| { + dom! { {err} } + }); + + dom! { +
+
{section.label.clone()}
+ {..fields} + {..error_node} +
{buttons}
+
+ } +} diff --git a/src/select_search.rs b/src/select_search.rs index 937e564..278588c 100644 --- a/src/select_search.rs +++ b/src/select_search.rs @@ -50,12 +50,14 @@ pub fn SelectSearch( let inner_filter = inner_filter.to_lowercase(); if inner_filter.len() >= params.min_chars { // Filter options - inner_options + let mut filtered = inner_options .into_iter() .filter(|(_, opt_value)| { opt_value.to_string().to_lowercase().contains(&inner_filter) }) - .collect::>() + .collect::>(); + filtered.sort_by_cached_key(|(_, opt_value)| opt_value.to_string()); + filtered } else { vec![] } diff --git a/storybook/Cargo.toml b/storybook/Cargo.toml index b1c861b..2358331 100644 --- a/storybook/Cargo.toml +++ b/storybook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vertigo-forms-storybook" -version = "0.1.3" +version = "0.2.0" authors = ["Michał Pokrywka "] edition = "2024" diff --git a/storybook/src/lib.rs b/storybook/src/lib.rs index 26deb57..9ac63c3 100644 --- a/storybook/src/lib.rs +++ b/storybook/src/lib.rs @@ -9,6 +9,7 @@ mod login; mod multi_drop_down; mod multi_select; mod popup; +mod resource_table; mod search_panel; mod select; mod select_search; @@ -29,6 +30,7 @@ fn render() -> DomNode { SelectSearch, SearchPanel, Form, + ResourceTable, Tabs, DropFile, Login, @@ -48,6 +50,7 @@ fn render() -> DomNode { "/select_search" => Self::SelectSearch, "/search_panel" => Self::SearchPanel, "/form" => Self::Form, + "/resource_table" => Self::ResourceTable, "/tabs" => Self::Tabs, "/drop_file" => Self::DropFile, "/login" => Self::Login, @@ -70,6 +73,7 @@ fn render() -> DomNode { Self::SelectSearch => write!(f, "/select_search"), Self::SearchPanel => write!(f, "/search_panel"), Self::Form => write!(f, "/form"), + Self::ResourceTable => write!(f, "/resource_table"), Self::Tabs => write!(f, "/tabs"), Self::DropFile => write!(f, "/drop_file"), Self::Login => write!(f, "/login"), @@ -125,6 +129,11 @@ fn render() -> DomNode { name: "Form".to_string(), render: Rc::new(|_| form::form()), }, + Tab { + key: TabRoute::ResourceTable, + name: "Resource Table".to_string(), + render: Rc::new(|_| resource_table::resource_table()), + }, Tab { key: TabRoute::Tabs, name: "Tabs".to_string(), diff --git a/storybook/src/resource_table/data_section.rs b/storybook/src/resource_table/data_section.rs new file mode 100644 index 0000000..75c5771 --- /dev/null +++ b/storybook/src/resource_table/data_section.rs @@ -0,0 +1,199 @@ +use std::rc::Rc; +use vertigo::{DomNode, Resource, Value, css, dom}; +use vertigo_forms::{ + form::{DataFieldValue, DataSection}, + resource_table::{ + ResourceTable, ResourceTableLabels, base_header_css, base_row_css, main_col_css, + normal_col_css, row_from_data_section, + }, +}; + +#[derive(Clone, PartialEq, Default)] +struct ComplexModel { + id: u32, + name: String, + is_active: bool, + role: Option, +} + +pub fn resource_table_data_section() -> DomNode { + let list_state = Value::new(vec![ + Value::new(Some(ComplexModel { + id: 1, + name: "Item 1".to_string(), + is_active: true, + role: Some(1), + })), + Value::new(Some(ComplexModel { + id: 2, + name: "Item 2".to_string(), + is_active: false, + role: Some(2), + })), + ]); + + let list = list_state.to_computed().map(|list| { + let computed_list = list.iter().map(|v| v.to_computed()).collect::>(); + Resource::Ready(Rc::new(computed_list)) + }); + + let table = ResourceTable { + list, + title: "My Resources (DataSection)".to_string(), + add_label: "Add Item".to_string(), + table_css: css!("margin-top: 40px;"), + render_header: || { + dom! { +
+
"ID"
+
"Name"
+
"Active"
+
"Role"
+
"Actions"
+
+ } + }, + render_filters: None, + create_new_model: Rc::new(|| ComplexModel { + id: 0, + name: String::new(), + is_active: false, + role: None, + }), + create_form_model: |model| { + Rc::new( + DataSection::new(model.id.to_string()) + .add_string_field("name", &model.name) + .add_bool_field("is_active", Some(model.is_active)) + .add_static_dict_field( + "role", + model.role, + vec![(1, "Admin".to_string()), (2, "User".to_string())], + ), + ) + }, + update_model: |model, form: &Rc, ctx| { + let mut name = String::new(); + let mut is_active = false; + let mut role = None; + for field in &form.fields { + match field.key.as_str() { + "name" => { + if let DataFieldValue::String(ref s) = field.value { + name = s.value.get(ctx); + } + } + "is_active" => { + if let DataFieldValue::Bool(ref b) = field.value { + is_active = b.value.get(ctx); + } + } + "role" => { + if let DataFieldValue::Dict(ref d) = field.value { + let val = d.value.get(ctx); + if val != 0 { + role = Some(val); + } + } + } + _ => {} + } + } + if name.is_empty() { + return Err(vec!["Name cannot be empty".to_string()]); + } + let mut new_model = model.clone(); + new_model.name = name; + new_model.is_active = is_active; + new_model.role = role; + Ok(new_model) + }, + render_row_view: |model, create_buttons, _alert| { + let role_str = match model.role { + Some(1) => "Admin", + Some(2) => "User", + _ => "Unknown", + }; + let active_str = if model.is_active { "Yes" } else { "No" }; + dom! { +
+
{model.id}
+
{model.name.clone()}
+
{active_str}
+
{role_str}
+
{create_buttons()}
+
+ } + }, + render_row_form: |form: &Rc, buttons| { + row_from_data_section(form, buttons, "50px 1fr 100px 150px 150px") + }, + on_create: Rc::new({ + let list_state = list_state.clone(); + move |new_model| { + let list_state = list_state.clone(); + Box::pin(async move { + vertigo::transaction(|ctx| { + let mut m = new_model.clone(); + let mut current = list_state.get(ctx); + m.id = current.len() as u32 + 10; + current.push(Value::new(Some(m))); + list_state.set(current); + }); + None + }) + } + }), + on_update: Rc::new({ + let list_state = list_state.clone(); + move |updated_model| { + let list_state = list_state.clone(); + Box::pin(async move { + vertigo::transaction(|ctx| { + let current = list_state.get(ctx); + for item_val in current.iter() { + if let Some(ref m) = item_val.get(ctx) + && m.id == updated_model.id + { + item_val.set(Some(updated_model.clone())); + break; + } + } + }); + None + }) + } + }), + on_delete: Some(Rc::new({ + let list_state = list_state.clone(); + move |deleted_model| { + let list_state = list_state.clone(); + Box::pin(async move { + vertigo::transaction(|ctx| { + let current = list_state.get(ctx); + for item_val in current.iter() { + if let Some(ref m) = item_val.get(ctx) + && m.id == deleted_model.id + { + item_val.set(None); + break; + } + } + }); + None + }) + } + })), + labels: ResourceTableLabels { + save: "Save".to_string(), + cancel: "Cancel".to_string(), + edit: "Edit".to_string(), + delete: "Delete".to_string(), + confirm_delete: "Confirm".to_string(), + confirm_question: "Are you sure?".to_string(), + processing: "Processing...".to_string(), + }, + }; + + table.mount() +} diff --git a/storybook/src/resource_table/mod.rs b/storybook/src/resource_table/mod.rs new file mode 100644 index 0000000..d2ae52a --- /dev/null +++ b/storybook/src/resource_table/mod.rs @@ -0,0 +1,13 @@ +use vertigo::{DomNode, dom}; + +mod data_section; +mod text_field; + +pub fn resource_table() -> DomNode { + dom! { +
+ {text_field::resource_table_text_field()} + {data_section::resource_table_data_section()} +
+ } +} diff --git a/storybook/src/resource_table/text_field.rs b/storybook/src/resource_table/text_field.rs new file mode 100644 index 0000000..a6cd77c --- /dev/null +++ b/storybook/src/resource_table/text_field.rs @@ -0,0 +1,158 @@ +use std::rc::Rc; +use vertigo::{DomNode, Resource, Value, css, dom}; +use vertigo_forms::{ + Input, + resource_table::{ + ResourceTable, ResourceTableLabels, base_header_css, base_row_css, main_col_css, + normal_col_css, + }, +}; + +#[derive(Clone, PartialEq, Default)] +struct MyModel { + id: u32, + name: String, +} + +#[derive(Clone)] +struct MyModelForm { + name: Value, +} + +pub fn resource_table_text_field() -> DomNode { + let list_state = Value::new(vec![ + Value::new(Some(MyModel { + id: 1, + name: "Item 1".to_string(), + })), + Value::new(Some(MyModel { + id: 2, + name: "Item 2".to_string(), + })), + ]); + + let list = list_state.to_computed().map(|list| { + let computed_list = list.iter().map(|v| v.to_computed()).collect::>(); + Resource::Ready(Rc::new(computed_list)) + }); + + let table = ResourceTable { + list, + title: "My Resources".to_string(), + add_label: "Add Item".to_string(), + table_css: css!(""), + render_header: || { + dom! { +
+
"ID"
+
"Name"
+
"Actions"
+
+ } + }, + render_filters: None, + create_new_model: Rc::new(|| MyModel { + id: 0, + name: String::new(), + }), + create_form_model: |model| MyModelForm { + name: Value::new(model.name.clone()), + }, + update_model: |model, form, context| { + let name = form.name.get(context); + if name.is_empty() { + return Err(vec!["Name cannot be empty".to_string()]); + } + let mut new_model = model.clone(); + new_model.name = name; + Ok(new_model) + }, + render_row_view: |model, create_buttons, _alert| { + dom! { +
+
{model.id}
+
{model.name.clone()}
+
{create_buttons()}
+
+ } + }, + render_row_form: |form, buttons| { + dom! { +
+
"-"
+
+ +
+
{buttons}
+
+ } + }, + on_create: Rc::new({ + let list_state = list_state.clone(); + move |new_model| { + let list_state = list_state.clone(); + Box::pin(async move { + vertigo::transaction(|ctx| { + let mut m = new_model.clone(); + let mut current = list_state.get(ctx); + m.id = current.len() as u32 + 10; + current.push(Value::new(Some(m))); + list_state.set(current); + }); + None + }) + } + }), + on_update: Rc::new({ + let list_state = list_state.clone(); + move |updated_model| { + let list_state = list_state.clone(); + Box::pin(async move { + vertigo::transaction(|ctx| { + let current = list_state.get(ctx); + for item_val in current.iter() { + if let Some(ref m) = item_val.get(ctx) + && m.id == updated_model.id + { + item_val.set(Some(updated_model.clone())); + break; + } + } + }); + None + }) + } + }), + on_delete: Some(Rc::new({ + let list_state = list_state.clone(); + move |deleted_model| { + let list_state = list_state.clone(); + Box::pin(async move { + vertigo::transaction(|ctx| { + let current = list_state.get(ctx); + for item_val in current.iter() { + if let Some(ref m) = item_val.get(ctx) + && m.id == deleted_model.id + { + item_val.set(None); + break; + } + } + }); + None + }) + } + })), + labels: ResourceTableLabels { + save: "Save".to_string(), + cancel: "Cancel".to_string(), + edit: "Edit".to_string(), + delete: "Delete".to_string(), + confirm_delete: "Confirm".to_string(), + confirm_question: "Are you sure?".to_string(), + processing: "Processing...".to_string(), + }, + }; + + table.mount() +}