diff --git a/base/src/model.rs b/base/src/model.rs index 422b068..0c9acd3 100644 --- a/base/src/model.rs +++ b/base/src/model.rs @@ -418,6 +418,7 @@ impl Model { CalcResult::new_error(Error::NIMPL, cell, "Arrays not implemented".to_string()) } VariableKind(defined_name) => { + println!("{:?}", defined_name); let parsed_defined_name = self .parsed_defined_names .get(&(Some(cell.sheet), defined_name.to_lowercase())) // try getting local defined name @@ -426,6 +427,7 @@ impl Model { .get(&(None, defined_name.to_lowercase())) }); // fallback to global + println!("Parsed: {:?}", defined_name); if let Some(parsed_defined_name) = parsed_defined_name { match parsed_defined_name { ParsedDefinedName::CellReference(reference) => { @@ -1986,6 +1988,95 @@ impl Model { .worksheet_mut(sheet)? .set_row_height(column, height) } + + /// Adds a new defined name + pub fn new_defined_name( + &mut self, + name: &str, + scope: Option, + formula: &str, + ) -> Result<(), String> { + let name_upper = name.to_uppercase(); + let defined_names = &self.workbook.defined_names; + // if the defined name already exist return error + for df in defined_names { + if df.name.to_uppercase() == name_upper && df.sheet_id == scope { + return Err("Defined name already exists".to_string()); + } + } + self.workbook.defined_names.push(DefinedName { + name: name.to_string(), + formula: formula.to_string(), + sheet_id: scope, + }); + self.reset_parsed_structures(); + Ok(()) + } + + /// Delete defined name of name and scope + pub fn delete_defined_name(&mut self, name: &str, scope: Option) -> Result<(), String> { + let name_upper = name.to_uppercase(); + let defined_names = &self.workbook.defined_names; + let mut index = None; + for (i, df) in defined_names.iter().enumerate() { + if df.name.to_uppercase() == name_upper && df.sheet_id == scope { + index = Some(i); + } + } + if let Some(i) = index { + self.workbook.defined_names.remove(i); + self.reset_parsed_structures(); + Ok(()) + } else { + Err("Defined name not found".to_string()) + } + } + + /// returns the formula for a defined name + pub fn get_defined_name_formula( + &self, + name: &str, + scope: Option, + ) -> Result { + let name_upper = name.to_uppercase(); + let defined_names = &self.workbook.defined_names; + for df in defined_names { + if df.name.to_uppercase() == name_upper && df.sheet_id == scope { + return Ok(df.formula.clone()); + } + } + Err("Defined name not found".to_string()) + } + + /// update defined name + pub fn update_defined_name( + &mut self, + name: &str, + scope: Option, + new_name: &str, + new_scope: Option, + new_formula: &str, + ) -> Result<(), String> { + let name_upper = name.to_uppercase(); + let defined_names = &self.workbook.defined_names; + let mut index = None; + for (i, df) in defined_names.iter().enumerate() { + if df.name.to_uppercase() == name_upper && df.sheet_id == scope { + index = Some(i); + } + } + if let Some(i) = index { + if let Some(df) = self.workbook.defined_names.get_mut(i) { + df.name = new_name.to_string(); + df.sheet_id = new_scope; + df.formula = new_formula.to_string(); + self.reset_parsed_structures(); + } + Ok(()) + } else { + Err("Defined name not found".to_string()) + } + } } #[cfg(test)] diff --git a/base/src/test/user_model/mod.rs b/base/src/test/user_model/mod.rs index c557104..86c8f18 100644 --- a/base/src/test/user_model/mod.rs +++ b/base/src/test/user_model/mod.rs @@ -3,6 +3,7 @@ mod test_autofill_columns; mod test_autofill_rows; mod test_border; mod test_clear_cells; +mod test_defined_names; mod test_diff_queue; mod test_evaluation; mod test_general; diff --git a/base/src/test/user_model/test_defined_names.rs b/base/src/test/user_model/test_defined_names.rs new file mode 100644 index 0000000..bb77fd3 --- /dev/null +++ b/base/src/test/user_model/test_defined_names.rs @@ -0,0 +1,40 @@ +#![allow(clippy::unwrap_used)] + +use crate::UserModel; + +#[test] +fn create_defined_name() { + let mut model = UserModel::new_empty("model", "en", "UTC").unwrap(); + model.set_user_input(0, 1, 1, "42").unwrap(); + model.new_defined_name("myName", None, "$A$1").unwrap(); + model.set_user_input(0, 5, 7, "=myName").unwrap(); + assert_eq!( + model.get_formatted_cell_value(0, 5, 7), + Ok("42".to_string()) + ); + + // rename it + model + .update_defined_name("myName", None, "myName", None, "$A$1*2") + .unwrap(); + assert_eq!( + model.get_formatted_cell_value(0, 5, 7), + Ok("42".to_string()) + ); + + // delete it + model.delete_defined_name("myName", None).unwrap(); + assert_eq!( + model.get_formatted_cell_value(0, 5, 7), + Ok("#REF!".to_string()) + ); +} + +#[test] +fn rename_defined_name() {} + +#[test] +fn delete_sheet() {} + +#[test] +fn change_scope() {} diff --git a/base/src/user_model/common.rs b/base/src/user_model/common.rs index 7306c44..fdf8439 100644 --- a/base/src/user_model/common.rs +++ b/base/src/user_model/common.rs @@ -13,8 +13,8 @@ use crate::{ }, model::Model, types::{ - Alignment, BorderItem, CellType, Col, HorizontalAlignment, SheetProperties, Style, - VerticalAlignment, + Alignment, BorderItem, CellType, Col, DefinedName, HorizontalAlignment, SheetProperties, + Style, VerticalAlignment, }, utils::is_valid_hex_color, }; @@ -1692,6 +1692,66 @@ impl UserModel { Ok(()) } + /// Returns the list of defined names + pub fn get_defined_name_list(&self) -> Vec { + self.model.workbook.defined_names.clone() + } + + /// Delete an existing defined name + pub fn delete_defined_name(&mut self, name: &str, scope: Option) -> Result<(), String> { + let old_value = self.model.get_defined_name_formula(name, scope)?; + let diff_list = vec![Diff::DeleteDefinedName { + name: name.to_string(), + scope, + old_value, + }]; + self.push_diff_list(diff_list); + self.model.delete_defined_name(name, scope)?; + self.evaluate_if_not_paused(); + Ok(()) + } + + /// Create a new defined name + pub fn new_defined_name( + &mut self, + name: &str, + scope: Option, + formula: &str, + ) -> Result<(), String> { + self.model.new_defined_name(name, scope, formula)?; + let diff_list = vec![Diff::CreateDefinedName { + name: name.to_string(), + scope, + value: formula.to_string(), + }]; + self.push_diff_list(diff_list); + self.evaluate_if_not_paused(); + Ok(()) + } + + /// Updates a defined name + pub fn update_defined_name( + &mut self, + name: &str, + scope: Option, + new_name: &str, + new_scope: Option, + new_formula: &str, + ) -> Result<(), String> { + let old_formula = self.model.get_defined_name_formula(name, scope)?; + let diff_list = vec![Diff::UpdateDefinedName { + name: name.to_string(), + scope, + old_formula: old_formula.to_string(), + new_name: new_name.to_string(), + new_scope, + new_formula: new_formula.to_string(), + }]; + self.push_diff_list(diff_list); + self.evaluate_if_not_paused(); + Ok(()) + } + // **** Private methods ****** // fn push_diff_list(&mut self, diff_list: DiffList) { @@ -1862,6 +1922,20 @@ impl UserModel { } => { self.model.set_show_grid_lines(*sheet, *old_value)?; } + Diff::CreateDefinedName { name, scope, value } => todo!(), + Diff::DeleteDefinedName { + name, + scope, + old_value, + } => todo!(), + Diff::UpdateDefinedName { + name, + scope, + old_formula, + new_name, + new_scope, + new_formula, + } => todo!(), } } if needs_evaluation { @@ -1989,6 +2063,20 @@ impl UserModel { } => { self.model.set_show_grid_lines(*sheet, *new_value)?; } + Diff::CreateDefinedName { name, scope, value } => todo!(), + Diff::DeleteDefinedName { + name, + scope, + old_value, + } => todo!(), + Diff::UpdateDefinedName { + name, + scope, + old_formula, + new_name, + new_scope, + new_formula, + } => todo!(), } } diff --git a/base/src/user_model/history.rs b/base/src/user_model/history.rs index 4403e43..949059c 100644 --- a/base/src/user_model/history.rs +++ b/base/src/user_model/history.rs @@ -108,7 +108,26 @@ pub(crate) enum Diff { sheet: u32, old_value: bool, new_value: bool, - }, // FIXME: we are missing SetViewDiffs + }, + CreateDefinedName { + name: String, + scope: Option, + value: String, + }, + DeleteDefinedName { + name: String, + scope: Option, + old_value: String, + }, + UpdateDefinedName { + name: String, + scope: Option, + old_formula: String, + new_name: String, + new_scope: Option, + new_formula: String, + }, + // FIXME: we are missing SetViewDiffs } pub(crate) type DiffList = Vec; diff --git a/bindings/wasm/fix_types.py b/bindings/wasm/fix_types.py index d3f663b..fd466ee 100644 --- a/bindings/wasm/fix_types.py +++ b/bindings/wasm/fix_types.py @@ -187,6 +187,20 @@ paste_from_clipboard_types = r""" pasteFromClipboard(source_sheet: number, source_range: [number, number, number, number], clipboard: ClipboardData, is_cut: boolean): void; """ +defined_name_list = r""" +/** +* @returns {any} +*/ + getDefinedNameList(): any; +""" + +defined_name_list_types = r""" +/** +* @returns {DefinedName[]} +*/ + getDefinedNameList(): DefinedName[]; +""" + def fix_types(text): text = text.replace(get_tokens_str, get_tokens_str_types) text = text.replace(update_style_str, update_style_str_types) @@ -200,6 +214,7 @@ def fix_types(text): text = text.replace(paste_csv_string, paste_csv_string_types) text = text.replace(clipboard, clipboard_types) text = text.replace(paste_from_clipboard, paste_from_clipboard_types) + text = text.replace(defined_name_list, defined_name_list_types) with open("types.ts") as f: types_str = f.read() header_types = "{}\n\n{}".format(header, types_str) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 2d26f1b..9802d18 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use wasm_bindgen::{ prelude::{wasm_bindgen, JsError}, JsValue, @@ -29,6 +30,13 @@ pub fn column_name_from_number(column: i32) -> Result { } } +#[derive(Serialize)] +struct DefinedName { + name: String, + scope: Option, + formula: String, +} + #[wasm_bindgen] pub struct Model { model: BaseModel, @@ -542,4 +550,52 @@ impl Model { .paste_csv_string(&range, csv) .map_err(|e| to_js_error(e.to_string())) } + + #[wasm_bindgen(js_name = "getDefinedNameList")] + pub fn get_defined_name_list(&self) -> Result { + let data: Vec = + self.model + .get_defined_name_list() + .iter() + .map(|s| DefinedName { + name: s.name.to_string(), + scope: s.sheet_id, + formula: s.formula.to_string(), + }).collect(); + // Ok(data) + serde_wasm_bindgen::to_value(&data).map_err(|e| to_js_error(e.to_string())) + } + + #[wasm_bindgen(js_name = "newDefinedName")] + pub fn new_defined_name( + &mut self, + name: &str, + scope: Option, + formula: &str, + ) -> Result<(), JsError> { + self.model + .new_defined_name(name, scope, formula) + .map_err(|e| to_js_error(e.to_string())) + } + + #[wasm_bindgen(js_name = "updateDefinedName")] + pub fn update_defined_name( + &mut self, + name: &str, + scope: Option, + new_name: &str, + new_scope: Option, + new_formula: &str, + ) -> Result<(), JsError> { + self.model + .update_defined_name(name, scope, new_name, new_scope, new_formula) + .map_err(|e| to_js_error(e.to_string())) + } + + #[wasm_bindgen(js_name = "deleteDefinedName")] + pub fn delete_definedname(&mut self, name: &str, scope: Option) -> Result<(), JsError> { + self.model + .delete_defined_name(name, scope) + .map_err(|e| to_js_error(e.to_string())) + } } diff --git a/bindings/wasm/types.ts b/bindings/wasm/types.ts index 76d1a79..e85d31e 100644 --- a/bindings/wasm/types.ts +++ b/bindings/wasm/types.ts @@ -226,4 +226,10 @@ export interface Clipboard { csv: string; data: ClipboardData; range: [number, number, number, number]; +} + +export interface DefinedName { + name: string; + scope?: number; + formula: string; } \ No newline at end of file diff --git a/webapp/src/components/NameManagerDialog.tsx b/webapp/src/components/NameManagerDialog.tsx new file mode 100644 index 0000000..5f8a626 --- /dev/null +++ b/webapp/src/components/NameManagerDialog.tsx @@ -0,0 +1,127 @@ +import type { Model } from "@ironcalc/wasm"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + styled, +} from "@mui/material"; +import { t } from "i18next"; +import { BookOpen, X } from "lucide-react"; +import { useState } from "react"; +import NamedRange from "./NamedRange"; +import type { NamedRangeObject } from "./NamedRange"; + +type NameManagerDialogProperties = { + onClose: () => void; + onSave: () => void; + open: boolean; + model: Model; +}; + +function NameManagerDialog(props: NameManagerDialogProperties) { + + const handleClose = () => { + props.onClose(); + }; + + // update child component values in model + const handleSave = () => { + props.onSave(); // => onNamedRangesUpdate from toolbar + props.onClose(); + }; + + //! Why are fields editable only while clicking them? + // update child component values in UI + const handleChange = (id: string, field: string, value: string) => { + console.log("change:", id, field, value); + + // previous array elements, plus updated value + // setNamedRangesLocal((prev) => + // prev.map((namedRange) => + // namedRange.id === id ? { ...namedRange, [field]: value } : namedRange, + // ), + // ); + }; + + const nameList = props.model.getDefinedNameList(); + + return ( + + + Named Ranges + + + + + + {nameList.map((e) => ( + + ))} + + + + + + {t("name_manager_dialog.help")} + + + + + + + + + ); +} + +const StyledDialog = styled(Dialog)(() => ({ + "& .MuiPaper-root": { + maxHeight: "60%", + minHeight: "40%", + }, +})); + +// font-weight: 600 is too bold compared to design, should be between 500 & 600 +const StyledDialogTitle = styled(DialogTitle)` +font-size: 14px; +font-weight: 600; +display: flex; +align-items: center; +justify-content: space-between; +`; + +const StyledDialogActions = styled(DialogActions)` +height: 40px; +display: flex; +align-items: center; +justify-content: space-between; +font-size: 12px; +color: #757575; +`; + +export default NameManagerDialog; diff --git a/webapp/src/components/NamedRange.tsx b/webapp/src/components/NamedRange.tsx new file mode 100644 index 0000000..d6d142b --- /dev/null +++ b/webapp/src/components/NamedRange.tsx @@ -0,0 +1,91 @@ +import type { Model, WorksheetProperties } from "@ironcalc/wasm"; +import { Box, IconButton, MenuItem, TextField, styled } from "@mui/material"; +import { Trash2 } from "lucide-react"; + +export type NamedRangeObject = { + id: string; + name: string; + scope: string; + range: string; +}; + +type NamedRangeProperties = { + name: string; + scope: string; + range: string; + model: Model; + worksheets: WorksheetProperties[]; + // update namedRange in model + onChange: (field: string, value: string) => void; +}; + +function NamedRange(props: NamedRangeProperties) { + // define onChange in parent for updating the model and values + + const handleDelete = () => { + // update model + console.log("deleted named range"); + }; + + return ( + + + props.onChange("name", event.target.value) + } + onKeyDown={(event) => { + event.stopPropagation(); + }} + onClick={(event) => event.stopPropagation()} + /> + + props.onChange("scope", event.target.value) + } + > + {props.worksheets.map((option) => ( + + {option.name} + + ))} + + + props.onChange("range", event.target.value) + } + onKeyDown={(event) => { + event.stopPropagation(); + }} + onClick={(event) => event.stopPropagation()} + /> + {/* remove round hover animation */} + + + + + ); +} + +const StyledBox = styled(Box)` +display: flex; +gap: 15px; +`; + +export default NamedRange; diff --git a/webapp/src/components/toolbar.tsx b/webapp/src/components/toolbar.tsx index 3cc94b3..9bf9150 100644 --- a/webapp/src/components/toolbar.tsx +++ b/webapp/src/components/toolbar.tsx @@ -1,6 +1,7 @@ import type { BorderOptions, HorizontalAlignment, + Model, VerticalAlignment, } from "@ironcalc/wasm"; import { styled } from "@mui/material/styles"; @@ -22,6 +23,7 @@ import { Percent, Redo2, Strikethrough, + Tags, Type, Underline, Undo2, @@ -34,6 +36,7 @@ import { DecimalPlacesIncreaseIcon, } from "../icons"; import { theme } from "../theme"; +import NameManagerDialog from "./NameManagerDialog"; import BorderPicker from "./borderPicker"; import ColorPicker from "./colorPicker"; import { TOOLBAR_HEIGHT } from "./constants"; @@ -60,6 +63,7 @@ type ToolbarProperties = { onFillColorPicked: (hex: string) => void; onNumberFormatPicked: (numberFmt: string) => void; onBorderChanged: (border: BorderOptions) => void; + onNamedRangesUpdate: () => void; fillColor: string; fontColor: string; bold: boolean; @@ -72,12 +76,14 @@ type ToolbarProperties = { numFmt: string; showGridLines: boolean; onToggleShowGridLines: (show: boolean) => void; + model: Model; }; function Toolbar(properties: ToolbarProperties) { const [fontColorPickerOpen, setFontColorPickerOpen] = useState(false); const [fillColorPickerOpen, setFillColorPickerOpen] = useState(false); const [borderPickerOpen, setBorderPickerOpen] = useState(false); + const [nameManagerDialogOpen, setNameManagerDialogOpen] = useState(false); const fontColorButton = useRef(null); const fillColorButton = useRef(null); @@ -340,6 +346,18 @@ function Toolbar(properties: ToolbarProperties) { > {properties.showGridLines ? : } + + { + setNameManagerDialogOpen(true); + }} + disabled={!canEdit} + title={t("toolbar.name_manager")} + > + + + { + setNameManagerDialogOpen(false); + }} + onSave={() => { + console.log( + "update NamedRanges in model => properties.onNamedRangesUpdate", + ); + }} + model={properties.model} + /> ); } diff --git a/webapp/src/components/workbook.tsx b/webapp/src/components/workbook.tsx index 6f6b3e9..184da53 100644 --- a/webapp/src/components/workbook.tsx +++ b/webapp/src/components/workbook.tsx @@ -113,6 +113,10 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { updateRangeStyle("num_fmt", numberFmt); }; + const onNamedRangesUpdate = () => { + // update named ranges in model + } + const onCopyStyles = () => { const { sheet, @@ -559,6 +563,8 @@ const Workbook = (props: { model: Model; workbookState: WorkbookState }) => { model.setShowGridLines(sheet, show); setRedrawId((id) => id + 1); }} + onNamedRangesUpdate={onNamedRangesUpdate} + model={model} />