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 @@
src/assets/icon-map.ts

View File

@@ -0,0 +1,50 @@
# @univerjs/sheets-conditional-formatting
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-conditional-formatting?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-conditional-formatting)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-conditional-formatting?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-conditional-formatting)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-conditional-formatting?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-conditional-formatting)
`@univerjs/sheets-conditional-formatting` adds the core conditional formatting model, commands, and calculation support for Univer Sheets.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-conditional-formatting` | `UniverSheetsConditionalFormatting` | No | Yes | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-conditional-formatting
# or
npm install @univerjs/sheets-conditional-formatting
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { LocaleType, mergeLocales, Univer } from '@univerjs/core';
import { UniverSheetsConditionalFormattingPlugin } from '@univerjs/sheets-conditional-formatting';
import EnUS from '@univerjs/sheets-conditional-formatting/locale/en-US';
const univer = new Univer({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(EnUS),
},
});
univer.registerPlugin(UniverSheetsConditionalFormattingPlugin);
```
## Integration Notes
Use this package with `@univerjs/sheets-conditional-formatting-ui` when users need menus and rule-management panels.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-conditional-formatting)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,101 @@
{
"name": "@univerjs/sheets-conditional-formatting",
"version": "1.0.0-rc.0",
"private": false,
"description": "Conditional formatting model, commands, and calculation support 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",
"conditional-formatting",
"spreadsheet",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./facade": "./src/facade/index.ts",
"./locale/*": "./src/locale/*.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": {
"prepare": "node --experimental-strip-types ./scripts/build-icons.mts",
"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/engine-formula": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/sheets": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"@univerjs/icons-svg": "^1.38.0",
"@univerjs/sheets-formula": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,146 @@
/**
* 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 fs from 'node:fs';
const progress = [
['progress0', '@univerjs/icons-svg/double/progress-0-double-icon.svg', { black: '#7A7A7A', '#E5E5E5': '#7A7A7A' }],
['progress25', '@univerjs/icons-svg/double/progress-25-double-icon.svg', { black: '#7A7A7A', '#E5E5E5': '#7A7A7A' }],
['progress50', '@univerjs/icons-svg/double/progress-50-double-icon.svg', { black: '#7A7A7A', '#E5E5E5': '#7A7A7A' }],
['progress75', '@univerjs/icons-svg/double/progress-75-double-icon.svg', { black: '#7A7A7A', '#E5E5E5': '#7A7A7A' }],
['progress100', '@univerjs/icons-svg/double/progress-100-double-icon.svg', { black: '#7A7A7A', '#E5E5E5': '#7A7A7A' }],
];
const star = [
['starEmpty', '@univerjs/icons-svg/double/star-empty-double-icon.svg', { black: '#FFBD37' }],
['starIncomplete', '@univerjs/icons-svg/double/star-incomplete-double-icon.svg', { black: '#FFBD37' }],
['starFull', '@univerjs/icons-svg/double/star-full-double-icon.svg', { black: '#FFBD37' }],
];
const feeling = [
['guffaw', '@univerjs/icons-svg/double/guffaw-double-icon.svg', { black: '#8F5F00', '#E5E5E5': '#FFBD37' }],
['smile', '@univerjs/icons-svg/double/smile-double-icon.svg', { black: '#8F5F00', '#E5E5E5': '#FFBD37' }],
['noninductive', '@univerjs/icons-svg/double/noninductive-double-icon.svg', { black: '#8F5F00', '#E5E5E5': '#FFBD37' }],
['dissatisfied', '@univerjs/icons-svg/double/dissatisfied-double-icon.svg', { black: '#8F5F00', '#E5E5E5': '#FFBD37' }],
['impatient', '@univerjs/icons-svg/double/impatient-double-icon.svg', { black: '#8F5F00', '#E5E5E5': '#FFBD37' }],
];
const signal = [
['signal0', '@univerjs/icons-svg/double/signal-0-double-icon.svg', { black: '#0493EE' }],
['signal25', '@univerjs/icons-svg/double/signal-25-double-icon.svg', { black: '#0493EE' }],
['signal50', '@univerjs/icons-svg/double/signal-50-double-icon.svg', { black: '#0493EE' }],
['signal75', '@univerjs/icons-svg/double/signal-75-double-icon.svg', { black: '#0493EE' }],
['signal100', '@univerjs/icons-svg/double/signal-100-double-icon.svg', { black: '#0493EE' }],
];
const feedback = [
['mistake', '@univerjs/icons-svg/double/mistake-double-icon.svg', { black: '#FE4B4B', '#E5E5E5': '#FFFFFF' }],
['warn', '@univerjs/icons-svg/double/warn-double-icon.svg', { black: '#FFBD37', '#E5E5E5': '#FFFFFF' }],
['correct', '@univerjs/icons-svg/double/correct-double-icon.svg', { black: '#59D01E', '#E5E5E5': '#FFFFFF' }],
];
const feedback2 = [
['mistake2', '@univerjs/icons-svg/single/close-icon.svg', { black: '#FE4B4B' }],
['warn2', '@univerjs/icons-svg/single/exclamation-icon.svg', { black: '#FFBD37' }],
['correct2', '@univerjs/icons-svg/single/check-mark-icon.svg', { black: '#59D01E' }],
];
const arrow = [
['down-red', '@univerjs/icons-svg/single/arrow-down-icon.svg', { black: '#FE4B4B' }],
['right-gold', '@univerjs/icons-svg/single/arrow-right-icon.svg', { black: '#FFBD37' }],
['up-green', '@univerjs/icons-svg/single/arrow-up-icon.svg', { black: '#59D01E' }],
['rightAndDown-gold', '@univerjs/icons-svg/single/arrow-tilt-down-icon.svg', { black: '#FFBD37' }],
['rightAndUp-gold', '@univerjs/icons-svg/single/arrow-tilt-up-icon.svg', { black: '#FFBD37' }],
['down-gray', '@univerjs/icons-svg/single/arrow-down-icon.svg', { black: '#999999' }],
['right-gray', '@univerjs/icons-svg/single/arrow-right-icon.svg', { black: '#999999' }],
['up-gray', '@univerjs/icons-svg/single/arrow-up-icon.svg', { black: '#999999' }],
['rightAndDown-gray', '@univerjs/icons-svg/single/arrow-tilt-down-icon.svg', { black: '#999999' }],
['rightAndUp-gray', '@univerjs/icons-svg/single/arrow-tilt-up-icon.svg', { black: '#999999' }],
];
const flag = [
['flag-green', '@univerjs/icons-svg/single/flag-icon.svg', { black: '#59D01E' }],
['flag-gold', '@univerjs/icons-svg/single/flag-icon.svg', { black: '#FFBD37' }],
['flag-red', '@univerjs/icons-svg/single/flag-icon.svg', { black: '#FE4B4B' }],
];
const shape = [
['cross', '@univerjs/icons-svg/single/cross-icon.svg', { black: '#FFBD37' }],
['up', '@univerjs/icons-svg/single/up-icon.svg', { black: '#59D01E' }],
['down', '@univerjs/icons-svg/single/down-icon.svg', { black: '#FE4B4B' }],
['rhomboid-red', '@univerjs/icons-svg/single/rhomboid-icon.svg', { black: '#FE4B4B' }],
['rhomboid-gold', '@univerjs/icons-svg/single/rhomboid-icon.svg', { black: '#FFBD37' }],
['roundness-greed', '@univerjs/icons-svg/single/roundness-icon.svg', { black: '#59D01E' }],
['roundness-gold', '@univerjs/icons-svg/single/roundness-icon.svg', { black: '#FFBD37' }],
['roundness-red', '@univerjs/icons-svg/single/roundness-icon.svg', { black: '#FE4B4B' }],
['roundness-pink', '@univerjs/icons-svg/single/roundness-icon.svg', { black: '#FB9D9D' }],
['roundness-gray', '@univerjs/icons-svg/single/roundness-icon.svg', { black: '#999999' }],
['roundness-black', '@univerjs/icons-svg/single/roundness-icon.svg'],
['triangle-gold', '@univerjs/icons-svg/single/triangle-icon.svg', { black: '#FFBD37' }],
['indicate-greed', '@univerjs/icons-svg/double/indicate-double-icon.svg', { '#E5E5E5': '#59D01E' }],
['indicate-gold', '@univerjs/icons-svg/double/indicate-double-icon.svg', { '#E5E5E5': '#FFBD37' }],
['indicate-red', '@univerjs/icons-svg/double/indicate-double-icon.svg', { '#E5E5E5': '#FE4B4B' }],
];
const cell = [
['cell-0', '@univerjs/icons-svg/double/cell-0-double-icon.svg', { white: '#0493EE', black: '#0493EE' }],
['cell-25', '@univerjs/icons-svg/double/cell-25-double-icon.svg', { white: '#0493EE', black: '#0493EE' }],
['cell-50', '@univerjs/icons-svg/double/cell-50-double-icon.svg', { white: '#0493EE', black: '#0493EE' }],
['cell-75', '@univerjs/icons-svg/double/cell-75-double-icon.svg', { white: '#0493EE', black: '#0493EE' }],
['cell-100', '@univerjs/icons-svg/double/cell-100-double-icon.svg', { white: '#0493EE', black: '#0493EE' }],
];
function replaceFillAndConvertToBase64(filePath: string, replaceMap: Record<string, string>) {
try {
let svgContent = fs.readFileSync(filePath, 'utf8');
if (replaceMap) {
for (const key in replaceMap) {
svgContent = svgContent.replace(new RegExp(`"${key}"`, 'g'), `"${replaceMap[key]}"`);
}
}
const base64Encoded = encodeURIComponent(svgContent);
return base64Encoded;
} catch (error) {
console.error('An error occurred:', error);
}
}
function runTask() {
const base64EncodedSVGs: Record<string, Record<string, string>> = {};
const obj = { feedback, star, progress, signal, feeling, arrow, shape, feedback2, flag, cell };
for (const iconType in obj) {
const list = obj[iconType as keyof typeof obj];
const map: Record<string, string> = {};
base64EncodedSVGs[iconType] = map;
for (const element of list) {
const [key, path, replaceMap] = element;
const base64 = replaceFillAndConvertToBase64(`./node_modules/${path}`, typeof replaceMap === 'object' ? replaceMap : {});
map[key as keyof typeof map] = `data:image/svg+xml;charset=utf-8,${base64}`;
}
}
if (!fs.existsSync('./src/assets')) {
fs.mkdirSync('./src/assets');
}
fs.writeFileSync('./src/assets/icon-map.ts', `/* eslint-disable */\nexport const ICON_MAP = ${JSON.stringify(base64EncodedSVGs, null, 4)};\n`);
};
runTask();

View File

@@ -0,0 +1,128 @@
/**
* 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 { IConditionFormattingRule, ITextHighlightCell } from '../models/type';
export const SHEET_CONDITIONAL_FORMATTING_PLUGIN = 'SHEET_CONDITIONAL_FORMATTING_PLUGIN';
export enum CFTextOperator {
beginsWith = 'beginsWith',
endsWith = 'endsWith',
containsText = 'containsText',
notContainsText = 'notContainsText',
equal = 'equal',
notEqual = 'notEqual',
containsBlanks = 'containsBlanks',
notContainsBlanks = 'notContainsBlanks',
containsErrors = 'containsErrors',
notContainsErrors = 'notContainsErrors',
}
export enum CFTimePeriodOperator {
today = 'today',
yesterday = 'yesterday',
tomorrow = 'tomorrow',
last7Days = 'last7Days',
thisMonth = 'thisMonth',
lastMonth = 'lastMonth',
nextMonth = 'nextMonth',
thisWeek = 'thisWeek',
lastWeek = 'lastWeek',
nextWeek = 'nextWeek',
}
export enum CFNumberOperator {
greaterThan = 'greaterThan',
greaterThanOrEqual = 'greaterThanOrEqual',
lessThan = 'lessThan',
lessThanOrEqual = 'lessThanOrEqual',
notBetween = 'notBetween',
between = 'between',
equal = 'equal',
notEqual = 'notEqual',
}
export enum CFRuleType {
highlightCell = 'highlightCell',
dataBar = 'dataBar',
colorScale = 'colorScale',
iconSet = 'iconSet',
}
export enum CFSubRuleType {
uniqueValues = 'uniqueValues',
duplicateValues = 'duplicateValues',
rank = 'rank',
text = 'text',
timePeriod = 'timePeriod',
number = 'number',
average = 'average',
formula = 'formula',
}
export enum CFValueType {
num = 'num',
min = 'min',
max = 'max',
percent = 'percent',
percentile = 'percentile',
formula = 'formula',
}
export const DEFAULT_BG_COLOR = '#fff';
export const DEFAULT_FONT_COLOR = '#000000';
export const createDefaultRule = () => ({
cfId: undefined as unknown as string,
ranges: [],
stopIfTrue: false,
rule: { type: CFRuleType.highlightCell, subType: CFSubRuleType.text, operator: CFTextOperator.containsText } as ITextHighlightCell,
} as IConditionFormattingRule);
export const createDefaultValue = (subType: CFSubRuleType, operator: CFTextOperator | CFNumberOperator | CFTimePeriodOperator) => {
switch (subType) {
case CFSubRuleType.text:{
if ([CFTextOperator.beginsWith, CFTextOperator.containsText, CFTextOperator.endsWith, CFTextOperator.equal, CFTextOperator.notContainsText, CFTextOperator.notEqual].includes(operator as CFTextOperator)) {
return '';
}
break;
}
case CFSubRuleType.number:{
if ([CFNumberOperator.between, CFNumberOperator.notBetween].includes(operator as CFNumberOperator)) {
return [10, 100] as [number, number];
}
return 10;
}
}
return '';
};
export const createDefaultValueByValueType = (type: CFValueType, defaultValue?: number) => {
switch (type) {
case CFValueType.formula:{
return '=';
}
case CFValueType.max:
case CFValueType.min:{
return '';
}
case CFValueType.percent:
case CFValueType.percentile:
case CFValueType.num:{
return defaultValue !== undefined ? defaultValue : 10;
}
}
return '';
};

View File

@@ -0,0 +1,268 @@
/**
* 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, Workbook, Worksheet } from '@univerjs/core';
import { ICommandService, IUndoRedoService, IUniverInstanceService } from '@univerjs/core';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { describe, expect, it, vi } from 'vitest';
import { ConditionalFormattingRangeIndexModel } from '../../../models/conditional-formatting-range-index-model';
import { ConditionalFormattingRuleModel } from '../../../models/conditional-formatting-rule-model';
import { ConditionalFormattingRangeTransformService } from '../../../services/conditional-formatting-range-transform.service';
import { DeleteConditionalRuleMutation } from '../../mutations/delete-conditional-rule.mutation';
import { SetConditionalRuleMutation } from '../../mutations/set-conditional-rule.mutation';
import { ClearRangeCfCommand } from '../clear-range-cf.command';
import { ClearWorksheetCfCommand } from '../clear-worksheet-cf.command';
class TestWorksheet {
constructor(private readonly _sheetId = 'sheet-1') {}
getSheetId() {
return this._sheetId;
}
}
class TestWorkbook {
constructor(
private readonly _unitId = 'unit-1',
private readonly _worksheet: TestWorksheet | null = new TestWorksheet()
) {}
getUnitId() {
return this._unitId;
}
getSheetBySheetId(sheetId: string) {
if (this._worksheet?.getSheetId() === sheetId) {
return this._worksheet as unknown as Worksheet;
}
return null;
}
getActiveSheet() {
return this._worksheet as unknown as Worksheet;
}
}
class TestUniverInstanceService {
constructor(private readonly _workbook: TestWorkbook | null = new TestWorkbook()) {}
getUnit(unitId: string) {
if (this._workbook?.getUnitId() === unitId) {
return this._workbook as unknown as Workbook;
}
return null;
}
getCurrentUnitOfType() {
return this._workbook as unknown as Workbook;
}
}
function createRuleModel() {
const ruleModel = new ConditionalFormattingRuleModel();
ruleModel.addRule('unit-1', 'sheet-1', {
cfId: 'rule-keep',
ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }],
stopIfTrue: false,
rule: {
type: 'highlightCell',
subType: 'text',
operator: 'containsText',
style: {},
value: 'todo',
},
} as never);
ruleModel.addRule('unit-1', 'sheet-1', {
cfId: 'rule-delete',
ranges: [{ startRow: 0, endRow: 0, startColumn: 2, endColumn: 2 }],
stopIfTrue: false,
rule: {
type: 'highlightCell',
subType: 'text',
operator: 'containsText',
style: {},
value: 'todo',
},
} as never);
return ruleModel;
}
function createAccessor(ruleModel: ConditionalFormattingRuleModel, univerInstanceService = new TestUniverInstanceService()) {
const rangeIndexModel = new ConditionalFormattingRangeIndexModel(ruleModel);
const rangeTransformService = new ConditionalFormattingRangeTransformService();
const commandService = {
syncExecuteCommand: vi.fn(() => true),
};
const undoRedoService = {
pushUndoRedo: vi.fn(),
};
const selectionManagerService = {
getCurrentSelections: vi.fn(() => [{
range: { startRow: 0, endRow: 0, startColumn: 2, endColumn: 2 },
}]),
};
const accessor = {
get(token: unknown) {
if (token === ConditionalFormattingRuleModel) {
return ruleModel;
}
if (token === ConditionalFormattingRangeIndexModel) {
return rangeIndexModel;
}
if (token === ConditionalFormattingRangeTransformService) {
return rangeTransformService;
}
if (token === ICommandService) {
return commandService;
}
if (token === IUndoRedoService) {
return undoRedoService;
}
if (token === SheetsSelectionsService) {
return selectionManagerService;
}
if (token === IUniverInstanceService) {
return univerInstanceService;
}
throw new Error(`Unknown dependency: ${String(token)}`);
},
} as IAccessor;
return {
accessor,
commandService,
undoRedoService,
selectionManagerService,
};
}
describe('clear conditional formatting commands', () => {
it('clears the selected range, updates overlapping rules, and records undo/redo', () => {
const ruleModel = createRuleModel();
const { accessor, commandService, undoRedoService } = createAccessor(ruleModel);
expect(ClearRangeCfCommand.handler(accessor, { ranges: [{ startRow: 0, endRow: 0, startColumn: 2, endColumn: 2 }] })).toBe(true);
expect(commandService.syncExecuteCommand).toHaveBeenCalledTimes(2);
expect(commandService.syncExecuteCommand).toHaveBeenNthCalledWith(1, DeleteConditionalRuleMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
cfId: 'rule-delete',
}, undefined);
expect(commandService.syncExecuteCommand).toHaveBeenNthCalledWith(2, SetConditionalRuleMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
rule: {
cfId: 'rule-keep',
ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 1 }],
stopIfTrue: false,
rule: {
type: 'highlightCell',
subType: 'text',
operator: 'containsText',
style: {},
value: 'todo',
},
},
}, undefined);
expect(undoRedoService.pushUndoRedo).toHaveBeenCalledWith(expect.objectContaining({
unitID: 'unit-1',
redoMutations: expect.arrayContaining([
expect.objectContaining({ id: DeleteConditionalRuleMutation.id }),
expect.objectContaining({ id: SetConditionalRuleMutation.id }),
]),
}));
});
it('clears conditional formatting from the current selection when ranges are omitted', () => {
const ruleModel = createRuleModel();
const { accessor, commandService, undoRedoService } = createAccessor(ruleModel);
expect(ClearRangeCfCommand.handler(accessor, {})).toBe(true);
expect(commandService.syncExecuteCommand).toHaveBeenCalledWith(DeleteConditionalRuleMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
cfId: 'rule-delete',
}, undefined);
expect(undoRedoService.pushUndoRedo).toHaveBeenCalledWith(expect.objectContaining({
unitID: 'unit-1',
redoMutations: expect.arrayContaining([
expect.objectContaining({ id: DeleteConditionalRuleMutation.id }),
]),
}));
});
it('clears every rule on the worksheet and records one delete per rule', () => {
const ruleModel = createRuleModel();
const { accessor, commandService, undoRedoService } = createAccessor(ruleModel);
expect(ClearWorksheetCfCommand.handler(accessor, { unitId: 'unit-1', subUnitId: 'sheet-1' })).toBe(true);
expect(commandService.syncExecuteCommand).toHaveBeenNthCalledWith(1, DeleteConditionalRuleMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
cfId: 'rule-delete',
}, undefined);
expect(commandService.syncExecuteCommand).toHaveBeenNthCalledWith(2, DeleteConditionalRuleMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
cfId: 'rule-keep',
}, undefined);
expect(undoRedoService.pushUndoRedo).toHaveBeenCalledWith(expect.objectContaining({
unitID: 'unit-1',
redoMutations: [
{ id: DeleteConditionalRuleMutation.id, params: { unitId: 'unit-1', subUnitId: 'sheet-1', cfId: 'rule-delete' } },
{ id: DeleteConditionalRuleMutation.id, params: { unitId: 'unit-1', subUnitId: 'sheet-1', cfId: 'rule-keep' } },
],
}));
});
it('returns false when the target, selection, or rules are missing', () => {
const ruleModel = new ConditionalFormattingRuleModel();
const { accessor, commandService, selectionManagerService } = createAccessor(ruleModel);
const missingTarget = createAccessor(ruleModel, new TestUniverInstanceService(null));
expect(ClearRangeCfCommand.handler(accessor, undefined as never)).toBe(false);
expect(ClearRangeCfCommand.handler(missingTarget.accessor, { ranges: [] })).toBe(false);
expect(ClearWorksheetCfCommand.handler(missingTarget.accessor, { unitId: 'unit-1', subUnitId: 'sheet-1' })).toBe(false);
selectionManagerService.getCurrentSelections.mockReturnValue([]);
expect(ClearRangeCfCommand.handler(accessor, {})).toBe(false);
expect(commandService.syncExecuteCommand).not.toHaveBeenCalled();
});
it('does not record undo redo when clearing rules fails during mutation sequence', () => {
const ruleModel = createRuleModel();
const { accessor, commandService, undoRedoService } = createAccessor(ruleModel);
commandService.syncExecuteCommand.mockReturnValue(false);
expect(ClearRangeCfCommand.handler(accessor, { ranges: [{ startRow: 0, endRow: 0, startColumn: 2, endColumn: 2 }] })).toBe(false);
expect(undoRedoService.pushUndoRedo).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,67 @@
/**
* 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 } from '@univerjs/core';
import type { IConditionFormattingRule } from '../../models/type';
import type { MakePropertyOptional } from '../../utils/type';
import type { IAddConditionalRuleMutationParams } from '../mutations/add-conditional-rule.mutation';
import {
CommandType,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
} from '@univerjs/core';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { AddConditionalRuleMutation, AddConditionalRuleMutationUndoFactory } from '../mutations/add-conditional-rule.mutation';
export interface IAddCfCommandParams {
unitId?: string;
subUnitId?: string;
rule: MakePropertyOptional<IConditionFormattingRule, 'cfId'>;
};
export const AddCfCommand: ICommand<IAddCfCommandParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.add-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const { rule } = params;
const undoRedoService = accessor.get(IUndoRedoService);
const commandService = accessor.get(ICommandService);
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId } = target;
const cfId = conditionalFormattingRuleModel.createCfId(unitId, subUnitId);
const config: IAddConditionalRuleMutationParams = { unitId, subUnitId, rule: { ...rule, cfId: rule.cfId || cfId } };
const undo = AddConditionalRuleMutationUndoFactory(accessor, config);
const result = commandService.syncExecuteCommand(AddConditionalRuleMutation.id, config);
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations: [{ id: AddConditionalRuleMutation.id, params: config }],
undoMutations: [undo],
});
}
return result;
},
};

View File

@@ -0,0 +1,95 @@
/**
* 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, IMutationInfo, IRange } from '@univerjs/core';
import type { CFRuleType } from '../../base/const';
import type { IConditionFormattingRule } from '../../models/type';
import type { IDeleteConditionalRuleMutationParams } from '../mutations/delete-conditional-rule.mutation';
import type { ISetConditionalRuleMutationParams } from '../mutations/set-conditional-rule.mutation';
import {
CommandType,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
sequenceExecute,
} from '@univerjs/core';
import { getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
import { ConditionalFormattingRangeIndexModel } from '../../models/conditional-formatting-range-index-model';
import { ConditionalFormattingRangeTransformService } from '../../services/conditional-formatting-range-transform.service';
import { DeleteConditionalRuleMutation, DeleteConditionalRuleMutationUndoFactory } from '../mutations/delete-conditional-rule.mutation';
import { SetConditionalRuleMutation, setConditionalRuleMutationUndoFactory } from '../mutations/set-conditional-rule.mutation';
export interface IClearRangeCfParams {
ranges?: IRange[];
unitId?: string;
subUnitId?: string;
types?: CFRuleType[];
}
export const ClearRangeCfCommand: ICommand<IClearRangeCfParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.clear-range-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const conditionalFormattingRangeIndexModel = accessor.get(ConditionalFormattingRangeIndexModel);
const conditionalFormattingRangeTransformService = accessor.get(ConditionalFormattingRangeTransformService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const selectionManagerService = accessor.get(SheetsSelectionsService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId } = target;
const ranges = params.ranges ?? selectionManagerService.getCurrentSelections()?.map((selection) => selection.range) ?? [];
const allRuleList = conditionalFormattingRangeIndexModel
.getRulesByRanges(unitId, subUnitId, ranges)
.filter((rule) => !params.types?.length || params.types.includes(rule.rule.type as CFRuleType));
if (!allRuleList?.length || !ranges.length) {
return false;
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
allRuleList.forEach((oldRule) => {
const newRanges = conditionalFormattingRangeTransformService.subtractRanges(oldRule.ranges, ranges);
if (newRanges.length) {
const rule: IConditionFormattingRule = { ...oldRule, ranges: newRanges };
const params = { unitId, subUnitId, rule } as ISetConditionalRuleMutationParams;
const undo = setConditionalRuleMutationUndoFactory(accessor, params);
redos.push({ id: SetConditionalRuleMutation.id, params });
undos.push(...undo);
} else {
const params = { unitId, subUnitId, cfId: oldRule.cfId } as IDeleteConditionalRuleMutationParams;
const undo = DeleteConditionalRuleMutationUndoFactory(accessor, params);
redos.push({ id: DeleteConditionalRuleMutation.id, params });
undos.push(...undo);
}
});
const result = sequenceExecute(redos, commandService).result;
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations: redos,
undoMutations: undos,
});
}
return result;
},
};

View File

@@ -0,0 +1,65 @@
/**
* 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, IMutationInfo } from '@univerjs/core';
import type { IDeleteConditionalRuleMutationParams } from '../mutations/delete-conditional-rule.mutation';
import {
CommandType,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
sequenceExecute,
} from '@univerjs/core';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { DeleteConditionalRuleMutation, DeleteConditionalRuleMutationUndoFactory } from '../mutations/delete-conditional-rule.mutation';
export interface IClearWorksheetCfParams {
unitId?: string;
subUnitId?: string;
}
export const ClearWorksheetCfCommand: ICommand<IClearWorksheetCfParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.clear-worksheet-conditional-rule',
handler(accessor, params) {
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
const univerInstanceService = accessor.get(IUniverInstanceService);
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId } = target;
const ruleList = conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId);
if (!ruleList?.length) {
return false;
}
const configList: IDeleteConditionalRuleMutationParams[] = ruleList.map((rule) => ({ cfId: rule.cfId, unitId, subUnitId }));
const redos: IMutationInfo[] = configList.map((config) => ({ id: DeleteConditionalRuleMutation.id, params: config }));
const undos: IMutationInfo[] = configList.map((config) => DeleteConditionalRuleMutationUndoFactory(accessor, config)[0]);
const result = sequenceExecute(redos, commandService).result;
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations: redos,
undoMutations: undos,
});
}
return result;
},
};

View File

@@ -0,0 +1,55 @@
/**
* 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 } from '@univerjs/core';
import type { IDeleteConditionalRuleMutationParams } from '../mutations/delete-conditional-rule.mutation';
import {
CommandType,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
} from '@univerjs/core';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { DeleteConditionalRuleMutation, DeleteConditionalRuleMutationUndoFactory } from '../mutations/delete-conditional-rule.mutation';
export interface IDeleteCfCommandParams {
unitId?: string;
subUnitId?: string;
cfId: string;
}
export const DeleteCfCommand: ICommand<IDeleteCfCommandParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.delete-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const undoRedoService = accessor.get(IUndoRedoService);
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId } = target;
const config: IDeleteConditionalRuleMutationParams = { unitId, subUnitId, cfId: params.cfId };
const undos = DeleteConditionalRuleMutationUndoFactory(accessor, config);
const result = commandService.syncExecuteCommand(DeleteConditionalRuleMutation.id, config);
if (result) {
undoRedoService.pushUndoRedo({ unitID: unitId, undoMutations: undos, redoMutations: [{ id: DeleteConditionalRuleMutation.id, params: config }] });
}
return result;
},
};

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 { ICommand } from '@univerjs/core';
import type { IAnchor } from '../../utils/anchor';
import type { IMoveConditionalRuleMutationParams } from '../mutations/move-conditional-rule.mutation';
import {
CommandType,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
} from '@univerjs/core';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { transformSupportSymmetryAnchor } from '../../utils/anchor';
import { MoveConditionalRuleMutation, MoveConditionalRuleMutationUndoFactory } from '../mutations/move-conditional-rule.mutation';
export interface IMoveCfCommandParams {
unitId?: string;
subUnitId?: string;
start: IAnchor;
end: IAnchor;
};
export const MoveCfCommand: ICommand<IMoveCfCommandParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.move-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const undoRedoService = accessor.get(IUndoRedoService);
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId } = target;
const anchorList = transformSupportSymmetryAnchor(params.start, params.end, conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId) || [], (rule) => rule.cfId);
if (!anchorList) {
return false;
}
const [start, end] = anchorList;
const config: IMoveConditionalRuleMutationParams = { unitId, subUnitId, start, end };
const undos = MoveConditionalRuleMutationUndoFactory(config);
const result = commandService.syncExecuteCommand(MoveConditionalRuleMutation.id, config);
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
redoMutations: [{ id: MoveConditionalRuleMutation.id, params: config }],
undoMutations: undos,
});
}
return result;
},
};

View File

@@ -0,0 +1,59 @@
/**
* 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 } from '@univerjs/core';
import type { IConditionFormattingRule } from '../../models/type';
import type { ISetConditionalRuleMutationParams } from '../mutations/set-conditional-rule.mutation';
import {
CommandType,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
} from '@univerjs/core';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { SetConditionalRuleMutation, setConditionalRuleMutationUndoFactory } from '../mutations/set-conditional-rule.mutation';
export interface ISetCfCommandParams {
unitId?: string;
subUnitId?: string;
cfId?: string;
rule: IConditionFormattingRule;
};
export const SetCfCommand: ICommand<ISetCfCommandParams> = {
type: CommandType.COMMAND,
id: 'sheet.command.set-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const undoRedoService = accessor.get(IUndoRedoService);
const commandService = accessor.get(ICommandService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService, params);
if (!target) return false;
const { unitId, subUnitId } = target;
const config: ISetConditionalRuleMutationParams = { unitId, subUnitId, rule: params.rule, cfId: params.cfId };
const undos = setConditionalRuleMutationUndoFactory(accessor, config);
const result = commandService.syncExecuteCommand(SetConditionalRuleMutation.id, config);
if (result) {
undoRedoService.pushUndoRedo({ unitID: unitId, undoMutations: undos, redoMutations: [{ id: SetConditionalRuleMutation.id, params: config }] });
}
return result;
},
};

View File

@@ -0,0 +1,30 @@
/**
* 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 { ConditionalFormattingFormulaMarkDirty } from '../formula-mark-dirty.mutation';
describe('ConditionalFormattingFormulaMarkDirty', () => {
it('accepts dirty formula maps as a command message', () => {
expect(ConditionalFormattingFormulaMarkDirty.handler({} as never, {
'book-1': {
'sheet-1': {
'cf-1_=A1>0': true,
},
},
})).toBe(true);
});
});

View File

@@ -0,0 +1,44 @@
/**
* 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, IMutation } from '@univerjs/core';
import type { IConditionFormattingRule } from '../../models/type';
import type { IDeleteConditionalRuleMutationParams } from './delete-conditional-rule.mutation';
import { CommandType } from '@univerjs/core';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { DeleteConditionalRuleMutation } from './delete-conditional-rule.mutation';
export interface IAddConditionalRuleMutationParams {
unitId: string;
subUnitId: string;
rule: IConditionFormattingRule;
}
export const AddConditionalRuleMutationUndoFactory = (accessor: IAccessor, param: IAddConditionalRuleMutationParams) => {
return { id: DeleteConditionalRuleMutation.id, params: { unitId: param.unitId, subUnitId: param.subUnitId, cfId: param.rule.cfId } as IDeleteConditionalRuleMutationParams };
};
export const AddConditionalRuleMutation: IMutation<IAddConditionalRuleMutationParams> = {
type: CommandType.MUTATION,
id: 'sheet.mutation.add-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, subUnitId, rule } = params;
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
conditionalFormattingRuleModel.addRule(unitId, subUnitId, rule);
return true;
},
};

View File

@@ -0,0 +1,77 @@
/**
* 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, IMutation, IMutationInfo } from '@univerjs/core';
import type { IAddConditionalRuleMutationParams } from './add-conditional-rule.mutation';
import type { IMoveConditionalRuleMutationParams } from './move-conditional-rule.mutation';
import { CommandType, Tools } from '@univerjs/core';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { transformSupportSymmetryAnchor } from '../../utils/anchor';
import { AddConditionalRuleMutation } from './add-conditional-rule.mutation';
import { MoveConditionalRuleMutation } from './move-conditional-rule.mutation';
export interface IDeleteConditionalRuleMutationParams {
unitId: string;
subUnitId: string;
cfId: string;
}
export const DeleteConditionalRuleMutationUndoFactory = (accessor: IAccessor, param: IDeleteConditionalRuleMutationParams) => {
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
const { unitId, subUnitId, cfId } = param;
const ruleList = ([...(conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId) || [])]);
const index = ruleList.findIndex((item) => item.cfId === cfId);
const beforeRule = ruleList[index - 1];
if (index > -1) {
const rule = ruleList[index];
const result: IMutationInfo[] = [{
id: AddConditionalRuleMutation.id,
params: { unitId, subUnitId, rule: Tools.deepClone(rule) } as IAddConditionalRuleMutationParams,
}];
ruleList.splice(index, 1);
if (index !== 0) {
const firstRule = ruleList[0];
if (firstRule) {
const transformResult = transformSupportSymmetryAnchor({ id: firstRule.cfId, type: 'before' }, { id: beforeRule.cfId, type: 'after' }, ruleList, (rule) => rule.cfId);
if (!transformResult) {
return result;
}
const [start, end] = transformResult;
const params: IMoveConditionalRuleMutationParams = {
unitId,
subUnitId,
start,
end,
};
result.push({ id: MoveConditionalRuleMutation.id, params });
}
}
return result;
}
return [];
};
export const DeleteConditionalRuleMutation: IMutation<IDeleteConditionalRuleMutationParams> = {
type: CommandType.MUTATION,
id: 'sheet.mutation.delete-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, subUnitId, cfId } = params;
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
conditionalFormattingRuleModel.deleteRule(unitId, subUnitId, cfId);
return true;
},
};

View File

@@ -0,0 +1,27 @@
/**
* 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 { IMutation } from '@univerjs/core';
import { CommandType } from '@univerjs/core';
export interface IConditionalFormattingFormulaMarkDirtyParams { [unitId: string]: { [sunUnitId: string]: { [formulaId: string]: boolean } } }
export const ConditionalFormattingFormulaMarkDirty: IMutation<IConditionalFormattingFormulaMarkDirtyParams> = {
type: CommandType.MUTATION,
id: 'sheet.mutation.conditional-formatting-formula-mark-dirty',
handler() {
return true;
},
};

View File

@@ -0,0 +1,54 @@
/**
* 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 { IMutation } from '@univerjs/core';
import type { IAnchor } from '../../utils/anchor';
import { CommandType } from '@univerjs/core';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { anchorUndoFactory } from '../../utils/anchor';
export interface IMoveConditionalRuleMutationParams {
unitId: string;
subUnitId: string;
start: IAnchor;
end: IAnchor;
}
export const MoveConditionalRuleMutation: IMutation<IMoveConditionalRuleMutationParams> = {
type: CommandType.MUTATION,
id: 'sheet.mutation.move-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, subUnitId, start, end } = params;
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
conditionalFormattingRuleModel.moveRulePriority(unitId, subUnitId, start, end);
return true;
},
};
export const MoveConditionalRuleMutationUndoFactory = (param: IMoveConditionalRuleMutationParams) => {
const { unitId, subUnitId } = param;
const undo = anchorUndoFactory(param.start, param.end);
if (!undo) {
return [];
}
const [start, end] = undo;
return [{ id: MoveConditionalRuleMutation.id, params: { unitId, subUnitId, start, end } as IMoveConditionalRuleMutationParams },
];
};

View File

@@ -0,0 +1,62 @@
/**
* 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, IMutation } from '@univerjs/core';
import type { IConditionFormattingRule } from '../../models/type';
import { CommandType, Tools } from '@univerjs/core';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
export interface ISetConditionalRuleMutationParams {
unitId: string;
subUnitId: string;
cfId?: string;
rule: IConditionFormattingRule;
}
export const SetConditionalRuleMutation: IMutation<ISetConditionalRuleMutationParams> = {
type: CommandType.MUTATION,
id: 'sheet.mutation.set-conditional-rule',
handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, subUnitId, rule } = params;
const cfId = params.cfId || params.rule.cfId;
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
conditionalFormattingRuleModel.setRule(unitId, subUnitId, rule, cfId);
return true;
},
};
export const setConditionalRuleMutationUndoFactory = (accessor: IAccessor, param: ISetConditionalRuleMutationParams) => {
const conditionalFormattingRuleModel = accessor.get(ConditionalFormattingRuleModel);
const { unitId, subUnitId } = param;
const cfId = param.cfId || param.rule.cfId;
const rule = conditionalFormattingRuleModel.getRule(unitId, subUnitId, cfId);
if (rule) {
return [{
id: SetConditionalRuleMutation.id,
params: {
unitId,
subUnitId,
cfId,
rule: Tools.deepClone(rule),
} as ISetConditionalRuleMutationParams,
}];
}
return [];
};

View File

@@ -0,0 +1,24 @@
/**
* 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_CONDITIONAL_FORMATTING_PLUGIN_CONFIG_KEY = 'ssheets-conditional-formatting.config';
export const configSymbol = Symbol(SHEETS_CONDITIONAL_FORMATTING_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsConditionalFormattingConfig {
}
export const defaultPluginConfig: IUniverSheetsConditionalFormattingConfig = {};

View File

@@ -0,0 +1,237 @@
/**
* 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 {
ActiveDirtyManagerService,
DefinedNamesService,
DescriptionService,
FormulaCalculationSessionController,
FormulaCalculationSessionService,
FormulaDataModel,
FunctionService,
IActiveDirtyManagerService,
IDefinedNamesService,
IDescriptionService,
IFunctionService,
IRegisterFunctionService,
ISheetRowFilteredService,
ISuperTableService,
LexerTreeBuilder,
RegisterFunctionService,
RegisterOtherFormulaService,
SheetRowFilteredService,
SuperTableService,
} from '@univerjs/engine-formula';
import { Engine, IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
RefRangeService,
SheetInterceptorService,
SheetSkeletonService,
SheetsSelectionsService,
} from '@univerjs/sheets';
import {
ConditionalFormattingFormulaService,
ConditionalFormattingRangeIndexModel,
ConditionalFormattingRangeTransformService,
ConditionalFormattingRuleModel,
ConditionalFormattingService,
ConditionalFormattingStyleComposer,
ConditionalFormattingViewModel,
} from '@univerjs/sheets-conditional-formatting';
import enUS from '@univerjs/sheets/locale/en-US';
import zhCN from '@univerjs/sheets/locale/zh-CN';
import '@univerjs/sheets/facade';
import '@univerjs/sheets-conditional-formatting/facade';
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: {},
resources: [
{
name: 'SHEET_CONDITIONAL_FORMATTING_PLUGIN',
data: '{"sheet1":[{"cfId":"AEGZdW8C","ranges":[{"startRow":2,"startColumn":1,"endRow":5,"endColumn":5,"startAbsoluteRefType":0,"endAbsoluteRefType":0,"rangeType":0}],"rule":{"type":"highlightCell","subType":"text","operator":"containsText","style":{"cl":{"rgb":"#2f56ef"},"bg":{"rgb":"#e8ecfc"}},"value":""},"stopIfTrue":false},{"cfId":"4ICEXdJj","ranges":[{"startRow":4,"startColumn":1,"endRow":7,"endColumn":7,"startAbsoluteRefType":0,"endAbsoluteRefType":0,"rangeType":0}],"rule":{"type":"highlightCell","subType":"text","operator":"containsText","style":{"cl":{"rgb":"#2f56ef"},"bg":{"rgb":"#e8ecfc"}},"value":""},"stopIfTrue":false},{"cfId":"geCv018z","ranges":[{"startRow":11,"startColumn":1,"endRow":12,"endColumn":5,"startAbsoluteRefType":0,"endAbsoluteRefType":0,"rangeType":0}],"rule":{"type":"highlightCell","subType":"text","operator":"containsText","style":{"cl":{"rgb":"#2f56ef"},"bg":{"rgb":"#e8ecfc"}},"value":""},"stopIfTrue":false}]}',
},
],
};
}
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([IRenderManagerService, { useClass: RenderManagerServiceTestBed }]);
injector.add([SheetSkeletonService]);
injector.add([FormulaDataModel]);
injector.add([LexerTreeBuilder]);
injector.add([RefRangeService]);
injector.add([IFunctionService, { useClass: FunctionService }]);
injector.add([IDefinedNamesService, { useClass: DefinedNamesService }]);
injector.add([ISuperTableService, { useClass: SuperTableService }]);
injector.add([IDescriptionService, { useClass: DescriptionService }]);
injector.add([IRegisterFunctionService, { useClass: RegisterFunctionService }]);
injector.add([FormulaCalculationSessionService]);
injector.add([FormulaCalculationSessionController]);
// register feature modules
([
// conditional formatting
[ConditionalFormattingService],
[ConditionalFormattingFormulaService],
[ConditionalFormattingRangeTransformService],
[ConditionalFormattingStyleComposer],
[ConditionalFormattingRuleModel],
[ConditionalFormattingRangeIndexModel],
[ConditionalFormattingViewModel],
[RegisterOtherFormulaService],
[IActiveDirtyManagerService, { useClass: ActiveDirtyManagerService }],
[ISheetRowFilteredService, { useClass: SheetRowFilteredService }],
] as Dependency[]).forEach((d) => {
injector.add(d);
});
dependencies?.forEach((d) => injector.add(d));
this._injector.get(SheetInterceptorService);
this._injector.get(FormulaCalculationSessionController);
this._injector.get(ConditionalFormattingService);
this._injector.get(ConditionalFormattingViewModel);
}
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,434 @@
/**
* 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, Univer } from '@univerjs/core';
import type { FUniver } from '@univerjs/core/facade';
import { ICommandService } from '@univerjs/core/services/command/command.service.js';
import {
FormulaExecuteStageType,
OtherFormulaMarkDirty,
RemoveOtherFormulaMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,
SetFormulaCalculationStartMutation,
SetOtherFormulaMutation,
} from '@univerjs/engine-formula';
import { SetSelectionsOperation } from '@univerjs/sheets';
import {
AddCfCommand,
AddConditionalRuleMutation,
CFNumberOperator,
CFTimePeriodOperator,
CFValueType,
ClearRangeCfCommand,
ClearWorksheetCfCommand,
ConditionalFormattingFormulaService,
ConditionalFormattingViewModel,
DeleteCfCommand,
DeleteConditionalRuleMutation,
IIconSetType,
MoveCfCommand,
MoveConditionalRuleMutation,
SetCfCommand,
SetConditionalRuleMutation,
} from '@univerjs/sheets-conditional-formatting';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createFacadeTestBed } from './create-test-bed';
import '@univerjs/sheets-formula/facade';
describe('Test conditional formatting facade', () => {
let get: Injector['get'];
let commandService: ICommandService;
let univerAPI: FUniver;
let univer: Univer = null as any;
beforeEach(() => {
if (univer) {
univer.dispose();
}
const testBed = createFacadeTestBed();
get = testBed.get;
univerAPI = testBed.univerAPI;
univer = testBed.univer;
commandService = get(ICommandService);
[
AddCfCommand,
AddConditionalRuleMutation,
ClearRangeCfCommand,
ClearWorksheetCfCommand,
DeleteCfCommand,
DeleteConditionalRuleMutation,
MoveCfCommand,
MoveConditionalRuleMutation,
SetConditionalRuleMutation,
SetCfCommand,
SetSelectionsOperation,
OtherFormulaMarkDirty,
RemoveOtherFormulaMutation,
SetOtherFormulaMutation,
SetFormulaCalculationStartMutation,
SetFormulaCalculationNotificationMutation,
SetFormulaCalculationResultMutation,
].forEach((m) => {
commandService.registerCommand(m);
});
vi.stubGlobal('requestIdleCallback', ((callback: IdleRequestCallback) => {
callback({ didTimeout: false, timeRemaining: () => 16 } as IdleDeadline);
return 1;
}) as typeof requestIdleCallback);
});
it('Gets all the conditional formatting for the current sheet', () => {
const rules = univerAPI.getActiveWorkbook()?.getActiveSheet().getConditionalFormattingRules();
expect(rules?.length).toEqual(3);
});
it('Gets all the conditional formatting for the current range', () => {
const workbook = univerAPI.getActiveWorkbook();
const worksheet = workbook?.getActiveSheet();
workbook?.setActiveRange(worksheet?.getRange(5, 5, 3, 3)!);
const rules = univerAPI.getActiveWorkbook()?.getActiveRange()?.getConditionalFormattingRules();
expect(rules?.length).toEqual(2);
});
it('Creates a constructor for conditional formatting', () => {
const workbook = univerAPI.getActiveWorkbook();
const worksheet = workbook?.getActiveSheet();
const range = worksheet?.getRange(0, 0, 2, 2);
const rule = worksheet?.newConditionalFormattingRule()
.whenCellNotEmpty()
.setRanges([range!.getRange()])
.setBold(true)
.setItalic(false)
.setStrikethrough(true)
.setUnderline(true)
.setBackground('red')
.setFontColor('green')
.setBackground()
.setFontColor()
.build();
expect({ ...rule, cfId: 123 }).toEqual({
rule: {
type: 'highlightCell',
subType: 'text',
operator: 'notEqual',
value: '',
style: {
bl: 1,
it: 0,
st: {
s: 1,
},
ul: {
s: 1,
},
},
},
ranges: [
{
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 1,
unitId: 'test',
sheetId: 'sheet1',
},
],
cfId: 123,
stopIfTrue: false,
});
});
it('Builds conditional formatting rules for text, numbers, dates, rank, duplicate values, data bars, color scales, and icon sets', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet();
const range = worksheet.getRange('A1:B2').getRange();
const builder = worksheet.newConditionalFormattingRule();
expect(univerAPI.Enum.ConditionFormatNumberOperatorEnum.greaterThan).toBe(CFNumberOperator.greaterThan);
expect(univerAPI.Enum.ConditionFormatTimePeriodOperatorEnum.last7Days).toBe(CFTimePeriodOperator.last7Days);
expect(univerAPI.Enum.ConditionFormatValueTypeEnum.num).toBe(CFValueType.num);
expect(univerAPI.Enum.ConditionFormatIconSetTypeEnum.threeArrows).toBe(IIconSetType.threeArrows);
const rules = [
builder.whenCellEmpty().setRanges([range]).build(),
builder.whenDate(CFTimePeriodOperator.last7Days).setRanges([range]).build(),
builder.whenFormulaSatisfied('=$A1>10').setRanges([range]).build(),
builder.whenNumberBetween(20, 10).setRanges([range]).build(),
builder.whenNumberEqualTo(10).setRanges([range]).build(),
builder.whenNumberGreaterThan(10).setRanges([range]).build(),
builder.whenNumberGreaterThanOrEqualTo(10).setRanges([range]).build(),
builder.whenNumberLessThan(10).setRanges([range]).build(),
builder.whenNumberLessThanOrEqualTo(10).setRanges([range]).build(),
builder.whenNumberNotBetween(20, 10).setRanges([range]).build(),
builder.whenNumberNotEqualTo(10).setRanges([range]).build(),
builder.whenTextContains('apple').setRanges([range]).build(),
builder.whenTextDoesNotContain('apple').setRanges([range]).build(),
builder.whenTextEndsWith('.ai').setRanges([range]).build(),
builder.whenTextEqualTo('done').setRanges([range]).build(),
builder.whenTextStartsWith('https://').setRanges([range]).build(),
builder.setAverage(CFNumberOperator.greaterThan).setRanges([range]).build(),
builder.setUniqueValues().setRanges([range]).build(),
builder.setDuplicateValues().setRanges([range]).build(),
builder.setRank({ isBottom: true, isPercent: true, value: 10 }).setRanges([range]).build(),
builder.setDataBar({
min: { type: CFValueType.num, value: -100 },
max: { type: CFValueType.num, value: 100 },
positiveColor: '#00ff00',
nativeColor: '#ff0000',
isGradient: true,
isShowValue: true,
}).setRanges([range]).build(),
builder.setColorScale([
{ index: 0, color: '#00ff00', value: { type: CFValueType.min } },
{ index: 1, color: '#ffff00', value: { type: CFValueType.percent, value: 50 } },
{ index: 2, color: '#ff0000', value: { type: CFValueType.max } },
]).setRanges([range]).build(),
builder.setIconSet({
isShowValue: true,
iconConfigs: [
{
iconType: IIconSetType.threeArrows,
iconId: '0',
operator: CFNumberOperator.greaterThan,
value: { type: CFValueType.num, value: 20 },
},
{
iconType: IIconSetType.threeArrows,
iconId: '1',
operator: CFNumberOperator.greaterThan,
value: { type: CFValueType.num, value: 10 },
},
{
iconType: IIconSetType.threeArrows,
iconId: '2',
operator: CFNumberOperator.lessThanOrEqual,
value: { type: CFValueType.num, value: 10 },
},
],
}).setRanges([range]).build(),
];
expect(rules.map((rule) => rule.rule.type)).toEqual([
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'highlightCell',
'dataBar',
'colorScale',
'iconSet',
]);
expect(rules.map((rule) => ('operator' in rule.rule ? rule.rule.operator : undefined))).toContain(CFNumberOperator.between);
expect(rules.map((rule) => ('operator' in rule.rule ? rule.rule.operator : undefined))).toContain(CFNumberOperator.notBetween);
expect(rules[3].rule).toMatchObject({ value: [10, 20] });
expect(rules[20].rule).toMatchObject({
type: 'dataBar',
isShowValue: true,
config: {
min: { type: 'num', value: -100 },
max: { type: 'num', value: 100 },
positiveColor: '#00ff00',
nativeColor: '#ff0000',
isGradient: true,
},
});
expect(rules[21].rule).toMatchObject({ type: 'colorScale', config: [{ index: 0 }, { index: 1 }, { index: 2 }] });
expect(rules[22].rule).toMatchObject({ type: 'iconSet', isShowValue: true, config: [{ iconId: '0' }, { iconId: '1' }, { iconId: '2' }] });
expect(builder.getIconMap()[IIconSetType.threeArrows].length).toBeGreaterThan(0);
});
it('Creates rule and add', () => {
const workbook = univerAPI.getActiveWorkbook();
const worksheet = workbook?.getActiveSheet();
const rule = worksheet?.newConditionalFormattingRule()
.whenCellNotEmpty()
.setRanges([{ startRow: 0, endRow: 100, startColumn: 0, endColumn: 100 }])
.setItalic(true)
.setItalic(true)
.setBackground('red')
.setFontColor('green')
.build();
worksheet?.addConditionalFormattingRule(rule!);
const conditionalFormattingViewModel = get(ConditionalFormattingViewModel);
const cell4 = conditionalFormattingViewModel.getCellCfs(workbook!.getId(), worksheet!.getSheetId(), 0, 4);
const cell5 = conditionalFormattingViewModel.getCellCfs(workbook!.getId(), worksheet!.getSheetId(), 0, 5);
expect(cell4?.map((e) => e.result)).toEqual([
{
it: 1,
bg: {
rgb: 'rgb(255,0,0)',
},
cl: {
rgb: 'rgb(0,128,0)',
},
},
]);
expect(cell5?.map((e) => e.result)).toEqual([{}]);
});
it('resolves onCalculationResultApplied after conditional-formatting custom formula results are emitted', async () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet();
const unitId = workbook.getId();
const subUnitId = worksheet.getSheetId();
const formula = univerAPI.getFormula();
await univerAPI.executeCommand('sheet.mutation.add-conditional-rule', {
unitId,
subUnitId,
rule: {
cfId: 'cf-1',
stopIfTrue: false,
ranges: [
{
startRow: 0,
startColumn: 0,
endRow: 9,
endColumn: 0,
startAbsoluteRefType: 0,
endAbsoluteRefType: 0,
rangeType: 0,
unitId,
sheetId: subUnitId,
},
],
rule: {
type: 'highlightCell',
subType: 'formula',
operator: 'containsText',
value: '=VALUE($A1)<=3',
style: {
bg: { rgb: 'rgb(254,243,199)' },
},
},
},
});
const formulaService = get(ConditionalFormattingFormulaService);
const registeredFormula = formulaService.getSubUnitFormulaMap(unitId, subUnitId)?.getValues()[0];
expect(registeredFormula?.cfId).toBe('cf-1');
expect(registeredFormula?.formulaText).toBe('=VALUE($A1)<=3');
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: {
[unitId]: {
[subUnitId]: {
[registeredFormula!.formulaId]: {
0: {
0: [[{ v: true }]],
},
},
},
},
},
});
await expect(waitForResult).resolves.toBeUndefined();
});
it('Delete conditional format according to cfId', async () => {
const workbook = univerAPI.getActiveWorkbook();
const worksheet = workbook?.getActiveSheet();
const rules = worksheet?.getConditionalFormattingRules();
expect(rules?.length).toEqual(3);
await worksheet?.deleteConditionalFormattingRule(rules![0].cfId);
expect(worksheet?.getConditionalFormattingRules()?.length).toEqual(2);
});
it('Modify the priority of the conditional format', () => {
const workbook = univerAPI.getActiveWorkbook();
const worksheet = workbook?.getActiveSheet();
const rules = worksheet?.getConditionalFormattingRules()!;
const rule = rules[2];
const targetRule = rules[0];
worksheet?.moveConditionalFormattingRule(rule.cfId, targetRule.cfId, 'before');
const index = worksheet?.getConditionalFormattingRules()!.findIndex((r) => r.cfId === rule.cfId);
expect(index).toEqual(0);
});
it('Set the conditional format according to cfId', () => {
const workbook = univerAPI.getActiveWorkbook();
const worksheet = workbook?.getActiveSheet();
const rules = worksheet?.getConditionalFormattingRules()!;
const rule = rules[0];
worksheet?.setConditionalFormattingRule(rule.cfId, { ...rule, ranges: [] });
const afterEditRule = worksheet?.getConditionalFormattingRules()[0];
expect(afterEditRule?.cfId).toEqual(rule.cfId);
expect(afterEditRule?.ranges).toEqual([]);
});
it('Clears conditional formatting from a range without removing unrelated sheet rules', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet();
const range = worksheet.getRange('A1:B2');
const rule = range.createConditionalFormattingRule()
.whenNumberGreaterThan(100)
.setBackground('#ff0000')
.build();
worksheet.addConditionalFormattingRule(rule);
expect(range.getConditionalFormattingRules().map((item) => item.cfId)).toContain(rule.cfId);
range.clearConditionalFormatRules();
expect(range.getConditionalFormattingRules().map((item) => item.cfId)).not.toContain(rule.cfId);
expect(worksheet.getConditionalFormattingRules().length).toBe(3);
});
it('Clears all conditional formatting rules from a worksheet', () => {
const workbook = univerAPI.getActiveWorkbook()!;
const worksheet = workbook.getActiveSheet();
expect(worksheet.getConditionalFormattingRules().length).toBeGreaterThan(0);
worksheet.clearConditionalFormatRules();
expect(worksheet.getConditionalFormattingRules()).toEqual([]);
});
});

View File

@@ -0,0 +1,68 @@
/**
* 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 { CFNumberOperator, CFTimePeriodOperator, CFValueType, IIconSetType } from '@univerjs/sheets-conditional-formatting';
/**
* @ignore
*/
export interface IFSheetsConditionalFormattingEnumMixin {
/**
* Conditional formatting number operator
*/
ConditionFormatNumberOperatorEnum: typeof CFNumberOperator;
/**
* Conditional formatting time period operator
*/
ConditionFormatTimePeriodOperatorEnum: typeof CFTimePeriodOperator;
/**
* Conditional formatting icon set type
*/
ConditionFormatIconSetTypeEnum: typeof IIconSetType;
/**
* Conditional formatting value type
*/
ConditionFormatValueTypeEnum: typeof CFValueType;
}
export class FSheetsConditionalFormattingEnumMixin extends FEnum implements IFSheetsConditionalFormattingEnumMixin {
override get ConditionFormatNumberOperatorEnum(): typeof CFNumberOperator {
return CFNumberOperator;
}
override get ConditionFormatTimePeriodOperatorEnum(): typeof CFTimePeriodOperator {
return CFTimePeriodOperator;
}
override get ConditionFormatIconSetTypeEnum(): typeof IIconSetType {
return IIconSetType;
}
override get ConditionFormatValueTypeEnum(): typeof CFValueType {
return CFValueType;
}
}
FEnum.extend(FSheetsConditionalFormattingEnumMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
export interface FEnum extends IFSheetsConditionalFormattingEnumMixin {
}
}

View File

@@ -0,0 +1,133 @@
/**
* 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 {
IClearRangeCfParams,
IConditionFormattingRule,
} from '@univerjs/sheets-conditional-formatting';
import { Rectangle } from '@univerjs/core';
import {
ClearRangeCfCommand,
ConditionalFormattingRuleModel,
} from '@univerjs/sheets-conditional-formatting';
import { FRange } from '@univerjs/sheets/facade';
import { FConditionalFormattingBuilder } from './f-conditional-formatting-builder';
/**
* @ignore
*/
export interface IFRangeSheetsConditionalFormattingMixin {
/**
* Gets all the conditional formatting for the current range.
* @returns {IConditionFormattingRule[]} conditional formatting rules for the current range.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a conditional formatting rule that sets the cell format to italic, red background, and green font color when the cell is not empty.
* const fRange = fWorksheet.getRange('A1:T100');
* const rule = fWorksheet.newConditionalFormattingRule()
* .whenCellNotEmpty()
* .setRanges([fRange.getRange()])
* .setItalic(true)
* .setBackground('red')
* .setFontColor('green')
* .build();
* fWorksheet.addConditionalFormattingRule(rule);
*
* // Get all the conditional formatting rules for the range F6:H8.
* const targetRange = fWorksheet.getRange('F6:H8');
* const rules = targetRange.getConditionalFormattingRules();
* console.log(rules);
* ```
*/
getConditionalFormattingRules(): IConditionFormattingRule[];
/**
* Creates a constructor for conditional formatting
* @returns {FConditionalFormattingBuilder} The conditional formatting builder
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a conditional formatting rule that sets the cell format to italic, red background, and green font color when the cell is not empty.
* const fRange = fWorksheet.getRange('A1:T100');
* const rule = fRange.createConditionalFormattingRule()
* .whenCellNotEmpty()
* .setItalic(true)
* .setBackground('red')
* .setFontColor('green')
* .build();
* fWorksheet.addConditionalFormattingRule(rule);
* console.log(fRange.getConditionalFormattingRules());
* ```
*/
createConditionalFormattingRule(): FConditionalFormattingBuilder;
/**
* Clear the conditional rules for the range.
* @returns {FRange} Returns the current range instance for method chaining
* @memberof IFRangeConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:T100');
*
* // Clear all conditional format rules for the range
* fRange.clearConditionalFormatRules();
* console.log(fRange.getConditionalFormattingRules()); // []
* ```
*/
clearConditionalFormatRules(): FRange;
}
export class FRangeSheetsConditionalFormattingMixin extends FRange implements IFRangeSheetsConditionalFormattingMixin {
private _getConditionalFormattingRuleModel(): ConditionalFormattingRuleModel {
return this._injector.get(ConditionalFormattingRuleModel);
}
override getConditionalFormattingRules(): IConditionFormattingRule[] {
const rules = this._getConditionalFormattingRuleModel().getSubunitRules(this._workbook.getUnitId(), this._worksheet.getSheetId()) || [];
return [...rules].filter((rule) => rule.ranges.some((range) => Rectangle.intersects(range, this._range)));
}
override createConditionalFormattingRule(): FConditionalFormattingBuilder {
return new FConditionalFormattingBuilder({ ranges: [this._range] });
}
override clearConditionalFormatRules(): FRange {
const params: IClearRangeCfParams = {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
ranges: [this._range],
};
this._commandService.syncExecuteCommand(ClearRangeCfCommand.id, params);
return this;
}
}
FRange.extend(FRangeSheetsConditionalFormattingMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FRange extends IFRangeSheetsConditionalFormattingMixin { }
}

View File

@@ -0,0 +1,262 @@
/**
* 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 {
IAddCfCommandParams,
IAnchor,
IClearWorksheetCfParams,
IConditionFormattingRule,
IDeleteCfCommandParams,
IMoveCfCommandParams,
ISetCfCommandParams,
} from '@univerjs/sheets-conditional-formatting';
import {
AddCfCommand,
ClearWorksheetCfCommand,
ConditionalFormattingRuleModel,
DeleteCfCommand,
MoveCfCommand,
SetCfCommand,
} from '@univerjs/sheets-conditional-formatting';
import { FWorksheet } from '@univerjs/sheets/facade';
import { FConditionalFormattingBuilder } from './f-conditional-formatting-builder';
/**
* @ignore
*/
export interface IFWorksheetConditionalFormattingMixin {
/**
* Gets all the conditional formatting for the current sheet
* @returns {IConditionFormattingRule[]} conditional formatting rules for the current sheet
* @memberof IFWorksheetConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const rules = fWorksheet.getConditionalFormattingRules();
* console.log(rules);
* ```
*/
getConditionalFormattingRules(): IConditionFormattingRule[];
/**
* Creates a constructor for conditional formatting
* @returns {FConditionalFormattingBuilder} The conditional formatting builder
* @memberof IFWorksheetConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a conditional formatting rule that sets the cell format to italic, red background, and green font color when the cell is not empty.
* const fRange = fWorksheet.getRange('A1:T100');
* const rule = fWorksheet.newConditionalFormattingRule()
* .whenCellNotEmpty()
* .setRanges([fRange.getRange()])
* .setItalic(true)
* .setBackground('red')
* .setFontColor('green')
* .build();
* fWorksheet.addConditionalFormattingRule(rule);
* ```
*/
newConditionalFormattingRule(): FConditionalFormattingBuilder;
/**
* Add a new conditional format
* @param {IConditionFormattingRule} rule - The conditional formatting rule to add
* @returns {FWorksheet} Returns the current worksheet instance for method chaining
* @memberof IFWorksheetConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a conditional formatting rule that sets the cell format to italic, red background, and green font color when the cell is not empty.
* const fRange = fWorksheet.getRange('A1:T100');
* const rule = fWorksheet.newConditionalFormattingRule()
* .whenCellNotEmpty()
* .setRanges([fRange.getRange()])
* .setItalic(true)
* .setBackground('red')
* .setFontColor('green')
* .build();
* fWorksheet.addConditionalFormattingRule(rule);
* ```
*/
addConditionalFormattingRule(rule: IConditionFormattingRule): FWorksheet;
/**
* Delete conditional format according to `cfId`
* @param {string} cfId - The conditional formatting rule id to delete
* @returns {FWorksheet} Returns the current worksheet instance for method chaining
* @memberof IFWorksheetConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const rules = fWorksheet.getConditionalFormattingRules();
*
* // Delete the first rule
* fWorksheet.deleteConditionalFormattingRule(rules[0]?.cfId);
* ```
*/
deleteConditionalFormattingRule(cfId: string): FWorksheet;
/**
* Modify the priority of the conditional format
* @param {string} cfId - The conditional formatting rule id to move
* @param {string} toCfId Target rule
* @param {IAnchor['type']} [type] After the default move to the destination rule, if type = before moves to the front, the default value is after
* @returns {FWorksheet} Returns the current worksheet instance for method chaining
* @memberof FWorksheetConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const rules = fWorksheet.getConditionalFormattingRules();
*
* // Move the third rule before the first rule
* const rule = rules[2];
* const targetRule = rules[0];
* fWorksheet.moveConditionalFormattingRule(rule?.cfId, targetRule?.cfId, 'before');
* ```
*/
moveConditionalFormattingRule(cfId: string, toCfId: string, type?: IAnchor['type']): FWorksheet;
/**
* Set the conditional format according to `cfId`
* @param {string} cfId - The conditional formatting rule id to set
* @param {IConditionFormattingRule} rule - The conditional formatting rule to set
* @returns {FWorksheet} Returns the current worksheet instance for method chaining
* @memberof IFWorksheetConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a conditional formatting rule that sets the cell format to italic, red background, and green font color when the cell is not empty.
* const fRange = fWorksheet.getRange('A1:T100');
* const rule = fWorksheet.newConditionalFormattingRule()
* .whenCellNotEmpty()
* .setRanges([fRange.getRange()])
* .setItalic(true)
* .setBackground('red')
* .setFontColor('green')
* .build();
* fWorksheet.addConditionalFormattingRule(rule);
*
* // Modify the first rule to apply to a new range
* const rules = fWorksheet.getConditionalFormattingRules();
* const newRuleRange = fWorksheet.getRange('A1:D10');
* fWorksheet.setConditionalFormattingRule(rules[0]?.cfId, { ...rules[0], ranges: [newRuleRange.getRange()] });
* ```
*/
setConditionalFormattingRule(cfId: string, rule: IConditionFormattingRule): FWorksheet;
/**
* Removes all conditional format rules from the sheet.
* @returns {FWorksheet} Returns the current worksheet instance for method chaining
* @memberof IFWorksheetConditionalFormattingMixin
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* fWorksheet.clearConditionalFormatRules();
* console.log(fWorksheet.getConditionalFormattingRules()); // []
* ```
*/
clearConditionalFormatRules(): FWorksheet;
}
export class FWorksheetConditionalFormattingMixin extends FWorksheet implements IFWorksheetConditionalFormattingMixin {
private _getConditionalFormattingRuleModel(): ConditionalFormattingRuleModel {
return this._injector.get(ConditionalFormattingRuleModel);
}
override getConditionalFormattingRules(): IConditionFormattingRule[] {
const rules = this._getConditionalFormattingRuleModel().getSubunitRules(this._workbook.getUnitId(), this._worksheet.getSheetId()) || [];
return [...rules];
}
override newConditionalFormattingRule(): FConditionalFormattingBuilder {
return new FConditionalFormattingBuilder();
}
override addConditionalFormattingRule(rule: IConditionFormattingRule): FWorksheet {
const params: IAddCfCommandParams = {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
rule,
};
this._commandService.syncExecuteCommand(AddCfCommand.id, params);
return this;
}
override deleteConditionalFormattingRule(cfId: string): FWorksheet {
const params: IDeleteCfCommandParams = {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
cfId,
};
this._commandService.syncExecuteCommand(DeleteCfCommand.id, params);
return this;
}
override moveConditionalFormattingRule(cfId: string, toCfId: string, type: IAnchor['type'] = 'after'): FWorksheet {
const params: IMoveCfCommandParams = {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
start: { id: cfId, type: 'self' },
end: { id: toCfId, type },
};
this._commandService.syncExecuteCommand(MoveCfCommand.id, params);
return this;
}
override setConditionalFormattingRule(cfId: string, rule: IConditionFormattingRule): FWorksheet {
const params: ISetCfCommandParams = {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
cfId,
rule,
};
this._commandService.syncExecuteCommand(SetCfCommand.id, params);
return this;
}
override clearConditionalFormatRules(): FWorksheet {
const params: IClearWorksheetCfParams = {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
};
this._commandService.syncExecuteCommand(ClearWorksheetCfCommand.id, params);
return this;
}
}
FWorksheet.extend(FWorksheetConditionalFormattingMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorksheet extends IFWorksheetConditionalFormattingMixin { }
}

View File

@@ -0,0 +1,23 @@
/**
* 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-range';
import './f-worksheet';
import './f-enum';
export { FConditionalFormattingBuilder } from './f-conditional-formatting-builder';
export type { IFRangeSheetsConditionalFormattingMixin } from './f-range';
export type { IFWorksheetConditionalFormattingMixin } from './f-worksheet';

View File

@@ -0,0 +1,68 @@
/**
* 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 * from './base/const';
export { AddCfCommand } from './commands/commands/add-cf.command';
export type { IAddCfCommandParams } from './commands/commands/add-cf.command';
export { ClearRangeCfCommand } from './commands/commands/clear-range-cf.command';
export type { IClearRangeCfParams } from './commands/commands/clear-range-cf.command';
export { ClearWorksheetCfCommand } from './commands/commands/clear-worksheet-cf.command';
export type { IClearWorksheetCfParams } from './commands/commands/clear-worksheet-cf.command';
export { DeleteCfCommand } from './commands/commands/delete-cf.command';
export type { IDeleteCfCommandParams } from './commands/commands/delete-cf.command';
export { MoveCfCommand } from './commands/commands/move-cf.command';
export type { IMoveCfCommandParams } from './commands/commands/move-cf.command';
export { SetCfCommand } from './commands/commands/set-cf.command';
export type { ISetCfCommandParams } from './commands/commands/set-cf.command';
export {
AddConditionalRuleMutation,
AddConditionalRuleMutationUndoFactory,
} from './commands/mutations/add-conditional-rule.mutation';
export type { IAddConditionalRuleMutationParams } from './commands/mutations/add-conditional-rule.mutation';
export {
DeleteConditionalRuleMutation,
DeleteConditionalRuleMutationUndoFactory,
} from './commands/mutations/delete-conditional-rule.mutation';
export type { IDeleteConditionalRuleMutationParams } from './commands/mutations/delete-conditional-rule.mutation';
export {
MoveConditionalRuleMutation,
MoveConditionalRuleMutationUndoFactory,
} from './commands/mutations/move-conditional-rule.mutation';
export type { IMoveConditionalRuleMutationParams } from './commands/mutations/move-conditional-rule.mutation';
export type { ISetConditionalRuleMutationParams } from './commands/mutations/set-conditional-rule.mutation';
export {
SetConditionalRuleMutation,
setConditionalRuleMutationUndoFactory,
} from './commands/mutations/set-conditional-rule.mutation';
export type { IUniverSheetsConditionalFormattingConfig } from './config/config';
export * from './models/calculate-unit-v2/utils';
export * from './models/conditional-formatting-range-index-model';
export * from './models/conditional-formatting-rule-model';
export * from './models/conditional-formatting-view-model';
export * from './models/icon-map';
export * from './models/type';
export { UniverSheetsConditionalFormattingPlugin } from './plugin';
export * from './render/data-bar.render';
export * from './render/icon.render';
export * from './render/type';
export * from './services/conditional-formatting-formula.service';
export * from './services/conditional-formatting-range-transform.service';
export * from './services/conditional-formatting-style-composer.service';
export * from './services/conditional-formatting.service';
export * from './utils/anchor';
export * from './utils/create-cf-id';
export * from './utils/remove-undefined-attr';
export * from './utils/type';

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'الاتجاه',
shape: 'الشكل',
mark: 'العلامة',
rank: 'الترتيب',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Direcció',
shape: 'Forma',
mark: 'Marca',
rank: 'Rang',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Richtung',
shape: 'Form',
mark: 'Zeichen',
rank: 'Rang',
},
},
};
export default locale;

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.
*/
const locale = {
'sheets-conditional-formatting': {
iconSet: {
direction: 'Direction',
shape: 'Shape',
mark: 'Mark',
rank: 'Rank',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Dirección',
shape: 'Forma',
mark: 'Marca',
rank: 'Rango',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'جهت',
shape: 'شکل',
mark: 'علامت',
rank: 'رتبه',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Direction',
shape: 'Forme',
mark: 'Marque',
rank: 'Rang',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Arah',
shape: 'Bentuk',
mark: 'Tanda',
rank: 'Peringkat',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Direzione',
shape: 'Forma',
mark: 'Marca',
rank: 'Grado',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: '方向',
shape: '図形',
mark: 'インジケーター',
rank: '評価',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: '방향',
shape: '모양',
mark: '표식',
rank: '순위',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Kierunek',
shape: 'Kształt',
mark: 'Znak',
rank: 'Ranga',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Direção',
shape: 'Forma',
mark: 'Marca',
rank: 'Classificação',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Направление',
shape: 'Форма',
mark: 'Знак',
rank: 'Ранг',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Smer',
shape: 'Tvar',
mark: 'Značka',
rank: 'Poradie',
},
},
};
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,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: 'Hướng',
shape: 'Hình dạng',
mark: 'Dấu',
rank: 'Hạng',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: '方向',
shape: '形状',
mark: '标记',
rank: '等级',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: '方向',
shape: '形狀',
mark: '標記',
rank: '等級',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* 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-conditional-formatting': {
iconSet: {
direction: '方向',
shape: '形狀',
mark: '標記',
rank: '等級',
},
},
};
export default locale;

