mirror of
https://github.com/dream-num/univer.git
synced 2026-10-02 04:24:34 +08:00
fix(sheets): preserve imported workbook semantics and formula caches (#7673)
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+20
-1
@@ -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 {
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+21
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+4
-2
@@ -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;
|
||||
});
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+164
-35
@@ -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,
|
||||
|
||||
+24
-2
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
+1
@@ -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
Reference in New Issue
Block a user