import jspreadsheet from 'jspreadsheet-ce'; import { Prop, toNative } from 'vue-facing-decorator'; import TsxComponent, { Component } from '../../app/vuetsx'; import { isNullOrEmpty } from '../../common/utils/is-null-or-empty'; import { getSpreadsheetRowsRuntimeData, spreadsheetCellValuesEqual } from './spreadsheet-data'; import { createVirtualizationPlugin, type VirtualizationOptions } from './virtualization-plugin'; import 'jspreadsheet-ce/dist/jspreadsheet.css'; import 'jsuites/dist/jsuites.css'; import './css/spreadsheet.css'; export interface SpreadsheetArgs { rows: any[][]; autoTrackData?: boolean; allowToolbar?: boolean; columns: SpreadsheetColumn[]; rootCssClass?: string; columnResize?: boolean; rowResize?: boolean; rowDrag?: boolean; columnsDrag?: boolean; nestedHeaders?: NestedHeaderCell[][]; contextMenu?: (el, x, y, e) => SpreadsheetContextMenuItem[]; mergeCells?: { [index: string]: number[] }; beforeChange?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void; changed?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void; afterChanges?: () => void; updateTable?: (instance?: any, cell?: any, col?: number, row?: number, val?: any, id?: any) => void; /** * Maps to jspreadsheet-ce v5's `ondeleterow`, fired AFTER one or more rows are * removed. `removedRows` holds the pre-delete row indexes of the removed rows. */ deleteRow?: (instance?: any, removedRows?: number[]) => void; rowHeight?: number; /** * Toggle row virtualization. When true, only rows close to the viewport are kept * visible; rows outside the window are display-hidden. Off by default — opt in for * very large sheets where jspreadsheet's own `lazyLoading` either does not engage * or is insufficient. */ virtualize?: boolean; /** * Total number of rows kept visible when `virtualize` is true. The window is * centered roughly on the scroll position. Default 300. */ virtualWindowSize?: number; /** Activate the table lazyloading. */ lazyLoading?: boolean; /** Number of columns frozen at the top of the spreadsheet. */ freezeColumns?: number; /** * Enable column filters. * @default false */ filters?: boolean; /** Number of rows per page. */ pagination?: number; /** * Values available in the dropdown for choosing the number of rows per page. * * This dropdown is only visible when the `search` option is true and the * `pagination` option is greater than 0. */ paginationOptions?: number[]; /** * Allow table overflow. * @default false */ tableOverflow?: boolean; } export interface SpreadsheetContextMenuItem { title: string; onclick: () => void; } /** Mirrors jspreadsheet-ce's `NestedHeaderCell` (v5). */ export interface NestedHeaderCell { id?: string; colspan?: number; title?: string; align?: string; } /** * @deprecated Use `NestedHeaderCell`. Kept as an alias so existing consumers * that import `SpreadsheetNestedHeader` keep compiling. */ export type SpreadsheetNestedHeader = NestedHeaderCell; /** * Column types supported by the powerduck `Spreadsheet`. * * Empty cells in a `numeric` column are preserved as `''` (and therefore `null` * after the consumer's `toNumber` parse) rather than coerced to the literal * `0` — see `mapColumnsForJspreadsheet` below. * * `nullableNumeric` is kept as a back-compat alias and behaves identically to * `numeric`. New code should use `numeric`. */ export interface SpreadsheetColumn { type: 'text' | 'dropdown' | 'calendar' | 'image' | 'checkbox' | 'numeric' | 'nullableNumeric' | 'color' | 'hidden'; title: string; width: string; source?: string[] | { id: number | string; name: string }[]; mask?: string; decimal?: string; readOnly?: boolean; multiple?: boolean; } /** * jspreadsheet-ce (v5) coerces a cleared `numeric` cell to the literal `0` * unless `allowEmpty: true` is passed (see the closeEditor branch in * `dist/index.js`: `r = column.allowEmpty ? '' : 0`). That coercion is * indistinguishable from a deliberately-priced zero, so cleared price cells * silently become "FREE" rows in admin grids. `allowEmpty: true` is therefore * applied to every numeric column by default; the `nullableNumeric` legacy * alias maps to the same shape. */ const mapColumnsForJspreadsheet = (columns: SpreadsheetColumn[]): any[] => { if (!columns) { return columns as any; } return columns.map((c) => { if (c.type === 'numeric' || c.type === 'nullableNumeric') { return { ...c, type: 'numeric', allowEmpty: true, }; } return c; }); }; @Component class SpreadsheetComponent extends TsxComponent implements SpreadsheetArgs { @Prop() rows: any[][]; @Prop() autoTrackData?: boolean; @Prop() allowToolbar?: boolean; @Prop() columns: SpreadsheetColumn[]; @Prop() columnResize?: boolean; @Prop() rowResize?: boolean; @Prop() rowDrag?: boolean; @Prop() columnsDrag?: boolean; @Prop() rootCssClass?: string; @Prop() nestedHeaders?: NestedHeaderCell[][]; @Prop() beforeChange?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void; @Prop() changed?: (instance?: any, cell?: any, x?: number, y?: number, value?: any) => void; @Prop() afterChanges?: () => void; @Prop() contextMenu?: (el, x, y, e) => SpreadsheetContextMenuItem[]; @Prop() mergeCells?: { [index: string]: number[] }; @Prop() updateTable?: (instance?: any, cell?: any, col?: number, row?: number, val?: any, id?: any) => void; @Prop() deleteRow?: (instance?: any, removedRows?: number[]) => void; @Prop() rowHeight?: number; @Prop({ default: false }) virtualize?: boolean; @Prop({ default: 300 }) virtualWindowSize?: number; @Prop() lazyLoading?: boolean; @Prop() freezeColumns?: number; @Prop() filters?: boolean; @Prop() pagination?: number; @Prop() paginationOptions?: number[]; @Prop() tableOverflow?: boolean; private _instances: any[] = []; // True while a cell editor is open (between jspreadsheet's oneditionstart / // oneditionend). `updated()` reads it to skip the model→grid sync mid-edit. private _editing: boolean = false; private getRuntimeColumnCount(): number { return Math.max(this.columns?.length || 0, 1); } private getRuntimeRows(): any[][] { const rows = getSpreadsheetRowsRuntimeData(this.rows); if (rows.length > 0) { return rows; } return [Array.from({ length: this.getRuntimeColumnCount() }, () => '')]; } mounted() { // `nullableNumeric` columns are mapped to a jspreadsheet `numeric` column // with `allowEmpty`, so clearing a cell commits an empty string. jspreadsheet // skips the write whenever that empty string loose-equals the cell's previous // value — which happens only when the previous value is 0 (`0 == ''` in JS). // The cell is then left showing 0 and no `onchange` fires, so a 0-valued cell // could never be cleared. On edition end, detect that stuck case and force the // empty value through as `null` (`0 != null`), which dispatches `onchange`. // Track active cell editing so `updated()` can skip the model→grid sync while an // editor is open — otherwise setData() closes the editor and discards in-progress // input (the consumer often re-renders mid-edit while flushing into its reactive // model). oneditionend fires for both save and cancel, so `_editing` always clears. const onEditionStart = (): void => { this._editing = true; }; const onEditionEnd = ( instance: any, _td: any, x: number, y: number, editorValue: any, wasSaved: boolean, ): void => { this._editing = false; const column = this.columns?.[x]; if (column == null || column.type !== 'nullableNumeric') { return; } if (!wasSaved || !isNullOrEmpty(editorValue)) { return; } const current = instance?.getValueFromCoords?.(x, y); const currentText = current == null ? '' : String(current).trim(); if (currentText.length > 0 && Number(currentText.replace(',', '.')) === 0) { instance.setValueFromCoords( x, y, null, true, ); } }; const worksheetOptions: any = { data: this.getRuntimeRows(), columns: mapColumnsForJspreadsheet(this.columns), minDimensions: [ this.getRuntimeColumnCount(), 1, ], mergeCells: this.mergeCells || {}, columnResize: this.columnResize != false, rowResize: this.rowResize != false, rowDrag: this.rowDrag != false, columnsDrag: this.columnsDrag != false, nestedHeaders: this.nestedHeaders, contextMenu: this.contextMenu, onbeforechange: this.beforeChange, onchange: this.changed, onafterchanges: this.afterChanges, ondeleterow: this.deleteRow, oneditionstart: onEditionStart, oneditionend: onEditionEnd, }; // Forward optional jspreadsheet-native worksheet options only when the consumer // passed them — leaving them undefined preserves jspreadsheet's own defaults. if (this.lazyLoading != null) { worksheetOptions.lazyLoading = this.lazyLoading; } if (this.freezeColumns != null) { worksheetOptions.freezeColumns = this.freezeColumns; } if (this.filters != null) { worksheetOptions.filters = this.filters; } if (this.pagination != null) { worksheetOptions.pagination = this.pagination; } if (this.paginationOptions != null) { worksheetOptions.paginationOptions = this.paginationOptions; } if (this.tableOverflow != null) { worksheetOptions.tableOverflow = this.tableOverflow; } if (this.updateTable) { worksheetOptions.updateTable = this.updateTable; } // Register the virtualization plugin only when opted in. The factory captures the // requested window size so multiple sheets on a page can each have their own. const plugins: Record any> = {}; if (this.virtualize === true) { const opts: VirtualizationOptions = { windowSize: this.virtualWindowSize ?? 300 }; plugins.virtualization = () => createVirtualizationPlugin(opts); } // jspreadsheet-ce 5.x dispatches lifecycle events (`onchange`, // `onbeforechange`, `onafterchanges`, …) via the TOP-LEVEL spreadsheet // config rather than the worksheet config — see `dispatch.A`: // `r = t.parent ? t.parent : t` // `if (typeof r.config[e] === 'function') r.config[e].apply(...)`. // Mirroring the handlers at both levels keeps the worksheet config self- // describing while ensuring the dispatcher actually fires the callbacks // the consumer wired in. Without the top-level mirror the worksheet // handlers are silently ignored. const spreadsheetOptions: any = { worksheets: [worksheetOptions], toolbar: this.allowToolbar, onbeforechange: this.beforeChange, onchange: this.changed, onafterchanges: this.afterChanges, ondeleterow: this.deleteRow, oneditionstart: onEditionStart, oneditionend: onEditionEnd, }; if (this.updateTable) { spreadsheetOptions.updateTable = this.updateTable; } if (Object.keys(plugins).length > 0) { spreadsheetOptions.plugins = plugins; } this._instances = jspreadsheet(this.$el as HTMLDivElement, spreadsheetOptions); if (this.rowHeight) { this.setRowHeight(); } } private getMainInstance(): any { return this._instances?.[0]; } setRowHeight() { const main = this.getMainInstance(); if (main == null) { return; } const rowCount = this.rows.length; for (let i = 0; i < rowCount; i++) { main.setHeight(i, this.rowHeight); } } insertRow( rowNumber: number, values: string[], height?: number, ) { const main = this.getMainInstance(); if (main == null) { return; } main.insertRow( values, rowNumber, true, ); main.setHeight(rowNumber, height || this.rowHeight); } getData(): any[][] { return this.getMainInstance()?.getData() || []; } dataHaveChanged(): boolean { const main = this.getMainInstance(); if (main == null) { return false; } const currentData = main.getData(); const newData = this.getRuntimeRows(); if (currentData?.length != newData?.length) { return true; } for (let i = 0, len = newData.length; i < len; i++) { const oldRow = currentData[i]; const newRow = newData[i]; if (oldRow?.length != newRow?.length) { return true; } for (let j = 0, lenJ = oldRow.length; j < lenJ; j++) { if (!spreadsheetCellValuesEqual(oldRow[j], newRow[j])) { return true; } } } return false; } updated() { // Don't sync the model into the grid while the user is editing a cell — setData() // would close the open editor and discard in-progress input. The re-render that // fires right after edition ends (when the consumer flushes the committed value) // performs the sync instead. if (this._editing) { return; } if (this.autoTrackData != false && this.dataHaveChanged()) { this.getMainInstance()?.setData(this.getRuntimeRows()); } } render(h) { return
; } } const Spreadsheet = toNative(SpreadsheetComponent); export default Spreadsheet;