View File

@@ -0,0 +1,98 @@
/**
* 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 { IConditionFormattingRule } from '../type';
import { describe, expect, it } from 'vitest';
import { CFRuleType, CFSubRuleType, CFTextOperator } from '../../base/const';
import { ConditionalFormattingRangeIndexModel } from '../conditional-formatting-range-index-model';
import { ConditionalFormattingRuleModel } from '../conditional-formatting-rule-model';
function createRule(cfId: string, startRow: number, endRow: number, startColumn: number, endColumn: number): IConditionFormattingRule {
return {
cfId,
ranges: [{ startRow, endRow, startColumn, endColumn }],
stopIfTrue: false,
rule: {
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.containsText,
value: 'x',
style: { bg: { rgb: '#fff' } },
},
};
}
describe('ConditionalFormattingRangeIndexModel', () => {
it('queries rule ids by cell and range through rule model changes', () => {
const ruleModel = new ConditionalFormattingRuleModel();
const rangeIndex = new ConditionalFormattingRangeIndexModel(ruleModel);
const unitId = 'unit';
const subUnitId = 'sheet';
ruleModel.addRule(unitId, subUnitId, createRule('a', 0, 2, 0, 2));
ruleModel.addRule(unitId, subUnitId, createRule('b', 10, 12, 10, 12));
expect(rangeIndex.getRuleIdsByCell(unitId, subUnitId, 1, 1)).toEqual(new Set(['a']));
expect(rangeIndex.getRuleIdsByRanges(unitId, subUnitId, [{
startRow: 1,
endRow: 11,
startColumn: 1,
endColumn: 11,
}])).toEqual(new Set(['a', 'b']));
ruleModel.setRule(unitId, subUnitId, createRule('a', 20, 21, 20, 21), 'a');
expect(rangeIndex.getRuleIdsByCell(unitId, subUnitId, 1, 1)).toEqual(new Set());
expect(rangeIndex.getRuleIdsByCell(unitId, subUnitId, 20, 20)).toEqual(new Set(['a']));
ruleModel.deleteRule(unitId, subUnitId, 'a');
expect(rangeIndex.getRuleIdsByCell(unitId, subUnitId, 20, 20)).toEqual(new Set());
rangeIndex.dispose();
});
it('indexes rules that already exist before construction', () => {
const ruleModel = new ConditionalFormattingRuleModel();
const unitId = 'unit';
const subUnitId = 'sheet';
ruleModel.addRule(unitId, subUnitId, createRule('a', 0, 2, 0, 2));
const rangeIndex = new ConditionalFormattingRangeIndexModel(ruleModel);
expect(rangeIndex.getRuleIdsByCell(unitId, subUnitId, 1, 1)).toEqual(new Set(['a']));
rangeIndex.dispose();
});
it('can rebuild after unit rules are removed without rule change events', () => {
const ruleModel = new ConditionalFormattingRuleModel();
const rangeIndex = new ConditionalFormattingRangeIndexModel(ruleModel);
const unitId = 'unit';
const subUnitId = 'sheet';
ruleModel.addRule(unitId, subUnitId, createRule('a', 0, 2, 0, 2));
expect(rangeIndex.getRuleIdsByCell(unitId, subUnitId, 1, 1)).toEqual(new Set(['a']));
ruleModel.deleteUnitId(unitId);
rangeIndex.rebuild();
expect(rangeIndex.getRuleIdsByCell(unitId, subUnitId, 1, 1)).toEqual(new Set());
rangeIndex.dispose();
});
});

View File

@@ -0,0 +1,62 @@
/**
* 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 { IColorScale } from '../../type';
import { CellValueType } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { CFRuleType, CFValueType } from '../../../base/const';
import { ColorScaleCalculateUnit } from '../color-scale-calculate-unit';
describe('test-color-scale', () => {
it('test-sync-computing', () => {
const colorScaleUnit = new ColorScaleCalculateUnit({
unitId: '',
subUnitId: '',
workbook: null as any,
worksheet: null as any,
rule: {
cfId: '123',
ranges: [],
stopIfTrue: false,
rule: {
type: CFRuleType.colorScale,
config: [{ index: 0, value: { type: CFValueType.num, value: 10 }, color: 'red' }, { index: 1, value: { type: CFValueType.num, value: 20 }, color: 'white' }, { index: 2, value: { type: CFValueType.num, value: 30 }, color: 'green' }],
} as IColorScale,
},
limit: 1000,
accessor: null as any,
getCellValue: (row, col) => ({ v: row + col, t: CellValueType.NUMBER }),
});
const count = vi.spyOn(colorScaleUnit, 'getCellResult' as any);
const color1 = colorScaleUnit.getCell(1, 1);
const color2 = colorScaleUnit.getCell(2, 12);
const color3 = colorScaleUnit.getCell(5, 20);
const color4 = colorScaleUnit.getCell(11, 20);
expect(color1).toBe('rgb(255,0,0)');
expect(color2).toBe('rgb(255,102,102)');
expect(color3).toBe('rgb(128,192,128)');
expect(color4).toBe('rgb(0,128,0)');
// 测试缓存
colorScaleUnit.getCell(1, 1);
// 之前已经 colorScaleUnit.getCell(1, 1) 后,会缓存,而跳过执行一次计算.
expect(count.mock.calls.length).toBe(4);
colorScaleUnit.clearCache();
colorScaleUnit.getCell(1, 1);
// 清除缓存后,会重新计算一次。
expect(count.mock.calls.length).toBe(5);
});
});

View File

@@ -0,0 +1,171 @@
/**
* 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 { IColorScale, IConditionFormattingRule } from '../../type';
import { beforeEach, describe, expect, it } from 'vitest';
import { CFRuleType, CFValueType } from '../../../base/const';
import { createTestBed } from './test.util';
describe('Test colorScale', () => {
let testBed: ReturnType<typeof createTestBed>;
beforeEach(() => {
testBed && testBed.univer.dispose();
testBed = createTestBed();
testBed.getConditionalFormattingViewModel();
});
it('colorScale normal test', () => {
const params: IConditionFormattingRule<IColorScale> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 5, endColumn: 6 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
type: CFRuleType.colorScale,
config: [{
index: 0,
color: '#d0d9fb',
value: {
type: CFValueType.min,
},
}, {
index: 1,
color: '#2e55ef',
value: {
type: CFValueType.max,
},
}],
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0);
const one = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0);
const two = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const three = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 2);
const four = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 3);
const five = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 4);
expect(one).toEqual({
style: {
bg: {
rgb: 'rgb(208,217,251)',
},
},
});
expect(two).toEqual({
style: {
bg: {
rgb: 'rgb(181,195,249)',
},
},
});
expect(three).toEqual({
style: {
bg: {
rgb: 'rgb(154,173,247)',
},
},
});
expect(four).toEqual({
style: {
bg: {
rgb: 'rgb(127,151,245)',
},
},
});
expect(five).toEqual({
style: {
bg: {
rgb: 'rgb(100,129,243)',
},
},
});
});
it('The colors are consistent, but the values are interchangeable', () => {
const params: IConditionFormattingRule<IColorScale> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 5, endColumn: 6 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
type: CFRuleType.colorScale,
config: [{
index: 0,
color: '#d0d9fb',
value: {
type: CFValueType.num,
value: 1,
},
}, {
index: 1,
color: '#2e55ef',
value: {
type: CFValueType.num,
value: -5,
},
}, {
index: 2,
color: 'rgb(231, 37, 143)',
value: {
type: CFValueType.num,
value: 3,
},
}],
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0);
const one = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0); // 1
const two = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1); // 2
const three = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 2);// 3
const four = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 3); // 4
const five = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 6); // is same as 3
expect(one).toEqual({
style: {
bg: {
rgb: 'rgb(46,85,239)',
},
},
});
expect(two).toEqual({
style: {
bg: {
rgb: 'rgb(139,61,191)',
},
},
});
expect(three).toEqual({
style: {
bg: {
rgb: 'rgb(231,37,143)',
},
},
});
expect(four).toEqual({
style: {
bg: {
rgb: 'rgb(231,37,143)',
},
},
});
expect(five).toEqual({
style: {
bg: {
rgb: 'rgb(231,37,143)',
},
},
});
});
});

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 type { IConditionFormattingRule, IDataBar } from '../../type';
import { beforeEach, describe, expect, it } from 'vitest';
import { CFRuleType, CFValueType } from '../../../base/const';
import { createTestBed } from './test.util';
describe('Test conditional formatting data bar', () => {
let testBed: ReturnType<typeof createTestBed>;
beforeEach(() => {
testBed && testBed.univer.dispose();
testBed = createTestBed();
testBed.getConditionalFormattingViewModel();
});
it('The maximum and minimum values of the data bar are corrected automatically when minimum is greater than maximum.', () => {
const params: IConditionFormattingRule<IDataBar> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 6 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
type: CFRuleType.dataBar,
isShowValue: false,
config: {
// The minimum is greater than the maximum
min: { type: CFValueType.num, value: 7 },
max: { type: CFValueType.num, value: 1 },
isGradient: false,
positiveColor: '#abd91a',
nativeColor: '#ffbe38',
},
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const start = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 0, 0);
const end = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 0, 6);
expect(start).toEqual({});
expect(end).toEqual({
dataBar: {
color: '#abd91a',
startPoint: 0,
value: 100,
isGradient: false,
isShowValue: false,
},
isShowValue: false,
});
});
it('test data bar normal', () => {
const params: IConditionFormattingRule<IDataBar> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 6, endColumn: 6 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
type: CFRuleType.dataBar,
isShowValue: true,
config: {
min: { value: 2, type: CFValueType.num },
max: { value: 5, type: CFValueType.num },
isGradient: true,
positiveColor: '#abd91a',
nativeColor: '#ffbe38',
},
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const one = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0);
const two = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const three = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 2);
const four = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 3);
const five = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 4);
const six = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 6, 0);
// If less than the minimum value,do not render the data bar.
expect(one).toEqual({});
expect(two).toEqual({});
expect(three).toEqual({
dataBar: {
color: '#abd91a',
startPoint: 0,
value: 33.333333333333336,
isGradient: true,
isShowValue: true,
},
isShowValue: true,
});
expect(four).toEqual({
dataBar: {
color: '#abd91a',
startPoint: 0,
value: 66.66666666666667,
isGradient: true,
isShowValue: true,
},
isShowValue: true,
});
expect(five).toEqual({
dataBar: {
color: '#abd91a',
startPoint: 0,
value: 100,
isGradient: true,
isShowValue: true,
},
isShowValue: true,
});
expect(six).toEqual({});
});
});

View File

@@ -0,0 +1,749 @@
/**
* 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 { IAverageHighlightCell, IConditionFormattingRule, INumberHighlightCell, IRankHighlightCell, ITextHighlightCell } from '../../type';
import { beforeEach, describe, expect, it } from 'vitest';
import { CFNumberOperator, CFRuleType, CFSubRuleType, CFTextOperator } from '../../../base/const';
import { createTestBed } from './test.util';
describe('Test conditional formatting highlight', () => {
let testBed: ReturnType<typeof createTestBed>;
beforeEach(() => {
testBed && testBed.univer.dispose();
testBed = createTestBed();
testBed.getConditionalFormattingViewModel();
});
describe('Test Number', async () => {
it('Should apply higher-priority rule style when multiple rules are matched', () => {
const lowerPriorityRule: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#FFC000' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.between,
value: [0, 10],
},
};
const higherPriorityRule: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#FFFF00' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.between,
value: [0, 10],
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, lowerPriorityRule);
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, higherPriorityRule);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#FFFF00' } } });
});
it('Should not stop at stopIfTrue rule when the rule is not matched', () => {
const shouldApplyLaterRule: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.between,
value: [0, 10],
},
};
const stopIfTrueButNotMatched: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: true,
rule: {
style: { bg: { rgb: '#FF0000' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.greaterThan,
value: 100,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, shouldApplyLaterRule);
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, stopIfTrueButNotMatched);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is CFNumberOperator.between', async () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.between,
value: [0, 10],
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is not CFNumberOperator.between', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.between,
value: [5, 10],
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('Is CFNumberOperator.equal', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.equal,
value: 2,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is not CFNumberOperator.equal', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.equal,
value: 2,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 2);
expect(result).toEqual({ style: {} });
});
it('Is CFNumberOperator.greaterThan', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.greaterThan,
value: 0,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is not CFNumberOperator.greaterThan', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.greaterThan,
value: 2,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('Is CFNumberOperator.greaterThanOrEqual', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.greaterThanOrEqual,
value: 0,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is not CFNumberOperator.greaterThanOrEqual', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.greaterThanOrEqual,
value: 3,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('Is CFNumberOperator.lessThan', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.lessThan,
value: 3,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it(`Is not ${CFNumberOperator.lessThan}`, () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.lessThan,
value: 0,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it(`Is ${CFNumberOperator.lessThanOrEqual}`, () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.lessThanOrEqual,
value: 3,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is not CFNumberOperator.lessThanOrEqual', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.lessThanOrEqual,
value: 0,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('Is CFNumberOperator.notBetween', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.notBetween,
value: [5, 7],
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is not CFNumberOperator.notBetween', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.notBetween,
value: [0, 3],
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('Is CFNumberOperator.notEqual', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.notEqual,
value: 11,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('Is not CFNumberOperator.notEqual', () => {
const params: IConditionFormattingRule<INumberHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.notEqual,
value: 2,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
});
describe('Test Text', () => {
it('is CFTextOperator.beginsWith', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.beginsWith,
value: '2',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is not CFTextOperator.beginsWith', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.beginsWith,
value: '11',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('is CFTextOperator.endsWith', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.endsWith,
value: '2',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is not CFTextOperator.endsWith', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.endsWith,
value: '11',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('is CFTextOperator.containsText', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.containsText,
value: '2',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is not CFTextOperator.containsText', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.containsText,
value: '11',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('is CFTextOperator.equal', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.containsText,
value: '2',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is not CFTextOperator.equal', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.containsText,
value: '11',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('is CFTextOperator.notEqual', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.notEqual,
value: '22',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is not CFTextOperator.notEqual', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.notEqual,
value: '2',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
it('is CFTextOperator.notContainsText', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.notEqual,
value: '11',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is not CFTextOperator.notContainsText', () => {
const params: IConditionFormattingRule<ITextHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.text,
operator: CFTextOperator.notEqual,
value: '2',
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: {} });
});
});
describe('Test average', () => {
it(`is ${CFNumberOperator.greaterThanOrEqual} average`, () => {
const params: IConditionFormattingRule<IAverageHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.average,
operator: CFNumberOperator.greaterThanOrEqual,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it(`is ${CFNumberOperator.lessThanOrEqual} average`, () => {
const params: IConditionFormattingRule<IAverageHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 2, endColumn: 2 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.average,
operator: CFNumberOperator.lessThanOrEqual,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
});
describe('Test rank', () => {
it('is bottom 2', () => {
const params: IConditionFormattingRule<IRankHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 4, endColumn: 4 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.rank,
isBottom: true,
isPercent: false,
value: 2,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 0);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is bottom 6', () => {
const params: IConditionFormattingRule<IRankHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 4, endColumn: 4 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.rank,
isBottom: true,
isPercent: false,
value: 6,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
const result = testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 1, 1);
expect(result).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
});
it('is bottom 22%', () => {
const params: IConditionFormattingRule<IRankHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 8 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.rank,
isBottom: true,
isPercent: true,
value: 22,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const results = [
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 0, 0),
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 0, 1),
];
expect(results[0]).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
expect(results[1]).toEqual({ style: {} });
});
it('is bottom 23%', () => {
const params: IConditionFormattingRule<IRankHighlightCell> = {
ranges: [{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 8 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.rank,
isBottom: true,
isPercent: true,
value: 23,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const results = [
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 0, 0),
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 0, 1),
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 0, 2),
];
expect(results[0]).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
expect(results[1]).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
expect(results[2]).toEqual({ style: {} });
});
// In this case, 2 items should be highlighted. Where the second item is exactly 20%.
it('is bottom 20%', () => {
const params: IConditionFormattingRule<IRankHighlightCell> = {
ranges: [{ startRow: 7, startColumn: 0, endRow: 7, endColumn: 9 }],
cfId: testBed.getConditionalFormattingRuleModel().createCfId(testBed.unitId, testBed.subUnitId),
stopIfTrue: false,
rule: {
style: { bg: { rgb: '#2AEAEA' } },
type: CFRuleType.highlightCell,
subType: CFSubRuleType.rank,
isBottom: true,
isPercent: true,
value: 20,
},
};
testBed.getConditionalFormattingRuleModel().addRule(testBed.unitId, testBed.subUnitId, params);
const results = [
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 7, 0),
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 7, 1),
testBed.getConditionalFormattingService().composeStyle(testBed.unitId, testBed.subUnitId, 7, 2),
];
expect(results[0]).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
expect(results[1]).toEqual({ style: { bg: { rgb: '#2AEAEA' } } });
expect(results[2]).toEqual({ style: {} });
});
});
});

View File

@@ -0,0 +1,129 @@
/**
* 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 { ICommandService, Inject, Injector, IUniverInstanceService, LocaleType, Plugin, Univer, UniverInstanceType } from '@univerjs/core';
import { IActiveDirtyManagerService, RegisterOtherFormulaService } from '@univerjs/engine-formula';
import { SheetInterceptorService } from '@univerjs/sheets';
import { AddConditionalRuleMutation } from '../../../commands/mutations/add-conditional-rule.mutation';
import { DeleteConditionalRuleMutation } from '../../../commands/mutations/delete-conditional-rule.mutation';
import { MoveConditionalRuleMutation } from '../../../commands/mutations/move-conditional-rule.mutation';
import { SetConditionalRuleMutation } from '../../../commands/mutations/set-conditional-rule.mutation';
import { ConditionalFormattingFormulaService } from '../../../services/conditional-formatting-formula.service';
import { ConditionalFormattingRangeTransformService } from '../../../services/conditional-formatting-range-transform.service';
import { ConditionalFormattingStyleComposer } from '../../../services/conditional-formatting-style-composer.service';
import { ConditionalFormattingService } from '../../../services/conditional-formatting.service';
import { ConditionalFormattingRangeIndexModel } from '../../conditional-formatting-range-index-model';
import { ConditionalFormattingRuleModel } from '../../conditional-formatting-rule-model';
import { ConditionalFormattingViewModel } from '../../conditional-formatting-view-model';
const TEST_WORKBOOK_DATA_DEMO: () => IWorkbookData = () => ({
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: { 0: { v: 1, t: 2 }, 1: { v: 2, t: 2 }, 2: { v: 3, t: 2 }, 3: { v: 4, t: 2 }, 4: { v: 5, t: 2 }, 5: { v: 6, t: 2 }, 6: { v: 7, t: 2 }, 7: { v: 8, t: 2 }, 8: { v: 9, t: 2 } },
1: { 0: { v: 1, t: 2 }, 1: { v: 2, t: 2 }, 2: { v: 3, t: 2 }, 3: { v: 4, t: 2 }, 4: { v: 5, t: 2 }, 5: { v: 6, t: 2 }, 6: { v: 7, t: 2 } },
2: { 0: { v: 1, t: 2 }, 1: { v: 2, t: 2 }, 2: { v: 3, t: 2 }, 3: { v: 4, t: 2 }, 4: { v: 5, t: 2 }, 5: { v: 6, t: 2 }, 6: { v: 7, t: 2 } },
3: { 0: { v: 1, t: 2 }, 1: { v: 2, t: 2 }, 2: { v: 3, t: 2 }, 3: { v: 4, t: 2 }, 4: { v: 5, t: 2 }, 5: { v: 6, t: 2 }, 6: { v: 7, t: 2 } },
4: { 0: { v: 1, t: 2 }, 1: { v: 2, t: 2 }, 2: { v: 3, t: 2 }, 3: { v: 4, t: 2 }, 4: { v: 5, t: 2 }, 5: { v: 6, t: 2 }, 6: { v: 7, t: 2 } },
5: { 0: { v: 1, t: 2 }, 1: { v: 2, t: 2 }, 2: { v: 3, t: 2 }, 3: { v: 4, t: 2 }, 4: { v: 5, t: 2 }, 5: { v: 6, t: 2 }, 6: { v: 7, t: 2 } },
6: { 0: { v: 0, t: 2 } },
7: { 0: { v: 1, t: 2 }, 1: { v: 2, t: 2 }, 2: { v: 3, t: 2 }, 3: { v: 4, t: 2 }, 4: { v: 5, t: 2 }, 5: { v: 6, t: 2 }, 6: { v: 7, t: 2 }, 7: { v: 8, t: 2 }, 8: { v: 9, t: 2 }, 9: { v: 10, t: 2 } },
},
rowCount: 8,
columnCount: 10,
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
resources: [],
});
export const createTestBed = (dependencies?: Dependency[]) => {
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;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
this._injector.add([SheetInterceptorService]);
this._injector.add([ConditionalFormattingService]);
this._injector.add([RegisterOtherFormulaService]);
this._injector.add([ConditionalFormattingFormulaService]);
this._injector.add([ConditionalFormattingRangeTransformService]);
this._injector.add([ConditionalFormattingStyleComposer]);
this._injector.add([ConditionalFormattingRuleModel]);
this._injector.add([ConditionalFormattingRangeIndexModel]);
this._injector.add([ConditionalFormattingViewModel]);
this._injector.add([IActiveDirtyManagerService, { useFactory: () => ({ register: () => { /* empty */ } } as any) }]);
}
override onStarting(): void {
dependencies?.forEach((d) => this._injector.add(d));
}
}
univer.registerPlugin(TestPlugin);
const workbookJson = TEST_WORKBOOK_DATA_DEMO();
const workbook = univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookJson);
const univerInstanceService = injector.get(IUniverInstanceService);
const commandService = injector.get(ICommandService);
[
AddConditionalRuleMutation,
DeleteConditionalRuleMutation,
SetConditionalRuleMutation,
MoveConditionalRuleMutation,
].forEach((commandInfo) => {
commandService.registerCommand(commandInfo);
});
const unitId = workbookJson.id;
const subUnitId = workbookJson.sheets.sheet1.id!;
univerInstanceService.focusUnit('test');
univerInstanceService.setCurrentUnitForType(unitId);
const getConditionalFormattingRuleModel = () => injector.get(ConditionalFormattingRuleModel);
const getConditionalFormattingViewModel = () => injector.get(ConditionalFormattingViewModel);
const getConditionalFormattingService = () => injector.get(ConditionalFormattingService);
return {
univer,
get,
workbook,
unitId,
subUnitId,
commandService,
getConditionalFormattingRuleModel,
getConditionalFormattingViewModel,
getConditionalFormattingService,
};
};

