diff --git a/quadratic-api/src/app.ts b/quadratic-api/src/app.ts index 9f988ea270..3a265385d9 100644 --- a/quadratic-api/src/app.ts +++ b/quadratic-api/src/app.ts @@ -88,8 +88,8 @@ registerRoutes().then(() => { app.use((err: any, req: Request, res: Response, next: NextFunction) => { if (NODE_ENV !== 'test') { if (err.status >= 500) { - console.error(`[${new Date().toISOString()}] ${err.message}`); - if (NODE_ENV !== 'production') console.log(`[${new Date().toISOString()}] ${err.message}`); + if (NODE_ENV === 'production') console.error(`[${new Date().toISOString()}]`, err); + else console.log(`[${new Date().toISOString()}]`, err); } } next(err); diff --git a/quadratic-client/src/app/atoms/inlineEditorAtom.ts b/quadratic-client/src/app/atoms/inlineEditorAtom.ts new file mode 100644 index 0000000000..eb17a32cef --- /dev/null +++ b/quadratic-client/src/app/atoms/inlineEditorAtom.ts @@ -0,0 +1,32 @@ +import { inlineEditorMonaco } from '@/app/gridGL/HTMLGrid/inlineEditor/inlineEditorMonaco'; +import { atom } from 'recoil'; + +export interface InlineEditorState { + visible: boolean; + formula: boolean; + left: number; + top: number; + lineHeight: number; +} + +export const defaultInlineEditor: InlineEditorState = { + visible: false, + formula: false, + left: 0, + top: 0, + lineHeight: 19, +}; + +export const inlineEditorAtom = atom({ + key: 'inlineEditorState', + default: defaultInlineEditor, + effects: [ + ({ onSet }) => { + onSet((newValue) => { + if (newValue.visible) { + inlineEditorMonaco.focus(); + } + }); + }, + ], +}); diff --git a/quadratic-client/src/app/atoms/userMessageAtom.ts b/quadratic-client/src/app/atoms/userMessageAtom.ts new file mode 100644 index 0000000000..3c8a212625 --- /dev/null +++ b/quadratic-client/src/app/atoms/userMessageAtom.ts @@ -0,0 +1,14 @@ +import { atom } from 'recoil'; + +interface UserMessageState { + message: string | undefined; +} + +const defaultBorderMenuState: UserMessageState = { + message: undefined, +}; + +export const userMessageAtom = atom({ + key: 'userMessageState', + default: defaultBorderMenuState, +}); diff --git a/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/HtmlCells.tsx b/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/HtmlCells.tsx index 2eb48d3623..bac66c9bc8 100644 --- a/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/HtmlCells.tsx +++ b/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/HtmlCells.tsx @@ -4,8 +4,8 @@ import { htmlCellsHandler } from './htmlCellsHandler'; // parent of htmlCells. Handled in htmlCells.ts export const HtmlCells = () => { - const divRef = useCallback((node: HTMLDivElement | null) => { - htmlCellsHandler.init(node); + const divRef = useCallback((node: HTMLDivElement) => { + htmlCellsHandler.attach(node); }, []); return ( diff --git a/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/htmlCellsHandler.ts b/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/htmlCellsHandler.ts index ce6d2e7dc5..b8d0df1314 100644 --- a/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/htmlCellsHandler.ts +++ b/quadratic-client/src/app/gridGL/HTMLGrid/htmlCells/htmlCellsHandler.ts @@ -4,15 +4,15 @@ import { JsHtmlOutput } from '@/app/quadratic-core-types'; import { HtmlCell } from './HtmlCell'; class HTMLCellsHandler { - private cells: Set = new Set(); - // used to attach the html-cells to react - private div?: HTMLDivElement; + private div: HTMLDivElement; - // used to hold the data if the div is not yet created - private dataWaitingForDiv?: JsHtmlOutput[]; + private cells: Set = new Set(); constructor() { + this.div = document.createElement('div'); + this.div.className = 'html-cells'; + events.on('htmlOutput', this.htmlOutput); events.on('htmlUpdate', this.htmlUpdate); events.on('changeSheet', this.changeSheet); @@ -20,12 +20,12 @@ class HTMLCellsHandler { events.on('resizeRowHeights', (sheetId) => this.updateOffsets([sheetId])); } - private htmlOutput = (data: JsHtmlOutput[]) => { - if (this.div) { - this.updateHtmlCells(data); - } else { - this.dataWaitingForDiv = data; - } + attach = (parent: HTMLDivElement) => { + parent.appendChild(this.div); + }; + + private htmlOutput = (htmlCells: JsHtmlOutput[]) => { + this.prepareCells([...this.cells], htmlCells); }; private htmlUpdate = (data: JsHtmlOutput) => { @@ -50,24 +50,6 @@ class HTMLCellsHandler { } }; - private attach(parent: HTMLDivElement) { - if (this.div) { - parent.appendChild(this.div); - } - } - - init(parent: HTMLDivElement | null) { - this.div = this.div ?? document.createElement('div'); - this.div.className = 'html-cells'; - if (parent) { - this.attach(parent); - } - if (this.dataWaitingForDiv) { - this.updateHtmlCells(this.dataWaitingForDiv); - this.dataWaitingForDiv = undefined; - } - } - private changeSheet = () => { this.cells.forEach((cell) => cell.changeSheet(sheets.sheet.id)); }; @@ -102,10 +84,6 @@ class HTMLCellsHandler { }); } - updateHtmlCells(htmlCells: JsHtmlOutput[]) { - this.prepareCells([...this.cells], htmlCells); - } - clearHighlightEdges() { this.cells.forEach((cell) => cell.clearHighlightEdges()); } diff --git a/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/InlineEditor.tsx b/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/InlineEditor.tsx index 3c1ac9fc1f..219088ccf4 100644 --- a/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/InlineEditor.tsx +++ b/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/InlineEditor.tsx @@ -2,12 +2,14 @@ //! button that opens the full-sized code editor. All functionality is defined //! in inlineEditorHandler.ts. +import { inlineEditorAtom } from '@/app/atoms/inlineEditorAtom'; import { inlineEditorHandler } from '@/app/gridGL/HTMLGrid/inlineEditor/inlineEditorHandler'; import { colors } from '@/app/theme/colors'; import { Button } from '@/shared/shadcn/ui/button'; import { SubtitlesOutlined } from '@mui/icons-material'; import { Tooltip } from '@mui/material'; import { useCallback } from 'react'; +import { useRecoilValue } from 'recoil'; import './inlineEditorStyles.scss'; export const InlineEditor = () => { @@ -20,29 +22,45 @@ export const InlineEditor = () => { // fix its positioning problem. There's probably a workaround, but it was too // much work. + const { visible, formula, left, top } = useRecoilValue(inlineEditorAtom); + return ( -
+
- - - + {visible && formula ? ( + + + + ) : null}
); }; diff --git a/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/inlineEditorHandler.ts b/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/inlineEditorHandler.ts index 8bd4b37c9e..3dfc3fb262 100644 --- a/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/inlineEditorHandler.ts +++ b/quadratic-client/src/app/gridGL/HTMLGrid/inlineEditor/inlineEditorHandler.ts @@ -28,9 +28,6 @@ const MINIMUM_MOVE_VIEWPORT = 50; class InlineEditorHandler { private div?: HTMLDivElement; - // this is used to display the formula expand button - private formulaExpandButton?: HTMLDivElement; - private open = false; private showing = false; @@ -71,7 +68,7 @@ class InlineEditorHandler { this.cursorIsMoving = false; this.x = this.y = this.width = this.height = 0; this.location = undefined; - this.formula = false; + this.formula = undefined; this.formatSummary = undefined; this.temporaryBold = undefined; this.temporaryItalic = undefined; @@ -141,7 +138,6 @@ class InlineEditorHandler { window.removeEventListener('keydown', inlineEditorKeyboard.keyDown); this.updateMonacoCursorPosition(); this.keepCursorVisible(); - inlineEditorMonaco.focus(); } inlineEditorFormula.cursorMoved(); } else { @@ -199,7 +195,6 @@ class InlineEditorHandler { this.changeToFormula(changeToFormula); this.updateMonacoCursorPosition(); this.keepCursorVisible(); - inlineEditorMonaco.focus(); } else { this.close(0, 0, false); } @@ -296,12 +291,16 @@ class InlineEditorHandler { cellOutlineOffset + (verticalAlign === 'bottom' ? Math.min(y, y + cellContentHeight - inlineEditorHeight) : y); this.width = inlineEditorWidth; this.height = inlineEditorHeight; - this.div?.style.setProperty('left', this.x + 'px'); - this.div?.style.setProperty('top', this.y + 'px'); - if (!this.formulaExpandButton) { - throw new Error('Expected formulaExpandDiv to be defined in InlineEditorHandler'); + + if (!pixiAppSettings.setInlineEditorState) { + throw new Error('Expected pixiAppSettings.setInlineEditorState to be defined in InlineEditorHandler'); } - this.formulaExpandButton.style.lineHeight = this.height + 'px'; + pixiAppSettings.setInlineEditorState((prev) => ({ + ...prev, + left: this.x, + top: this.y, + lineHeight: this.height, + })); pixiApp.cursor.dirty = true; }; @@ -309,8 +308,8 @@ class InlineEditorHandler { // Toggle between normal editor and formula editor. private changeToFormula = (formula: boolean) => { if (this.formula === formula) return; - if (!this.formulaExpandButton) { - throw new Error('Expected formulaExpandDiv to be defined in InlineEditorHandler'); + if (!pixiAppSettings.setInlineEditorState) { + throw new Error('Expected pixiAppSettings.setInlineEditorState to be defined in InlineEditorHandler'); } this.formula = formula; if (formula) { @@ -322,10 +321,10 @@ class InlineEditorHandler { inlineEditorMonaco.setLanguage('plaintext'); } - // We need to use visibility instead of display to avoid an annoying warning - // with . - this.formulaExpandButton.style.visibility = formula ? 'visible' : 'hidden'; - this.formulaExpandButton.style.pointerEvents = formula ? 'auto' : 'none'; + pixiAppSettings.setInlineEditorState((prev) => ({ + ...prev, + formula, + })); if (formula && this.location) { inlineEditorFormula.cellHighlights(this.location, inlineEditorMonaco.get().slice(1)); @@ -412,7 +411,7 @@ class InlineEditorHandler { }; // Handler for the click for the expand code editor button. - private openCodeEditor = (e: MouseEvent) => { + openCodeEditor = (e: React.MouseEvent) => { e.stopPropagation(); if (!pixiAppSettings.setEditorInteractionState) { throw new Error('Expected setEditorInteractionState to be defined in openCodeEditor'); @@ -439,12 +438,6 @@ class InlineEditorHandler { } this.div = div; - const expandButton = div?.childNodes[1] as HTMLDivElement | undefined; - if (expandButton) { - this.formulaExpandButton = expandButton; - this.formulaExpandButton.removeEventListener('click', this.openCodeEditor); - this.formulaExpandButton.addEventListener('click', this.openCodeEditor); - } this.hideDiv(); } @@ -464,31 +457,41 @@ class InlineEditorHandler { return this.open; } - showDiv() { + showDiv = () => { if (!this.div) { throw new Error('Expected div to be defined in showDiv'); } - // We need to use visibility instead of display to avoid an annoying warning - // with . - this.div.style.visibility = 'visible'; - this.div.style.pointerEvents = 'auto'; - this.showing = true; - } + if (!pixiAppSettings.setInlineEditorState) { + throw new Error('Expected pixiAppSettings.setInlineEditorState to be defined in InlineEditorHandler'); + } - hideDiv() { - if (!this.div) return; + pixiAppSettings.setInlineEditorState((prev) => ({ + ...prev, + visible: true, + })); + + this.showing = true; + }; - // We need to use visibility instead of display to avoid an annoying warning - // with . - this.div.style.visibility = 'hidden'; - this.div.style.pointerEvents = 'none'; + hideDiv = () => { + if (!this.div) { + throw new Error('Expected div to be defined in showDiv'); + } - if (this.formulaExpandButton) { - this.formulaExpandButton.style.visibility = 'hidden'; + if (!pixiAppSettings.setInlineEditorState) { + throw new Error('Expected pixiAppSettings.setInlineEditorState to be defined in InlineEditorHandler'); } + + pixiAppSettings.setInlineEditorState((prev) => ({ + ...prev, + visible: false, + formula: false, + })); + this.location = undefined; + inlineEditorMonaco.set(''); this.showing = false; - } + }; // Called when manually changing cell position via clicking on a new cell // (except when editing formula). diff --git a/quadratic-client/src/app/gridGL/QuadraticGrid.tsx b/quadratic-client/src/app/gridGL/QuadraticGrid.tsx index 041e7e7517..d4c8082953 100644 --- a/quadratic-client/src/app/gridGL/QuadraticGrid.tsx +++ b/quadratic-client/src/app/gridGL/QuadraticGrid.tsx @@ -1,3 +1,4 @@ +import { inlineEditorAtom } from '@/app/atoms/inlineEditorAtom'; import { events } from '@/app/events/events'; import { matchShortcut } from '@/app/helpers/keyboardShortcuts.js'; import { ImportProgress } from '@/app/ui/components/ImportProgress'; @@ -37,9 +38,11 @@ export default function QuadraticGrid() { }, []); const [editorInteractionState, setEditorInteractionState] = useRecoilState(editorInteractionStateAtom); + const [inlineEditorState, setInlineEditorState] = useRecoilState(inlineEditorAtom); useEffect(() => { pixiAppSettings.updateEditorInteractionState(editorInteractionState, setEditorInteractionState); - }, [editorInteractionState, setEditorInteractionState]); + pixiAppSettings.updateInlineEditorState(inlineEditorState, setInlineEditorState); + }, [editorInteractionState, inlineEditorState, setEditorInteractionState, setInlineEditorState]); // Right click menu const [showContextMenu, setShowContextMenu] = useState(false); diff --git a/quadratic-client/src/app/gridGL/UI/UICellMoving.ts b/quadratic-client/src/app/gridGL/UI/UICellMoving.ts index a086cb52a6..e1bd802905 100644 --- a/quadratic-client/src/app/gridGL/UI/UICellMoving.ts +++ b/quadratic-client/src/app/gridGL/UI/UICellMoving.ts @@ -19,7 +19,7 @@ export class UICellMoving extends Container { } private drawMove() { - const moving = pixiApp.pointer.pointerCellMoving.moving; + const moving = pixiApp.pointer.pointerCellMoving.movingCells; if (!moving) { throw new Error('Expected moving to be defined in drawMove'); } diff --git a/quadratic-client/src/app/gridGL/interaction/pointer/Pointer.ts b/quadratic-client/src/app/gridGL/interaction/pointer/Pointer.ts index 936b0cfb5f..3bd3e4f6f5 100644 --- a/quadratic-client/src/app/gridGL/interaction/pointer/Pointer.ts +++ b/quadratic-client/src/app/gridGL/interaction/pointer/Pointer.ts @@ -139,6 +139,8 @@ export class Pointer { this.pointerHeading.pointerUp() || this.pointerAutoComplete.pointerUp() || this.pointerDown.pointerUp(); + + this.updateCursor(); }; handleEscape(): boolean { diff --git a/quadratic-client/src/app/gridGL/interaction/pointer/PointerCellMoving.ts b/quadratic-client/src/app/gridGL/interaction/pointer/PointerCellMoving.ts index ee9621e184..e62ad61be8 100644 --- a/quadratic-client/src/app/gridGL/interaction/pointer/PointerCellMoving.ts +++ b/quadratic-client/src/app/gridGL/interaction/pointer/PointerCellMoving.ts @@ -27,7 +27,8 @@ interface MoveCells { } export class PointerCellMoving { - moving?: MoveCells; + startCell?: Point; + movingCells?: MoveCells; state?: 'hover' | 'move'; get cursor(): string | undefined { @@ -47,8 +48,9 @@ export class PointerCellMoving { pointerDown(event: PointerEvent): boolean { if (isMobile || pixiAppSettings.panMode !== PanMode.Disabled || event.button === 1) return false; - if (this.state === 'hover' && this.moving) { + if (this.state === 'hover' && this.movingCells && event.button === 0) { this.state = 'move'; + this.startCell = new Point(this.movingCells.column, this.movingCells.row); events.emit('cellMoving', true); pixiApp.viewport.mouseEdges({ distance: MOUSE_EDGES_DISTANCE, @@ -61,23 +63,24 @@ export class PointerCellMoving { } private reset() { - this.moving = undefined; + this.movingCells = undefined; if (this.state === 'move') { pixiApp.cellMoving.dirty = true; events.emit('cellMoving', false); pixiApp.viewport.plugins.remove('mouse-edges'); } this.state = undefined; + this.startCell = undefined; } private pointerMoveMoving(world: Point) { - if (this.state !== 'move' || !this.moving) { + if (this.state !== 'move' || !this.movingCells) { throw new Error('Expected moving to be defined in pointerMoveMoving'); } const sheet = sheets.sheet; const position = sheet.getColumnRowFromScreen(world.x, world.y); - this.moving.toColumn = position.column + this.moving.offset.x; - this.moving.toRow = position.row + this.moving.offset.y; + this.movingCells.toColumn = position.column + this.movingCells.offset.x; + this.movingCells.toRow = position.row + this.movingCells.offset.y; pixiApp.cellMoving.dirty = true; } @@ -165,7 +168,7 @@ export class PointerCellMoving { const offset = sheets.sheet.getColumnRowFromScreen(world.x, world.y); offset.column = Math.min(Math.max(offset.column, rectangle.left), rectangle.right); offset.row = Math.min(Math.max(offset.row, rectangle.top), rectangle.bottom); - this.moving = { + this.movingCells = { column, row, width: rectangle.width, @@ -196,15 +199,21 @@ export class PointerCellMoving { pointerUp(): boolean { if (this.state === 'move') { - if (this.moving) { + if (this.startCell === undefined) { + throw new Error('[PointerCellMoving] Expected startCell to be defined in pointerUp'); + } + if ( + this.movingCells && + (this.startCell.x !== this.movingCells.toColumn || this.startCell.y !== this.movingCells.toRow) + ) { const rectangle = sheets.sheet.cursor.getLargestMultiCursorRectangle(); quadraticCore.moveCells( rectToSheetRect( new Rectangle(rectangle.x, rectangle.y, rectangle.width - 1, rectangle.height - 1), sheets.sheet.id ), - this.moving.toColumn, - this.moving.toRow, + this.movingCells.toColumn, + this.movingCells.toRow, sheets.sheet.id ); @@ -220,8 +229,8 @@ export class PointerCellMoving { ...pixiAppSettings.editorInteractionState, initialCode: pixiAppSettings.unsavedEditorChanges, selectedCell: { - x: state.selectedCell.x + this.moving.toColumn - this.moving.column, - y: state.selectedCell.y + this.moving.toRow - this.moving.row, + x: state.selectedCell.x + this.movingCells.toColumn - this.movingCells.column, + y: state.selectedCell.y + this.movingCells.toRow - this.movingCells.row, }, }); } diff --git a/quadratic-client/src/app/gridGL/interaction/pointer/PointerHtmlCells.ts b/quadratic-client/src/app/gridGL/interaction/pointer/PointerHtmlCells.ts index 2f9ab9cd3a..5e9bd141fe 100644 --- a/quadratic-client/src/app/gridGL/interaction/pointer/PointerHtmlCells.ts +++ b/quadratic-client/src/app/gridGL/interaction/pointer/PointerHtmlCells.ts @@ -49,7 +49,7 @@ export class PointerHtmlCells { this.hovering = undefined; } this.cursor = undefined; - return true; + return false; } } this.cursor = undefined; @@ -142,8 +142,9 @@ export class PointerHtmlCells { if (target === 'body' && cell === active) { this.setDoubleClick(); this.clicked = cell; + return true; } - if (target) return true; + if (target) return false; } return false; } diff --git a/quadratic-client/src/app/gridGL/pixiApp/PixiApp.ts b/quadratic-client/src/app/gridGL/pixiApp/PixiApp.ts index a6fe6a31e9..80e23421dd 100644 --- a/quadratic-client/src/app/gridGL/pixiApp/PixiApp.ts +++ b/quadratic-client/src/app/gridGL/pixiApp/PixiApp.ts @@ -271,11 +271,8 @@ export class PixiApp { reset(): void { this.viewport.scale.set(1); - if (pixiAppSettings.showHeadings) { - this.viewport.position.set(HEADING_SIZE, HEADING_SIZE); - } else { - this.viewport.position.set(0, 0); - } + const { x, y } = this.getStartingViewport(); + this.viewport.position.set(x, y); pixiAppSettings.setEditorInteractionState?.(editorInteractionStateDefault); } @@ -296,9 +293,9 @@ export class PixiApp { getStartingViewport(): { x: number; y: number } { if (pixiAppSettings.showHeadings) { - return { x: HEADING_SIZE, y: HEADING_SIZE }; + return { x: HEADING_SIZE + 1, y: HEADING_SIZE + 1 }; } else { - return { x: 0, y: 0 }; + return { x: 1, y: 1 }; } } diff --git a/quadratic-client/src/app/gridGL/pixiApp/PixiAppSettings.ts b/quadratic-client/src/app/gridGL/pixiApp/PixiAppSettings.ts index 2ee8fe68f1..98db0cda80 100644 --- a/quadratic-client/src/app/gridGL/pixiApp/PixiAppSettings.ts +++ b/quadratic-client/src/app/gridGL/pixiApp/PixiAppSettings.ts @@ -1,10 +1,11 @@ +import { defaultInlineEditor, InlineEditorState } from '@/app/atoms/inlineEditorAtom'; import { events } from '@/app/events/events'; import { inlineEditorHandler } from '@/app/gridGL/HTMLGrid/inlineEditor/inlineEditorHandler'; import { multiplayer } from '@/app/web-workers/multiplayerWebWorker/multiplayer'; import { ApiTypes } from 'quadratic-shared/typesAndSchemas'; import { EditorInteractionState, editorInteractionStateDefault } from '../../atoms/editorInteractionStateAtom'; import { sheets } from '../../grid/controller/Sheets'; -import { GridSettings, defaultGridSettings } from '../../ui/menus/TopBar/SubMenus/useGridSettings'; +import { defaultGridSettings, GridSettings } from '../../ui/menus/TopBar/SubMenus/useGridSettings'; import { pixiApp } from './PixiApp'; export enum PanMode { @@ -37,6 +38,8 @@ class PixiAppSettings { editorInteractionState = editorInteractionStateDefault; setEditorInteractionState?: (value: EditorInteractionState) => void; addGlobalSnackbar?: (message: string, options?: { severity?: 'error' | 'warning' }) => void; + inlineEditorState = defaultInlineEditor; + setInlineEditorState?: (fn: (prev: InlineEditorState) => InlineEditorState) => void; constructor() { const settings = localStorage.getItem('viewSettings'); @@ -96,6 +99,22 @@ class PixiAppSettings { } } + updateInlineEditorState( + inlineEditorState: InlineEditorState, + setInlineEditorState: (fn: (prev: InlineEditorState) => InlineEditorState) => void + ): void { + this.inlineEditorState = inlineEditorState; + this.setInlineEditorState = setInlineEditorState; + + // these ifs are needed to because pixiApp may be in a bad state during hmr + if (pixiApp.headings) { + pixiApp.headings.dirty = true; + } + if (pixiApp.cursor) { + pixiApp.cursor.dirty = true; + } + } + get showGridLines(): boolean { return !this.settings.presentationMode && this.settings.showGridLines; } diff --git a/quadratic-client/src/app/helpers/files.ts b/quadratic-client/src/app/helpers/files.ts index 7902d1b9d1..02fc7acb10 100644 --- a/quadratic-client/src/app/helpers/files.ts +++ b/quadratic-client/src/app/helpers/files.ts @@ -1,3 +1,5 @@ +export type DragAndDropFileType = 'csv' | 'excel' | 'parquet'; + export function readFileAsArrayBuffer(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -39,12 +41,16 @@ export function isCsv(file: File): boolean { return file.type === 'text/csv' || file.type === 'text/tab-separated-values' || hasExtension(file.name, 'csv'); } -export function isExcel(file: File): boolean { - return ( - file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || hasExtension(file.name, 'xlsx') +export function isExcelMimeType(mimeType: string): boolean { + return ['application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'].includes( + mimeType ); } +export function isExcel(file: File): boolean { + return isExcelMimeType(file.type) || hasExtension(file.name, 'xlsx'); +} + export function isGrid(file: File): boolean { return file.type === 'application/json' || hasExtension(file.name, 'grid'); } diff --git a/quadratic-client/src/app/ui/QuadraticUI.tsx b/quadratic-client/src/app/ui/QuadraticUI.tsx index 2362fb34ea..5221a9dd5f 100644 --- a/quadratic-client/src/app/ui/QuadraticUI.tsx +++ b/quadratic-client/src/app/ui/QuadraticUI.tsx @@ -1,6 +1,7 @@ import { CodeEditorProvider } from '@/app/ui/menus/CodeEditor/CodeEditorContext'; import ConnectionsMenu from '@/app/ui/menus/ConnectionsMenu'; import { ShareFileDialog } from '@/shared/components/ShareDialog'; +import { UserMessage } from '@/shared/components/UserMessage'; import { useEffect } from 'react'; import { useNavigation, useParams } from 'react-router'; import { useRecoilState } from 'recoil'; @@ -104,6 +105,7 @@ export default function QuadraticUI() { {!isEmbed && } +
); } diff --git a/quadratic-client/src/app/ui/UpdateAlertVersion.tsx b/quadratic-client/src/app/ui/UpdateAlertVersion.tsx index 7901803510..bee32076a1 100644 --- a/quadratic-client/src/app/ui/UpdateAlertVersion.tsx +++ b/quadratic-client/src/app/ui/UpdateAlertVersion.tsx @@ -1,9 +1,9 @@ +import { FixedBottomAlert } from '@/shared/components/FixedBottomAlert'; import { Type } from '@/shared/components/Type'; import { Button } from '@/shared/shadcn/ui/button'; import { RocketIcon } from '@radix-ui/react-icons'; import { useEffect, useState } from 'react'; import { events } from '../events/events'; -import { FixedBottomAlert } from './components/PermissionOverlay'; export const UpdateAlertVersion = () => { const [showDialog, setShowDialog] = useState(false); diff --git a/quadratic-client/src/app/ui/components/FileUploadWrapper.tsx b/quadratic-client/src/app/ui/components/FileUploadWrapper.tsx index 63b3b5c0a2..6f5169a6e3 100644 --- a/quadratic-client/src/app/ui/components/FileUploadWrapper.tsx +++ b/quadratic-client/src/app/ui/components/FileUploadWrapper.tsx @@ -1,15 +1,18 @@ +import { hasPermissionToEditFile } from '@/app/actions'; +import { userMessageAtom } from '@/app/atoms/userMessageAtom'; import { sheets } from '@/app/grid/controller/Sheets'; import { pixiApp } from '@/app/gridGL/pixiApp/PixiApp'; +import { pixiAppSettings } from '@/app/gridGL/pixiApp/PixiAppSettings'; import { Coordinate } from '@/app/gridGL/types/size'; -import { isCsv, isParquet } from '@/app/helpers/files'; +import { DragAndDropFileType, isCsv, isExcel, isExcelMimeType, isParquet } from '@/app/helpers/files'; import { quadraticCore } from '@/app/web-workers/quadraticCore/quadraticCore'; import { useGlobalSnackbar } from '@/shared/components/GlobalSnackbarProvider'; import { DragEvent, PropsWithChildren, useRef, useState } from 'react'; - -export type DragAndDropFileType = 'csv' | 'parquet'; +import { useSetRecoilState } from 'recoil'; const getFileType = (file: File): DragAndDropFileType => { if (isCsv(file)) return 'csv'; + if (isExcel(file)) return 'excel'; if (isParquet(file)) return 'parquet'; throw new Error(`Unsupported file type`); @@ -20,6 +23,7 @@ export const FileUploadWrapper = (props: PropsWithChildren) => { const [dragActive, setDragActive] = useState(false); const divRef = useRef(null); const { addGlobalSnackbar } = useGlobalSnackbar(); + const setUserMessageState = useSetRecoilState(userMessageAtom); const moveCursor = (e: DragEvent): void => { const clientBoundingRect = divRef?.current?.getBoundingClientRect(); @@ -46,11 +50,22 @@ export const FileUploadWrapper = (props: PropsWithChildren) => { const handleDrag = function (e: DragEvent) { e.preventDefault(); e.stopPropagation(); - if (e.type === 'dragenter' || e.type === 'dragover') { + + if (!hasPermissionToEditFile(pixiAppSettings.permissions)) return; + + if (e.type === 'dragenter') { setDragActive(true); - moveCursor(e); + } else if (e.type === 'dragover') { + const mimeType = e.dataTransfer.items[0].type; + if (isExcelMimeType(mimeType)) { + setUserMessageState({ message: 'Dropped Excel file(s) will be imported as new sheet(s) in this file.' }); + } else { + setUserMessageState({ message: undefined }); + moveCursor(e); + } } else if (e.type === 'dragleave') { setDragActive(false); + setUserMessageState({ message: undefined }); } }; @@ -58,6 +73,9 @@ export const FileUploadWrapper = (props: PropsWithChildren) => { const handleDrop = async (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); + + if (!hasPermissionToEditFile(pixiAppSettings.permissions)) return; + setDragActive(false); if (e.dataTransfer.files && e.dataTransfer.files[0]) { @@ -78,6 +96,29 @@ export const FileUploadWrapper = (props: PropsWithChildren) => { if (error) { addGlobalSnackbar(`Error loading ${file.name}: ${error}`, { severity: 'warning' }); } + } else if (fileType === 'excel') { + setUserMessageState({ message: undefined }); + for (const file of e.dataTransfer.files) { + try { + const fileType = getFileType(file); + if (fileType !== 'excel') { + throw new Error('Cannot load multiple file types'); + } + + const contents = await file.arrayBuffer().catch(console.error); + if (!contents) { + throw new Error('Failed to read file'); + } + + const buffer = new Uint8Array(contents); + const { error } = await quadraticCore.importExcel(buffer, file.name, sheets.getCursorPosition()); + if (error) { + throw new Error(error); + } + } catch (error) { + addGlobalSnackbar(`Error loading ${file.name}: ${error}`, { severity: 'warning' }); + } + } } else if (fileType === 'parquet') { const error = await quadraticCore.importParquet(sheets.sheet.id, file, insertAtCellLocation); if (error) { @@ -109,7 +150,6 @@ export const FileUploadWrapper = (props: PropsWithChildren) => { {dragActive && (
- {children} -
- ); -} diff --git a/quadratic-client/src/app/ui/menus/TopBar/SubMenus/DataMenu.tsx b/quadratic-client/src/app/ui/menus/TopBar/SubMenus/DataMenu.tsx index b976d77044..3904ec6a64 100644 --- a/quadratic-client/src/app/ui/menus/TopBar/SubMenus/DataMenu.tsx +++ b/quadratic-client/src/app/ui/menus/TopBar/SubMenus/DataMenu.tsx @@ -7,7 +7,7 @@ import { DataIcon } from '@/app/ui/icons'; import { useRootRouteLoaderData } from '@/routes/_root'; import { useFileRouteLoaderData } from '@/shared/hooks/useFileRouteLoaderData'; import { useGlobalSnackbar } from '@/shared/components/GlobalSnackbarProvider'; -import { CSV_IMPORT_MESSAGE, PARQUET_IMPORT_MESSAGE } from '@/shared/constants/appConstants'; +import { CSV_IMPORT_MESSAGE, EXCEL_IMPORT_MESSAGE, PARQUET_IMPORT_MESSAGE } from '@/shared/constants/appConstants'; import { Menu, MenuDivider, MenuItem } from '@szhsin/react-menu'; import '@szhsin/react-menu/dist/index.css'; import { useSetRecoilState } from 'recoil'; @@ -49,6 +49,13 @@ export const DataMenu = () => { > + { + addGlobalSnackbar(EXCEL_IMPORT_MESSAGE); + }} + > + + { addGlobalSnackbar(PARQUET_IMPORT_MESSAGE); diff --git a/quadratic-client/src/app/ui/menus/TopBar/SubMenus/useBorders.ts b/quadratic-client/src/app/ui/menus/TopBar/SubMenus/useBorders.ts index 8151b4a17e..d0379b7a5d 100644 --- a/quadratic-client/src/app/ui/menus/TopBar/SubMenus/useBorders.ts +++ b/quadratic-client/src/app/ui/menus/TopBar/SubMenus/useBorders.ts @@ -31,9 +31,7 @@ export const useBorders = (): UseBordersResults => { const cursor = sheets.sheet.cursor; if (cursor.multiCursor && cursor.multiCursor.length > 1) { console.log('TODO: implement multiCursor border support'); - } - // apply border only on selection change, else only update border menu state - else if (options.selection !== undefined) { + } else { const rectangle = cursor.multiCursor ? cursor.multiCursor[0] : new Rectangle(cursor.cursorPosition.x, cursor.cursorPosition.y, 1, 1); @@ -45,11 +43,18 @@ export const useBorders = (): UseBordersResults => { red: Math.floor(colorArray[0] * 255), green: Math.floor(colorArray[1] * 255), blue: Math.floor(colorArray[2] * 255), - alpha: 0xff, + alpha: 1, }, line, }; - quadraticCore.setRegionBorders(sheet.id, rectangle, options.selection, style); + if (options.selection) { + quadraticCore.setRegionBorders(sheet.id, rectangle, options.selection, style); + } else if ( + prev.selection && + ((!!options.color && options.color !== prev.color) || (!!options.line && options.line !== prev.line)) + ) { + quadraticCore.setRegionBorders(sheet.id, rectangle, prev.selection, style); + } } return { selection, color, line }; }); diff --git a/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/javascriptCompile.ts b/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/javascriptCompile.ts index 5c27d16c74..856681cdda 100644 --- a/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/javascriptCompile.ts +++ b/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/javascriptCompile.ts @@ -1,13 +1,9 @@ -// Converts the Javascript code before sending it to the worker. This includes -// using esbuild to find syntax errors, promoting import statements to the top -// of the file (this is to ensure they are not placed inside of the async -// anonymous function that allows await at the top level), and attempting to -// track line numbers so we can return a lineNumber for the return statement. It -// uses a naive approach to handling multi-line strings with return numbers. We -// track the ` character and only add the variables where there's an even number -// of them. We always try to compile and run the code with and without line -// numbers to ensure that we don't break something when inserting the line -// numbers. +//! Converts the Javascript code before sending it to the worker. This includes +//! using esbuild to find syntax errors, promoting import statements to the top +//! of the file (this is to ensure they are not placed inside of the async +//! anonymous function that allows await at the top level), and adding line +//! numbers to all return statements via a caught thrown error (the only way to +//! get line numbers in JS). import * as esbuild from 'esbuild-wasm'; import { LINE_NUMBER_VAR } from './javascript'; @@ -37,47 +33,15 @@ export async function javascriptFindSyntaxError(transformed: { } } -// Adds line number variable, keeping track of ` to ensure we don't place line -// number variables within multiline strings. -// -// TODO: A full JS parser would be better as it would handle all cases and can -// be used to move the import statements to the top of the code as well. +// Uses a thrown error to find the line number of the return statement. export function javascriptAddLineNumberVars(transform: JavascriptTransformedCode): string { - const imports = transform.imports.split('\n'); const list = transform.code.split('\n'); - let multiLineCount = 0; let s = ''; - let add = imports.length + 1; - let inMultiLineComment = false; for (let i = 0; i < list.length; i++) { - multiLineCount += [...list[i].matchAll(/`/g)].length; - s += list[i]; - if (multiLineCount % 2 === 0) { - // inserts a line break if the line includes a comment marker - if (s.includes('//')) s += '\n'; - - // track multi-line comments created with /* */ - if (inMultiLineComment) { - if (s.includes('*/')) { - inMultiLineComment = false; - } else { - add++; - continue; - } - } - - // if we're inside a multi-line comment, don't add line numbers but track it - if (s.includes('/*') && !s.includes('*/')) { - inMultiLineComment = true; - add++; - continue; - } else { - s += `;${LINE_NUMBER_VAR} += ${add};\n`; - } - add = 1; - } else { - add++; + if (list[i].includes('return')) { + s += `try { throw new Error() } catch (e) { const stackLines = e.stack.split("\\n"); const match = stackLines[1].match(/:(\\d+):(\\d+)/); if (match) { ${LINE_NUMBER_VAR} = match[1];} }`; } + s += list[i] + '\n'; } return s; } @@ -107,7 +71,7 @@ export function prepareJavascriptCode( const code = withLineNumbers ? javascriptAddLineNumberVars(transform) : transform.code; const compiledCode = transform.imports + - (withLineNumbers ? `let ${LINE_NUMBER_VAR} = 1;` : '') + + (withLineNumbers ? `let ${LINE_NUMBER_VAR} = 0;` : '') + javascriptLibrary.replace('{x:0,y:0}', `{x:${x},y:${y}}`) + // replace the pos() with the correct x,y coordinates '(async() => {try{' + 'let results = await (async () => {' + diff --git a/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/generateJavascriptForRunner.ts b/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/generateJavascriptForRunner.ts index f60fd3bc1b..30af5d9b29 100644 --- a/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/generateJavascriptForRunner.ts +++ b/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/generateJavascriptForRunner.ts @@ -1,4 +1,4 @@ // Generated file from ./compileJavascriptRunner.mjs -export const javascriptLibrary = `const getCellsDB=(x0,y0,x1,y1,sheetName)=>{try{let sharedBuffer=new SharedArrayBuffer(12),int32View=new Int32Array(sharedBuffer,0,3);Atomics.store(int32View,0,0),self.postMessage({type:"getCellsLength",sharedBuffer,x0,y0,x1,y1,sheetName});let result=Atomics.wait(int32View,0,0);const length=int32View[1];if(result!=="ok"||length===0)return[];const id=int32View[2];if(sharedBuffer=new SharedArrayBuffer(4+length),int32View=new Int32Array(sharedBuffer,0,1),Atomics.store(int32View,0,0),self.postMessage({type:"getCellsData",id,sharedBuffer}),result=Atomics.wait(int32View,0,0),result!=="ok")return[];let uint8View=new Uint8Array(sharedBuffer,4,length);const nonSharedBuffer=new ArrayBuffer(uint8View.byteLength),nonSharedView=new Uint8Array(nonSharedBuffer);nonSharedView.set(uint8View),sharedBuffer=void 0,int32View=void 0,uint8View=void 0;const cellsStringified=new TextDecoder().decode(nonSharedView);return convertNullToUndefined(JSON.parse(cellsStringified))}catch(e){console.warn("[javascriptLibrary] getCells error",e)}return[]};function convertNullToUndefined(arr){return arr.map(subArr=>subArr.map(element=>element===null?void 0:element))}const getCells=(x0,y0,x1,y1,sheetName)=>{if(isNaN(x0)||isNaN(y0)||isNaN(x1))throw new Error("getCells requires at least 3 arguments, received getCells("+x0+", "+y0+", "+x1+", "+y1+")"+(___line_number___!==void 0?" at line "+___line_number___:""));return getCellsDB(x0,y0,x1,y1,sheetName)},cells=getCells,getCellsWithHeadings=(x0,y0,x1,y1,sheetName)=>{if(isNaN(x0)||isNaN(y0)||isNaN(x1))throw new Error("getCellsWithHeadings requires at least 3 arguments, received getCellsWithHeadings("+x0+", "+y0+", "+x1+", "+y1+")"+(___line_number___!==void 0?" at line "+___line_number___:""));const cells2=getCells(x0,y0,x1,y1,sheetName),headers=cells2[0];return cells2.slice(1).map(row=>{const obj={};return headers.forEach((header,i)=>{obj[header]=row[i]}),obj})},getCell=(x,y,sheetName)=>{if(isNaN(x)||isNaN(y))throw new Error("getCell requires at least 2 arguments, received getCell("+x+", "+y+")"+(___line_number___!==void 0?" at line "+___line_number___:""));return getCells(x,y,x,y,sheetName)?.[0]?.[0]},c=getCell,cell=getCell,pos=()=>({x:0,y:0}),relCell=(deltaX,deltaY)=>{const p=pos();if(isNaN(deltaX)||isNaN(deltaY))throw new Error("relCell requires at least 2 arguments, received relCell("+deltaX+", "+deltaY+")"+(___line_number___!==void 0?" at line "+___line_number___:""));return getCell(deltaX+p.x,deltaY+p.y)},relCells=(deltaX0,deltaY0,deltaX1,deltaY1)=>{const p=pos();if(isNaN(deltaX0)||isNaN(deltaY0)||isNaN(deltaX1)||isNaN(deltaY1))throw new Error("relCells requires at least 4 arguments, received relCells("+deltaX0+", "+deltaY0+", "+deltaX1+", "+deltaY1+")"+(___line_number___!==void 0?" at line "+___line_number___:""));return getCells(deltaX0+p.x,deltaY0+p.y,deltaX1+p.x,deltaY1+p.y)},rc=relCell,TAB=" ";class JavascriptConsole{oldConsoleLog;logs=[];constructor(){this.oldConsoleLog=console.log,console.log=this.consoleMap,console.warn=this.consoleMap}log(...args){this.oldConsoleLog(args)}consoleMap=(...args)=>{args=args.map(a=>this.mapArgument(a)),this.logs.push(...args)};reset(){this.logs=[]}push(s){Array.isArray(s)?this.logs.push(...s):this.logs.push(s)}output(){return this.logs.length?this.logs.join(""):null}tab=n=>Array(n).fill(TAB).join("");mapArgument(a,level=0){if(Array.isArray(a)){if(a.length===0)return"Array: []\\n";let s="Array: [\\n";for(let i=0;i{try{let sharedBuffer=new SharedArrayBuffer(12),int32View=new Int32Array(sharedBuffer,0,3);Atomics.store(int32View,0,0),self.postMessage({type:"getCellsLength",sharedBuffer,x0,y0,x1,y1,sheetName});let result=Atomics.wait(int32View,0,0);const length=int32View[1];if(result!=="ok"||length===0)return[];const id=int32View[2];if(sharedBuffer=new SharedArrayBuffer(4+length),int32View=new Int32Array(sharedBuffer,0,1),Atomics.store(int32View,0,0),self.postMessage({type:"getCellsData",id,sharedBuffer}),result=Atomics.wait(int32View,0,0),result!=="ok")return[];let uint8View=new Uint8Array(sharedBuffer,4,length);const nonSharedBuffer=new ArrayBuffer(uint8View.byteLength),nonSharedView=new Uint8Array(nonSharedBuffer);nonSharedView.set(uint8View),sharedBuffer=void 0,int32View=void 0,uint8View=void 0;const cellsStringified=new TextDecoder().decode(nonSharedView);return convertNullToUndefined(JSON.parse(cellsStringified))}catch(e){console.warn("[javascriptLibrary] getCells error",e)}return[]};function convertNullToUndefined(arr){return arr.map(subArr=>subArr.map(element=>element===null?void 0:element))}function lineNumber(){try{throw new Error}catch(e){const match=e.stack.split("\\n")[3].match(/:(\\d+):(\\d+)/);if(match)return match[1]}}const getCells=(x0,y0,x1,y1,sheetName)=>{if(isNaN(x0)||isNaN(y0)||isNaN(x1)){const line=lineNumber();throw new Error("getCells requires at least 3 arguments, received getCells("+x0+", "+y0+", "+x1+", "+y1+")"+(line!==void 0?" at line "+(line-1):""))}return getCellsDB(x0,y0,x1,y1,sheetName)},cells=getCells,getCellsWithHeadings=(x0,y0,x1,y1,sheetName)=>{if(isNaN(x0)||isNaN(y0)||isNaN(x1)){const line=lineNumber();throw new Error("getCellsWithHeadings requires at least 3 arguments, received getCellsWithHeadings("+x0+", "+y0+", "+x1+", "+y1+")"+(line!==void 0?" at line "+(line-1):""))}const cells2=getCells(x0,y0,x1,y1,sheetName),headers=cells2[0];return cells2.slice(1).map(row=>{const obj={};return headers.forEach((header,i)=>{obj[header]=row[i]}),obj})},getCell=(x,y,sheetName)=>{if(isNaN(x)||isNaN(y)){const line=lineNumber();throw new Error("getCell requires at least 2 arguments, received getCell("+x+", "+y+")"+(line!==void 0?" at line "+(line-1):""))}return getCells(x,y,x,y,sheetName)?.[0]?.[0]},c=getCell,cell=getCell,pos=()=>({x:0,y:0}),relCell=(deltaX,deltaY)=>{const p=pos();if(isNaN(deltaX)||isNaN(deltaY)){const line=lineNumber();throw new Error("relCell requires at least 2 arguments, received relCell("+deltaX+", "+deltaY+")"+(line!==void 0?" at line "+(line-1):""))}return getCell(deltaX+p.x,deltaY+p.y)},relCells=(deltaX0,deltaY0,deltaX1,deltaY1)=>{const p=pos();if(isNaN(deltaX0)||isNaN(deltaY0)||isNaN(deltaX1)||isNaN(deltaY1)){const line=lineNumber();throw new Error("relCells requires at least 4 arguments, received relCells("+deltaX0+", "+deltaY0+", "+deltaX1+", "+deltaY1+")"+(line!==void 0?" at line "+(line-1):""))}return getCells(deltaX0+p.x,deltaY0+p.y,deltaX1+p.x,deltaY1+p.y)},rc=relCell,TAB=" ";class JavascriptConsole{oldConsoleLog;logs=[];constructor(){this.oldConsoleLog=console.log,console.log=this.consoleMap,console.warn=this.consoleMap}log(...args){this.oldConsoleLog(args)}consoleMap=(...args)=>{args=args.map(a=>this.mapArgument(a)),this.logs.push(...args)};reset(){this.logs=[]}push(s){Array.isArray(s)?this.logs.push(...s):this.logs.push(s)}output(){return this.logs.length?this.logs.join(""):null}tab=n=>Array(n).fill(TAB).join("");mapArgument(a,level=0){if(Array.isArray(a)){if(a.length===0)return"Array: []\\n";let s="Array: [\\n";for(let i=0;i subArr.map((element) => (element === null ? undefined : element))); } +function lineNumber(): number | undefined { + try { + throw new Error() + } catch (e: any) { + const stackLines = e.stack.split("\\n"); + const match = stackLines[3].match(/:(\\d+):(\\d+)/); + if (match) { + return match[1]; + } + } +} + export const getCells = ( x0: number, y0: number, @@ -189,6 +199,7 @@ export const getCells = ( sheetName?: string ): (number | string | boolean | undefined)[][] => { if (isNaN(x0) || isNaN(y0) || isNaN(x1)) { + const line = lineNumber(); throw new Error( 'getCells requires at least 3 arguments, received getCells(' + x0 + @@ -199,7 +210,7 @@ export const getCells = ( ', ' + y1 + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } return getCellsDB(x0, y0, x1, y1, sheetName); @@ -215,6 +226,7 @@ export const getCellsWithHeadings = ( sheetName?: string ): Record[] => { if (isNaN(x0) || isNaN(y0) || isNaN(x1)) { + const line = lineNumber(); throw new Error( 'getCellsWithHeadings requires at least 3 arguments, received getCellsWithHeadings(' + x0 + @@ -225,7 +237,7 @@ export const getCellsWithHeadings = ( ', ' + y1 + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } const cells = getCells(x0, y0, x1, y1, sheetName); @@ -241,13 +253,14 @@ export const getCellsWithHeadings = ( export const getCell = (x: number, y: number, sheetName?: string): number | string | boolean | undefined => { if (isNaN(x) || isNaN(y)) { + const line = lineNumber(); throw new Error( 'getCell requires at least 2 arguments, received getCell(' + x + ', ' + y + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } const results = getCells(x, y, x, y, sheetName); @@ -265,13 +278,14 @@ export const pos = (): { x: number; y: number } => { export const relCell = (deltaX: number, deltaY: number) => { const p = pos(); if (isNaN(deltaX) || isNaN(deltaY)) { + const line = lineNumber(); throw new Error( 'relCell requires at least 2 arguments, received relCell(' + deltaX + ', ' + deltaY + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } @@ -281,6 +295,7 @@ export const relCell = (deltaX: number, deltaY: number) => { export const relCells = (deltaX0: number, deltaY0: number, deltaX1: number, deltaY1: number) => { const p = pos(); if (isNaN(deltaX0) || isNaN(deltaY0) || isNaN(deltaX1) || isNaN(deltaY1)) { + const line = lineNumber(); throw new Error( 'relCells requires at least 4 arguments, received relCells(' + deltaX0 + @@ -291,12 +306,11 @@ export const relCells = (deltaX0: number, deltaY0: number, deltaX1: number, delt ', ' + deltaY1 + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } return getCells(deltaX0 + p.x, deltaY0 + p.y, deltaX1 + p.x, deltaY1 + p.y); }; -export const rc = relCell; -`; +export const rc = relCell;`; diff --git a/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/javascriptLibrary.ts b/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/javascriptLibrary.ts index cfb34cdbc7..d4a8746dc3 100644 --- a/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/javascriptLibrary.ts +++ b/quadratic-client/src/app/web-workers/javascriptWebWorker/worker/javascript/runner/javascriptLibrary.ts @@ -1,8 +1,6 @@ declare var self: WorkerGlobalScope & typeof globalThis; declare global { - let ___line_number___: number; - /** * Get a range of cells from the sheet * @param x0 x coordinate of the top-left cell @@ -180,6 +178,18 @@ function convertNullToUndefined( return arr.map((subArr) => subArr.map((element) => (element === null ? undefined : element))); } +function lineNumber(): number | undefined { + try { + throw new Error() + } catch (e: any) { + const stackLines = e.stack.split("\\n"); + const match = stackLines[3].match(/:(\\d+):(\\d+)/); + if (match) { + return match[1]; + } + } +} + export const getCells = ( x0: number, y0: number, @@ -188,6 +198,7 @@ export const getCells = ( sheetName?: string ): (number | string | boolean | undefined)[][] => { if (isNaN(x0) || isNaN(y0) || isNaN(x1)) { + const line = lineNumber(); throw new Error( 'getCells requires at least 3 arguments, received getCells(' + x0 + @@ -198,7 +209,7 @@ export const getCells = ( ', ' + y1 + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } return getCellsDB(x0, y0, x1, y1, sheetName); @@ -214,6 +225,7 @@ export const getCellsWithHeadings = ( sheetName?: string ): Record[] => { if (isNaN(x0) || isNaN(y0) || isNaN(x1)) { + const line = lineNumber(); throw new Error( 'getCellsWithHeadings requires at least 3 arguments, received getCellsWithHeadings(' + x0 + @@ -224,7 +236,7 @@ export const getCellsWithHeadings = ( ', ' + y1 + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } const cells = getCells(x0, y0, x1, y1, sheetName); @@ -240,13 +252,14 @@ export const getCellsWithHeadings = ( export const getCell = (x: number, y: number, sheetName?: string): number | string | boolean | undefined => { if (isNaN(x) || isNaN(y)) { + const line = lineNumber(); throw new Error( 'getCell requires at least 2 arguments, received getCell(' + x + ', ' + y + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } const results = getCells(x, y, x, y, sheetName); @@ -264,13 +277,14 @@ export const pos = (): { x: number; y: number } => { export const relCell = (deltaX: number, deltaY: number) => { const p = pos(); if (isNaN(deltaX) || isNaN(deltaY)) { + const line = lineNumber(); throw new Error( 'relCell requires at least 2 arguments, received relCell(' + deltaX + ', ' + deltaY + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } @@ -280,6 +294,7 @@ export const relCell = (deltaX: number, deltaY: number) => { export const relCells = (deltaX0: number, deltaY0: number, deltaX1: number, deltaY1: number) => { const p = pos(); if (isNaN(deltaX0) || isNaN(deltaY0) || isNaN(deltaX1) || isNaN(deltaY1)) { + const line = lineNumber(); throw new Error( 'relCells requires at least 4 arguments, received relCells(' + deltaX0 + @@ -290,11 +305,11 @@ export const relCells = (deltaX0: number, deltaY0: number, deltaX1: number, delt ', ' + deltaY1 + ')' + - (___line_number___ !== undefined ? ' at line ' + ___line_number___ : '') + (line !== undefined ? ' at line ' + (line - 1) : '') ); } return getCells(deltaX0 + p.x, deltaY0 + p.y, deltaX1 + p.x, deltaY1 + p.y); }; -export const rc = relCell; +export const rc = relCell; \ No newline at end of file diff --git a/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts b/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts index 286f13a304..8aa58c13d0 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/coreClientMessages.ts @@ -808,6 +808,7 @@ export interface ClientCoreImportExcel { type: 'clientCoreImportExcel'; file: Uint8Array; fileName: string; + cursor?: string; id: number; } @@ -932,7 +933,6 @@ export type ClientCoreMessage = | ClientCoreCancelExecution | ClientCoreGetJwt | ClientCoreMoveCells - | ClientCoreMoveCells | ClientCoreGetFormatAll | ClientCoreGetFormatColumn | ClientCoreGetFormatRow diff --git a/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts b/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts index 5e3ffd4500..2b66573670 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/quadraticCore.ts @@ -972,7 +972,8 @@ class QuadraticCore { // create a new grid file and import an xlsx file importExcel = async ( file: Uint8Array, - fileName: string + fileName: string, + cursor?: string ): Promise<{ contents?: Uint8Array; fileName: string; @@ -988,6 +989,7 @@ class QuadraticCore { type: 'clientCoreImportExcel', file, fileName, + cursor, id, }); }); diff --git a/quadratic-client/src/app/web-workers/quadraticCore/worker/core.ts b/quadratic-client/src/app/web-workers/quadraticCore/worker/core.ts index 0f6b6ec559..7de812088d 100644 --- a/quadratic-client/src/app/web-workers/quadraticCore/worker/core.ts +++ b/quadratic-client/src/app/web-workers/quadraticCore/worker/core.ts @@ -823,11 +823,17 @@ class Core { async importExcel( message: ClientCoreImportExcel ): Promise<{ contents?: Uint8Array; version?: string; error?: string }> { - await initCore(); try { - const gc = GridController.importExcel(message.file, message.fileName); - const contents = gc.exportToFile(); - return { contents: contents, version: gc.getVersion() }; + if (message.cursor === undefined) { + await initCore(); + const gc = GridController.importExcel(message.file, message.fileName); + const contents = gc.exportToFile(); + return { contents: contents, version: gc.getVersion() }; + } else { + if (!this.gridController) throw new Error('Expected gridController to be defined'); + this.gridController.importExcelIntoExistingFile(message.file, message.fileName, message.cursor); + return {}; + } } catch (error: unknown) { // TODO(ddimaria): standardize on how WASM formats errors for a consistent error // type in the UI. diff --git a/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/CellLabel.ts b/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/CellLabel.ts index 1af3277e45..7fca74ad31 100644 --- a/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/CellLabel.ts +++ b/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/CellLabel.ts @@ -20,7 +20,7 @@ import { CellsLabels } from './CellsLabels'; import { LabelMeshEntry } from './LabelMeshEntry'; import { LabelMeshes } from './LabelMeshes'; import { extractCharCode, splitTextToCharacters } from './bitmapTextUtils'; -import { convertNumber, reduceDecimals } from './convertNumber'; +import { convertNumber, getFractionDigits, reduceDecimals } from './convertNumber'; interface CharRenderData { charData: RenderBitmapChar; @@ -108,6 +108,13 @@ export class CellLabel { default: if (cell.value !== undefined && cell.number) { this.number = cell.number; + if (cell.language) { + // fraction digits in number, max 16 (f64 precision) + const numberFractionDigits = Math.min(getFractionDigits(cell.value, cell.number), 16); + // display fraction digits in number, default 9 + const displayFractionDigits = Math.min(this.number.decimals ?? 9, numberFractionDigits); + this.number.decimals = displayFractionDigits; + } return convertNumber(cell.value, cell.number).toUpperCase(); } else { return cell?.value; @@ -149,7 +156,7 @@ export class CellLabel { this.updateFontName(); this.align = cell.align ?? 'left'; this.verticalAlign = cell.verticalAlign ?? 'top'; - this.wrap = cell.wrap ?? 'overflow'; + this.wrap = cell.wrap === undefined && this.isNumber() ? 'clip' : cell.wrap ?? 'overflow'; this.updateCellLimits(); } @@ -172,7 +179,7 @@ export class CellLabel { const nextLeftWidth = this.AABB.right - nextLeft; if (this.nextLeftWidth !== nextLeftWidth) { this.nextLeftWidth = nextLeftWidth; - if (this.number !== undefined) { + if (this.isNumber()) { this.updateText(labelMeshes); } return true; @@ -192,7 +199,7 @@ export class CellLabel { const nextRightWidth = nextRight - this.AABB.left; if (this.nextRightWidth !== nextRightWidth) { this.nextRightWidth = nextRightWidth; - if (this.number !== undefined) { + if (this.isNumber()) { this.updateText(labelMeshes); } return true; @@ -207,8 +214,12 @@ export class CellLabel { return false; }; + isNumber = (): boolean => { + return this.number !== undefined; + }; + checkNumberClip = (): boolean => { - if (this.number === undefined) return false; + if (!this.isNumber()) return false; const clipLeft = Math.max(this.cellClipLeft ?? -Infinity, this.AABB.right - (this.nextLeftWidth ?? Infinity)); if (this.actualLeft < clipLeft) return true; @@ -269,12 +280,11 @@ export class CellLabel { this.chars = processedText.chars; this.textWidth = processedText.textWidth; this.textHeight = processedText.textHeight; - this.unwrappedTextWidth = processedText.unwrappedTextWidth; this.horizontalAlignOffsets = processedText.horizontalAlignOffsets; + this.unwrappedTextWidth = this.getUnwrappedTextWidth(this.text); this.calculatePosition(); - // replaces numbers with pound signs when the number overflows if (this.checkNumberClip()) { const clippedNumber = this.getClippedNumber(this.originalText, this.text, this.number); const processedNumberText = this.processText(labelMeshes, clippedNumber); @@ -282,7 +292,7 @@ export class CellLabel { this.chars = processedNumberText.chars; this.textWidth = processedNumberText.textWidth; - this.textHeight = processedText.textHeight; + this.textHeight = processedNumberText.textHeight; this.horizontalAlignOffsets = processedNumberText.horizontalAlignOffsets; this.calculatePosition(); @@ -388,6 +398,36 @@ export class CellLabel { maxLineWidth = Math.max(maxLineWidth, lastLineWidth); } + const horizontalAlignOffsets = []; + for (let i = 0; i <= line; i++) { + let alignOffset = 0; + if (this.align === 'right') { + alignOffset = maxLineWidth - lineWidths[i]; + } else if (this.align === 'center') { + alignOffset = (maxLineWidth - lineWidths[i]) / 2; + } else if (this.align === 'justify') { + alignOffset = lineSpaces[i] < 0 ? 0 : (maxLineWidth - lineWidths[i]) / lineSpaces[i]; + } + horizontalAlignOffsets.push(alignOffset); + } + + return { + chars, + textWidth: maxLineWidth * scale + OPEN_SANS_FIX.x * 2, + textHeight: Math.max(textHeight * scale, CELL_HEIGHT), + displayText, + horizontalAlignOffsets, + }; + }; + + private getUnwrappedTextWidth = (text: string): number => { + const data = this.cellsLabels.bitmapFonts[this.fontName]; + if (!data) throw new Error(`Expected BitmapFont ${this.fontName} to be defined in CellLabel.updateText`); + + const scale = this.fontSize / data.size; + + const charsInput = splitTextToCharacters(text); + let prevCharCode = null; // calculate the unwrapped text width, content can be multi-line due to \n or \r let curUnwrappedTextWidth = 0; let maxUnwrappedTextWidth = 0; @@ -409,28 +449,7 @@ export class CellLabel { prevCharCode = charCode; } const unwrappedTextWidth = (maxUnwrappedTextWidth + 3 * CELL_TEXT_MARGIN_LEFT) * scale; - - const horizontalAlignOffsets = []; - for (let i = 0; i <= line; i++) { - let alignOffset = 0; - if (this.align === 'right') { - alignOffset = maxLineWidth - lineWidths[i]; - } else if (this.align === 'center') { - alignOffset = (maxLineWidth - lineWidths[i]) / 2; - } else if (this.align === 'justify') { - alignOffset = lineSpaces[i] < 0 ? 0 : (maxLineWidth - lineWidths[i]) / lineSpaces[i]; - } - horizontalAlignOffsets.push(alignOffset); - } - - return { - chars, - textWidth: maxLineWidth * scale + OPEN_SANS_FIX.x * 2, - textHeight: Math.max(textHeight * scale, CELL_HEIGHT), - unwrappedTextWidth, - displayText, - horizontalAlignOffsets, - }; + return unwrappedTextWidth; }; // This attempts to reduce the decimal precision to ensure the number fits @@ -440,19 +459,25 @@ export class CellLabel { let digits: number | undefined = undefined; let infinityProtection = 0; + let textWidth = this.getUnwrappedTextWidth(text); do { const result = reduceDecimals(originalText, text, number, digits); // we cannot reduce decimals anymore, so we show pound characters - if (!result) return this.getPoundText(); + if (!result) { + return this.getPoundText(); + } digits = result.currentFractionDigits - 1; text = result.number; - } while (this.textWidth > this.AABB.width && digits >= 0 && infinityProtection++ < 1000); + textWidth = this.getUnwrappedTextWidth(text); + } while (textWidth > this.AABB.width && digits >= 0 && infinityProtection++ < 1000); // we were not able to reduce the number to fit the cell, so we show pound characters - if (digits < 0) return this.getPoundText(); + if (digits < 0) { + return this.getPoundText(); + } - return text; + return text.toUpperCase(); }; private getPoundText = () => { diff --git a/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/convertNumber.ts b/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/convertNumber.ts index 59021a4cb9..fa3ccb05df 100644 --- a/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/convertNumber.ts +++ b/quadratic-client/src/app/web-workers/renderWebWorker/worker/cellsLabel/convertNumber.ts @@ -57,25 +57,22 @@ export const reduceDecimals = ( format: JsNumber, currentFractionDigits?: number ): { number: string; currentFractionDigits: number } | undefined => { + if (currentFractionDigits === undefined) { + currentFractionDigits = getFractionDigits(number, format); + currentFractionDigits = Math.max(0, currentFractionDigits - 1); + } + const updated = convertNumber(number, format, currentFractionDigits); + if (updated !== current) { + return { number: updated, currentFractionDigits }; + } +}; + +export const getFractionDigits = (number: string, format: JsNumber): number => { // this only works if there is a fractional part if (format.format?.type === 'EXPONENTIAL') { - if (currentFractionDigits === undefined) { - currentFractionDigits = number.length - (number[0] === '-' ? 3 : 2); - } - const updated = convertNumber(number, format, currentFractionDigits); - if (updated !== current) { - return { number: updated, currentFractionDigits }; - } - } else { - if (number.includes('.')) { - if (currentFractionDigits === undefined) { - const split = number.split('.'); - currentFractionDigits = split[1].length - 1; - } - const updated = convertNumber(number, format, currentFractionDigits); - if (updated !== current) { - return { number: updated, currentFractionDigits }; - } - } + return number.length - (number[0] === '-' ? 2 : 1); + } else if (number.includes('.')) { + return number.split('.')[1].length; } + return 0; }; diff --git a/quadratic-client/src/shared/components/FixedBottomAlert.tsx b/quadratic-client/src/shared/components/FixedBottomAlert.tsx new file mode 100644 index 0000000000..9fd334afb1 --- /dev/null +++ b/quadratic-client/src/shared/components/FixedBottomAlert.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export function FixedBottomAlert({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/quadratic-client/src/shared/components/SlideUpBottomAlert.tsx b/quadratic-client/src/shared/components/SlideUpBottomAlert.tsx new file mode 100644 index 0000000000..80ab0d5ae7 --- /dev/null +++ b/quadratic-client/src/shared/components/SlideUpBottomAlert.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export function SlideUpBottomAlert({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/quadratic-client/src/shared/components/UserMessage.tsx b/quadratic-client/src/shared/components/UserMessage.tsx new file mode 100644 index 0000000000..045166caf4 --- /dev/null +++ b/quadratic-client/src/shared/components/UserMessage.tsx @@ -0,0 +1,18 @@ +import { userMessageAtom } from '@/app/atoms/userMessageAtom'; +import { SlideUpBottomAlert } from '@/shared/components/SlideUpBottomAlert'; +import { Type } from '@/shared/components/Type'; +import { useRecoilValue } from 'recoil'; + +export function UserMessage() { + const { message } = useRecoilValue(userMessageAtom); + + if (!message) { + return null; + } + + return ( + + {message} + + ); +} diff --git a/quadratic-client/src/shared/constants/appConstants.ts b/quadratic-client/src/shared/constants/appConstants.ts index fa2b84893a..9d3e4bee3d 100644 --- a/quadratic-client/src/shared/constants/appConstants.ts +++ b/quadratic-client/src/shared/constants/appConstants.ts @@ -1,6 +1,7 @@ export const SUPPORT_EMAIL = 'support@quadratichq.com'; export const DEFAULT_FILE_NAME = 'Untitled'; export const CSV_IMPORT_MESSAGE = 'Drag and drop a CSV file on the grid to import it.'; +export const EXCEL_IMPORT_MESSAGE = 'Drag and drop an Excel file on the grid to import it.'; export const PARQUET_IMPORT_MESSAGE = 'Drag and drop a Parquet file on the grid to import it.'; export const TYPE = { // Borrowed from mui typography diff --git a/quadratic-client/tailwind.config.ts b/quadratic-client/tailwind.config.ts index 2d36022b3b..9b885b135f 100644 --- a/quadratic-client/tailwind.config.ts +++ b/quadratic-client/tailwind.config.ts @@ -75,10 +75,15 @@ const config: Config = { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: '0' }, }, + 'slide-up': { + '0%': { transform: 'translateY(100%) translateX(-50%)' }, + '100%': { transform: 'translateY(0) translateX(-50%)' }, + }, }, animation: { 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', + 'slide-up': 'slide-up 0.3s ease-out', }, }, }, diff --git a/quadratic-core/src/controller/execution/execute_operation/execute_borders.rs b/quadratic-core/src/controller/execution/execute_operation/execute_borders.rs index 2d1fcda4ab..d3144cf645 100644 --- a/quadratic-core/src/controller/execution/execute_operation/execute_borders.rs +++ b/quadratic-core/src/controller/execution/execute_operation/execute_borders.rs @@ -31,7 +31,7 @@ impl GridController { borders: old_borders, }); - if cfg!(test) || (cfg!(target_family = "wasm") && !transaction.is_server()) { + if (cfg!(test) || cfg!(target_family = "wasm")) && !transaction.is_server() { self.send_updated_bounds(sheet_rect.sheet_id); self.send_render_borders(sheet_rect.sheet_id); } diff --git a/quadratic-core/src/controller/execution/run_code/run_formula.rs b/quadratic-core/src/controller/execution/run_code/run_formula.rs index a728129cde..fd611660d8 100644 --- a/quadratic-core/src/controller/execution/run_code/run_formula.rs +++ b/quadratic-core/src/controller/execution/run_code/run_formula.rs @@ -17,27 +17,23 @@ impl GridController { let mut ctx = Ctx::new(self.grid(), sheet_pos); transaction.current_sheet_pos = Some(sheet_pos); match parse_formula(&code, sheet_pos.into()) { - Ok(parsed) => match parsed.eval(&mut ctx) { - Ok(value) => { - transaction.cells_accessed = ctx.cells_accessed; - let new_code_run = CodeRun { - std_out: None, - std_err: None, - formatted_code_string: None, - spill_error: false, - last_modified: Utc::now(), - cells_accessed: transaction.cells_accessed.clone(), - result: CodeRunResult::Ok(value), - return_type: None, - line_number: None, - output_type: None, - }; - self.finalize_code_run(transaction, sheet_pos, Some(new_code_run), None); - } - Err(error) => { - let _ = self.code_cell_sheet_error(transaction, &error); - } - }, + Ok(parsed) => { + let output = parsed.eval(&mut ctx).into_non_tuple(); + transaction.cells_accessed = ctx.cells_accessed; + let new_code_run = CodeRun { + std_out: None, + std_err: None, + formatted_code_string: None, + spill_error: false, + last_modified: Utc::now(), + cells_accessed: transaction.cells_accessed.clone(), + result: CodeRunResult::Ok(output.inner), + return_type: None, + line_number: None, + output_type: None, + }; + self.finalize_code_run(transaction, sheet_pos, Some(new_code_run), None); + } Err(error) => { let _ = self.code_cell_sheet_error(transaction, &error); } diff --git a/quadratic-core/src/controller/operations/import.rs b/quadratic-core/src/controller/operations/import.rs index 15ea3f3aba..95c29856aa 100644 --- a/quadratic-core/src/controller/operations/import.rs +++ b/quadratic-core/src/controller/operations/import.rs @@ -136,6 +136,12 @@ impl GridController { let mut workbook: Xlsx<_> = ExcelReader::new(cursor).map_err(error)?; let sheets = workbook.sheet_names().to_owned(); + let existing_sheet_names = self.sheet_names(); + for sheet_name in sheets.iter() { + if existing_sheet_names.contains(&sheet_name.as_str()) { + bail!("Sheet with name {} already exists", sheet_name); + } + } // first cell in excel is A1, but first cell in quadratic is A0 // so we need to offset rows by 1, so that values are inserted in the original A1 notations cell // this is required so that cell references (A1 notations) in formulas are correct @@ -422,7 +428,7 @@ mod test { fn import_excel() { let mut gc = GridController::test_blank(); let file = include_bytes!("../../../test-files/simple.xlsx"); - gc.import_excel(file.to_vec(), "simple.xlsx").unwrap(); + gc.import_excel(file.to_vec(), "simple.xlsx", None).unwrap(); let sheet_id = gc.grid.sheets()[0].id; let sheet = gc.sheet(sheet_id); @@ -451,7 +457,7 @@ mod test { fn import_excel_invalid() { let mut gc = GridController::test_blank(); let file = include_bytes!("../../../test-files/invalid.xlsx"); - let result = gc.import_excel(file.to_vec(), "invalid.xlsx"); + let result = gc.import_excel(file.to_vec(), "invalid.xlsx", None); assert!(result.is_err()); } } diff --git a/quadratic-core/src/controller/user_actions/import.rs b/quadratic-core/src/controller/user_actions/import.rs index 918067d3ed..7f7c89d968 100644 --- a/quadratic-core/src/controller/user_actions/import.rs +++ b/quadratic-core/src/controller/user_actions/import.rs @@ -21,9 +21,18 @@ impl GridController { /// Imports an Excel file into the grid. /// /// Returns a [`TransactionSummary`]. - pub fn import_excel(&mut self, file: Vec, file_name: &str) -> Result<()> { - let import_ops = self.import_excel_operations(file, file_name)?; - self.server_apply_transaction(import_ops); + pub fn import_excel( + &mut self, + file: Vec, + file_name: &str, + cursor: Option, + ) -> Result<()> { + let ops = self.import_excel_operations(file, file_name)?; + if cursor.is_some() { + self.start_user_transaction(ops, cursor, TransactionName::Import); + } else { + self.server_apply_transaction(ops); + } // Rerun all code cells after importing Excel file // This is required to run compute cells in order @@ -167,7 +176,7 @@ mod tests { let mut grid_controller = GridController::test_blank(); let pos = Pos { x: 0, y: 0 }; let file: Vec = std::fs::read(EXCEL_FILE).expect("Failed to read file"); - let _ = grid_controller.import_excel(file, "basic.xlsx"); + let _ = grid_controller.import_excel(file, "basic.xlsx", None); let sheet_id = grid_controller.grid.sheets()[0].id; print_table( @@ -211,7 +220,66 @@ mod tests { "1.1", "2024-01-01 13:00:00", "1", + "Divide by zero", + "TRUE", + "Hello Bold", + "Hello Red", + ], + ); + } + + #[test] + #[parallel] + fn imports_a_simple_excel_file_into_existing_file() { + let mut grid_controller = GridController::test(); + let pos = Pos { x: 0, y: 0 }; + let file: Vec = std::fs::read(EXCEL_FILE).expect("Failed to read file"); + let _ = grid_controller.import_excel(file, "basic.xlsx", Some("".into())); + + let new_sheet_id = grid_controller.grid.sheets()[1].id; + + print_table( + &grid_controller, + new_sheet_id, + Rect::new_span(pos, Pos { x: 10, y: 10 }), + ); + + assert_cell_value_row( + &grid_controller, + new_sheet_id, + 0, + 10, + 1, + vec![ + "Empty", + "String", + "DateTimeIso", + "DurationIso", + "Float", + "DateTime", + "Int", + "Error", + "Bool", + "Bold", + "Red", + ], + ); + + assert_cell_value_row( + &grid_controller, + new_sheet_id, + 0, + 10, + 2, + vec![ + "", + "Hello", + "2016-10-20 00:00:00", "", + "1.1", + "2024-01-01 13:00:00", + "1", + "Divide by zero", "TRUE", "Hello Bold", "Hello Red", @@ -225,7 +293,7 @@ mod tests { let mut grid_controller = GridController::test_blank(); let pos = Pos { x: 0, y: 0 }; let file: Vec = std::fs::read(EXCEL_FUNCTIONS_FILE).expect("Failed to read file"); - let _ = grid_controller.import_excel(file, "all_excel_functions.xlsx"); + let _ = grid_controller.import_excel(file, "all_excel_functions.xlsx", None); let sheet_id = grid_controller.grid.sheets()[0].id; print_table( diff --git a/quadratic-core/src/error_run.rs b/quadratic-core/src/error_run.rs index 9e4afd178c..3ed172fff1 100644 --- a/quadratic-core/src/error_run.rs +++ b/quadratic-core/src/error_run.rs @@ -69,6 +69,8 @@ pub enum RunErrorMsg { BadFunctionName, BadCellReference, BadNumber, + /// NaN or ±Infinity + NaN, // Array size errors ExactArraySizeMismatch { @@ -158,6 +160,9 @@ impl fmt::Display for RunErrorMsg { Self::BadNumber => { write!(f, "Bad numeric literal") } + Self::NaN => { + write!(f, "NaN") + } Self::ExactArraySizeMismatch { expected, got } => { write!( diff --git a/quadratic-core/src/formulas/ast.rs b/quadratic-core/src/formulas/ast.rs index db5566f152..734e78ca89 100644 --- a/quadratic-core/src/formulas/ast.rs +++ b/quadratic-core/src/formulas/ast.rs @@ -55,8 +55,11 @@ impl AstNodeContents { impl Formula { /// Evaluates a formula. - pub fn eval(&self, ctx: &mut Ctx<'_>) -> CodeResult { - self.ast.eval(ctx)?.into_non_error_value() + pub fn eval(&self, ctx: &mut Ctx<'_>) -> Spanned { + self.ast.eval(ctx).unwrap_or_else(|e| Spanned { + span: self.ast.span, + inner: e.into(), + }) } } @@ -133,7 +136,7 @@ impl AstNode { // Single cell references return 1x1 arrays for Excel compatibility. AstNodeContents::CellRef(cell_ref) => { let pos = ctx.resolve_ref(cell_ref, self.span)?.inner; - Array::from(ctx.get_cell(pos, self.span)?.inner).into() + Array::from(ctx.get_cell(pos, self.span).inner).into() } AstNodeContents::String(s) => Value::from(s.to_string()), diff --git a/quadratic-core/src/formulas/ctx.rs b/quadratic-core/src/formulas/ctx.rs index 450c20a87f..0e03ce7a6c 100644 --- a/quadratic-core/src/formulas/ctx.rs +++ b/quadratic-core/src/formulas/ctx.rs @@ -89,41 +89,41 @@ impl<'ctx> Ctx<'ctx> { } } - /// Fetches the contents of the cell at `ref_pos` evaluated at - /// `self.sheet_pos`, or returns an error in the case of a circular - /// reference. - pub fn get_cell(&mut self, sheet_pos: SheetPos, span: Span) -> CodeResult> { + /// Fetches the contents of the cell at `pos` evaluated at `self.sheet_pos`, + /// or returns an error in the case of a circular reference. + pub fn get_cell(&mut self, pos: SheetPos, span: Span) -> Spanned { if self.skip_computation { - return Ok(CellValue::Blank).with_span(span); + let value = CellValue::Blank; + return Spanned { span, inner: value }; } - if sheet_pos == self.sheet_pos { - return Err(RunErrorMsg::CircularReference.with_span(span)); + let error_value = |e: RunErrorMsg| { + let value = CellValue::Error(Box::new(e.with_span(span))); + Spanned { inner: value, span } + }; + + let Some(sheet) = self.grid.try_sheet(pos.sheet_id) else { + return error_value(RunErrorMsg::BadCellReference); + }; + if pos == self.sheet_pos { + return error_value(RunErrorMsg::CircularReference); } - self.cells_accessed.insert(sheet_pos.into()); + self.cells_accessed.insert(pos.into()); - let sheet = self - .grid - .try_sheet(sheet_pos.sheet_id) - .ok_or(RunErrorMsg::BadCellReference.with_span(span))?; - let value = sheet - .display_value(sheet_pos.into()) - .unwrap_or(CellValue::Blank); - Ok(value).with_span(span) + let value = sheet.get_cell_for_formula(pos.into()); + Spanned { inner: value, span } } - pub fn get_cell_array( - &mut self, - sheet_rect: SheetRect, - span: Span, - ) -> CodeResult> { + /// Fetches the contents of the cell array at `rect`, or returns an error in + /// the case of a circular reference. + pub fn get_cell_array(&mut self, rect: SheetRect, span: Span) -> CodeResult> { if self.skip_computation { return Ok(CellValue::Blank.into()).with_span(span); } - let sheet_id = sheet_rect.sheet_id; - let array_size = sheet_rect.size(); + let sheet_id = rect.sheet_id; + let array_size = rect.size(); if std::cmp::max(array_size.w, array_size.h).get() > crate::limits::CELL_RANGE_LIMIT { return Err(RunErrorMsg::ArrayTooBig.with_span(span)); } @@ -131,10 +131,10 @@ impl<'ctx> Ctx<'ctx> { let mut flat_array = smallvec![]; // Reuse the same `CellRef` object so that we don't have to // clone `sheet_name.` - for y in sheet_rect.y_range() { - for x in sheet_rect.x_range() { + for y in rect.y_range() { + for x in rect.x_range() { // TODO: record array dependency instead of many individual cell dependencies - flat_array.push(self.get_cell(SheetPos { x, y, sheet_id }, span)?.inner); + flat_array.push(self.get_cell(SheetPos { x, y, sheet_id }, span).inner); } } diff --git a/quadratic-core/src/formulas/functions/logic.rs b/quadratic-core/src/formulas/functions/logic.rs index 17ce448044..15fb54b1cf 100644 --- a/quadratic-core/src/formulas/functions/logic.rs +++ b/quadratic-core/src/formulas/functions/logic.rs @@ -81,6 +81,27 @@ fn get_functions() -> Vec { if condition { t } else { f }.clone() } ), + formula_fn!( + /// Returns `fallback` if there was an error computing `value`; + /// otherwise returns `value`. + #[examples( + "IFERROR(1/A6, \"error: division by zero!\")", + "IFERROR(A7, \"Something went wrong\")" + )] + #[zip_map] + fn IFERROR([value]: CellValue, [fallback]: CellValue) { + // This is slightly inconsistent with Excel; Excel does a weird + // sort of zip-map here that doesn't require `value` and + // `fallback` to have the same size, and also has special + // handling if `value` is size=1 along an axis. This is + // something we could try to fix later, but it's probably not + // worth it. + value + .clone() + .into_non_error_value() + .unwrap_or(fallback.clone()) + } + ), ] } @@ -92,17 +113,57 @@ mod tests { #[test] #[parallel] fn test_formula_if() { - let form = parse_formula("IF(A1='q', 'yep', 'nope')", pos![A0]).unwrap(); - let mut g = Grid::new(); let sheet = &mut g.sheets_mut()[0]; - let _ = sheet.set_cell_value(Pos { x: 0, y: 1 }, "q"); - let _ = sheet.set_cell_value(Pos { x: 1, y: 1 }, "w"); + sheet.set_cell_value(Pos { x: 0, y: 1 }, "q"); + sheet.set_cell_value(Pos { x: 1, y: 1 }, "w"); let sheet_id = sheet.id; - let mut ctx = Ctx::new(&g, pos![A0].to_sheet_pos(sheet_id)); - assert_eq!("yep".to_string(), form.eval(&mut ctx).unwrap().to_string()); - let mut ctx = Ctx::new(&g, pos![B0].to_sheet_pos(sheet_id)); - assert_eq!("nope".to_string(), form.eval(&mut ctx).unwrap().to_string()); + let s = "IF(A1='q', 'yep', 'nope')"; + let pos = pos![A0].to_sheet_pos(sheet_id); + assert_eq!("yep", eval_to_string_at(&g, pos, s)); + let pos = pos![B0].to_sheet_pos(sheet_id); + assert_eq!("nope", eval_to_string_at(&g, pos, s)); + + // Test short-circuiting + assert_eq!("ok", eval_to_string(&g, "IF(TRUE,\"ok\",1/0)")); + // Test error passthrough + assert_eq!( + RunErrorMsg::DivideByZero, + eval_to_err(&g, "IF(FALSE,\"ok\",1/0)").msg, + ); + } + + #[test] + #[parallel] + fn test_formula_iferror() { + let mut g = Grid::new(); + + assert_eq!("ok", eval_to_string(&g, "IFERROR(\"ok\", 42)")); + assert_eq!("ok", eval_to_string(&g, "IFERROR(\"ok\", 0/0)")); + assert_eq!("42", eval_to_string(&g, "IFERROR(0/0, 42)")); + assert_eq!( + RunErrorMsg::DivideByZero, + eval_to_err(&g, "IFERROR(0/0, 0/0)").msg, + ); + + assert_eq!( + "complex!", + eval_to_string(&g, "IFERROR(SQRT(-1), \"complex!\")"), + ); + + g.sheets_mut()[0].set_cell_value(pos![A6], "happy"); + assert_eq!("happy", eval_to_string(&g, "IFERROR(A6, 42)")); + assert_eq!("happy", eval_to_string(&g, "IFERROR(A6, 0/0)")); + + g.sheets_mut()[0].set_cell_value( + pos![A6], + CellValue::Error(Box::new(RunErrorMsg::Infinity.without_span())), + ); + assert_eq!("42", eval_to_string(&g, "IFERROR(A6, 42)")); + assert_eq!( + RunErrorMsg::DivideByZero, + eval_to_err(&g, "IFERROR(A6, 0/0)").msg, + ); } } diff --git a/quadratic-core/src/formulas/functions/lookup.rs b/quadratic-core/src/formulas/functions/lookup.rs index b8280f83c9..d7d7c6bd89 100644 --- a/quadratic-core/src/formulas/functions/lookup.rs +++ b/quadratic-core/src/formulas/functions/lookup.rs @@ -21,10 +21,12 @@ fn get_functions() -> Vec { #[examples("INDIRECT(\"Cn7\")", "INDIRECT(\"F\" & B0)")] #[zip_map] fn INDIRECT(ctx: Ctx, [cellref_string]: (Spanned)) { + let span = cellref_string.span; + // TODO: support array references let cell_ref = CellRef::parse_a1(&cellref_string.inner, ctx.sheet_pos.into()) - .ok_or(RunErrorMsg::BadCellReference.with_span(cellref_string.span))?; - let pos = ctx.resolve_ref(&cell_ref, cellref_string.span)?.inner; - ctx.get_cell(pos, cellref_string.span)?.inner + .ok_or(RunErrorMsg::BadCellReference.with_span(span))?; + let pos = ctx.resolve_ref(&cell_ref, span)?.inner; + ctx.get_cell(pos, span).inner } ), formula_fn!( @@ -602,6 +604,7 @@ enum LookupSearchMode { } impl LookupSearchMode { fn from_is_sorted(is_sorted: Option) -> Self { + // TODO: the default behavior here may be incorrect. match is_sorted { Some(false) | None => LookupSearchMode::LinearForward, Some(true) => LookupSearchMode::BinaryAscending, @@ -768,6 +771,15 @@ mod tests { } } + /// Test that VLOOKUP ignores error values. + #[test] + #[parallel] + fn test_vlookup_ignore_errors() { + let g = Grid::from_array(pos![A1], &array!["a", 10; 1.0 / 0.0, 20; "b", 30]); + assert_eq!("10", eval_to_string(&g, "VLOOKUP(\"a\", A1:B3, 2)")); + assert_eq!("30", eval_to_string(&g, "VLOOKUP(\"b\", A1:B3, 2)")); + } + /// Test HLOOKUP error conditions. #[test] #[parallel] diff --git a/quadratic-core/src/formulas/functions/macros.rs b/quadratic-core/src/formulas/functions/macros.rs index c37235462b..fe752ea2bf 100644 --- a/quadratic-core/src/formulas/functions/macros.rs +++ b/quadratic-core/src/formulas/functions/macros.rs @@ -203,6 +203,8 @@ macro_rules! formula_fn_eval_inner { $ctx.zip_map( &args_to_zip_map, move |_ctx, zipped_args| -> CodeResult { + // If you get a warning that `zipped_args` is unused, make sure + // you surrounded your zip-mapped arguments with `[]` formula_fn_args!(@unzip(_ctx, zipped_args); $($params)*); if skip_computation { diff --git a/quadratic-core/src/formulas/functions/mathematics.rs b/quadratic-core/src/formulas/functions/mathematics.rs index 759f69c4ab..1c74a2546a 100644 --- a/quadratic-core/src/formulas/functions/mathematics.rs +++ b/quadratic-core/src/formulas/functions/mathematics.rs @@ -463,30 +463,21 @@ mod tests { let g = Grid::new(); crate::util::assert_f64_approx_eq(3.0_f64.sqrt(), &eval_to_string(&g, "SQRT(3)")); assert_eq!("4", eval_to_string(&g, "SQRT(16)")); - let mut ctx = Ctx::new(&g, Pos::ORIGIN.to_sheet_pos(g.sheets()[0].id)); assert_eq!( RunErrorMsg::MissingRequiredArgument { func_name: "SQRT".into(), arg_name: "number".into(), }, - parse_formula("SQRT()", Pos::ORIGIN) - .unwrap() - .eval(&mut ctx) - .unwrap_err() - .msg, + eval_to_err(&g, "SQRT()").msg, ); - let mut ctx = Ctx::new(&g, Pos::ORIGIN.to_sheet_pos(g.sheets()[0].id)); assert_eq!( RunErrorMsg::TooManyArguments { func_name: "SQRT".into(), max_arg_count: 1, }, - parse_formula("SQRT(16, 17)", Pos::ORIGIN) - .unwrap() - .eval(&mut ctx) - .unwrap_err() - .msg, + eval_to_err(&g, "SQRT(16, 17)").msg, ); + assert_eq!(RunErrorMsg::NaN, eval_to_err(&g, "SQRT(-1)").msg); } #[test] diff --git a/quadratic-core/src/formulas/functions/statistics.rs b/quadratic-core/src/formulas/functions/statistics.rs index 507da771f1..94e092a87b 100644 --- a/quadratic-core/src/formulas/functions/statistics.rs +++ b/quadratic-core/src/formulas/functions/statistics.rs @@ -52,7 +52,7 @@ fn get_functions() -> Vec { // Ignore error values. numbers .filter(|x| matches!(x, Ok(CellValue::Number(_)))) - .count() as f64 + .count() } ), formula_fn!( @@ -65,7 +65,7 @@ fn get_functions() -> Vec { #[examples("COUNTA(A1:A10)")] fn COUNTA(range: (Iter)) { // Count error values. - range.filter_ok(|v| !v.is_blank()).count() as f64 + range.filter_ok(|v| !v.is_blank()).count() } ), formula_fn!( @@ -81,8 +81,10 @@ fn get_functions() -> Vec { fn COUNTIF(range: (Spanned), [criteria]: (Spanned)) { let criteria = Criterion::try_from(*criteria)?; // Ignore error values. + // The `let` binding is necessary to avoid a lifetime error. + #[allow(clippy::let_and_return)] let count = criteria.iter_matching(range, None)?.count(); - count as f64 + count } ), formula_fn!( @@ -126,7 +128,7 @@ fn get_functions() -> Vec { range .filter_map(|v| v.ok()) .filter(|v| v.is_blank_or_empty_string()) - .count() as f64 + .count() } ), formula_fn!( @@ -170,7 +172,7 @@ mod tests { let sheet_id = sheet.id; let mut ctx = Ctx::new(&g, pos![nAn1].to_sheet_pos(sheet_id)); - assert_eq!("7.5".to_string(), form.eval(&mut ctx).unwrap().to_string()); + assert_eq!("7.5".to_string(), form.eval(&mut ctx).to_string()); assert_eq!( "17", diff --git a/quadratic-core/src/formulas/functions/string.rs b/quadratic-core/src/formulas/functions/string.rs index 61703905f6..07309deff8 100644 --- a/quadratic-core/src/formulas/functions/string.rs +++ b/quadratic-core/src/formulas/functions/string.rs @@ -9,14 +9,43 @@ pub const CATEGORY: FormulaFunctionCategory = FormulaFunctionCategory { }; fn get_functions() -> Vec { - vec![formula_fn!( - /// [Concatenates](https://en.wikipedia.org/wiki/Concatenation) all - /// values as strings. - #[examples("CONCAT(\"Hello, \", C0, \"!\")")] - fn CONCAT(strings: (Iter)) { - strings.try_fold(String::new(), |a, b| Ok(a + &b?)) - } - )] + vec![ + formula_fn!( + /// [Concatenates](https://en.wikipedia.org/wiki/Concatenation) all + /// values as strings. + #[examples("CONCAT(\"Hello, \", C0, \"!\")")] + fn CONCAT(strings: (Iter)) { + strings.try_fold(String::new(), |a, b| Ok(a + &b?)) + } + ), + formula_fn!( + /// Returns the half the length of the string in [Unicode + /// code-points](https://tonsky.me/blog/unicode/). This is often the + /// same as the number of characters in a string, but not in the + /// certain diacritics, emojis, or other cases. + #[examples("LEN(\"abc\") = 3", "LEN(\"résumé\") = 6", "LEN(\"ȍ̶̭h̸̲͝ ̵͈̚ņ̶̾ő̶͖\") = ??")] + #[zip_map] + fn LEN([s]: String) { + // In Google Sheets, this function counts UTF-16 codepoints. + // In Excel, this function counts UTF-16 codepoints. + // We count UTF-8 codepoints. + s.chars().count() + } + ), + formula_fn!( + /// Returns the half the length of the string in bytes, using UTF-8 + /// encoding. + #[examples("LENB(\"abc\") = 3", "LENB(\"résumé\") = 8")] + #[zip_map] + fn LENB([s]: String) { + // In Google Sheets, this function counts UTF-16 bytes. + // In Excel in a CJK locale, this function counts UTF-16 bytes. + // In Excel in a non-CJK locale, this function counts UTF-16 codepoints. + // We count UTF-8 bytes. + s.len() + } + ), + ] } #[cfg(test)] @@ -30,7 +59,49 @@ mod tests { let g = Grid::new(); assert_eq!( "Hello, 14000605 worlds!".to_string(), - eval_to_string(&g, "'Hello, ' & 14000605 & ' worlds!'"), + eval_to_string(&g, "\"Hello, \" & 14000605 & ' worlds!'"), + ); + assert_eq!( + "Hello, 14000605 worlds!".to_string(), + eval_to_string(&g, "CONCAT('Hello, ',14000605,\" worlds!\")"), + ); + } + + #[test] + #[parallel] + fn test_formula_len_and_lenb() { + let g = Grid::new(); + + // Excel uses UTF-16 code points, so those are included here in case we + // later decide we want that for compatibility. + for (string, codepoints, bytes, _utf_16_code_units) in [ + ("", 0, 0, 0), + ("résumé", 6, 8, 8), + ("ȍ̶̭h̸̲͝ ̵͈̚ņ̶̾ő̶͖", 17, 32, 17), + ("ą̷̬͔̖̤̎̀͆̄̅̓̕͝", 14, 28, 14), + ("😂", 1, 4, 2), + ("ĩ", 1, 2, 5), + ("👨‍🚀", 3, 11, 5), + ("👍🏿", 2, 8, 4), + ] { + assert_eq!( + codepoints.to_string(), + eval_to_string(&g, &format!("LEN(\"{string}\")")), + ); + assert_eq!( + bytes.to_string(), + eval_to_string(&g, &format!("LENB(\"{string}\")")), + ); + } + + // Test zip-mapping + assert_eq!( + "{2, 3; 1, 4}", + eval_to_string(&g, "LEN({\"aa\", \"bbb\"; \"c\", \"dddd\"})"), + ); + assert_eq!( + "{2, 3; 1, 4}", + eval_to_string(&g, "LENB({\"aa\", \"bbb\"; \"c\", \"dddd\"})"), ); } } diff --git a/quadratic-core/src/formulas/functions/tests.rs b/quadratic-core/src/formulas/functions/tests.rs index eceea9546a..a3bbf781c2 100644 --- a/quadratic-core/src/formulas/functions/tests.rs +++ b/quadratic-core/src/formulas/functions/tests.rs @@ -34,7 +34,7 @@ fn get_functions() -> Vec { #[include_args_in_completion(false)] #[examples("_TEST_TUPLE(1)")] fn _TEST_TUPLE(tuple: (Vec)) { - tuple.len() as f64 + tuple.len() } ), ] @@ -42,25 +42,25 @@ fn get_functions() -> Vec { #[test] fn test_convert_to_cell_value() { - let mut g = Grid::new(); + let g = Grid::new(); - assert_eq!("3", eval_to_string(&mut g, "_TEST_CELL_VALUE(3)")); - assert_eq!("", eval_to_string(&mut g, "_TEST_CELL_VALUE(A1)")); - assert_eq!("3", eval_to_string(&mut g, "_TEST_CELL_VALUE({3})")); - assert_eq!("", eval_to_string(&mut g, "_TEST_CELL_VALUE(A1:A1)")); + assert_eq!("3", eval_to_string(&g, "_TEST_CELL_VALUE(3)")); + assert_eq!("", eval_to_string(&g, "_TEST_CELL_VALUE(A1)")); + assert_eq!("3", eval_to_string(&g, "_TEST_CELL_VALUE({3})")); + assert_eq!("", eval_to_string(&g, "_TEST_CELL_VALUE(A1:A1)")); assert_eq!( RunErrorMsg::Expected { expected: "single value".into(), got: Some("array".into()) }, - eval_to_err(&mut g, "_TEST_CELL_VALUE(A1:A10)").msg, + eval_to_err(&g, "_TEST_CELL_VALUE(A1:A10)").msg, ); assert_eq!( RunErrorMsg::Expected { expected: "single value".into(), got: Some("tuple".into()) }, - eval_to_err(&mut g, "_TEST_CELL_VALUE((A1:A10, C1:C10))").msg, + eval_to_err(&g, "_TEST_CELL_VALUE((A1:A10, C1:C10))").msg, ); } @@ -68,10 +68,10 @@ fn test_convert_to_cell_value() { fn test_convert_to_array() { let mut g = Grid::new(); - assert_eq!("{3}", eval_to_string(&mut g, "_TEST_ARRAY(3)")); - assert_eq!("{}", eval_to_string(&mut g, "_TEST_ARRAY(A1)")); - assert_eq!("{3}", eval_to_string(&mut g, "_TEST_ARRAY({3})")); - assert_eq!("{}", eval_to_string(&mut g, "_TEST_ARRAY(A1:A1)")); + assert_eq!("{3}", eval_to_string(&g, "_TEST_ARRAY(3)")); + assert_eq!("{}", eval_to_string(&g, "_TEST_ARRAY(A1)")); + assert_eq!("{3}", eval_to_string(&g, "_TEST_ARRAY({3})")); + assert_eq!("{}", eval_to_string(&g, "_TEST_ARRAY(A1:A1)")); g.sheets_mut()[0].set_cell_value(pos![A2], 0); g.sheets_mut()[0].set_cell_value(pos![A3], 1); g.sheets_mut()[0].set_cell_value(pos![A4], -5); @@ -80,14 +80,14 @@ fn test_convert_to_array() { // unambiguous representation, so it's fine that the string is unquoted. assert_eq!( "{; 0; 1; -5; hello; ; ; ; ; }", - eval_to_string(&mut g, "_TEST_ARRAY(A1:A10)"), + eval_to_string(&g, "_TEST_ARRAY(A1:A10)"), ); assert_eq!( RunErrorMsg::Expected { expected: "array".into(), got: Some("tuple".into()) }, - eval_to_err(&mut g, "_TEST_ARRAY((A1:A10, C1:C10))").msg, + eval_to_err(&g, "_TEST_ARRAY((A1:A10, C1:C10))").msg, ); } @@ -95,16 +95,16 @@ fn test_convert_to_array() { fn test_convert_to_tuple() { let mut g = Grid::new(); - assert_eq!("1", eval_to_string(&mut g, "_TEST_TUPLE(3)")); - assert_eq!("1", eval_to_string(&mut g, "_TEST_TUPLE(A1)")); - assert_eq!("1", eval_to_string(&mut g, "_TEST_TUPLE({3})")); - assert_eq!("1", eval_to_string(&mut g, "_TEST_TUPLE(A1:A1)")); + assert_eq!("1", eval_to_string(&g, "_TEST_TUPLE(3)")); + assert_eq!("1", eval_to_string(&g, "_TEST_TUPLE(A1)")); + assert_eq!("1", eval_to_string(&g, "_TEST_TUPLE({3})")); + assert_eq!("1", eval_to_string(&g, "_TEST_TUPLE(A1:A1)")); g.sheets_mut()[0].set_cell_value(pos![A2], 0); g.sheets_mut()[0].set_cell_value(pos![A3], 1); g.sheets_mut()[0].set_cell_value(pos![A4], -5); g.sheets_mut()[0].set_cell_value(pos![A5], "hello"); // This string format is used for testing and potentially display; not as an // unambiguous representation, so it's fine that the string is unquoted. - assert_eq!("1", eval_to_string(&mut g, "_TEST_TUPLE(A1:A10)"),); - assert_eq!("2", eval_to_string(&mut g, "_TEST_TUPLE((A1:A10, C1:C10))"),); + assert_eq!("1", eval_to_string(&g, "_TEST_TUPLE(A1:A10)")); + assert_eq!("2", eval_to_string(&g, "_TEST_TUPLE((A1:A10, C1:C10))")); } diff --git a/quadratic-core/src/formulas/mod.rs b/quadratic-core/src/formulas/mod.rs index 08f0b93cdb..e86fbb5304 100644 --- a/quadratic-core/src/formulas/mod.rs +++ b/quadratic-core/src/formulas/mod.rs @@ -1,7 +1,3 @@ -#[cfg(test)] -#[macro_use] -pub(crate) mod tests; - pub mod ast; mod cell_ref; mod criteria; @@ -14,6 +10,9 @@ mod params; mod parser; mod wildcards; +#[cfg(test)] +pub mod tests; + use ast::AstNode; pub use ast::Formula; pub use cell_ref::*; diff --git a/quadratic-core/src/formulas/parser/mod.rs b/quadratic-core/src/formulas/parser/mod.rs index 8c56fccda0..0f2ebb187a 100644 --- a/quadratic-core/src/formulas/parser/mod.rs +++ b/quadratic-core/src/formulas/parser/mod.rs @@ -12,7 +12,7 @@ use lexer::Token; use rules::SyntaxRule; use super::*; -use crate::{grid::Grid, CodeResult, Pos, RunError, RunErrorMsg, Span, Spanned}; +use crate::{grid::Grid, CodeResult, CoerceInto, Pos, RunError, RunErrorMsg, Span, Spanned}; pub fn parse_formula(source: &str, pos: Pos) -> CodeResult { Ok(Formula { @@ -56,7 +56,7 @@ pub fn parse_and_check_formula(formula_string: &str, x: i64, y: i64) -> bool { Ok(parsed) => { let grid = Grid::new(); let mut ctx = Ctx::new_for_syntax_check(&grid); - parsed.eval(&mut ctx).is_ok() + parsed.eval(&mut ctx).into_non_error_value().is_ok() } Err(_) => false, } diff --git a/quadratic-core/src/formulas/tests.rs b/quadratic-core/src/formulas/tests.rs index 5aafe17efe..af503b8f1a 100644 --- a/quadratic-core/src/formulas/tests.rs +++ b/quadratic-core/src/formulas/tests.rs @@ -4,43 +4,44 @@ pub(crate) use super::*; pub(crate) use crate::grid::Grid; pub(crate) use crate::values::*; pub(crate) use crate::{array, CodeResult, RunError, RunErrorMsg, Spanned}; -use crate::{Pos, SheetPos}; +use crate::{CoerceInto, Pos, SheetPos}; use serial_test::parallel; -pub(crate) fn try_eval_at(grid: &Grid, pos: SheetPos, s: &str) -> CodeResult { - let mut ctx = Ctx::new(grid, pos); - parse_formula(s, Pos::ORIGIN)?.eval(&mut ctx) -} - -#[track_caller] -pub(crate) fn eval_at(grid: &Grid, sheet_pos: SheetPos, s: &str) -> Value { - try_eval_at(grid, sheet_pos, s).expect("error evaluating formula") -} -#[track_caller] -pub(crate) fn eval_to_string_at(grid: &Grid, sheet_pos: SheetPos, s: &str) -> String { - eval_at(grid, sheet_pos, s).to_string() -} - -pub(crate) fn try_eval(grid: &Grid, s: &str) -> CodeResult { - try_eval_at(grid, Pos::ORIGIN.to_sheet_pos(grid.sheets()[0].id), s) -} #[track_caller] pub(crate) fn try_check_syntax(grid: &Grid, s: &str) -> CodeResult<()> { println!("Checking syntax of formula {s:?}"); let mut ctx = Ctx::new_for_syntax_check(grid); - parse_formula(s, Pos::ORIGIN)?.eval(&mut ctx).map(|_| ()) + parse_formula(s, Pos::ORIGIN)? + .eval(&mut ctx) + .into_non_error_value() + .map(|_| ()) +} + +#[track_caller] +pub(crate) fn eval_at(grid: &Grid, pos: SheetPos, s: &str) -> Value { + println!("Evaluating formula {s:?}"); + let mut ctx = Ctx::new(grid, pos); + match parse_formula(s, Pos::ORIGIN) { + Ok(formula) => formula.eval(&mut ctx).inner, + Err(e) => e.into(), + } } #[track_caller] pub(crate) fn eval(grid: &Grid, s: &str) -> Value { - try_eval(grid, s).expect("error evaluating formula") + eval_at(grid, Pos::ORIGIN.to_sheet_pos(grid.sheets()[0].id), s) +} +#[track_caller] +pub(crate) fn eval_to_string_at(grid: &Grid, sheet_pos: SheetPos, s: &str) -> String { + eval_at(grid, sheet_pos, s).to_string() } #[track_caller] pub(crate) fn eval_to_string(grid: &Grid, s: &str) -> String { eval(grid, s).to_string() } + #[track_caller] pub(crate) fn eval_to_err(grid: &Grid, s: &str) -> RunError { - try_eval(grid, s).expect_err("expected error") + eval(grid, s).unwrap_err() } #[track_caller] @@ -84,10 +85,7 @@ fn test_formula_cell_ref() { // Evaluate at B2 let mut ctx = Ctx::new(&g, pos![B2].to_sheet_pos(sheet_id)); - assert_eq!( - "11111".to_string(), - form.eval(&mut ctx).unwrap().to_string(), - ); + assert_eq!("11111".to_string(), form.eval(&mut ctx).to_string(),); } #[test] @@ -97,10 +95,12 @@ fn test_formula_circular_array_ref() { let g = Grid::new(); let mut ctx = Ctx::new(&g, pos![B2].to_sheet_pos(g.sheets()[0].id)); - assert_eq!( RunErrorMsg::CircularReference, - form.eval(&mut ctx).unwrap_err().msg, + form.eval(&mut ctx).inner.cell_values_slice().unwrap()[4] + .clone() + .unwrap_err() + .msg, ); } @@ -423,3 +423,15 @@ fn test_currency_string() { let g = Grid::new(); assert_eq!("30", eval_to_string(&g, "\"$10\" + 20")); } + +#[test] +fn test_syntax_check_ok() { + let g = Grid::new(); + assert_check_syntax_succeeds(&g, "1+1"); + assert_check_syntax_succeeds(&g, "1/0"); + assert_check_syntax_succeeds(&g, "SUM(1, 2, 3)"); + assert_check_syntax_succeeds(&g, "{1, 2, 3}"); + assert_check_syntax_succeeds(&g, "{1, 2; 3, 4}"); + assert_check_syntax_succeeds(&g, "XLOOKUP(\"zebra\", A1:Z1, A4:Z6)"); + assert_check_syntax_succeeds(&g, "ABS(({1, 2; 3, 4}, A1:C10))"); +} diff --git a/quadratic-core/src/grid/code_run.rs b/quadratic-core/src/grid/code_run.rs index 5b629dff28..3beab63241 100644 --- a/quadratic-core/src/grid/code_run.rs +++ b/quadratic-core/src/grid/code_run.rs @@ -4,7 +4,7 @@ //! any given CellValue::Code type (ie, if it doesn't exist then a run hasn't been //! performed yet). -use crate::{ArraySize, CellValue, Pos, Rect, RunError, SheetPos, SheetRect, Value}; +use crate::{ArraySize, CellValue, Pos, Rect, RunError, RunErrorMsg, SheetPos, SheetRect, Value}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -48,6 +48,26 @@ impl CodeRun { } } + /// Returns the cell value at a relative location (0-indexed) into the code + /// run output, for use when a formula references a cell. + pub fn get_cell_for_formula(&self, x: u32, y: u32) -> CellValue { + if self.spill_error { + CellValue::Blank + } else { + match &self.result { + CodeRunResult::Ok(value) => match value { + Value::Single(v) => v.clone(), + Value::Array(a) => a.get(x, y).cloned().unwrap_or(CellValue::Blank), + Value::Tuple(_) => CellValue::Error(Box::new( + RunErrorMsg::InternalError("tuple saved as code run result".into()) + .without_span(), + )), // should never happen + }, + CodeRunResult::Err(e) => CellValue::Error(Box::new(e.clone())), + } + } + } + /// Returns the size of the output array, or defaults to `_1X1` (since output always includes the code_cell). /// Note: this does not take spill_error into account. pub fn output_size(&self) -> ArraySize { diff --git a/quadratic-core/src/grid/file/v1_5/run_error.rs b/quadratic-core/src/grid/file/v1_5/run_error.rs index 2462b23d3c..8312d44f4d 100644 --- a/quadratic-core/src/grid/file/v1_5/run_error.rs +++ b/quadratic-core/src/grid/file/v1_5/run_error.rs @@ -45,6 +45,7 @@ pub enum RunErrorMsg { BadFunctionName, BadCellReference, BadNumber, + NaN, // Array size errors ExactArraySizeMismatch { @@ -117,6 +118,7 @@ impl RunError { crate::RunErrorMsg::BadFunctionName => RunErrorMsg::BadFunctionName, crate::RunErrorMsg::BadCellReference => RunErrorMsg::BadCellReference, crate::RunErrorMsg::BadNumber => RunErrorMsg::BadNumber, + crate::RunErrorMsg::NaN => RunErrorMsg::NaN, // Array size errors crate::RunErrorMsg::ExactArraySizeMismatch { expected, got } => { @@ -211,6 +213,7 @@ impl From for crate::RunError { RunErrorMsg::BadFunctionName => crate::RunErrorMsg::BadFunctionName, RunErrorMsg::BadCellReference => crate::RunErrorMsg::BadCellReference, RunErrorMsg::BadNumber => crate::RunErrorMsg::BadNumber, + RunErrorMsg::NaN => crate::RunErrorMsg::NaN, // Array size errors RunErrorMsg::ExactArraySizeMismatch { expected, got } => { diff --git a/quadratic-core/src/grid/sheet.rs b/quadratic-core/src/grid/sheet.rs index b848f73779..899c8d44b6 100644 --- a/quadratic-core/src/grid/sheet.rs +++ b/quadratic-core/src/grid/sheet.rs @@ -238,6 +238,22 @@ impl Sheet { column.values.get(&pos.y) } + /// Returns the cell value at a position using both `column.values` and + /// `code_runs`, for use when a formula references a cell. + pub fn get_cell_for_formula(&self, pos: Pos) -> CellValue { + match self + .get_column(pos.x) + .and_then(|column| column.values.get(&pos.y)) + .unwrap_or(&CellValue::Blank) + { + CellValue::Blank | CellValue::Code(_) => match self.code_runs.get(&pos) { + Some(run) => run.get_cell_for_formula(0, 0), + None => CellValue::Blank, + }, + other => other.clone(), + } + } + /// Returns a formatting property of a cell. pub fn get_formatting_value(&self, pos: Pos) -> Option { let column = self.get_column(pos.x)?; diff --git a/quadratic-core/src/values/array.rs b/quadratic-core/src/values/array.rs index cfd6b180b3..f4d609da72 100644 --- a/quadratic-core/src/values/array.rs +++ b/quadratic-core/src/values/array.rs @@ -9,7 +9,8 @@ use smallvec::{smallvec, SmallVec}; use super::{ArraySize, Axis, CellValue, Spanned, Value}; use crate::{ - controller::operations::operation::Operation, grid::Sheet, CodeResult, Pos, RunErrorMsg, + controller::operations::operation::Operation, grid::Sheet, CodeResult, Pos, RunError, + RunErrorMsg, }; #[macro_export] @@ -283,6 +284,19 @@ impl Array { Ok(NonZeroU32::new(common_len).expect("bad array size")) } + /// Returns the first error in the array if there is one. + pub fn first_error(&self) -> Option<&RunError> { + self.values.iter().find_map(|v| v.error()) + } + /// Returns the first error in the array if there is one; otherwise returns + /// the original array. + pub fn into_non_error_array(self) -> CodeResult { + match self.first_error() { + Some(e) => Err(e.clone()), + None => Ok(self), + } + } + pub fn from_string_list( start: Pos, sheet: &mut Sheet, diff --git a/quadratic-core/src/values/cellvalue.rs b/quadratic-core/src/values/cellvalue.rs index 5e23568487..759e79ec16 100644 --- a/quadratic-core/src/values/cellvalue.rs +++ b/quadratic-core/src/values/cellvalue.rs @@ -323,6 +323,13 @@ impl CellValue { _ => None, } } + /// Converts an error value into an actual error. + pub fn into_non_error_value(self) -> CodeResult { + match self { + CellValue::Error(e) => Err(*e), + other => Ok(other), + } + } /// Coerces the value to a specific type; returns `None` if the conversion /// fails or the original value is `None`. @@ -537,6 +544,16 @@ impl CellValue { pub fn is_image(&self) -> bool { matches!(self, CellValue::Image(_)) } + + /// Returns the contained error, or panics the value is not an error. + #[cfg(test)] + #[track_caller] + pub fn unwrap_err(self) -> RunError { + match self { + CellValue::Error(e) => *e, + other => panic!("expected error value; got {other:?}"), + } + } } #[cfg(test)] diff --git a/quadratic-core/src/values/convert.rs b/quadratic-core/src/values/convert.rs index f0be56490f..3ca121c873 100644 --- a/quadratic-core/src/values/convert.rs +++ b/quadratic-core/src/values/convert.rs @@ -1,4 +1,5 @@ use bigdecimal::{BigDecimal, ToPrimitive, Zero}; +use itertools::Itertools; use super::{CellValue, IsBlank, Value}; use crate::{CodeResult, CodeResultExt, RunErrorMsg, Span, Spanned, Unspan}; @@ -35,11 +36,13 @@ impl From<&str> for CellValue { CellValue::Text(value.to_string()) } } -// todo: this might be wrong for formulas impl From for CellValue { fn from(value: f64) -> Self { - BigDecimal::try_from(value) - .map_or_else(|_| CellValue::Text(value.to_string()), CellValue::Number) + match BigDecimal::try_from(value) { + Ok(n) => CellValue::Number(n), + // TODO: add span information + Err(_) => CellValue::Error(Box::new(RunErrorMsg::NaN.without_span())), + } } } impl From for CellValue { @@ -57,6 +60,11 @@ impl From for CellValue { CellValue::Number(BigDecimal::from(value)) } } +impl From for CellValue { + fn from(value: usize) -> Self { + CellValue::Number(BigDecimal::from(value as u64)) + } +} impl From for CellValue { fn from(value: bool) -> Self { CellValue::Logical(value) @@ -236,6 +244,11 @@ where for<'a> &'a Self: Into, Self::Unspanned: IsBlank, { + /// Returns an error if the value contains **any** errors; otherwise, + /// returns the value unchanged. + /// + /// Errors should be preserved whenever possible, so do not call this for + /// intermediate results. fn into_non_error_value(self) -> CodeResult; /// Coerces a value, returning an error if the value has the wrong type. @@ -244,9 +257,7 @@ where Self::Unspanned: TryInto, { let span = (&self).into(); - - // If coercion fails, return an error. - self.into_non_error_value()?.try_into().with_span(span) + self.without_span().try_into().with_span(span) } /// Coerces a value, returning `None` if the value has the wrong type and @@ -285,17 +296,27 @@ impl CoerceInto for Spanned { impl<'a> CoerceInto for Spanned<&'a Value> { fn into_non_error_value(self) -> CodeResult<&'a Value> { - match &self.inner { - Value::Single(CellValue::Error(e)) => Err((**e).clone()), - other => Ok(other), + let error = match self.inner { + Value::Single(v) => v.error(), + Value::Array(a) => a.first_error(), + Value::Tuple(t) => t.iter().find_map(|a| a.first_error()), + }; + match error { + Some(e) => Err(e.clone()), + None => Ok(self.inner), } } } impl CoerceInto for Spanned { fn into_non_error_value(self) -> CodeResult { match self.inner { - Value::Single(CellValue::Error(e)) => Err(*e), - other => Ok(other), + Value::Single(v) => v.into_non_error_value().map(Value::Single), + Value::Array(a) => a.into_non_error_array().map(Value::Array), + Value::Tuple(t) => t + .into_iter() + .map(|a| a.into_non_error_array()) + .try_collect() + .map(Value::Tuple), } } } diff --git a/quadratic-core/src/values/isblank.rs b/quadratic-core/src/values/isblank.rs index 887ca67a86..3bea10b2a5 100644 --- a/quadratic-core/src/values/isblank.rs +++ b/quadratic-core/src/values/isblank.rs @@ -62,10 +62,10 @@ mod tests { assert!(!Value::from(0).is_blank()); assert!(!Value::from(1).is_blank()); - assert!((&Value::Single(CellValue::Blank)).is_blank()); - assert!(!(&Value::from("")).is_blank()); - assert!(!(&Value::from(0)).is_blank()); - assert!(!(&Value::from(1)).is_blank()); + assert!((Value::Single(CellValue::Blank)).is_blank()); + assert!(!(Value::from("")).is_blank()); + assert!(!(Value::from(0)).is_blank()); + assert!(!(Value::from(1)).is_blank()); let a = Array::from_random_floats(crate::ArraySize::new(1, 2).unwrap()); assert!(!Value::Array(a.clone()).is_blank()); diff --git a/quadratic-core/src/values/mod.rs b/quadratic-core/src/values/mod.rs index 4d0f912c49..1ee8bc53ec 100644 --- a/quadratic-core/src/values/mod.rs +++ b/quadratic-core/src/values/mod.rs @@ -22,7 +22,7 @@ pub use convert::CoerceInto; pub use isblank::IsBlank; pub use time::{Duration, Instant}; -use crate::{CodeResult, CodeResultExt, RunErrorMsg, SpannableIterExt, Spanned}; +use crate::{CodeResult, CodeResultExt, RunError, RunErrorMsg, SpannableIterExt, Spanned}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(untagged)] @@ -56,6 +56,11 @@ impl From for Value { Value::Array(array) } } +impl From for Value { + fn from(value: RunError) -> Self { + Value::Single(CellValue::Error(Box::new(value))) + } +} impl Value { /// Returns the cell value for a single value or an array. Returns an error @@ -130,7 +135,7 @@ impl Value { Value::Tuple(arrays) => match arrays.first() { Some(a) => a.get(x, y), None => Err(RunErrorMsg::Expected { - expected: "value or array".into(), + expected: "single value or array".into(), got: Some("empty tuple".into()), }), }, @@ -161,6 +166,17 @@ impl Value { h: Array::common_len(Axis::Y, values.into_iter().filter_map(|v| v.as_array()))?, }) } + + /// Returns the contained error, or panics the value is not just a single + /// error. + #[cfg(test)] + #[track_caller] + pub fn unwrap_err(self) -> crate::RunError { + match self { + Value::Single(v) => v.unwrap_err(), + other => panic!("expected error value; got {other:?}"), + } + } } impl Spanned { pub fn cell_value(&self) -> CodeResult> { @@ -253,11 +269,38 @@ impl Spanned { .map(|inner| Spanned { span, inner }) }) } + + /// Returns the value if is an array or single value, or an error value if + /// it is a tuple. + pub fn into_non_tuple(self) -> Self { + let span = self.span; + self.map(|v| { + if matches!(v, Value::Tuple(_)) { + RunErrorMsg::Expected { + expected: "single value or array".into(), + got: Some("tuple".into()), + } + .with_span(span) + .into() + } else { + v + } + }) + } + + /// Returns the contained error, or panics the value is not just a single + /// error. + #[cfg(test)] + #[track_caller] + pub fn unwrap_err(self) -> crate::RunError { + self.inner.unwrap_err() + } } #[cfg(test)] mod tests { use crate::formulas::tests::*; + use crate::Span; #[test] fn test_value_repr() { @@ -266,4 +309,43 @@ mod tests { assert_eq!(s, eval(&g, s).repr()); } } + + #[test] + fn test_value_into_non_tuple() { + let span = Span { start: 10, end: 20 }; + + // Test a bunch of things that shouldn't error. + for v in [ + Value::Single("a".into()), + Value::Single(1.into()), + Value::Single(CellValue::Blank), + Value::Array(Array::new_empty(ArraySize::_1X1)), + Value::Array(Array::new_empty(ArraySize::new(5, 4).unwrap())), + ] { + let v = Spanned { span, inner: v }; + assert_eq!(v.clone(), v.into_non_tuple()); + } + + // Test with tuples that should error. + for v in [ + Value::Tuple(vec![ + Array::new_empty(ArraySize::new(5, 4).unwrap()), + Array::new_empty(ArraySize::_1X1), + ]), + Value::Tuple(vec![Array::new_empty(ArraySize::_1X1)]), + Value::Tuple(vec![Array::new_empty(ArraySize::new(5, 4).unwrap())]), + ] { + let v = Spanned { span, inner: v }; + assert_eq!( + Value::Single(CellValue::Error(Box::new( + RunErrorMsg::Expected { + expected: "single value or array".into(), + got: Some("tuple".into()), + } + .with_span(span) + ))), + v.into_non_tuple().inner, + ); + } + } } diff --git a/quadratic-core/src/wasm_bindings/controller/import.rs b/quadratic-core/src/wasm_bindings/controller/import.rs index 38ffd4dc04..d41f839266 100644 --- a/quadratic-core/src/wasm_bindings/controller/import.rs +++ b/quadratic-core/src/wasm_bindings/controller/import.rs @@ -34,13 +34,29 @@ impl GridController { let grid = Grid::new_blank(); let mut grid_controller = GridController::from_grid(grid, 0); grid_controller - .import_excel(file, file_name) + .import_excel(file, file_name, None) .map_err(|e| e.to_string())?; Ok(grid_controller) } } +#[wasm_bindgen] +impl GridController { + #[wasm_bindgen(js_name = "importExcelIntoExistingFile")] + pub fn js_import_excel_into_existing_file( + &mut self, + file: Vec, + file_name: &str, + cursor: Option, + ) -> Result<(), JsValue> { + self.import_excel(file, file_name, cursor) + .map_err(|e| e.to_string())?; + + Ok(()) + } +} + #[wasm_bindgen] impl GridController { #[wasm_bindgen(js_name = "importParquet")]