From dc23a7f29c0300701e5ced527a7a575c36b3f958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher=20Andr=C3=A9s?= Date: Sun, 18 Aug 2024 11:44:16 +0200 Subject: [PATCH] UPDATE: Adds web app (#79) Things missing: * Browse mode * Front end tests * Storybook --- Makefile | 6 +- base/src/constants.rs | 2 + base/src/lib.rs | 1 + base/src/new_empty.rs | 10 +- base/src/test/user_model/mod.rs | 6 + .../test/user_model/test_add_delete_sheets.rs | 12 + base/src/test/user_model/test_border.rs | 416 ++ .../user_model/test_keyboard_navigation.rs | 136 + .../test/user_model/test_on_area_selection.rs | 33 + .../test_on_expand_selected_range.rs | 151 + .../test/user_model/test_on_paste_styles.rs | 48 + base/src/test/user_model/test_view.rs | 17 +- base/src/test/user_model/test_window_size.rs | 29 + base/src/types.rs | 4 + .../{user_model.rs => user_model/common.rs} | 511 +-- base/src/user_model/history.rs | 164 + base/src/user_model/mod.rs | 12 + base/src/user_model/ui.rs | 687 +++ bindings/wasm/fix_types.py | 32 + bindings/wasm/src/lib.rs | 112 +- bindings/wasm/types.ts | 41 +- webapp/.gitignore | 2 + webapp/README.md | 58 + webapp/biome.json | 18 + webapp/index.html | 16 + webapp/package-lock.json | 3742 +++++++++++++++++ webapp/package.json | 37 + webapp/src/App.css | 6 + webapp/src/App.tsx | 59 + webapp/src/components/README.md | 12 + .../components/WorksheetCanvas/constants.ts | 18 + .../WorksheetCanvas/worksheetCanvas.ts | 1356 ++++++ webapp/src/components/borderPicker.tsx | 538 +++ webapp/src/components/colorPicker.tsx | 271 ++ webapp/src/components/formatMenu.tsx | 136 + webapp/src/components/formatPicker.tsx | 49 + webapp/src/components/formatUtil.ts | 36 + webapp/src/components/formulaDialog.tsx | 50 + webapp/src/components/formulabar.tsx | 132 + webapp/src/components/navigation/index.ts | 2 + webapp/src/components/navigation/menus.tsx | 122 + .../src/components/navigation/navigation.tsx | 145 + webapp/src/components/navigation/sheet.tsx | 142 + webapp/src/components/navigation/types.ts | 5 + webapp/src/components/tests/model.test.ts | 13 + webapp/src/components/tests/util.test.ts | 7 + webapp/src/components/toolbar.tsx | 453 ++ webapp/src/components/types.ts | 11 + .../src/components/useKeyboardNavigation.ts | 225 + webapp/src/components/usePointer.ts | 169 + webapp/src/components/util.ts | 42 + webapp/src/components/workbook.tsx | 378 ++ webapp/src/components/workbookState.ts | 48 + webapp/src/components/worksheet.tsx | 464 ++ webapp/src/fonts.css | 16 + webapp/src/fonts/inter-v13-latin-600.woff2 | Bin 0 -> 22820 bytes .../src/fonts/inter-v13-latin-regular.woff2 | Bin 0 -> 21564 bytes webapp/src/i18n.ts | 18 + webapp/src/icons/arrow-middle-from-line.svg | 14 + webapp/src/icons/border-bottom.svg | 6 + webapp/src/icons/border-center-h.svg | 4 + webapp/src/icons/border-center-v.svg | 4 + webapp/src/icons/border-inner.svg | 5 + webapp/src/icons/border-left.svg | 6 + webapp/src/icons/border-none.svg | 5 + webapp/src/icons/border-outer.svg | 5 + webapp/src/icons/border-right.svg | 6 + webapp/src/icons/border-style.svg | 15 + webapp/src/icons/border-top.svg | 6 + webapp/src/icons/decrease-decimal.svg | 6 + webapp/src/icons/delete-column.svg | 10 + webapp/src/icons/delete-row.svg | 8 + webapp/src/icons/fx.svg | 3 + webapp/src/icons/increase-decimal.svg | 7 + webapp/src/icons/index.ts | 46 + webapp/src/icons/insert-column-left.svg | 7 + webapp/src/icons/insert-column-right.svg | 7 + webapp/src/icons/insert-row-above.svg | 7 + webapp/src/icons/insert-row-below.svg | 7 + webapp/src/index.css | 4 + webapp/src/locale/en_us.json | 55 + webapp/src/main.tsx | 15 + webapp/src/theme.ts | 66 + webapp/src/vite-env.d.ts | 2 + webapp/tsconfig.json | 26 + webapp/tsconfig.node.json | 10 + webapp/vite.config.ts | 14 + xlsx/src/import/mod.rs | 7 +- xlsx/tests/example.ic | Bin 4780 -> 4786 bytes 89 files changed, 11245 insertions(+), 364 deletions(-) create mode 100644 base/src/test/user_model/test_border.rs create mode 100644 base/src/test/user_model/test_keyboard_navigation.rs create mode 100644 base/src/test/user_model/test_on_area_selection.rs create mode 100644 base/src/test/user_model/test_on_expand_selected_range.rs create mode 100644 base/src/test/user_model/test_on_paste_styles.rs create mode 100644 base/src/test/user_model/test_window_size.rs rename base/src/{user_model.rs => user_model/common.rs} (84%) create mode 100644 base/src/user_model/history.rs create mode 100644 base/src/user_model/mod.rs create mode 100644 base/src/user_model/ui.rs create mode 100644 webapp/.gitignore create mode 100644 webapp/README.md create mode 100644 webapp/biome.json create mode 100644 webapp/index.html create mode 100644 webapp/package-lock.json create mode 100644 webapp/package.json create mode 100644 webapp/src/App.css create mode 100644 webapp/src/App.tsx create mode 100644 webapp/src/components/README.md create mode 100644 webapp/src/components/WorksheetCanvas/constants.ts create mode 100644 webapp/src/components/WorksheetCanvas/worksheetCanvas.ts create mode 100644 webapp/src/components/borderPicker.tsx create mode 100644 webapp/src/components/colorPicker.tsx create mode 100644 webapp/src/components/formatMenu.tsx create mode 100644 webapp/src/components/formatPicker.tsx create mode 100644 webapp/src/components/formatUtil.ts create mode 100644 webapp/src/components/formulaDialog.tsx create mode 100644 webapp/src/components/formulabar.tsx create mode 100644 webapp/src/components/navigation/index.ts create mode 100644 webapp/src/components/navigation/menus.tsx create mode 100644 webapp/src/components/navigation/navigation.tsx create mode 100644 webapp/src/components/navigation/sheet.tsx create mode 100644 webapp/src/components/navigation/types.ts create mode 100644 webapp/src/components/tests/model.test.ts create mode 100644 webapp/src/components/tests/util.test.ts create mode 100644 webapp/src/components/toolbar.tsx create mode 100644 webapp/src/components/types.ts create mode 100644 webapp/src/components/useKeyboardNavigation.ts create mode 100644 webapp/src/components/usePointer.ts create mode 100644 webapp/src/components/util.ts create mode 100644 webapp/src/components/workbook.tsx create mode 100644 webapp/src/components/workbookState.ts create mode 100644 webapp/src/components/worksheet.tsx create mode 100644 webapp/src/fonts.css create mode 100644 webapp/src/fonts/inter-v13-latin-600.woff2 create mode 100644 webapp/src/fonts/inter-v13-latin-regular.woff2 create mode 100644 webapp/src/i18n.ts create mode 100644 webapp/src/icons/arrow-middle-from-line.svg create mode 100644 webapp/src/icons/border-bottom.svg create mode 100644 webapp/src/icons/border-center-h.svg create mode 100644 webapp/src/icons/border-center-v.svg create mode 100644 webapp/src/icons/border-inner.svg create mode 100644 webapp/src/icons/border-left.svg create mode 100644 webapp/src/icons/border-none.svg create mode 100644 webapp/src/icons/border-outer.svg create mode 100644 webapp/src/icons/border-right.svg create mode 100644 webapp/src/icons/border-style.svg create mode 100644 webapp/src/icons/border-top.svg create mode 100644 webapp/src/icons/decrease-decimal.svg create mode 100644 webapp/src/icons/delete-column.svg create mode 100644 webapp/src/icons/delete-row.svg create mode 100644 webapp/src/icons/fx.svg create mode 100644 webapp/src/icons/increase-decimal.svg create mode 100644 webapp/src/icons/index.ts create mode 100644 webapp/src/icons/insert-column-left.svg create mode 100644 webapp/src/icons/insert-column-right.svg create mode 100644 webapp/src/icons/insert-row-above.svg create mode 100644 webapp/src/icons/insert-row-below.svg create mode 100644 webapp/src/index.css create mode 100644 webapp/src/locale/en_us.json create mode 100644 webapp/src/main.tsx create mode 100644 webapp/src/theme.ts create mode 100644 webapp/src/vite-env.d.ts create mode 100644 webapp/tsconfig.json create mode 100644 webapp/tsconfig.node.json create mode 100644 webapp/vite.config.ts diff --git a/Makefile b/Makefile index 9344de3..503248f 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ lint: cargo fmt -- --check cargo clippy --all-targets --all-features + cd webapp && npm install && npm run check format: cargo fmt @@ -10,7 +11,10 @@ tests: lint ./target/debug/documentation cmp functions.md wiki/functions.md || exit 1 make remove-artifacts - cd bindings/wasm/ && wasm-pack build --target nodejs && node tests/test.mjs + # Regretabbly we need to build the wasm twice, once for the nodejs tests + # and a second one for the vitest. + cd bindings/wasm/ && wasm-pack build --target nodejs && node tests/test.mjs && make + cd webapp && npm run test remove-artifacts: rm -f xlsx/hello-calc.xlsx diff --git a/base/src/constants.rs b/base/src/constants.rs index f1f7348..ef58273 100644 --- a/base/src/constants.rs +++ b/base/src/constants.rs @@ -6,6 +6,8 @@ pub(crate) const DEFAULT_COLUMN_WIDTH: f64 = 100.0; pub(crate) const DEFAULT_ROW_HEIGHT: f64 = 21.0; pub(crate) const COLUMN_WIDTH_FACTOR: f64 = 12.0; pub(crate) const ROW_HEIGHT_FACTOR: f64 = 2.0; +pub(crate) const DEFAULT_WINDOW_HEIGH: i64 = 600; +pub(crate) const DEFAULT_WINDOW_WIDTH: i64 = 800; pub(crate) const LAST_COLUMN: i32 = 16_384; pub(crate) const LAST_ROW: i32 = 1_048_576; diff --git a/base/src/lib.rs b/base/src/lib.rs index a5c632b..8c0e45d 100644 --- a/base/src/lib.rs +++ b/base/src/lib.rs @@ -57,4 +57,5 @@ pub mod mock_time; pub use model::get_milliseconds_since_epoch; pub use model::Model; +pub use user_model::BorderArea; pub use user_model::UserModel; diff --git a/base/src/new_empty.rs b/base/src/new_empty.rs index 3b01cc4..fc4ba11 100644 --- a/base/src/new_empty.rs +++ b/base/src/new_empty.rs @@ -4,6 +4,7 @@ use std::collections::HashMap; use crate::{ calc_result::Range, + constants::{DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH}, expressions::{ lexer::LexerMode, parser::{ @@ -353,7 +354,14 @@ impl Model { let now = dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut views = HashMap::new(); - views.insert(0, WorkbookView { sheet: 0 }); + views.insert( + 0, + WorkbookView { + sheet: 0, + window_width: DEFAULT_WINDOW_WIDTH, + window_height: DEFAULT_WINDOW_HEIGH, + }, + ); // String versions of the locale are added here to simplify the serialize/deserialize logic let workbook = Workbook { diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index 1259726..0bb632c 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -1,14 +1,20 @@ mod test_add_delete_sheets; mod test_autofill_columns; mod test_autofill_rows; +mod test_border; mod test_clear_cells; mod test_diff_queue; mod test_evaluation; mod test_general; mod test_grid_lines; +mod test_keyboard_navigation; +mod test_on_area_selection; +mod test_on_expand_selected_range; +mod test_on_paste_styles; mod test_rename_sheet; mod test_row_column; mod test_styles; mod test_to_from_bytes; mod test_undo_redo; mod test_view; +mod test_window_size; diff --git a/base/src/test/user_model/test_add_delete_sheets.rs b/base/src/test/user_model/test_add_delete_sheets.rs index 72ae562..cc07536 100644 --- a/base/src/test/user_model/test_add_delete_sheets.rs +++ b/base/src/test/user_model/test_add_delete_sheets.rs @@ -82,3 +82,15 @@ fn delete_sheet_propagates() { let sheets_info = model2.get_worksheets_properties(); assert_eq!(sheets_info.len(), 1); } + +#[test] +fn delete_last_sheet() { + // Deleting the last sheet, selects the previous + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + model.new_sheet(); + model.new_sheet(); + model.set_selected_sheet(2).unwrap(); + model.delete_sheet(2).unwrap(); + + assert_eq!(model.get_selected_sheet(), 1); +} diff --git a/base/src/test/user_model/test_border.rs b/base/src/test/user_model/test_border.rs new file mode 100644 index 0000000..7437ce6 --- /dev/null +++ b/base/src/test/user_model/test_border.rs @@ -0,0 +1,416 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + expressions::{types::Area, utils::number_to_column}, + types::{Border, BorderItem, BorderStyle}, + BorderArea, UserModel, +}; + +#[test] +fn borders_all() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "All" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: Some(border_item.clone()), + top: Some(border_item.clone()), + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } + + // Lets remove all of them: + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "None" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: None, + top: None, + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_inner() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Inner" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + // The inner part all have borders + for row in 6..8 { + for column in 7..8 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: Some(border_item.clone()), + top: Some(border_item.clone()), + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } + // F5 has border only left and bottom + { + // We check the border on F5 + let style = model.get_cell_style(0, 5, 6).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be right and bottom + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: Some(border_item.clone()), + top: None, + bottom: Some(border_item), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + { + // Then let's try the bottom-right border + let style = model.get_cell_style(0, 8, 8).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be only left and top + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: None, + top: Some(border_item.clone()), + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } +} + +#[test] +fn borders_outer() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Outer" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + { + // We check the border on F5 + let style = model.get_cell_style(0, 5, 6).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be only left and top + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: None, + top: Some(border_item), + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + { + // Then let's try the bottom-right border + let style = model.get_cell_style(0, 8, 8).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + // It should be only left and top + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: Some(border_item.clone()), + top: None, + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } +} + +#[test] +fn borders_top() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Top" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: None, + top: Some(border_item.clone()), + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_right() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Right" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: Some(border_item.clone()), + top: None, + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_bottom() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Bottom" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: None, + right: None, + top: None, + bottom: Some(border_item.clone()), + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} + +#[test] +fn borders_left() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + // We set an outer border in cells F5:H9 + let range = &Area { + sheet: 0, + row: 5, + column: 6, + width: 3, + height: 4, + }; + assert_eq!(number_to_column(6).unwrap(), "F"); + assert_eq!(number_to_column(8).unwrap(), "H"); + // ATM we don't have a way to create the object from Rust, that's ok. + let border_area: BorderArea = serde_json::from_str( + r##"{ + "item": { + "style": "thin", + "color": "#FF5566" + }, + "type": "Left" + }"##, + ) + .unwrap(); + model.set_area_with_border(range, &border_area).unwrap(); + for row in 5..9 { + for column in 6..9 { + let style = model.get_cell_style(0, row, column).unwrap(); + let border_item = BorderItem { + style: BorderStyle::Thin, + color: Some("#FF5566".to_string()), + }; + let expected_border = Border { + diagonal_up: false, + diagonal_down: false, + left: Some(border_item.clone()), + right: None, + top: None, + bottom: None, + diagonal: None, + }; + assert_eq!(style.border, expected_border); + } + } +} diff --git a/base/src/test/user_model/test_keyboard_navigation.rs b/base/src/test/user_model/test_keyboard_navigation.rs new file mode 100644 index 0000000..a61acae --- /dev/null +++ b/base/src/test/user_model/test_keyboard_navigation.rs @@ -0,0 +1,136 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{ + DEFAULT_COLUMN_WIDTH, DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH, + LAST_COLUMN, + }, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn basic_navigation() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 2, 1, 2]); + assert_eq!(view.column, 2); + assert_eq!(view.row, 1); + + model.on_arrow_left().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); + + model.on_arrow_left().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); + + model.on_arrow_down().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [2, 1, 2, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 2); + + model.on_arrow_up().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); + + model.on_arrow_up().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); + assert_eq!(view.column, 1); + assert_eq!(view.row, 1); +} + +#[test] +fn scroll_right() { + let window_width = DEFAULT_WINDOW_WIDTH as f64; + let column_width = DEFAULT_COLUMN_WIDTH; + let column_count = f64::floor(window_width / column_width) as i32; + + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_arrow_right().unwrap(); + + model.set_selected_cell(3, column_count).unwrap(); + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.left_column, 2); + + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.left_column, 3); +} + +#[test] +fn last_colum() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_selected_cell(3, LAST_COLUMN).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.column, LAST_COLUMN); + + model.on_arrow_right().unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.column, LAST_COLUMN); +} + +#[test] +fn page_down() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let window_height = DEFAULT_WINDOW_HEIGH as f64; + let row_height = DEFAULT_ROW_HEIGHT; + let row_count = f64::floor(window_height / row_height) as i32; + model.on_page_down().unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.row, 1 + row_count); + let scroll_y = model.get_scroll_y().unwrap(); + assert_eq!(scroll_y, (row_count as f64) * DEFAULT_ROW_HEIGHT); +} + +// we just test that page up and page down are inverse operations +#[test] +fn page_up() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_page_down().unwrap(); + let row1 = model.get_selected_view().row; + + model.on_page_down().unwrap(); + let row2 = model.get_selected_view().row; + + model.on_page_down().unwrap(); + let row3 = model.get_selected_view().row; + + model.on_page_down().unwrap(); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, row3); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, row2); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, row1); + + model.on_page_up().unwrap(); + assert_eq!(model.get_selected_view().row, 1); +} + +#[test] +fn page_up_fails_on_row1() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_arrow_up().unwrap(); + assert_eq!(model.get_selected_view().row, 1); +} diff --git a/base/src/test/user_model/test_on_area_selection.rs b/base/src/test/user_model/test_on_area_selection.rs new file mode 100644 index 0000000..f4025b2 --- /dev/null +++ b/base/src/test/user_model/test_on_area_selection.rs @@ -0,0 +1,33 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{DEFAULT_COLUMN_WIDTH, DEFAULT_WINDOW_WIDTH}, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn basic_test() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + model.on_area_selecting(2, 4).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 2, 4]); +} + +// this checks that is we select in the boundary we automatically scroll +#[test] +fn scroll_right() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let window_width = DEFAULT_WINDOW_WIDTH as f64; + let column_width = DEFAULT_COLUMN_WIDTH; + let column_count = f64::floor(window_width / column_width) as i32; + model.set_selected_cell(3, column_count).unwrap(); + + model.on_area_selecting(3, column_count + 3).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [3, column_count, 3, column_count + 3]); + assert_eq!(view.left_column, 4); +} diff --git a/base/src/test/user_model/test_on_expand_selected_range.rs b/base/src/test/user_model/test_on_expand_selected_range.rs new file mode 100644 index 0000000..46b93e5 --- /dev/null +++ b/base/src/test/user_model/test_on_expand_selected_range.rs @@ -0,0 +1,151 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{DEFAULT_COLUMN_WIDTH, DEFAULT_WINDOW_WIDTH, LAST_COLUMN}, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn arrow_right() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_expand_selected_range("ArrowRight").unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 2]); +} + +#[test] +fn arrow_right_decreases() { + // if the selected cell is on the upper right corner, right-arrow will decrease the size of teh area + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let (start_row, start_column, end_row, end_column) = (5, 3, 10, 8); + model.set_selected_cell(start_row, end_column).unwrap(); + model + .set_selected_range(start_row, start_column, end_row, end_column) + .unwrap(); + + model.on_expand_selected_range("ArrowRight").unwrap(); + let view = model.get_selected_view(); + assert_eq!( + view.range, + [start_row, start_column + 1, end_row, end_column] + ); +} + +#[test] +fn arrow_right_last_column() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_selected_cell(1, LAST_COLUMN).unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, LAST_COLUMN, 1, LAST_COLUMN]); +} + +#[test] +fn arrow_right_scroll_right() { + let window_width = DEFAULT_WINDOW_WIDTH as f64; + let column_width = DEFAULT_COLUMN_WIDTH; + let column_count = f64::floor(window_width / column_width) as i32; + + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + // initially the column to the left is A + let view = model.get_selected_view(); + assert_eq!(view.left_column, 1); + + // We select all columns from 1 to the last visible + let (start_row, start_column, end_row, end_column) = (1, 1, 1, column_count); + model.set_selected_cell(start_row, start_column).unwrap(); + model + .set_selected_range(start_row, start_column, end_row, end_column) + .unwrap(); + + // Now we select one more column + model.on_expand_selected_range("ArrowRight").unwrap(); + + // The view has updated and the first visible column is B + let view = model.get_selected_view(); + assert_eq!( + view.range, + [start_row, start_column, end_row, end_column + 1] + ); + assert_eq!(view.left_column, 2); + + // now we click on cell B2 and we + model.set_selected_cell(2, 2).unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.range, [2, 1, 2, 2]); + assert_eq!(view.left_column, 1); + + // a second arrow left won't do anything + model.on_expand_selected_range("ArrowLeft").unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.range, [2, 1, 2, 2]); + assert_eq!(view.left_column, 1); +} + +#[test] +fn arrow_left() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.set_selected_cell(5, 3).unwrap(); + model.set_selected_range(5, 3, 10, 8).unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [5, 3, 10, 7]); +} + +#[test] +fn arrow_left_left_border() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + model.on_expand_selected_range("ArrowLeft").unwrap(); + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 1, 1, 1]); +} + +#[test] +fn arrow_left_increases() { + // If the selected cell is on the top right corner + // arrow left increases the selected area by + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + let (start_row, start_column, end_row, end_column) = (4, 10, 4, 20); + model.set_selected_cell(start_row, end_column).unwrap(); + model + .set_selected_range(start_row, start_column, end_row, end_column) + .unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + let view = model.get_selected_view(); + assert_eq!( + view.range, + [start_row, start_column - 1, end_row, end_column] + ); +} + +#[test] +fn arrow_left_scrolls_left() { + // If the selected cell is on the top right corner + // arrow left increases the selected area by + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + + model.set_top_left_visible_cell(1, 50).unwrap(); + + model.set_selected_cell(1, 50).unwrap(); + // arrow left x 2 + model.on_expand_selected_range("ArrowLeft").unwrap(); + model.on_expand_selected_range("ArrowLeft").unwrap(); + + let view = model.get_selected_view(); + assert_eq!(view.range, [1, 48, 1, 50]); + assert_eq!(view.left_column, 48); + assert_eq!(view.column, 50); +} diff --git a/base/src/test/user_model/test_on_paste_styles.rs b/base/src/test/user_model/test_on_paste_styles.rs new file mode 100644 index 0000000..bb1b9d3 --- /dev/null +++ b/base/src/test/user_model/test_on_paste_styles.rs @@ -0,0 +1,48 @@ +#![allow(clippy::unwrap_used)] + +use crate::test::util::new_empty_model; +use crate::types::Fill; +use crate::UserModel; + +#[test] +fn simple_pasting() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let mut style = model.get_cell_style(0, 1, 1).unwrap(); + style.fill = Fill { + pattern_type: "solid".to_string(), + fg_color: Some("#FF5577".to_string()), + bg_color: Some("#33FF44".to_string()), + }; + let styles = vec![vec![style.clone()]]; + + model.set_selected_cell(5, 4).unwrap(); + model.set_selected_range(5, 4, 10, 9).unwrap(); + model.on_paste_styles(&styles).unwrap(); + + for row in 5..10 { + for column in 4..9 { + let cell_style = model.get_cell_style(0, row, column).unwrap(); + assert_eq!(cell_style, style); + } + } + + model.undo().unwrap(); + let base_style = model.get_cell_style(0, 100, 100).unwrap(); + + for row in 5..10 { + for column in 4..9 { + let cell_style = model.get_cell_style(0, row, column).unwrap(); + assert_eq!(cell_style, base_style); + } + } + + model.redo().unwrap(); + + for row in 5..10 { + for column in 4..9 { + let cell_style = model.get_cell_style(0, row, column).unwrap(); + assert_eq!(cell_style, style); + } + } +} diff --git a/base/src/test/user_model/test_view.rs b/base/src/test/user_model/test_view.rs index 5a6dcd6..3f9d907 100644 --- a/base/src/test/user_model/test_view.rs +++ b/base/src/test/user_model/test_view.rs @@ -52,19 +52,12 @@ fn set_the_cell_sets_the_range() { fn set_the_range_does_not_set_the_cell() { let model = new_empty_model(); let mut model = UserModel::from_model(model); - model.set_selected_range(5, 4, 10, 6).unwrap(); - assert_eq!(model.get_selected_sheet(), 0); - assert_eq!(model.get_selected_cell(), (0, 1, 1)); assert_eq!( - model.get_selected_view(), - SelectedView { - sheet: 0, - row: 1, - column: 1, - range: [5, 4, 10, 6], - top_row: 1, - left_column: 1 - } + model.set_selected_range(5, 4, 10, 6), + Err( + "The selected cells is not in one of the corners. Row: '1' and row range '(5, 10)'" + .to_string() + ) ); } diff --git a/base/src/test/user_model/test_window_size.rs b/base/src/test/user_model/test_window_size.rs new file mode 100644 index 0000000..85a6a0c --- /dev/null +++ b/base/src/test/user_model/test_window_size.rs @@ -0,0 +1,29 @@ +#![allow(clippy::unwrap_used)] + +use crate::{ + constants::{DEFAULT_ROW_HEIGHT, DEFAULT_WINDOW_HEIGH, DEFAULT_WINDOW_WIDTH}, + test::util::new_empty_model, + UserModel, +}; + +#[test] +fn basic_test() { + let model = new_empty_model(); + let mut model = UserModel::from_model(model); + let window_height = model.get_window_height().unwrap(); + assert_eq!(window_height, DEFAULT_WINDOW_HEIGH); + + let window_width = model.get_window_width().unwrap(); + assert_eq!(window_width, DEFAULT_WINDOW_WIDTH); + + // Set the window height to double the default and check that page_down behaves as expected + model.set_window_height((window_height * 2) as f64); + model.on_page_down().unwrap(); + + let row_height = DEFAULT_ROW_HEIGHT; + let row_count = f64::floor((window_height * 2) as f64 / row_height) as i32; + let view = model.get_selected_view(); + assert_eq!(view.row, 1 + row_count); + let scroll_y = model.get_scroll_y().unwrap(); + assert_eq!(scroll_y, (row_count as f64) * DEFAULT_ROW_HEIGHT); +} diff --git a/base/src/types.rs b/base/src/types.rs index ebda7c2..b62cdb5 100644 --- a/base/src/types.rs +++ b/base/src/types.rs @@ -33,6 +33,10 @@ pub struct WorkbookSettings { pub struct WorkbookView { /// The index of the currently selected sheet. pub sheet: u32, + /// The current width of the window + pub window_width: i64, + /// The current heigh of the window + pub window_height: i64, } /// An internal representation of an IronCalc Workbook diff --git a/base/src/user_model.rs b/base/src/user_model/common.rs similarity index 84% rename from base/src/user_model.rs rename to base/src/user_model/common.rs index 9351f53..c8f84d7 100644 --- a/base/src/user_model.rs +++ b/base/src/user_model/common.rs @@ -2,7 +2,6 @@ use std::{collections::HashMap, fmt::Debug}; -use bitcode::{Decode, Encode}; use serde::{Deserialize, Serialize}; use crate::{ @@ -13,180 +12,35 @@ use crate::{ }, model::Model, types::{ - Alignment, BorderItem, BorderStyle, Cell, CellType, Col, HorizontalAlignment, Row, - SheetProperties, Style, VerticalAlignment, + Alignment, BorderItem, BorderStyle, CellType, Col, HorizontalAlignment, SheetProperties, + Style, VerticalAlignment, }, utils::is_valid_hex_color, }; +use crate::user_model::history::{ + ColumnData, Diff, DiffList, DiffType, History, QueueDiffs, RowData, +}; + #[derive(Serialize, Deserialize)] -#[cfg_attr(test, derive(PartialEq, Debug))] -pub struct SelectedView { - pub sheet: u32, - pub row: i32, - pub column: i32, - pub range: [i32; 4], - pub top_row: i32, - pub left_column: i32, +pub enum BorderType { + All, + Inner, + Outer, + Top, + Right, + Bottom, + Left, + CenterH, + CenterV, + None, } -#[derive(Clone, Encode, Decode)] -struct RowData { - row: Option, - data: HashMap, -} - -#[derive(Clone, Encode, Decode)] -struct ColumnData { - column: Option, - data: HashMap, -} - -#[derive(Clone, Encode, Decode)] -enum Diff { - // Cell diffs - SetCellValue { - sheet: u32, - row: i32, - column: i32, - new_value: String, - old_value: Box>, - }, - CellClearContents { - sheet: u32, - row: i32, - column: i32, - old_value: Box>, - }, - CellClearAll { - sheet: u32, - row: i32, - column: i32, - old_value: Box>, - old_style: Box + + + + + + diff --git a/webapp/src/icons/border-top.svg b/webapp/src/icons/border-top.svg new file mode 100644 index 0000000..9051f73 --- /dev/null +++ b/webapp/src/icons/border-top.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/webapp/src/icons/decrease-decimal.svg b/webapp/src/icons/decrease-decimal.svg new file mode 100644 index 0000000..ab46d1e --- /dev/null +++ b/webapp/src/icons/decrease-decimal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/webapp/src/icons/delete-column.svg b/webapp/src/icons/delete-column.svg new file mode 100644 index 0000000..36fc423 --- /dev/null +++ b/webapp/src/icons/delete-column.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/webapp/src/icons/delete-row.svg b/webapp/src/icons/delete-row.svg new file mode 100644 index 0000000..ffebabf --- /dev/null +++ b/webapp/src/icons/delete-row.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/webapp/src/icons/fx.svg b/webapp/src/icons/fx.svg new file mode 100644 index 0000000..fb9e203 --- /dev/null +++ b/webapp/src/icons/fx.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/src/icons/increase-decimal.svg b/webapp/src/icons/increase-decimal.svg new file mode 100644 index 0000000..c8ffa01 --- /dev/null +++ b/webapp/src/icons/increase-decimal.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/index.ts b/webapp/src/icons/index.ts new file mode 100644 index 0000000..5f2dd16 --- /dev/null +++ b/webapp/src/icons/index.ts @@ -0,0 +1,46 @@ +import DecimalPlacesDecreaseIcon from "./decrease-decimal.svg?react"; +import DecimalPlacesIncreaseIcon from "./increase-decimal.svg?react"; + +import BorderBottomIcon from "./border-bottom.svg?react"; +import BorderCenterHIcon from "./border-center-h.svg?react"; +import BorderCenterVIcon from "./border-center-v.svg?react"; +import BorderInnerIcon from "./border-inner.svg?react"; +import BorderLeftIcon from "./border-left.svg?react"; +import BorderNoneIcon from "./border-none.svg?react"; +import BorderOuterIcon from "./border-outer.svg?react"; +import BorderRightIcon from "./border-right.svg?react"; +import BorderStyleIcon from "./border-style.svg?react"; +import BorderTopIcon from "./border-top.svg?react"; + +import ArrowMiddleFromLine from "./arrow-middle-from-line.svg?react"; +import DeleteColumnIcon from "./delete-column.svg?react"; +import DeleteRowIcon from "./delete-row.svg?react"; +import InsertColumnLeftIcon from "./insert-column-left.svg?react"; +import InsertColumnRightIcon from "./insert-column-right.svg?react"; +import InsertRowAboveIcon from "./insert-row-above.svg?react"; +import InsertRowBelow from "./insert-row-below.svg?react"; + +import Fx from "./fx.svg?react"; + +export { + ArrowMiddleFromLine, + DecimalPlacesDecreaseIcon, + DecimalPlacesIncreaseIcon, + BorderBottomIcon, + BorderCenterHIcon, + BorderCenterVIcon, + BorderInnerIcon, + BorderLeftIcon, + BorderOuterIcon, + BorderRightIcon, + BorderTopIcon, + BorderNoneIcon, + BorderStyleIcon, + DeleteColumnIcon, + DeleteRowIcon, + InsertColumnLeftIcon, + InsertColumnRightIcon, + InsertRowAboveIcon, + InsertRowBelow, + Fx, +}; diff --git a/webapp/src/icons/insert-column-left.svg b/webapp/src/icons/insert-column-left.svg new file mode 100644 index 0000000..1d6e56d --- /dev/null +++ b/webapp/src/icons/insert-column-left.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/insert-column-right.svg b/webapp/src/icons/insert-column-right.svg new file mode 100644 index 0000000..5cc5178 --- /dev/null +++ b/webapp/src/icons/insert-column-right.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/insert-row-above.svg b/webapp/src/icons/insert-row-above.svg new file mode 100644 index 0000000..336d193 --- /dev/null +++ b/webapp/src/icons/insert-row-above.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/icons/insert-row-below.svg b/webapp/src/icons/insert-row-below.svg new file mode 100644 index 0000000..8b7f88b --- /dev/null +++ b/webapp/src/icons/insert-row-below.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/webapp/src/index.css b/webapp/src/index.css new file mode 100644 index 0000000..ea1e941 --- /dev/null +++ b/webapp/src/index.css @@ -0,0 +1,4 @@ +body { + margin: 0; + padding: 0; +} diff --git a/webapp/src/locale/en_us.json b/webapp/src/locale/en_us.json new file mode 100644 index 0000000..0852bdf --- /dev/null +++ b/webapp/src/locale/en_us.json @@ -0,0 +1,55 @@ +{ + "toolbar": { + "redo": "Redo", + "undo": "Undo", + "copy_styles": "Copy styles", + "euro": "Format as Euro", + "percentage": "Format as Percentage", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strike_through": "Strikethrough", + "align_left": "Align left", + "align_right": "Align right", + "align_center": "Align center", + "format_number": "Format number", + "font_color": "Font color", + "fill_color": "Fill color", + "borders": "Borders", + "decimal_places_increase": "Increase decimal places", + "decimal_places_decrease": "Decrease decimal places", + "format_menu": { + "auto": "Auto", + "number": "Number", + "percentage": "Percentage", + "currency_eur": "Euro (EUR)", + "currency_usd": "Dollar (USD", + "currency_gbp": "British Pound (GBD)", + "date_short": "Short date", + "date_long": "Long date", + "custom": "Custom", + "number_example": "1,000.00", + "percentage_example": "10%", + "currency_eur_example": "€", + "currency_usd_example": "$", + "currency_gbp_example": "£", + "date_short_example": "09/24/2024", + "date_long_example": "Tuesday, September 24, 2024" + } + }, + "num_fmt": { + "title": "Custom number format", + "label": "Number format", + "save": "Save" + }, + "sheet_rename": { + "rename": "Save", + "label": "New name", + "title": "Rename Sheet" + }, + "formula_input": { + "update": "Update", + "label": "Formula", + "title": "Update formula" + } +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx new file mode 100644 index 0000000..edb6ee8 --- /dev/null +++ b/webapp/src/main.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; +import ThemeProvider from "@mui/material/styles/ThemeProvider"; +import { theme } from "./theme.ts"; + +// biome-ignore lint: we know the 'root' element exists. +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/webapp/src/theme.ts b/webapp/src/theme.ts new file mode 100644 index 0000000..6f4080d --- /dev/null +++ b/webapp/src/theme.ts @@ -0,0 +1,66 @@ +import { createTheme } from "@mui/material/styles"; +import "./fonts.css"; + +export const theme = createTheme({ + typography: { + fontFamily: "Inter", + }, + palette: { + common: { + black: "#272525", + white: "#FFF", + }, + primary: { + main: "#F2994A", + light: "#EFAA6D", + dark: "#D68742", + contrastText: "#FFF", + }, + secondary: { + main: "#2F80ED", + light: "#4E92EC", + dark: "#2B6EC8", + contrastText: "#FFF", + }, + error: { + main: "#EB5757", + light: "#E77A7A", + dark: "#CB4C4C", + contrastText: "#FFF", + }, + warning: { + main: "#F2C94C", + light: "#EED384", + dark: "#D6B244", + contrastText: "#FFF", + }, + info: { + main: "#9E9E9E", + light: "#E0E0E0", + dark: "#757575", + contrastText: "#FFF", + }, + success: { + main: "#27AE60", + light: "#57BD82", + dark: "#239152", + contrastText: "#FFF", + }, + grey: { + "50": "#F2F2F2", + "100": "#F5F5F5", + "200": "#EEEEEE", + "300": "#E0E0E0", + "400": "#BDBDBD", + "500": "#9E9E9E", + "600": "#757575", + "700": "#616161", + "800": "#424242", + "900": "#333333", + A100: "#F2F2F2", + A200: "#EEEEEE", + A400: "#bdbdbd", + A700: "#616161", + }, + }, +}); diff --git a/webapp/src/vite-env.d.ts b/webapp/src/vite-env.d.ts new file mode 100644 index 0000000..b1f45c7 --- /dev/null +++ b/webapp/src/vite-env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json new file mode 100644 index 0000000..47787c0 --- /dev/null +++ b/webapp/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/webapp/tsconfig.node.json b/webapp/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/webapp/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts new file mode 100644 index 0000000..60a7425 --- /dev/null +++ b/webapp/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react(), svgr()], + server: { + fs: { + // Allow serving files from one level up to the project root + allow: ['..'], + }, + }, +}); diff --git a/xlsx/src/import/mod.rs b/xlsx/src/import/mod.rs index d130237..cbc0d49 100644 --- a/xlsx/src/import/mod.rs +++ b/xlsx/src/import/mod.rs @@ -93,6 +93,8 @@ fn load_xlsx_from_reader( 0, WorkbookView { sheet: selected_sheet, + window_width: 800, + window_height: 600, }, ); Ok(Workbook { @@ -111,8 +113,6 @@ fn load_xlsx_from_reader( }) } -// Public methods - /// Imports a file from disk into an internal representation fn load_from_excel(file_name: &str, locale: &str, tz: &str) -> Result { let file_path = std::path::Path::new(file_name); @@ -134,6 +134,7 @@ pub fn load_from_xlsx(file_name: &str, locale: &str, tz: &str) -> Result Result { let contents = fs::read(file_name) .map_err(|e| XlsxError::IO(format!("Could not extract workbook name: {}", e)))?; - let workbook: Workbook = bitcode::decode(&contents).unwrap(); + let workbook: Workbook = bitcode::decode(&contents) + .map_err(|e| XlsxError::IO(format!("Failed to decode file: {}", e)))?; Model::from_workbook(workbook).map_err(XlsxError::Workbook) } diff --git a/xlsx/tests/example.ic b/xlsx/tests/example.ic index 1d312197a4642cecfb0ee515a4f6a69038465698..740cd6bebc42f78ced0640739450fb46812ab4c5 100644 GIT binary patch delta 1479 zcmZ`(OOG2x5UzgB^gKM%GoJO^j(66(-p$K+c`j1q!7@l3$OR6FQ%Dpg65qEaDGP7Yo0ARb14^$a7HL4%;v=M zSfJLT4y{?cW{XQfoOUg0#@KwZF&9OtJ9QAK7 zZLZ2;I_532r47sZ;?ixp7%vv@i~!zcA6;>PXP0hU09$ha7|vk`kV6K;G)+fy3NV8G z(ikD;<+d0dq~%&3jK>G#-jh!}ZcV3b zI`wd4e*e^Zy3reU2c506r_YSfjq=UyomLdL*Xl{Lp_1eg1EVGX1N{bc>El_Y^vWI( zGFyqVpAtu}RT5fSns~_dfk`kDj-Z0}8{$OU#&6CIVXFOxXQ*vcSRTO&CVs~#CQx?_ zN(ZW}N=S=O>@8<-nJfwuoc zYtl`>X22eP-@C;PIVo;QulQVAxv;rKfiQ2wabsTwJKIq>uC)jK{;=8Uw$@KII^99P z)lSyxy?&#)nX1v&=`&}~o#%kBcZ%Z@{D1&f3VcnD>*9|Y7ud2qIzb2!1d}qH+u{zL za2L3h+b4ch&Swu>0OnRq`}(?XJ(vCAJ0HIE`!D(-LH7LJ!*4$L`+IFG?f-FB#eF+_ z|AOs*`QH86<;BOJUil3J=|7LooBjX* delta 1452 zcmZ`&JB%Au6n)=$pZSkx{kM14>y7g_ULeULMLV&EBs4To(Ge6uga9SlXeA0J1r-QU z3`zkKLR1J*Lm7%exhYa zJAJ8>ZfoEVvp^8l9>p6tb)q%QBI2|pSBapp@R{RDPL*~!CX|^63zs>*qsYi>(_xG8 zK^jD^klhgLUfk)gC1Z&xVF)v5A{;>{#e{GUEOsa^2@VMkAc2oZ{tJf#7yw9I11wqv zb!5>nF|5{L$fd+#lu*n9*9%>bO02ZfD-|FFGs;{Kd(>eb4??$;z)8%Qq?kJrO0krf z_?mGQ#4>RsFN6x^OV^7uL_ruQf`M4rtd{Hsk(LRGRp_sXS{vwn_^C671)-cW4j~6^ za0QHm;jFCh5;6+61HQi4+OKA)Xaxe6HsJ%yd^b@cb)*+WT*NIMMFj4!X`WsX@4kW% znh}I{u6%U!{Kag#mos!lUAnL=P)>4^3B(YZu)M4-$|p4t$}>nO%jI+lD8S7o`}?zN zv)Sz0{$#%>rUj4%lorJvY{2Rv|3_Xkj3I|E7Vv%YsZ*z)t|Qk!v-6DWrE#>@?IoR7 zyFVU|HU{fuHrbpVI&y65#PR9T!|7x2#6zU3Fip+-H@nNc2@ZA}KWy^5uNxj2-!viety^}(l2-~3MXwis7m zi0q|b4TbE;XU%9k((?8QR+`<)O0+xKBWBgdVmwE-&5n6|JG|oU>Z+VqH>9pUlTPXK zL@P%yPZEUbraz011mWR+)*X!oy^Zy8yOXXBTkTP&H|q8W<7n&9WP0@2iIc}+66Odk z2u2h^3=e=IDW%p*A)%#eIBUZI1#?I+6Z{l8%`{>TUVxgkwC6dQH^uh6=KMF#=a-6` zpS=3lo!$4(3h&GJ?wQ|8n*Zsk4`2M__ec1H-RExK`sRawzBhHq)cs=kpLajM_uD(2 zAHHkW&md_Eel!h{$-jLt0OZ#H4#LFd^Ye38ufP1}-t{-Czg4dq>-F@7*Dhb)TWl=1 PcT99{d*}99{g%8B&$^oB