View File

@@ -0,0 +1,157 @@
/**
* 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 { IConditionFormattingRule } from '../../type';
import { BooleanNumber, CellValueType, ColorKit, ObjectMatrix } from '@univerjs/core';
import { FormulaResultStatus } from '@univerjs/engine-formula';
import { describe, expect, it } from 'vitest';
import { CFNumberOperator, CFRuleType, CFSubRuleType, CFValueType } from '../../../base/const';
import {
compareWithNumber,
filterRange,
getCacheStyleMatrix,
getCellValue,
getColorScaleFromValue,
getMaxInFormulaResult,
getOppositeOperator,
getValueByType,
isFloatsEqual,
isNullable,
serialTimeToTimestamp,
} from '../utils';
describe('conditional formatting calculate utilities', () => {
it('normalizes cell values used by conditional formatting rules', () => {
expect(isFloatsEqual(1, 1 + Number.EPSILON / 2)).toBe(true);
expect(isNullable(null)).toBe(true);
expect(isNullable(undefined)).toBe(true);
expect(isNullable(' ')).toBe(true);
expect(getCellValue()).toBeNull();
expect(getCellValue({ t: CellValueType.BOOLEAN, v: BooleanNumber.TRUE })).toBe('TRUE');
expect(getCellValue({ t: CellValueType.BOOLEAN, v: BooleanNumber.FALSE })).toBe('FALSE');
expect(getCellValue({ v: '', p: { body: { dataStream: 'rich\r\n' } } as any })).toBe('rich');
expect(getCellValue({ v: 'plain' })).toBe('plain');
});
it('converts spreadsheet serial dates and filters ranges to sheet bounds', () => {
expect(serialTimeToTimestamp(44927.5)).toBe(new Date(2023, 0, 1, 12).getTime());
expect(filterRange([
{ startRow: 0, startColumn: 0, endRow: 10, endColumn: 10 },
{ startRow: 100, startColumn: 0, endRow: 101, endColumn: 1 },
], 5, 6)).toEqual([
{ startRow: 0, startColumn: 0, endRow: 5, endColumn: 6 },
]);
});
it('calculates threshold values from matrices for data bars, color scales and icon sets', () => {
const matrix = new ObjectMatrix<number>();
matrix.setValue(0, 0, 2);
matrix.setValue(0, 1, 6);
matrix.setValue(0, 2, 10);
const context = {
cfId: 'cf-1',
unitId: 'unit',
subUnitId: 'sheet',
accessor: {
get() {
throw new Error('formula service is not used by this test');
},
},
} as any;
expect(getValueByType({ type: CFValueType.max }, matrix, context)).toEqual({
status: FormulaResultStatus.SUCCESS,
result: 10,
});
expect(getValueByType({ type: CFValueType.min }, matrix, context)).toEqual({
status: FormulaResultStatus.SUCCESS,
result: 2,
});
expect(getValueByType({ type: CFValueType.percent, value: 50 }, matrix, context)).toEqual({
status: FormulaResultStatus.SUCCESS,
result: 6,
});
expect(getValueByType({ type: CFValueType.percentile, value: 50 }, matrix, context)).toEqual({
status: FormulaResultStatus.SUCCESS,
result: 6,
});
expect(getValueByType({ type: CFValueType.num, value: 'bad-number' }, matrix, context)).toEqual({
status: FormulaResultStatus.SUCCESS,
result: 0,
});
});
it('compares numbers and derives opposite operators for icon range bands', () => {
expect(compareWithNumber({ operator: CFNumberOperator.between, value: [10, 1] }, 5)).toBe(true);
expect(compareWithNumber({ operator: CFNumberOperator.notBetween, value: [1, 10] }, 12)).toBe(true);
expect(compareWithNumber({ operator: CFNumberOperator.equal, value: 3 }, 3)).toBe(true);
expect(compareWithNumber({ operator: CFNumberOperator.notEqual, value: 3 }, 4)).toBe(true);
expect(compareWithNumber({ operator: CFNumberOperator.greaterThan, value: 3 }, 4)).toBe(true);
expect(compareWithNumber({ operator: CFNumberOperator.greaterThanOrEqual, value: 3 }, 3)).toBe(true);
expect(compareWithNumber({ operator: CFNumberOperator.lessThan, value: 3 }, 2)).toBe(true);
expect(compareWithNumber({ operator: CFNumberOperator.lessThanOrEqual, value: 3 }, 3)).toBe(true);
expect(compareWithNumber({ operator: 'unknown' as CFNumberOperator, value: 3 }, 3)).toBe(false);
expect(getOppositeOperator(CFNumberOperator.greaterThan)).toBe(CFNumberOperator.lessThanOrEqual);
expect(getOppositeOperator(CFNumberOperator.greaterThanOrEqual)).toBe(CFNumberOperator.lessThan);
expect(getOppositeOperator(CFNumberOperator.lessThan)).toBe(CFNumberOperator.greaterThanOrEqual);
expect(getOppositeOperator(CFNumberOperator.lessThanOrEqual)).toBe(CFNumberOperator.greaterThan);
expect(getOppositeOperator(CFNumberOperator.equal)).toBe(CFNumberOperator.equal);
});
it('interpolates colors, reads cached style results, and computes formula result maxima', () => {
expect(getColorScaleFromValue([
{ value: 0, color: new ColorKit('#000000') },
{ value: 10, color: new ColorKit('#ffffff') },
], -1)).toBe('rgb(0,0,0)');
expect(getColorScaleFromValue([
{ value: 0, color: new ColorKit('#000000') },
{ value: 10, color: new ColorKit('#ffffff') },
], 5)).toBe('rgb(128,128,128)');
expect(getColorScaleFromValue([
{ value: 0, color: new ColorKit('#000000') },
{ value: 10, color: new ColorKit('#ffffff') },
], 20)).toBe('rgb(255,255,255)');
const rule: IConditionFormattingRule = {
cfId: 'cf-1',
ranges: [{ startRow: 0, startColumn: 0, endRow: 1, endColumn: 1 }],
stopIfTrue: false,
rule: {
type: CFRuleType.highlightCell,
subType: CFSubRuleType.number,
operator: CFNumberOperator.equal,
style: {},
},
} as any;
const matrix = getCacheStyleMatrix<string>('unit', 'sheet', rule, {
accessor: {
get() {
return {
getCellCfs(_unitId: string, _subUnitId: string, row: number, col: number) {
return row === 0 && col === 0
? [{ cfId: 'cf-1', result: 'hit' }, { cfId: 'cf-2', result: 'miss' }]
: undefined;
},
};
},
},
} as any);
expect(matrix.getValue(0, 0)).toBe('hit');
expect(matrix.getValue(1, 1)).toBeUndefined();
expect(getMaxInFormulaResult({ 0: { 0: 1, 1: '9' } })).toBe(9);
});
});

View File

@@ -0,0 +1,148 @@
/**
* 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, Nullable, Workbook, Worksheet } from '@univerjs/core';
import type { IConditionFormattingRule } from '../type';
import { LRUMap } from '@univerjs/core';
import { BehaviorSubject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
export enum CalculateEmitStatus {
preComputingStart = 'preComputingStart',
preComputing = 'preComputing',
preComputingEnd = 'preComputingEnd',
preComputingError = 'preComputingError',
}
export interface IContext {
unitId: string;
subUnitId: string;
workbook: Workbook;
worksheet: Worksheet;
accessor: IAccessor;
rule: IConditionFormattingRule;
getCellValue: (row: number, col: number) => ICellData;
limit: number;
}
/**
* Processing Main Path Calculation Logic
*/
export abstract class BaseCalculateUnit<C = any, S = any> {
/**
* 3nd-level cache
*/
private _cache: LRUMap<string, Nullable<S>>;
protected _preComputingStatus$ = new BehaviorSubject<CalculateEmitStatus>(CalculateEmitStatus.preComputingStart);
public preComputingStatus$ = this._preComputingStatus$.asObservable().pipe(distinctUntilChanged());
/**
* 2nd-level cache
*/
private _preComputingCache: Nullable<C>;
private _rule: IConditionFormattingRule;
constructor(private _context: IContext) {
this._cache = new LRUMap(_context.limit);
this._rule = _context.rule;
this._preComputingCache = null;
this._initClearCacheListener();
}
public setCacheLength(length: number) {
this._cache.limit = length;
}
public clearCache() {
this._cache.clear();
}
public resetPreComputingCache() {
this._preComputingStatus$.next(CalculateEmitStatus.preComputingStart);
this._preComputingCache = null;
}
public updateRule(rule: IConditionFormattingRule) {
this._rule = rule;
this.resetPreComputingCache();
}
public getCell(row: number, col: number) {
const key = this._createKey(row, col);
if (this._preComputingStatus$.getValue() === CalculateEmitStatus.preComputing) {
// If the pre-calculation is not yet complete, return the previous result directly.
return this._cache.get(key);
}
let preComputingResult = this.getPreComputingResult(row, col);
if (preComputingResult === null) {
this._preComputingStatus$.next(CalculateEmitStatus.preComputingStart);
this.preComputing(row, col, this._getContext());
preComputingResult = this.getPreComputingResult(row, col);
if (preComputingResult === null) {
// If the calculation is not yet complete, return the previous cache.
return this._cache.get(key);
}
}
this._preComputingStatus$.next(CalculateEmitStatus.preComputingEnd);
if (this._cache.has(key)) {
return this._cache.get(key);
}
const result = this.getCellResult(row, col, preComputingResult, this._getContext());
if (result !== null) {
this._setCache(row, col, result);
}
return result;
}
// If a null value is set, it indicates that caching is not required.
abstract preComputing(row: number, col: number, context: IContext): void;
/**
* If a null value is returned, it indicates that caching is not required.
*/
protected abstract getCellResult(row: number, col: number, preComputingResult: Nullable<C>, context: IContext): Nullable<S>;
protected setPreComputingCache(v: C) {
this._preComputingCache = v;
}
protected getPreComputingResult(_row: number, _col: number) {
return this._preComputingCache;
}
private _createKey(row: number, col: number) {
return `${row}_${col}`;
}
private _setCache(row: number, col: number, v: Nullable<S>) {
const key = this._createKey(row, col);
this._cache.set(key, v);
}
private _getContext() {
return { ...this._context, rule: this._rule };
}
private _initClearCacheListener() {
this.preComputingStatus$.subscribe((e) => {
if (e === CalculateEmitStatus.preComputingEnd) {
this.clearCache();
}
});
}
}

