diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b14ff13..6f150fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: - node-version: [18, 20, 22] + node-version: [20, 22] steps: - name: Checkout repository @@ -126,32 +126,3 @@ jobs: - name: Run tests with WASM run: npm test - - # # Publish to npm on release (manual trigger or tag) - # publish: - # name: Publish to npm - # runs-on: ubuntu-latest - # needs: [test, build] - # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - - # steps: - # - name: Checkout repository - # uses: actions/checkout@v4 - - # - name: Setup Node.js - # uses: actions/setup-node@v4 - # with: - # node-version: 20 - # cache: 'npm' - # registry-url: 'https://registry.npmjs.org' - - # - name: Install dependencies - # run: npm ci - - # - name: Build package - # run: npm run build - - # - name: Publish to npm - # run: npm publish --access public - # env: - # NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0822cde --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,57 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + +jobs: + publish: + name: Publish to npm + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + + - name: Build package + run: npm run build + + - name: Verify build output + run: | + test -f dist/esm/index.js + test -f dist/cjs/index.js + test -f dist/types/index.d.ts + + - name: Publish to npm + run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true + body: | + ## Installation + + ```bash + npm install cellify@${{ github.ref_name }} + ``` + + See [CHANGELOG.md](https://github.com/abdullahmujahidali/Cellify/blob/main/CHANGELOG.md) for details. + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9a4c724 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,118 @@ +# Changelog + +All notable changes to Cellify will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **Event System** + - `sheet.on()` and `sheet.off()` for subscribing to sheet events + - `cellChange` event for value/formula changes + - `cellStyleChange` event for style changes + - `cellAdded` event when new cells are created + - `cellDeleted` event when cells are deleted + - Wildcard `'*'` listener for all events + - `sheet.setEventsEnabled()` to disable events during bulk operations + +- **Change Tracking** + - `sheet.getChanges()` returns all changes since last commit + - `sheet.commitChanges()` clears the change buffer + - `sheet.changeCount` property for pending change count + - Each change has unique ID, type, address, old/new values, and timestamp + +- **Undo/Redo** + - `sheet.undo()` and `sheet.redo()` for reversing changes + - `sheet.canUndo` and `sheet.canRedo` to check availability + - `sheet.undoCount` and `sheet.redoCount` for history size + - `sheet.batch(() => {...})` to group changes as single undo step + - `sheet.clearHistory()` to clear undo/redo stacks + - `sheet.setMaxUndoHistory(n)` to limit history size (default: 100) + +- **Sorting** + - `sheet.sort(column, options)` for single column sorting + - `sheet.sortBy(columns, options)` for multi-column sorting + - Ascending/descending order support + - Header row preservation with `hasHeader` option + - Numeric sorting for string numbers with `numeric` option + - Case-insensitive sorting by default + - Date values sorted correctly + - Null values sorted to end + - Preserves cell styles, formulas, and comments when sorting + - Range-specific sorting with `range` option + +- **Filtering** + - `sheet.filter(column, criteria)` for single column filtering + - `sheet.filterBy(filters)` for multi-column filtering + - `sheet.clearFilter()` to remove all filters + - `sheet.clearColumnFilter(column)` to remove filter on specific column + - Criteria options: `equals`, `notEquals`, `contains`, `startsWith`, `endsWith` + - Numeric criteria: `greaterThan`, `lessThan`, `between`, `notBetween` + - Value list criteria: `in`, `notIn` + - Empty checks: `isEmpty`, `isNotEmpty` + - Custom filter function support + - Case-insensitive string matching + - `sheet.isRowFiltered(row)` to check if row is hidden by filter + - `sheet.activeFilters` to get current filter configuration + - `sheet.filteredRows` to get set of filtered row indices + +## [0.1.0] - 2025-12-21 + +### Added + +- **Core Features** + - `Workbook` class for managing spreadsheet documents + - `Sheet` class with cell management, row/column configuration + - `Cell` class with values, formulas, styles, comments, hyperlinks, and validation + +- **Excel Support** + - XLSX import with `xlsxToWorkbook()` and `xlsxBlobToWorkbook()` + - XLSX export with `workbookToXlsx()` and `workbookToXlsxBlob()` + - Shared strings and style registry for optimized file size + - Optional WASM acceleration for large files + +- **CSV Support** + - CSV import with `csvToWorkbook()`, `csvToSheet()`, `csvBufferToWorkbook()` + - CSV export with `sheetToCsv()`, `sheetToCsvBuffer()`, `sheetsToCsv()` + - Automatic delimiter detection (comma, semicolon, tab, pipe) + - Smart type detection (numbers, dates, booleans, percentages, currency) + - RFC 4180 compliant parsing and writing + +- **Styling** + - Font styling (bold, italic, underline, color, size, family) + - Fill patterns and colors + - Border styles (thin, medium, thick, double, dashed, dotted) + - Cell alignment (horizontal, vertical, text wrap, rotation) + - Number formats + +- **Cell Features** + - Formula support (storage and cached results) + - Cell comments with author + - Hyperlinks with tooltips + - Data validation (whole, decimal, list, date, time, textLength, custom) + - Merged cells + +- **Sheet Features** + - Row height and column width configuration + - Hidden rows and columns + - Frozen panes + - Auto-filter + - Sheet protection + - Named ranges + +- **Accessibility** + - ARIA attribute helpers + - Screen reader announcements + - Keyboard navigation support + +- **Developer Experience** + - Full TypeScript support with comprehensive types + - ESM and CommonJS builds + - Zero dependencies for core (only fflate for compression) + - Works in Node.js, Bun, Deno, and browsers + +[Unreleased]: https://github.com/abdullahmujahidali/Cellify/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/abdullahmujahidali/Cellify/releases/tag/v0.1.0 diff --git a/docs-site/docs/guides/events.md b/docs-site/docs/guides/events.md new file mode 100644 index 0000000..28cc478 --- /dev/null +++ b/docs-site/docs/guides/events.md @@ -0,0 +1,309 @@ +--- +sidebar_position: 10 +--- + +# Events, Change Tracking & Undo/Redo + +Cellify provides an event system for tracking changes to sheets. This enables real-time sync, undo/redo, and integration with collaboration libraries like Yjs or Liveblocks. + +## Subscribing to Events + +Use `sheet.on()` to listen for changes: + +```typescript +import { Workbook } from 'cellify'; + +const workbook = new Workbook(); +const sheet = workbook.addSheet('Data'); + +// Listen for cell value changes +sheet.on('cellChange', (event) => { + console.log(`Cell ${event.address} changed`); + console.log(` Old value: ${event.oldValue}`); + console.log(` New value: ${event.newValue}`); +}); + +// Listen for style changes +sheet.on('cellStyleChange', (event) => { + console.log(`Cell ${event.address} style changed`); +}); + +// Listen for new cells +sheet.on('cellAdded', (event) => { + console.log(`New cell created at ${event.address}`); +}); + +// Listen for deleted cells +sheet.on('cellDeleted', (event) => { + console.log(`Cell ${event.address} deleted, had value: ${event.value}`); +}); +``` + +## Wildcard Listener + +Use `'*'` to listen to all events: + +```typescript +sheet.on('*', (event) => { + console.log(`Event: ${event.type} on ${event.address}`); +}); +``` + +## Unsubscribing + +Use `sheet.off()` to remove a listener: + +```typescript +const handler = (event) => console.log(event); + +sheet.on('cellChange', handler); +// ... later +sheet.off('cellChange', handler); +``` + +## Change Tracking + +Cellify tracks all changes for sync purposes: + +```typescript +// Make changes +sheet.cell('A1').value = 'Hello'; +sheet.cell('B1').value = 'World'; +sheet.cell('A1').style = { font: { bold: true } }; + +// Get all changes since last commit +const changes = sheet.getChanges(); +console.log(`${changes.length} changes pending`); + +// Each change has: +// - id: Unique identifier +// - type: 'value' | 'style' | 'formula' | 'delete' +// - address: Cell address (A1 notation) +// - row, col: Cell coordinates +// - oldValue, newValue: For value changes +// - oldStyle, newStyle: For style changes +// - timestamp: When the change occurred + +// Sync to server +await fetch('/api/sync', { + method: 'POST', + body: JSON.stringify({ changes }), +}); + +// Clear change buffer after successful sync +sheet.commitChanges(); +``` + +## Disabling Events + +For bulk operations, disable events to improve performance: + +```typescript +sheet.setEventsEnabled(false); + +// Bulk import - no events fired +for (let i = 0; i < 10000; i++) { + sheet.cell(i, 0).value = i; +} + +sheet.setEventsEnabled(true); +``` + +Check event state with `sheet.eventsEnabled`. + +**Important:** When events are disabled, changes are also **not tracked in the undo history**. If you need undo support for bulk operations, use `sheet.batch()` instead: + +```typescript +// This preserves undo capability as a single undo step +sheet.batch(() => { + for (let i = 0; i < 10000; i++) { + sheet.cell(i, 0).value = i; + } +}); +``` + +## Undo/Redo + +Cellify provides built-in undo/redo functionality: + +```typescript +import { Workbook } from 'cellify'; + +const workbook = new Workbook(); +const sheet = workbook.addSheet('Data'); + +sheet.cell('A1').value = 'Hello'; +sheet.cell('A1').value = 'World'; + +// Undo last change +sheet.undo(); // A1 is now 'Hello' +sheet.undo(); // A1 is now null + +// Redo undone changes +sheet.redo(); // A1 is now 'Hello' +sheet.redo(); // A1 is now 'World' +``` + +### Checking Undo/Redo State + +```typescript +if (sheet.canUndo) { + sheet.undo(); +} + +if (sheet.canRedo) { + sheet.redo(); +} + +console.log(`${sheet.undoCount} undo steps available`); +console.log(`${sheet.redoCount} redo steps available`); +``` + +### Batch Operations + +Group multiple changes into a single undo step: + +```typescript +sheet.batch(() => { + sheet.cell('A1').value = 'Hello'; + sheet.cell('B1').value = 'World'; + sheet.cell('C1').value = '!'; +}); + +// Single undo reverts all three changes +sheet.undo(); +``` + +### Managing History + +```typescript +// Clear all undo/redo history +sheet.clearHistory(); + +// Limit history size (default: 100) +sheet.setMaxUndoHistory(50); +``` + +## Event Types + +### CellChangeEvent + +```typescript +interface CellChangeEvent { + type: 'cellChange'; + sheetName: string; + address: string; // A1 notation + row: number; + col: number; + oldValue: CellValue; + newValue: CellValue; + timestamp: number; +} +``` + +### CellStyleChangeEvent + +```typescript +interface CellStyleChangeEvent { + type: 'cellStyleChange'; + sheetName: string; + address: string; + row: number; + col: number; + oldStyle: CellStyle | undefined; + newStyle: CellStyle | undefined; + timestamp: number; +} +``` + +### CellAddedEvent + +```typescript +interface CellAddedEvent { + type: 'cellAdded'; + sheetName: string; + address: string; + row: number; + col: number; + timestamp: number; +} +``` + +### CellDeletedEvent + +```typescript +interface CellDeletedEvent { + type: 'cellDeleted'; + sheetName: string; + address: string; + row: number; + col: number; + value: CellValue; + timestamp: number; +} +``` + +## Integration with Yjs + +Example of syncing with [Yjs](https://yjs.dev/) for real-time collaboration: + +```typescript +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; +import { Workbook } from 'cellify'; + +const ydoc = new Y.Doc(); +const ymap = ydoc.getMap('spreadsheet'); +const provider = new WebsocketProvider('ws://localhost:1234', 'room', ydoc); + +const workbook = new Workbook(); +const sheet = workbook.addSheet('Data'); + +// Send local changes to Yjs +sheet.on('cellChange', (event) => { + ymap.set(event.address, { + value: event.newValue, + timestamp: event.timestamp, + }); +}); + +// Apply remote changes from Yjs +ymap.observe((event) => { + sheet.setEventsEnabled(false); // Prevent echo + event.changes.keys.forEach((change, key) => { + if (change.action === 'add' || change.action === 'update') { + const data = ymap.get(key); + sheet.cell(key).value = data.value; + } + }); + sheet.setEventsEnabled(true); +}); +``` + +## Integration with Liveblocks + +Example with [Liveblocks](https://liveblocks.io/): + +```typescript +import { createClient } from '@liveblocks/client'; +import { Workbook } from 'cellify'; + +const client = createClient({ publicApiKey: 'pk_...' }); +const { room } = client.enterRoom('spreadsheet-room'); +const storage = await room.getStorage(); + +const workbook = new Workbook(); +const sheet = workbook.addSheet('Data'); + +// Send local changes +sheet.on('cellChange', (event) => { + storage.root.set(event.address, event.newValue); +}); + +// Receive remote changes +room.subscribe(storage.root, () => { + sheet.setEventsEnabled(false); + // Apply updates... + sheet.setEventsEnabled(true); +}); +``` diff --git a/docs-site/docs/guides/sorting-filtering.md b/docs-site/docs/guides/sorting-filtering.md new file mode 100644 index 0000000..41b3355 --- /dev/null +++ b/docs-site/docs/guides/sorting-filtering.md @@ -0,0 +1,258 @@ +--- +sidebar_position: 11 +--- + +# Sorting & Filtering + +Cellify provides powerful sorting and filtering capabilities for organizing and viewing your spreadsheet data. + +## Sorting + +### Single Column Sort + +Sort rows by the values in a single column: + +```typescript +import { Workbook } from 'cellify'; + +const workbook = new Workbook(); +const sheet = workbook.addSheet('Data'); + +// Sample data +sheet.setValues('A1', [ + ['Name', 'Age', 'City'], + ['Charlie', 30, 'NYC'], + ['Alice', 25, 'LA'], + ['Bob', 28, 'Chicago'], +]); + +// Sort by column A (Name) ascending +sheet.sort('A', { hasHeader: true }); + +// Sort by column B (Age) descending +sheet.sort('B', { hasHeader: true, descending: true }); +``` + +### Sort Options + +```typescript +sheet.sort('A', { + descending: false, // Sort direction (default: false = ascending) + hasHeader: true, // Preserve header row (default: false) + range: 'A1:C10', // Sort specific range only + numeric: true, // Sort string numbers numerically + caseSensitive: false, // Case-sensitive comparison (default: false) +}); +``` + +### Multi-Column Sort + +Sort by multiple columns with different options for each: + +```typescript +// Sort by Name, then by Age (descending) +sheet.sortBy([ + { column: 'A' }, // Primary sort + { column: 'B', descending: true }, // Secondary sort +], { hasHeader: true }); +``` + +### Sorting Preserves Data + +When sorting, Cellify preserves: +- Cell styles (bold, colors, etc.) +- Formulas +- Hyperlinks +- Comments + +```typescript +sheet.cell('A1').value = 'Important'; +sheet.cell('A1').style = { font: { bold: true } }; + +sheet.sort('A'); + +// Style is preserved after sorting +console.log(sheet.cell('A3').style?.font?.bold); // true (if sorted to row 3) +``` + +### Null Values + +Null/empty values are always sorted to the end, regardless of sort direction: + +```typescript +sheet.cell('A1').value = 'B'; +sheet.cell('A2').value = null; +sheet.cell('A3').value = 'A'; + +sheet.sort('A'); +// Result: A, B, null +``` + +## Filtering + +### Basic Filtering + +Filter rows to show only those matching specific criteria: + +```typescript +// Show only rows where Status equals 'Active' +sheet.filter('A', { equals: 'Active' }); + +// Show rows where Price is greater than 100 +sheet.filter('B', { greaterThan: 100 }); +``` + +### Filter Criteria + +#### Equality + +```typescript +sheet.filter('A', { equals: 'Active' }); +sheet.filter('A', { notEquals: 'Inactive' }); +``` + +#### String Operations + +All string operations are case-insensitive by default: + +```typescript +sheet.filter('A', { contains: 'test' }); +sheet.filter('A', { notContains: 'draft' }); +sheet.filter('A', { startsWith: 'Report' }); +sheet.filter('A', { endsWith: '.pdf' }); +``` + +#### Numeric Operations + +```typescript +sheet.filter('A', { greaterThan: 100 }); +sheet.filter('A', { greaterThanOrEqual: 100 }); +sheet.filter('A', { lessThan: 50 }); +sheet.filter('A', { lessThanOrEqual: 50 }); +sheet.filter('A', { between: [10, 100] }); +sheet.filter('A', { notBetween: [0, 10] }); +``` + +#### Value Lists + +```typescript +// Show rows where Status is 'Active' or 'Pending' +sheet.filter('A', { in: ['Active', 'Pending'] }); + +// Hide rows where Status is 'Deleted' or 'Archived' +sheet.filter('A', { notIn: ['Deleted', 'Archived'] }); +``` + +#### Empty Checks + +```typescript +sheet.filter('A', { isEmpty: true }); // Show only empty cells +sheet.filter('A', { isNotEmpty: true }); // Show only non-empty cells +``` + +#### Custom Filter Function + +For complex filtering logic, use a custom function: + +```typescript +// Show only even numbers +sheet.filter('A', { + custom: (value) => typeof value === 'number' && value % 2 === 0 +}); + +// Show dates in the current year +sheet.filter('A', { + custom: (value) => value instanceof Date && value.getFullYear() === 2024 +}); +``` + +### Multi-Column Filtering + +Filter by multiple columns (AND logic): + +```typescript +sheet.filterBy([ + { column: 'A', criteria: { equals: 'Active' } }, + { column: 'B', criteria: { greaterThan: 100 } }, +]); +// Shows rows where Status = 'Active' AND Price > 100 +``` + +### Filter with Header + +Preserve the header row when filtering: + +```typescript +sheet.filter('A', { equals: 'Active' }, { hasHeader: true }); +``` + +### Clearing Filters + +```typescript +// Clear all filters +sheet.clearFilter(); + +// Clear filter on specific column only +sheet.clearColumnFilter('A'); +``` + +### Checking Filter State + +```typescript +// Check if a specific row is filtered (hidden) +if (sheet.isRowFiltered(5)) { + console.log('Row 5 is hidden by filter'); +} + +// Get all filtered row indices +console.log(`${sheet.filteredRows.size} rows are hidden`); + +// Get active filter configuration +for (const [colIndex, criteria] of sheet.activeFilters) { + console.log(`Column ${colIndex} has filter:`, criteria); +} +``` + +## Combining Sort and Filter + +You can combine sorting and filtering for powerful data views: + +```typescript +const workbook = new Workbook(); +const sheet = workbook.addSheet('Sales'); + +// Load data +sheet.setValues('A1', [ + ['Product', 'Category', 'Sales'], + ['Widget A', 'Electronics', 500], + ['Widget B', 'Electronics', 300], + ['Gadget X', 'Accessories', 150], + ['Gadget Y', 'Electronics', 800], +]); + +// Filter to Electronics only +sheet.filter('B', { equals: 'Electronics' }, { hasHeader: true }); + +// Sort by Sales descending +sheet.sort('C', { hasHeader: true, descending: true }); + +// Now showing only Electronics, sorted by highest sales first +``` + +## Performance Tips + +1. **Apply filters before sorting** when possible to reduce the dataset size. + +2. **Use batch operations** for multiple changes: + ```typescript + sheet.filterBy([ + { column: 'A', criteria: { equals: 'Active' } }, + { column: 'B', criteria: { greaterThan: 100 } }, + ]); + ``` + +3. **Clear filters before applying new ones** if you want to start fresh: + ```typescript + sheet.clearFilter(); + sheet.filter('A', { equals: 'New Status' }); + ``` diff --git a/package.json b/package.json index a0d644c..17f97fc 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,13 @@ "test:coverage": "vitest run --coverage", "lint": "tsc --noEmit", "clean": "rm -rf dist", - "prepublishOnly": "npm run clean && npm run build:all && npm run test", + "prepublishOnly": "npm run clean && npm run build && npm run test", "demo": "vite demo", - "demo:build": "vite build --config demo/vite.config.js" + "demo:build": "vite build --config demo/vite.config.js", + "release": "./scripts/release.sh", + "release:patch": "./scripts/release.sh patch", + "release:minor": "./scripts/release.sh minor", + "release:major": "./scripts/release.sh major" }, "keywords": [ "excel", diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..68a95be --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,80 @@ +#!/bin/bash + +# Cellify Release Script +# Usage: ./scripts/release.sh [patch|minor|major] + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +VERSION_TYPE=${1:-patch} + +if [[ ! "$VERSION_TYPE" =~ ^(patch|minor|major)$ ]]; then + echo -e "${RED}Error: Invalid version type. Use 'patch', 'minor', or 'major'${NC}" + exit 1 +fi + +echo -e "${YELLOW}๐Ÿš€ Starting Cellify release process...${NC}" + +CURRENT_BRANCH=$(git branch --show-current) +if [ "$CURRENT_BRANCH" != "main" ]; then + echo -e "${RED}Error: Must be on 'main' branch to release. Currently on '$CURRENT_BRANCH'${NC}" + exit 1 +fi + +if [ -n "$(git status --porcelain)" ]; then + echo -e "${RED}Error: Working directory is not clean. Commit or stash changes first.${NC}" + exit 1 +fi + +echo -e "${GREEN}๐Ÿ“ฅ Pulling latest changes...${NC}" +git pull origin main + +echo -e "${GREEN}๐Ÿงช Running tests...${NC}" +npm test + +echo -e "${GREEN}๐Ÿ”จ Building...${NC}" +npm run build + +CURRENT_VERSION=$(node -p "require('./package.json').version") +echo -e "${GREEN}๐Ÿ“Œ Current version: ${CURRENT_VERSION}${NC}" + +echo -e "${GREEN}๐Ÿ“ฆ Bumping ${VERSION_TYPE} version...${NC}" +NEW_VERSION=$(npm version $VERSION_TYPE --no-git-tag-version) +NEW_VERSION=${NEW_VERSION#v} # Remove 'v' prefix if present + +echo -e "${GREEN}๐Ÿ“Œ New version: ${NEW_VERSION}${NC}" + +TODAY=$(date +%Y-%m-%d) +sed -i.bak "s/## \[Unreleased\]/## [Unreleased]\n\n## [${NEW_VERSION}] - ${TODAY}/" CHANGELOG.md + +sed -i.bak "s|\[Unreleased\]: \(.*\)/compare/v.*\.\.\.HEAD|[Unreleased]: \1/compare/v${NEW_VERSION}...HEAD|" CHANGELOG.md + +sed -i.bak "/^\[${CURRENT_VERSION}\]:/i\\ +[${NEW_VERSION}]: https://github.com/abdullahmujahidali/Cellify/compare/v${CURRENT_VERSION}...v${NEW_VERSION} +" CHANGELOG.md + +rm -f CHANGELOG.md.bak + +echo -e "${GREEN}๐Ÿ“ Committing changes...${NC}" +git add package.json CHANGELOG.md +git commit -m "chore: release v${NEW_VERSION}" + +echo -e "${GREEN}๐Ÿท๏ธ Creating tag v${NEW_VERSION}...${NC}" +git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}" + +echo -e "${GREEN}๐Ÿ“ค Pushing to remote...${NC}" +git push origin main +git push origin "v${NEW_VERSION}" + +echo -e "${GREEN}๐Ÿ“ฆ Publishing to npm...${NC}" +npm publish + +echo -e "${GREEN}โœ… Successfully released v${NEW_VERSION}!${NC}" +echo "" +echo -e "${YELLOW}Next steps:${NC}" +echo "1. Create GitHub release at: https://github.com/abdullahmujahidali/Cellify/releases/new?tag=v${NEW_VERSION}" +echo "2. Copy relevant CHANGELOG entries to the release notes" diff --git a/src/core/Cell.ts b/src/core/Cell.ts index 032f28c..21e9bea 100644 --- a/src/core/Cell.ts +++ b/src/core/Cell.ts @@ -12,6 +12,15 @@ import type { import type { CellStyle } from '../types/style.types.js'; import { getCellValueType, addressToA1 } from '../types/cell.types.js'; +/** + * Callback type for cell change notifications + */ +export type CellChangeCallback = ( + cell: Cell, + changeType: 'value' | 'style' | 'formula', + oldValue?: CellValue | CellStyle +) => void; + /** * Represents a single cell in a spreadsheet. * @@ -34,6 +43,12 @@ export class Cell { private _merge: MergeRange | undefined; private _mergedInto: CellAddress | undefined; + /** + * Optional callback for change notifications (set by Sheet) + * @internal + */ + _onChange: CellChangeCallback | undefined; + /** * The row index of this cell (0-based) */ @@ -74,11 +89,16 @@ export class Cell { * Set the cell's value */ set value(val: CellValue) { + const oldValue = this._value; this._value = val; // Clear formula when value is set directly if (this._formula) { this._formula = undefined; } + // Notify of change + if (this._onChange && oldValue !== val) { + this._onChange(this, 'value', oldValue); + } } /** @@ -104,11 +124,15 @@ export class Cell { * @param result - Optional cached result value from Excel */ setFormula(formulaText: string, result?: CellValue): this { + const oldValue = this._value; const text = formulaText.startsWith('=') ? formulaText.slice(1) : formulaText; this._formula = { formula: text, result: result, }; + if (this._onChange) { + this._onChange(this, 'formula', oldValue); + } return this; } @@ -131,18 +155,27 @@ export class Cell { * Set the cell's style (replaces existing style) */ set style(style: CellStyle | undefined) { + const oldStyle = this._style; this._style = style; + // Notify of change + if (this._onChange && oldStyle !== style) { + this._onChange(this, 'style', oldStyle); + } } /** * Apply partial style updates (merges with existing style) */ applyStyle(style: Partial): this { + const oldStyle = this._style; if (!this._style) { this._style = { ...style }; } else { this._style = this.mergeStyles(this._style, style); } + if (this._onChange) { + this._onChange(this, 'style', oldStyle); + } return this; } diff --git a/src/core/Sheet.ts b/src/core/Sheet.ts index f0b3171..8981b4d 100644 --- a/src/core/Sheet.ts +++ b/src/core/Sheet.ts @@ -9,8 +9,18 @@ import type { RangeDefinition, ConditionalFormatRule, AutoFilter, + FilterCriteria, } from '../types/range.types.js'; import { parseRangeReference, iterateRange, rangesOverlap } from '../types/range.types.js'; +import type { + SheetEventMap, + SheetEventHandler, + ChangeRecord, + CellChangeEvent, + CellStyleChangeEvent, + CellAddedEvent, + CellDeletedEvent, +} from '../types/event.types.js'; /** * Row configuration @@ -121,6 +131,18 @@ export class Sheet { private _minCol = Infinity; private _maxCol = -Infinity; + // Event system + private _eventListeners: Map> = new Map(); + private _changes: ChangeRecord[] = []; + private _changeIdCounter = 0; + private _eventsEnabled = true; + + // Undo/Redo system + private _undoStack: ChangeRecord[] = []; + private _redoStack: ChangeRecord[] = []; + private _maxUndoHistory = 100; + private _isUndoRedoOperation = false; + constructor(name: string) { this._name = name; } @@ -171,6 +193,10 @@ export class Sheet { cell = new Cell(row, column); this._cells.set(key, cell); this.updateDimensions(row, column); + // Set up change callback + cell._onChange = this.handleCellChange.bind(this); + // Emit cell added event + this.emitCellAdded(cell); } return cell; @@ -237,13 +263,16 @@ export class Sheet { } const key = cellKey(row, column); - const deleted = this._cells.delete(key); + const cell = this._cells.get(key); - if (deleted) { + if (cell) { + this.emitCellDeleted(cell); + this._cells.delete(key); this.recalculateDimensions(); + return true; } - return deleted; + return false; } /** @@ -692,6 +721,610 @@ export class Sheet { return this; } + // ============ Sorting ============ + + /** + * Sort rows by the values in a column + * + * @param column - Column index (0-based) or letter (e.g., 'A') to sort by + * @param options - Sort options + * @returns this for chaining + * + * @example + * ```typescript + * // Sort by column A ascending + * sheet.sort('A'); + * + * // Sort by column B descending + * sheet.sort('B', { descending: true }); + * + * // Sort with header row (don't move first row) + * sheet.sort('A', { hasHeader: true }); + * + * // Sort specific range + * sheet.sort('A', { range: 'A1:C10' }); + * ``` + */ + sort( + column: number | string, + options: { + descending?: boolean; + hasHeader?: boolean; + range?: string | RangeDefinition; + numeric?: boolean; + caseSensitive?: boolean; + } = {} + ): this { + const { + descending = false, + hasHeader = false, + range, + numeric = false, + caseSensitive = false, + } = options; + + // Convert column letter to index if needed + const colIndex = typeof column === 'string' + ? this.columnLetterToIndex(column) + : column; + + // Determine range to sort + let sortRange: RangeDefinition; + if (range) { + sortRange = typeof range === 'string' ? parseRangeReference(range) : range; + } else { + const dims = this.dimensions; + if (!dims) return this; + sortRange = dims; + } + + // Adjust for header row + const startRow = hasHeader ? sortRange.startRow + 1 : sortRange.startRow; + const endRow = sortRange.endRow; + + if (startRow > endRow) return this; + + // Collect rows with their data + const rows: { rowIndex: number; sortValue: CellValue; cells: Map }[] = []; + + for (let r = startRow; r <= endRow; r++) { + const sortCell = this.getCell(r, colIndex); + const sortValue = sortCell?.value ?? null; + + const cells = new Map(); + for (let c = sortRange.startCol; c <= sortRange.endCol; c++) { + const cell = this.getCell(r, c); + if (cell) { + cells.set(c, cell.clone()); + } + } + + rows.push({ rowIndex: r, sortValue, cells }); + } + + // Sort rows + rows.sort((a, b) => { + const aVal = a.sortValue; + const bVal = b.sortValue; + + let result = this.compareValues(aVal, bVal, numeric, caseSensitive); + return descending ? -result : result; + }); + + // Disable events during reordering + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + + try { + // Clear existing cells in range + for (let r = startRow; r <= endRow; r++) { + for (let c = sortRange.startCol; c <= sortRange.endCol; c++) { + const key = cellKey(r, c); + this._cells.delete(key); + } + } + + // Place sorted rows + for (let i = 0; i < rows.length; i++) { + const targetRow = startRow + i; + const { cells } = rows[i]; + + for (const [col, cell] of cells) { + const newCell = new Cell(targetRow, col, cell.value); + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); + + newCell._onChange = this.handleCellChange.bind(this); + this._cells.set(cellKey(targetRow, col), newCell); + } + } + + this.recalculateDimensions(); + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + /** + * Sort rows by multiple columns + * + * @param columns - Array of column sort specifications + * @param options - Sort options + * + * @example + * ```typescript + * // Sort by column A, then by column B descending + * sheet.sortBy([ + * { column: 'A' }, + * { column: 'B', descending: true } + * ]); + * ``` + */ + sortBy( + columns: Array<{ + column: number | string; + descending?: boolean; + numeric?: boolean; + }>, + options: { + hasHeader?: boolean; + range?: string | RangeDefinition; + caseSensitive?: boolean; + } = {} + ): this { + const { hasHeader = false, range, caseSensitive = false } = options; + + // Convert column letters to indices + const sortColumns = columns.map((c) => ({ + colIndex: typeof c.column === 'string' ? this.columnLetterToIndex(c.column) : c.column, + descending: c.descending ?? false, + numeric: c.numeric ?? false, + })); + + // Determine range to sort + let sortRange: RangeDefinition; + if (range) { + sortRange = typeof range === 'string' ? parseRangeReference(range) : range; + } else { + const dims = this.dimensions; + if (!dims) return this; + sortRange = dims; + } + + const startRow = hasHeader ? sortRange.startRow + 1 : sortRange.startRow; + const endRow = sortRange.endRow; + + if (startRow > endRow) return this; + + // Collect rows + const rows: { rowIndex: number; sortValues: CellValue[]; cells: Map }[] = []; + + for (let r = startRow; r <= endRow; r++) { + const sortValues = sortColumns.map((sc) => { + const cell = this.getCell(r, sc.colIndex); + return cell?.value ?? null; + }); + + const cells = new Map(); + for (let c = sortRange.startCol; c <= sortRange.endCol; c++) { + const cell = this.getCell(r, c); + if (cell) { + cells.set(c, cell.clone()); + } + } + + rows.push({ rowIndex: r, sortValues, cells }); + } + + // Sort with multi-column comparison + rows.sort((a, b) => { + for (let i = 0; i < sortColumns.length; i++) { + const aVal = a.sortValues[i]; + const bVal = b.sortValues[i]; + const { descending, numeric } = sortColumns[i]; + + const result = this.compareValues(aVal, bVal, numeric, caseSensitive); + if (result !== 0) { + return descending ? -result : result; + } + } + return 0; + }); + + // Disable events and reorder + const wasEventsEnabled = this._eventsEnabled; + this._eventsEnabled = false; + + try { + for (let r = startRow; r <= endRow; r++) { + for (let c = sortRange.startCol; c <= sortRange.endCol; c++) { + this._cells.delete(cellKey(r, c)); + } + } + + for (let i = 0; i < rows.length; i++) { + const targetRow = startRow + i; + const { cells } = rows[i]; + + for (const [col, cell] of cells) { + const newCell = new Cell(targetRow, col, cell.value); + if (cell.style) newCell.style = cell.style; + if (cell.formula) newCell.setFormula('=' + cell.formula.formula, cell.formula.result); + if (cell.hyperlink) newCell.setHyperlink(cell.hyperlink.target, cell.hyperlink.tooltip); + if (cell.comment) newCell.setComment(cell.comment.text as string, cell.comment.author); + + newCell._onChange = this.handleCellChange.bind(this); + this._cells.set(cellKey(targetRow, col), newCell); + } + } + + this.recalculateDimensions(); + } finally { + this._eventsEnabled = wasEventsEnabled; + } + + return this; + } + + /** + * Compare two cell values for sorting + */ + private compareValues(a: CellValue, b: CellValue, numeric: boolean, caseSensitive: boolean): number { + // Handle nulls - always sort to end + if (a === null && b === null) return 0; + if (a === null) return 1; + if (b === null) return -1; + + // Numeric comparison + if (numeric || (typeof a === 'number' && typeof b === 'number')) { + const numA = typeof a === 'number' ? a : parseFloat(String(a)); + const numB = typeof b === 'number' ? b : parseFloat(String(b)); + + if (!isNaN(numA) && !isNaN(numB)) { + return numA - numB; + } + } + + // Date comparison + if (a instanceof Date && b instanceof Date) { + return a.getTime() - b.getTime(); + } + + // String comparison + const strA = String(a); + const strB = String(b); + + if (caseSensitive) { + return strA.localeCompare(strB); + } + return strA.toLowerCase().localeCompare(strB.toLowerCase()); + } + + /** + * Convert column letter to index + */ + private columnLetterToIndex(letter: string): number { + let index = 0; + const upper = letter.toUpperCase(); + for (let i = 0; i < upper.length; i++) { + index = index * 26 + (upper.charCodeAt(i) - 64); + } + return index - 1; + } + + // ============ Filtering ============ + + // Track filtered (hidden) rows + private _filteredRows: Set = new Set(); + private _activeFilters: Map = new Map(); + + /** + * Filter rows based on column values + * + * @param column - Column index (0-based) or letter (e.g., 'A') to filter by + * @param criteria - Filter criteria + * @param options - Filter options + * @returns this for chaining + * + * @example + * ```typescript + * // Show only rows where column A equals 'Active' + * sheet.filter('A', { equals: 'Active' }); + * + * // Show rows where column B contains 'test' + * sheet.filter('B', { contains: 'test' }); + * + * // Show rows where column C is greater than 100 + * sheet.filter('C', { greaterThan: 100 }); + * + * // Custom filter function + * sheet.filter('D', { custom: (value) => value !== null && value > 0 }); + * ``` + */ + filter( + column: number | string, + criteria: FilterCriteria, + options: { + hasHeader?: boolean; + range?: string | RangeDefinition; + } = {} + ): this { + const { hasHeader = false, range } = options; + + const colIndex = typeof column === 'string' + ? this.columnLetterToIndex(column) + : column; + + // Store the active filter + this._activeFilters.set(colIndex, criteria); + + // Apply all filters + this.applyFilters(hasHeader, range); + + return this; + } + + /** + * Filter rows based on multiple column criteria + * + * @param filters - Array of column filter specifications + * @param options - Filter options + * + * @example + * ```typescript + * sheet.filterBy([ + * { column: 'A', criteria: { equals: 'Active' } }, + * { column: 'B', criteria: { greaterThan: 100 } } + * ]); + * ``` + */ + filterBy( + filters: Array<{ + column: number | string; + criteria: FilterCriteria; + }>, + options: { + hasHeader?: boolean; + range?: string | RangeDefinition; + } = {} + ): this { + const { hasHeader = false, range } = options; + + // Store all filters + for (const filter of filters) { + const colIndex = typeof filter.column === 'string' + ? this.columnLetterToIndex(filter.column) + : filter.column; + this._activeFilters.set(colIndex, filter.criteria); + } + + // Apply all filters + this.applyFilters(hasHeader, range); + + return this; + } + + /** + * Clear all filters and show all rows + */ + clearFilter(): this { + this._activeFilters.clear(); + + // Show all filtered rows + for (const row of this._filteredRows) { + this.showRow(row); + } + this._filteredRows.clear(); + + return this; + } + + /** + * Clear filter on a specific column + */ + clearColumnFilter(column: number | string): this { + const colIndex = typeof column === 'string' + ? this.columnLetterToIndex(column) + : column; + + this._activeFilters.delete(colIndex); + + // Re-apply remaining filters + if (this._activeFilters.size > 0) { + // Show all rows first, then re-apply filters + for (const row of this._filteredRows) { + this.showRow(row); + } + this._filteredRows.clear(); + this.applyFilters(false); + } else { + // No more filters, show all rows + this.clearFilter(); + } + + return this; + } + + /** + * Get active filters + */ + get activeFilters(): ReadonlyMap { + return this._activeFilters; + } + + /** + * Check if a row is currently filtered out (hidden by filter) + */ + isRowFiltered(row: number): boolean { + return this._filteredRows.has(row); + } + + /** + * Get all filtered row indices + */ + get filteredRows(): ReadonlySet { + return this._filteredRows; + } + + /** + * Internal: Apply all active filters to the sheet + */ + private applyFilters(hasHeader: boolean, range?: string | RangeDefinition): void { + // Determine range to filter + let filterRange: RangeDefinition; + if (range) { + filterRange = typeof range === 'string' ? parseRangeReference(range) : range; + } else { + const dims = this.dimensions; + if (!dims) return; + filterRange = dims; + } + + const startRow = hasHeader ? filterRange.startRow + 1 : filterRange.startRow; + const endRow = filterRange.endRow; + + // Show all rows first + for (const row of this._filteredRows) { + this.showRow(row); + } + this._filteredRows.clear(); + + // Apply each filter + for (let r = startRow; r <= endRow; r++) { + let matches = true; + + for (const [colIndex, criteria] of this._activeFilters) { + const cell = this.getCell(r, colIndex); + const value = cell?.value ?? null; + + if (!this.matchesCriteria(value, criteria)) { + matches = false; + break; + } + } + + if (!matches) { + this.hideRow(r); + this._filteredRows.add(r); + } + } + } + + /** + * Internal: Check if a value matches filter criteria + */ + private matchesCriteria(value: CellValue, criteria: FilterCriteria): boolean { + // Custom function takes precedence + if (criteria.custom) { + return criteria.custom(value); + } + + // isEmpty + if (criteria.isEmpty !== undefined) { + const isEmpty = value === null || value === undefined || value === ''; + return criteria.isEmpty ? isEmpty : !isEmpty; + } + + // isNotEmpty + if (criteria.isNotEmpty !== undefined) { + const isEmpty = value === null || value === undefined || value === ''; + return criteria.isNotEmpty ? !isEmpty : isEmpty; + } + + // equals + if (criteria.equals !== undefined) { + if (typeof criteria.equals === 'string' && typeof value === 'string') { + return value.toLowerCase() === criteria.equals.toLowerCase(); + } + return value === criteria.equals; + } + + // notEquals + if (criteria.notEquals !== undefined) { + if (typeof criteria.notEquals === 'string' && typeof value === 'string') { + return value.toLowerCase() !== criteria.notEquals.toLowerCase(); + } + return value !== criteria.notEquals; + } + + // String operations + if (typeof value === 'string') { + const lowerValue = value.toLowerCase(); + + if (criteria.contains !== undefined) { + return lowerValue.includes(criteria.contains.toLowerCase()); + } + + if (criteria.notContains !== undefined) { + return !lowerValue.includes(criteria.notContains.toLowerCase()); + } + + if (criteria.startsWith !== undefined) { + return lowerValue.startsWith(criteria.startsWith.toLowerCase()); + } + + if (criteria.endsWith !== undefined) { + return lowerValue.endsWith(criteria.endsWith.toLowerCase()); + } + } + + // Numeric operations + const numValue = typeof value === 'number' ? value : parseFloat(String(value)); + if (!isNaN(numValue)) { + if (criteria.greaterThan !== undefined) { + return numValue > criteria.greaterThan; + } + + if (criteria.greaterThanOrEqual !== undefined) { + return numValue >= criteria.greaterThanOrEqual; + } + + if (criteria.lessThan !== undefined) { + return numValue < criteria.lessThan; + } + + if (criteria.lessThanOrEqual !== undefined) { + return numValue <= criteria.lessThanOrEqual; + } + + if (criteria.between !== undefined) { + const [min, max] = criteria.between; + return numValue >= min && numValue <= max; + } + + if (criteria.notBetween !== undefined) { + const [min, max] = criteria.notBetween; + return numValue < min || numValue > max; + } + } + + // in / notIn (value list) + if (criteria.in !== undefined) { + return criteria.in.some((v: string | number | boolean | null) => { + if (typeof v === 'string' && typeof value === 'string') { + return v.toLowerCase() === value.toLowerCase(); + } + return v === value; + }); + } + + if (criteria.notIn !== undefined) { + return !criteria.notIn.some((v: string | number | boolean | null) => { + if (typeof v === 'string' && typeof value === 'string') { + return v.toLowerCase() === value.toLowerCase(); + } + return v === value; + }); + } + + // Default: if no criteria matched, include the row + return true; + } + // ============ Utility Methods ============ /** @@ -748,4 +1381,486 @@ export class Sheet { conditionalFormats: this._conditionalFormats, }; } + + // ============ Event System ============ + + /** + * Subscribe to sheet events + * + * @param eventType - The event type to listen for ('cellChange', 'cellStyleChange', 'cellAdded', 'cellDeleted', '*') + * @param handler - The callback function to invoke when the event occurs + * @returns this for chaining + * + * @example + * ```typescript + * sheet.on('cellChange', (event) => { + * console.log(`Cell ${event.address} changed from ${event.oldValue} to ${event.newValue}`); + * }); + * + * // Listen to all events + * sheet.on('*', (event) => { + * console.log(`Event: ${event.type}`); + * }); + * ``` + */ + on(eventType: K, handler: SheetEventHandler): this { + if (!this._eventListeners.has(eventType)) { + this._eventListeners.set(eventType, new Set()); + } + this._eventListeners.get(eventType)!.add(handler as SheetEventHandler); + return this; + } + + /** + * Unsubscribe from sheet events + * + * @param eventType - The event type to stop listening for + * @param handler - The callback function to remove + * @returns this for chaining + */ + off(eventType: K, handler: SheetEventHandler): this { + const listeners = this._eventListeners.get(eventType); + if (listeners) { + listeners.delete(handler as SheetEventHandler); + } + return this; + } + + /** + * Enable or disable event emission + * + * @param enabled - Whether events should be emitted + * + * @example + * ```typescript + * // Disable events during bulk operations + * sheet.setEventsEnabled(false); + * for (let i = 0; i < 1000; i++) { + * sheet.cell(i, 0).value = i; + * } + * sheet.setEventsEnabled(true); + * ``` + */ + setEventsEnabled(enabled: boolean): this { + this._eventsEnabled = enabled; + return this; + } + + /** + * Check if events are currently enabled + */ + get eventsEnabled(): boolean { + return this._eventsEnabled; + } + + /** + * Get all tracked changes since last commit + * + * @returns Array of change records + * + * @example + * ```typescript + * sheet.cell('A1').value = 'Hello'; + * sheet.cell('B1').value = 'World'; + * + * const changes = sheet.getChanges(); + * console.log(changes.length); // 2 + * + * // Sync changes to server + * await syncChanges(changes); + * + * // Clear change buffer + * sheet.commitChanges(); + * ``` + */ + getChanges(): readonly ChangeRecord[] { + return this._changes; + } + + /** + * Clear the change buffer + * + * Call this after successfully syncing changes to indicate they've been persisted. + */ + commitChanges(): this { + this._changes = []; + return this; + } + + /** + * Get the number of pending changes + */ + get changeCount(): number { + return this._changes.length; + } + + // ============ Undo/Redo ============ + + /** + * Check if there are changes that can be undone + */ + get canUndo(): boolean { + return this._undoStack.length > 0; + } + + /** + * Check if there are changes that can be redone + */ + get canRedo(): boolean { + return this._redoStack.length > 0; + } + + /** + * Get the number of undo steps available + */ + get undoCount(): number { + return this._undoStack.length; + } + + /** + * Get the number of redo steps available + */ + get redoCount(): number { + return this._redoStack.length; + } + + /** + * Undo the last change + * + * @returns true if undo was successful, false if nothing to undo + * + * @example + * ```typescript + * sheet.cell('A1').value = 'Hello'; + * sheet.cell('A1').value = 'World'; + * + * sheet.undo(); // A1 is now 'Hello' + * sheet.undo(); // A1 is now null + * ``` + */ + undo(): boolean { + if (!this.canUndo) { + return false; + } + + const change = this._undoStack.pop()!; + this._isUndoRedoOperation = true; + + try { + // Check if this is a batch change + const batchChanges = (change as ChangeRecord & { _batchChanges?: ChangeRecord[] })._batchChanges; + + if (batchChanges) { + // Undo batch changes in reverse order + for (let i = batchChanges.length - 1; i >= 0; i--) { + const batchChange = batchChanges[i]; + this.applyUndoChange(batchChange); + } + } else { + this.applyUndoChange(change); + } + + // Push to redo stack + this._redoStack.push(change); + + return true; + } finally { + this._isUndoRedoOperation = false; + } + } + + /** + * Internal: Apply a single undo change + */ + private applyUndoChange(change: ChangeRecord): void { + if (change.type === 'value' || change.type === 'formula') { + const cell = this.cell(change.row, change.col); + cell.value = change.oldValue ?? null; + } else if (change.type === 'style') { + const cell = this.cell(change.row, change.col); + cell.style = change.oldStyle; + } + } + + /** + * Redo the last undone change + * + * @returns true if redo was successful, false if nothing to redo + * + * @example + * ```typescript + * sheet.cell('A1').value = 'Hello'; + * sheet.undo(); // A1 is now null + * sheet.redo(); // A1 is now 'Hello' + * ``` + */ + redo(): boolean { + if (!this.canRedo) { + return false; + } + + const change = this._redoStack.pop()!; + this._isUndoRedoOperation = true; + + try { + // Check if this is a batch change + const batchChanges = (change as ChangeRecord & { _batchChanges?: ChangeRecord[] })._batchChanges; + + if (batchChanges) { + // Redo batch changes in order + for (const batchChange of batchChanges) { + this.applyRedoChange(batchChange); + } + } else { + this.applyRedoChange(change); + } + + // Push back to undo stack + this._undoStack.push(change); + + return true; + } finally { + this._isUndoRedoOperation = false; + } + } + + /** + * Internal: Apply a single redo change + */ + private applyRedoChange(change: ChangeRecord): void { + if (change.type === 'value' || change.type === 'formula') { + const cell = this.cell(change.row, change.col); + cell.value = change.newValue ?? null; + } else if (change.type === 'style') { + const cell = this.cell(change.row, change.col); + cell.style = change.newStyle; + } + } + + /** + * Clear the undo and redo history + * + * Useful after saving or when you want to prevent undoing past a certain point. + */ + clearHistory(): this { + this._undoStack = []; + this._redoStack = []; + return this; + } + + /** + * Set the maximum number of undo steps to keep + * + * @param max - Maximum history size (default: 100) + */ + setMaxUndoHistory(max: number): this { + this._maxUndoHistory = max; + // Trim if necessary + while (this._undoStack.length > max) { + this._undoStack.shift(); + } + return this; + } + + /** + * Execute a batch of operations as a single undo step + * + * @param fn - Function containing the batch operations + * + * @example + * ```typescript + * sheet.batch(() => { + * sheet.cell('A1').value = 'Hello'; + * sheet.cell('B1').value = 'World'; + * sheet.cell('C1').value = '!'; + * }); + * + * // Single undo reverts all three changes + * sheet.undo(); + * ``` + */ + batch(fn: () => void): this { + const startIndex = this._undoStack.length; + + fn(); + + // Collect all changes made during the batch + const batchChanges = this._undoStack.splice(startIndex); + + if (batchChanges.length > 0) { + // Create a composite change record + const compositeChange: ChangeRecord = { + id: `${this._name}-batch-${++this._changeIdCounter}`, + type: 'value', + address: batchChanges[0].address, + row: batchChanges[0].row, + col: batchChanges[0].col, + oldValue: batchChanges[0].oldValue, + newValue: batchChanges[batchChanges.length - 1].newValue, + timestamp: Date.now(), + }; + + // Store batch changes for proper undo + (compositeChange as ChangeRecord & { _batchChanges?: ChangeRecord[] })._batchChanges = batchChanges; + + this._undoStack.push(compositeChange); + } + + return this; + } + + /** + * Internal: Handle cell change notifications + */ + private handleCellChange(cell: Cell, changeType: 'value' | 'style' | 'formula', oldValue?: CellValue | CellStyle): void { + if (!this._eventsEnabled) return; + + const timestamp = Date.now(); + + if (changeType === 'value' || changeType === 'formula') { + const changeRecord: ChangeRecord = { + id: `${this._name}-${++this._changeIdCounter}`, + type: changeType, + address: cell.address, + row: cell.row, + col: cell.col, + oldValue: oldValue as CellValue, + newValue: cell.value, + timestamp, + }; + + // Record the change + this._changes.push(changeRecord); + + // Add to undo stack (unless this is an undo/redo operation) + if (!this._isUndoRedoOperation) { + this._undoStack.push(changeRecord); + // Limit undo history + if (this._undoStack.length > this._maxUndoHistory) { + this._undoStack.shift(); + } + // Clear redo stack on new change + this._redoStack = []; + } + + // Emit event + const event: CellChangeEvent = { + type: 'cellChange', + sheetName: this._name, + address: cell.address, + row: cell.row, + col: cell.col, + oldValue: oldValue as CellValue, + newValue: cell.value, + timestamp, + }; + this.emit('cellChange', event); + } else if (changeType === 'style') { + const changeRecord: ChangeRecord = { + id: `${this._name}-${++this._changeIdCounter}`, + type: 'style', + address: cell.address, + row: cell.row, + col: cell.col, + oldStyle: oldValue as CellStyle, + newStyle: cell.style, + timestamp, + }; + + // Record the change + this._changes.push(changeRecord); + + // Add to undo stack (unless this is an undo/redo operation) + if (!this._isUndoRedoOperation) { + this._undoStack.push(changeRecord); + if (this._undoStack.length > this._maxUndoHistory) { + this._undoStack.shift(); + } + this._redoStack = []; + } + + // Emit event + const event: CellStyleChangeEvent = { + type: 'cellStyleChange', + sheetName: this._name, + address: cell.address, + row: cell.row, + col: cell.col, + oldStyle: oldValue as CellStyle, + newStyle: cell.style, + timestamp, + }; + this.emit('cellStyleChange', event); + } + } + + /** + * Internal: Emit a cell added event + */ + private emitCellAdded(cell: Cell): void { + if (!this._eventsEnabled) return; + + const event: CellAddedEvent = { + type: 'cellAdded', + sheetName: this._name, + address: cell.address, + row: cell.row, + col: cell.col, + timestamp: Date.now(), + }; + this.emit('cellAdded', event); + } + + /** + * Internal: Emit a cell deleted event + */ + private emitCellDeleted(cell: Cell): void { + if (!this._eventsEnabled) return; + + const timestamp = Date.now(); + + const event: CellDeletedEvent = { + type: 'cellDeleted', + sheetName: this._name, + address: cell.address, + row: cell.row, + col: cell.col, + value: cell.value, + timestamp, + }; + + // Record the change + this._changes.push({ + id: `${this._name}-${++this._changeIdCounter}`, + type: 'delete', + address: cell.address, + row: cell.row, + col: cell.col, + oldValue: cell.value, + timestamp, + }); + + this.emit('cellDeleted', event); + } + + /** + * Internal: Emit an event to all listeners + */ + private emit(eventType: K, event: SheetEventMap[K]): void { + // Emit to specific listeners + const listeners = this._eventListeners.get(eventType); + if (listeners) { + for (const handler of listeners) { + handler(event); + } + } + + // Emit to wildcard listeners + const wildcardListeners = this._eventListeners.get('*'); + if (wildcardListeners) { + for (const handler of wildcardListeners) { + handler(event); + } + } + } } diff --git a/src/core/index.ts b/src/core/index.ts index 1aa796d..931eae6 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,5 @@ export { Cell } from './Cell.js'; +export type { CellChangeCallback } from './Cell.js'; export { Sheet } from './Sheet.js'; export type { RowConfig, ColumnConfig, SheetView, PageSetup, SheetProtection } from './Sheet.js'; export { Workbook } from './Workbook.js'; diff --git a/src/types/event.types.ts b/src/types/event.types.ts new file mode 100644 index 0000000..0baf753 --- /dev/null +++ b/src/types/event.types.ts @@ -0,0 +1,150 @@ +/** + * Event types for Cellify + */ + +import type { CellValue } from './cell.types.js'; +import type { CellStyle } from './style.types.js'; + +/** + * Base event interface + */ +export interface SheetEvent { + /** The sheet where the event occurred */ + sheetName: string; + /** Timestamp of the event */ + timestamp: number; +} + +/** + * Cell change event + */ +export interface CellChangeEvent extends SheetEvent { + type: 'cellChange'; + /** Cell address in A1 notation */ + address: string; + /** Row index (0-based) */ + row: number; + /** Column index (0-based) */ + col: number; + /** Previous value */ + oldValue: CellValue; + /** New value */ + newValue: CellValue; +} + +/** + * Cell style change event + */ +export interface CellStyleChangeEvent extends SheetEvent { + type: 'cellStyleChange'; + /** Cell address in A1 notation */ + address: string; + /** Row index (0-based) */ + row: number; + /** Column index (0-based) */ + col: number; + /** Previous style */ + oldStyle: CellStyle | undefined; + /** New style */ + newStyle: CellStyle | undefined; +} + +/** + * Range change event (for batch operations) + */ +export interface RangeChangeEvent extends SheetEvent { + type: 'rangeChange'; + /** Range in A1 notation (e.g., "A1:C3") */ + range: string; + /** Start row */ + startRow: number; + /** Start column */ + startCol: number; + /** End row */ + endRow: number; + /** End column */ + endCol: number; + /** Number of cells affected */ + cellCount: number; +} + +/** + * Cell added event + */ +export interface CellAddedEvent extends SheetEvent { + type: 'cellAdded'; + /** Cell address in A1 notation */ + address: string; + /** Row index (0-based) */ + row: number; + /** Column index (0-based) */ + col: number; +} + +/** + * Cell deleted event + */ +export interface CellDeletedEvent extends SheetEvent { + type: 'cellDeleted'; + /** Cell address in A1 notation */ + address: string; + /** Row index (0-based) */ + row: number; + /** Column index (0-based) */ + col: number; + /** Value at time of deletion */ + value: CellValue; +} + +/** + * Union of all event types + */ +export type SheetEventType = + | CellChangeEvent + | CellStyleChangeEvent + | RangeChangeEvent + | CellAddedEvent + | CellDeletedEvent; + +/** + * Event handler function + */ +export type SheetEventHandler = (event: T) => void; + +/** + * Map of event types to their handlers + */ +export interface SheetEventMap { + cellChange: CellChangeEvent; + cellStyleChange: CellStyleChangeEvent; + rangeChange: RangeChangeEvent; + cellAdded: CellAddedEvent; + cellDeleted: CellDeletedEvent; + '*': SheetEventType; // Wildcard for all events +} + +/** + * Change record for tracking modifications + */ +export interface ChangeRecord { + /** Unique ID for this change */ + id: string; + /** Type of change */ + type: 'value' | 'style' | 'formula' | 'delete'; + /** Cell address */ + address: string; + /** Row index */ + row: number; + /** Column index */ + col: number; + /** Value before change */ + oldValue?: CellValue; + /** Value after change */ + newValue?: CellValue; + /** Style before change */ + oldStyle?: CellStyle; + /** Style after change */ + newStyle?: CellStyle; + /** Timestamp */ + timestamp: number; +} diff --git a/src/types/index.ts b/src/types/index.ts index 0704df1..c68a1db 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -67,6 +67,7 @@ export type { ConditionalFormatRule, AutoFilter, AutoFilterColumn, + FilterCriteria, } from './range.types.js'; export { @@ -79,3 +80,16 @@ export { iterateRange, getRangeDimensions, } from './range.types.js'; + +export type { + SheetEvent, + CellChangeEvent, + CellStyleChangeEvent, + RangeChangeEvent, + CellAddedEvent, + CellDeletedEvent, + SheetEventType, + SheetEventHandler, + SheetEventMap, + ChangeRecord, +} from './event.types.js'; diff --git a/src/types/range.types.ts b/src/types/range.types.ts index 07d62ab..06b0e00 100644 --- a/src/types/range.types.ts +++ b/src/types/range.types.ts @@ -1,4 +1,4 @@ -import type { CellAddress } from './cell.types.js'; +import type { CellAddress, CellValue } from './cell.types.js'; import type { CellStyle } from './style.types.js'; /** @@ -245,6 +245,40 @@ export interface AutoFilter { columns?: AutoFilterColumn[]; } +/** + * Filter criteria for sheet.filter() method + */ +export interface FilterCriteria { + // Equality + equals?: string | number | boolean | null; + notEquals?: string | number | boolean | null; + + // String operations (case-insensitive) + contains?: string; + notContains?: string; + startsWith?: string; + endsWith?: string; + + // Numeric operations + greaterThan?: number; + greaterThanOrEqual?: number; + lessThan?: number; + lessThanOrEqual?: number; + between?: [number, number]; + notBetween?: [number, number]; + + // Value list + in?: (string | number | boolean | null)[]; + notIn?: (string | number | boolean | null)[]; + + // Empty checks + isEmpty?: boolean; + isNotEmpty?: boolean; + + // Custom function + custom?: (value: CellValue) => boolean; +} + /** * Auto filter column configuration */ diff --git a/tests/events.test.ts b/tests/events.test.ts new file mode 100644 index 0000000..1944d81 --- /dev/null +++ b/tests/events.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; +import type { CellChangeEvent, CellStyleChangeEvent, CellAddedEvent, CellDeletedEvent } from '../src/types/event.types.js'; + +describe('Sheet Event System', () => { + describe('cellChange events', () => { + it('should emit cellChange event when cell value changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + // First set a value so the cell exists + sheet.cell('A1').value = 'Hello'; + + // Subscribe after initial value is set + sheet.on('cellChange', handler); + sheet.cell('A1').value = 'World'; + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0] as CellChangeEvent; + expect(event.type).toBe('cellChange'); + expect(event.address).toBe('A1'); + expect(event.oldValue).toBe('Hello'); + expect(event.newValue).toBe('World'); + expect(event.sheetName).toBe('Test'); + }); + + it('should not emit event when value is unchanged', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.cell('A1').value = 'Hello'; + sheet.on('cellChange', handler); + sheet.cell('A1').value = 'Hello'; // Same value + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should emit cellChange event for formula changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.on('cellChange', handler); + sheet.cell('A1').setFormula('=SUM(B1:B10)'); + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0] as CellChangeEvent; + expect(event.type).toBe('cellChange'); + }); + }); + + describe('cellStyleChange events', () => { + it('should emit cellStyleChange event when style is set', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.cell('A1').value = 'Test'; + sheet.on('cellStyleChange', handler); + sheet.cell('A1').style = { font: { bold: true } }; + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0] as CellStyleChangeEvent; + expect(event.type).toBe('cellStyleChange'); + expect(event.address).toBe('A1'); + expect(event.oldStyle).toBeUndefined(); + expect(event.newStyle?.font?.bold).toBe(true); + }); + + it('should emit cellStyleChange event when applyStyle is called', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.cell('A1').value = 'Test'; + sheet.on('cellStyleChange', handler); + sheet.cell('A1').applyStyle({ font: { italic: true } }); + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('cellAdded events', () => { + it('should emit cellAdded event when new cell is created', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.on('cellAdded', handler); + sheet.cell('A1').value = 'Hello'; + sheet.cell('B2').value = 'World'; + + expect(handler).toHaveBeenCalledTimes(2); + + const event1 = handler.mock.calls[0][0] as CellAddedEvent; + expect(event1.type).toBe('cellAdded'); + expect(event1.address).toBe('A1'); + + const event2 = handler.mock.calls[1][0] as CellAddedEvent; + expect(event2.address).toBe('B2'); + }); + + it('should not emit cellAdded for existing cells', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + + const handler = vi.fn(); + sheet.on('cellAdded', handler); + + sheet.cell('A1').value = 'Updated'; + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('cellDeleted events', () => { + it('should emit cellDeleted event when cell is deleted', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + + const handler = vi.fn(); + sheet.on('cellDeleted', handler); + + sheet.deleteCell('A1'); + + expect(handler).toHaveBeenCalledTimes(1); + const event = handler.mock.calls[0][0] as CellDeletedEvent; + expect(event.type).toBe('cellDeleted'); + expect(event.address).toBe('A1'); + expect(event.value).toBe('Hello'); + }); + }); + + describe('wildcard listener', () => { + it('should receive all events with * listener', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.on('*', handler); + + sheet.cell('A1').value = 'Hello'; // cellAdded + cellChange (null->Hello) + sheet.cell('A1').value = 'World'; // cellChange (Hello->World) + sheet.cell('A1').style = { font: { bold: true } }; // cellStyleChange + + expect(handler).toHaveBeenCalledTimes(4); + expect(handler.mock.calls[0][0].type).toBe('cellAdded'); + expect(handler.mock.calls[1][0].type).toBe('cellChange'); // null -> Hello + expect(handler.mock.calls[2][0].type).toBe('cellChange'); // Hello -> World + expect(handler.mock.calls[3][0].type).toBe('cellStyleChange'); + }); + }); + + describe('event unsubscription', () => { + it('should stop receiving events after off()', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + // Set initial value before subscribing + sheet.cell('A1').value = 'Hello'; + + sheet.on('cellChange', handler); + sheet.cell('A1').value = 'World'; // 1 event + + sheet.off('cellChange', handler); + sheet.cell('A1').value = 'Again'; // No event (unsubscribed) + + expect(handler).toHaveBeenCalledTimes(1); + }); + }); + + describe('events enabled/disabled', () => { + it('should not emit events when disabled', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.on('cellChange', handler); + sheet.setEventsEnabled(false); + + sheet.cell('A1').value = 'Hello'; + sheet.cell('A1').value = 'World'; + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should resume emitting events when re-enabled', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler = vi.fn(); + + sheet.on('cellChange', handler); + sheet.setEventsEnabled(false); + sheet.cell('A1').value = 'Hello'; + + sheet.setEventsEnabled(true); + sheet.cell('A1').value = 'World'; + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it('should report eventsEnabled state', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + expect(sheet.eventsEnabled).toBe(true); + sheet.setEventsEnabled(false); + expect(sheet.eventsEnabled).toBe(false); + }); + }); + + describe('change tracking', () => { + it('should track value changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; // Change from null to Hello + sheet.cell('A1').value = 'World'; // Change from Hello to World + + const changes = sheet.getChanges(); + expect(changes.length).toBe(2); // Both changes tracked + expect(changes[0].type).toBe('value'); + expect(changes[0].address).toBe('A1'); + expect(changes[0].oldValue).toBeNull(); + expect(changes[0].newValue).toBe('Hello'); + expect(changes[1].oldValue).toBe('Hello'); + expect(changes[1].newValue).toBe('World'); + }); + + it('should track style changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.cell('A1').style = { font: { bold: true } }; + + const changes = sheet.getChanges(); + const styleChange = changes.find((c) => c.type === 'style'); + expect(styleChange).toBeDefined(); + expect(styleChange?.newStyle?.font?.bold).toBe(true); + }); + + it('should track delete changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.deleteCell('A1'); + + const changes = sheet.getChanges(); + const deleteChange = changes.find((c) => c.type === 'delete'); + expect(deleteChange).toBeDefined(); + expect(deleteChange?.oldValue).toBe('Hello'); + }); + + it('should clear changes on commit', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.cell('A1').value = 'World'; + + expect(sheet.changeCount).toBe(2); // Two value changes + sheet.commitChanges(); + expect(sheet.changeCount).toBe(0); + expect(sheet.getChanges().length).toBe(0); + }); + + it('should have unique change IDs', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.cell('A1').value = 'World'; + sheet.cell('B1').value = 'Test'; + sheet.cell('B1').value = 'Again'; + + const changes = sheet.getChanges(); + const ids = changes.map((c) => c.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + }); + }); + + describe('multiple listeners', () => { + it('should notify all listeners for same event', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + sheet.cell('A1').value = 'Hello'; + + sheet.on('cellChange', handler1); + sheet.on('cellChange', handler2); + + sheet.cell('A1').value = 'World'; + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/filtering.test.ts b/tests/filtering.test.ts new file mode 100644 index 0000000..57ee7ae --- /dev/null +++ b/tests/filtering.test.ts @@ -0,0 +1,381 @@ +import { describe, it, expect } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; + +describe('Sheet Filtering', () => { + describe('basic filter', () => { + it('should filter by equals', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Active'; + sheet.cell('A2').value = 'Inactive'; + sheet.cell('A3').value = 'Active'; + + sheet.filter('A', { equals: 'Active' }); + + expect(sheet.isRowFiltered(0)).toBe(false); // Active - shown + expect(sheet.isRowFiltered(1)).toBe(true); // Inactive - hidden + expect(sheet.isRowFiltered(2)).toBe(false); // Active - shown + }); + + it('should filter by notEquals', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('A2').value = 'B'; + sheet.cell('A3').value = 'C'; + + sheet.filter('A', { notEquals: 'B' }); + + expect(sheet.isRowFiltered(0)).toBe(false); // A - shown + expect(sheet.isRowFiltered(1)).toBe(true); // B - hidden + expect(sheet.isRowFiltered(2)).toBe(false); // C - shown + }); + + it('should filter by column index', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Yes'; + sheet.cell('A2').value = 'No'; + sheet.cell('A3').value = 'Yes'; + + sheet.filter(0, { equals: 'Yes' }); // Column index 0 = A + + expect(sheet.isRowFiltered(0)).toBe(false); + expect(sheet.isRowFiltered(1)).toBe(true); + expect(sheet.isRowFiltered(2)).toBe(false); + }); + }); + + describe('string filters', () => { + it('should filter by contains', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello World'; + sheet.cell('A2').value = 'Goodbye'; + sheet.cell('A3').value = 'Hello There'; + + sheet.filter('A', { contains: 'Hello' }); + + expect(sheet.isRowFiltered(0)).toBe(false); // Contains Hello + expect(sheet.isRowFiltered(1)).toBe(true); // Doesn't contain + expect(sheet.isRowFiltered(2)).toBe(false); // Contains Hello + }); + + it('should filter by startsWith', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Apple'; + sheet.cell('A2').value = 'Banana'; + sheet.cell('A3').value = 'Apricot'; + + sheet.filter('A', { startsWith: 'A' }); + + expect(sheet.isRowFiltered(0)).toBe(false); // Starts with A + expect(sheet.isRowFiltered(1)).toBe(true); // Starts with B + expect(sheet.isRowFiltered(2)).toBe(false); // Starts with A + }); + + it('should filter by endsWith', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'test.txt'; + sheet.cell('A2').value = 'data.csv'; + sheet.cell('A3').value = 'file.txt'; + + sheet.filter('A', { endsWith: '.txt' }); + + expect(sheet.isRowFiltered(0)).toBe(false); + expect(sheet.isRowFiltered(1)).toBe(true); + expect(sheet.isRowFiltered(2)).toBe(false); + }); + + it('should be case insensitive', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'ACTIVE'; + sheet.cell('A2').value = 'active'; + sheet.cell('A3').value = 'Active'; + + sheet.filter('A', { equals: 'active' }); + + expect(sheet.isRowFiltered(0)).toBe(false); + expect(sheet.isRowFiltered(1)).toBe(false); + expect(sheet.isRowFiltered(2)).toBe(false); + }); + }); + + describe('numeric filters', () => { + it('should filter by greaterThan', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 10; + sheet.cell('A2').value = 50; + sheet.cell('A3').value = 100; + + sheet.filter('A', { greaterThan: 25 }); + + expect(sheet.isRowFiltered(0)).toBe(true); // 10 <= 25 + expect(sheet.isRowFiltered(1)).toBe(false); // 50 > 25 + expect(sheet.isRowFiltered(2)).toBe(false); // 100 > 25 + }); + + it('should filter by lessThan', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 10; + sheet.cell('A2').value = 50; + sheet.cell('A3').value = 100; + + sheet.filter('A', { lessThan: 50 }); + + expect(sheet.isRowFiltered(0)).toBe(false); // 10 < 50 + expect(sheet.isRowFiltered(1)).toBe(true); // 50 not < 50 + expect(sheet.isRowFiltered(2)).toBe(true); // 100 not < 50 + }); + + it('should filter by between', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 5; + sheet.cell('A2').value = 15; + sheet.cell('A3').value = 25; + + sheet.filter('A', { between: [10, 20] }); + + expect(sheet.isRowFiltered(0)).toBe(true); // 5 not in range + expect(sheet.isRowFiltered(1)).toBe(false); // 15 in range + expect(sheet.isRowFiltered(2)).toBe(true); // 25 not in range + }); + }); + + describe('value list filters', () => { + it('should filter by in (value list)', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Red'; + sheet.cell('A2').value = 'Green'; + sheet.cell('A3').value = 'Blue'; + sheet.cell('A4').value = 'Yellow'; + + sheet.filter('A', { in: ['Red', 'Blue'] }); + + expect(sheet.isRowFiltered(0)).toBe(false); // Red in list + expect(sheet.isRowFiltered(1)).toBe(true); // Green not in list + expect(sheet.isRowFiltered(2)).toBe(false); // Blue in list + expect(sheet.isRowFiltered(3)).toBe(true); // Yellow not in list + }); + + it('should filter by notIn', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('A2').value = 'B'; + sheet.cell('A3').value = 'C'; + + sheet.filter('A', { notIn: ['B'] }); + + expect(sheet.isRowFiltered(0)).toBe(false); + expect(sheet.isRowFiltered(1)).toBe(true); + expect(sheet.isRowFiltered(2)).toBe(false); + }); + }); + + describe('empty filters', () => { + it('should filter by isEmpty', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Value'; + sheet.cell('A2').value = null; + sheet.cell('A3').value = ''; + + sheet.filter('A', { isEmpty: true }); + + expect(sheet.isRowFiltered(0)).toBe(true); // Not empty + expect(sheet.isRowFiltered(1)).toBe(false); // null is empty + expect(sheet.isRowFiltered(2)).toBe(false); // '' is empty + }); + + it('should filter by isNotEmpty', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Value'; + sheet.cell('A2').value = null; + sheet.cell('A3').value = 'Another'; + + sheet.filter('A', { isNotEmpty: true }); + + expect(sheet.isRowFiltered(0)).toBe(false); // Has value + expect(sheet.isRowFiltered(1)).toBe(true); // null is empty + expect(sheet.isRowFiltered(2)).toBe(false); // Has value + }); + }); + + describe('custom filter', () => { + it('should filter using custom function', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 1; + sheet.cell('A2').value = 2; + sheet.cell('A3').value = 3; + sheet.cell('A4').value = 4; + + // Only show even numbers + sheet.filter('A', { + custom: (value) => typeof value === 'number' && value % 2 === 0 + }); + + expect(sheet.isRowFiltered(0)).toBe(true); // 1 is odd + expect(sheet.isRowFiltered(1)).toBe(false); // 2 is even + expect(sheet.isRowFiltered(2)).toBe(true); // 3 is odd + expect(sheet.isRowFiltered(3)).toBe(false); // 4 is even + }); + }); + + describe('filter with header', () => { + it('should preserve header row when hasHeader is true', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Status'; // Header + sheet.cell('A2').value = 'Active'; + sheet.cell('A3').value = 'Inactive'; + sheet.cell('A4').value = 'Active'; + + sheet.filter('A', { equals: 'Active' }, { hasHeader: true }); + + expect(sheet.isRowFiltered(0)).toBe(false); // Header not filtered + expect(sheet.isRowFiltered(1)).toBe(false); // Active - shown + expect(sheet.isRowFiltered(2)).toBe(true); // Inactive - hidden + expect(sheet.isRowFiltered(3)).toBe(false); // Active - shown + }); + }); + + describe('multi-column filter', () => { + it('should filter by multiple columns', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Active'; + sheet.cell('B1').value = 100; + sheet.cell('A2').value = 'Active'; + sheet.cell('B2').value = 50; + sheet.cell('A3').value = 'Inactive'; + sheet.cell('B3').value = 100; + + sheet.filterBy([ + { column: 'A', criteria: { equals: 'Active' } }, + { column: 'B', criteria: { greaterThan: 75 } } + ]); + + expect(sheet.isRowFiltered(0)).toBe(false); // Active AND > 75 + expect(sheet.isRowFiltered(1)).toBe(true); // Active but NOT > 75 + expect(sheet.isRowFiltered(2)).toBe(true); // Not Active + }); + }); + + describe('clear filter', () => { + it('should clear all filters', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('A2').value = 'B'; + sheet.cell('A3').value = 'C'; + + sheet.filter('A', { equals: 'A' }); + + expect(sheet.isRowFiltered(1)).toBe(true); + expect(sheet.isRowFiltered(2)).toBe(true); + + sheet.clearFilter(); + + expect(sheet.isRowFiltered(0)).toBe(false); + expect(sheet.isRowFiltered(1)).toBe(false); + expect(sheet.isRowFiltered(2)).toBe(false); + }); + + it('should clear filter on specific column', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Active'; + sheet.cell('B1').value = 100; + sheet.cell('A2').value = 'Inactive'; + sheet.cell('B2').value = 100; + + sheet.filterBy([ + { column: 'A', criteria: { equals: 'Active' } }, + { column: 'B', criteria: { greaterThan: 50 } } + ]); + + // Row 2 is hidden (Inactive) + expect(sheet.isRowFiltered(1)).toBe(true); + + // Clear only column A filter + sheet.clearColumnFilter('A'); + + // Now row 2 should be visible (only B filter active, and B2=100 > 50) + expect(sheet.isRowFiltered(1)).toBe(false); + }); + }); + + describe('activeFilters property', () => { + it('should return active filters', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + + expect(sheet.activeFilters.size).toBe(0); + + sheet.filter('A', { equals: 'Test' }); + + expect(sheet.activeFilters.size).toBe(1); + expect(sheet.activeFilters.get(0)).toEqual({ equals: 'Test' }); + }); + }); + + describe('filteredRows property', () => { + it('should return filtered row indices', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('A2').value = 'B'; + sheet.cell('A3').value = 'A'; + + sheet.filter('A', { equals: 'A' }); + + expect(sheet.filteredRows.size).toBe(1); + expect(sheet.filteredRows.has(1)).toBe(true); + }); + }); + + describe('filter on empty sheet', () => { + it('should handle empty sheet gracefully', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + // Should not throw + sheet.filter('A', { equals: 'test' }); + + expect(sheet.activeFilters.size).toBe(1); + expect(sheet.filteredRows.size).toBe(0); + }); + }); +}); diff --git a/tests/sorting.test.ts b/tests/sorting.test.ts new file mode 100644 index 0000000..b31e732 --- /dev/null +++ b/tests/sorting.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; + +describe('Sheet Sorting', () => { + describe('basic sort', () => { + it('should sort by string column ascending', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Charlie'; + sheet.cell('A2').value = 'Alice'; + sheet.cell('A3').value = 'Bob'; + + sheet.sort('A'); + + expect(sheet.cell('A1').value).toBe('Alice'); + expect(sheet.cell('A2').value).toBe('Bob'); + expect(sheet.cell('A3').value).toBe('Charlie'); + }); + + it('should sort by string column descending', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Alice'; + sheet.cell('A2').value = 'Charlie'; + sheet.cell('A3').value = 'Bob'; + + sheet.sort('A', { descending: true }); + + expect(sheet.cell('A1').value).toBe('Charlie'); + expect(sheet.cell('A2').value).toBe('Bob'); + expect(sheet.cell('A3').value).toBe('Alice'); + }); + + it('should sort by numeric column', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 30; + sheet.cell('A2').value = 10; + sheet.cell('A3').value = 20; + + sheet.sort('A'); + + expect(sheet.cell('A1').value).toBe(10); + expect(sheet.cell('A2').value).toBe(20); + expect(sheet.cell('A3').value).toBe(30); + }); + + it('should sort by column index', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'C'; + sheet.cell('A2').value = 'A'; + sheet.cell('A3').value = 'B'; + + sheet.sort(0); // Column index 0 = A + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('A2').value).toBe('B'); + expect(sheet.cell('A3').value).toBe('C'); + }); + }); + + describe('sort with header', () => { + it('should preserve header row when hasHeader is true', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Name'; // Header + sheet.cell('A2').value = 'Charlie'; + sheet.cell('A3').value = 'Alice'; + sheet.cell('A4').value = 'Bob'; + + sheet.sort('A', { hasHeader: true }); + + expect(sheet.cell('A1').value).toBe('Name'); // Header unchanged + expect(sheet.cell('A2').value).toBe('Alice'); + expect(sheet.cell('A3').value).toBe('Bob'); + expect(sheet.cell('A4').value).toBe('Charlie'); + }); + }); + + describe('sort preserves row data', () => { + it('should move entire rows when sorting', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Charlie'; + sheet.cell('B1').value = 30; + sheet.cell('A2').value = 'Alice'; + sheet.cell('B2').value = 25; + sheet.cell('A3').value = 'Bob'; + sheet.cell('B3').value = 28; + + sheet.sort('A'); + + expect(sheet.cell('A1').value).toBe('Alice'); + expect(sheet.cell('B1').value).toBe(25); + expect(sheet.cell('A2').value).toBe('Bob'); + expect(sheet.cell('B2').value).toBe(28); + expect(sheet.cell('A3').value).toBe('Charlie'); + expect(sheet.cell('B3').value).toBe(30); + }); + + it('should preserve cell styles when sorting', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'B'; + sheet.cell('A1').style = { font: { bold: true } }; + sheet.cell('A2').value = 'A'; + sheet.cell('A2').style = { font: { italic: true } }; + + sheet.sort('A'); + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('A1').style?.font?.italic).toBe(true); + expect(sheet.cell('A2').value).toBe('B'); + expect(sheet.cell('A2').style?.font?.bold).toBe(true); + }); + }); + + describe('sort with nulls', () => { + it('should sort null values to the end', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'B'; + sheet.cell('A2').value = null; + sheet.cell('A3').value = 'A'; + + sheet.sort('A'); + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('A2').value).toBe('B'); + expect(sheet.cell('A3').value).toBeNull(); + }); + }); + + describe('numeric sort option', () => { + it('should sort string numbers numerically when numeric option is true', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = '10'; + sheet.cell('A2').value = '2'; + sheet.cell('A3').value = '1'; + + sheet.sort('A', { numeric: true }); + + expect(sheet.cell('A1').value).toBe('1'); + expect(sheet.cell('A2').value).toBe('2'); + expect(sheet.cell('A3').value).toBe('10'); + }); + }); + + describe('case sensitive sort', () => { + it('should be case insensitive by default', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'banana'; + sheet.cell('A2').value = 'Apple'; + sheet.cell('A3').value = 'cherry'; + + sheet.sort('A'); + + expect(sheet.cell('A1').value).toBe('Apple'); + expect(sheet.cell('A2').value).toBe('banana'); + expect(sheet.cell('A3').value).toBe('cherry'); + }); + }); + + describe('sortBy multiple columns', () => { + it('should sort by primary then secondary column', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'B'; + sheet.cell('B1').value = 2; + sheet.cell('A2').value = 'A'; + sheet.cell('B2').value = 2; + sheet.cell('A3').value = 'A'; + sheet.cell('B3').value = 1; + sheet.cell('A4').value = 'B'; + sheet.cell('B4').value = 1; + + sheet.sortBy([{ column: 'A' }, { column: 'B' }]); + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('B1').value).toBe(1); + expect(sheet.cell('A2').value).toBe('A'); + expect(sheet.cell('B2').value).toBe(2); + expect(sheet.cell('A3').value).toBe('B'); + expect(sheet.cell('B3').value).toBe(1); + expect(sheet.cell('A4').value).toBe('B'); + expect(sheet.cell('B4').value).toBe(2); + }); + + it('should support mixed ascending/descending in multi-column sort', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 1; + sheet.cell('A2').value = 'A'; + sheet.cell('B2').value = 2; + sheet.cell('A3').value = 'B'; + sheet.cell('B3').value = 1; + + sheet.sortBy([{ column: 'A' }, { column: 'B', descending: true }]); + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('B1').value).toBe(2); // Descending + expect(sheet.cell('A2').value).toBe('A'); + expect(sheet.cell('B2').value).toBe(1); + }); + }); + + describe('sort range', () => { + it('should only sort specified range', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'C'; + sheet.cell('A2').value = 'B'; + sheet.cell('A3').value = 'A'; + sheet.cell('A4').value = 'Z'; // Outside range + + sheet.sort('A', { range: 'A1:A3' }); + + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('A2').value).toBe('B'); + expect(sheet.cell('A3').value).toBe('C'); + expect(sheet.cell('A4').value).toBe('Z'); // Unchanged + }); + }); + + describe('sort with dates', () => { + it('should sort date values correctly', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = new Date('2024-03-01'); + sheet.cell('A2').value = new Date('2024-01-01'); + sheet.cell('A3').value = new Date('2024-02-01'); + + sheet.sort('A'); + + expect((sheet.cell('A1').value as Date).getMonth()).toBe(0); // January + expect((sheet.cell('A2').value as Date).getMonth()).toBe(1); // February + expect((sheet.cell('A3').value as Date).getMonth()).toBe(2); // March + }); + }); + + describe('sort on empty sheet', () => { + it('should handle empty sheet gracefully', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + // Should not throw + sheet.sort('A'); + + expect(sheet.dimensions).toBeNull(); + }); + }); +}); diff --git a/tests/undo-redo.test.ts b/tests/undo-redo.test.ts new file mode 100644 index 0000000..f8396c2 --- /dev/null +++ b/tests/undo-redo.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest'; +import { Workbook } from '../src/core/Workbook.js'; + +describe('Sheet Undo/Redo', () => { + describe('basic undo', () => { + it('should undo a single value change', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + expect(sheet.cell('A1').value).toBe('Hello'); + + sheet.undo(); + expect(sheet.cell('A1').value).toBeNull(); + }); + + it('should undo multiple value changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A1').value = 'Second'; + sheet.cell('A1').value = 'Third'; + + expect(sheet.cell('A1').value).toBe('Third'); + + sheet.undo(); + expect(sheet.cell('A1').value).toBe('Second'); + + sheet.undo(); + expect(sheet.cell('A1').value).toBe('First'); + + sheet.undo(); + expect(sheet.cell('A1').value).toBeNull(); + }); + + it('should undo style changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Test'; + sheet.cell('A1').style = { font: { bold: true } }; + + expect(sheet.cell('A1').style?.font?.bold).toBe(true); + + sheet.undo(); + expect(sheet.cell('A1').style).toBeUndefined(); + }); + + it('should return false when nothing to undo', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + expect(sheet.undo()).toBe(false); + }); + }); + + describe('basic redo', () => { + it('should redo an undone change', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'Hello'; + sheet.undo(); + expect(sheet.cell('A1').value).toBeNull(); + + sheet.redo(); + expect(sheet.cell('A1').value).toBe('Hello'); + }); + + it('should redo multiple undone changes', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A1').value = 'Second'; + + sheet.undo(); + sheet.undo(); + + expect(sheet.cell('A1').value).toBeNull(); + + sheet.redo(); + expect(sheet.cell('A1').value).toBe('First'); + + sheet.redo(); + expect(sheet.cell('A1').value).toBe('Second'); + }); + + it('should return false when nothing to redo', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + expect(sheet.redo()).toBe(false); + }); + + it('should clear redo stack on new change', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A1').value = 'Second'; + + sheet.undo(); // Back to First + expect(sheet.canRedo).toBe(true); + + sheet.cell('A1').value = 'New'; // New change clears redo + expect(sheet.canRedo).toBe(false); + }); + }); + + describe('canUndo and canRedo', () => { + it('should report canUndo correctly', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + expect(sheet.canUndo).toBe(false); + + sheet.cell('A1').value = 'Hello'; + expect(sheet.canUndo).toBe(true); + + sheet.undo(); + expect(sheet.canUndo).toBe(false); + }); + + it('should report canRedo correctly', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + expect(sheet.canRedo).toBe(false); + + sheet.cell('A1').value = 'Hello'; + expect(sheet.canRedo).toBe(false); + + sheet.undo(); + expect(sheet.canRedo).toBe(true); + + sheet.redo(); + expect(sheet.canRedo).toBe(false); + }); + }); + + describe('undoCount and redoCount', () => { + it('should track undo count', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + expect(sheet.undoCount).toBe(0); + + sheet.cell('A1').value = 'First'; + expect(sheet.undoCount).toBe(1); + + sheet.cell('A1').value = 'Second'; + expect(sheet.undoCount).toBe(2); + + sheet.undo(); + expect(sheet.undoCount).toBe(1); + }); + + it('should track redo count', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A1').value = 'Second'; + + expect(sheet.redoCount).toBe(0); + + sheet.undo(); + expect(sheet.redoCount).toBe(1); + + sheet.undo(); + expect(sheet.redoCount).toBe(2); + + sheet.redo(); + expect(sheet.redoCount).toBe(1); + }); + }); + + describe('clearHistory', () => { + it('should clear undo and redo history', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'First'; + sheet.cell('A1').value = 'Second'; + sheet.undo(); + + expect(sheet.canUndo).toBe(true); + expect(sheet.canRedo).toBe(true); + + sheet.clearHistory(); + + expect(sheet.canUndo).toBe(false); + expect(sheet.canRedo).toBe(false); + }); + }); + + describe('setMaxUndoHistory', () => { + it('should limit undo history', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.setMaxUndoHistory(3); + + sheet.cell('A1').value = 'One'; + sheet.cell('A1').value = 'Two'; + sheet.cell('A1').value = 'Three'; + sheet.cell('A1').value = 'Four'; + sheet.cell('A1').value = 'Five'; + + expect(sheet.undoCount).toBe(3); // Only last 3 kept + + sheet.undo(); + expect(sheet.cell('A1').value).toBe('Four'); + + sheet.undo(); + expect(sheet.cell('A1').value).toBe('Three'); + + sheet.undo(); + expect(sheet.cell('A1').value).toBe('Two'); + + // Can't undo further + expect(sheet.canUndo).toBe(false); + }); + }); + + describe('batch operations', () => { + it('should undo batch as single operation', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.batch(() => { + sheet.cell('A1').value = 'Hello'; + sheet.cell('B1').value = 'World'; + sheet.cell('C1').value = '!'; + }); + + expect(sheet.cell('A1').value).toBe('Hello'); + expect(sheet.cell('B1').value).toBe('World'); + expect(sheet.cell('C1').value).toBe('!'); + + // Single undo should revert all three + sheet.undo(); + + expect(sheet.cell('A1').value).toBeNull(); + expect(sheet.cell('B1').value).toBeNull(); + expect(sheet.cell('C1').value).toBeNull(); + }); + + it('should redo batch as single operation', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.batch(() => { + sheet.cell('A1').value = 'Hello'; + sheet.cell('B1').value = 'World'; + }); + + sheet.undo(); + sheet.redo(); + + expect(sheet.cell('A1').value).toBe('Hello'); + expect(sheet.cell('B1').value).toBe('World'); + }); + + it('should count batch as single undo step', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.batch(() => { + sheet.cell('A1').value = 'One'; + sheet.cell('A2').value = 'Two'; + sheet.cell('A3').value = 'Three'; + }); + + expect(sheet.undoCount).toBe(1); + }); + }); + + describe('multiple cells', () => { + it('should undo changes to different cells independently', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.cell('A1').value = 'A'; + sheet.cell('B1').value = 'B'; + sheet.cell('C1').value = 'C'; + + sheet.undo(); // Undo C + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('B1').value).toBe('B'); + expect(sheet.cell('C1').value).toBeNull(); + + sheet.undo(); // Undo B + expect(sheet.cell('A1').value).toBe('A'); + expect(sheet.cell('B1').value).toBeNull(); + + sheet.undo(); // Undo A + expect(sheet.cell('A1').value).toBeNull(); + }); + }); + + describe('undo/redo with events disabled', () => { + it('should not track changes in undo history when events are disabled', () => { + const workbook = new Workbook(); + const sheet = workbook.addSheet('Test'); + + sheet.setEventsEnabled(false); + sheet.cell('A1').value = 'Hello'; + sheet.setEventsEnabled(true); + + // No undo available because events were disabled + expect(sheet.canUndo).toBe(false); + }); + }); +});