fix(sheets): preserve imported workbook semantics and formula caches (#7673)

This commit is contained in:
Univer
2026-09-17 17:13:51 +08:00
committed by GitHub
parent 33770fe732
commit 1484a245ba
192 changed files with 3586 additions and 513 deletions
+12
View File
@@ -34,6 +34,7 @@ import {
DeveloperMetadataVisibility,
Dimension,
Direction,
FormulaType,
HorizontalAlign,
ImageSourceType,
InterpolationPointType,
@@ -52,6 +53,7 @@ import {
ThemeColorType,
UniverInstanceType,
VerticalAlign,
WorksheetHiddenState,
WrapStrategy,
} from '@univerjs/core';
@@ -59,6 +61,16 @@ import {
* @hideconstructor
*/
export class FEnum {
/** SpreadsheetML formula types. */
get FormulaType(): typeof FormulaType {
return FormulaType;
}
/** Worksheet hiding states: VISIBLE (0), HIDDEN (1), VERY_HIDDEN (2). */
get WorksheetHiddenState(): typeof WorksheetHiddenState {
return WorksheetHiddenState;
}
/**
* @ignore
*/
@@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest';
import { Tools } from '../../shared/tools';
import { BooleanNumber } from '../../types/enum';
import { cloneWorksheetData } from '../clone';
import { WorksheetHiddenState } from '../typedef';
function createTestWorksheetData(rowCount: number, colCount: number): IWorksheetData {
const cellData: IWorksheetData['cellData'] = {};
@@ -58,7 +59,7 @@ function createTestWorksheetData(rowCount: number, colCount: number): IWorksheet
id: 'test-sheet-id',
name: 'Test Sheet',
tabColor: '#FF0000',
hidden: BooleanNumber.FALSE,
hidden: WorksheetHiddenState.VERY_HIDDEN,
freeze: {
xSplit: 0,
ySplit: 0,
@@ -112,6 +113,8 @@ describe('cloneWorksheetData', () => {
expect(cloned.id).toBe(original.id);
expect(cloned.name).toBe(original.name);
expect(cloned.tabColor).toBe(original.tabColor);
expect(cloned.hidden).toBe(WorksheetHiddenState.VERY_HIDDEN);
expect(cloned).not.toHaveProperty('visibility');
expect(cloned.rowCount).toBe(original.rowCount);
expect(cloned.columnCount).toBe(original.columnCount);
@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { BooleanNumber } from '../../types/enum';
import { cloneCellData, cloneCellDataWithSpanAndDisplay } from '../clone';
import { FormulaType } from '../typedef';
describe('formula metadata cloning', () => {
it.each([BooleanNumber.FALSE, BooleanNumber.TRUE])('retains the array kind and dynamic flag %s in cell and clipboard clones', (fd) => {
const source = { f: '=SUM(A1:A2)', ft: FormulaType.ARRAY, fd };
expect(cloneCellData(source)).toEqual(source);
expect(cloneCellDataWithSpanAndDisplay({ ...source, rowSpan: 2, displayV: '3' })).toEqual({
...source,
rowSpan: 2,
displayV: '3',
});
expect(cloneCellData({ v: 'text' })).toEqual({ v: 'text' });
});
});
@@ -18,8 +18,9 @@ import type { Univer } from '../../univer';
import type { IWorkbookData } from '../typedef';
import type { Workbook } from '../workbook';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { BooleanNumber, DateSystem } from '../../types/enum';
import { DateSystem } from '../../types/enum';
import { LocaleType } from '../../types/enum/locale-type';
import { WorksheetHiddenState } from '../typedef';
import { createCoreTestBed } from './create-core-test-bed';
describe('Test workbook', () => {
@@ -131,7 +132,7 @@ describe('Test workbook', () => {
styles: {},
sheetOrder: ['s1', 's2'],
sheets: {
s1: { id: 's1', name: 'Hidden', hidden: BooleanNumber.TRUE },
s1: { id: 's1', name: 'Hidden', hidden: WorksheetHiddenState.VERY_HIDDEN },
s2: { id: 's2', name: 'Visible' },
},
};
@@ -143,6 +144,8 @@ describe('Test workbook', () => {
expect(hiddenWorkbook.getActiveSheet().getSheetId()).toBe('s2');
expect(hiddenWorkbook.getActiveSheetIndex()).toBe(1);
expect(hiddenWorkbook.getHiddenWorksheets()).toEqual(['s1']);
expect(hiddenWorkbook.getHiddenWorksheets(false)).toEqual([]);
expect(hiddenWorkbook.getSheetBySheetId('s1')?.getHiddenState()).toBe(WorksheetHiddenState.VERY_HIDDEN);
expect(hiddenWorkbook.getUnhiddenWorksheets()).toEqual(['s2']);
expect(hiddenWorkbook.checkSheetName('visible')).toBe(true);
+9 -50
View File
@@ -95,6 +95,14 @@ export function cloneCellData(cell: Nullable<ICellData>): Nullable<ICellData> {
result.f = cell.f;
}
if (cell.ft !== undefined) {
result.ft = cell.ft;
}
if (cell.fd !== undefined) {
result.fd = cell.fd;
}
// ref - formula array reference
if (cell.ref !== undefined) {
result.ref = cell.ref;
@@ -129,56 +137,7 @@ export function cloneCellDataWithSpanAndDisplay(cell: Nullable<ICellDataWithSpan
return cell;
}
const result: ICellDataWithSpanAndDisplay = {};
// p - IDocumentData (complex object, needs deep clone)
if (cell.p !== undefined) {
result.p = cell.p === null ? null : cloneValue(cell.p);
}
// s - style id (string) or IStyleData (object)
if (cell.s !== undefined) {
if (cell.s === null || typeof cell.s === 'string') {
result.s = cell.s;
} else {
result.s = cloneValue(cell.s);
}
}
// v - primitive value (string | number | boolean)
if (cell.v !== undefined) {
result.v = cell.v;
}
// t - CellValueType (number enum)
if (cell.t !== undefined) {
result.t = cell.t;
}
// f - formula string
if (cell.f !== undefined) {
result.f = cell.f;
}
// ref - formula array reference
if (cell.ref !== undefined) {
result.ref = cell.ref;
}
// xf - Excel formula prefix
if (cell.xf !== undefined) {
result.xf = cell.xf;
}
// si - formula id
if (cell.si !== undefined) {
result.si = cell.si;
}
// custom - user stored custom fields
if (cell.custom !== undefined) {
result.custom = cell.custom === null ? null : cloneValue(cell.custom);
}
const result: ICellDataWithSpanAndDisplay = cloneCellData(cell)!;
// rowSpan - span properties (primitives)
if (cell.rowSpan !== undefined) {
+32 -7
View File
@@ -89,6 +89,16 @@ export interface IWorkbookData {
custom?: CustomData;
}
/** Worksheet-only hiding states; row/column and cell flags still use BooleanNumber. */
export enum WorksheetHiddenState {
/** Visible in the sheet bar. Preserves the existing hidden: 0 value. */
VISIBLE = 0,
/** Hidden, and listed in the UI's Unhide dialog. Preserves hidden: 1. */
HIDDEN = 1,
/** Omitted from Unhide UI; can be revealed through the API. Not an access permission. */
VERY_HIDDEN = 2,
}
/**
* Snapshot of a worksheet.
*/
@@ -104,14 +114,12 @@ export interface IWorksheetData {
tabColor: string;
/**
* Determine whether the sheet is hidden.
*
* @remarks
* See {@link BooleanNumber| the BooleanNumber enum} for more details.
*
* @defaultValue `BooleanNumber.FALSE`
* Sheet hiding state: 0 = visible, 1 = hidden, 2 = very hidden.
* BooleanNumber remains accepted for existing 0/1 snapshots and integrations.
* Use WorksheetHiddenState for new code; do not interpret this as a boolean flag.
* @defaultValue WorksheetHiddenState.VISIBLE
*/
hidden: BooleanNumber;
hidden: WorksheetHiddenState | BooleanNumber;
freeze: IFreeze;
@@ -246,6 +254,15 @@ export interface IColAutoWidthInfo {
*/
export type CellValue = string | number | boolean;
/** Formula kinds from SpreadsheetML ST_CellFormulaType. */
export enum FormulaType {
NORMAL = 0,
SHARED = 1,
ARRAY = 2,
/** What-if analysis data table, not a structured table. */
DATA_TABLE = 3,
}
/**
* Cell data
*/
@@ -272,6 +289,12 @@ export interface ICellData {
*/
f?: Nullable<string>;
/** Formula type, mapped from OOXML f@t. Omission preserves existing runtime behavior. */
ft?: Nullable<FormulaType>;
/** Dynamic array flag, mapped from the referenced dynamicArrayProperties@fDynamic metadata. */
fd?: Nullable<BooleanNumber>;
/**
* If the formula is a formula array, this field is used to store the referencing range.
* @ignore
@@ -349,6 +372,8 @@ export function isICellData(value: any): value is ICellData {
(value as ICellData).v !== undefined ||
(value as ICellData).t !== undefined ||
(value as ICellData).f !== undefined ||
(value as ICellData).ft !== undefined ||
(value as ICellData).fd !== undefined ||
(value as ICellData).si !== undefined ||
(value as ICellData).custom !== undefined)
);
+5 -3
View File
@@ -27,6 +27,7 @@ import { BooleanNumber } from '../types/enum';
import { DateSystem } from '../types/enum/date-system';
import { getEmptySnapshot } from './empty-snapshot';
import { Styles } from './styles';
import { WorksheetHiddenState } from './typedef';
import { Worksheet } from './worksheet';
export function getWorksheetUID(workbook: Workbook, worksheet: Worksheet): string {
@@ -385,15 +386,16 @@ export class Workbook extends UnitModel<IWorkbookData, UniverInstanceType.UNIVER
return this._worksheets.get(sheetOrder[index]);
}
getHiddenWorksheets(): string[] {
getHiddenWorksheets(includeVeryHidden = true): string[] {
return this.getSheets()
.filter((s) => s.getConfig().hidden === BooleanNumber.TRUE)
.filter((s) => s.isSheetHidden() === BooleanNumber.TRUE)
.filter((s) => includeVeryHidden || s.getHiddenState() !== WorksheetHiddenState.VERY_HIDDEN)
.map((s) => s.getConfig().id);
}
getUnhiddenWorksheets(): string[] {
return this.getSheets()
.filter((s) => s.getConfig().hidden !== BooleanNumber.TRUE)
.filter((s) => s.isSheetHidden() !== BooleanNumber.TRUE)
.map((s) => s.getConfig().id);
}
+15 -8
View File
@@ -17,7 +17,7 @@
import type { IDisposable } from '../common/di';
import type { IInterceptor } from '../common/interceptor';
import type { IObjectMatrixPrimitiveType, Nullable } from '../shared';
import type { BooleanNumber, HorizontalAlign, TextDirection, VerticalAlign, WrapStrategy } from '../types/enum';
import type { HorizontalAlign, TextDirection, VerticalAlign, WrapStrategy } from '../types/enum';
import type { IDocumentData, IDocumentRenderConfig, IPaddingData, IStyleData, ITextRotation } from '../types/interfaces';
import type { Styles } from './styles';
import type {
@@ -36,14 +36,14 @@ import { composeStyles, ObjectMatrix, toDisposable, Tools } from '../shared';
import { generateRandomId } from '../shared/random-id';
import { createRowColIter } from '../shared/row-col-iter';
import { DEFAULT_STYLES } from '../types/const';
import { CellValueType } from '../types/enum';
import { BooleanNumber, CellValueType } from '../types/enum';
import { DocumentFlavor } from '../types/interfaces';
import { ColumnManager } from './column-manager';
import { Range } from './range';
import { RowManager } from './row-manager';
import { mergeWorksheetSnapshotWithDefault } from './sheet-snapshot-utils';
import { SpanModel } from './span-model';
import { CellModeEnum } from './typedef';
import { CellModeEnum, WorksheetHiddenState } from './typedef';
import {
addLinkToDocumentModel,
createDocumentModelWithStyle,
@@ -828,12 +828,19 @@ export class Worksheet {
this._snapshot.columnCount = count;
}
/**
* isSheetHidden
* @returns hidden status of sheet
*/
/** Returns 0 for visible, 1 for either hidden state, preserving the existing predicate API. */
isSheetHidden(): BooleanNumber {
return this._snapshot.hidden;
return this._snapshot.hidden === WorksheetHiddenState.VISIBLE ? BooleanNumber.FALSE : BooleanNumber.TRUE;
}
/** Returns the complete persisted state, including VERY_HIDDEN (2). */
getHiddenState(): WorksheetHiddenState {
return this._snapshot.hidden as WorksheetHiddenState;
}
/** Model mutation primitive. User-facing changes must go through worksheet hide/show commands. */
setHiddenState(hidden: WorksheetHiddenState | BooleanNumber): void {
this._snapshot.hidden = hidden;
}
/**
+2 -1
View File
@@ -245,7 +245,8 @@ export interface IUnitExcludedCell {
}
export interface IUnitRowData {
[unitId: string]: Nullable<{ [sheetId: string]: IObjectArrayPrimitiveType<Partial<IRowData>> }>;
// Calculation transport only: distinguish filter exclusions from manually hidden rows in Workers.
[unitId: string]: Nullable<{ [sheetId: string]: IObjectArrayPrimitiveType<Partial<IRowData> & { filtered?: boolean }> }>;
}
export interface IFormulaDatasetConfig {
+8 -34
View File
@@ -98,41 +98,13 @@ export function calculateMdeterm(matrix: number[][]): number {
}
export function calculateMinverse(matrix: number[][]): number[][] | null {
const det = calculateMdeterm(matrix);
const { smallPivotDetected, luMatrix, permutation } = performLUDecomposition(matrix, false, Number.EPSILON);
if (det === 0) {
if (smallPivotDetected) {
return null; // Matrix is irreversible
}
if (matrix.length === 1) {
return [[1 / det]];
}
const adjugate = adjoint(matrix);
const inverseMatrix = adjugate.map((row) => row.map((value) => value / det));
return inverseMatrix;
}
function minor(matrix: number[][], row: number, col: number): number[][] {
return matrix
.filter((_, r) => r !== row)
.map((row) => row.filter((_, c) => c !== col));
}
function adjoint(matrix: number[][]): number[][] {
const n = matrix.length;
const adj = Array.from({ length: n }, () => new Array(n).fill(0));
for (let i = 0; i < n; i++) {
for (let j = 0; j < n; j++) {
const sign = ((i + j) % 2 === 0 ? 1 : -1);
const res = sign * calculateMdeterm(minor(matrix, i, j));
adj[j][i] = (res === 0 ? 0 : res); // deal with -0 case
}
}
return adj;
return transformMatrix(luMatrix, permutation);
}
export function calculateMmult(matrix1: number[][], matrix2: number[][]): number[][] {
@@ -155,8 +127,8 @@ export function inverseMatrixByLUD(matrix: number[][]) {
return transformMatrix(luMatrix, permutation);
}
function performLUDecomposition(matrix: number[][]) {
const decomposedMatrix = matrixTranspose(matrix);
function performLUDecomposition(matrix: number[][], transpose: boolean = true, minimumPivot: number = 1e-11) {
const decomposedMatrix = transpose ? matrixTranspose(matrix) : matrix.map((row) => [...row]);
const numRows = decomposedMatrix.length;
const numCols = decomposedMatrix[0].length;
let isRowSwap = true;
@@ -195,7 +167,9 @@ function performLUDecomposition(matrix: number[][]) {
}
}
if (Math.abs(luMatrix[pivotRow][c]) < 1e-11) {
const pivot = Math.abs(luMatrix[pivotRow][c]);
if (!Number.isFinite(pivot) || pivot <= minimumPivot) {
smallPivotDetected = true;
break;
}
+1 -1
View File
@@ -34,7 +34,7 @@ export const RANGE_SYMBOL = '\\s*?:\\s*?';
const COLUMN_REGEX = '[A-Za-z]+';
const ROW_REGEX = '[1-9][0-9]*';
export const UNIT_NAME_SHEET_NAME_REGEX = `'?(${UNIT_NAME_REGEX})?(${SHEET_NAME_REGEX})?'?`;
export const UNIT_NAME_SHEET_NAME_REGEX = `(?:'(?:[^'\\[]|'')*[\\\\/]|'?)(${UNIT_NAME_REGEX})?(${SHEET_NAME_REGEX})?'?`;
export const SIMPLE_SINGLE_RANGE_REGEX = `\\${ABSOLUTE_SYMBOL}?${COLUMN_REGEX}\\${ABSOLUTE_SYMBOL}?${ROW_REGEX}`;
@@ -1025,6 +1025,85 @@ function getSlopeAndInterceptOfConstbIsFalse(knownXsValues: number[], knownYsVal
};
}
function solveLeastSquaresByQR(matrix: number[][], values: number[]): number[] | null {
const rows = matrix.length;
const columns = matrix[0]?.length ?? 0;
if (!columns || rows < columns || values.length !== rows || matrix.some((row) => row.length !== columns)) {
return null;
}
const qr = matrix.map((row) => [...row]);
const scales: number[] = [];
for (let column = 0; column < columns; column++) {
let sumSquares = 0;
for (let row = 0; row < rows; row++) {
sumSquares += qr[row][column] ** 2;
}
const scale = Math.sqrt(sumSquares);
if (!Number.isFinite(scale) || scale === 0) {
return null;
}
scales.push(scale);
for (let row = 0; row < rows; row++) {
qr[row][column] /= scale;
}
}
const transformed = [...values];
for (let pivot = 0; pivot < columns; pivot++) {
let sumSquares = 0;
for (let row = pivot; row < rows; row++) {
sumSquares += qr[row][pivot] ** 2;
}
const norm = Math.sqrt(sumSquares);
if (!Number.isFinite(norm) || norm <= Number.EPSILON * rows) {
return null;
}
const alpha = qr[pivot][pivot] >= 0 ? -norm : norm;
const reflector = Array.from({ length: rows - pivot }, (_, offset) => qr[pivot + offset][pivot]);
reflector[0] -= alpha;
const reflectorNorm = reflector.reduce((sum, value) => sum + value ** 2, 0);
if (!Number.isFinite(reflectorNorm) || reflectorNorm === 0) {
return null;
}
for (let column = pivot; column < columns; column++) {
let projection = 0;
for (let offset = 0; offset < reflector.length; offset++) {
projection += reflector[offset] * qr[pivot + offset][column];
}
const factor = 2 * projection / reflectorNorm;
for (let offset = 0; offset < reflector.length; offset++) {
qr[pivot + offset][column] -= factor * reflector[offset];
}
}
let projection = 0;
for (let offset = 0; offset < reflector.length; offset++) {
projection += reflector[offset] * transformed[pivot + offset];
}
const factor = 2 * projection / reflectorNorm;
for (let offset = 0; offset < reflector.length; offset++) {
transformed[pivot + offset] -= factor * reflector[offset];
}
}
const coefficients = new Array(columns).fill(0);
for (let row = columns - 1; row >= 0; row--) {
const diagonal = qr[row][row];
if (!Number.isFinite(diagonal) || Math.abs(diagonal) <= Number.EPSILON * rows) {
return null;
}
let remainder = 0;
for (let column = row + 1; column < columns; column++) {
remainder += qr[row][column] * coefficients[column];
}
coefficients[row] = (transformed[row] - remainder) / diagonal;
}
return coefficients.map((coefficient, index) => coefficient / scales[index]);
}
export function getKnownsArrayCoefficients(knownYsValues: number[][], knownXsValues: number[][], newXsValues: number[][], constb: number, isExponentialTransform: boolean) {
const isOneRow = knownYsValues.length === 1 && knownYsValues[0].length > 1;
@@ -1049,8 +1128,6 @@ export function getKnownsArrayCoefficients(knownYsValues: number[][], knownXsVal
const XT = matrixTranspose(X);
const XTX = calculateMmult(XT, X);
const XTY = calculateMmult(XT, Y);
let XTXInverse = inverseMatrixByLUD(XTX);
if (!XTXInverse) {
@@ -1061,14 +1138,17 @@ export function getKnownsArrayCoefficients(knownYsValues: number[][], knownXsVal
}
}
let coefficients = calculateMmult(XTXInverse, XTY);
const solved = solveLeastSquaresByQR(X, Y.map((row) => row[0]));
// Preserve the pseudoinverse solution for rank-deficient input.
const coefficients = solved
? [solved]
: matrixTranspose(calculateMmult(XTXInverse, calculateMmult(XT, Y)));
if (!constb) {
coefficients.push([0]);
coefficients[0].push(0);
}
coefficients = matrixTranspose(coefficients);
const pop = coefficients[0].pop() as number;
coefficients[0].reverse();
@@ -1162,13 +1242,17 @@ const NESTED_AGGREGATE_FORMULA_CACHE = new WeakMap<FormulaDataModel, Map<string,
// Check if the cell is a nested SUBTOTAL or AGGREGATE result
function isNestedAggregateOrSubtotal(
cellData: ObjectMatrix<ICellData>,
cellData: Nullable<ObjectMatrix<ICellData>>,
rowIndex: number,
columnIndex: number,
sheetId: string,
unitId: string,
formulaDataModel: FormulaDataModel
): boolean {
if (!cellData) {
return false;
}
const cellValue = cellData.getValue(rowIndex, columnIndex);
if (!cellValue?.f && !cellValue?.si) {
@@ -19,6 +19,7 @@ import type { FormulaDependencyTreeVirtual } from '../../dependency/dependency-t
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ErrorType } from '../../../basics/error-type';
import { IFormulaCurrentConfigService } from '../../../services/current-data.service';
import { IFeatureCalculationManagerService } from '../../../services/feature-calculation-manager.service';
import { IOtherFormulaManagerService } from '../../../services/other-formula-manager.service';
import { IFormulaRuntimeService } from '../../../services/runtime.service';
import { FormulaDependencyTree } from '../../dependency/dependency-tree';
@@ -297,6 +298,59 @@ describe('Test dependency', () => {
]);
});
it('orders a newly registered feature after formulas in its dependency range', async () => {
formulaCurrentConfigService.load({
formulaData: {
[testUnitId]: {
[testSheetId]: {
0: {
1: { f: '=A1' },
},
},
},
},
arrayFormulaCellData: {},
arrayFormulaRange: {},
forceCalculate: false,
dirtyRanges: [{
unitId: testUnitId,
sheetId: testSheetId,
range: { startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
}],
dirtyNameMap: {},
dirtyDefinedNameMap: {},
dirtyUnitFeatureMap: {},
dirtyUnitOtherFormulaMap: {},
excludedCell: {},
allUnitData: {
[testUnitId]: testSheetData,
},
});
get(IFeatureCalculationManagerService).register(testUnitId, testSheetId, 'feature-1', {
unitId: testUnitId,
subUnitId: testSheetId,
dependencyRanges: [{
unitId: testUnitId,
sheetId: testSheetId,
range: { startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 },
}],
getDirtyData: () => ({
runtimeCellData: {},
dirtyRanges: {
[testUnitId]: {
[testSheetId]: [{ startRow: 0, endRow: 0, startColumn: 2, endColumn: 2 }],
},
},
}),
});
const treeList = await formulaDependencyGenerator.generate();
const executionOrder = treeList.toReversed().map((tree) => tree.featureId ?? tree.formula);
expect(executionOrder).toEqual(['=A1', 'feature-1']);
});
it('ignores a non-reference result while collecting dependency ranges', async () => {
formulaCurrentConfigService.load({
formulaData: {
@@ -712,6 +712,11 @@ describe('lexer nodeMaker test', () => {
});
describe('moveFormulaRefOffset', () => {
it('preserves escaped quotes and reference-like text while moving cell references', () => {
expect(lexerTreeBuilder.moveFormulaRefOffset('=IF(A1="a""b","[@Column]","""quoted""")', 1, 1))
.toBe('=IF(B2="a""b","[@Column]","""quoted""")');
});
it('move all', () => {
const result = lexerTreeBuilder.moveFormulaRefOffset('=sum(A1:B1)', 1, 1, false);
expect(result).toStrictEqual('=sum(B2:C2)');
@@ -992,6 +997,12 @@ describe('lexer nodeMaker test', () => {
expect(JSON.stringify(exprTreeNode)).toStrictEqual('{"value":"SUM(Table1[Column1])+MAX(Table2[ColumnA],Table2[ColumnB])","children":[{"value":"SUM(Table1[Column1])","children":[{"value":"Table1[Column1]","children":[],"startIndex":-1}],"startIndex":0},{"value":"MAX(Table2[ColumnA],Table2[ColumnB])","children":[{"value":"Table2[ColumnA]","children":[],"startIndex":-1},{"value":"Table2[ColumnB]","children":[],"startIndex":-1}],"startIndex":23}],"startIndex":-1}');
});
it('keeps missing table references in the expression tree', () => {
const exprTreeNode = lexerTreeBuilder.getFormulaExprTree('=SUM(Tout[Quantity])', 'mockUnitId', hasFunction, getDefinedNameName, getTable) || {};
expect(JSON.stringify(exprTreeNode)).toStrictEqual('{"value":"SUM(Tout[Quantity])","children":[{"value":"Tout[Quantity]","children":[],"startIndex":-1}],"startIndex":0}');
});
it('builds ExprTree with mixed defined names and table references', () => {
const exprTreeNode = lexerTreeBuilder.getFormulaExprTree('=SUM(defineName1, Table1[Column1]) + OFFSET(Table2[ColumnC], defineName2, 0)', 'mockUnitId', hasFunction, getDefinedNameName, getTable) || {};
@@ -23,7 +23,14 @@ import { AbsoluteRefType, Disposable, isValidRange, MAX_COLUMN_COUNT, MAX_ROW_CO
import { FormulaAstLRU } from '../../basics/cache-lru';
import { ERROR_TYPE_COUNT_ARRAY, ERROR_TYPE_SET, ErrorType } from '../../basics/error-type';
import { isFormulaLexerToken, isTokenCannotBeAtEnd, isTokenCannotPrecedeSuffixToken } from '../../basics/match-token';
import { isReferenceString, regexTestSingeRange } from '../../basics/regex';
import {
isReferenceString,
regexTestReferenceTableAllColumn,
regexTestReferenceTableMultipleColumn,
regexTestReferenceTableSingleColumn,
regexTestReferenceTableTitleOnlyAnyHash,
regexTestSingeRange,
} from '../../basics/regex';
import {
matchToken,
OPERATOR_TOKEN_PRIORITY,
@@ -390,6 +397,11 @@ export class LexerTreeBuilder extends Disposable {
for (let i = 0, len = sequenceNodes.length; i < len; i++) {
const node = sequenceNodes[i];
if (typeof node !== 'string' && node.nodeType === sequenceNodeType.STRING) {
// The lexer decodes doubled quotes; serializing a formula must escape them again.
newSequenceNodes.push({ ...node, token: `"${node.token.slice(1, -1).replace(/"/g, '""')}"` });
continue;
}
if (typeof node === 'string' || node.nodeType !== sequenceNodeType.REFERENCE) {
newSequenceNodes.push(node);
continue;
@@ -2485,7 +2497,13 @@ export class LexerTreeBuilder extends Disposable {
private _getTableNameFromStructuredRef(token: string, getTable: (unitId: string, tableName: string) => Nullable<ISuperTable>) {
const { tableName } = splitTableStructuredRef(token);
if (getTable(this._currentUnitId, tableName)) {
const isExplicitStructuredReference = token.includes('[') && (
regexTestReferenceTableAllColumn(token)
|| regexTestReferenceTableSingleColumn(token)
|| regexTestReferenceTableMultipleColumn(token)
|| regexTestReferenceTableTitleOnlyAnyHash(token)
);
if (getTable(this._currentUnitId, tableName) || isExplicitStructuredReference) {
return tableName;
}
return null;
@@ -16,10 +16,13 @@
import type { IFormulaCurrentConfigService } from '../../../services/current-data.service';
import type { IFormulaUnitReferenceResolver } from '../../../services/unit-reference-resolver.service';
import type { BaseValueObject } from '../../value-object/base-value-object';
import { describe, expect, it, vi } from 'vitest';
import { ErrorType } from '../../../basics/error-type';
import { ReferenceObjectType } from '../../utils/value-object';
import { ErrorValueObject } from '../../value-object/base-value-object';
import { BaseAstNode } from '../base-ast-node';
import { ReferenceNode } from '../reference-node';
import { ReferenceNode, ReferenceNodeFactory } from '../reference-node';
import { UnionNode } from '../union-node';
describe('ReferenceNode external range loading', () => {
@@ -33,6 +36,7 @@ describe('ReferenceNode external range loading', () => {
getUnitStylesData: () => ({}),
};
const runtime = {
markExternalReferenceUnavailable: vi.fn(),
currentUnitId: 'host',
currentSubUnitId: 'host-sheet',
currentRow: 0,
@@ -82,4 +86,78 @@ describe('ReferenceNode external range loading', () => {
token: "'[Sales]Data'!A1:B2",
}));
});
it('returns REF for a missing local structured table reference', () => {
const currentConfig = {
getSheetNameMap: () => ({}),
getUnitData: () => ({}),
getArrayFormulaCellData: () => ({}),
getArrayFormulaRange: () => ({}),
getUnitStylesData: () => ({}),
};
const runtime = {
markExternalReferenceUnavailable: vi.fn(),
currentUnitId: 'host',
currentSubUnitId: 'host-sheet',
currentRow: 0,
currentColumn: 0,
getUnitData: () => ({}),
getRuntimeArrayFormulaCellData: () => ({}),
getUnitArrayFormula: () => ({}),
getRuntimeFeatureCellData: () => ({}),
};
const factory = new ReferenceNodeFactory(
currentConfig as never,
runtime as never,
{} as never,
{
getTableMap: () => null,
getTableOptionMap: () => new Map(),
} as never,
{ resolve: () => ({ unitId: 'host' }) } as IFormulaUnitReferenceResolver,
{ load: vi.fn() } as never
);
const node = factory.checkAndCreateNodeType('Tout[Quantity]');
expect(node).toBeInstanceOf(ReferenceNode);
node?.execute();
expect((node?.getValue() as BaseValueObject).getValue()).toBe(ErrorType.REF);
});
it('keeps ordinary cell references out of the structured-table path', () => {
const currentConfig = {
getSheetNameMap: () => ({}),
getUnitData: () => ({}),
getArrayFormulaCellData: () => ({}),
getArrayFormulaRange: () => ({}),
getUnitStylesData: () => ({}),
};
const runtime = {
markExternalReferenceUnavailable: vi.fn(),
currentUnitId: 'host',
currentSubUnitId: 'host-sheet',
currentRow: 0,
currentColumn: 0,
getUnitData: () => ({}),
getRuntimeArrayFormulaCellData: () => ({}),
getUnitArrayFormula: () => ({}),
getRuntimeFeatureCellData: () => ({}),
};
const factory = new ReferenceNodeFactory(
currentConfig as never,
runtime as never,
{} as never,
{
getTableMap: () => null,
getTableOptionMap: () => new Map(),
} as never,
{ resolve: () => ({ unitId: 'host' }) } as IFormulaUnitReferenceResolver,
{ load: vi.fn() } as never
);
const node = factory.checkAndCreateNodeType('G2');
expect(node).toBeInstanceOf(ReferenceNode);
node?.execute();
expect(node?.getValue()).not.toBeInstanceOf(ErrorValueObject);
});
});
@@ -57,6 +57,7 @@ type ReferenceNodeCurrentConfigService = Pick<
type ReferenceNodeRuntimeService = Pick<
IFormulaRuntimeService,
| 'markExternalReferenceUnavailable'
| 'currentColumn'
| 'currentRow'
| 'currentSubUnitId'
@@ -111,6 +112,7 @@ export class ReferenceNode extends BaseAstNode {
hostUnitId: runtimeService.currentUnitId,
qualifier: unitQualifier,
referenceKind: 'table',
onUnavailable: () => runtimeService.markExternalReferenceUnavailable(),
});
if (typeof resolution === 'string') {
this.setValue(ErrorValueObject.create(resolution));
@@ -138,6 +140,7 @@ export class ReferenceNode extends BaseAstNode {
hostUnitId: runtimeService.currentUnitId,
qualifier: unitQualifier,
referenceKind: 'a1',
onUnavailable: () => runtimeService.markExternalReferenceUnavailable(),
});
if (typeof resolution === 'string') {
this.setValue(ErrorValueObject.create(resolution));
@@ -171,6 +174,7 @@ export class ReferenceNode extends BaseAstNode {
hostUnitId,
qualifier: unitQualifier,
referenceKind,
onUnavailable: () => this._runtimeService.markExternalReferenceUnavailable(),
});
if (typeof resolution === 'string') {
this.setValue(ErrorValueObject.create(resolution));
@@ -189,6 +193,9 @@ export class ReferenceNode extends BaseAstNode {
resolution,
});
if (error) {
if (error === ErrorType.NA) {
this._runtimeService.markExternalReferenceUnavailable();
}
this.setValue(ErrorValueObject.create(error));
return AstNodePromiseType.ERROR;
}
@@ -354,25 +361,21 @@ export class ReferenceNodeFactory extends BaseAstNodeFactory {
}
private _getTableReferenceNode(tokenTrim: string, isPrepareMerge: boolean, isSuperTableDirectly: boolean = false) {
if (!this._checkTokenIsTableReference(tokenTrim) && !isSuperTableDirectly) {
if ((!tokenTrim.includes('[') || !this._checkTokenIsTableReference(tokenTrim)) && !isSuperTableDirectly) {
return;
}
const { unitQualifier, tableName, columnStruct } = splitTableStructuredRef(tokenTrim);
const tableMap = this._getTableMap();
const hasLocalTable = Array.from(tableMap?.keys() || []).some((name) => name.toLocaleLowerCase() === tableName.toLocaleLowerCase());
if (unitQualifier || hasLocalTable) {
return new ReferenceNode(
this._currentConfigService,
this._formulaRuntimeService,
tokenTrim,
ReferenceObjectType.COLUMN,
this._unitReferenceResolver,
this._superTableService,
this._externalReferenceDataLoader,
isPrepareMerge,
{ unitQualifier, tableName, columnStruct }
);
}
return new ReferenceNode(
this._currentConfigService,
this._formulaRuntimeService,
tokenTrim,
ReferenceObjectType.COLUMN,
this._unitReferenceResolver,
this._superTableService,
this._externalReferenceDataLoader,
isPrepareMerge,
{ unitQualifier, tableName, columnStruct }
);
}
private _checkTokenIsTableReference(token: string): boolean {
@@ -281,8 +281,7 @@ export class FormulaDependencyGenerator extends Disposable implements IFormulaDe
this._dependencyTreeCache.set(FDtree.treeId, FDtree);
const treeCache = this._dependencyManagerService.getFeatureFormulaDependency(params.unitId, params.subUnitId, featureId);
if (treeCache) {
if (treeId != null) {
FDtree.isCache = true;
}
@@ -117,6 +117,39 @@ describe('TableReferenceObject current row', () => {
});
});
it('matches escaped hash characters in table column names', () => {
const table = {
sheetId: 'sheet',
titleMap: new Map([['Account #', 1], ['Unit #', 2]]),
range: { startRow: 0, endRow: 3, startColumn: 0, endColumn: 2 },
};
expect(new TableReferenceObject(
"Table1[Account '#]",
table,
"[Account '#]",
options
).getRangeData()).toEqual({
startRow: 1,
endRow: 3,
startColumn: 1,
endColumn: 1,
});
const currentRow = new TableReferenceObject(
"Table1[[#This Row],[Unit '#]]",
table,
"[[#This Row],[Unit '#]]",
options
);
currentRow.setCurrentRowAndColumn(2, 4);
expect(currentRow.getRangeData()).toEqual({
startRow: 2,
endRow: 2,
startColumn: 2,
endColumn: 2,
});
});
it('treats commas inside single-bracket column titles as title text', () => {
const reference = new TableReferenceObject('Table1[Deposit,\r\nCredit (+)]', {
sheetId: 'sheet',
@@ -349,7 +349,7 @@ export class TableReferenceObject extends BaseReferenceObject {
/** Column title → column index; returns -1 if not found (caller should handle as parse error) */
private _titleToIndex(name: string, titleMap: Map<string, number>): number {
const key = name.trim();
const key = this._unescapeColumnName(name.trim());
const hit = titleMap.get(key);
if (hit !== undefined) return hit;
@@ -367,6 +367,10 @@ export class TableReferenceObject extends BaseReferenceObject {
return -1;
}
private _unescapeColumnName(name: string): string {
return name.replace(/'(['#\[\]@])/g, '$1');
}
/** Resolve #This Row's row number; takes first data row (tableStartRow+1) when no context available */
private _resolveThisRow(dataStartRow: number, dataEndRow: number): number {
this._isCurrentRowForRange = true;
@@ -210,6 +210,10 @@ export function handleRefStringInfo(refString: string) {
if (unitIdMatch != null) {
unitQualifier = unitIdMatch[0].trim();
unitQualifier = unquoteSheetName(unitQualifier.slice(1, unitQualifier.length - 1));
const pathPrefix = refString.slice(0, unitIdMatch.index);
if (pathPrefix.startsWith("'") && /[\\/]$/.test(pathPrefix)) {
refString = `'${refString.slice(unitIdMatch.index)}`;
}
refString = refString.replace(UNIT_NAME_REGEX_PRECOMPILING, '');
}
@@ -33,6 +33,18 @@ describe('StringValueObject', () => {
expect(date1900Value.convertToNumberObjectValue().getValue()).toBe(1462);
expect(date1904Value.convertToNumberObjectValue().getValue()).toBe(0);
expect(date1900Value.convertToNumberObjectValue().getValue()).toBe(1462);
expect(
StringValueObject.create('March 2025')
.withDateSystem(DateSystem.Date1904)
.convertToNumberObjectValue()
.getValue()
).toBe(44255);
expect(
StringValueObject.create('April2024')
.withDateSystem(DateSystem.Date1904)
.convertToNumberObjectValue()
.getValue()
).toBe(43921);
});
it('does not reuse a hyperlink object as a plain string', () => {
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DateSystem, getNumfmtParseValueFilter, isRealNum, numfmt, Tools } from '@univerjs/core';
import { DateSystem, excelDateSerial, getNumfmtParseValueFilter, isRealNum, numfmt, Tools } from '@univerjs/core';
import { FormulaAstLRU } from '../../basics/cache-lru';
import { reverseCompareOperator } from '../../basics/calculate';
import { BooleanValue, ConcatenateType } from '../../basics/common';
@@ -29,6 +29,45 @@ export type PrimitiveValueType = string | boolean | number | null;
export type FormulaFunctionValueType = PrimitiveValueType | PrimitiveValueType[][] | BaseValueObject;
export type FormulaFunctionResultValueType = PrimitiveValueType | PrimitiveValueType[][];
function parseCompactEnglishMonthYear(rawValue: string, dateSystem: DateSystem): number | null {
const match = rawValue.trim().match(/^([A-Za-z]{3,9})(\d{4})$/);
if (!match) {
return null;
}
const months: Record<string, number> = {
jan: 0,
january: 0,
feb: 1,
february: 1,
mar: 2,
march: 2,
apr: 3,
april: 3,
may: 4,
jun: 5,
june: 5,
jul: 6,
july: 6,
aug: 7,
august: 7,
sep: 8,
sept: 8,
september: 8,
oct: 9,
october: 9,
nov: 10,
november: 10,
dec: 11,
december: 11,
};
const month = months[match[1].toLowerCase()];
const year = Number(match[2]);
if (month === undefined || year < 1900 || year > 9999) {
return null;
}
return excelDateSerial(new Date(Date.UTC(year, month, 1)), dateSystem);
}
export class NullValueObject extends BaseValueObject {
private static _instance: NullValueObject;
@@ -1587,6 +1626,11 @@ export class StringValueObject extends BaseValueObject {
return createNumberValueObjectByRawValue(parseData.v, parseData.z).withDateSystem(this.getDateSystem());
}
const compactMonthYear = parseCompactEnglishMonthYear(rawValue, this.getDateSystem());
if (compactMonthYear !== null) {
return createNumberValueObjectByRawValue(compactMonthYear).withDateSystem(this.getDateSystem());
}
return createNumberValueObjectByRawValue(rawValue).withDateSystem(this.getDateSystem());
}
@@ -633,7 +633,7 @@ export class BaseFunction {
createReferenceObject(reference: BaseReferenceObject, range: IRange) {
const unitId = reference.getForcedUnitId() || reference.getDefaultUnitId() || '';
const sheetId = reference.getForcedSheetId() || reference.getDefaultUnitId() || '';
const sheetId = reference.getForcedSheetId() || reference.getDefaultSheetId() || '';
const sheetName = reference.getForcedSheetName();
const gridRangeName = {
@@ -131,7 +131,7 @@ describe('Test convert function', () => {
toUnit = StringValueObject.create('Rank');
result = testFunction.calculate(number, fromUnit, toUnit);
expect(getObjectValue(result)).toStrictEqual(491.67);
expect(getObjectValue(result)).toBeCloseTo(491.67, 12);
toUnit = StringValueObject.create('Reau');
result = testFunction.calculate(number, fromUnit, toUnit);
@@ -145,13 +145,20 @@ describe('Test convert function', () => {
let result = testFunction.calculate(number, fromUnit, toUnit);
expect(getObjectValue(result)).toStrictEqual(0);
const precise = testFunction.calculate(
NumberValueObject.create(4),
fromUnit,
StringValueObject.create('C')
);
expect(getObjectValue(precise)).toBeCloseTo(-15.555555555555555, 12);
toUnit = StringValueObject.create('K');
result = testFunction.calculate(number, fromUnit, toUnit);
expect(getObjectValue(result)).toStrictEqual(273.15);
toUnit = StringValueObject.create('Rank');
result = testFunction.calculate(number, fromUnit, toUnit);
expect(getObjectValue(result)).toStrictEqual(491.67);
expect(getObjectValue(result)).toBeCloseTo(491.67, 12);
toUnit = StringValueObject.create('Reau');
result = testFunction.calculate(number, fromUnit, toUnit);
@@ -171,7 +178,7 @@ describe('Test convert function', () => {
toUnit = StringValueObject.create('Rank');
result = testFunction.calculate(number, fromUnit, toUnit);
expect(getObjectValue(result)).toStrictEqual(491.67);
expect(getObjectValue(result)).toBeCloseTo(491.67, 12);
toUnit = StringValueObject.create('Reau');
result = testFunction.calculate(number, fromUnit, toUnit);
@@ -106,7 +106,6 @@ export class Convert extends BaseFunction {
if (from[3] === 'temperature') {
result = this._getTemperatureConversion(numberValue, from[1] as string, to[1] as string);
result = +result.toFixed(2);
} else {
result = (numberValue * (from[6] as number) * fromMultiplier) / ((to[6] as number) * toMultiplier);
}
@@ -34,6 +34,25 @@ describe('Test irr function', () => {
expect(getObjectValue(result, true)).toBe(0.0866309480365);
});
it('handles long cashflows when low bracket rates overflow', () => {
const cashflows = [
-149150,
...Array.from({ length: 24 }, () => 250),
...Array.from({ length: 276 }, () => 832.1256634329588),
];
const values = ArrayValueObject.create({
calculateValueList: transformToValueObject([cashflows]),
rowCount: 1,
columnCount: cashflows.length,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
expect(getObjectValue(testFunction.calculate(values), true)).toBe(0.00308889248291);
});
it('Value is normal, but no positive and negative number', () => {
const values = ArrayValueObject.create('{700000,120000,150000,180000,210000,260000}');
const guess = NumberValueObject.create(0.1);
@@ -50,6 +50,7 @@ const getTestWorkbookData = (): IWorkbookData => {
1: {
v: 2,
t: CellValueType.NUMBER,
s: 'date-style',
},
},
1: {
@@ -150,7 +151,11 @@ const getTestWorkbookData = (): IWorkbookData => {
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: ['sheet1', 'sheet2'],
styles: {},
styles: {
'date-style': {
n: { pattern: 'yyyy-mm-dd' },
},
},
};
};
@@ -240,6 +245,9 @@ describe('Test cell function', () => {
result = await calculate('=Cell("width", D3)');
expect(result).toStrictEqual([[88, true]]);
result = await calculate('=Cell("format", B1)');
expect(result).toBe('D4');
result = await calculate('=Cell("address", \'Startup Costs\'!B2)');
expect(result).toBe("'Startup Costs'!$B$2");
});
@@ -80,7 +80,14 @@ export class Cell extends BaseFunction {
const { columnData, defaultColumnWidth } = currentActiveSheetData;
const forcedSheetName = (_reference as BaseReferenceObject).getForcedSheetName();
_reference = (_reference as BaseReferenceObject).toArrayValueObject();
const referenceObject = _reference as BaseReferenceObject;
const cellData = referenceObject.getCellData(
referenceObject.getRangePosition().startRow,
referenceObject.getRangePosition().startColumn
);
const numberFormat = referenceObject.getCurrentStylesData()?.getStyleByCell(cellData)?.n?.pattern;
_reference = referenceObject.toArrayValueObject();
const _currentRow = (_reference as ArrayValueObject).getCurrentRow();
const _currentColumn = (_reference as ArrayValueObject).getCurrentColumn();
@@ -107,8 +114,7 @@ export class Cell extends BaseFunction {
// This value is not supported in Excel for the web. google sheet return #VALUE!.
return ErrorValueObject.create(ErrorType.VALUE);
case 'format':
// This value is not supported in Excel for the web. google sheet return G.
return StringValueObject.create('G');
return StringValueObject.create(getCellFormatCode(numberFormat));
case 'parentheses':
// This value is not supported in Excel for the web. return 0.
return NumberValueObject.create(0);
@@ -155,3 +161,29 @@ export class Cell extends BaseFunction {
return ArrayValueObject.createByArray(resultArray);
}
}
function getCellFormatCode(pattern?: string): string {
if (!pattern || /^general$/iu.test(pattern.trim())) return 'G';
const normalized = pattern
.split(';')[0]
.replace(/"[^"]*"/gu, '')
.replace(/\\./gu, '')
.replace(/_.|\*./gu, '')
.toLocaleLowerCase();
const hasDate = /[yd]/u.test(normalized);
const hasTime = /h|s/u.test(normalized);
if (hasDate) return 'D4';
if (hasTime) return /s/u.test(normalized) ? 'D8' : 'D9';
if (/%/u.test(normalized)) return `P${decimalPlaces(normalized)}`;
if (/[eE][+-]?0+/u.test(normalized)) return `S${decimalPlaces(normalized)}`;
if (/[$£¥€]/u.test(normalized)) return `C${decimalPlaces(normalized)}`;
if (/,/u.test(normalized)) return `,${decimalPlaces(normalized)}`;
if (/[0#?]/u.test(normalized)) return `F${decimalPlaces(normalized)}`;
return 'G';
}
function decimalPlaces(pattern: string): number {
return pattern.match(/\.([0#?]+)/u)?.[1]?.length ?? 0;
}
@@ -23,8 +23,13 @@ import { Lexer } from '../../../../engine/analysis/lexer';
import { AstTreeBuilder } from '../../../../engine/analysis/parser';
import { Interpreter } from '../../../../engine/interpreter/interpreter';
import { generateExecuteAstNodeData } from '../../../../engine/utils/ast-node-tool';
import { IFormulaCurrentConfigService } from '../../../../services/current-data.service';
import { IFunctionService } from '../../../../services/function.service';
import { IFormulaRuntimeService } from '../../../../services/runtime.service';
import { ISheetRowFilteredService } from '../../../../services/sheet-row-filtered.service';
import { createFunctionTestBed } from '../../../__tests__/create-function-test-bed';
import { FUNCTION_NAMES_MATH } from '../../../math/function-names';
import { Subtotal } from '../../../math/subtotal';
import { FUNCTION_NAMES_META } from '../../../meta/function-names';
import { Multiply } from '../../../meta/multiply';
import { getObjectValue } from '../../../util';
@@ -38,9 +43,10 @@ describe('Test byrow', () => {
let lexer: Lexer;
let astTreeBuilder: AstTreeBuilder;
let interpreter: Interpreter;
let testBed: ReturnType<typeof createFunctionTestBed>;
beforeEach(() => {
const testBed = createFunctionTestBed();
testBed = createFunctionTestBed();
get = testBed.get;
@@ -53,7 +59,8 @@ describe('Test byrow', () => {
functionService.registerExecutors(
new Byrow(FUNCTION_NAMES_LOGICAL.BYROW),
new Lambda(FUNCTION_NAMES_LOGICAL.LAMBDA),
new Multiply(FUNCTION_NAMES_META.MULTIPLY)
new Multiply(FUNCTION_NAMES_META.MULTIPLY),
new Subtotal(FUNCTION_NAMES_MATH.SUBTOTAL)
);
});
@@ -95,5 +102,51 @@ describe('Test byrow', () => {
result = await interpreter.executeAsync(generateExecuteAstNodeData(astNode as BaseAstNode));
expect(getObjectValue(result)).toStrictEqual(ErrorType.VALUE);
});
it('keeps row references so SUBTOTAL can observe filtered rows', async () => {
get(ISheetRowFilteredService).register((_unitId, _sheetId, row) => row === 1);
get(IFormulaCurrentConfigService).load({
formulaData: {},
arrayFormulaCellData: {},
arrayFormulaRange: {},
forceCalculate: false,
dirtyRanges: [],
dirtyNameMap: {},
dirtyDefinedNameMap: {},
dirtyUnitFeatureMap: {},
excludedCell: {},
allUnitData: { [testBed.unitId]: testBed.sheetData },
dirtyUnitOtherFormulaMap: {},
});
const sheetItem = testBed.sheetData[testBed.sheetId];
get(IFormulaRuntimeService).setCurrent(
0,
0,
sheetItem.rowCount,
sheetItem.columnCount,
testBed.sheetId,
testBed.unitId
);
const lexerNode = lexer.treeBuilder('=BYROW(A1:A3,LAMBDA(x,SUBTOTAL(3,x)))');
const astNode = astTreeBuilder.parse(lexerNode as LexerNode);
const result = await interpreter.executeAsync(generateExecuteAstNodeData(astNode as BaseAstNode));
expect(getObjectValue(result)).toStrictEqual([
[1],
[0],
[1],
]);
const multiColumnLexerNode = lexer.treeBuilder('=BYROW(A1:B3,LAMBDA(x,SUBTOTAL(3,x)))');
const multiColumnAstNode = astTreeBuilder.parse(multiColumnLexerNode as LexerNode);
const multiColumnResult = await interpreter.executeAsync(generateExecuteAstNodeData(multiColumnAstNode as BaseAstNode));
expect(getObjectValue(multiColumnResult)).toStrictEqual([
[2],
[0],
[2],
]);
});
});
});
@@ -17,6 +17,7 @@
import { describe, expect, it } from 'vitest';
import { ErrorType } from '../../../../basics/error-type';
import { ArrayValueObject, transformToValueObject } from '../../../../engine/value-object/array-value-object';
import { ErrorValueObject } from '../../../../engine/value-object/base-value-object';
import { StringValueObject } from '../../../../engine/value-object/primitive-object';
import { FUNCTION_NAMES_LOOKUP } from '../../function-names';
import { ImageFunction } from '../index';
@@ -51,6 +52,14 @@ describe('ImageFunction', () => {
});
});
it('should propagate scalar optional argument errors', () => {
const result = fn.calculate(
StringValueObject.create('https://image'),
ErrorValueObject.create(ErrorType.REF)
);
expect(result.getValue()).toBe(ErrorType.REF);
});
it('should validate sizing and size constraints', () => {
const source = StringValueObject.create('https://image');
@@ -627,8 +627,7 @@ describe('Test index', () => {
// null
let result = await calculate('=INDEX(A6:B7,2)');
// reference Google Sheets
expect(result).toStrictEqual([['Alex', 'Mickey']]);
expect(result).toBe(ErrorType.REF);
// blank cell
result = await calculate('=INDEX(A6:B7,2,)');
@@ -692,8 +691,7 @@ describe('Test index', () => {
// null
let result = await calculate('=INDEX(A6:B7,A3:A5)');
// reference Google Sheets
expect(result).toStrictEqual([['Tom'], ['Tom'], ['Tom']]);
expect(result).toStrictEqual([[ErrorType.REF], ['Tom'], ['Tom']]);
// blank cell
result = await calculate('=INDEX(A6:B7,A3:A5,)');
@@ -742,6 +740,10 @@ describe('Test index', () => {
});
describe('Reference is Array', () => {
it('returns a whole row when column number is omitted', async () => {
expect(await calculate('=INDEX({1,2,3;4,5,6},2)')).toStrictEqual([[4, 5, 6]]);
});
it('Row number 1, column number 1', async () => {
// number
let result = await calculate('=INDEX({1,2;3,4},0,2)');
@@ -198,6 +198,16 @@ export class Index extends BaseFunction {
_reference = (reference as CubeValueObject).getCubeValues()[areaNumValue - 1];
}
if (
!columnNum &&
rowNumValue > 0 &&
referenceRowColumnCount.rowCount > 1 &&
referenceRowColumnCount.columnCount > 1 &&
_reference.isReferenceObject()
) {
return ErrorValueObject.create(ErrorType.REF);
}
if (_reference.isReferenceObject()) {
return this._calculateReferenceObject(_reference as BaseReferenceObject, rowNumValue, columnNumValue);
}
@@ -120,6 +120,25 @@ describe('Test xlookup', () => {
expect(getObjectValue(resultObject)).toBe(0);
});
it('Blank lookup value should match a blank cell', () => {
const lookupArray = ArrayValueObject.createByArray([
[null],
['key'],
]);
const returnArray = ArrayValueObject.createByArray([
['blank-match'],
['key-match'],
]);
const resultObject = testFunction.calculate(
NullValueObject.create(),
lookupArray,
returnArray
) as BaseValueObject;
expect(getObjectValue(resultObject)).toBe('blank-match');
});
it('keeps blank, numeric zero, text zero, and empty text distinct', () => {
const lookupArray = ArrayValueObject.create({
calculateValueList: [
@@ -71,6 +71,46 @@ describe('Test minverse function', () => {
]);
});
it('Returns Excel-compatible values for non-zero floating-point pivots', () => {
const values = [
[0, 90, 161, 235, 307, 380, 452, 525],
[-90, 0, 71, 145, 217, 290, 362, 435],
[-161, -71, 0, 74, 146, 219, 291, 364],
[-235, -145, -74, 0, 72, 145, 217, 290],
[-307, -217, -146, -72, 0, 73, 145, 218],
[-380, -290, -219, -145, -73, 0, 72, 145],
[-452, -362, -291, -217, -145, -72, 0, 73],
[-525, -435, -364, -290, -218, -145, -73, 0],
];
const array = ArrayValueObject.create({
calculateValueList: transformToValueObject(values),
rowCount: values.length,
columnCount: values[0].length,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
const result = getObjectValue(testFunction.calculate(array)) as number[][];
const expectedFirstRow = [
20_312_366_176_490.105,
3_127_499_741_229.562,
-30_395_388_110_074.422,
32_643_278_549_083.152,
-51_212_808_262_633.188,
4_691_249_611_844.3125,
781_874_935_307.3981,
20_051_927_358_753.352,
];
expect(result).toHaveLength(8);
expect(result[0]).toHaveLength(8);
result[0].forEach((value, index) => {
expect(Math.abs((value - expectedFirstRow[index]) / expectedFirstRow[index])).toBeLessThan(1e-12);
});
});
it('ArrayRowCount !== arrayColumnCount', () => {
const array = ArrayValueObject.create({
calculateValueList: transformToValueObject([
@@ -221,6 +221,11 @@ describe('Test randarray function', () => {
]);
});
it('explicit implicit intersection keeps a legacy scalar formula from spilling', async () => {
const result = await calculate('=@RANDARRAY(10,1,1,1,TRUE)');
expect(result).toBe(1);
});
it('rows and columns is exceed', async () => {
const result = await calculate('=RANDARRAY(10000,10000)');
expect(result).toBe(ErrorType.VALUE);
@@ -244,13 +244,16 @@ describe('Test sumif function', () => {
const error = ErrorValueObject.create(ErrorType.NAME);
const result = await calculate(`=SUMIF(${error},A1:A4)`);
expect(result).toBe(ErrorType.VALUE);
expect(result).toBe(ErrorType.NAME);
const result2 = await calculate(`=SUMIF(A1:A4,${error})`);
expect(result2).toBe(0);
const result3 = await calculate(`=SUMIF(A1:A4,">40",${error})`);
expect(result3).toBe(ErrorType.VALUE);
expect(result3).toBe(ErrorType.NAME);
const result4 = await calculate('=SUMIF(#REF!,A1:A4,#REF!)');
expect(result4).toBe(ErrorType.REF);
});
it('Range or sumRange is not referenceObject', async () => {
@@ -31,6 +31,12 @@ export class Sumif extends BaseFunction {
override needsReferenceObject = true;
override calculate(range: FunctionVariantType, criteria: FunctionVariantType, sumRange?: FunctionVariantType): BaseValueObject {
if (range.isError()) {
return range as BaseValueObject;
}
if (sumRange?.isError()) {
return sumRange as BaseValueObject;
}
if (!range.isReferenceObject() || (sumRange && !sumRange.isReferenceObject())) {
return ErrorValueObject.create(ErrorType.VALUE);
}
@@ -134,6 +134,15 @@ describe('Test sumproduct function', () => {
});
describe('Sumproduct', () => {
it.each([
'=SUMPRODUCT({1;2},{1;2;3})',
'=SUMPRODUCT({1;2;3},{1;2})',
'=SUMPRODUCT({1,2},{1,2,3})',
'=SUMPRODUCT({1,2,3},{1,2})',
])('rejects different dimensions: %s', (formula) => {
expect(calculate(formula)).toBe(ErrorType.VALUE);
});
it('Array1 is array, not includes error', () => {
const array1 = ArrayValueObject.create({
calculateValueList: [
@@ -84,9 +84,7 @@ export class Sumproduct extends BaseFunction {
const variantRowCount = arr.getRowCount();
const variantColumnCount = arr.getColumnCount();
// Keep original rule:
// only when BOTH row and column mismatch => #VALUE!
if (variantRowCount !== rowCount && variantColumnCount !== columnCount) {
if (variantRowCount !== rowCount || variantColumnCount !== columnCount) {
return ErrorValueObject.create(ErrorType.VALUE);
}
}
@@ -59,6 +59,30 @@ describe('Test forecast function', () => {
expect(getObjectValue(result, true)).toBe(29.9520353143);
});
it('accepts equally sized row and column ranges', () => {
const x = NumberValueObject.create(2.5);
const knownYs = ArrayValueObject.create({
calculateValueList: transformToValueObject([[1, 3]]),
rowCount: 1,
columnCount: 2,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
const knownXs = ArrayValueObject.create({
calculateValueList: transformToValueObject([[1], [3]]),
rowCount: 2,
columnCount: 1,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
expect(getObjectValue(testFunction.calculate(x, knownYs, knownXs))).toBe(2.5);
});
it('X value is array', () => {
const x = ArrayValueObject.create({
calculateValueList: transformToValueObject([
@@ -581,7 +581,7 @@ describe('Test growth function', () => {
[27.2459520893],
[43.0060060724],
[67.8822509939],
[107.147824707258],
[expect.closeTo(107.147824707258, 9)],
]);
const knownYs3 = ArrayValueObject.create({
@@ -147,17 +147,17 @@ describe('Test linest function', () => {
const stats = BooleanValueObject.create(true);
const result = testFunction.calculate(knownYs, knownXs, constb, stats);
expect(getObjectValue(result, true)).toStrictEqual([
[204.080843005, -3407.54648312088, 12743.5528364893, -127.712022706619, 303849.43123395],
[651.098478042223, 27243.5182021205, 20457.1543147678, 281.254697108, 634296.647114014],
[204.080843005, -3407.54648311873, 12743.5528364872, -127.712022706613, 303849.431233932],
[651.098478042223, 27243.5182021205, 20457.1543147677, 281.254697108, 634296.647114014],
[0.107464822024, 49689.6546787527, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[0.180606027654, 6, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[1783709761.98331, 14814370692.5621, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[1783709761.98333, 14814370692.5621, ErrorType.NA, ErrorType.NA, ErrorType.NA],
]);
const constb2 = BooleanValueObject.create(false);
const result2 = testFunction.calculate(knownYs, knownXs, constb2, stats);
expect(getObjectValue(result2, true)).toStrictEqual([
[104.931508746584, -10969.5506756924, 12875.3758749945, 6.2539663735, 0],
[104.931508746583, -10969.5506756927, 12875.3758749944, 6.2539663735, 0],
[582.358051352254, 20946.2250961889, 19296.6787389021, 28.2242490263, ErrorType.NA],
[0.378600130052, 46875.144599683, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[1.06622202487, 7, ErrorType.NA, ErrorType.NA, ErrorType.NA],
@@ -167,7 +167,7 @@ describe('Test linest function', () => {
const stats2 = BooleanValueObject.create(false);
const result3 = testFunction.calculate(knownYs, knownXs, constb, stats2);
expect(getObjectValue(result3, true)).toStrictEqual([
[204.080843005, -3407.54648312088, 12743.5528364893, -127.712022706619, 303849.43123395],
[204.080843005, -3407.54648311873, 12743.5528364872, -127.712022706613, 303849.431233932],
]);
});
@@ -201,24 +201,76 @@ describe('Test linest function', () => {
const stats = BooleanValueObject.create(true);
const result = testFunction.calculate(knownYs, knownXs, constb, stats);
expect(getObjectValue(result, true)).toStrictEqual([
[0.25951909813, 0.000629921633028, 0.172592800629, 0.548068078609, -0.134842435655],
[0.259519098763, 0.000629921769464, 0.172592784662, 0.548068059432, -0.134842435684],
[0, 0, 0, 0, 0],
[1, 0, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[ErrorType.NUM, 0, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[152301876.8, 2.44622825105e-7, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[152301876.8, 2.64699020724e-23, ErrorType.NA, ErrorType.NA, ErrorType.NA],
]);
const constb2 = BooleanValueObject.create(false);
const result2 = testFunction.calculate(knownYs, knownXs, constb2, stats);
expect(getObjectValue(result2, true)).toStrictEqual([
[0.254604257643, 0.000585979549214, 0.176345787942, 0.549325160682, 0],
[0.0772515444895, 0.00105259139493, 0.127125044698, 0.0480042261908, ErrorType.NA],
[0.999999999828, 0.18882049421, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[1451318743.33408, 1, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[206976507.964347, 0.0356531790337, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[0.254604259712, 0.000585979662783, 0.176345792867, 0.549325164445, 0],
[0.0772515108466, 0.00105259093653, 0.127124989335, 0.0480042052851, ErrorType.NA],
[0.999999999828, 0.188820411979, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[1451320007.4257, 1, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[206976507.964347, 0.03565314798, ErrorType.NA, ErrorType.NA, ErrorType.NA],
]);
});
it('Keeps high-order polynomial coefficients stable', () => {
const xValues = [155, 178, 195, 205, 189, 179, 142, 130, 152, 166, 194];
const yValues = [
975097,
1076088.07,
985880.56,
1264998.15,
1487692.02,
879096.99,
1013152.71,
480886.67,
651359.67,
472135.74,
720614.91,
];
const knownYs = ArrayValueObject.create({
calculateValueList: transformToValueObject(yValues.map((value) => [value])),
rowCount: yValues.length,
columnCount: 1,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
const knownXs = ArrayValueObject.create({
calculateValueList: transformToValueObject(
xValues.map((value) => Array.from({ length: 6 }, (_, index) => value ** (index + 1)))
),
rowCount: xValues.length,
columnCount: 6,
unitId: '',
sheetId: '',
row: 0,
column: 0,
});
const result = getObjectValue(testFunction.calculate(knownYs, knownXs)) as number[][];
const expected = [
0.003030803284094725,
-3.014286876228756,
1242.0301184586228,
-271378.1017228177,
33159765.83632094,
-2148345015.0620275,
57656015344.78786,
];
result[0].forEach((actual, index) => {
const relativeError = Math.abs(actual - expected[index]) / Math.max(Math.abs(expected[index]), 1);
expect(relativeError).toBeLessThan(1e-8);
});
});
it('Stats value test', () => {
const knownYs = ArrayValueObject.create({
calculateValueList: transformToValueObject([
@@ -100,6 +100,7 @@ describe('Test logest function', () => {
]);
});
// Coefficients verified using 80-digit QR least squares on log(knownYs).
it('KnownYs is one column, and knownXs is multiple columns', () => {
const knownYs = ArrayValueObject.create({
calculateValueList: transformToValueObject([
@@ -147,7 +148,7 @@ describe('Test logest function', () => {
const stats = BooleanValueObject.create(true);
const result = testFunction.calculate(knownYs, knownXs, constb, stats);
expect(getObjectValue(result, true)).toStrictEqual([
[1.00225626311, 0.955992743217, 1.32201735202, 0.997967326319, 1251594.75697854],
[1.00225626311, 0.955992743218, 1.32201735202, 0.997967326319, expect.closeTo(1251594.756974595, 6)],
[0.0111189448553, 0.465243257003, 0.349351101858, 0.00480304527334, 10.8320164753],
[0.123427911828, 0.848560623139, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[0.211211228649, 6, ErrorType.NA, ErrorType.NA, ErrorType.NA],
@@ -160,14 +161,14 @@ describe('Test logest function', () => {
[0.997675048746, 0.67406990963, 1.33009450491, 1.00416405143, 1],
[0.0110423660294, 0.397171265872, 0.365893438396, 0.000535173314646, ErrorType.NA],
[0.994809235519, 0.888821753471, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[335.387237981475, 7, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[expect.closeTo(335.387237981475, 9), 7, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[1059.82918504031, 5.5300287661, ErrorType.NA, ErrorType.NA, ErrorType.NA],
]);
const stats2 = BooleanValueObject.create(false);
const result3 = testFunction.calculate(knownYs, knownXs, constb, stats2);
expect(getObjectValue(result3, true)).toStrictEqual([
[1.00225626311, 0.955992743217, 1.32201735202, 0.997967326319, 1251594.75697854],
[1.00225626311, 0.955992743218, 1.32201735202, 0.997967326319, expect.closeTo(1251594.756974595, 6)],
]);
});
@@ -201,7 +202,7 @@ describe('Test logest function', () => {
const stats = BooleanValueObject.create(true);
const result = testFunction.calculate(knownYs, knownXs, constb, stats);
expect(getObjectValue(result, true)).toStrictEqual([
[1.03240326243, 0.999873102005, 1.02157358567, 0.950065530365, 1.95065522079],
[1.03240326225, 0.999873102003, 1.02157358597, 0.950065530249, 1.95065522079],
[0, 0, 0, 0, 0],
[1, 0, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[ErrorType.NUM, 0, ErrorType.NA, ErrorType.NA, ErrorType.NA],
@@ -211,10 +212,10 @@ describe('Test logest function', () => {
const constb2 = BooleanValueObject.create(false);
const result2 = testFunction.calculate(knownYs, knownXs, constb2, stats);
expect(getObjectValue(result2, true)).toStrictEqual([
[1.05785486342, 1.00009083808, 1.00275120551, 0.944165822215, 1],
[1.05785486328, 1.00009083808, 1.00275120574, 0.944165822137, 1],
[0.382793300408, 0.00521575247085, 0.629924174927, 0.237868333877, ErrorType.NA],
[0.994573882203, 0.935634628939, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[45.8234560762, 1, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[0.994573882203, expect.closeTo(0.935634628939, 9), ErrorType.NA, ErrorType.NA, ErrorType.NA],
[expect.closeTo(45.8234560762, 8), 1, ErrorType.NA, ErrorType.NA, ErrorType.NA],
[160.457642442172, 0.875412158869, ErrorType.NA, ErrorType.NA, ErrorType.NA],
]);
});
@@ -570,19 +570,9 @@ describe('Test trend function', () => {
]);
const result4 = testFunction.calculate(knownYs2, knownXs2, newXs3, constb2);
expect(getObjectValue(result4)).toStrictEqual([
[1],
[2],
[3],
[4],
[5],
[6],
[7],
[8],
[9],
[10],
[11],
]);
expect(getObjectValue(result4)).toStrictEqual(
Array.from({ length: 11 }, (_, index) => [expect.closeTo(index + 1, 11)])
);
const knownYs3 = ArrayValueObject.create({
calculateValueList: transformToValueObject([
@@ -497,12 +497,13 @@ export class FormulaDataModel extends Disposable {
const startRow = 0;
const endRow = sheet.getRowCount() - 1;
const sheetRowData: IObjectArrayPrimitiveType<Partial<IRowData>> = Object.create(null);
const sheetRowData: IObjectArrayPrimitiveType<Partial<IRowData> & { filtered?: boolean }> = Object.create(null);
for (let i = startRow; i <= endRow; i++) {
if (!sheet.getRowVisible(i)) {
sheetRowData[i] = {
hd: BooleanNumber.TRUE,
...(sheet.getRowFiltered(i) ? { filtered: true } : {}),
};
}
}
@@ -24,6 +24,7 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { createFunctionTestBed } from '../../functions/__tests__/create-function-test-bed';
import { FormulaDataModel } from '../../models/formula-data.model';
import { FormulaCurrentConfigService, IFormulaCurrentConfigService } from '../current-data.service';
import { ISheetRowFilteredService } from '../sheet-row-filtered.service';
@@ -107,6 +108,22 @@ function createService(withCurrentWorkbook = true) {
}
describe('FormulaCurrentConfigService', () => {
it('distinguishes transported filter exclusions from manual hiding and clears stale exclusions', () => {
const testBed = createFunctionTestBed();
try {
const service = testBed.get(IFormulaCurrentConfigService);
const localFilter = testBed.get(ISheetRowFilteredService).register((_unitId, _sheetId, row) => row === 3);
service.loadDataLite({ test: { sheet1: { 1: { hd: 1, filtered: true }, 2: { hd: 1 } } } });
expect(service.getFilteredOutRows('test', 'sheet1', 0, 4)).toEqual([1, 3]);
expect(service.getUnitData().test.sheet1.rowData[2]).toEqual({ hd: 1 });
localFilter.dispose();
service.loadDataLite({ test: { sheet1: {} } });
expect(service.getFilteredOutRows('test', 'sheet1', 0, 4)).toEqual([]);
} finally {
testBed.univer.dispose();
}
});
it('should load explicit dataset config and merge dirty names to sheet-id map', () => {
const { service } = createService();
const unitData = {
@@ -27,11 +27,16 @@ describe('SheetRowFilteredService', () => {
service = injector.get(ISheetRowFilteredService);
});
it('delegates row visibility checks to the active filter feature', () => {
service.register((unitId, subUnitId, row) => unitId === 'book-1' && subUnitId === 'sheet-1' && row === 3);
it('combines row visibility checks from every filter feature', () => {
const sheetFilter = service.register((unitId, subUnitId, row) => unitId === 'book-1' && subUnitId === 'sheet-1' && row === 3);
service.register((unitId, subUnitId, row) => unitId === 'book-1' && subUnitId === 'sheet-1' && row === 4);
expect(service.getRowFiltered('book-1', 'sheet-1', 3)).toBe(true);
expect(service.getRowFiltered('book-1', 'sheet-1', 4)).toBe(false);
expect(service.getRowFiltered('book-1', 'sheet-1', 4)).toBe(true);
expect(service.getRowFiltered('book-1', 'sheet-2', 3)).toBe(false);
sheetFilter.dispose();
expect(service.getRowFiltered('book-1', 'sheet-1', 3)).toBe(false);
expect(service.getRowFiltered('book-1', 'sheet-1', 4)).toBe(true);
});
});
@@ -159,6 +159,8 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC
private _externalReferences: IFormulaExternalReferences = Object.create(null);
private _calculationRowData: IUnitRowData = Object.create(null);
private _calculationGeneration = 0;
private _forceCalculate: boolean = false;
@@ -203,6 +205,7 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC
this._sheetNameMap = Object.create(null);
this._unitNameMap = Object.create(null);
this._externalReferences = Object.create(null);
this._calculationRowData = Object.create(null);
this._clearDependencyTreeCache = Object.create(null);
this._dirtyRanges = [];
this._dirtyNameMap = Object.create(null);
@@ -361,7 +364,8 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC
const filteredOutRows: number[] = [];
for (let r = startRow; r <= endRow; r++) {
if (this._sheetRowFilteredService.getRowFiltered(unitId, sheetId, r)) {
if (this._calculationRowData[unitId]?.[sheetId]?.[r]?.filtered
|| this._sheetRowFilteredService.getRowFiltered(unitId, sheetId, r)) {
filteredOutRows.push(r);
}
}
@@ -371,6 +375,7 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC
load(config: IFormulaDatasetConfig) {
this._calculationGeneration++;
this._calculationRowData = Object.create(null);
if (config.allUnitData && config.unitSheetNameMap && config.unitStylesData) {
this._unitData = copyUnitData(config.allUnitData);
this._unitStylesData = config.unitStylesData;
@@ -523,6 +528,7 @@ export class FormulaCurrentConfigService extends Disposable implements IFormulaC
* @param rowData
*/
private _applyUnitRowData(rowData: IUnitRowData) {
this._calculationRowData = rowData;
for (const unitId of Object.keys(rowData)) {
if (rowData[unitId] == null) {
continue;
@@ -129,6 +129,8 @@ export interface IFormulaRuntimeService {
getFunctionDefinitionPrivacyVar(lambdaId: string): Nullable<Map<string, Nullable<BaseAstNode>>>;
markExternalReferenceUnavailable(): void;
setRuntimeData(functionVariant: FunctionVariantType): void;
getUnitData(): IRuntimeUnitDataType;
@@ -212,6 +214,7 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
private _formulaExecuteStage: FormulaExecuteStageType = FormulaExecuteStageType.IDLE;
private _stopState = false;
private _externalReferenceUnavailable = false;
private _currentRow: number = -1;
private _currentColumn: number = -1;
@@ -427,6 +430,7 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
sheetId: string,
unitId: string
) {
this._externalReferenceUnavailable = false;
this._currentRow = row;
this._currentColumn = column;
this._currentRowCount = rowCount;
@@ -510,7 +514,23 @@ export class FormulaRuntimeService extends Disposable implements IFormulaRuntime
subComponentData[formulaId][y][x] = cellDatas;
}
markExternalReferenceUnavailable(): void {
this._externalReferenceUnavailable = true;
}
// eslint-disable-next-line max-lines-per-function
setRuntimeData(functionVariant: FunctionVariantType) {
if (this._externalReferenceUnavailable) {
const cell = this._currentConfigService.getUnitData()[this._currentUnitId]?.[this._currentSubUnitId]
?.cellData
.getValue(this._currentRow, this._currentColumn);
// Leaving the original cell in place also lets downstream formulas read its cached value.
const formula = this._currentConfigService.getFormulaData()[this._currentUnitId]?.[this._currentSubUnitId]
?.[this._currentRow]?.[this._currentColumn]?.f;
if (cell?.f && cell.v != null && (formula == null || formula === cell.f)) {
return;
}
}
const row = this._currentRow;
const column = this._currentColumn;
const rowCount = this._currentRowCount;
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { createIdentifier, Disposable } from '@univerjs/core';
import type { IDisposable } from '@univerjs/core';
import { createIdentifier, Disposable, toDisposable } from '@univerjs/core';
type CallbackFunction = (unitId: string, subUnitId: string, row: number) => boolean;
@@ -22,24 +23,32 @@ type CallbackFunction = (unitId: string, subUnitId: string, row: number) => bool
* The service that gets the row filter status
*/
export interface ISheetRowFilteredService {
register(callback: CallbackFunction): void;
register(callback: CallbackFunction): IDisposable;
getRowFiltered(unitId: string, subUnitId: string, row: number): boolean;
}
export class SheetRowFilteredService extends Disposable implements ISheetRowFilteredService {
private _getRowFilteredCallback: CallbackFunction | undefined;
private _getRowFilteredCallbacks = new Set<CallbackFunction>();
register(callback: CallbackFunction) {
this._getRowFilteredCallback = callback;
this._getRowFilteredCallbacks.add(callback);
return toDisposable(() => this._getRowFilteredCallbacks.delete(callback));
}
getRowFiltered(unitId: string, subUnitId: string, row: number): boolean {
if (!this._getRowFilteredCallback) {
return false;
for (const callback of this._getRowFilteredCallbacks) {
if (callback(unitId, subUnitId, row)) {
return true;
}
}
return this._getRowFilteredCallback(unitId, subUnitId, row) ?? false;
return false;
}
override dispose(): void {
this._getRowFilteredCallbacks.clear();
super.dispose();
}
}
@@ -25,6 +25,8 @@ export interface IFormulaUnitReferenceResolveInput {
hostUnitId: string;
qualifier: string;
referenceKind: FormulaUnitReferenceKind;
/** Called only when the source workbook cannot be resolved. */
onUnavailable?: () => void;
}
export interface IFormulaUnitReferenceResolution {
@@ -62,6 +64,7 @@ export class FormulaUnitReferenceResolver implements IFormulaUnitReferenceResolv
hostUnitId,
qualifier,
referenceKind,
onUnavailable,
}: IFormulaUnitReferenceResolveInput): IFormulaUnitReferenceResolution | ErrorType {
const unitNameMap = this._currentConfigService.getUnitNameMap();
const unitData = this._currentConfigService.getUnitData();
@@ -95,6 +98,9 @@ export class FormulaUnitReferenceResolver implements IFormulaUnitReferenceResolv
);
if (matches.length !== 1) {
if (matches.length === 0) {
onUnavailable?.();
}
return ErrorType.REF;
}
@@ -31,7 +31,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { setupRenderTestEnv } from '../../../__tests__/render-test-utils';
import { BORDER_TYPE } from '../../../basics/const';
import { FontCache } from '../../docs/layout/shaping-engine/font-cache';
import { getGeneralNumberDisplayText, SpreadsheetSkeleton } from '../sheet.render-skeleton';
import { getCustomNumberDisplayText, getGeneralNumberDisplayText, SpreadsheetSkeleton } from '../sheet.render-skeleton';
describe('Rich-text render snapshot isolation', () => {
it.each(['font cache', 'row height', 'column width'])(
@@ -303,3 +303,22 @@ describe('General number display', () => {
expect(getGeneralNumberDisplayText(148706409, '148706409', '13.33px SimSun', 70)).toBe('148706409');
});
});
describe('Custom number display', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('uses hashes when a formatted number does not fit', () => {
vi.spyOn(FontCache, 'getMeasureText').mockImplementation((text) => ({
fontBoundingBoxAscent: 0,
fontBoundingBoxDescent: 0,
actualBoundingBoxAscent: 0,
actualBoundingBoxDescent: 0,
width: text.length * 7,
}));
expect(getCustomNumberDisplayText('2022-11-01 0:00:00', '13.33px SimSun', 66)).toBe('#########');
expect(getCustomNumberDisplayText('10/1/2022', '13.33px SimSun', 70)).toBe('10/1/2022');
});
});
@@ -100,6 +100,14 @@ export const FULL_WIDTH_HORIZONTAL_ALIGNMENTS = new Set([
HorizontalAlign.DISTRIBUTED,
]);
function getNumberOverflowText(fontString: string, availableWidth: number): string {
const hashWidth = FontCache.getMeasureText('#', fontString).width;
if (hashWidth <= 0) {
return '#';
}
return '#'.repeat(Math.max(1, Math.floor(availableWidth / hashWidth)));
}
export function getShrinkToFitScale(contentWidth: number, availableWidth: number, fontSize: number): number {
if (contentWidth <= availableWidth || contentWidth <= 0 || availableWidth <= 0 || fontSize <= 0) {
return 1;
@@ -141,11 +149,22 @@ export function getGeneralNumberDisplayText(
return '0';
}
const hashWidth = FontCache.getMeasureText('#', fontString).width;
if (hashWidth <= 0) {
return '#';
return getNumberOverflowText(fontString, availableWidth);
}
export function getCustomNumberDisplayText(
displayText: string,
fontString: string,
availableWidth: number
): string {
if (
availableWidth <= 0 ||
FontCache.getMeasureText(displayText, fontString).width <= availableWidth
) {
return displayText;
}
return '#'.repeat(Math.max(1, Math.floor(availableWidth / hashWidth)));
return getNumberOverflowText(fontString, availableWidth);
}
export function scaleDocumentDataForShrinkToFit(documentData: IDocumentData, scale: number, fallbackFontSize: number): IDocumentData {
@@ -1445,23 +1464,15 @@ export class SpreadsheetSkeleton extends SheetSkeleton {
}
}
private _applyGeneralNumberDisplay(row: number, col: number, fontCache: IFontCacheItem, style: IStyleData): void {
private _applyNumberDisplay(row: number, col: number, fontCache: IFontCacheItem, style: IStyleData): void {
const cellData = fontCache.cellData;
if (!cellData || style.stf === BooleanNumber.TRUE || fontCache.documentSkeleton) {
return;
}
if (!isDefaultFormat(style.n?.pattern)) {
return;
}
if (cellData.t !== CellValueType.NUMBER && (Tools.isDefine(cellData.t) || typeof cellData.v !== 'number')) {
return;
}
const value = Number(cellData.v);
if (!Number.isFinite(value)) {
return;
}
const cellInfo = this.getCellWithCoordByIndex(row, col, false);
const startX = cellInfo.isMergedMainCell ? cellInfo.mergeInfo.startX : cellInfo.startX;
const endX = cellInfo.isMergedMainCell ? cellInfo.mergeInfo.endX : cellInfo.endX;
@@ -1472,12 +1483,15 @@ export class SpreadsheetSkeleton extends SheetSkeleton {
- (padding.r ?? DEFAULT_PADDING_DATA.r)
- (extension?.leftOffset ?? 0)
- (extension?.rightOffset ?? 0);
fontCache.displayText = getGeneralNumberDisplayText(
value,
fontCache.displayText ?? getDisplayValueFromCell(cellData),
fontCache.fontString,
availableWidth
);
const displayText = fontCache.displayText ?? getDisplayValueFromCell(cellData);
if (isDefaultFormat(style.n?.pattern)) {
const value = Number(cellData.v);
if (Number.isFinite(value)) {
fontCache.displayText = getGeneralNumberDisplayText(value, displayText, fontCache.fontString, availableWidth);
}
} else {
fontCache.displayText = getCustomNumberDisplayText(displayText, fontCache.fontString, availableWidth);
}
}
_setFontStylesCache(row: number, col: number, cellData: Nullable<ICellDataForSheetInterceptor>, style: IStyleData, hasMergeData = true) {
@@ -1495,7 +1509,7 @@ export class SpreadsheetSkeleton extends SheetSkeleton {
const cacheItem = cacheValue as IFontCacheItem;
cacheItem.cellData = cellData;
setRenderTextCache(cacheItem, cellData);
this._applyGeneralNumberDisplay(row, col, cacheItem, style);
this._applyNumberDisplay(row, col, cacheItem, style);
this._stylesCache.fontMatrix.setValue(row, col, cacheValue as IFontCacheItem);
return;
}
@@ -1554,7 +1568,7 @@ export class SpreadsheetSkeleton extends SheetSkeleton {
}
const fontCacheItem = config as IFontCacheItem;
setRenderTextCache(fontCacheItem, cellData);
this._applyGeneralNumberDisplay(row, col, fontCacheItem, style);
this._applyNumberDisplay(row, col, fontCacheItem, style);
this._applyShrinkToFit(row, col, fontCacheItem, style);
this._calculateOverflowCell(row, col, fontCacheItem, hasMergeData);
this._stylesCache.fontMatrix.setValue(row, col, fontCacheItem);
@@ -26,6 +26,20 @@ export enum CellValueType {
UNRECOGNIZED = -1,
}
export enum FormulaType {
FORMULA_TYPE_NORMAL = 0,
FORMULA_TYPE_SHARED = 1,
FORMULA_TYPE_ARRAY = 2,
FORMULA_TYPE_DATA_TABLE = 3,
UNRECOGNIZED = -1,
}
export enum BooleanNumber {
BOOLEAN_NUMBER_FALSE = 0,
BOOLEAN_NUMBER_TRUE = 1,
UNRECOGNIZED = -1,
}
export interface IWorksheetMeta {
type: number;
id: string;
@@ -83,6 +97,12 @@ export interface ICellData {
ref: string;
/** New Excel formulas need to add prefixes for differentiation, e.g., _xlfn._xlws. _xlfn. _xlws. _xludf. */
xf: string;
ft?:
| FormulaType
| undefined;
fd?:
| BooleanNumber
| undefined;
}
export interface ISheetBlock {
@@ -38,7 +38,7 @@ describe('DataValidationRejectInputController', () => {
| undefined;
const open = vi.fn();
const close = vi.fn();
const getRuleById = vi.fn(() => ({ type: 'list', errorStyle: DataValidationErrorStyle.STOP }));
const getRuleById = vi.fn(() => ({ type: 'list', errorStyle: DataValidationErrorStyle.STOP, showErrorMessage: true }));
const model = {
getRuleIdByLocation: vi.fn(() => 'rule-1'),
getRuleById,
@@ -82,7 +82,10 @@ describe('DataValidationRejectInputController', () => {
await expect(interceptedHandler!(Promise.resolve(true), context, next)).resolves.toBe(true);
getRuleById.mockReturnValue({ type: 'list', errorStyle: DataValidationErrorStyle.WARNING });
getRuleById.mockReturnValue({ type: 'list', errorStyle: DataValidationErrorStyle.STOP, showErrorMessage: false });
await expect(interceptedHandler!(Promise.resolve(true), context, next)).resolves.toBe(true);
getRuleById.mockReturnValue({ type: 'list', errorStyle: DataValidationErrorStyle.WARNING, showErrorMessage: true });
await expect(interceptedHandler!(Promise.resolve(true), context, next)).resolves.toBe(true);
controller.showReject('Bad input');
@@ -49,7 +49,7 @@ export class DataValidationRejectInputController extends Disposable {
return next(Promise.resolve(false))!;
}
if (!rule || rule.errorStyle !== DataValidationErrorStyle.STOP) {
if (!rule || rule.showErrorMessage === false || rule.errorStyle !== DataValidationErrorStyle.STOP) {
return next(Promise.resolve(true))!;
}
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
@@ -53,6 +53,7 @@ interface IRule {
formula1?: string;
renderMode?: DataValidationRenderMode;
errorStyle?: DataValidationErrorStyle;
showErrorMessage?: boolean;
bizInfo?: Record<string, unknown>;
}
@@ -413,6 +414,26 @@ describe('DataValidationDropdownManagerService', () => {
});
});
it('allows an invalid date selection when the error alert is disabled', async () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-date-alert-disabled',
type: 'date',
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: false,
}, {
dropdownType: DataValidatorDropdownType.DATE,
validator: () => false,
});
showDropdown(testBed, 4);
const dropdown = getDropdown(testBed);
await expect((dropdown.props as { onChange: (value: ReturnType<typeof dateKit>) => Promise<boolean> }).onChange(dateKit('2026-06-17'))).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({
v: expect.any(Number),
});
});
it('keeps an unchanged date value and closes cell editing when confirmed', async () => {
testBed = createTestBed();
setRule(testBed, {
@@ -221,6 +221,7 @@ export class DataValidationDropdownManagerService extends Disposable {
const handleSerialSave = async (serial: number, targetPatternType: DatePatternType, interceptValue: string) => {
if (
rule.showErrorMessage === false ||
rule.errorStyle !== DataValidationErrorStyle.STOP ||
(await validator.validator({
value: serial,
@@ -268,5 +268,11 @@ describe('Test FDataValidationBuilder', () => {
error: 'Choose an approval state',
});
expect(copiedRule.uid).not.toBe(rule.uid);
const disabledAlert = new FDataValidationBuilder({
...rule,
showErrorMessage: false,
});
expect(disabledAlert.getAllowInvalid()).toBe(true);
});
});
@@ -128,7 +128,7 @@ export class FDataValidationBuilder {
* ```
*/
getAllowInvalid(): boolean {
return this._rule.errorStyle !== DataValidationErrorStyle.STOP;
return this._rule.showErrorMessage === false || this._rule.errorStyle !== DataValidationErrorStyle.STOP;
}
/**
@@ -70,7 +70,7 @@ export class FDataValidation {
* ```
*/
getAllowInvalid(): boolean {
return this.rule.errorStyle !== DataValidationErrorStyle.STOP;
return this.rule.showErrorMessage === false || this.rule.errorStyle !== DataValidationErrorStyle.STOP;
};
/**
@@ -191,7 +191,7 @@ describe('test mutations of sheets filter', () => {
expect(filterModel!.getRange()).toEqual({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(filterModel!.filteredOutRows.size).toBe(0);
expect(commandService.syncExecuteCommand(SetSheetsFilterCriteriaMutation.id, {
const params = {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
@@ -199,8 +199,10 @@ describe('test mutations of sheets filter', () => {
colId: 0,
filters: ['A'],
},
} as ISetSheetsFilterCriteriaMutationParams)).toBeTruthy();
} as ISetSheetsFilterCriteriaMutationParams;
expect(commandService.syncExecuteCommand(SetSheetsFilterCriteriaMutation.id, params)).toBeTruthy();
expect(filterModel!.filteredOutRows.size).toBe(5);
expect(params.filteredOutRows).toEqual(Array.from(filterModel!.filteredOutRows));
});
it('should return false when set filter criteria on a non-existing filter model', () => {
@@ -32,6 +32,7 @@ import { SheetsFilterService } from '../../services/sheet-filter.service';
*/
export interface ISetSheetsFilterRangeMutationParams extends ISheetCommandSharedParams {
range: IRange;
filteredOutRows?: number[];
}
/**
@@ -51,6 +52,7 @@ export const SetSheetsFilterRangeMutation: IMutation<ISetSheetsFilterRangeMutati
// check if the range is in bound?
const filterModel = sheetsFilterService.ensureFilterModel(unitId, subUnitId);
filterModel.setRange(range);
params.filteredOutRows = Array.from(filterModel.filteredOutRows);
return true;
},
@@ -66,6 +68,7 @@ export interface ISetSheetsFilterCriteriaMutationParams extends ISheetCommandSha
col: number;
criteria: Nullable<IFilterColumn>;
reCalc?: boolean;
filteredOutRows?: number[];
}
/**
@@ -82,6 +85,7 @@ export const SetSheetsFilterCriteriaMutation: IMutation<ISetSheetsFilterCriteria
if (!filterModel) return false;
filterModel.setCriteria(col, criteria, reCalc);
params.filteredOutRows = Array.from(filterModel.filteredOutRows);
return true;
},
};
@@ -95,7 +99,11 @@ export const RemoveSheetsFilterMutation: IMutation<ISheetCommandSharedParams> =
handler: (accessor, params) => {
const { unitId, subUnitId } = params;
const sheetsFilterService = accessor.get(SheetsFilterService);
return sheetsFilterService.removeFilterModel(unitId, subUnitId);
const removed = sheetsFilterService.removeFilterModel(unitId, subUnitId);
if (removed) {
(params as ISheetCommandSharedParams & { filteredOutRows?: number[] }).filteredOutRows = [];
}
return removed;
},
};
@@ -114,6 +122,9 @@ export const ReCalcSheetsFilterMutation: IMutation<ISheetCommandSharedParams> =
}
filterModel.reCalc();
(params as ISheetCommandSharedParams & { filteredOutRows?: number[] }).filteredOutRows = Array.from(
filterModel.filteredOutRows
);
return true;
},
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Injector, IUniverInstanceService } from '@univerjs/core';
import { Injector, IUniverInstanceService, toDisposable } from '@univerjs/core';
import { IActiveDirtyManagerService, ISheetRowFilteredService } from '@univerjs/engine-formula';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SheetsFilterFormulaService } from '../sheet-filter-formula.service';
@@ -31,7 +31,12 @@ describe('SheetsFilterFormulaService', () => {
}
class TestSheetRowFilteredService {
register = (callback: never) => (rowFilteredCallback = callback);
register = (callback: never) => {
rowFilteredCallback = callback;
return toDisposable(() => {
rowFilteredCallback = undefined;
});
};
}
class TestSheetsFilterService {
@@ -87,8 +87,8 @@ export class SheetsFilterFormulaService extends Disposable {
}
private _registerSheetRowFiltered() {
this._sheetRowFilteredService.register((unitId, subUnitId, row) => {
this.disposeWithMe(this._sheetRowFilteredService.register((unitId, subUnitId, row) => {
return this._sheetsFilterService.getFilterModel(unitId, subUnitId)?.isRowFiltered(row) ?? false;
});
}));
}
}
@@ -18,7 +18,9 @@ import type { Dependency, ICellData, IDisposable, IRange, IWorkbookData, Nullabl
import type { ISetRangeValuesMutationParams } from '@univerjs/sheets';
import type { ICellDataWithSpanInfo } from '@univerjs/sheets-ui';
import {
BooleanNumber,
DisposableCollection,
FormulaType,
ICommandService,
ILogService,
Inject,
@@ -472,46 +474,53 @@ describe('Test cut command with formulas', () => {
univer.dispose();
});
it('pastes cross-page formula payload with relative reference offsets', async () => {
get(SheetsSelectionsService).addSelections([
{
range: { startRow: 4, startColumn: 3, endRow: 4, endColumn: 3, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
it.each([BooleanNumber.FALSE, BooleanNumber.TRUE])(
'pastes cross-page formula payload with relative reference offsets and array metadata (%s)',
async (fd) => {
get(SheetsSelectionsService).addSelections([
{
range: { startRow: 4, startColumn: 3, endRow: 4, endColumn: 3, rangeType: RANGE_TYPE.NORMAL },
primary: null,
style: null,
},
]);
const formulaItem = {
types: ['web application/x-univer-sheets-formula', 'text/html'],
getType: async (type: string) => {
if (type === 'web application/x-univer-sheets-formula') {
return new Blob([JSON.stringify({
rowCount: 1,
columnCount: 1,
origin: {
row: 1,
column: 1,
},
formulas: [
{
row: 0,
column: 0,
f: '=C2',
const formulaItem = {
types: ['web application/x-univer-sheets-formula', 'text/html'],
getType: async (type: string) => {
if (type === 'web application/x-univer-sheets-formula') {
return new Blob([JSON.stringify({
rowCount: 1,
columnCount: 1,
origin: {
row: 1,
column: 1,
},
],
})], { type });
}
formulas: [
{
row: 0,
column: 0,
f: '=C2',
ft: FormulaType.ARRAY,
fd,
},
],
})], { type });
}
return new Blob([
'<google-sheets-html-origin><table><tbody><tr><td>formula result</td></tr></tbody></table></google-sheets-html-origin>',
], { type });
},
} as unknown as ClipboardItem;
return new Blob([
'<google-sheets-html-origin><table><tbody><tr><td>formula result</td></tr></tbody></table></google-sheets-html-origin>',
], { type });
},
} as unknown as ClipboardItem;
await sheetClipboardService.paste(formulaItem);
await sheetClipboardService.paste(formulaItem);
expect(getValues(4, 3, 4, 3)?.[0][0]?.f).toBe('=E5');
});
expect(getValues(4, 3, 4, 3)?.[0][0]?.f).toBe('=E5');
expect(getValues(4, 3, 4, 3)?.[0][0]?.ft).toBe(FormulaType.ARRAY);
expect(getValues(4, 3, 4, 3)?.[0][0]?.fd).toBe(fd);
}
);
it('keeps non-formula html cells when formula payload restores formulas', async () => {
get(SheetsSelectionsService).addSelections([
@@ -934,6 +943,8 @@ describe('Test paste with formula', () => {
12: {
3: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1163,6 +1174,8 @@ describe('Test paste with formula', () => {
5: {
5: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1172,6 +1185,8 @@ describe('Test paste with formula', () => {
},
6: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1181,6 +1196,8 @@ describe('Test paste with formula', () => {
},
7: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1190,6 +1207,8 @@ describe('Test paste with formula', () => {
},
8: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1201,6 +1220,8 @@ describe('Test paste with formula', () => {
6: {
5: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1210,6 +1231,8 @@ describe('Test paste with formula', () => {
},
6: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1219,6 +1242,8 @@ describe('Test paste with formula', () => {
},
7: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1228,6 +1253,8 @@ describe('Test paste with formula', () => {
},
8: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1239,6 +1266,8 @@ describe('Test paste with formula', () => {
7: {
5: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1248,6 +1277,8 @@ describe('Test paste with formula', () => {
},
6: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1257,6 +1288,8 @@ describe('Test paste with formula', () => {
},
7: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1266,6 +1299,8 @@ describe('Test paste with formula', () => {
},
8: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1277,6 +1312,8 @@ describe('Test paste with formula', () => {
8: {
5: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1286,6 +1323,8 @@ describe('Test paste with formula', () => {
},
6: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1295,6 +1334,8 @@ describe('Test paste with formula', () => {
},
7: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1304,6 +1345,8 @@ describe('Test paste with formula', () => {
},
8: {
custom: null,
ft: null,
fd: null,
s: null,
f: null,
si: null,
@@ -1481,6 +1524,92 @@ describe('getSetCellFormulaMutations matrix branches', () => {
return { get, has };
}
it.each([
[PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, COPY_TYPE.COPY],
[PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA, COPY_TYPE.COPY],
[PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE, COPY_TYPE.COPY],
[PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE, COPY_TYPE.CUT],
])('replaces formula metadata and restores it on undo for %s / %s', (pasteType, copyType) => {
const commandService = get(ICommandService);
commandService.registerCommand(SetRangeValuesMutation);
const worksheet = get(IUniverInstanceService).getUnit<Workbook>('test')!.getSheetBySheetId('sheet1')!;
const previous: ICellData = { f: '=OLD()', ft: FormulaType.ARRAY, fd: BooleanNumber.TRUE };
for (const source of [
{ f: '=A1', v: 12 },
{ f: '=A1', v: 12, ft: FormulaType.ARRAY, fd: BooleanNumber.FALSE },
{ f: '=A1', v: 12, ft: FormulaType.ARRAY, fd: BooleanNumber.TRUE },
]) {
commandService.syncExecuteCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: { 8: { 4: previous }, 9: { 4: previous } },
});
const result = get(Injector).invoke((accessor) => getSetCellFormulaMutations(
'test',
'sheet1',
{ rows: [8, 9], cols: [4] },
new ObjectMatrix<ICellDataWithSpanInfo>({ 0: { 0: source }, 1: { 0: source } }),
accessor,
{ copyType, pasteType },
get(LexerTreeBuilder),
get(FormulaDataModel),
false,
{ unitId: 'test', subUnitId: 'sheet1', range: { rows: [0], cols: [0] } }
));
const keepsFormula = pasteType !== PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE;
for (const mutations of [result.redos, result.undos, result.redos]) {
mutations.forEach((mutation) => {
expect(commandService.syncExecuteCommand(mutation.id, mutation.params)).toBe(true);
});
for (const row of [8, 9]) {
const cell = worksheet.getCellRaw(row, 4);
if (mutations === result.undos) {
expect(cell).toMatchObject(previous);
} else {
expect(cell?.ft).toBe(keepsFormula ? source.ft : undefined);
expect(cell?.fd).toBe(keepsFormula ? source.fd : undefined);
if (!keepsFormula) {
expect(cell?.f).toBeUndefined();
expect(cell?.v).toBe(12);
} else if (row === 8) {
expect(cell?.f).toBe(copyType === COPY_TYPE.CUT ? '=A1' : '=E9');
} else if (copyType === COPY_TYPE.COPY) {
expect(cell?.si).toBeTruthy();
}
}
}
}
}
});
it('clears imported metadata when external formula text replaces an array formula', () => {
const commandService = get(ICommandService);
commandService.registerCommand(SetRangeValuesMutation);
commandService.syncExecuteCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: { 8: { 4: { f: '=OLD()', ft: FormulaType.ARRAY, fd: BooleanNumber.FALSE } } },
});
const result = get(Injector).invoke((accessor) => getSetCellFormulaMutations(
'test',
'sheet1',
{ rows: [8], cols: [4] },
new ObjectMatrix<ICellDataWithSpanInfo>({ 0: { 0: { v: '=A1' } } }),
accessor,
{ copyType: COPY_TYPE.COPY, pasteType: PREDEFINED_HOOK_NAME_PASTE.DEFAULT_PASTE },
get(LexerTreeBuilder),
get(FormulaDataModel),
false,
null
));
result.redos.forEach((mutation) => commandService.syncExecuteCommand(mutation.id, mutation.params));
const worksheet = get(IUniverInstanceService).getUnit<Workbook>('test')!.getSheetBySheetId('sheet1')!;
expect(worksheet.getCellRaw(8, 4)).toMatchObject({ f: '=A1' });
expect(worksheet.getCellRaw(8, 4)?.ft).toBeUndefined();
expect(worksheet.getCellRaw(8, 4)?.fd).toBeUndefined();
});
it('converts pasted formula-looking text into cell formulas when there is no paste source', () => {
const matrix = new ObjectMatrix<ICellDataWithSpanInfo>({
0: {
@@ -14,9 +14,24 @@
* limitations under the License.
*/
import type { IAccessor, ICellData, ICellDataWithSpanAndDisplay, IMutationInfo, IRange, Nullable, Workbook, Worksheet } from '@univerjs/core';
import type {
IAccessor,
ICellData,
ICellDataWithSpanAndDisplay,
IMutationInfo,
IRange,
Nullable,
Workbook,
Worksheet,
} from '@univerjs/core';
import type { IDiscreteRange, ISetRangeValuesMutationParams } from '@univerjs/sheets';
import type { ICellDataWithSpanInfo, ICopyPastePayload, IPasteHookValueType, ISheetClipboardHook, ISheetDiscreteRangeLocation } from '@univerjs/sheets-ui';
import type {
ICellDataWithSpanInfo,
ICopyPastePayload,
IPasteHookValueType,
ISheetClipboardHook,
ISheetDiscreteRangeLocation,
} from '@univerjs/sheets-ui';
import {
DEFAULT_EMPTY_DOCUMENT_VALUE,
Disposable,
@@ -32,7 +47,12 @@ import {
} from '@univerjs/core';
import { FormulaDataModel, LexerTreeBuilder } from '@univerjs/engine-formula';
import { SetRangeValuesMutation, SetRangeValuesUndoMutationFactory } from '@univerjs/sheets';
import { COPY_TYPE, ISheetClipboardService, PREDEFINED_HOOK_NAME_COPY, PREDEFINED_HOOK_NAME_PASTE } from '@univerjs/sheets-ui';
import {
COPY_TYPE,
ISheetClipboardService,
PREDEFINED_HOOK_NAME_COPY,
PREDEFINED_HOOK_NAME_PASTE,
} from '@univerjs/sheets-ui';
export const DEFAULT_PASTE_FORMULA = 'default-paste-formula';
@@ -118,10 +138,14 @@ export class FormulaClipboardController extends Disposable {
matrixFragment.setValue(rowIndexInMatrix, columnIndexInMatrix, {
...getEmptyCell(),
f: formulaString,
ft: cellData.ft,
fd: cellData.fd,
});
plainMatrix.setValue(rowIndexInMatrix, columnIndexInMatrix, {
...getEmptyCell(),
f: formulaString,
ft: cellData.ft,
fd: cellData.fd,
displayV: formulaString,
});
} else {
@@ -256,6 +280,25 @@ export function getSetCellFormulaMutations(
};
}
const worksheet = accessor.get(IUniverInstanceService).getUnit<Workbook>(unitId)?.getSheetBySheetId(subUnitId);
matrix.forValue((row, col, source) => {
const targetRow = range.rows[row];
const targetCol = range.cols[col];
const value = valueMatrix.getValue(targetRow, targetCol);
if (!value) {
return;
}
// Paste replaces the target formula; omitted source metadata must not retain target flags.
const target = worksheet?.getCellRaw(targetRow, targetCol);
const keepsFormula = pasteFrom && copyInfo.pasteType !== PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE
&& (isFormulaString(value.f) || isFormulaId(value.si));
if (source.ft != null || source.fd != null || target?.ft != null || target?.fd != null) {
value.ft = keepsFormula ? source.ft ?? null : null;
value.fd = keepsFormula ? source.fd ?? null : null;
}
});
// set cell value and style
const setValuesMutation: ISetRangeValuesMutationParams = {
unitId,
@@ -14,12 +14,12 @@
* limitations under the License.
*/
import type { Dependency, Direction, Nullable } from '@univerjs/core';
import type { Dependency, Direction, ICellData, Nullable } from '@univerjs/core';
import type { IAutoFillLocation } from '@univerjs/sheets';
import { BooleanNumber, FormulaType } from '@univerjs/core';
import { LexerTreeBuilder } from '@univerjs/engine-formula';
import { AUTO_FILL_APPLY_TYPE, AUTO_FILL_DATA_TYPE, AutoFillService, IAutoFillService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { FormulaAutoFillController } from '../formula-auto-fill.controller';
@@ -175,4 +175,26 @@ describe('FormulaAutoFillController', () => {
},
]);
});
it.each([undefined, BooleanNumber.FALSE, BooleanNumber.TRUE])(
'preserves source formula metadata when filling shared followers (%s)',
(fd) => {
const source: ICellData = { f: '=A1' };
if (fd !== undefined) {
source.ft = FormulaType.ARRAY;
source.fd = fd;
}
const rule = testBed.injector.get(IAutoFillService).getRules().find((rule) => rule.type === AUTO_FILL_DATA_TYPE.FORMULA)!;
const result = rule.applyFunctions![AUTO_FILL_APPLY_TYPE.COPY]!({
data: [source],
index: [0],
}, 2, 2, { formula: [{ data: [source], index: [0] }] }, createLocation([0], [1, 2]));
expect(result[0]?.f).toBe('=A2');
expect(result[1]?.si).toBe(result[0]?.si);
for (const cell of result) {
expect(cell?.ft).toBe(source.ft);
expect(cell?.fd).toBe(fd);
}
}
);
});
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'نص التلميح',
addTitle: 'إدراج ارتباط تشعبي',
label: 'التسمية',
type: 'النوع',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Text d’ajuda',
addTitle: 'Insereix enllaç',
label: 'Etiqueta',
type: 'Tipus',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Hinweistext',
addTitle: 'Link einfügen',
label: 'Beschriftung',
type: 'Typ',
@@ -17,6 +17,7 @@
const locale = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Screen tip',
addTitle: 'Insert Link',
label: 'Label',
type: 'Type',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Texto de ayuda',
addTitle: 'Insertar enlace',
label: 'Etiqueta',
type: 'Tipo',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'متن راهنما',
addTitle: 'درج لینک',
label: 'برچسب',
type: 'نوع',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Info-bulle',
addTitle: 'Insérer un lien',
label: 'Titre',
type: 'Type',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Teks petunjuk',
addTitle: 'Sisipkan Tautan',
label: 'Label',
type: 'Tipe',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Suggerimento',
addTitle: 'Inserisci collegamento',
label: 'Etichetta',
type: 'Tipo',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'ヒント',
addTitle: 'リンクの挿入',
label: '表示文字列',
type: '種類',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: '설명',
addTitle: '링크 삽입',
label: '표시 텍스트',
type: '유형',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Podpowiedź',
addTitle: 'Wstaw łącze',
label: 'Etykieta',
type: 'Typ',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Texto de dica',
addTitle: 'Inserir Link',
label: 'Rótulo',
type: 'Tipo',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Подсказка',
addTitle: 'Вставить ссылку',
label: 'Метка',
type: 'Тип',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Pomocný text',
addTitle: 'Vložiť odkaz',
label: 'Popisok',
type: 'Typ',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: 'Văn bản gợi ý',
addTitle: 'Chèn liên kết',
label: 'Văn bản',
labelPlaceholder: 'Nhập văn bản',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: '提示文字',
addTitle: '插入链接',
label: '文本',
labelPlaceholder: '输入文本',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: '提示文字',
addTitle: '插入連結',
label: '文字',
labelPlaceholder: '輸入文字',
@@ -19,6 +19,7 @@ import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-hyper-link-ui': {
form: {
tooltip: '提示文字',
addTitle: '插入連結',
label: '文字',
labelPlaceholder: '輸入文字',
@@ -65,6 +65,7 @@ import { isBlankInput, resolveRangePayload } from './CellLinkEdit/utils';
export const CellLinkEdit = () => {
const [id, setId] = useState('');
const [display, setDisplay] = useState('');
const [tooltip, setTooltip] = useState('');
const [showLabel, setShowLabel] = useState(true);
const [type, setType] = useState<SheetHyperLinkType | string>(SheetHyperLinkType.URL);
const [payload, setPayload] = useState('');
@@ -128,6 +129,7 @@ export const CellLinkEdit = () => {
id: customRange?.rangeId ?? '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
tooltip: customRange?.properties?.tooltip ?? customRange?.properties?._xlsxHyperlinkTooltip ?? '',
row,
column: col,
};
@@ -145,6 +147,7 @@ export const CellLinkEdit = () => {
id: '',
display: '',
payload: range?.properties?.url ?? '',
tooltip: range?.properties?.tooltip ?? range?.properties?._xlsxHyperlinkTooltip ?? '',
row,
column: col,
};
@@ -160,6 +163,7 @@ export const CellLinkEdit = () => {
id: '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
tooltip: customRange?.properties?.tooltip ?? customRange?.properties?._xlsxHyperlinkTooltip ?? '',
row,
column: col,
};
@@ -167,6 +171,7 @@ export const CellLinkEdit = () => {
}
setId(link.id);
setTooltip(link.tooltip);
const customLink = sidePanelService.findCustomHyperLink(link);
if (customLink) {
const customLinkInfo = customLink.convert(link);
@@ -377,6 +382,7 @@ export const CellLinkEdit = () => {
subUnitId: editing.subUnitId,
payload: {
display: showLabel ? display : '',
tooltip,
payload: formatUrl(type, payload),
},
row: editing.row,
@@ -394,6 +400,7 @@ export const CellLinkEdit = () => {
column: editing.col,
payload: formatUrl(type, payload),
display: showLabel ? display : '',
tooltip,
},
documentId: editorBridgeService.getCurrentEditorId(),
});
@@ -456,6 +463,9 @@ export const CellLinkEdit = () => {
</FormLayout>
)
: null}
<FormLayout label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.tooltip')}>
<Input value={tooltip} onChange={setTooltip} />
</FormLayout>
<FormLayout label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.type')}>
<Select
className="univer-w-full"
@@ -65,12 +65,13 @@ export const CellLinkPopupPure = (props: ICellLinkPopupPureProps) => {
return null;
}
const linkObj = parserHyperLinkService.parseHyperLink(customRange.properties.url ?? '');
const tooltip = customRange.properties.tooltip ?? customRange.properties._xlsxHyperlinkTooltip;
const isError = linkObj.type === SheetHyperLinkType.INVALID;
return (
<div
className={clsx(`
univer-mb-1 univer-flex univer-max-w-80 univer-flex-row univer-items-center univer-justify-between
univer-mb-1 univer-grid univer-max-w-80 univer-grid-cols-[minmax(0,1fr)_auto] univer-items-center
univer-gap-2 univer-overflow-hidden univer-rounded-lg univer-bg-gray-0 univer-p-2 univer-shadow-md
dark:!univer-bg-gray-900
`, borderClassName)}
@@ -192,6 +193,16 @@ export const CellLinkPopupPure = (props: ICellLinkPopupPureProps) => {
</>
)}
</div>
{typeof tooltip === 'string' && tooltip && (
<div
className="
univer-col-span-2 univer-max-h-32 univer-overflow-y-auto univer-whitespace-pre-wrap
univer-break-words univer-text-sm univer-text-gray-500
"
>
{tooltip}
</div>
)}
</div>
);
};
@@ -65,6 +65,7 @@ import { isBlankInput, resolveRangePayload } from './CellLinkEdit/utils';
export const MobileCellLinkEdit = () => {
const [id, setId] = useState('');
const [display, setDisplay] = useState('');
const [tooltip, setTooltip] = useState('');
const [showLabel, setShowLabel] = useState(true);
const [type, setType] = useState<SheetHyperLinkType | string>(SheetHyperLinkType.URL);
const [payload, setPayload] = useState('');
@@ -128,6 +129,7 @@ export const MobileCellLinkEdit = () => {
id: customRange?.rangeId ?? '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
tooltip: customRange?.properties?.tooltip ?? customRange?.properties?._xlsxHyperlinkTooltip ?? '',
row,
column: col,
};
@@ -145,6 +147,7 @@ export const MobileCellLinkEdit = () => {
id: '',
display: '',
payload: range?.properties?.url ?? '',
tooltip: range?.properties?.tooltip ?? range?.properties?._xlsxHyperlinkTooltip ?? '',
row,
column: col,
};
@@ -160,6 +163,7 @@ export const MobileCellLinkEdit = () => {
id: '',
display: label ?? '',
payload: customRange?.properties?.url ?? '',
tooltip: customRange?.properties?.tooltip ?? customRange?.properties?._xlsxHyperlinkTooltip ?? '',
row,
column: col,
};
@@ -167,6 +171,7 @@ export const MobileCellLinkEdit = () => {
}
setId(link.id);
setTooltip(link.tooltip);
const customLink = sidePanelService.findCustomHyperLink(link);
if (customLink) {
const customLinkInfo = customLink.convert(link);
@@ -377,6 +382,7 @@ export const MobileCellLinkEdit = () => {
subUnitId: editing.subUnitId,
payload: {
display: showLabel ? display : '',
tooltip,
payload: formatUrl(type, payload),
},
row: editing.row,
@@ -394,6 +400,7 @@ export const MobileCellLinkEdit = () => {
column: editing.col,
payload: formatUrl(type, payload),
display: showLabel ? display : '',
tooltip,
},
documentId: editorBridgeService.getCurrentEditorId(),
});
@@ -453,6 +460,9 @@ export const MobileCellLinkEdit = () => {
</FormLayout>
)
: null}
<FormLayout label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.tooltip')}>
<Input value={tooltip} onChange={setTooltip} />
</FormLayout>
<FormLayout label={localeService.t<LocaleKey>('sheets-hyper-link-ui.form.type')}>
<MobileSelect
className="univer-w-full"
@@ -57,6 +57,7 @@ export function MobileCellLinkPopupPure(props: IMobileCellLinkPopupPureProps) {
}
const linkObj = parserHyperLinkService.parseHyperLink(customRange.properties.url);
const tooltip = customRange.properties.tooltip ?? customRange.properties._xlsxHyperlinkTooltip;
const isError = linkObj.type === SheetHyperLinkType.INVALID;
const close = () => popupService.hideCurrentPopup(undefined, true);
@@ -74,6 +75,16 @@ export function MobileCellLinkPopupPure(props: IMobileCellLinkPopupPureProps) {
close();
}}
/>
{typeof tooltip === 'string' && tooltip && (
<div
className="
univer-max-h-32 univer-overflow-y-auto univer-whitespace-pre-wrap univer-break-words
univer-text-sm univer-text-gray-500
"
>
{tooltip}
</div>
)}
{copyPermission && (
<MobileActionRow
title={localeService.t<LocaleKey>('sheets-hyper-link-ui.popup.copy')}
@@ -57,7 +57,10 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { of } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { CloseHyperLinkPopupOperation, OpenHyperLinkEditPanelOperation } from '../../commands/operations/popup.operations';
import {
CloseHyperLinkPopupOperation,
OpenHyperLinkEditPanelOperation,
} from '../../commands/operations/popup.operations';
import { ISheetsHyperLinkPopupService, SheetsHyperLinkPopupService } from '../../services/popup.service';
import { SheetsHyperLinkResolverService } from '../../services/resolver.service';
import { SheetsHyperLinkSidePanelService } from '../../services/side-panel.service';
@@ -395,6 +398,22 @@ describe('CellLinkPopupPure', () => {
currentTestBed = undefined;
});
it.each([CellLinkPopupPure, MobileCellLinkPopupPure])('renders the tip inside the existing link card', (Popup) => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const customRange = { ...currentTestBed.customRange, properties: { ...currentTestBed.customRange.properties, tooltip: '<b>Read this first</b>' } };
act(() => root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<Popup unitId={UNIT_ID} subUnitId={SUB_UNIT_ID} row={0} col={0} customRange={customRange} type={HyperLinkEditSourceType.VIEWING} editPermission copyPermission />
</RediContext.Provider>
));
expect(container.textContent).toContain('<b>Read this first</b>');
expect(container.querySelector('b')).toBeNull();
expect(container.firstElementChild?.textContent).toContain('<b>Read this first</b>');
});
it('opens the edit panel for the selected sheet hyperlink range', async () => {
currentTestBed = createPopupTestBed();
container = document.createElement('div');
@@ -514,9 +533,10 @@ describe('CellLinkEdit', () => {
});
const inputs = Array.from(container.querySelectorAll('input'));
expect(inputs).toHaveLength(2);
expect(inputs).toHaveLength(3);
expect(inputs[0].hasAttribute('dir')).toBe(false);
expect(inputs[1].dir).toBe('ltr');
expect(inputs[1].hasAttribute('dir')).toBe(false);
expect(inputs[2].dir).toBe('ltr');
}
});
@@ -544,10 +564,11 @@ describe('CellLinkEdit', () => {
});
const inputs = Array.from(container.querySelectorAll('input'));
expect(inputs).toHaveLength(1);
expect(inputs).toHaveLength(2);
await act(async () => {
inputText(inputs[0], 'docs.univer.ai');
inputText(inputs[0], 'Documentation tip');
inputText(inputs[1], 'docs.univer.ai');
await Promise.resolve();
});
renderManagerService.enableScrollRender = true;
@@ -567,6 +588,7 @@ describe('CellLinkEdit', () => {
expect(body?.dataStream).toBe('Univer\r\n');
expect(linkRange?.properties?.url).toBe('http://docs.univer.ai');
expect(linkRange?.properties?.tooltip).toBe('Documentation tip');
expect(popupService.currentEditing).toBeNull();
});
@@ -17,9 +17,26 @@
import type { ICellData, ICommand, IDocumentData, IMutationInfo } from '@univerjs/core';
import type { ISetRangeValuesMutationParams } from '@univerjs/sheets';
import type { ISheetHyperLink } from '../../types/interfaces/i-hyper-link';
import { BuildTextUtils, CellValueType, CommandType, CustomRangeType, generateRandomId, ICommandService, IUndoRedoService, IUniverInstanceService, sequenceExecute, TextX, Tools } from '@univerjs/core';
import {
BuildTextUtils,
CellValueType,
CommandType,
CustomRangeType,
generateRandomId,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
sequenceExecute,
TextX,
Tools,
} from '@univerjs/core';
import { addCustomRangeBySelectionFactory } from '@univerjs/docs';
import { getSheetCommandTarget, SetRangeValuesMutation, SetRangeValuesUndoMutationFactory, SheetInterceptorService } from '@univerjs/sheets';
import {
getSheetCommandTarget,
SetRangeValuesMutation,
SetRangeValuesUndoMutationFactory,
SheetInterceptorService,
} from '@univerjs/sheets';
import { HyperLinkModel } from '../../models/hyper-link.model';
import { AddHyperLinkMutation } from '../mutations/add-hyper-link.mutation';
import { RemoveHyperLinkMutation } from '../mutations/remove-hyper-link.mutation';
@@ -36,7 +53,6 @@ export const AddHyperLinkCommand: ICommand<IAddHyperLinkCommandParams> = {
type: CommandType.COMMAND,
id: 'sheets.command.add-hyper-link',
// eslint-disable-next-line max-lines-per-function
async handler(accessor, params) {
if (!params) return false;
@@ -76,6 +92,7 @@ export const AddHyperLinkCommand: ICommand<IAddHyperLinkCommandParams> = {
rangeId: id,
properties: {
url: payload,
tooltip: link.tooltip,
// refId: id,
},
}],
@@ -90,6 +107,7 @@ export const AddHyperLinkCommand: ICommand<IAddHyperLinkCommandParams> = {
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: payload,
tooltip: link.tooltip,
refId: id,
},
});
@@ -196,6 +214,7 @@ export const AddRichHyperLinkCommand: ICommand<IAddRichHyperLinkCommandParams> =
rangeType: CustomRangeType.HYPERLINK,
properties: {
url: payload,
tooltip: link.tooltip,
refId: newId,
},
});
@@ -16,9 +16,27 @@
import type { DocumentDataModel, ICellData, ICommand, IMutationInfo } from '@univerjs/core';
import type { ICellLinkContent } from '../../types/interfaces/i-hyper-link';
import { CellValueType, CommandType, CustomRangeType, generateRandomId, getBodySlice, ICommandService, IUndoRedoService, IUniverInstanceService, sequenceExecute, TextX, Tools, UniverInstanceType } from '@univerjs/core';
import {
CellValueType,
CommandType,
CustomRangeType,
generateRandomId,
getBodySlice,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
sequenceExecute,
TextX,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { replaceSelectionFactory } from '@univerjs/docs';
import { getSheetCommandTarget, SetRangeValuesMutation, SetRangeValuesUndoMutationFactory, SheetInterceptorService } from '@univerjs/sheets';
import {
getSheetCommandTarget,
SetRangeValuesMutation,
SetRangeValuesUndoMutationFactory,
SheetInterceptorService,
} from '@univerjs/sheets';
import { HyperLinkModel } from '../../models/hyper-link.model';
import { AddHyperLinkMutation } from '../mutations/add-hyper-link.mutation';
import { RemoveHyperLinkMutation } from '../mutations/remove-hyper-link.mutation';
@@ -35,7 +53,6 @@ export interface IUpdateHyperLinkCommandParams {
export const UpdateHyperLinkCommand: ICommand<IUpdateHyperLinkCommandParams> = {
type: CommandType.COMMAND,
id: 'sheets.command.update-hyper-link',
// eslint-disable-next-line max-lines-per-function
async handler(accessor, params) {
if (!params) return false;
@@ -84,6 +101,7 @@ export const UpdateHyperLinkCommand: ICommand<IUpdateHyperLinkCommandParams> = {
endIndex: display.length - 1,
properties: {
url: payload,
tooltip: link.tooltip ?? range.properties?.tooltip ?? range.properties?._xlsxHyperlinkTooltip,
},
}],
textRuns: textRun ? [textRun] : undefined,
@@ -211,6 +229,7 @@ export const UpdateRichHyperLinkCommand: ICommand<IUpdateRichHyperLinkCommandPar
endIndex: display.length - 1,
properties: {
url: payload.payload,
tooltip: payload.tooltip ?? range.properties?.tooltip ?? range.properties?._xlsxHyperlinkTooltip,
},
}],
textRuns: textRun ? [textRun] : undefined,
@@ -15,7 +15,17 @@
*/
import type { IWorkbookData, UnitModel } from '@univerjs/core';
import { ILogService, IUniverInstanceService, LocaleType, LogLevel, Univer, UniverInstanceType } from '@univerjs/core';
import {
ICommandService,
ILogService,
IUniverInstanceService,
LocaleType,
LogLevel,
RedoCommand,
UndoCommand,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { DocSelectionManagerService } from '@univerjs/docs';
import { IDefinedNamesService } from '@univerjs/engine-formula';
@@ -66,6 +76,23 @@ describe('FRangeSheetsHyperlinkMixin', () => {
univer.dispose();
});
it('persists, preserves and clears hyperlink tips through undoable facade edits', async () => {
const cell = univerAPI.getActiveWorkbook()!.getActiveSheet().getRange('A1');
await expect(cell.setHyperLink('https://univer.ai', 'Original', 'Imported tip')).resolves.toBe(true);
expect(cell.getHyperLinks()[0].tooltip).toBe('Imported tip');
await cell.updateHyperLink('https://docs.univer.ai', 'Edited');
expect(cell.getHyperLinks()[0]).toMatchObject({ label: 'Edited', tooltip: 'Imported tip' });
await cell.updateHyperLink('https://docs.univer.ai', undefined, '');
expect(cell.getHyperLinks()[0]).toMatchObject({ label: 'Edited', tooltip: '' });
const commands = univer.__getInjector().get(ICommandService);
await commands.executeCommand(UndoCommand.id);
expect(cell.getHyperLinks()[0].tooltip).toBe('Imported tip');
await commands.executeCommand(RedoCommand.id);
expect(cell.getHyperLinks()[0].tooltip).toBe('');
const body = cell.getCellData()?.p?.body;
expect(JSON.parse(JSON.stringify(body)).customRanges[0].properties.tooltip).toBe('');
});
it('should set, read, update and cancel hyperlinks through the real facade API', async () => {
const worksheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const a1 = worksheet.getRange('A1');
@@ -14,9 +14,18 @@
* limitations under the License.
*/
import type { IAddHyperLinkCommandParams, ICancelHyperLinkCommandParams, IUpdateHyperLinkCommandParams } from '@univerjs/sheets-hyper-link';
import type {
IAddHyperLinkCommandParams,
ICancelHyperLinkCommandParams,
IUpdateHyperLinkCommandParams,
} from '@univerjs/sheets-hyper-link';
import { CustomRangeType, DataStreamTreeTokenType, generateRandomId } from '@univerjs/core';
import { AddHyperLinkCommand, CancelHyperLinkCommand, SheetsHyperLinkParserService, UpdateHyperLinkCommand } from '@univerjs/sheets-hyper-link';
import {
AddHyperLinkCommand,
CancelHyperLinkCommand,
SheetsHyperLinkParserService,
UpdateHyperLinkCommand,
} from '@univerjs/sheets-hyper-link';
import { FRange } from '@univerjs/sheets/facade';
export interface ICellHyperLink {
@@ -25,6 +34,7 @@ export interface ICellHyperLink {
column: number;
url: string;
label: string;
tooltip?: string;
}
/**
@@ -37,6 +47,7 @@ export interface IFRangeSheetsHyperlinkMixin {
* When the hyperlink is a range link or a sheet link, the url should be the url of the target range or sheet.
* @param {string} url - The hyperlink url, can be a URL, a range link, or a sheet link.
* @param {string} [label] - The display text of the hyperlink. If omitted, the existing cell text is linked. Supply a label when linking an empty cell.
* @param {string} [tooltip] - Optional text shown inside the hyperlink popup.
* @return {Promise<boolean>} A promise that resolves to true if the hyperlink is set successfully, otherwise false.
*
* @example
@@ -68,7 +79,7 @@ export interface IFRangeSheetsHyperlinkMixin {
* await fRange4.setHyperLink(definedNameHyperlinkUrl, 'Link to MyDefinedName');
* ```
*/
setHyperLink(url: string, label?: string): Promise<boolean>;
setHyperLink(url: string, label?: string, tooltip?: string): Promise<boolean>;
/**
* Gets the first hyperlink from each cell containing hyperlinks in this range.
@@ -87,7 +98,8 @@ export interface IFRangeSheetsHyperlinkMixin {
/**
* Update the hyperlink of this range top left cell.
* @param {string} url - The new hyperlink url, can be a URL, a range link, or a sheet link.
* @param {string} [label] - The new display text of the hyperlink. If omitted, the replacement display text is empty. Supply a label to keep the link visible.
* @param {string} [label] - The new display text of the hyperlink. If omitted, the existing hyperlink label is preserved.
* @param {string} [tooltip] - Omit to preserve the current tooltip; use an empty string to clear it.
*
* @returns {Promise<boolean>} A promise resolving to whether the update succeeded.
* @throws {Error} The promise rejects if the top-left cell contains no hyperlink.
@@ -108,7 +120,7 @@ export interface IFRangeSheetsHyperlinkMixin {
* await fRange.updateHyperLink(rangeUrl, 'Link to B2:D4');
* ```
*/
updateHyperLink(url: string, label?: string): Promise<boolean>;
updateHyperLink(url: string, label?: string, tooltip?: string): Promise<boolean>;
/**
* Cancel all hyperlinks in this range. If a hyperlink is provided, only cancel the specified hyperlink.
@@ -157,7 +169,7 @@ export interface IFRangeSheetsHyperlinkMixin {
}
export class FRangeSheetsHyperlinkMixin extends FRange implements IFRangeSheetsHyperlinkMixin {
override setHyperLink(url: string, label?: string): Promise<boolean> {
override setHyperLink(url: string, label?: string, tooltip?: string): Promise<boolean> {
return this._commandService.executeCommand<IAddHyperLinkCommandParams>(AddHyperLinkCommand.id, {
unitId: this.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
@@ -167,6 +179,7 @@ export class FRangeSheetsHyperlinkMixin extends FRange implements IFRangeSheetsH
column: this._range.startColumn,
payload: url,
display: label,
tooltip,
},
});
}
@@ -193,6 +206,7 @@ export class FRangeSheetsHyperlinkMixin extends FRange implements IFRangeSheetsH
column,
url,
label,
tooltip: properties?.tooltip ?? properties?._xlsxHyperlinkTooltip,
});
}
});
@@ -200,7 +214,7 @@ export class FRangeSheetsHyperlinkMixin extends FRange implements IFRangeSheetsH
return hyperlinks;
}
override updateHyperLink(url: string, label?: string): Promise<boolean> {
override updateHyperLink(url: string, label?: string, tooltip?: string): Promise<boolean> {
const hyperlink = this.getHyperLinks().find((link) => link.row === this._range.startRow && link.column === this._range.startColumn);
if (!hyperlink) {
@@ -217,7 +231,8 @@ export class FRangeSheetsHyperlinkMixin extends FRange implements IFRangeSheetsH
column,
payload: {
payload: url,
display: label,
display: label ?? hyperlink.label,
tooltip,
},
});
}
@@ -23,6 +23,8 @@ export interface ICellLinkContent {
* file: file://a.xlsx
*/
payload: string;
/** Optional text shown inside the hyperlink popup. Empty string clears it on update. */
tooltip?: string;
/**
* only for notify, if you wan't to read the display of text, use cell-value of cell-matrix instead
*/
+2
View File
@@ -21,6 +21,8 @@ export const UNIVER_SHEET_TABLE_FILTER_PANEL_ID = 'UNIVER_SHEET_Table_FILTER_PAN
export const TABLE_SELECTOR_DIALOG = 'TABLE_SELECTOR_DIALOG';
export const SHEET_TABLE_RENAME_DIALOG = 'SHEET_TABLE_RENAME_DIALOG';
export const SHEET_TABLE_RENAME_DIALOG_ID = 'SHEET_TABLE_RENAME_DIALOG_ID';
export const SHEET_TABLE_SETTINGS_DIALOG = 'SHEET_TABLE_SETTINGS_DIALOG';
export const SHEET_TABLE_SETTINGS_DIALOG_ID = 'SHEET_TABLE_SETTINGS_DIALOG_ID';
export const SHEET_TABLE_THEME_PANEL_ID = 'SHEET_TABLE_THEME_PANEL_ID';
export const SHEET_TABLE_THEME_PANEL = 'SHEET_TABLE_THEME_PANEL';
@@ -184,6 +184,7 @@ describe('SheetTableControlsRenderController', () => {
rename: 'sheets-table-ui.rename',
'update-range': 'sheets-table-ui.updateRange',
'set-theme': 'sheets-table-ui.setTheme',
settings: 'sheets-table-ui.settings',
delete: 'sheets-table-ui.removeTable',
});

Some files were not shown because too many files have changed in this diff Show More