View File

@@ -0,0 +1,95 @@
/**
* 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 { IColorScale, IConditionFormattingRule } from '../type';
import type { IContext } from './base-calculate-unit';
import { CellValueType, ColorKit, ObjectMatrix, Range } from '@univerjs/core';
import { FormulaResultStatus } from '@univerjs/engine-formula';
import { isObject } from '@univerjs/engine-render';
import { CFValueType } from '../../base/const';
import { BaseCalculateUnit, CalculateEmitStatus } from './base-calculate-unit';
import { filterRange, getColorScaleFromValue, getValueByType, isNullable } from './utils';
;
interface IConfigItem {
value: number;
color: ColorKit;
}
export class ColorScaleCalculateUnit extends BaseCalculateUnit<IConfigItem[], string> {
override preComputing(_row: number, _col: number, context: IContext): void {
const rule = context.rule as IConditionFormattingRule<IColorScale>;
const worksheet = context.worksheet;
const matrix = new ObjectMatrix<number>();
const isNeedMatrix = !rule.rule.config.every((item) => item.value.type === CFValueType.num);
if (isNeedMatrix) {
const ranges = filterRange(rule.ranges, worksheet.getMaxRows() - 1, worksheet.getMaxColumns() - 1);
ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
const cell = context.getCellValue(row, col);
const v = cell && cell.v;
if (!isNullable(v) && cell?.t === CellValueType.NUMBER) {
const _value = Number(v);
!Number.isNaN(_value) && matrix.setValue(row, col, _value);
}
});
});
}
const _configList = [...rule.rule.config].sort((a, b) => a.index - b.index).map((config) => {
return {
value: getValueByType(config.value, matrix, {
...context,
cfId: rule.cfId,
}),
color: new ColorKit(config.color),
};
});
const isAllFinished = !_configList.some((item) => isObject(item.value) ? item.value.status !== FormulaResultStatus.SUCCESS : false);
if (isAllFinished) {
const colorList = _configList
.map((item) => item.color)
.reduce((result, color, index) => {
result.result.push({ color, value: result.sortValue[index] });
return result;
}, {
result: [] as { value: number; color: ColorKit }[],
sortValue: _configList.map((item) => item.value.result as number).sort((a, b) => a - b),
})
.result;
this.setPreComputingCache(colorList);
this._preComputingStatus$.next(CalculateEmitStatus.preComputingEnd);
return;
}
this._preComputingStatus$.next(CalculateEmitStatus.preComputing);
}
protected override getCellResult(row: number, col: number, preComputingResult: IConfigItem[], context: IContext) {
if (!preComputingResult) {
return null;
}
const cell = context.getCellValue(row, col);
if (cell.t === CellValueType.NUMBER) {
const v = Number(cell.v);
if (!Number.isNaN(v)) {
const color = getColorScaleFromValue(preComputingResult, v);
return color;
}
}
return undefined;
}
}

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 { ICellData } from '@univerjs/core';
import type { IConditionFormattingRule, IDataBar } from '../type';
import type { IContext } from './base-calculate-unit';
import { CellValueType, ObjectMatrix, Range } from '@univerjs/core';
import { FormulaResultStatus } from '@univerjs/engine-formula';
import { CFValueType } from '../../base/const';
import { defaultDataBarNativeColor, defaultDataBarPositiveColor, defaultPlaceholderColor } from '../../render/data-bar.render';
import { BaseCalculateUnit, CalculateEmitStatus } from './base-calculate-unit';
import { filterRange, getValueByType, isNullable } from './utils';
;
const getSafeValue = (v: number) => Math.max(Math.min(100, v), 0);
const getValue = (row: number, col: number, getCell: (row: number, col: number) => ICellData) => {
const cell = getCell(row, col);
if (cell && cell.t === CellValueType.NUMBER) {
const value = Number(cell.v);
return Number.isNaN(value) ? null : value;
}
return null;
};
interface IConfig {
min: number;
max: number;
startPoint: number;
}
export class DataBarCalculateUnit extends BaseCalculateUnit<IConfig> {
override preComputing(row: number, col: number, context: IContext): void {
const rule = context.rule as IConditionFormattingRule<IDataBar>;
const ruleConfig = rule.rule;
const worksheet = context.worksheet;
const matrix = new ObjectMatrix<number>();
const isNeedMatrix = ![rule.rule.config.max, rule.rule.config.min].every((e) => e.type === CFValueType.num);
if (isNeedMatrix) {
const ranges = filterRange(rule.ranges, worksheet.getMaxRows() - 1, worksheet.getMaxColumns() - 1);
ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
const cell = context.getCellValue(row, col);
const v = cell && cell.v;
if (!isNullable(v) && cell?.t === CellValueType.NUMBER) {
const _value = Number(v);
!Number.isNaN(_value) && matrix.setValue(row, col, _value);
}
});
});
}
const _min = getValueByType(ruleConfig.config.min, matrix, { ...context, cfId: rule.cfId });
const _max = getValueByType(ruleConfig.config.max, matrix, { ...context, cfId: rule.cfId });
let min = 0;
let max = 0;
// If the formula triggers the calculation, wait for the result,
// and use the previous style cache until the result comes out。
if (_min.status === FormulaResultStatus.SUCCESS) {
const v = Number(_min.result); // Get the minimum value
min = Number.isNaN(v) ? 0 : v;
} else {
this._preComputingStatus$.next(CalculateEmitStatus.preComputing);
return;
}
if (_max.status === FormulaResultStatus.SUCCESS) {
const maxResult = Number(_max.result); // Get the maximum value
const v = Number.isNaN(maxResult) ? 0 : maxResult;
max = Math.max(v, min);
min = Math.min(v, min);
let startPoint = 50;
if (min < 0 && max <= 0) {
startPoint = 100;
} else if (min < 0 && max > 0) {
const length = Math.abs(max) + Math.abs(min);
startPoint = Math.abs(min) / length * 100;
} else if (min >= 0 && max > 0) {
startPoint = 0;
}
this.setPreComputingCache({ min, max, startPoint });
this._preComputingStatus$.next(CalculateEmitStatus.preComputingEnd);
return;
}
this._preComputingStatus$.next(CalculateEmitStatus.preComputing);
}
// eslint-disable-next-line complexity
protected override getCellResult(row: number, col: number, preComputingResult: IConfig, context: IContext) {
const { min, max, startPoint } = preComputingResult;
const rule = context.rule as IConditionFormattingRule<IDataBar>;
const ruleConfig = rule.rule;
const isShowValue = ruleConfig.isShowValue;
const isGradient = ruleConfig.config.isGradient;
const value = getValue(row, col, context.getCellValue);
if (value === null || value < min || (min === max || max < min)) {
return undefined;
}
if (value === 0) {
// Renders a placeholder if the current value is 0
return { color: defaultPlaceholderColor, startPoint, value: 0, isGradient, isShowValue };
}
if (min < 0 && max <= 0) {
const length = max - min;
const v = getSafeValue((max - value) / length * 100);
if (v === 0) {
// Renders noting if the result value is 0
return undefined;
}
return { color: ruleConfig.config.nativeColor || defaultDataBarNativeColor, startPoint, value: -v, isGradient, isShowValue };
} else if (min < 0 && max > 0) {
if (value > 0) {
const v = getSafeValue(Math.min(value / max, 1) * 100);
if (v === 0) {
return undefined;
}
return { color: ruleConfig.config.positiveColor || defaultDataBarPositiveColor, startPoint, value: v, isGradient, isShowValue };
} else {
const v = getSafeValue(Math.min(Math.abs(value) / Math.abs(min), 1) * 100);
if (v === 0) {
return undefined;
}
return { color: ruleConfig.config.nativeColor || defaultDataBarNativeColor, startPoint, value: -v, isGradient, isShowValue };
}
} else if (min >= 0 && max > 0) {
const length = max - min;
const startPoint = 0;
const v = getSafeValue((1 - (max - value) / length) * 100);
if (v === 0) {
return undefined;
}
return { color: ruleConfig.config.positiveColor || defaultDataBarPositiveColor, startPoint, value: v, isGradient, isShowValue };
}
}
}

View File

@@ -0,0 +1,422 @@
/**
* 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 { IStyleData, Nullable } from '@univerjs/core';
import type { IAverageHighlightCell, IFormulaHighlightCell, IHighlightCell, INumberHighlightCell, IRankHighlightCell, ITextHighlightCell, ITimePeriodHighlightCell } from '../type';
import type { IContext } from './base-calculate-unit';
import { CellValueType, dateKit, Range, Tools } from '@univerjs/core';
import { ERROR_TYPE_SET, FormulaResultStatus } from '@univerjs/engine-formula';
import { CFNumberOperator, CFSubRuleType, CFTextOperator, CFTimePeriodOperator } from '../../base/const';
import { ConditionalFormattingFormulaService } from '../../services/conditional-formatting-formula.service';
import { BaseCalculateUnit, CalculateEmitStatus } from './base-calculate-unit';
import { compareWithNumber, filterRange, getCellValue, isFloatsEqual, isNullable, serialTimeToTimestamp } from './utils';
interface IConfig {
value: any;
type: CFSubRuleType;
}
const isFormulaResultMatched = (value: unknown) => value === true || value === 1 || value === 'TRUE';
const sortRangesByTopLeft = <T extends { startRow: number; startColumn: number }>(ranges: T[]) => [...ranges].sort((a, b) => {
if (a.startRow !== b.startRow) {
return a.startRow - b.startRow;
}
return a.startColumn - b.startColumn;
});
export class HighlightCellCalculateUnit extends BaseCalculateUnit<Nullable<IConfig>, Nullable<IStyleData>> {
// eslint-disable-next-line max-lines-per-function
override preComputing(row: number, col: number, context: IContext): void {
const ruleConfig = context.rule.rule as IHighlightCell;
const ranges = filterRange(context.rule.ranges, context.worksheet.getMaxRows() - 1, context.worksheet.getMaxColumns() - 1);
// eslint-disable-next-line max-lines-per-function, complexity
const getCache = () => {
switch (ruleConfig.subType) {
case CFSubRuleType.average: {
let sum = 0;
let count = 0;
ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
const cell = context.getCellValue(row, col);
const v = getCellValue(cell);
if (cell && cell.t === CellValueType.NUMBER && !isNullable(v)) {
sum += Number(v) || 0;
count++;
}
});
});
return { value: sum / count, type: ruleConfig.subType };
}
case CFSubRuleType.uniqueValues:
case CFSubRuleType.duplicateValues: {
const cacheMap: Map<any, number> = new Map();
ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
const cell = context.getCellValue(row, col);
const v = getCellValue(cell);
if (!isNullable(v)) {
const cache = cacheMap.get(v);
if (cache) {
cacheMap.set(v, cache + 1);
} else {
cacheMap.set(v, 1);
}
}
});
});
return { value: cacheMap, type: ruleConfig.subType };
}
case CFSubRuleType.rank: {
let allValue: number[] = [];
ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
const cell = context.getCellValue(row, col);
const v = getCellValue(cell);
if (cell && cell.t === CellValueType.NUMBER && !isNullable(v)) {
allValue.push(Number(v) || 0);
}
});
});
allValue.sort((a, b) => b - a);
const configRule = context.rule.rule as IRankHighlightCell;
if (configRule.isPercent) {
if (configRule.isBottom) {
allValue = allValue.slice().reverse();
}
// Calculate the index directly based on the threshold percentage.
const threshold = Tools.clamp(configRule.value, 0, 100) / 100;
const targetIndex = Math.floor(threshold * allValue.length);
// Ensure the index is within bounds
const safeIndex = Tools.clamp(targetIndex - 1, 0, allValue.length - 1);
return { value: allValue[safeIndex], type: ruleConfig.subType };
}
const targetIndex = Math.floor(Tools.clamp(configRule.isBottom ? (configRule.value - 1) : configRule.value, 0, allValue.length));
if (configRule.isBottom) {
return { value: allValue[allValue.length - targetIndex - 1], type: ruleConfig.subType };
} else {
return { value: allValue[Math.max(targetIndex - 1, 0)], type: ruleConfig.subType };
}
}
case CFSubRuleType.formula: {
const _ruleConfig = ruleConfig as IFormulaHighlightCell;
const conditionalFormattingFormulaService = context.accessor.get(ConditionalFormattingFormulaService);
const normalizedRanges = sortRangesByTopLeft(context.rule.ranges);
conditionalFormattingFormulaService.registerFormulaWithRange(context.unitId, context.subUnitId, context.rule.cfId, _ruleConfig.value, normalizedRanges);
const result = conditionalFormattingFormulaService.getFormulaMatrix(context.unitId, context.subUnitId, context.rule.cfId, _ruleConfig.value);
if (result && result.status === FormulaResultStatus.SUCCESS) {
this._preComputingStatus$.next(CalculateEmitStatus.preComputingEnd);
return {
value: result.result,
type: ruleConfig.subType,
};
} else {
this._preComputingStatus$.next(CalculateEmitStatus.preComputing);
}
return null;
}
case CFSubRuleType.timePeriod: {
const subRuleConfig = ruleConfig as ITimePeriodHighlightCell;
switch (subRuleConfig.operator) {
case CFTimePeriodOperator.last7Days: {
return {
value: {
start: dateKit().subtract(7, 'day').valueOf(),
end: dateKit().valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.lastMonth: {
return {
value: {
start: dateKit().startOf('month').subtract(1, 'month').valueOf(),
end: dateKit().endOf('month').subtract(1, 'month').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.lastWeek: {
return {
value: {
start: dateKit().startOf('week').subtract(1, 'week').valueOf(),
end: dateKit().endOf('week').subtract(1, 'week').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.nextMonth: {
return {
value: {
start: dateKit().startOf('month').add(1, 'month').valueOf(),
end: dateKit().endOf('month').add(1, 'month').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.nextWeek: {
return {
value: {
start: dateKit().startOf('week').add(1, 'week').valueOf(),
end: dateKit().endOf('week').add(1, 'week').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.thisMonth: {
return {
value: {
start: dateKit().startOf('month').valueOf(),
end: dateKit().endOf('month').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.thisWeek: {
return {
value: {
start: dateKit().startOf('week').valueOf(),
end: dateKit().endOf('week').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.today: {
return {
value: {
start: dateKit().startOf('day').valueOf(),
end: dateKit().endOf('day').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.tomorrow: {
return {
value: {
start: dateKit().startOf('day').add(1, 'day').valueOf(),
end: dateKit().endOf('day').add(1, 'day').valueOf(),
},
type: ruleConfig.subType,
};
}
case CFTimePeriodOperator.yesterday: {
return {
value: {
start: dateKit().startOf('day').subtract(1, 'day').valueOf(),
end: dateKit().endOf('day').subtract(1, 'day').valueOf(),
},
type: ruleConfig.subType,
};
}
}
}
}
};
const result = getCache();
this.setPreComputingCache(result);
}
// eslint-disable-next-line max-lines-per-function
protected override getCellResult(row: number, col: number, preComputingResult: Nullable<IConfig>, context: IContext) {
const cellValue = context.getCellValue(row, col);
const ruleConfig = context.rule.rule as IHighlightCell;
// eslint-disable-next-line max-lines-per-function, complexity
const run = () => {
switch (ruleConfig.subType) {
case CFSubRuleType.number: {
const v = cellValue && Number(cellValue.v);
const isNumber = cellValue?.t === CellValueType.NUMBER;
const subRuleConfig = ruleConfig as INumberHighlightCell;
if (!isNumber) {
if ([CFNumberOperator.notEqual, CFNumberOperator.notBetween].includes(subRuleConfig.operator)) {
return true;
}
return false;
}
if (isNullable(v) || Number.isNaN(v)) {
return;
}
return compareWithNumber({ operator: subRuleConfig.operator, value: subRuleConfig.value || 0 }, v || 0);
}
case CFSubRuleType.text: {
const subRuleConfig = ruleConfig as ITextHighlightCell;
const value = getCellValue(cellValue);
const v = isNullable(value) ? '' : String(value);
const condition = subRuleConfig.value || '';
switch (subRuleConfig.operator) {
case CFTextOperator.beginsWith: {
return v.startsWith(condition);
}
case CFTextOperator.containsBlanks: {
return /^\s*$/.test(v);
}
case CFTextOperator.notContainsBlanks: {
return !/^\s*$/.test(v);
}
case CFTextOperator.containsErrors: {
return (ERROR_TYPE_SET as Set<unknown>).has(v);
}
case CFTextOperator.notContainsErrors: {
return !(ERROR_TYPE_SET as Set<unknown>).has(v);
}
case CFTextOperator.containsText: {
return v.indexOf(condition) > -1;
}
case CFTextOperator.notContainsText: {
return v.indexOf(condition) === -1;
}
case CFTextOperator.endsWith: {
return v.endsWith(condition);
}
case CFTextOperator.equal: {
return v === condition;
}
case CFTextOperator.notEqual: {
return v !== condition;
}
default: {
return false;
}
}
}
case CFSubRuleType.timePeriod: {
const value = getCellValue(cellValue);
if (isNullable(value) || Number.isNaN(Number(value)) || cellValue?.t !== CellValueType.NUMBER || !preComputingResult) {
return;
}
const v = serialTimeToTimestamp(Number(value), context.workbook.getDateSystem());
const { start, end } = preComputingResult.value!;
return v >= start && v <= end;
}
case CFSubRuleType.average: {
const value = cellValue && cellValue.v;
const v = Number(value);
const isNumber = cellValue?.t === CellValueType.NUMBER;
const subRuleConfig = ruleConfig as IAverageHighlightCell;
if (!isNumber) {
if (CFNumberOperator.notEqual === subRuleConfig.operator) {
return true;
}
return false;
}
if (isNullable(value) || Number.isNaN(v) || !preComputingResult) {
return false;
}
const average = preComputingResult.value;
switch (subRuleConfig.operator) {
case CFNumberOperator.greaterThan: {
return v > average;
}
case CFNumberOperator.greaterThanOrEqual: {
return v >= average;
}
case CFNumberOperator.lessThan: {
return v < average;
}
case CFNumberOperator.lessThanOrEqual: {
return v <= average;
}
case CFNumberOperator.equal: {
return isFloatsEqual(v, average);
}
case CFNumberOperator.notEqual: {
return !isFloatsEqual(v, average);
}
default: {
return false;
}
}
}
case CFSubRuleType.rank: {
const value = getCellValue(cellValue);
const v = Number(value);
if (isNullable(value) || Number.isNaN(v) || cellValue?.t !== CellValueType.NUMBER || !preComputingResult) {
return false;
}
const targetValue = preComputingResult.value;
const subRuleConfig = ruleConfig as IRankHighlightCell;
if (subRuleConfig.isBottom) {
return v <= targetValue;
} else {
return v >= targetValue;
}
}
case CFSubRuleType.uniqueValues: {
const value = getCellValue(cellValue);
if (isNullable(value) || !preComputingResult) {
return false;
}
const uniqueCache = preComputingResult.value;
return uniqueCache.get(value) === 1;
}
case CFSubRuleType.duplicateValues: {
const value = getCellValue(cellValue);
if (isNullable(value) || !preComputingResult) {
return false;
}
const uniqueCacheValue = preComputingResult.value.get(value);
return uniqueCacheValue && uniqueCacheValue !== 1;
}
case CFSubRuleType.formula: {
const _ruleConfig = ruleConfig as IFormulaHighlightCell;
const conditionalFormattingFormulaService = context.accessor.get(ConditionalFormattingFormulaService);
// The formula engine stores results at relative offsets from the first range's top-left.
const firstRange = sortRangesByTopLeft(context.rule.ranges)[0];
const relativeRow = row - firstRange.startRow;
const relativeCol = col - firstRange.startColumn;
const formulaResult = conditionalFormattingFormulaService.getFormulaResultWithCoords(
context.unitId,
context.subUnitId,
context.rule.cfId,
_ruleConfig.value,
relativeRow,
relativeCol
);
if (formulaResult.status === FormulaResultStatus.SUCCESS && formulaResult.result !== undefined) {
return isFormulaResultMatched(formulaResult.result);
}
const cache = preComputingResult?.value;
if (cache) {
const value = cache.getValue(relativeRow, relativeCol);
return isFormulaResultMatched(value);
}
return false;
}
}
};
const result = run();
return result ? ruleConfig.style : {};
}
}

View File

@@ -0,0 +1,121 @@
/**
* 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 { CFNumberOperator } from '../../base/const';
import type { IIconSet } from '../type';
import type { IContext } from './base-calculate-unit';
import { CellValueType, ObjectMatrix, Range } from '@univerjs/core';
import { FormulaResultStatus } from '@univerjs/engine-formula';
import { CFValueType } from '../../base/const';
import { BaseCalculateUnit, CalculateEmitStatus } from './base-calculate-unit';
import { compareWithNumber, filterRange, getOppositeOperator, getValueByType, isNullable } from './utils';
;
const getValue = (row: number, col: number, getCell: (row: number, col: number) => ICellData) => {
const cell = getCell(row, col);
if (cell && cell.t === CellValueType.NUMBER) {
const value = Number(cell.v);
return Number.isNaN(value) ? null : value;
}
return null;
};
interface IConfigItem {
operator: CFNumberOperator;
value: number;
iconType: string;
iconId: string;
}
export class IconSetCalculateUnit extends BaseCalculateUnit<IConfigItem[]> {
override preComputing(_row: number, _col: number, context: IContext): void {
const ruleConfig = context.rule.rule as IIconSet;
const worksheet = context.worksheet;
const matrix = new ObjectMatrix<number>();
const isNeedMatrix = !ruleConfig.config.every((e) => e.value.type === CFValueType.num);
if (isNeedMatrix) {
const ranges = filterRange(context.rule.ranges, worksheet.getMaxRows() - 1, worksheet.getMaxColumns() - 1);
ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
const cell = context.getCellValue(row, col);
const v = cell && cell.v;
if (!isNullable(v) && cell?.t === CellValueType.NUMBER) {
const _value = Number(v);
!Number.isNaN(_value) && matrix.setValue(row, col, _value);
}
});
});
}
const splitValueResult = ruleConfig.config.map((v) => getValueByType(v.value, matrix, { ...context, cfId: context.rule.cfId }));
const isAllFinished = !splitValueResult.some((item) => item.status !== FormulaResultStatus.SUCCESS);
if (isAllFinished) {
const splitValue = splitValueResult.map((item, index) => ({
operator: ruleConfig.config[index].operator,
value: Number(item.result) || 0,
})).reduce((result, cur, index, list) => {
const item = ruleConfig.config[index];
if (!index || index === list.length - 1) {
result.push({ ...cur, iconId: item.iconId, iconType: item.iconType });
} else {
const pre = list[index - 1];
if (!compareWithNumber(pre, cur.value)) {
result.push({ ...cur, iconId: item.iconId, iconType: item.iconType });
}
}
return result;
}, [] as { operator: CFNumberOperator; value: number; iconType: string; iconId: string }[]);
this.setPreComputingCache(splitValue);
this._preComputingStatus$.next(CalculateEmitStatus.preComputingEnd);
return;
}
this._preComputingStatus$.next(CalculateEmitStatus.preComputing);
}
protected override getCellResult(row: number, col: number, preComputingResult: Nullable<IConfigItem[]>, context: IContext) {
if (!preComputingResult) {
return null;
}
const value = getValue(row, col, context.getCellValue);
if (value === null) {
return;
}
const ruleConfig = context.rule.rule as IIconSet;
const isShowValue = ruleConfig.isShowValue === undefined ? true : !!ruleConfig.isShowValue;
for (let index = 0; index < preComputingResult.length; index++) {
const item = preComputingResult[index];
const start = { ...item };
const end = { ...item };
const { iconId, iconType } = item;
if (index === 0) {
if (compareWithNumber(item, value)) {
return { iconId, iconType, isShowValue };
}
} else if (index === preComputingResult.length - 1) {
return { iconId, iconType, isShowValue };
} else {
const pre = preComputingResult[index - 1];
end.operator = getOppositeOperator(pre.operator);
end.value = pre.value;
if (compareWithNumber(start, value) && compareWithNumber(end, value)) {
return { iconId, iconType, isShowValue };
}
}
}
}
}

View File

@@ -0,0 +1,281 @@
/**
* 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 { CellValue, ICellData, IObjectMatrixPrimitiveType, IRange, Nullable } from '@univerjs/core';
import type { IConditionFormattingRule, IValueConfig } from '../type';
import type { IContext } from './base-calculate-unit';
import { BooleanNumber, CellValueType, ColorKit, DateSystem, excelSerialToDateTime, ObjectMatrix, Range } from '@univerjs/core';
import { BooleanValue, FormulaResultStatus } from '@univerjs/engine-formula';
import { CFNumberOperator, CFValueType } from '../../base/const';
import { ConditionalFormattingFormulaService } from '../../services/conditional-formatting-formula.service';
import { ConditionalFormattingViewModel } from '../conditional-formatting-view-model';
export function isFloatsEqual(a: number, b: number) {
return Math.abs(a - b) < Number.EPSILON;
}
export const isNullable = (v: Nullable<CellValue>) => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
export const getCellValue = (cell?: ICellData) => {
if (!cell) {
return null;
}
if (cell.t === CellValueType.BOOLEAN) {
return cell.v === BooleanNumber.TRUE ? BooleanValue.TRUE : BooleanValue.FALSE;
}
const v = cell.v;
const dataStream = cell.p?.body?.dataStream.replace(/\r\n$/, '');
return !isNullable(v) ? v : !isNullable(dataStream) ? dataStream : null;
};
export const serialTimeToTimestamp = (value: number, dateSystem = DateSystem.Date1900) => {
const utcDate = excelSerialToDateTime(value, dateSystem);
if (Number.isNaN(utcDate.getTime())) {
return Number.NaN;
}
// Conditional-formatting date comparisons use local Date timestamps for compatibility with existing predicates.
return new Date(
utcDate.getUTCFullYear(),
utcDate.getUTCMonth(),
utcDate.getUTCDate(),
utcDate.getUTCHours(),
utcDate.getUTCMinutes(),
utcDate.getUTCSeconds(),
utcDate.getUTCMilliseconds()
).getTime();
};
// eslint-disable-next-line max-lines-per-function
export const getValueByType = (value: IValueConfig, matrix: ObjectMatrix<number>, context: IContext & { cfId: string }) => {
switch (value.type) {
case CFValueType.max: {
let max = 0;
matrix.forValue((row, col, value) => {
if (value > max) {
max = value;
}
});
return {
status: FormulaResultStatus.SUCCESS,
result: max,
};
}
case CFValueType.min: {
let min: number | undefined;
matrix.forValue((row, col, value) => {
if (min === undefined) {
min = value;
}
if (value < min!) {
min = value;
}
});
return {
status: FormulaResultStatus.SUCCESS,
result: min,
};
}
case CFValueType.percent: {
let max: number | undefined;
let min: number | undefined;
matrix.forValue((row, col, value) => {
if (max === undefined || min === undefined) {
max = value;
min = value;
}
if (value > max!) {
max = value;
}
if (value < min!) {
min = value;
}
});
const length = (max || 0) - (min || 0);
const v = Math.max(Math.min(Number(value.value) || 0, 100), 0);
return {
status: FormulaResultStatus.SUCCESS,
result: length * (v / 100) + (min || 0),
};
}
case CFValueType.percentile: {
const list = matrix.toNativeArray().sort((a, b) => a - b);
const v = Math.max(Math.min(Number(value.value) || 0, 100), 0);
const index = (list.length - 1) * v / 100;
const intIndex = Math.floor(index);
const decimalIndex = index - intIndex;
const result = list[intIndex] + (list[Math.min(intIndex + 1, list.length - 1)] - list[intIndex]) * decimalIndex;
return {
status: FormulaResultStatus.SUCCESS,
result,
};
}
case CFValueType.formula: {
const { accessor, unitId, subUnitId, cfId } = context;
const formulaText = String(value.value);
const conditionalFormattingFormulaService = accessor.get(ConditionalFormattingFormulaService);
conditionalFormattingFormulaService.registerFormulaWithRange(unitId, subUnitId, cfId, formulaText);
const result = conditionalFormattingFormulaService.getFormulaResultWithCoords(unitId, subUnitId, cfId, formulaText);
return result;
}
case CFValueType.num:
default: {
const v = Number(value.value);
return {
status: FormulaResultStatus.SUCCESS,
result: Number.isNaN(v) ? 0 : v,
};
}
}
};
export const getCacheStyleMatrix = <S = any>(unitId: string, subUnitId: string, rule: IConditionFormattingRule, context: IContext) => {
const { accessor } = context;
const conditionalFormattingViewModel = accessor.get(ConditionalFormattingViewModel);
const matrix = new ObjectMatrix<S>();
rule.ranges.forEach((range) => {
Range.foreach(range, (row, col) => {
const cellCfItem = conditionalFormattingViewModel.getCellCfs(unitId, subUnitId, row, col);
if (cellCfItem) {
const item = cellCfItem.find((item) => item.cfId === rule.cfId);
item?.result && matrix.setValue(row, col, item.result);
}
});
});
return matrix;
};
// eslint-disable-next-line complexity
export const compareWithNumber = (config: { operator: CFNumberOperator; value: number | [number, number] }, v: number) => {
switch (config.operator) {
case CFNumberOperator.between: {
if (typeof config.value !== 'object' || !(config.value as unknown as Array<number>).length) {
return;
}
const start = Math.min(...config.value as [number, number]);
const end = Math.max(...config.value as [number, number]);
return v >= start && v <= end;
}
case CFNumberOperator.notBetween: {
if (typeof config.value !== 'object' || !(config.value as unknown as Array<number>).length) {
return;
}
const start = Math.min(...config.value as [number, number]);
const end = Math.max(...config.value as [number, number]);
return !(v >= start && v <= end);
}
case CFNumberOperator.equal: {
const condition = (config.value || 0) as number;
return isFloatsEqual(condition, v);
}
case CFNumberOperator.notEqual: {
const condition = (config.value || 0) as number;
return !isFloatsEqual(condition, v);
}
case CFNumberOperator.greaterThan: {
const condition = (config.value || 0) as number;
return v > condition;
}
case CFNumberOperator.greaterThanOrEqual: {
const condition = (config.value || 0) as number;
return v >= condition;
}
case CFNumberOperator.lessThan: {
const condition = (config.value || 0) as number;
return v < condition;
}
case CFNumberOperator.lessThanOrEqual: {
const condition = (config.value || 0) as number;
return v <= condition;
}
default: {
return false;
}
}
};
export const getOppositeOperator = (operator: CFNumberOperator) => {
switch (operator) {
case CFNumberOperator.greaterThan: {
return CFNumberOperator.lessThanOrEqual;
}
case CFNumberOperator.greaterThanOrEqual: {
return CFNumberOperator.lessThan;
}
case CFNumberOperator.lessThan: {
return CFNumberOperator.greaterThanOrEqual;
}
case CFNumberOperator.lessThanOrEqual: {
return CFNumberOperator.greaterThan;
}
}
return operator;
};
export const getColorScaleFromValue = (colorList: { color: ColorKit; value: number }[], value: number) => {
interface IRgbColor {
b: number;
g: number;
r: number;
a?: number;
}
const prefixRgba = (rgb: IRgbColor): Required<IRgbColor> => {
if (rgb.a !== undefined) {
return rgb as Required<IRgbColor>;
} else {
return { ...rgb, a: 1 };
}
};
const index = colorList.findIndex((item) => item.value >= value);
const preIndex = index - 1;
if (index === 0) {
return colorList[0].color.toRgbString();
} else if (preIndex >= 0) {
const minItem = colorList[preIndex];
const maxItem = colorList[index];
if (minItem.color.isValid && maxItem.color.isValid) {
const minRgb = prefixRgba(minItem.color.toRgb());
const maxRgb = prefixRgba(maxItem.color.toRgb());
const length = maxItem.value - minItem.value;
const v = (value - minItem.value) / length;
const rgbResult = ['r', 'g', 'b', 'a'].reduce((obj, key) => {
const minV = minRgb[key as unknown as keyof IRgbColor];
obj[key as unknown as keyof IRgbColor] = (maxRgb[key as unknown as keyof IRgbColor] - minV) * v + minV;
return obj;
}, {} as IRgbColor);
const result = new ColorKit(rgbResult).toRgbString();
return result;
}
} else {
return colorList[colorList.length - 1].color.toRgbString();
}
};
export const filterRange = (ranges: IRange[], maxRow: number, maxCol: number): IRange[] => {
return ranges.map((range) => {
if (range.startColumn > maxCol || range.startRow > maxRow) {
return null as unknown as IRange;
}
const _range = { ...range };
_range.endRow = Math.min(_range.endRow, maxRow);
_range.endColumn = Math.min(_range.endColumn, maxCol);
return _range;
}).filter((range) => !!range);
};
export function getMaxInFormulaResult(result: IObjectMatrixPrimitiveType<Nullable<CellValue>>) {
let max = 0;
new ObjectMatrix(result).forValue((row, col, value) => {
max = Math.max(Number.isNaN(max) ? 0 : max, Number(value));
});
return max;
}

View File

@@ -0,0 +1,115 @@
/**
* 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 { Disposable, Inject, RTree } from '@univerjs/core';
import { ConditionalFormattingRuleModel } from './conditional-formatting-rule-model';
export class ConditionalFormattingRangeIndexModel extends Disposable {
private _rTreeManager: RTree = new RTree();
constructor(
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel
) {
super();
this.rebuild();
this._initRuleListener();
}
getRuleIdsByCell(unitId: string, subUnitId: string, row: number, col: number) {
return this.getRuleIdsByRanges(unitId, subUnitId, [{
startRow: row,
endRow: row,
startColumn: col,
endColumn: col,
}]);
}
getRuleIdsByRanges(unitId: string, subUnitId: string, ranges: IRange[]) {
if (!ranges.length) {
return new Set<string>();
}
const ids = this._rTreeManager.bulkSearch(ranges.map((range) => ({
unitId,
sheetId: subUnitId,
range,
})));
return new Set(Array.from(ids).map((id) => String(id)));
}
getRulesByRanges(unitId: string, subUnitId: string, ranges: IRange[]) {
const ids = this.getRuleIdsByRanges(unitId, subUnitId, ranges);
const ruleList = this._conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId) ?? [];
return ruleList.filter((rule) => ids.has(rule.cfId));
}
override dispose(): void {
this.clear();
super.dispose();
}
clear() {
this._rTreeManager.clear();
}
rebuild() {
this.clear();
this._conditionalFormattingRuleModel.getAll().forEach((subUnitMap, unitId) => {
subUnitMap.forEach((rules, subUnitId) => {
rules.forEach((rule) => {
this._insert(unitId, subUnitId, rule.cfId, rule.ranges);
});
});
});
}
private _initRuleListener() {
this.disposeWithMe(
this._conditionalFormattingRuleModel.$ruleChange.subscribe((e) => {
const { unitId, subUnitId, rule } = e;
const { cfId, ranges } = rule;
switch (e.type) {
case 'add': {
this._insert(unitId, subUnitId, cfId, ranges);
break;
}
case 'delete': {
this._remove(unitId, subUnitId, cfId, ranges);
break;
}
case 'set': {
const oldRule = e.oldRule!;
this._remove(unitId, subUnitId, oldRule.cfId, oldRule.ranges);
this._insert(unitId, subUnitId, cfId, ranges);
break;
}
}
})
);
}
private _insert(unitId: string, subUnitId: string, cfId: string, ranges: IRange[]) {
this._rTreeManager.bulkInsert(ranges.map((range) => ({ unitId, sheetId: subUnitId, id: cfId, range })));
}
private _remove(unitId: string, subUnitId: string, cfId: string, ranges: IRange[]) {
this._rTreeManager.bulkRemove(ranges.map((range) => ({ unitId, sheetId: subUnitId, id: cfId, range })));
}
}

View File

@@ -0,0 +1,126 @@
/**
* 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 { IAnchor } from '../utils/anchor';
import type { IConditionFormattingRule, IRuleModel } from './type';
import { Tools } from '@univerjs/core';
import { Subject } from 'rxjs';
import { findIndexByAnchor, moveByAnchor } from '../utils/anchor';
import { createCfId } from '../utils/create-cf-id';
type RuleOperatorType = 'delete' | 'set' | 'add' | 'sort';
export class ConditionalFormattingRuleModel {
// Map<unitID ,<sheetId ,IConditionFormattingRule[]>>
private _model: IRuleModel = new Map();
private _ruleChange$ = new Subject<{ rule: IConditionFormattingRule; oldRule?: IConditionFormattingRule; unitId: string; subUnitId: string; type: RuleOperatorType }>();
public $ruleChange = this._ruleChange$.asObservable();
private _ensureList(unitId: string, subUnitId: string) {
let list = this.getSubunitRules(unitId, subUnitId);
if (!list) {
list = [];
let unitMap = this._model.get(unitId);
if (!unitMap) {
unitMap = new Map<string, IConditionFormattingRule[]>();
this._model.set(unitId, unitMap);
}
unitMap.set(subUnitId, list);
}
return list;
}
getRule(unitId: string, subUnitId: string, cfId?: string) {
const list = this.getSubunitRules(unitId, subUnitId);
if (list) {
return list.find((item) => item.cfId === cfId);
}
return null;
}
getUnitRules(unitId: string) {
const map = this._model.get(unitId);
return map || null;
}
getSubunitRules(unitId: string, subUnitId: string) {
const list = this._model.get(unitId)?.get(subUnitId);
return list || null;
}
getAll() {
return this._model;
}
deleteRule(unitId: string, subUnitId: string, cfId: string) {
const list = this.getSubunitRules(unitId, subUnitId);
if (list) {
const index = list.findIndex((e) => e.cfId === cfId);
const rule = list[index];
if (rule) {
list.splice(index, 1);
this._ruleChange$.next({ rule, subUnitId, unitId, type: 'delete' });
}
}
}
setRule(unitId: string, subUnitId: string, rule: IConditionFormattingRule, oldCfId: string) {
const list = this._ensureList(unitId, subUnitId);
const oldRule = list.find((item) => item.cfId === oldCfId);
if (oldRule) {
const cloneRule = Tools.deepClone(oldRule);
Object.assign(oldRule, rule);
this._ruleChange$.next({ rule: oldRule, subUnitId, unitId, type: 'set', oldRule: cloneRule });
}
}
addRule(unitId: string, subUnitId: string, rule: IConditionFormattingRule) {
const list = this._ensureList(unitId, subUnitId);
const item = list.find((item) => item.cfId === rule.cfId);
if (!item) {
// The new conditional formatting has a higher priority
list.unshift(rule);
}
this._ruleChange$.next({ rule, subUnitId, unitId, type: 'add' });
}
/**
* example [1,2,3,4,5,6],if you move behind 5 to 2, then cfId=5,targetId=2.
* if targetId does not exist, it defaults to top
*/
moveRulePriority(unitId: string, subUnitId: string, start: IAnchor, end: IAnchor) {
const list = this._ensureList(unitId, subUnitId);
const curIndex = findIndexByAnchor(start, list, (rule) => rule.cfId);
const targetCfIndex = findIndexByAnchor(end, list, (rule) => rule.cfId);
if (targetCfIndex === null || curIndex === null || targetCfIndex === curIndex) {
return;
}
const rule = list[curIndex];
if (rule) {
moveByAnchor(start, end, list, (rule) => rule.cfId);
this._ruleChange$.next({ rule, subUnitId, unitId, type: 'sort' });
}
}
createCfId(_unitId: string, _subUnitId: string) {
return createCfId();
}
deleteUnitId(unitId: string) {
this._model.delete(unitId);
}
}

