snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
# @univerjs/sheets-formula
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-formula?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-formula)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-formula?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-formula)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-formula?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-formula)
`@univerjs/sheets-formula` connects the formula engine to Univer Sheets, including formula data services, dependency handling, and sheet-aware calculation.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-formula` | `UniverSheetsFormula` | No | Yes | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-formula
# or
npm install @univerjs/sheets-formula
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import EnUS from '@univerjs/sheets-formula/locale/en-US';
import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula';
univer.registerPlugin(UniverSheetsFormulaPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
Exported plugin classes:
- `UniverSheetsFormulaPlugin`
- `UniverRemoteSheetsFormulaPlugin`
## Integration Notes
Register this package with `@univerjs/engine-formula` and `@univerjs/sheets` before adding formula UI packages.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-formula)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,100 @@
{
"name": "@univerjs/sheets-formula",
"version": "1.0.0-rc.0",
"private": false,
"description": "Sheet formula services and calculation integration for Univer Sheets.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"sheets",
"formula",
"calculation",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/engine-formula": "workspace:*",
"@univerjs/rpc": "workspace:*",
"@univerjs/sheets": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,97 @@
/**
* 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 type { IAccessor } from '@univerjs/core';
import * as core from '@univerjs/core';
import { ICommandService } from '@univerjs/core';
import { SetRangeValuesCommand } from '@univerjs/sheets';
import { describe, expect, it, vi } from 'vitest';
import { InsertFunctionCommand } from '../insert-function.command';
describe('InsertFunctionCommand', () => {
it('should write shared-formula ranges and direct formulas in one SetRangeValuesCommand call', async () => {
vi.spyOn(core, 'generateRandomId').mockReturnValue('ABC123');
const executeCommand = vi.fn().mockResolvedValue(true);
const accessor = {
get: (token: unknown) => {
if (token === ICommandService) {
return { executeCommand };
}
throw new Error(`Unexpected token: ${String(token)}`);
},
} as unknown as IAccessor;
const result = await InsertFunctionCommand.handler!(accessor, {
list: [{
range: {
startRow: 1,
endRow: 2,
startColumn: 1,
endColumn: 2,
},
primary: {
row: 1,
column: 1,
},
formula: '=SUM(B1:C2)',
}],
listOfRangeHasNumber: [{
range: {
startRow: 4,
endRow: 4,
startColumn: 0,
endColumn: 0,
},
primary: {
row: 4,
column: 0,
},
formula: '=SUM(A1:A4)',
}],
});
expect(result).toBe(true);
expect(executeCommand).toHaveBeenCalledWith(SetRangeValuesCommand.id, {
value: {
1: {
1: {
f: '=SUM(B1:C2)',
si: 'ABC123',
},
2: {
si: 'ABC123',
},
},
2: {
1: {
si: 'ABC123',
},
2: {
si: 'ABC123',
},
},
4: {
0: {
f: '=SUM(A1:A4)',
},
},
},
});
});
});

View File

@@ -0,0 +1,93 @@
/**
* 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 type { IAccessor, ICellData, ICommand, IRange } from '@univerjs/core';
import type { ISetRangeValuesCommandParams } from '@univerjs/sheets';
import { CommandType, generateRandomId, ICommandService, ObjectMatrix } from '@univerjs/core';
import { SetRangeValuesCommand } from '@univerjs/sheets';
export interface IInsertFunction {
/**
* The range into which the function is to be inserted
*/
range: IRange;
/**
* Where there is a function id, other locations reference this function id
*/
primary: {
row: number;
column: number;
};
/**
* Function name
*/
formula: string;
}
export interface IInsertFunctionCommandParams {
list: IInsertFunction[];
listOfRangeHasNumber?: IInsertFunction[];
}
export const InsertFunctionCommand: ICommand = {
id: 'formula.command.insert-function',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor, params: IInsertFunctionCommandParams) => {
const { list, listOfRangeHasNumber } = params;
const commandService = accessor.get(ICommandService);
const cellMatrix = new ObjectMatrix<ICellData>();
// Insert function when the range cell has no number value
list.forEach((item) => {
const { range, primary, formula } = item;
const { row, column } = primary;
const formulaId = generateRandomId(6);
cellMatrix.setValue(row, column, {
f: formula,
si: formulaId,
});
const { startRow, startColumn, endRow, endColumn } = range;
for (let i = startRow; i <= endRow; i++) {
for (let j = startColumn; j <= endColumn; j++) {
if (i !== row || j !== column) {
cellMatrix.setValue(i, j, {
si: formulaId,
});
}
}
}
});
// Insert function when the range cell has number value
if (listOfRangeHasNumber && listOfRangeHasNumber.length > 0) {
listOfRangeHasNumber.forEach((item) => {
const { primary, formula } = item;
cellMatrix.setValue(primary.row, primary.column, {
f: formula,
});
});
}
const setRangeValuesParams: ISetRangeValuesCommandParams = {
value: cellMatrix.clone(),
};
return commandService.executeCommand(SetRangeValuesCommand.id, setRangeValuesParams);
},
};

View File

@@ -0,0 +1,17 @@
/**
* 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.
*/
export const SHEETS_FORMULA_PLUGIN_NAME = 'SHEETS_FORMULA_PLUGIN';

View File

@@ -0,0 +1,17 @@
/**
* 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.
*/
export const META_KEY_CTRL_AND_SHIFT = 'meta_key_ctrl_And_Shift';

View File

@@ -0,0 +1,73 @@
/**
* 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 type { Ctor } from '@univerjs/core';
import type { BaseFunction, IFunctionInfo, IFunctionNames } from '@univerjs/engine-formula';
/**
* Base configuration for the plugin.
*/
export const PLUGIN_CONFIG_KEY_BASE = 'sheets-formula.base.config';
export const configSymbolBase = Symbol(PLUGIN_CONFIG_KEY_BASE);
export enum CalculationMode {
/**
* Force calculation of all formulas
*/
FORCED,
/**
* Partial calculation, only cells with formulas but no v values are calculated
*/
WHEN_EMPTY,
/**
* All formulas are not calculated
*/
NO_CALCULATION,
}
export interface IUniverSheetsFormulaBaseConfig {
notExecuteFormula?: boolean;
description?: IFunctionInfo[];
function?: Array<[Ctor<BaseFunction>, IFunctionNames]>;
/**
* Define the calculation mode during initialization, default is `WHEN_EMPTY`
*/
initialFormulaComputing?: CalculationMode;
}
export const defaultPluginBaseConfig: IUniverSheetsFormulaBaseConfig = {};
/**
* Remote configuration for the plugin.
*/
export const PLUGIN_CONFIG_KEY_REMOTE = 'sheets-formula.remote.config';
export const configSymbolRemote = Symbol(PLUGIN_CONFIG_KEY_REMOTE);
export interface IUniverSheetsFormulaRemoteConfig {}
export const defaultPluginRemoteConfig: IUniverSheetsFormulaRemoteConfig = {};
/**
* Mobile configuration for the plugin.
*/
export const PLUGIN_CONFIG_KEY_MOBILE = 'sheets-formula.mobile.config';
export const configSymbolMobile = Symbol(PLUGIN_CONFIG_KEY_MOBILE);

View File

@@ -0,0 +1,394 @@
/**
* 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 type { Dependency, ICommandInfo, IWorkbookData } from '@univerjs/core';
import type { ISetDefinedNameMutationParam } from '@univerjs/engine-formula';
import type { IInsertSheetMutationParams, IRemoveRowsMutationParams } from '@univerjs/sheets';
import type { ITestBed } from '../../facade/__tests__/create-test-bed';
import { LocaleType } from '@univerjs/core';
import {
ActiveDirtyManagerService,
FormulaDataModel,
IActiveDirtyManagerService,
RemoveDefinedNameMutation,
SetDefinedNameMutation,
} from '@univerjs/engine-formula';
import {
InsertSheetMutation,
MoveRangeMutation,
RemoveColMutation,
RemoveRowMutation,
RemoveSheetMutation,
ReorderRangeMutation,
SetRangeValuesMutation,
SetRowHiddenMutation,
SetStyleCommand,
} from '@univerjs/sheets';
import { afterEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { ActiveDirtyController } from '../active-dirty.controller';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet1'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
cellData: {},
rowCount: 6,
columnCount: 8,
},
},
styles: {},
};
}
function createControllerTestBed(): ITestBed {
const dependencies: Dependency[] = [
[IActiveDirtyManagerService, { useClass: ActiveDirtyManagerService }],
[ActiveDirtyController],
];
return createFacadeTestBed(createWorkbookData(), dependencies);
}
function getDirtyData(testBed: ITestBed, command: ICommandInfo) {
const activeDirtyManagerService = testBed.injector.get(IActiveDirtyManagerService);
const conversion = activeDirtyManagerService.get(command.id);
expect(conversion).toBeDefined();
return conversion!.getDirtyData(command);
}
function shouldTrigger(testBed: ITestBed, command: ICommandInfo) {
const conversion = testBed.injector.get(IActiveDirtyManagerService).get(command.id);
expect(conversion).toBeDefined();
return conversion!.shouldTrigger?.(command);
}
describe('ActiveDirtyController', () => {
let testBed: ITestBed;
afterEach(() => {
testBed?.univer.dispose();
});
it('should convert range mutations into dirty ranges and skip style-triggered updates', () => {
testBed = createControllerTestBed();
const formulaDataModel = testBed.injector.get(FormulaDataModel);
formulaDataModel.setArrayFormulaRange({
test: {
sheet1: {
0: {
0: {
startRow: 0,
startColumn: 0,
endRow: 1,
endColumn: 1,
},
},
},
},
});
testBed.injector.get(ActiveDirtyController);
expect(shouldTrigger(testBed, {
id: SetRangeValuesMutation.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
trigger: SetStyleCommand.id,
cellValue: {
1: {
1: { v: 1 },
},
},
},
} as ICommandInfo)).toBe(false);
expect(getDirtyData(testBed, {
id: SetRangeValuesMutation.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
1: {
1: { v: 1 },
},
3: {
2: { v: 2 },
},
},
},
} as ICommandInfo)).toEqual({
dirtyRanges: [
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 1, startColumn: 1, endRow: 1, endColumn: 1 },
},
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 3, startColumn: 2, endRow: 3, endColumn: 2 },
},
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 },
},
],
});
});
it('should collect dirty ranges and dependency cache clears for move and reorder mutations', () => {
testBed = createControllerTestBed();
testBed.injector.get(ActiveDirtyController);
expect(getDirtyData(testBed, {
id: MoveRangeMutation.id,
params: {
unitId: 'test',
from: {
subUnitId: 'sheet1',
value: {
0: {
0: { v: 1 },
},
},
},
to: {
subUnitId: 'sheet1',
value: {
2: {
2: { v: 2 },
},
},
},
},
} as ICommandInfo)).toEqual({
dirtyRanges: [
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 },
},
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 2, startColumn: 2, endRow: 2, endColumn: 2 },
},
],
clearDependencyTreeCache: {
test: {
sheet1: '1',
},
},
});
expect(getDirtyData(testBed, {
id: ReorderRangeMutation.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
range: {
startRow: 1,
startColumn: 1,
endRow: 2,
endColumn: 3,
},
},
} as ICommandInfo)).toEqual({
dirtyRanges: [
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 1, startColumn: 1, endRow: 2, endColumn: 3 },
},
],
clearDependencyTreeCache: {
test: {
sheet1: '1',
},
},
});
});
it('should expand row and column removals to worksheet bounds and keep hide-row ranges', () => {
testBed = createControllerTestBed();
testBed.injector.get(ActiveDirtyController);
const removeRowsParams: IRemoveRowsMutationParams = {
unitId: 'test',
subUnitId: 'sheet1',
range: {
startRow: 1,
startColumn: 0,
endRow: 2,
endColumn: 0,
},
};
expect(getDirtyData(testBed, {
id: RemoveRowMutation.id,
params: removeRowsParams,
} as ICommandInfo)).toEqual({
dirtyRanges: [
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 1, startColumn: 0, endRow: 2, endColumn: 7 },
},
],
clearDependencyTreeCache: {
test: {
sheet1: '1',
},
},
});
expect(getDirtyData(testBed, {
id: RemoveColMutation.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
range: {
startRow: 0,
startColumn: 2,
endRow: 0,
endColumn: 3,
},
},
} as ICommandInfo)).toEqual({
dirtyRanges: [
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 0, startColumn: 2, endRow: 6, endColumn: 3 },
},
],
clearDependencyTreeCache: {
test: {
sheet1: '1',
},
},
});
expect(getDirtyData(testBed, {
id: SetRowHiddenMutation.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
ranges: [{ startRow: 4, startColumn: 0, endRow: 5, endColumn: 1 }],
},
} as ICommandInfo)).toEqual({
dirtyRanges: [
{
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 4, startColumn: 0, endRow: 5, endColumn: 1 },
},
],
clearDependencyTreeCache: {
test: {
sheet1: '1',
},
},
});
});
it('should pass through sheet and defined-name dirty maps', () => {
testBed = createControllerTestBed();
testBed.injector.get(ActiveDirtyController);
const definedNameParams: ISetDefinedNameMutationParam = {
unitId: 'test',
id: 'defined-name',
name: 'DIRTY_NAME',
formulaOrRefString: 'Sheet1!$A$1',
};
const insertSheetParams = {
unitId: 'test',
sheet: {
...createWorkbookData().sheets.sheet1,
id: 'sheet-added',
name: 'Added Sheet',
},
} as IInsertSheetMutationParams;
expect(getDirtyData(testBed, {
id: RemoveSheetMutation.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
subUnitName: 'Sheet1',
},
} as ICommandInfo)).toEqual({
dirtyNameMap: {
test: {
sheet1: 'Sheet1',
},
},
clearDependencyTreeCache: {
test: {
sheet1: '1',
},
},
});
expect(getDirtyData(testBed, {
id: InsertSheetMutation.id,
params: insertSheetParams,
} as ICommandInfo)).toEqual({
dirtyNameMap: {
test: {
'sheet-added': 'Added Sheet',
},
},
});
expect(getDirtyData(testBed, {
id: SetDefinedNameMutation.id,
params: definedNameParams,
} as ICommandInfo)).toEqual({
dirtyDefinedNameMap: {
test: {
DIRTY_NAME: 'Sheet1!$A$1',
},
},
});
expect(getDirtyData(testBed, {
id: RemoveDefinedNameMutation.id,
params: definedNameParams,
} as ICommandInfo)).toEqual({
dirtyDefinedNameMap: {
test: {
DIRTY_NAME: 'Sheet1!$A$1',
},
},
});
});
});

View File

@@ -0,0 +1,145 @@
/**
* 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 type { Dependency, IWorkbookData, Workbook, Worksheet } from '@univerjs/core';
import {
CellValueType,
ICommandService,
InterceptorEffectEnum,
LocaleType,
} from '@univerjs/core';
import {
FormulaDataModel,
SetArrayFormulaDataMutation,
} from '@univerjs/engine-formula';
import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { ArrayFormulaCellInterceptorController } from '../array-formula-cell-interceptor.controller';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet1'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
1: {
1: { v: '1.2300000000000002', t: CellValueType.NUMBER },
},
2: {
2: { v: 'stale', t: CellValueType.STRING },
},
3: {
0: { f: '=SUM(A1)' },
1: { f: '=SUM(A1)' },
2: { f: '=SKIP()', x: 1 } as unknown as never,
},
},
},
},
styles: {},
};
}
function createControllerTestBed() {
const dependencies: Dependency[] = [[ArrayFormulaCellInterceptorController]];
return createFacadeTestBed(createWorkbookData(), dependencies);
}
function getInterceptedCell(worksheet: Worksheet, workbook: Workbook, row: number, col: number, interceptorService: SheetInterceptorService) {
const rawData = worksheet.getCellRaw(row, col);
return interceptorService.fetchThroughInterceptors(INTERCEPTOR_POINT.CELL_CONTENT, InterceptorEffectEnum.Value)(
rawData,
{
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
row,
col,
worksheet,
workbook,
rawData,
}
);
}
describe('ArrayFormulaCellInterceptorController', () => {
let testBed: ReturnType<typeof createControllerTestBed>;
let commandService: ICommandService;
let formulaDataModel: FormulaDataModel;
let interceptorService: SheetInterceptorService;
let workbook: Workbook;
let worksheet: Worksheet;
beforeEach(() => {
testBed = createControllerTestBed();
commandService = testBed.injector.get(ICommandService);
formulaDataModel = testBed.injector.get(FormulaDataModel);
interceptorService = testBed.injector.get(SheetInterceptorService);
workbook = testBed.sheet as Workbook;
worksheet = workbook.getActiveSheet()!;
commandService.registerCommand(SetArrayFormulaDataMutation);
testBed.injector.get(ArrayFormulaCellInterceptorController);
});
afterEach(() => {
vi.restoreAllMocks();
testBed?.univer.dispose();
});
it('should intercept array formula cells with default values, precision cleanup, raw passthrough and array values', () => {
formulaDataModel.setArrayFormulaCellData({
test: {
sheet1: {
0: {
0: { v: null, t: null },
},
1: {
1: { v: 99, t: CellValueType.NUMBER },
},
2: {
2: { v: 7, t: CellValueType.NUMBER },
},
},
},
});
expect(getInterceptedCell(worksheet, workbook, 0, 0, interceptorService)).toEqual({
v: 0,
t: CellValueType.NUMBER,
});
expect(getInterceptedCell(worksheet, workbook, 1, 1, interceptorService)).toEqual({
v: 1.23,
t: CellValueType.NUMBER,
});
expect(getInterceptedCell(worksheet, workbook, 2, 2, interceptorService)).toEqual({
v: 7,
t: CellValueType.NUMBER,
});
expect(getInterceptedCell(worksheet, workbook, 4, 4, interceptorService)).toBeUndefined();
});
});

View File

@@ -0,0 +1,345 @@
/**
* 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 type { ICommand, IWorkbookData, Workbook, Worksheet } from '@univerjs/core';
import type { FFormula } from '@univerjs/engine-formula/facade';
import type { IMessageProtocol } from '@univerjs/rpc';
import {
ICommandService,
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleType,
LogLevel,
Plugin,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import {
CalculateController,
ErrorType,
FormulaDataModel,
SetArrayFormulaDataMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,
SetFormulaCalculationStartMutation,
UniverFormulaEnginePlugin,
} from '@univerjs/engine-formula';
import {
ChannelService,
DataSyncPrimaryController,
DataSyncReplicaController,
IRemoteInstanceService,
IRemoteSyncService,
IRPCChannelService,
RemoteSyncPrimaryService,
WebWorkerRemoteInstanceService,
} from '@univerjs/rpc';
import { SetRangeValuesMutation, UniverSheetsPlugin } from '@univerjs/sheets';
import { ReplaySubject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import formulaEnUS from '../../locale/en-US';
import { UniverRemoteSheetsFormulaPlugin, UniverSheetsFormulaPlugin } from '../../plugin';
import '@univerjs/engine-formula/facade';
const unitId = 'test';
const subUnitId = 'sheet1';
function createPairedMessageProtocols(): [IMessageProtocol, IMessageProtocol] {
const mainToWorker$ = new ReplaySubject<unknown>(1);
const workerToMain$ = new ReplaySubject<unknown>(1);
return [
{
send(message: unknown) {
mainToWorker$.next(message);
},
onMessage: workerToMain$.asObservable(),
},
{
send(message: unknown) {
workerToMain$.next(message);
},
onMessage: mainToWorker$.asObservable(),
},
];
}
function waitForMicrotasks(count = 4): Promise<void> {
let promise = Promise.resolve();
for (let i = 0; i < count; i++) {
promise = promise.then(() => undefined);
}
return promise;
}
function registerCommand(commandService: ICommandService, command: ICommand) {
try {
commandService.registerCommand(command);
} catch (error) {
if (!(error instanceof Error) || !error.message.includes('has been registered before')) {
throw error;
}
}
}
async function waitForCondition(condition: () => boolean): Promise<void> {
for (let i = 0; i < 20; i++) {
if (condition()) {
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
throw new Error('Timed out waiting for condition.');
}
function createWorkbookData(): IWorkbookData {
const sourceCellData: NonNullable<IWorkbookData['sheets'][string]['cellData']> = {};
for (let row = 2; row <= 9; row++) {
sourceCellData[row] = {};
for (let column = 2; column <= 6; column++) {
sourceCellData[row]![column] = {
v: row + column,
t: 2,
};
}
}
return {
id: unitId,
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: [subUnitId],
styles: {},
sheets: {
[subUnitId]: {
id: subUnitId,
name: 'Sheet1',
rowCount: 30,
columnCount: 20,
cellData: {
...sourceCellData,
12: {
6: {
f: '=C3:G10',
},
},
},
},
},
};
}
function createWorkerFormulaTestBed() {
const [mainProtocol, workerProtocol] = createPairedMessageProtocols();
class MainRPCPlugin extends Plugin {
static override pluginName = 'test-main-rpc-plugin';
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
this._injector.add([IRPCChannelService, {
// eslint-disable-next-line react/no-unnecessary-use-prefix
useFactory: () => new ChannelService(mainProtocol),
}]);
this._injector.add([DataSyncPrimaryController]);
this._injector.add([IRemoteSyncService, { useClass: RemoteSyncPrimaryService }]);
this._injector.get(DataSyncPrimaryController);
}
}
class WorkerRPCPlugin extends Plugin {
static override pluginName = 'test-worker-rpc-plugin';
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
this._injector.add([IRPCChannelService, {
// eslint-disable-next-line react/no-unnecessary-use-prefix
useFactory: () => new ChannelService(workerProtocol),
}]);
this._injector.add([IRemoteInstanceService, { useClass: WebWorkerRemoteInstanceService }]);
this._injector.add([DataSyncReplicaController]);
this._injector.get(DataSyncReplicaController);
}
}
const workerUniver = new Univer({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: formulaEnUS,
},
logLevel: LogLevel.SILENT,
});
workerUniver.registerPlugins([
[UniverSheetsPlugin, { onlyRegisterFormulaRelatedMutations: true }],
[UniverFormulaEnginePlugin],
[WorkerRPCPlugin],
[UniverRemoteSheetsFormulaPlugin],
]);
const mainUniver = new Univer({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: formulaEnUS,
},
logLevel: LogLevel.SILENT,
});
mainUniver.registerPlugins([
[MainRPCPlugin],
[UniverSheetsPlugin, { notExecuteFormula: true }],
[UniverFormulaEnginePlugin, { notExecuteFormula: true }],
[UniverSheetsFormulaPlugin, { notExecuteFormula: true }],
]);
const mainInjector = mainUniver.__getInjector();
const workerInjector = workerUniver.__getInjector();
const mainCommandService = mainInjector.get(ICommandService);
const workerCommandService = workerInjector.get(ICommandService);
mainInjector.get(ILogService).setLogLevel(LogLevel.SILENT);
workerInjector.get(ILogService).setLogLevel(LogLevel.SILENT);
const sheet = mainUniver.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
return {
commandService: mainCommandService,
formulaDataModel: mainInjector.get(FormulaDataModel),
mainUniver,
sheet,
workerCommandService,
workerFormulaEngine: FUniver.newAPI(workerUniver).getFormula() as FFormula,
workerInjector,
workerInstanceService: workerInjector.get(IUniverInstanceService),
workerUniver,
};
}
describe('Array formula SPILL with worker formula calculation', () => {
let testBed: ReturnType<typeof createWorkerFormulaTestBed>;
let commandService: ICommandService;
let formulaDataModel: FormulaDataModel;
let worksheet: Worksheet;
beforeEach(() => {
testBed = createWorkerFormulaTestBed();
commandService = testBed.commandService;
formulaDataModel = testBed.formulaDataModel;
worksheet = testBed.sheet.getSheetBySheetId(subUnitId) as Worksheet;
});
afterEach(() => {
testBed.mainUniver.dispose();
testBed.workerUniver.dispose();
});
async function calculateInWorker() {
await waitForCondition(() => testBed.workerInstanceService.getUnit(unitId) != null);
[
SetArrayFormulaDataMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,
SetFormulaCalculationStartMutation,
SetRangeValuesMutation,
].forEach((command) => {
registerCommand(commandService, command);
registerCommand(testBed.workerCommandService, command);
});
testBed.workerInjector.get(CalculateController);
const calculationEnd = new Promise<void>((resolve) => {
const disposable = testBed.workerFormulaEngine.calculationEnd(() => {
disposable.dispose();
resolve();
});
});
// Also wait for the calculation result to be synced back to the main thread.
const resultSyncedToMain = new Promise<void>((resolve) => {
const disposable = commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetFormulaCalculationResultMutation.id) {
disposable.dispose();
resolve();
}
});
});
testBed.workerCommandService.syncExecuteCommand(SetFormulaCalculationStartMutation.id, { forceCalculation: true }, { onlyLocal: true });
await calculationEnd;
await resultSyncedToMain;
await waitForMicrotasks(2);
}
async function setCell(row: number, column: number, value: number) {
await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId,
subUnitId,
cellValue: {
[row]: {
[column]: {
v: value,
t: 2,
},
},
},
});
}
it('should preserve multiple real blockers inside a spilled array reference formula', async () => {
await calculateInWorker();
expect(formulaDataModel.getArrayFormulaRange()[unitId]?.[subUnitId]?.[12]?.[6]).toEqual({
startRow: 12,
startColumn: 6,
endRow: 19,
endColumn: 10,
});
await setCell(14, 9, 333);
await calculateInWorker();
expect(worksheet.getCellRaw(12, 6)?.v).toBe(ErrorType.SPILL);
expect(worksheet.getCellRaw(14, 9)?.v).toBe(333);
expect(formulaDataModel.getArrayFormulaCellData()[unitId]?.[subUnitId]?.[14]?.[9]).toBeUndefined();
await setCell(15, 9, 444);
await calculateInWorker();
expect(worksheet.getCellRaw(12, 6)?.v).toBe(ErrorType.SPILL);
expect(worksheet.getCellRaw(14, 9)?.v).toBe(333);
expect(worksheet.getCellRaw(15, 9)?.v).toBe(444);
expect(formulaDataModel.getArrayFormulaCellData()[unitId]?.[subUnitId]?.[14]?.[9]).toBeUndefined();
expect(formulaDataModel.getArrayFormulaCellData()[unitId]?.[subUnitId]?.[15]?.[9]).toBeUndefined();
});
});

View File

@@ -0,0 +1,198 @@
/**
* 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 type { Dependency, IWorkbookData } from '@univerjs/core';
import type { ITestBed } from '../../facade/__tests__/create-test-bed';
import { ICommandService, IUniverInstanceService, LocaleType, UniverInstanceType } from '@univerjs/core';
import {
DescriptionService,
FunctionType,
IDefinedNamesService,
IDescriptionService,
RemoveDefinedNameMutation,
SetDefinedNameMutation,
} from '@univerjs/engine-formula';
import { SCOPE_WORKBOOK_VALUE_DEFINED_NAME, SetWorksheetActiveOperation } from '@univerjs/sheets';
import { afterEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { DefinedNameController } from '../defined-name.controller';
function createWorkbookData(id = 'test'): IWorkbookData {
return {
id,
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: id,
sheetOrder: ['sheet1', 'sheet2'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
cellData: {},
rowCount: 20,
columnCount: 20,
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
cellData: {},
rowCount: 20,
columnCount: 20,
},
},
styles: {},
};
}
function createControllerTestBed(): ITestBed {
const dependencies: Dependency[] = [
[IDescriptionService, { useClass: DescriptionService }],
[DefinedNameController],
];
return createFacadeTestBed(createWorkbookData(), dependencies);
}
describe('DefinedNameController', () => {
let testBed: ITestBed;
afterEach(() => {
testBed?.univer.dispose();
});
it('should register descriptions by current sheet and refresh them when active sheet or unit changes', async () => {
testBed = createControllerTestBed();
const injector = testBed.injector;
const commandService = injector.get(ICommandService);
const descriptionService = injector.get(IDescriptionService);
const definedNamesService = injector.get(IDefinedNamesService);
const univerInstanceService = injector.get(IUniverInstanceService);
commandService.registerCommand(SetWorksheetActiveOperation);
injector.get(DefinedNameController);
definedNamesService.registerDefinedNames('test', {
workbook: {
id: 'workbook',
name: 'UNIT_SCOPE_NAME',
formulaOrRefString: 'Sheet1!$A$1',
localSheetId: SCOPE_WORKBOOK_VALUE_DEFINED_NAME,
},
localSheet1: {
id: 'local-sheet1',
name: 'LOCAL_SHEET1_NAME',
formulaOrRefString: 'Sheet1!$B$2',
localSheetId: 'sheet1',
},
localSheet2: {
id: 'local-sheet2',
name: 'LOCAL_SHEET2_NAME',
formulaOrRefString: 'Sheet2!$C$3',
localSheetId: 'sheet2',
},
});
expect(descriptionService.hasDescription('UNIT_SCOPE_NAME')).toBe(true);
expect(descriptionService.hasDescription('LOCAL_SHEET1_NAME')).toBe(true);
expect(descriptionService.hasDescription('LOCAL_SHEET2_NAME')).toBe(false);
await commandService.executeCommand(SetWorksheetActiveOperation.id, { unitId: 'test', subUnitId: 'sheet2' });
expect(descriptionService.hasDescription('UNIT_SCOPE_NAME')).toBe(true);
expect(descriptionService.hasDescription('LOCAL_SHEET1_NAME')).toBe(false);
expect(descriptionService.hasDescription('LOCAL_SHEET2_NAME')).toBe(true);
testBed.univer.createUnit(UniverInstanceType.UNIVER_SHEET, createWorkbookData('secondary'));
univerInstanceService.setCurrentUnitForType('test');
definedNamesService.registerDefinedNames('secondary', {
secondary: {
id: 'secondary',
name: 'SECONDARY_UNIT_NAME',
formulaOrRefString: 'Sheet1!$D$4',
localSheetId: SCOPE_WORKBOOK_VALUE_DEFINED_NAME,
},
});
univerInstanceService.setCurrentUnitForType('secondary');
expect(descriptionService.hasDescription('UNIT_SCOPE_NAME')).toBe(false);
expect(descriptionService.hasDescription('LOCAL_SHEET2_NAME')).toBe(false);
expect(descriptionService.hasDescription('SECONDARY_UNIT_NAME')).toBe(true);
});
it('should register mutation descriptions based on the provided sheet context and remove them on delete', async () => {
testBed = createControllerTestBed();
const injector = testBed.injector;
const commandService = injector.get(ICommandService);
const descriptionService = injector.get(IDescriptionService);
commandService.registerCommand(SetDefinedNameMutation);
commandService.registerCommand(RemoveDefinedNameMutation);
injector.get(DefinedNameController);
await commandService.executeCommand(SetDefinedNameMutation.id, {
unitId: 'test',
id: 'off-sheet',
name: 'OFF_SHEET_NAME',
formulaOrRefString: 'Sheet2!$A$1',
localSheetId: 'sheet2',
});
expect(descriptionService.hasDescription('OFF_SHEET_NAME')).toBe(false);
await commandService.executeCommand(SetDefinedNameMutation.id, {
unitId: 'test',
subUnitId: 'sheet2',
id: 'sheet2-name',
name: 'SHEET2_MUTATION_NAME',
formulaOrRefString: 'Sheet2!$C$4',
localSheetId: 'sheet2',
});
expect(descriptionService.hasDescription('SHEET2_MUTATION_NAME')).toBe(true);
await commandService.executeCommand(SetDefinedNameMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
id: 'active-sheet',
name: 'ACTIVE_SHEET_NAME',
formulaOrRefString: 'Sheet1!$B$2',
comment: ' active comment',
localSheetId: 'sheet1',
});
expect(descriptionService.getFunctionInfo('ACTIVE_SHEET_NAME')).toEqual({
functionName: 'ACTIVE_SHEET_NAME',
functionType: FunctionType.DefinedName,
description: 'Sheet1!$B$2active comment',
abstract: 'Sheet1!$B$2',
functionParameter: [],
});
await commandService.executeCommand(RemoveDefinedNameMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
id: 'active-sheet',
name: 'ACTIVE_SHEET_NAME',
formulaOrRefString: 'Sheet1!$B$2',
localSheetId: 'sheet1',
});
expect(descriptionService.hasDescription('ACTIVE_SHEET_NAME')).toBe(false);
});
});

View File

@@ -0,0 +1,178 @@
/**
* 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 type { Dependency, Direction, Nullable } from '@univerjs/core';
import type { IAutoFillLocation } from '@univerjs/sheets';
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';
function createControllerTestBed() {
const dependencies: Dependency[] = [
[IAutoFillService, { useClass: AutoFillService }],
[FormulaAutoFillController],
];
return createFacadeTestBed(undefined, dependencies);
}
function createLocation(sourceRows: number[], targetRows: number[]): IAutoFillLocation {
return {
unitId: 'test',
subUnitId: 'sheet1',
source: {
rows: sourceRows,
cols: [0],
},
target: {
rows: targetRows,
cols: [0],
},
};
}
describe('FormulaAutoFillController', () => {
const directions: Array<{ direction: Direction; expected: { offsetX: number; offsetY: number } }> = [
{ direction: 0, expected: { offsetX: 0, offsetY: -3 } },
{ direction: 1, expected: { offsetX: 2, offsetY: 0 } },
{ direction: 2, expected: { offsetX: 0, offsetY: 3 } },
{ direction: 3, expected: { offsetX: -4, offsetY: 0 } },
];
let testBed: ReturnType<typeof createControllerTestBed>;
beforeEach(() => {
testBed = createControllerTestBed();
testBed.injector.get(FormulaAutoFillController);
});
afterEach(() => {
vi.restoreAllMocks();
testBed?.univer.dispose();
});
it('should register a formula auto-fill rule and recognize formula cells', () => {
const autoFillService = testBed.injector.get(IAutoFillService);
const formulaRule = autoFillService.getRules().find((rule) => rule.type === AUTO_FILL_DATA_TYPE.FORMULA);
expect(formulaRule).toBeDefined();
expect(formulaRule?.match({ f: '=SUM(A1)' }, testBed.injector as never)).toBe(true);
expect(formulaRule?.match({ si: 'shared-id' }, testBed.injector as never)).toBe(true);
expect(formulaRule?.match({ v: 1 }, testBed.injector as never)).toBe(false);
expect(formulaRule?.isContinue({ type: AUTO_FILL_DATA_TYPE.FORMULA, cellData: { f: '=A1' } }, null)).toBe(true);
expect(formulaRule?.isContinue({ type: AUTO_FILL_DATA_TYPE.NUMBER, cellData: { v: 1 } }, null)).toBe(false);
});
it('should offset copied formulas by direction and reuse the generated formula id', () => {
const autoFillService = testBed.injector.get(IAutoFillService);
const lexerTreeBuilder = testBed.injector.get(LexerTreeBuilder);
const formulaRule = autoFillService.getRules().find((rule) => rule.type === AUTO_FILL_DATA_TYPE.FORMULA);
expect(formulaRule).toBeDefined();
if (!formulaRule || !formulaRule.applyFunctions?.[AUTO_FILL_APPLY_TYPE.COPY]) {
throw new Error('Formula auto-fill copy rule is not registered');
}
const applyCopy = formulaRule.applyFunctions[AUTO_FILL_APPLY_TYPE.COPY];
const moveFormulaRefOffsetSpy = vi
.spyOn(lexerTreeBuilder, 'moveFormulaRefOffset')
.mockImplementation((formula, offsetX, offsetY) => `${formula}|${offsetX},${offsetY}`);
directions.forEach(({ direction, expected }) => {
const result = applyCopy(
{
data: [{ f: '=SUM(A1)' }],
index: [0],
},
2,
direction,
{
formula: [{
data: [{ f: '=SUM(A1)' }, { f: '=SUM(B1)' }],
index: [0, 1],
}],
},
createLocation([5], direction === 0 ? [2] : direction === 2 ? [8] : [5])
);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
f: `=SUM(A1)|${expected.offsetX},${expected.offsetY}`,
si: expect.any(String),
v: null,
p: null,
t: null,
});
expect(result[1]).toMatchObject({
si: result[0]?.si,
f: null,
v: null,
p: null,
t: null,
});
});
expect(moveFormulaRefOffsetSpy.mock.calls).toEqual([
['=SUM(A1)', 0, -3],
['=SUM(A1)', 2, 0],
['=SUM(A1)', 0, 3],
['=SUM(A1)', -4, 0],
]);
});
it('should preserve shared formula ids and skip non-formula or null cells', () => {
const autoFillService = testBed.injector.get(IAutoFillService);
const formulaRule = autoFillService.getRules().find((rule) => rule.type === AUTO_FILL_DATA_TYPE.FORMULA);
expect(formulaRule).toBeDefined();
if (!formulaRule || !formulaRule.applyFunctions?.[AUTO_FILL_APPLY_TYPE.COPY]) {
throw new Error('Formula auto-fill copy rule is not registered');
}
const applyCopy = formulaRule.applyFunctions[AUTO_FILL_APPLY_TYPE.COPY];
const result = applyCopy(
{
data: [{ si: 'shared-id' }, { v: 1 }, null] as Array<Nullable<{ si?: string; v?: number }>>,
index: [0, 1, 2],
},
3,
2,
{
formula: [{
data: [{ si: 'shared-id' }],
index: [0],
}],
},
createLocation([0, 1, 2], [3, 4, 5])
);
expect(result).toEqual([
{
si: 'shared-id',
f: null,
v: null,
p: null,
t: null,
},
]);
});
});

View File

@@ -0,0 +1,36 @@
/**
* 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 { ICommandService } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { InsertFunctionCommand } from '../../commands/commands/insert-function.command';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { FormulaController } from '../formula.controller';
describe('FormulaController', () => {
it('registers InsertFunctionCommand through the real command service', () => {
const testBed = createFacadeTestBed(undefined, [[FormulaController]]);
const commandService = testBed.injector.get(ICommandService);
expect(commandService.hasCommand(InsertFunctionCommand.id)).toBe(false);
testBed.injector.get(FormulaController);
expect(commandService.hasCommand(InsertFunctionCommand.id)).toBe(true);
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,190 @@
/**
* 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 type { Dependency, IWorkbookData, Workbook, Worksheet } from '@univerjs/core';
import type { IImageFormulaInfo } from '@univerjs/engine-formula';
import { awaitTime, CellValueType, ICommandService, InterceptorEffectEnum, LocaleType, ObjectMatrix } from '@univerjs/core';
import { ErrorType, FormulaDataModel, SetImageFormulaDataMutation } from '@univerjs/engine-formula';
import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { ImageFormulaCellInterceptorController } from '../image-formula-cell-interceptor.controller';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet1'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {},
},
},
styles: {},
};
}
function createControllerTestBed() {
const dependencies: Dependency[] = [[ImageFormulaCellInterceptorController]];
return createFacadeTestBed(createWorkbookData(), dependencies);
}
function getInterceptedCell(worksheet: Worksheet, workbook: Workbook, row: number, col: number, interceptorService: SheetInterceptorService) {
const rawData = worksheet.getCellRaw(row, col);
return interceptorService.fetchThroughInterceptors(INTERCEPTOR_POINT.CELL_CONTENT, InterceptorEffectEnum.Value)(
rawData,
{
unitId: workbook.getUnitId(),
subUnitId: worksheet.getSheetId(),
row,
col,
worksheet,
workbook,
rawData,
}
);
}
async function waitForAssertion(assertion: () => void, timeout = 1000, interval = 10) {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
assertion();
return;
} catch {
await awaitTime(interval);
}
}
assertion();
}
describe('ImageFormulaCellInterceptorController', () => {
let testBed: ReturnType<typeof createControllerTestBed>;
let controller: ImageFormulaCellInterceptorController;
let commandService: ICommandService;
let formulaDataModel: FormulaDataModel;
let interceptorService: SheetInterceptorService;
let workbook: Workbook;
let worksheet: Worksheet;
beforeEach(() => {
testBed = createControllerTestBed();
commandService = testBed.injector.get(ICommandService);
commandService.registerCommand(SetImageFormulaDataMutation);
controller = testBed.injector.get(ImageFormulaCellInterceptorController);
formulaDataModel = testBed.injector.get(FormulaDataModel);
interceptorService = testBed.injector.get(SheetInterceptorService);
workbook = testBed.sheet as Workbook;
worksheet = workbook.getActiveSheet()!;
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
testBed?.univer.dispose();
});
it('hydrates natural image size from mutation data and injects a document snapshot into cell content', async () => {
const refreshRender = vi.fn();
class MockImage {
width = 320;
height = 240;
private _src = '';
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
get src() {
return this._src;
}
set src(value: string) {
this._src = value;
if (value === 'https://img.test/success.png') {
queueMicrotask(() => this.onload?.());
} else {
queueMicrotask(() => this.onerror?.());
}
}
}
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
controller.registerRefreshRenderFunction(refreshRender);
await commandService.executeCommand(SetImageFormulaDataMutation.id, {
imageFormulaData: [{
unitId: 'test',
sheetId: 'sheet1',
row: 0,
column: 0,
source: 'https://img.test/success.png',
}],
});
await waitForAssertion(() => {
expect(formulaDataModel.getUnitImageFormulaData()?.test?.sheet1?.getValue(0, 0)).toMatchObject({
source: 'https://img.test/success.png',
imageNaturalWidth: 320,
imageNaturalHeight: 240,
isErrorImage: false,
});
});
expect(refreshRender).toHaveBeenCalledTimes(1);
expect(getInterceptedCell(worksheet, workbook, 0, 0, interceptorService)).toEqual(expect.objectContaining({
p: expect.any(Object),
}));
});
it('returns VALUE errors for failed images and ignores empty mutation payloads', async () => {
const refreshRender = vi.fn();
controller.registerRefreshRenderFunction(refreshRender);
await commandService.executeCommand(SetImageFormulaDataMutation.id, {
imageFormulaData: [],
});
expect(refreshRender).not.toHaveBeenCalled();
const matrix = new ObjectMatrix<IImageFormulaInfo>();
matrix.setValue(1, 1, {
altText: '',
sizing: 0,
height: 0,
width: 0,
source: 'https://img.test/fail.png',
isErrorImage: true,
});
formulaDataModel.mergeUnitImageFormulaData({
test: {
sheet1: matrix,
},
});
expect(getInterceptedCell(worksheet, workbook, 1, 1, interceptorService)).toEqual({
v: ErrorType.VALUE,
t: CellValueType.STRING,
});
});
});

View File

@@ -0,0 +1,117 @@
/**
* 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 {
ICommandService,
isNodeEnv,
LifecycleService,
LifecycleStages,
LocaleService,
LocaleType,
Univer,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { SetFormulaCalculationStartMutation } from '@univerjs/engine-formula';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { UniverSheetsFormulaPlugin } from '../../plugin';
import '@univerjs/engine-formula/facade';
import '@univerjs/sheets/facade';
import '../../facade';
function createWorkbookData() {
return {
id: 'standalone-sheet-formula',
name: 'Standalone Sheet Formula',
appVersion: 'test',
locale: LocaleType.EN_US,
styles: {},
sheetOrder: ['sheet-1'],
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { v: 2 },
1: { f: '=A1*2' },
},
},
},
},
};
}
describe('standalone Sheet formula calculation trigger', () => {
let univer: Univer | undefined;
afterEach(() => {
univer?.dispose();
univer = undefined;
vi.restoreAllMocks();
});
it('calculates on cold start and recalculates after value and formula edits', async () => {
vi.mocked(isNodeEnv).mockReturnValue(false);
univer = new Univer();
const injector = univer.__getInjector();
injector.get(LocaleService).load({ [LocaleType.EN_US]: {} });
injector.get(LocaleService).setLocale(LocaleType.EN_US);
univer.registerPlugin(UniverSheetsFormulaPlugin);
let calculationStarts = 0;
injector.get(ICommandService).onCommandExecuted((command) => {
if (command.id === SetFormulaCalculationStartMutation.id) {
calculationStarts++;
}
});
const api = FUniver.newAPI(univer);
const workbook = api.createWorkbook(createWorkbookData());
const sheet = workbook.getActiveSheet();
const input = sheet.getRange('A1');
const output = sheet.getRange('B1');
const startupResultApplied = api.getFormula().onCalculationResultApplied(10_000);
injector.get(LifecycleService).stage = LifecycleStages.Rendered;
await startupResultApplied;
expect(output.getValue()).toBe(4);
expect(calculationStarts).toBe(1);
const valueEditResultApplied = api.getFormula().onCalculationResultApplied(10_000);
input.setValue(3);
await valueEditResultApplied;
expect(output.getValue()).toBe(6);
expect(calculationStarts).toBe(2);
const formulaEditResultApplied = api.getFormula().onCalculationResultApplied(10_000);
output.setFormula('=A1*3');
await formulaEditResultApplied;
expect(output.getValue()).toBe(9);
expect(calculationStarts).toBe(3);
});
});
vi.mock('@univerjs/core', async (importOriginal) => {
const actual = await importOriginal<typeof import('@univerjs/core')>();
return {
...actual,
isNodeEnv: vi.fn(actual.isNodeEnv),
};
});

View File

@@ -0,0 +1,198 @@
/**
* 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 type { Dependency, IWorkbookData } from '@univerjs/core';
import type { ISuperTable } from '@univerjs/engine-formula';
import type { ITestBed } from '../../facade/__tests__/create-test-bed';
import { ICommandService, IUniverInstanceService, LocaleType, UniverInstanceType } from '@univerjs/core';
import {
DescriptionService,
FunctionType,
IDescriptionService,
ISuperTableService,
RemoveSuperTableMutation,
serializeRangeWithSheet,
SetSuperTableMutation,
SuperTableService,
} from '@univerjs/engine-formula';
import { SetWorksheetActiveOperation } from '@univerjs/sheets';
import { afterEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { SuperTableController } from '../super-table.controller';
function createWorkbookData(id = 'test'): IWorkbookData {
return {
id,
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: id,
sheetOrder: ['sheet1', 'sheet2'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
cellData: {},
rowCount: 20,
columnCount: 20,
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
cellData: {},
rowCount: 20,
columnCount: 20,
},
},
styles: {},
};
}
function createSuperTableReference(sheetId: string): ISuperTable {
return {
sheetId,
titleMap: new Map(),
range: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
};
}
function createControllerTestBed(): ITestBed {
const dependencies: Dependency[] = [
[IDescriptionService, { useClass: DescriptionService }],
[ISuperTableService, { useClass: SuperTableService }],
[SuperTableController],
];
return createFacadeTestBed(createWorkbookData(), dependencies);
}
describe('SuperTableController', () => {
let testBed: ITestBed;
afterEach(() => {
testBed?.univer.dispose();
});
it('should register table descriptions and refresh them when current unit changes', () => {
testBed = createControllerTestBed();
const injector = testBed.injector;
const descriptionService = injector.get(IDescriptionService);
const superTableService = injector.get(ISuperTableService);
const univerInstanceService = injector.get(IUniverInstanceService);
injector.get(SuperTableController);
superTableService.registerTable('test', 'TABLE_ONE', createSuperTableReference('sheet1'));
superTableService.registerTable('test', 'TABLE_TWO', createSuperTableReference('sheet2'));
expect(descriptionService.getFunctionInfo('TABLE_ONE')).toEqual({
functionName: 'TABLE_ONE',
functionType: FunctionType.Table,
description: serializeRangeWithSheet('Sheet1', createSuperTableReference('sheet1').range),
abstract: serializeRangeWithSheet('Sheet1', createSuperTableReference('sheet1').range),
functionParameter: [],
});
expect(descriptionService.hasDescription('TABLE_TWO')).toBe(true);
testBed.univer.createUnit(UniverInstanceType.UNIVER_SHEET, createWorkbookData('secondary'));
univerInstanceService.setCurrentUnitForType('test');
superTableService.registerTable('secondary', 'TABLE_THREE', createSuperTableReference('sheet1'));
univerInstanceService.setCurrentUnitForType('secondary');
expect(descriptionService.hasDescription('TABLE_ONE')).toBe(false);
expect(descriptionService.hasDescription('TABLE_TWO')).toBe(false);
expect(descriptionService.hasDescription('TABLE_THREE')).toBe(true);
});
it('should register and remove table descriptions through mutations', async () => {
testBed = createControllerTestBed();
const injector = testBed.injector;
const commandService = injector.get(ICommandService);
const descriptionService = injector.get(IDescriptionService);
const reference = createSuperTableReference('sheet2');
const expectedRef = serializeRangeWithSheet('Sheet2', reference.range);
commandService.registerCommand(SetSuperTableMutation);
commandService.registerCommand(RemoveSuperTableMutation);
injector.get(SuperTableController);
await commandService.executeCommand(SetSuperTableMutation.id, {
unitId: 'test',
subUnitId: 'sheet2',
tableName: 'TABLE_MUTATION',
reference,
});
expect(descriptionService.getFunctionInfo('TABLE_MUTATION')).toEqual({
functionName: 'TABLE_MUTATION',
functionType: FunctionType.Table,
description: expectedRef,
abstract: expectedRef,
functionParameter: [],
});
await commandService.executeCommand(RemoveSuperTableMutation.id, {
unitId: 'test',
tableName: 'TABLE_MUTATION',
});
expect(descriptionService.hasDescription('TABLE_MUTATION')).toBe(false);
});
it('should ignore collaborative sheet switches and skip unknown mutation targets', async () => {
testBed = createControllerTestBed();
const injector = testBed.injector;
const commandService = injector.get(ICommandService);
const descriptionService = injector.get(IDescriptionService);
const superTableService = injector.get(ISuperTableService);
commandService.registerCommand(SetWorksheetActiveOperation);
commandService.registerCommand(SetSuperTableMutation);
injector.get(SuperTableController);
superTableService.registerTable('test', 'TABLE_ONE', createSuperTableReference('sheet1'));
await commandService.executeCommand(
SetWorksheetActiveOperation.id,
{
unitId: 'test',
subUnitId: 'sheet2',
},
{
fromCollab: true,
}
);
expect(descriptionService.hasDescription('TABLE_ONE')).toBe(true);
await commandService.executeCommand(SetSuperTableMutation.id, {
unitId: 'missing-unit',
subUnitId: 'sheet1',
tableName: 'UNKNOWN_TABLE',
reference: createSuperTableReference('sheet1'),
});
expect(descriptionService.hasDescription('UNKNOWN_TABLE')).toBe(false);
});
});

View File

@@ -0,0 +1,376 @@
/**
* 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 type { Dependency, IWorkbookData } from '@univerjs/core';
import type { IExecutionInProgressParams } from '@univerjs/engine-formula';
import {
CommandType,
ICommandService,
IConfigService,
LocaleType,
} from '@univerjs/core';
import {
ActiveDirtyManagerService,
ENGINE_FORMULA_CYCLE_REFERENCE_COUNT,
ENGINE_FORMULA_RETURN_DEPENDENCY_TREE,
FormulaCalculationSessionController,
FormulaCalculationSessionService,
FormulaCalculationTriggerService,
FormulaDataModel,
FormulaExecutedStateType,
FormulaExecuteStageType,
IActiveDirtyManagerService,
RegisterOtherFormulaService,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationStartMutation,
SetFormulaCalculationStopMutation,
SetFormulaStringBatchCalculationMutation,
SetTriggerFormulaCalculationStartMutation,
} from '@univerjs/engine-formula';
import { SetRangeValuesMutation, SetStyleCommand } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CalculationMode, PLUGIN_CONFIG_KEY_BASE } from '../../config/config';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { SheetFormulaCalculationResultApplyController } from '../sheet-formula-calculation-result-apply.controller';
import { TriggerCalculationController } from '../trigger-calculation.controller';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet1'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { f: '=SUM(B1)' },
},
},
},
},
styles: {},
};
}
function createControllerTestBed(initialFormulaComputing = CalculationMode.WHEN_EMPTY) {
const dependencies: Dependency[] = [
[IActiveDirtyManagerService, { useClass: ActiveDirtyManagerService }],
[FormulaCalculationTriggerService],
[RegisterOtherFormulaService],
[FormulaCalculationSessionService],
[FormulaCalculationSessionController],
[SheetFormulaCalculationResultApplyController],
[TriggerCalculationController],
];
const testBed = createFacadeTestBed(createWorkbookData(), dependencies);
const injector = testBed.injector;
const commandService = injector.get(ICommandService);
const configService = injector.get(IConfigService);
configService.setConfig(PLUGIN_CONFIG_KEY_BASE, { initialFormulaComputing }, { merge: true });
configService.setConfig(ENGINE_FORMULA_RETURN_DEPENDENCY_TREE, true);
configService.setConfig(ENGINE_FORMULA_CYCLE_REFERENCE_COUNT, 7);
[
SetTriggerFormulaCalculationStartMutation,
SetFormulaCalculationStartMutation,
SetFormulaCalculationStopMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaStringBatchCalculationMutation,
SetRangeValuesMutation,
].forEach((command) => commandService.registerCommand(command));
commandService.registerCommand({
id: 'formula.test-dirty-1',
type: CommandType.COMMAND,
handler: () => true,
});
commandService.registerCommand({
id: 'formula.test-dirty-2',
type: CommandType.COMMAND,
handler: () => true,
});
commandService.registerCommand({
id: 'formula.test-dirty-restart',
type: CommandType.COMMAND,
handler: () => true,
});
const executedCommands: Array<{ id: string; params?: object; options?: object }> = [];
const executedDisposable = commandService.onCommandExecuted((command, options) => {
executedCommands.push({
id: command.id,
params: command.params as object | undefined,
options: options as object | undefined,
});
});
injector.get(FormulaCalculationTriggerService).start();
injector.get(FormulaCalculationSessionController);
injector.get(SheetFormulaCalculationResultApplyController);
const controller = injector.get(TriggerCalculationController);
return {
testBed,
controller,
commandService,
formulaDataModel: injector.get(FormulaDataModel),
activeDirtyManagerService: injector.get(IActiveDirtyManagerService),
executedCommands,
executedDisposable,
};
}
async function settleInitialCalculation(testBed: ReturnType<typeof createControllerTestBed>): Promise<void> {
await vi.advanceTimersByTimeAsync(10);
if (testBed.executedCommands.some(({ id }) => id === SetFormulaCalculationStartMutation.id)) {
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
functionsExecutedState: FormulaExecutedStateType.SUCCESS,
});
}
testBed.executedCommands.length = 0;
}
describe('TriggerCalculationController', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('should trigger initial calculation and enrich calculation params through real command hooks', async () => {
const testBed = createControllerTestBed();
await vi.advanceTimersByTimeAsync(10);
await testBed.commandService.executeCommand(SetFormulaStringBatchCalculationMutation.id, {});
expect(testBed.executedCommands.find((command) => command.id === SetFormulaCalculationStartMutation.id)).toMatchObject({
id: SetFormulaCalculationStartMutation.id,
params: expect.objectContaining({
isCalculateTreeModel: true,
maxIteration: 7,
rowData: testBed.formulaDataModel.getHiddenRowsFiltered(),
}),
});
expect(testBed.executedCommands.find((command) => command.id === SetFormulaStringBatchCalculationMutation.id)).toMatchObject({
id: SetFormulaStringBatchCalculationMutation.id,
params: expect.objectContaining({
maxIteration: 7,
rowData: testBed.formulaDataModel.getHiddenRowsFiltered(),
}),
});
testBed.executedDisposable.dispose();
testBed.testBed.univer.dispose();
});
it('should not enqueue an empty initial calculation in no-calculation mode', async () => {
const testBed = createControllerTestBed(CalculationMode.NO_CALCULATION);
await vi.advanceTimersByTimeAsync(10);
expect(testBed.executedCommands.some(({ id }) => id === SetTriggerFormulaCalculationStartMutation.id)).toBe(false);
expect(testBed.executedCommands.some(({ id }) => id === SetFormulaCalculationStartMutation.id)).toBe(false);
testBed.executedDisposable.dispose();
testBed.testBed.univer.dispose();
});
it('should merge dirty data from real active-dirty registrations and skip style-triggered range updates', async () => {
const testBed = createControllerTestBed();
await settleInitialCalculation(testBed);
testBed.activeDirtyManagerService.register('formula.test-dirty-1', {
commandId: 'formula.test-dirty-1',
getDirtyData: () => ({
dirtyRanges: [
{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 } },
{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 } },
],
dirtyNameMap: { test: { sheet1: '1' } },
dirtyDefinedNameMap: { test: { definedNameA: '1' } },
dirtyUnitFeatureMap: { test: { sheet1: { featureA: true } } },
dirtyUnitOtherFormulaMap: { test: { sheet1: { formulaA: true } } },
clearDependencyTreeCache: { test: { sheet1: '1' } },
}),
});
testBed.activeDirtyManagerService.register('formula.test-dirty-2', {
commandId: 'formula.test-dirty-2',
getDirtyData: () => ({
dirtyRanges: [{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 2, startColumn: 0, endRow: 3, endColumn: 1 } }],
dirtyUnitFeatureMap: { test: { sheet1: { featureB: false } } },
forceCalculation: true,
}),
});
testBed.activeDirtyManagerService.register(SetRangeValuesMutation.id, {
commandId: SetRangeValuesMutation.id,
shouldTrigger: (command) => (command.params as { trigger?: string })?.trigger !== SetStyleCommand.id,
getDirtyData: () => ({
dirtyRanges: [{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 9, startColumn: 9, endRow: 9, endColumn: 9 } }],
}),
});
await testBed.commandService.executeCommand('formula.test-dirty-1');
await testBed.commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
trigger: SetStyleCommand.id,
cellValue: { 0: { 0: { v: 1 } } },
});
await testBed.commandService.executeCommand('formula.test-dirty-2');
await vi.advanceTimersByTimeAsync(150);
const startCommand = testBed.executedCommands.findLast((command) => command.id === SetFormulaCalculationStartMutation.id);
expect(startCommand).toMatchObject({
params: {
forceCalculation: true,
dirtyRanges: [
{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 } },
{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 2, startColumn: 0, endRow: 3, endColumn: 1 } },
],
dirtyNameMap: { test: { sheet1: '1' } },
dirtyDefinedNameMap: { test: { definedNameA: '1' } },
dirtyUnitFeatureMap: { test: { sheet1: { featureA: true, featureB: false } } },
dirtyUnitOtherFormulaMap: { test: { sheet1: { formulaA: true } } },
clearDependencyTreeCache: { test: { sheet1: '1' } },
},
options: { onlyLocal: true },
});
testBed.executedDisposable.dispose();
testBed.testBed.univer.dispose();
});
it('should stop and restart calculation through real notification flow', async () => {
const testBed = createControllerTestBed();
await settleInitialCalculation(testBed);
testBed.activeDirtyManagerService.register('formula.test-dirty-restart', {
commandId: 'formula.test-dirty-restart',
getDirtyData: () => ({
forceCalculation: true,
dirtyRanges: [{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 4, startColumn: 0, endRow: 4, endColumn: 1 } }],
}),
});
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
totalFormulasToCalculate: 1,
completedFormulasCount: 0,
totalArrayFormulasToCalculate: 0,
completedArrayFormulasCount: 0,
formulaCycleIndex: 0,
stage: FormulaExecuteStageType.CURRENTLY_CALCULATING,
} as IExecutionInProgressParams,
});
await testBed.commandService.executeCommand('formula.test-dirty-restart');
await vi.advanceTimersByTimeAsync(150);
expect(testBed.executedCommands.findLast((command) => command.id === SetFormulaCalculationStopMutation.id)).toBeDefined();
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
functionsExecutedState: FormulaExecutedStateType.STOP_EXECUTION,
});
await vi.advanceTimersByTimeAsync(10);
expect(testBed.executedCommands.findLast((command) => command.id === SetFormulaCalculationStartMutation.id)).toMatchObject({
options: { onlyLocal: true },
params: expect.objectContaining({
dirtyRanges: [{ unitId: 'test', sheetId: 'sheet1', range: { startRow: 4, startColumn: 0, endRow: 4, endColumn: 1 } }],
}),
});
testBed.executedDisposable.dispose();
testBed.testBed.univer.dispose();
});
it('should publish progress updates through real notification commands', async () => {
const testBed = createControllerTestBed();
await settleInitialCalculation(testBed);
const progressValues: Array<{ done: number; count: number; label?: string }> = [];
const subscription = testBed.controller.progress$.subscribe((value) => progressValues.push(value));
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
stage: FormulaExecuteStageType.START,
totalFormulasToCalculate: 0,
completedFormulasCount: 0,
totalArrayFormulasToCalculate: 0,
completedArrayFormulasCount: 0,
formulaCycleIndex: 0,
} as IExecutionInProgressParams,
});
vi.advanceTimersByTime(1000);
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
stage: FormulaExecuteStageType.CURRENTLY_CALCULATING,
totalFormulasToCalculate: 5,
completedFormulasCount: 2,
totalArrayFormulasToCalculate: 3,
completedArrayFormulasCount: 1,
formulaCycleIndex: 0,
} as IExecutionInProgressParams,
});
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
stage: FormulaExecuteStageType.START_DEPENDENCY_ARRAY_FORMULA,
totalFormulasToCalculate: 5,
completedFormulasCount: 2,
totalArrayFormulasToCalculate: 3,
completedArrayFormulasCount: 1,
formulaCycleIndex: 0,
} as IExecutionInProgressParams,
});
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
stage: FormulaExecuteStageType.CURRENTLY_CALCULATING_ARRAY_FORMULA,
totalFormulasToCalculate: 5,
completedFormulasCount: 4,
totalArrayFormulasToCalculate: 3,
completedArrayFormulasCount: 2,
formulaCycleIndex: 0,
} as IExecutionInProgressParams,
});
await testBed.commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
functionsExecutedState: FormulaExecutedStateType.SUCCESS,
});
expect(progressValues).toContainEqual({ done: 0, count: 1, label: 'sheets-formula.progress.analyzing' });
expect(progressValues).toContainEqual({ done: 3, count: 8, label: 'sheets-formula.progress.calculating' });
expect(progressValues).toContainEqual({ done: 3, count: 8, label: 'sheets-formula.progress.array-analysis' });
expect(progressValues).toContainEqual({ done: 6, count: 8, label: 'sheets-formula.progress.array-calculation' });
expect(progressValues).toContainEqual({ done: 1, count: 1, label: 'sheets-formula.progress.done' });
subscription.unsubscribe();
testBed.executedDisposable.dispose();
testBed.testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,43 @@
/**
* 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 { ObjectMatrix } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { collectUnitQualifierFormulaPatches } from '../unit-qualifier-rename.controller';
describe('UnitQualifierRenameController', () => {
it('builds persisted Sheet cell patches for a renamed Base Unit', () => {
const workbook = {
getUnitId: () => 'host',
getSheets: () => [{
getSheetId: () => 'sheet',
getCellMatrix: () => new ObjectMatrix({
0: { 0: { f: '=SUM(BaseData!Tasks[Amount])' } },
1: { 0: { f: '=INDIRECT("[BaseData]Data!A1")&"BaseData!Tasks[Amount]"' } },
}),
}],
} as never;
expect(collectUnitQualifierFormulaPatches(workbook, 'BaseData', 'FY Base')).toEqual([{
unitId: 'host',
subUnitId: 'sheet',
cellValue: {
0: { 0: { f: "=SUM('FY Base'!Tasks[Amount])" } },
1: { 0: { f: '=INDIRECT("[FY Base]Data!A1")&"BaseData!Tasks[Amount]"' } },
},
}]);
});
});

View File

@@ -0,0 +1,66 @@
/**
* 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 type { IFormulaData } from '@univerjs/engine-formula';
import { describe, expect, it } from 'vitest';
import { UpdateFormulaController } from '../update-formula.controller';
import { FormulaReferenceMoveType } from '../utils/ref-range-formula';
describe('UpdateFormulaController Unit rename', () => {
it('refactors qualifiers across host workbooks and leaves ordinary strings intact', () => {
const controller = Object.create(UpdateFormulaController.prototype) as {
_getFormulaReferenceMoveInfo: (
data: IFormulaData,
sheetNames: Record<string, Record<string, string>>,
move: object
) => { newFormulaData: IFormulaData };
};
const formulaData = {
host: {
sheet: {
0: { 0: { f: '=SUM([Sales.xlsx]Data!A1)+SUM(Sales.xlsx!T[V])' } },
1: { 0: { f: '=INDIRECT("[Sales.xlsx]Data!B2")&"[Sales.xlsx]Data!B2"' } },
},
},
} as IFormulaData;
const { newFormulaData } = controller._getFormulaReferenceMoveInfo(formulaData, {}, {
type: FormulaReferenceMoveType.SetUnitName,
unitId: 'sales-unit',
sheetId: '',
oldUnitName: 'Sales.xlsx',
unitName: 'FY 2027.xlsx',
});
expect(newFormulaData.host?.sheet?.[0]?.[0]?.f).toBe("=SUM([FY 2027.xlsx]Data!A1)+SUM('FY 2027.xlsx'!T[V])");
expect(newFormulaData.host?.sheet?.[1]?.[0]?.f).toBe('=INDIRECT("[FY 2027.xlsx]Data!B2")&"[Sales.xlsx]Data!B2"');
});
});
/**
* 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.
*/

View File

@@ -0,0 +1,219 @@
/**
* 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 type { Dependency, IWorkbookData } from '@univerjs/core';
import { LocaleType } from '@univerjs/core';
import { ErrorType, IDefinedNamesService, SetDefinedNameMutation } from '@univerjs/engine-formula';
import { MoveRangeCommand, RemoveDefinedNameCommand, RemoveSheetCommand, SetDefinedNameCommand, SetWorksheetNameCommand, SheetInterceptorService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { UpdateDefinedNameController } from '../update-defined-name.controller';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet1', 'sheet2'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
cellData: {},
rowCount: 20,
columnCount: 20,
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
cellData: {},
rowCount: 20,
columnCount: 20,
},
},
styles: {},
};
}
function createControllerTestBed() {
const dependencies: Dependency[] = [[UpdateDefinedNameController]];
return createFacadeTestBed(createWorkbookData(), dependencies);
}
describe('UpdateDefinedNameController', () => {
let testBed: ReturnType<typeof createControllerTestBed>;
beforeEach(() => {
testBed = createControllerTestBed();
const definedNamesService = testBed.injector.get(IDefinedNamesService);
definedNamesService.registerDefinedNames('test', {
rangeName: {
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
comment: '',
},
rowsName: {
id: 'rows-name',
name: 'ROWS_NAME',
formulaOrRefString: 'Sheet1!$1:$3',
localSheetId: 'AllDefaultWorkbook',
comment: '',
},
otherSheetName: {
id: 'other-sheet-name',
name: 'OTHER_SHEET_NAME',
formulaOrRefString: 'Sheet2!$C$3',
localSheetId: 'AllDefaultWorkbook',
comment: '',
},
});
testBed.injector.get(UpdateDefinedNameController);
});
afterEach(() => {
testBed?.univer.dispose();
});
it('should ignore commands that are handled by UpdateFormulaController', () => {
const interceptorService = testBed.injector.get(SheetInterceptorService);
const setDefinedNameResult = interceptorService.onCommandExecute({
id: SetDefinedNameCommand.id,
params: {
unitId: 'test',
id: 'temp',
name: 'TEMP_NAME',
formulaOrRefString: 'Sheet1!$A$1',
localSheetId: 'AllDefaultWorkbook',
},
});
const removeDefinedNameResult = interceptorService.onCommandExecute({
id: RemoveDefinedNameCommand.id,
params: {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
},
});
expect(setDefinedNameResult).toEqual({ redos: [], undos: [], preRedos: [], preUndos: [] });
expect(removeDefinedNameResult).toEqual({ redos: [], undos: [], preRedos: [], preUndos: [] });
});
it('should generate undo and redo mutations for moved references', () => {
const interceptorService = testBed.injector.get(SheetInterceptorService);
const result = interceptorService.onCommandExecute({
id: MoveRangeCommand.id,
params: {
fromRange: {
startRow: 0,
startColumn: 0,
endRow: 1,
endColumn: 1,
rangeType: 0,
},
toRange: {
startRow: 0,
startColumn: 3,
endRow: 1,
endColumn: 4,
rangeType: 0,
},
},
});
expect(result.redos).toContainEqual({
id: SetDefinedNameMutation.id,
params: expect.objectContaining({
unitId: 'test',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$D$1:$E$2',
}),
});
expect(result.undos).toContainEqual({
id: SetDefinedNameMutation.id,
params: expect.objectContaining({
unitId: 'test',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
}),
});
});
it('should rename references on the targeted sheet and skip references on other sheets', () => {
const interceptorService = testBed.injector.get(SheetInterceptorService);
const result = interceptorService.onCommandExecute({
id: SetWorksheetNameCommand.id,
params: {
subUnitId: 'sheet1',
name: 'Sheet1Rename',
},
});
expect(result.redos).toContainEqual({
id: SetDefinedNameMutation.id,
params: expect.objectContaining({
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1Rename!$A$1:$B$2',
}),
});
expect(result.redos).toContainEqual({
id: SetDefinedNameMutation.id,
params: expect.objectContaining({
name: 'ROWS_NAME',
formulaOrRefString: 'Sheet1Rename!$1:$3',
}),
});
expect(result.redos.find((mutation) => (mutation.params as { name?: string } | undefined)?.name === 'OTHER_SHEET_NAME')).toBeUndefined();
});
it('should turn references into REF errors when the referenced sheet is removed', () => {
const interceptorService = testBed.injector.get(SheetInterceptorService);
const result = interceptorService.onCommandExecute({
id: RemoveSheetCommand.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
},
});
expect(result.redos).toContainEqual({
id: SetDefinedNameMutation.id,
params: expect.objectContaining({
name: 'RANGE_NAME',
formulaOrRefString: ErrorType.REF,
}),
});
expect(result.redos).toContainEqual({
id: SetDefinedNameMutation.id,
params: expect.objectContaining({
name: 'ROWS_NAME',
formulaOrRefString: ErrorType.REF,
}),
});
expect(result.redos.find((mutation) => (mutation.params as { name?: string } | undefined)?.name === 'OTHER_SHEET_NAME')).toBeUndefined();
});
});

View File

@@ -0,0 +1,896 @@
/**
* 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 type { Dependency, ICellData, IWorkbookData, Nullable, Workbook } from '@univerjs/core';
import { CellValueType, ICommandService, IConfigService, IUniverInstanceService, LocaleType, RedoCommand, UndoCommand, UniverInstanceType } from '@univerjs/core';
import {
FormulaDataModel,
IDefinedNamesService,
RemoveDefinedNameMutation,
SetArrayFormulaDataMutation,
SetDefinedNameMutation,
SetFormulaDataMutation,
SetTriggerFormulaCalculationStartMutation,
} from '@univerjs/engine-formula';
import { InsertSheetMutation, MoveRangeCommand, MoveRangeMutation, RemoveDefinedNameCommand, RemoveSheetCommand, RemoveSheetMutation, SetDefinedNameCommand, SetRangeValuesCommand, SetRangeValuesMutation, SetSelectionsOperation, SetStyleCommand, SetWorksheetNameCommand, SheetInterceptorService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CalculationMode, PLUGIN_CONFIG_KEY_BASE } from '../../config/config';
import { createFacadeTestBed } from '../../facade/__tests__/create-test-bed';
import { UpdateFormulaController } from '../update-formula.controller';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet1', 'sheet2'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { v: 1, t: CellValueType.NUMBER },
1: { v: 1, t: CellValueType.NUMBER },
},
1: {
0: { v: 1, t: CellValueType.NUMBER },
1: { v: 1, t: CellValueType.NUMBER },
},
5: {
2: { f: '=SUM(A1:B2)' },
3: { v: 1, t: CellValueType.NUMBER },
},
6: {
0: { f: '=SUM(RANGE_NAME)' },
1: { f: '=RANGE_NAME' },
},
},
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { f: '=Sheet1!A1:B2' },
1: { f: '=RANGE_NAME' },
},
1: {
0: { f: '=RANGE_NAME' },
1: { f: '=RANGE_NAME+SUM(RANGE_NAME)' },
},
},
},
},
styles: {},
};
}
function createControllerTestBed(initialFormulaComputing?: CalculationMode) {
const dependencies: Dependency[] = [
[UpdateFormulaController],
];
const testBed = createFacadeTestBed(createWorkbookData(), dependencies);
if (initialFormulaComputing != null) {
testBed.injector.get(IConfigService).setConfig(PLUGIN_CONFIG_KEY_BASE, { initialFormulaComputing });
}
return testBed;
}
describe('UpdateFormulaController', () => {
let testBed: ReturnType<typeof createControllerTestBed>;
let commandService: ICommandService;
let formulaDataModel: FormulaDataModel;
let definedNamesService: IDefinedNamesService;
beforeEach(() => {
testBed = createControllerTestBed();
commandService = testBed.injector.get(ICommandService);
formulaDataModel = testBed.injector.get(FormulaDataModel);
definedNamesService = testBed.injector.get(IDefinedNamesService);
commandService.registerCommand(MoveRangeCommand);
commandService.registerCommand(MoveRangeMutation);
commandService.registerCommand(InsertSheetMutation);
commandService.registerCommand(RemoveSheetMutation);
commandService.registerCommand(SetSelectionsOperation);
commandService.registerCommand(SetRangeValuesCommand);
commandService.registerCommand(SetRangeValuesMutation);
commandService.registerCommand(SetDefinedNameCommand);
commandService.registerCommand(RemoveDefinedNameCommand);
commandService.registerCommand(SetDefinedNameMutation);
commandService.registerCommand(RemoveDefinedNameMutation);
commandService.registerCommand(SetFormulaDataMutation);
commandService.registerCommand(SetArrayFormulaDataMutation);
definedNamesService.registerDefinedName('test', {
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
comment: '',
});
testBed.injector.get(UpdateFormulaController);
});
afterEach(() => {
vi.restoreAllMocks();
testBed?.univer.dispose();
});
it('does not schedule workbook-added calculation in no-calculation mode', () => {
testBed.univer.dispose();
testBed = createControllerTestBed(CalculationMode.NO_CALCULATION);
commandService = testBed.injector.get(ICommandService);
commandService.registerCommand(SetFormulaDataMutation);
const executeCommand = vi.spyOn(commandService, 'executeCommand');
testBed.injector.get(UpdateFormulaController);
testBed.injector.get(IUniverInstanceService).createUnit(UniverInstanceType.UNIVER_SHEET, {
...createWorkbookData(),
id: 'second',
});
expect(executeCommand).not.toHaveBeenCalledWith(
SetTriggerFormulaCalculationStartMutation.id,
expect.anything(),
expect.anything()
);
});
it('should update formula references when a referenced range moves', async () => {
expect(await commandService.executeCommand(MoveRangeCommand.id, {
fromRange: {
startRow: 0,
startColumn: 0,
endRow: 1,
endColumn: 1,
rangeType: 0,
},
toRange: {
startRow: 0,
startColumn: 3,
endRow: 1,
endColumn: 4,
rangeType: 0,
},
})).toBe(true);
const values = testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(5, 2, 5, 2)
.getValues() as Array<Array<Nullable<ICellData>>>;
expect(values).toEqual([[{ f: '=SUM(D1:E2)' }]]);
});
it('should move the formula cell itself without changing its formula string', async () => {
expect(await commandService.executeCommand(MoveRangeCommand.id, {
fromRange: {
startRow: 5,
startColumn: 2,
endRow: 5,
endColumn: 2,
rangeType: 0,
},
toRange: {
startRow: 5,
startColumn: 3,
endRow: 5,
endColumn: 3,
rangeType: 0,
},
})).toBe(true);
const values = testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(5, 2, 5, 3)
.getValues() as Array<Array<Nullable<ICellData>>>;
expect(values).toEqual([[null, { f: '=SUM(A1:B2)' }]]);
});
it('should rewrite formulas when referenced sheets change', () => {
const sheetInterceptorService = testBed.injector.get(SheetInterceptorService);
const renameSheetResult = sheetInterceptorService.onCommandExecute({
id: SetWorksheetNameCommand.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
name: 'Sheet1Renamed',
},
});
expect(renameSheetResult.redos).toEqual(expect.arrayContaining([
expect.objectContaining({
id: SetRangeValuesMutation.id,
params: expect.objectContaining({
unitId: 'test',
subUnitId: 'sheet2',
cellValue: {
0: {
0: expect.objectContaining({ f: '=Sheet1Renamed!A1:B2' }),
},
},
}),
}),
]));
expect(renameSheetResult.undos).toEqual(expect.arrayContaining([
expect.objectContaining({
id: SetRangeValuesMutation.id,
params: expect.objectContaining({
unitId: 'test',
subUnitId: 'sheet2',
cellValue: {
0: {
0: expect.objectContaining({ f: '=Sheet1!A1:B2' }),
},
},
}),
}),
]));
const removeSheetResult = sheetInterceptorService.onCommandExecute({
id: RemoveSheetCommand.id,
params: {
unitId: 'test',
subUnitId: 'sheet1',
},
});
expect(removeSheetResult.redos).toEqual(expect.arrayContaining([
expect.objectContaining({
id: SetRangeValuesMutation.id,
params: expect.objectContaining({
unitId: 'test',
subUnitId: 'sheet2',
cellValue: {
0: {
0: expect.objectContaining({ f: '=#REF!' }),
},
},
}),
}),
]));
});
it('should sync defined-name formulas when a defined name is renamed, undone, and redone', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet2?.[0]?.[1]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(0, 1, 0, 1)
.getValue()
?.f;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RENAMED_RANGE',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getFormula()).toBe('=RENAMED_RANGE');
expect(getCellFormula()).toBe('=RENAMED_RANGE');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getFormula()).toBe('=RANGE_NAME');
expect(getCellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getFormula()).toBe('=RENAMED_RANGE');
expect(getCellFormula()).toBe('=RENAMED_RANGE');
});
it('should sync defined-name formulas when a defined name is removed, undone, and redone', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet2?.[0]?.[1]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(0, 1, 0, 1)
.getValue()
?.f;
expect(await commandService.executeCommand(RemoveDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getFormula()).toBe('=#REF!');
expect(getCellFormula()).toBe('=#REF!');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getFormula()).toBe('=RANGE_NAME');
expect(getCellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getFormula()).toBe('=#REF!');
expect(getCellFormula()).toBe('=#REF!');
});
it('should keep defined-name formulas synchronized when the defined name range changes, then undo and redo', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet2?.[0]?.[1]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(0, 1, 0, 1)
.getValue()
?.f;
const getDefinedNameRef = () => definedNamesService.getValueById('test', 'range-name')?.formulaOrRefString;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: '=SUM(Sheet1!$C$1:$C$2)',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getFormula()).toBe('=RANGE_NAME');
expect(getCellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('Sheet1!$A$1:$B$2');
expect(getFormula()).toBe('=RANGE_NAME');
expect(getCellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getFormula()).toBe('=RANGE_NAME');
expect(getCellFormula()).toBe('=RANGE_NAME');
});
it('should sync defined-name formulas wrapped by functions when renamed, undone, and redone', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[0]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(6, 0, 6, 0)
.getValue()
?.f;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RENAMED_RANGE',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getFormula()).toBe('=SUM(RENAMED_RANGE)');
expect(getCellFormula()).toBe('=SUM(RENAMED_RANGE)');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getFormula()).toBe('=SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=SUM(RANGE_NAME)');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getFormula()).toBe('=SUM(RENAMED_RANGE)');
expect(getCellFormula()).toBe('=SUM(RENAMED_RANGE)');
});
it('should sync defined-name formulas wrapped by functions when removed, undone, and redone', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[0]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(6, 0, 6, 0)
.getValue()
?.f;
expect(await commandService.executeCommand(RemoveDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getFormula()).toBe('=SUM(#REF!)');
expect(getCellFormula()).toBe('=SUM(#REF!)');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getFormula()).toBe('=SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=SUM(RANGE_NAME)');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getFormula()).toBe('=SUM(#REF!)');
expect(getCellFormula()).toBe('=SUM(#REF!)');
});
it('should keep function-wrapped defined-name formulas synchronized when the defined name range changes, then undo and redo', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[0]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(6, 0, 6, 0)
.getValue()
?.f;
const getDefinedNameRef = () => definedNamesService.getValueById('test', 'range-name')?.formulaOrRefString;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: '=SUM(Sheet1!$C$1:$C$2)',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getFormula()).toBe('=SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=SUM(RANGE_NAME)');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('Sheet1!$A$1:$B$2');
expect(getFormula()).toBe('=SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=SUM(RANGE_NAME)');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getFormula()).toBe('=SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=SUM(RANGE_NAME)');
});
it('should sync multiple defined-name formulas across sheets when renamed, undone, and redone', async () => {
const getSheet1Formula = () => formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[1]?.f;
const getSheet2Formula = () => formulaDataModel.getFormulaData().test?.sheet2?.[1]?.[0]?.f;
const getSheet1CellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(6, 1, 6, 1)
.getValue()
?.f;
const getSheet2CellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(1, 0, 1, 0)
.getValue()
?.f;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RENAMED_RANGE',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getSheet1Formula()).toBe('=RENAMED_RANGE');
expect(getSheet2Formula()).toBe('=RENAMED_RANGE');
expect(getSheet1CellFormula()).toBe('=RENAMED_RANGE');
expect(getSheet2CellFormula()).toBe('=RENAMED_RANGE');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getSheet1Formula()).toBe('=RANGE_NAME');
expect(getSheet2Formula()).toBe('=RANGE_NAME');
expect(getSheet1CellFormula()).toBe('=RANGE_NAME');
expect(getSheet2CellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getSheet1Formula()).toBe('=RENAMED_RANGE');
expect(getSheet2Formula()).toBe('=RENAMED_RANGE');
expect(getSheet1CellFormula()).toBe('=RENAMED_RANGE');
expect(getSheet2CellFormula()).toBe('=RENAMED_RANGE');
});
it('should sync multiple defined-name formulas across sheets when removed, undone, and redone', async () => {
const getSheet1Formula = () => formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[1]?.f;
const getSheet2Formula = () => formulaDataModel.getFormulaData().test?.sheet2?.[1]?.[0]?.f;
const getSheet1CellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(6, 1, 6, 1)
.getValue()
?.f;
const getSheet2CellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(1, 0, 1, 0)
.getValue()
?.f;
expect(await commandService.executeCommand(RemoveDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getSheet1Formula()).toBe('=#REF!');
expect(getSheet2Formula()).toBe('=#REF!');
expect(getSheet1CellFormula()).toBe('=#REF!');
expect(getSheet2CellFormula()).toBe('=#REF!');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getSheet1Formula()).toBe('=RANGE_NAME');
expect(getSheet2Formula()).toBe('=RANGE_NAME');
expect(getSheet1CellFormula()).toBe('=RANGE_NAME');
expect(getSheet2CellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getSheet1Formula()).toBe('=#REF!');
expect(getSheet2Formula()).toBe('=#REF!');
expect(getSheet1CellFormula()).toBe('=#REF!');
expect(getSheet2CellFormula()).toBe('=#REF!');
});
it('should keep multiple defined-name formulas across sheets synchronized when the defined name range changes, then undo and redo', async () => {
const getSheet1Formula = () => formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[1]?.f;
const getSheet2Formula = () => formulaDataModel.getFormulaData().test?.sheet2?.[1]?.[0]?.f;
const getSheet1CellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(6, 1, 6, 1)
.getValue()
?.f;
const getSheet2CellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(1, 0, 1, 0)
.getValue()
?.f;
const getDefinedNameRef = () => definedNamesService.getValueById('test', 'range-name')?.formulaOrRefString;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: '=SUM(Sheet1!$C$1:$C$2)',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getSheet1Formula()).toBe('=RANGE_NAME');
expect(getSheet2Formula()).toBe('=RANGE_NAME');
expect(getSheet1CellFormula()).toBe('=RANGE_NAME');
expect(getSheet2CellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('Sheet1!$A$1:$B$2');
expect(getSheet1Formula()).toBe('=RANGE_NAME');
expect(getSheet2Formula()).toBe('=RANGE_NAME');
expect(getSheet1CellFormula()).toBe('=RANGE_NAME');
expect(getSheet2CellFormula()).toBe('=RANGE_NAME');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getSheet1Formula()).toBe('=RANGE_NAME');
expect(getSheet2Formula()).toBe('=RANGE_NAME');
expect(getSheet1CellFormula()).toBe('=RANGE_NAME');
expect(getSheet2CellFormula()).toBe('=RANGE_NAME');
});
it('should sync every occurrence of a defined name in one formula when renamed, undone, and redone', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet2?.[1]?.[1]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(1, 1, 1, 1)
.getValue()
?.f;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RENAMED_RANGE',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getFormula()).toBe('=RENAMED_RANGE+SUM(RENAMED_RANGE)');
expect(getCellFormula()).toBe('=RENAMED_RANGE+SUM(RENAMED_RANGE)');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getFormula()).toBe('=RENAMED_RANGE+SUM(RENAMED_RANGE)');
expect(getCellFormula()).toBe('=RENAMED_RANGE+SUM(RENAMED_RANGE)');
});
it('should sync every occurrence of a defined name in one formula when removed, undone, and redone', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet2?.[1]?.[1]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(1, 1, 1, 1)
.getValue()
?.f;
expect(await commandService.executeCommand(RemoveDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: 'Sheet1!$A$1:$B$2',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getFormula()).toBe('=#REF!+SUM(#REF!)');
expect(getCellFormula()).toBe('=#REF!+SUM(#REF!)');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getFormula()).toBe('=#REF!+SUM(#REF!)');
expect(getCellFormula()).toBe('=#REF!+SUM(#REF!)');
});
it('should keep every occurrence of a defined name in one formula synchronized when the defined name range changes, then undo and redo', async () => {
const getFormula = () => formulaDataModel.getFormulaData().test?.sheet2?.[1]?.[1]?.f;
const getCellFormula = () => testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet2')
?.getRange(1, 1, 1, 1)
.getValue()
?.f;
const getDefinedNameRef = () => definedNamesService.getValueById('test', 'range-name')?.formulaOrRefString;
expect(await commandService.executeCommand(SetDefinedNameCommand.id, {
unitId: 'test',
id: 'range-name',
name: 'RANGE_NAME',
formulaOrRefString: '=SUM(Sheet1!$C$1:$C$2)',
localSheetId: 'AllDefaultWorkbook',
})).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(await commandService.executeCommand(UndoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('Sheet1!$A$1:$B$2');
expect(getFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(await commandService.executeCommand(RedoCommand.id)).toBe(true);
expect(getDefinedNameRef()).toBe('=SUM(Sheet1!$C$1:$C$2)');
expect(getFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
expect(getCellFormula()).toBe('=RANGE_NAME+SUM(RANGE_NAME)');
});
it('should sync formula data for value mutations and ignore style-only updates', async () => {
const executeCommandSpy = vi.spyOn(commandService, 'executeCommand');
await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
6: {
0: { f: '=A1' },
},
},
});
expect(formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[0]).toMatchObject({ f: '=A1' });
const formulaSyncCallCount = executeCommandSpy.mock.calls.filter(([id]) => id === SetFormulaDataMutation.id).length;
await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
trigger: SetStyleCommand.id,
cellValue: {
7: {
0: { f: '=B1' },
},
},
});
expect(executeCommandSpy.mock.calls.filter(([id]) => id === SetFormulaDataMutation.id)).toHaveLength(formulaSyncCallCount);
await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
6: {
0: { s: { bg: { rgb: '#ff0000' } } },
},
},
});
const cellFormula = testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet1')
?.getRange(6, 0, 6, 0)
.getValue()
?.f;
expect(formulaDataModel.getFormulaData().test?.sheet1?.[6]?.[0]).toMatchObject({ f: '=A1' });
expect(cellFormula).toBe('=A1');
expect(executeCommandSpy.mock.calls.filter(([id]) => id === SetFormulaDataMutation.id)).toHaveLength(formulaSyncCallCount);
});
it('should sync array formula data when editing a spill cell without formula data changes', async () => {
const executeCommandSpy = vi.spyOn(commandService, 'executeCommand');
formulaDataModel.setArrayFormulaRange({
test: {
sheet1: {
5: {
5: {
startRow: 5,
startColumn: 5,
endRow: 6,
endColumn: 6,
},
},
},
},
});
formulaDataModel.setArrayFormulaCellData({
test: {
sheet1: {
5: {
5: { v: 1 },
6: { v: 2 },
},
6: {
5: { v: 3 },
6: { v: 4 },
},
},
},
});
await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
6: {
6: { v: 111 },
},
},
});
expect(formulaDataModel.getArrayFormulaCellData().test?.sheet1).toEqual({
5: {
5: { v: 1 },
6: { v: 2 },
},
6: {
5: { v: 3 },
},
});
expect(executeCommandSpy.mock.calls.some(([id]) => id === SetArrayFormulaDataMutation.id)).toBe(true);
});
it('should initialize formula data for added sheets and workbooks, then clear removed sheets', async () => {
const configService = testBed.injector.get(IConfigService);
const executeCommandSpy = vi.spyOn(commandService, 'executeCommand');
const workbookSnapshot = testBed.injector.get(IUniverInstanceService).getUnit<Workbook>('test')?.getSnapshot();
configService.setConfig(PLUGIN_CONFIG_KEY_BASE, {
initialFormulaComputing: CalculationMode.FORCED,
});
await commandService.executeCommand(InsertSheetMutation.id, {
unitId: 'test',
sheet: {
...workbookSnapshot!.sheets.sheet1,
id: 'sheet-added',
name: 'Added Sheet',
cellData: {
0: {
0: { f: '=Sheet1!A1', si: 'inserted-shared-formula' },
},
1: {
0: { si: 'inserted-shared-formula' },
},
},
},
});
const addedSheetCellMatrix = testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('test')
?.getSheetBySheetId('sheet-added')
?.getCellMatrix();
const scanAddedSheet = vi.spyOn(addedSheetCellMatrix!, 'forValue');
expect(formulaDataModel.getFormulaStringByCell(1, 0, 'sheet-added', 'test')).toBe('=Sheet1!A2');
expect(scanAddedSheet).not.toHaveBeenCalled();
expect(formulaDataModel.getFormulaData().test?.['sheet-added']?.[0]?.[0]).toMatchObject({ f: '=Sheet1!A1' });
await commandService.executeCommand(RemoveSheetMutation.id, {
unitId: 'test',
subUnitId: 'sheet-added',
subUnitName: 'Added Sheet',
});
expect(formulaDataModel.getFormulaData().test?.['sheet-added']).toBeUndefined();
testBed.univer.createUnit(UniverInstanceType.UNIVER_SHEET, {
id: 'secondary',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'secondary',
sheetOrder: ['sheet1'],
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {
0: {
0: { f: '=SUM(A1)', si: 'workbook-shared-formula' },
},
1: {
0: { si: 'workbook-shared-formula' },
},
},
},
},
styles: {},
});
const secondaryCellMatrix = testBed.injector
.get(IUniverInstanceService)
.getUnit<Workbook>('secondary')
?.getSheetBySheetId('sheet1')
?.getCellMatrix();
const scanSecondarySheet = vi.spyOn(secondaryCellMatrix!, 'forValue');
expect(formulaDataModel.getFormulaStringByCell(1, 0, 'sheet1', 'secondary')).toBe('=SUM(A2)');
expect(scanSecondarySheet).not.toHaveBeenCalled();
expect(formulaDataModel.getFormulaData().secondary?.sheet1?.[0]?.[0]).toMatchObject({ f: '=SUM(A1)' });
expect(executeCommandSpy).toHaveBeenCalledWith(
SetTriggerFormulaCalculationStartMutation.id,
expect.objectContaining({
forceCalculation: true,
}),
{
onlyLocal: true,
}
);
});
});

View File

@@ -0,0 +1,543 @@
/**
* 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 type { ICellData, ICommandInfo, IObjectMatrixPrimitiveType, IRange, IUnitRange, Nullable, Workbook } from '@univerjs/core';
import type { IDirtyUnitDefinedNameMap, IDirtyUnitSheetNameMap, ISetDefinedNameMutationParam } from '@univerjs/engine-formula';
import type {
IInsertColMutationParams,
IInsertRowMutationParams,
IInsertSheetMutationParams,
IMoveColumnsMutationParams,
IMoveRangeMutationParams,
IMoveRowsMutationParams,
IRemoveColMutationParams,
IRemoveRowsMutationParams,
IRemoveSheetMutationParams,
IReorderRangeMutationParams,
ISetRangeValuesMutationParams,
ISetRowHiddenMutationParams,
ISetRowVisibleMutationParams,
} from '@univerjs/sheets';
import {
Disposable,
Inject,
IUniverInstanceService,
ObjectMatrix,
UniverInstanceType,
} from '@univerjs/core';
import { FormulaDataModel, IActiveDirtyManagerService, RemoveDefinedNameMutation, SetDefinedNameMutation } from '@univerjs/engine-formula';
import {
ClearSelectionFormatCommand,
InsertColMutation,
InsertRowMutation,
InsertSheetMutation,
MoveColsMutation,
MoveRangeMutation,
MoveRowsMutation,
RemoveColMutation,
RemoveRowMutation,
RemoveSheetMutation,
ReorderRangeMutation,
SetBorderCommand,
SetRangeValuesMutation,
SetRowHiddenMutation,
SetRowVisibleMutation,
SetStyleCommand,
} from '@univerjs/sheets';
export class ActiveDirtyController extends Disposable {
constructor(
@IActiveDirtyManagerService private readonly _activeDirtyManagerService: IActiveDirtyManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(FormulaDataModel) private readonly _formulaDataModel: FormulaDataModel
) {
super();
this._initialize();
}
private _initialize(): void {
this._initialConversion();
}
private _initialConversion() {
this._activeDirtyManagerService.register(SetRangeValuesMutation.id, {
commandId: SetRangeValuesMutation.id,
shouldTrigger: (command, options) => {
const params = command.params as ISetRangeValuesMutationParams;
return !options?.onlyLocal &&
params.trigger !== SetStyleCommand.id &&
params.trigger !== SetBorderCommand.id &&
params.trigger !== ClearSelectionFormatCommand.id;
},
getDirtyData: (command: ICommandInfo) => {
const params = command.params as ISetRangeValuesMutationParams;
return {
dirtyRanges: this._getSetRangeValuesMutationDirtyRange(params),
};
},
});
this._initialMove();
this._initialRowAndColumn();
this._initialHideRow();
this._initialSheet();
this._initialDefinedName();
}
private _initialMove() {
this._activeDirtyManagerService.register(MoveRangeMutation.id, {
commandId: MoveRangeMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IMoveRangeMutationParams;
return {
dirtyRanges: this._getMoveRangeMutationDirtyRange(params),
clearDependencyTreeCache: {
[params.unitId]: {
[params.to.subUnitId]: '1',
[params.from.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(MoveRowsMutation.id, {
commandId: MoveRowsMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IMoveRowsMutationParams;
return {
dirtyRanges: this._getMoveRowsMutationDirtyRange(params),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(MoveColsMutation.id, {
commandId: MoveColsMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IMoveColumnsMutationParams;
return {
dirtyRanges: this._getMoveRowsMutationDirtyRange(params),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(ReorderRangeMutation.id, {
commandId: ReorderRangeMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IReorderRangeMutationParams;
return {
dirtyRanges: this._getReorderRangeMutationDirtyRange(params),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
}
private _initialRowAndColumn() {
this._activeDirtyManagerService.register(RemoveRowMutation.id, {
commandId: RemoveRowMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IRemoveRowsMutationParams;
return {
dirtyRanges: this._getRemoveRowOrColumnMutation(params, true),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(RemoveColMutation.id, {
commandId: RemoveColMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IRemoveColMutationParams;
return {
dirtyRanges: this._getRemoveRowOrColumnMutation(params, false),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(InsertColMutation.id, {
commandId: InsertColMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IInsertColMutationParams;
return {
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(InsertRowMutation.id, {
commandId: InsertRowMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IInsertRowMutationParams;
return {
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
}
private _initialHideRow() {
this._activeDirtyManagerService.register(SetRowHiddenMutation.id, {
commandId: SetRowHiddenMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as ISetRowHiddenMutationParams;
return {
dirtyRanges: this._getHideRowMutation(params),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(SetRowVisibleMutation.id, {
commandId: SetRowVisibleMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as ISetRowVisibleMutationParams;
return {
dirtyRanges: this._getHideRowMutation(params),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
}
private _initialSheet() {
this._activeDirtyManagerService.register(RemoveSheetMutation.id, {
commandId: RemoveSheetMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IRemoveSheetMutationParams;
return {
dirtyNameMap: this._getRemoveSheetMutation(params),
clearDependencyTreeCache: {
[params.unitId]: {
[params.subUnitId]: '1',
},
},
};
},
});
this._activeDirtyManagerService.register(InsertSheetMutation.id, {
commandId: InsertSheetMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as IInsertSheetMutationParams;
return {
dirtyNameMap: this._getInsertSheetMutation(params),
};
},
});
}
private _initialDefinedName() {
this._activeDirtyManagerService.register(SetDefinedNameMutation.id, {
commandId: SetDefinedNameMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as ISetDefinedNameMutationParam;
return { dirtyDefinedNameMap: this._getDefinedNameMutation(params) };
},
});
this._activeDirtyManagerService.register(RemoveDefinedNameMutation.id, {
commandId: RemoveDefinedNameMutation.id,
getDirtyData: (command: ICommandInfo) => {
const params = command.params as ISetDefinedNameMutationParam;
return { dirtyDefinedNameMap: this._getDefinedNameMutation(params) };
},
});
}
private _getDefinedNameMutation(definedName: ISetDefinedNameMutationParam | null | undefined): IDirtyUnitDefinedNameMap {
if (definedName == null) {
return {};
}
const { unitId, name: definedNameName, formulaOrRefString } = definedName;
const result: IDirtyUnitDefinedNameMap = {
[unitId]: {
[definedNameName]: formulaOrRefString,
},
};
return result;
}
private _getSetRangeValuesMutationDirtyRange(params: ISetRangeValuesMutationParams) {
const { subUnitId: sheetId, unitId, cellValue } = params;
const dirtyRanges: IUnitRange[] = [];
if (cellValue == null) {
return dirtyRanges;
}
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, sheetId, cellValue));
dirtyRanges.push(...this._getDirtyRangesForArrayFormula(unitId, sheetId, cellValue));
return dirtyRanges;
}
private _getMoveRangeMutationDirtyRange(params: IMoveRangeMutationParams) {
const { unitId, from, to } = params;
const dirtyRanges: IUnitRange[] = [];
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, from.subUnitId, from.value));
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, to.subUnitId, to.value));
dirtyRanges.push(...this._getDirtyRangesForArrayFormula(unitId, to.subUnitId, to.value));
return dirtyRanges;
}
private _getMoveRowsMutationDirtyRange(params: IMoveRowsMutationParams) {
const { subUnitId: sheetId, unitId, sourceRange, targetRange } = params;
const dirtyRanges: IUnitRange[] = [];
const sourceMatrix = this._rangeToMatrix(sourceRange).clone();
const targetMatrix = this._rangeToMatrix(targetRange).clone();
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, sheetId, sourceMatrix));
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, sheetId, targetMatrix));
dirtyRanges.push(...this._getDirtyRangesForArrayFormula(unitId, sheetId, targetMatrix));
return dirtyRanges;
}
private _getReorderRangeMutationDirtyRange(params: IReorderRangeMutationParams) {
const { unitId, subUnitId: sheetId, range } = params;
const matrix = this._rangeToMatrix(range).clone();
const dirtyRanges: IUnitRange[] = [];
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, sheetId, matrix));
dirtyRanges.push(...this._getDirtyRangesForArrayFormula(unitId, sheetId, matrix));
return dirtyRanges;
}
private _getRemoveRowOrColumnMutation(params: IRemoveRowsMutationParams, isRow: boolean = true) {
const { subUnitId: sheetId, unitId, range } = params;
const dirtyRanges: IUnitRange[] = [];
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const worksheet = workbook?.getSheetBySheetId(sheetId);
const rowCount = worksheet?.getRowCount() || 0;
const columnCount = worksheet?.getColumnCount() || 0;
let matrix: Nullable<ObjectMatrix<Nullable<ICellData>>> = null;
const { startRow, endRow, startColumn, endColumn } = range;
if (isRow === true) {
matrix = this._rangeToMatrix({
startRow,
startColumn: 0,
endRow,
endColumn: columnCount - 1,
});
} else {
matrix = this._rangeToMatrix({
startRow: 0,
startColumn,
endRow: rowCount,
endColumn,
});
}
const matrixData = matrix.clone();
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, sheetId, matrixData));
dirtyRanges.push(...this._getDirtyRangesForArrayFormula(unitId, sheetId, matrixData));
return dirtyRanges;
}
private _getHideRowMutation(params: ISetRowHiddenMutationParams) {
const { subUnitId, unitId, ranges } = params;
const dirtyRanges: IUnitRange[] = [];
// covert ranges to dirtyRanges
ranges.forEach((range) => {
const matrix = this._rangeToMatrix(range).getMatrix();
dirtyRanges.push(...this._getDirtyRangesByCellValue(unitId, subUnitId, matrix));
});
return dirtyRanges;
}
private _getRemoveSheetMutation(params: IRemoveSheetMutationParams) {
const dirtyNameMap: IDirtyUnitSheetNameMap = {};
const { subUnitId: sheetId, unitId, subUnitName } = params;
// const dirtyNameMap: IDirtyUnitSheetNameMap = {};
if (dirtyNameMap[unitId] == null) {
dirtyNameMap[unitId] = {};
}
dirtyNameMap[unitId]![sheetId] = subUnitName;
return dirtyNameMap;
}
private _getInsertSheetMutation(params: IInsertSheetMutationParams) {
const dirtyNameMap: IDirtyUnitSheetNameMap = {};
const { sheet, unitId } = params;
if (dirtyNameMap[unitId] == null) {
dirtyNameMap[unitId] = {};
}
dirtyNameMap[unitId]![sheet.id] = sheet.name;
return dirtyNameMap;
}
private _rangeToMatrix(range: IRange) {
const matrix = new ObjectMatrix<Nullable<ICellData>>();
const { startRow, startColumn, endRow, endColumn } = range;
for (let r = startRow; r <= endRow; r++) {
for (let c = startColumn; c <= endColumn; c++) {
matrix.setValue(r, c, {});
}
}
return matrix;
}
private _getDirtyRangesByCellValue(
unitId: string,
sheetId: string,
cellValue?: IObjectMatrixPrimitiveType<Nullable<ICellData>>
) {
const dirtyRanges: IUnitRange[] = [];
if (cellValue == null) {
return dirtyRanges;
}
const cellMatrix = new ObjectMatrix(cellValue);
const discreteRanges = cellMatrix.getDiscreteRanges();
discreteRanges.forEach((range) => {
dirtyRanges.push({ unitId, sheetId, range });
});
return dirtyRanges;
}
/**
* The array formula is a range where only the top-left corner contains the formula value.
* All other positions, apart from the top-left corner, need to be marked as dirty.
*/
private _getDirtyRangesForArrayFormula(
unitId: string,
sheetId: string,
cellValue: IObjectMatrixPrimitiveType<Nullable<ICellData>>
) {
const dirtyRanges: IUnitRange[] = [];
if (cellValue == null) {
return dirtyRanges;
}
const cellMatrix = new ObjectMatrix(cellValue);
const arrayFormulaRange = this._formulaDataModel.getArrayFormulaRange();
/**
* The array formula is a range where only the top-left corner contains the formula value.
* All other positions, apart from the top-left corner, need to be marked as dirty.
*/
if (arrayFormulaRange?.[unitId]?.[sheetId]) {
const cellRangeData = new ObjectMatrix<IRange>(arrayFormulaRange?.[unitId]?.[sheetId]);
cellMatrix.forValue((row, column) => {
cellRangeData.forValue((arrayFormulaRow, arrayFormulaColumn, arrayFormulaRange) => {
if (arrayFormulaRange == null) {
return true;
}
const { startRow, startColumn, endRow, endColumn } = arrayFormulaRange;
if (row >= startRow && row <= endRow && column >= startColumn && column <= endColumn) {
dirtyRanges.push({
unitId,
sheetId,
range: {
startRow,
startColumn,
endRow: startRow,
endColumn: startColumn,
},
});
}
});
});
}
return dirtyRanges;
}
}

View File

@@ -0,0 +1,102 @@
/**
* 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 type { ICommandInfo } from '@univerjs/core';
import type { ISetArrayFormulaDataMutationParams } from '@univerjs/engine-formula';
import { CellValueType, Disposable, ICommandService, Inject, InterceptorEffectEnum, isRealNum } from '@univerjs/core';
import { FormulaDataModel, SetArrayFormulaDataMutation, stripErrorMargin } from '@univerjs/engine-formula';
import { INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
export class ArrayFormulaCellInterceptorController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(FormulaDataModel) private readonly _formulaDataModel: FormulaDataModel
) {
super();
this._initialize();
}
private _initialize(): void {
this._commandExecutedListener();
this._initInterceptorCellContent();
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
// Synchronous data from worker
if (command.id === SetArrayFormulaDataMutation.id) {
const params = command.params as ISetArrayFormulaDataMutationParams;
if (params == null) {
return;
}
const { arrayFormulaRange, arrayFormulaCellData } = params;
this._formulaDataModel.setArrayFormulaRange(arrayFormulaRange);
this._formulaDataModel.setArrayFormulaCellData(arrayFormulaCellData);
}
})
);
}
private _initInterceptorCellContent() {
this.disposeWithMe(
this._sheetInterceptorService.intercept(INTERCEPTOR_POINT.CELL_CONTENT, {
priority: 100,
effect: InterceptorEffectEnum.Value,
handler: (cell_, location, next) => {
let cell = cell_;
const { unitId, subUnitId, row, col } = location;
const arrayFormulaCellData = this._formulaDataModel.getArrayFormulaCellData();
const cellData = arrayFormulaCellData?.[unitId]?.[subUnitId]?.[row]?.[col];
if (cellData == null) {
return next(cell);
}
if (!cell || cell === location.rawData) {
cell = { ...location.rawData };
}
// The cell in the upper left corner of the array formula also triggers the default value determination
if (cellData.v == null && cellData.t == null) {
cell.v = 0; // Default value for empty cell
cell.t = CellValueType.NUMBER;
return next(cell);
}
// Dealing with precision issues
// Need to be compatible with the case where v is a string but the cell type is a number
// e.g.
// "v": "123413.23000000001",
// "t": 2,
if (cell?.t === CellValueType.NUMBER && cell.v !== undefined && cell.v !== null && isRealNum(cell.v)) {
cell.v = stripErrorMargin(Number(cell.v));
return next(cell);
}
cell.v = cellData.v;
cell.t = cellData.t;
return next(cell);
},
})
);
}
}

View File

@@ -0,0 +1,252 @@
/**
* 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 type { ICommandInfo, IExecutionOptions, Workbook } from '@univerjs/core';
import type { IDefinedNamesUpdateEvent, IFunctionInfo, ISetDefinedNameMutationParam } from '@univerjs/engine-formula';
import type { ISetWorksheetActiveOperationParams } from '@univerjs/sheets';
import {
Disposable,
ICommandService,
IUniverInstanceService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import {
FunctionType,
IDefinedNamesService,
IDescriptionService,
RemoveDefinedNameMutation,
SetDefinedNameMutation,
} from '@univerjs/engine-formula';
import {
getSheetCommandTarget,
SCOPE_WORKBOOK_VALUE_DEFINED_NAME,
SetWorksheetActiveOperation,
} from '@univerjs/sheets';
/**
* header highlight
* column menu: show, hover and mousedown event
*/
export class DefinedNameController extends Disposable {
private _preUnitId: string | null = null;
constructor(
@IDescriptionService private readonly _descriptionService: IDescriptionService,
@IDefinedNamesService private readonly _definedNamesService: IDefinedNamesService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._initialize();
}
private _initialize() {
this._descriptionListener();
this._changeUnitListener();
this._changeSheetListener();
}
private _descriptionListener() {
this.disposeWithMe(
toDisposable(
this._definedNamesService.update$.subscribe((event) => {
this._updateDescriptions(event);
})
)
);
}
private _changeUnitListener() {
this.disposeWithMe(
toDisposable(
this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).subscribe((workbook) => {
this._unRegisterDescriptions();
if (workbook) {
this._initRegisterDescriptions(workbook.getUnitId());
}
})
)
);
}
private _changeSheetListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo, options?: IExecutionOptions) => {
if (options?.fromCollab) {
return;
}
if (command.id === SetWorksheetActiveOperation.id) {
const params = command.params as ISetWorksheetActiveOperationParams;
this._unregisterDescriptionsForNotInSheetId(params.unitId, params.subUnitId);
this._initRegisterDescriptions(params.unitId, params.subUnitId);
} else if (command.id === SetDefinedNameMutation.id) {
// Since command interception will supplement mutation, it is necessary to monitor mutation changes here
// SetDefinedNameMutation and RemoveDefinedNameMutation already cover all possible Defined Name updates
const params = command.params as ISetDefinedNameMutationParam;
this._registerDescription(params);
} else if (command.id === RemoveDefinedNameMutation.id) {
const params = command.params as ISetDefinedNameMutationParam;
this._unregisterDescription(params);
}
})
);
}
private _updateDescriptions(event: IDefinedNamesUpdateEvent) {
const target = getSheetCommandTarget(this._univerInstanceService);
if (!target) return;
const { unitId, subUnitId } = target;
const { type, unitId: updateUnitId, definedNames } = event;
if (updateUnitId !== unitId) {
return;
}
if (type === 'update') {
const functionList: IFunctionInfo[] = [];
definedNames.forEach((definedName) => {
const { name, comment, formulaOrRefString, localSheetId } = definedName;
if (localSheetId == null || localSheetId === SCOPE_WORKBOOK_VALUE_DEFINED_NAME || localSheetId === subUnitId) {
functionList.push({
functionName: name,
description: formulaOrRefString + (comment || ''),
abstract: formulaOrRefString,
functionType: FunctionType.DefinedName,
functionParameter: [],
});
}
});
this._descriptionService.registerDescriptions(functionList);
} else if (type === 'remove') {
const functionList: string[] = [];
definedNames.forEach((definedName) => {
functionList.push(definedName.name);
});
this._descriptionService.unregisterDescriptions(functionList);
}
}
private _registerDescription(params: ISetDefinedNameMutationParam) {
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) return;
const { subUnitId } = target;
const { name, comment, formulaOrRefString, localSheetId } = params;
if (this._descriptionService.hasDescription(name)) {
return;
}
if (localSheetId == null || localSheetId === SCOPE_WORKBOOK_VALUE_DEFINED_NAME || localSheetId === subUnitId) {
this._descriptionService.registerDescriptions([{
functionName: name,
description: formulaOrRefString + (comment || ''),
abstract: formulaOrRefString,
functionType: FunctionType.DefinedName,
functionParameter: [],
}]);
}
}
private _unregisterDescription(param: ISetDefinedNameMutationParam) {
const { name } = param;
this._descriptionService.unregisterDescriptions([name]);
}
private _unRegisterDescriptions() {
if (this._preUnitId === null) {
return;
}
const definedNames = this._definedNamesService.getDefinedNameMap(this._preUnitId);
if (!definedNames) {
return;
}
const functionList: string[] = [];
Object.values(definedNames).forEach((value) => {
const { name } = value;
functionList.push(name);
});
this._descriptionService.unregisterDescriptions(functionList);
this._preUnitId = null;
}
private _initRegisterDescriptions(unitId: string, subUnitId?: string) {
const target = getSheetCommandTarget(this._univerInstanceService, { unitId, subUnitId });
if (!target) return;
const { unitId: _unitId, subUnitId: _subUnitId } = target;
const definedNames = this._definedNamesService.getDefinedNameMap(_unitId);
if (!definedNames) {
return;
}
const functionList: IFunctionInfo[] = [];
this._preUnitId = _unitId;
Object.values(definedNames).forEach((value) => {
const { name, comment, formulaOrRefString, localSheetId } = value;
if (this._descriptionService.hasDescription(name)) {
return;
}
if (localSheetId == null || localSheetId === SCOPE_WORKBOOK_VALUE_DEFINED_NAME || localSheetId === _subUnitId) {
functionList.push({
functionName: name,
description: formulaOrRefString + (comment || ''),
abstract: formulaOrRefString,
functionType: FunctionType.DefinedName,
functionParameter: [],
});
}
});
this._descriptionService.registerDescriptions(functionList);
}
private _unregisterDescriptionsForNotInSheetId(unitId: string, subUnitId: string) {
const definedNames = this._definedNamesService.getDefinedNameMap(unitId);
if (!definedNames) {
return;
}
const functionList: string[] = [];
Object.values(definedNames).forEach((value) => {
const { name, localSheetId } = value;
if (localSheetId !== SCOPE_WORKBOOK_VALUE_DEFINED_NAME && localSheetId !== subUnitId) {
functionList.push(name);
}
});
this._descriptionService.unregisterDescriptions(functionList);
}
}

View File

@@ -0,0 +1,172 @@
/**
* 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 type { ICellData, Nullable } from '@univerjs/core';
import type { AutoFillService, IAutoFillCopyDataInTypeIndexInfo, IAutoFillCopyDataPiece, IAutoFillLocation, IAutoFillRule } from '@univerjs/sheets';
import {
Direction,
Disposable,
generateRandomId,
Inject,
isFormulaId,
isFormulaString,
Tools,
} from '@univerjs/core';
import { LexerTreeBuilder } from '@univerjs/engine-formula';
import { AUTO_FILL_APPLY_TYPE, AUTO_FILL_DATA_TYPE, IAutoFillService } from '@univerjs/sheets';
export class FormulaAutoFillController extends Disposable {
constructor(
@IAutoFillService private readonly _autoFillService: AutoFillService,
@Inject(LexerTreeBuilder) private readonly _lexerTreeBuilder: LexerTreeBuilder
) {
super();
this._registerAutoFill();
}
private _registerAutoFill(): void {
const formulaRule: IAutoFillRule = {
type: AUTO_FILL_DATA_TYPE.FORMULA,
priority: 1001,
match: (cellData) => isFormulaString(cellData?.f) || isFormulaId(cellData?.si),
isContinue: (prev, cur) => {
if (prev.type === AUTO_FILL_DATA_TYPE.FORMULA) {
return true;
}
return false;
},
applyFunctions: {
[AUTO_FILL_APPLY_TYPE.COPY]: (dataWithIndex, len, direction, copyDataPiece, location) => {
const { data, index } = dataWithIndex;
return this._fillCopyFormula(data, len, direction, index, copyDataPiece, location as IAutoFillLocation);
},
},
};
this._autoFillService.registerRule(formulaRule);
}
private _fillCopyFormula(
data: Array<Nullable<ICellData>>,
len: number,
direction: Direction,
index: IAutoFillCopyDataInTypeIndexInfo,
copyDataPiece: IAutoFillCopyDataPiece,
location: IAutoFillLocation
) {
const step = getDataLength(copyDataPiece);
const applyData = [];
const formulaIdMap = new Map<number, string>();
for (let i = 1; i <= len; i++) {
const dataIndex = (i - 1) % data.length;
const sourceIndex = index[dataIndex];
const d = Tools.deepClone(data[dataIndex]);
if (d) {
const originalFormula = data[dataIndex]?.f || '';
const originalFormulaId = data[dataIndex]?.si || '';
const checkFormula = isFormulaString(originalFormula);
const checkFormulaId = isFormulaId(originalFormulaId);
if (checkFormulaId) {
d.si = originalFormulaId;
d.f = null;
d.v = null;
d.p = null;
d.t = null;
applyData.push(d);
} else if (checkFormula) {
// The first position setting formula and formulaId
let formulaId = formulaIdMap.get(dataIndex);
if (!formulaId) {
formulaId = generateRandomId(6);
formulaIdMap.set(dataIndex, formulaId);
const { offsetX, offsetY } = directionToOffset(step, len, direction, location, sourceIndex);
const shiftedFormula = this._lexerTreeBuilder.moveFormulaRefOffset(
originalFormula,
offsetX,
offsetY
);
d.si = formulaId;
d.f = shiftedFormula;
d.v = null;
d.p = null;
d.t = null;
} else {
// At the beginning of the second formula, set formulaId only
d.si = formulaId;
d.f = null;
d.v = null;
d.p = null;
d.t = null;
}
applyData.push(d);
}
}
}
return applyData;
}
}
function directionToOffset(step: number, len: number, direction: Direction, location: IAutoFillLocation, sourceIndex: number) {
const { source, target } = location;
const { rows: targetRows } = target;
const { rows: sourceRows } = source;
let offsetX = 0;
let offsetY = 0;
switch (direction) {
case Direction.UP:
// The formula fills upwards, and the cell containing f and si must be at the top.
// This happens when the step row is hidden, find the relative row that is not hidden.
// For example, a hidden row caused by a sheets-filter.
offsetY = targetRows[sourceIndex] - sourceRows[sourceIndex];
break;
case Direction.RIGHT:
offsetX = step;
break;
case Direction.DOWN:
// This happens when the step row is hidden, find the relative row that is not hidden.
// For example, a hidden row caused by a sheets-filter.
offsetY = targetRows[sourceIndex] - sourceRows[sourceIndex];
break;
case Direction.LEFT:
offsetX = -step * len; // The formula fills leftwards, and the cell containing f and si must be at the left.
break;
}
return { offsetX, offsetY };
}
function getDataLength(copyDataPiece: IAutoFillCopyDataPiece) {
let length = 0;
for (const t in copyDataPiece) {
copyDataPiece[t].forEach((item) => {
length += item.data.length;
});
}
return length;
}

View File

@@ -0,0 +1,28 @@
/**
* 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 { Disposable, ICommandService } from '@univerjs/core';
import { InsertFunctionCommand } from '../commands/commands/insert-function.command';
export class FormulaController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._commandService.registerCommand(InsertFunctionCommand);
}
}

View File

@@ -0,0 +1,235 @@
/**
* 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 type { ICellData, ICommandInfo, Nullable } from '@univerjs/core';
import type { IImageFormulaInfo, IRuntimeImageFormulaDataType, ISetImageFormulaDataMutationParams, IUnitImageFormulaDataType } from '@univerjs/engine-formula';
import {
BooleanNumber,
BuildTextUtils,
CellValueType,
createDocumentModelWithStyle,
Disposable,
DrawingTypeEnum,
generateRandomId,
ICommandService,
ImageSourceType,
Inject,
InterceptorEffectEnum,
ObjectMatrix,
PositionedObjectLayoutType,
WrapTextType,
} from '@univerjs/core';
import { buildDocTransform } from '@univerjs/docs';
import { ErrorType, FormulaDataModel, SetImageFormulaDataMutation } from '@univerjs/engine-formula';
import { InterceptCellContentPriority, INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
export class ImageFormulaCellInterceptorController extends Disposable {
private _errorValueCell: ICellData = {
v: ErrorType.VALUE,
t: CellValueType.STRING,
};
private _refreshRender: () => void;
constructor(
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(FormulaDataModel) private readonly _formulaDataModel: FormulaDataModel
) {
super();
this._initialize();
}
private _initialize(): void {
this._commandExecutedListener();
this._initInterceptorCellContent();
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted(async (command: ICommandInfo) => {
// Synchronous data from worker
if (command.id === SetImageFormulaDataMutation.id) {
const params = command.params as ISetImageFormulaDataMutationParams;
if (!params) return;
const { imageFormulaData } = params;
if (!imageFormulaData || imageFormulaData.length === 0) return;
const updateRuntimeImageFormulaData = await Promise.all(
imageFormulaData.map((imageFormulaInfo) => {
return this._getImageNatureSize(imageFormulaInfo);
})
);
const unitImageFormulaData: IUnitImageFormulaDataType = {};
updateRuntimeImageFormulaData.forEach((imageFormulaInfo) => {
const { unitId, sheetId, row, column, ...imageInfo } = imageFormulaInfo;
if (!unitImageFormulaData[unitId]) {
unitImageFormulaData[unitId] = {};
}
if (!unitImageFormulaData[unitId]![sheetId]) {
unitImageFormulaData[unitId]![sheetId] = new ObjectMatrix<Nullable<IImageFormulaInfo>>();
}
unitImageFormulaData[unitId]![sheetId]!.setValue(row, column, imageInfo);
});
this._formulaDataModel.mergeUnitImageFormulaData(unitImageFormulaData);
// Refresh render
this._refreshRender();
}
})
);
}
// eslint-disable-next-line max-lines-per-function
private _initInterceptorCellContent() {
this.disposeWithMe(
this._sheetInterceptorService.intercept(INTERCEPTOR_POINT.CELL_CONTENT, {
priority: InterceptCellContentPriority.CELL_IMAGE,
effect: InterceptorEffectEnum.Value | InterceptorEffectEnum.Style,
handler: (cell, location, next) => {
const { unitId, subUnitId, row, col } = location;
const unitImageFormulaData = this._formulaDataModel.getUnitImageFormulaData();
const imageInfo = unitImageFormulaData?.[unitId]?.[subUnitId]?.getValue(row, col);
if (!imageInfo) {
return next(cell);
}
const {
source,
// altText,
// sizing,
height,
width,
isErrorImage,
imageNaturalWidth,
imageNaturalHeight,
} = imageInfo;
// If the image failed to load, return #VALUE! error.
if (isErrorImage) {
return next(this._errorValueCell);
}
const finalWidth = width || imageNaturalWidth;
const finalHeight = height || imageNaturalHeight;
if (!finalWidth || !finalHeight) {
return next(this._errorValueCell);
}
// TODO: @Wpxp123456 - now not support altText and sizing, need to be implemented in the future.
const docDataModel = createDocumentModelWithStyle('', {});
const docDrawingParam = {
unitId,
subUnitId,
drawingId: generateRandomId(),
drawingType: DrawingTypeEnum.DRAWING_IMAGE,
imageSourceType: ImageSourceType.URL,
source,
transform: {
left: 0,
top: 0,
width: finalWidth,
height: finalHeight,
},
docTransform: buildDocTransform(finalWidth, finalHeight),
behindDoc: BooleanNumber.FALSE,
title: '',
description: '',
layoutType: PositionedObjectLayoutType.INLINE, // Insert inline drawing by default.
wrapText: WrapTextType.BOTH_SIDES,
distB: 0,
distL: 0,
distR: 0,
distT: 0,
};
const jsonXActions = BuildTextUtils.drawing.add({
documentDataModel: docDataModel,
drawings: [docDrawingParam],
selection: {
collapsed: true,
startOffset: 0,
endOffset: 0,
},
});
if (jsonXActions) {
docDataModel.apply(jsonXActions);
return next({
...cell,
p: docDataModel.getSnapshot(),
});
}
return next(this._errorValueCell);
},
})
);
}
private async _getImageNatureSize(imageFormulaInfo: IRuntimeImageFormulaDataType): Promise<IRuntimeImageFormulaDataType> {
const imageInfo = await this._getImageSize(imageFormulaInfo.source);
if (!imageInfo.image) {
return { ...imageFormulaInfo, isErrorImage: true };
}
return {
...imageFormulaInfo,
isErrorImage: false,
imageNaturalHeight: imageInfo.height,
imageNaturalWidth: imageInfo.width,
};
}
private async _getImageSize(src: string): Promise<Required<{
width: number;
height: number;
image: HTMLImageElement | null;
}>> {
return new Promise((resolve) => {
const image = new Image();
image.src = src;
image.onload = () => {
resolve({
width: image.width,
height: image.height,
image,
});
};
image.onerror = () => {
resolve({
width: 0,
height: 0,
image: null,
});
};
});
};
registerRefreshRenderFunction(refreshRender: () => void) {
this._refreshRender = refreshRender;
}
}

View File

@@ -0,0 +1,36 @@
/**
* 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 { Disposable, ICommandService, Inject } from '@univerjs/core';
import { FormulaCalculationSessionService, FormulaResultApplicationType } from '@univerjs/engine-formula';
import { SetRangeValuesMutation } from '@univerjs/sheets';
export class SheetFormulaCalculationResultApplyController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@Inject(FormulaCalculationSessionService) private readonly _sessionService: FormulaCalculationSessionService
) {
super();
this.disposeWithMe(
this._commandService.onCommandExecuted((command, options) => {
if (command.id === SetRangeValuesMutation.id && options?.applyFormulaCalculationResult) {
this._sessionService.markResultApplied(FormulaResultApplicationType.SHEET);
}
})
);
}
}

View File

@@ -0,0 +1,226 @@
/**
* 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 type { ICommandInfo, IExecutionOptions, Nullable, Workbook } from '@univerjs/core';
import type {
IFunctionInfo,
ISetSuperTableMutationParam,
ISetSuperTableMutationSearchParam,
} from '@univerjs/engine-formula';
import {
Disposable,
ICommandService,
IUniverInstanceService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import {
FunctionType,
IDescriptionService,
ISuperTableService,
RemoveSuperTableMutation,
serializeRangeWithSheet,
SetSuperTableMutation,
} from '@univerjs/engine-formula';
import { SetWorksheetActiveOperation } from '@univerjs/sheets';
/**
* header highlight
* column menu: show, hover and mousedown event
*/
export class SuperTableController extends Disposable {
private _preUnitId: Nullable<string> = null;
constructor(
@IDescriptionService private readonly _descriptionService: IDescriptionService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@ISuperTableService private readonly _superTableService: ISuperTableService
) {
super();
this._initialize();
}
private _initialize() {
this._descriptionListener();
this._changeUnitListener();
this._changeSheetListener();
}
private _descriptionListener() {
toDisposable(
this._superTableService.update$.subscribe(() => {
this._registerDescriptions();
})
);
}
private _changeUnitListener() {
toDisposable(
this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).subscribe((workbook) => {
this._unRegisterDescriptions();
if (workbook) {
this._registerDescriptions();
}
})
);
}
private _changeSheetListener() {
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo, options?: IExecutionOptions) => {
if (options?.fromCollab) {
return;
}
if (command.id === SetWorksheetActiveOperation.id) {
this._unregisterDescriptionsForNotInSheetId();
this._registerDescriptions();
}
// Since command interception will supplement mutation, it is necessary to monitor mutation changes here
// SetDefinedNameMutation and RemoveDefinedNameMutation already cover all possible Defined Name updates
else if (command.id === SetSuperTableMutation.id) {
const param = command.params as ISetSuperTableMutationParam;
this._registerDescription(param);
} else if (command.id === RemoveSuperTableMutation.id) {
const param = command.params as ISetSuperTableMutationSearchParam;
this._unregisterDescription(param);
}
})
);
}
private _registerDescription(param: ISetSuperTableMutationParam) {
const target = this._getUnitIdAndSheetId(param);
if (!target) return;
const { unitId } = target;
const { tableName, reference } = param;
if (!this._descriptionService.hasDescription(tableName)) {
const sheetName = this._univerInstanceService.getUnit<Workbook>(unitId)?.getSheetBySheetId(reference.sheetId)?.getName() || '';
const refString = serializeRangeWithSheet(sheetName, reference.range);
this._descriptionService.registerDescriptions([{
functionName: tableName,
description: refString,
abstract: refString,
functionType: FunctionType.Table,
functionParameter: [],
}]);
}
}
private _unregisterDescription(param: ISetSuperTableMutationSearchParam) {
const { tableName } = param;
this._descriptionService.unregisterDescriptions([tableName]);
}
private _unRegisterDescriptions() {
if (this._preUnitId == null) {
return;
}
const superTables = this._superTableService.getTableMap(this._preUnitId);
if (superTables == null) {
return;
}
const functionList: string[] = [];
superTables.forEach((_, tableName) => {
functionList.push(tableName);
});
this._descriptionService.unregisterDescriptions(functionList);
this._preUnitId = null;
}
private _getUnitIdAndSheetId(params: { unitId?: string; subUnitId?: string } = {}) {
const { unitId, subUnitId } = params;
const workbook = unitId
? this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET)
: this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) return null;
const worksheet = subUnitId
? workbook.getSheetBySheetId(subUnitId)
: workbook.getActiveSheet(true);
if (!worksheet) return null;
return {
unitId: workbook.getUnitId(),
sheetId: worksheet.getSheetId(),
};
}
private _registerDescriptions() {
const target = this._getUnitIdAndSheetId();
if (!target) return;
const { unitId } = target;
const superTables = this._superTableService.getTableMap(unitId);
if (!superTables) {
return;
}
const functionList: IFunctionInfo[] = [];
this._preUnitId = unitId;
superTables.forEach((table, tableName) => {
const sheetName = this._univerInstanceService.getUnit<Workbook>(unitId)?.getSheetBySheetId(table.sheetId)?.getName() || '';
const refString = serializeRangeWithSheet(sheetName, table.range);
if (!this._descriptionService.hasDescription(tableName)) {
functionList.push({
functionName: tableName,
description: refString,
abstract: refString,
functionType: FunctionType.Table,
functionParameter: [],
});
}
});
this._descriptionService.registerDescriptions(functionList);
}
private _unregisterDescriptionsForNotInSheetId() {
const target = this._getUnitIdAndSheetId();
if (!target) return;
const { unitId } = target;
const superTables = this._superTableService.getTableMap(unitId);
if (!superTables) {
return;
}
const functionList: string[] = [];
superTables.forEach((_, tableName) => {
functionList.push(tableName);
});
this._descriptionService.unregisterDescriptions(functionList);
}
}

View File

@@ -0,0 +1,323 @@
/**
* 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 type { ICommandInfo, IUnitRange, Nullable, Workbook } from '@univerjs/core';
import type { IDirtyUnitDefinedNameMap, IDirtyUnitFeatureMap, IDirtyUnitOtherFormulaMap, IDirtyUnitSheetNameMap, IDirtyUnitSuperTableMap, IExecutionInProgressParams, IFormulaDirtyData, ISetFormulaCalculationNotificationMutation, ISetFormulaCalculationStartMutation } from '@univerjs/engine-formula';
import type { IUniverSheetsFormulaBaseConfig } from '../config/config';
import type { LocaleKey } from '../locale/types';
import {
Disposable,
ICommandService,
IConfigService,
ILogService,
Inject,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import {
ENGINE_FORMULA_CYCLE_REFERENCE_COUNT,
ENGINE_FORMULA_RETURN_DEPENDENCY_TREE,
FormulaDataModel,
FormulaExecutedStateType,
FormulaExecuteStageType,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationStartMutation,
SetFormulaCalculationStopMutation,
SetFormulaStringBatchCalculationMutation,
SetTriggerFormulaCalculationStartMutation,
} from '@univerjs/engine-formula';
import { BehaviorSubject } from 'rxjs';
import { CalculationMode, PLUGIN_CONFIG_KEY_BASE } from '../config/config';
/**
* This interface is for the progress bar to display the calculation progress.
*/
export interface ICalculationProgress {
/** Task that already completed. */
done: number;
/** The total number of formulas need to calculate. */
count: number;
/** The label of the calculation progress. */
label?: string;
}
const NilProgress: ICalculationProgress = { done: 0, count: 0 };
const lo = { onlyLocal: true };
export class TriggerCalculationController extends Disposable {
private _startExecutionTime: number = 0;
private _totalCalculationTaskCount: number = 0;
private _doneCalculationTaskCount: number = 0;
private _executionInProgressParams: Nullable<IExecutionInProgressParams> = null;
private readonly _progress$ = new BehaviorSubject<ICalculationProgress>(NilProgress);
readonly progress$ = this._progress$.asObservable();
private _emitProgress(label?: string): void {
this._progress$.next({ done: this._doneCalculationTaskCount, count: this._totalCalculationTaskCount, label });
}
private _startProgress(): void {
this._doneCalculationTaskCount = 0;
this._totalCalculationTaskCount = 1;
const analyzing = this._localeService.t<LocaleKey>('sheets-formula.progress.analyzing');
this._emitProgress(analyzing);
}
private _calculateProgress(label: string): void {
if (this._executionInProgressParams) {
const {
totalFormulasToCalculate,
completedFormulasCount,
totalArrayFormulasToCalculate,
completedArrayFormulasCount,
} = this._executionInProgressParams;
this._doneCalculationTaskCount = completedFormulasCount + completedArrayFormulasCount;
this._totalCalculationTaskCount = totalFormulasToCalculate + totalArrayFormulasToCalculate;
if (this._totalCalculationTaskCount === 0) {
return;
}
this._emitProgress(label);
}
}
private _completeProgress(): void {
this._doneCalculationTaskCount = this._totalCalculationTaskCount = 1;
const done = this._localeService.t<LocaleKey>('sheets-formula.progress.done');
this._emitProgress(done);
}
clearProgress(): void {
this._doneCalculationTaskCount = 0;
this._totalCalculationTaskCount = 0;
this._emitProgress();
}
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ILogService private readonly _logService: ILogService,
@IConfigService private readonly _configService: IConfigService,
@Inject(FormulaDataModel) private readonly _formulaDataModel: FormulaDataModel,
@Inject(LocaleService) private readonly _localeService: LocaleService
) {
super();
this._commandExecutedListener();
this._initialExecuteFormulaProcessListener();
this._initialExecuteFormula();
this.disposeWithMe(
this._univerInstanceService.getTypeOfUnitAdded$<Workbook>(UniverInstanceType.UNIVER_SHEET).subscribe(() => {
this._initialExecuteFormula();
})
);
}
override dispose(): void {
super.dispose();
this._progress$.next(NilProgress);
this._progress$.complete();
}
private _getCalculationMode(): CalculationMode {
const config = this._configService.getConfig<IUniverSheetsFormulaBaseConfig>(PLUGIN_CONFIG_KEY_BASE);
return config?.initialFormulaComputing ?? CalculationMode.WHEN_EMPTY;
}
private _commandExecutedListener() {
// The filtering information is not synchronized to the worker and must be passed in from the main thread each time
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id === SetFormulaCalculationStartMutation.id || command.id === SetFormulaStringBatchCalculationMutation.id) {
const params = command.params as ISetFormulaCalculationStartMutation;
if (command.id === SetFormulaCalculationStartMutation.id) {
const isCalculateTreeModel = this._configService.getConfig<boolean>(ENGINE_FORMULA_RETURN_DEPENDENCY_TREE) || false;
params.isCalculateTreeModel = isCalculateTreeModel;
}
params.maxIteration = this._configService.getConfig(ENGINE_FORMULA_CYCLE_REFERENCE_COUNT) as number | undefined;
params.rowData = this._formulaDataModel.getHiddenRowsFiltered();
}
})
);
}
// eslint-disable-next-line max-lines-per-function
private _initialExecuteFormulaProcessListener() {
// Assignment operation after formula calculation.
let startDependencyTimer: NodeJS.Timeout | null = null;
let calculationProcessCount = 0; // Multiple calculations are performed in parallel, but only one progress bar is displayed, and the progress is only closed after the last calculation is completed.
this.disposeWithMe(
// eslint-disable-next-line max-lines-per-function, complexity
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (command.id === SetFormulaCalculationStopMutation.id) {
this.clearProgress();
}
if (command.id !== SetFormulaCalculationNotificationMutation.id) {
return;
}
const params = command.params as ISetFormulaCalculationNotificationMutation;
if (params.stageInfo != null) {
const {
stage,
} = params.stageInfo;
if (stage === FormulaExecuteStageType.START) {
// When calculations are started multiple times in succession, only the first time is recognized
if (calculationProcessCount === 0) {
this._startExecutionTime = performance.now();
}
// Increment the calculation process count and assign a new ID
calculationProcessCount++;
// Clear any existing timer to prevent duplicate executions
if (startDependencyTimer !== null) {
clearTimeout(startDependencyTimer);
startDependencyTimer = null;
}
// If the total calculation time exceeds 1s, a progress bar is displayed.
startDependencyTimer = setTimeout(() => {
startDependencyTimer = null;
this._startProgress();
}, 1000);
} else if (stage === FormulaExecuteStageType.CURRENTLY_CALCULATING) {
this._executionInProgressParams = params.stageInfo;
if (startDependencyTimer === null) {
const calculating = this._localeService.t<LocaleKey>('sheets-formula.progress.calculating');
this._calculateProgress(calculating);
}
} else if (stage === FormulaExecuteStageType.START_DEPENDENCY_ARRAY_FORMULA) {
this._executionInProgressParams = params.stageInfo;
if (startDependencyTimer === null) {
const arrayAnalysis = this._localeService.t<LocaleKey>('sheets-formula.progress.array-analysis');
this._calculateProgress(arrayAnalysis);
}
} else if (stage === FormulaExecuteStageType.CURRENTLY_CALCULATING_ARRAY_FORMULA) {
this._executionInProgressParams = params.stageInfo;
if (startDependencyTimer === null) {
const arrayCalculation = this._localeService.t<LocaleKey>('sheets-formula.progress.array-calculation');
this._calculateProgress(arrayCalculation);
}
}
} else {
const state = params.functionsExecutedState;
let result = '';
// Decrement the calculation process count
calculationProcessCount--;
switch (state) {
case FormulaExecutedStateType.NOT_EXECUTED:
result = 'No tasks are being executed anymore';
break;
case FormulaExecutedStateType.STOP_EXECUTION:
result = 'The execution of the formula has been stopped';
calculationProcessCount = 0;
break;
case FormulaExecutedStateType.SUCCESS:
result = 'Formula calculation succeeded';
// When the calculation is stopped and then a successful calculation is triggered, the value is -1
if (calculationProcessCount === 0 || calculationProcessCount === -1) {
result += `. Total time consumed: ${performance.now() - this._startExecutionTime} ms`;
}
break;
case FormulaExecutedStateType.INITIAL:
result = 'Waiting for calculation';
break;
}
// When the calculation is stopped and then a successful calculation is triggered, the value is -1
if (calculationProcessCount === 0 || calculationProcessCount === -1) {
if (startDependencyTimer) {
// The total calculation time does not exceed 1s, and the progress bar is not displayed.
clearTimeout(startDependencyTimer);
startDependencyTimer = null;
this.clearProgress();
} else {
// Manually hide the progress bar only if no other calculations are in process
this._completeProgress();
}
calculationProcessCount = 0;
this._doneCalculationTaskCount = 0;
this._totalCalculationTaskCount = 0;
}
this._executionInProgressParams = null;
this._logService.debug('[TriggerCalculationController]', result);
}
})
);
}
private _initialExecuteFormula() {
const calculationMode = this._getCalculationMode();
if (calculationMode !== CalculationMode.NO_CALCULATION) {
const params = this._getDirtyDataByCalculationMode(calculationMode);
this._commandService.executeCommand(SetTriggerFormulaCalculationStartMutation.id, params, lo);
}
}
private _getDirtyDataByCalculationMode(calculationMode: CalculationMode): IFormulaDirtyData {
const forceCalculation = calculationMode === CalculationMode.FORCED;
// loop all sheets cell data, and get the dirty data
const dirtyRanges: IUnitRange[] = calculationMode === CalculationMode.WHEN_EMPTY ? this._formulaDataModel.getFormulaDirtyRanges() : [];
const dirtyNameMap: IDirtyUnitSheetNameMap = {};
const dirtyDefinedNameMap: IDirtyUnitDefinedNameMap = {};
const dirtySuperTableMap: IDirtyUnitSuperTableMap = {};
const dirtyUnitFeatureMap: IDirtyUnitFeatureMap = {};
const dirtyUnitOtherFormulaMap: IDirtyUnitOtherFormulaMap = {};
const clearDependencyTreeCache: IDirtyUnitSheetNameMap = {};
return {
forceCalculation,
dirtyRanges,
dirtyNameMap,
dirtyDefinedNameMap,
dirtySuperTableMap,
dirtyUnitFeatureMap,
dirtyUnitOtherFormulaMap,
clearDependencyTreeCache,
};
}
}

View File

@@ -0,0 +1,119 @@
/**
* 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 type { ICellData, IMutationInfo, UnitModel, Workbook } from '@univerjs/core';
import { Disposable, ICommandService, IUndoRedoService, IUniverInstanceService, ObjectMatrix, sequenceExecute, UniverInstanceType } from '@univerjs/core';
import { IDefinedNamesService, refactorFormulaUnitQualifier, SetDefinedNameMutation } from '@univerjs/engine-formula';
import { SetRangeValuesMutation } from '@univerjs/sheets';
export interface IUnitQualifierFormulaPatch {
unitId: string;
subUnitId: string;
cellValue: Record<number, Record<number, ICellData>>;
}
export function collectUnitQualifierFormulaPatches(
workbook: Workbook,
oldName: string,
newName: string
): IUnitQualifierFormulaPatch[] {
const unitId = workbook.getUnitId();
return workbook.getSheets().flatMap((sheet) => {
const updates = new ObjectMatrix<ICellData>();
sheet.getCellMatrix().forValue((row, column, cell) => {
if (!cell?.f) return;
const formula = refactorFormulaUnitQualifier(cell.f, oldName, newName);
if (formula !== cell.f) updates.setValue(row, column, { f: formula });
});
const cellValue = updates.clone();
return Object.keys(cellValue).length > 0
? [{ unitId, subUnitId: sheet.getSheetId(), cellValue }]
: [];
});
}
/** Keeps persisted Sheet formulas and defined names aligned when a Base Unit is renamed. */
export class UnitQualifierRenameController extends Disposable {
private readonly _names = new Map<string, string>();
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUndoRedoService private readonly _undoRedoService: IUndoRedoService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IDefinedNamesService private readonly _definedNamesService: IDefinedNamesService
) {
super();
this._univerInstanceService.getAllUnitsForType<UnitModel>(UniverInstanceType.UNIVER_BASE)
.forEach((unit) => this._watch(unit));
this.disposeWithMe(this._univerInstanceService.getTypeOfUnitAdded$<UnitModel>(UniverInstanceType.UNIVER_BASE)
.subscribe(({ unit }) => this._watch(unit)));
this.disposeWithMe(this._univerInstanceService.getTypeOfUnitDisposed$<UnitModel>(UniverInstanceType.UNIVER_BASE)
.subscribe((unit) => this._names.delete(unit.getUnitId())));
}
private _watch(unit: UnitModel): void {
const unitId = unit.getUnitId();
this.disposeWithMe(unit.name$.subscribe((name) => {
const oldName = this._names.get(unitId);
this._names.set(unitId, name);
if (!oldName || oldName === name) return;
this._refactor(unitId, oldName, name);
}));
}
private _refactor(renamedUnitId: string, oldName: string, newName: string): void {
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
for (const workbook of this._univerInstanceService.getAllUnitsForType<Workbook>(UniverInstanceType.UNIVER_SHEET)) {
for (const patch of collectUnitQualifierFormulaPatches(workbook, oldName, newName)) {
const sheet = workbook.getSheetBySheetId(patch.subUnitId);
if (!sheet) continue;
const undoCellValue = new ObjectMatrix<ICellData | null>();
new ObjectMatrix(patch.cellValue).forValue((row, column) => {
undoCellValue.setValue(row, column, sheet.getCellRaw(row, column) ?? null);
});
redos.push({ id: SetRangeValuesMutation.id, params: patch });
undos.unshift({
id: SetRangeValuesMutation.id,
params: { ...patch, cellValue: undoCellValue.clone() },
});
}
const definedNames = this._definedNamesService.getDefinedNameMap(workbook.getUnitId());
for (const item of Object.values(definedNames ?? {})) {
const formulaOrRefString = refactorFormulaUnitQualifier(item.formulaOrRefString, oldName, newName);
if (formulaOrRefString !== item.formulaOrRefString) {
redos.push({ id: SetDefinedNameMutation.id, params: {
unitId: workbook.getUnitId(),
...item,
formulaOrRefString,
} });
undos.unshift({ id: SetDefinedNameMutation.id, params: {
unitId: workbook.getUnitId(),
...item,
} });
}
}
}
if (!redos.length || !sequenceExecute(redos, this._commandService).result) {
return;
}
this._undoRedoService.pushUndoRedo({
unitID: renamedUnitId,
undoMutations: undos,
redoMutations: redos,
});
}
}

View File

@@ -0,0 +1,236 @@
/**
* 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 type { IMutationInfo, Nullable, Workbook } from '@univerjs/core';
import type { IDefinedNamesServiceParam, ISetDefinedNameMutationParam } from '@univerjs/engine-formula';
import type { IFormulaReferenceMoveParam } from './utils/ref-range-formula';
import type { IUnitRangeWithOffset } from './utils/ref-range-move';
import {
Disposable,
Inject,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { deserializeRangeWithSheetWithCache, ErrorType, generateStringWithSequence, IDefinedNamesService, LexerTreeBuilder, refactorFormulaUnitQualifier, sequenceNodeType, serializeRangeToRefString, SetDefinedNameMutation } from '@univerjs/engine-formula';
import { RemoveDefinedNameCommand, SetDefinedNameCommand, SheetInterceptorService } from '@univerjs/sheets';
import { FormulaReferenceMoveType, updateRefOffset } from './utils/ref-range-formula';
import { getNewRangeByMoveParam } from './utils/ref-range-move';
import { getReferenceMoveParams } from './utils/ref-range-param';
export class UpdateDefinedNameController extends Disposable {
constructor(
@IDefinedNamesService private readonly _definedNamesService: IDefinedNamesService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(LexerTreeBuilder) private readonly _lexerTreeBuilder: LexerTreeBuilder
) {
super();
this._initialize();
}
private _initialize() {
this._commandExecutedListener();
}
private _commandExecutedListener() {
// remove defined name when sheet is removed
this.disposeWithMe(
this._sheetInterceptorService.interceptCommand({
getMutations: (command) => {
// Exclude processing in getReferenceMoveParams, SetDefinedNameCommand is only handled in UpdateFormulaController
if (command.id === SetDefinedNameCommand.id || command.id === RemoveDefinedNameCommand.id) {
return {
redos: [],
undos: [],
};
}
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (workbook == null) {
return {
redos: [],
undos: [],
};
}
const result = getReferenceMoveParams(workbook, command);
if (!result) {
return {
redos: [],
undos: [],
};
}
if (result.type === FormulaReferenceMoveType.SetUnitName) {
result.oldUnitName = this._univerInstanceService
.getUnit<Workbook>(result.unitId, UniverInstanceType.UNIVER_SHEET)
?.name;
}
return this._getUpdateDefinedNameMutations(workbook, result);
},
})
);
}
// eslint-disable-next-line max-lines-per-function
private _getUpdateDefinedNameMutations(workbook: Workbook, moveParams: IFormulaReferenceMoveParam) {
const { type, unitId, sheetId } = moveParams;
const definedNames = this._definedNamesService.getDefinedNameMap(unitId);
if (!definedNames) {
return {
redos: [],
undos: [],
};
}
const redoMutations: IMutationInfo<ISetDefinedNameMutationParam>[] = [];
const undoMutations: IMutationInfo<ISetDefinedNameMutationParam>[] = [];
// eslint-disable-next-line max-lines-per-function
Object.values(definedNames).forEach((item) => {
const { formulaOrRefString } = item;
if (type === FormulaReferenceMoveType.SetUnitName) {
const { oldUnitName, unitName } = moveParams;
if (!oldUnitName || !unitName) return true;
const nextFormula = refactorFormulaUnitQualifier(formulaOrRefString, oldUnitName, unitName);
if (nextFormula === formulaOrRefString) return true;
redoMutations.push({ id: SetDefinedNameMutation.id, params: { unitId, ...item, formulaOrRefString: nextFormula } });
undoMutations.push({ id: SetDefinedNameMutation.id, params: { unitId, ...item } });
return true;
}
const sequenceNodes = this._lexerTreeBuilder.sequenceNodesBuilder(formulaOrRefString);
if (sequenceNodes == null) {
return true;
}
let shouldModify = false;
const refChangeIds: number[] = [];
for (let i = 0, len = sequenceNodes.length; i < len; i++) {
const node = sequenceNodes[i];
if (typeof node === 'string' || node.nodeType !== sequenceNodeType.REFERENCE) {
continue;
}
const { token } = node;
const sequenceGrid = deserializeRangeWithSheetWithCache(token);
const { range, sheetName, unitId: sequenceUnitId } = sequenceGrid;
const sequenceSheetId = workbook.getSheetBySheetName(sheetName)?.getSheetId() || '';
const sequenceUnitRangeWidthOffset: IUnitRangeWithOffset = {
range,
sheetId: sequenceSheetId,
unitId: sequenceUnitId,
sheetName,
refOffsetX: 0,
refOffsetY: 0,
};
let newRefString: Nullable<string> = null;
if (type === FormulaReferenceMoveType.RemoveSheet) {
newRefString = this._removeSheet(item, unitId, sheetId);
} else if (type === FormulaReferenceMoveType.SetName) {
const {
sheetId: userSheetId,
sheetName: newSheetName,
} = moveParams;
if (newSheetName == null) {
continue;
}
if (sequenceSheetId == null || sequenceSheetId.length === 0) {
continue;
}
if (userSheetId !== sequenceSheetId) {
continue;
}
newRefString = serializeRangeToRefString({
range,
sheetName: newSheetName,
unitId: sequenceUnitId,
});
} else {
newRefString = getNewRangeByMoveParam(
sequenceUnitRangeWidthOffset,
moveParams,
unitId,
sheetId,
{ preserveSheetQualifier: true }
);
}
if (newRefString != null) {
sequenceNodes[i] = {
...node,
token: newRefString,
};
shouldModify = true;
refChangeIds.push(i);
}
}
if (!shouldModify) {
return true;
}
const newSequenceString = generateStringWithSequence(updateRefOffset(sequenceNodes, refChangeIds));
const redoMutation = {
id: SetDefinedNameMutation.id,
params: {
unitId,
...item,
formulaOrRefString: newSequenceString,
},
};
redoMutations.push(redoMutation);
const undoMutation = {
id: SetDefinedNameMutation.id,
params: {
unitId,
...item,
},
};
undoMutations.push(undoMutation);
});
return {
redos: redoMutations,
undos: undoMutations,
};
}
private _removeSheet(item: IDefinedNamesServiceParam, unitId: string, subUnitId: string) {
const { formulaOrRefString } = item;
const sheetId = this._definedNamesService.getWorksheetByRef(unitId, formulaOrRefString)?.getSheetId();
if (sheetId === subUnitId) {
return ErrorType.REF;
}
return null;
}
}

View File

@@ -0,0 +1,771 @@
/**
* 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 type {
ICellData,
ICommandInfo,
IExecutionOptions,
IObjectMatrixPrimitiveType,
IUnitRange,
Nullable,
Workbook,
} from '@univerjs/core';
import type { IDirtyUnitDefinedNameMap, IDirtyUnitFeatureMap, IDirtyUnitOtherFormulaMap, IDirtyUnitSheetNameMap, IFormulaData, IFormulaDataItem, IFormulaDirtyData, IUnitSheetNameMap } from '@univerjs/engine-formula';
import type {
IInsertSheetMutationParams,
IRemoveSheetMutationParams,
ISetRangeValuesMutationParams,
} from '@univerjs/sheets';
import type { IUniverSheetsFormulaBaseConfig } from '../config/config';
import type { IFormulaReferenceMoveParam } from './utils/ref-range-formula';
import type { IUnitRangeWithOffset } from './utils/ref-range-move';
import {
Disposable,
ICommandService,
IConfigService,
Inject,
Injector,
IUniverInstanceService,
ObjectMatrix,
Rectangle,
Tools,
UniverInstanceType,
} from '@univerjs/core';
import { deserializeRangeWithSheetWithCache, ErrorType, FormulaDataModel, generateStringWithSequence, IDefinedNamesService, LexerTreeBuilder, refactorFormulaUnitQualifier, sequenceNodeType, serializeRangeToRefString, SetArrayFormulaDataMutation, SetFormulaDataMutation, SetTriggerFormulaCalculationStartMutation, splitTableStructuredRef } from '@univerjs/engine-formula';
import {
ClearSelectionFormatCommand,
InsertSheetMutation,
RemoveSheetMutation,
SetBorderCommand,
SetRangeCustomMetadataCommand,
SetRangeValuesMutation,
SetStyleCommand,
SheetInterceptorService,
} from '@univerjs/sheets';
import { map } from 'rxjs';
import { CalculationMode, PLUGIN_CONFIG_KEY_BASE } from '../config/config';
import { removeFormulaData } from './utils/offset-formula-data';
import { checkIsSameUnitAndSheet, formulaDataToCellData, FormulaReferenceMoveType, getFormulaReferenceMoveUndoRedo, updateRefOffset } from './utils/ref-range-formula';
import { getNewRangeByMoveParam } from './utils/ref-range-move';
import { getReferenceMoveParams } from './utils/ref-range-param';
/**
* Update formula process
*
* 1. Command intercepts, converts the command information to adapt refRange, offsets the formula content, and obtains the formula that requires offset content.
*
* 2. Use refRange to offset the formula position and return undo/redo data to setRangeValues mutation
* - Redo data: Delete the old value at the old position on the match, and add the new value at the new position (the new value first checks whether the old position has offset content, if so, use the new offset content, if not, take the old value)
* - Undo data: the old position on the match saves the old value, and the new position delete value. Using undos when undoing will operate the data after the offset position.
*
* 3. onCommandExecuted, before formula calculation, use the setRangeValues information to delete the old formulaData, ArrayFormula and ArrayFormulaCellData, and send the worker (complementary setRangeValues after collaborative conflicts, normal operation triggers formula update, undo/redo are captured and processed here)
*/
export class UpdateFormulaController extends Disposable {
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(LexerTreeBuilder) private readonly _lexerTreeBuilder: LexerTreeBuilder,
@Inject(FormulaDataModel) private readonly _formulaDataModel: FormulaDataModel,
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@IDefinedNamesService private readonly _definedNamesService: IDefinedNamesService,
@IConfigService private readonly _configService: IConfigService,
@Inject(Injector) readonly _injector: Injector
) {
super();
this._commandExecutedListener();
}
private _commandExecutedListener() {
this.disposeWithMe(this._sheetInterceptorService.interceptCommand({
getMutations: (command) => this._getUpdateFormula(command),
}));
this.disposeWithMe(
this._commandService.onCommandExecuted((command: ICommandInfo) => {
if (!command.params) return;
if (command.id === RemoveSheetMutation.id) {
const { subUnitId: sheetId, unitId } = command.params as IRemoveSheetMutationParams;
this._handleWorkbookDisposed(unitId, sheetId);
} else if (command.id === InsertSheetMutation.id) {
this._handleInsertSheetMutation(command.params as IInsertSheetMutationParams);
}
})
);
// Make sure to get the complete formula history data before updating, which contains the complete mapping of f and si
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo, options?: IExecutionOptions) => {
if (command.id === SetRangeValuesMutation.id) {
const params = command.params as ISetRangeValuesMutationParams;
if (shouldSkipFormulaUpdateForSetRangeValues(params, options)) {
return;
}
this._handleSetRangeValuesMutation(params as ISetRangeValuesMutationParams);
}
})
);
this.disposeWithMe(this._univerInstanceService.getTypeOfUnitAdded$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.subscribe((event) => this._handleWorkbookAdded(event.unit)));
this.disposeWithMe(this._univerInstanceService.getTypeOfUnitDisposed$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.pipe(map((unit) => unit.getUnitId()))
.subscribe((unitId) => this._handleWorkbookDisposed(unitId)));
}
private _handleSetRangeValuesMutation(params: ISetRangeValuesMutationParams) {
const { subUnitId: sheetId, unitId, cellValue } = params;
if (cellValue == null) {
return;
}
const newSheetFormulaData = this._formulaDataModel.updateFormulaData(unitId, sheetId, cellValue);
const arrayFormulaCellDataChanged = this._formulaDataModel.updateArrayFormulaCellData(unitId, sheetId, cellValue);
const arrayFormulaRangeChanged = this._formulaDataModel.updateArrayFormulaRange(unitId, sheetId, cellValue);
if (Object.keys(newSheetFormulaData).length === 0) {
if (arrayFormulaCellDataChanged || arrayFormulaRangeChanged) {
this._commandService.executeCommand(
SetArrayFormulaDataMutation.id,
{
arrayFormulaRange: this._formulaDataModel.getArrayFormulaRange(),
arrayFormulaCellData: this._formulaDataModel.getArrayFormulaCellData(),
},
{
onlyLocal: true,
remove: true, // remove array formula range shape
}
);
}
return;
}
const newFormulaData = {
[unitId]: {
[sheetId]: newSheetFormulaData,
},
};
// update core snapshot
this._commandService.executeCommand(
SetRangeValuesMutation.id,
{
unitId,
subUnitId: sheetId,
cellValue: formulaDataToCellData(newSheetFormulaData, cellValue),
},
{
onlyLocal: true,
fromFormula: true,
}
);
// update image formula data
this._formulaDataModel.updateImageFormulaData(unitId, sheetId, cellValue);
// TODO@Dushusir: When the amount of data is large, the communication overhead is high. The main thread and the worker update their own models to reduce the communication overhead.
this._commandService.executeCommand(
SetFormulaDataMutation.id,
{
formulaData: newFormulaData,
},
{
onlyLocal: true,
}
);
this._commandService.executeCommand(
SetArrayFormulaDataMutation.id,
{
arrayFormulaRange: this._formulaDataModel.getArrayFormulaRange(),
arrayFormulaCellData: this._formulaDataModel.getArrayFormulaCellData(),
},
{
onlyLocal: true,
remove: true, // remove array formula range shape
}
);
}
private _handleWorkbookDisposed(unitId: string, sheetId?: string) {
this._formulaDataModel.clearFormulaIdMap(unitId, sheetId);
const formulaData = this._formulaDataModel.getFormulaData();
const newFormulaData = removeFormulaData(formulaData, unitId, sheetId);
const arrayFormulaRange = this._formulaDataModel.getArrayFormulaRange();
const newArrayFormulaRange = removeFormulaData(arrayFormulaRange, unitId, sheetId);
const arrayFormulaCellData = this._formulaDataModel.getArrayFormulaCellData();
const newArrayFormulaCellData = removeFormulaData(arrayFormulaCellData, unitId, sheetId);
if (newFormulaData) {
this._commandService.executeCommand(
SetFormulaDataMutation.id,
{
formulaData: newFormulaData,
},
{
onlyLocal: true,
}
);
}
if (newArrayFormulaRange && newArrayFormulaCellData) {
this._commandService.executeCommand(
SetArrayFormulaDataMutation.id,
{
arrayFormulaRange,
arrayFormulaCellData,
},
{
onlyLocal: true,
}
);
}
}
private _handleInsertSheetMutation(params: IInsertSheetMutationParams) {
const { sheet, unitId } = params;
const formulaData = this._formulaDataModel.getFormulaData();
const { id: sheetId, cellData } = sheet;
const cellMatrix = new ObjectMatrix<Nullable<ICellData>>(cellData);
const newFormulaData = this._formulaDataModel.initSheetFormulaData(formulaData, unitId, sheetId, cellMatrix);
this._commandService.executeCommand(
SetFormulaDataMutation.id,
{
formulaData: newFormulaData,
},
{
onlyLocal: true,
}
);
}
private _handleWorkbookAdded(unit: Workbook) {
const formulaData: IFormulaData = {};
const unitId = unit.getUnitId();
const newFormulaData: IFormulaData = { [unitId]: {} };
const worksheets = unit.getSheets();
worksheets.forEach((worksheet) => {
const cellMatrix = worksheet.getCellMatrix();
const sheetId = worksheet.getSheetId();
const currentSheetData = this._formulaDataModel.initSheetFormulaData(formulaData, unitId, sheetId, cellMatrix);
newFormulaData[unitId]![sheetId] = currentSheetData[unitId]?.[sheetId];
});
this._commandService.executeCommand(SetFormulaDataMutation.id, { formulaData: newFormulaData }, { onlyLocal: true });
const config = this._configService.getConfig<IUniverSheetsFormulaBaseConfig>(PLUGIN_CONFIG_KEY_BASE);
const calculationMode = config?.initialFormulaComputing ?? CalculationMode.WHEN_EMPTY;
if (calculationMode === CalculationMode.NO_CALCULATION) return;
const params = this._getDirtyDataByCalculationMode(calculationMode);
this._commandService.executeCommand(SetTriggerFormulaCalculationStartMutation.id, params, { onlyLocal: true });
}
private _getDirtyDataByCalculationMode(calculationMode: CalculationMode): IFormulaDirtyData {
const forceCalculation = calculationMode === CalculationMode.FORCED;
// loop all sheets cell data, and get the dirty data
const dirtyRanges: IUnitRange[] = calculationMode === CalculationMode.WHEN_EMPTY ? this._formulaDataModel.getFormulaDirtyRanges() : [];
const dirtyNameMap: IDirtyUnitSheetNameMap = {};
const dirtyDefinedNameMap: IDirtyUnitDefinedNameMap = {};
const dirtyUnitFeatureMap: IDirtyUnitFeatureMap = {};
const dirtyUnitOtherFormulaMap: IDirtyUnitOtherFormulaMap = {};
const clearDependencyTreeCache: IDirtyUnitSheetNameMap = {};
return {
forceCalculation,
dirtyRanges,
dirtyNameMap,
dirtyDefinedNameMap,
dirtyUnitFeatureMap,
dirtyUnitOtherFormulaMap,
clearDependencyTreeCache,
};
}
private _getUpdateFormula(command: ICommandInfo) {
const workbook = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return {
undos: [],
redos: [],
};
}
const result = getReferenceMoveParams(workbook, command);
if (result) {
const { unitNameMap, unitSheetNameMap } = this._formulaDataModel.getCalculateData();
if (result.type === FormulaReferenceMoveType.SetUnitName) {
result.oldUnitName = unitNameMap?.[result.unitId]?.name;
}
const oldFormulaData = this._formulaDataModel.getFormulaData();
// change formula reference
const { newFormulaData } = this._getFormulaReferenceMoveInfo(
oldFormulaData,
unitSheetNameMap,
result
);
const { undos, redos } = getFormulaReferenceMoveUndoRedo(oldFormulaData, newFormulaData, result);
return {
undos,
redos,
};
}
return {
undos: [],
redos: [],
};
}
// eslint-disable-next-line max-lines-per-function
private _getFormulaReferenceMoveInfo(
formulaData: IFormulaData,
unitSheetNameMap: IUnitSheetNameMap,
formulaReferenceMoveParam: IFormulaReferenceMoveParam
) {
if (!Tools.isDefine(formulaData)) return { newFormulaData: {} };
const formulaDataKeys = Object.keys(formulaData);
if (formulaDataKeys.length === 0) return { newFormulaData: {} };
const newFormulaData: IFormulaData = {};
const { unitId: fromUnitId, sheetId: fromSheetId, sheetName: fromSheetName, targetUnitId, targetSheetId, type, from, to } = formulaReferenceMoveParam;
const inCrossSheetCutRangeNewFormulas: Array<{
fromRow: number;
fromColumn: number;
formulaString: string;
}> = [];
for (const unitId of formulaDataKeys) {
const sheetData = formulaData[unitId];
if (sheetData == null) {
continue;
}
const sheetDataKeys = Object.keys(sheetData);
if (!Tools.isDefine(newFormulaData[unitId])) {
newFormulaData[unitId] = {};
}
for (const sheetId of sheetDataKeys) {
const matrixData = new ObjectMatrix(sheetData[sheetId] || {});
const newFormulaDataItem = new ObjectMatrix<IFormulaDataItem>();
const shouldModifySi: string[] = [];
// eslint-disable-next-line max-lines-per-function, complexity
matrixData.forValue((row, column, formulaDataItem) => {
if (!formulaDataItem) return true;
const { f: formulaString, x, y, si } = formulaDataItem;
if (type === FormulaReferenceMoveType.SetUnitName) {
const { oldUnitName, unitName } = formulaReferenceMoveParam;
if (!oldUnitName || !unitName) return true;
const nextFormula = refactorFormulaUnitQualifier(formulaString, oldUnitName, unitName);
if (nextFormula !== formulaString) newFormulaDataItem.setValue(row, column, { f: nextFormula });
return true;
}
const sequenceNodes = this._lexerTreeBuilder.sequenceNodesBuilder(formulaString);
if (sequenceNodes == null) {
return true;
}
let shouldModify = false;
const refChangeIds: number[] = [];
// Whether the formula cell is in the moved range and the move is a cross-worksheet cut operation
const inCrossSheetCutRange = type === FormulaReferenceMoveType.MoveRange &&
(targetUnitId !== fromUnitId || targetSheetId !== fromSheetId) &&
unitId === fromUnitId &&
sheetId === fromSheetId &&
from &&
from.startRow <= row &&
row <= from.endRow &&
from.startColumn <= column &&
column <= from.endColumn;
const inCrossSheetCutRangeSequenceNodes = [...sequenceNodes];
for (let i = 0, len = sequenceNodes.length; i < len; i++) {
const node = sequenceNodes[i];
if (typeof node === 'string') {
continue;
}
const { token, nodeType } = node;
// The impact of defined name changes on formula calculation
// 1. ref range only changes formulaOrRefString to trigger recalculation
// 2. set defined name command, change name to trigger formula update, otherwise trigger recalculation
// 3. remove defined name command, change name to #REF! to trigger formula update
// 4. insert defined name No processing required
// 5. remove sheet, trigger recalculation
// FIXME: Why is the node type of defined name 3?
if ((type === FormulaReferenceMoveType.SetDefinedName || type === FormulaReferenceMoveType.RemoveDefinedName) && (nodeType === sequenceNodeType.DEFINED_NAME || nodeType === sequenceNodeType.FUNCTION)) {
const { definedNameId, definedName } = formulaReferenceMoveParam;
if (definedNameId === undefined || definedName === undefined) {
continue;
}
const oldDefinedName = this._definedNamesService.getValueById(unitId, definedNameId);
if (oldDefinedName === undefined || oldDefinedName === null) {
continue;
}
// Make sure the current token is the defined name to be updated.
if (oldDefinedName.name !== token) {
continue;
}
// Update the defined name in the formula, if the defined name is removed, update the token to #REF!
sequenceNodes[i] = {
...node,
token: type === FormulaReferenceMoveType.SetDefinedName ? definedName : ErrorType.REF,
};
shouldModify = true;
refChangeIds.push(i);
continue;
} else if ((type === FormulaReferenceMoveType.SetSuperTableName || type === FormulaReferenceMoveType.RemoveSuperTableName || type === FormulaReferenceMoveType.RemoveSuperTableColumn) && (nodeType === sequenceNodeType.TABLE || nodeType === sequenceNodeType.FUNCTION)) {
const { oldTableName, tableName, tableColumnNames } = formulaReferenceMoveParam;
if (oldTableName === undefined || (type === FormulaReferenceMoveType.SetSuperTableName && tableName === undefined)) {
continue;
}
const { tableName: tokenTableName, columnStruct = '' } = splitTableStructuredRef(token);
if (tokenTableName !== oldTableName) {
continue;
}
if (type === FormulaReferenceMoveType.RemoveSuperTableColumn && !tableReferenceContainsColumn(columnStruct, tableColumnNames)) {
continue;
}
sequenceNodes[i] = {
...node,
token: type === FormulaReferenceMoveType.SetSuperTableName ? `${tableName}${columnStruct}` : ErrorType.REF,
};
const nextNode = sequenceNodes[i + 1];
if ((type === FormulaReferenceMoveType.RemoveSuperTableName || type === FormulaReferenceMoveType.RemoveSuperTableColumn) && typeof nextNode === 'string' && nextNode.startsWith(']')) {
sequenceNodes[i + 1] = nextNode.slice(1);
}
shouldModify = true;
refChangeIds.push(i);
continue;
} else if (nodeType !== sequenceNodeType.REFERENCE) {
continue;
}
const sequenceGrid = deserializeRangeWithSheetWithCache(token);
const { range, sheetName, unitId: sequenceUnitId } = sequenceGrid;
const mapUnitId =
sequenceUnitId == null || sequenceUnitId.length === 0 ? unitId : sequenceUnitId;
const sequenceSheetId = unitSheetNameMap?.[mapUnitId]?.[sheetName] || '';
if (
!checkIsSameUnitAndSheet(
formulaReferenceMoveParam.unitId,
formulaReferenceMoveParam.sheetId,
unitId,
sheetId,
sequenceUnitId,
sequenceSheetId
)
) {
continue;
}
const sequenceUnitRangeWidthOffset: IUnitRangeWithOffset = {
range,
sheetId: sequenceSheetId,
unitId: sequenceUnitId,
sheetName,
refOffsetX: x || 0,
refOffsetY: y || 0,
};
let newRefString: Nullable<string> = null;
if (type === FormulaReferenceMoveType.SetName) {
const {
unitId: userUnitId,
sheetId: userSheetId,
sheetName: newSheetName,
} = formulaReferenceMoveParam;
if (newSheetName == null) {
continue;
}
if (sequenceSheetId == null || sequenceSheetId.length === 0) {
continue;
}
if (userSheetId !== sequenceSheetId) {
continue;
}
newRefString = serializeRangeToRefString({
range,
sheetName: newSheetName,
unitId: sequenceUnitId,
});
} else if (type === FormulaReferenceMoveType.RemoveSheet) {
const {
unitId: userUnitId,
sheetId: userSheetId,
sheetName: newSheetName,
} = formulaReferenceMoveParam;
if (sequenceSheetId == null || sequenceSheetId.length === 0) {
continue;
}
if (userSheetId !== sequenceSheetId) {
continue;
}
newRefString = ErrorType.REF;
} else if (type !== FormulaReferenceMoveType.SetDefinedName) {
newRefString = getNewRangeByMoveParam(
sequenceUnitRangeWidthOffset,
formulaReferenceMoveParam,
unitId,
sheetId,
{
inCrossSheetCutRange,
}
);
}
if (newRefString != null) {
sequenceNodes[i] = {
...node,
token: newRefString,
};
shouldModify = true;
refChangeIds.push(i);
// newRefString = ErrorType.REF;
// If the formula cell has an si, it means the formula cell is source of other same si cells, so the si cells needs to be updated.
if (si && (x ?? 0) === 0 && (y ?? 0) === 0) shouldModifySi.push(si);
}
/**
* If the reference sequence range is not affected by the move, and the move is a cross-worksheet cut operation, it may be necessary to rewrite the sheet name in the ref string after move, to make sure the ref still works after move.
* For example, if a formula cell is `=SUM(A1:A5)` in Sheet1, and formula cell cut to Sheet2, the formula should be rewritten to `=SUM(Sheet1!A1:A5)`, otherwise it will become `=SUM(A1:A5)` and reference the wrong range in Sheet2.
*/
if (inCrossSheetCutRange) {
if (newRefString != null) {
inCrossSheetCutRangeSequenceNodes[i] = {
...node,
token: newRefString,
};
} else if ((!sequenceUnitId || sequenceUnitId === fromUnitId) && (!sequenceSheetId || sequenceSheetId === fromSheetId)) {
/**
* Only the reference range is in the from worksheet need to rewrite the sheet name, otherwise the ref string will be rewritten unnecessarily when moving between other worksheets.
* For example, if a formula cell is `=SUM(Sheet3!A1:A5)` in Sheet1, and formula cell cut to Sheet2, the formula should not be rewritten, otherwise it will become `=SUM(Sheet1!A1:A5)` and reference the wrong range in Sheet1, while the original ref is referencing Sheet3 and should not be affected by the move between Sheet1 and Sheet2.
*/
const sequenceRange = Rectangle.moveOffset(range, x || 0, y || 0);
inCrossSheetCutRangeSequenceNodes[i] = {
...node,
token: serializeRangeToRefString({
range: sequenceRange,
sheetName: fromSheetName || sheetName,
unitId: targetUnitId !== fromUnitId ? fromUnitId : '',
}),
};
shouldModify = true;
}
}
}
if (!shouldModify) {
/**
* If the operation is a move type, and the formula cell has si and is the same as the current shouldModifySi, unpack the si to f.
* Or the source formula cell is in the moved range.
* This is to ensure that the si formula can be recalculated correctly after the move.
*/
if (
si &&
[FormulaReferenceMoveType.MoveRows, FormulaReferenceMoveType.MoveCols, FormulaReferenceMoveType.MoveRange].includes(type)
) {
if (from && from.startRow <= row && row <= from.endRow && from.startColumn <= column && column <= from.endColumn) {
if ((x ?? 0) === 0 && (y ?? 0) === 0) shouldModifySi.push(si);
} else if (!shouldModifySi.includes(si)) {
return true;
}
} else {
return true;
}
}
if (inCrossSheetCutRange) {
const newSequenceNodes = updateRefOffset(inCrossSheetCutRangeSequenceNodes, refChangeIds, x, y);
inCrossSheetCutRangeNewFormulas.push({
fromRow: row,
fromColumn: column,
formulaString: `=${generateStringWithSequence(newSequenceNodes)}`,
});
return true;
}
const newSequenceNodes = updateRefOffset(sequenceNodes, refChangeIds, x, y);
newFormulaDataItem.setValue(row, column, {
f: `=${generateStringWithSequence(newSequenceNodes)}`,
});
});
if (newFormulaData[unitId]) {
newFormulaData[unitId]![sheetId] = newFormulaDataItem.clone();
}
}
}
if (inCrossSheetCutRangeNewFormulas.length > 0 && targetUnitId && targetSheetId) {
if (!newFormulaData[targetUnitId]) {
newFormulaData[targetUnitId] = {};
}
if (!newFormulaData[targetUnitId][targetSheetId]) {
newFormulaData[targetUnitId][targetSheetId] = {};
}
for (const newFormula of inCrossSheetCutRangeNewFormulas) {
const { fromRow, fromColumn, formulaString } = newFormula;
const targetRow = fromRow + ((to?.startRow ?? 0) - (from?.startRow ?? 0));
const targetColumn = fromColumn + ((to?.startColumn ?? 0) - (from?.startColumn ?? 0));
if (!newFormulaData[targetUnitId][targetSheetId][targetRow]) {
newFormulaData[targetUnitId][targetSheetId][targetRow] = {};
}
newFormulaData[targetUnitId][targetSheetId][targetRow][targetColumn] = {
f: formulaString,
};
}
}
return { newFormulaData };
}
}
/**
* Whether to skip the formula update when the setRangeValues mutation is executed.
* The style-only cell value does not affect the formula calculation, so it can be skipped.
*/
function shouldSkipFormulaUpdateForSetRangeValues(params: ISetRangeValuesMutationParams, options?: IExecutionOptions): boolean {
if (
options &&
(options.onlyLocal === true || options.syncOnly === true || options.fromChangeset === true)
) {
return true;
}
const { cellValue, trigger } = params;
if (
trigger &&
[
SetStyleCommand.id,
SetBorderCommand.id,
ClearSelectionFormatCommand.id,
SetRangeCustomMetadataCommand.id,
].includes(trigger)
) {
return true;
}
if (!cellValue) {
return true;
}
return isStyleOnlyCellValue(cellValue);
}
function isStyleOnlyCellValue(cellValue: IObjectMatrixPrimitiveType<Nullable<ICellData>>): boolean {
const matrix = new ObjectMatrix(cellValue);
let hasCell = false;
let styleOnly = true;
matrix.forValue((_row, _col, cell) => {
hasCell = true;
if (!cell) {
styleOnly = false;
return false;
}
const keys = Object.keys(cell);
if (keys.length !== 1 || keys[0] !== 's') {
styleOnly = false;
return false;
}
});
return hasCell && styleOnly;
}
function tableReferenceContainsColumn(columnStruct: string, columnNames: string[] | undefined): boolean {
if (!columnNames?.length || columnStruct.length === 0) {
return false;
}
const columnNameSet = new Set(columnNames);
const completedColumnStruct = columnStruct.endsWith(']') ? columnStruct : `${columnStruct}]`;
const columnMatches = completedColumnStruct.matchAll(/\[([^\]]+)\]/g);
for (const match of columnMatches) {
const columnName = match[1].replace(/^\[/, '').trim();
if (!columnName.startsWith('#') && columnNameSet.has(columnName)) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,132 @@
/**
* 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 { removeFormulaData } from '../offset-formula-data';
describe('Utils offset formula data test', () => {
describe('function removeFormulaData, remove sheet', () => {
it('remove data', () => {
const unitId = 'workbook-01';
const sheetId = 'sheet-0011';
const formulaData = {
[unitId]: {
[sheetId]: {
0: {
0: {
f: '=SUM(A1)',
},
},
},
},
};
removeFormulaData(formulaData, unitId, sheetId);
expect(formulaData).toStrictEqual({
[unitId]: {},
});
});
it('remove blank worksheet', () => {
const unitId = 'workbook-01';
const sheetId = 'sheet-0011';
const formulaData = {
[unitId]: {
[sheetId]: {},
},
};
removeFormulaData(formulaData, unitId, sheetId);
expect(formulaData).toStrictEqual({
[unitId]: {},
});
});
it('remove blank workbook', () => {
const unitId = 'workbook-01';
const sheetId = 'sheet-0011';
const formulaData = {
[unitId]: {},
};
removeFormulaData(formulaData, unitId, sheetId);
expect(formulaData).toStrictEqual({
[unitId]: {},
});
});
it('remove blank object', () => {
const unitId = 'workbook-01';
const sheetId = 'sheet-0011';
const formulaData = {};
removeFormulaData(formulaData, unitId, sheetId);
expect(formulaData).toStrictEqual({});
});
});
describe('function removeUnitFormulaData, remove unit', () => {
it('remove data', () => {
const unitId = 'workbook-01';
const sheetId = 'sheet-0011';
const formulaData = {
[unitId]: {
[sheetId]: {
0: {
0: {
f: '=SUM(A1)',
},
},
},
},
'unit-2': {
},
};
removeFormulaData(formulaData, unitId);
expect(formulaData).toStrictEqual({
'unit-2': {},
});
});
it('remove blank workbook', () => {
const unitId = 'workbook-01';
const formulaData = {
[unitId]: {},
'unit-2': {},
};
removeFormulaData(formulaData, unitId);
expect(formulaData).toStrictEqual({
'unit-2': {},
});
});
it('remove blank object', () => {
const unitId = 'workbook-01';
const formulaData = {};
removeFormulaData(formulaData, unitId);
expect(formulaData).toStrictEqual({});
});
});
});

View File

@@ -0,0 +1,149 @@
/**
* 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 type { IFormulaDataItem } from '@univerjs/engine-formula';
import { describe, expect, it } from 'vitest';
import { formulaDataItemToCellData, formulaDataToCellData, isFormulaDataItem } from '../ref-range-formula';
describe('Ref range formula test', () => {
describe('Util function', () => {
it('Function formulaDataItemToCellData', () => {
let formulaDataItem: IFormulaDataItem = {
f: '=SUM(1)',
};
let result = formulaDataItemToCellData(formulaDataItem);
expect(result).toStrictEqual({
f: '=SUM(1)',
si: null,
});
formulaDataItem = {
f: '',
si: 'id1',
};
result = formulaDataItemToCellData(formulaDataItem);
expect(result).toStrictEqual({
si: 'id1',
f: null,
});
formulaDataItem = {
f: '=SUM(1)',
si: 'id1',
};
result = formulaDataItemToCellData(formulaDataItem);
expect(result).toStrictEqual({
f: '=SUM(1)',
si: 'id1',
});
formulaDataItem = {
f: '=SUM(1)',
si: 'id1',
x: 0,
y: 0,
};
result = formulaDataItemToCellData(formulaDataItem);
expect(result).toStrictEqual({
f: '=SUM(1)',
si: 'id1',
});
formulaDataItem = {
f: '=SUM(1)',
si: 'id1',
x: 0,
y: 1,
};
result = formulaDataItemToCellData(formulaDataItem);
expect(result).toStrictEqual({
si: 'id1',
f: null,
});
formulaDataItem = {
f: '',
si: '',
x: 0,
y: 1,
};
result = formulaDataItemToCellData(formulaDataItem);
expect(result).toStrictEqual({ f: null, si: null });
});
it('Function formulaDataToCellData', () => {
const formulaData = {
0: {
0: {
f: '=SUM(1)',
},
1: {
f: '',
si: 'id1',
},
2: {
f: '=SUM(1)',
si: 'id2',
x: 0,
y: 1,
},
3: null,
},
};
const cellData = {
0: {
0: {
f: '=SUM(1)',
si: null,
},
1: {
f: null,
si: 'id1',
},
2: {
f: null,
si: 'id2',
},
3: {
f: null,
si: null,
},
},
};
expect(formulaDataToCellData(formulaData)).toStrictEqual(cellData);
});
it('isFormulaDataItem', () => {
expect(isFormulaDataItem({ f: '=SUM(1)' })).toBeTruthy();
expect(isFormulaDataItem({ f: '' })).toBeFalsy();
expect(isFormulaDataItem({ f: '', si: 'id1' })).toBeTruthy();
expect(isFormulaDataItem({ f: '', si: undefined })).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,335 @@
/**
* 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 { RANGE_TYPE } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { checkMoveEdge, OriginRangeEdgeType } from '../ref-range-move';
describe('checkMoveEdge', () => {
it('should return ALL when originRange is completely within fromRange', () => {
const originRange = {
startRow: 2,
endRow: 4,
startColumn: 2,
endColumn: 4,
};
const fromRange = {
startRow: 1,
endRow: 5,
startColumn: 1,
endColumn: 5,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.ALL);
});
it('should return UP when fromRange is at the top edge of originRange', () => {
const originRange = {
startRow: 2,
endRow: 6,
startColumn: 2,
endColumn: 4,
};
const fromRange = {
startRow: 2,
endRow: 5,
startColumn: 2,
endColumn: 4,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.UP);
});
it('should return DOWN when fromRange is at the bottom edge of originRange', () => {
const originRange = {
startRow: 1,
endRow: 4,
startColumn: 2,
endColumn: 4,
};
const fromRange = {
startRow: 2,
endRow: 5,
startColumn: 2,
endColumn: 4,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.DOWN);
});
it('should return LEFT when fromRange is at the left edge of originRange', () => {
const originRange = {
startRow: 2,
endRow: 5,
startColumn: 2,
endColumn: 6,
};
const fromRange = {
startRow: 2,
endRow: 5,
startColumn: 2,
endColumn: 5,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.LEFT);
});
it('should return RIGHT when fromRange is at the right edge of originRange', () => {
const originRange = {
startRow: 2,
endRow: 5,
startColumn: 1,
endColumn: 4,
};
const fromRange = {
startRow: 2,
endRow: 5,
startColumn: 2,
endColumn: 4,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.RIGHT);
});
it('should return null when fromRange is outside originRange', () => {
const originRange = {
startRow: 6,
endRow: 8,
startColumn: 6,
endColumn: 8,
};
const fromRange = {
startRow: 2,
endRow: 5,
startColumn: 2,
endColumn: 5,
};
expect(checkMoveEdge(originRange, fromRange)).toBeNull();
});
it('should handle NaN values correctly when originRange is an entire row', () => {
const originRange = {
startRow: 2,
endRow: 3,
startColumn: Number.NaN,
endColumn: Number.NaN,
rangeType: RANGE_TYPE.ROW,
};
const fromRange = {
startRow: 2,
endRow: 2,
startColumn: 0,
endColumn: 10,
};
expect(checkMoveEdge(originRange, fromRange)).toBeNull();
const originRange2 = {
startRow: 2,
endRow: 3,
startColumn: Number.NaN,
endColumn: Number.NaN,
rangeType: RANGE_TYPE.ROW,
};
const fromRange2 = {
startRow: 2,
endRow: 2,
startColumn: 0,
endColumn: 10,
rangeType: RANGE_TYPE.ROW,
};
expect(checkMoveEdge(originRange2, fromRange2)).toBe(OriginRangeEdgeType.UP);
});
it('should handle NaN values correctly when originRange is an entire column', () => {
const originRange = {
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: 3,
endColumn: 4,
rangeType: RANGE_TYPE.COLUMN,
};
const fromRange = {
startRow: 0,
endRow: 10,
startColumn: 3,
endColumn: 3,
};
expect(checkMoveEdge(originRange, fromRange)).toBeNull();
const originRange2 = {
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: 3,
endColumn: 4,
rangeType: RANGE_TYPE.COLUMN,
};
const fromRange2 = {
startRow: 0,
endRow: 10,
startColumn: 3,
endColumn: 3,
rangeType: RANGE_TYPE.COLUMN,
};
expect(checkMoveEdge(originRange2, fromRange2)).toBe(OriginRangeEdgeType.LEFT);
});
it('should handle NaN values correctly when originRange is the entire sheet', () => {
const originRange = {
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: Number.NaN,
endColumn: Number.NaN,
rangeType: RANGE_TYPE.ALL,
};
const fromRange = {
startRow: 0,
endRow: 100,
startColumn: 0,
endColumn: 100,
};
expect(checkMoveEdge(originRange, fromRange)).toBeNull();
const originRange2 = {
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: Number.NaN,
endColumn: Number.NaN,
rangeType: RANGE_TYPE.ALL,
};
const fromRange2 = {
startRow: 0,
endRow: 100,
startColumn: 0,
endColumn: 100,
rangeType: RANGE_TYPE.ALL,
};
expect(checkMoveEdge(originRange2, fromRange2)).toBe(OriginRangeEdgeType.ALL);
});
it('should return UP when fromRange is at the top edge of originRange (an entire row)', () => {
const originRange = {
startRow: 2,
endRow: 5,
startColumn: 1,
endColumn: 10,
};
const fromRange = {
startRow: 2,
endRow: 4,
startColumn: Number.NaN,
endColumn: Number.NaN,
rangeType: RANGE_TYPE.ROW,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.UP);
});
it('should return DOWN when fromRange is at the bottom edge of originRange (an entire row)', () => {
const originRange = {
startRow: 2,
endRow: 5,
startColumn: 1,
endColumn: 10,
};
const fromRange = {
startRow: 3,
endRow: 5,
startColumn: Number.NaN,
endColumn: Number.NaN,
rangeType: RANGE_TYPE.ROW,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.DOWN);
});
it('should return LEFT when fromRange is at the left edge of originRange (an entire column)', () => {
const originRange = {
startRow: 1,
endRow: 10,
startColumn: 3,
endColumn: 5,
};
const fromRange = {
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: 3,
endColumn: 4,
rangeType: RANGE_TYPE.COLUMN,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.LEFT);
});
it('should return RIGHT when fromRange is at the right edge of originRange (an entire column)', () => {
const originRange = {
startRow: 1,
endRow: 10,
startColumn: 2,
endColumn: 4,
};
const fromRange = {
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: 3,
endColumn: 4,
rangeType: RANGE_TYPE.COLUMN,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.RIGHT);
});
it('should handle when fromRange is entirely unbounded (entire sheet)', () => {
const originRange = {
startRow: 0,
endRow: 100,
startColumn: 0,
endColumn: 100,
};
const fromRange = {
startRow: Number.NaN,
endRow: Number.NaN,
startColumn: Number.NaN,
endColumn: Number.NaN,
rangeType: RANGE_TYPE.ALL,
};
expect(checkMoveEdge(originRange, fromRange)).toBe(OriginRangeEdgeType.ALL);
});
});

View File

@@ -0,0 +1,342 @@
/**
* 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 type { IWorkbookData, Workbook } from '@univerjs/core';
import { LocaleType, RANGE_TYPE } from '@univerjs/core';
import {
DeleteRangeMoveLeftCommand,
DeleteRangeMoveUpCommand,
InsertColCommand,
InsertRangeMoveDownCommand,
InsertRangeMoveRightCommand,
InsertRowCommand,
MoveColsCommand,
MoveRangeCommand,
MoveRowsCommand,
RemoveColCommand,
RemoveDefinedNameCommand,
RemoveRowCommand,
RemoveSheetCommand,
SetDefinedNameCommand,
SetWorksheetNameCommand,
} from '@univerjs/sheets';
import { afterEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from '../../../facade/__tests__/create-test-bed';
import { FormulaReferenceMoveType } from '../ref-range-formula';
import { getReferenceMoveParams } from '../ref-range-param';
function createWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
name: 'test',
sheetOrder: ['sheet1'],
locale: LocaleType.EN_US,
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 12,
columnCount: 8,
cellData: {},
},
},
styles: {},
};
}
describe('getReferenceMoveParams', () => {
afterEach(() => {
// disposal is handled per test to keep workbook state isolated
});
it('returns null for unknown commands or missing params', () => {
const testBed = createFacadeTestBed(createWorkbookData());
const workbook = testBed.sheet as Workbook;
expect(getReferenceMoveParams(workbook, { id: 'unknown.command' })).toBeNull();
expect(getReferenceMoveParams(workbook, { id: MoveRangeCommand.id })).toBeNull();
expect(getReferenceMoveParams(workbook, { id: MoveRowsCommand.id })).toBeNull();
testBed.univer.dispose();
});
it('builds move params for range, row, and column operations from the active sheet', () => {
const testBed = createFacadeTestBed(createWorkbookData());
const workbook = testBed.sheet as Workbook;
expect(getReferenceMoveParams(workbook, {
id: MoveRangeCommand.id,
params: {
fromRange: { startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 },
toRange: { startRow: 4, endRow: 5, startColumn: 3, endColumn: 4 },
},
})).toEqual({
type: FormulaReferenceMoveType.MoveRange,
unitId: 'test',
sheetId: 'sheet1',
sheetName: 'Sheet1',
targetUnitId: 'test',
targetSheetId: 'sheet1',
targetSheetName: 'Sheet1',
from: { startRow: 1, endRow: 2, startColumn: 1, endColumn: 2 },
to: { startRow: 4, endRow: 5, startColumn: 3, endColumn: 4 },
});
expect(getReferenceMoveParams(workbook, {
id: MoveRowsCommand.id,
params: {
fromRange: { startRow: 2, endRow: 4, startColumn: 0, endColumn: 0 },
toRange: { startRow: 6, endRow: 8, startColumn: 0, endColumn: 0 },
},
})).toEqual({
type: FormulaReferenceMoveType.MoveRows,
unitId: 'test',
sheetId: 'sheet1',
from: {
startRow: 2,
endRow: 4,
startColumn: 0,
endColumn: 7,
rangeType: RANGE_TYPE.ROW,
},
to: {
startRow: 6,
endRow: 8,
startColumn: 0,
endColumn: 7,
rangeType: RANGE_TYPE.ROW,
},
});
expect(getReferenceMoveParams(workbook, {
id: MoveColsCommand.id,
params: {
fromRange: { startRow: 0, endRow: 0, startColumn: 1, endColumn: 2 },
toRange: { startRow: 0, endRow: 0, startColumn: 4, endColumn: 5 },
},
})).toEqual({
type: FormulaReferenceMoveType.MoveCols,
unitId: 'test',
sheetId: 'sheet1',
from: {
startRow: 0,
endRow: 11,
startColumn: 1,
endColumn: 2,
rangeType: RANGE_TYPE.COLUMN,
},
to: {
startRow: 0,
endRow: 11,
startColumn: 4,
endColumn: 5,
rangeType: RANGE_TYPE.COLUMN,
},
});
testBed.univer.dispose();
});
it('builds insert, remove, and delete params with the expected sheet identity', () => {
const testBed = createFacadeTestBed(createWorkbookData());
const workbook = testBed.sheet as Workbook;
const range = { startRow: 3, endRow: 4, startColumn: 2, endColumn: 3 };
expect(getReferenceMoveParams(workbook, {
id: InsertRowCommand.id,
params: { range, unitId: 'test', subUnitId: 'sheet1' },
})).toEqual({
type: FormulaReferenceMoveType.InsertRow,
unitId: 'test',
sheetId: 'sheet1',
range,
});
expect(getReferenceMoveParams(workbook, {
id: InsertColCommand.id,
params: { range, unitId: 'test', subUnitId: 'sheet1' },
})).toEqual({
type: FormulaReferenceMoveType.InsertColumn,
unitId: 'test',
sheetId: 'sheet1',
range,
});
expect(getReferenceMoveParams(workbook, {
id: InsertRangeMoveRightCommand.id,
params: { range },
})).toEqual({
type: FormulaReferenceMoveType.InsertMoveRight,
unitId: 'test',
sheetId: 'sheet1',
range,
});
expect(getReferenceMoveParams(workbook, {
id: InsertRangeMoveDownCommand.id,
params: { range },
})).toEqual({
type: FormulaReferenceMoveType.InsertMoveDown,
unitId: 'test',
sheetId: 'sheet1',
range,
});
expect(getReferenceMoveParams(workbook, {
id: RemoveRowCommand.id,
params: { range, unitId: 'test', subUnitId: 'sheet1' },
})).toEqual({
type: FormulaReferenceMoveType.RemoveRow,
unitId: 'test',
sheetId: 'sheet1',
range,
rangeFilteredRows: [],
});
expect(getReferenceMoveParams(workbook, {
id: RemoveColCommand.id,
params: { range, unitId: 'test', subUnitId: 'sheet1' },
})).toEqual({
type: FormulaReferenceMoveType.RemoveColumn,
unitId: 'test',
sheetId: 'sheet1',
range,
});
expect(getReferenceMoveParams(workbook, {
id: DeleteRangeMoveUpCommand.id,
params: { range },
})).toEqual({
type: FormulaReferenceMoveType.DeleteMoveUp,
unitId: 'test',
sheetId: 'sheet1',
range,
});
expect(getReferenceMoveParams(workbook, {
id: DeleteRangeMoveLeftCommand.id,
params: { range },
})).toEqual({
type: FormulaReferenceMoveType.DeleteMoveLeft,
unitId: 'test',
sheetId: 'sheet1',
range,
});
testBed.univer.dispose();
});
it('builds sheet rename, sheet removal, and defined-name params with explicit ids', () => {
const testBed = createFacadeTestBed(createWorkbookData());
const workbook = testBed.sheet as Workbook;
expect(getReferenceMoveParams(workbook, {
id: SetWorksheetNameCommand.id,
params: {
unitId: 'other-unit',
subUnitId: 'other-sheet',
name: 'Renamed Sheet',
},
})).toEqual({
type: FormulaReferenceMoveType.SetName,
unitId: 'other-unit',
sheetId: 'other-sheet',
sheetName: 'Renamed Sheet',
});
expect(getReferenceMoveParams(workbook, {
id: RemoveSheetCommand.id,
params: {
unitId: 'other-unit',
subUnitId: 'other-sheet',
},
})).toEqual({
type: FormulaReferenceMoveType.RemoveSheet,
unitId: 'other-unit',
sheetId: 'other-sheet',
});
expect(getReferenceMoveParams(workbook, {
id: SetDefinedNameCommand.id,
params: {
unitId: 'test',
id: 'defined-name-id',
name: 'Revenue',
},
})).toEqual({
type: FormulaReferenceMoveType.SetDefinedName,
unitId: 'test',
sheetId: 'sheet1',
definedName: 'Revenue',
definedNameId: 'defined-name-id',
});
expect(getReferenceMoveParams(workbook, {
id: RemoveDefinedNameCommand.id,
params: {
unitId: 'test',
id: 'defined-name-id',
name: 'Revenue',
},
})).toEqual({
type: FormulaReferenceMoveType.RemoveDefinedName,
unitId: 'test',
sheetId: 'sheet1',
definedName: 'Revenue',
definedNameId: 'defined-name-id',
});
expect(getReferenceMoveParams(workbook, {
id: 'sheet.command.set-table-config',
params: {
unitId: 'test',
tableId: 'table-id',
name: 'Sales',
oldTableName: 'Orders',
},
})).toEqual({
type: FormulaReferenceMoveType.SetSuperTableName,
unitId: 'test',
sheetId: 'sheet1',
tableName: 'Sales',
oldTableName: 'Orders',
});
expect(getReferenceMoveParams(workbook, {
id: 'sheet.command.delete-table',
params: {
unitId: 'test',
tableId: 'table-id',
tableName: 'Orders',
},
})).toEqual({
type: FormulaReferenceMoveType.RemoveSuperTableName,
unitId: 'test',
sheetId: 'sheet1',
oldTableName: 'Orders',
});
expect(getReferenceMoveParams(workbook, {
id: 'sheet.command.table-remove-column-at',
params: {
unitId: 'test',
subUnitId: 'sheet1',
tableId: 'table-id',
range: { startRow: 0, endRow: 11, startColumn: 1, endColumn: 1 },
tableName: 'Orders',
removedColumnNames: ['1'],
},
})).toEqual({
type: FormulaReferenceMoveType.RemoveSuperTableColumn,
unitId: 'test',
sheetId: 'sheet1',
range: { startRow: 0, endRow: 11, startColumn: 1, endColumn: 1 },
oldTableName: 'Orders',
tableColumnNames: ['1'],
});
testBed.univer.dispose();
});
});

View File

@@ -0,0 +1,52 @@
/**
* 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 type { IRange } from '@univerjs/core';
import type { IArrayFormulaRangeType, IArrayFormulaUnitCellType, IFormulaData } from '@univerjs/engine-formula';
export interface IRefRangeWithPosition {
row: number;
column: number;
range: IRange;
}
export function checkFormulaDataNull(formulaData: IFormulaData, unitId: string, sheetId: string) {
if (formulaData == null || formulaData[unitId] == null || formulaData[unitId]?.[sheetId] == null) {
return true;
}
return false;
}
export function removeFormulaData(formulaData: IFormulaData | IArrayFormulaRangeType | IArrayFormulaUnitCellType, unitId: string, sheetId?: string) {
if (sheetId) {
if (formulaData && formulaData[unitId] && formulaData[unitId]?.[sheetId]) {
delete formulaData[unitId]![sheetId];
return {
[unitId]: {
[sheetId]: null,
},
};
}
} else {
if (formulaData && formulaData[unitId]) {
delete formulaData[unitId];
return {
[unitId]: null,
};
}
}
}

View File

@@ -0,0 +1,759 @@
/**
* 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 type { ICellData, IMutationInfo, IObjectMatrixPrimitiveType, IRange, Nullable } from '@univerjs/core';
import type { IFormulaData, IFormulaDataItem, IRangeChange, ISequenceNode } from '@univerjs/engine-formula';
import type { ISetRangeValuesMutationParams } from '@univerjs/sheets';
import { cellToRange, Direction, isFormulaId, isFormulaString, ObjectMatrix, Rectangle, Tools } from '@univerjs/core';
import { deserializeRangeWithSheetWithCache, sequenceNodeType, serializeRangeToRefString } from '@univerjs/engine-formula';
import { EffectRefRangId, handleDeleteRangeMoveLeft, handleDeleteRangeMoveUp, handleInsertCol, handleInsertRangeMoveDown, handleInsertRangeMoveRight, handleInsertRow, handleIRemoveCol, handleIRemoveRow, handleMoveCols, handleMoveRange, handleMoveRows, runRefRangeMutations, SetRangeValuesMutation } from '@univerjs/sheets';
import { checkFormulaDataNull } from './offset-formula-data';
export enum FormulaReferenceMoveType {
MoveRange, // range
MoveRows, // move rows
MoveCols, // move columns
InsertRow, // row
InsertColumn, // column
RemoveRow, // row
RemoveColumn, // column
DeleteMoveLeft, // range
DeleteMoveUp, // range
InsertMoveDown, // range
InsertMoveRight, // range
SetName,
SetUnitName,
RemoveSheet,
SetDefinedName, // update defined name
RemoveDefinedName, // remove defined name
SetSuperTableName, // update super table name
RemoveSuperTableName, // remove super table name
RemoveSuperTableColumn, // remove super table column
}
export interface IFormulaReferenceMoveParam {
type: FormulaReferenceMoveType;
unitId: string;
sheetId: string;
targetUnitId?: string;
targetSheetId?: string;
targetSheetName?: string;
range?: IRange;
from?: IRange;
to?: IRange;
sheetName?: string;
oldUnitName?: string;
unitName?: string;
/**
* defined name id
*/
definedNameId?: string;
/**
* new defined name
*/
definedName?: string;
oldTableName?: string;
tableName?: string;
tableColumnNames?: string[];
/**
* The filtered rows contained in the range, used for remove rows operation, etc.
*/
rangeFilteredRows?: number[];
}
const formulaReferenceSheetList = [
FormulaReferenceMoveType.SetName,
FormulaReferenceMoveType.SetUnitName,
FormulaReferenceMoveType.RemoveSheet,
FormulaReferenceMoveType.SetDefinedName,
FormulaReferenceMoveType.RemoveDefinedName,
FormulaReferenceMoveType.SetSuperTableName,
FormulaReferenceMoveType.RemoveSuperTableName,
];
export function getFormulaReferenceMoveUndoRedo(oldFormulaData: IFormulaData, newFormulaData: IFormulaData, formulaReferenceMoveParam: IFormulaReferenceMoveParam) {
const { type } = formulaReferenceMoveParam;
if (formulaReferenceSheetList.includes(type) || (type === FormulaReferenceMoveType.RemoveSuperTableColumn && formulaReferenceMoveParam.range == null)) {
return getFormulaReferenceSheet(oldFormulaData, newFormulaData);
} else {
return getFormulaReferenceRange(oldFormulaData, newFormulaData, formulaReferenceMoveParam);
}
}
export function getFormulaReferenceSheet(oldFormulaData: IFormulaData, newFormulaData: IFormulaData) {
const undos: IMutationInfo[] = [];
const redos: IMutationInfo[] = [];
Object.keys(newFormulaData).forEach((unitId) => {
const newSheetData = newFormulaData[unitId];
const oldSheetData = oldFormulaData[unitId];
if (newSheetData == null) {
return true;
}
if (oldSheetData == null) {
return true;
}
Object.keys(newSheetData).forEach((subUnitId) => {
const newSheetFormula = new ObjectMatrix(newSheetData[subUnitId] || {});
const oldSheetFormula = new ObjectMatrix(oldSheetData[subUnitId] || {});
const redoFormulaMatrix = new ObjectMatrix<Nullable<ICellData>>();
const undoFormulaMatrix = new ObjectMatrix<Nullable<ICellData>>();
newSheetFormula.forValue((r, c, cell) => {
if (cell == null) {
return true;
}
const newValue = formulaDataItemToCellData(cell);
if (newValue === null) {
return;
}
redoFormulaMatrix.setValue(r, c, newValue);
undoFormulaMatrix.setValue(r, c, oldSheetFormula.getValue(r, c));
});
if (redoFormulaMatrix.getSizeOf() === 0) {
return;
}
const redoSetRangeValuesMutationParams: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
cellValue: redoFormulaMatrix.getMatrix(),
};
const redoMutation = {
id: SetRangeValuesMutation.id,
params: redoSetRangeValuesMutationParams,
};
redos.push(redoMutation);
const undoSetRangeValuesMutationParams: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
cellValue: undoFormulaMatrix.getMatrix(),
};
const undoMutation = {
id: SetRangeValuesMutation.id,
params: undoSetRangeValuesMutationParams,
};
undos.push(undoMutation);
});
});
return {
undos,
redos,
};
}
export function getFormulaReferenceRange(oldFormulaData: IFormulaData, newFormulaData: IFormulaData, formulaReferenceMoveParam: IFormulaReferenceMoveParam) {
const { redoFormulaData, undoFormulaData } = refRangeFormula(oldFormulaData, newFormulaData, formulaReferenceMoveParam);
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
Object.keys(redoFormulaData).forEach((unitId) => {
Object.keys(redoFormulaData[unitId]).forEach((subUnitId) => {
if (Object.keys(redoFormulaData[unitId][subUnitId]).length !== 0) {
const redoSetRangeValuesMutationParams: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
cellValue: redoFormulaData[unitId][subUnitId],
};
const redoMutation = {
id: SetRangeValuesMutation.id,
params: redoSetRangeValuesMutationParams,
};
redos.push(redoMutation);
}
});
});
Object.keys(undoFormulaData).forEach((unitId) => {
Object.keys(undoFormulaData[unitId]).forEach((subUnitId) => {
if (Object.keys(undoFormulaData[unitId][subUnitId]).length !== 0) {
const undoSetRangeValuesMutationParams: ISetRangeValuesMutationParams = {
subUnitId,
unitId,
cellValue: undoFormulaData[unitId][subUnitId],
};
const undoMutation = {
id: SetRangeValuesMutation.id,
params: undoSetRangeValuesMutationParams,
};
undos.push(undoMutation);
}
});
});
return {
undos,
redos,
};
}
/**
* For different Command operations, it may be necessary to perform traversal in reverse or in forward order, so first determine the type of Command and then perform traversal.
* @param oldFormulaData
* @param newFormulaData
* @param formulaReferenceMoveParam
* @returns
*/
export function refRangeFormula(oldFormulaData: IFormulaData, newFormulaData: IFormulaData, formulaReferenceMoveParam: IFormulaReferenceMoveParam) {
const redoFormulaData: Record<string, Record<string, IObjectMatrixPrimitiveType<Nullable<ICellData>>>> = {};
const undoFormulaData: Record<string, Record<string, IObjectMatrixPrimitiveType<Nullable<ICellData>>>> = {};
const { unitId: fromUnitId, sheetId: fromSheetId } = formulaReferenceMoveParam;
const targetUnitId = formulaReferenceMoveParam.targetUnitId ?? fromUnitId;
const targetSheetId = formulaReferenceMoveParam.targetSheetId ?? fromSheetId;
const isCrossSheet = fromUnitId !== targetUnitId || fromSheetId !== targetSheetId;
// Iterate over all unitId in oldFormulaData
const allUnitIds = new Set([...Object.keys(oldFormulaData), ...Object.keys(newFormulaData)]);
allUnitIds.forEach((unitId) => {
if (checkFormulaDataNull(oldFormulaData, unitId, fromSheetId)) {
return;
}
const allSheetIds = new Set([
...Object.keys(oldFormulaData[unitId] || {}),
...Object.keys(newFormulaData[unitId] || {}),
]);
allSheetIds.forEach((currentSheetId) => {
const currentOldFormulaData = oldFormulaData[unitId]?.[currentSheetId];
const currentNewFormulaData = newFormulaData[unitId]?.[currentSheetId];
const oldFormulaMatrix = new ObjectMatrix(currentOldFormulaData || {});
const newFormulaMatrix = new ObjectMatrix(currentNewFormulaData || {});
let rangeList: IRangeChange[] = [];
// If the sheet where the range is changed is different from the current sheet, the position will not be changed.
// Simply get the data from newFormulaMatrix to update the range.
if (isCrossSheet || unitId !== fromUnitId || currentSheetId !== fromSheetId) {
rangeList = processFormulaRange(newFormulaMatrix);
} else {
rangeList = processFormulaChanges(oldFormulaMatrix, newFormulaMatrix, formulaReferenceMoveParam);
}
const sheetRedoFormulaData = getRedoFormulaData(rangeList, oldFormulaMatrix, newFormulaMatrix);
const sheetUndoFormulaData = getUndoFormulaData(rangeList, oldFormulaMatrix);
if (!redoFormulaData[unitId]) {
redoFormulaData[unitId] = {};
}
if (!undoFormulaData[unitId]) {
undoFormulaData[unitId] = {};
}
redoFormulaData[unitId][currentSheetId] = {
...redoFormulaData[unitId][currentSheetId],
...sheetRedoFormulaData,
};
undoFormulaData[unitId][currentSheetId] = {
...undoFormulaData[unitId][currentSheetId],
...sheetUndoFormulaData,
};
});
});
return {
redoFormulaData,
undoFormulaData,
};
}
function processFormulaChanges(oldFormulaMatrix: ObjectMatrix<Nullable<IFormulaDataItem>>, newFormulaMatrix: ObjectMatrix<Nullable<IFormulaDataItem>>, formulaReferenceMoveParam: IFormulaReferenceMoveParam) {
// When undoing and redoing, the traversal order may be different. Record the range list of all single formula offsets, and then retrieve the traversal as needed.
const { type, from, to, range } = formulaReferenceMoveParam;
const rangeList: IRangeChange[] = [];
oldFormulaMatrix.forValue((row, column, cell) => {
// Offset is only needed when there is a formula
if (cell == null || !isFormulaDataItem(cell)) return true;
const oldCell = cellToRange(row, column);
let newCell = null;
let isReverse = false;
// Handle moves
if ([FormulaReferenceMoveType.MoveRange, FormulaReferenceMoveType.MoveRows, FormulaReferenceMoveType.MoveCols].includes(type)) {
newCell = handleMove(type, from, to, oldCell);
} else if (range !== undefined && range !== null) { // Handle inserts and deletes
const result = handleInsertDelete(oldCell, formulaReferenceMoveParam);
// When removing a cell containing a formula, newCell is null, but the formula value of oldCell is required when undoing it, newCell can be null
newCell = result.newCell;
isReverse = result.isReverse;
}
// If oldCell and newCell are the same and this cell no formula updated, it means that the formula is not offset, skip it directly
if (Tools.diffValue(oldCell, newCell) && !newFormulaMatrix.getValue(row, column)) {
return true;
}
// Don't intercept newCell null here
// Note: The formula may only update the reference and not offset the position. The situation where the position is not shifted cannot be intercepted here.
isReverse ? rangeList.unshift({ oldCell, newCell }) : rangeList.push({ oldCell, newCell });
});
return rangeList;
}
function processFormulaRange(newFormulaMatrix: ObjectMatrix<Nullable<IFormulaDataItem>>) {
const rangeList: IRangeChange[] = [];
newFormulaMatrix.forValue((row, column, cell) => {
if (cell == null || !isFormulaDataItem(cell)) return true;
const newCell = cellToRange(row, column);
rangeList.push({ oldCell: newCell, newCell });
});
return rangeList;
}
function handleMove(type: FormulaReferenceMoveType, from: Nullable<IRange>, to: Nullable<IRange>, oldCell: IRange) {
if (from == null || to == null) {
return null;
}
switch (type) {
case FormulaReferenceMoveType.MoveRange:
return handleRefMoveRange(from, to, oldCell);
case FormulaReferenceMoveType.MoveRows:
return handleRefMoveRows(from, to, oldCell);
case FormulaReferenceMoveType.MoveCols:
return handleRefMoveCols(from, to, oldCell);
default:
return null;
}
}
function handleInsertDelete(oldCell: IRange, formulaReferenceMoveParam: IFormulaReferenceMoveParam) {
const { type, rangeFilteredRows } = formulaReferenceMoveParam;
const range = formulaReferenceMoveParam.range as IRange;
let newCell: IRange | null = null;
let isReverse = false;
switch (type) {
case FormulaReferenceMoveType.InsertRow:
newCell = handleRefInsertRow(range, oldCell);
isReverse = true;
break;
case FormulaReferenceMoveType.InsertColumn:
newCell = handleRefInsertCol(range, oldCell);
isReverse = true;
break;
case FormulaReferenceMoveType.RemoveRow:
newCell = handleRefRemoveRow(range, oldCell, rangeFilteredRows);
break;
case FormulaReferenceMoveType.RemoveColumn:
case FormulaReferenceMoveType.RemoveSuperTableColumn:
newCell = handleRefRemoveCol(range, oldCell);
break;
case FormulaReferenceMoveType.DeleteMoveLeft:
newCell = handleRefDeleteMoveLeft(range, oldCell);
break;
case FormulaReferenceMoveType.DeleteMoveUp:
newCell = handleRefDeleteMoveUp(range, oldCell);
break;
case FormulaReferenceMoveType.InsertMoveDown:
newCell = handleRefInsertMoveDown(range, oldCell);
isReverse = true;
break;
case FormulaReferenceMoveType.InsertMoveRight:
newCell = handleRefInsertMoveRight(range, oldCell);
isReverse = true;
break;
default:
break;
}
return { newCell, isReverse };
}
function handleRefMoveRange(from: IRange, to: IRange, oldCell: IRange) {
const operators = handleMoveRange(
{
id: EffectRefRangId.MoveRangeCommandId,
params: { toRange: to, fromRange: from },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefMoveRows(from: IRange, to: IRange, oldCell: IRange) {
const operators = handleMoveRows(
{
id: EffectRefRangId.MoveRowsCommandId,
params: { toRange: to, fromRange: from },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefMoveCols(from: IRange, to: IRange, oldCell: IRange) {
const operators = handleMoveCols(
{
id: EffectRefRangId.MoveColsCommandId,
params: { toRange: to, fromRange: from },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefInsertRow(range: IRange, oldCell: IRange) {
const operators = handleInsertRow(
{
id: EffectRefRangId.InsertRowCommandId,
params: { range, unitId: '', subUnitId: '', direction: Direction.DOWN },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefInsertCol(range: IRange, oldCell: IRange) {
const operators = handleInsertCol(
{
id: EffectRefRangId.InsertColCommandId,
params: { range, unitId: '', subUnitId: '', direction: Direction.RIGHT },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefRemoveRow(range: IRange, oldCell: IRange, rangeFilteredRows?: number[]) {
const operators = handleIRemoveRow(
{
id: EffectRefRangId.RemoveRowCommandId,
params: { range },
},
oldCell,
rangeFilteredRows
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefRemoveCol(range: IRange, oldCell: IRange) {
const operators = handleIRemoveCol(
{
id: EffectRefRangId.RemoveColCommandId,
params: { range },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefDeleteMoveLeft(range: IRange, oldCell: IRange) {
const operators = handleDeleteRangeMoveLeft(
{
id: EffectRefRangId.DeleteRangeMoveLeftCommandId,
params: { range },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefDeleteMoveUp(range: IRange, oldCell: IRange) {
const operators = handleDeleteRangeMoveUp(
{
id: EffectRefRangId.DeleteRangeMoveUpCommandId,
params: { range },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefInsertMoveDown(range: IRange, oldCell: IRange) {
const operators = handleInsertRangeMoveDown(
{
id: EffectRefRangId.InsertRangeMoveDownCommandId,
params: { range },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
function handleRefInsertMoveRight(range: IRange, oldCell: IRange) {
const operators = handleInsertRangeMoveRight(
{
id: EffectRefRangId.InsertRangeMoveRightCommandId,
params: { range },
},
oldCell
);
return runRefRangeMutations(operators, oldCell);
}
/**
* Delete the old value at the old position on the match, and add the new value at the new position (the new value first checks whether the old position has offset content, if so, use the new offset content, if not, take the old value)
* @param rangeList
* @param oldFormulaData
* @param newFormulaData
*/
function getRedoFormulaData(rangeList: IRangeChange[], oldFormulaMatrix: ObjectMatrix<Nullable<IFormulaDataItem>>, newFormulaMatrix: ObjectMatrix<Nullable<IFormulaDataItem>>) {
const redoFormulaData = new ObjectMatrix<Nullable<ICellData>>({});
for (let i = 0; i < rangeList.length; i++) {
const { oldCell, newCell } = rangeList[i];
// Clear the old position formula data, if the position of redoFormulaData has set a new formula, no additional processing is required
if (!(redoFormulaData.getValue(oldCell.startRow, oldCell.startColumn)?.f || redoFormulaData.getValue(oldCell.startRow, oldCell.startColumn)?.si)) {
redoFormulaData.setValue(oldCell.startRow, oldCell.startColumn, { f: null, si: null });
}
if (newCell) {
// Save the new position formula data to redoFormulaData
// newFormulaMatrix.getValue(oldCell.startRow, oldCell.startColumn) may be null, it means that the formula is not offset, so use the old value
const newFormula = newFormulaMatrix.getValue(oldCell.startRow, oldCell.startColumn) ?? oldFormulaMatrix.getValue(oldCell.startRow, oldCell.startColumn);
const newValue = formulaDataItemToCellData(newFormula);
redoFormulaData.setValue(newCell.startRow, newCell.startColumn, newValue);
}
}
return redoFormulaData.getMatrix();
}
/**
* The old position on the match saves the old value, and the new position delete value(for formulaData)
* @param rangeList
* @param oldFormulaData
* @param newFormulaData
*/
function getUndoFormulaData(rangeList: IRangeChange[], oldFormulaMatrix: ObjectMatrix<Nullable<IFormulaDataItem>>) {
const undoFormulaData = new ObjectMatrix<Nullable<ICellData>>({});
for (let i = rangeList.length - 1; i >= 0; i--) {
const { oldCell, newCell } = rangeList[i];
// Save old position old formula data to undoFormulaData
const oldCellOldFormula = oldFormulaMatrix.getValue(oldCell.startRow, oldCell.startColumn);
const oldCellOldValue = formulaDataItemToCellData(oldCellOldFormula);
undoFormulaData.setValue(oldCell.startRow, oldCell.startColumn, oldCellOldValue);
if (newCell) {
// If the newCell is not null, save new position old formula data to undoFormulaData.
// If the new position old formula data is null, clear the new position formula data
const newCellOldFormula = oldFormulaMatrix.getValue(newCell.startRow, newCell.startColumn);
const newCellOldValue = formulaDataItemToCellData(newCellOldFormula);
undoFormulaData.setValue(newCell.startRow, newCell.startColumn, newCellOldValue ?? { f: null, si: null });
}
}
return undoFormulaData.getMatrix();
}
/**
* Transfer the formulaDataItem to the cellData
* ┌────────────────────────────────┬─────────────────┐
* │ IFormulaDataItem │ ICellData │
* ├──────────────────┬─────┬───┬───┼───────────┬─────┤
* │ f │ si │ x │ y │ f │ si │
* ├──────────────────┼─────┼───┼───┼───────────┼─────┤
* │ =SUM(1) │ │ │ │ =SUM(1) │ │
* │ │ id1 │ │ │ │ id1 │
* │ =SUM(1) │ id1 │ │ │ =SUM(1) │ id1 │
* │ =SUM(1) │ id1 │ 0 │ 0 │ =SUM(1) │ id1 │
* │ =SUM(1) │ id1 │ 0 │ 1 │ │ id1 │
* └──────────────────┴─────┴───┴───┴───────────┴─────┘
*/
export function formulaDataItemToCellData(formulaDataItem: Nullable<IFormulaDataItem>): Nullable<ICellData> {
if (formulaDataItem === undefined) {
return;
}
if (formulaDataItem === null) {
// null presents clearing cell content
return {
f: null,
si: null,
};
}
const { f, si, x = 0, y = 0 } = formulaDataItem;
const checkFormulaString = isFormulaString(f);
const checkFormulaId = isFormulaId(si);
if (!checkFormulaString && !checkFormulaId) {
return {
f: null,
si: null,
};
}
const cellData: ICellData = {};
if (checkFormulaId) {
cellData.si = si;
}
if (checkFormulaString && x === 0 && y === 0) {
cellData.f = f;
}
if (cellData.f === undefined) {
cellData.f = null;
}
if (cellData.si === undefined) {
cellData.si = null;
}
return cellData;
}
/**
* Convert formulaData to cellData
* @param formulaData
* @returns
*/
export function formulaDataToCellData(formulaData: IObjectMatrixPrimitiveType<IFormulaDataItem | null>, changedCellValue?: IObjectMatrixPrimitiveType<Nullable<ICellData>>): IObjectMatrixPrimitiveType<Nullable<ICellData>> {
const cellData = new ObjectMatrix<Nullable<ICellData>>({});
const formulaDataMatrix = new ObjectMatrix(formulaData);
formulaDataMatrix.forValue((r, c, formulaDataItem) => {
const cellDataItem = formulaDataItemToCellData(formulaDataItem);
// Originally matrix clone would filter out undefined, but after changing to getMatrix, you need to filter manually here
// Filter out meaningless undefined, but consider null meaningful because null represents clearing cell content
if (cellDataItem === undefined) {
return;
}
/**
* If the cell value has been changed and contains a formula, clear the current cell value and type to avoid the formula calculation could not be recalculated after it was interrupted in certain situations.
*/
if (changedCellValue && changedCellValue[r]?.[c] && (cellDataItem?.f || cellDataItem?.si)) {
cellDataItem.v = null;
cellDataItem.t = null;
}
cellData.setValue(r, c, cellDataItem);
});
return cellData.getMatrix();
}
export function isFormulaDataItem(cell: IFormulaDataItem) {
const formulaString = cell?.f || '';
const formulaId = cell?.si || '';
const checkFormulaString = isFormulaString(formulaString);
const checkFormulaId = isFormulaId(formulaId);
if (checkFormulaString || checkFormulaId) {
return true;
}
return false;
}
export function checkIsSameUnitAndSheet(
userUnitId: string,
userSheetId: string,
currentFormulaUnitId: string,
currentFormulaSheetId: string,
sequenceRangeUnitId: string,
sequenceRangeSheetId: string
) {
if (
(sequenceRangeUnitId == null || sequenceRangeUnitId.length === 0) &&
(sequenceRangeSheetId == null || sequenceRangeSheetId.length === 0)
) {
if (userUnitId === currentFormulaUnitId && userSheetId === currentFormulaSheetId) {
return true;
}
} else if (
(userUnitId === sequenceRangeUnitId || sequenceRangeUnitId == null || sequenceRangeUnitId.length === 0) &&
userSheetId === sequenceRangeSheetId
) {
return true;
}
return false;
}
export function updateRefOffset(
sequenceNodes: Array<string | ISequenceNode>,
refChangeIds: number[],
refOffsetX: number = 0,
refOffsetY: number = 0
) {
const newSequenceNodes: Array<string | ISequenceNode> = [];
for (let i = 0, len = sequenceNodes.length; i < len; i++) {
const node = sequenceNodes[i];
if (typeof node === 'string' || node.nodeType !== sequenceNodeType.REFERENCE || refChangeIds.includes(i)) {
newSequenceNodes.push(node);
continue;
}
const { token } = node;
const sequenceGrid = deserializeRangeWithSheetWithCache(token);
const { range, sheetName, unitId: sequenceUnitId } = sequenceGrid;
const newRange = Rectangle.moveOffset(range, refOffsetX, refOffsetY);
newSequenceNodes.push({
...node,
token: serializeRangeToRefString({
range: newRange,
unitId: sequenceUnitId,
sheetName,
}),
});
}
return newSequenceNodes;
}

View File

@@ -0,0 +1,682 @@
/**
* 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 type { IRange, IUnitRange, Nullable } from '@univerjs/core';
import type { IFormulaReferenceMoveParam } from './ref-range-formula';
import { Direction, getIntersectRange, RANGE_TYPE, Rectangle } from '@univerjs/core';
import { ErrorType, serializeRangeToRefString } from '@univerjs/engine-formula';
import {
EffectRefRangId,
handleDeleteRangeMoveLeft,
handleDeleteRangeMoveUp,
handleInsertCol,
handleInsertRangeMoveDown,
handleInsertRangeMoveRight,
handleInsertRow,
handleIRemoveCol,
handleIRemoveRow,
handleMoveCols,
handleMoveRange,
handleMoveRows,
runRefRangeMutations,
} from '@univerjs/sheets';
import { checkIsSameUnitAndSheet, FormulaReferenceMoveType } from './ref-range-formula';
export interface IUnitRangeWithOffset extends IUnitRange {
refOffsetX: number;
refOffsetY: number;
sheetName: string;
}
export enum OriginRangeEdgeType {
UP,
DOWN,
LEFT,
RIGHT,
ALL,
}
// eslint-disable-next-line max-lines-per-function, complexity
export function getNewRangeByMoveParam(
unitRangeWidthOffset: IUnitRangeWithOffset,
formulaReferenceMoveParam: IFormulaReferenceMoveParam,
currentFormulaUnitId: string,
currentFormulaSheetId: string,
options: {
preserveSheetQualifier?: boolean;
inCrossSheetCutRange?: boolean;
} = {}
) {
const {
type,
unitId: userUnitId,
sheetId: userSheetId,
targetUnitId,
targetSheetId,
targetSheetName,
range,
from,
to,
rangeFilteredRows,
} = formulaReferenceMoveParam;
const {
range: unitRange,
sheetId: sequenceRangeSheetId,
unitId: sequenceRangeUnitId,
sheetName: sequenceRangeSheetName,
refOffsetX,
refOffsetY,
} = unitRangeWidthOffset;
const { preserveSheetQualifier = false, inCrossSheetCutRange = false } = options;
if (
!checkIsSameUnitAndSheet(
userUnitId,
userSheetId,
currentFormulaUnitId,
currentFormulaSheetId,
sequenceRangeUnitId,
sequenceRangeSheetId
)
) {
return;
}
const sequenceRange = Rectangle.moveOffset(unitRange, refOffsetX, refOffsetY);
let newRange: Nullable<IRange> = null;
if (type === FormulaReferenceMoveType.MoveRange) {
if (from == null || to == null) {
return;
}
const moveEdge = checkMoveEdge(sequenceRange, from);
const remainRange = getIntersectRange(sequenceRange, from);
if (remainRange == null || moveEdge !== OriginRangeEdgeType.ALL) {
return;
}
const operators = handleMoveRange(
{ id: EffectRefRangId.MoveRangeCommandId, params: { toRange: to, fromRange: from } },
remainRange
);
const result = runRefRangeMutations(operators, remainRange);
if (result == null) {
return ErrorType.REF;
}
newRange = getMoveNewRange(moveEdge, result, from, to, sequenceRange, remainRange);
} else if (type === FormulaReferenceMoveType.MoveRows) {
if (from == null || to == null) {
return;
}
const moveEdge = checkMoveEdge(sequenceRange, from);
let remainRange = getIntersectRange(sequenceRange, from);
if (
remainRange == null &&
(
(from.endRow < sequenceRange.startRow && to.endRow <= sequenceRange.startRow) ||
(from.startRow > sequenceRange.endRow && to.startRow > sequenceRange.endRow)
)
) {
return;
}
if (remainRange == null) {
remainRange = {
startRow: sequenceRange.startRow,
endRow: sequenceRange.endRow,
startColumn: sequenceRange.startColumn,
endColumn: sequenceRange.endColumn,
rangeType: RANGE_TYPE.NORMAL,
};
}
const operators = handleMoveRows(
{ id: EffectRefRangId.MoveRowsCommandId, params: { toRange: to, fromRange: from } },
remainRange
);
const result = runRefRangeMutations(operators, remainRange);
if (result == null) {
return ErrorType.REF;
}
newRange = getMoveNewRange(moveEdge, result, from, to, sequenceRange, remainRange);
} else if (type === FormulaReferenceMoveType.MoveCols) {
if (from == null || to == null) {
return;
}
const moveEdge = checkMoveEdge(sequenceRange, from);
let remainRange = getIntersectRange(sequenceRange, from);
if (
remainRange == null &&
(
(from.endColumn < sequenceRange.startColumn && to.endColumn <= sequenceRange.startColumn) ||
(from.startColumn > sequenceRange.endColumn && to.startColumn > sequenceRange.endColumn)
)
) {
return;
}
if (remainRange == null) {
remainRange = {
startRow: sequenceRange.startRow,
endRow: sequenceRange.endRow,
startColumn: sequenceRange.startColumn,
endColumn: sequenceRange.endColumn,
rangeType: RANGE_TYPE.NORMAL,
};
}
const operators = handleMoveCols(
{ id: EffectRefRangId.MoveColsCommandId, params: { toRange: to, fromRange: from } },
remainRange
);
const result = runRefRangeMutations(operators, remainRange);
if (result == null) {
return ErrorType.REF;
}
newRange = getMoveNewRange(moveEdge, result, from, to, sequenceRange, remainRange);
}
if (range != null) {
if (type === FormulaReferenceMoveType.InsertRow) {
const operators = handleInsertRow(
{
id: EffectRefRangId.InsertRowCommandId,
params: { range, unitId: '', subUnitId: '', direction: Direction.DOWN },
},
sequenceRange
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return;
}
newRange = {
...sequenceRange,
...result,
};
} else if (type === FormulaReferenceMoveType.InsertColumn) {
const operators = handleInsertCol(
{
id: EffectRefRangId.InsertColCommandId,
params: { range, unitId: '', subUnitId: '', direction: Direction.RIGHT },
},
sequenceRange
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return;
}
newRange = {
...sequenceRange,
...result,
};
} else if (type === FormulaReferenceMoveType.RemoveRow) {
const operators = handleIRemoveRow(
{
id: EffectRefRangId.RemoveRowCommandId,
params: { range },
},
sequenceRange,
rangeFilteredRows
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return ErrorType.REF;
}
newRange = {
...sequenceRange,
...result,
};
} else if (type === FormulaReferenceMoveType.RemoveColumn) {
const operators = handleIRemoveCol(
{
id: EffectRefRangId.RemoveColCommandId,
params: { range },
},
sequenceRange
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return ErrorType.REF;
}
newRange = {
...sequenceRange,
...result,
};
} else if (type === FormulaReferenceMoveType.DeleteMoveLeft) {
const operators = handleDeleteRangeMoveLeft(
{
id: EffectRefRangId.DeleteRangeMoveLeftCommandId,
params: { range },
},
sequenceRange
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return ErrorType.REF;
}
newRange = {
...sequenceRange,
...result,
};
} else if (type === FormulaReferenceMoveType.DeleteMoveUp) {
const operators = handleDeleteRangeMoveUp(
{
id: EffectRefRangId.DeleteRangeMoveUpCommandId,
params: { range },
},
sequenceRange
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return ErrorType.REF;
}
newRange = {
...sequenceRange,
...result,
};
} else if (type === FormulaReferenceMoveType.InsertMoveDown) {
const operators = handleInsertRangeMoveDown(
{
id: EffectRefRangId.InsertRangeMoveDownCommandId,
params: { range },
},
sequenceRange
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return;
}
newRange = {
...sequenceRange,
...result,
};
} else if (type === FormulaReferenceMoveType.InsertMoveRight) {
const operators = handleInsertRangeMoveRight(
{
id: EffectRefRangId.InsertRangeMoveRightCommandId,
params: { range },
},
sequenceRange
);
const result = runRefRangeMutations(operators, sequenceRange);
if (result == null) {
return;
}
newRange = {
...sequenceRange,
...result,
};
}
}
if (newRange == null) {
return;
}
const shouldRewriteSheet = type === FormulaReferenceMoveType.MoveRange
&& !!targetSheetId
&& targetSheetId !== userSheetId
&& !inCrossSheetCutRange;
const rewrittenSheetId = shouldRewriteSheet ? targetSheetId : sequenceRangeSheetId;
const rewrittenSheetName = shouldRewriteSheet ? (targetSheetName || sequenceRangeSheetName) : sequenceRangeSheetName;
const rewrittenUnitId = shouldRewriteSheet ? (targetUnitId || sequenceRangeUnitId) : sequenceRangeUnitId;
// We need the destination sheet to decide whether a rewritten ref should stay local (`A1`)
// or become explicitly qualified (`Sheet2!A1`) after a move.
const isCurrentFormulaWorkbook = rewrittenUnitId == null
|| rewrittenUnitId.length === 0
|| rewrittenUnitId === currentFormulaUnitId;
const isCurrentFormulaSheet = rewrittenSheetId === currentFormulaSheetId;
return serializeRangeToRefString({
range: newRange,
sheetName: preserveSheetQualifier || !(isCurrentFormulaWorkbook && isCurrentFormulaSheet) ? rewrittenSheetName : '',
unitId: isCurrentFormulaWorkbook ? '' : rewrittenUnitId,
});
}
/**
* Calculate the new ref information for the moving selection.
* @param moveEdge the 'from' range lie on the edge of the original range, or does it completely cover the original range
* @param result The original range is divided by 'from' and moved to a new position range.
* @param from The initial range of the moving selection.
* @param to The result range after moving the initial range.
* @param origin The original target range.
* @param remain "The range subtracted from the initial range by 'from'.
* @returns
*/
// eslint-disable-next-line
function getMoveNewRange(
moveEdge: Nullable<OriginRangeEdgeType>,
result: IRange,
from: IRange,
to: IRange,
origin: IRange,
remain: IRange
) {
const { startRow, endRow, startColumn, endColumn, rangeType } = getStartEndValue(result);
const {
startRow: fromStartRow,
startColumn: fromStartColumn,
endRow: fromEndRow,
endColumn: fromEndColumn,
rangeType: fromRangeType = RANGE_TYPE.NORMAL,
} = getStartEndValue(from);
const { startRow: toStartRow, startColumn: toStartColumn, endRow: toEndRow, endColumn: toEndColumn } = getStartEndValue(to);
const {
startRow: remainStartRow,
endRow: remainEndRow,
startColumn: remainStartColumn,
endColumn: remainEndColumn,
} = getStartEndValue(remain);
const {
startRow: originStartRow,
endRow: originEndRow,
startColumn: originStartColumn,
endColumn: originEndColumn,
rangeType: originRangeType = RANGE_TYPE.NORMAL,
} = getStartEndValue(origin);
const newRange = { ...origin };
function rowsCover(): boolean {
if (rangeType === RANGE_TYPE.COLUMN && originRangeType === RANGE_TYPE.COLUMN) {
return true;
}
return startColumn >= originStartColumn && endColumn <= originEndColumn;
}
function columnsCover(): boolean {
if (rangeType === RANGE_TYPE.ROW && originRangeType === RANGE_TYPE.ROW) {
return true;
}
return startRow >= originStartRow && endRow <= originEndRow;
}
if (moveEdge === OriginRangeEdgeType.UP) {
if (rowsCover()) {
if (startRow < originStartRow) {
newRange.startRow = startRow;
} else if (startRow >= originEndRow) {
newRange.endRow -= fromEndRow + 1 - originStartRow;
} else {
return;
}
} else {
return;
}
} else if (moveEdge === OriginRangeEdgeType.DOWN) {
if (rowsCover()) {
if (endRow > originEndRow) {
newRange.endRow = endRow;
} else if (endRow <= originStartRow) {
newRange.startRow += originEndRow - fromStartRow + 1;
} else {
return;
}
} else {
return;
}
} else if (moveEdge === OriginRangeEdgeType.LEFT) {
if (columnsCover()) {
if (startColumn < originStartColumn) {
newRange.startColumn = startColumn;
} else if (startColumn >= originEndColumn) {
newRange.endColumn -= fromEndColumn + 1 - originStartColumn;
} else {
return;
}
} else {
return;
}
} else if (moveEdge === OriginRangeEdgeType.RIGHT) {
if (columnsCover()) {
if (endColumn > originEndColumn) {
newRange.endColumn = endColumn;
} else if (endColumn <= originStartColumn) {
newRange.startColumn += originEndColumn - fromStartColumn + 1;
} else {
return;
}
} else {
return;
}
} else if (moveEdge === OriginRangeEdgeType.ALL) {
newRange.startRow = startRow;
newRange.startColumn = startColumn;
newRange.endRow = endRow;
newRange.endColumn = endColumn;
} else if ((fromStartColumn <= originStartColumn && fromEndColumn >= originEndColumn) || (fromRangeType === RANGE_TYPE.ROW && originRangeType === RANGE_TYPE.ROW)) { // For the entire row, originStartColumn and originEndColumn may be NaN
if (from.endRow < originStartRow) {
if (toStartRow >= originStartRow) {
newRange.startRow -= fromEndRow - fromStartRow + 1;
}
if (toStartRow >= originEndRow) {
newRange.endRow -= fromEndRow - fromStartRow + 1;
}
} else if (from.startRow > originEndRow) {
if (toEndRow <= originEndRow) {
newRange.endRow += fromEndRow - fromStartRow + 1;
}
if (toEndRow <= originStartRow) {
newRange.startRow += fromEndRow - fromStartRow + 1;
}
} else if (from.startRow >= originStartRow && from.endRow <= originEndRow) { // from range is in the middle of the original range
if (toStartRow <= originStartRow) {
newRange.startRow += fromEndRow - fromStartRow + 1;
} else if (toStartRow >= originEndRow) {
newRange.endRow -= fromEndRow - fromStartRow + 1;
}
}
} else if ((fromStartRow <= originStartRow && fromEndRow >= originEndRow) || (fromRangeType === RANGE_TYPE.COLUMN && originRangeType === RANGE_TYPE.COLUMN)) { // For the entire column, originStartRow and originEndRow may be NaN
if (from.endColumn < originStartColumn) {
if (toStartColumn >= originStartColumn) {
newRange.startColumn -= fromEndColumn - fromStartColumn + 1;
}
if (toStartColumn >= originEndColumn) {
newRange.endColumn -= fromEndColumn - fromStartColumn + 1;
}
} else if (from.startColumn > originEndColumn) {
if (toEndColumn <= originEndColumn) {
newRange.endColumn += fromEndColumn - fromStartColumn + 1;
}
if (toEndColumn <= originStartColumn) {
newRange.startColumn += fromEndColumn - fromStartColumn + 1;
}
} else if (from.startColumn >= originStartColumn && from.endColumn <= originEndColumn) { // from range is in the middle of the original range
if (toStartColumn <= originStartColumn) {
newRange.startColumn += fromEndColumn - fromStartColumn + 1;
} else if (toStartColumn >= originEndColumn) {
newRange.endColumn -= fromEndColumn - fromStartColumn + 1;
}
}
} else if (
((toStartColumn <= remainEndColumn + 1 && toEndColumn >= originEndColumn) ||
(toStartColumn <= originStartColumn && toEndColumn >= remainStartColumn - 1)) &&
toStartRow <= originStartRow &&
toEndRow >= originEndRow
) {
newRange.startRow = startRow;
newRange.startColumn = startColumn;
newRange.endRow = endRow;
newRange.endColumn = endColumn;
} else if (
((toStartRow <= remainEndRow + 1 && toEndRow >= originEndRow) ||
(toStartRow <= originStartRow && toEndRow >= remainStartRow - 1)) &&
toStartColumn <= originStartColumn &&
toEndColumn >= originEndColumn
) {
newRange.startRow = startRow;
newRange.startColumn = startColumn;
newRange.endRow = endRow;
newRange.endColumn = endColumn;
} else {
// By default, the offset of result is taken
newRange.startRow = startRow;
newRange.startColumn = startColumn;
newRange.endRow = endRow;
newRange.endColumn = endColumn;
}
return newRange;
}
/**
* Determine the range of the moving selection,
* and check if it is at the edge of the reference range of the formula.
* @param originRange
* @param fromRange
*/
export function checkMoveEdge(originRange: IRange, fromRange: IRange): Nullable<OriginRangeEdgeType> {
const startRow = getStartValue(originRange.startRow);
const endRow = getEndValue(originRange.endRow);
const startColumn = getStartValue(originRange.startColumn);
const endColumn = getEndValue(originRange.endColumn);
const fromStartRow = getStartValue(fromRange.startRow);
const fromEndRow = getEndValue(fromRange.endRow);
const fromStartColumn = getStartValue(fromRange.startColumn);
const fromEndColumn = getEndValue(fromRange.endColumn);
function rowsCover(): boolean {
if (originRange.rangeType === RANGE_TYPE.COLUMN && fromRange.rangeType === RANGE_TYPE.COLUMN) {
return true;
}
return startRow >= fromStartRow && endRow <= fromEndRow;
}
function columnsCover(): boolean {
if (originRange.rangeType === RANGE_TYPE.ROW && fromRange.rangeType === RANGE_TYPE.ROW) {
return true;
}
return startColumn >= fromStartColumn && endColumn <= fromEndColumn;
}
function allCover(): boolean {
return originRange.rangeType === RANGE_TYPE.ALL && fromRange.rangeType === RANGE_TYPE.ALL;
}
if ((rowsCover() && columnsCover()) || allCover()) {
return OriginRangeEdgeType.ALL;
}
if (
columnsCover() &&
startRow >= fromStartRow &&
startRow <= fromEndRow &&
endRow > fromEndRow
) {
return OriginRangeEdgeType.UP;
}
if (
columnsCover() &&
endRow >= fromStartRow &&
endRow <= fromEndRow &&
startRow < fromStartRow
) {
return OriginRangeEdgeType.DOWN;
}
if (
rowsCover() &&
startColumn >= fromStartColumn &&
startColumn <= fromEndColumn &&
endColumn > fromEndColumn
) {
return OriginRangeEdgeType.LEFT;
}
if (
rowsCover() &&
endColumn >= fromStartColumn &&
endColumn <= fromEndColumn &&
startColumn < fromStartColumn
) {
return OriginRangeEdgeType.RIGHT;
}
return null; // Return null if no edge type matches
}
// Helper functions to get start values, treating NaN as unbounded
function getStartValue(value: number): number {
// If value is NaN, treat as -Infinity (unbounded start)
return isNaN(value) ? -Infinity : value;
}
// Helper functions to get end values, treating NaN as unbounded
function getEndValue(value: number): number {
// If value is NaN, treat as Infinity (unbounded end)
return isNaN(value) ? Infinity : value;
}
function getStartEndValue(range: IRange) {
const { startRow, endRow, startColumn, endColumn } = range;
return {
...range,
startRow: getStartValue(startRow),
endRow: getEndValue(endRow),
startColumn: getStartValue(startColumn),
endColumn: getEndValue(endColumn),
};
}

View File

@@ -0,0 +1,508 @@
/**
* 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 type { ICommandInfo, IRange, Nullable, Workbook } from '@univerjs/core';
import type { ISetDefinedNameMutationParam } from '@univerjs/engine-formula';
import type {
IDeleteRangeMoveLeftCommandParams,
IDeleteRangeMoveUpCommandParams,
IInsertColCommandParams,
IInsertRangeMoveDownCommandParams,
IInsertRangeMoveRightCommandParams,
IInsertRowCommandParams,
IMoveColsCommandParams,
IMoveRangeCommandParams,
IMoveRowsCommandParams,
IRemoveRowColCommandInterceptParams,
IRemoveSheetCommandParams,
ISetWorkbookNameCommandParams,
ISetWorksheetNameCommandParams,
} from '@univerjs/sheets';
import type { IFormulaReferenceMoveParam } from './ref-range-formula';
import { RANGE_TYPE } from '@univerjs/core';
import {
DeleteRangeMoveLeftCommand,
DeleteRangeMoveUpCommand,
InsertColCommand,
InsertRangeMoveDownCommand,
InsertRangeMoveRightCommand,
InsertRowCommand,
MoveColsCommand,
MoveRangeCommand,
MoveRowsCommand,
RemoveColCommand,
RemoveDefinedNameCommand,
RemoveRowCommand,
RemoveSheetCommand,
SetDefinedNameCommand,
SetWorkbookNameCommand,
SetWorksheetNameCommand,
} from '@univerjs/sheets';
import { FormulaReferenceMoveType } from './ref-range-formula';
const SET_SHEET_TABLE_COMMAND_ID = 'sheet.command.set-table-config';
const DELETE_SHEET_TABLE_COMMAND_ID = 'sheet.command.delete-table';
const REMOVE_SHEET_TABLE_COLUMN_AT_COMMAND_ID = 'sheet.command.table-remove-column-at';
const REMOVE_SHEET_TABLE_COLUMN_COMMAND_ID = 'sheet.command.table-remove-col';
interface ISetSheetTableNameCommandParam {
unitId: string;
name?: string;
oldTableName?: string;
}
interface IDeleteSheetTableCommandParam {
unitId: string;
tableName?: string;
}
interface IRemoveSheetTableColumnCommandParam {
unitId: string;
subUnitId?: string;
range?: IRange;
tableName?: string;
removedColumnNames?: string[];
}
export function getReferenceMoveParams(workbook: Workbook, command: ICommandInfo) {
const { id } = command;
let result: Nullable<IFormulaReferenceMoveParam> = null;
switch (id) {
case MoveRangeCommand.id:
result = handleRefMoveRange(command as ICommandInfo<IMoveRangeCommandParams>, workbook);
break;
case MoveRowsCommand.id:
result = handleRefMoveRows(command as ICommandInfo<IMoveRowsCommandParams>, workbook);
break;
case MoveColsCommand.id:
result = handleRefMoveCols(command as ICommandInfo<IMoveColsCommandParams>, workbook);
break;
case InsertRowCommand.id:
result = handleRefInsertRow(command as ICommandInfo<IInsertRowCommandParams>);
break;
case InsertColCommand.id:
result = handleRefInsertCol(command as ICommandInfo<IInsertColCommandParams>);
break;
case InsertRangeMoveRightCommand.id:
result = handleRefInsertRangeMoveRight(command as ICommandInfo<IInsertRangeMoveRightCommandParams>, workbook);
break;
case InsertRangeMoveDownCommand.id:
result = handleRefInsertRangeMoveDown(command as ICommandInfo<IInsertRangeMoveDownCommandParams>, workbook);
break;
case RemoveRowCommand.id:
result = handleRefRemoveRow(command as ICommandInfo<IRemoveRowColCommandInterceptParams>, workbook);
break;
case RemoveColCommand.id:
result = handleRefRemoveCol(command as ICommandInfo<IRemoveRowColCommandInterceptParams>);
break;
case DeleteRangeMoveUpCommand.id:
result = handleRefDeleteRangeMoveUp(command as ICommandInfo<IDeleteRangeMoveUpCommandParams>, workbook);
break;
case DeleteRangeMoveLeftCommand.id:
result = handleRefDeleteRangeMoveLeft(command as ICommandInfo<IDeleteRangeMoveLeftCommandParams>, workbook);
break;
case SetWorksheetNameCommand.id:
result = handleRefSetWorksheetName(command as ICommandInfo<ISetWorksheetNameCommandParams>, workbook);
break;
case SetWorkbookNameCommand.id:
result = handleRefSetWorkbookName(command as ICommandInfo<ISetWorkbookNameCommandParams>);
break;
case RemoveSheetCommand.id:
result = handleRefRemoveWorksheet(command as ICommandInfo<IRemoveSheetCommandParams>, workbook);
break;
case SetDefinedNameCommand.id:
result = handleRefSetDefinedName(command as ICommandInfo<ISetDefinedNameMutationParam>, workbook);
break;
case RemoveDefinedNameCommand.id:
result = handleRefRemoveDefinedName(command as ICommandInfo<ISetDefinedNameMutationParam>, workbook);
break;
case SET_SHEET_TABLE_COMMAND_ID:
result = handleRefSetSheetTableName(command as ICommandInfo<ISetSheetTableNameCommandParam>, workbook);
break;
case DELETE_SHEET_TABLE_COMMAND_ID:
result = handleRefRemoveSheetTableName(command as ICommandInfo<IDeleteSheetTableCommandParam>, workbook);
break;
case REMOVE_SHEET_TABLE_COLUMN_AT_COMMAND_ID:
case REMOVE_SHEET_TABLE_COLUMN_COMMAND_ID:
result = handleRefRemoveSheetTableColumn(command as ICommandInfo<IRemoveSheetTableColumnCommandParam>, workbook);
break;
}
return result;
}
function getCurrentSheetInfo(workbook: Workbook) {
const unitId = workbook.getUnitId();
const sheetId = workbook.getActiveSheet()?.getSheetId() || '';
return {
unitId,
sheetId,
};
}
function handleRefMoveRange(command: ICommandInfo<IMoveRangeCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const {
fromRange,
toRange,
fromUnitId,
fromSubUnitId,
toUnitId,
toSubUnitId,
} = params;
if (!fromRange || !toRange) return null;
const { unitId: currentUnitId, sheetId: currentSheetId } = getCurrentSheetInfo(workbook);
const unitId = fromUnitId || toUnitId || currentUnitId;
const sheetId = fromSubUnitId || currentSheetId;
const sheetName = workbook.getSheetBySheetId(sheetId)?.getName();
const targetSheetId = toSubUnitId || fromSubUnitId || currentSheetId;
const targetUnitId = toUnitId || fromUnitId || currentUnitId;
const targetSheetName = workbook.getSheetBySheetId(targetSheetId)?.getName();
return {
type: FormulaReferenceMoveType.MoveRange,
from: fromRange,
to: toRange,
unitId,
sheetId,
sheetName,
targetUnitId,
targetSheetId,
targetSheetName,
};
}
function handleRefMoveRows(command: ICommandInfo<IMoveRowsCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const {
fromRange: { startRow: fromStartRow, endRow: fromEndRow },
toRange: { startRow: toStartRow, endRow: toEndRow },
} = params;
const unitId = workbook.getUnitId();
const worksheet = workbook.getActiveSheet();
if (!worksheet) return null;
const sheetId = worksheet.getSheetId();
const from = {
startRow: fromStartRow,
startColumn: 0,
endRow: fromEndRow,
endColumn: worksheet.getColumnCount() - 1,
rangeType: RANGE_TYPE.ROW,
};
const to = {
startRow: toStartRow,
startColumn: 0,
endRow: toEndRow,
endColumn: worksheet.getColumnCount() - 1,
rangeType: RANGE_TYPE.ROW,
};
return {
type: FormulaReferenceMoveType.MoveRows,
from,
to,
unitId,
sheetId,
};
}
function handleRefMoveCols(command: ICommandInfo<IMoveColsCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const {
fromRange: { startColumn: fromStartCol, endColumn: fromEndCol },
toRange: { startColumn: toStartCol, endColumn: toEndCol },
} = params;
const unitId = workbook.getUnitId();
const worksheet = workbook.getActiveSheet();
if (!worksheet) return null;
const sheetId = worksheet.getSheetId();
const from = {
startRow: 0,
startColumn: fromStartCol,
endRow: worksheet.getRowCount() - 1,
endColumn: fromEndCol,
rangeType: RANGE_TYPE.COLUMN,
};
const to = {
startRow: 0,
startColumn: toStartCol,
endRow: worksheet.getRowCount() - 1,
endColumn: toEndCol,
rangeType: RANGE_TYPE.COLUMN,
};
return {
type: FormulaReferenceMoveType.MoveCols,
from,
to,
unitId,
sheetId,
};
}
function handleRefInsertRow(command: ICommandInfo<IInsertRowCommandParams>) {
const { params } = command;
if (!params) return null;
const { range, unitId, subUnitId } = params;
return {
type: FormulaReferenceMoveType.InsertRow,
range,
unitId,
sheetId: subUnitId,
};
}
function handleRefInsertCol(command: ICommandInfo<IInsertColCommandParams>) {
const { params } = command;
if (!params) return null;
const { range, unitId, subUnitId } = params;
return {
type: FormulaReferenceMoveType.InsertColumn,
range,
unitId,
sheetId: subUnitId,
};
}
function handleRefInsertRangeMoveRight(command: ICommandInfo<IInsertRangeMoveRightCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const { range } = params;
const { unitId, sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.InsertMoveRight,
range,
unitId,
sheetId,
};
}
function handleRefInsertRangeMoveDown(command: ICommandInfo<IInsertRangeMoveDownCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const { range } = params;
const { unitId, sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.InsertMoveDown,
range,
unitId,
sheetId,
};
}
function handleRefRemoveRow(command: ICommandInfo<IRemoveRowColCommandInterceptParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const { range, unitId, subUnitId: sheetId } = params;
return {
type: FormulaReferenceMoveType.RemoveRow,
range,
unitId,
sheetId,
rangeFilteredRows: workbook.getSheetBySheetId(sheetId)?.getRangeFilterRows(range) ?? [],
};
}
function handleRefRemoveCol(command: ICommandInfo<IRemoveRowColCommandInterceptParams>) {
const { params } = command;
if (!params) return null;
const { range, unitId, subUnitId: sheetId } = params;
return {
type: FormulaReferenceMoveType.RemoveColumn,
range,
unitId,
sheetId,
};
}
function handleRefDeleteRangeMoveUp(command: ICommandInfo<IDeleteRangeMoveUpCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const { range } = params;
const { unitId, sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.DeleteMoveUp,
range,
unitId,
sheetId,
};
}
function handleRefDeleteRangeMoveLeft(command: ICommandInfo<IDeleteRangeMoveLeftCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const { range } = params;
const { unitId, sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.DeleteMoveLeft,
range,
unitId,
sheetId,
};
}
function handleRefSetWorksheetName(command: ICommandInfo<ISetWorksheetNameCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const { unitId, subUnitId, name } = params;
const { unitId: workbookId, sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.SetName,
unitId: unitId || workbookId,
sheetId: subUnitId || sheetId,
sheetName: name,
};
}
function handleRefSetWorkbookName(command: ICommandInfo<ISetWorkbookNameCommandParams>): Nullable<IFormulaReferenceMoveParam> {
const { params } = command;
if (!params) return null;
return {
type: FormulaReferenceMoveType.SetUnitName,
unitId: params.unitId,
sheetId: '',
unitName: params.name,
};
}
function handleRefRemoveWorksheet(command: ICommandInfo<IRemoveSheetCommandParams>, workbook: Workbook) {
const { params } = command;
if (!params) return null;
const { unitId, subUnitId } = params;
const { unitId: workbookId, sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.RemoveSheet,
unitId: unitId || workbookId,
sheetId: subUnitId || sheetId,
};
}
function handleRefSetDefinedName(command: ICommandInfo<ISetDefinedNameMutationParam>, workbook: Workbook): Nullable<IFormulaReferenceMoveParam> {
const { params } = command;
if (!params) return null;
const { unitId, name, id } = params;
const { sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.SetDefinedName,
unitId,
sheetId,
definedName: name,
definedNameId: id,
};
}
function handleRefRemoveDefinedName(command: ICommandInfo<ISetDefinedNameMutationParam>, workbook: Workbook): Nullable<IFormulaReferenceMoveParam> {
const { params } = command;
if (!params) return null;
const { unitId, name, id } = params;
const { sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.RemoveDefinedName,
unitId,
sheetId,
definedName: name,
definedNameId: id,
};
}
function handleRefSetSheetTableName(command: ICommandInfo<ISetSheetTableNameCommandParam>, workbook: Workbook): Nullable<IFormulaReferenceMoveParam> {
const { params } = command;
if (!params || !params.name || !params.oldTableName || params.oldTableName === params.name) return null;
const { unitId, name: tableName, oldTableName } = params;
const { sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.SetSuperTableName,
unitId,
sheetId,
tableName,
oldTableName,
};
}
function handleRefRemoveSheetTableName(command: ICommandInfo<IDeleteSheetTableCommandParam>, workbook: Workbook): Nullable<IFormulaReferenceMoveParam> {
const { params } = command;
if (!params || !params.tableName) return null;
const { unitId, tableName } = params;
const { sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.RemoveSuperTableName,
unitId,
sheetId,
oldTableName: tableName,
};
}
function handleRefRemoveSheetTableColumn(command: ICommandInfo<IRemoveSheetTableColumnCommandParam>, workbook: Workbook): Nullable<IFormulaReferenceMoveParam> {
const { params } = command;
if (!params || !params.tableName || !params.removedColumnNames?.length) return null;
const { unitId, subUnitId, range, tableName, removedColumnNames } = params;
const { sheetId } = getCurrentSheetInfo(workbook);
return {
type: FormulaReferenceMoveType.RemoveSuperTableColumn,
unitId,
sheetId: subUnitId || sheetId,
range,
oldTableName: tableName,
tableColumnNames: removedColumnNames,
};
}

View File

@@ -0,0 +1,198 @@
/**
* 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 type { Dependency, IWorkbookData, UnitModel } from '@univerjs/core';
import type { IRender } from '@univerjs/engine-render';
import {
ILogService,
Inject,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
set,
ThemeService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import {
DefinedNamesService,
FormulaDataModel,
FunctionService,
IDefinedNamesService,
IFunctionService,
LexerTreeBuilder,
} from '@univerjs/engine-formula';
import { Engine, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
RangeProtectionRuleModel,
RefRangeService,
SheetInterceptorService,
SheetSkeletonService,
SheetsSelectionsService,
WorkbookPermissionService,
WorksheetPermissionService,
WorksheetProtectionPointModel,
WorksheetProtectionRuleModel,
} from '@univerjs/sheets';
import enUS from '@univerjs/sheets/locale/en-US';
import zhCN from '@univerjs/sheets/locale/zh-CN';
import '@univerjs/sheets/facade';
import '../f-workbook';
import '../f-range';
function getTestWorkbookDataDemo(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
3: {
f: '=SUM(A1)',
si: '3e4r5t',
},
4: {
v: 123,
t: 2,
},
},
1: {
3: {
f: '=SUM(A2)',
si: 'OSPtzm',
},
},
2: {
3: {
si: 'OSPtzm',
},
},
3: {
3: {
si: 'OSPtzm',
},
},
},
rowCount: 100,
columnCount: 100,
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
}
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: UnitModel<IWorkbookData>;
univerAPI: FUniver;
injector: Injector;
}
class RenderManagerServiceTestBed extends RenderManagerService {
override createRender(unitId: string): IRender {
const renderer = this._createRender(unitId, new Engine('', { elementHeight: 100, elementWidth: 100 }));
return renderer;
}
}
export function createFacadeTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
injector.add([SheetsSelectionsService]);
injector.add([SheetInterceptorService]);
injector.add([IFunctionService, { useClass: FunctionService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerServiceTestBed }]);
injector.add([SheetSkeletonService]);
injector.add([FormulaDataModel]);
injector.add([LexerTreeBuilder]);
injector.add([RefRangeService]);
injector.add([WorksheetPermissionService]);
injector.add([WorkbookPermissionService]);
injector.add([WorksheetProtectionPointModel]);
injector.add([RangeProtectionRuleModel]);
injector.add([WorksheetProtectionRuleModel]);
injector.add([IDefinedNamesService, { useClass: DefinedNamesService }]);
dependencies?.forEach((d) => injector.add(d));
this._injector.get(SheetInterceptorService);
this._injector.get(WorkbookPermissionService);
this._injector.get(WorksheetPermissionService);
}
override onReady(): void {
}
}
// load i18n
injector.get(LocaleService).load({ zhCN, enUS });
// load theme service
const themeService = injector.get(ThemeService);
const theme = themeService.getCurrentTheme();
const newTheme = set(theme, 'gray.1000', '#35322b');
themeService.setTheme(newTheme);
// register builtin plugins
// note that UI plugins are not registered here, because the unit test environment does not have a UI
univer.registerPlugin(TestPlugin);
const sheet = univer.createUnit<IWorkbookData, UnitModel<IWorkbookData>>(UniverInstanceType.UNIVER_SHEET, workbookData || getTestWorkbookDataDemo());
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
// set log level
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT); // NOTE: change this to `LogLevel.VERBOSE` to debug tests via logs
// init data validation
const univerAPI = FUniver.newAPI(injector);
return {
univer,
get: injector.get.bind(injector),
sheet,
univerAPI,
injector,
};
}

View File

@@ -0,0 +1,483 @@
/**
* 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 type { Injector } from '@univerjs/core';
import type { FUniver } from '@univerjs/core/facade';
import { ICommandService, IConfigService, LifecycleService, LifecycleStages } from '@univerjs/core';
import {
ActiveDirtyManagerService,
DescriptionService,
FormulaCalculationSessionController,
FormulaCalculationSessionService,
FormulaExecutedStateType,
FormulaExecuteStageType,
IActiveDirtyManagerService,
IDescriptionService,
IFunctionService,
IRegisterFunctionService,
ISuperTableService,
RegisterFunctionService,
RegisterOtherFormulaService,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,
SetFormulaCalculationStartMutation,
SetTriggerFormulaCalculationStartMutation,
SuperTableService,
} from '@univerjs/engine-formula';
import { SetRangeValuesMutation } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SheetFormulaCalculationResultApplyController,
} from '../../controllers/sheet-formula-calculation-result-apply.controller';
import {
CalculationMode,
PLUGIN_CONFIG_KEY_BASE,
} from '../../index';
import { createFacadeTestBed } from './create-test-bed';
import '@univerjs/engine-formula/facade';
import '@univerjs/sheets/facade';
import '../../facade';
describe('sheets-formula facade mixins', () => {
let get: Injector['get'];
let univerAPI: FUniver;
let commandService: ICommandService;
let disposeUniver: () => void;
beforeEach(() => {
const testBed = createFacadeTestBed();
testBed.injector.add([IDescriptionService, { useClass: DescriptionService }]);
testBed.injector.add([ISuperTableService, { useClass: SuperTableService }]);
testBed.injector.add([IRegisterFunctionService, { useClass: RegisterFunctionService }]);
testBed.injector.add([IActiveDirtyManagerService, { useClass: ActiveDirtyManagerService }]);
testBed.injector.add([RegisterOtherFormulaService]);
testBed.injector.add([FormulaCalculationSessionService]);
testBed.injector.add([FormulaCalculationSessionController]);
testBed.injector.add([SheetFormulaCalculationResultApplyController]);
testBed.injector.get(FormulaCalculationSessionService);
testBed.injector.get(FormulaCalculationSessionController);
testBed.injector.get(SheetFormulaCalculationResultApplyController);
get = testBed.get;
univerAPI = testBed.univerAPI;
disposeUniver = () => testBed.univer.dispose();
commandService = get(ICommandService);
commandService.registerCommand(SetFormulaCalculationStartMutation);
commandService.registerCommand(SetFormulaCalculationNotificationMutation);
commandService.registerCommand(SetFormulaCalculationResultMutation);
commandService.registerCommand(SetRangeValuesMutation);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
disposeUniver();
});
it('registers functions through FFormula and triggers a debounced recalculation command', async () => {
vi.useFakeTimers();
const functionService = get(IFunctionService);
const executeCommandSpy = vi.spyOn(commandService, 'executeCommand').mockResolvedValue(true);
const formula = univerAPI.getFormula();
const formulaDisposable = formula.registerFunction('FORMULA_SIDE', (value) => Number(value) + 1, 'Registered from FFormula');
const asyncDisposable = formula.registerAsyncFunction('FORMULA_ASYNC', async () => 2, 'Async formula');
await vi.advanceTimersByTimeAsync(11);
expect(get(IRegisterFunctionService)).toBeDefined();
expect(functionService.hasExecutor('FORMULA_SIDE')).toBe(true);
expect(functionService.hasExecutor('FORMULA_ASYNC')).toBe(true);
expect(executeCommandSpy).toHaveBeenCalledWith(
SetTriggerFormulaCalculationStartMutation.id,
{
commands: [],
forceCalculation: true,
},
{
onlyLocal: true,
}
);
formulaDisposable.dispose();
asyncDisposable.dispose();
expect(functionService.hasExecutor('FORMULA_SIDE')).toBe(false);
expect(functionService.hasExecutor('FORMULA_ASYNC')).toBe(false);
});
it('updates initial calculation mode and warns after the Starting lifecycle', () => {
const formula = univerAPI.getFormula();
const configService = get(IConfigService);
const lifecycleService = get(LifecycleService);
formula.setInitialFormulaComputing(CalculationMode.FORCED);
expect(configService.getConfig(PLUGIN_CONFIG_KEY_BASE)).toMatchObject({
initialFormulaComputing: CalculationMode.FORCED,
});
lifecycleService.stage = LifecycleStages.Ready;
expect(() => formula.setInitialFormulaComputing(CalculationMode.NO_CALCULATION)).not.toThrow();
formula.setInitialFormulaComputing(CalculationMode.NO_CALCULATION);
expect(configService.getConfig(PLUGIN_CONFIG_KEY_BASE)).toMatchObject({
initialFormulaComputing: CalculationMode.NO_CALCULATION,
});
});
it('fires calculationResultApplied only after result mutation and applied range update are both observed', async () => {
vi.stubGlobal('requestIdleCallback', ((callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 16 } as IdleDeadline);
return 1;
}) as typeof requestIdleCallback);
const formula = univerAPI.getFormula();
const resultPayload = {
unitData: {
test: {
sheet1: {
0: {
0: { v: 1 },
},
},
},
},
unitOtherData: {},
};
await new Promise<void>((resolve) => {
const disposable = formula.calculationResultApplied((result) => {
expect(result).toEqual(resultPayload);
disposable.dispose();
resolve();
});
commandService.executeCommand(SetFormulaCalculationResultMutation.id, resultPayload);
commandService.executeCommand(
SetRangeValuesMutation.id,
{
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {},
},
{
applyFormulaCalculationResult: true,
}
);
});
});
it('fires calculationResultApplied when range update is observed before the result mutation', async () => {
vi.stubGlobal('requestIdleCallback', ((callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 16 } as IdleDeadline);
return 1;
}) as typeof requestIdleCallback);
const formula = univerAPI.getFormula();
const resultPayload = {
unitData: {
unit1: {
sheet1: {
0: {
0: { v: 1 },
},
},
},
},
unitOtherData: {},
};
await new Promise<void>((resolve) => {
const disposable = formula.calculationResultApplied((result) => {
expect(result).toEqual(resultPayload);
disposable.dispose();
resolve();
});
commandService.executeCommand(
SetRangeValuesMutation.id,
{
unitId: 'unit1',
subUnitId: 'sheet1',
cellValue: {},
},
{
applyFormulaCalculationResult: true,
}
);
commandService.executeCommand(SetFormulaCalculationResultMutation.id, resultPayload);
});
});
it('fires calculationResultApplied once for repeated range apply notifications', async () => {
vi.stubGlobal('requestIdleCallback', ((callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 16 } as IdleDeadline);
return 1;
}) as typeof requestIdleCallback);
const formula = univerAPI.getFormula();
const resultPayload = {
unitData: {
unit1: {
sheet1: {
0: {
0: { v: 1 },
},
},
},
},
unitOtherData: {},
};
const callback = vi.fn();
const disposable = formula.calculationResultApplied(callback);
await commandService.executeCommand(SetFormulaCalculationResultMutation.id, resultPayload);
await commandService.executeCommand(
SetRangeValuesMutation.id,
{
unitId: 'unit1',
subUnitId: 'sheet1',
cellValue: {},
},
{
applyFormulaCalculationResult: true,
}
);
await commandService.executeCommand(
SetRangeValuesMutation.id,
{
unitId: 'unit1',
subUnitId: 'sheet1',
cellValue: {},
},
{
applyFormulaCalculationResult: true,
}
);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(resultPayload);
disposable.dispose();
});
it('resolves onCalculationResultApplied when no calculation actually starts', async () => {
vi.useFakeTimers();
const formula = univerAPI.getFormula();
const waitForResult = formula.onCalculationResultApplied();
await vi.advanceTimersByTimeAsync(500);
await expect(waitForResult).resolves.toBeUndefined();
});
it('resolves onCalculationResultApplied for other-formula-only results without range value application', async () => {
vi.useFakeTimers();
vi.stubGlobal('requestIdleCallback', ((callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 16 } as IdleDeadline);
return 1;
}) as typeof requestIdleCallback);
const formula = univerAPI.getFormula();
const waitForResult = formula.onCalculationResultApplied();
await commandService.executeCommand(SetFormulaCalculationStartMutation.id, {}, { onlyLocal: true });
await commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
stage: FormulaExecuteStageType.START_CALCULATION,
completedFormulasCount: 0,
completedArrayFormulasCount: 0,
formulaCycleIndex: 0,
totalArrayFormulasToCalculate: 0,
totalFormulasToCalculate: 1,
},
});
await commandService.executeCommand(SetFormulaCalculationResultMutation.id, {
unitData: {},
unitOtherData: {
unit1: {
sheet1: {
'formula.cf-1': {
0: {
0: { v: true },
},
},
},
},
},
});
await expect(waitForResult).resolves.toBeUndefined();
});
it('resolves onCalculationResultApplied after the latest restarted calculation is applied', async () => {
vi.useFakeTimers();
const formula = univerAPI.getFormula();
const waitForResult = formula.onCalculationResultApplied();
let resolved = false;
waitForResult.then(() => {
resolved = true;
});
await commandService.executeCommand(SetFormulaCalculationStartMutation.id, {}, { onlyLocal: true });
await commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
stage: FormulaExecuteStageType.CURRENTLY_CALCULATING,
completedFormulasCount: 0,
completedArrayFormulasCount: 0,
formulaCycleIndex: 0,
totalArrayFormulasToCalculate: 0,
totalFormulasToCalculate: 1,
},
});
await commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
functionsExecutedState: FormulaExecutedStateType.STOP_EXECUTION,
});
await Promise.resolve();
expect(resolved).toBe(false);
await commandService.executeCommand(SetFormulaCalculationStartMutation.id, {}, { onlyLocal: true });
await commandService.executeCommand(SetFormulaCalculationResultMutation.id, {
unitData: {
test: {
sheet1: {
0: {
0: { v: 1 },
},
},
},
},
unitOtherData: {},
});
await Promise.resolve();
expect(resolved).toBe(false);
await commandService.executeCommand(
SetRangeValuesMutation.id,
{
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {},
},
{
applyFormulaCalculationResult: true,
}
);
await expect(waitForResult).resolves.toBeUndefined();
});
it('does not wait for a sheet application when the result targets an unknown unit', async () => {
await commandService.executeCommand(SetFormulaCalculationStartMutation.id, {}, { onlyLocal: true });
const waitForResult = univerAPI.getFormula().onCalculationResultApplied();
await commandService.executeCommand(SetFormulaCalculationResultMutation.id, {
unitData: {
'unknown-unit': {
sheet1: {
0: {
0: { v: 1 },
},
},
},
},
unitOtherData: {},
});
await expect(waitForResult).resolves.toBeUndefined();
});
it('reads each of ten consecutive sheet results after its own application finishes', async () => {
vi.stubGlobal('requestIdleCallback', ((callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 16 } as IdleDeadline);
return 1;
}) as typeof requestIdleCallback);
const formula = univerAPI.getFormula();
const workbook = univerAPI.getActiveWorkbook();
if (!workbook) {
throw new Error('Expected an active workbook in the facade test bed.');
}
const range = workbook.getActiveSheet().getRange('A1');
const expectedValues = [10, 3, 77, -5, 1000, 0, 42, 8.5, 999, -1200];
const actualValues: unknown[] = [];
for (const value of expectedValues) {
const resultApplied = formula.onCalculationResultApplied(1_000);
const resultPayload = {
unitData: {
test: {
sheet1: {
0: {
0: { v: value },
},
},
},
},
unitOtherData: {},
};
await commandService.executeCommand(SetFormulaCalculationStartMutation.id, {}, { onlyLocal: true });
await commandService.executeCommand(SetFormulaCalculationResultMutation.id, resultPayload);
await commandService.executeCommand(
SetRangeValuesMutation.id,
{
unitId: 'test',
subUnitId: 'sheet1',
cellValue: { 0: { 0: { v: value } } },
},
{
applyFormulaCalculationResult: true,
}
);
await resultApplied;
actualValues.push(range.getValue());
}
expect(actualValues).toEqual(expectedValues);
});
it('rejects onCalculationResultApplied when an explicit timeout is exceeded', async () => {
vi.useFakeTimers();
const formula = univerAPI.getFormula();
const waitForResult = expect(formula.onCalculationResultApplied(1000)).rejects.toThrowError('Calculation end timeout');
await commandService.executeCommand(SetFormulaCalculationStartMutation.id, {}, { onlyLocal: true });
await commandService.executeCommand(SetFormulaCalculationNotificationMutation.id, {
stageInfo: {
stage: FormulaExecuteStageType.START_CALCULATION,
completedFormulasCount: 0,
completedArrayFormulasCount: 0,
formulaCycleIndex: 0,
totalArrayFormulasToCalculate: 1,
totalFormulasToCalculate: 1,
},
});
await vi.advanceTimersByTimeAsync(1000);
await waitForResult;
});
});

View File

@@ -0,0 +1,291 @@
/**
* 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 type { Injector, IWorkbookData } from '@univerjs/core';
import type { FUniver } from '@univerjs/core/facade';
import { ICommandService, LocaleType } from '@univerjs/core';
import { ErrorType } from '@univerjs/engine-formula';
import { SetRangeValuesCommand, SetRangeValuesMutation } from '@univerjs/sheets';
import { beforeEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from './create-test-bed';
function getTestWorkbookDataWithFormulas(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
0: {
v: 1,
},
1: {
v: 2,
},
2: {
f: '=SUM(A1:B1)',
v: 3,
},
3: {
f: '=A1/0',
v: ErrorType.DIV_BY_ZERO,
t: 4,
},
},
1: {
0: {
f: '=UNKNOWN_FUNCTION()',
v: ErrorType.NAME,
t: 4,
},
1: {
f: '=SUM(A1,A2)',
v: 2,
},
2: {
f: '=SQRT(-1)',
v: ErrorType.NUM,
t: 4,
},
},
2: {
0: {
v: 'text',
},
1: {
f: '=A3+B2',
v: ErrorType.VALUE,
t: 4,
},
2: {
v: 'normal text',
},
},
3: {
0: {
v: 10,
},
1: {
v: 20,
},
2: {
f: '=A4+B4',
v: 30,
},
},
},
rowCount: 100,
columnCount: 100,
},
},
locale: LocaleType.ZH_CN,
name: 'Test Workbook',
sheetOrder: ['sheet1'],
styles: {},
};
}
describe('Test FRangeEngineFormulaMixin', () => {
let get: Injector['get'];
let commandService: ICommandService;
let univerAPI: FUniver;
beforeEach(() => {
const testBed = createFacadeTestBed(getTestWorkbookDataWithFormulas());
get = testBed.get;
univerAPI = testBed.univerAPI;
commandService = get(ICommandService);
commandService.registerCommand(SetRangeValuesCommand);
commandService.registerCommand(SetRangeValuesMutation);
});
it('should get formula errors in a specific range', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test range that contains errors (A1:D2)
const range = worksheet.getRange('A1:D2');
const errors = range.getFormulaError();
expect(errors).toBeDefined();
expect(Array.isArray(errors)).toBe(true);
expect(errors.length).toBeGreaterThan(0);
// Check for specific error types in this range
const divByZeroErrors = errors.filter((e) => e.errorType === ErrorType.DIV_BY_ZERO);
const nameErrors = errors.filter((e) => e.errorType === ErrorType.NAME);
const numErrors = errors.filter((e) => e.errorType === ErrorType.NUM);
expect(divByZeroErrors.length).toBe(1);
expect(nameErrors.length).toBe(1);
expect(numErrors.length).toBe(1);
// All errors should be within the specified range
errors.forEach((error) => {
expect(error.row >= 0).toBe(true);
expect(error.row <= 1).toBe(true);
expect(error.column >= 0).toBe(true);
expect(error.column <= 3).toBe(true);
expect(error.sheetName).toBe('sheet1');
});
});
it('should return empty array for range with no formula errors', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test range that contains no errors (A1:B1 - only regular values)
const range = worksheet.getRange('A1:B1');
const errors = range.getFormulaError();
expect(errors).toBeDefined();
expect(Array.isArray(errors)).toBe(true);
expect(errors.length).toBe(0);
});
it('should get formula errors in a single cell range', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test single cell with error (D1 contains DIV_BY_ZERO error)
const range = worksheet.getRange('D1');
const errors = range.getFormulaError();
expect(errors).toBeDefined();
expect(Array.isArray(errors)).toBe(true);
expect(errors.length).toBe(1);
const error = errors[0];
expect(error.errorType).toBe(ErrorType.DIV_BY_ZERO);
expect(error.row).toBe(0);
expect(error.column).toBe(3);
expect(error.formula).toBe('=A1/0');
expect(error.sheetName).toBe('sheet1');
});
it('should handle range with mixed content (errors and normal values)', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test range that contains both errors and normal values (A1:C3)
const range = worksheet.getRange('A1:C3');
const errors = range.getFormulaError();
expect(errors).toBeDefined();
expect(Array.isArray(errors)).toBe(true);
expect(errors.length).toBe(3); // Should find VALUE and NUM errors
const errorTypes = new Set(errors.map((e) => e.errorType));
expect(errorTypes.has(ErrorType.NAME)).toBe(true);
expect(errorTypes.has(ErrorType.VALUE)).toBe(true);
expect(errorTypes.has(ErrorType.NUM)).toBe(true);
});
it('should validate error object properties', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test range with known error
const range = worksheet.getRange('A2:C2');
const errors = range.getFormulaError();
expect(errors.length).toBeGreaterThan(0);
errors.forEach((error) => {
expect(error).toHaveProperty('sheetName');
expect(error).toHaveProperty('row');
expect(error).toHaveProperty('column');
expect(error).toHaveProperty('formula');
expect(error).toHaveProperty('errorType');
expect(typeof error.sheetName).toBe('string');
expect(typeof error.row).toBe('number');
expect(typeof error.column).toBe('number');
expect(typeof error.formula).toBe('string');
expect(typeof error.errorType).toBe('string');
expect(error.sheetName).toBe('sheet1');
expect(error.row >= 1).toBe(true);
expect(error.row <= 1).toBe(true);
expect(error.column >= 0).toBe(true);
expect(error.column <= 2).toBe(true);
expect(error.formula.startsWith('=')).toBe(true);
});
});
it('should return empty array for range with only normal formulas', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test range that contains normal formulas without errors (A4:C4)
const range = worksheet.getRange('A4:C4');
const errors = range.getFormulaError();
expect(errors).toBeDefined();
expect(Array.isArray(errors)).toBe(true);
expect(errors.length).toBe(0);
});
it('should handle large range correctly', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test large range that includes all test data
const range = worksheet.getRange('A1:Z100');
const errors = range.getFormulaError();
expect(errors).toBeDefined();
expect(Array.isArray(errors)).toBe(true);
// Should find all the errors in the worksheet
const errorTypes = new Set(errors.map((e) => e.errorType));
expect(errorTypes.has(ErrorType.DIV_BY_ZERO)).toBe(true);
expect(errorTypes.has(ErrorType.NAME)).toBe(true);
expect(errorTypes.has(ErrorType.NUM)).toBe(true);
expect(errorTypes.has(ErrorType.VALUE)).toBe(true);
});
it('should identify different error types correctly in range', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet()!;
// Test range containing different error types
const range = worksheet.getRange('A1:D3');
const errors = range.getFormulaError();
const errorsByType: Partial<Record<ErrorType, number>> = {
[ErrorType.DIV_BY_ZERO]: 0,
[ErrorType.NAME]: 0,
[ErrorType.NUM]: 0,
[ErrorType.VALUE]: 0,
};
errors.forEach((error) => {
if (error.errorType in errorsByType) {
errorsByType[error.errorType] = (errorsByType[error.errorType] || 0) + 1;
}
});
expect(errorsByType[ErrorType.DIV_BY_ZERO]).toBe(1);
expect(errorsByType[ErrorType.NAME]).toBe(1);
expect(errorsByType[ErrorType.NUM]).toBe(1);
expect(errorsByType[ErrorType.VALUE]).toBe(1);
});
});

View File

@@ -0,0 +1,196 @@
/**
* 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 type { Injector, IWorkbookData } from '@univerjs/core';
import type { FUniver } from '@univerjs/core/facade';
import { ICommandService, LocaleType } from '@univerjs/core';
import { ErrorType } from '@univerjs/engine-formula';
import { SetRangeValuesCommand, SetRangeValuesMutation } from '@univerjs/sheets';
import { beforeEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from './create-test-bed';
function getTestWorkbookDataWithFormulas(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
0: {
v: 1,
},
1: {
v: 2,
},
2: {
f: '=SUM(A1:B1)',
v: 3,
},
3: {
f: '=A1/0',
v: ErrorType.DIV_BY_ZERO,
t: 4,
},
},
1: {
0: {
f: '=UNKNOWN_FUNCTION()',
v: ErrorType.NAME,
t: 4,
},
1: {
f: '=SUM(A1,A2)',
v: 2,
},
2: {
f: '=SQRT(-1)',
v: ErrorType.NUM,
t: 4,
},
},
2: {
0: {
v: 'text',
},
1: {
f: '=A3+B2',
v: ErrorType.VALUE,
t: 4,
},
},
},
rowCount: 100,
columnCount: 100,
},
sheet2: {
id: 'sheet2',
name: 'sheet2',
cellData: {
0: {
0: {
f: '=1/0',
v: ErrorType.DIV_BY_ZERO,
t: 4,
},
},
},
rowCount: 100,
columnCount: 100,
},
},
locale: LocaleType.ZH_CN,
name: 'Test Workbook',
sheetOrder: ['sheet1', 'sheet2'],
styles: {},
};
}
describe('Test FWorkbookEngineFormulaMixin', () => {
let get: Injector['get'];
let commandService: ICommandService;
let univerAPI: FUniver;
beforeEach(() => {
const testBed = createFacadeTestBed(getTestWorkbookDataWithFormulas());
get = testBed.get;
univerAPI = testBed.univerAPI;
commandService = get(ICommandService);
commandService.registerCommand(SetRangeValuesCommand);
commandService.registerCommand(SetRangeValuesMutation);
});
it('should get all formula errors in the workbook', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const errors = workbook.getAllFormulaError();
expect(errors).toBeDefined();
expect(Array.isArray(errors)).toBe(true);
expect(errors.length).toBeGreaterThan(0);
// Check for specific error types
const divByZeroErrors = errors.filter((e) => e.errorType === ErrorType.DIV_BY_ZERO);
const nameErrors = errors.filter((e) => e.errorType === ErrorType.NAME);
const numErrors = errors.filter((e) => e.errorType === ErrorType.NUM);
const valueErrors = errors.filter((e) => e.errorType === ErrorType.VALUE);
expect(divByZeroErrors.length).toBeGreaterThan(0);
expect(nameErrors.length).toBeGreaterThan(0);
expect(numErrors.length).toBeGreaterThan(0);
expect(valueErrors.length).toBeGreaterThan(0);
// Check error details
const firstError = errors[0];
expect(firstError).toHaveProperty('sheetName');
expect(firstError).toHaveProperty('row');
expect(firstError).toHaveProperty('column');
expect(firstError).toHaveProperty('formula');
expect(firstError).toHaveProperty('errorType');
expect(typeof firstError.sheetName).toBe('string');
expect(typeof firstError.row).toBe('number');
expect(typeof firstError.column).toBe('number');
expect(typeof firstError.formula).toBe('string');
});
it('should identify different error types correctly', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const errors = workbook.getAllFormulaError();
const errorTypes = new Set(errors.map((e) => e.errorType));
// Should contain multiple error types from our test data
expect(errorTypes.has(ErrorType.DIV_BY_ZERO)).toBe(true);
expect(errorTypes.has(ErrorType.NAME)).toBe(true);
expect(errorTypes.has(ErrorType.NUM)).toBe(true);
expect(errorTypes.has(ErrorType.VALUE)).toBe(true);
});
it('should include correct formula strings in error results', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const errors = workbook.getAllFormulaError();
// Find specific errors and check their formula strings
const divByZeroError = errors.find((e) => e.errorType === ErrorType.DIV_BY_ZERO);
const nameError = errors.find((e) => e.errorType === ErrorType.NAME);
expect(divByZeroError).toBeDefined();
expect(divByZeroError!.formula).toContain('=');
expect(nameError).toBeDefined();
expect(nameError!.formula).toContain('=');
});
it('should find errors across multiple sheets', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const errors = workbook.getAllFormulaError();
const sheetNames = new Set(errors.map((e) => e.sheetName));
// Should find errors in both sheets
expect(sheetNames.has('sheet1')).toBe(true);
expect(sheetNames.has('sheet2')).toBe(true);
expect(sheetNames.size).toBe(2);
});
});

View File

@@ -0,0 +1,41 @@
/**
* 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 { FEnum } from '@univerjs/core/facade';
import { CalculationMode } from '@univerjs/sheets-formula';
/**
* @ignore
*/
export interface IFSheetsFormulaEnumMixin {
/**
* Define the calculation mode during initialization
*/
CalculationMode: typeof CalculationMode;
}
export class FSheetsFormulaEnumMixin extends FEnum implements IFSheetsFormulaEnumMixin {
override get CalculationMode(): typeof CalculationMode {
return CalculationMode;
}
}
FEnum.extend(FSheetsFormulaEnumMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
export interface FEnum extends IFSheetsFormulaEnumMixin {
}
}

View File

@@ -0,0 +1,380 @@
/**
* 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 type { IDisposable, ILocales } from '@univerjs/core';
import type {
IFunctionInfo,
IRegisterAsyncFunction,
IRegisterFunction,
ISingleFunctionRegisterParams,
} from '@univerjs/engine-formula';
import type { CalculationMode, IUniverSheetsFormulaBaseConfig } from '@univerjs/sheets-formula';
import { debounce, IConfigService, ILogService, LifecycleService, LifecycleStages } from '@univerjs/core';
import { IRegisterFunctionService, SetTriggerFormulaCalculationStartMutation } from '@univerjs/engine-formula';
import { FFormula } from '@univerjs/engine-formula/facade';
import { PLUGIN_CONFIG_KEY_BASE } from '@univerjs/sheets-formula';
/**
* @ignore
*/
export interface IFFormulaSheetsMixin {
/**
* Update the calculation mode of the formula. It will take effect the next time the Univer Sheet is constructed.
* The calculation mode only handles formulas data when the workbook initializes data.
* @param {CalculationMode} calculationMode - The calculation mode of the formula.
* @example
* ```ts
* const formulaEngine = univerAPI.getFormula();
* formulaEngine.setInitialFormulaComputing(0);
* ```
*/
setInitialFormulaComputing(calculationMode: CalculationMode): void;
/**
* Register a custom synchronous formula function.
* @param {string} name - The name of the function to register. This will be used in formulas (e.g., =MYFUNC()).
* @param {IRegisterFunction} func - The implementation of the function.
* @param {string} [description] - A string describing the function's purpose and usage.
* @returns {IDisposable} A disposable object that will unregister the function when disposed.
* @example
* ```ts
* // Register a simple greeting function
* const formulaEngine = univerAPI.getFormula();
* formulaEngine.registerFunction(
* 'HELLO',
* (name) => `Hello, ${name}!`,
* 'A simple greeting function'
* );
*
* // Use the function in a cell
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cellA1 = fWorksheet.getRange('A1');
* cellA1.setValue('World');
* const cellA2 = fWorksheet.getRange('A2');
* cellA2.setValue({ f: '=HELLO(A1)' });
*
* // A2 will display: "Hello, World!"
* formulaEngine.calculationEnd((functionsExecutedState) => {
* if (functionsExecutedState === 3) {
* console.log(cellA2.getValue()); // Hello, World!
* }
* })
* ```
* @example
* ```ts
* // Register a discount calculation function
* const formulaEngine = univerAPI.getFormula();
* formulaEngine.registerFunction(
* 'DISCOUNT',
* (price, discountPercent) => price * (1 - discountPercent / 100),
* 'Calculates final price after discount'
* );
*
* // Use the function in a cell
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cellA1 = fWorksheet.getRange('A1');
* cellA1.setValue(100);
* const cellA2 = fWorksheet.getRange('A2');
* cellA2.setValue({ f: '=DISCOUNT(A1, 20)' });
*
* // A2 will display: 80
* formulaEngine.calculationEnd((functionsExecutedState) => {
* if (functionsExecutedState === 3) {
* console.log(cellA2.getValue()); // 80
* }
* })
* ```
* @example
* ```ts
* // Registered formulas support lambda functions
* const formulaEngine = univerAPI.getFormula();
* formulaEngine.registerFunction(
* 'CUSTOMSUM',
* (...variants) => {
* let sum = 0;
* const last = variants[variants.length - 1];
*
* if (last.isLambda && last.isLambda()) {
* variants.pop();
* const variantsList = variants.map((variant) => Array.isArray(variant) ? variant[0][0]: variant);
* sum += last.executeCustom(...variantsList).getValue();
* }
*
* for (const variant of variants) {
* sum += Number(variant) || 0;
* }
*
* return sum;
* },
* 'Adds its arguments'
* );
*
* // Use the function in a cell
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cellA1 = fWorksheet.getRange('A1');
* cellA1.setValue(1);
* const cellA2 = fWorksheet.getRange('A2');
* cellA2.setValue(2);
* const cellA3 = fWorksheet.getRange('A3');
* cellA3.setValue({ f: '=CUSTOMSUM(A1,A2,LAMBDA(x,y,x*y))' });
*
* // A3 will display: 5
* formulaEngine.calculationEnd((functionsExecutedState) => {
* if (functionsExecutedState === 3) {
* console.log(cellA3.getValue()); // 5
* }
* })
* ```
*/
registerFunction(name: string, func: IRegisterFunction, description?: string): IDisposable;
/**
* Register a custom synchronous formula function with localization support.
* @param {string} name - The name of the function to register. This will be used in formulas (e.g., =MYFUNC()).
* @param {IRegisterFunction} func - The implementation of the function.
* @param {{ locales?: ILocales; description?: string | IFunctionInfo }} [options] - Object containing locales and description.
* @param {ILocales} options.locales - Object containing locales.
* @param {string | IFunctionInfo} options.description - Object containing description.
* @returns {IDisposable} A disposable object that will unregister the function when disposed.
* @example
* ```js
* // Register a simple greeting function
* const formulaEngine = univerAPI.getFormula();
* formulaEngine.registerFunction(
* 'HELLO',
* (name) => `Hello, ${name}!`,
* {
* description: 'customFunction.HELLO.description',
* locales: {
* 'zhCN': {
* 'customFunction': {
* 'HELLO': {
* 'description': '一个简单的问候函数'
* }
* }
* },
* 'enUS': {
* 'customFunction': {
* 'HELLO': {
* 'description': 'A simple greeting function'
* }
* }
* }
* }
* }
* );
*
* // Use the function in a cell
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cellA1 = fWorksheet.getRange('A1');
* cellA1.setValue('World');
* const cellA2 = fWorksheet.getRange('A2');
* cellA2.setValue({ f: '=HELLO(A1)' });
*
* // A2 will display: "Hello, World!"
* formulaEngine.calculationEnd((functionsExecutedState) => {
* if (functionsExecutedState === 3) {
* console.log(cellA2.getValue()); // Hello, World!
* }
* })
* ```
*/
registerFunction(name: string, func: IRegisterFunction, { locales, description }: { locales?: ILocales; description?: string | IFunctionInfo }): IDisposable;
/**
* Register a custom asynchronous formula function.
* @param {string} name - The name of the function to register. This will be used in formulas (e.g., =ASYNCFUNC()).
* @param {IRegisterAsyncFunction} func - The async implementation of the function.
* @param {string} [description] - A string describing the function's purpose and usage.
* @returns {IDisposable} A disposable object that will unregister the function when disposed.
* @example
* ```ts
* const formulaEngine = univerAPI.getFormula();
* formulaEngine.registerAsyncFunction(
* 'RANDOM_DELAYED',
* async () => {
* await new Promise(resolve => setTimeout(resolve, 500));
* return Math.random();
* },
* 'Mock a random number generation function'
* );
*
* // Use the function in a cell
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cellA1 = fWorksheet.getRange('A1');
* cellA1.setValue({ f: '=RANDOM_DELAYED()' });
*
* // After 0.5 second, A1 will display a random number
* ```
*/
registerAsyncFunction(name: string, func: IRegisterAsyncFunction, description?: string): IDisposable;
/**
* Register a custom asynchronous formula function with description.
* @param {string} name - The name of the function to register. This will be used in formulas (e.g., =ASYNCFUNC()).
* @param {IRegisterAsyncFunction} func - The async implementation of the function.
* @param {{ locales?: ILocales; description?: string | IFunctionInfo }} [options] - Object containing locales and description.
* @param {ILocales} options.locales - Object containing locales.
* @param {string | IFunctionInfo} options.description - Object containing description.
* @returns {IDisposable} A disposable object that will unregister the function when disposed.
* @example
* ```ts
* // Mock a user score fetching function
* const formulaEngine = univerAPI.getFormula();
* formulaEngine.registerAsyncFunction(
* 'FETCH_USER_SCORE',
* async (userId) => {
* await new Promise(resolve => setTimeout(resolve, 1000));
* // Mock fetching user score from database
* return userId * 10 + Math.floor(Math.random() * 20);
* },
* {
* description: 'customFunction.FETCH_USER_SCORE.description',
* locales: {
* 'zhCN': {
* 'customFunction': {
* 'FETCH_USER_SCORE': {
* 'description': '从数据库中获取用户分数'
* }
* }
* },
* 'enUS': {
* 'customFunction': {
* 'FETCH_USER_SCORE': {
* 'description': 'Mock fetching user score from database'
* }
* }
* }
* }
* }
* );
*
* // Use the function in a cell
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cellA1 = fWorksheet.getRange('A1');
* cellA1.setValue({ f: '=FETCH_USER_SCORE(42)' });
*
* // After 1 second, A1 will display a score
* ```
*/
registerAsyncFunction(name: string, func: IRegisterAsyncFunction, { locales, description }: { locales?: ILocales; description?: string | IFunctionInfo }): IDisposable;
}
export class FFormulaSheetsMixin extends FFormula implements IFFormulaSheetsMixin {
/**
* RegisterFunction may be executed multiple times, triggering multiple formula forced refreshes.
*/
declare private _debouncedFormulaCalculation: () => void;
/**
* Initialize the FUniver instance.
* @ignore
*/
override _initialize(): void {
this._debouncedFormulaCalculation = debounce(() => {
this._commandService.executeCommand(
SetTriggerFormulaCalculationStartMutation.id,
{
commands: [],
forceCalculation: true,
},
{
onlyLocal: true,
}
);
}, 10);
}
override setInitialFormulaComputing(calculationMode: CalculationMode): void {
const lifecycleService = this._injector.get(LifecycleService);
const lifecycleStage = lifecycleService.stage;
const logService = this._injector.get(ILogService);
const configService = this._injector.get(IConfigService);
if (lifecycleStage > LifecycleStages.Starting) {
logService.warn('[FFormula]', 'CalculationMode is called after the Starting lifecycle and will take effect the next time the Univer Sheet is constructed. If you want it to take effect when the Univer Sheet is initialized this time, consider calling it before the Ready lifecycle or using configuration.');
}
const config = configService.getConfig<Partial<IUniverSheetsFormulaBaseConfig>>(PLUGIN_CONFIG_KEY_BASE);
if (!config) {
configService.setConfig(PLUGIN_CONFIG_KEY_BASE, { initialFormulaComputing: calculationMode });
return;
}
config.initialFormulaComputing = calculationMode;
}
override registerFunction(name: string, func: IRegisterFunction): IDisposable;
override registerFunction(name: string, func: IRegisterFunction, description: string): IDisposable;
override registerFunction(
name: string,
func: IRegisterFunction,
options?: string | { locales?: ILocales; description?: string | IFunctionInfo }
): IDisposable {
const registerFunctionService = this._injector.get(IRegisterFunctionService);
const params: ISingleFunctionRegisterParams = {
name,
func,
description: typeof options === 'string' ? options : options?.description ?? '',
locales: typeof options === 'object' ? options.locales : undefined,
};
const functionsDisposable = registerFunctionService.registerFunction(params);
this._debouncedFormulaCalculation();
return functionsDisposable;
}
override registerAsyncFunction(name: string, func: IRegisterAsyncFunction): IDisposable;
override registerAsyncFunction(name: string, func: IRegisterAsyncFunction, description: string): IDisposable;
override registerAsyncFunction(
name: string,
func: IRegisterAsyncFunction,
options?: string | { locales?: ILocales; description?: string | IFunctionInfo }
): IDisposable {
const registerFunctionService = this._injector.get(IRegisterFunctionService);
const params: ISingleFunctionRegisterParams = {
name,
func,
description: typeof options === 'string' ? options : options?.description ?? '',
locales: typeof options === 'object' ? options.locales : undefined,
};
const functionsDisposable = registerFunctionService.registerAsyncFunction(params);
this._debouncedFormulaCalculation();
return functionsDisposable;
}
}
FFormula.extend(FFormulaSheetsMixin);
declare module '@univerjs/engine-formula/facade' {
// eslint-disable-next-line ts/naming-convention
interface FFormula extends IFFormulaSheetsMixin {}
}

View File

@@ -0,0 +1,89 @@
/**
* 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 type { ISheetFormulaError } from '@univerjs/engine-formula';
import { extractFormulaError, FormulaDataModel } from '@univerjs/engine-formula';
import { FRange } from '@univerjs/sheets/facade';
/**
* @ignore
*/
export interface IFRangeEngineFormulaMixin {
/**
* Get formula errors in the current range
* @returns {ISheetFormulaError[]} Array of formula errors in the range
* @example
* ```typescript
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const range = fWorksheet.getRange('A1:B10');
* const errors = range.getFormulaError();
* console.log('Formula errors in range:', errors);
* ```
*/
getFormulaError(): ISheetFormulaError[];
}
/**
* @ignore
*/
export class FRangeEngineFormulaMixin extends FRange implements IFRangeEngineFormulaMixin {
override getFormulaError(): ISheetFormulaError[] {
const errors: ISheetFormulaError[] = [];
const unitId = this._workbook.getUnitId();
const sheetId = this._worksheet.getSheetId();
const sheetName = this._worksheet.getName();
const worksheet = this._workbook.getSheetBySheetId(sheetId);
if (!worksheet) return errors;
const arrayFormula = this._injector.get(FormulaDataModel).getArrayFormulaCellData();
const arrayFormulaSheet = arrayFormula?.[unitId]?.[sheetId] || {};
const cellMatrix = worksheet.getCellMatrix();
const { startRow, endRow, startColumn, endColumn } = this._range;
// Traverse cells in the current range
for (let row = startRow; row <= endRow; row++) {
for (let column = startColumn; column <= endColumn; column++) {
const cell = cellMatrix.getValue(row, column);
if (!cell) continue;
const arrayFormulaCellData = arrayFormulaSheet?.[row]?.[column];
const errorType = extractFormulaError(cell, !!arrayFormulaCellData);
// Check if the cell value is an error
if (errorType) {
errors.push({
sheetName,
row,
column,
formula: cell.f || '',
errorType,
});
}
}
}
return errors;
}
}
FRange.extend(FRangeEngineFormulaMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FRange extends IFRangeEngineFormulaMixin {}
}

View File

@@ -0,0 +1,82 @@
/**
* 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 type { ISheetFormulaError } from '@univerjs/engine-formula';
import { extractFormulaError, FormulaDataModel } from '@univerjs/engine-formula';
import { FWorkbook } from '@univerjs/sheets/facade';
/**
* @ignore
*/
export interface IFWorkbookEngineFormulaMixin {
/**
* Get all formula errors in the workbook
* @returns {ISheetFormulaError[]} Array of formula errors
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const errors = fWorkbook.getAllFormulaError();
* console.log('Formula errors:', errors);
* ```
*/
getAllFormulaError(): ISheetFormulaError[];
}
export class FWorkbookEngineFormulaMixin extends FWorkbook implements IFWorkbookEngineFormulaMixin {
override getAllFormulaError(): ISheetFormulaError[] {
const errors: ISheetFormulaError[] = [];
const workbook = this._workbook;
const unitId = workbook.getUnitId();
// Get all worksheets in the workbook
const worksheets = workbook.getSheets();
const arrayFormula = this._injector.get(FormulaDataModel).getArrayFormulaCellData();
worksheets.forEach((worksheet) => {
const sheetName = worksheet.getName();
const sheetId = worksheet.getSheetId();
const cellMatrix = worksheet.getCellMatrix();
const arrayFormulaSheet = arrayFormula?.[unitId]?.[sheetId] || {};
// Traverse all cells in the worksheet
cellMatrix.forValue((row, column, cell) => {
if (!cell) return;
const arrayFormulaCellData = arrayFormulaSheet?.[row]?.[column];
const errorType = extractFormulaError(cell, !!arrayFormulaCellData);
// Check if the cell value is an error
if (errorType) {
errors.push({
sheetName,
row,
column,
formula: cell.f || '',
errorType,
});
}
});
});
return errors;
}
}
FWorkbook.extend(FWorkbookEngineFormulaMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorkbook extends IFWorkbookEngineFormulaMixin { }
}

View File

@@ -0,0 +1,22 @@
/**
* 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 './f-formula';
import './f-enum';
import './f-workbook';
import './f-range';
export type * from './f-formula';

View File

@@ -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.
*/
export { InsertFunctionCommand } from './commands/commands/insert-function.command';
export type { IInsertFunction, IInsertFunctionCommandParams } from './commands/commands/insert-function.command';
export { CalculationMode, PLUGIN_CONFIG_KEY_BASE } from './config/config';
export type { IUniverSheetsFormulaBaseConfig, IUniverSheetsFormulaRemoteConfig } from './config/config';
export { FormulaAutoFillController } from './controllers/formula-auto-fill.controller';
export { ImageFormulaCellInterceptorController } from './controllers/image-formula-cell-interceptor.controller';
export { TriggerCalculationController } from './controllers/trigger-calculation.controller';
export { collectUnitQualifierFormulaPatches, UnitQualifierRenameController } from './controllers/unit-qualifier-rename.controller';
export { UpdateDefinedNameController } from './controllers/update-defined-name.controller';
export { UpdateFormulaController } from './controllers/update-formula.controller';
export { UniverRemoteSheetsFormulaPlugin, UniverSheetsFormulaPlugin } from './plugin';
export { FormulaRefRangeService } from './services/formula-ref-range.service';
export {
IRemoteRegisterFunctionService,
RemoteRegisterFunctionService,
} from './services/remote/remote-register-function.service';
export { calculateFormula } from './util/calculate';

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'جارٍ تحليل الصيغ...',
calculating: 'جارٍ حساب الصيغ...',
'array-analysis': 'جارٍ تحليل صيغ الصفائف...',
'array-calculation': 'جارٍ حساب صيغ الصفائف...',
done: 'تم',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Analitzant fórmules...',
calculating: 'Calculant fórmules...',
'array-analysis': 'Analitzant fórmules de matriu...',
'array-calculation': 'Calculant fórmules de matriu...',
done: 'Fet',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Formeln werden analysiert...',
calculating: 'Formeln werden berechnet...',
'array-analysis': 'Matrixformeln werden analysiert...',
'array-calculation': 'Matrixformeln werden berechnet...',
done: 'Fertig',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* 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.
*/
const locale = {
'sheets-formula': {
progress: {
analyzing: 'Analyzing formulas...',
calculating: 'Calculating formulas...',
'array-analysis': 'Analyzing array formulas...',
'array-calculation': 'Calculating array formulas...',
done: 'Done',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Analizando fórmulas...',
calculating: 'Calculando fórmulas...',
'array-analysis': 'Analizando fórmulas de matriz...',
'array-calculation': 'Calculando fórmulas de matriz...',
done: 'Listo',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'در حال تحلیل فرمول‌ها...',
calculating: 'در حال محاسبه فرمول‌ها...',
'array-analysis': 'در حال تحلیل فرمول‌های آرایه‌ای...',
'array-calculation': 'در حال محاسبه فرمول‌های آرایه‌ای...',
done: 'انجام شد',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Analyse des formules...',
calculating: 'Calcul des formules...',
'array-analysis': 'Analyse des formules matricielles...',
'array-calculation': 'Calcul des formules matricielles...',
done: 'Terminé',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Menganalisis rumus...',
calculating: 'Menghitung rumus...',
'array-analysis': 'Menganalisis rumus array...',
'array-calculation': 'Menghitung rumus array...',
done: 'Selesai',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Analisi delle formule...',
calculating: 'Calcolo delle formule...',
'array-analysis': 'Analisi delle formule matrice...',
'array-calculation': 'Calcolo delle formule matrice...',
done: 'Completato',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: '数式を分析中...',
calculating: '数式を計算中...',
'array-analysis': '配列数式を分析中...',
'array-calculation': '配列数式を計算中...',
done: '完了',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: '수식 분석 중...',
calculating: '수식 계산 중...',
'array-analysis': '배열 수식 분석 중...',
'array-calculation': '배열 수식 계산 중...',
done: '완료',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Analizowanie formuł...',
calculating: 'Obliczanie formuł...',
'array-analysis': 'Analizowanie formuł tablicowych...',
'array-calculation': 'Obliczanie formuł tablicowych...',
done: 'Gotowe',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Analisando fórmulas...',
calculating: 'Calculando fórmulas...',
'array-analysis': 'Analisando fórmulas de matriz...',
'array-calculation': 'Calculando fórmulas de matriz...',
done: 'Concluído',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Анализ формул...',
calculating: 'Вычисление формул...',
'array-analysis': 'Анализ формул массива...',
'array-calculation': 'Вычисление формул массива...',
done: 'Готово',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Analyzujú sa vzorce...',
calculating: 'Počítajú sa vzorce...',
'array-analysis': 'Analyzujú sa maticové vzorce...',
'array-calculation': 'Počítajú sa maticové vzorce...',
done: 'Hotovo',
},
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* 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 type { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: 'Đang phân tích công thức...',
calculating: 'Đang tính toán công thức...',
'array-analysis': 'Đang phân tích công thức mảng...',
'array-calculation': 'Đang tính toán công thức mảng...',
done: 'Hoàn tất',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: '正在分析公式...',
calculating: '正在计算公式...',
'array-analysis': '正在分析数组公式...',
'array-calculation': '正在计算数组公式...',
done: '完成',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: '正在分析公式...',
calculating: '正在計算公式...',
'array-analysis': '正在分析陣列公式...',
'array-calculation': '正在計算陣列公式...',
done: '完成',
},
},
};
export default locale;

View File

@@ -0,0 +1,31 @@
/**
* 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 type enUS from './en-US';
const locale: typeof enUS = {
'sheets-formula': {
progress: {
analyzing: '正在分析公式...',
calculating: '正在計算公式...',
'array-analysis': '正在分析陣列公式...',
'array-calculation': '正在計算陣列公式...',
done: '完成',
},
},
};
export default locale;

View File

@@ -0,0 +1,185 @@
/**
* 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 type { Dependency } from '@univerjs/core';
import type { IUniverSheetsFormulaBaseConfig, IUniverSheetsFormulaRemoteConfig } from './config/config';
import {
DependentOn,
IConfigService,
Inject,
Injector,
isNodeEnv,
merge,
Plugin,
touchDependencies,
UniverInstanceType,
} from '@univerjs/core';
import { IDescriptionService, UniverFormulaEnginePlugin } from '@univerjs/engine-formula';
import { fromModule, IRPCChannelService, toModule } from '@univerjs/rpc';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import pkg from '../package.json';
import { SHEETS_FORMULA_PLUGIN_NAME } from './common/plugin-name';
import {
defaultPluginBaseConfig,
defaultPluginRemoteConfig,
PLUGIN_CONFIG_KEY_BASE,
PLUGIN_CONFIG_KEY_REMOTE,
} from './config/config';
import { ActiveDirtyController } from './controllers/active-dirty.controller';
import { ArrayFormulaCellInterceptorController } from './controllers/array-formula-cell-interceptor.controller';
import { DefinedNameController } from './controllers/defined-name.controller';
import { FormulaAutoFillController } from './controllers/formula-auto-fill.controller';
import { FormulaController } from './controllers/formula.controller';
import { ImageFormulaCellInterceptorController } from './controllers/image-formula-cell-interceptor.controller';
import {
SheetFormulaCalculationResultApplyController,
} from './controllers/sheet-formula-calculation-result-apply.controller';
import { SuperTableController } from './controllers/super-table.controller';
import { TriggerCalculationController } from './controllers/trigger-calculation.controller';
import { UnitQualifierRenameController } from './controllers/unit-qualifier-rename.controller';
import { UpdateDefinedNameController } from './controllers/update-defined-name.controller';
import { UpdateFormulaController } from './controllers/update-formula.controller';
import { FormulaRefRangeService } from './services/formula-ref-range.service';
import {
IRemoteRegisterFunctionService,
RemoteRegisterFunctionService,
RemoteRegisterFunctionServiceName,
} from './services/remote/remote-register-function.service';
@DependentOn(UniverFormulaEnginePlugin)
export class UniverRemoteSheetsFormulaPlugin extends Plugin {
static override pluginName = 'SHEET_FORMULA_REMOTE_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsFormulaRemoteConfig> = defaultPluginRemoteConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginRemoteConfig,
this._config
);
this._configService.setConfig(PLUGIN_CONFIG_KEY_REMOTE, rest);
}
override onStarting(): void {
this._injector.add([RemoteRegisterFunctionService]);
this._injector.get(IRPCChannelService).registerChannel(
RemoteRegisterFunctionServiceName,
fromModule(this._injector.get(RemoteRegisterFunctionService))
);
}
}
@DependentOn(UniverFormulaEnginePlugin, UniverSheetsPlugin)
export class UniverSheetsFormulaPlugin extends Plugin {
static override pluginName = SHEETS_FORMULA_PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsFormulaBaseConfig> = defaultPluginBaseConfig,
@Inject(Injector) override readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginBaseConfig,
this._config
);
this._configService.setConfig(PLUGIN_CONFIG_KEY_BASE, rest, { merge: true });
}
override onStarting(): void {
const j = this._injector;
const dependencies: Dependency[] = [
[SheetFormulaCalculationResultApplyController],
[FormulaController],
[FormulaRefRangeService],
[ArrayFormulaCellInterceptorController],
[ImageFormulaCellInterceptorController],
[TriggerCalculationController],
[UpdateFormulaController],
[ActiveDirtyController],
[DefinedNameController],
[UpdateDefinedNameController],
[SuperTableController],
[FormulaAutoFillController],
[UnitQualifierRenameController],
];
// If the plugin do not execute formula, it should delegate a remote proxy.
// So custom functions should be synced to that remote calculator.
if (this._config.notExecuteFormula) {
const rpcChannelService = j.get(IRPCChannelService);
dependencies.push([IRemoteRegisterFunctionService, {
useFactory: () => toModule<IRemoteRegisterFunctionService>(rpcChannelService.requestChannel(RemoteRegisterFunctionServiceName)),
}]);
}
dependencies.forEach((dependency) => j.add(dependency));
if (this._config.description?.length) {
this.disposeWithMe(j.get(IDescriptionService).registerDescriptions(this._config.description));
}
}
override onReady(): void {
touchDependencies(this._injector, [
[FormulaController],
[ActiveDirtyController],
[ArrayFormulaCellInterceptorController],
[ImageFormulaCellInterceptorController],
[UpdateFormulaController],
[UpdateDefinedNameController],
[FormulaAutoFillController],
[UnitQualifierRenameController],
[SheetFormulaCalculationResultApplyController],
]);
// There is no rendering in the nodejs environment, so initialize it here
if (isNodeEnv()) {
touchDependencies(this._injector, [
[TriggerCalculationController],
]);
}
}
override onRendered(): void {
touchDependencies(this._injector, [
[DefinedNameController],
[SuperTableController],
]);
// Wait for rendering to complete before initializing formula calculation
if (!isNodeEnv()) {
touchDependencies(this._injector, [
[TriggerCalculationController],
]);
}
}
}

View File

@@ -0,0 +1,46 @@
/**
* 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 type { ITestBed } from './util';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FormulaRefRangeService } from '../formula-ref-range.service';
import { createCommandTestBed } from './util';
describe('FormulaRefRangeService service coverage', () => {
let testBed: ITestBed;
beforeEach(() => {
testBed = createCommandTestBed();
});
afterEach(() => {
testBed.univer.dispose();
});
it('updates sheet formula references when a referenced range moves', () => {
const service = testBed.get(FormulaRefRangeService);
const transformedFormula = service.transformFormulaByEffectCommand('test', 'sheet1', '=SUM(A1)', {
id: 'sheet.command.move-range',
params: {
fromRange: { startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 },
toRange: { startRow: 2, endRow: 2, startColumn: 1, endColumn: 1 },
},
});
expect(transformedFormula).toBe('=SUM(B3)');
});
});

View File

@@ -0,0 +1,456 @@
/**
* 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 type { IRange, IWorkbookData } from '@univerjs/core';
import type { ITestBed } from './util';
import { Direction, ICommandService, LocaleType } from '@univerjs/core';
import { InsertColCommand, MoveRangeCommand, RemoveColCommand } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FormulaRefRangeService } from '../formula-ref-range.service';
import { createCommandTestBed } from './util';
const MoveRangeCommandId = 'sheet.command.move-range';
function createMultiSheetWorkbookData(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
cellData: {
0: {
0: {
v: 1,
},
},
},
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
cellData: {
0: {
0: {
v: 2,
},
},
},
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: ['sheet1', 'sheet2'],
styles: {},
};
}
describe('FormulaRefRangeService', () => {
let testBed: ITestBed;
beforeEach(() => {
testBed = createCommandTestBed();
});
afterEach(() => {
testBed.univer.dispose();
});
it('transform formula string with move range', () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
const resFormula = formulaRefRangeService.transformFormulaByEffectCommand(
'test',
'sheet1',
'=SUM(A1)',
{
id: MoveRangeCommandId,
params: {
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
},
}
);
expect(resFormula).toBe('=SUM(B3)');
});
it('not transform formula string with move range', () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
const resFormula = formulaRefRangeService.transformFormulaByEffectCommand(
'test',
'sheet1',
'=SUM(A1:A10)',
{
id: MoveRangeCommandId,
params: {
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
},
}
);
expect(resFormula).toBe('=SUM(A1:A10)');
});
it('only transforms references on the current sheet', () => {
testBed.univer.dispose();
testBed = createCommandTestBed(createMultiSheetWorkbookData());
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
const resFormula = formulaRefRangeService.transformFormulaByEffectCommand(
'test',
'sheet1',
'=SUM(Sheet2!A1)+SUM(A1)',
{
id: MoveRangeCommandId,
params: {
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
},
}
);
expect(resFormula).toBe('=SUM(Sheet2!A1)+SUM(B3)');
});
it('throws when the referenced sheet cannot be resolved', () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
expect(() => formulaRefRangeService.transformFormulaByEffectCommand(
'test',
'sheet1',
'=SUM(Sheet2!A1)',
{
id: MoveRangeCommandId,
params: {
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
},
}
)).toThrowError('Sheet not found');
});
it('transform range formula string with move range', async () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
let newFormulas: {
formulas: string[];
ranges: IRange[];
}[] = [];
formulaRefRangeService.registerRangeFormula(
'test',
'sheet1',
[{
startRow: 5,
endRow: 10,
startColumn: 5,
endColumn: 5,
}],
['=SUM(A1)'],
(res) => {
newFormulas = res;
return {
redos: [],
undos: [],
};
}
);
await testBed.get(ICommandService).executeCommand(
MoveRangeCommand.id,
{
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
}
);
expect(newFormulas).toEqual(
[
{ formulas: ['=SUM(A3)'], ranges: [{ startColumn: 5, endColumn: 5, startRow: 7, endRow: 10 }] },
{ formulas: ['=SUM(B3)'], ranges: [{ startColumn: 5, endColumn: 5, startRow: 5, endRow: 6 }] },
]
);
});
it('not transform range formula string with move range', async () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
let newFormulas: {
formulas: string[];
ranges: IRange[];
}[] = [];
formulaRefRangeService.registerRangeFormula(
'test',
'sheet1',
[{
startRow: 5,
endRow: 10,
startColumn: 5,
endColumn: 5,
}],
['=SUM(A1:A10)'],
(res) => {
newFormulas = res;
return {
redos: [],
undos: [],
};
}
);
await testBed.get(ICommandService).executeCommand(
MoveRangeCommand.id,
{
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
}
);
expect(newFormulas).toEqual(
[
{
formulas: ['=SUM(A1:A10)'],
ranges: [{
startRow: 5,
endRow: 10,
startColumn: 5,
endColumn: 5,
}],
},
]
);
});
it('transform range formula string with delete col', async () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
let newFormulas: {
formulas: string[];
ranges: IRange[];
}[] = [];
formulaRefRangeService.registerRangeFormula(
'test',
'sheet1',
[{
startRow: 5,
endRow: 10,
startColumn: 5,
endColumn: 10,
}],
['=A1'],
(res) => {
newFormulas = res;
return {
redos: [],
undos: [],
};
}
);
await testBed.get(ICommandService).executeCommand(
RemoveColCommand.id,
{
range: {
startColumn: 0,
endColumn: 1,
startRow: 0,
endRow: 9999,
},
}
);
expect(newFormulas).toEqual(
[
{ formulas: ['=A1'], ranges: [{ startColumn: 5, endColumn: 8, startRow: 5, endRow: 10 }] },
{ formulas: ['=#REF!'], ranges: [{ startColumn: 3, endColumn: 4, startRow: 5, endRow: 10 }] },
]
);
});
it('transform range formula string with insert col', async () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
let newFormulas: {
formulas: string[];
ranges: IRange[];
}[] = [];
formulaRefRangeService.registerRangeFormula(
'test',
'sheet1',
[{
startRow: 5,
endRow: 10,
startColumn: 5,
endColumn: 10,
}],
['=A1'],
(res) => {
newFormulas = res;
return {
redos: [],
undos: [],
};
}
);
await testBed.get(ICommandService).executeCommand(
InsertColCommand.id,
{
unitId: 'test',
subUnitId: 'sheet1',
range: {
startColumn: 0,
endColumn: 1,
startRow: 0,
endRow: 9999,
},
direction: Direction.LEFT,
}
);
expect(newFormulas).toEqual(
[
{
formulas: ['=C1'],
ranges: [{ startColumn: 7, endColumn: 12, startRow: 5, endRow: 10 }],
},
]
);
});
it('stops listening for range changes after registerFormula is disposed', async () => {
const formulaRefRangeService = testBed.get(FormulaRefRangeService);
const updatedFormulas: string[] = [];
const disposable = formulaRefRangeService.registerFormula(
'test',
'sheet1',
'=SUM(A1)',
(formula) => {
updatedFormulas.push(formula);
return {
redos: [],
undos: [],
};
}
);
await testBed.get(ICommandService).executeCommand(
MoveRangeCommand.id,
{
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
}
);
disposable.dispose();
await testBed.get(ICommandService).executeCommand(
MoveRangeCommand.id,
{
fromRange: {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
},
toRange: {
startRow: 2,
endRow: 2,
startColumn: 1,
endColumn: 1,
},
}
);
expect(updatedFormulas).toEqual(['=SUM(B3)']);
});
});

View File

@@ -0,0 +1,140 @@
/**
* 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 type { Dependency, IWorkbookData, Workbook } from '@univerjs/core';
import { ICommandService, ILogService, Inject, Injector, IUniverInstanceService, LocaleType, LogLevel, Plugin, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { CalculateFormulaService, DefinedNamesService, FormulaCurrentConfigService, FormulaDataModel, FormulaRuntimeService, ICalculateFormulaService, IDefinedNamesService, IFormulaCurrentConfigService, IFormulaRuntimeService, LexerTreeBuilder } from '@univerjs/engine-formula';
import { DefinedNameDataController, InsertColByRangeCommand, InsertColCommand, InsertRowByRangeCommand, IRefSelectionsService, MoveColsCommand, MoveRangeCommand, RangeProtectionRuleModel, RefRangeService, RemoveColByRangeCommand, RemoveColCommand, RemoveRowByRangeCommand, SheetInterceptorService, SheetsSelectionsService, WorkbookPermissionService, WorksheetPermissionService, WorksheetProtectionPointModel, WorksheetProtectionRuleModel } from '@univerjs/sheets';
import { FormulaRefRangeService } from '../formula-ref-range.service';
const TEST_WORKBOOK_DATA_DEMO: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 1,
},
1: {
f: '=SUM(A1)',
},
},
1: {
1: {
v: 1,
},
},
},
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: Workbook;
}
// eslint-disable-next-line max-lines-per-function
export function createCommandTestBed(workbookData?: IWorkbookData, dependencies?: Dependency[]): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class TestPlugin extends Plugin {
static override pluginName = 'test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
private _formulaDataModel: FormulaDataModel | null = null;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
// injector.add([ICommandService, { useClass: CommandService }]);
injector.add([WorksheetPermissionService]);
injector.add([WorksheetProtectionPointModel]);
injector.add([RangeProtectionRuleModel]);
injector.add([WorkbookPermissionService]);
injector.add([WorksheetProtectionRuleModel]);
injector.add([SheetsSelectionsService]);
injector.add([SheetInterceptorService]);
injector.add([ICalculateFormulaService, { useClass: CalculateFormulaService }]);
injector.add([FormulaDataModel]);
injector.add([LexerTreeBuilder]);
injector.add([DocSelectionManagerService]);
injector.add([IDefinedNamesService, { useClass: DefinedNamesService }]);
injector.add([DefinedNameDataController]);
injector.add([IFormulaRuntimeService, { useClass: FormulaRuntimeService }]);
injector.add([IFormulaCurrentConfigService, { useClass: FormulaCurrentConfigService }]);
injector.add([IRefSelectionsService, { useClass: SheetsSelectionsService }]);
injector.add([RefRangeService]);
injector.add([FormulaRefRangeService]);
dependencies?.forEach((d) => injector.add(d));
this._injector.get(SheetInterceptorService);
this._injector.get(WorkbookPermissionService);
this._injector.get(WorksheetPermissionService);
this._injector.get(DefinedNameDataController);
[
MoveRangeCommand,
RemoveColCommand,
InsertColCommand,
MoveColsCommand,
RemoveColByRangeCommand,
RemoveRowByRangeCommand,
InsertColByRangeCommand,
InsertRowByRangeCommand,
].forEach((command) => {
this._injector.get(ICommandService).registerCommand(command);
});
}
override onReady(): void {
this._formulaDataModel = get(FormulaDataModel);
}
}
univer.registerPlugin(TestPlugin);
const sheet = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookData || TEST_WORKBOOK_DATA_DEMO);
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT); // change this to `LogLevel.VERBOSE` to debug tests via logs
return {
univer,
get,
sheet,
};
}

View File

@@ -0,0 +1,66 @@
/**
* 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 type { IFunctionInfo } from '@univerjs/engine-formula';
import { LocaleService, LocaleType, Univer } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { generateParam, getFunctionName } from '../utils';
describe('sheets-formula service utils', () => {
let univer: Univer;
let localeService: LocaleService;
beforeEach(() => {
univer = new Univer();
localeService = univer.__getInjector().get(LocaleService);
localeService.load({
[LocaleType.ZH_CN]: {},
[LocaleType.EN_US]: {
custom: {
alias: 'ALIAS_NAME',
},
},
});
});
afterEach(() => {
univer.dispose();
});
it('prefers translated alias names and falls back to the original function name', () => {
const aliasInfo = {
functionName: 'FUNC_NAME',
aliasFunctionName: 'custom.alias',
} as IFunctionInfo;
const noAliasInfo = {
functionName: 'FALLBACK_NAME',
} as IFunctionInfo;
expect(getFunctionName(aliasInfo, localeService)).toBe('FUNC_NAME');
localeService.setLocale(LocaleType.EN_US);
expect(getFunctionName(aliasInfo, localeService)).toBe('ALIAS_NAME');
expect(getFunctionName(noAliasInfo, localeService)).toBe('FALLBACK_NAME');
});
it('formats function parameters for each require and repeat combination', () => {
expect(generateParam({ name: 'arg', detail: '', example: '', require: 0, repeat: 0 })).toBe('[arg]');
expect(generateParam({ name: 'arg', detail: '', example: '', require: 1, repeat: 0 })).toBe('arg');
expect(generateParam({ name: 'arg', detail: '', example: '', require: 0, repeat: 1 })).toBe('[arg,...]');
expect(generateParam({ name: 'arg', detail: '', example: '', require: 1, repeat: 1 })).toBe('arg,...');
});
});

View File

@@ -0,0 +1,425 @@
/**
* 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 type { IDisposable, IMutationInfo, IRange, Workbook } from '@univerjs/core';
import type { EffectRefRangeParams } from '@univerjs/sheets';
import { AbsoluteRefType, Disposable, DisposableCollection, getIntersectRange, Inject, Injector, isFormulaString, IUniverInstanceService, moveRangeByOffset, Rectangle, UniverInstanceType } from '@univerjs/core';
import { deserializeRangeWithSheetWithCache, ErrorType, generateStringWithSequence, LexerTreeBuilder, sequenceNodeType, serializeRange, serializeRangeWithSheet, serializeRangeWithSpreadsheet } from '@univerjs/engine-formula';
import { getSeparateEffectedRangesOnCommand, handleCommonDefaultRangeChangeWithEffectRefCommands, handleDefaultRangeChangeWithEffectRefCommands, RefRangeService } from '@univerjs/sheets';
export type FormulaChangeMap = Record<string, Record<string, Record<string, string>>>;
export type FormulaChangeCallback = (formulaString: string) => {
redos: IMutationInfo[];
undos: IMutationInfo[];
};
export type RangeFormulaChangeCallback = (infos: { formulas: string[]; ranges: IRange[] }[]) => {
redos: IMutationInfo[];
undos: IMutationInfo[];
};
function getFormulaKeyOffset(lexerTreeBuilder: LexerTreeBuilder, formulaString: string, refOffsetX: number, refOffsetY: number) {
const sequenceNodes = lexerTreeBuilder.sequenceNodesBuilder(formulaString);
if (sequenceNodes == null) {
return formulaString;
}
const newSequenceNodes: Array<{ unitId: string; sheetName: string; range: IRange }> = [];
for (let i = 0, len = sequenceNodes.length; i < len; i++) {
const node = sequenceNodes[i];
if (typeof node === 'string' || node.nodeType !== sequenceNodeType.REFERENCE) {
continue;
}
const { token } = node;
const sequenceGrid = deserializeRangeWithSheetWithCache(token);
const { sheetName, unitId: sequenceUnitId } = sequenceGrid;
let newRange: IRange = sequenceGrid.range;
if (newRange.startAbsoluteRefType === AbsoluteRefType.ALL && newRange.endAbsoluteRefType === AbsoluteRefType.ALL) {
continue;
} else {
newRange = moveRangeByOffset(newRange, refOffsetX, refOffsetY);
}
newSequenceNodes.push({
unitId: sequenceUnitId,
sheetName,
range: newRange,
});
}
return newSequenceNodes.map((item) => `${item.unitId}!${item.sheetName}!${item.range.startRow}!${item.range.endRow}!${item.range.startColumn}!${item.range.endColumn}`).join('|');
}
export class FormulaRefRangeService extends Disposable {
constructor(
@Inject(RefRangeService) private readonly _refRangeService: RefRangeService,
@Inject(LexerTreeBuilder) private readonly _lexerTreeBuilder: LexerTreeBuilder,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(Injector) private readonly _injector: Injector
) {
super();
}
transformFormulaByEffectCommand(unitId: string, subUnitId: string, formula: string, params: EffectRefRangeParams) {
const sequenceNodes = this._lexerTreeBuilder.sequenceNodesBuilder(formula);
const currentUnit = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const currentSheet = currentUnit.getActiveSheet();
const currentUnitId = currentUnit.getUnitId();
const currentSheetId = currentSheet.getSheetId();
const transformSequenceNodes = sequenceNodes?.map((node) => {
if (typeof node === 'object' && node.nodeType === sequenceNodeType.REFERENCE) {
const gridRangeName = deserializeRangeWithSheetWithCache(node.token);
const { range, unitId: rangeUnitId, sheetName: rangeSheetName } = gridRangeName;
const workbook = this._univerInstanceService.getUnit<Workbook>(rangeUnitId || unitId);
const worksheet = rangeSheetName ? workbook?.getSheetBySheetName(rangeSheetName) : workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) {
throw new Error('Sheet not found');
}
const realUnitId = workbook!.getUnitId();
const realSheetId = worksheet.getSheetId();
if ((realUnitId !== currentUnitId) || (realSheetId !== currentSheetId)) {
return node;
}
const newRange = handleDefaultRangeChangeWithEffectRefCommands(range, params);
let newToken = '';
if (newRange) {
const offsetX = newRange.startColumn - range.startColumn;
const offsetY = newRange.startRow - range.startRow;
const finalRange = moveRangeByOffset(range, offsetX, offsetY);
if (rangeUnitId && rangeSheetName) {
newToken = serializeRangeWithSpreadsheet(rangeUnitId, rangeSheetName, finalRange);
} else if (rangeSheetName) {
newToken = serializeRangeWithSheet(rangeSheetName, finalRange);
} else {
newToken = serializeRange(finalRange);
}
} else {
newToken = ErrorType.REF;
}
return {
...node,
token: newToken,
};
} else {
return node;
};
});
return transformSequenceNodes ? `=${generateStringWithSequence(transformSequenceNodes)}` : '';
}
registerFormula(unitId: string, subUnitId: string, formula: string, callback: FormulaChangeCallback): IDisposable {
const rangeMap = new Map<string, { unitId: string; subUnitId: string; range: IRange; sheetName: string }>();
const sequenceNodes = this._lexerTreeBuilder.sequenceNodesBuilder(formula);
const disposableCollection = new DisposableCollection();
const handleChange = (params: EffectRefRangeParams) => {
const currentUnit = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const currentSheet = currentUnit.getActiveSheet();
const currentUnitId = currentUnit.getUnitId();
const currentSheetId = currentSheet.getSheetId();
const transformSequenceNodes = sequenceNodes?.map((node) => {
if (typeof node === 'object' && node.nodeType === sequenceNodeType.REFERENCE) {
const rangeInfo = rangeMap.get(node.token)!;
if ((rangeInfo.unitId !== currentUnitId) || (rangeInfo.subUnitId !== currentSheetId)) {
return node;
}
const newRange = handleDefaultRangeChangeWithEffectRefCommands(rangeInfo.range, params);
let newToken = '';
if (newRange) {
const offsetX = newRange.startColumn - rangeInfo.range.startColumn;
const offsetY = newRange.startRow - rangeInfo.range.startRow;
const finalRange = moveRangeByOffset(rangeInfo.range, offsetX, offsetY);
if (rangeInfo.unitId && rangeInfo.sheetName) {
newToken = serializeRangeWithSpreadsheet(rangeInfo.unitId, rangeInfo.sheetName, finalRange);
} else if (rangeInfo.sheetName) {
newToken = serializeRangeWithSheet(rangeInfo.sheetName, finalRange);
} else {
newToken = serializeRange(finalRange);
}
} else {
newToken = ErrorType.REF;
}
return {
...node,
token: newToken,
};
} else {
return node;
};
});
const newFormulaString = transformSequenceNodes && generateStringWithSequence(transformSequenceNodes);
return callback(`=${newFormulaString}`);
};
sequenceNodes?.forEach((node) => {
if (typeof node === 'object' && node.nodeType === sequenceNodeType.REFERENCE) {
const gridRangeName = deserializeRangeWithSheetWithCache(node.token);
const { range, unitId: rangeUnitId, sheetName: rangeSheetName } = gridRangeName;
const workbook = this._univerInstanceService.getUnit<Workbook>(rangeUnitId || unitId);
const worksheet = rangeSheetName ? workbook?.getSheetBySheetName(rangeSheetName) : workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) {
return;
}
const realUnitId = workbook!.getUnitId();
const realSheetId = worksheet.getSheetId();
const item = {
unitId: realUnitId,
subUnitId: realSheetId,
range,
sheetName: rangeSheetName,
};
rangeMap.set(node.token, item);
disposableCollection.add(this._refRangeService.registerRefRange(range, handleChange, realUnitId, realSheetId));
}
});
return disposableCollection;
}
private _getFormulaDependcy(unitId: string, subUnitId: string, formula: string | undefined, ranges: IRange[]) {
const nodes = isFormulaString(formula) ? this._lexerTreeBuilder.sequenceNodesBuilder(formula!) : null;
const dependencyRanges: { unitId: string; subUnitId: string; ranges: IRange[] }[] = [];
nodes?.forEach((node) => {
if (typeof node === 'object' && node.nodeType === sequenceNodeType.REFERENCE) {
const gridRangeName = deserializeRangeWithSheetWithCache(node.token);
const { range, unitId: rangeUnitId, sheetName: rangeSheetName } = gridRangeName;
// ignore all absolute reference
if (range.startAbsoluteRefType === AbsoluteRefType.ALL && range.endAbsoluteRefType === AbsoluteRefType.ALL) {
return;
}
const workbook = this._univerInstanceService.getUnit<Workbook>(rangeUnitId || unitId);
const worksheet = rangeSheetName ? workbook?.getSheetBySheetName(rangeSheetName) : workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) {
return;
}
const realUnitId = workbook!.getUnitId();
const realSheetId = worksheet.getSheetId();
const orginStartRow = ranges[0].startRow;
const orginStartColumn = ranges[0].startColumn;
const currentStartRow = range.startRow;
const currentStartColumn = range.startColumn;
const offsetRanges = ranges.map((range) => ({
startRow: range.startRow - orginStartRow + currentStartRow,
endRow: range.endRow - orginStartRow + currentStartRow,
startColumn: range.startColumn - orginStartColumn + currentStartColumn,
endColumn: range.endColumn - orginStartColumn + currentStartColumn,
}));
dependencyRanges.push({
unitId: realUnitId,
subUnitId: realSheetId,
ranges: offsetRanges,
});
}
});
return dependencyRanges;
}
// eslint-disable-next-line max-lines-per-function
registerRangeFormula(unitId: string, subUnitId: string, oldRanges: IRange[], formulas: string[], callback: RangeFormulaChangeCallback): IDisposable {
const disposableCollection = new DisposableCollection();
const formulaDeps = formulas.map((formula) => this._getFormulaDependcy(unitId, subUnitId, formula, oldRanges));
// eslint-disable-next-line max-lines-per-function
const handleRangeChange = (commandInfo: EffectRefRangeParams) => {
const effectedRanges = getSeparateEffectedRangesOnCommand(this._injector, commandInfo);
if (!effectedRanges) {
return {
undos: [],
redos: [],
};
}
const originStartRow = oldRanges[0].startRow;
const originStartColumn = oldRanges[0].startColumn;
const deps = [{ unitId, subUnitId, ranges: oldRanges }, ...formulaDeps.flat()];
const matchedEffectedRanges: IRange[][] = [];
// 1. calculate effected ranges
for (const { unitId: depUnitId, subUnitId: depSubUnitId, ranges } of deps) {
if (depUnitId === effectedRanges.unitId && depSubUnitId === effectedRanges.subUnitId) {
const intersectedRanges: IRange[] = [];
const currentStartRow = ranges[0].startRow;
const currentStartColumn = ranges[0].startColumn;
const offsetRow = currentStartRow - originStartRow;
const offsetColumn = currentStartColumn - originStartColumn;
for (const range of effectedRanges.ranges) {
const intersectedRange: IRange[] = [];
for (const r of ranges) {
const intersect = getIntersectRange(range, r);
if (intersect) {
intersectedRange.push(intersect);
}
}
if (intersectedRange.length > 0) {
intersectedRanges.push(...intersectedRange);
}
}
if (intersectedRanges.length > 0) {
matchedEffectedRanges.push(
intersectedRanges.map((range) => ({
startRow: range.startRow - offsetRow,
endRow: range.endRow - offsetRow,
startColumn: range.startColumn - offsetColumn,
endColumn: range.endColumn - offsetColumn,
}))
);
}
}
}
if (matchedEffectedRanges.length > 0) {
// 2. split effected ranges to enusre there is no overlap
const ranges = Rectangle.splitIntoGrid([...matchedEffectedRanges.flat()]);
const noEffectRanges = Rectangle.subtractMulti(oldRanges, ranges);
noEffectRanges.sort((a, b) => a.startRow - b.startRow || a.startColumn - b.startColumn);
// 3. calculate every effected range to get new range and new formula
const keyMap = new Map<string, { formulas: { newFormula: string }[]; ranges: IRange[] }[]>();
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
const currentRow = range.startRow;
const currentColumn = range.startColumn;
const offsetRow = currentRow - originStartRow;
const offsetColumn = currentColumn - originStartColumn;
const transformedRange = handleCommonDefaultRangeChangeWithEffectRefCommands(range, commandInfo).sort((a, b) => a.startRow - b.startRow || a.startColumn - b.startColumn);
if (!transformedRange.length) {
continue;
}
const transformedRow = transformedRange[0].startRow;
const transformedColumn = transformedRange[0].startColumn;
const transformedOffsetRow = transformedRow - originStartRow;
const transformedOffsetColumn = transformedColumn - originStartColumn;
const transformedFormulas = [];
for (let j = 0; j < formulas.length; j++) {
const formula = formulas[j];
const isFormulaFormulaString = isFormulaString(formula);
// 3.1 move formula ref offset to get formula string for this range before this command
const formulaString = isFormulaFormulaString ? this._lexerTreeBuilder.moveFormulaRefOffset(formula!, offsetColumn, offsetRow) : formula!;
// 3.2 transform formula by effect command
const newFormula = isFormulaFormulaString ? this.transformFormulaByEffectCommand(unitId, subUnitId, formulaString, commandInfo) : formulaString;
// 3.3 get formula key offset for this range after this command
const orginFormula = getFormulaKeyOffset(this._lexerTreeBuilder, newFormula, -transformedOffsetColumn, -transformedOffsetRow);
transformedFormulas.push({
newFormula,
orginFormula,
});
}
const item = {
formulas: transformedFormulas,
ranges: transformedRange,
key: transformedFormulas.map((item) => item.orginFormula).join('_'),
};
if (keyMap.has(item.key)) {
keyMap.get(item.key)!.push(item);
} else {
keyMap.set(item.key, [item]);
}
}
// 4. handle no effected ranges
const originKey = formulas.map((item) => getFormulaKeyOffset(this._lexerTreeBuilder, item, 0, 0)).join('_');
if (noEffectRanges.length > 0) {
const currentRow = noEffectRanges[0].startRow;
const currentColumn = noEffectRanges[0].startColumn;
const noEffectFormulas = [];
for (let i = 0; i < formulas.length; i++) {
const formula = formulas[i];
noEffectFormulas.push({
newFormula: isFormulaString(formula) ? this._lexerTreeBuilder.moveFormulaRefOffset(formula, currentColumn - originStartColumn, currentRow - originStartRow) : formula,
orginFormula: formula,
});
}
const item = {
formulas: noEffectFormulas,
ranges: noEffectRanges,
key: originKey,
};
if (keyMap.has(item.key)) {
keyMap.get(item.key)!.push(item);
} else {
keyMap.set(item.key, [item]);
}
}
// 5. merge effected ranges and get new formula,
// if origin formula was same, means these ranges can use same formula
const res = [];
const keys = Array.from(keyMap.keys());
for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i];
const ranges = keyMap.get(key)!.sort((a, b) => a.ranges[0].startRow - b.ranges[0].startRow || a.ranges[0].startColumn - b.ranges[0].startColumn);
const formulas = [];
for (let j = 0; j < ranges[0].formulas.length; j++) {
formulas.push(ranges[0].formulas[j].newFormula);
}
const newRanges = Rectangle.mergeRanges(ranges.map((item) => item.ranges).flat());
newRanges.sort((a, b) => a.startRow - b.startRow || a.startColumn - b.startColumn);
res.push({
formulas,
ranges: newRanges,
});
}
return callback(res);
}
return {
undos: [],
redos: [],
};
};
oldRanges.forEach((range) => {
const disposable = this._refRangeService.registerRefRange(range, handleRangeChange, unitId, subUnitId);
disposableCollection.add(disposable);
});
[...formulaDeps.flat()].forEach(({ unitId, subUnitId, ranges }) => {
ranges.forEach((range) => {
const disposable = this._refRangeService.registerRefRange(range, handleRangeChange, unitId, subUnitId);
disposableCollection.add(disposable);
});
});
return disposableCollection;
}
}

View File

@@ -0,0 +1,70 @@
/**
* 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 { Injector } from '@univerjs/core';
import { FunctionService, IFunctionService } from '@univerjs/engine-formula';
import { describe, expect, it } from 'vitest';
import { IRemoteRegisterFunctionService, RemoteRegisterFunctionService } from '../remote-register-function.service';
function createService(): { service: IRemoteRegisterFunctionService; functionService: IFunctionService } {
const injector = new Injector();
injector.add([IFunctionService, { useClass: FunctionService }]);
injector.add([IRemoteRegisterFunctionService, { useClass: RemoteRegisterFunctionService }]);
return {
service: injector.get(IRemoteRegisterFunctionService),
functionService: injector.get(IFunctionService),
};
}
describe('RemoteRegisterFunctionService', () => {
it('rejects serialized remote formula executors without evaluating them', async () => {
const { service, functionService } = createService();
const marker = '__REMOTE_REGISTER_FUNCTION_SERVICE_POC_EXECUTED__';
delete (globalThis as Record<string, unknown>)[marker];
await expect(service.registerFunctions([
[`(() => { globalThis.${marker} = true; return (value) => value + 1; })()`, 'REMOTE_ADD_ONE'],
])).rejects.toThrow('Remote custom function registration is disabled');
await expect(service.registerAsyncFunctions([
[`(() => { globalThis.${marker} = true; return async (value) => value * 2; })()`, 'REMOTE_DOUBLE'],
])).rejects.toThrow('Remote custom function registration is disabled');
expect((globalThis as Record<string, unknown>)[marker]).toBeUndefined();
expect(functionService.hasExecutor('REMOTE_ADD_ONE')).toBe(false);
expect(functionService.hasExecutor('REMOTE_DOUBLE')).toBe(false);
delete (globalThis as Record<string, unknown>)[marker];
});
it('removes remote formula executors and their metadata', async () => {
const { service, functionService } = createService();
functionService.registerDescriptions({
functionName: 'REMOTE_ADD_ONE',
functionType: 0,
description: 'remote add one',
abstract: 'remote add one',
functionParameter: [],
} as never);
await service.unregisterFunctions(['REMOTE_ADD_ONE', 'REMOTE_DOUBLE']);
expect(functionService.hasExecutor('REMOTE_ADD_ONE')).toBe(false);
expect(functionService.hasExecutor('REMOTE_DOUBLE')).toBe(false);
expect(functionService.hasDescription('REMOTE_ADD_ONE')).toBe(false);
});
});

View File

@@ -0,0 +1,58 @@
/**
* 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 { createIdentifier } from '@univerjs/core';
import { IFunctionService } from '@univerjs/engine-formula';
export interface IRemoteRegisterFunctionService {
registerFunctions(serializedFuncs: Array<[string, string]>): Promise<void>;
registerAsyncFunctions(serializedFuncs: Array<[string, string]>): Promise<void>;
unregisterFunctions(names: string[]): Promise<void>;
}
export const RemoteRegisterFunctionServiceName = 'sheets-formula.remote-register-function.service';
export const IRemoteRegisterFunctionService = createIdentifier<IRemoteRegisterFunctionService>(RemoteRegisterFunctionServiceName);
/**
* This class should resident in the remote process.
*/
export class RemoteRegisterFunctionService implements IRemoteRegisterFunctionService {
constructor(
@IFunctionService private readonly _functionService: IFunctionService
) {}
async registerFunctions(serializedFuncs: Array<[string, string]>): Promise<void> {
rejectRemoteCustomFunctionRegistration(serializedFuncs);
}
async registerAsyncFunctions(serializedFuncs: Array<[string, string]>): Promise<void> {
rejectRemoteCustomFunctionRegistration(serializedFuncs);
}
async unregisterFunctions(names: string[]): Promise<void> {
this._functionService.unregisterExecutors(...names);
this._functionService.unregisterDescriptions(...names);
this._functionService.deleteFormulaAstCacheKey(...names);
}
}
function rejectRemoteCustomFunctionRegistration(serializedFuncs: Array<[string, string]>): void {
if (serializedFuncs.length === 0) {
return;
}
throw new Error('Remote custom function registration is disabled because function deserialization over RPC is unsafe.');
}

View File

@@ -0,0 +1,45 @@
/**
* 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 type { LocaleService } from '@univerjs/core';
import type { IFunctionInfo, IFunctionParam } from '@univerjs/engine-formula';
export function getFunctionName(item: IFunctionInfo, localeService: LocaleService) {
let functionName = '';
if (item.aliasFunctionName) {
functionName = localeService.t(item.aliasFunctionName);
if (functionName === item.aliasFunctionName) {
functionName = item.functionName;
}
} else {
functionName = item.functionName;
}
return functionName;
}
export function generateParam(param: IFunctionParam) {
if (!param.require && !param.repeat) {
return `[${param.name}]`;
} else if (param.require && !param.repeat) {
return param.name;
} else if (!param.require && param.repeat) {
return `[${param.name},...]`;
} else if (param.require && param.repeat) {
return `${param.name},...`;
}
}

View File

@@ -0,0 +1,72 @@
/**
* 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 type { Injector } from '@univerjs/core';
import type { ISheetData } from '@univerjs/engine-formula';
import { AstTreeBuilder, IFormulaCurrentConfigService, Interpreter, Lexer } from '@univerjs/engine-formula';
import { describe, expect, it, vi } from 'vitest';
import { calculateFormula } from '../calculate';
describe('calculateFormula', () => {
it('should load formula config, build lexer and ast, then return the executed value', () => {
const load = vi.fn();
const treeBuilder = vi.fn(() => ['lexer-node']);
const parse = vi.fn(() => ({ type: 'ast-node' }));
const execute = vi.fn(() => ({
isReferenceObject: () => false,
isArray: () => false,
isNumber: () => true,
getValue: () => 42,
}));
const services = new Map<unknown, unknown>([
[IFormulaCurrentConfigService, { load }],
[Lexer, { treeBuilder }],
[AstTreeBuilder, { parse }],
[Interpreter, { execute }],
]);
const injector = {
get: (token: unknown) => services.get(token),
} as unknown as Injector;
const sheetData = {
sheet1: {
cellData: {},
rowCount: 10,
columnCount: 10,
rowData: {},
columnData: {},
},
} as unknown as ISheetData;
const result = calculateFormula(injector, '=SUM(A1)', 'unit-1', sheetData);
expect(load).toHaveBeenCalledWith(expect.objectContaining({
allUnitData: {
'unit-1': sheetData,
},
formulaData: {},
arrayFormulaCellData: {},
arrayFormulaRange: {},
forceCalculate: false,
}));
expect(treeBuilder).toHaveBeenCalledWith('=SUM(A1)');
expect(parse).toHaveBeenCalledWith(['lexer-node']);
expect(execute).toHaveBeenCalled();
expect(result).toBe(42);
});
});

View File

@@ -0,0 +1,46 @@
/**
* 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 type { Injector } from '@univerjs/core';
import type { BaseAstNode, ISheetData, LexerNode } from '@univerjs/engine-formula';
import { AstTreeBuilder, generateExecuteAstNodeData, getObjectValue, IFormulaCurrentConfigService, Interpreter, Lexer } from '@univerjs/engine-formula';
export function calculateFormula(inject: Injector, formulaString: string, unitId: string, sheetData: ISheetData) {
const formulaCurrentConfigService = inject.get(IFormulaCurrentConfigService);
const lexer = inject.get(Lexer);
const astTreeBuilder = inject.get(AstTreeBuilder);
const interpreter = inject.get(Interpreter);
formulaCurrentConfigService.load({
formulaData: {},
arrayFormulaCellData: {},
arrayFormulaRange: {},
forceCalculate: false,
dirtyRanges: [],
dirtyNameMap: {},
dirtyDefinedNameMap: {},
dirtyUnitFeatureMap: {},
excludedCell: {},
allUnitData: {
[unitId]: sheetData,
},
dirtyUnitOtherFormulaMap: {},
});
const lexerNode = lexer.treeBuilder(formulaString);
const astNode = astTreeBuilder.parse(lexerNode as LexerNode);
const result = interpreter.execute(generateExecuteAstNodeData(astNode as BaseAstNode));
return getObjectValue(result);
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();