View File

@@ -0,0 +1,300 @@
/**
* 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 { Workbook } from '@univerjs/core';
import type { BaseCalculateUnit, IContext } from './calculate-unit-v2/base-calculate-unit';
import type { IConditionFormattingRule } from './type';
import { Disposable, Inject, Injector, IUniverInstanceService, LRUMap } from '@univerjs/core';
import { Subject } from 'rxjs';
import { bufferTime, filter, map } from 'rxjs/operators';
import { CFRuleType, CFSubRuleType } from '../base/const';
import { ConditionalFormattingFormulaService } from '../services/conditional-formatting-formula.service';
import { ColorScaleCalculateUnit } from './calculate-unit-v2/color-scale-calculate-unit';
import { DataBarCalculateUnit } from './calculate-unit-v2/data-bar-calculate-unit';
import { HighlightCellCalculateUnit } from './calculate-unit-v2/highlight-cell-calculate-unit';
import { IconSetCalculateUnit } from './calculate-unit-v2/icon-set-calculate-unit';
import { ConditionalFormattingRangeIndexModel } from './conditional-formatting-range-index-model';
import { ConditionalFormattingRuleModel } from './conditional-formatting-rule-model';
// The default is a 50-row,20-column viewable area.
export const CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH = 50 * 20 * 3 * 3;
export class ConditionalFormattingViewModel extends Disposable {
// Map<unitID ,<sheetId ,ObjectMatrix>>
private _calculateUnitManagers: Map<string, Map<string, Map<string, BaseCalculateUnit>>> = new Map();
/**
* 1nd-level cache
*/
private _cellCache = new LRUMap<string, { cfId: string; result: any; priority: number }[]>(CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH);
private _markDirty$ = new Subject<{ cfId: string; unitId: string; subUnitId: string; isImmediately?: boolean }>();
private _cacheClear$ = new Subject<void>();
/**
* The rendering layer listens to this variable to determine whether a reRender is necessary.
* @memberof ConditionalFormattingViewModel
*/
public markDirty$ = this._markDirty$.asObservable();
public cacheClear$ = this._cacheClear$.asObservable();
constructor(
@Inject(Injector) private _injector: Injector,
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel,
@Inject(ConditionalFormattingRangeIndexModel) private _conditionalFormattingRangeIndexModel: ConditionalFormattingRangeIndexModel,
@Inject(ConditionalFormattingFormulaService) private _conditionalFormattingFormulaService: ConditionalFormattingFormulaService,
@IUniverInstanceService private _univerInstanceService: IUniverInstanceService
) {
super();
this._initRuleListener();
this._handleCustomFormulasSeparately();
this._initCFFormulaListener();
}
private _initCFFormulaListener() {
// If a conditional format depends on multiple formula results,removing the 2nd level cache when a single result is returned can cause screen flickering.
// It is necessary to wait until all results are ready before removing the cache.
this.disposeWithMe(
this._conditionalFormattingFormulaService.result$.subscribe(({ unitId, subUnitId, cfId, isAllFinished }) => {
if (isAllFinished) {
this._markRuleDirtyAtOnce(unitId, subUnitId, cfId, isAllFinished);
}
})
);
}
public getCellCfs(unitId: string, subUnitId: string, row: number, col: number) {
const key = this._createCacheKey(unitId, subUnitId, row, col);
if (this._cellCache.has(key)) {
return this._cellCache.get(key);
}
const result = this._getCellCfs(unitId, subUnitId, row, col);
if (result.length) {
this._cellCache.set(key, result);
}
return result;
}
private _getCellCfs(unitId: string, subUnitId: string, row: number, col: number) {
const subunitRules = this._conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId) ?? [];
const _calculateUnitManagers = this._ensureCalculateUnitManager(unitId, subUnitId);
const list = this._conditionalFormattingRangeIndexModel.getRuleIdsByCell(unitId, subUnitId, row, col);
const rules = subunitRules.filter((rule) => list.has(rule.cfId));
if (!rules.length) {
return [];
}
const result = rules.map((rule) => {
const calculateUnit = _calculateUnitManagers.get(rule.cfId);
if (!calculateUnit) {
return null;
}
return {
cfId: rule.cfId,
result: calculateUnit.getCell(row, col),
};
}).filter((e) => !!e).map((e, index) => ({ ...e, priority: index }));
return result;
}
/**
`isNeedResetPreComputingCache` indicates whether it is necessary to remove the 2nd-level cache for each rule individually.
Generally, when the logic of a rule calculation is modified, the cache for that rule needs to be removed.
Changes in style/priority do not require the clearing of the 2nd-level cache.
Rule changes/region changes require the removal of the 2nd-level cache.
There is also a situation where preComputing is asynchronously calculated.
After the calculation is finished, it is only for marking as dirty, and the 2nd-level cache need to be cleared.
* @param {boolean} [isNeedResetPreComputingCache]
*/
private _markRuleDirtyAtOnce = (unitId: string, subUnitId: string, cfId: string, isNeedResetPreComputingCache: boolean = true) => {
this._cellCache.clear();
if (isNeedResetPreComputingCache) {
const _calculateUnitManagers = this._ensureCalculateUnitManager(unitId, subUnitId);
const calculateUnit = _calculateUnitManagers.get(cfId);
if (calculateUnit) {
calculateUnit.resetPreComputingCache();
}
}
this._markDirty$.next({
unitId,
subUnitId,
cfId,
});
};
/**
* For the same condition format being marked dirty multiple times at the same time,
* it will cause the style cache to be cleared, thereby causing the screen to flicker.
* Here,multiple dirties are merged into one..
*/
public markRuleDirty = (() => {
const rxItem = new Subject<{ unitId: string; subUnitId: string; cfId: string; isNeedResetPreComputingCache: boolean }>();
this.disposeWithMe(rxItem.pipe(bufferTime(100), filter((e) => !!e.length), map((list) => {
const set = new Set();
const result: typeof list = [];
list.forEach((item) => {
const kye = `${item.unitId}_${item.subUnitId}_${item.cfId}`;
if (set.has(kye)) {
if (item.isNeedResetPreComputingCache) {
const i = result.find((e) => e.cfId === item.cfId)!;
i.isNeedResetPreComputingCache = true;
}
return;
}
set.add(kye);
result.push({ ...item });
});
return result;
})).subscribe((list) => {
list.forEach((item) => {
this._markRuleDirtyAtOnce(item.unitId, item.subUnitId, item.cfId, item.isNeedResetPreComputingCache);
});
}));
return (unitId: string, subUnitId: string, cfId: string, isNeedResetPreComputingCache: boolean = true) => {
rxItem.next({ unitId, subUnitId, cfId, isNeedResetPreComputingCache });
};
})();
override dispose(): void {
this.clearCache();
super.dispose();
}
clearCache() {
this._calculateUnitManagers.clear();
this._cellCache.clear();
this._cacheClear$.next();
}
private _handleCustomFormulasSeparately() {
this.disposeWithMe(
this._conditionalFormattingRuleModel.$ruleChange.subscribe((e) => {
if (e.type === 'set') {
const { unitId, subUnitId } = e;
const oldRule = e.oldRule!;
// If the range of a custom formula is modified,then the cached formulas must be cleared and recalculated.
if (oldRule.rule.type === CFRuleType.highlightCell && oldRule.rule.subType === CFSubRuleType.formula) {
this._conditionalFormattingFormulaService.deleteCache(unitId, subUnitId, oldRule.cfId);
}
}
})
);
}
private _initRuleListener() {
this.disposeWithMe(
this._conditionalFormattingRuleModel.$ruleChange.subscribe((e) => {
const { unitId, subUnitId, rule } = e;
const { cfId, ranges } = rule;
const calculateUnitManager = this._ensureCalculateUnitManager(unitId, subUnitId);
this.markRuleDirty(unitId, subUnitId, cfId);
switch (e.type) {
case 'add': {
const instance = this._createRuleCalculateUnitInstance(unitId, subUnitId, rule);
if (!instance) {
return;
}
calculateUnitManager.set(rule.cfId, instance);
break;
}
case 'delete': {
calculateUnitManager.delete(rule.cfId);
break;
}
case 'set': {
const oldRule = e.oldRule!;
if (oldRule.rule.type !== rule.rule.type) {
const instance = this._createRuleCalculateUnitInstance(unitId, subUnitId, rule);
if (!instance) {
return;
}
calculateUnitManager.delete(oldRule.cfId);
calculateUnitManager.set(rule.cfId, instance);
} else {
const instance = calculateUnitManager.get(oldRule.cfId);
if (!instance) {
return;
}
instance.updateRule(rule);
}
}
}
})
);
}
private _ensureCalculateUnitManager(unitId: string, subUnitId: string) {
let unitMap = this._calculateUnitManagers.get(unitId);
if (!unitMap) {
unitMap = new Map();
this._calculateUnitManagers.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new Map();
unitMap.set(subUnitId, subUnitMap);
}
return subUnitMap;
}
private _createRuleCalculateUnitInstance(unitId: string, subUnitId: string, rule: IConditionFormattingRule) {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (!workbook || !worksheet) {
return;
}
const context: IContext = {
workbook,
worksheet,
unitId,
subUnitId,
accessor: this._injector,
rule,
limit: CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH,
getCellValue: (row: number, col: number) => worksheet.getCellRaw(row, col) || {},
};
switch (rule.rule.type) {
case CFRuleType.colorScale: {
return new ColorScaleCalculateUnit(context);
}
case CFRuleType.dataBar: {
return new DataBarCalculateUnit(context);
}
case CFRuleType.highlightCell: {
return new HighlightCellCalculateUnit(context);
}
case CFRuleType.iconSet: {
return new IconSetCalculateUnit(context);
}
}
}
private _createCacheKey(unitId: string, subUnitId: string, row: number, col: number) {
return `${unitId}_${subUnitId}_${row}_${col}`;
}
public setCacheLength(length: number = CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH) {
if (this._cellCache.limit === length) {
return;
}
this._cellCache.limit = length;
this._calculateUnitManagers.forEach((subunitMap) => {
subunitMap.forEach((unitMap) => {
unitMap.forEach((unit) => {
unit.setCacheLength(length);
});
});
});
}
}

View File

@@ -0,0 +1,164 @@
/**
* 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 { LocaleKey } from '../locale/types';
import { ICON_MAP } from '../assets/icon-map';
export enum IIconSetType {
threeArrows = '3Arrows',
threeArrowsGray = '3ArrowsGray',
fourArrows = '4Arrows',
fourArrowsGray = '4ArrowsGray',
fiveArrows = '5Arrows',
fiveArrowsGray = '5ArrowsGray',
threeTriangles = '3Triangles',
threeTrafficLights1 = '3TrafficLights1',
threeSigns = '3Signs',
threeTrafficLights2 = '3TrafficLights2',
fourRedToBlack = '4RedToBlack',
fourTrafficLights = '4TrafficLights',
threeSymbols = '3Symbols',
threeSymbols2 = '3Symbols2',
threeFlags = '3Flags',
fourRating = '4Rating',
fiveRating = '5Rating',
fiveQuarters = '5Quarters',
fiveFelling = '_5Felling',
fiveBoxes = '5Boxes',
threeStars = '3Stars',
empty = 'EMPTY_ICON_TYPE',
}
export const iconGroup: { title: LocaleKey; group: { name: IIconSetType; list: string[] }[] }[] = [
{
title: 'sheets-conditional-formatting.iconSet.direction',
group: [
{
name: IIconSetType.threeArrows,
list: [ICON_MAP.arrow['up-green'], ICON_MAP.arrow['right-gold'], ICON_MAP.arrow['down-red']],
},
{
name: IIconSetType.threeArrowsGray,
list: [ICON_MAP.arrow['up-gray'], ICON_MAP.arrow['right-gray'], ICON_MAP.arrow['down-gray']],
},
{
name: IIconSetType.fourArrows,
list: [ICON_MAP.arrow['up-green'], ICON_MAP.arrow['rightAndUp-gold'], ICON_MAP.arrow['rightAndDown-gold'], ICON_MAP.arrow['down-red']],
},
{
name: IIconSetType.fourArrowsGray,
list: [ICON_MAP.arrow['up-gray'], ICON_MAP.arrow['rightAndUp-gray'], ICON_MAP.arrow['rightAndDown-gray'], ICON_MAP.arrow['down-gray']],
},
{
name: IIconSetType.fiveArrows,
list: [ICON_MAP.arrow['up-green'], ICON_MAP.arrow['rightAndUp-gold'], ICON_MAP.arrow['right-gold'], ICON_MAP.arrow['rightAndDown-gold'], ICON_MAP.arrow['down-red']],
},
{
name: IIconSetType.fiveArrowsGray,
list: [ICON_MAP.arrow['up-gray'], ICON_MAP.arrow['rightAndUp-gray'], ICON_MAP.arrow['right-gray'], ICON_MAP.arrow['rightAndDown-gray'], ICON_MAP.arrow['down-gray']],
},
{
name: IIconSetType.threeTriangles,
list: [ICON_MAP.shape.up, ICON_MAP.shape.cross, ICON_MAP.shape.down],
},
],
},
{
title: 'sheets-conditional-formatting.iconSet.shape',
group: [
{
name: IIconSetType.threeTrafficLights1,
list: [ICON_MAP.shape['roundness-greed'], ICON_MAP.shape['roundness-gold'], ICON_MAP.shape['roundness-red']],
},
{
name: IIconSetType.threeSigns,
list: [ICON_MAP.shape['roundness-greed'], ICON_MAP.shape['triangle-gold'], ICON_MAP.shape['rhomboid-red']],
},
{
name: IIconSetType.threeTrafficLights2,
list: [ICON_MAP.shape['indicate-greed'], ICON_MAP.shape['indicate-gold'], ICON_MAP.shape['indicate-red']],
},
{
name: IIconSetType.fourRedToBlack,
list: [ICON_MAP.shape['roundness-red'], ICON_MAP.shape['roundness-pink'], ICON_MAP.shape['roundness-gray'], ICON_MAP.shape['roundness-black']],
},
{
name: IIconSetType.fourTrafficLights,
list: [ICON_MAP.shape['roundness-greed'], ICON_MAP.shape['roundness-gold'], ICON_MAP.shape['roundness-red'], ICON_MAP.shape['roundness-black']],
},
],
},
{
title: 'sheets-conditional-formatting.iconSet.mark',
group: [
{
name: IIconSetType.threeSymbols,
list: [ICON_MAP.feedback.correct, ICON_MAP.feedback.warn, ICON_MAP.feedback.mistake],
},
{
name: IIconSetType.threeSymbols2,
list: [ICON_MAP.feedback2.correct2, ICON_MAP.feedback2.warn2, ICON_MAP.feedback2.mistake2],
},
{
name: IIconSetType.threeFlags,
list: [ICON_MAP.flag['flag-green'], ICON_MAP.flag['flag-gold'], ICON_MAP.flag['flag-red']],
},
],
},
{
title: 'sheets-conditional-formatting.iconSet.rank',
group: [
{
name: IIconSetType.fourRating,
list: [ICON_MAP.signal.signal25, ICON_MAP.signal.signal50, ICON_MAP.signal.signal75, ICON_MAP.signal.signal100],
},
{
name: IIconSetType.fiveRating,
list: [ICON_MAP.signal.signal0, ICON_MAP.signal.signal25, ICON_MAP.signal.signal50, ICON_MAP.signal.signal75, ICON_MAP.signal.signal100],
},
{
name: IIconSetType.fiveQuarters,
list: [ICON_MAP.progress.progress100, ICON_MAP.progress.progress75, ICON_MAP.progress.progress50, ICON_MAP.progress.progress25, ICON_MAP.progress.progress0],
},
{
name: IIconSetType.fiveFelling,
list: [ICON_MAP.feeling.guffaw, ICON_MAP.feeling.smile, ICON_MAP.feeling.noninductive, ICON_MAP.feeling.dissatisfied, ICON_MAP.feeling.impatient],
},
{
name: IIconSetType.fiveBoxes,
list: [ICON_MAP.cell['cell-100'], ICON_MAP.cell['cell-75'], ICON_MAP.cell['cell-50'], ICON_MAP.cell['cell-25'], ICON_MAP.cell['cell-0']],
},
{
name: IIconSetType.threeStars,
list: [ICON_MAP.star.starFull, ICON_MAP.star.starIncomplete, ICON_MAP.star.starEmpty],
},
],
},
];
export const iconMap = iconGroup.reduce((result, cur) => {
const { group } = cur;
for (const v of group) {
result[v.name] = v.list;
}
return result;
}, {} as Record<IIconSetType, string[]>);
for (const key in iconMap) {
const v = iconMap[key as IIconSetType];
Object.freeze(v);
}

View File

@@ -0,0 +1,112 @@
/**
* 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, IStyleBase } from '@univerjs/core';
import type { CFNumberOperator, CFRuleType, CFSubRuleType, CFTextOperator, CFTimePeriodOperator, CFValueType } from '../base/const';
import type { IIconSetType } from './icon-map';
export interface IBaseCfRule {
type: string;
}
export interface IHighlightCell extends IBaseCfRule {
style: IStyleBase;
type: CFRuleType.highlightCell; // cellIs
subType: CFSubRuleType;
}
export interface IValueConfig {
type: CFValueType;
value?: number | string;
}
export interface IUniqueValuesHighlightCell extends IHighlightCell {
subType: CFSubRuleType.uniqueValues;
}
export interface IDuplicateValuesHighlightCell extends IHighlightCell {
subType: CFSubRuleType.duplicateValues;
}
export interface IRankHighlightCell extends IHighlightCell {
subType: CFSubRuleType.rank; // top10
isBottom: boolean;
isPercent: boolean;
value: number;
}
export interface ITextHighlightCell extends IHighlightCell {
subType: CFSubRuleType.text;
operator: CFTextOperator;
value?: string;
}
export interface ITimePeriodHighlightCell extends IHighlightCell {
subType: CFSubRuleType.timePeriod;
operator: CFTimePeriodOperator;
}
export interface IFormulaHighlightCell extends IHighlightCell {
subType: CFSubRuleType.formula;
value: string;
}
export type INumberHighlightCell = ({
subType: CFSubRuleType.number;
operator: CFNumberOperator.between | CFNumberOperator.notBetween;
value: [number, number];
} & IHighlightCell) | ({
subType: CFSubRuleType.number;
operator: CFNumberOperator.equal | CFNumberOperator.notEqual | CFNumberOperator.greaterThan | CFNumberOperator.greaterThanOrEqual | CFNumberOperator.lessThanOrEqual | CFNumberOperator.lessThan;
value?: number;
} & IHighlightCell);
export interface IAverageHighlightCell extends IHighlightCell {
subType: CFSubRuleType.average;
operator: CFNumberOperator.greaterThan | CFNumberOperator.greaterThanOrEqual | CFNumberOperator.lessThan | CFNumberOperator.lessThanOrEqual | CFNumberOperator.equal | CFNumberOperator.notEqual;
}
export interface IDataBar extends IBaseCfRule {
type: CFRuleType.dataBar;
isShowValue: boolean;
config: {
min: IValueConfig;
max: IValueConfig;
isGradient: boolean;
positiveColor: string;
nativeColor: string;
};
}
export interface IColorScale extends IBaseCfRule {
type: CFRuleType.colorScale;
config: { index: number; color: string; value: IValueConfig }[];
}
export interface IIconSet extends IBaseCfRule {
type: CFRuleType.iconSet;
isShowValue: boolean;
config: { operator: CFNumberOperator; value: IValueConfig; iconType: IIconSetType; iconId: string }[];
}
export type IConditionalFormattingRuleConfig = IColorScale | IDataBar | IUniqueValuesHighlightCell |
IDuplicateValuesHighlightCell | IRankHighlightCell | ITextHighlightCell |
ITimePeriodHighlightCell | INumberHighlightCell | IAverageHighlightCell | IFormulaHighlightCell | IIconSet;
export interface IConditionFormattingRule<C = IConditionalFormattingRuleConfig> {
ranges: IRange [];
cfId: string;
stopIfTrue: boolean;
rule: C;
}
export type IRuleModel = Map<string, Map<string, IConditionFormattingRule[]>>;
export type IRuleModelJson = Record<string, Record<string, IConditionFormattingRule[]>>;

View File

@@ -0,0 +1,105 @@
/**
* 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 { IUniverSheetsConditionalFormattingConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, touchDependencies, UniverInstanceType } from '@univerjs/core';
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import pkg from '../package.json';
import { SHEET_CONDITIONAL_FORMATTING_PLUGIN } from './base/const';
import { AddCfCommand } from './commands/commands/add-cf.command';
import { ClearRangeCfCommand } from './commands/commands/clear-range-cf.command';
import { ClearWorksheetCfCommand } from './commands/commands/clear-worksheet-cf.command';
import { DeleteCfCommand } from './commands/commands/delete-cf.command';
import { MoveCfCommand } from './commands/commands/move-cf.command';
import { SetCfCommand } from './commands/commands/set-cf.command';
import { AddConditionalRuleMutation } from './commands/mutations/add-conditional-rule.mutation';
import { DeleteConditionalRuleMutation } from './commands/mutations/delete-conditional-rule.mutation';
import { MoveConditionalRuleMutation } from './commands/mutations/move-conditional-rule.mutation';
import { SetConditionalRuleMutation } from './commands/mutations/set-conditional-rule.mutation';
import {
defaultPluginConfig,
SHEETS_CONDITIONAL_FORMATTING_PLUGIN_CONFIG_KEY,
} from './config/config';
import { ConditionalFormattingRangeIndexModel } from './models/conditional-formatting-range-index-model';
import { ConditionalFormattingRuleModel } from './models/conditional-formatting-rule-model';
import { ConditionalFormattingViewModel } from './models/conditional-formatting-view-model';
import { ConditionalFormattingFormulaService } from './services/conditional-formatting-formula.service';
import { ConditionalFormattingRangeTransformService } from './services/conditional-formatting-range-transform.service';
import { ConditionalFormattingStyleComposer } from './services/conditional-formatting-style-composer.service';
import { ConditionalFormattingService } from './services/conditional-formatting.service';
@DependentOn(UniverFormulaEnginePlugin, UniverSheetsPlugin)
export class UniverSheetsConditionalFormattingPlugin extends Plugin {
static override pluginName = SHEET_CONDITIONAL_FORMATTING_PLUGIN;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsConditionalFormattingConfig> = defaultPluginConfig,
@Inject(Injector) override readonly _injector: Injector,
@Inject(ICommandService) private _commandService: ICommandService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(SHEETS_CONDITIONAL_FORMATTING_PLUGIN_CONFIG_KEY, rest);
([
[ConditionalFormattingService],
[ConditionalFormattingFormulaService],
[ConditionalFormattingRangeTransformService],
[ConditionalFormattingStyleComposer],
[ConditionalFormattingRuleModel],
[ConditionalFormattingRangeIndexModel],
[ConditionalFormattingViewModel],
] as Dependency[]).forEach((dependency) => {
this._injector.add(dependency);
});
[
AddCfCommand,
ClearRangeCfCommand,
ClearWorksheetCfCommand,
DeleteCfCommand,
MoveCfCommand,
SetCfCommand,
AddConditionalRuleMutation,
DeleteConditionalRuleMutation,
SetConditionalRuleMutation,
MoveConditionalRuleMutation,
].forEach((m) => {
this._commandService.registerCommand(m);
});
}
override onStarting(): void {
this._injector.get(ConditionalFormattingService);
touchDependencies(this._injector, [
[ConditionalFormattingService],
[ConditionalFormattingRangeIndexModel],
[ConditionalFormattingViewModel],
]);
}
}

View File

@@ -0,0 +1,287 @@
/**
* 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 { describe, expect, it, vi } from 'vitest';
import { IIconSetType } from '../../models/icon-map';
import { DataBar } from '../data-bar.render';
import { ConditionalFormattingIcon } from '../icon.render';
function createCtx() {
const gradient = { addColorStop: vi.fn() };
return {
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arcTo: vi.fn(),
closePath: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
drawImage: vi.fn(),
createLinearGradient: vi.fn(() => gradient),
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
gradient,
} as any;
}
function createCellBounds(row: number, col: number, overrides?: Record<string, unknown>) {
const startX = col * 80;
const startY = row * 24;
return {
startRow: row,
endRow: row,
startColumn: col,
endColumn: col,
startX,
endX: startX + 80,
startY,
endY: startY + 24,
isMerged: false,
isMergedMainCell: false,
mergeInfo: {
startRow: row,
endRow: row,
startColumn: col,
endColumn: col,
startX,
endX: startX + 80,
startY,
endY: startY + 24,
},
...overrides,
};
}
function createSkeleton(cells: Record<string, unknown>, hiddenRows = new Set<number>(), hiddenCols = new Set<number>()) {
return {
rowColumnSegment: { startRow: 0, endRow: 2, startColumn: 0, endColumn: 2 },
worksheet: {
getUnitId: () => 'unit-1',
getSheetId: () => 'sheet-1',
getMergeData: () => [],
getRowVisible: (row: number) => !hiddenRows.has(row),
getColVisible: (col: number) => !hiddenCols.has(col),
getCell: vi.fn((row: number, col: number) => cells[`${row},${col}`]),
},
getCellWithCoordByIndex: (row: number, col: number) => createCellBounds(row, col),
} as any;
}
describe('conditional formatting render extensions', () => {
it('draws data bars for positive and negative cell values in visible cells', () => {
const ctx = createCtx();
const skeleton = createSkeleton({
'0,0': {
dataBar: {
color: '#2f56ef',
value: 75,
startPoint: 25,
isGradient: false,
isShowValue: true,
},
},
'0,1': {
dataBar: {
color: '#f45531',
value: -50,
startPoint: 60,
isGradient: true,
isShowValue: true,
},
},
'1,0': {
dataBar: {
color: '#111111',
value: 100,
startPoint: 0,
isGradient: false,
isShowValue: true,
},
},
}, new Set([1]));
new DataBar().draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, [
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 },
]);
expect(ctx.save).toHaveBeenCalled();
expect(ctx.fill).toHaveBeenCalledTimes(2);
expect(ctx.createLinearGradient).toHaveBeenCalledTimes(1);
expect(ctx.gradient.addColorStop).toHaveBeenCalledWith(0, 'rgb(255 255 255)');
expect(ctx.gradient.addColorStop).toHaveBeenCalledWith(1, '#f45531');
expect(ctx.stroke).toHaveBeenCalledTimes(1);
expect(ctx.restore).toHaveBeenCalled();
});
it('skips data bar drawing when worksheet is missing or bar dimensions collapse', () => {
const ctx = createCtx();
const dataBar = new DataBar();
expect(dataBar.draw(ctx, { scaleX: 1, scaleY: 1 }, { worksheet: undefined } as any, [])).toBe(false);
(dataBar as any)._drawRectWithRoundedCorner(ctx, 0, 0, 0, 10, true, true, true, true);
(dataBar as any)._drawRectWithRoundedCorner(ctx, 0, 0, 10, 0, true, true, true, true);
expect(ctx.beginPath).not.toHaveBeenCalled();
});
it('skips data bar cell visits only when a configured rule-range resolver confirms no matching rule', () => {
const ctx = createCtx();
const skeleton = createSkeleton({
'0,0': {
dataBar: {
color: '#2f56ef',
value: 75,
startPoint: 25,
isGradient: false,
isShowValue: true,
},
},
});
const dataBar = new DataBar();
const resolver = vi.fn(() => []);
dataBar.setRenderRangeResolver(resolver);
const ranges = [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }];
dataBar.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(resolver).toHaveBeenCalledWith('unit-1', 'sheet-1', ranges);
expect(skeleton.worksheet.getCell).not.toHaveBeenCalled();
expect(ctx.save).not.toHaveBeenCalled();
});
it('falls back to the original data bar path when indexed ranges are unavailable or merges exist', () => {
const ctx = createCtx();
const skeleton = createSkeleton({
'0,0': {
dataBar: {
color: '#2f56ef',
value: 75,
startPoint: 25,
isGradient: false,
isShowValue: true,
},
},
});
const dataBar = new DataBar();
const resolver = vi.fn<() => IRange[] | null>(() => null);
dataBar.setRenderRangeResolver(resolver);
const ranges = [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }];
dataBar.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(ctx.fill).toHaveBeenCalledTimes(1);
resolver.mockReturnValue([]);
skeleton.worksheet.getMergeData = () => [ranges[0]] as never;
dataBar.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(resolver).toHaveBeenCalledTimes(1);
expect(ctx.fill).toHaveBeenCalledTimes(2);
});
it('draws configured conditional-formatting icons and skips unavailable icons', () => {
const ctx = createCtx();
const icon = new ConditionalFormattingIcon();
const nativeImage = document.createElement('img');
(icon as any)._imageMap.set(`${IIconSetType.threeArrows}_0`, nativeImage);
const skeleton = createSkeleton({
'0,0': {
iconSet: {
iconType: IIconSetType.threeArrows,
iconId: '0',
isShowValue: true,
},
},
'0,1': {
iconSet: {
iconType: IIconSetType.empty,
iconId: '0',
isShowValue: true,
},
},
'0,2': {
iconSet: {
iconType: IIconSetType.threeArrows,
iconId: '99',
isShowValue: true,
},
},
});
icon.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, [
{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 },
]);
expect(ctx.drawImage).toHaveBeenCalledTimes(1);
expect(ctx.drawImage).toHaveBeenCalledWith(nativeImage, 2, 4.5, 15, 15);
});
it('skips icon drawing for missing worksheet and cells too small for the icon', () => {
const ctx = createCtx();
const icon = new ConditionalFormattingIcon();
const nativeImage = document.createElement('img');
(icon as any)._imageMap.set(`${IIconSetType.threeArrows}_0`, nativeImage);
expect(icon.draw(ctx, { scaleX: 1, scaleY: 1 }, { worksheet: undefined } as any, [])).toBe(false);
const skeleton = createSkeleton({
'0,0': {
iconSet: {
iconType: IIconSetType.threeArrows,
iconId: '0',
isShowValue: true,
},
},
});
skeleton.getCellWithCoordByIndex = () => createCellBounds(0, 0, {
endX: 10,
endY: 10,
mergeInfo: {
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 0,
startX: 0,
endX: 10,
startY: 0,
endY: 10,
},
}) as any;
icon.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, [
{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 },
]);
expect(ctx.drawImage).not.toHaveBeenCalled();
});
it('skips icon cell visits when a configured rule-range resolver confirms no matching rule', () => {
const ctx = createCtx();
const skeleton = createSkeleton({});
const icon = new ConditionalFormattingIcon();
const resolver = vi.fn(() => []);
icon.setRenderRangeResolver(resolver);
const ranges = [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }];
icon.draw(ctx, { scaleX: 1, scaleY: 1 }, skeleton, ranges);
expect(resolver).toHaveBeenCalledWith('unit-1', 'sheet-1', ranges);
expect(skeleton.worksheet.getCell).not.toHaveBeenCalled();
expect(ctx.save).not.toHaveBeenCalled();
});
});

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 { IRange, IScale } from '@univerjs/core';
import type { SpreadsheetSkeleton, UniverRenderingContext } from '@univerjs/engine-render';
import type { IConditionalFormattingRenderRangeResolver, IDataBarCellData } from './type';
import { Range } from '@univerjs/core';
import { FIX_ONE_PIXEL_BLUR_OFFSET, SheetExtension, SpreadsheetExtensionRegistry } from '@univerjs/engine-render';
export const dataBarUKey = 'sheet-conditional-rule-data-bar';
export const defaultDataBarPositiveColor = '#ffbe38';
export const defaultDataBarNativeColor = '#abd91a';
export const defaultPlaceholderColor = '#000';
const EXTENSION_Z_INDEX = 34;
const stringifyRange = (range: IRange) => {
const { startRow, endRow, startColumn, endColumn } = range;
return `${startRow}-${endRow}-${startColumn}-${endColumn}`;
};
export class DataBar extends SheetExtension {
private _paddingRightAndLeft = 2;
private _paddingTopAndBottom = 2;
private _renderRangeResolver: IConditionalFormattingRenderRangeResolver | null = null;
override uKey = dataBarUKey;
override Z_INDEX = EXTENSION_Z_INDEX;
_radius = 1;
setRenderRangeResolver(resolver: IConditionalFormattingRenderRangeResolver | null) {
this._renderRangeResolver = resolver;
}
// eslint-disable-next-line max-lines-per-function
override draw(
ctx: UniverRenderingContext,
_parentScale: IScale,
spreadsheetSkeleton: SpreadsheetSkeleton,
diffRanges: IRange[]
) {
const { worksheet } = spreadsheetSkeleton;
if (!worksheet) {
return false;
}
const mergeCellRendered = new Set<string>();
const sourceRanges = diffRanges?.length ? diffRanges : [spreadsheetSkeleton.rowColumnSegment];
const renderRanges = this._renderRangeResolver && worksheet.getMergeData().length === 0
? this._renderRangeResolver(worksheet.getUnitId(), worksheet.getSheetId(), sourceRanges) ?? sourceRanges
: sourceRanges;
if (!renderRanges.length) {
return;
}
ctx.save();
// ctx.globalCompositeOperation = 'destination-over';
// eslint-disable-next-line max-lines-per-function
renderRanges.forEach((range) => {
Range.foreach(range, (row, col) => {
if (!worksheet.getRowVisible(row) || !worksheet.getColVisible(col)) {
return;
}
const primaryWithCoord = spreadsheetSkeleton.getCellWithCoordByIndex(row, col, false);
const { isMerged, isMergedMainCell, mergeInfo } = primaryWithCoord;
let cellData = worksheet.getCell(row, col) as IDataBarCellData;
if (isMerged) {
cellData = worksheet.getCell(mergeInfo.startRow, mergeInfo.startColumn) as IDataBarCellData;
}
if (!cellData?.dataBar) {
return;
}
if (!this.isRenderDiffRangesByCell(mergeInfo, diffRanges)) {
return;
}
if (isMerged || isMergedMainCell) {
const rangeStr = stringifyRange(mergeInfo);
if (mergeCellRendered.has(rangeStr)) {
return;
}
mergeCellRendered.add(rangeStr);
}
const { color, value, startPoint, isGradient } = cellData.dataBar;
const { startX, endX, startY, endY } = (isMerged || isMergedMainCell) ? mergeInfo : primaryWithCoord;
const borderWidth = endX - startX;
const borderHeight = (endY + FIX_ONE_PIXEL_BLUR_OFFSET) - startY;
const width = borderWidth - this._paddingRightAndLeft * 2;
const height = borderHeight - this._paddingTopAndBottom * 2;
if (value > 0) {
// Width less than 1, almost invisible
const dataBarWidth = Math.max(width * (1 - startPoint / 100) * value / 100, 1);
const x0 = startX + this._paddingRightAndLeft + (startPoint / 100) * width;
const y0 = startY + this._paddingTopAndBottom;
if (isGradient) {
const gradient = ctx.createLinearGradient(x0, y0, x0 + dataBarWidth, y0);
gradient.addColorStop(0, color);
gradient.addColorStop(1, 'rgb(255 255 255)');
ctx.fillStyle = gradient;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
} else {
ctx.fillStyle = color;
}
this._drawRectWithRoundedCorner(ctx, x0, y0, dataBarWidth, height, false, true, true, false);
if (isGradient) {
ctx.stroke();
}
} else {
// Width less than 1, almost invisible
const dataBarWidth = Math.max(width * startPoint / 100 * Math.abs(value) / 100, 1);
const x0 = startX + this._paddingRightAndLeft + (startPoint / 100) * width - dataBarWidth;
const y0 = startY + this._paddingTopAndBottom;
if (isGradient) {
const gradient = ctx.createLinearGradient(x0, y0, x0 + dataBarWidth, y0);
gradient.addColorStop(0, 'rgb(255 255 255)');
gradient.addColorStop(1, color);
ctx.fillStyle = gradient;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
} else {
ctx.fillStyle = color;
}
this._drawRectWithRoundedCorner(ctx, x0, y0, dataBarWidth, height, true, false, false, true);
if (isGradient) {
ctx.stroke();
}
}
});
});
ctx.restore();
}
override dispose(): void {
this._renderRangeResolver = null;
super.dispose();
}
private _drawRectWithRoundedCorner(ctx: UniverRenderingContext, x: number, y: number, width: number, height: number, topLeftRadius: boolean, topRightRadius: boolean, bottomRightRadius: boolean, bottomLeftRadius: boolean) {
const radius = this._radius;
if (!height || !width) {
return;
}
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.lineTo(x + width - radius, y);
if (topRightRadius) {
ctx.arcTo(x + width, y, x + width, y + radius, radius);
} else {
ctx.lineTo(x + width, y);
}
ctx.lineTo(x + width, y + height - radius);
if (bottomRightRadius) {
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
} else {
ctx.lineTo(x + width, y + height);
}
ctx.lineTo(x + radius, y + height);
if (bottomLeftRadius) {
ctx.arcTo(x, y + height, x, y + height - radius, radius);
} else {
ctx.lineTo(x, y + height);
}
ctx.lineTo(x, y + radius);
if (topLeftRadius) {
ctx.arcTo(x, y, x + radius, y, radius);
} else {
ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
}
}
SpreadsheetExtensionRegistry.add(DataBar);

View File

@@ -0,0 +1,154 @@
/**
* 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, IScale } from '@univerjs/core';
import type { SpreadsheetSkeleton, UniverRenderingContext } from '@univerjs/engine-render';
import type { IConditionalFormattingRenderRangeResolver, IIconSetCellData } from './type';
import { Range } from '@univerjs/core';
import { SheetExtension, SpreadsheetExtensionRegistry } from '@univerjs/engine-render';
import { iconMap, IIconSetType } from '../models/icon-map';
export const IconUKey = 'sheet-conditional-rule-icon';
const EXTENSION_Z_INDEX = 35;
export const DEFAULT_WIDTH = 15;
export const DEFAULT_PADDING = 2;
const stringifyRange = (range: IRange) => {
const { startRow, endRow, startColumn, endColumn } = range;
return `${startRow}-${endRow}-${startColumn}-${endColumn}`;
};
export class ConditionalFormattingIcon extends SheetExtension {
private _paddingRightAndLeft = DEFAULT_PADDING;
private _width = DEFAULT_WIDTH;
private _imageMap: Map<string, HTMLImageElement> = new Map();
private _renderRangeResolver: IConditionalFormattingRenderRangeResolver | null = null;
override uKey = IconUKey;
override Z_INDEX = EXTENSION_Z_INDEX;
_radius = 1;
constructor() {
super();
this._init();
}
setRenderRangeResolver(resolver: IConditionalFormattingRenderRangeResolver | null) {
this._renderRangeResolver = resolver;
}
override draw(
ctx: UniverRenderingContext,
_parentScale: IScale,
spreadsheetSkeleton: SpreadsheetSkeleton,
diffRanges: IRange[]
) {
const { worksheet } = spreadsheetSkeleton;
if (!worksheet) {
return false;
}
const mergeCellRendered = new Set<string>();
const sourceRanges = diffRanges?.length ? diffRanges : [spreadsheetSkeleton.rowColumnSegment];
const renderRanges = this._renderRangeResolver && worksheet.getMergeData().length === 0
? this._renderRangeResolver(worksheet.getUnitId(), worksheet.getSheetId(), sourceRanges) ?? sourceRanges
: sourceRanges;
if (!renderRanges.length) {
return;
}
ctx.save();
// ctx.globalCompositeOperation = 'destination-over';
renderRanges.forEach((range) => {
Range.foreach(range, (row, col) => {
if (!worksheet.getRowVisible(row) || !worksheet.getColVisible(col)) {
return;
}
const primaryWithCoord = spreadsheetSkeleton.getCellWithCoordByIndex(row, col, false);
const { isMerged, isMergedMainCell, mergeInfo } = primaryWithCoord;
let cellData = worksheet.getCell(row, col) as IIconSetCellData;
if (isMerged) {
cellData = worksheet.getCell(mergeInfo.startRow, mergeInfo.startColumn) as IIconSetCellData;
}
if (!cellData?.iconSet) {
return;
}
const { iconType, iconId } = cellData.iconSet;
if (iconType === IIconSetType.empty) {
return;
}
const icon = this._imageMap.get(this._createKey(iconType, iconId));
if (!icon) {
return;
}
if (!this.isRenderDiffRangesByCell(mergeInfo, diffRanges)) {
return;
}
if (isMerged || isMergedMainCell) {
const rangeStr = stringifyRange(mergeInfo);
if (mergeCellRendered.has(rangeStr)) {
return;
}
mergeCellRendered.add(rangeStr);
}
const { startX, endX, startY, endY } = (isMerged || isMergedMainCell) ? mergeInfo : primaryWithCoord;
const borderWidth = endX - startX;
const borderHeight = endY - startY;
if (this._width > borderHeight || this._width > borderWidth + this._paddingRightAndLeft * 2) {
return;
}
// Highly centered processing
const y = (borderHeight - this._width) / 2 + startY;
ctx.drawImage(icon, startX + this._paddingRightAndLeft, y, this._width, this._width);
});
});
ctx.restore();
}
override dispose(): void {
this._renderRangeResolver = null;
super.dispose();
}
private _init() {
for (const type in iconMap) {
const list = iconMap[type as IIconSetType];
list.forEach((base64, index) => {
const key = this._createKey(type as IIconSetType, String(index));
const image = new Image();
image.onload = () => {
this._imageMap.set(key, image);
};
image.src = base64;
});
}
}
private _createKey(iconType: IIconSetType, iconIndex: string) {
return `${iconType}_${iconIndex}`;
}
}
SpreadsheetExtensionRegistry.add(ConditionalFormattingIcon);

View File

@@ -0,0 +1,48 @@
/**
* 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, IRange } from '@univerjs/core';
import type { IIconSetType } from '../models/icon-map';
export type IConditionalFormattingRenderRangeResolver = (
unitId: string,
subUnitId: string,
ranges: IRange[]
) => IRange[] | null;
export interface IDataBarRenderParams {
color: string;
value: number; // -100 - 100.
startPoint: number; //0-100
isGradient: boolean;
isShowValue: boolean;
}
export interface IDataBarCellData extends ICellData {
dataBar?: IDataBarRenderParams;
}
export interface IIconSetRenderParams {
iconId: string;
iconType: IIconSetType;
isShowValue: boolean;
}
export interface IIconSetCellData extends ICellData {
iconSet?: IIconSetRenderParams;
_originV?: ICellData['v'];
}
export type IConditionalFormattingCellData = IDataBarCellData & IIconSetCellData;

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 { CellValueType, Injector } from '@univerjs/core';
import { FormulaResultStatus, RegisterOtherFormulaService } from '@univerjs/engine-formula';
import { Subject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CFRuleType, CFSubRuleType, CFValueType } from '../../base/const';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { ConditionalFormattingFormulaService } from '../conditional-formatting-formula.service';
describe('ConditionalFormattingFormulaService', () => {
let service: ConditionalFormattingFormulaService;
let registerFormulaWithRange: ReturnType<typeof vi.fn>;
let deleteFormula: ReturnType<typeof vi.fn>;
let formulaResults: Map<string, unknown>;
let formulaResult$: Subject<Record<string, Record<string, unknown[]>>>;
let ruleChange$: Subject<any>;
class TestRegisterOtherFormulaService {
formulaResult$ = formulaResult$;
registerFormulaWithRange = registerFormulaWithRange;
getFormulaValueSync = (_unitId: string, _subUnitId: string, formulaId: string) => formulaResults.get(formulaId);
deleteFormula = deleteFormula;
}
class TestConditionalFormattingRuleModel {
$ruleChange = ruleChange$;
}
beforeEach(() => {
formulaResults = new Map();
registerFormulaWithRange = vi.fn(() => 'formula-1');
deleteFormula = vi.fn();
formulaResult$ = new Subject();
ruleChange$ = new Subject();
const injector = new Injector();
injector.add([RegisterOtherFormulaService, { useClass: TestRegisterOtherFormulaService as never }]);
injector.add([ConditionalFormattingRuleModel, { useClass: TestConditionalFormattingRuleModel as never }]);
injector.add([ConditionalFormattingFormulaService]);
service = injector.get(ConditionalFormattingFormulaService);
});
it('registers conditional-formatting formulas with a stable top-left anchor range', () => {
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=A1>0', [
{ startRow: 5, endRow: 5, startColumn: 5, endColumn: 5 },
{ startRow: 1, endRow: 1, startColumn: 2, endColumn: 2 },
]);
expect(registerFormulaWithRange).toHaveBeenCalledWith(
'book-1',
'sheet-1',
'=A1>0',
[
{ startRow: 1, endRow: 1, startColumn: 2, endColumn: 2 },
{ startRow: 5, endRow: 5, startColumn: 5, endColumn: 5 },
],
undefined,
expect.any(String),
'cf-1'
);
});
it('returns boolean formula results as business booleans for conditional checks', () => {
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=A1>0');
formulaResults.set('formula-1', {
status: FormulaResultStatus.SUCCESS,
result: { 0: { 0: [[{ t: CellValueType.BOOLEAN, v: 1 }]] } },
});
expect(service.getFormulaResultWithCoords('book-1', 'sheet-1', 'cf-1', '=A1>0')).toEqual({
status: FormulaResultStatus.SUCCESS,
result: true,
});
});
it('returns not-register and pending statuses when formula results are unavailable', () => {
expect(service.getFormulaResultWithCoords('book-1', 'sheet-1', 'cf-1', '=A1>0')).toEqual({
status: FormulaResultStatus.NOT_REGISTER,
});
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=A1>0');
expect(service.getFormulaResultWithCoords('book-1', 'sheet-1', 'cf-1', '=A1>0')).toEqual({
status: FormulaResultStatus.NOT_REGISTER,
});
formulaResults.set('formula-1', { status: FormulaResultStatus.WAIT });
expect(service.getFormulaResultWithCoords('book-1', 'sheet-1', 'cf-1', '=A1>0')).toEqual({
status: FormulaResultStatus.WAIT,
});
});
it('returns formula matrices with extracted scalar cell values', () => {
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=A1:B2');
formulaResults.set('formula-1', {
status: FormulaResultStatus.SUCCESS,
result: {
0: {
0: [[{ t: CellValueType.BOOLEAN, v: 0 }]],
1: [[{ v: 12 }]],
},
1: {
0: [[null]],
1: [[{ v: 'done' }]],
},
},
});
const matrix = service.getFormulaMatrix('book-1', 'sheet-1', 'cf-1', '=A1:B2');
expect(matrix.status).toBe(FormulaResultStatus.SUCCESS);
expect(matrix.result?.getValue(0, 0)).toBe(false);
expect(matrix.result?.getValue(0, 1)).toBe(12);
expect(matrix.result?.getValue(1, 0)).toBe(false);
expect(matrix.result?.getValue(1, 1)).toBe('done');
});
it('registers and removes formulas in response to rule changes', () => {
ruleChange$.next({
type: 'add',
unitId: 'book-1',
subUnitId: 'sheet-1',
rule: {
cfId: 'cf-1',
ranges: [{ startRow: 3, endRow: 3, startColumn: 1, endColumn: 1 }],
rule: {
type: CFRuleType.highlightCell,
subType: CFSubRuleType.formula,
value: '=A1>0',
},
},
});
ruleChange$.next({
type: 'delete',
unitId: 'book-1',
subUnitId: 'sheet-1',
rule: { cfId: 'cf-1' },
});
expect(registerFormulaWithRange).toHaveBeenCalledWith(
'book-1',
'sheet-1',
'=A1>0',
[{ startRow: 3, endRow: 3, startColumn: 1, endColumn: 1 }],
undefined,
expect.any(String),
'cf-1'
);
expect(deleteFormula).toHaveBeenCalledWith('book-1', 'sheet-1', ['formula-1']);
});
it('removes formula caches when formula-backed scale rules are replaced', () => {
registerFormulaWithRange.mockReturnValueOnce('formula-1').mockReturnValueOnce('formula-2');
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=MIN(A:A)');
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=MAX(A:A)');
ruleChange$.next({
type: 'set',
unitId: 'book-1',
subUnitId: 'sheet-1',
rule: {
cfId: 'cf-1',
rule: {
type: CFRuleType.colorScale,
config: [{ value: { type: CFValueType.formula } }],
},
},
});
expect(deleteFormula).toHaveBeenCalledWith('book-1', 'sheet-1', ['formula-1', 'formula-2']);
expect(service.getSubUnitFormulaMap('book-1', 'sheet-1')?.getValues()).toEqual([]);
});
it('publishes result changes and reports whether every formula for the rule finished', () => {
registerFormulaWithRange.mockReturnValueOnce('formula-1').mockReturnValueOnce('formula-2');
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=A1>0');
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=B1>0');
const results: unknown[] = [];
service.result$.subscribe((result) => results.push(result));
formulaResults.set('formula-1', { status: FormulaResultStatus.SUCCESS, result: {} });
formulaResults.set('formula-2', { status: FormulaResultStatus.WAIT });
formulaResult$.next({ 'book-1': { 'sheet-1': [{ formulaId: 'formula-1' }] } });
formulaResults.set('formula-2', { status: FormulaResultStatus.SUCCESS, result: {} });
formulaResult$.next({ 'book-1': { 'sheet-1': [{ formulaId: 'formula-2' }, { formulaId: 'unknown' }] } });
expect(results).toEqual([
expect.objectContaining({ formulaId: 'formula-1', isAllFinished: false }),
expect.objectContaining({ formulaId: 'formula-2', isAllFinished: true }),
]);
});
it('deletes one formula cache by text without unregistering other cached formulas', () => {
registerFormulaWithRange.mockReturnValueOnce('formula-1').mockReturnValueOnce('formula-2');
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=A1>0');
service.registerFormulaWithRange('book-1', 'sheet-1', 'cf-1', '=B1>0');
expect(service.deleteCache('book-1', 'sheet-1', 'cf-1', '=A1>0')).toEqual([]);
expect(service.getSubUnitFormulaMap('book-1', 'sheet-1')?.getValue('cf-1_=A1>0', ['id'])).toBeNull();
expect(service.getSubUnitFormulaMap('book-1', 'sheet-1')?.getValue('cf-1_=B1>0', ['id'])).toMatchObject({
formulaId: 'formula-2',
});
expect(service.createCFormulaId('cf-1', '=B1>0')).toBe('cf-1_=B1>0');
});
});

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 { Injector } from '@univerjs/core';
import { describe, expect, it } from 'vitest';
import { ConditionalFormattingRangeTransformService } from '../conditional-formatting-range-transform.service';
const sortRanges = <T extends { startRow: number; startColumn: number; endRow: number; endColumn: number }>(ranges: T[]) => {
return [...ranges].sort((a, b) =>
a.startRow - b.startRow ||
a.startColumn - b.startColumn ||
a.endRow - b.endRow ||
a.endColumn - b.endColumn
);
};
function createService(): ConditionalFormattingRangeTransformService {
const injector = new Injector();
injector.add([ConditionalFormattingRangeTransformService]);
return injector.get(ConditionalFormattingRangeTransformService);
}
describe('ConditionalFormattingRangeTransformService', () => {
it('subtracts ranges without expanding to a cell matrix', () => {
const service = createService();
const ranges = service.subtractRanges(
[{ startRow: 0, endRow: 4, startColumn: 0, endColumn: 4 }],
[{ startRow: 1, endRow: 3, startColumn: 1, endColumn: 3 }]
);
expect(sortRanges(ranges)).toEqual(sortRanges([
{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 4 },
{ startRow: 4, startColumn: 0, endRow: 4, endColumn: 4 },
{ startRow: 1, startColumn: 0, endRow: 3, endColumn: 0 },
{ startRow: 1, startColumn: 4, endRow: 3, endColumn: 4 },
]));
});
it('intersects source ranges and translates them to a target anchor', () => {
const service = createService();
const ranges = service.copyIntersectingRanges(
[{ startRow: 0, endRow: 2, startColumn: 0, endColumn: 2 }],
{ startRow: 1, endRow: 1, startColumn: 1, endColumn: 2 },
{ row: 10, col: 20 }
);
expect(ranges).toEqual([
{ startRow: 10, endRow: 10, startColumn: 20, endColumn: 21 },
]);
});
it('adds and merges translated ranges', () => {
const service = createService();
const ranges = service.addRanges(
[{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }],
[{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }]
);
expect(ranges).toEqual([{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 1 }]);
});
it('applies batched subtract and add operations once per rule', () => {
const service = createService();
const ranges = service.applyRangeDelta(
[{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 2 }],
[{ startRow: 0, endRow: 0, startColumn: 1, endColumn: 1 }],
[{ startRow: 1, endRow: 1, startColumn: 0, endColumn: 1 }]
);
expect(sortRanges(ranges)).toEqual(sortRanges([
{ startRow: 0, startColumn: 0, endRow: 0, endColumn: 0 },
{ startRow: 0, startColumn: 2, endRow: 0, endColumn: 2 },
{ startRow: 1, startColumn: 0, endRow: 1, endColumn: 1 },
]));
});
});

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 { Injector } from '@univerjs/core';
import { Subject } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CFRuleType } from '../../base/const';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { ConditionalFormattingViewModel } from '../../models/conditional-formatting-view-model';
import { ConditionalFormattingStyleComposer } from '../conditional-formatting-style-composer.service';
describe('ConditionalFormattingStyleComposer', () => {
let service: ConditionalFormattingStyleComposer;
const rules = new Map<string, unknown>();
let cellCfs: Array<{ cfId: string; result: unknown; priority: number }>;
let ruleChange$: Subject<void>;
let markDirty$: Subject<void>;
let cacheClear$: Subject<void>;
const getCellCfs = vi.fn(() => cellCfs);
class TestConditionalFormattingRuleModel {
$ruleChange = ruleChange$;
getRule(_unitId: string, _subUnitId: string, cfId: string) {
return rules.get(cfId);
}
}
class TestConditionalFormattingViewModel {
markDirty$ = markDirty$;
cacheClear$ = cacheClear$;
getCellCfs = getCellCfs;
}
beforeEach(() => {
rules.clear();
cellCfs = [];
ruleChange$ = new Subject();
markDirty$ = new Subject();
cacheClear$ = new Subject();
getCellCfs.mockClear();
const injector = new Injector();
injector.add([ConditionalFormattingRuleModel, { useClass: TestConditionalFormattingRuleModel as never }]);
injector.add([ConditionalFormattingViewModel, { useClass: TestConditionalFormattingViewModel as never }]);
injector.add([ConditionalFormattingStyleComposer]);
service = injector.get(ConditionalFormattingStyleComposer);
});
it('merges matched conditional styles in rule priority order', () => {
rules.set('color-scale', { stopIfTrue: false, rule: { type: CFRuleType.colorScale } });
rules.set('highlight', { stopIfTrue: false, rule: { type: CFRuleType.highlightCell } });
cellCfs = [
{ cfId: 'color-scale', result: '#ffeeaa', priority: 1 },
{ cfId: 'highlight', result: { cl: { rgb: '#333333' } }, priority: 2 },
];
expect(service.composeStyle('book-1', 'sheet-1', 1, 1)).toEqual({
style: { cl: { rgb: '#333333' }, bg: { rgb: '#ffeeaa' } },
});
});
it('composes highlight, data bar, and icon set rules on the same cell', () => {
const dataBar = {
color: '#38bdf8',
isGradient: false,
isShowValue: true,
startPoint: 0,
value: 50,
};
const iconSet = {
iconId: '0',
iconType: '3Arrows',
isShowValue: true,
};
rules.set('highlight', { stopIfTrue: false, rule: { type: CFRuleType.highlightCell } });
rules.set('data-bar', { stopIfTrue: false, rule: { type: CFRuleType.dataBar } });
rules.set('icon-set', { stopIfTrue: false, rule: { type: CFRuleType.iconSet } });
cellCfs = [
{ cfId: 'icon-set', result: iconSet, priority: 1 },
{ cfId: 'data-bar', result: dataBar, priority: 2 },
{ cfId: 'highlight', result: { bg: { rgb: '#fff7cc' } }, priority: 3 },
];
expect(service.composeStyle('book-1', 'sheet-1', 1, 1)).toEqual({
style: { bg: { rgb: '#fff7cc' } },
dataBar,
iconSet,
isShowValue: true,
});
});
it('stops evaluating lower-priority rules after a matched stop-if-true rule', () => {
rules.set('stop', { stopIfTrue: true, rule: { type: CFRuleType.highlightCell } });
rules.set('ignored', { stopIfTrue: false, rule: { type: CFRuleType.colorScale } });
cellCfs = [
{ cfId: 'stop', result: { bg: { rgb: '#ff0000' } }, priority: 1 },
{ cfId: 'ignored', result: '#00ff00', priority: 2 },
];
expect(service.composeStyle('book-1', 'sheet-1', 1, 1)).toEqual({
style: { bg: { rgb: '#ff0000' } },
});
});
it('reuses the composed result until conditional formatting changes', () => {
rules.set('highlight', { stopIfTrue: false, rule: { type: CFRuleType.highlightCell } });
cellCfs = [{ cfId: 'highlight', result: { cl: { rgb: '#333333' } }, priority: 1 }];
const first = service.composeStyle('book-1', 'sheet-1', 1, 1);
const second = service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(second).toBe(first);
expect(getCellCfs).toHaveBeenCalledOnce();
markDirty$.next();
service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(getCellCfs).toHaveBeenCalledTimes(2);
ruleChange$.next();
service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(getCellCfs).toHaveBeenCalledTimes(3);
cacheClear$.next();
service.composeStyle('book-1', 'sheet-1', 1, 1);
expect(getCellCfs).toHaveBeenCalledTimes(4);
});
});

View File

@@ -0,0 +1,254 @@
/**
* 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, Injector, IResourceManagerService, IUniverInstanceService } from '@univerjs/core';
import {
CopySheetCommand,
InsertColMutation,
InsertRowMutation,
MoveColsMutation,
MoveRangeMutation,
MoveRowsMutation,
RemoveSheetCommand,
ReorderRangeMutation,
SetRangeValuesMutation,
SheetInterceptorService,
} from '@univerjs/sheets';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConditionalFormattingRangeIndexModel } from '../../models/conditional-formatting-range-index-model';
import { ConditionalFormattingRuleModel } from '../../models/conditional-formatting-rule-model';
import { ConditionalFormattingViewModel } from '../../models/conditional-formatting-view-model';
import { ConditionalFormattingStyleComposer } from '../conditional-formatting-style-composer.service';
import { ConditionalFormattingService } from '../conditional-formatting.service';
describe('ConditionalFormattingService', () => {
let service: ConditionalFormattingService;
let composeStyle: ReturnType<typeof vi.fn>;
let resourceConfig: any;
let sheetInterceptorConfig: any;
let commandListener: (commandInfo: any) => void;
let unitRules: Map<string, unknown[]> | null;
let deleteUnitId: ReturnType<typeof vi.fn>;
let addRule: ReturnType<typeof vi.fn>;
let rebuild: ReturnType<typeof vi.fn>;
let clearCache: ReturnType<typeof vi.fn>;
let markRuleDirty: ReturnType<typeof vi.fn>;
class TestConditionalFormattingRuleModel {
getUnitRules = () => unitRules;
getSubunitRules = (_unitId: string, subUnitId: string) => unitRules?.get(subUnitId);
deleteUnitId = deleteUnitId;
addRule = addRule;
createCfId = (_unitId: string, subUnitId: string) => `${subUnitId}-new-cf`;
}
class TestConditionalFormattingRangeIndexModel {
rebuild = rebuild;
getRulesByRanges = (_unitId: string, _subUnitId: string, ranges: unknown[]) => ranges.length ? [{ cfId: 'cf-1' }] : [];
remove = vi.fn();
add = vi.fn();
}
class TestConditionalFormattingStyleComposer {
composeStyle = composeStyle;
}
class TestConditionalFormattingViewModel {
clearCache = clearCache;
markRuleDirty = markRuleDirty;
markCellDirty = vi.fn();
}
const worksheet = {
getSheetId: () => 'sheet-1',
getRowCount: () => 100,
getColumnCount: () => 50,
};
const workbook = {
getUnitId: () => 'book-1',
getSheetBySheetId: (sheetId: string) => (sheetId === 'sheet-1' || sheetId === 'sheet-2' ? { ...worksheet, getSheetId: () => sheetId } : null),
getActiveSheet: () => worksheet,
};
class TestUniverInstanceService {
getUnit = (unitId: string) => (unitId === 'book-1' ? workbook : null);
getCurrentUnitOfType = () => workbook;
}
class TestResourceManagerService {
registerPluginResource(config: unknown) {
resourceConfig = config;
return { dispose: vi.fn() };
}
}
class TestSheetInterceptorService {
interceptCommand = vi.fn((config: unknown) => {
sheetInterceptorConfig = config;
return { dispose: vi.fn() };
});
}
class TestCommandService {
onCommandExecuted(listener: (commandInfo: any) => void) {
commandListener = listener;
return { dispose: vi.fn() };
}
}
beforeEach(() => {
composeStyle = vi.fn(() => ({ style: { bg: { rgb: '#fff000' } } }));
resourceConfig = null;
sheetInterceptorConfig = null;
commandListener = () => undefined;
unitRules = null;
deleteUnitId = vi.fn();
addRule = vi.fn();
rebuild = vi.fn();
clearCache = vi.fn();
markRuleDirty = vi.fn();
const injector = new Injector();
injector.add([ConditionalFormattingRuleModel, { useClass: TestConditionalFormattingRuleModel as never }]);
injector.add([ConditionalFormattingRangeIndexModel, { useClass: TestConditionalFormattingRangeIndexModel as never }]);
injector.add([ConditionalFormattingStyleComposer, { useClass: TestConditionalFormattingStyleComposer as never }]);
injector.add([ConditionalFormattingViewModel, { useClass: TestConditionalFormattingViewModel as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([IResourceManagerService, { useClass: TestResourceManagerService as never }]);
injector.add([SheetInterceptorService, { useClass: TestSheetInterceptorService as never }]);
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([ConditionalFormattingService]);
service = injector.get(ConditionalFormattingService);
});
it('composes the conditional style for the requested cell through the style composer', () => {
expect(service.composeStyle('book-1', 'sheet-1', 2, 3)).toEqual({ style: { bg: { rgb: '#fff000' } } });
expect(composeStyle).toHaveBeenCalledWith('book-1', 'sheet-1', 2, 3);
});
it('serializes, loads, and unloads conditional-formatting snapshot resources', () => {
const firstRule = { cfId: 'cf-1', ranges: [] };
const secondRule = { cfId: 'cf-2', ranges: [] };
unitRules = new Map([['sheet-1', [firstRule, secondRule]]]);
expect(resourceConfig.toJson('book-1')).toBe(JSON.stringify({ 'sheet-1': [firstRule, secondRule] }));
expect(resourceConfig.parseJson('')).toEqual({});
expect(resourceConfig.parseJson('{bad')).toEqual({});
expect(resourceConfig.parseJson('{"sheet-1":[]}')).toEqual({ 'sheet-1': [] });
resourceConfig.onLoad('book-2', { 'sheet-2': [firstRule, secondRule] });
expect(addRule).toHaveBeenNthCalledWith(1, 'book-2', 'sheet-2', secondRule);
expect(addRule).toHaveBeenNthCalledWith(2, 'book-2', 'sheet-2', firstRule);
resourceConfig.onUnLoad('book-1');
expect(deleteUnitId).toHaveBeenCalledWith('book-1');
expect(rebuild).toHaveBeenCalled();
expect(clearCache).toHaveBeenCalled();
});
it('marks affected conditional-formatting rules dirty after cell value and reorder mutations', () => {
commandListener({
id: SetRangeValuesMutation.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
cellValue: {
1: { 2: { v: 10 } },
3: { 4: { s: 'style-only' } },
},
},
});
commandListener({
id: ReorderRangeMutation.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
range: { startRow: 1, endRow: 3, startColumn: 0, endColumn: 2 },
},
});
commandListener({ id: 'unrelated.command', params: {} });
expect(markRuleDirty).toHaveBeenCalledTimes(2);
expect(markRuleDirty).toHaveBeenCalledWith('book-1', 'sheet-1', 'cf-1');
});
it('creates conditional-formatting undo redo mutations for sheet remove and copy commands', () => {
const firstRule = { cfId: 'cf-1', ranges: [] };
const secondRule = { cfId: 'cf-2', ranges: [] };
unitRules = new Map([['sheet-1', [firstRule, secondRule]]]);
const removeResult = sheetInterceptorConfig.getMutations({
id: RemoveSheetCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1' },
});
expect(removeResult.redos).toEqual([
{ id: 'sheet.mutation.delete-conditional-rule', params: { unitId: 'book-1', subUnitId: 'sheet-1', cfId: 'cf-1' } },
{ id: 'sheet.mutation.delete-conditional-rule', params: { unitId: 'book-1', subUnitId: 'sheet-1', cfId: 'cf-2' } },
]);
expect(removeResult.undos.length).toBeGreaterThan(0);
const copyResult = sheetInterceptorConfig.getMutations({
id: CopySheetCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', targetSubUnitId: 'sheet-2' },
});
expect(copyResult.redos).toEqual([
{
id: 'sheet.mutation.add-conditional-rule',
params: { unitId: 'book-1', subUnitId: 'sheet-2', rule: { cfId: 'sheet-2-new-cf', ranges: [] } },
},
{
id: 'sheet.mutation.add-conditional-rule',
params: { unitId: 'book-1', subUnitId: 'sheet-2', rule: { cfId: 'sheet-2-new-cf', ranges: [] } },
},
]);
expect(sheetInterceptorConfig.getMutations({ id: 'unrelated.command', params: {} })).toEqual({ redos: [], undos: [] });
expect(sheetInterceptorConfig.getMutations({ id: RemoveSheetCommand.id, params: { unitId: 'missing', subUnitId: 'sheet-1' } })).toEqual({ redos: [], undos: [] });
});
it('marks conditional-formatting rules dirty after row and column structure changes', () => {
const range = { startRow: 2, endRow: 3, startColumn: 4, endColumn: 5 };
commandListener({ id: InsertColMutation.id, params: { unitId: 'book-1', subUnitId: 'sheet-1', range } });
commandListener({ id: InsertRowMutation.id, params: { unitId: 'book-1', subUnitId: 'sheet-1', range } });
commandListener({
id: MoveRowsMutation.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
sourceRange: { startRow: 5, endRow: 6, startColumn: 0, endColumn: 10 },
targetRange: { startRow: 1, endRow: 2, startColumn: 0, endColumn: 10 },
},
});
commandListener({
id: MoveColsMutation.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
sourceRange: { startRow: 0, endRow: 10, startColumn: 5, endColumn: 6 },
targetRange: { startRow: 0, endRow: 10, startColumn: 1, endColumn: 2 },
},
});
commandListener({
id: MoveRangeMutation.id,
params: {
unitId: 'book-1',
from: { subUnitId: 'sheet-1', value: { 0: { 0: { v: 1 } } } },
to: { subUnitId: 'sheet-1', value: { 2: { 2: { v: 1 } } } },
},
});
expect(markRuleDirty).toHaveBeenCalledWith('book-1', 'sheet-1', 'cf-1');
expect(markRuleDirty.mock.calls.length).toBeGreaterThanOrEqual(6);
});
});

View File

@@ -0,0 +1,273 @@
/**
* 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, IRange, Nullable } from '@univerjs/core';
import type { IOtherFormulaResult } from '@univerjs/engine-formula';
import type { IConditionalFormattingRuleConfig, IConditionFormattingRule } from '../models/type';
import { BooleanNumber, CellValueType, Disposable, Inject, ObjectMatrix, RefAlias } from '@univerjs/core';
import { FormulaResultStatus, OtherFormulaBizType, RegisterOtherFormulaService } from '@univerjs/engine-formula';
import { Subject } from 'rxjs';
import { CFRuleType, CFSubRuleType, CFValueType } from '../base/const';
import { ConditionalFormattingRuleModel } from '../models/conditional-formatting-rule-model';
// eslint-disable-next-line ts/consistent-type-definitions
type IFormulaItem = {
formulaText: string;
cfId: string;
id: string;
unitId: string;
subUnitId: string;
formulaId: string;
};
// TODO: @Gggpound
// It may be possible later to abstract a service that manages the results of an asynchronous calculation to handle the use of the last calculation before waiting for the result to return.
export class ConditionalFormattingFormulaService extends Disposable {
// Cache Formula ID and formula mapping.
private _formulaMap: Map<string, Map<string, RefAlias<IFormulaItem, 'id' | 'formulaId'>>> = new Map();
private _result$ = new Subject<IFormulaItem & { isAllFinished: boolean }>();
public result$ = this._result$.asObservable();
constructor(
@Inject(RegisterOtherFormulaService) private _registerOtherFormulaService: RegisterOtherFormulaService,
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel
) {
super();
this._initFormulaResultChange();
this._initRuleChange();
}
private _initRuleChange() {
const isNeedMarkFormulaDirty = (rule: IConditionalFormattingRuleConfig) => {
switch (rule.type) {
case CFRuleType.colorScale: {
return rule.config.some((item) => item.value.type === CFValueType.formula);
}
case CFRuleType.dataBar: {
return [rule.config.max, rule.config.min].some((item) => item.type === CFValueType.formula);
}
case CFRuleType.iconSet: {
return rule.config.some((item) => item.value.type === CFValueType.formula);
}
}
};
this.disposeWithMe(this._conditionalFormattingRuleModel.$ruleChange.subscribe((option) => {
const { unitId, subUnitId, rule, oldRule } = option;
if (option.type === 'delete') {
this._removeFormulaByCfId(unitId, subUnitId, rule.cfId);
}
if (option.type === 'set') {
if (isNeedMarkFormulaDirty(rule.rule) || (oldRule && isNeedMarkFormulaDirty(oldRule.rule))) {
this._removeFormulaByCfId(unitId, subUnitId, rule.cfId);
}
}
if (option.type === 'add') {
this._registerRuleFormulas(unitId, subUnitId, rule);
}
}));
}
/**
* Register formulas for a specific rule based on its type
*/
private _registerRuleFormulas(unitId: string, subUnitId: string, rule: IConditionFormattingRule): void {
switch (rule.rule.type) {
case CFRuleType.highlightCell: {
// For highlight cell with formula subtype
if (rule.rule.subType === CFSubRuleType.formula) {
const formulaText = rule.rule.value;
if (formulaText) {
this.registerFormulaWithRange(
unitId,
subUnitId,
rule.cfId,
formulaText,
rule.ranges
);
}
}
break;
}
}
}
private _initFormulaResultChange() {
// Subscribe to formula calculation results
this.disposeWithMe(this._registerOtherFormulaService.formulaResult$.subscribe((results: Record<string, Record<string, IOtherFormulaResult[]>>) => {
for (const unitId in results) {
for (const subUnitId in results[unitId]) {
const subUnitResults = results[unitId][subUnitId];
for (const formulaResult of subUnitResults) {
const formulaMapAlias = this._ensureSubunitFormulaMap(unitId, subUnitId).getValue(formulaResult.formulaId, ['formulaId']);
if (!formulaMapAlias) {
continue;
}
const allFormulaMapAlias = this._getAllFormulaResultByCfId(unitId, subUnitId, formulaMapAlias.cfId);
const isAllFinished = allFormulaMapAlias.every((item) => {
const result = this._registerOtherFormulaService.getFormulaValueSync(unitId, subUnitId, item.formulaId);
return result?.status === FormulaResultStatus.SUCCESS;
});
this._result$.next({ ...formulaMapAlias, isAllFinished });
}
}
}
}));
}
private _ensureSubunitFormulaMap(unitId: string, subUnitId: string) {
let unitMap = this._formulaMap.get(unitId);
if (!unitMap) {
unitMap = new Map<string, RefAlias<IFormulaItem, 'formulaId' | 'id'>>();
this._formulaMap.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new RefAlias<IFormulaItem, 'formulaId' | 'id'>([], ['formulaId', 'id']);
unitMap.set(subUnitId, subUnitMap);
}
return subUnitMap;
}
public getSubUnitFormulaMap(unitId: string, subUnitId: string) {
return this._formulaMap.get(unitId)?.get(subUnitId);
}
public registerFormulaWithRange(unitId: string, subUnitId: string, cfId: string, formulaText: string, ranges: IRange[] = [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }]) {
const formulaMap = this._ensureSubunitFormulaMap(unitId, subUnitId);
const cfFormulaId = this.createCFormulaId(cfId, formulaText);
if (formulaMap.getValue(cfFormulaId, ['id'])) {
return;
}
// Always sort ranges by top-left so the formula engine uses a consistent anchor (ranges[0])
// regardless of the call path (e.g. rule-add event vs preComputing).
const sortedRanges = [...ranges].sort((a, b) =>
a.startRow !== b.startRow ? a.startRow - b.startRow : a.startColumn - b.startColumn
);
const formulaId = this._registerOtherFormulaService.registerFormulaWithRange(unitId, subUnitId, formulaText, sortedRanges, undefined, OtherFormulaBizType.CONDITIONAL_FORMATTING, cfId);
formulaMap.addValue({
formulaText,
unitId,
subUnitId,
cfId,
id: cfFormulaId,
formulaId,
});
}
private _removeFormulaByCfId(unitId: string, subUnitId: string, cfId: string) {
const values = this.deleteCache(unitId, subUnitId, cfId);
const formulaIdList = values.map((item) => item.formulaId);
this._registerOtherFormulaService.deleteFormula(unitId, subUnitId, formulaIdList);
}
public getFormulaResultWithCoords(unitId: string, subUnitId: string, cfId: string, formulaText: string, row: number = 0, col: number = 0) {
const map = this.getSubUnitFormulaMap(unitId, subUnitId);
if (!map) {
return { status: FormulaResultStatus.NOT_REGISTER };
}
const item = map.getValue(this.createCFormulaId(cfId, formulaText), ['id']);
if (!item) {
return { status: FormulaResultStatus.NOT_REGISTER };
}
const formulaResult = this._registerOtherFormulaService.getFormulaValueSync(unitId, subUnitId, item.formulaId);
if (!formulaResult) {
return { status: FormulaResultStatus.NOT_REGISTER };
}
if (formulaResult.status === FormulaResultStatus.SUCCESS && formulaResult.result) {
const cellData = formulaResult.result[row]?.[col];
const result = this._getCellValue(cellData);
return { result, status: FormulaResultStatus.SUCCESS };
}
return { status: formulaResult.status };
}
public getFormulaMatrix(unitId: string, subUnitId: string, cfId: string, formulaText: string) {
const map = this.getSubUnitFormulaMap(unitId, subUnitId);
if (!map) {
return { status: FormulaResultStatus.NOT_REGISTER };
}
const item = map.getValue(this.createCFormulaId(cfId, formulaText), ['id']);
if (!item) {
return { status: FormulaResultStatus.NOT_REGISTER };
}
const formulaResult = this._registerOtherFormulaService.getFormulaValueSync(unitId, subUnitId, item.formulaId);
if (!formulaResult) {
return { status: FormulaResultStatus.NOT_REGISTER };
}
if (formulaResult.status === FormulaResultStatus.SUCCESS && formulaResult.result) {
const result = new ObjectMatrix<string | number | boolean | undefined | null | void>();
const resultMatrix = new ObjectMatrix(formulaResult.result);
resultMatrix.forValue((row, col, cellData) => {
result.setValue(row, col, this._getCellValue(cellData));
});
return { result, status: FormulaResultStatus.SUCCESS };
}
return { status: formulaResult.status };
}
private _getCellValue(cellData: Nullable<ICellData>[][]): string | number | boolean | undefined | null | void {
if (!cellData || !cellData[0] || !cellData[0][0]) {
return false;
}
const cell = cellData[0][0];
if (cell?.t === CellValueType.BOOLEAN) {
return cell.v === BooleanNumber.TRUE || cell.v === true;
}
return cell?.v;
}
/**
* If `formulaText` is not provided, then all caches related to `cfId` will be deleted.
*/
public deleteCache(unitId: string, subUnitId: string, cfId: string, formulaText?: string) {
const map = this.getSubUnitFormulaMap(unitId, subUnitId);
if (!map) {
return [];
}
if (formulaText) {
const key = this.createCFormulaId(cfId, formulaText);
map.deleteValue(key, ['id']);
return [];
} else {
const values = map.getValues().filter((v) => v.cfId === cfId);
values.forEach((e) => {
map.deleteValue(e.formulaId, ['formulaId']);
});
return values;
}
}
private _getAllFormulaResultByCfId(unitId: string, subUnitId: string, cfId: string) {
const map = this.getSubUnitFormulaMap(unitId, subUnitId);
if (!map) {
return [];
}
const values = map.getValues().filter((v) => v.cfId === cfId);
return values;
}
/**
* A conditional formatting may have multiple formulas;if the formulas are identical,then the results will be consistent.
*/
public createCFormulaId(cfId: string, formulaText: string) {
return `${cfId}_${formulaText}`;
}
}

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 type { IRange } from '@univerjs/core';
import { getIntersectRange, Rectangle } from '@univerjs/core';
export interface IRangeAnchor {
row: number;
col: number;
}
export class ConditionalFormattingRangeTransformService {
subtractRanges(sourceRanges: IRange[], rangesToSubtract: IRange[]) {
return Rectangle.mergeRanges(Rectangle.subtractMulti(sourceRanges, rangesToSubtract));
}
addRanges(sourceRanges: IRange[], rangesToAdd: IRange[]) {
return Rectangle.mergeRanges([...sourceRanges, ...rangesToAdd]);
}
applyRangeDelta(sourceRanges: IRange[], rangesToSubtract: IRange[], rangesToAdd: IRange[]) {
const ranges = rangesToSubtract.length
? this.subtractRanges(sourceRanges, rangesToSubtract)
: sourceRanges;
return rangesToAdd.length
? this.addRanges(ranges, rangesToAdd)
: ranges;
}
copyIntersectingRanges(sourceRanges: IRange[], sourceRange: IRange, targetAnchor: IRangeAnchor) {
const fragments: IRange[] = [];
sourceRanges.forEach((range) => {
const intersect = getIntersectRange(range, sourceRange);
if (!intersect) {
return;
}
fragments.push(this.translateRange(intersect, {
row: targetAnchor.row - sourceRange.startRow,
col: targetAnchor.col - sourceRange.startColumn,
}));
});
return Rectangle.mergeRanges(fragments);
}
translateRange(range: IRange, offset: IRangeAnchor): IRange {
return {
startRow: range.startRow + offset.row,
endRow: range.endRow + offset.row,
startColumn: range.startColumn + offset.col,
endColumn: range.endColumn + offset.col,
};
}
}

View File

@@ -0,0 +1,143 @@
/**
* 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 { IConditionFormattingRule, IHighlightCell } from '../models/type';
import type { IDataBarCellData, IDataBarRenderParams, IIconSetCellData, IIconSetRenderParams } from '../render/type';
import { Disposable, Inject, LRUMap, merge } from '@univerjs/core';
import { CFRuleType } from '../base/const';
import { ConditionalFormattingRuleModel } from '../models/conditional-formatting-rule-model';
import { CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH, ConditionalFormattingViewModel } from '../models/conditional-formatting-view-model';
type IComposedStyle = { style?: IHighlightCell['style'] } & IDataBarCellData & IIconSetCellData & { isShowValue: boolean };
export class ConditionalFormattingStyleComposer extends Disposable {
private _cache = new LRUMap<string, IComposedStyle | null>(CONDITIONAL_FORMATTING_VIEWPORT_CACHE_LENGTH);
constructor(
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel,
@Inject(ConditionalFormattingViewModel) private _conditionalFormattingViewModel: ConditionalFormattingViewModel
) {
super();
this.disposeWithMe(this._conditionalFormattingRuleModel.$ruleChange.subscribe(() => this._cache.clear()));
this.disposeWithMe(this._conditionalFormattingViewModel.markDirty$.subscribe(() => this._cache.clear()));
this.disposeWithMe(this._conditionalFormattingViewModel.cacheClear$.subscribe(() => this._cache.clear()));
}
override dispose(): void {
this._cache.clear();
super.dispose();
}
// Conditional formats need to be evaluated in priority order.
// Evaluation of subsequent rules stops only if the current rule is matched and stopIfTrue=true.
composeStyle(unitId: string, subUnitId: string, row: number, col: number) {
const cacheKey = `${unitId}_${subUnitId}_${row}_${col}`;
if (this._cache.has(cacheKey)) {
return this._cache.get(cacheKey) ?? null;
}
const cellCfs = this._conditionalFormattingViewModel.getCellCfs(unitId, subUnitId, row, col);
if (!cellCfs?.length) {
this._cache.set(cacheKey, null);
return null;
}
const matchedRules: Array<{
rule: IConditionFormattingRule;
cacheItem: (typeof cellCfs)[number];
}> = [];
let stopIfTrueIndex = -1;
for (const cacheItem of cellCfs) {
const rule = this._conditionalFormattingRuleModel.getRule(unitId, subUnitId, cacheItem.cfId);
if (!rule) {
continue;
}
matchedRules.push({ rule, cacheItem });
if (stopIfTrueIndex === -1 && rule.stopIfTrue && this._isRuleMatched(rule, cacheItem.result)) {
stopIfTrueIndex = matchedRules.length - 1;
}
}
if (!matchedRules.length) {
this._cache.set(cacheKey, null);
return null;
}
const effectiveRules = stopIfTrueIndex > -1
? matchedRules.slice(0, stopIfTrueIndex + 1)
: matchedRules;
const result = {} as IComposedStyle;
for (let i = effectiveRules.length - 1; i >= 0; i--) {
const { rule, cacheItem } = effectiveRules[i];
this._mergeComposeResult(result, rule, cacheItem.result);
}
this._cache.set(cacheKey, result);
return result;
}
private _mergeComposeResult(
result: { style?: IHighlightCell['style'] } & IDataBarCellData & IIconSetCellData & { isShowValue: boolean },
rule: IConditionFormattingRule,
ruleResult: unknown
) {
const type = rule.rule.type;
if (type === CFRuleType.highlightCell) {
ruleResult && merge(result, { style: ruleResult });
return;
}
if (type === CFRuleType.colorScale) {
if (ruleResult && typeof ruleResult === 'string') {
const preStyle = result.style || {};
result.style = { ...preStyle, bg: { rgb: ruleResult } };
}
return;
}
if (type === CFRuleType.dataBar) {
const ruleCache = ruleResult as IDataBarRenderParams;
if (ruleCache) {
result.dataBar = ruleCache;
result.isShowValue = ruleCache.isShowValue;
}
return;
}
if (type === CFRuleType.iconSet) {
const ruleCache = ruleResult as IIconSetRenderParams;
if (ruleCache) {
result.iconSet = ruleCache;
result.isShowValue = ruleCache.isShowValue;
}
}
}
private _isRuleMatched(rule: IConditionFormattingRule, ruleResult: unknown) {
if (rule.rule.type === CFRuleType.highlightCell) {
return !!ruleResult && typeof ruleResult === 'object' && Object.keys(ruleResult as object).length > 0;
}
return !!ruleResult;
}
}

View File

@@ -0,0 +1,325 @@
/**
* 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, IRange } from '@univerjs/core';
import type {
ICopySheetCommandParams,
IInsertColMutationParams,
IInsertRowMutationParams,
IMoveColumnsMutationParams,
IMoveRangeMutationParams,
IMoveRowsMutationParams,
IRemoveColMutationParams,
IRemoveRowsMutationParams,
IRemoveSheetCommandParams,
IReorderRangeMutationParams,
ISetRangeValuesMutationParams,
} from '@univerjs/sheets';
import type { IAddConditionalRuleMutationParams } from '../commands/mutations/add-conditional-rule.mutation';
import type { IDeleteConditionalRuleMutationParams } from '../commands/mutations/delete-conditional-rule.mutation';
import type { IRuleModelJson } from '../models/type';
import {
Disposable,
ICommandService,
Inject,
Injector,
IResourceManagerService,
isInternalEditorID,
IUniverInstanceService,
ObjectMatrix,
UniverInstanceType,
} from '@univerjs/core';
import {
CopySheetCommand,
getSheetCommandTarget,
InsertColMutation,
InsertRowMutation,
MoveColsMutation,
MoveRangeMutation,
MoveRowsMutation,
RemoveColMutation,
RemoveRowMutation,
RemoveSheetCommand,
ReorderRangeMutation,
SetRangeValuesMutation,
SheetInterceptorService,
} from '@univerjs/sheets';
import { SHEET_CONDITIONAL_FORMATTING_PLUGIN } from '../base/const';
import { AddConditionalRuleMutation, AddConditionalRuleMutationUndoFactory } from '../commands/mutations/add-conditional-rule.mutation';
import { DeleteConditionalRuleMutation, DeleteConditionalRuleMutationUndoFactory } from '../commands/mutations/delete-conditional-rule.mutation';
import { ConditionalFormattingRangeIndexModel } from '../models/conditional-formatting-range-index-model';
import { ConditionalFormattingRuleModel } from '../models/conditional-formatting-rule-model';
import { ConditionalFormattingViewModel } from '../models/conditional-formatting-view-model';
import { ConditionalFormattingStyleComposer } from './conditional-formatting-style-composer.service';
export class ConditionalFormattingService extends Disposable {
get _conditionalFormattingViewModelV2() {
return this._injector.get(ConditionalFormattingViewModel);
}
constructor(
@Inject(ConditionalFormattingRuleModel) private _conditionalFormattingRuleModel: ConditionalFormattingRuleModel,
@Inject(ConditionalFormattingRangeIndexModel) private _conditionalFormattingRangeIndexModel: ConditionalFormattingRangeIndexModel,
@Inject(ConditionalFormattingStyleComposer) private _conditionalFormattingStyleComposer: ConditionalFormattingStyleComposer,
@Inject(Injector) private _injector: Injector,
@Inject(IUniverInstanceService) private _univerInstanceService: IUniverInstanceService,
@Inject(IResourceManagerService) private _resourceManagerService: IResourceManagerService,
@Inject(SheetInterceptorService) private _sheetInterceptorService: SheetInterceptorService,
@Inject(ICommandService) private _commandService: ICommandService
) {
super();
this._initCellChange();
this._initSnapshot();
this._initSheetChange();
}
public composeStyle(unitId: string, subUnitId: string, row: number, col: number) {
return this._conditionalFormattingStyleComposer.composeStyle(unitId, subUnitId, row, col);
}
private _initSnapshot() {
const toJson = (unitID: string) => {
const map = this._conditionalFormattingRuleModel.getUnitRules(unitID);
const resultMap: IRuleModelJson[keyof IRuleModelJson] = {};
if (map) {
map.forEach((v, key) => {
resultMap[key] = v;
});
return JSON.stringify(resultMap);
}
return '';
};
const parseJson = (json: string): IRuleModelJson[keyof IRuleModelJson] => {
if (!json) {
return {};
}
try {
return JSON.parse(json);
} catch {
return {};
}
};
this.disposeWithMe(
this._resourceManagerService.registerPluginResource<IRuleModelJson[keyof IRuleModelJson]>({
pluginName: SHEET_CONDITIONAL_FORMATTING_PLUGIN,
businesses: [UniverInstanceType.UNIVER_SHEET],
toJson: (unitID) => toJson(unitID),
parseJson: (json) => parseJson(json),
onUnLoad: (unitID) => {
this._conditionalFormattingRuleModel.deleteUnitId(unitID);
this._conditionalFormattingRangeIndexModel.rebuild();
if (isInternalEditorID(unitID)) return;
this._conditionalFormattingViewModelV2.clearCache();
},
onLoad: (unitID, value) => {
Object.keys(value).forEach((subunitId) => {
const ruleList = [...value[subunitId]].reverse();
ruleList.forEach((rule) => {
this._conditionalFormattingRuleModel.addRule(unitID, subunitId, rule);
});
});
},
})
);
}
private _initSheetChange() {
this.disposeWithMe(
this._sheetInterceptorService.interceptCommand({
getMutations: (commandInfo) => {
if (commandInfo.id === RemoveSheetCommand.id) {
const params = commandInfo.params as IRemoveSheetCommandParams;
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) {
return { redos: [], undos: [] };
}
const { unitId, subUnitId } = target;
const ruleList = this._conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId);
if (!ruleList) {
return { redos: [], undos: [] };
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
ruleList.forEach((item) => {
const params: IDeleteConditionalRuleMutationParams = {
unitId,
subUnitId,
cfId: item.cfId,
};
redos.push({
id: DeleteConditionalRuleMutation.id,
params,
});
undos.push(...DeleteConditionalRuleMutationUndoFactory(this._injector, params));
});
return {
redos,
undos,
};
} else if (commandInfo.id === CopySheetCommand.id) {
const params = commandInfo.params as ICopySheetCommandParams & { targetSubUnitId: string };
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) {
return { redos: [], undos: [] };
}
const { unitId, subUnitId } = target;
const { targetSubUnitId } = params;
const ruleList = this._conditionalFormattingRuleModel.getSubunitRules(unitId, subUnitId);
if (!ruleList) {
return { redos: [], undos: [] };
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
ruleList.forEach((item) => {
const params: IAddConditionalRuleMutationParams = {
unitId,
subUnitId: targetSubUnitId,
rule: {
...item,
cfId: this._conditionalFormattingRuleModel.createCfId(unitId, targetSubUnitId),
},
};
redos.push({ id: AddConditionalRuleMutation.id, params });
undos.push(AddConditionalRuleMutationUndoFactory(this._injector, params));
});
return { redos, undos };
}
return { redos: [], undos: [] };
},
})
);
}
// eslint-disable-next-line max-lines-per-function
private _initCellChange() {
this.disposeWithMe(
// eslint-disable-next-line max-lines-per-function
this._commandService.onCommandExecuted((commandInfo) => {
const markRulesDirtyByRanges = (unitId: string, subUnitId: string, ranges: IRange[]) => {
const rules = this._conditionalFormattingRangeIndexModel.getRulesByRanges(unitId, subUnitId, ranges);
rules.forEach((rule) => {
this._conditionalFormattingViewModelV2.markRuleDirty(unitId, subUnitId, rule.cfId);
});
};
switch (commandInfo.id) {
case SetRangeValuesMutation.id: {
const params = commandInfo.params as ISetRangeValuesMutationParams;
const { subUnitId, unitId, cellValue } = params;
const ranges: IRange[] = [];
new ObjectMatrix(cellValue).forValue((row, col, value) => {
// When P and V are involved
const result = value && Object.keys(value).some((key) => ['p', 'v'].includes(key));
if (result) {
ranges.push({ startRow: row, endRow: row, startColumn: col, endColumn: col });
}
});
markRulesDirtyByRanges(unitId, subUnitId, ranges);
break;
}
case InsertColMutation.id:
case RemoveColMutation.id: {
const params = commandInfo.params as IInsertColMutationParams | IRemoveColMutationParams;
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) return;
const { worksheet, unitId, subUnitId } = target;
const { range } = params;
const effectRange: IRange = { ...range, endColumn: worksheet.getColumnCount() - 1 };
markRulesDirtyByRanges(unitId, subUnitId, [effectRange]);
break;
}
case RemoveRowMutation.id:
case InsertRowMutation.id: {
const params = commandInfo.params as IRemoveRowsMutationParams | IInsertRowMutationParams;
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) return;
const { worksheet, unitId, subUnitId } = target;
const { range } = params;
const effectRange: IRange = { ...range, endRow: worksheet.getRowCount() - 1 };
markRulesDirtyByRanges(unitId, subUnitId, [effectRange]);
break;
}
case MoveRowsMutation.id: {
const params = commandInfo.params as IMoveRowsMutationParams;
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) return;
const { worksheet, unitId, subUnitId } = target;
const { sourceRange, targetRange } = params;
const effectRange: IRange = {
startRow: Math.min(sourceRange.startRow, targetRange.startRow),
endRow: worksheet.getRowCount() - 1,
startColumn: 0,
endColumn: worksheet.getColumnCount() - 1,
};
markRulesDirtyByRanges(unitId, subUnitId, [effectRange]);
break;
}
case MoveColsMutation.id: {
const params = commandInfo.params as IMoveColumnsMutationParams;
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) return;
const { worksheet, unitId, subUnitId } = target;
const { sourceRange, targetRange } = params;
const effectRange: IRange = {
startRow: 0,
endRow: worksheet.getRowCount() - 1,
startColumn: Math.min(sourceRange.startColumn, targetRange.startColumn),
endColumn: worksheet.getColumnCount() - 1,
};
markRulesDirtyByRanges(unitId, subUnitId, [effectRange]);
break;
}
case MoveRangeMutation.id: {
const { unitId, to, from } = commandInfo.params as IMoveRangeMutationParams;
const handleSubUnit = (value: IMoveRangeMutationParams['from']) => {
const ranges: IRange[] = [];
new ObjectMatrix(value.value).forValue((row, col) => {
ranges.push({ startRow: row, endRow: row, startColumn: col, endColumn: col });
});
markRulesDirtyByRanges(unitId, value.subUnitId, ranges);
};
handleSubUnit(to);
handleSubUnit(from);
break;
}
case ReorderRangeMutation.id: {
const { range, unitId, subUnitId } = commandInfo.params as IReorderRangeMutationParams;
markRulesDirtyByRanges(unitId, subUnitId, [range]);
break;
}
}
})
);
}
}

View File

@@ -0,0 +1,340 @@
/**
* 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 { IConditionFormattingRule } from '../../models/type';
import type { IAnchor } from '../anchor';
import { beforeEach, describe, expect, it } from 'vitest';
import { anchorUndoFactory, moveByAnchor, transformSupportSymmetryAnchor } from '../anchor';
describe('Test anchor', () => {
let list: IConditionFormattingRule[] = [];
beforeEach(() => {
list = [];
new Array(10).fill('').forEach((_v, index) => {
list.push({ cfId: String(index) } as IConditionFormattingRule);
});
});
describe('Move forward from the back', () => {
it('Move 8 in front of 2', () => {
const startAnchor: IAnchor = { id: '8', type: 'self' };
const endAnchor: IAnchor = { id: '2', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0182345679');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 8 in behind of 2', () => {
const startAnchor: IAnchor = { id: '8', type: 'self' };
const endAnchor: IAnchor = { id: '2', type: 'after' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0128345679');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 8 in front of 2, self', () => {
const startAnchor: IAnchor = { id: '8', type: 'self' };
const endAnchor: IAnchor = { id: '2', type: 'self' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0182345679');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
//--------------------------------------------------------------------------------------
it('Move 7 in front of 2', () => {
const startAnchor: IAnchor = { id: '8', type: 'before' };
const endAnchor: IAnchor = { id: '2', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0172345689');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 7 in behind of 2', () => {
const startAnchor: IAnchor = { id: '8', type: 'before' };
const endAnchor: IAnchor = { id: '2', type: 'after' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0127345689');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 7 in front of 2,self', () => {
const startAnchor: IAnchor = { id: '8', type: 'before' };
const endAnchor: IAnchor = { id: '2', type: 'self' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0172345689');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
//--------------------------------------------------------------------------------------
it('Move 9 in front of 2', () => {
const startAnchor: IAnchor = { id: '8', type: 'after' };
const endAnchor: IAnchor = { id: '2', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0192345678');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 9 in behind of 2', () => {
const startAnchor: IAnchor = { id: '8', type: 'after' };
const endAnchor: IAnchor = { id: '2', type: 'after' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0129345678');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 9 in front of 2,self', () => {
const startAnchor: IAnchor = { id: '8', type: 'after' };
const endAnchor: IAnchor = { id: '2', type: 'self' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0192345678');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 9 in front of 0,self', () => {
const startAnchor: IAnchor = { id: '8', type: 'after' };
const endAnchor: IAnchor = { id: '0', type: 'self' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('9012345678');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 9 in front of 0', () => {
const startAnchor: IAnchor = { id: '8', type: 'after' };
const endAnchor: IAnchor = { id: '0', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('9012345678');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
});
describe('Move back and forth', () => {
it('Move 2 in front of 8', () => {
const startAnchor: IAnchor = { id: '2', type: 'self' };
const endAnchor: IAnchor = { id: '8', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0134567289');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 2 in behind of 8', () => {
const startAnchor: IAnchor = { id: '2', type: 'self' };
const endAnchor: IAnchor = { id: '8', type: 'after' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0134567829');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 2 in front of 8, self', () => {
const startAnchor: IAnchor = { id: '2', type: 'self' };
const endAnchor: IAnchor = { id: '8', type: 'self' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0134567289');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
//--------------------------------------------------------------------------------------
it('Move 1 in front of 8', () => {
const startAnchor: IAnchor = { id: '2', type: 'before' };
const endAnchor: IAnchor = { id: '8', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0234567189');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 1 in behind of 8', () => {
const startAnchor: IAnchor = { id: '2', type: 'before' };
const endAnchor: IAnchor = { id: '8', type: 'after' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0234567819');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 1 in front of 8,self', () => {
const startAnchor: IAnchor = { id: '2', type: 'before' };
const endAnchor: IAnchor = { id: '8', type: 'self' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0234567189');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
//--------------------------------------------------------------------------------------
it('Move 3 in front of 8', () => {
const startAnchor: IAnchor = { id: '2', type: 'after' };
const endAnchor: IAnchor = { id: '8', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0124567389');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 3 in behind of 8', () => {
const startAnchor: IAnchor = { id: '2', type: 'after' };
const endAnchor: IAnchor = { id: '8', type: 'after' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0124567839');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 3 in front of 8,self', () => {
const startAnchor: IAnchor = { id: '2', type: 'after' };
const endAnchor: IAnchor = { id: '8', type: 'self' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0124567389');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
// -----------------------------------------------------
it('Move 0 in front of 8', () => {
const startAnchor: IAnchor = { id: '0', type: 'self' };
const endAnchor: IAnchor = { id: '8', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('1234567089');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('Move 0 in behind of 8', () => {
const startAnchor: IAnchor = { id: '1', type: 'before' };
const endAnchor: IAnchor = { id: '8', type: 'before' };
const redoList = transformSupportSymmetryAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId)!;
expect(!!redoList).toBeTruthy();
moveByAnchor(...redoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('1234567089');
const undoList = anchorUndoFactory(...redoList)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
// -----------------------------------------------------
it('use before and after', () => {
const startAnchor: IAnchor = { id: '1', type: 'before' };
const endAnchor: IAnchor = { id: '1', type: 'after' };
moveByAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('1023456789');
const undoList = anchorUndoFactory(startAnchor, endAnchor)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
it('use after and before', () => {
const startAnchor: IAnchor = { id: '1', type: 'after' };
const endAnchor: IAnchor = { id: '1', type: 'before' };
moveByAnchor(startAnchor, endAnchor, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0213456789');
const undoList = anchorUndoFactory(startAnchor, endAnchor)!;
expect(!!undoList).toBeTruthy();
moveByAnchor(...undoList, list, (rule) => rule.cfId);
expect(list.map((item) => item.cfId).join('')).toBe('0123456789');
});
});
});

View File

@@ -0,0 +1,143 @@
/**
* 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 interface IAnchor {
id: string;
type: 'before' | 'after' | 'self';
}
export const findIndexByAnchor = <T = unknown[]>(anchor: IAnchor, ruleList: T[], get: (v: T) => string): number | null => {
if (!ruleList) {
return null;
}
const anchorIndex = ruleList.findIndex((rule) => get(rule) === anchor.id);
if (anchorIndex < 0) {
return null;
}
switch (anchor.type) {
case 'after': {
return anchorIndex + 1;
}
case 'before': {
return anchorIndex - 1;
}
case 'self': {
return anchorIndex;
}
}
};
/**
* This function has side effects that modify the ruleList
* @param {IAnchor} start
* @param {IAnchor} end
* @param {ReturnType<ConditionalFormattingRuleModel['getSubunitRules']>} ruleList
* @return {*}
*/
export const moveByAnchor = <T = unknown[]>(start: IAnchor, end: IAnchor, ruleList: T[], get: (v: T) => string) => {
if (!ruleList) {
return null;
}
const startIndex = findIndexByAnchor(start, ruleList, get);
let endIndex = findIndexByAnchor(end, ruleList, get);
if (startIndex === null || endIndex === null || startIndex === endIndex) {
return;
}
const rule = ruleList.splice(startIndex, 1)[0];
if (startIndex < endIndex) {
endIndex = findIndexByAnchor(end, ruleList, get)!;
}
switch (end.type) {
case 'before': {
ruleList.splice(endIndex + 1, 0, rule);
break;
}
case 'self':
case 'after': {
ruleList.splice(endIndex, 0, rule);
break;
}
}
};
/**
* Only [after,after] and [after,before] can support symmetric operations
*/
export const transformSupportSymmetryAnchor = <T = unknown[]>(start: IAnchor, end: IAnchor, ruleList: T[], get: (v: T) => string): [IAnchor, IAnchor] | null => {
if (start.type === 'after' && ['after', 'before'].includes(end.type)) {
return [start, end];
}
const _start = { ...start };
const _end = { ...end };
if (_start.type !== 'after') {
const index = findIndexByAnchor(_start, ruleList, get);
if (index === null) {
return null;
}
if (index - 1 < 0) {
const nextItem = ruleList[index + 1];
if (!nextItem) {
return null;
} else {
_start.id = get(nextItem);
_start.type = 'before';
}
} else {
const id = get(ruleList[index - 1]);
_start.id = id;
_start.type = 'after';
}
}
if (!['after', 'before'].includes(_end.type)) {
const index = findIndexByAnchor(_end, ruleList, get);
if (index === null) {
return null;
}
if (index === 0) {
_end.type = 'before';
} else if (index - 1 >= 0) {
const id = get(ruleList[index - 1]);
_end.id = id;
_end.type = 'after';
} else if (index + 1 <= ruleList.length - 1) {
const id = get(ruleList[index + 1]);
_end.id = id;
_end.type = 'before';
} else {
return null;
}
}
if (_start.id === _end.id && _start.type === _end.type) {
return null;
}
return [_start, _end];
};
export const anchorUndoFactory = (start: IAnchor, end: IAnchor): [IAnchor, IAnchor] | null => {
if (['after', 'before'].includes(end.type)) {
if (start.type === 'after') {
return [end, start];
} else if (start.type === 'before') {
return [end, { ...start, type: 'self' }];
}
}
return null;
};
export const isAnchorEqual = (anchor1: IAnchor, anchor2: IAnchor) => {
return anchor1.id === anchor2.id && anchor1.type === anchor2.type;
};

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 { generateRandomId } from '@univerjs/core';
// Given that unit and sunUnit will change in the case of replica creation, the ID will not be spelled in here
export const createCfId = () => `${generateRandomId(8)}`;

View File

@@ -0,0 +1,34 @@
/**
* 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 function removeUndefinedAttr<T = unknown>(obj: T): T {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
const result: Record<string, any> = {};
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = removeUndefinedAttr(obj[key]);
if (value !== undefined) {
result[key] = value;
}
}
}
return result as T;
}

View File

@@ -0,0 +1,19 @@
/**
* 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 type MakePropertyOptional<T, K extends keyof T> = {
[P in keyof T]: P extends K ? T[P] | undefined : T[P];
};

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();