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,46 @@
# @univerjs/sheets-data-validation-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-data-validation-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-data-validation-ui)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-data-validation-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-data-validation-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-data-validation-ui?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-data-validation-ui)
`@univerjs/sheets-data-validation-ui` adds data validation menus, dialogs, dropdowns, and validation UI for Univer Sheets.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-data-validation-ui` | `UniverSheetsDataValidationUi` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/sheets-data-validation-ui
# or
npm install @univerjs/sheets-data-validation-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import '@univerjs/sheets-data-validation-ui/lib/index.css';
import EnUS from '@univerjs/sheets-data-validation-ui/locale/en-US';
import { UniverSheetsDataValidationUIPlugin } from '@univerjs/sheets-data-validation-ui';
univer.registerPlugin(UniverSheetsDataValidationUIPlugin);
// Merge EnUS into your Univer locale map when this package contributes UI text.
```
Exported plugin classes:
- `UniverSheetsDataValidationUIPlugin`
- `UniverSheetsDataValidationMobileUIPlugin`
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-data-validation-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,101 @@
{
"name": "@univerjs/sheets-data-validation-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Data validation UI 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",
"data-validation",
"validation",
"ui"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./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"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/data-validation": "workspace:*",
"@univerjs/design": "workspace:*",
"@univerjs/engine-formula": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/sheets": "workspace:*",
"@univerjs/sheets-data-validation": "workspace:*",
"@univerjs/sheets-formula-ui": "workspace:*",
"@univerjs/sheets-numfmt": "workspace:*",
"@univerjs/sheets-ui": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,107 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IWorkbookData, Workbook } from '@univerjs/core';
import type { ISheetClipboardHook } from '@univerjs/sheets-ui';
import {
Disposable,
ICommandService,
ILogService,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
toDisposable,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDataValidationPlugin } from '@univerjs/data-validation';
import { IAutoFillService, UniverSheetsPlugin } from '@univerjs/sheets';
import { SheetDataValidationModel, UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation';
import { ISheetClipboardService } from '@univerjs/sheets-ui';
import enUS from '@univerjs/sheets/locale/en-US';
const TEST_WORKBOOK_DATA: IWorkbookData = {
id: 'test',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet1', 'sheet2'],
styles: {},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 200_000,
columnCount: 2_000,
cellData: {},
},
sheet2: {
id: 'sheet2',
name: 'Sheet2',
rowCount: 100,
columnCount: 100,
cellData: {},
},
},
};
class TestSheetClipboardService extends Disposable {
private _hooks: ISheetClipboardHook[] = [];
addClipboardHook(hook: ISheetClipboardHook) {
this._hooks.push(hook);
return toDisposable(() => {
this._hooks = this._hooks.filter((item) => item !== hook);
});
}
getHooks() {
return this._hooks;
}
}
export function createDvUiTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
univer.registerPlugin(UniverDataValidationPlugin);
univer.registerPlugin(UniverSheetsPlugin, { notExecuteFormula: true });
univer.registerPlugin(UniverSheetsDataValidationPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, Tools.deepClone(TEST_WORKBOOK_DATA));
get(IUniverInstanceService).focusUnit(workbook.getUnitId());
get(ILogService).setLogLevel(LogLevel.SILENT);
get(LocaleService).load({ enUS });
get(LocaleService).setLocale(LocaleType.EN_US);
injector.add([ISheetClipboardService, { useClass: TestSheetClipboardService as never }]);
const clipboardService = get(ISheetClipboardService) as unknown as TestSheetClipboardService;
return {
univer,
injector,
workbook,
commandService: get(ICommandService),
dataValidationModel: get(SheetDataValidationModel),
autoFillService: get(IAutoFillService),
getClipboardHook: () => clipboardService.getHooks()[0],
unitId: 'test',
subUnitId: 'sheet1',
};
}

View File

@@ -0,0 +1,409 @@
/**
* 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,
ICommand,
ICommandInfo,
IDataValidationRule,
IDisposable,
IWorkbookData,
Workbook,
} from '@univerjs/core';
import type { ISidebarMethodOptions } from '@univerjs/ui';
import {
CommandType,
DataValidationOperator,
DataValidationRenderMode,
DataValidationType,
ICommandService,
Inject,
Injector,
IUniverInstanceService,
LocaleType,
Plugin,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DataValidationModel } from '@univerjs/data-validation';
import { serializeListOptions, SheetsSelectionsService } from '@univerjs/sheets';
import { AddSheetDataValidationCommand, ClearRangeDataValidationCommand } from '@univerjs/sheets-data-validation';
import { ISidebarService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { DataValidationPanelService } from '../../../services/data-validation-panel.service';
import { DATA_VALIDATION_PANEL, OpenValidationPanelOperation } from '../../operations/data-validation.operation';
import {
AddSheetDataValidationAndOpenCommand,
ClearQuickSheetDataValidationCommand,
InsertQuickSheetDataValidationCommand,
} from '../data-validation-ui.command';
const SELECTION_RANGE = {
startRow: 1,
endRow: 2,
startColumn: 3,
endColumn: 4,
};
const CHECKBOX_RULE = {
type: DataValidationType.CHECKBOX,
operator: undefined,
formula1: undefined,
formula2: undefined,
};
function createDropdownRule(formula1: string) {
return {
type: DataValidationType.LIST,
operator: undefined,
formula1,
formula2: '',
renderMode: DataValidationRenderMode.CUSTOM,
};
}
interface ITestBed {
univer: Univer;
get: Injector['get'];
workbook?: Workbook;
executedCommands: ICommandInfo[];
}
class TestDataValidationModel {
rejectAdd = false;
readonly rules = new Map<string, IDataValidationRule>();
addRule(unitId: string, subUnitId: string, rule: IDataValidationRule): void {
this.rules.set(`${unitId}:${subUnitId}:${rule.uid}`, rule);
}
getRuleById(unitId: string, subUnitId: string, ruleId: string): IDataValidationRule | undefined {
return this.rules.get(`${unitId}:${subUnitId}:${ruleId}`);
}
}
class TestSidebarService {
readonly sidebarOptions$ = new BehaviorSubject<ISidebarMethodOptions>({ visible: true });
readonly openedPanels: ISidebarMethodOptions[] = [];
disposeCount = 0;
open(options: ISidebarMethodOptions): IDisposable {
this.openedPanels.push(options);
return toDisposable(() => {
this.disposeCount++;
});
}
}
function createWorkbookData(): IWorkbookData {
return {
id: 'book-1',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet-1'],
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {},
},
},
styles: {},
};
}
function registerDependencies(injector: Injector): void {
const dependencies: Dependency[] = [
[SheetsSelectionsService],
[DataValidationModel, { useClass: TestDataValidationModel }],
[ISidebarService, { useClass: TestSidebarService }],
[DataValidationPanelService],
];
dependencies.forEach((dependency) => injector.add(dependency));
}
function createTestBed(createWorkbook = true): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
const executedCommands: ICommandInfo[] = [];
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 {
registerDependencies(this._injector);
}
}
univer.registerPlugin(TestPlugin);
if (!createWorkbook) {
registerDependencies(injector);
}
const commandService = injector.get(ICommandService);
commandService.registerCommand(AddSheetDataValidationAndOpenCommand);
commandService.registerCommand(InsertQuickSheetDataValidationCommand);
commandService.registerCommand(ClearQuickSheetDataValidationCommand);
commandService.registerCommand(OpenValidationPanelOperation);
commandService.registerCommand({
id: AddSheetDataValidationCommand.id,
type: CommandType.COMMAND,
handler: (accessor, params) => {
const model = accessor.get(DataValidationModel) as unknown as TestDataValidationModel;
executedCommands.push({ id: AddSheetDataValidationCommand.id, params });
const addParams = params as { unitId: string; subUnitId: string; rule: IDataValidationRule } | undefined;
if (model.rejectAdd || !addParams) {
return false;
}
model.addRule(addParams.unitId, addParams.subUnitId, addParams.rule);
return true;
},
} as ICommand);
commandService.registerCommand({
id: ClearRangeDataValidationCommand.id,
type: CommandType.COMMAND,
handler: (_accessor, params) => {
executedCommands.push({ id: ClearRangeDataValidationCommand.id, params });
return true;
},
} as ICommand);
const workbook = createWorkbook
? univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData())
: undefined;
if (workbook) {
injector.get(IUniverInstanceService).focusUnit(workbook.getUnitId());
injector.get(SheetsSelectionsService).setSelections(workbook.getUnitId(), 'sheet-1', [{
range: SELECTION_RANGE,
primary: null,
}] as never);
}
return {
univer,
get: injector.get.bind(injector),
workbook,
executedCommands,
};
}
describe('AddSheetDataValidationAndOpenCommand', () => {
let testBed: ITestBed | undefined;
afterEach(() => {
testBed?.univer.dispose();
testBed = undefined;
});
it('adds a default rule to the active sheet and opens the panel for that rule', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const panelService = testBed.get(DataValidationPanelService);
const sidebarService = testBed.get(ISidebarService) as unknown as TestSidebarService;
expect(commandService.syncExecuteCommand(AddSheetDataValidationAndOpenCommand.id)).toBe(true);
const addParams = testBed.executedCommands[0].params as { unitId: string; subUnitId: string; rule: IDataValidationRule };
expect(addParams.unitId).toBe('book-1');
expect(addParams.subUnitId).toBe('sheet-1');
expect(addParams.rule).toMatchObject({
type: DataValidationType.DECIMAL,
operator: DataValidationOperator.EQUAL,
formula1: '100',
});
expect(panelService.activeRule).toEqual({
unitId: 'book-1',
subUnitId: 'sheet-1',
rule: addParams.rule,
});
expect(panelService.isOpen).toBe(true);
expect(sidebarService.openedPanels.at(-1)).toMatchObject({
id: DATA_VALIDATION_PANEL,
header: { title: 'sheets-data-validation-ui.panel.addTitle' },
width: 312,
});
});
it('inserts the supplied checkbox rule into the current selection', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
expect(commandService.syncExecuteCommand(InsertQuickSheetDataValidationCommand.id, { rule: CHECKBOX_RULE })).toBe(true);
const addParams = testBed.executedCommands[0].params as { rule: IDataValidationRule };
expect(addParams.rule).toMatchObject({
type: DataValidationType.CHECKBOX,
ranges: [SELECTION_RANGE],
});
expect(addParams.rule.operator).toBeUndefined();
expect(addParams.rule.formula1).toBeUndefined();
expect(addParams.rule.formula2).toBeUndefined();
});
it('inserts a custom dropdown rule into the current selection', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const formula1 = serializeListOptions(['Yes', 'No']);
expect(commandService.syncExecuteCommand(InsertQuickSheetDataValidationCommand.id, { rule: createDropdownRule(formula1) })).toBe(true);
const addParams = testBed.executedCommands[0].params as { rule: IDataValidationRule };
expect(addParams.rule).toMatchObject({
type: DataValidationType.LIST,
formula1,
formula2: '',
renderMode: DataValidationRenderMode.CUSTOM,
ranges: [SELECTION_RANGE],
});
expect(addParams.rule.operator).toBeUndefined();
});
it('inserts a date-time picker rule into the current selection', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
expect(commandService.syncExecuteCommand(InsertQuickSheetDataValidationCommand.id, {
rule: {
type: DataValidationType.DATE,
operator: undefined,
formula1: undefined,
formula2: undefined,
bizInfo: { showTime: true },
},
})).toBe(true);
const addParams = testBed.executedCommands[0].params as { rule: IDataValidationRule };
expect(addParams.rule).toMatchObject({
type: DataValidationType.DATE,
ranges: [SELECTION_RANGE],
bizInfo: { showTime: true },
});
});
it('adds a dropdown rule and opens the existing panel to edit it', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const panelService = testBed.get(DataValidationPanelService);
const formula1 = serializeListOptions(['Option 1', 'Option 2']);
expect(commandService.syncExecuteCommand(AddSheetDataValidationAndOpenCommand.id, { rule: createDropdownRule(formula1) })).toBe(true);
const addParams = testBed.executedCommands[0].params as { rule: IDataValidationRule };
expect(addParams.rule).toMatchObject({
type: DataValidationType.LIST,
formula1,
formula2: '',
renderMode: DataValidationRenderMode.CUSTOM,
ranges: [SELECTION_RANGE],
});
expect(panelService.activeRule?.rule.uid).toBe(addParams.rule.uid);
expect(panelService.isOpen).toBe(true);
});
it('clears only dropdown validation types from the current selection', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
expect(commandService.syncExecuteCommand(ClearQuickSheetDataValidationCommand.id, {
types: [DataValidationType.LIST, DataValidationType.LIST_MULTIPLE],
})).toBe(true);
expect(testBed.executedCommands).toEqual([{
id: ClearRangeDataValidationCommand.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
ranges: [SELECTION_RANGE],
types: [DataValidationType.LIST, DataValidationType.LIST_MULTIPLE],
},
}]);
});
it('clears only checkbox validation from the current selection', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
expect(commandService.syncExecuteCommand(ClearQuickSheetDataValidationCommand.id, {
types: [DataValidationType.CHECKBOX],
})).toBe(true);
expect(testBed.executedCommands[0]).toEqual({
id: ClearRangeDataValidationCommand.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
ranges: [SELECTION_RANGE],
types: [DataValidationType.CHECKBOX],
},
});
});
it('does not insert, edit, or clear without a current selection', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
testBed.get(SheetsSelectionsService).clearCurrentSelections();
expect(commandService.syncExecuteCommand(InsertQuickSheetDataValidationCommand.id, { rule: CHECKBOX_RULE })).toBe(false);
expect(commandService.syncExecuteCommand(AddSheetDataValidationAndOpenCommand.id, {
rule: createDropdownRule(serializeListOptions(['Option 1', 'Option 2'])),
})).toBe(false);
expect(commandService.syncExecuteCommand(ClearQuickSheetDataValidationCommand.id, {
types: [DataValidationType.LIST, DataValidationType.LIST_MULTIPLE],
})).toBe(false);
expect(testBed.executedCommands).toEqual([]);
});
it('does not add or open when there is no active sheet target', () => {
testBed = createTestBed(false);
const commandService = testBed.get(ICommandService);
const panelService = testBed.get(DataValidationPanelService);
const sidebarService = testBed.get(ISidebarService) as unknown as TestSidebarService;
expect(commandService.syncExecuteCommand(AddSheetDataValidationAndOpenCommand.id)).toBe(false);
expect(testBed.executedCommands).toEqual([]);
expect(panelService.isOpen).toBe(false);
expect(sidebarService.openedPanels).toEqual([]);
});
it('does not open the panel when the rule add command rejects the new rule', () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const panelService = testBed.get(DataValidationPanelService);
const model = testBed.get(DataValidationModel) as unknown as TestDataValidationModel;
model.rejectAdd = true;
expect(commandService.syncExecuteCommand(AddSheetDataValidationAndOpenCommand.id)).toBe(false);
expect(testBed.executedCommands).toHaveLength(1);
expect(panelService.isOpen).toBe(false);
expect(panelService.activeRule).toBeUndefined();
});
});

View File

@@ -0,0 +1,127 @@
/**
* 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 { DataValidationType, IAccessor, ICommand, ISheetDataValidationRule } from '@univerjs/core';
import type { IAddSheetDataValidationCommandParams } from '@univerjs/sheets-data-validation';
import {
CommandType,
ICommandService,
IUniverInstanceService,
} from '@univerjs/core';
import { getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
import {
AddSheetDataValidationCommand,
ClearRangeDataValidationCommand,
createDefaultNewRule,
} from '@univerjs/sheets-data-validation';
import { OpenValidationPanelOperation } from '../operations/data-validation.operation';
export type IQuickDataValidationRuleInput = Pick<ISheetDataValidationRule, 'type'> & Partial<Omit<ISheetDataValidationRule, 'uid' | 'ranges' | 'type'>>;
interface IInsertQuickSheetDataValidationCommandParams {
rule: IQuickDataValidationRuleInput;
}
interface IClearQuickSheetDataValidationCommandParams {
types: DataValidationType[];
}
interface IAddSheetDataValidationAndOpenCommandParams {
rule?: IQuickDataValidationRuleInput;
}
function createRule(accessor: IAccessor, rule?: IQuickDataValidationRuleInput): ISheetDataValidationRule {
const defaultRule = createDefaultNewRule(accessor);
return {
...defaultRule,
...rule,
uid: defaultRule.uid,
ranges: defaultRule.ranges,
};
}
export const InsertQuickSheetDataValidationCommand: ICommand<IInsertQuickSheetDataValidationCommandParams> = {
type: CommandType.COMMAND,
id: 'data-validation.command.insert-quick-rule',
handler(accessor, params) {
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
const ranges = accessor.get(SheetsSelectionsService).getCurrentSelections().map((selection) => selection.range);
if (!target || !ranges.length || !params?.rule) return false;
const rule = createRule(accessor, params.rule);
return accessor.get(ICommandService).syncExecuteCommand(AddSheetDataValidationCommand.id, {
unitId: target.workbook.getUnitId(),
subUnitId: target.worksheet.getSheetId(),
rule,
});
},
};
export const ClearQuickSheetDataValidationCommand: ICommand<IClearQuickSheetDataValidationCommandParams> = {
type: CommandType.COMMAND,
id: 'data-validation.command.clear-quick-rule',
handler(accessor, params) {
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService));
const ranges = accessor.get(SheetsSelectionsService).getCurrentSelections().map((selection) => selection.range);
if (!target || !ranges.length || !params?.types?.length) return false;
return accessor.get(ICommandService).syncExecuteCommand(ClearRangeDataValidationCommand.id, {
unitId: target.workbook.getUnitId(),
subUnitId: target.worksheet.getSheetId(),
ranges,
types: params.types,
});
},
};
export const AddSheetDataValidationAndOpenCommand: ICommand<IAddSheetDataValidationAndOpenCommandParams> = {
type: CommandType.COMMAND,
id: 'data-validation.command.addRuleAndOpen',
handler(accessor, params) {
const univerInstanceService = accessor.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { workbook, worksheet } = target;
const ranges = accessor.get(SheetsSelectionsService).getCurrentSelections();
if (!ranges.length) return false;
const rule = createRule(accessor, params?.rule);
const commandService = accessor.get(ICommandService);
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
const addParams: IAddSheetDataValidationCommandParams = {
rule,
unitId,
subUnitId,
};
const res = commandService.syncExecuteCommand(AddSheetDataValidationCommand.id, addParams);
if (res) {
commandService.syncExecuteCommand(OpenValidationPanelOperation.id, {
ruleId: rule.uid,
isAdd: true,
});
return true;
}
return false;
},
};

View File

@@ -0,0 +1,279 @@
/**
* 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, IDataValidationRule, IDisposable, IWorkbookData, Workbook } from '@univerjs/core';
import type { ISidebarMethodOptions } from '@univerjs/ui';
import type { IDataValidationDropdownParam } from '../../../services/dropdown-manager.service';
import {
DataValidationOperator,
DataValidationType,
ICommandService,
Inject,
Injector,
IUniverInstanceService,
LocaleType,
Plugin,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DataValidationModel } from '@univerjs/data-validation';
import { ISidebarService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { DataValidationPanelService } from '../../../services/data-validation-panel.service';
import { DataValidationDropdownManagerService } from '../../../services/dropdown-manager.service';
import {
CloseValidationPanelOperation,
DATA_VALIDATION_PANEL,
HideDataValidationDropdown,
OpenValidationPanelOperation,
ShowDataValidationDropdown,
ToggleValidationPanelOperation,
} from '../data-validation.operation';
interface ITestBed {
univer: Univer;
get: Injector['get'];
workbook?: Workbook;
}
const RULE: IDataValidationRule = {
uid: 'rule-1',
type: DataValidationType.DECIMAL,
operator: DataValidationOperator.EQUAL,
formula1: '100',
ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }],
};
class TestDataValidationModel {
getRuleById(unitId: string, subUnitId: string, ruleId: string): IDataValidationRule | undefined {
return unitId === 'book-1' && subUnitId === 'sheet-1' && ruleId === RULE.uid ? RULE : undefined;
}
}
class TestSidebarService {
readonly sidebarOptions$ = new BehaviorSubject<ISidebarMethodOptions>({ visible: true });
readonly openedPanels: ISidebarMethodOptions[] = [];
disposeCount = 0;
open(options: ISidebarMethodOptions): IDisposable {
this.openedPanels.push(options);
return toDisposable(() => {
this.disposeCount++;
});
}
}
class TestDropdownManagerService {
activeDropdown: IDataValidationDropdownParam | null = null;
readonly shownLocations: Array<{ unitId: string; subUnitId: string; row: number; column: number }> = [];
hideCount = 0;
showDataValidationDropdown(unitId: string, subUnitId: string, row: number, column: number): void {
this.shownLocations.push({ unitId, subUnitId, row, column });
this.activeDropdown = {
location: {
unitId,
subUnitId,
row,
col: column,
},
} as IDataValidationDropdownParam;
}
hideDropdown(): void {
this.hideCount++;
this.activeDropdown = null;
}
}
function createWorkbookData(): IWorkbookData {
return {
id: 'book-1',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'test',
sheetOrder: ['sheet-1'],
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet1',
rowCount: 20,
columnCount: 20,
cellData: {},
},
},
styles: {},
};
}
function registerDependencies(injector: Injector): void {
const dependencies: Dependency[] = [
[DataValidationModel, { useClass: TestDataValidationModel }],
[ISidebarService, { useClass: TestSidebarService }],
[DataValidationPanelService],
[DataValidationDropdownManagerService, { useClass: TestDropdownManagerService }],
];
dependencies.forEach((dependency) => injector.add(dependency));
}
function createTestBed(createWorkbook = true): 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 {
registerDependencies(this._injector);
}
}
univer.registerPlugin(TestPlugin);
if (!createWorkbook) {
registerDependencies(injector);
}
const commandService = injector.get(ICommandService);
[
OpenValidationPanelOperation,
CloseValidationPanelOperation,
ToggleValidationPanelOperation,
ShowDataValidationDropdown,
HideDataValidationDropdown,
].forEach((command) => commandService.registerCommand(command));
const workbook = createWorkbook
? univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData())
: undefined;
if (workbook) {
injector.get(IUniverInstanceService).focusUnit(workbook.getUnitId());
}
return {
univer,
get: injector.get.bind(injector),
workbook,
};
}
describe('data validation operations', () => {
let testBed: ITestBed | undefined;
afterEach(() => {
testBed?.univer.dispose();
testBed = undefined;
});
it('opens the panel for an active sheet rule and closes it from the sidebar callback', async () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const panelService = testBed.get(DataValidationPanelService);
const sidebarService = testBed.get(ISidebarService) as unknown as TestSidebarService;
await expect(commandService.executeCommand(OpenValidationPanelOperation.id, { ruleId: RULE.uid, isAdd: true })).resolves.toBe(true);
expect(panelService.isOpen).toBe(true);
expect(panelService.activeRule).toEqual({
unitId: 'book-1',
subUnitId: 'sheet-1',
rule: RULE,
});
expect(sidebarService.openedPanels.at(-1)).toMatchObject({
id: DATA_VALIDATION_PANEL,
header: { title: 'sheets-data-validation-ui.panel.addTitle' },
children: { label: DATA_VALIDATION_PANEL },
width: 312,
});
sidebarService.openedPanels.at(-1)!.onClose?.();
expect(panelService.isOpen).toBe(false);
});
it('opens the rule list with empty params and returns false when no sheet is active', async () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const panelService = testBed.get(DataValidationPanelService);
await expect(commandService.executeCommand(OpenValidationPanelOperation.id)).resolves.toBe(false);
expect(panelService.isOpen).toBe(false);
await expect(commandService.executeCommand(OpenValidationPanelOperation.id, {})).resolves.toBe(true);
expect(panelService.isOpen).toBe(true);
expect(panelService.activeRule).toBeUndefined();
testBed.univer.dispose();
testBed = createTestBed(false);
await expect(testBed.get(ICommandService).executeCommand(OpenValidationPanelOperation.id, {})).resolves.toBe(false);
});
it('toggles an opened validation panel closed through the command service', async () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const panelService = testBed.get(DataValidationPanelService);
const sidebarService = testBed.get(ISidebarService) as unknown as TestSidebarService;
await expect(commandService.executeCommand(OpenValidationPanelOperation.id, {})).resolves.toBe(true);
expect(panelService.isOpen).toBe(true);
expect(sidebarService.openedPanels).toHaveLength(1);
await expect(commandService.executeCommand(ToggleValidationPanelOperation.id)).resolves.toBe(true);
expect(panelService.isOpen).toBe(false);
expect(sidebarService.disposeCount).toBe(1);
});
it('does not recreate dropdowns for the same cell and hides an active dropdown on demand', async () => {
testBed = createTestBed();
const commandService = testBed.get(ICommandService);
const dropdownService = testBed.get(DataValidationDropdownManagerService) as unknown as TestDropdownManagerService;
await expect(commandService.executeCommand(ShowDataValidationDropdown.id)).resolves.toBe(false);
await expect(commandService.executeCommand(HideDataValidationDropdown.id)).resolves.toBe(false);
await expect(commandService.executeCommand(ShowDataValidationDropdown.id, {
unitId: 'book-1',
subUnitId: 'sheet-1',
row: 1,
column: 2,
})).resolves.toBe(true);
await expect(commandService.executeCommand(ShowDataValidationDropdown.id, {
unitId: 'book-1',
subUnitId: 'sheet-1',
row: 1,
column: 2,
})).resolves.toBe(true);
expect(dropdownService.shownLocations).toEqual([
{ unitId: 'book-1', subUnitId: 'sheet-1', row: 1, column: 2 },
]);
await expect(commandService.executeCommand(HideDataValidationDropdown.id, { reason: 'selection-change' })).resolves.toBe(true);
expect(dropdownService.hideCount).toBe(1);
expect(dropdownService.activeDropdown).toBeNull();
});
});

View File

@@ -0,0 +1,147 @@
/**
* 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 { CommandType, ICommandService, IUniverInstanceService } from '@univerjs/core';
import { DataValidationModel } from '@univerjs/data-validation';
import { getSheetCommandTarget } from '@univerjs/sheets';
import { ISidebarService } from '@univerjs/ui';
import { DataValidationPanelService } from '../../services/data-validation-panel.service';
import { DataValidationDropdownManagerService } from '../../services/dropdown-manager.service';
export const DATA_VALIDATION_PANEL = 'DataValidationPanel';
export interface IOpenValidationPanelOperationParams {
ruleId?: string;
isAdd?: boolean;
}
export const OpenValidationPanelOperation: ICommand<IOpenValidationPanelOperationParams> = {
id: 'data-validation.operation.open-validation-panel',
type: CommandType.OPERATION,
handler(accessor, params) {
if (!params) {
return false;
}
const { ruleId, isAdd } = params;
const dataValidationPanelService = accessor.get(DataValidationPanelService);
const dataValidationModel = accessor.get(DataValidationModel);
const univerInstanceService = accessor.get(IUniverInstanceService);
const sidebarService = accessor.get(ISidebarService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return false;
const { unitId, subUnitId } = target;
const rule = ruleId ? dataValidationModel.getRuleById(unitId, subUnitId, ruleId) : undefined;
dataValidationPanelService.open();
dataValidationPanelService.setActiveRule(rule && {
unitId,
subUnitId,
rule,
});
const disposable = sidebarService.open({
id: DATA_VALIDATION_PANEL,
header: { title: isAdd ? 'sheets-data-validation-ui.panel.addTitle' : 'sheets-data-validation-ui.panel.title' },
children: { label: DATA_VALIDATION_PANEL },
width: 312,
onClose: () => dataValidationPanelService.close(),
});
dataValidationPanelService.setCloseDisposable(disposable);
return true;
},
};
export const CloseValidationPanelOperation: ICommand = {
id: 'data-validation.operation.close-validation-panel',
type: CommandType.OPERATION,
handler(accessor) {
const dataValidationPanelService = accessor.get(DataValidationPanelService);
dataValidationPanelService.close();
return true;
},
};
export const ToggleValidationPanelOperation: ICommand = {
id: 'data-validation.operation.toggle-validation-panel',
type: CommandType.OPERATION,
handler(accessor) {
const commandService = accessor.get(ICommandService);
const dataValidationPanelService = accessor.get(DataValidationPanelService);
dataValidationPanelService.open();
const isOpen = dataValidationPanelService.isOpen;
if (isOpen) {
commandService.executeCommand(CloseValidationPanelOperation.id);
} else {
commandService.executeCommand(OpenValidationPanelOperation.id);
}
return true;
},
};
export interface IShowDataValidationDropdownParams {
unitId: string;
subUnitId: string;
row: number;
column: number;
}
export const ShowDataValidationDropdown: ICommand<IShowDataValidationDropdownParams> = {
type: CommandType.OPERATION,
id: 'sheet.operation.show-data-validation-dropdown',
handler(accessor, params) {
if (!params) {
return false;
}
const dataValidationDropdownManagerService = accessor.get(DataValidationDropdownManagerService);
const { unitId, subUnitId, row, column } = params;
const activeDropdown = dataValidationDropdownManagerService.activeDropdown;
const currLoc = activeDropdown?.location;
if (
currLoc &&
currLoc.unitId === unitId &&
currLoc.subUnitId === subUnitId &&
currLoc.row === row &&
currLoc.col === column
) {
return true;
}
dataValidationDropdownManagerService.showDataValidationDropdown(
unitId,
subUnitId,
row,
column
);
return true;
},
};
export const HideDataValidationDropdown: ICommand = {
type: CommandType.OPERATION,
id: 'sheet.operation.hide-data-validation-dropdown',
handler(accessor, params) {
if (!params) {
return false;
}
const dataValidationDropdownManagerService = accessor.get(DataValidationDropdownManagerService);
dataValidationDropdownManagerService.hideDropdown();
return true;
},
};

View File

@@ -0,0 +1,39 @@
/**
* 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 { MenuConfig } from '@univerjs/ui';
export const SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY = 'sheets-data-validation-ui.config';
export const configSymbol = Symbol(SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsDataValidationUIConfig {
menu?: MenuConfig;
/**
* Whether to show the edit button in the dropdown list of data validation
* @default true
*/
showEditOnDropdown?: boolean;
/**
* Whether to show the search box in the dropdown list of data validation
* @default true
*/
showSearchOnDropdown?: boolean;
}
export const defaultPluginConfig: IUniverSheetsDataValidationUIConfig = {};

View File

@@ -0,0 +1,18 @@
/**
* 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 DROP_DOWN_DEFAULT_COLOR = 'gray.100';
export const DROP_DOWN_ICON_COLOR = 'gray.600';

View File

@@ -0,0 +1,106 @@
/**
* 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 { IHoverCellPosition } from '@univerjs/sheets-ui';
import type { Subject } from 'rxjs';
import {
DataValidationOperator,
DataValidationStatus,
DataValidationType,
IUniverInstanceService,
LocaleService,
} from '@univerjs/core';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { IRefSelectionsService, RefSelectionsService, SetRangeValuesMutation } from '@univerjs/sheets';
import { AddSheetDataValidationCommand } from '@univerjs/sheets-data-validation';
import {
CellAlertManagerService,
CellPopupManagerService,
HoverManagerService,
SheetCanvasPopManagerService,
} from '@univerjs/sheets-ui';
import { CanvasPopupService, ICanvasPopupService } from '@univerjs/ui';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createDvUiTestBed } from '../../__tests__/create-dv-ui-test-bed';
import enUS from '../../locale/en-US';
import { DataValidationAlertController } from '../dv-alert.controller';
afterEach(() => {
vi.useRealTimers();
});
describe('DataValidationAlertController', () => {
it.each(['active', 'inactive', 'disposed', 'switched-after-open'] as const)('handles delayed validation hover for a %s worksheet', async (state) => {
vi.useFakeTimers();
const { univer, injector, workbook, commandService, dataValidationModel } = createDvUiTestBed();
try {
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([IRefSelectionsService, { useClass: RefSelectionsService }]);
injector.add([SheetCanvasPopManagerService]);
injector.add([CellPopupManagerService]);
injector.add([CellAlertManagerService]);
injector.add([HoverManagerService]);
injector.add([DataValidationAlertController]);
injector.get(LocaleService).load({ enUS });
injector.get(DataValidationAlertController);
const rule = {
uid: 'delayed-alert-rule',
type: DataValidationType.DECIMAL,
operator: DataValidationOperator.GREATER_THAN,
formula1: '111',
ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }],
};
commandService.syncExecuteCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: { 0: { 0: { v: 1 } } },
});
commandService.syncExecuteCommand(AddSheetDataValidationCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
rule,
});
const location = { unitId: 'test', subUnitId: 'sheet1', row: 0, col: 0 };
const validation = { ...location, workbook, worksheet: workbook.getActiveSheet() };
dataValidationModel.validator(rule, validation);
await vi.advanceTimersByTimeAsync(0);
expect(dataValidationModel.validator(rule, validation)).toBe(DataValidationStatus.INVALID);
const hover = injector.get(HoverManagerService);
const input = hover as unknown as { _currentCell$: Subject<IHoverCellPosition> };
input._currentCell$.next({ location } as IHoverCellPosition);
if (state === 'inactive') {
workbook.setActiveSheet(workbook.getSheetBySheetId('sheet2')!);
} else if (state === 'disposed') {
injector.get(IUniverInstanceService).disposeUnit(workbook.getUnitId());
}
await vi.advanceTimersByTimeAsync(120);
const alerts = injector.get(CellAlertManagerService);
expect(alerts.currentAlert.size).toBe(state === 'active' || state === 'switched-after-open' ? 1 : 0);
if (state === 'switched-after-open') {
workbook.setActiveSheet(workbook.getSheetBySheetId('sheet2')!);
expect(alerts.currentAlert.size).toBe(0);
}
if (state === 'active') {
hover.triggerScroll();
await vi.advanceTimersByTimeAsync(120);
expect(alerts.currentAlert.size).toBe(0);
}
} finally {
univer.dispose();
}
});
});

View File

@@ -0,0 +1,135 @@
/**
* 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 { ISheetAutoFillHook } from '@univerjs/sheets';
import { DataValidationType, Direction, Range } from '@univerjs/core';
import { AddDataValidationMutation } from '@univerjs/data-validation';
import { AUTO_FILL_APPLY_TYPE } from '@univerjs/sheets';
import { DATA_VALIDATION_PLUGIN_NAME } from '@univerjs/sheets-data-validation';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createDvUiTestBed } from '../../__tests__/create-dv-ui-test-bed';
import { DataValidationAutoFillController } from '../dv-auto-fill.controller';
describe('DataValidationAutoFillController', () => {
let testBed: ReturnType<typeof createDvUiTestBed>;
let hook: ISheetAutoFillHook;
beforeEach(() => {
testBed = createDvUiTestBed();
testBed.injector.add([DataValidationAutoFillController]);
testBed.injector.get(DataValidationAutoFillController);
const registeredHook = testBed.autoFillService.getAllHooks().find((item) => item.id === DATA_VALIDATION_PLUGIN_NAME);
if (!registeredHook) {
throw new Error('Data validation autofill hook was not registered');
}
hook = registeredHook;
});
afterEach(() => {
vi.restoreAllMocks();
testBed.univer.dispose();
});
it('disables series fill for checkbox rules without querying each source cell', async () => {
await testBed.commandService.executeCommand(AddDataValidationMutation.id, {
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
rule: {
uid: 'checkbox-rule',
type: DataValidationType.CHECKBOX,
ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }],
},
});
vi.spyOn(testBed.dataValidationModel, 'getRuleByLocation').mockImplementation(() => {
throw new Error('must not query validation by cell');
});
hook.onBeforeFillData?.({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
source: {
rows: Array.from({ length: 100_000 }, (_, index) => index),
cols: Array.from({ length: 1_000 }, (_, index) => index),
},
target: { rows: [100_000], cols: [0] },
}, Direction.DOWN);
expect(testBed.autoFillService.menu.find((item) => item.value === AUTO_FILL_APPLY_TYPE.SERIES)?.disable).toBe(true);
});
it('ignores checkbox rules that only cover filtered-out source rows', async () => {
await testBed.commandService.executeCommand(AddDataValidationMutation.id, {
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
rule: {
uid: 'filtered-checkbox-rule',
type: DataValidationType.CHECKBOX,
ranges: [{ startRow: 1, endRow: 1, startColumn: 0, endColumn: 0 }],
},
});
vi.spyOn(testBed.dataValidationModel, 'getRuleByLocation').mockImplementation(() => {
throw new Error('must not query validation by cell');
});
hook.onBeforeFillData?.({
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
source: { rows: [0, 2], cols: [0] },
target: { rows: [3], cols: [0] },
}, Direction.DOWN);
expect(testBed.autoFillService.menu.find((item) => item.value === AUTO_FILL_APPLY_TYPE.SERIES)?.disable).toBe(false);
});
it('executes and undoes real validation mutations without enumerating autofill cells', async () => {
await testBed.commandService.executeCommand(AddDataValidationMutation.id, {
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
rule: {
uid: 'decimal-rule',
type: DataValidationType.DECIMAL,
formula1: '1',
ranges: [{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }],
},
});
const foreach = vi.spyOn(Range, 'foreach').mockImplementation(() => {
throw new Error('must not enumerate cells');
});
const location = {
unitId: testBed.unitId,
subUnitId: testBed.subUnitId,
source: { rows: [0], cols: [0] },
target: { rows: [1], cols: [0] },
};
const result = hook.onFillData?.(location, Direction.DOWN, AUTO_FILL_APPLY_TYPE.COPY);
if (!result) {
throw new Error('Data validation autofill did not return mutations');
}
foreach.mockRestore();
for (const mutation of result.redos) {
await testBed.commandService.executeCommand(mutation.id, mutation.params);
}
expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 0, 0)?.uid).toBe('decimal-rule');
expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 1, 0)?.uid).toBe('decimal-rule');
for (const mutation of result.undos) {
await testBed.commandService.executeCommand(mutation.id, mutation.params);
}
expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 0, 0)?.uid).toBe('decimal-rule');
expect(testBed.dataValidationModel.getRuleByLocation(testBed.unitId, testBed.subUnitId, 1, 0)).toBeUndefined();
});
});

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 { AddSheetDataValidationCommand, UpdateSheetDataValidationRangeCommand } from '@univerjs/sheets-data-validation';
import { describe, expect, it, vi } from 'vitest';
import { DataValidationPermissionController } from '../dv-permission.controller';
describe('DataValidationPermissionController', () => {
it('blocks add/update commands when range permissions fail', () => {
let beforeCommandExecutedHandler: ((command: { id: string; params: unknown }) => void) | undefined;
const permissionCheckWithRanges = vi.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(false);
const blockExecuteWithoutPermission = vi.fn();
const controller = new DataValidationPermissionController(
{ t: (key: string) => key } as never,
{
beforeCommandExecuted: vi.fn((handler) => {
beforeCommandExecutedHandler = handler;
return { dispose: vi.fn() };
}),
} as never,
{ permissionCheckWithRanges, blockExecuteWithoutPermission } as never
);
expect(controller).toBeTruthy();
beforeCommandExecutedHandler!({
id: AddSheetDataValidationCommand.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
rule: { ranges: [{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }] },
},
});
beforeCommandExecutedHandler!({
id: UpdateSheetDataValidationRangeCommand.id,
params: {
unitId: 'book-1',
subUnitId: 'sheet-1',
ranges: [{ startRow: 2, endRow: 3, startColumn: 0, endColumn: 1 }],
},
});
expect(permissionCheckWithRanges).toHaveBeenCalledTimes(2);
expect(blockExecuteWithoutPermission).toHaveBeenCalledTimes(2);
expect(blockExecuteWithoutPermission).toHaveBeenCalledWith('sheets-data-validation-ui.permission.dialog.setStyleErr');
});
});

View File

@@ -0,0 +1,93 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleService } from '@univerjs/core';
import type { DataValidatorRegistryService } from '@univerjs/data-validation';
import type { SheetInterceptorService } from '@univerjs/sheets';
import type { SheetDataValidationModel, SheetsDataValidationValidatorService } from '@univerjs/sheets-data-validation';
import type { IDialogService } from '@univerjs/ui';
import { DataValidationErrorStyle, DataValidationStatus } from '@univerjs/core';
import { VALIDATE_CELL } from '@univerjs/sheets';
import { describe, expect, it, vi } from 'vitest';
import { DataValidationRejectInputController } from '../dv-reject-input.controller';
interface IInterceptContext {
row: number;
col: number;
unitId: string;
subUnitId: string;
}
describe('DataValidationRejectInputController', () => {
it('intercepts invalid STOP rules and opens a reject dialog, while allowing valid cases through', async () => {
let interceptedHandler:
| ((lastResult: Promise<boolean>, context: IInterceptContext, next: (value: Promise<boolean>) => Promise<boolean>) => Promise<boolean>)
| undefined;
const open = vi.fn();
const close = vi.fn();
const getRuleById = vi.fn(() => ({ type: 'list', errorStyle: DataValidationErrorStyle.STOP }));
const model = {
getRuleIdByLocation: vi.fn(() => 'rule-1'),
getRuleById,
};
const validator = { getRuleFinalError: vi.fn(() => 'Invalid value') };
const controller = new DataValidationRejectInputController(
{
writeCellInterceptor: {
intercept: vi.fn((name, config) => {
expect(name).toBe(VALIDATE_CELL);
interceptedHandler = config.handler;
return { dispose: vi.fn() };
}),
},
} as unknown as SheetInterceptorService,
model as unknown as SheetDataValidationModel,
{ getValidatorItem: vi.fn(() => validator) } as unknown as DataValidatorRegistryService,
{ open, close } as unknown as IDialogService,
{ t: (key: string) => key } as LocaleService,
{
validatorCell: vi.fn()
.mockResolvedValueOnce(DataValidationStatus.INVALID)
.mockResolvedValueOnce(DataValidationStatus.VALID),
} as unknown as SheetsDataValidationValidatorService
);
expect(controller).toBeTruthy();
expect(interceptedHandler).toBeDefined();
const next = (value: Promise<boolean>) => value;
const context: IInterceptContext = { row: 1, col: 2, unitId: 'book-1', subUnitId: 'sheet-1' };
await expect(interceptedHandler!(Promise.resolve(false), context, next)).resolves.toBe(false);
expect(open).not.toHaveBeenCalled();
await expect(interceptedHandler!(Promise.resolve(true), context, next)).resolves.toBe(false);
expect(open).toHaveBeenCalledWith(expect.objectContaining({
id: 'reject-input-dialog',
children: { title: 'Invalid value' },
}));
await expect(interceptedHandler!(Promise.resolve(true), context, next)).resolves.toBe(true);
getRuleById.mockReturnValue({ type: 'list', errorStyle: DataValidationErrorStyle.WARNING });
await expect(interceptedHandler!(Promise.resolve(true), context, next)).resolves.toBe(true);
controller.showReject('Bad input');
expect(open).toHaveBeenLastCalledWith(expect.objectContaining({
children: { title: 'Bad input' },
}));
});
});

View File

@@ -0,0 +1,277 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Ctor, ICommandInfo, IDisposable, IWorkbookData, Workbook } from '@univerjs/core';
import type { ISetWorksheetRowAutoHeightMutationParams } from '@univerjs/sheets';
import {
DataValidationRenderMode,
DataValidationStatus,
DataValidationType,
ICommandService,
ILogService,
InterceptorEffectEnum,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Tools,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { AddDataValidationMutation, DataValidatorRegistryService } from '@univerjs/data-validation';
import { DocSelectionManagerService } from '@univerjs/docs';
import { EditorService, IEditorService } from '@univerjs/docs-ui';
import {
CanvasColorService,
ICanvasColorService,
IRenderManagerService,
RenderManagerService,
} from '@univerjs/engine-render';
import {
INTERCEPTOR_POINT,
IRefSelectionsService,
SetWorksheetRowAutoHeightMutation,
SheetInterceptorService,
SheetsSelectionsService,
UniverSheetsPlugin,
} from '@univerjs/sheets';
import { DataValidationCacheService, UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation';
import {
AutoHeightController,
EditorBridgeService,
IEditorBridgeService,
ISheetCellDropdownManagerService,
SheetCanvasPopManagerService,
SheetCellDropdownManagerService,
SheetSkeletonManagerService,
} from '@univerjs/sheets-ui';
import { CanvasPopupService, ICanvasPopupService, IMenuManagerService, MenuManagerService } from '@univerjs/ui';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../locale/en-US';
import { DataValidationDropdownManagerService } from '../../services/dropdown-manager.service';
import { SheetsDataValidationRenderController } from '../dv-render.controller';
import { SheetsDataValidationMobileRenderController } from '../mobile/dv-render.controller';
import { SheetsDataValidationUIController } from '../ui.controller';
vi.hoisted(() => {
vi.stubGlobal('Path2D', class {});
});
afterAll(() => vi.unstubAllGlobals());
const controllers: Ctor<IDisposable>[] = [SheetsDataValidationRenderController, SheetsDataValidationMobileRenderController];
for (const Controller of controllers) {
describe(Controller.name, () => {
let univer: Univer;
let peer: Workbook;
let first: Workbook;
let second: Workbook;
beforeEach(() => {
vi.useFakeTimers();
// Only the unavailable Canvas backend is replaced; controllers and providers are real.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function (this: HTMLCanvasElement) {
return {
canvas: this,
setTransform: vi.fn(),
clearRect: vi.fn(),
measureText: (text: string) => ({
width: text.length * 7,
fontBoundingBoxAscent: 10,
fontBoundingBoxDescent: 4,
actualBoundingBoxAscent: 10,
actualBoundingBoxDescent: 4,
}),
} as unknown as CanvasRenderingContext2D;
});
univer = new Univer();
univer.registerPlugin(UniverSheetsPlugin, { notExecuteFormula: true });
univer.registerPlugin(UniverSheetsDataValidationPlugin);
peer = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookSnapshot('peer', 70));
const injector = univer.__getInjector();
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
injector.get(LocaleService).load({ enUS });
injector.get(LocaleService).setLocale(LocaleType.EN_US);
injector.add([ICanvasColorService, { useClass: CanvasColorService }]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([AutoHeightController]);
injector.add([DocSelectionManagerService]);
injector.add([IEditorService, { useClass: EditorService }]);
injector.add([IEditorBridgeService, { useClass: EditorBridgeService }]);
injector.add([IRefSelectionsService, { useClass: SheetsSelectionsService }]);
injector.add([ICanvasPopupService, { useClass: CanvasPopupService }]);
injector.add([SheetCanvasPopManagerService]);
injector.add([ISheetCellDropdownManagerService, { useClass: SheetCellDropdownManagerService }]);
injector.add([DataValidationDropdownManagerService]);
injector.add([IMenuManagerService, { useClass: MenuManagerService }]);
injector.add([Controller]);
injector.add([SheetsDataValidationUIController]);
const instances = injector.get(IUniverInstanceService);
first = instances.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
workbookSnapshot('first', 50),
{ makeCurrent: false }
);
second = instances.createUnit<IWorkbookData, Workbook>(
UniverInstanceType.UNIVER_SHEET,
workbookSnapshot('second', 60),
{ makeCurrent: false }
);
const renders = injector.get(IRenderManagerService);
renders.registerRenderModule(UniverInstanceType.UNIVER_SHEET, [SheetSkeletonManagerService]);
for (const workbook of [peer, first, second]) {
renders.createRender(workbook.getUnitId());
}
injector.get(SheetsDataValidationUIController);
injector.get(Controller);
});
afterEach(() => {
vi.clearAllTimers();
univer.dispose();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('keeps each workbook and worksheet in a shared batch isolated after focus changes', async () => {
const injector = univer.__getInjector();
const instances = injector.get(IUniverInstanceService);
instances.setCurrentUnitForType('first');
const peerBefore = Tools.deepClone(peer.getSnapshot());
const heights: ISetWorksheetRowAutoHeightMutationParams[] = [];
const commands = injector.get(ICommandService);
const listener = commands.onCommandExecuted((command: ICommandInfo) => {
if (command.id === SetWorksheetRowAutoHeightMutation.id) {
heights.push(command.params as ISetWorksheetRowAutoHeightMutationParams);
}
});
addRule(univer, 'first', 'sheet1', 1, DataValidationType.LIST);
addRule(univer, 'first', 'sheet1', 2, DataValidationType.LIST_MULTIPLE);
addRule(univer, 'first', 'sheet2', 3, DataValidationType.LIST);
addRule(univer, 'second', 'sheet1', 4, DataValidationType.LIST_MULTIPLE);
instances.setCurrentUnitForType('peer');
instances.focusUnit('peer');
expect(first.getSheetBySheetId('sheet1')!.getRowHeight(1)).toBe(100);
await vi.advanceTimersByTimeAsync(100);
expect(first.getSheetBySheetId('sheet1')!.getRowHeight(1)).toBe(50);
expect(first.getSheetBySheetId('sheet1')!.getRowHeight(2)).toBe(50);
expect(first.getSheetBySheetId('sheet2')!.getRowHeight(3)).toBe(55);
expect(second.getSheetBySheetId('sheet1')!.getRowHeight(4)).toBe(60);
expect(first.getSheetBySheetId('sheet1')!.getRowHeight(4)).toBe(100);
expect(peer.getSnapshot()).toEqual(peerBefore);
expect(heights).toHaveLength(3);
expect(heights).toEqual(expect.arrayContaining([
{ unitId: 'first', subUnitId: 'sheet1', rowsAutoHeightInfo: [{ row: 1, autoHeight: 50 }, { row: 2, autoHeight: 50 }] },
{ unitId: 'first', subUnitId: 'sheet2', rowsAutoHeightInfo: [{ row: 3, autoHeight: 55 }] },
{ unitId: 'second', subUnitId: 'sheet1', rowsAutoHeightInfo: [{ row: 4, autoHeight: 60 }] },
]));
listener.dispose();
});
it('cancels buffered and future work when its controller is disposed', async () => {
const injector = univer.__getInjector();
injector.get(IUniverInstanceService).setCurrentUnitForType('first');
addRule(univer, 'first', 'sheet1', 1, DataValidationType.LIST);
injector.get(Controller).dispose();
addRule(univer, 'first', 'sheet1', 2, DataValidationType.LIST);
const before = Tools.deepClone(first.getSnapshot());
await vi.advanceTimersByTimeAsync(200);
expect(first.getSnapshot()).toEqual(before);
});
it('ignores patched changes and non-list rules without suppressing command-origin list changes', async () => {
addRule(univer, 'first', 'sheet1', 1, DataValidationType.LIST, 'patched');
addRule(univer, 'first', 'sheet1', 2, DataValidationType.WHOLE);
addRule(univer, 'first', 'sheet1', 3, DataValidationType.LIST);
await vi.advanceTimersByTimeAsync(100);
const sheet = first.getSheetBySheetId('sheet1')!;
expect([sheet.getRowHeight(1), sheet.getRowHeight(2), sheet.getRowHeight(3)]).toEqual([100, 100, 50]);
});
it('discards a disposed target without changing the current workbook', async () => {
addRule(univer, 'first', 'sheet1', 1, DataValidationType.LIST);
const before = Tools.deepClone(peer.getSnapshot());
univer.__getInjector().get(IUniverInstanceService).disposeUnit('first');
await vi.advanceTimersByTimeAsync(100);
expect(peer.getSnapshot()).toEqual(before);
});
it('preserves invalid markers, custom rendering, and list layout through real cell interceptors', () => {
const injector = univer.__getInjector();
addRule(univer, 'peer', 'sheet1', 1, DataValidationType.LIST, 'patched');
injector.get(DataValidationCacheService).ensureCache('peer', 'sheet1').setValue(1, 0, DataValidationStatus.INVALID);
const worksheet = peer.getSheetBySheetId('sheet1')!;
const raw = { v: 'bad', markers: { bl: { size: 1, color: '#112233' } } };
const cell = injector.get(SheetInterceptorService).fetchThroughInterceptors(
INTERCEPTOR_POINT.CELL_CONTENT,
InterceptorEffectEnum.Style
)(raw, { row: 1, col: 0, unitId: 'peer', subUnitId: 'sheet1', rawData: raw, workbook: peer, worksheet })!;
const validator = injector.get(DataValidatorRegistryService).getValidatorItem(DataValidationType.LIST)!;
expect(cell).not.toBe(raw);
expect(cell.markers?.bl).toEqual(raw.markers.bl);
expect(cell.markers?.tr?.size).toBeGreaterThan(0);
expect(cell.customRender).toContain(validator.canvasRender);
expect(cell.fontRenderExtension?.isSkip).toBe(true);
expect(cell.coverable).toBe(false);
expect(cell.interceptorAutoHeight?.()).toBeGreaterThan(0);
expect(cell.interceptorAutoWidth?.()).toBeGreaterThan(0);
});
});
}
function addRule(
univer: Univer,
unitId: string,
subUnitId: string,
row: number,
type: DataValidationType,
source: 'command' | 'patched' = 'command'
) {
expect(univer.__getInjector().get(ICommandService).syncExecuteCommand(AddDataValidationMutation.id, {
unitId,
subUnitId,
source,
rule: {
uid: `rule-${row}`,
type,
formula1: 'red,blue',
ranges: [{ startRow: row, endRow: row, startColumn: 0, endColumn: 0 }],
renderMode: DataValidationRenderMode.CUSTOM,
},
})).toBe(true);
}
function workbookSnapshot(id: string, defaultRowHeight: number): IWorkbookData {
return {
id,
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: id,
styles: {},
sheetOrder: ['sheet1', 'sheet2'],
sheets: Object.fromEntries(['sheet1', 'sheet2'].map((sheetId, index) => [sheetId, {
id: sheetId,
name: sheetId,
rowCount: 6,
columnCount: 3,
defaultRowHeight: defaultRowHeight + index * 5,
rowData: { 1: { ah: 100 }, 2: { ah: 100 }, 3: { ah: 100 }, 4: { ah: 100 } },
}])),
};
}

View File

@@ -0,0 +1,52 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { awaitTime } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetsDataValidationReRenderController } from '../dv-rerender.controller';
describe('SheetsDataValidationReRenderController', () => {
it('marks affected skeletons dirty and forces a rerender when validation status changes', async () => {
const validStatusChange$ = new Subject<{ subUnitId: string }>();
const makeDirty1 = vi.fn();
const makeDirty2 = vi.fn();
const makeForceDirty = vi.fn();
const controller = new SheetsDataValidationReRenderController(
{ mainComponent: { makeForceDirty } } as never,
{ validStatusChange$ } as never,
{
getSkeletonParam: vi.fn((sheetId: string) => {
if (sheetId === 'sheet-1') return { skeleton: { makeDirty: makeDirty1 } };
if (sheetId === 'sheet-2') return { skeleton: { makeDirty: makeDirty2 } };
return undefined;
}),
} as never
);
validStatusChange$.next({ subUnitId: 'sheet-1' });
validStatusChange$.next({ subUnitId: 'sheet-2' });
validStatusChange$.next({ subUnitId: 'sheet-1' });
expect(controller).toBeTruthy();
await awaitTime(30);
expect(makeDirty1).toHaveBeenCalledTimes(1);
expect(makeDirty2).toHaveBeenCalledTimes(1);
expect(makeForceDirty).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,58 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { CheckboxIcon, DataValidationIcon, DatePickerIcon, DropdownListIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { DATA_VALIDATION_PANEL } from '../commands/operations/data-validation.operation';
import { DataValidationPanel } from '../views/components';
import { DateShowTimeOption } from '../views/components/DateShowTimeOption';
import { DROPDOWN_PRESETS_COMPONENT, DropdownPresets } from '../views/components/DropdownPresets';
import { FORMULA_INPUTS } from '../views/components/formula-input';
import { ListRenderModeInput } from '../views/components/ListRenderModeInput';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerIcons();
this._registerComponents();
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
CheckboxIcon,
DataValidationIcon,
DatePickerIcon,
DropdownListIcon,
}));
}
private _registerComponents(): void {
([
[DATA_VALIDATION_PANEL, DataValidationPanel],
[ListRenderModeInput.componentKey, ListRenderModeInput],
[DateShowTimeOption.componentKey, DateShowTimeOption],
[DROPDOWN_PRESETS_COMPONENT, DropdownPresets],
...FORMULA_INPUTS,
] as const).forEach(([key, component]) => {
this.disposeWithMe(this._componentManager.register(key, component));
});
}
}

View File

@@ -0,0 +1,106 @@
/**
* 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 { BaseDataValidator } from '@univerjs/data-validation';
import type { LocaleKey } from '../locale/types';
import {
DataValidationStatus,
Disposable,
Inject,
IUniverInstanceService,
LocaleService,
UniverInstanceType,
} from '@univerjs/core';
import { SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { CellAlertManagerService, CellAlertType, HoverManagerService } from '@univerjs/sheets-ui';
import { debounceTime, of, switchMap } from 'rxjs';
const ALERT_KEY = 'SHEET_DATA_VALIDATION_ALERT';
export class DataValidationAlertController extends Disposable {
constructor(
@Inject(HoverManagerService) private readonly _hoverManagerService: HoverManagerService,
@Inject(CellAlertManagerService) private readonly _cellAlertManagerService: CellAlertManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@Inject(SheetDataValidationModel) private readonly _dataValidationModel: SheetDataValidationModel
) {
super();
this._init();
}
private _init() {
this._initCellAlertPopup();
}
private _initCellAlertPopup() {
this.disposeWithMe(this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET).pipe(
switchMap((workbook) => workbook?.activeSheet$ ?? of(null))
).subscribe(() => this._cellAlertManagerService.removeAlert(ALERT_KEY)));
this.disposeWithMe(this._hoverManagerService.currentCell$.pipe(debounceTime(100)).subscribe((cellPos) => {
if (cellPos) {
const workbook = this._univerInstanceService.getUnit<Workbook>(cellPos.location.unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook || workbook.getActiveSheet()?.getSheetId() !== cellPos.location.subUnitId) {
this._cellAlertManagerService.removeAlert(ALERT_KEY);
return;
}
const worksheet = workbook.getSheetBySheetId(cellPos.location.subUnitId);
if (!worksheet) return;
const rule = this._dataValidationModel.getRuleByLocation(cellPos.location.unitId, cellPos.location.subUnitId, cellPos.location.row, cellPos.location.col);
if (!rule) {
this._cellAlertManagerService.removeAlert(ALERT_KEY);
return;
}
const validStatus = this._dataValidationModel.validator(rule, { ...cellPos.location, workbook, worksheet });
if (validStatus === DataValidationStatus.INVALID) {
const currentAlert = this._cellAlertManagerService.currentAlert.get(ALERT_KEY);
const currentLoc = currentAlert?.alert?.location;
if (
currentLoc &&
currentLoc.row === cellPos.location.row &&
currentLoc.col === cellPos.location.col &&
currentLoc.subUnitId === cellPos.location.subUnitId &&
currentLoc.unitId === cellPos.location.unitId
) {
this._cellAlertManagerService.removeAlert(ALERT_KEY);
return;
}
const validator = this._dataValidationModel.getValidator(rule.type) as BaseDataValidator;
if (!validator) {
this._cellAlertManagerService.removeAlert(ALERT_KEY);
return;
}
this._cellAlertManagerService.showAlert({
type: CellAlertType.ERROR,
title: this._localeService.t<LocaleKey>('sheets-data-validation-ui.error.title'),
message: validator?.getRuleFinalError(rule, cellPos.location),
location: cellPos.location,
width: 200,
height: 74,
key: ALERT_KEY,
});
return;
}
}
this._cellAlertManagerService.removeAlert(ALERT_KEY);
}));
}
}

View File

@@ -0,0 +1,120 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IRange } from '@univerjs/core';
import type { IAutoFillLocation, ISheetAutoFillHook } from '@univerjs/sheets';
import { DataValidationType, Disposable, getIntersectRange, Inject, Injector, Rectangle } from '@univerjs/core';
import { AUTO_FILL_APPLY_TYPE, AutoFillTools, IAutoFillService } from '@univerjs/sheets';
import { DATA_VALIDATION_PLUGIN_NAME, getDataValidationDiffMutations, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { virtualizeDiscreteRanges } from '@univerjs/sheets-ui';
// TODO: adjust imports
export class DataValidationAutoFillController extends Disposable {
constructor(
@IAutoFillService private readonly _autoFillService: IAutoFillService,
@Inject(SheetDataValidationModel) private readonly _sheetDataValidationModel: SheetDataValidationModel,
@Inject(Injector) private readonly _injector: Injector
) {
super();
this._initAutoFill();
}
// eslint-disable-next-line max-lines-per-function
private _initAutoFill() {
const noopReturnFunc = () => ({ redos: [], undos: [] });
const generalApplyFunc = (location: IAutoFillLocation, applyType: AUTO_FILL_APPLY_TYPE) => {
const { source: sourceRange, target: targetRange, unitId, subUnitId } = location;
const ruleMatrixCopy = this._sheetDataValidationModel.getRuleObjectMatrix(unitId, subUnitId).clone();
const virtualRange = virtualizeDiscreteRanges([sourceRange, targetRange]);
const [vSourceRange, vTargetRange] = virtualRange.ranges;
const { mapRange, projectRange } = virtualRange;
const repeats = AutoFillTools.getAutoFillRepeatRange(vSourceRange, vTargetRange);
const additionsByRuleId = new Map<string, IRange[]>();
additionsByRuleId.set('', repeats.flatMap((repeat) => mapRange(Rectangle.getPositionRange(repeat.relativeRange, {
startRow: repeat.repeatStartCell.row,
endRow: repeat.repeatStartCell.row,
startColumn: repeat.repeatStartCell.col,
endColumn: repeat.repeatStartCell.col,
}))));
this._sheetDataValidationModel.getRules(unitId, subUnitId).forEach((rule) => {
const relativeSourceRanges = rule.ranges.flatMap((range) => {
const projected = projectRange(range);
const intersected = projected && getIntersectRange(projected, vSourceRange);
return intersected
? [Rectangle.getRelativeRange(intersected, vSourceRange)]
: [];
});
const targetRanges = repeats.flatMap((repeat) => relativeSourceRanges.flatMap((sourceRange) => {
const copiedRange = getIntersectRange(sourceRange, repeat.relativeRange);
if (!copiedRange) {
return [];
}
return mapRange(Rectangle.getPositionRange(copiedRange, {
startRow: repeat.repeatStartCell.row,
endRow: repeat.repeatStartCell.row,
startColumn: repeat.repeatStartCell.col,
endColumn: repeat.repeatStartCell.col,
}));
}));
if (targetRanges.length) {
additionsByRuleId.set(rule.uid, targetRanges);
}
});
const additions = Array.from(additionsByRuleId, ([id, ranges]) => ({
id,
ranges: ranges.length > 1 ? Rectangle.mergeRanges(ranges) : ranges,
}));
ruleMatrixCopy.addRangeRules(additions);
const diffs = ruleMatrixCopy.diff(this._sheetDataValidationModel.getRules(unitId, subUnitId));
const { redoMutations, undoMutations } = getDataValidationDiffMutations(unitId, subUnitId, diffs, this._injector, 'patched', applyType === AUTO_FILL_APPLY_TYPE.ONLY_FORMAT);
return {
undos: undoMutations,
redos: redoMutations,
};
};
const hook: ISheetAutoFillHook = {
id: DATA_VALIDATION_PLUGIN_NAME,
onBeforeFillData: (location) => {
const { source: sourceRange, unitId, subUnitId } = location;
const { projectRange } = virtualizeDiscreteRanges([sourceRange]);
const hasCheckbox = this._sheetDataValidationModel.getRules(unitId, subUnitId).some((rule) => (
rule.type === DataValidationType.CHECKBOX && rule.ranges.some((range) => projectRange(range) !== null)
));
if (hasCheckbox) {
this._autoFillService.setDisableApplyType(AUTO_FILL_APPLY_TYPE.SERIES, true);
}
},
onFillData: (location, direction, applyType) => {
if (
applyType === AUTO_FILL_APPLY_TYPE.COPY ||
applyType === AUTO_FILL_APPLY_TYPE.ONLY_FORMAT ||
applyType === AUTO_FILL_APPLY_TYPE.SERIES
) {
return generalApplyFunc(location, applyType);
}
return noopReturnFunc();
},
onAfterFillData: () => {
},
};
this.disposeWithMe(this._autoFillService.addHook(hook));
}
}

View File

@@ -0,0 +1,191 @@
/**
* 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, ISheetDataValidationRule, Nullable } from '@univerjs/core';
import type { ICopyPastePayload, IPasteHookValueType, ISheetDiscreteRangeLocation } from '@univerjs/sheets-ui';
import { Disposable, Inject, Injector, IUniverInstanceService, Rectangle } from '@univerjs/core';
import { getSheetCommandTarget, rangeToDiscreteRange } from '@univerjs/sheets';
import { DATA_VALIDATION_PLUGIN_NAME, getDataValidationDiffMutations, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { COPY_TYPE, getRepeatRange, ISheetClipboardService, PREDEFINED_HOOK_NAME_PASTE, virtualizeDiscreteRanges } from '@univerjs/sheets-ui';
interface ICopyInfoType {
rules: Map<string, IRange[]>;
unitId: string;
subUnitId: string;
}
const specialPastes: IPasteHookValueType[] = [
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_COL_WIDTH,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_VALUE,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMAT,
PREDEFINED_HOOK_NAME_PASTE.SPECIAL_PASTE_FORMULA,
];
export class DataValidationCopyPasteController extends Disposable {
private _copyInfo: Nullable<ICopyInfoType>;
constructor(
@ISheetClipboardService private _sheetClipboardService: ISheetClipboardService,
@Inject(SheetDataValidationModel) private _sheetDataValidationModel: SheetDataValidationModel,
@Inject(Injector) private _injector: Injector,
@Inject(IUniverInstanceService) private _univerInstanceService: IUniverInstanceService
) {
super();
this._initCopyPaste();
}
private _initCopyPaste() {
this._sheetClipboardService.addClipboardHook({
id: DATA_VALIDATION_PLUGIN_NAME,
onBeforeCopy: (unitId, subUnitId, range) => this._collect(unitId, subUnitId, range),
onPasteCells: (pasteFrom, pasteTo, data, payload) => {
if (!pasteFrom || !this._copyInfo || specialPastes.includes(payload.pasteType)) {
return { redos: [], undos: [] };
}
return this._generateMutations(pasteFrom, pasteTo, payload);
},
});
}
private _collect(unitId: string, subUnitId: string, range: IRange) {
const rules = new Map<string, IRange[]>();
this._copyInfo = {
unitId,
subUnitId,
rules,
};
const discreteRange = this._injector.invoke((accessor) => {
return rangeToDiscreteRange(range, accessor, unitId, subUnitId);
});
if (!discreteRange) {
return;
}
const { projectRange } = virtualizeDiscreteRanges([discreteRange]);
this._sheetDataValidationModel.getRules(unitId, subUnitId).forEach((rule) => {
const projectedRanges = rule.ranges.flatMap((ruleRange) => {
const projected = projectRange(ruleRange);
return projected ? [projected] : [];
});
if (projectedRanges.length) {
rules.set(rule.uid, projectedRanges.length > 1 ? Rectangle.mergeRanges(projectedRanges) : projectedRanges);
}
});
}
// eslint-disable-next-line max-lines-per-function
private _generateMutations(pasteFrom: ISheetDiscreteRangeLocation, pasteTo: ISheetDiscreteRangeLocation, payload: ICopyPastePayload) {
const copyInfo = this._copyInfo;
if (!copyInfo) {
return { redos: [], undos: [] };
}
const { unitId: copyUnitId, subUnitId: copySubUnitId, range: copyRange } = pasteFrom;
const { unitId: pastedUnitId, subUnitId: pastedSubUnitId, range: pastedRange } = pasteTo;
const { copyType = COPY_TYPE.COPY } = payload;
const target = getSheetCommandTarget(this._univerInstanceService, { unitId: pastedUnitId, subUnitId: pastedSubUnitId });
if (!target) {
return { redos: [], undos: [] };
}
// If it is cut and paste in the same worksheet, do not need to handle the data validation rules, because the move range had handle the ref range of data validation rules, to see dv-formula-ref-range.controller.ts.
if (copyType === COPY_TYPE.CUT && pastedUnitId === copyUnitId && pastedSubUnitId === copySubUnitId) {
this._copyInfo = null;
return { redos: [], undos: [] };
}
const sourceVirtualRange = virtualizeDiscreteRanges([copyRange]).ranges[0];
const targetVirtualization = virtualizeDiscreteRanges([pastedRange]);
const targetVirtualRange = targetVirtualization.ranges[0];
const repeatRange = getRepeatRange(sourceVirtualRange, targetVirtualRange, true);
const clearTargetRanges = targetVirtualization.mapRange(targetVirtualRange);
const getTargetRanges = (sourceRanges: IRange[]) => repeatRange.flatMap(({ startRange }) => sourceRanges.flatMap((sourceRange) => (
targetVirtualization.mapRange(Rectangle.getPositionRange(sourceRange, startRange))
)));
if (pastedUnitId !== copyUnitId || pastedSubUnitId !== copySubUnitId) {
const ruleMatrix = this._sheetDataValidationModel.getRuleObjectMatrix(pastedUnitId, pastedSubUnitId).clone();
const additionRules: Map<string, ISheetDataValidationRule> = new Map();
const additions = [{ id: '', ranges: clearTargetRanges }];
copyInfo.rules.forEach((ranges, ruleId) => {
const transformedRuleId = `${copySubUnitId}-${ruleId}`;
const oldRule = this._sheetDataValidationModel.getRuleById(copyUnitId, copySubUnitId, ruleId);
if (!this._sheetDataValidationModel.getRuleById(pastedUnitId, pastedSubUnitId, transformedRuleId) && oldRule) {
additionRules.set(transformedRuleId, { ...oldRule, uid: transformedRuleId });
}
additions.push({ id: transformedRuleId, ranges: getTargetRanges(ranges) });
});
ruleMatrix.addRangeRules(additions);
const { redoMutations, undoMutations } = getDataValidationDiffMutations(
pastedUnitId,
pastedSubUnitId,
ruleMatrix.diffWithAddition(this._sheetDataValidationModel.getRules(pastedUnitId, pastedSubUnitId), additionRules.values()),
this._injector,
'patched',
false
);
if (copyType === COPY_TYPE.CUT) {
// Delete rules in copy range
const copySheetRuleMatrix = this._sheetDataValidationModel.getRuleObjectMatrix(copyUnitId, copySubUnitId).clone();
copySheetRuleMatrix.addRangeRules([
{
id: '',
ranges: virtualizeDiscreteRanges([copyRange]).mapRange(sourceVirtualRange),
},
]);
const { redoMutations: cutRedos, undoMutations: cutUndos } = getDataValidationDiffMutations(
copyUnitId,
copySubUnitId,
copySheetRuleMatrix.diff(this._sheetDataValidationModel.getRules(copyUnitId, copySubUnitId)),
this._injector,
'patched',
false
);
redoMutations.push(...cutRedos);
undoMutations.push(...cutUndos);
}
return {
redos: redoMutations,
undos: undoMutations,
};
} else {
const ruleMatrix = this._sheetDataValidationModel.getRuleObjectMatrix(copyUnitId, copySubUnitId).clone();
const additions = [
{ id: '', ranges: clearTargetRanges },
...Array.from(copyInfo.rules, ([id, ranges]) => ({ id, ranges: getTargetRanges(ranges) })),
];
ruleMatrix.addRangeRules(additions);
const { redoMutations, undoMutations } = getDataValidationDiffMutations(
pastedUnitId,
pastedSubUnitId,
ruleMatrix.diff(this._sheetDataValidationModel.getRules(copyUnitId, copySubUnitId)),
this._injector,
'patched',
false
);
return {
redos: redoMutations,
undos: undoMutations,
};
}
}
}

View File

@@ -0,0 +1,71 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommandInfo } from '@univerjs/core';
import type {
IAddSheetDataValidationCommandParams,
IUpdateSheetDataValidationRangeCommandParams,
} from '@univerjs/sheets-data-validation';
import type { LocaleKey } from '../locale/types';
import { Disposable, ICommandService, Inject, LocaleService } from '@univerjs/core';
import {
RangeProtectionPermissionEditPoint,
SheetPermissionCheckController,
WorkbookEditablePermission,
WorksheetEditPermission,
WorksheetSetCellStylePermission,
} from '@univerjs/sheets';
import { AddSheetDataValidationCommand, UpdateSheetDataValidationRangeCommand } from '@univerjs/sheets-data-validation';
export class DataValidationPermissionController extends Disposable {
constructor(
@Inject(LocaleService) private _localeService: LocaleService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetPermissionCheckController) private readonly _sheetPermissionCheckController: SheetPermissionCheckController
) {
super();
this._commandExecutedListener();
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id === AddSheetDataValidationCommand.id) {
const { unitId, subUnitId, rule: { ranges } } = command.params as IAddSheetDataValidationCommandParams;
const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
worksheetTypes: [WorksheetEditPermission, WorksheetSetCellStylePermission],
}, ranges, unitId, subUnitId);
if (!permission) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-data-validation-ui.permission.dialog.setStyleErr'));
}
}
if (command.id === UpdateSheetDataValidationRangeCommand.id) {
const { unitId, subUnitId, ranges } = command.params as IUpdateSheetDataValidationRangeCommandParams;
const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookEditablePermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
worksheetTypes: [WorksheetEditPermission, WorksheetSetCellStylePermission],
}, ranges, unitId, subUnitId);
if (!permission) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-data-validation-ui.permission.dialog.setStyleErr'));
}
}
})
);
}
}

View File

@@ -0,0 +1,120 @@
/**
* 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 { DataValidationErrorStyle, DataValidationStatus, Disposable, Inject, LocaleService } from '@univerjs/core';
import { DataValidatorRegistryService } from '@univerjs/data-validation';
import { Button } from '@univerjs/design';
import { SheetInterceptorService, VALIDATE_CELL } from '@univerjs/sheets';
import { SheetDataValidationModel, SheetsDataValidationValidatorService } from '@univerjs/sheets-data-validation';
import { IDialogService } from '@univerjs/ui';
import { createElement } from 'react';
export class DataValidationRejectInputController extends Disposable {
constructor(
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(SheetDataValidationModel) private readonly _dataValidationModel: SheetDataValidationModel,
@Inject(DataValidatorRegistryService) private readonly _dataValidatorRegistryService: DataValidatorRegistryService,
@IDialogService private readonly _dialogService: IDialogService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@Inject(SheetsDataValidationValidatorService) private readonly _sheetsDataValidationValidatorService: SheetsDataValidationValidatorService
) {
super();
this._initEditorBridgeInterceptor();
}
private _initEditorBridgeInterceptor() {
this.disposeWithMe(this._sheetInterceptorService.writeCellInterceptor.intercept(
VALIDATE_CELL,
{
handler: async (lastResult, context, next) => {
const cell = await lastResult;
const { row, col, unitId, subUnitId } = context;
const ruleId = this._dataValidationModel.getRuleIdByLocation(unitId, subUnitId, row, col);
const rule = ruleId ? this._dataValidationModel.getRuleById(unitId, subUnitId, ruleId) : undefined;
if (cell === false) {
return next(Promise.resolve(false))!;
}
if (!rule || rule.errorStyle !== DataValidationErrorStyle.STOP) {
return next(Promise.resolve(true))!;
}
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
if (!validator) {
return next(Promise.resolve(true))!;
}
const res = await this._sheetsDataValidationValidatorService.validatorCell(unitId, subUnitId, row, col);
if (res === DataValidationStatus.VALID) {
return next(Promise.resolve(true))!;
}
this._dialogService.open({
width: 368,
title: {
title: this._localeService.t<LocaleKey>('sheets-data-validation-ui.alert.title'),
},
id: 'reject-input-dialog',
children: {
title: validator.getRuleFinalError(rule, { row, col, unitId, subUnitId }),
},
footer: {
title: createElement(
Button,
{
variant: 'primary',
onClick: () => this._dialogService.close('reject-input-dialog'),
},
this._localeService.t<LocaleKey>('sheets-data-validation-ui.alert.ok')
),
},
onClose: () => {
this._dialogService.close('reject-input-dialog');
},
});
return next(Promise.resolve(false))!; // Add explicit return for invalid data
},
}
));
}
showReject(title: string) {
this._dialogService.open({
width: 368,
title: {
title: this._localeService.t<LocaleKey>('sheets-data-validation-ui.alert.title'),
},
id: 'reject-input-dialog',
children: {
title,
},
footer: {
title: createElement(
Button,
{
variant: 'primary',
onClick: () => this._dialogService.close('reject-input-dialog'),
},
this._localeService.t<LocaleKey>('sheets-data-validation-ui.alert.ok')
),
},
onClose: () => {
this._dialogService.close('reject-input-dialog');
},
});
}
}

View File

@@ -0,0 +1,302 @@
/**
* 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, ICellRenderContext, IRange, Nullable, Workbook } from '@univerjs/core';
import type { IRuleChange } from '@univerjs/data-validation';
import {
DataValidationStatus,
DataValidationType,
ICommandService,
Inject,
InterceptorEffectEnum,
IUniverInstanceService,
Optional,
RxDisposable,
sequenceExecute,
UniverInstanceType,
} from '@univerjs/core';
import { DataValidatorRegistryService } from '@univerjs/data-validation';
import { IRenderManagerService } from '@univerjs/engine-render';
import { InterceptCellContentPriority, INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { DataValidationCacheService, getCellValueOrigin, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { AutoHeightController, IEditorBridgeService, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { IMenuManagerService } from '@univerjs/ui';
import { bufferTime, filter, takeUntil } from 'rxjs';
import { menuSchema } from '../menu/schema';
import { DataValidationDropdownManagerService } from '../services/dropdown-manager.service';
const INVALID_MARK = {
tr: {
size: 6,
color: '#fe4b4b',
},
};
export class SheetsDataValidationRenderController extends RxDisposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(AutoHeightController) private readonly _autoHeightController: AutoHeightController,
@Inject(DataValidationDropdownManagerService) private readonly _dropdownManagerService: DataValidationDropdownManagerService,
@Inject(SheetDataValidationModel) private readonly _sheetDataValidationModel: SheetDataValidationModel,
@Inject(DataValidatorRegistryService) private readonly _dataValidatorRegistryService: DataValidatorRegistryService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(DataValidationCacheService) private readonly _dataValidationCacheService: DataValidationCacheService,
@Optional(IEditorBridgeService) private readonly _editorBridgeService?: IEditorBridgeService
) {
super();
this._initMenu();
this._initDropdown();
this._initViewModelIntercept();
this._initAutoHeight();
}
private _initMenu() {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initDropdown() {
if (!this._editorBridgeService) {
return;
}
this.disposeWithMe(this._editorBridgeService.visible$.subscribe((visible) => {
if (!visible.visible) {
if (this._dropdownManagerService.activeDropdown?.trigger === 'editor-bridge') {
this._dropdownManagerService.hideDropdown();
}
return;
}
const state = this._editorBridgeService!.getEditCellState();
if (state) {
const { unitId, sheetId, row, column } = state;
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return;
}
const rule = this._sheetDataValidationModel.getRuleByLocation(unitId, sheetId, row, column);
if (!rule) {
return;
}
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
if (!validator?.dropdownType) {
return;
}
const worksheet = workbook.getActiveSheet();
if (!worksheet) return;
const activeDropdown = this._dropdownManagerService.activeDropdown;
const currLoc = activeDropdown?.location;
if (
currLoc &&
currLoc.unitId === unitId &&
currLoc.subUnitId === sheetId &&
currLoc.row === row &&
currLoc.col === column
) {
return;
}
this._dropdownManagerService.showDropdown(
{
location: {
unitId,
subUnitId: sheetId,
row,
col: column,
workbook,
worksheet,
},
trigger: 'editor-bridge',
closeOnOutSide: false,
}
);
}
}));
}
// eslint-disable-next-line max-lines-per-function
private _initViewModelIntercept() {
this.disposeWithMe(
this._sheetInterceptorService.intercept(
INTERCEPTOR_POINT.CELL_CONTENT,
{
effect: InterceptorEffectEnum.Style,
// must be after numfmt
priority: InterceptCellContentPriority.DATA_VALIDATION,
// eslint-disable-next-line max-lines-per-function, complexity
handler: (cell, pos, next) => {
const { row, col, unitId, subUnitId, workbook, worksheet } = pos;
const ruleId = this._sheetDataValidationModel.getRuleIdByLocation(unitId, subUnitId, row, col);
if (!ruleId) {
return next(cell);
}
const rule = this._sheetDataValidationModel.getRuleById(unitId, subUnitId, ruleId);
if (!rule) {
return next(cell);
}
const validStatus = this._dataValidationCacheService.getValue(unitId, subUnitId, row, col) ?? DataValidationStatus.VALID;
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
const cellOrigin = pos.rawData;
let cache: Nullable<CellValue>;
const cellValue = {
get value() {
if (cache !== undefined) {
return cache;
}
cache = getCellValueOrigin(cellOrigin) ?? null;
return cache;
},
};
const valueStr = {
get value() {
return `${cellValue.value ?? ''}`;
},
};
if (!cell || cell === pos.rawData) {
cell = { ...pos.rawData };
}
cell.markers = {
...cell?.markers,
...validStatus === DataValidationStatus.INVALID ? INVALID_MARK : null,
};
cell.customRender = [
...(cell?.customRender ?? []),
...(validator?.canvasRender ? [validator.canvasRender] : []),
];
cell.fontRenderExtension = {
...cell?.fontRenderExtension,
isSkip: cell?.fontRenderExtension?.isSkip || validator?.skipDefaultFontRender?.(rule, cellValue.value, pos),
};
cell.interceptorStyle = {
...cell?.interceptorStyle,
...validator?.getExtraStyle(rule, valueStr.value, {
get style() {
const styleMap = workbook.getStyles();
return (typeof cell?.s === 'string' ? styleMap.get(cell?.s) : cell?.s) || {};
},
}, row, col),
};
cell.interceptorAutoHeight = () => {
const skeleton = this._renderManagerService.getRenderUnitById(unitId)
?.with(SheetSkeletonManagerService)
.getSkeletonParam(subUnitId)
?.skeleton;
if (!skeleton) {
return undefined;
}
const mergeCell = skeleton.worksheet.getMergedCell(row, col);
const info: ICellRenderContext = {
data: cell,
style: workbook.getStyles().getStyleByCell(cell),
primaryWithCoord: skeleton.getCellWithCoordByIndex(mergeCell?.startRow ?? row, mergeCell?.startColumn ?? col),
unitId,
subUnitId,
row,
col,
workbook,
worksheet,
};
return validator?.canvasRender?.calcCellAutoHeight?.(info);
};
cell.interceptorAutoWidth = () => {
const skeleton = this._renderManagerService.getRenderUnitById(unitId)
?.with(SheetSkeletonManagerService)
.getSkeletonParam(subUnitId)
?.skeleton;
if (!skeleton) {
return undefined;
}
const mergeCell = skeleton.worksheet.getMergedCell(row, col);
const info: ICellRenderContext = {
data: cell,
style: workbook.getStyles().getStyleByCell(cell),
primaryWithCoord: skeleton.getCellWithCoordByIndex(mergeCell?.startRow ?? row, mergeCell?.startColumn ?? col),
unitId,
subUnitId,
row,
col,
workbook,
worksheet,
};
return validator?.canvasRender?.calcCellAutoWidth?.(info);
};
cell.coverable = (cell?.coverable ?? true) && !(rule.type === DataValidationType.LIST || rule.type === DataValidationType.LIST_MULTIPLE);
return next(cell);
},
}
)
);
}
private _initAutoHeight() {
this._sheetDataValidationModel.ruleChange$
.pipe(
// patched data-validation change don't need to re-calc row height
// re-calc of row height will be triggered precisely by the origin command
filter((change) => change.source === 'command'),
bufferTime(100),
takeUntil(this.dispose$)
)
.subscribe((infos) => {
recalculateAutoHeight(infos, this._autoHeightController, this._commandService);
});
}
}
export function recalculateAutoHeight(
infos: IRuleChange[],
autoHeightController: AutoHeightController,
commandService: ICommandService
) {
const rangesByUnit = new Map<string, Map<string, IRange[]>>();
for (const { unitId, subUnitId, rule } of infos) {
if (rule.type !== DataValidationType.LIST && rule.type !== DataValidationType.LIST_MULTIPLE) {
continue;
}
if (!rule.ranges?.length) {
continue;
}
let rangesBySheet = rangesByUnit.get(unitId);
if (!rangesBySheet) {
rangesBySheet = new Map();
rangesByUnit.set(unitId, rangesBySheet);
}
const ranges = rangesBySheet.get(subUnitId) ?? [];
ranges.push(...rule.ranges);
rangesBySheet.set(subUnitId, ranges);
}
for (const [unitId, rangesBySheet] of rangesByUnit) {
for (const [subUnitId, ranges] of rangesBySheet) {
const mutations = autoHeightController.getUndoRedoParamsOfAutoHeight(ranges, subUnitId, undefined, unitId);
sequenceExecute(mutations.redos, commandService);
}
}
}

View File

@@ -0,0 +1,51 @@
/**
* 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 { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IValidStatusChange } from '@univerjs/sheets-data-validation';
import { bufferDebounceTime, Disposable, Inject } from '@univerjs/core';
import { SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { SheetSkeletonManagerService } from '@univerjs/sheets-ui';
export class SheetsDataValidationReRenderController extends Disposable implements IRenderModule {
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(SheetDataValidationModel) private readonly _sheetDataValidationModel: SheetDataValidationModel,
@Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService
) {
super();
this._initSkeletonChange();
}
private _initSkeletonChange() {
const reRender = (values: IValidStatusChange[]) => {
if (!values.length) {
return;
}
const sheetIds = new Set<string>();
values.forEach((value) => {
sheetIds.add(value.subUnitId);
});
sheetIds.forEach((sheetId) => {
this._sheetSkeletonManagerService.getSkeletonParam(sheetId)?.skeleton.makeDirty(true);
});
this._context.mainComponent?.makeForceDirty();
};
this.disposeWithMe(this._sheetDataValidationModel.validStatusChange$.pipe(bufferDebounceTime(16)).subscribe(reRender));
}
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { CheckboxIcon, DataValidationIcon, DatePickerIcon, DropdownListIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { DROPDOWN_PRESETS_COMPONENT, DropdownPresets } from '../../views/components/DropdownPresets';
export class MobileComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) componentManager: ComponentManager,
@Inject(IconManager) iconManager: IconManager
) {
super();
this.disposeWithMe(componentManager.register(DROPDOWN_PRESETS_COMPONENT, DropdownPresets));
this.disposeWithMe(iconManager.register({
CheckboxIcon,
DataValidationIcon,
DatePickerIcon,
DropdownListIcon,
}));
}
}

View File

@@ -0,0 +1,176 @@
/**
* 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 { ICellRenderContext } from '@univerjs/core';
import {
DataValidationStatus,
DataValidationType,
ICommandService,
Inject,
InterceptorEffectEnum,
RxDisposable,
} from '@univerjs/core';
import { DataValidatorRegistryService } from '@univerjs/data-validation';
import { IRenderManagerService } from '@univerjs/engine-render';
import { InterceptCellContentPriority, INTERCEPTOR_POINT, SheetInterceptorService } from '@univerjs/sheets';
import { DataValidationCacheService, getCellValueOrigin, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { AutoHeightController, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { bufferTime, filter, takeUntil } from 'rxjs';
import { recalculateAutoHeight } from '../dv-render.controller';
const INVALID_MARK = {
tr: {
size: 6,
color: '#fe4b4b',
},
};
// The mobile version does not provide the ability to change data validation model.
export class SheetsDataValidationMobileRenderController extends RxDisposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(AutoHeightController) private readonly _autoHeightController: AutoHeightController,
@Inject(DataValidatorRegistryService) private readonly _dataValidatorRegistryService: DataValidatorRegistryService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(SheetDataValidationModel) private readonly _sheetDataValidationModel: SheetDataValidationModel,
@Inject(DataValidationCacheService) private readonly _dataValidationCacheService: DataValidationCacheService
) {
super();
this._initViewModelIntercept();
this._initAutoHeight();
}
// eslint-disable-next-line max-lines-per-function
private _initViewModelIntercept() {
this.disposeWithMe(
this._sheetInterceptorService.intercept(
INTERCEPTOR_POINT.CELL_CONTENT,
{
effect: InterceptorEffectEnum.Style,
// must be after numfmt
priority: InterceptCellContentPriority.DATA_VALIDATION,
// eslint-disable-next-line complexity, max-lines-per-function
handler: (cell, pos, next) => {
const { row, col, unitId, subUnitId, workbook, worksheet } = pos;
const ruleId = this._sheetDataValidationModel.getRuleIdByLocation(unitId, subUnitId, row, col);
if (!ruleId) {
return next(cell);
}
const rule = this._sheetDataValidationModel.getRuleById(unitId, subUnitId, ruleId);
if (!rule) {
return next(cell);
}
const validStatus = this._dataValidationCacheService.getValue(unitId, subUnitId, row, col) ?? DataValidationStatus.VALID;
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
const cellOrigin = worksheet.getCellRaw(row, col);
const cellValue = getCellValueOrigin(cellOrigin);
const valueStr = `${cellValue ?? ''}`;
if (!cell || cell === pos.rawData) {
cell = { ...pos.rawData };
}
cell.markers = {
...cell?.markers,
...validStatus === DataValidationStatus.INVALID ? INVALID_MARK : null,
};
cell.customRender = [
...(cell?.customRender ?? []),
...(validator?.canvasRender ? [validator.canvasRender] : []),
];
cell.fontRenderExtension = {
...cell?.fontRenderExtension,
isSkip: cell?.fontRenderExtension?.isSkip || validator?.skipDefaultFontRender?.(rule, cellValue, pos),
};
cell.interceptorStyle = {
...cell?.interceptorStyle,
...validator?.getExtraStyle(rule, valueStr, {
get style() {
const styleMap = workbook.getStyles();
return (typeof cell?.s === 'string' ? styleMap.get(cell?.s) : cell?.s) || {};
},
}, row, col),
};
cell.interceptorAutoHeight = () => {
const skeleton = this._renderManagerService.getRenderUnitById(unitId)
?.with(SheetSkeletonManagerService)
.getSkeletonParam(subUnitId)
?.skeleton;
if (!skeleton) {
return undefined;
}
const mergeCell = skeleton.worksheet.getMergedCell(row, col);
const info: ICellRenderContext = {
data: cell,
style: workbook.getStyles().getStyleByCell(cell),
primaryWithCoord: skeleton.getCellWithCoordByIndex(mergeCell?.startRow ?? row, mergeCell?.startColumn ?? col),
unitId,
subUnitId,
row,
col,
workbook,
worksheet,
};
return validator?.canvasRender?.calcCellAutoHeight?.(info);
};
cell.interceptorAutoWidth = () => {
const skeleton = this._renderManagerService.getRenderUnitById(unitId)
?.with(SheetSkeletonManagerService)
.getSkeletonParam(subUnitId)
?.skeleton;
if (!skeleton) {
return undefined;
}
const mergeCell = skeleton.worksheet.getMergedCell(row, col);
const info: ICellRenderContext = {
data: cell,
style: workbook.getStyles().getStyleByCell(cell),
primaryWithCoord: skeleton.getCellWithCoordByIndex(mergeCell?.startRow ?? row, mergeCell?.startColumn ?? col),
unitId,
subUnitId,
row,
col,
workbook,
worksheet,
};
return validator?.canvasRender?.calcCellAutoWidth?.(info);
};
cell.coverable = (cell?.coverable ?? true) && !(rule.type === DataValidationType.LIST || rule.type === DataValidationType.LIST_MULTIPLE);
return next(cell);
},
}
)
);
}
private _initAutoHeight() {
this._sheetDataValidationModel.ruleChange$
.pipe(
filter((change) => change.source === 'command'),
bufferTime(16),
takeUntil(this.dispose$)
)
.subscribe((infos) => {
recalculateAutoHeight(infos, this._autoHeightController, this._commandService);
});
}
}

View File

@@ -0,0 +1,61 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Ctor } from '@univerjs/core';
import type { BaseSheetDataValidatorView } from '../views/validator-views/sheet-validator-view';
import { Inject, Injector, RxDisposable } from '@univerjs/core';
import { DataValidatorRegistryService } from '@univerjs/data-validation';
import { CheckboxValidatorView } from '../views/validator-views/checkbox-validator-view';
import { CustomFormulaValidatorView } from '../views/validator-views/custom-validator-view';
import { DateValidatorView } from '../views/validator-views/date-validator-view';
import { DecimalValidatorView } from '../views/validator-views/decimal-validator-view';
import { ListMultipleValidatorView } from '../views/validator-views/list-multiple-view';
import { ListValidatorView } from '../views/validator-views/list-validator-view';
import { TextLengthValidatorView } from '../views/validator-views/text-length-validator.view';
import { WholeValidatorView } from '../views/validator-views/whole-validator-view';
export class SheetsDataValidationUIController extends RxDisposable {
constructor(
@Inject(Injector) private readonly _injector: Injector,
@Inject(DataValidatorRegistryService) private readonly _dataValidatorRegistryService: DataValidatorRegistryService
) {
super();
this._registerValidatorViews();
}
private _registerValidatorViews(): void {
([
DecimalValidatorView,
WholeValidatorView,
TextLengthValidatorView,
DateValidatorView,
CheckboxValidatorView,
ListValidatorView,
ListMultipleValidatorView,
CustomFormulaValidatorView,
] as Ctor<BaseSheetDataValidatorView>[]).forEach((v) => {
const view = this._injector.createInstance(v);
const validator = this._dataValidatorRegistryService.getValidatorItem(view.id);
if (validator) {
validator.formulaInput = view.formulaInput;
validator.canvasRender = view.canvasRender;
validator.dropdownType = view.dropdownType;
validator.optionsInput = view.optionsInput;
}
});
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,22 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './global.css';
export type { IUniverSheetsDataValidationUIConfig } from './config/config';
export { menuSchema as SheetsDataValidationUIMenuSchema } from './menu/schema';
export { UniverSheetsDataValidationMobileUIPlugin } from './mobile-plugin';
export { UniverSheetsDataValidationUIPlugin } from './plugin';

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'التحقق من صحة البيانات',
ribbon: {
setCheckbox: 'تعيين خانة اختيار',
clearCheckbox: 'مسح خانة الاختيار',
dropdownPresetTitle: 'تطبيق إعداد مسبق:',
editDropdown: 'تحرير الخيارات',
clearDropdown: 'مسح القائمة المنسدلة',
dateTime: 'التاريخ والوقت',
presets: {
yes: 'نعم',
no: 'لا',
notStarted: 'لم يبدأ',
inProgress: 'قيد التنفيذ',
completed: 'مكتمل',
option1: 'الخيار 1',
option2: 'الخيار 2',
},
},
operators: {
legal: 'نوع قانوني',
},
validFail: {
formulaError: 'يحتوي نطاق المرجع على بيانات غير مرئية، يُرجى إعادة ضبط النطاق',
},
panel: {
title: 'إدارة التحقق من صحة البيانات',
addTitle: 'إنشاء تحقق جديد من صحة البيانات',
removeAll: 'إزالة الكل',
add: 'إضافة قاعدة',
range: 'النطاقات',
type: 'النوع',
options: 'خيارات متقدمة',
operator: 'المشغل',
removeRule: 'إزالة',
done: 'تم',
formulaPlaceholder: 'يُرجى إدخال قيمة أو صيغة',
valuePlaceholder: 'يُرجى إدخال قيمة',
formulaAnd: 'و',
invalid: 'غير صالح',
showWarning: 'إظهار التحذير',
rejectInput: 'رفض الإدخال',
messageInfo: 'رسالة مساعدة',
showInfo: 'إظهار نص المساعدة للخلية المحددة',
rangeError: 'النطاقات غير قانونية',
allowBlank: 'السماح بالقيم الفارغة',
},
date: {
title: 'تاريخ',
},
list: {
title: 'قائمة منسدلة',
add: 'إضافة',
options: 'خيارات',
customOptions: 'مخصص',
refOptions: 'من نطاق',
edit: 'تحرير',
},
checkbox: {
title: 'خانة اختيار',
tips: 'استخدم قيمًا مخصصة داخل الخلايا',
checked: 'القيمة المحددة',
unchecked: 'القيمة غير المحددة',
},
alert: {
title: 'خطأ',
ok: 'موافق',
},
error: {
title: 'غير صالح:',
},
renderMode: {
arrow: 'سهم',
chip: 'رقاقة',
text: 'نص عادي',
label: 'نمط العرض',
},
showTime: {
label: 'إظهار منتقي الوقت',
},
permission: {
dialog: {
setStyleErr: 'النطاق محمي، وليس لديك الإذن بتعيين الأنماط. لتعيين الأنماط، يُرجى التواصل مع المنشئ.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Validació de dades',
ribbon: {
setCheckbox: 'Defineix la casella de selecció',
clearCheckbox: 'Esborra la casella de selecció',
dropdownPresetTitle: 'Aplica una predefinició:',
editDropdown: 'Edita les opcions',
clearDropdown: 'Esborra el menú desplegable',
dateTime: 'Data i hora',
presets: {
yes: 'Sí',
no: 'No',
notStarted: 'No iniciat',
inProgress: 'En curs',
completed: 'Completat',
option1: 'Opció 1',
option2: 'Opció 2',
},
},
operators: {
legal: 'és tipus legal',
},
validFail: {
formulaError: 'L’interval de referència conté dades invisibles, ajusta l’interval',
},
panel: {
title: 'Gestió de la validació de dades',
addTitle: 'Crea una nova validació de dades',
removeAll: 'Elimina-ho tot',
add: 'Afegeix regla',
range: 'Intervals',
type: 'Tipus',
options: 'Opcions avançades',
operator: 'Operador',
removeRule: 'Elimina',
done: 'Fet',
formulaPlaceholder: 'Si us plau, introdueix valor o fórmula',
valuePlaceholder: 'Si us plau, introdueix valor',
formulaAnd: 'i',
invalid: 'Invàlid',
showWarning: 'Mostra advertència',
rejectInput: 'Rebutja l’entrada',
messageInfo: 'Missatge d’ajuda',
showInfo: 'Mostra text d’ajuda per a la cel·la seleccionada',
rangeError: 'Els intervals no són vàlids',
allowBlank: 'Permet valors en blanc',
},
date: {
title: 'Data',
},
list: {
title: 'Desplegable',
add: 'Afegeix',
options: 'Opcions',
customOptions: 'Personalitzat',
refOptions: 'D’un interval',
edit: 'Edita',
},
checkbox: {
title: 'Casella de selecció',
tips: 'Utilitza valors personalitzats dins de les cel·les',
checked: 'Valor seleccionat',
unchecked: 'Valor no seleccionat',
},
alert: {
title: 'Error',
ok: 'OK',
},
error: {
title: 'Invàlid:',
},
renderMode: {
arrow: 'Fletxa',
chip: 'Xip',
text: 'Text pla',
label: 'Estil de visualització',
},
showTime: {
label: 'Mostra el selector d’hora',
},
permission: {
dialog: {
setStyleErr: 'L\'interval està protegit i no teniu permís per establir estils. Per establir estils, contacteu amb el creador.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Datenüberprüfung',
ribbon: {
setCheckbox: 'Kontrollkästchen festlegen',
clearCheckbox: 'Kontrollkästchen löschen',
dropdownPresetTitle: 'Vorlage anwenden:',
editDropdown: 'Optionen bearbeiten',
clearDropdown: 'Dropdown löschen',
dateTime: 'Datum und Uhrzeit',
presets: {
yes: 'Ja',
no: 'Nein',
notStarted: 'Nicht begonnen',
inProgress: 'In Bearbeitung',
completed: 'Abgeschlossen',
option1: 'Option 1',
option2: 'Option 2',
},
},
operators: {
legal: 'ist gültiger Typ',
},
validFail: {
formulaError: 'Der Referenzbereich enthält unsichtbare Daten, bitte passen Sie den Bereich an',
},
panel: {
title: 'Datenüberprüfungsverwaltung',
addTitle: 'Neue Datenüberprüfung erstellen',
removeAll: 'Alle entfernen',
add: 'Regel hinzufügen',
range: 'Bereiche',
type: 'Typ',
options: 'Erweiterte Optionen',
operator: 'Operator',
removeRule: 'Entfernen',
done: 'Fertig',
formulaPlaceholder: 'Bitte Wert oder Formel eingeben',
valuePlaceholder: 'Bitte Wert eingeben',
formulaAnd: 'und',
invalid: 'Ungültig',
showWarning: 'Warnung anzeigen',
rejectInput: 'Eingabe ablehnen',
messageInfo: 'Hilfemeldung',
showInfo: 'Hilfetext für ausgewählte Zelle anzeigen',
rangeError: 'Bereiche sind ungültig',
allowBlank: 'Leere Werte zulassen',
},
date: {
title: 'Datum',
},
list: {
title: 'Dropdown',
add: 'Hinzufügen',
options: 'Optionen',
customOptions: 'Benutzerdefiniert',
refOptions: 'Aus einem Bereich',
edit: 'Bearbeiten',
},
checkbox: {
title: 'Kontrollkästchen',
tips: 'Benutzerdefinierte Werte in Zellen verwenden',
checked: 'Ausgewählter Wert',
unchecked: 'Nicht ausgewählter Wert',
},
alert: {
title: 'Fehler',
ok: 'OK',
},
error: {
title: 'Ungültig:',
},
renderMode: {
arrow: 'Pfeil',
chip: 'Chip',
text: 'Reiner Text',
label: 'Anzeigestil',
},
showTime: {
label: 'Zeitauswahl anzeigen',
},
permission: {
dialog: {
setStyleErr: 'Der Bereich ist geschützt und Sie haben keine Berechtigung, Stile festzulegen. Um Stile festzulegen, wenden Sie sich bitte an den Ersteller.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* 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-data-validation-ui': {
title: 'Data validation',
ribbon: {
setCheckbox: 'Set Checkbox',
clearCheckbox: 'Clear Checkbox',
dropdownPresetTitle: 'Apply a preset:',
editDropdown: 'Edit options',
clearDropdown: 'Clear Dropdown',
dateTime: 'Date & Time',
presets: {
yes: 'Yes',
no: 'No',
notStarted: 'Not Started',
inProgress: 'In Progress',
completed: 'Completed',
option1: 'Option 1',
option2: 'Option 2',
},
},
operators: {
legal: 'is legal type',
},
validFail: {
formulaError: 'The reference range contains invisible data, please readjust the range',
},
panel: {
title: 'Data validation management',
addTitle: 'Create new data validation',
removeAll: 'Remove All',
add: 'Add Rule',
range: 'Ranges',
type: 'Type',
options: 'Advance options',
operator: 'Operator',
removeRule: 'Remove',
done: 'Done',
formulaPlaceholder: 'Please input value or formula',
valuePlaceholder: 'Please input value',
formulaAnd: 'and',
invalid: 'Invalid',
showWarning: 'Show warning',
rejectInput: 'Reject input',
messageInfo: 'Helper message',
showInfo: 'Show help text for a selected cell',
rangeError: 'Ranges are not legal',
allowBlank: 'Allow blank values',
},
date: {
title: 'Date',
},
list: {
title: 'Dropdown',
add: 'Add',
options: 'Options',
customOptions: 'Custom',
refOptions: 'From a range',
edit: 'Edit',
},
checkbox: {
title: 'Checkbox',
tips: 'Use custom values within cells',
checked: 'Selected value',
unchecked: 'Unselected value',
},
alert: {
title: 'Error',
ok: 'OK',
},
error: {
title: 'Invalid:',
},
renderMode: {
arrow: 'Arrow',
chip: 'Chip',
text: 'Plain text',
label: 'Display style',
},
showTime: {
label: 'Show TimePicker',
},
permission: {
dialog: {
setStyleErr: 'The range is protected, and you do not have permission to set styles. To set styles, please contact the creator.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Validación de datos',
ribbon: {
setCheckbox: 'Establecer casilla de verificación',
clearCheckbox: 'Borrar casilla de verificación',
dropdownPresetTitle: 'Aplicar un preajuste:',
editDropdown: 'Editar opciones',
clearDropdown: 'Borrar lista desplegable',
dateTime: 'Fecha y hora',
presets: {
yes: 'Sí',
no: 'No',
notStarted: 'No iniciado',
inProgress: 'En curso',
completed: 'Completado',
option1: 'Opción 1',
option2: 'Opción 2',
},
},
operators: {
legal: 'es tipo legal',
},
validFail: {
formulaError: 'El rango de referencia contiene datos invisibles, ajusta el rango',
},
panel: {
title: 'Gestión de validación de datos',
addTitle: 'Crear nueva validación de datos',
removeAll: 'Eliminar todo',
add: 'Añadir regla',
range: 'Rangos',
type: 'Tipo',
options: 'Opciones avanzadas',
operator: 'Operador',
removeRule: 'Eliminar',
done: 'Hecho',
formulaPlaceholder: 'Por favor, introduce valor o fórmula',
valuePlaceholder: 'Por favor, introduce valor',
formulaAnd: 'y',
invalid: 'Inválido',
showWarning: 'Mostrar advertencia',
rejectInput: 'Rechazar entrada',
messageInfo: 'Mensaje de ayuda',
showInfo: 'Mostrar texto de ayuda para la celda seleccionada',
rangeError: 'Los rangos no son válidos',
allowBlank: 'Permitir valores en blanco',
},
date: {
title: 'Fecha',
},
list: {
title: 'Desplegable',
add: 'Añadir',
options: 'Opciones',
customOptions: 'Personalizado',
refOptions: 'De un rango',
edit: 'Editar',
},
checkbox: {
title: 'Casilla de verificación',
tips: 'Usa valores personalizados dentro de las celdas',
checked: 'Valor seleccionado',
unchecked: 'Valor no seleccionado',
},
alert: {
title: 'Error',
ok: 'OK',
},
error: {
title: 'Inválido:',
},
renderMode: {
arrow: 'Flecha',
chip: 'Chip',
text: 'Texto plano',
label: 'Estilo de visualización',
},
showTime: {
label: 'Mostrar selector de hora',
},
permission: {
dialog: {
setStyleErr: 'El rango está protegido y no tienes permiso para establecer estilos. Para establecer estilos, contacta al creador.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'اعتبارسنجی داده',
ribbon: {
setCheckbox: 'تنظیم کادر انتخاب',
clearCheckbox: 'پاک کردن کادر انتخاب',
dropdownPresetTitle: 'اعمال یک پیش‌تنظیم:',
editDropdown: 'ویرایش گزینه‌ها',
clearDropdown: 'پاک کردن فهرست کشویی',
dateTime: 'تاریخ و زمان',
presets: {
yes: 'بله',
no: 'خیر',
notStarted: 'شروع نشده',
inProgress: 'در حال انجام',
completed: 'تکمیل‌شده',
option1: 'گزینه ۱',
option2: 'گزینه ۲',
},
},
operators: {
legal: 'نوع قانونی است',
},
validFail: {
formulaError: 'محدوده مرجع حاوی داده‌های نامرئی است، لطفا محدوده را دوباره تنظیم کنید',
},
panel: {
title: 'مدیریت اعتبارسنجی داده',
addTitle: 'ایجاد اعتبارسنجی داده جدید',
removeAll: 'حذف همه',
add: 'افزودن قانون',
range: 'محدوده‌ها',
type: 'نوع',
options: 'گزینه‌های پیشرفته',
operator: 'عملگر',
removeRule: 'حذف',
done: 'انجام شد',
formulaPlaceholder: 'لطفا مقدار یا فرمول را وارد کنید',
valuePlaceholder: 'لطفا مقدار را وارد کنید',
formulaAnd: 'و',
invalid: 'نامعتبر',
showWarning: 'نمایش هشدار',
rejectInput: 'رد ورودی',
messageInfo: 'پیام راهنما',
showInfo: 'نمایش متن راهنما برای یک سلول انتخاب شده',
rangeError: 'محدوده‌ها نامعتبر هستند',
allowBlank: 'رد سلول خالی',
},
date: {
title: 'تاریخ',
},
list: {
title: 'لیست کشویی',
add: 'افزودن',
options: 'گزینه‌ها',
customOptions: 'سفارشی',
refOptions: 'از یک محدوده',
edit: 'ویرایش',
},
checkbox: {
title: 'کادر انتخاب',
tips: 'از مقادیر سفارشی در سلول‌ها استفاده کنید',
checked: 'مقدار انتخاب شده',
unchecked: 'مقدار انتخاب نشده',
},
alert: {
title: 'خطا',
ok: 'باشه',
},
error: {
title: 'نامعتبر:',
},
renderMode: {
arrow: 'فلش',
chip: 'چیپ',
text: 'متن ساده',
label: 'سبک نمایش',
},
showTime: {
label: 'نمایش انتخاب زمان',
},
permission: {
dialog: {
setStyleErr: 'محدوده محافظت شده است و شما مجوز تنظیم سبک‌ها را ندارید. برای تنظیم سبک‌ها، لطفا با سازنده تماس بگیرید.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Validation des données',
ribbon: {
setCheckbox: 'Définir la case à cocher',
clearCheckbox: 'Effacer la case à cocher',
dropdownPresetTitle: 'Appliquer un préréglage :',
editDropdown: 'Modifier les options',
clearDropdown: 'Effacer la liste déroulante',
dateTime: 'Date et heure',
presets: {
yes: 'Oui',
no: 'Non',
notStarted: 'Non commencé',
inProgress: 'En cours',
completed: 'Terminé',
option1: 'Option 1',
option2: 'Option 2',
},
},
operators: {
legal: 'est de type légal',
},
validFail: {
formulaError: 'La plage de référence contient des données invisibles, veuillez réajuster la plage',
},
panel: {
title: 'Gestion de la validation des données',
addTitle: 'Créer une nouvelle validation des données',
removeAll: 'Tout supprimer',
add: 'Ajouter une règle',
range: 'Plages',
type: 'Type',
options: 'Options avancées',
operator: 'Opérateur',
removeRule: 'Supprimer',
done: 'Terminé',
formulaPlaceholder: 'Veuillez entrer une valeur ou une formule',
valuePlaceholder: 'Veuillez entrer une valeur',
formulaAnd: 'et',
invalid: 'Invalide',
showWarning: 'Afficher un avertissement',
rejectInput: 'Rejeter l\'entrée',
messageInfo: 'Message d\'aide',
showInfo: 'Afficher le texte d\'aide pour une cellule sélectionnée',
rangeError: 'Les plages ne sont pas légales',
allowBlank: 'Autoriser les valeurs vides',
},
date: {
title: 'Date',
},
list: {
title: 'Liste déroulante',
add: 'Ajouter',
options: 'Options',
customOptions: 'Personnalisé',
refOptions: 'D\'une plage',
edit: 'Éditer',
},
checkbox: {
title: 'Case à cocher',
tips: 'Utiliser des valeurs personnalisées dans les cellules',
checked: 'Valeur sélectionnée',
unchecked: 'Valeur non sélectionnée',
},
alert: {
title: 'Erreur',
ok: 'OK',
},
error: {
title: 'Invalide :',
},
renderMode: {
arrow: 'Flèche',
chip: 'Puce',
text: 'Texte brut',
label: 'Style d\'affichage',
},
showTime: {
label: 'Afficher le sélecteur de temps',
},
permission: {
dialog: {
setStyleErr: 'La plage est protégée, et vous n\'avez pas la permission de définir les styles. Pour définir les styles, veuillez contacter le créateur.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Validasi data',
ribbon: {
setCheckbox: 'Atur Kotak Centang',
clearCheckbox: 'Hapus Kotak Centang',
dropdownPresetTitle: 'Terapkan preset:',
editDropdown: 'Edit opsi',
clearDropdown: 'Hapus Dropdown',
dateTime: 'Tanggal dan waktu',
presets: {
yes: 'Ya',
no: 'Tidak',
notStarted: 'Belum Dimulai',
inProgress: 'Sedang Berlangsung',
completed: 'Selesai',
option1: 'Opsi 1',
option2: 'Opsi 2',
},
},
operators: {
legal: 'tipe yang sah',
},
validFail: {
formulaError: 'Rentang referensi berisi data yang tidak terlihat, harap sesuaikan rentangnya',
},
panel: {
title: 'Manajemen validasi data',
addTitle: 'Buat validasi data baru',
removeAll: 'Hapus Semua',
add: 'Tambah Aturan',
range: 'Rentang',
type: 'Jenis',
options: 'Opsi lanjutan',
operator: 'Operator',
removeRule: 'Hapus',
done: 'Selesai',
formulaPlaceholder: 'Harap masukkan nilai atau rumus',
valuePlaceholder: 'Harap masukkan nilai',
formulaAnd: 'dan',
invalid: 'Tidak valid',
showWarning: 'Tampilkan peringatan',
rejectInput: 'Tolak input',
messageInfo: 'Pesan bantuan',
showInfo: 'Tampilkan teks bantuan untuk sel yang dipilih',
rangeError: 'Rentang tidak sah',
allowBlank: 'Izinkan nilai kosong',
},
date: {
title: 'Tanggal',
},
list: {
title: 'Dropdown',
add: 'Tambah',
options: 'Opsi',
customOptions: 'Kustom',
refOptions: 'Dari rentang',
edit: 'Edit',
},
checkbox: {
title: 'Kotak centang',
tips: 'Gunakan nilai kustom dalam sel',
checked: 'Nilai terpilih',
unchecked: 'Nilai tidak terpilih',
},
alert: {
title: 'Kesalahan',
ok: 'OK',
},
error: {
title: 'Tidak valid:',
},
renderMode: {
arrow: 'Panah',
chip: 'Chip',
text: 'Teks biasa',
label: 'Gaya tampilan',
},
showTime: {
label: 'Tampilkan TimePicker',
},
permission: {
dialog: {
setStyleErr: 'Rentang ini dilindungi, dan Anda tidak memiliki izin untuk mengatur gaya. Untuk mengatur gaya, silakan hubungi pembuatnya.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Convalida dati',
ribbon: {
setCheckbox: 'Imposta casella di controllo',
clearCheckbox: 'Cancella casella di controllo',
dropdownPresetTitle: 'Applica una preimpostazione:',
editDropdown: 'Modifica opzioni',
clearDropdown: 'Cancella elenco a discesa',
dateTime: 'Data e ora',
presets: {
yes: 'Sì',
no: 'No',
notStarted: 'Non iniziato',
inProgress: 'In corso',
completed: 'Completato',
option1: 'Opzione 1',
option2: 'Opzione 2',
},
},
operators: {
legal: 'è un tipo valido',
},
validFail: {
formulaError: 'L\'intervallo di riferimento contiene dati invisibili, riadatta l\'intervallo',
},
panel: {
title: 'Gestione convalida dati',
addTitle: 'Crea nuova convalida dati',
removeAll: 'Rimuovi Tutto',
add: 'Aggiungi Regola',
range: 'Intervalli',
type: 'Tipo',
options: 'Opzioni avanzate',
operator: 'Operatore',
removeRule: 'Rimuovi',
done: 'Fatto',
formulaPlaceholder: 'Inserisci valore o formula',
valuePlaceholder: 'Inserisci valore',
formulaAnd: 'e',
invalid: 'Non valido',
showWarning: 'Mostra avviso',
rejectInput: 'Rifiuta input',
messageInfo: 'Messaggio di aiuto',
showInfo: 'Mostra testo di aiuto per una cella selezionata',
rangeError: 'Gli intervalli non sono validi',
allowBlank: 'Consenti valori vuoti',
},
date: {
title: 'Data',
},
list: {
title: 'Menu a discesa',
add: 'Aggiungi',
options: 'Opzioni',
customOptions: 'Personalizzato',
refOptions: 'Da un intervallo',
edit: 'Modifica',
},
checkbox: {
title: 'Casella di controllo',
tips: 'Usa valori personalizzati all\'interno delle celle',
checked: 'Valore selezionato',
unchecked: 'Valore deselezionato',
},
alert: {
title: 'Errore',
ok: 'OK',
},
error: {
title: 'Non valido:',
},
renderMode: {
arrow: 'Freccia',
chip: 'Chip',
text: 'Testo semplice',
label: 'Stile di visualizzazione',
},
showTime: {
label: 'Mostra TimePicker',
},
permission: {
dialog: {
setStyleErr: 'L\'intervallo è protetto e non hai l\'autorizzazione per impostare gli stili. Per impostare gli stili, contatta il creatore.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'データの入力規則',
ribbon: {
setCheckbox: 'チェックボックスを設定',
clearCheckbox: 'チェックボックスをクリア',
dropdownPresetTitle: 'プリセットを適用:',
editDropdown: 'オプションを編集',
clearDropdown: 'プルダウンをクリア',
dateTime: '日付と時刻',
presets: {
yes: 'はい',
no: 'いいえ',
notStarted: '未着手',
inProgress: '進行中',
completed: '完了',
option1: 'オプション 1',
option2: 'オプション 2',
},
},
operators: {
legal: '有効な形式',
},
validFail: {
formulaError: '参照範囲に見えないデータが含まれています。範囲を再設定してください',
},
panel: {
title: 'データの入力規則の管理',
addTitle: '新しい入力規則を追加',
removeAll: 'すべて削除',
add: 'ルールを追加',
range: '適用範囲',
type: '条件',
options: '詳細オプション',
operator: 'データ',
removeRule: 'ルールを削除',
done: '完了',
formulaPlaceholder: '値または数式を入力',
valuePlaceholder: '値を入力',
formulaAnd: 'と',
invalid: '無効なデータ',
showWarning: '警告を表示',
rejectInput: '入力を拒否',
messageInfo: '入力時メッセージ',
showInfo: 'セルを選択したときに入力時メッセージを表示する',
rangeError: '範囲が正しくありません',
allowBlank: '空白を無視する',
},
date: {
title: '日付',
},
list: {
title: 'リスト',
add: '追加',
options: 'オプション',
customOptions: 'カスタム',
refOptions: '範囲を参照',
edit: '編集',
},
checkbox: {
title: 'チェックボックス',
tips: 'セル内のカスタム値を使用してください',
checked: 'チェックされた値',
unchecked: 'チェックされていない値',
},
alert: {
title: 'エラー',
ok: 'OK',
},
error: {
title: '無効な値:',
},
renderMode: {
arrow: '矢印',
chip: 'チップ',
text: 'プレーンテキスト',
label: '表示スタイル',
},
showTime: {
label: '時間セレクターを表示',
},
permission: {
dialog: {
setStyleErr: 'この範囲は保護されており、スタイルを変更する権限がありません。変更するには作成者に連絡してください。',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: '데이터 유효성 검사',
ribbon: {
setCheckbox: '체크박스 설정',
clearCheckbox: '체크박스 지우기',
dropdownPresetTitle: '프리셋 적용:',
editDropdown: '옵션 편집',
clearDropdown: '드롭다운 지우기',
dateTime: '날짜 및 시간',
presets: {
yes: '예',
no: '아니요',
notStarted: '시작 전',
inProgress: '진행 중',
completed: '완료',
option1: '옵션 1',
option2: '옵션 2',
},
},
operators: {
legal: '유효한 형식',
},
validFail: {
formulaError: '참조 범위에 보이지 않는 데이터가 포함되어 있습니다. 범위를 다시 설정하세요',
},
panel: {
title: '데이터 유효성 검사 관리',
addTitle: '새 데이터 유효성 검사 추가',
removeAll: '모두 제거',
add: '규칙 추가',
range: '적용 범위',
type: '유형',
options: '고급 옵션',
operator: '연산자',
removeRule: '규칙 제거',
done: '완료',
formulaPlaceholder: '값 또는 수식 입력',
valuePlaceholder: '값 입력',
formulaAnd: '그리고',
invalid: '유효하지 않음',
showWarning: '경고 표시',
rejectInput: '입력 거부',
messageInfo: '도움말 메시지',
showInfo: '선택한 셀에 도움말 표시',
rangeError: '범위가 올바르지 않습니다',
allowBlank: '빈 셀 허용',
},
date: {
title: '날짜',
},
list: {
title: '목록',
add: '추가',
options: '옵션',
customOptions: '사용자 지정',
refOptions: '범위 참조',
edit: '수정',
},
checkbox: {
title: '체크박스',
tips: '셀 내 사용자 지정 값을 사용하세요',
checked: '선택된 값',
unchecked: '선택되지 않은 값',
},
alert: {
title: '오류',
ok: '확인',
},
error: {
title: '잘못된 값:',
},
renderMode: {
arrow: '화살표',
chip: '칩',
text: '일반 텍스트',
label: '표시 스타일',
},
showTime: {
label: '시간 선택기 표시',
},
permission: {
dialog: {
setStyleErr: '해당 범위는 보호되어 있어 스타일 지정 권한이 없습니다. 스타일을 변경하려면 작성자에게 문의하세요.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Sprawdzanie poprawności danych',
ribbon: {
setCheckbox: 'Ustaw pole wyboru',
clearCheckbox: 'Wyczyść pole wyboru',
dropdownPresetTitle: 'Zastosuj ustawienie wstępne:',
editDropdown: 'Edytuj opcje',
clearDropdown: 'Wyczyść listę rozwijaną',
dateTime: 'Data i czas',
presets: {
yes: 'Tak',
no: 'Nie',
notStarted: 'Nierozpoczęte',
inProgress: 'W toku',
completed: 'Ukończone',
option1: 'Opcja 1',
option2: 'Opcja 2',
},
},
operators: {
legal: 'jest prawidłowym typem',
},
validFail: {
formulaError: 'Zakres odwołania zawiera niewidoczne dane. Dostosuj zakres.',
},
panel: {
title: 'Zarządzanie sprawdzaniem poprawności danych',
addTitle: 'Utwórz nowe sprawdzanie poprawności danych',
removeAll: 'Usuń wszystko',
add: 'Dodaj regułę',
range: 'Zakresy',
type: 'Typ',
options: 'Opcje zaawansowane',
operator: 'Operator',
removeRule: 'Usuń',
done: 'Gotowe',
formulaPlaceholder: 'Wprowadź wartość lub formułę',
valuePlaceholder: 'Wprowadź wartość',
formulaAnd: 'i',
invalid: 'Nieprawidłowy',
showWarning: 'Pokaż ostrzeżenie',
rejectInput: 'Odrzuć wprowadzenie',
messageInfo: 'Wiadomość pomocnicza',
showInfo: 'Pokaż tekst pomocy dla zaznaczonej komórki',
rangeError: 'Zakresy są nieprawidłowe',
allowBlank: 'Zezwalaj na puste wartości',
},
date: {
title: 'Data',
},
list: {
title: 'Lista rozwijana',
add: 'Dodaj',
options: 'Opcje',
customOptions: 'Niestandardowe',
refOptions: 'Z zakresu',
edit: 'Edytuj',
},
checkbox: {
title: 'Pole wyboru',
tips: 'Użyj niestandardowych wartości w komórkach',
checked: 'Wartość zaznaczona',
unchecked: 'Wartość niezaznaczona',
},
alert: {
title: 'Błąd',
ok: 'OK',
},
error: {
title: 'Nieprawidłowe:',
},
renderMode: {
arrow: 'Strzałka',
chip: 'Znacznik',
text: 'Zwykły tekst',
label: 'Styl wyświetlania',
},
showTime: {
label: 'Pokaż selektor czasu',
},
permission: {
dialog: {
setStyleErr: 'Zakres jest chroniony i nie masz uprawnień do ustawiania stylów. Aby ustawić style, skontaktuj się z twórcą.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Validação de dados',
ribbon: {
setCheckbox: 'Definir caixa de seleção',
clearCheckbox: 'Limpar caixa de seleção',
dropdownPresetTitle: 'Aplicar predefinição:',
editDropdown: 'Editar opções',
clearDropdown: 'Limpar lista suspensa',
dateTime: 'Data e hora',
presets: {
yes: 'Sim',
no: 'Não',
notStarted: 'Não iniciado',
inProgress: 'Em andamento',
completed: 'Concluído',
option1: 'Opção 1',
option2: 'Opção 2',
},
},
operators: {
legal: 'é tipo válido',
},
validFail: {
formulaError: 'O intervalo de referência contém dados invisíveis, ajuste o intervalo',
},
panel: {
title: 'Gerenciamento de validação de dados',
addTitle: 'Criar nova validação de dados',
removeAll: 'Remover tudo',
add: 'Adicionar regra',
range: 'Intervalos',
type: 'Tipo',
options: 'Opções avançadas',
operator: 'Operador',
removeRule: 'Remover',
done: 'Concluir',
formulaPlaceholder: 'Insira um valor ou fórmula',
valuePlaceholder: 'Insira um valor',
formulaAnd: 'e',
invalid: 'Inválido',
showWarning: 'Mostrar aviso',
rejectInput: 'Rejeitar entrada',
messageInfo: 'Mensagem de ajuda',
showInfo: 'Mostrar texto de ajuda para uma célula selecionada',
rangeError: 'Os intervalos não são válidos',
allowBlank: 'Permitir valores em branco',
},
date: {
title: 'Data',
},
list: {
title: 'Lista suspensa',
add: 'Adicionar',
options: 'Opções',
customOptions: 'Personalizado',
refOptions: 'De um intervalo',
edit: 'Editar',
},
checkbox: {
title: 'Caixa de seleção',
tips: 'Use valores personalizados dentro das células',
checked: 'Valor selecionado',
unchecked: 'Valor não selecionado',
},
alert: {
title: 'Erro',
ok: 'OK',
},
error: {
title: 'Inválido:',
},
renderMode: {
arrow: 'Seta',
chip: 'Chip',
text: 'Texto simples',
label: 'Estilo de exibição',
},
showTime: {
label: 'Mostrar seletor de hora',
},
permission: {
dialog: {
setStyleErr: 'O intervalo está protegido e você não tem permissão para definir estilos. Para definir estilos, entre em contato com o criador.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Проверка данных',
ribbon: {
setCheckbox: 'Установить флажок',
clearCheckbox: 'Очистить флажок',
dropdownPresetTitle: 'Применить шаблон:',
editDropdown: 'Изменить варианты',
clearDropdown: 'Очистить раскрывающийся список',
dateTime: 'Дата и время',
presets: {
yes: 'Да',
no: 'Нет',
notStarted: 'Не начато',
inProgress: 'В процессе',
completed: 'Завершено',
option1: 'Вариант 1',
option2: 'Вариант 2',
},
},
operators: {
legal: 'является допустимым типом',
},
validFail: {
formulaError: 'Диапазон ссылок содержит невидимые данные, пожалуйста, пересмотрите диапазон',
},
panel: {
title: 'Управление проверкой данных',
addTitle: 'Создать новую проверку данных',
removeAll: 'Удалить все',
add: 'Добавить правило',
range: 'Диапазоны',
rangeError: 'Диапазоны не являются законными',
type: 'Тип',
options: 'Дополнительные параметры',
operator: 'Оператор',
removeRule: 'Удалить',
done: 'Готово',
formulaPlaceholder: 'Пожалуйста, введите значение или формулу',
valuePlaceholder: 'Пожалуйста, введите значение',
formulaAnd: 'и',
invalid: 'Недопустимо',
showWarning: 'Показать предупреждение',
rejectInput: 'Отклонить ввод',
messageInfo: 'Сообщение помощи',
showInfo: 'Показать текст помощи для выбранной ячейки',
allowBlank: 'Игнорировать пустые значения',
},
date: {
title: 'Дата',
},
list: {
title: 'Выпадающий список',
add: 'Добавить',
options: 'Параметры',
customOptions: 'Пользовательские',
refOptions: 'Из диапазона',
edit: 'Редактировать',
},
checkbox: {
title: 'Флажок',
tips: 'Используйте пользовательские значения в ячейках',
checked: 'Выбранное значение',
unchecked: 'Не выбранное значение',
},
alert: {
title: 'Ошибка',
ok: 'OK',
},
error: {
title: 'Недопустимо:',
},
renderMode: {
arrow: 'Стрелка',
chip: 'Чип',
text: 'Обычный текст',
label: 'Стиль отображения',
},
showTime: {
label: 'Показать выбор времени',
},
permission: {
dialog: {
setStyleErr: 'Диапазон защищен, и у вас нет разрешения на установку стилей. Для установки стилей свяжитесь с создателем.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: 'Overenie údajov',
ribbon: {
setCheckbox: 'Nastaviť začiarkavacie políčko',
clearCheckbox: 'Vymazať začiarkavacie políčko',
dropdownPresetTitle: 'Použiť predvoľbu:',
editDropdown: 'Upraviť možnosti',
clearDropdown: 'Vymazať rozbaľovací zoznam',
dateTime: 'Dátum a čas',
presets: {
yes: 'Áno',
no: 'Nie',
notStarted: 'Nezačaté',
inProgress: 'Prebieha',
completed: 'Dokončené',
option1: 'Možnosť 1',
option2: 'Možnosť 2',
},
},
operators: {
legal: 'je platný typ',
},
validFail: {
formulaError: 'Referenčný rozsah obsahuje neviditeľné údaje, upravte rozsah',
},
panel: {
title: 'Správa overovania údajov',
addTitle: 'Vytvoriť nové overenie údajov',
removeAll: 'Odstrániť všetko',
add: 'Pridať pravidlo',
range: 'Rozsahy',
type: 'Typ',
options: 'Rozšírené možnosti',
operator: 'Operátor',
removeRule: 'Odstrániť',
done: 'Hotovo',
formulaPlaceholder: 'Zadajte hodnotu alebo vzorec',
valuePlaceholder: 'Zadajte hodnotu',
formulaAnd: 'a',
invalid: 'Neplatné',
showWarning: 'Zobraziť upozornenie',
rejectInput: 'Odmietnuť vstup',
messageInfo: 'Pomocná správa',
showInfo: 'Zobraziť pomocný text pre vybranú bunku',
rangeError: 'Rozsahy nie sú platné',
allowBlank: 'Povoliť prázdne hodnoty',
},
date: {
title: 'Dátum',
},
list: {
title: 'Rozbaľovací zoznam',
add: 'Pridať',
options: 'Možnosti',
customOptions: 'Vlastné',
refOptions: 'Z rozsahu',
edit: 'Upraviť',
},
checkbox: {
title: 'Začiarkavacie políčko',
tips: 'Použite vlastné hodnoty v bunkách',
checked: 'Vybraná hodnota',
unchecked: 'Nevybraná hodnota',
},
alert: {
title: 'Chyba',
ok: 'OK',
},
error: {
title: 'Neplatné:',
},
renderMode: {
arrow: 'Šípka',
chip: 'Odznak',
text: 'Čistý text',
label: 'Štýl zobrazenia',
},
showTime: {
label: 'Zobraziť výber času',
},
permission: {
dialog: {
setStyleErr: 'Rozsah je chránený a nemáte oprávnenie nastavovať štýly. Ak chcete nastavovať štýly, kontaktujte autora.',
},
},
},
};
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,108 @@
/**
* 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-data-validation-ui': {
title: 'Xác thực dữ liệu',
ribbon: {
setCheckbox: 'Đặt hộp kiểm',
clearCheckbox: 'Xóa hộp kiểm',
dropdownPresetTitle: 'Áp dụng mẫu:',
editDropdown: 'Chỉnh sửa tùy chọn',
clearDropdown: 'Xóa danh sách thả xuống',
dateTime: 'Ngày giờ',
presets: {
yes: 'Có',
no: 'Không',
notStarted: 'Chưa bắt đầu',
inProgress: 'Đang thực hiện',
completed: 'Hoàn thành',
option1: 'Tùy chọn 1',
option2: 'Tùy chọn 2',
},
},
operators: {
legal: 'là kiểu hợp lệ',
},
validFail: {
formulaError: 'Phạm vi tham chiếu chứa dữ liệu không hiển thị, vui lòng điều chỉnh lại phạm vi',
},
panel: {
title: 'Quản lý xác thực dữ liệu',
addTitle: 'Tạo xác thực dữ liệu mới',
removeAll: 'Xóa tất cả',
add: 'Tạo quy tắc mới',
range: 'Phạm vi áp dụng',
rangeError: 'Phạm vi không hợp pháp',
type: 'Loại điều kiện',
options: 'Cài đặt nâng cao',
operator: 'Dữ liệu',
removeRule: 'Xóa quy tắc',
done: 'Xác nhận',
formulaPlaceholder: 'Vui lòng nhập giá trị hoặc công thức',
valuePlaceholder: 'Vui lòng nhập giá trị',
formulaAnd: 'Và',
invalid: 'Dữ liệu không hợp lệ khi',
showWarning: 'Hiển thị cảnh báo',
rejectInput: 'Từ chối đầu vào',
messageInfo: 'Thông báo',
showInfo: 'Hiển thị thông báo cho ô đã chọn',
allowBlank: 'Bỏ qua giá trị rỗng',
},
date: {
title: 'Ngày',
},
list: {
title: 'Danh sách thả xuống',
add: 'Thêm tùy chọn',
options: 'Nguồn tùy chọn',
customOptions: 'Tùy chỉnh',
refOptions: 'Tham chiếu dữ liệu',
edit: 'Biên tập',
},
checkbox: {
title: 'Hộp kiểm',
tips: 'Sử dụng giá trị tùy chỉnh trong ô',
checked: 'Giá trị khi chọn',
unchecked: 'Giá trị khi không chọn',
},
alert: {
title: 'Thông báo',
ok: 'Xác nhận',
},
error: {
title: 'Không hợp lệ:',
},
renderMode: {
arrow: 'Mũi tên',
chip: 'Nhãn dạng thanh',
text: 'Văn bản thuần túy',
label: 'Hiển thị kiểu',
},
showTime: {
label: 'Hiển thị lựa chọn thởi gian',
},
permission: {
dialog: {
setStyleErr: 'Phạm vi này đã được bảo vệ, hiện không có quyền thiết lập kiểu. Nếu cần thiết lập kiểu, vui lòng liên hệ với người tạo.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: '数据验证',
ribbon: {
setCheckbox: '设置复选框',
clearCheckbox: '清除复选框',
dropdownPresetTitle: '应用预设:',
editDropdown: '编辑选项',
clearDropdown: '清除下拉菜单',
dateTime: '日期时间',
presets: {
yes: '是',
no: '否',
notStarted: '未开始',
inProgress: '进行中',
completed: '已完成',
option1: '选项 1',
option2: '选项 2',
},
},
operators: {
legal: '是合法类型',
},
validFail: {
formulaError: '引用范围内包含不可见的数据,请重新调整范围',
},
panel: {
title: '管理数据验证',
addTitle: '新建数据验证',
removeAll: '全部删除',
add: '新建规则',
range: '应用范围',
rangeError: '请输入合法的应用范围',
type: '条件类型',
options: '高级设置',
operator: '数据',
removeRule: '删除规则',
done: '确认',
formulaPlaceholder: '请输入值或公式',
valuePlaceholder: '请输入值',
formulaAnd: '与',
invalid: '数据无效时',
showWarning: '显示警告',
rejectInput: '拒绝输入',
messageInfo: '文字提示',
showInfo: '显示所选单元格的提示文字',
allowBlank: '忽略空值',
},
date: {
title: '日期',
},
list: {
title: '下拉菜单',
add: '添加选项',
options: '选项来源',
customOptions: '自定义',
refOptions: '引用数据',
edit: '编辑',
},
checkbox: {
title: '复选框',
tips: '在单元格内使用自定义值',
checked: '选中值',
unchecked: '未选中值',
},
alert: {
title: '提示',
ok: '确定',
},
error: {
title: '无效:',
},
renderMode: {
arrow: '箭头',
chip: '条状标签',
text: '纯文本',
label: '显示样式',
},
showTime: {
label: '展示时间选择',
},
permission: {
dialog: {
setStyleErr: '该范围已被保护,目前无设置样式权限。如需设置样式,请联系创建者。',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: '資料驗證',
ribbon: {
setCheckbox: '設定核取方塊',
clearCheckbox: '清除核取方塊',
dropdownPresetTitle: '套用預設:',
editDropdown: '編輯選項',
clearDropdown: '清除下拉式選單',
dateTime: '日期時間',
presets: {
yes: '是',
no: '否',
notStarted: '未開始',
inProgress: '進行中',
completed: '已完成',
option1: '選項 1',
option2: '選項 2',
},
},
operators: {
legal: '是合法類型',
},
validFail: {
formulaError: '引用範圍內包含不可見的數據,請重新調整範圍',
},
panel: {
title: '管理資料驗證',
addTitle: '新資料驗證',
removeAll: '全部刪除',
add: '新建規則',
range: '應用範圍',
rangeError: '應用範圍不合法',
type: '條件型別',
options: '進階設定',
operator: '資料',
removeRule: '刪除規則',
done: '確認',
formulaPlaceholder: '請輸入數值或公式',
valuePlaceholder: '請輸入值',
formulaAnd: '與',
invalid: '資料無效時',
showWarning: '顯示警告',
rejectInput: '拒絕輸入',
messageInfo: '文字提示',
showInfo: '顯示所選儲存格的提示文字',
allowBlank: '忽略空值',
},
date: {
title: '日期',
},
list: {
title: '下拉選單',
add: '新增選項',
options: '選項來源',
customOptions: '自訂',
refOptions: '引用資料',
edit: '編輯',
},
checkbox: {
title: '複選框',
tips: '在儲存格內使用自訂值',
checked: '選取值',
unchecked: '未選取值',
},
alert: {
title: '提示',
ok: '確定',
},
error: {
title: '無效:',
},
renderMode: {
arrow: '箭頭',
chip: '條狀標籤',
text: '純文字',
label: '顯示樣式',
},
showTime: {
label: '展示時間選擇',
},
permission: {
dialog: {
setStyleErr: '該範圍已被保護,目前無設定樣式權限。如需設定樣式,請聯絡建立者。 ',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,108 @@
/**
* 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-data-validation-ui': {
title: '資料驗證',
ribbon: {
setCheckbox: '設定核取方塊',
clearCheckbox: '清除核取方塊',
dropdownPresetTitle: '套用預設:',
editDropdown: '編輯選項',
clearDropdown: '清除下拉式選單',
dateTime: '日期時間',
presets: {
yes: '是',
no: '否',
notStarted: '尚未開始',
inProgress: '進行中',
completed: '已完成',
option1: '選項 1',
option2: '選項 2',
},
},
operators: {
legal: '是合法類型',
},
validFail: {
formulaError: '引用範圍內包含不可見的數據,請重新調整範圍',
},
panel: {
title: '管理資料驗證',
addTitle: '新資料驗證',
removeAll: '全部刪除',
add: '新建規則',
range: '應用範圍',
rangeError: '應用範圍不合法',
type: '條件型別',
options: '進階設定',
operator: '資料',
removeRule: '刪除規則',
done: '確認',
formulaPlaceholder: '請輸入數值或公式',
valuePlaceholder: '請輸入值',
formulaAnd: '與',
invalid: '資料無效時',
showWarning: '顯示警告',
rejectInput: '拒絕輸入',
messageInfo: '文字提示',
showInfo: '顯示所選儲存格的提示文字',
allowBlank: '忽略空值',
},
date: {
title: '日期',
},
list: {
title: '下拉選單',
add: '新增選項',
options: '選項來源',
customOptions: '自訂',
refOptions: '引用資料',
edit: '編輯',
},
checkbox: {
title: '複選框',
tips: '在儲存格內使用自訂值',
checked: '選取值',
unchecked: '未選取值',
},
alert: {
title: '提示',
ok: '確定',
},
error: {
title: '無效:',
},
renderMode: {
arrow: '箭頭',
chip: '條狀標籤',
text: '純文字',
label: '顯示樣式',
},
showTime: {
label: '展示時間選擇',
},
permission: {
dialog: {
setStyleErr: '該範圍已被保護,目前無設定樣式權限。如需設定樣式,請聯絡建立者。 ',
},
},
},
};
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.
*/
import { describe, expect, it } from 'vitest';
import { OpenValidationPanelOperation } from '../../commands/operations/data-validation.operation';
import { openDataValidationMenuFactory } from '../dv.menu';
describe('data validation menu', () => {
it('passes the empty parameter object required to open the manager', () => {
expect(openDataValidationMenuFactory()).toMatchObject({
id: OpenValidationPanelOperation.id,
params: {},
});
});
});

View File

@@ -0,0 +1,218 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor } from '@univerjs/core';
import type { IMenuItem, IMenuSelectorItem } from '@univerjs/ui';
import type { IQuickDataValidationRuleInput } from '../commands/commands/data-validation-ui.command';
import type { LocaleKey } from '../locale/types';
import { DataValidationRenderMode, DataValidationType, LocaleService, UniverInstanceType } from '@univerjs/core';
import {
RangeProtectionPermissionEditPoint,
serializeListOptions,
WorkbookEditablePermission,
WorksheetEditPermission,
WorksheetSetCellStylePermission,
} from '@univerjs/sheets';
import { getCurrentRangeDisable$ } from '@univerjs/sheets-ui';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import {
AddSheetDataValidationAndOpenCommand,
ClearQuickSheetDataValidationCommand,
InsertQuickSheetDataValidationCommand,
} from '../commands/commands/data-validation-ui.command';
import { OpenValidationPanelOperation } from '../commands/operations/data-validation.operation';
import { DROPDOWN_PRESETS_COMPONENT } from '../views/components/DropdownPresets';
export const DATA_VALIDATION_MENU_ID = 'sheet.menu.data-validation';
export const QUICK_DROPDOWN_MENU_ID = 'sheet.menu.quick-dropdown';
export const QUICK_DATE_MENU_ID = 'sheet.menu.quick-date';
const CHECKBOX_RULE_INPUT: IQuickDataValidationRuleInput = {
type: DataValidationType.CHECKBOX,
operator: undefined,
formula1: undefined,
formula2: undefined,
};
const DATE_RULE_INPUT: IQuickDataValidationRuleInput = {
type: DataValidationType.DATE,
operator: undefined,
formula1: undefined,
formula2: undefined,
};
const DATE_TIME_RULE_INPUT: IQuickDataValidationRuleInput = {
...DATE_RULE_INPUT,
bizInfo: { showTime: true },
};
function createDropdownRuleInput(formula1: string): IQuickDataValidationRuleInput {
return {
type: DataValidationType.LIST,
operator: undefined,
formula1,
formula2: '',
renderMode: DataValidationRenderMode.CUSTOM,
};
}
function createDropdownCommandParams(value?: string | number) {
return typeof value === 'undefined' ? {} : { rule: createDropdownRuleInput(String(value)) };
}
function getQuickDataValidationMenuState(accessor: IAccessor) {
return {
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}),
};
}
export function dataValidationMenuFactory(accessor: IAccessor): IMenuItem<LocaleKey> {
return {
id: DATA_VALIDATION_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'DataValidationIcon',
tooltip: 'sheets-data-validation-ui.title',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookEditablePermission],
worksheetTypes: [WorksheetSetCellStylePermission, WorksheetEditPermission],
rangeTypes: [RangeProtectionPermissionEditPoint],
}),
};
}
export function quickCheckboxMenuFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
return {
id: InsertQuickSheetDataValidationCommand.id,
type: MenuItemType.BUTTON_SELECTOR,
icon: 'CheckboxIcon',
tooltip: 'sheets-data-validation-ui.checkbox.title',
params: { rule: CHECKBOX_RULE_INPUT },
selectionsCommandId: InsertQuickSheetDataValidationCommand.id,
selections: [
{
label: 'sheets-data-validation-ui.ribbon.setCheckbox',
value: DataValidationType.CHECKBOX,
params: { rule: CHECKBOX_RULE_INPUT },
},
{
label: 'sheets-data-validation-ui.ribbon.clearCheckbox',
id: ClearQuickSheetDataValidationCommand.id,
value: DataValidationType.CHECKBOX,
params: { types: [DataValidationType.CHECKBOX] },
},
],
...getQuickDataValidationMenuState(accessor),
};
}
export function quickDropdownMenuFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
const localeService = accessor.get(LocaleService);
const editDropdownValue = serializeListOptions([
localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.presets.option1'),
localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.presets.option2'),
]);
return {
id: QUICK_DROPDOWN_MENU_ID,
type: MenuItemType.SUBITEMS,
icon: 'DropdownListIcon',
tooltip: 'sheets-data-validation-ui.list.title',
selectionsCommandId: InsertQuickSheetDataValidationCommand.id,
selections: [
{
label: {
name: DROPDOWN_PRESETS_COMPONENT,
hoverable: false,
selectable: false,
},
params: createDropdownCommandParams,
},
{
id: AddSheetDataValidationAndOpenCommand.id,
label: 'sheets-data-validation-ui.ribbon.editDropdown',
value: editDropdownValue,
params: { rule: createDropdownRuleInput(editDropdownValue) },
},
{
id: ClearQuickSheetDataValidationCommand.id,
label: 'sheets-data-validation-ui.ribbon.clearDropdown',
value: DataValidationType.LIST,
params: { types: [DataValidationType.LIST, DataValidationType.LIST_MULTIPLE] },
},
],
...getQuickDataValidationMenuState(accessor),
};
}
export function quickDateMenuFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
return {
id: QUICK_DATE_MENU_ID,
commandId: InsertQuickSheetDataValidationCommand.id,
type: MenuItemType.BUTTON_SELECTOR,
icon: 'DatePickerIcon',
tooltip: 'sheets-data-validation-ui.date.title',
params: { rule: DATE_RULE_INPUT },
selectionsCommandId: InsertQuickSheetDataValidationCommand.id,
selections: [
{
label: 'sheets-data-validation-ui.date.title',
value: DataValidationType.DATE,
params: { rule: DATE_RULE_INPUT },
},
{
label: 'sheets-data-validation-ui.ribbon.dateTime',
value: DataValidationType.DATE,
params: { rule: DATE_TIME_RULE_INPUT },
},
{
id: AddSheetDataValidationAndOpenCommand.id,
label: 'sheets-data-validation-ui.list.edit',
value: DataValidationType.DATE,
params: { rule: DATE_RULE_INPUT },
},
{
id: ClearQuickSheetDataValidationCommand.id,
label: 'sheets-data-validation-ui.panel.removeRule',
value: DataValidationType.DATE,
params: { types: [DataValidationType.DATE] },
},
],
...getQuickDataValidationMenuState(accessor),
};
}
export function openDataValidationMenuFactory(): IMenuItem<LocaleKey> {
return {
id: OpenValidationPanelOperation.id,
title: 'sheets-data-validation-ui.panel.title',
type: MenuItemType.BUTTON,
params: {},
};
}
export function addDataValidationMenuFactory(): IMenuItem<LocaleKey> {
return {
id: AddSheetDataValidationAndOpenCommand.id,
title: 'sheets-data-validation-ui.panel.add',
type: MenuItemType.BUTTON,
};
}

View File

@@ -0,0 +1,66 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { RibbonDataGroup, RibbonInsertGroup } from '@univerjs/ui';
import {
AddSheetDataValidationAndOpenCommand,
InsertQuickSheetDataValidationCommand,
} from '../commands/commands/data-validation-ui.command';
import { OpenValidationPanelOperation } from '../commands/operations/data-validation.operation';
import {
addDataValidationMenuFactory,
DATA_VALIDATION_MENU_ID,
dataValidationMenuFactory,
openDataValidationMenuFactory,
QUICK_DATE_MENU_ID,
QUICK_DROPDOWN_MENU_ID,
quickCheckboxMenuFactory,
quickDateMenuFactory,
quickDropdownMenuFactory,
} from './dv.menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.CELL]: {
[InsertQuickSheetDataValidationCommand.id]: {
order: 0,
menuItemFactory: quickCheckboxMenuFactory,
},
[QUICK_DROPDOWN_MENU_ID]: {
order: 1,
menuItemFactory: quickDropdownMenuFactory,
},
[QUICK_DATE_MENU_ID]: {
order: 2,
menuItemFactory: quickDateMenuFactory,
},
},
[RibbonDataGroup.RULES]: {
[DATA_VALIDATION_MENU_ID]: {
order: 0,
gridLayout: { row: 1, column: 1, showLabel: true },
menuItemFactory: dataValidationMenuFactory,
[OpenValidationPanelOperation.id]: {
order: 0,
menuItemFactory: openDataValidationMenuFactory,
},
[AddSheetDataValidationAndOpenCommand.id]: {
order: 1,
menuItemFactory: addDataValidationMenuFactory,
},
},
},
};

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 { Dependency } from '@univerjs/core';
import type { IUniverSheetsDataValidationUIConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDataValidationPlugin } from '@univerjs/data-validation';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation';
import { UniverSheetsMobileUIPlugin } from '@univerjs/sheets-ui';
import { UniverMobileUIPlugin } from '@univerjs/ui';
import pkg from '../package.json';
import { ClearQuickSheetDataValidationCommand, InsertQuickSheetDataValidationCommand } from './commands/commands/data-validation-ui.command';
import {
HideDataValidationDropdown,
ShowDataValidationDropdown,
} from './commands/operations/data-validation.operation';
import { defaultPluginConfig, SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { DataValidationAlertController } from './controllers/dv-alert.controller';
import { DataValidationAutoFillController } from './controllers/dv-auto-fill.controller';
import { DataValidationCopyPasteController } from './controllers/dv-copy-paste.controller';
import { DataValidationPermissionController } from './controllers/dv-permission.controller';
import { SheetsDataValidationReRenderController } from './controllers/dv-rerender.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { SheetsDataValidationMobileRenderController } from './controllers/mobile/dv-render.controller';
import { SheetsDataValidationUIController } from './controllers/ui.controller';
import { DataValidationDropdownManagerService } from './services/dropdown-manager.service';
@DependentOn(
UniverDataValidationPlugin,
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverMobileUIPlugin,
UniverSheetsMobileUIPlugin,
UniverSheetsDataValidationPlugin
)
export class UniverSheetsDataValidationMobileUIPlugin extends Plugin {
static override pluginName: string = 'SHEET_DATA_VALIDATION_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsDataValidationUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@ICommandService private readonly _commandService: ICommandService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([MobileComponentsController]);
this._injector.get(MobileComponentsController);
([
[DataValidationDropdownManagerService],
[DataValidationAlertController],
[DataValidationAutoFillController],
[SheetsDataValidationMobileRenderController],
[DataValidationPermissionController],
[DataValidationCopyPasteController],
[SheetsDataValidationUIController],
] as Dependency[]).forEach((dep) => {
this._injector.add(dep);
});
[
InsertQuickSheetDataValidationCommand,
ClearQuickSheetDataValidationCommand,
ShowDataValidationDropdown,
HideDataValidationDropdown,
].forEach((command) => {
this._commandService.registerCommand(command);
});
}
override onReady(): void {
this._injector.get(DataValidationCopyPasteController);
this._injector.get(DataValidationPermissionController);
const renderManager = this._injector.get(IRenderManagerService);
renderManager.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsDataValidationReRenderController] as Dependency
);
}
override onRendered(): void {
this._injector.get(SheetsDataValidationUIController);
this._injector.get(SheetsDataValidationMobileRenderController);
}
override onSteady(): void {
this._injector.get(DataValidationAutoFillController);
}
}

View File

@@ -0,0 +1,136 @@
/**
* 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 { IUniverSheetsDataValidationUIConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDataValidationPlugin } from '@univerjs/data-validation';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsDataValidationPlugin } from '@univerjs/sheets-data-validation';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import pkg from '../package.json';
import {
AddSheetDataValidationAndOpenCommand,
ClearQuickSheetDataValidationCommand,
InsertQuickSheetDataValidationCommand,
} from './commands/commands/data-validation-ui.command';
import {
CloseValidationPanelOperation,
HideDataValidationDropdown,
OpenValidationPanelOperation,
ShowDataValidationDropdown,
ToggleValidationPanelOperation,
} from './commands/operations/data-validation.operation';
import { defaultPluginConfig, SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DataValidationAlertController } from './controllers/dv-alert.controller';
import { DataValidationAutoFillController } from './controllers/dv-auto-fill.controller';
import { DataValidationCopyPasteController } from './controllers/dv-copy-paste.controller';
import { DataValidationPermissionController } from './controllers/dv-permission.controller';
import { DataValidationRejectInputController } from './controllers/dv-reject-input.controller';
import { SheetsDataValidationRenderController } from './controllers/dv-render.controller';
import { SheetsDataValidationReRenderController } from './controllers/dv-rerender.controller';
import { SheetsDataValidationUIController } from './controllers/ui.controller';
import { DataValidationPanelService } from './services/data-validation-panel.service';
import { DataValidationDropdownManagerService } from './services/dropdown-manager.service';
@DependentOn(
UniverDataValidationPlugin,
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverSheetsUIPlugin,
UniverSheetsDataValidationPlugin
)
export class UniverSheetsDataValidationUIPlugin extends Plugin {
static override pluginName: string = 'SHEET_DATA_VALIDATION_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsDataValidationUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@ICommandService private readonly _commandService: ICommandService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
([
[DataValidationPanelService],
[DataValidationDropdownManagerService],
[DataValidationAlertController],
[DataValidationAutoFillController],
[SheetsDataValidationRenderController],
[DataValidationPermissionController],
[DataValidationCopyPasteController],
[DataValidationRejectInputController],
[SheetsDataValidationUIController],
] as Dependency[]).forEach((dep) => {
this._injector.add(dep);
});
[
AddSheetDataValidationAndOpenCommand,
InsertQuickSheetDataValidationCommand,
ClearQuickSheetDataValidationCommand,
ShowDataValidationDropdown,
HideDataValidationDropdown,
CloseValidationPanelOperation,
OpenValidationPanelOperation,
ToggleValidationPanelOperation,
].forEach((command) => {
this._commandService.registerCommand(command);
});
}
override onReady(): void {
this._injector.get(DataValidationCopyPasteController);
this._injector.get(DataValidationPermissionController);
this._injector.get(DataValidationRejectInputController);
this._injector.get(DataValidationAlertController);
const renderManager = this._injector.get(IRenderManagerService);
renderManager.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsDataValidationReRenderController] as Dependency
);
}
override onRendered(): void {
this._injector.get(SheetsDataValidationUIController);
this._injector.get(SheetsDataValidationRenderController);
}
override onSteady(): void {
this._injector.get(DataValidationAutoFillController);
}
}

View File

@@ -0,0 +1,125 @@
/**
* 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 { IDataValidationRule, IUniverInstanceService, Nullable, UnitModel } from '@univerjs/core';
import type { ISidebarMethodOptions, ISidebarService } from '@univerjs/ui';
import type { Observable } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DATA_VALIDATION_PANEL } from '../../commands/operations/data-validation.operation';
import { DataValidationPanelService } from '../data-validation-panel.service';
type SidebarOptions = ISidebarMethodOptions;
type UniverInstanceServiceStub = Pick<IUniverInstanceService, 'getCurrentTypeOfUnit$'>;
type SidebarServiceStub = Pick<ISidebarService, 'sidebarOptions$'>;
describe('DataValidationPanelService', () => {
let sheet$ = new Subject<unknown>();
let sidebarOptions$ = new BehaviorSubject<SidebarOptions>({ visible: true });
beforeEach(() => {
vi.useFakeTimers();
sheet$ = new Subject();
sidebarOptions$ = new BehaviorSubject<SidebarOptions>({ visible: true });
});
afterEach(() => {
vi.useRealTimers();
});
function createService() {
const univerInstanceService: UniverInstanceServiceStub = {
getCurrentTypeOfUnit$<T extends UnitModel<object, number>>(): Observable<Nullable<T>> {
return sheet$.asObservable() as Observable<Nullable<T>>;
},
};
const sidebarService: SidebarServiceStub = {
sidebarOptions$,
};
return new DataValidationPanelService(
univerInstanceService as unknown as IUniverInstanceService,
sidebarService as unknown as ISidebarService
);
}
it('tracks open state, active rules, and runs close disposables exactly once', () => {
const service = createService();
const openStates: boolean[] = [];
const activeRules: Array<Nullable<{ unitId: string; subUnitId: string; rule: IDataValidationRule }>> = [];
const disposeSpy = vi.fn();
const openSub = service.open$.subscribe((value) => openStates.push(value));
const activeSub = service.activeRule$.subscribe((value) => activeRules.push(value));
service.setCloseDisposable({ dispose: disposeSpy });
service.open();
service.setActiveRule({
unitId: 'unit-1',
subUnitId: 'sheet-1',
rule: { uid: 'rule-1' } as IDataValidationRule,
});
service.close();
service.close();
expect(service.isOpen).toBe(false);
expect(service.activeRule).toEqual({
unitId: 'unit-1',
subUnitId: 'sheet-1',
rule: { uid: 'rule-1' },
});
expect(openStates).toEqual([false, true, false]);
expect(activeRules).toEqual([
undefined,
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
rule: { uid: 'rule-1' },
},
]);
expect(disposeSpy).toHaveBeenCalledTimes(1);
openSub.unsubscribe();
activeSub.unsubscribe();
service.dispose();
});
it('closes automatically when the current sheet disappears', () => {
const service = createService();
service.open();
sheet$.next(null);
expect(service.isOpen).toBe(false);
service.dispose();
});
it('re-emits a hidden sidebar state after the data validation panel closes', async () => {
const service = createService();
sidebarOptions$.next({
id: DATA_VALIDATION_PANEL,
visible: false,
});
await vi.runAllTimersAsync();
expect(sidebarOptions$.getValue()).toEqual({ visible: false });
service.dispose();
});
});

View File

@@ -0,0 +1,627 @@
/**
* 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, ICommand, ICommandInfo, IDisposable, IWorkbookData, Workbook } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { IDropdownParam } from '@univerjs/sheets-ui';
import {
awaitTime,
CommandType,
DataValidationErrorStyle,
DataValidationRenderMode,
dateKit,
DateSystem,
excelDateTimePartsToSerial,
ICommandService,
Inject,
Injector,
LocaleType,
Plugin,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DataValidatorDropdownType, DataValidatorRegistryService } from '@univerjs/data-validation';
import { serializeListOptions, SetRangeValuesCommand, SheetsSelectionsService } from '@univerjs/sheets';
import { SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import {
IEditorBridgeService,
ISheetCellDropdownManagerService,
SetCellEditVisibleOperation,
} from '@univerjs/sheets-ui';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { OpenValidationPanelOperation } from '../../commands/operations/data-validation.operation';
import { DataValidationDropdownManagerService } from '../dropdown-manager.service';
interface IRule {
uid: string;
type: string;
formula1?: string;
renderMode?: DataValidationRenderMode;
errorStyle?: DataValidationErrorStyle;
bizInfo?: Record<string, unknown>;
}
interface IValidator {
dropdownType?: DataValidatorDropdownType;
getListWithColor?: (rule: IRule, unitId: string, subUnitId: string) => Array<{ label: string; color?: string }>;
validator?: (context: unknown, rule: IRule) => boolean | Promise<boolean>;
getRuleFinalError?: (rule: IRule, location: unknown) => string;
}
class TestDataValidatorRegistryService {
validator: IValidator | undefined;
getValidatorItem(): IValidator | undefined {
return this.validator;
}
}
class TestSheetDataValidationModel {
rule: IRule | null = null;
getRuleByLocation(_unitId: string, _subUnitId: string, row: number, col: number): IRule | null {
return col === 2 && row >= 1 && row <= 10 ? this.rule : null;
}
}
class TestSheetsSelectionsService {
private readonly _selectionMoveEnd$ = new Subject<ISelectionWithStyle[]>();
readonly selectionMoveEnd$ = this._selectionMoveEnd$.asObservable();
moveEnd(selections: ISelectionWithStyle[]): void {
this._selectionMoveEnd$.next(selections);
}
}
class TestCellDropdownManagerService {
dropdownParam: (IDropdownParam & { onHide?: () => void }) | undefined;
hideCount = 0;
showDropdown(param: IDropdownParam & { onHide?: () => void }): IDisposable {
this.dropdownParam = param;
return toDisposable(() => {
this.hideCount++;
});
}
}
class TestEditorBridgeService {
visible = true;
isVisible(): { visible: boolean } {
return { visible: this.visible };
}
}
interface ITestBed {
univer: Univer;
get: Injector['get'];
workbook: Workbook;
executedCommands: ICommandInfo[];
}
function createWorkbookData(dateSystem = DateSystem.Date1904): IWorkbookData {
return {
id: 'book-1',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
dateSystem,
name: 'test',
sheetOrder: ['sheet-1'],
sheets: {
'sheet-1': {
id: 'sheet-1',
rowCount: 20,
columnCount: 20,
cellData: {
1: {
2: { v: 'Open' },
},
2: {
2: { v: 'East/West' },
},
3: {
2: { v: '#ff0000' },
},
4: {
2: { v: 1.5 },
},
5: {
2: { v: 0.5 },
},
6: {
2: { v: 2.5 },
},
7: {
2: { v: 60.25 + 0.456 / 86400 },
},
8: {
2: { v: 1.25, s: { n: { pattern: '[h]:mm:ss.000' } } },
},
9: {
2: { v: -1 },
},
},
},
},
styles: {},
};
}
function createTestBed(dateSystem = DateSystem.Date1904): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
const executedCommands: ICommandInfo[] = [];
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 dependencies: Dependency[] = [
[DataValidatorRegistryService, { useClass: TestDataValidatorRegistryService }],
[SheetDataValidationModel, { useClass: TestSheetDataValidationModel }],
[SheetsSelectionsService, { useClass: TestSheetsSelectionsService }],
[ISheetCellDropdownManagerService, { useClass: TestCellDropdownManagerService }],
[IEditorBridgeService, { useClass: TestEditorBridgeService }],
[DataValidationDropdownManagerService],
];
dependencies.forEach((dependency) => this._injector.add(dependency));
}
}
univer.registerPlugin(TestPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData(dateSystem));
const commandService = injector.get(ICommandService);
[SetRangeValuesCommand.id, SetCellEditVisibleOperation.id, OpenValidationPanelOperation.id].forEach((id) => {
commandService.registerCommand({
id,
type: CommandType.COMMAND,
handler: (_accessor, params) => {
executedCommands.push({ id, params });
return true;
},
} as ICommand);
});
return {
univer,
get: injector.get.bind(injector),
workbook,
executedCommands,
};
}
function setRule(testBed: ITestBed, rule: IRule, validator: IValidator): void {
(testBed.get(SheetDataValidationModel) as unknown as TestSheetDataValidationModel).rule = rule;
(testBed.get(DataValidatorRegistryService) as unknown as TestDataValidatorRegistryService).validator = validator;
}
function getDropdown(testBed: ITestBed): IDropdownParam {
return (testBed.get(ISheetCellDropdownManagerService) as unknown as TestCellDropdownManagerService).dropdownParam!;
}
function showDropdown(testBed: ITestBed, row: number, col = 2): void {
testBed.get(DataValidationDropdownManagerService).showDataValidationDropdown('book-1', 'sheet-1', row, col);
}
function getSetRangeValue(testBed: ITestBed): unknown {
return (testBed.executedCommands.find((command) => command.id === SetRangeValuesCommand.id)?.params as { value?: unknown } | undefined)?.value;
}
describe('DataValidationDropdownManagerService', () => {
let testBed: ITestBed | undefined;
afterEach(() => {
testBed?.univer.dispose();
testBed = undefined;
});
it('shows a list dropdown, saves the selected item, and opens the validation panel for editing', async () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-list',
type: 'list',
renderMode: DataValidationRenderMode.CUSTOM,
}, {
dropdownType: DataValidatorDropdownType.LIST,
getListWithColor: () => [
{ label: 'Open', color: '#00aa00' },
{ label: 'Closed' },
],
});
showDropdown(testBed, 1);
const dropdown = getDropdown(testBed);
expect(dropdown.type).toBe('list');
expect(dropdown.props).toMatchObject({
defaultValue: 'Open',
multiple: false,
showEdit: true,
showSearch: true,
options: [
{ label: 'Open', value: 'Open', color: '#00aa00' },
{ label: 'Closed', value: 'Closed' },
],
});
await (dropdown.props as { onChange: (value: string[]) => Promise<boolean> }).onChange(['Closed']);
await awaitTime(0);
expect(getSetRangeValue(testBed)).toEqual({
v: 'Closed',
p: null,
f: null,
si: null,
});
expect(testBed.executedCommands.some((command) => command.id === SetCellEditVisibleOperation.id)).toBe(true);
(dropdown.props as { onEdit: () => void }).onEdit();
expect(testBed.executedCommands.at(-1)).toMatchObject({
id: OpenValidationPanelOperation.id,
params: { ruleId: 'rule-list' },
});
expect((testBed.get(ISheetCellDropdownManagerService) as unknown as TestCellDropdownManagerService).hideCount).toBe(1);
});
it('keeps a multiple-list dropdown open after serializing selected values', async () => {
testBed = createTestBed();
(testBed.get(IEditorBridgeService) as unknown as TestEditorBridgeService).visible = false;
setRule(testBed, {
uid: 'rule-multiple',
type: 'list',
}, {
dropdownType: DataValidatorDropdownType.MULTIPLE_LIST,
getListWithColor: () => [
{ label: '1,2', color: '#00aa00' },
{ label: '3', color: '#ff0000' },
],
});
showDropdown(testBed, 1);
const dropdown = getDropdown(testBed);
await expect((dropdown.props as { onChange: (value: string[]) => Promise<boolean> }).onChange(['1,2', '3'])).resolves.toBe(false);
expect(getSetRangeValue(testBed)).toEqual({
v: serializeListOptions(['1,2', '3']),
p: null,
f: null,
si: null,
});
expect(testBed.executedCommands.some((command) => command.id === SetCellEditVisibleOperation.id)).toBe(false);
});
it('saves cascader and color dropdown values with synchronous command flow', () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-cascade',
type: 'cascade',
formula1: '[{"label":"East","value":"East","children":[{"label":"West","value":"West"}]}]',
}, {
dropdownType: DataValidatorDropdownType.CASCADE,
});
showDropdown(testBed, 2);
let dropdown = getDropdown(testBed);
expect(dropdown.type).toBe('cascader');
expect(dropdown.props).toMatchObject({
defaultValue: ['East', 'West'],
});
expect((dropdown.props as { onChange: (value: string[]) => boolean }).onChange(['North', 'South'])).toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({ v: 'North/South' });
setRule(testBed, {
uid: 'rule-color',
type: 'color',
}, {
dropdownType: DataValidatorDropdownType.COLOR,
});
showDropdown(testBed, 3);
dropdown = getDropdown(testBed);
expect(dropdown.type).toBe('color');
expect(dropdown.props).toMatchObject({ defaultValue: '#ff0000' });
expect((dropdown.props as { onChange: (value: string) => boolean }).onChange('#336699')).toBe(true);
expect(testBed.executedCommands.at(-2)).toMatchObject({
id: SetRangeValuesCommand.id,
params: expect.objectContaining({
value: expect.objectContaining({ v: '#336699' }),
}),
});
});
it('saves date-like dropdown values as serial numbers and closes cell editing', async () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-date',
type: 'date',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.DATE,
validator: () => true,
});
showDropdown(testBed, 4);
const dropdown = getDropdown(testBed);
expect(dropdown.type).toBe('datepicker');
expect(dropdown.props).toMatchObject({
showTime: false,
patternType: 'date',
});
expect((dropdown.props as { defaultValue: ReturnType<typeof dateKit> }).defaultValue.format('YYYY-MM-DD HH:mm:ss')).toBe('1904-01-02 12:00:00');
await expect((dropdown.props as { onChange: (value: ReturnType<typeof dateKit>) => Promise<boolean> }).onChange(dateKit('2026-06-17'))).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toEqual(expect.objectContaining({
v: excelDateTimePartsToSerial({ year: 2026, month: 6, day: 17, hours: 12, minutes: 0, seconds: 0, fractionalSecond: 0 }, { dateSystem: DateSystem.Date1904 }),
t: 2,
s: {
n: {
pattern: 'yyyy-MM-dd',
},
},
}));
expect(testBed.executedCommands.at(-1)?.id).toBe(SetCellEditVisibleOperation.id);
setRule(testBed, {
uid: 'rule-time',
type: 'time',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.TIME,
validator: () => true,
});
showDropdown(testBed, 5);
const timeDropdown = getDropdown(testBed);
expect(timeDropdown.props).toMatchObject({ patternType: 'time' });
const defaultTime = (timeDropdown.props as { defaultValue: ReturnType<typeof dateKit> }).defaultValue;
expect(defaultTime.format('YYYY-MM-DD HH:mm:ss')).toBe('1904-01-01 12:00:00');
const changedTime = defaultTime.toDate();
changedTime.setHours(12, 30, 0, 0);
await expect((timeDropdown.props as { onChange: (value: ReturnType<typeof dateKit>) => Promise<boolean> }).onChange(dateKit(changedTime))).resolves.toBe(true);
expect(testBed.executedCommands.at(-2)?.params).toMatchObject({
value: expect.objectContaining({
s: {
n: {
pattern: 'HH:mm:ss',
},
},
}),
});
});
it('keeps an unchanged date value and closes cell editing when confirmed', async () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-date',
type: 'date',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.DATE,
validator: () => true,
});
showDropdown(testBed, 4);
const dropdown = getDropdown(testBed);
await expect((dropdown.props as { onChange: (value?: ReturnType<typeof dateKit>) => Promise<boolean> }).onChange()).resolves.toBe(true);
expect(testBed.executedCommands).not.toContainEqual(expect.objectContaining({ id: SetRangeValuesCommand.id }));
expect(testBed.executedCommands).toContainEqual(expect.objectContaining({ id: SetCellEditVisibleOperation.id }));
});
it('stores a date selected for an empty cell at midnight', async () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-date',
type: 'date',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.DATE,
validator: () => true,
});
showDropdown(testBed, 10);
const dropdown = getDropdown(testBed);
await expect((dropdown.props as { onChange: (value: ReturnType<typeof dateKit>) => Promise<boolean> }).onChange(
dateKit('2026-08-21 14:30:45')
)).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({
v: excelDateTimePartsToSerial({
year: 2026,
month: 8,
day: 21,
hours: 0,
minutes: 0,
seconds: 0,
fractionalSecond: 0,
}, { dateSystem: DateSystem.Date1904 }),
});
});
it('saves a time selected for an empty cell using the 1904 date-system epoch', async () => {
testBed = createTestBed(DateSystem.Date1904);
setRule(testBed, {
uid: 'rule-time',
type: 'time',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.TIME,
validator: () => true,
});
showDropdown(testBed, 10);
const dropdown = getDropdown(testBed);
const defaultValue = (dropdown.props as { defaultValue: ReturnType<typeof dateKit> }).defaultValue;
expect(defaultValue.format('YYYY-MM-DD HH:mm:ss')).toBe('1904-01-01 00:00:00');
const changedTime = defaultValue.toDate();
changedTime.setHours(12, 30, 0, 0);
await expect((dropdown.props as { onChange: (value: ReturnType<typeof dateKit>, changeType: 'time') => Promise<boolean> }).onChange(dateKit(changedTime), 'time')).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({ v: 12.5 / 24 });
});
it('preserves the original calendar date when editing a time value', async () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-time',
type: 'time',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.TIME,
validator: () => true,
});
showDropdown(testBed, 6);
const dropdown = getDropdown(testBed);
await expect((dropdown.props as { onChange: (value: ReturnType<typeof dateKit>) => Promise<boolean> }).onChange(dateKit('1900-01-01 12:30:00'))).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({ v: 2 + 12.5 / 24 });
});
it('preserves serial 60 when only its datetime clock value changes', async () => {
testBed = createTestBed(DateSystem.Date1900);
setRule(testBed, {
uid: 'rule-datetime',
type: 'datetime',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.DATETIME,
validator: () => true,
});
showDropdown(testBed, 7);
const dropdown = getDropdown(testBed);
expect((dropdown.props as { defaultValue: ReturnType<typeof dateKit> }).defaultValue.format('YYYY-MM-DD HH:mm:ss')).toBe('1900-02-28 06:00:00');
expect(dropdown.props).toMatchObject({ exceptionalDateLabel: '1900-02-29 06:00:00' });
await expect((dropdown.props as { onChange: (value: ReturnType<typeof dateKit>, changeType: 'time') => Promise<boolean> }).onChange(dateKit('1900-02-28 12:30:00'), 'time')).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({ v: 60 + 12.5 / 24 });
});
it('replaces serial 60 when the user selects a real calendar date', async () => {
testBed = createTestBed(DateSystem.Date1900);
setRule(testBed, {
uid: 'rule-datetime',
type: 'datetime',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.DATETIME,
validator: () => true,
});
showDropdown(testBed, 7);
const dropdown = getDropdown(testBed);
await expect((dropdown.props as { onChange: (value: ReturnType<typeof dateKit>, changeType: 'date') => Promise<boolean> }).onChange(dateKit('1900-02-28 06:00:00'), 'date')).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({ v: 59.25 });
});
it('edits accumulated duration without wrapping at 24 hours', async () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-duration',
type: 'time',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.TIME,
validator: () => true,
});
showDropdown(testBed, 8);
const dropdown = getDropdown(testBed);
expect(dropdown.props).toMatchObject({ patternType: 'duration', durationValue: 1.25 });
await expect((dropdown.props as { onDurationChange: (value: number) => Promise<boolean> }).onDurationChange(1.5)).resolves.toBe(true);
expect(getSetRangeValue(testBed)).toMatchObject({
v: 1.5,
s: { n: { pattern: '[h]:mm:ss.000' } },
});
});
it('exposes an unsupported negative temporal serial instead of falling back to today', () => {
testBed = createTestBed(DateSystem.Date1900);
setRule(testBed, {
uid: 'rule-date',
type: 'date',
errorStyle: DataValidationErrorStyle.STOP,
}, {
dropdownType: DataValidatorDropdownType.DATE,
validator: () => true,
});
showDropdown(testBed, 9);
expect(getDropdown(testBed).props).toMatchObject({ unsupportedValue: -1 });
expect(getDropdown(testBed).props).not.toHaveProperty('defaultValue');
});
it('hides the active dropdown when selection leaves validation cells', () => {
testBed = createTestBed();
setRule(testBed, {
uid: 'rule-list',
type: 'list',
}, {
dropdownType: DataValidatorDropdownType.LIST,
getListWithColor: () => [{ label: 'Open' }],
});
let hideCount = 0;
testBed.get(DataValidationDropdownManagerService).showDataValidationDropdown('book-1', 'sheet-1', 1, 2, () => {
hideCount++;
});
expect(testBed.get(DataValidationDropdownManagerService).activeDropdown).toMatchObject({
location: expect.objectContaining({ row: 1, col: 2 }),
});
(testBed.get(SheetsSelectionsService) as unknown as TestSheetsSelectionsService).moveEnd([{
primary: {
unitId: 'book-1',
sheetId: 'sheet-1',
actualRow: 9,
actualColumn: 9,
},
} as never]);
expect(testBed.get(DataValidationDropdownManagerService).activeDropdown).toBeNull();
expect(hideCount).toBe(1);
});
it('does not open a dropdown when workbook, rule, or validator dropdown type is unavailable', () => {
testBed = createTestBed();
const service = testBed.get(DataValidationDropdownManagerService);
service.showDataValidationDropdown('missing-book', 'sheet-1', 1, 2);
expect((testBed.get(ISheetCellDropdownManagerService) as unknown as TestCellDropdownManagerService).dropdownParam).toBeUndefined();
service.showDataValidationDropdown('book-1', 'sheet-1', 1, 2);
expect((testBed.get(ISheetCellDropdownManagerService) as unknown as TestCellDropdownManagerService).dropdownParam).toBeUndefined();
setRule(testBed, {
uid: 'rule-no-dropdown',
type: 'unknown',
}, {});
service.showDataValidationDropdown('book-1', 'sheet-1', 1, 2);
expect(service.activeDropdown).toBeUndefined();
});
});

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 { IDataValidationRule, IDisposable, Nullable } from '@univerjs/core';
import { Disposable, IUniverInstanceService, toDisposable, UniverInstanceType } from '@univerjs/core';
import { ISidebarService } from '@univerjs/ui';
import { BehaviorSubject, distinctUntilChanged, filter } from 'rxjs';
import { DATA_VALIDATION_PANEL } from '../commands/operations/data-validation.operation';
export class DataValidationPanelService extends Disposable {
private _open$ = new BehaviorSubject<boolean>(false);
readonly open$ = this._open$.pipe(distinctUntilChanged());
private _activeRule: Nullable<{ unitId: string; subUnitId: string; rule: IDataValidationRule }> = undefined;
private _activeRule$ = new BehaviorSubject<Nullable<{ unitId: string; subUnitId: string; rule: IDataValidationRule }>>(undefined);
readonly activeRule$ = this._activeRule$.asObservable();
get activeRule() {
return this._activeRule;
}
get isOpen(): boolean {
return this._open$.getValue();
}
private _closeDisposable: Nullable<IDisposable> = null;
// Record the worksheet to which the rule using the range selector belongs, so that the rule list is not switched when making cross-sheet selections
private _focusFormulaEditorActiveRuleSubUnitId: string | null = null;
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ISidebarService private readonly _sidebarService: ISidebarService
) {
super();
this.disposeWithMe(
this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET)
.pipe(filter((sheet) => !sheet)).subscribe(() => {
this.close();
})
);
this.disposeWithMe(this._sidebarService.sidebarOptions$.subscribe((info) => {
if (info.id === DATA_VALIDATION_PANEL) {
if (!info.visible) {
setTimeout(() => {
this._sidebarService.sidebarOptions$.next({ visible: false });
});
}
}
}));
}
override dispose(): void {
super.dispose();
this._open$.next(false);
this._open$.complete();
this._activeRule$.complete();
this._closeDisposable?.dispose();
}
open(): void {
this._open$.next(true);
}
close(): void {
this._open$.next(false);
this._closeDisposable?.dispose();
}
setCloseDisposable(disposable: IDisposable): void {
this._closeDisposable = toDisposable(() => {
disposable.dispose();
this._closeDisposable = null;
});
}
setActiveRule(rule: Nullable<{ unitId: string; subUnitId: string; rule: IDataValidationRule }>): void {
this._activeRule = rule;
this._activeRule$.next(rule);
}
setFocusFormulaEditorActiveRuleSubUnitId(subUnitId: string | null): void {
this._focusFormulaEditorActiveRuleSubUnitId = subUnitId;
}
getFocusFormulaEditorActiveRuleSubUnitId(): string | null {
return this._focusFormulaEditorActiveRuleSubUnitId;
}
}

View File

@@ -0,0 +1,622 @@
/**
* 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, DateKit, IDisposable, IExcelDateTimeParts, Nullable, Workbook } from '@univerjs/core';
import type { ISetRangeValuesCommandParams, ISheetLocation } from '@univerjs/sheets';
import type { ListValidator } from '@univerjs/sheets-data-validation';
import type { IDropdownParam, IEditorBridgeServiceVisibleParam } from '@univerjs/sheets-ui';
import type { IUniverSheetsDataValidationUIConfig } from '../config/config';
import { CellValueType, DataValidationErrorStyle, DataValidationRenderMode, dateKit, DateSystem, Disposable, DisposableCollection, excelDateTimePartsToSerial, excelSerialToDateTimeParts, ICommandService, IConfigService, Inject, Injector, IUniverInstanceService, numfmt, UniverInstanceType } from '@univerjs/core';
import { DataValidatorDropdownType, DataValidatorRegistryService } from '@univerjs/data-validation';
import { DeviceInputEventType } from '@univerjs/engine-render';
import { serializeListOptions, SetRangeValuesCommand, SheetsSelectionsService } from '@univerjs/sheets';
import { getCellValueOrigin, getDataValidationCellValue, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { getPatternType } from '@univerjs/sheets-numfmt';
import { IEditorBridgeService, ISheetCellDropdownManagerService, SetCellEditVisibleOperation } from '@univerjs/sheets-ui';
import { KeyCode } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { OpenValidationPanelOperation } from '../commands/operations/data-validation.operation';
import { SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY } from '../config/config';
import { DROP_DOWN_DEFAULT_COLOR } from '../const';
import { DataValidationRejectInputController } from '../controllers/dv-reject-input.controller';
export interface IDataValidationDropdownParam {
location: ISheetLocation;
onHide?: () => void;
trigger?: 'editor-bridge';
closeOnOutSide?: boolean;
}
export interface IDropdownComponentProps {
componentKey: string;
location: ISheetLocation;
hideFn: () => void;
}
type DatePatternType = 'datetime' | 'date' | 'time' | 'duration';
type DateChangeType = 'date' | 'time';
function getExceptionalDateLabel(parts: IExcelDateTimeParts | null, patternType: DatePatternType): string | undefined {
if (!parts || (parts.day !== 0 && !(parts.year === 1900 && parts.month === 2 && parts.day === 29))) {
return undefined;
}
const date = `${String(parts.year).padStart(4, '0')}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
if (patternType === 'date') return date;
const time = `${String(parts.hours).padStart(2, '0')}:${String(parts.minutes).padStart(2, '0')}:${String(parts.seconds).padStart(2, '0')}`;
return `${date} ${time}`;
}
const transformDate = (value: Nullable<CellValue>, dateSystem: DateSystem, patternType: Exclude<DatePatternType, 'duration'>) => {
// A pure-time picker needs a native Date anchor that is valid in the workbook's date system.
const timeAnchorYear = dateSystem === DateSystem.Date1904 ? 1904 : 1900;
if (value === undefined || value === null) {
return patternType === 'time' ? dateKit(new Date(timeAnchorYear, 0, 1)) : undefined;
}
if (typeof value === 'boolean') {
return undefined;
}
if (value === '') return patternType === 'time' ? dateKit(new Date(timeAnchorYear, 0, 1)) : dateKit();
if (typeof value === 'number' || !Number.isNaN(+value)) {
const parts = excelSerialToDateTimeParts(Number(value), { dateSystem });
if (!parts) return undefined;
const year = patternType === 'time' ? timeAnchorYear : parts.year;
const month = patternType === 'time' ? 1 : parts.month;
// The picker needs a real Date anchor; the original pseudo-date is restored during save unless the date was changed.
const day = patternType === 'time'
? 1
: parts.day === 0
? 1
: parts.year === 1900 && parts.month === 2 && parts.day === 29 ? 28 : parts.day;
return dateKit(new Date(year, month - 1, day, parts.hours, parts.minutes, parts.seconds, Math.round(parts.fractionalSecond * 1000)));
}
const date = dateKit(value);
if (date.isValid()) {
return date;
}
return undefined;
};
function getDefaultFormat(patternType: DatePatternType, format: string) {
// Numfmt categorizes elapsed-time formats as time, so detect duration before comparing the general type.
if (patternType === 'duration' && numfmt.getFormatDateInfo(format).isDuration) {
return format;
}
const originPartternType = getPatternType(format);
if (patternType === originPartternType) {
return format;
}
switch (patternType) {
case 'datetime':
return 'yyyy-MM-dd hh:mm:ss';
case 'date':
return 'yyyy-MM-dd';
case 'time':
return 'HH:mm:ss';
case 'duration':
return '[h]:mm:ss';
}
}
export class DataValidationDropdownManagerService extends Disposable {
private _activeDropdown: Nullable<IDataValidationDropdownParam>;
private _activeDropdown$ = new Subject<Nullable<IDataValidationDropdownParam>>();
private _currentPopup: Nullable<IDisposable> = null;
activeDropdown$ = this._activeDropdown$.asObservable();
get activeDropdown() {
return this._activeDropdown;
}
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(DataValidatorRegistryService) private readonly _dataValidatorRegistryService: DataValidatorRegistryService,
@Inject(SheetDataValidationModel) private readonly _dataValidationModel: SheetDataValidationModel,
@Inject(SheetsSelectionsService) private readonly _sheetsSelectionsService: SheetsSelectionsService,
@Inject(ISheetCellDropdownManagerService) private readonly _cellDropdownManagerService: ISheetCellDropdownManagerService,
@Inject(SheetDataValidationModel) private readonly _sheetDataValidationModel: SheetDataValidationModel,
@ICommandService private readonly _commandService: ICommandService,
@IEditorBridgeService private readonly _editorBridgeService: IEditorBridgeService,
@Inject(Injector) private readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
this._initSelectionChange();
this.disposeWithMe(() => {
this._activeDropdown$.complete();
});
}
private _getDropdownByCell(unitId: string | undefined, subUnitId: string | undefined, row: number, col: number) {
const workbook = unitId ?
this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET)
: this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return;
}
const worksheet = subUnitId ? workbook.getSheetBySheetId(subUnitId) : workbook.getActiveSheet();
if (!worksheet) {
return;
}
const rule = this._dataValidationModel.getRuleByLocation(workbook.getUnitId(), worksheet.getSheetId(), row, col);
if (!rule) {
return;
}
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
return validator?.dropdownType;
}
private _initSelectionChange() {
this.disposeWithMe(this._sheetsSelectionsService.selectionMoveEnd$.subscribe((selections) => {
if (selections && selections.every((selection) => !(selection.primary && this._getDropdownByCell(selection.primary.unitId, selection.primary.sheetId, selection.primary.actualRow, selection.primary.actualColumn)))) {
this.hideDropdown();
}
}));
}
// eslint-disable-next-line max-lines-per-function, complexity
showDropdown(param: IDataValidationDropdownParam) {
const { location } = param;
const { row, col, unitId, subUnitId, workbook, worksheet } = location;
if (this._currentPopup) {
this._currentPopup.dispose();
};
this._activeDropdown = param;
this._activeDropdown$.next(this._activeDropdown);
const rule = this._sheetDataValidationModel.getRuleByLocation(unitId, subUnitId, row, col);
if (!rule) {
return;
}
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
if (!validator?.dropdownType) {
return;
}
let popupDisposable: Nullable<IDisposable>;
const dateSystem = workbook.getDateSystem();
const originalValue = getCellValueOrigin(worksheet.getCellRaw(row, col));
const originalSerial = typeof originalValue === 'number'
? originalValue
: typeof originalValue === 'string' && originalValue !== '' && !Number.isNaN(+originalValue) ? Number(originalValue) : null;
const originalParts = originalSerial == null ? null : excelSerialToDateTimeParts(originalSerial, { dateSystem });
const cellData = worksheet.getCell(row, col);
const cellStyle = workbook.getStyles().getStyleByCell(cellData);
const format = cellStyle?.n?.pattern ?? '';
const finishCellEditing = async () => {
await this._commandService.executeCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.Keyboard,
unitId,
keycode: KeyCode.ESC,
} as IEditorBridgeServiceVisibleParam);
return true;
};
const handleSerialSave = async (serial: number, targetPatternType: DatePatternType, interceptValue: string) => {
if (
rule.errorStyle !== DataValidationErrorStyle.STOP ||
(await validator.validator({
value: serial,
unitId,
subUnitId,
row,
column: col,
worksheet,
workbook,
interceptValue,
t: CellValueType.NUMBER,
}, rule))
) {
await this._commandService.executeCommand(SetRangeValuesCommand.id, {
unitId,
subUnitId,
range: {
startColumn: col,
endColumn: col,
startRow: row,
endRow: row,
},
value: {
v: serial,
t: CellValueType.NUMBER,
p: null,
f: null,
si: null,
s: {
n: {
pattern: getDefaultFormat(targetPatternType, format),
},
},
},
});
return finishCellEditing();
}
if (this._injector.has(DataValidationRejectInputController)) {
const rejectInputController = this._injector.get(DataValidationRejectInputController);
rejectInputController.showReject(validator.getRuleFinalError(rule, { row, col, unitId, subUnitId }));
}
return false;
};
const handleSave = async (date: DateKit | undefined, targetPatternType: Exclude<DatePatternType, 'duration'>, changeType?: DateChangeType) => {
if (!date) {
return finishCellEditing();
}
const nativeDate = date.toDate();
const selectedParts = {
year: nativeDate.getFullYear(),
month: nativeDate.getMonth() + 1,
day: nativeDate.getDate(),
hours: nativeDate.getHours(),
minutes: nativeDate.getMinutes(),
seconds: nativeDate.getSeconds(),
fractionalSecond: nativeDate.getMilliseconds() / 1000,
};
// Native Date uses the nearest real date for Excel's serial 0/60; a time-only change must restore the pseudo-day.
const isExceptionalDateAnchor = originalParts != null && (
(originalParts.day === 0 && selectedParts.year === 1900 && selectedParts.month === 1 && selectedParts.day === 1) ||
(originalParts.year === 1900 && originalParts.month === 2 && originalParts.day === 29 && selectedParts.year === 1900 && selectedParts.month === 2 && selectedParts.day === 28)
);
// An empty pure-date cell starts at midnight; only an existing serial can supply hidden time fields.
const timeParts = targetPatternType === 'date'
? (originalParts ?? { hours: 0, minutes: 0, seconds: 0, fractionalSecond: 0 })
: selectedParts;
const dateParts = {
year: targetPatternType === 'time' && originalParts && (originalSerial ?? 0) >= 1 ? originalParts.year : selectedParts.year,
month: targetPatternType === 'time' && originalParts && (originalSerial ?? 0) >= 1 ? originalParts.month : selectedParts.month,
day: targetPatternType === 'time' && originalParts && (originalSerial ?? 0) >= 1
? originalParts.day
: targetPatternType === 'datetime' && changeType === 'time' && isExceptionalDateAnchor ? originalParts!.day : selectedParts.day,
hours: timeParts.hours,
minutes: timeParts.minutes,
seconds: timeParts.seconds,
fractionalSecond: timeParts.fractionalSecond,
};
const serialNum = excelDateTimePartsToSerial(dateParts, { dateSystem });
if (serialNum == null) return false;
const serialTime = targetPatternType === 'time' && (originalSerial == null || originalSerial < 1)
? serialNum - Math.floor(serialNum)
: serialNum;
const dateStr = date.format(targetPatternType === 'date' ? 'YYYY-MM-DD 00:00:00' : 'YYYY-MM-DD HH:mm:ss');
return handleSerialSave(serialTime, targetPatternType, dateStr.replace('Z', '').replace('T', ' '));
};
let dropdownParam: IDropdownParam;
// Keep an out-of-range serial editable as a number instead of coercing it through an invalid native Date.
const unsupportedValue = originalSerial != null && !originalParts ? originalSerial : undefined;
const serialProps = unsupportedValue == null
? {}
: {
unsupportedValue,
onSerialChange: (newValue: number | undefined) => {
if (newValue == null || !excelSerialToDateTimeParts(newValue, { dateSystem })) {
return Promise.resolve(false);
}
return handleSerialSave(newValue, validator.dropdownType === DataValidatorDropdownType.DATE ? 'date' : validator.dropdownType === DataValidatorDropdownType.TIME ? 'time' : 'datetime', String(newValue));
},
};
switch (validator.dropdownType) {
case DataValidatorDropdownType.DATE: {
const originDate = transformDate(originalValue, dateSystem, 'date');
const showTime = Boolean(rule.bizInfo?.showTime);
dropdownParam = {
location,
type: 'datepicker',
props: {
showTime,
onChange: (newValue, changeType) => handleSave(newValue, showTime ? 'datetime' : 'date', changeType),
...(originDate ? { defaultValue: originDate } : {}),
exceptionalDateLabel: getExceptionalDateLabel(originalParts, showTime ? 'datetime' : 'date'),
patternType: 'date',
preserveDefaultValue: true,
...serialProps,
},
};
break;
}
case DataValidatorDropdownType.TIME: {
if (numfmt.getFormatDateInfo(format).isDuration) {
dropdownParam = {
location,
type: 'datepicker',
props: {
durationValue: originalSerial ?? undefined,
onDurationChange: (newValue) => newValue == null
? finishCellEditing()
: handleSerialSave(newValue, 'duration', String(newValue)),
patternType: 'duration',
preserveDefaultValue: true,
},
};
break;
}
const originDate = transformDate(originalValue, dateSystem, 'time');
dropdownParam = {
location,
type: 'datepicker',
props: {
onChange: (newValue, changeType) => handleSave(newValue, 'time', changeType),
...(originDate ? { defaultValue: originDate } : {}),
patternType: 'time',
preserveDefaultValue: true,
...serialProps,
},
};
break;
}
case DataValidatorDropdownType.DATETIME: {
const originDate = transformDate(originalValue, dateSystem, 'datetime');
dropdownParam = {
location,
type: 'datepicker',
props: {
onChange: (newValue, changeType) => handleSave(newValue, 'datetime', changeType),
...(originDate ? { defaultValue: originDate } : {}),
exceptionalDateLabel: getExceptionalDateLabel(originalParts, 'datetime'),
patternType: 'datetime',
preserveDefaultValue: true,
...serialProps,
},
};
break;
}
case DataValidatorDropdownType.LIST:
case DataValidatorDropdownType.MULTIPLE_LIST: {
const multiple = validator.dropdownType === DataValidatorDropdownType.MULTIPLE_LIST;
const handleSave = async (newValue: string[]) => {
const str = multiple ? serializeListOptions(newValue) : (newValue[0] ?? '');
const params: ISetRangeValuesCommandParams = {
unitId,
subUnitId,
range: {
startColumn: col,
endColumn: col,
startRow: row,
endRow: row,
},
value: {
v: str,
p: null,
f: null,
si: null,
},
};
this._commandService.executeCommand(SetRangeValuesCommand.id, params);
if (this._editorBridgeService.isVisible().visible) {
await this._commandService.executeCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.Keyboard,
unitId,
keycode: KeyCode.ESC,
} as IEditorBridgeServiceVisibleParam);
}
if (multiple) {
return false;
}
return true;
};
const showColor = rule?.renderMode === DataValidationRenderMode.CUSTOM || rule?.renderMode === undefined;
const list = (validator as ListValidator).getListWithColor(rule, unitId, subUnitId);
const cellStr = getDataValidationCellValue(worksheet.getCellRaw(row, col));
const handleEdit = () => {
this._commandService.executeCommand(OpenValidationPanelOperation.id, {
ruleId: rule.uid,
});
popupDisposable?.dispose();
};
const options = list.map((item) => ({
label: item.label,
value: item.label,
color: (showColor || item.color) ? (item.color || DROP_DOWN_DEFAULT_COLOR) : 'transparent',
}));
dropdownParam = {
location,
type: 'list',
props: {
onChange: (newValue) => handleSave(newValue),
options,
onEdit: handleEdit,
defaultValue: cellStr,
multiple,
showEdit: this._configService.getConfig<IUniverSheetsDataValidationUIConfig>(SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY)?.showEditOnDropdown ?? true,
showSearch: this._configService.getConfig<IUniverSheetsDataValidationUIConfig>(SHEETS_DATA_VALIDATION_UI_PLUGIN_CONFIG_KEY)?.showSearchOnDropdown ?? true,
},
};
break;
}
case DataValidatorDropdownType.CASCADE: {
const handleSave = (newValue: string[]) => {
const params: ISetRangeValuesCommandParams = {
unitId,
subUnitId,
range: {
startColumn: col,
endColumn: col,
startRow: row,
endRow: row,
},
value: {
v: newValue.join('/'),
p: null,
f: null,
si: null,
},
};
this._commandService.syncExecuteCommand(SetRangeValuesCommand.id, params);
if (this._editorBridgeService.isVisible().visible) {
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.Keyboard,
unitId,
keycode: KeyCode.ESC,
} as IEditorBridgeServiceVisibleParam);
}
return true;
};
dropdownParam = {
type: 'cascader',
props: {
onChange: handleSave,
defaultValue: getDataValidationCellValue(worksheet.getCellRaw(row, col)).split('/'),
options: JSON.parse(rule.formula1 ?? '[]'),
},
location,
};
break;
}
case DataValidatorDropdownType.COLOR: {
const handleSave = (newValue: string) => {
const params: ISetRangeValuesCommandParams = {
unitId,
subUnitId,
range: {
startColumn: col,
endColumn: col,
startRow: row,
endRow: row,
},
value: {
v: newValue,
p: null,
f: null,
si: null,
},
};
this._commandService.syncExecuteCommand(SetRangeValuesCommand.id, params);
if (this._editorBridgeService.isVisible().visible) {
this._commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, {
visible: false,
eventType: DeviceInputEventType.Keyboard,
unitId,
keycode: KeyCode.ESC,
} as IEditorBridgeServiceVisibleParam);
}
return true;
};
dropdownParam = {
type: 'color',
props: {
onChange: handleSave,
defaultValue: getDataValidationCellValue(worksheet.getCellRaw(row, col)),
},
location,
};
break;
}
default:
throw new Error('[DataValidationDropdownManagerService]: unknown type!');
}
popupDisposable = this._cellDropdownManagerService.showDropdown({
...dropdownParam,
onHide: () => {
this._activeDropdown = null;
this._activeDropdown$.next(null);
},
});
if (!popupDisposable) {
throw new Error('[DataValidationDropdownManagerService]: cannot show dropdown!');
}
const disposableCollection = new DisposableCollection();
disposableCollection.add(popupDisposable);
disposableCollection.add({
dispose: () => {
this._activeDropdown?.onHide?.();
},
});
this._currentPopup = disposableCollection;
}
hideDropdown() {
if (!this._activeDropdown) {
return;
}
this._currentPopup && this._currentPopup.dispose();
this._currentPopup = null;
this._activeDropdown = null;
this._activeDropdown$.next(null);
}
showDataValidationDropdown(unitId: string, subUnitId: string, row: number, col: number, onHide?: () => void) {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return;
}
const worksheet = workbook.getSheetBySheetId(subUnitId);
if (!worksheet) {
return;
}
const rule = this._dataValidationModel.getRuleByLocation(workbook.getUnitId(), worksheet.getSheetId(), row, col);
if (!rule) {
return;
}
const validator = this._dataValidatorRegistryService.getValidatorItem(rule.type);
if (!validator || !validator.dropdownType) {
this.hideDropdown();
return;
}
this.showDropdown({
location: {
workbook,
worksheet,
row,
col,
unitId,
subUnitId,
},
onHide,
});
}
}

View File

@@ -0,0 +1,430 @@
/**
* 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 {
DataValidationOperator,
DataValidationType,
IDataValidationRuleBase,
IDataValidationRuleOptions,
IExecutionOptions,
ISheetDataValidationRule,
IUnitRange,
Workbook,
} from '@univerjs/core';
import type { IUpdateSheetDataValidationRangeCommandParams } from '@univerjs/sheets-data-validation';
import type { IRangeSelectorInstance } from '@univerjs/sheets-formula-ui';
import type { LocaleKey } from '../../locale/types';
import {
debounce,
ICommandService,
isUnitRangesEqual,
IUniverInstanceService,
LocaleService,
RedoCommand,
shallowEqual,
UndoCommand,
UniverInstanceType,
} from '@univerjs/core';
import {
DataValidationModel,
DataValidatorRegistryScope,
DataValidatorRegistryService,
getRuleOptions,
getRuleSetting,
TWO_FORMULA_OPERATOR_COUNT,
} from '@univerjs/data-validation';
import { ActionRow, Button, Checkbox, FormLayout, Select } from '@univerjs/design';
import { deserializeRangeWithSheet, serializeRange } from '@univerjs/engine-formula';
import { SetWorksheetActiveOperation, SheetsSelectionsService } from '@univerjs/sheets';
import {
RemoveSheetDataValidationCommand,
UpdateSheetDataValidationOptionsCommand,
UpdateSheetDataValidationRangeCommand,
UpdateSheetDataValidationSettingCommand,
} from '@univerjs/sheets-data-validation';
import { RangeSelector } from '@univerjs/sheets-formula-ui';
import { ComponentManager, useDependency, useEvent, useObservable } from '@univerjs/ui';
import { useEffect, useMemo, useRef, useState } from 'react';
import { of } from 'rxjs';
import { DataValidationPanelService } from '../../services/data-validation-panel.service';
import { DataValidationOptions } from './DataValidationOptions';
// debounce execute commands, for better redo-undo experience
const debounceExecuteFactory = (commandService: ICommandService) => debounce(
async (id: string, params?: any, options?: IExecutionOptions | undefined, callback?: (success: boolean) => void) => {
const res = await commandService.executeCommand(id, params, options);
callback?.(res);
},
1000
);
function getSheetIdByName(univerInstanceService: IUniverInstanceService, unitId: string, name: string) {
if (unitId) {
return univerInstanceService.getUnit<Workbook>(unitId)?.getSheetBySheetName(name)?.getSheetId() || '';
}
return univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)?.getSheetBySheetName(name)?.getSheetId() || '';
}
export function DataValidationDetail() {
const dataValidationPanelService = useDependency(DataValidationPanelService);
const activeRuleInfo = useObservable(dataValidationPanelService.activeRule$, dataValidationPanelService.activeRule)!;
const { unitId, subUnitId, rule } = activeRuleInfo || {};
if (!unitId || !subUnitId || !rule) {
return null;
}
return <DataValidationDetailInner activeRuleInfo={{ unitId, subUnitId, rule }} />;
}
function DataValidationDetailInner(props: { activeRuleInfo: { unitId: string; subUnitId: string; rule: ISheetDataValidationRule } }) {
const [key, setKey] = useState(0);
const dataValidationPanelService = useDependency(DataValidationPanelService);
const { unitId, subUnitId, rule } = props.activeRuleInfo;
const ruleId = rule.uid;
const validatorService = useDependency(DataValidatorRegistryService);
const univerInstanceService = useDependency(IUniverInstanceService);
const componentManager = useDependency(ComponentManager);
const commandService = useDependency(ICommandService);
const dataValidationModel = useDependency(DataValidationModel);
const localeService = useDependency(LocaleService);
const workbook = useObservable(
() => univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET),
undefined,
undefined,
[]
);
const worksheet = useObservable(() => workbook?.activeSheet$ ?? of(null), undefined, undefined, []);
const [localRule, setLocalRule] = useState<ISheetDataValidationRule>(rule);
const validator = validatorService.getValidatorItem(localRule.type);
const [showError, setShowError] = useState(false);
const validators = validatorService.getValidatorsByScope(DataValidatorRegistryScope.SHEET);
const [localRanges, setLocalRanges] = useState<IUnitRange[]>(() => localRule.ranges.map((i) => ({ unitId: '', sheetId: '', range: i })));
const debounceExecute = useMemo(() => debounceExecuteFactory(commandService), [commandService]);
const [isRangeError, setIsRangeError] = useState(false);
const [isFocusRangeSelector, setIsFocusRangeSelector] = useState(false);
const rangeSelectorInstance = useRef<IRangeSelectorInstance>(null);
const sheetSelectionService = useDependency(SheetsSelectionsService);
useEffect(() => {
return () => {
const currentSelection = sheetSelectionService.getCurrentLastSelection();
if (currentSelection) {
sheetSelectionService.setSelections([currentSelection]);
}
};
}, [sheetSelectionService]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
const disposable = commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === UndoCommand.id || commandInfo.id === RedoCommand.id) {
if (timer !== undefined) {
clearTimeout(timer);
}
timer = setTimeout(() => {
const activeRule = dataValidationModel.getRuleById(unitId, subUnitId, ruleId) as ISheetDataValidationRule;
setKey((k) => k + 1);
if (activeRule) {
setLocalRule(activeRule);
setLocalRanges(activeRule.ranges.map((i) => ({ unitId: '', sheetId: '', range: i })));
}
}, 20);
}
});
return () => {
disposable.dispose();
if (timer !== undefined) {
clearTimeout(timer);
}
};
}, [commandService, dataValidationModel, ruleId, subUnitId, unitId]);
const handleUpdateRuleRanges = useEvent((rangeText: string) => {
const unitRanges = rangeText.split(',').filter(Boolean).map(deserializeRangeWithSheet).map((unitRange) => {
const sheetName = unitRange.sheetName;
if (sheetName) {
const sheetId = getSheetIdByName(univerInstanceService, unitRange.unitId, sheetName);
return { ...unitRange, sheetId };
}
return {
...unitRange,
sheetId: '',
};
});
if (isUnitRangesEqual(unitRanges, localRanges)) {
return;
}
setLocalRanges(unitRanges);
const ranges = unitRanges.filter((i) => (!i.unitId || i.unitId === unitId) && (!i.sheetId || i.sheetId === subUnitId)).map((i) => i.range);
setLocalRule({
...localRule,
ranges,
});
if (ranges.length === 0) {
return;
}
const params: IUpdateSheetDataValidationRangeCommandParams = {
unitId,
subUnitId,
ruleId,
ranges,
};
debounceExecute(UpdateSheetDataValidationRangeCommand.id, params);
});
const rangeStr = useMemo(() => localRanges.map((i) => serializeRange(i.range)).join(','), []);
if (!validator) {
return null;
}
const operators = validator.operators;
const operatorNames = validator.operatorNames;
const isTwoFormula = localRule.operator ? TWO_FORMULA_OPERATOR_COUNT.includes(localRule.operator) : false;
const goBackActiveRuleSheet = () => {
if (worksheet?.getSheetId() !== subUnitId) {
commandService.syncExecuteCommand(SetWorksheetActiveOperation.id, {
unitId,
subUnitId,
});
}
};
const handleOk = () => {
if (rangeSelectorInstance.current?.editor?.isFocus()) {
handleUpdateRuleRanges(rangeSelectorInstance.current?.getValue());
}
if (!localRule.ranges.length || isRangeError) {
return;
}
if (validator.validatorFormula(localRule, unitId, subUnitId).success) {
dataValidationPanelService.setActiveRule(null);
} else {
setShowError(true);
}
goBackActiveRuleSheet();
};
const handleUpdateRuleSetting = (setting: IDataValidationRuleBase) => {
if (shallowEqual(setting, getRuleSetting(localRule))) {
return;
}
setLocalRule({
...localRule,
...setting,
});
const params = {
unitId,
subUnitId,
ruleId,
setting,
};
debounceExecute(
UpdateSheetDataValidationSettingCommand.id,
params,
undefined
);
};
const handleDelete = async () => {
await commandService.executeCommand(RemoveSheetDataValidationCommand.id, {
ruleId,
unitId,
subUnitId,
});
dataValidationPanelService.setActiveRule(null);
goBackActiveRuleSheet();
};
const baseRule = {
type: localRule.type,
operator: localRule.operator,
formula1: localRule.formula1,
formula2: localRule.formula2,
allowBlank: localRule.allowBlank,
};
const handleChangeType = (newType: string) => {
const validator = validatorService.getValidatorItem(newType);
if (!validator) {
return;
}
const operators = validator.operators;
const rule = dataValidationModel.getRuleById(unitId, subUnitId, ruleId);
const newRule = (newType === rule?.type || (newType.includes('list') && rule?.type.includes('list')))
? {
...rule,
type: newType as DataValidationType,
}
: {
...localRule,
type: newType as DataValidationType,
operator: operators[0],
formula1: undefined,
formula2: undefined,
};
setLocalRule(newRule);
commandService.executeCommand(UpdateSheetDataValidationSettingCommand.id, {
unitId,
subUnitId,
ruleId: localRule.uid,
setting: getRuleSetting(newRule),
});
};
const FormulaInput = componentManager.get(validator.formulaInput!);
const options: IDataValidationRuleOptions = getRuleOptions(localRule);
const handleUpdateRuleOptions = (newOptions: IDataValidationRuleOptions) => {
if (shallowEqual(newOptions, getRuleOptions(localRule))) {
return;
}
setLocalRule({
...localRule,
...newOptions,
});
debounceExecute(
UpdateSheetDataValidationOptionsCommand.id,
{
unitId,
subUnitId,
ruleId,
options: newOptions,
}
);
};
const shouldHideFormula = operators.length && !localRule.operator;
return (
<div data-u-comp="data-validation-detail" className="univer-py-2">
<FormLayout
label={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.range')}
error={(!localRule.ranges.length || isRangeError) ? localeService.t<LocaleKey>('sheets-data-validation-ui.panel.rangeError') : ''}
>
<RangeSelector
selectorRef={rangeSelectorInstance}
unitId={unitId}
subUnitId={subUnitId}
initialValue={rangeStr}
onChange={(doc, str) => {
if (!isFocusRangeSelector && rangeSelectorInstance.current?.verify()) {
handleUpdateRuleRanges(str);
}
}}
onFocusChange={(focusing, str) => {
setIsFocusRangeSelector(focusing);
if (!focusing && str && rangeSelectorInstance.current?.verify()) {
handleUpdateRuleRanges(str);
}
}}
onVerify={(isValid) => setIsRangeError(!isValid)}
/>
</FormLayout>
<FormLayout label={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.type')}>
<Select
className="univer-w-full"
value={localRule.type}
options={validators?.sort((a, b) => a.order - b.order)?.map((validator) => ({
label: localeService.t(validator.title),
value: validator.id,
}))}
onChange={handleChangeType}
/>
</FormLayout>
{operators?.length
? (
<FormLayout label={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.operator')}>
<Select
className="univer-w-full"
value={`${localRule.operator}`}
options={[
{
value: '',
label: localeService.t<LocaleKey>('sheets-data-validation-ui.operators.legal'),
},
...operators.map((op, i) => ({
value: `${op}`,
label: operatorNames[i],
})),
]}
onChange={(operator) => {
handleUpdateRuleSetting({
...baseRule,
operator: operator as DataValidationOperator,
});
}}
/>
</FormLayout>
)
: null}
{FormulaInput && !shouldHideFormula
? (
<FormulaInput
key={key + localRule.type}
isTwoFormula={isTwoFormula}
value={{
formula1: localRule.formula1,
formula2: localRule.formula2,
}}
onChange={(value: any) => {
handleUpdateRuleSetting({
...baseRule,
...value,
});
}}
showError={showError}
validResult={validator.validatorFormula(localRule, unitId, subUnitId)}
unitId={unitId}
subUnitId={subUnitId}
ruleId={ruleId}
/>
)
: null}
<FormLayout>
<Checkbox
checked={(localRule.allowBlank ?? true)}
onChange={() => handleUpdateRuleSetting({
...baseRule,
allowBlank: !(localRule.allowBlank ?? true),
})}
>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.allowBlank')}
</Checkbox>
</FormLayout>
<DataValidationOptions value={options} onChange={handleUpdateRuleOptions} extraComponent={validator.optionsInput} />
<ActionRow className="univer-mt-5 univer-flex univer-flex-row univer-justify-end">
<Button className="univer-ml-3" onClick={handleDelete}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.removeRule')}
</Button>
<Button className="univer-ml-3" variant="primary" onClick={handleOk}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.done')}
</Button>
</ActionRow>
</div>
);
}

View File

@@ -0,0 +1,142 @@
/**
* 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 { ISheetDataValidationRule } from '@univerjs/core';
import type { MouseEvent } from 'react';
import { ColorKit, ICommandService, ThemeService } from '@univerjs/core';
import { DataValidatorRegistryService } from '@univerjs/data-validation';
import { clsx } from '@univerjs/design';
import { serializeRange } from '@univerjs/engine-formula';
import { DeleteIcon } from '@univerjs/icons';
import { RemoveSheetDataValidationCommand } from '@univerjs/sheets-data-validation';
import { IMarkSelectionService } from '@univerjs/sheets-ui';
import { useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useMemo, useRef, useState } from 'react';
export interface IDataValidationDetailProps {
rule: ISheetDataValidationRule;
onClick: () => void;
unitId: string;
subUnitId: string;
disable?: boolean;
}
export const DataValidationItem = (props: IDataValidationDetailProps) => {
const { rule, onClick, unitId, subUnitId, disable } = props;
const validatorRegistry = useDependency(DataValidatorRegistryService);
const commandService = useDependency(ICommandService);
const markSelectionService = useDependency(IMarkSelectionService);
const validator = validatorRegistry.getValidatorItem(rule.type);
const ids = useRef<(string | null)[]>(undefined);
const [isHover, setIsHover] = useState(false);
const themeService = useDependency(ThemeService);
const theme = useObservable(themeService.currentTheme$);
const style = useMemo(() => {
const defaultColor = themeService.getColorFromTheme('primary.600');
const key = themeService.getColorFromTheme('loop-color.2');
const color = themeService.getColorFromTheme(key) ?? defaultColor;
const rgb = new ColorKit(color).toRgb();
return {
fill: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.1)`,
stroke: color,
};
}, [theme]);
const handleDelete = (e: MouseEvent<HTMLDivElement, globalThis.MouseEvent>) => {
commandService.executeCommand(RemoveSheetDataValidationCommand.id, {
ruleId: rule.uid,
unitId,
subUnitId,
});
e.stopPropagation();
};
useEffect(() => {
return () => {
if (ids.current) {
ids.current?.forEach((id) => {
id && markSelectionService.removeShape(id);
});
}
};
}, [markSelectionService]);
return (
<div
className={clsx(
`
univer-bg-secondary univer-relative univer--mx-2 univer-box-border univer-flex univer-w-[287px]
univer-cursor-pointer univer-flex-col univer-justify-between univer-overflow-hidden univer-rounded-md
univer-p-2 univer-pr-9
rtl:univer-pl-9 rtl:univer-pr-2
`,
{
'hover:univer-bg-gray-50 dark:hover:!univer-bg-gray-700': !disable,
'univer-opacity-50': disable,
}
)}
onClick={onClick}
onMouseEnter={() => {
if (disable) return;
setIsHover(true);
ids.current = rule.ranges.map((range) => markSelectionService.addShape({
range,
style,
primary: null,
}));
}}
onMouseLeave={() => {
setIsHover(false);
ids.current?.forEach((id) => {
id && markSelectionService.removeShape(id);
});
ids.current = undefined;
}}
>
<div
className={`
univer-truncate univer-text-sm univer-font-medium univer-leading-[22px] univer-text-gray-900
dark:!univer-text-gray-0
`}
>
{validator?.generateRuleName(rule)}
</div>
<div
className={`
univer-text-secondary univer-truncate univer-text-xs univer-leading-[18px]
dark:!univer-text-gray-300
`}
>
{rule.ranges.map((range) => serializeRange(range)).join(',')}
</div>
{isHover && (
<div
className={`
univer-absolute univer-right-2 univer-top-[19px] univer-flex univer-size-5 univer-items-center
univer-justify-center univer-rounded
hover:univer-bg-gray-200
rtl:univer-left-2 rtl:univer-right-auto
dark:!univer-text-gray-300
dark:hover:!univer-bg-gray-700
`}
onClick={handleDelete}
>
<DeleteIcon />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,134 @@
/**
* 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 { ISheetDataValidationRule, Workbook } from '@univerjs/core';
import type { IAddSheetDataValidationCommandParams } from '@univerjs/sheets-data-validation';
import type { LocaleKey } from '../../locale/types';
import { ICommandService, Injector, IUniverInstanceService, LocaleService, UniverInstanceType } from '@univerjs/core';
import { ActionRow, Button } from '@univerjs/design';
import { checkRangesEditablePermission } from '@univerjs/sheets';
import {
AddSheetDataValidationCommand,
createDefaultNewRule,
RemoveSheetAllDataValidationCommand,
SheetDataValidationModel,
} from '@univerjs/sheets-data-validation';
import { useDependency, useObservable } from '@univerjs/ui';
import { filter, map, startWith } from 'rxjs';
import { DataValidationPanelService } from '../../services/data-validation-panel.service';
import { DataValidationItem } from './DataValidationItem';
export function DataValidationList(props: { workbook: Workbook }) {
const sheetDataValidationModel = useDependency(SheetDataValidationModel);
const univerInstanceService = useDependency(IUniverInstanceService);
const commandService = useDependency(ICommandService);
const injector = useDependency(Injector);
const dataValidationPanelService = useDependency(DataValidationPanelService);
const localeService = useDependency(LocaleService);
const { workbook } = props;
const worksheet = useObservable(workbook.activeSheet$, undefined, true)!;
const unitId = workbook.getUnitId();
const subUnitId = worksheet?.getSheetId();
const rules = useObservable(
() => sheetDataValidationModel.ruleChange$.pipe(
filter((change) => change.unitId === unitId && change.subUnitId === subUnitId),
map(() => sheetDataValidationModel.getRules(unitId, subUnitId)),
startWith(sheetDataValidationModel.getRules(unitId, subUnitId))
),
[],
false,
[sheetDataValidationModel, subUnitId, unitId]
);
const handleAddRule = async () => {
const rule = createDefaultNewRule(injector);
const params: IAddSheetDataValidationCommandParams = {
unitId,
subUnitId,
rule,
};
await commandService.executeCommand(AddSheetDataValidationCommand.id, params);
dataValidationPanelService.setActiveRule({
unitId,
subUnitId,
rule,
});
};
const handleRemoveAll = () => {
commandService.executeCommand(RemoveSheetAllDataValidationCommand.id, {
unitId,
subUnitId,
});
};
const getDvRulesByPermissionCorrect = (rules: ISheetDataValidationRule[]) => {
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const worksheet = workbook.getActiveSheet();
const unitId = workbook.getUnitId();
const subUnitId = worksheet.getSheetId();
const rulesByPermissionCheck = rules.map((rule) => {
const hasPermission = checkRangesEditablePermission(injector, unitId, subUnitId, rule.ranges);
if (hasPermission) {
return { ...rule };
} else {
return { ...rule, disable: true };
}
});
return rulesByPermissionCheck;
};
const rulesByPermissionCheck: (ISheetDataValidationRule & { disable?: boolean })[] = getDvRulesByPermissionCorrect(rules);
const hasDisableRule = rulesByPermissionCheck?.some((rule) => rule.disable);
return (
<div className="univer-pb-4">
{rulesByPermissionCheck?.map((rule) => (
<DataValidationItem
unitId={unitId}
subUnitId={subUnitId}
onClick={() => {
if (rule.disable) return;
dataValidationPanelService.setActiveRule({
unitId,
subUnitId,
rule,
});
}}
rule={rule}
key={rule.uid}
disable={rule.disable ?? false}
/>
))}
<ActionRow
className="univer-mt-4 univer-flex univer-flex-row univer-justify-end univer-gap-2"
>
{(rules.length && !hasDisableRule)
? (
<Button onClick={handleRemoveAll}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.removeAll')}
</Button>
)
: null}
<Button variant="primary" onClick={handleAddRule}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.add')}
</Button>
</ActionRow>
</div>
);
};

View File

@@ -0,0 +1,99 @@
/**
* 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 { IDataValidationRuleOptions } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { DataValidationErrorStyle, LocaleService } from '@univerjs/core';
import { Checkbox, FormLayout, Input, Radio, RadioGroup } from '@univerjs/design';
import { MoreDownIcon, MoreUpIcon } from '@univerjs/icons';
import { ComponentManager, useDependency } from '@univerjs/ui';
import { useState } from 'react';
export interface IDataValidationOptionsParams {
value: IDataValidationRuleOptions;
onChange: (value: IDataValidationRuleOptions) => void;
extraComponent?: string;
}
export function DataValidationOptions(props: IDataValidationOptionsParams) {
const localeService = useDependency(LocaleService);
const componentManager = useDependency(ComponentManager);
const { value, onChange, extraComponent } = props;
const [show, setShow] = useState(false);
const ExtraOptions = extraComponent ? componentManager.get(extraComponent) : null;
return (
<>
<div
className={`
univer-mb-3 univer-flex univer-cursor-pointer univer-items-center univer-text-sm univer-text-gray-900
dark:!univer-text-gray-0
`}
onClick={() => setShow(!show)}
>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.options')}
{show
? (
<MoreUpIcon className="univer-ml-1" />
)
: (
<MoreDownIcon className="univer-ml-1" />
)}
</div>
{show && (
<>
{ExtraOptions ? <ExtraOptions value={value} onChange={onChange} /> : null}
<FormLayout
label={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.invalid')}
>
<RadioGroup
value={`${value.errorStyle ?? DataValidationErrorStyle.WARNING}`}
onChange={(errorStyle) => onChange({ ...value, errorStyle: +errorStyle })}
>
<Radio value={`${DataValidationErrorStyle.WARNING}`}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.showWarning')}
</Radio>
<Radio value={`${DataValidationErrorStyle.STOP}`}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.rejectInput')}
</Radio>
</RadioGroup>
</FormLayout>
<FormLayout
label={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.messageInfo')}
>
<Checkbox
checked={value.showErrorMessage}
onChange={() => onChange({
...value,
showErrorMessage: !value.showErrorMessage,
})}
>
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.showInfo')}
</Checkbox>
</FormLayout>
{value.showErrorMessage
? (
<FormLayout>
<Input value={value.error} onChange={(error) => onChange({ ...value, error })} />
</FormLayout>
)
: null}
</>
)}
</>
);
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Workbook } from '@univerjs/core';
import { IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { useDependency, useObservable } from '@univerjs/ui';
import { of } from 'rxjs';
import { DataValidationPanelService } from '../../services/data-validation-panel.service';
import { DataValidationDetail } from './DataValidationDetail';
import { DataValidationList } from './DataValidationList';
export const DataValidationPanel = () => {
const dataValidationPanelService = useDependency(DataValidationPanelService);
const activeRule = useObservable(dataValidationPanelService.activeRule$, dataValidationPanelService.activeRule);
const univerInstanceService = useDependency(IUniverInstanceService);
const workbook = useObservable(
() => univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET),
undefined,
undefined,
[]
);
const worksheet = useObservable(() => workbook?.activeSheet$ ?? of(null), undefined, undefined, []);
if (!workbook || !worksheet) return null;
return (
activeRule && (activeRule.subUnitId === worksheet.getSheetId() || activeRule.subUnitId === dataValidationPanelService.getFocusFormulaEditorActiveRuleSubUnitId())
? <DataValidationDetail key={activeRule.rule.uid} />
: <DataValidationList workbook={workbook} />
);
};

View File

@@ -0,0 +1,53 @@
/**
* 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 { IDataValidationRuleOptions } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { LocaleService } from '@univerjs/core';
import { Checkbox, FormLayout } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
export interface IDateShowTimeOptionProps {
value: IDataValidationRuleOptions;
onChange: (value: IDataValidationRuleOptions) => void;
}
const DATE_SHOW_TIME_OPTION = 'DATE_SHOW_TIME_OPTION';
export function DateShowTimeOption(props: IDateShowTimeOptionProps) {
const { value, onChange } = props;
const localeService = useDependency(LocaleService);
return (
<FormLayout>
<Checkbox
checked={value.bizInfo?.showTime}
onChange={(showTime) => {
onChange({
...value,
bizInfo: {
...value.bizInfo,
showTime: showTime as boolean,
},
});
}}
>
{localeService.t<LocaleKey>('sheets-data-validation-ui.showTime.label')}
</Checkbox>
</FormLayout>
);
}
DateShowTimeOption.componentKey = DATE_SHOW_TIME_OPTION;

View File

@@ -0,0 +1,73 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICustomComponentProps } from '@univerjs/ui';
import type { LocaleKey } from '../../locale/types';
import { LocaleService } from '@univerjs/core';
import { Separator } from '@univerjs/design';
import { serializeListOptions } from '@univerjs/sheets';
import { useDependency } from '@univerjs/ui';
export const DROPDOWN_PRESETS_COMPONENT = 'SHEETS_DATA_VALIDATION_DROPDOWN_PRESETS';
export function DropdownPresets({ onChange }: ICustomComponentProps<string | undefined>) {
const localeService = useDependency(LocaleService);
const presets = [
[
localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.presets.yes'),
localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.presets.no'),
],
[
localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.presets.notStarted'),
localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.presets.inProgress'),
localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.presets.completed'),
],
['A', 'B', 'C'],
];
return (
<div className="univer-box-border univer-w-[360px]">
<div className="univer-px-2 univer-text-sm univer-font-semibold">
{localeService.t<LocaleKey>('sheets-data-validation-ui.ribbon.dropdownPresetTitle')}
</div>
<div className="univer-mt-2 univer-grid univer-grid-cols-3 univer-gap-1.5">
{presets.map((values) => (
<button
key={values.join('\0')}
type="button"
aria-label={values.join(' / ')}
className={`
univer-flex univer-min-h-20 univer-cursor-pointer univer-flex-col univer-items-start
univer-gap-1.5 univer-rounded-md univer-border-0 univer-bg-gray-50 univer-p-3 univer-text-left
univer-text-sm univer-leading-5 univer-text-gray-900 univer-transition-colors
hover:univer-bg-gray-100
focus-visible:univer-outline-none focus-visible:univer-ring-2
focus-visible:univer-ring-primary-600 focus-visible:univer-ring-offset-0
dark:!univer-bg-gray-800 dark:!univer-text-gray-0
dark:hover:!univer-bg-gray-700
`}
onClick={() => onChange(serializeListOptions(values))}
>
{values.map((value) => <span key={value} className="univer-w-full univer-break-words">{value}</span>)}
</button>
))}
</div>
<Separator className="univer-mt-2" />
</div>
);
}

View File

@@ -0,0 +1,50 @@
/**
* 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 { IDataValidationRuleOptions } from '@univerjs/core';
import type { LocaleKey } from '../../locale/types';
import { DataValidationRenderMode, LocaleService } from '@univerjs/core';
import { FormLayout, Radio, RadioGroup } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
export interface IListRenderModeInputProps {
value: IDataValidationRuleOptions;
onChange: (value: IDataValidationRuleOptions) => void;
}
const LIST_RENDER_MODE_OPTION_INPUT = 'LIST_RENDER_MODE_OPTION_INPUT';
export function ListRenderModeInput(props: IListRenderModeInputProps) {
const { value, onChange } = props;
const localeService = useDependency(LocaleService);
return (
<FormLayout label={localeService.t<LocaleKey>('sheets-data-validation-ui.renderMode.label')}>
<RadioGroup value={`${value.renderMode ?? DataValidationRenderMode.CUSTOM}`} onChange={(renderMode) => onChange({ ...value, renderMode: +renderMode })}>
<Radio value={`${DataValidationRenderMode.CUSTOM}`}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.renderMode.chip')}
</Radio>
<Radio value={`${DataValidationRenderMode.ARROW}`}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.renderMode.arrow')}
</Radio>
<Radio value={`${DataValidationRenderMode.TEXT}`}>
{localeService.t<LocaleKey>('sheets-data-validation-ui.renderMode.text')}
</Radio>
</RadioGroup>
</FormLayout>
);
}
ListRenderModeInput.componentKey = LIST_RENDER_MODE_OPTION_INPUT;

View File

@@ -0,0 +1,866 @@
/**
* 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,
IDisposable,
IDocumentData,
IRange,
ISheetDataValidationRule,
IWorkbookData,
Workbook,
} from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { Root } from 'react-dom/client';
import {
createIdentifier,
DataValidationErrorStyle,
DataValidationOperator,
DataValidationRenderMode,
DataValidationType,
ICommandService,
ILogService,
Inject,
Injector,
LocaleService,
LocaleType,
LogLevel,
Plugin,
set,
ThemeService,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { AddDataValidationMutation, DataValidationModel, DataValidatorRegistryService, UniverDataValidationPlugin } from '@univerjs/data-validation';
import {
ActiveDirtyManagerService,
DefinedNamesService,
FormulaDataModel,
FunctionService,
IActiveDirtyManagerService,
IDefinedNamesService,
IFunctionService,
ISheetRowFilteredService,
ISuperTableService,
LexerTreeBuilder,
RegisterOtherFormulaService,
SheetRowFilteredService,
SuperTableService,
} from '@univerjs/engine-formula';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
RangeProtectionRuleModel,
RefRangeService,
serializeListOptions,
SheetInterceptorService,
SheetSkeletonService,
SheetsSelectionsService,
WorkbookPermissionService,
WorksheetPermissionService,
WorksheetProtectionPointModel,
WorksheetProtectionRuleModel,
} from '@univerjs/sheets';
import {
BASE_FORMULA_INPUT_NAME,
CheckboxValidator,
DataValidationCacheService,
DataValidationCustomFormulaService,
DataValidationFormulaController,
DataValidationFormulaService,
DataValidationListCacheService,
DateValidator,
LIST_FORMULA_INPUT_NAME,
ListMultipleValidator,
ListValidator,
RemoveSheetDataValidationCommand,
SheetDataValidationModel,
SheetsDataValidationValidatorService,
UpdateSheetDataValidationOptionsCommand,
UpdateSheetDataValidationRangeCommand,
UpdateSheetDataValidationSettingCommand,
} from '@univerjs/sheets-data-validation';
import { IMarkSelectionService } from '@univerjs/sheets-ui';
import {
ComponentManager,
DesktopSidebarService,
IDialogService,
ILayoutService,
IPlatformService,
IShortcutService,
ISidebarService,
IUIRuntimeScopeService,
PlatformService,
RediContext,
ShortcutService,
UIRuntimeScopeService,
} from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { of, Subject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { DataValidationPanelService } from '../../../services/data-validation-panel.service';
import { DataValidationDetail } from '../DataValidationDetail';
import { DateShowTimeOption } from '../DateShowTimeOption';
import { FORMULA_INPUTS } from '../formula-input';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'test';
const SUB_UNIT_ID = 'sheet1';
const FIRST_RANGE: IRange = {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 0,
};
class TestMarkSelectionService {
addShape(selection: ISelectionWithStyle) {
return `shape-${selection.range.startRow}`;
}
addShapeWithNoFresh(selection: ISelectionWithStyle) {
return this.addShape(selection);
}
removeShape() {
// The detail tests do not need selection shape state.
}
removeAllShapes() {
// The detail tests do not need selection shape state.
}
refreshShapes() {
// The detail tests do not need selection shape state.
}
getShapeMap() {
return new Map();
}
}
class TestLayoutService {
readonly rootContainerElement = document.body;
}
class TestRenderManagerService {
getRenderUnitById() {
return undefined;
}
}
class TestDescriptionService {
}
class TestDialogService {
open(): IDisposable {
return toDisposable(() => undefined);
}
close(): void {}
closeAll(): void {}
getDialogs$() {
return of([]);
}
}
const IEditorService = createIdentifier<TestEditorService>('univer.editor.service');
const IDescriptionService = createIdentifier<TestDescriptionService>('formula.description-service');
class TestRangeEditor {
readonly blur$ = new Subject<void>();
readonly focus$ = new Subject<void>();
readonly input$ = new Subject<void>();
readonly selectionChange$ = new Subject<void>();
private _data: IDocumentData;
private readonly _change$ = new Subject<void>();
private _isFocus = false;
readonly render = {
isDisposed: () => true,
with: () => ({
getViewModel: () => ({
getDataModel: () => ({
updateDocumentDataPageSize: () => undefined,
}),
}),
getSkeleton: () => ({
getActualSize: () => ({ actualWidth: 0, actualHeight: 0 }),
}),
}),
scene: {
transformByState: () => undefined,
getViewport: () => undefined,
},
mainComponent: {
resize: () => undefined,
translate: () => undefined,
},
components: {
get: () => undefined,
},
};
constructor(private readonly _editorId: string, initialSnapshot: IDocumentData) {
this._data = initialSnapshot;
}
getEditorId() {
return this._editorId;
}
getDocumentData() {
return this._data;
}
setDocumentData(data: IDocumentData) {
this._data = data;
this._change$.next();
}
replaceText(text: string) {
this._data = {
...this._data,
body: {
...this._data.body,
dataStream: `${text}\r\n`,
textRuns: [],
},
};
this._change$.next();
}
getDocumentDataModel() {
return {
change$: this._change$.asObservable(),
getPlainText: () => this._getPlainText(),
};
}
setSelectionRanges() {
this.selectionChange$.next();
}
getSelectionRanges() {
return [];
}
focus() {
this._isFocus = true;
this.focus$.next();
}
blur() {
this._isFocus = false;
this.blur$.next();
}
isFocus() {
return this._isFocus;
}
isSheetEditor() {
return false;
}
getBoundingClientRect() {
return { width: 0, height: 0 };
}
private _getPlainText() {
return this._data.body?.dataStream.replace(/\r\n$/, '') ?? '';
}
}
class TestEditorService {
private readonly _editors = new Map<string, TestRangeEditor>();
private _focusId: string | undefined;
readonly blur$ = new Subject<void>();
readonly focus$ = new Subject<{ unitId: string }>();
register(config: { editorUnitId: string; initialSnapshot: IDocumentData }): IDisposable {
this._editors.set(config.editorUnitId, new TestRangeEditor(config.editorUnitId, config.initialSnapshot));
return toDisposable(() => {
this._editors.delete(config.editorUnitId);
});
}
getEditor(id?: string) {
return id ? this._editors.get(id) : this.getFocusEditor();
}
getAllEditor() {
return this._editors;
}
isEditor(editorUnitId: string) {
return this._editors.has(editorUnitId);
}
getEditorRenderConfig() {
return null;
}
isSheetEditor() {
return false;
}
blur() {
this._focusId = undefined;
this.blur$.next();
}
focus(editorUnitId: string) {
this._focusId = editorUnitId;
this.focus$.next({ unitId: editorUnitId });
}
getFocusId() {
return this._focusId;
}
getFocusEditor() {
return this._focusId ? this._editors.get(this._focusId) : null;
}
}
function createWorkbookData(): IWorkbookData {
return {
id: UNIT_ID,
appVersion: '3.0.0-alpha',
sheets: {
[SUB_UNIT_ID]: {
id: SUB_UNIT_ID,
name: 'sheet1',
cellData: {},
rowCount: 20,
columnCount: 20,
},
},
locale: LocaleType.EN_US,
name: 'test workbook',
sheetOrder: [SUB_UNIT_ID],
styles: {},
};
}
function createDateRule(uid = 'rule-date'): ISheetDataValidationRule {
return {
uid,
type: DataValidationType.DATE,
ranges: [FIRST_RANGE],
allowBlank: true,
bizInfo: {
showTime: false,
},
};
}
function createDateBetweenRule(uid = 'rule-date-between'): ISheetDataValidationRule {
return {
...createDateRule(uid),
operator: DataValidationOperator.BETWEEN,
formula1: '2024-01-01',
formula2: '2024-01-31',
};
}
function createListRule(uid = 'rule-list'): ISheetDataValidationRule {
return {
uid,
type: DataValidationType.LIST,
ranges: [FIRST_RANGE],
formula1: serializeListOptions(['Pending', 'Approved']),
formula2: '#FDCECE,#DEF6A2',
renderMode: DataValidationRenderMode.CUSTOM,
allowBlank: true,
};
}
function createDetailTestBed(rule: ISheetDataValidationRule) {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'sheets-data-validation-ui-detail-behavior-test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
([
[SheetsSelectionsService],
[SheetInterceptorService],
[SheetSkeletonService],
[ComponentManager],
[IDialogService, { useClass: TestDialogService }],
[IEditorService, { useClass: TestEditorService }],
[IDescriptionService, { useClass: TestDescriptionService }],
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
[IPlatformService, { useClass: PlatformService }],
[IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }],
[IShortcutService, { useClass: ShortcutService }],
[ILayoutService, { useClass: TestLayoutService as never }],
[WorksheetPermissionService],
[WorkbookPermissionService],
[WorksheetProtectionPointModel],
[WorksheetProtectionRuleModel],
[RangeProtectionRuleModel],
[FormulaDataModel],
[LexerTreeBuilder],
[RefRangeService],
[IFunctionService, { useClass: FunctionService }],
[IDefinedNamesService, { useClass: DefinedNamesService }],
[ISuperTableService, { useClass: SuperTableService }],
[DataValidationCacheService],
[DataValidationListCacheService],
[DataValidationFormulaService],
[DataValidationCustomFormulaService],
[DataValidationFormulaController],
[RegisterOtherFormulaService],
[IActiveDirtyManagerService, { useClass: ActiveDirtyManagerService }],
[ISheetRowFilteredService, { useClass: SheetRowFilteredService }],
[SheetsDataValidationValidatorService],
[SheetDataValidationModel],
[IMarkSelectionService, { useClass: TestMarkSelectionService as never }],
[ISidebarService, { useClass: DesktopSidebarService }],
[DataValidationPanelService],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
this._injector.get(WorkbookPermissionService);
this._injector.get(WorksheetPermissionService);
}
}
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'sheets-data-validation': {
checkbox: {
title: 'Checkbox',
},
date: {
title: 'Date',
},
list: {
title: 'Dropdown',
},
listMultiple: {
title: 'Dropdown multiple',
},
validFail: {
date: 'Enter a valid date',
list: 'Please input options',
listIntersects: 'The selected range cannot intersect the rule range',
listInvalid: 'Enter a valid range formula',
number: 'Enter a valid number',
},
},
'sheets-data-validation-ui': {
list: {
add: 'Add item',
customOptions: 'Custom options',
options: 'Options',
refOptions: 'From range',
},
operators: {
legal: 'Is valid',
},
panel: {
allowBlank: 'Allow blank',
done: 'Done',
formulaAnd: 'and',
formulaPlaceholder: 'Value or formula',
invalid: 'Invalid data',
messageInfo: 'Message',
options: 'Options',
range: 'Apply to range',
rangeError: 'Select a range',
rejectInput: 'Reject input',
removeRule: 'Remove rule',
showInfo: 'Show message',
showWarning: 'Show warning',
type: 'Criteria',
operator: 'Condition',
},
showTime: {
label: 'Show time',
},
},
},
});
const themeService = injector.get(ThemeService);
themeService.setTheme(set(themeService.getCurrentTheme(), 'gray.1000', '#35322b'));
univer.registerPlugin(UniverDataValidationPlugin);
univer.registerPlugin(TestPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
injector.get(ComponentManager).register(DateShowTimeOption.componentKey, DateShowTimeOption);
FORMULA_INPUTS.forEach(([key, component]) => {
injector.get(ComponentManager).register(key, component);
});
const commandService = injector.get(ICommandService);
commandService.registerCommand(UpdateSheetDataValidationSettingCommand);
commandService.registerCommand(UpdateSheetDataValidationOptionsCommand);
commandService.registerCommand(UpdateSheetDataValidationRangeCommand);
commandService.registerCommand(RemoveSheetDataValidationCommand);
const localeService = injector.get(LocaleService);
const validatorRegistry = injector.get(DataValidatorRegistryService);
const dateValidator = new DateValidator(localeService, injector);
dateValidator.formulaInput = BASE_FORMULA_INPUT_NAME;
dateValidator.optionsInput = DateShowTimeOption.componentKey;
validatorRegistry.register(dateValidator);
validatorRegistry.register(injector.createInstance(CheckboxValidator));
const listValidator = injector.createInstance(ListValidator);
listValidator.formulaInput = LIST_FORMULA_INPUT_NAME;
const listMultipleValidator = injector.createInstance(ListMultipleValidator);
listMultipleValidator.formulaInput = LIST_FORMULA_INPUT_NAME;
validatorRegistry.register(listValidator);
validatorRegistry.register(listMultipleValidator);
const sheetDataValidationModel = injector.get(SheetDataValidationModel);
commandService.syncExecuteCommand(AddDataValidationMutation.id, {
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
rule,
});
injector.get(DataValidationPanelService).setActiveRule({
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
rule,
});
return {
univer,
injector,
commandService,
workbook,
rule,
dataValidationModel: injector.get(DataValidationModel),
panelService: injector.get(DataValidationPanelService),
sheetDataValidationModel,
};
}
async function renderDetail(root: Root, testBed: ReturnType<typeof createDetailTestBed>) {
await act(async () => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<DataValidationDetail />
</RediContext.Provider>
);
await Promise.resolve();
});
}
async function clickElement(element: Element) {
await act(async () => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
}
async function openSelect(element: HTMLElement) {
await act(async () => {
const PointerEventCtor = window.PointerEvent ?? MouseEvent;
element.dispatchEvent(new PointerEventCtor('pointerdown', { bubbles: true, button: 0 }));
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
}
async function changeInputValue(input: HTMLInputElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
}
async function waitForDebounce() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 1100));
});
}
function findElementByText(container: HTMLElement, text: string) {
return Array.from(container.querySelectorAll<HTMLElement>('div'))
.filter((element) => element.textContent?.includes(text))
.sort((a, b) => (a.textContent?.length ?? 0) - (b.textContent?.length ?? 0))[0];
}
function findButtonByText(container: HTMLElement, text: string) {
return Array.from(container.querySelectorAll<HTMLButtonElement>('button'))
.find((element) => element.textContent?.includes(text))!;
}
describe('DataValidationDetail rule editing', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createDetailTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('disposes command listeners when unmounted', async () => {
currentTestBed = createDetailTestBed(createDateRule());
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const commandListeners = () => (currentTestBed!.commandService as unknown as { _commandExecutedListeners: unknown[] })._commandExecutedListeners.length;
const initialListenerCount = commandListeners();
await renderDetail(root, currentTestBed);
expect(commandListeners()).toBeGreaterThan(initialListenerCount);
act(() => root?.unmount());
root = undefined;
expect(commandListeners()).toBe(initialListenerCount);
});
it('persists the allow-blank checkbox into the active rule setting command', async () => {
currentTestBed = createDetailTestBed(createDateRule('rule-date-allow-blank'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)?.allowBlank).toBe(true);
const allowBlankInput = container.querySelector<HTMLInputElement>('[data-u-comp="checkbox"] input')!;
await clickElement(allowBlankInput);
await waitForDebounce();
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)?.allowBlank).toBe(false);
});
it('persists date show-time options through the detail options command', async () => {
currentTestBed = createDetailTestBed(createDateRule());
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
await clickElement(findElementByText(container, 'panel.options'));
const showTimeInput = container.querySelectorAll<HTMLInputElement>('[data-u-comp="checkbox"] input')[1];
await clickElement(showTimeInput);
await waitForDebounce();
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)?.bizInfo).toEqual({
showTime: true,
});
});
it('persists both date bounds when editing a between-rule formula', async () => {
currentTestBed = createDetailTestBed(createDateBetweenRule());
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
const inputs = Array.from(container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input'));
await changeInputValue(inputs[0], '2024-02-01');
await waitForDebounce();
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toMatchObject({
operator: DataValidationOperator.BETWEEN,
formula1: '2024-02-01',
formula2: '2024-01-31',
});
await changeInputValue(inputs[1], '2024-02-29');
await waitForDebounce();
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toMatchObject({
operator: DataValidationOperator.BETWEEN,
formula1: '2024-02-01',
formula2: '2024-02-29',
});
});
it('updates the active rule range after confirming a new apply-to range', async () => {
currentTestBed = createDetailTestBed(createDateRule('rule-date-range-selector'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
const selectRangeIcon = container.querySelector<SVGElement>('svg')!;
await clickElement(selectRangeIcon);
const rangeInput = document.body.querySelector<HTMLInputElement>('[data-u-comp="input"] input')!;
await changeInputValue(rangeInput, 'B3:C4');
await clickElement(findButtonByText(document.body, 'rangeSelector.confirm'));
await waitForDebounce();
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)?.ranges).toEqual([{
startRow: 2,
endRow: 3,
startColumn: 1,
endColumn: 2,
}]);
});
it('clears date bounds and operator when switching the rule to checkbox validation', async () => {
currentTestBed = createDetailTestBed(createDateBetweenRule('rule-date-to-checkbox'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
await openSelect(container.querySelectorAll<HTMLElement>('[data-u-comp="select"]')[0]);
const options = Array.from(document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"], [role="menuitemradio"]'));
const checkboxOption = options.find((item) => item.textContent?.includes('sheets-data-validation.checkbox.title'))!;
await clickElement(checkboxOption);
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toMatchObject({
type: DataValidationType.CHECKBOX,
operator: undefined,
formula1: undefined,
formula2: undefined,
});
});
it('preserves dropdown choices and colors when switching a list rule to multi-select', async () => {
currentTestBed = createDetailTestBed(createListRule('rule-list-to-multiple'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
await openSelect(container.querySelectorAll<HTMLElement>('[data-u-comp="select"]')[0]);
const options = Array.from(document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"], [role="menuitemradio"]'));
const multipleListOption = options.find((item) => item.textContent?.includes('sheets-data-validation.listMultiple.title'))!;
await clickElement(multipleListOption);
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toMatchObject({
type: DataValidationType.LIST_MULTIPLE,
formula1: serializeListOptions(['Pending', 'Approved']),
formula2: '#FDCECE,#DEF6A2',
renderMode: DataValidationRenderMode.CUSTOM,
});
});
it('removes stale date bound inputs after changing validation type to checkbox', async () => {
currentTestBed = createDetailTestBed(createDateBetweenRule('rule-date-editor-to-checkbox'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
const dateInputs = Array.from(container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input'));
expect(dateInputs.map((input) => input.value)).toEqual(['2024-01-01', '2024-01-31']);
await openSelect(container.querySelectorAll<HTMLElement>('[data-u-comp="select"]')[0]);
const options = Array.from(document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"], [role="menuitemradio"]'));
const checkboxOption = options.find((item) => item.textContent?.includes('sheets-data-validation.checkbox.title'))!;
await clickElement(checkboxOption);
expect(Array.from(container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input')).map((input) => input.value)).toEqual([]);
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toMatchObject({
type: DataValidationType.CHECKBOX,
operator: undefined,
formula1: undefined,
formula2: undefined,
});
});
it('keeps the switched checkbox rule while saving panel options after a type change', async () => {
currentTestBed = createDetailTestBed(createDateBetweenRule('rule-date-checkbox-options'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
await openSelect(container.querySelectorAll<HTMLElement>('[data-u-comp="select"]')[0]);
const options = Array.from(document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"], [role="menuitemradio"]'));
const checkboxOption = options.find((item) => item.textContent?.includes('sheets-data-validation.checkbox.title'))!;
await clickElement(checkboxOption);
await clickElement(findElementByText(container, 'panel.options'));
await clickElement(container.querySelectorAll<HTMLInputElement>('[data-u-comp="radio"] input')[1]);
await waitForDebounce();
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toMatchObject({
type: DataValidationType.CHECKBOX,
operator: undefined,
formula1: undefined,
formula2: undefined,
errorStyle: DataValidationErrorStyle.STOP,
});
});
it('removes the active rule and closes the detail panel from the remove action', async () => {
currentTestBed = createDetailTestBed(createDateRule('rule-date-remove'));
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderDetail(root, currentTestBed);
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeDefined();
await clickElement(findButtonByText(container, 'panel.removeRule'));
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeUndefined();
expect(currentTestBed.panelService.activeRule).toBe(null);
expect(container.querySelector('[data-u-comp="data-validation-detail"]')).toBe(null);
});
});

View File

@@ -0,0 +1,964 @@
/**
* 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,
IDisposable,
IDocumentData,
IRange,
ISheetDataValidationRule,
IWorkbookData,
Workbook,
} from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { Root } from 'react-dom/client';
import {
createIdentifier,
DataValidationType,
ICommandService,
ILogService,
Inject,
Injector,
IPermissionService,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
Plugin,
set,
ThemeService,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import {
AddDataValidationMutation,
DataValidationModel,
DataValidatorRegistryService,
UniverDataValidationPlugin,
} from '@univerjs/data-validation';
import {
ActiveDirtyManagerService,
DefinedNamesService,
FormulaDataModel,
FunctionService,
IActiveDirtyManagerService,
IDefinedNamesService,
IFunctionService,
ISheetRowFilteredService,
ISuperTableService,
LexerTreeBuilder,
RegisterOtherFormulaService,
SheetRowFilteredService,
SuperTableService,
} from '@univerjs/engine-formula';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
RangeProtectionRuleModel,
RefRangeService,
SetWorksheetActiveOperation,
SheetInterceptorService,
SheetSkeletonService,
SheetsSelectionsService,
WorkbookEditablePermission,
WorkbookPermissionService,
WorksheetPermissionService,
WorksheetProtectionPointModel,
WorksheetProtectionRuleModel,
} from '@univerjs/sheets';
import {
AddSheetDataValidationCommand,
CHECKBOX_FORMULA_1,
CHECKBOX_FORMULA_2,
CheckboxValidator,
DataValidationCacheService,
DataValidationCustomFormulaService,
DataValidationFormulaService,
DataValidationListCacheService,
RemoveSheetAllDataValidationCommand,
RemoveSheetDataValidationCommand,
SheetDataValidationModel,
SheetsDataValidationValidatorService,
} from '@univerjs/sheets-data-validation';
import { IMarkSelectionService } from '@univerjs/sheets-ui';
import {
ComponentManager,
DesktopSidebarService,
ILayoutService,
IPlatformService,
IShortcutService,
ISidebarService,
IUIRuntimeScopeService,
PlatformService,
RediContext,
ShortcutService,
UIRuntimeScopeService,
} from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Subject } from 'rxjs';
import { afterEach, describe, expect, it } from 'vitest';
import { DataValidationPanelService } from '../../../services/data-validation-panel.service';
import { DataValidationDetail } from '../DataValidationDetail';
import { DataValidationItem } from '../DataValidationItem';
import { DataValidationList } from '../DataValidationList';
import { DataValidationPanel } from '../DataValidationPanel';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'test';
const SUB_UNIT_ID = 'sheet1';
const OTHER_SUB_UNIT_ID = 'sheet2';
const FIRST_RANGE: IRange = {
startRow: 0,
endRow: 1,
startColumn: 0,
endColumn: 0,
};
const SECOND_RANGE: IRange = {
startRow: 4,
endRow: 4,
startColumn: 2,
endColumn: 3,
};
const ADD_RANGE: IRange = {
startRow: 6,
endRow: 7,
startColumn: 1,
endColumn: 2,
};
class TestMarkSelectionService {
private _nextId = 0;
readonly added: Array<{ id: string; selection: ISelectionWithStyle }> = [];
readonly removed: string[] = [];
addShape(selection: ISelectionWithStyle) {
const id = `shape-${this._nextId++}`;
this.added.push({ id, selection });
return id;
}
addShapeWithNoFresh(selection: ISelectionWithStyle) {
return this.addShape(selection);
}
removeShape(id: string) {
this.removed.push(id);
}
removeAllShapes() {
this.removed.push(...this.added.map((item) => item.id));
}
refreshShapes() {
// The component test only needs the service state changes.
}
getShapeMap() {
return new Map();
}
}
class TestLayoutService {
readonly rootContainerElement = document.body;
}
class TestRenderManagerService {
getRenderUnitById() {
return undefined;
}
}
class TestDescriptionService {
}
const IEditorService = createIdentifier<TestEditorService>('univer.editor.service');
const IDescriptionService = createIdentifier<TestDescriptionService>('formula.description-service');
class TestRangeEditor {
readonly blur$ = new Subject<void>();
readonly focus$ = new Subject<void>();
readonly input$ = new Subject<void>();
readonly selectionChange$ = new Subject<void>();
private _data: IDocumentData;
private readonly _change$ = new Subject<void>();
private _isFocus = false;
readonly render = {
isDisposed: () => true,
};
constructor(private readonly _editorId: string, initialSnapshot: IDocumentData) {
this._data = initialSnapshot;
}
getEditorId() {
return this._editorId;
}
getDocumentData() {
return this._data;
}
setDocumentData(data: IDocumentData) {
this._data = data;
this._change$.next();
}
getDocumentDataModel() {
return {
change$: this._change$.asObservable(),
getPlainText: () => this._getPlainText(),
};
}
setSelectionRanges() {
this.selectionChange$.next();
}
getSelectionRanges() {
return [];
}
focus() {
this._isFocus = true;
this.focus$.next();
}
blur() {
this._isFocus = false;
this.blur$.next();
}
isFocus() {
return this._isFocus;
}
isSheetEditor() {
return false;
}
getBoundingClientRect() {
return { width: 0, height: 0 };
}
private _getPlainText() {
return this._data.body?.dataStream.replace(/\r\n$/, '') ?? '';
}
}
class TestEditorService {
private readonly _editors = new Map<string, TestRangeEditor>();
private _focusId: string | undefined;
readonly blur$ = new Subject<void>();
readonly focus$ = new Subject<{ unitId: string }>();
register(config: { editorUnitId: string; initialSnapshot: IDocumentData }): IDisposable {
this._editors.set(config.editorUnitId, new TestRangeEditor(config.editorUnitId, config.initialSnapshot));
return toDisposable(() => {
this._editors.delete(config.editorUnitId);
});
}
getEditor(id?: string) {
return id ? this._editors.get(id) : this.getFocusEditor();
}
getAllEditor() {
return this._editors;
}
isEditor(editorUnitId: string) {
return this._editors.has(editorUnitId);
}
getEditorRenderConfig() {
return null;
}
isSheetEditor() {
return false;
}
blur() {
this._focusId = undefined;
this.blur$.next();
}
focus(editorUnitId: string) {
this._focusId = editorUnitId;
this.focus$.next({ unitId: editorUnitId });
}
getFocusId() {
return this._focusId;
}
getFocusEditor() {
return this._focusId ? this._editors.get(this._focusId) : null;
}
}
function createWorkbookData(): IWorkbookData {
return {
id: UNIT_ID,
appVersion: '3.0.0-alpha',
sheets: {
[SUB_UNIT_ID]: {
id: SUB_UNIT_ID,
name: 'sheet1',
cellData: {},
rowCount: 20,
columnCount: 20,
},
[OTHER_SUB_UNIT_ID]: {
id: OTHER_SUB_UNIT_ID,
name: 'sheet2',
cellData: {},
rowCount: 20,
columnCount: 20,
},
},
locale: LocaleType.EN_US,
name: 'test workbook',
sheetOrder: [SUB_UNIT_ID, OTHER_SUB_UNIT_ID],
styles: {},
};
}
function createCheckboxRule(uid = 'rule-checkbox'): ISheetDataValidationRule {
return {
uid,
type: DataValidationType.CHECKBOX,
ranges: [FIRST_RANGE, SECOND_RANGE],
formula1: `${CHECKBOX_FORMULA_1}`,
formula2: `${CHECKBOX_FORMULA_2}`,
};
}
function createInvalidCheckboxRule(): ISheetDataValidationRule {
return {
...createCheckboxRule('rule-checkbox-invalid'),
formula1: `${CHECKBOX_FORMULA_1}`,
formula2: `${CHECKBOX_FORMULA_1}`,
};
}
function createItemTestBed(rule: ISheetDataValidationRule = createCheckboxRule()) {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override pluginName = 'sheets-data-validation-ui-item-test-plugin';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
([
[SheetsSelectionsService],
[SheetInterceptorService],
[SheetSkeletonService],
[ComponentManager],
[IEditorService, { useClass: TestEditorService }],
[IDescriptionService, { useClass: TestDescriptionService }],
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
[IPlatformService, { useClass: PlatformService }],
[IUIRuntimeScopeService, { useClass: UIRuntimeScopeService }],
[IShortcutService, { useClass: ShortcutService }],
[ILayoutService, { useClass: TestLayoutService as never }],
[WorksheetPermissionService],
[WorkbookPermissionService],
[WorksheetProtectionPointModel],
[WorksheetProtectionRuleModel],
[RangeProtectionRuleModel],
[FormulaDataModel],
[LexerTreeBuilder],
[RefRangeService],
[IFunctionService, { useClass: FunctionService }],
[IDefinedNamesService, { useClass: DefinedNamesService }],
[ISuperTableService, { useClass: SuperTableService }],
[DataValidationCacheService],
[DataValidationListCacheService],
[DataValidationFormulaService],
[DataValidationCustomFormulaService],
[RegisterOtherFormulaService],
[IActiveDirtyManagerService, { useClass: ActiveDirtyManagerService }],
[ISheetRowFilteredService, { useClass: SheetRowFilteredService }],
[SheetsDataValidationValidatorService],
[SheetDataValidationModel],
[IMarkSelectionService, { useClass: TestMarkSelectionService as never }],
[ISidebarService, { useClass: DesktopSidebarService }],
[DataValidationPanelService],
] as Dependency[]).forEach((dependency) => this._injector.add(dependency));
this._injector.get(WorkbookPermissionService);
this._injector.get(WorksheetPermissionService);
}
override onReady(): void {
// no extra lifecycle work is needed for this component test.
}
}
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'sheets-data-validation': {
checkbox: {
title: 'Checkbox',
},
},
'sheets-data-validation-ui': {
panel: {
add: 'Add',
removeAll: 'Remove all',
},
},
},
});
const themeService = injector.get(ThemeService);
themeService.setTheme(set(themeService.getCurrentTheme(), 'gray.1000', '#35322b'));
univer.registerPlugin(UniverDataValidationPlugin);
univer.registerPlugin(TestPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
injector.get(IUniverInstanceService).focusUnit(UNIT_ID);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
const commandService = injector.get(ICommandService);
commandService.registerCommand(AddSheetDataValidationCommand);
commandService.registerCommand(RemoveSheetAllDataValidationCommand);
commandService.registerCommand(RemoveSheetDataValidationCommand);
commandService.registerCommand(SetWorksheetActiveOperation);
const validatorRegistry = injector.get(DataValidatorRegistryService);
validatorRegistry.register(injector.createInstance(CheckboxValidator));
const sheetDataValidationModel = injector.get(SheetDataValidationModel);
commandService.syncExecuteCommand(AddDataValidationMutation.id, {
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
rule,
});
return {
univer,
injector,
commandService,
workbook,
rule,
dataValidationModel: injector.get(DataValidationModel),
panelService: injector.get(DataValidationPanelService),
sheetDataValidationModel,
markSelectionService: injector.get(IMarkSelectionService) as unknown as TestMarkSelectionService,
};
}
function renderItem(
root: Root,
testBed: ReturnType<typeof createItemTestBed>,
onClick: () => void
) {
act(() => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<DataValidationItem
rule={testBed.rule}
onClick={onClick}
unitId={UNIT_ID}
subUnitId={SUB_UNIT_ID}
/>
</RediContext.Provider>
);
});
return document.querySelector('.univer-bg-secondary') as HTMLElement;
}
async function enterItem(item: HTMLElement) {
await act(async () => {
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: document.body }));
await Promise.resolve();
});
}
async function leaveItem(item: HTMLElement) {
await act(async () => {
item.dispatchEvent(new MouseEvent('mouseout', { bubbles: true, relatedTarget: document.body }));
await Promise.resolve();
});
}
describe('DataValidationItem', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createItemTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('marks each rule range while the rule item is hovered and clears the marks on leave', async () => {
currentTestBed = createItemTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const item = renderItem(root, currentTestBed, () => undefined);
await enterItem(item);
expect(currentTestBed.markSelectionService.added.map((entry) => entry.selection.range)).toEqual([
FIRST_RANGE,
SECOND_RANGE,
]);
expect(currentTestBed.markSelectionService.added.every((entry) => entry.selection.primary === null)).toBe(true);
await leaveItem(item);
expect(currentTestBed.markSelectionService.removed).toEqual(['shape-0', 'shape-1']);
});
it('clears highlighted rule ranges when the hovered item is unmounted', async () => {
currentTestBed = createItemTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const item = renderItem(root, currentTestBed, () => undefined);
await enterItem(item);
expect(currentTestBed.markSelectionService.added.map((entry) => entry.selection.range)).toEqual([
FIRST_RANGE,
SECOND_RANGE,
]);
act(() => {
root?.unmount();
});
root = undefined;
expect(currentTestBed.markSelectionService.removed).toEqual(['shape-0', 'shape-1']);
});
it('positions and runs the hovered rule delete action in RTL without selecting the item', async () => {
currentTestBed = createItemTestBed();
container = document.createElement('div');
container.dir = 'rtl';
document.body.appendChild(container);
root = createRoot(container);
let selectedCount = 0;
const item = renderItem(root, currentTestBed, () => {
selectedCount += 1;
});
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeDefined();
await enterItem(item);
const deleteButton = container.querySelector('.univer-absolute') as HTMLElement;
expect(item.className).toContain('rtl:univer-pl-9');
expect(item.className).toContain('rtl:univer-pr-2');
expect(deleteButton.className).toContain('rtl:univer-left-2');
expect(deleteButton.className).toContain('rtl:univer-right-auto');
await act(async () => {
deleteButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(selectedCount).toBe(0);
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeUndefined();
expect(currentTestBed.dataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeUndefined();
});
});
describe('DataValidationList', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createItemTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
function renderList() {
act(() => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<DataValidationList workbook={currentTestBed!.workbook} />
</RediContext.Provider>
);
});
}
it('adds a new validation rule from the current sheet selection and opens it for editing', async () => {
currentTestBed = createItemTestBed();
currentTestBed.injector.get(SheetsSelectionsService).setSelections([{ range: ADD_RANGE, primary: null }] as never);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderList();
const buttons = Array.from(container.querySelectorAll('[data-u-comp="button"]')) as HTMLElement[];
const addButton = buttons[buttons.length - 1];
await act(async () => {
addButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
const rules = currentTestBed.sheetDataValidationModel.getRules(UNIT_ID, SUB_UNIT_ID);
const activeRule = currentTestBed.panelService.activeRule;
expect(rules).toHaveLength(2);
expect(rules[1].ranges).toEqual([ADD_RANGE]);
expect(activeRule?.unitId).toBe(UNIT_ID);
expect(activeRule?.subUnitId).toBe(SUB_UNIT_ID);
expect(activeRule?.rule.uid).toBe(rules[1].uid);
});
it('removes all validation rules from the active sheet through the list action', async () => {
currentTestBed = createItemTestBed();
const secondRule = createCheckboxRule('rule-checkbox-second');
currentTestBed.commandService.syncExecuteCommand(AddDataValidationMutation.id, {
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
rule: secondRule,
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderList();
expect(currentTestBed.sheetDataValidationModel.getRules(UNIT_ID, SUB_UNIT_ID)).toHaveLength(2);
const buttons = Array.from(container.querySelectorAll('[data-u-comp="button"]')) as HTMLElement[];
const removeAllButton = buttons[0];
await act(async () => {
removeAllButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.sheetDataValidationModel.getRules(UNIT_ID, SUB_UNIT_ID)).toEqual([]);
expect(container.textContent).not.toContain('A1:A2');
expect(container.textContent).not.toContain('C5:D5');
});
it('keeps workbook-locked rules from being highlighted, opened, or bulk removed', async () => {
currentTestBed = createItemTestBed();
currentTestBed.injector
.get(IPermissionService)
.updatePermissionPoint(new WorkbookEditablePermission(UNIT_ID).id, false);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
renderList();
const item = container.querySelector('.univer-bg-secondary') as HTMLElement;
await enterItem(item);
expect(currentTestBed.markSelectionService.added).toEqual([]);
await act(async () => {
item.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.panelService.activeRule?.rule.uid).toBeUndefined();
expect(container.textContent).not.toContain('Remove all');
expect(currentTestBed.sheetDataValidationModel.getRules(UNIT_ID, SUB_UNIT_ID)).toHaveLength(1);
});
});
describe('DataValidationDetail', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createItemTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
async function renderDetail() {
currentTestBed!.panelService.setActiveRule({
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
rule: currentTestBed!.rule,
});
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<DataValidationDetail />
</RediContext.Provider>
);
await Promise.resolve();
});
return container!.querySelector('[data-u-comp="data-validation-detail"]') as HTMLElement;
}
it('removes the active validation rule and returns to the rule list', async () => {
currentTestBed = createItemTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const detail = await renderDetail();
const buttons = Array.from(detail.querySelectorAll('[data-u-comp="button"]')) as HTMLElement[];
const removeButton = buttons[buttons.length - 2];
await act(async () => {
removeButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeUndefined();
expect(currentTestBed.panelService.activeRule).toBeNull();
});
it('keeps a valid rule and closes the detail view when done is clicked', async () => {
currentTestBed = createItemTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const detail = await renderDetail();
const buttons = Array.from(detail.querySelectorAll('[data-u-comp="button"]')) as HTMLElement[];
const doneButton = buttons[buttons.length - 1];
await act(async () => {
doneButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeDefined();
expect(currentTestBed.panelService.activeRule).toBeNull();
});
it('keeps the detail open when the rule formula is invalid', async () => {
currentTestBed = createItemTestBed(createInvalidCheckboxRule());
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
const detail = await renderDetail();
const buttons = Array.from(detail.querySelectorAll('[data-u-comp="button"]')) as HTMLElement[];
const doneButton = buttons[buttons.length - 1];
await act(async () => {
doneButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.panelService.activeRule?.rule.uid).toBe(currentTestBed.rule.uid);
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeDefined();
});
});
describe('DataValidationPanel', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createItemTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('ignores an active rule from another sheet until a current-sheet rule is selected', async () => {
currentTestBed = createItemTestBed();
currentTestBed.panelService.setActiveRule({
unitId: UNIT_ID,
subUnitId: OTHER_SUB_UNIT_ID,
rule: currentTestBed.rule,
});
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<DataValidationPanel />
</RediContext.Provider>
);
await Promise.resolve();
});
const item = container.querySelector('.univer-bg-secondary') as HTMLElement;
await act(async () => {
item.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.panelService.activeRule?.subUnitId).toBe(SUB_UNIT_ID);
expect(currentTestBed.panelService.activeRule?.rule.uid).toBe(currentTestBed.rule.uid);
expect(container.querySelector('[data-u-comp="data-validation-detail"]')).not.toBeNull();
});
it('keeps showing a cross-sheet active rule while its formula editor has focus', async () => {
currentTestBed = createItemTestBed();
currentTestBed.panelService.setActiveRule({
unitId: UNIT_ID,
subUnitId: OTHER_SUB_UNIT_ID,
rule: currentTestBed.rule,
});
currentTestBed.panelService.setFocusFormulaEditorActiveRuleSubUnitId(OTHER_SUB_UNIT_ID);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<DataValidationPanel />
</RediContext.Provider>
);
await Promise.resolve();
});
expect(container.querySelector('[data-u-comp="data-validation-detail"]')).not.toBeNull();
expect(currentTestBed.panelService.activeRule?.subUnitId).toBe(OTHER_SUB_UNIT_ID);
});
it('returns to the cross-sheet active rule sheet after the focused rule is accepted', async () => {
currentTestBed = createItemTestBed();
currentTestBed.panelService.setActiveRule({
unitId: UNIT_ID,
subUnitId: OTHER_SUB_UNIT_ID,
rule: currentTestBed.rule,
});
currentTestBed.panelService.setFocusFormulaEditorActiveRuleSubUnitId(OTHER_SUB_UNIT_ID);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<DataValidationPanel />
</RediContext.Provider>
);
await Promise.resolve();
});
expect(currentTestBed.workbook.getActiveSheet().getSheetId()).toBe(SUB_UNIT_ID);
const detail = container.querySelector('[data-u-comp="data-validation-detail"]') as HTMLElement;
const buttons = Array.from(detail.querySelectorAll('[data-u-comp="button"]')) as HTMLElement[];
const doneButton = buttons[buttons.length - 1];
await act(async () => {
doneButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.workbook.getActiveSheet().getSheetId()).toBe(OTHER_SUB_UNIT_ID);
expect(currentTestBed.panelService.activeRule).toBeNull();
});
it('opens the selected rule in detail and returns to the list after the rule is accepted', async () => {
currentTestBed = createItemTestBed();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<DataValidationPanel />
</RediContext.Provider>
);
await Promise.resolve();
});
const item = container.querySelector('.univer-bg-secondary') as HTMLElement;
await act(async () => {
item.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.panelService.activeRule?.rule.uid).toBe(currentTestBed.rule.uid);
const detail = container.querySelector('[data-u-comp="data-validation-detail"]') as HTMLElement;
const buttons = Array.from(detail.querySelectorAll('[data-u-comp="button"]')) as HTMLElement[];
const doneButton = buttons[buttons.length - 1];
await act(async () => {
doneButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve();
});
expect(currentTestBed.panelService.activeRule).toBeNull();
expect(currentTestBed.sheetDataValidationModel.getRuleById(UNIT_ID, SUB_UNIT_ID, currentTestBed.rule.uid)).toBeDefined();
expect(container.textContent).toContain('A1:A2');
});
});

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 { IDataValidationRuleOptions } from '@univerjs/core';
import {
DataValidationErrorStyle,
DataValidationRenderMode,
LocaleService,
LocaleType,
Univer,
} from '@univerjs/core';
import { ComponentManager, RediContext } from '@univerjs/ui';
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { DataValidationOptions } from '../DataValidationOptions';
import { ListRenderModeInput } from '../ListRenderModeInput';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function createOptionsTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
injector.add([ComponentManager]);
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'sheets-data-validation-ui': {
panel: {
invalid: 'Invalid data',
messageInfo: 'Message',
options: 'Options',
rejectInput: 'Reject input',
showInfo: 'Show message',
showWarning: 'Show warning',
},
renderMode: {
arrow: 'Arrow',
chip: 'Chip',
label: 'Display style',
text: 'Text',
},
},
},
});
return {
injector,
univer,
};
}
function OptionsHarness(props: {
initialValue: IDataValidationRuleOptions;
changes: IDataValidationRuleOptions[];
extraComponent?: string;
}) {
const [value, setValue] = useState(props.initialValue);
return (
<DataValidationOptions
value={value}
onChange={(nextValue) => {
props.changes.push(nextValue);
setValue(nextValue);
}}
extraComponent={props.extraComponent}
/>
);
}
async function clickElement(element: HTMLElement) {
await act(async () => {
element.click();
await Promise.resolve();
});
}
async function changeInputValue(input: HTMLInputElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
}
describe('DataValidationOptions', () => {
let root: ReturnType<typeof createRoot> | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createOptionsTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('saves invalid-data handling and custom error message options through change parameters', async () => {
currentTestBed = createOptionsTestBed();
const changes: IDataValidationRuleOptions[] = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<OptionsHarness
initialValue={{
errorStyle: DataValidationErrorStyle.WARNING,
showErrorMessage: false,
error: 'old message',
}}
changes={changes}
/>
</RediContext.Provider>
);
await Promise.resolve();
});
await clickElement(container.firstElementChild as HTMLElement);
await clickElement(container.querySelectorAll<HTMLInputElement>('[data-u-comp="radio"] input')[1]);
expect(changes.at(-1)).toEqual({
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: false,
error: 'old message',
});
await clickElement(container.querySelector<HTMLInputElement>('[data-u-comp="checkbox"] input')!);
expect(changes.at(-1)).toEqual({
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: true,
error: 'old message',
});
await changeInputValue(container.querySelector<HTMLInputElement>('[data-u-comp="input"] input')!, 'Only choose approved values');
expect(changes.at(-1)).toEqual({
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: true,
error: 'Only choose approved values',
});
});
it('keeps a custom error message while message display is toggled off and back on', async () => {
currentTestBed = createOptionsTestBed();
const changes: IDataValidationRuleOptions[] = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<OptionsHarness
initialValue={{
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: true,
error: 'Keep this instruction',
}}
changes={changes}
/>
</RediContext.Provider>
);
await Promise.resolve();
});
await clickElement(container.firstElementChild as HTMLElement);
const showMessageCheckbox = container.querySelector<HTMLInputElement>('[data-u-comp="checkbox"] input')!;
await clickElement(showMessageCheckbox);
expect(changes.at(-1)).toEqual({
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: false,
error: 'Keep this instruction',
});
await clickElement(showMessageCheckbox);
expect(changes.at(-1)).toEqual({
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: true,
error: 'Keep this instruction',
});
expect(container.querySelector<HTMLInputElement>('[data-u-comp="input"] input')?.value).toBe('Keep this instruction');
});
it('keeps edited invalid-data options after the options section is collapsed and reopened', async () => {
currentTestBed = createOptionsTestBed();
const changes: IDataValidationRuleOptions[] = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<OptionsHarness
initialValue={{
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: true,
error: 'Initial instruction',
}}
changes={changes}
/>
</RediContext.Provider>
);
await Promise.resolve();
});
const optionsToggle = container.firstElementChild as HTMLElement;
await clickElement(optionsToggle);
await changeInputValue(container.querySelector<HTMLInputElement>('[data-u-comp="input"] input')!, 'Updated instruction');
expect(changes.at(-1)).toEqual({
errorStyle: DataValidationErrorStyle.STOP,
showErrorMessage: true,
error: 'Updated instruction',
});
await clickElement(optionsToggle);
expect(container.querySelector<HTMLInputElement>('[data-u-comp="input"] input')).toBeNull();
await clickElement(optionsToggle);
expect(container.querySelector<HTMLInputElement>('[data-u-comp="input"] input')?.value).toBe('Updated instruction');
});
it('saves list render mode changes from the registered extra options component', async () => {
currentTestBed = createOptionsTestBed();
currentTestBed.injector
.get(ComponentManager)
.register(ListRenderModeInput.componentKey, ListRenderModeInput);
const changes: IDataValidationRuleOptions[] = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<OptionsHarness
initialValue={{
errorStyle: DataValidationErrorStyle.WARNING,
renderMode: DataValidationRenderMode.CUSTOM,
showErrorMessage: false,
}}
changes={changes}
extraComponent={ListRenderModeInput.componentKey}
/>
</RediContext.Provider>
);
await Promise.resolve();
});
await clickElement(container.firstElementChild as HTMLElement);
await clickElement(container.querySelectorAll<HTMLInputElement>('[data-u-comp="radio"] input')[1]);
expect(changes.at(-1)).toEqual({
errorStyle: DataValidationErrorStyle.WARNING,
renderMode: DataValidationRenderMode.ARROW,
showErrorMessage: false,
});
});
});

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 { Root } from 'react-dom/client';
import { LocaleService, LocaleType, Univer } from '@univerjs/core';
import { serializeListOptions } from '@univerjs/sheets';
import { RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import enUS from '../../../locale/en-US';
import { DropdownPresets } from '../DropdownPresets';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe('DropdownPresets', () => {
let univer: Univer | undefined;
let root: Root | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
act(() => root?.unmount());
container?.remove();
univer?.dispose();
root = undefined;
container = undefined;
univer = undefined;
});
it('emits each localized preset as a serialized dropdown value', async () => {
univer = new Univer({ locale: LocaleType.EN_US });
const injector = univer.__getInjector();
injector.get(LocaleService).load({ [LocaleType.EN_US]: enUS });
const onChange = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector }}>
<DropdownPresets value={undefined} onChange={onChange} />
</RediContext.Provider>
);
await Promise.resolve();
});
const buttons = [...container.querySelectorAll('button')];
expect(buttons).toHaveLength(3);
for (const button of buttons) {
await act(async () => {
button.click();
await Promise.resolve();
});
}
expect(onChange.mock.calls.map(([value]) => value)).toEqual([
serializeListOptions(['Yes', 'No']),
serializeListOptions(['Not Started', 'In Progress', 'Completed']),
serializeListOptions(['A', 'B', 'C']),
]);
});
});

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 { LocaleKey } from '../../../locale/types';
import type { IFormulaInputProps } from './interface';
import { LocaleService } from '@univerjs/core';
import { FormLayout, Input } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
export const BaseFormulaInput = (props: IFormulaInputProps) => {
const { isTwoFormula = false, value, onChange, showError, validResult } = props;
const localeService = useDependency(LocaleService);
const formula1Res = showError ? validResult?.formula1 : '';
const formula2Res = showError ? validResult?.formula2 : '';
if (isTwoFormula) {
return (
<>
<FormLayout error={formula1Res}>
<Input
className="univer-w-full"
placeholder={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.formulaPlaceholder')}
value={value?.formula1}
onChange={(newValue) => {
onChange?.({
...value,
formula1: newValue,
});
}}
/>
</FormLayout>
<div className="-univer-mt-2 univer-mb-1 univer-text-sm univer-text-gray-400">
{localeService.t<LocaleKey>('sheets-data-validation-ui.panel.formulaAnd')}
</div>
<FormLayout error={formula2Res}>
<Input
className="univer-w-full"
placeholder={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.formulaPlaceholder')}
value={value?.formula2}
onChange={(newValue) => {
onChange?.({
...value,
formula2: newValue,
});
}}
/>
</FormLayout>
</>
);
}
return (
<FormLayout error={formula1Res}>
<Input
className="univer-w-full"
placeholder={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.formulaPlaceholder')}
value={value?.formula1}
onChange={(newValue) => {
onChange?.({ formula1: newValue });
}}
/>
</FormLayout>
);
};

View File

@@ -0,0 +1,89 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../../locale/types';
import type { IFormulaInputProps } from './interface';
import { LocaleService } from '@univerjs/core';
import { Checkbox, FormLayout, Input } from '@univerjs/design';
import { useDependency } from '@univerjs/ui';
import { useState } from 'react';
export function CheckboxFormulaInput(props: IFormulaInputProps) {
const { value, onChange, showError, validResult } = props;
const localeService = useDependency(LocaleService);
const formula1Res = showError ? validResult?.formula1 : '';
const formula2Res = showError ? validResult?.formula2 : '';
const [checked, setChecked] = useState(!(value?.formula1 === undefined && value?.formula2 === undefined));
return (
<>
<FormLayout>
<Checkbox
checked={checked}
onChange={(newValue) => {
if (newValue) {
setChecked(true);
} else {
setChecked(false);
onChange?.({
...value,
formula1: undefined,
formula2: undefined,
});
}
}}
>
{localeService.t<LocaleKey>('sheets-data-validation-ui.checkbox.tips')}
</Checkbox>
</FormLayout>
{checked
? (
<FormLayout label={localeService.t<LocaleKey>('sheets-data-validation-ui.checkbox.checked')} error={formula1Res}>
<Input
className="univer-w-full"
placeholder={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.valuePlaceholder')}
value={value?.formula1}
onChange={(newValue) => {
onChange?.({
...value,
formula1: newValue || undefined,
});
}}
/>
</FormLayout>
)
: null}
{checked
? (
<FormLayout label={localeService.t<LocaleKey>('sheets-data-validation-ui.checkbox.unchecked')} error={formula2Res}>
<Input
className="univer-w-full"
placeholder={localeService.t<LocaleKey>('sheets-data-validation-ui.panel.valuePlaceholder')}
value={value?.formula2}
onChange={(newValue) => {
onChange?.({
...value,
formula2: newValue || undefined,
});
}}
/>
</FormLayout>
)
: null}
</>
);
}

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 { IFormulaEditorRef } from '@univerjs/sheets-formula-ui';
import type { IFormulaInputProps } from './interface';
import { borderClassName, clsx, FormLayout } from '@univerjs/design';
import { FormulaEditor } from '@univerjs/sheets-formula-ui';
import { useSidebarClick } from '@univerjs/ui';
import { useRef, useState } from 'react';
export function CustomFormulaInput(props: IFormulaInputProps) {
const { unitId, subUnitId, value, onChange, showError, validResult } = props;
const formula1Res = showError ? validResult?.formula1 : undefined;
const formulaEditorRef = useRef<IFormulaEditorRef>(null);
const [isFocusFormulaEditor, setIsFocusFormulaEditor] = useState(false);
useSidebarClick((e: MouseEvent) => {
const isOutSide = formulaEditorRef.current?.isClickOutSide(e);
isOutSide && setIsFocusFormulaEditor(false);
});
return (
<FormLayout error={formula1Res}>
<FormulaEditor
ref={formulaEditorRef}
className={clsx(`
univer-box-border univer-h-8 univer-w-full univer-cursor-pointer univer-items-center univer-rounded-lg
univer-bg-gray-0 univer-pt-2 univer-transition-colors
hover:univer-border-primary-600
dark:!univer-bg-gray-700 dark:!univer-text-gray-0
[&>div:first-child]:univer-px-2.5
[&>div]:univer-h-5 [&>div]:univer-ring-transparent
`, borderClassName)}
initValue={value?.formula1 ?? '=' as any}
unitId={unitId}
subUnitId={subUnitId}
isFocus={isFocusFormulaEditor}
isSupportAcrossSheet
onChange={(newValue) => {
const newFormula = (newValue ?? '').trim();
if (newFormula === value?.formula1) {
return;
}
onChange?.({
...value,
formula1: newFormula,
});
}}
onFocus={() => setIsFocusFormulaEditor(true)}
/>
</FormLayout>
);
}

View File

@@ -0,0 +1,430 @@
/**
* 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 { ListValidator } from '@univerjs/sheets-data-validation';
import type { IFormulaEditorRef } from '@univerjs/sheets-formula-ui';
import type { LocaleKey } from '../../../locale/types';
import type { IFormulaInputProps } from './interface';
import { DataValidationType, generateRandomId, isFormulaString, LocaleService, ThemeService } from '@univerjs/core';
import { DataValidationModel, DataValidatorRegistryService } from '@univerjs/data-validation';
import { borderClassName, clsx, DraggableList, Dropdown, FormLayout, Input, Radio, RadioGroup } from '@univerjs/design';
import { DeleteIcon, GripVerticalIcon, IncreaseIcon, MoreDownIcon } from '@univerjs/icons';
import { deserializeListOptions } from '@univerjs/sheets';
import { DataValidationFormulaController } from '@univerjs/sheets-data-validation';
import { FormulaEditor } from '@univerjs/sheets-formula-ui';
import { useDependency, useEvent, useObservable, useSidebarClick } from '@univerjs/ui';
import { useEffect, useMemo, useRef, useState } from 'react';
import { debounceTime } from 'rxjs';
import { DROP_DOWN_DEFAULT_COLOR } from '../../../const';
import { DataValidationPanelService } from '../../../services/data-validation-panel.service';
import { buildCustomListFormulaPayload } from './utils';
const DEFAULT_COLOR_PRESET = [
'#FFFFFF',
'#FEE7E7',
'#FEF0E6',
'#EFFBD0',
'#E4F4FE',
'#E8ECFD',
'#F1EAFA',
'#FDE8F3',
'#E5E5E5',
'#FDCECE',
'#FDC49B',
'#DEF6A2',
'#9FDAFF',
'#D0D9FB',
'#E3D5F6',
'#FBD0E8',
'#656565',
'#FE4B4B',
'#FF8C51',
'#8BBB11',
'#0B9EFB',
'#3A60F7',
'#9E6DE3',
'#F248A6',
];
interface IDropdownItem {
color: string;
label: string;
isRef: boolean;
id: string;
}
interface IColorSelectProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
const ColorSelect = (props: IColorSelectProps) => {
const { value, onChange, disabled } = props;
const [open, setOpen] = useState(false);
const themeService = useDependency(ThemeService);
useObservable(themeService.currentTheme$);
const backgroundColor = value.includes('.') && themeService.isValidThemeColor(value)
? themeService.getColorFromTheme(value)
: value;
return (
<Dropdown
align="start"
disabled={disabled}
open={open}
onOpenChange={setOpen}
overlay={(
<div
className={`
univer-box-border univer-grid univer-w-fit univer-grid-cols-6 univer-flex-wrap univer-gap-2
univer-p-1.5
`}
>
{DEFAULT_COLOR_PRESET.map(
(color) => (
<div
key={color}
className={clsx('univer-box-border univer-size-4 univer-cursor-pointer univer-rounded', borderClassName)}
style={{ background: color }}
onClick={() => {
onChange(color);
setOpen(false);
}}
/>
)
)}
</div>
)}
>
<div
className={clsx(`
univer-box-border univer-inline-flex univer-h-8 univer-w-16 univer-cursor-pointer univer-items-center
univer-justify-between univer-gap-2 univer-rounded-lg univer-bg-gray-0 univer-px-2.5
univer-transition-colors univer-duration-200
hover:univer-border-primary-600
dark:!univer-bg-gray-700 dark:!univer-text-gray-0
`, borderClassName)}
>
<div
className="univer-box-border univer-size-4 univer-rounded univer-text-base"
style={{ background: backgroundColor }}
/>
<MoreDownIcon />
</div>
</Dropdown>
);
};
const Template = (props: { item: IDropdownItem; commonProps: any; className?: string }) => {
const { item, commonProps, className } = props;
const { onItemChange, onItemDelete } = commonProps;
return (
<div className={clsx('univer-flex univer-items-center univer-gap-2', className)}>
{!item.isRef && (
<div className={clsx('univer-cursor-move', 'draggableHandle')}>
<GripVerticalIcon />
</div>
)}
<ColorSelect
value={item.color}
onChange={(color) => {
onItemChange(item.id, item.label, color);
}}
/>
<Input
disabled={item.isRef}
value={item.label}
onChange={(label) => {
onItemChange(item.id, label, item.color);
}}
/>
{item.isRef
? null
: (
<div
className={`
univer-ml-1 univer-cursor-pointer univer-rounded univer-text-base
hover:univer-bg-gray-200
`}
>
<DeleteIcon onClick={() => onItemDelete(item.id)} />
</div>
)}
</div>
);
};
const NOOP = () => { /* empty */ };
export function ListFormulaInput(props: IFormulaInputProps) {
const { value, onChange: _onChange = NOOP, unitId, subUnitId, validResult, showError, ruleId } = props;
const { formula1 = '', formula2 = '' } = value || {};
const [isFormulaStr, setIsFormulaStr] = useState(() => isFormulaString(formula1) ? '1' : '0');
const [formulaStr, setFormulaStr] = useState(isFormulaStr === '1' ? formula1 : '=');
const [formulaStrCopy, setFormulaStrCopy] = useState(isFormulaStr === '1' ? formula1 : '=');
const localeService = useDependency(LocaleService);
const dataValidatorRegistryService = useDependency(DataValidatorRegistryService);
const dataValidationModel = useDependency(DataValidationModel);
const dataValidationFormulaController = useDependency(DataValidationFormulaController);
const dataValidationPanelService = useDependency(DataValidationPanelService);
const [refColors, setRefColors] = useState(() => formula2.split(','));
const listValidator = dataValidatorRegistryService.getValidatorItem(DataValidationType.LIST) as ListValidator;
const [refOptions, setRefOptions] = useState<string[]>([]);
const [localError, setLocalError] = useState('');
const formula1Res = showError ? validResult?.formula1 : '';
const ruleChange$ = useMemo(() => dataValidationModel.ruleChange$.pipe(debounceTime(16)), []);
const ruleChange = useObservable(ruleChange$);
const onChange = useEvent(_onChange);
useEffect(() => {
let cancelled = false;
const timer = setTimeout(() => {
const rule = dataValidationModel.getRuleById(unitId, subUnitId, ruleId);
const formula1 = rule?.formula1;
if (isFormulaString(formula1) && listValidator && rule) {
listValidator.getListAsync(rule, unitId, subUnitId).then((options) => {
if (!cancelled) {
setRefOptions(options);
}
});
}
}, 100);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [dataValidationModel, ruleChange, listValidator, ruleId, subUnitId, unitId]);
useEffect(() => {
if (isFormulaString(formula1) && formula1 !== formulaStrCopy) {
setFormulaStr(formula1);
setFormulaStrCopy(formula1);
}
}, [formulaStrCopy, formula1]);
const [strList, setStrList] = useState<IDropdownItem[]>(() => {
const strOptions = isFormulaStr !== '1' ? deserializeListOptions(formula1) : [];
const strColors = formula2.split(',');
return strOptions.map((label, i) => ({
label,
color: strColors[i] || DROP_DOWN_DEFAULT_COLOR,
isRef: false,
id: generateRandomId(4),
}));
});
const handleStrItemChange = (id: string, value: string, color: string) => {
const item = strList.find((i) => i.id === id);
if (!item) {
return;
}
item.label = value;
item.color = color;
setStrList([...strList]);
};
const handleStrItemDelete = (id: string) => {
const index = strList.findIndex((i) => i.id === id);
if (index !== -1) {
strList.splice(index, 1);
setStrList([...strList]);
}
};
const refFinalList: IDropdownItem[] = useMemo(() => refOptions.map((label, i) => ({
label,
color: refColors[i] || DROP_DOWN_DEFAULT_COLOR,
id: `${i}`,
isRef: true,
})), [refColors, refOptions]);
const handleRefItemChange = (id: string, value: string, color: string) => {
const newColors = [...refColors];
newColors[+id] = color;
setRefColors(newColors);
onChange({
formula1,
formula2: newColors.join(','),
});
};
const handleAdd = () => {
setStrList([
...strList,
{
label: '',
color: DROP_DOWN_DEFAULT_COLOR,
isRef: false,
id: generateRandomId(4),
},
]);
};
useEffect(() => {
if (isFormulaStr === '1') {
return;
}
onChange(buildCustomListFormulaPayload(strList, DROP_DOWN_DEFAULT_COLOR));
}, [strList, onChange, isFormulaStr]);
const updateFormula = useEvent(async (str: string) => {
if (!isFormulaString(str)) {
onChange?.({
formula1: '',
formula2,
});
return;
}
if (dataValidationFormulaController.getFormulaRefCheck(str)) {
onChange?.({
formula1: isFormulaString(str) ? str : '',
formula2,
});
setLocalError('');
} else {
onChange?.({
formula1: '',
formula2,
});
setFormulaStr('=');
setLocalError(localeService.t<LocaleKey>('sheets-data-validation-ui.validFail.formulaError'));
}
});
const formulaEditorRef = useRef<IFormulaEditorRef>(null);
const [isFocusFormulaEditor, setIsFocusFormulaEditor] = useState(false);
useSidebarClick((e: MouseEvent) => {
const isOutSide = formulaEditorRef.current?.isClickOutSide(e);
isOutSide && setIsFocusFormulaEditor(false);
});
useEffect(() => {
if (isFocusFormulaEditor) {
dataValidationPanelService.setFocusFormulaEditorActiveRuleSubUnitId(subUnitId);
} else {
dataValidationPanelService.setFocusFormulaEditorActiveRuleSubUnitId(null);
}
}, [isFocusFormulaEditor, subUnitId, dataValidationPanelService]);
return (
<>
<FormLayout label={localeService.t<LocaleKey>('sheets-data-validation-ui.list.options')}>
<RadioGroup
value={isFormulaStr}
onChange={(v) => {
setIsFormulaStr(v as string);
setFormulaStr(formulaStrCopy);
if (v === '1') {
onChange({
formula1: formulaStrCopy === '=' ? '' : formulaStrCopy,
formula2: refColors.join(','),
});
}
}}
>
<Radio value="0">{localeService.t<LocaleKey>('sheets-data-validation-ui.list.customOptions')}</Radio>
<Radio value="1">{localeService.t<LocaleKey>('sheets-data-validation-ui.list.refOptions')}</Radio>
</RadioGroup>
</FormLayout>
{isFormulaStr === '1'
? (
<FormLayout error={(formula1Res || localError) || undefined}>
<FormulaEditor
ref={formulaEditorRef}
className={clsx(`
univer-box-border univer-h-8 univer-w-full univer-cursor-pointer univer-items-center
univer-rounded-lg univer-bg-gray-0 univer-pt-2 univer-transition-colors
hover:univer-border-primary-600
dark:!univer-bg-gray-700 dark:!univer-text-gray-0
[&>div:first-child]:univer-px-2.5
[&>div]:univer-h-5 [&>div]:univer-ring-transparent
`, borderClassName)}
initValue={formulaStr as any}
unitId={unitId}
subUnitId={subUnitId}
isFocus={isFocusFormulaEditor}
isSupportAcrossSheet
onFocus={() => setIsFocusFormulaEditor(true)}
onChange={(v = '') => {
const str = (v ?? '').trim();
setFormulaStrCopy(str);
updateFormula(str);
}}
/>
{refFinalList.length > 0 && (
<div className="univer-mt-3">
{refFinalList.map((item) => {
return (
<Template
key={item.id}
className="univer-mb-3"
item={item}
commonProps={{ onItemChange: handleRefItemChange }}
/>
);
})}
</div>
)}
</FormLayout>
)
: (
<FormLayout error={formula1Res}>
<div>
<DraggableList
list={strList}
onListChange={setStrList}
rowHeight={28}
margin={[0, 12]}
draggableHandle=".draggableHandle"
itemRender={(item) => (
<Template
key={item.id}
item={item}
commonProps={{
onItemChange: handleStrItemChange,
onItemDelete: handleStrItemDelete,
}}
/>
)}
idKey="id"
/>
<a
className={`
univer-text-primary univer-flex univer-w-fit univer-cursor-pointer univer-flex-row
univer-items-center univer-rounded univer-p-1 univer-px-2 univer-text-sm
hover:univer-bg-primary-50
dark:hover:!univer-bg-gray-800
`}
onClick={handleAdd}
>
<IncreaseIcon className="univer-mr-1" />
{localeService.t<LocaleKey>('sheets-data-validation-ui.list.add')}
</a>
</div>
</FormLayout>
)}
</>
);
}

View File

@@ -0,0 +1,199 @@
/**
* 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 { IFormulaValue } from '@univerjs/data-validation';
import type { FormulaInputType } from '../interface';
import { LocaleService, LocaleType, Univer } from '@univerjs/core';
import { RediContext } from '@univerjs/ui';
import { act, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { BaseFormulaInput } from '../BaseFormulaInput';
import { CheckboxFormulaInput } from '../CheckboxFormulaInput';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function createFormulaInputTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
injector.get(LocaleService).load({
[LocaleType.EN_US]: {
'sheets-data-validation-ui': {
checkbox: {
checked: 'Checked',
tips: 'Use custom cell values',
unchecked: 'Unchecked',
},
panel: {
formulaAnd: 'and',
formulaPlaceholder: 'Enter a formula',
valuePlaceholder: 'Enter a value',
},
},
},
});
return {
injector,
univer,
};
}
function FormulaInputHarness(props: {
Component: FormulaInputType;
changes: IFormulaValue[];
initialValue: IFormulaValue;
isTwoFormula?: boolean;
}) {
const [value, setValue] = useState(props.initialValue);
return (
<props.Component
isTwoFormula={props.isTwoFormula}
value={value}
onChange={(nextValue) => {
if (!nextValue) {
return;
}
props.changes.push(nextValue);
setValue(nextValue);
}}
ruleId="rule-formula"
subUnitId="sheet-1"
unitId="unit-1"
/>
);
}
async function changeInputValue(input: HTMLInputElement, value: string) {
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
await Promise.resolve();
});
}
async function clickElement(element: HTMLElement) {
await act(async () => {
element.click();
await Promise.resolve();
});
}
describe('formula input behaviors', () => {
let root: ReturnType<typeof createRoot> | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createFormulaInputTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('keeps the other bound when editing a two-bound formula rule', async () => {
currentTestBed = createFormulaInputTestBed();
const changes: IFormulaValue[] = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<FormulaInputHarness
Component={BaseFormulaInput}
changes={changes}
initialValue={{ formula1: '10', formula2: '20' }}
isTwoFormula
/>
</RediContext.Provider>
);
await Promise.resolve();
});
const inputs = container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input');
await changeInputValue(inputs[0], '15');
expect(changes.at(-1)).toEqual({ formula1: '15', formula2: '20' });
await changeInputValue(inputs[1], '25');
expect(changes.at(-1)).toEqual({ formula1: '15', formula2: '25' });
});
it('clears custom checkbox values when default boolean cell values are restored', async () => {
currentTestBed = createFormulaInputTestBed();
const changes: IFormulaValue[] = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<FormulaInputHarness
Component={CheckboxFormulaInput}
changes={changes}
initialValue={{ formula1: 'DONE', formula2: 'TODO' }}
/>
</RediContext.Provider>
);
await Promise.resolve();
});
await clickElement(container.querySelector<HTMLInputElement>('[data-u-comp="checkbox"] input')!);
expect(changes.at(-1)).toEqual({ formula1: undefined, formula2: undefined });
expect(container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input')).toHaveLength(0);
});
it('keeps the paired checkbox value when editing one custom checkbox value', async () => {
currentTestBed = createFormulaInputTestBed();
const changes: IFormulaValue[] = [];
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root!.render(
<RediContext.Provider value={{ injector: currentTestBed!.injector }}>
<FormulaInputHarness
Component={CheckboxFormulaInput}
changes={changes}
initialValue={{ formula1: 'DONE', formula2: 'TODO' }}
/>
</RediContext.Provider>
);
await Promise.resolve();
});
const inputs = container.querySelectorAll<HTMLInputElement>('[data-u-comp="input"] input');
await changeInputValue(inputs[0], 'YES');
expect(changes.at(-1)).toEqual({ formula1: 'YES', formula2: 'TODO' });
await changeInputValue(inputs[1], 'NO');
expect(changes.at(-1)).toEqual({ formula1: 'YES', formula2: 'NO' });
});
});

View File

@@ -0,0 +1,51 @@
/**
* 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 { deserializeListOptions } from '@univerjs/sheets';
import { describe, expect, it } from 'vitest';
import { buildCustomListFormulaPayload } from '../utils';
describe('ListFormulaInput', () => {
it('keeps commas inside custom list values', () => {
const payload = buildCustomListFormulaPayload([
{ label: 'a,b', color: '#ff0000' },
{ label: 'c', color: '#ffffff' },
], '#ffffff');
expect(deserializeListOptions(payload.formula1)).toEqual(['a,b', 'c']);
expect(payload.formula2).toBe('#ff0000,');
});
it('keeps colors aligned with the first non-empty occurrence of each custom option', () => {
const payload = buildCustomListFormulaPayload([
{ label: '', color: '#ff0000' },
{ label: 'Pending', color: '#ffffff' },
{ label: 'Done', color: '#00ff00' },
{ label: 'Pending', color: '#0000ff' },
], '#ffffff');
expect(deserializeListOptions(payload.formula1)).toEqual(['Pending', 'Done']);
expect(payload.formula2).toBe(',#00ff00');
});
it('does not persist the themed default color', () => {
const payload = buildCustomListFormulaPayload([
{ label: 'Pending', color: 'gray.100' },
], 'gray.100');
expect(payload.formula2).toBe('');
});
});

View File

@@ -0,0 +1,33 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { FormulaInputType } from './interface';
import { BaseFormulaInput } from './BaseFormulaInput';
import { ListFormulaInput } from './ListFormulaInput';
export const BASE_FORMULA_INPUT_NAME = 'data-validation.formula-input';
export const LIST_FORMULA_INPUT_NAME = 'data-validation.list-formula-input';
export const FORMULA_INPUTS: [string, FormulaInputType][] = [
[
BASE_FORMULA_INPUT_NAME,
BaseFormulaInput,
],
[
LIST_FORMULA_INPUT_NAME,
ListFormulaInput,
],
];

View File

@@ -0,0 +1,46 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { FormulaInputType } from './interface';
import {
BASE_FORMULA_INPUT_NAME,
CHECKBOX_FORMULA_INPUT_NAME,
CUSTOM_FORMULA_INPUT_NAME,
LIST_FORMULA_INPUT_NAME,
} from '@univerjs/sheets-data-validation';
import { BaseFormulaInput } from './BaseFormulaInput';
import { CheckboxFormulaInput } from './CheckboxFormulaInput';
import { CustomFormulaInput } from './CustomFormulaInput';
import { ListFormulaInput } from './ListFormulaInput';
export const FORMULA_INPUTS: [string, FormulaInputType][] = [
[
CUSTOM_FORMULA_INPUT_NAME,
CustomFormulaInput,
],
[
BASE_FORMULA_INPUT_NAME,
BaseFormulaInput,
],
[
LIST_FORMULA_INPUT_NAME,
ListFormulaInput,
],
[
CHECKBOX_FORMULA_INPUT_NAME,
CheckboxFormulaInput,
],
];

View File

@@ -0,0 +1,31 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IFormulaValidResult, IFormulaValue } from '@univerjs/data-validation';
import type { ComponentType } from 'react';
export interface IFormulaInputProps {
isTwoFormula?: boolean;
value?: IFormulaValue;
onChange?: (value?: IFormulaValue) => void;
unitId: string;
subUnitId: string;
showError?: boolean;
validResult?: IFormulaValidResult;
ruleId: string;
}
export type FormulaInputType = ComponentType<IFormulaInputProps>;

View File

@@ -0,0 +1,41 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { serializeListOptions } from '@univerjs/sheets';
interface ICustomListItem {
color: string;
label: string;
}
export function buildCustomListFormulaPayload(items: ICustomListItem[], defaultColor: string) {
const labelSet = new Set<string>();
const finalList: ICustomListItem[] = [];
items.forEach((item) => {
if (!item.label || labelSet.has(item.label)) {
return;
}
labelSet.add(item.label);
finalList.push(item);
});
return {
formula1: serializeListOptions(finalList.map((item) => item.label)),
formula2: finalList.map((item) => item.color === defaultColor ? '' : item.color).join(','),
};
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DataValidationPanel } from './DataValidationPanel';

View File

@@ -0,0 +1,141 @@
/**
* 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 { IFormulaResult } from '@univerjs/data-validation';
import {
DataValidationType,
ICommandService,
Injector,
IUniverInstanceService,
LocaleService,
ThemeService,
} from '@univerjs/core';
import { DataValidatorDropdownType } from '@univerjs/data-validation';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
BASE_FORMULA_INPUT_NAME,
CHECKBOX_FORMULA_INPUT_NAME,
CUSTOM_FORMULA_INPUT_NAME,
DataValidationFormulaService,
LIST_FORMULA_INPUT_NAME,
SheetDataValidationModel,
} from '@univerjs/sheets-data-validation';
import { describe, expect, it } from 'vitest';
import { DateShowTimeOption } from '../../components/DateShowTimeOption';
import { ListRenderModeInput } from '../../components/ListRenderModeInput';
class TestPath2D {
constructor(_path?: string) {
// Path details are not used by these view choice tests.
}
}
Object.assign(globalThis, { Path2D: TestPath2D });
class TestCommandService {}
class TestUniverInstanceService {}
class TestRenderManagerService {}
class TestLocaleService {}
class TestThemeService {
getColorFromTheme(): string {
return '#155eef';
}
}
class TestFormulaService {
getRuleFormulaResult(): Promise<IFormulaResult[] | undefined> {
return Promise.resolve(undefined);
}
getRuleFormulaResultSync(): IFormulaResult[] | undefined {
return undefined;
}
}
class TestSheetDataValidationModel {}
function createValidatorViewInjector() {
const injector = new Injector();
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([LocaleService, { useClass: TestLocaleService as never }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([SheetDataValidationModel, { useClass: TestSheetDataValidationModel as never }]);
injector.add([DataValidationFormulaService, { useClass: TestFormulaService as never }]);
injector.add([ThemeService, { useClass: TestThemeService as never }]);
return injector;
}
describe('sheet data validation view choices', () => {
it('uses list editors and dropdown renderers for list validation rules', async () => {
const { DropdownMultipleWidget } = await import('../../widgets/dropdown-multiple-widget');
const { DropdownWidget } = await import('../../widgets/dropdown-widget');
const { ListMultipleValidatorView } = await import('../list-multiple-view');
const { ListValidatorView } = await import('../list-validator-view');
const injector = createValidatorViewInjector();
const singleListView = injector.createInstance(ListValidatorView);
const multipleListView = injector.createInstance(ListMultipleValidatorView);
expect(singleListView.id).toBe(DataValidationType.LIST);
expect(singleListView.formulaInput).toBe(LIST_FORMULA_INPUT_NAME);
expect(singleListView.optionsInput).toBe(ListRenderModeInput.componentKey);
expect(singleListView.dropdownType).toBe(DataValidatorDropdownType.LIST);
expect(singleListView.canvasRender).toBeInstanceOf(DropdownWidget);
expect(multipleListView.id).toBe(DataValidationType.LIST_MULTIPLE);
expect(multipleListView.dropdownType).toBe(DataValidatorDropdownType.MULTIPLE_LIST);
expect(multipleListView.canvasRender).toBeInstanceOf(DropdownMultipleWidget);
});
it('uses specialized formula editors for checkbox, custom, date, and numeric validation rules', async () => {
const { CheckboxRender } = await import('../../widgets/checkbox-widget');
const { CheckboxValidatorView } = await import('../checkbox-validator-view');
const { CustomFormulaValidatorView } = await import('../custom-validator-view');
const { DateValidatorView } = await import('../date-validator-view');
const { DecimalValidatorView } = await import('../decimal-validator-view');
const { TextLengthValidatorView } = await import('../text-length-validator.view');
const { WholeValidatorView } = await import('../whole-validator-view');
const injector = createValidatorViewInjector();
const checkboxView = injector.createInstance(CheckboxValidatorView);
const customView = injector.createInstance(CustomFormulaValidatorView);
const dateView = injector.createInstance(DateValidatorView);
const decimalView = injector.createInstance(DecimalValidatorView);
const textLengthView = injector.createInstance(TextLengthValidatorView);
const wholeView = injector.createInstance(WholeValidatorView);
expect(checkboxView.id).toBe(DataValidationType.CHECKBOX);
expect(checkboxView.formulaInput).toBe(CHECKBOX_FORMULA_INPUT_NAME);
expect(checkboxView.canvasRender).toBeInstanceOf(CheckboxRender);
expect(customView.id).toBe(DataValidationType.CUSTOM);
expect(customView.formulaInput).toBe(CUSTOM_FORMULA_INPUT_NAME);
expect(dateView.id).toBe(DataValidationType.DATE);
expect(dateView.formulaInput).toBe(BASE_FORMULA_INPUT_NAME);
expect(dateView.optionsInput).toBe(DateShowTimeOption.componentKey);
expect(dateView.dropdownType).toBe(DataValidatorDropdownType.DATE);
expect(decimalView.formulaInput).toBe(BASE_FORMULA_INPUT_NAME);
expect(textLengthView.formulaInput).toBe(BASE_FORMULA_INPUT_NAME);
expect(wholeView.formulaInput).toBe(BASE_FORMULA_INPUT_NAME);
});
});

View File

@@ -0,0 +1,26 @@
/**
* 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 { DataValidationType } from '@univerjs/core';
import { CHECKBOX_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
import { CheckboxRender } from '../widgets/checkbox-widget';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class CheckboxValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.CHECKBOX;
override canvasRender = this.injector.createInstance(CheckboxRender);
override formulaInput: string = CHECKBOX_FORMULA_INPUT_NAME;
}

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.
*/
import { DataValidationType } from '@univerjs/core';
import { CUSTOM_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class CustomFormulaValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.CUSTOM;
override formulaInput: string = CUSTOM_FORMULA_INPUT_NAME;
}

View File

@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DataValidationType } from '@univerjs/core';
import { DataValidatorDropdownType } from '@univerjs/data-validation';
import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
import { DateShowTimeOption } from '../components/DateShowTimeOption';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class DateValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.DATE;
override formulaInput: string = BASE_FORMULA_INPUT_NAME;
override optionsInput = DateShowTimeOption.componentKey;
override dropdownType = DataValidatorDropdownType.DATE;
}

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.
*/
import { DataValidationType } from '@univerjs/core';
import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class DecimalValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.DECIMAL;
override formulaInput: string = BASE_FORMULA_INPUT_NAME;
}

View File

@@ -0,0 +1,26 @@
/**
* 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 { DataValidationType } from '@univerjs/core';
import { DataValidatorDropdownType } from '@univerjs/data-validation';
import { DropdownMultipleWidget } from '../widgets/dropdown-multiple-widget';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class ListMultipleValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.LIST_MULTIPLE;
override canvasRender = this.injector.createInstance(DropdownMultipleWidget);
override dropdownType: DataValidatorDropdownType | undefined = DataValidatorDropdownType.MULTIPLE_LIST;
}

View File

@@ -0,0 +1,32 @@
/**
* 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 { Nullable } from '@univerjs/core';
import type { IBaseDataValidationWidget } from '@univerjs/data-validation';
import { DataValidationType } from '@univerjs/core';
import { DataValidatorDropdownType } from '@univerjs/data-validation';
import { LIST_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
import { ListRenderModeInput } from '../components/ListRenderModeInput';
import { DropdownWidget } from '../widgets/dropdown-widget';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class ListValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.LIST;
override canvasRender: Nullable<IBaseDataValidationWidget> = this.injector.createInstance(DropdownWidget);
override dropdownType: DataValidatorDropdownType | undefined = DataValidatorDropdownType.LIST;
override optionsInput: string | undefined = ListRenderModeInput.componentKey;
override formulaInput: string = LIST_FORMULA_INPUT_NAME;
}

View File

@@ -0,0 +1,38 @@
/**
* 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 { Nullable } from '@univerjs/core';
import type { DataValidatorDropdownType, IBaseDataValidationWidget } from '@univerjs/data-validation';
import { Inject, Injector } from '@univerjs/core';
import { LIST_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
/**
* This is the base class for all sheet data validator views. It is used to extend {@link BaseDataValidator}.
*/
export abstract class BaseSheetDataValidatorView {
canvasRender: Nullable<IBaseDataValidationWidget> = null;
dropdownType: DataValidatorDropdownType | undefined = undefined;
optionsInput: string | undefined = undefined;
abstract id: string;
formulaInput: string = LIST_FORMULA_INPUT_NAME;
constructor(
@Inject(Injector) protected readonly injector: Injector
) {
// empty
}
}

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.
*/
import { DataValidationType } from '@univerjs/core';
import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class TextLengthValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.TEXT_LENGTH;
override formulaInput: string = BASE_FORMULA_INPUT_NAME;
}

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.
*/
import { DataValidationType } from '@univerjs/core';
import { BASE_FORMULA_INPUT_NAME } from '@univerjs/sheets-data-validation';
import { BaseSheetDataValidatorView } from './sheet-validator-view';
export class WholeValidatorView extends BaseSheetDataValidatorView {
override id = DataValidationType.WHOLE;
override formulaInput: string = BASE_FORMULA_INPUT_NAME;
}

View File

@@ -0,0 +1,460 @@
/**
* 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 { ICellRenderContext, IDataValidationRule } from '@univerjs/core';
import type { IFormulaResult } from '@univerjs/data-validation';
import type { ISetRangeValuesCommandParams } from '@univerjs/sheets';
import {
DataValidationRenderMode,
DataValidationType,
ICommandService,
Injector,
IUniverInstanceService,
LocaleService,
ThemeService,
} from '@univerjs/core';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SetRangeValuesCommand } from '@univerjs/sheets';
import { DataValidationFormulaService, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { describe, expect, it } from 'vitest';
import { ShowDataValidationDropdown } from '../../../commands/operations/data-validation.operation';
class TestPath2D {
constructor(_path?: string) {
// Path details are not used by these pointer behavior tests.
}
}
Object.assign(globalThis, { Path2D: TestPath2D });
interface IExecutedCommand {
id: string;
params?: unknown;
}
class RecordingCommandService {
readonly commands: IExecutedCommand[] = [];
executeCommand(id: string, params?: unknown): Promise<boolean> {
this.commands.push({ id, params });
return Promise.resolve(true);
}
}
class TestUniverInstanceService {
}
class TestRenderManagerService {
}
class TestLocaleService {
}
class TestThemeService {
getColorFromTheme(): string {
return '#155eef';
}
}
class TestFormulaService {
results: { result: { v: string }[][][][] }[] | undefined;
getRuleFormulaResult(): Promise<IFormulaResult[] | undefined> {
return Promise.resolve(this.results as IFormulaResult[] | undefined);
}
getRuleFormulaResultSync(): IFormulaResult[] | undefined {
return this.results as IFormulaResult[] | undefined;
}
}
class TestCheckboxValidator {
skipDefaultFontRender(): boolean {
return true;
}
}
class TestListValidator {
id = DataValidationType.LIST;
skipDefaultFontRender(): boolean {
return true;
}
getListWithColorMap(): Record<string, string> {
return {
Done: '#34c759',
Hold: '#ffcc00',
};
}
}
class TestListMultipleValidator extends TestListValidator {
override id = DataValidationType.LIST_MULTIPLE;
parseCellValue(value: unknown): string[] {
return String(value || '').split(',').filter(Boolean);
}
}
class TestSheetDataValidationModel {
rule: IDataValidationRule = {
uid: 'rule-checkbox',
type: DataValidationType.CHECKBOX,
ranges: [{ startRow: 3, endRow: 3, startColumn: 4, endColumn: 4 }],
formula1: 'DONE',
formula2: 'TODO',
};
readonly checkboxValidator = new TestCheckboxValidator();
readonly listValidator = new TestListValidator();
readonly listMultipleValidator = new TestListMultipleValidator();
getRuleByLocation(): IDataValidationRule {
return this.rule;
}
getValidator(type: DataValidationType) {
if (type === DataValidationType.LIST) {
return this.listValidator;
}
if (type === DataValidationType.LIST_MULTIPLE) {
return this.listMultipleValidator;
}
return this.checkboxValidator;
}
}
class RecordingRenderContext {
readonly fillStyles: unknown[] = [];
private _fillStyle: unknown;
get fillStyle(): unknown {
return this._fillStyle;
}
set fillStyle(value: unknown) {
this._fillStyle = value;
this.fillStyles.push(value);
}
save(): void {}
restore(): void {}
translateWithPrecision(): void {}
translate(): void {}
beginPath(): void {}
rect(): void {}
clip(): void {}
fill(): void {}
closePath(): void {}
moveTo(): void {}
lineTo(): void {}
arc(): void {}
fillText(): void {}
}
function createInjector(): Injector {
const injector = new Injector();
injector.add([ICommandService, { useClass: RecordingCommandService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([LocaleService, { useClass: TestLocaleService as never }]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([SheetDataValidationModel, { useClass: TestSheetDataValidationModel as never }]);
injector.add([DataValidationFormulaService, { useClass: TestFormulaService as never }]);
injector.add([ThemeService, { useClass: TestThemeService as never }]);
return injector;
}
function createCellContext(value = 'DONE'): ICellRenderContext {
return {
data: { v: value },
style: null,
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 3,
col: 4,
primaryWithCoord: {
startX: 20,
startY: 30,
endX: 140,
endY: 70,
actualRow: 8,
actualColumn: 9,
isMerged: false,
isMergedMainCell: false,
mergeInfo: {
startX: 20,
startY: 30,
endX: 140,
endY: 70,
startRow: 3,
endRow: 3,
startColumn: 4,
endColumn: 4,
},
},
worksheet: {
getCellRaw: () => ({ v: value }),
},
} as unknown as ICellRenderContext;
}
function getCommandService(injector: Injector): RecordingCommandService {
return injector.get(ICommandService) as unknown as RecordingCommandService;
}
function getDataValidationModel(injector: Injector): TestSheetDataValidationModel {
return injector.get(SheetDataValidationModel) as unknown as TestSheetDataValidationModel;
}
function getFormulaService(injector: Injector): TestFormulaService {
return injector.get(DataValidationFormulaService) as unknown as TestFormulaService;
}
function createSkeleton() {
return {
rowHeaderWidth: 8,
columnHeaderHeight: 12,
};
}
function getRenderedDropdownInfo(widget: unknown) {
const map = (widget as { _dropdownInfoMap: Map<string, Map<string, { left: number; top: number; width: number; height: number }>> })._dropdownInfoMap;
return map.get('sheet-1')!.get('3.4')!;
}
describe('data validation canvas widgets', () => {
it('opens the single-select dropdown for the clicked cell and ignores secondary clicks', async () => {
const injector = createInjector();
const { DropdownWidget } = await import('../dropdown-widget');
const widget = injector.createInstance(DropdownWidget);
const cellContext = createCellContext();
const commandService = getCommandService(injector);
widget.onPointerDown(cellContext, { button: 0 } as never);
widget.onPointerDown(cellContext, { button: 2 } as never);
expect(commandService.commands).toEqual([
{
id: ShowDataValidationDropdown.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 3,
column: 4,
},
},
]);
});
it('limits multi-select dropdown hit testing to the arrow affordance before opening it', async () => {
const injector = createInjector();
const { DropdownMultipleWidget } = await import('../dropdown-multiple-widget');
const widget = injector.createInstance(DropdownMultipleWidget);
const cellContext = createCellContext();
expect(widget.isHit({ x: 132, y: 45 }, cellContext)).toBe(true);
expect(widget.isHit({ x: 120, y: 45 }, cellContext)).toBe(false);
widget.onPointerDown(cellContext, { button: 0 } as never);
expect(getCommandService(injector).commands).toEqual([
{
id: ShowDataValidationDropdown.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 3,
column: 4,
},
},
]);
});
it('uses the rendered capsule bounds for single-select dropdown hit testing', async () => {
const injector = createInjector();
const model = getDataValidationModel(injector);
model.rule = {
...model.rule,
type: DataValidationType.LIST,
renderMode: DataValidationRenderMode.CUSTOM,
};
const { DropdownWidget } = await import('../dropdown-widget');
const widget = injector.createInstance(DropdownWidget);
const ctx = new RecordingRenderContext();
const cellContext = createCellContext('Done');
widget.drawWith(ctx as never, cellContext, createSkeleton() as never);
const dropdownInfo = getRenderedDropdownInfo(widget);
expect(widget.isHit({ x: dropdownInfo.left + 1, y: dropdownInfo.top + 1 }, cellContext)).toBe(true);
expect(widget.isHit({ x: dropdownInfo.left - 1, y: dropdownInfo.top + 1 }, cellContext)).toBe(false);
expect(widget.isHit({ x: dropdownInfo.left + 1, y: dropdownInfo.top - 1 }, cellContext)).toBe(false);
});
it('limits arrow-mode single-select hit testing to the dropdown arrow region', async () => {
const injector = createInjector();
const model = getDataValidationModel(injector);
model.rule = {
...model.rule,
type: DataValidationType.LIST,
renderMode: DataValidationRenderMode.ARROW,
};
const { DropdownWidget } = await import('../dropdown-widget');
const widget = injector.createInstance(DropdownWidget);
const ctx = new RecordingRenderContext();
const cellContext = createCellContext('Done');
widget.drawWith(ctx as never, cellContext, createSkeleton() as never);
const dropdownInfo = getRenderedDropdownInfo(widget);
expect(widget.isHit({ x: dropdownInfo.left + 1, y: dropdownInfo.top + 1 }, cellContext)).toBe(true);
expect(widget.isHit({ x: dropdownInfo.left - 10, y: dropdownInfo.top + 1 }, cellContext)).toBe(false);
});
it('applies list option colors while drawing multi-select dropdown capsules', async () => {
const injector = createInjector();
const model = getDataValidationModel(injector);
model.rule = {
...model.rule,
type: DataValidationType.LIST_MULTIPLE,
};
const { DropdownMultipleWidget } = await import('../dropdown-multiple-widget');
const widget = injector.createInstance(DropdownMultipleWidget);
const ctx = new RecordingRenderContext();
widget.drawWith(ctx as never, createCellContext('Done,Hold'), createSkeleton() as never, {} as never);
expect(ctx.fillStyles).toContain('#34c759');
expect(ctx.fillStyles).toContain('#ffcc00');
});
it('calculates autofit dimensions for multi-select list capsules', async () => {
const injector = createInjector();
const model = getDataValidationModel(injector);
model.rule = {
...model.rule,
type: DataValidationType.LIST_MULTIPLE,
};
const { DropdownMultipleWidget } = await import('../dropdown-multiple-widget');
const widget = injector.createInstance(DropdownMultipleWidget);
const cellContext = createCellContext('Done,Hold,Done,Hold,Done,Hold');
expect(widget.calcCellAutoHeight(cellContext)).toBe(12);
expect(widget.calcCellAutoWidth(cellContext)).toBe(94);
});
it('toggles checkbox cells from the checked formula value to the unchecked formula value', async () => {
const injector = createInjector();
const { CheckboxRender } = await import('../checkbox-widget');
const widget = injector.createInstance(CheckboxRender);
await widget.onPointerDown(createCellContext('DONE'), { button: 0 } as never);
expect(getCommandService(injector).commands).toEqual([
{
id: SetRangeValuesCommand.id,
params: {
range: {
startColumn: 9,
endColumn: 9,
startRow: 8,
endRow: 8,
},
value: {
v: 'TODO',
p: null,
},
} satisfies ISetRangeValuesCommandParams,
},
]);
});
it('toggles checkbox cells from the unchecked formula value to the checked formula value', async () => {
const injector = createInjector();
const { CheckboxRender } = await import('../checkbox-widget');
const widget = injector.createInstance(CheckboxRender);
await widget.onPointerDown(createCellContext('TODO'), { button: 0 } as never);
expect(getCommandService(injector).commands).toEqual([
{
id: SetRangeValuesCommand.id,
params: {
range: {
startColumn: 9,
endColumn: 9,
startRow: 8,
endRow: 8,
},
value: {
v: 'DONE',
p: null,
},
} satisfies ISetRangeValuesCommandParams,
},
]);
});
it('writes the resolved unchecked value when a checked checkbox rule uses formulas', async () => {
const injector = createInjector();
const model = getDataValidationModel(injector);
model.rule = {
...model.rule,
formula1: '=A1',
formula2: '=B1',
};
getFormulaService(injector).results = [
{ result: [[[[{ v: 'DONE' }]]]] },
{ result: [[[[{ v: 'TODO' }]]]] },
];
const { CheckboxRender } = await import('../checkbox-widget');
const widget = injector.createInstance(CheckboxRender);
await widget.onPointerDown(createCellContext('DONE'), { button: 0 } as never);
expect(getCommandService(injector).commands).toEqual([
{
id: SetRangeValuesCommand.id,
params: {
range: {
startColumn: 9,
endColumn: 9,
startRow: 8,
endRow: 8,
},
value: {
v: 'TODO',
p: null,
},
} satisfies ISetRangeValuesCommandParams,
},
]);
});
it('keeps checkbox cell values unchanged on secondary clicks', async () => {
const injector = createInjector();
const { CheckboxRender } = await import('../checkbox-widget');
const widget = injector.createInstance(CheckboxRender);
await widget.onPointerDown(createCellContext('TODO'), { button: 2 } as never);
expect(getCommandService(injector).commands).toEqual([]);
});
});

View File

@@ -0,0 +1,260 @@
/**
* 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 { ICellRenderContext, IDataValidationRule, IStyleData, Nullable } from '@univerjs/core';
import type { IBaseDataValidationWidget, IFormulaResult } from '@univerjs/data-validation';
import type {
IMouseEvent,
IPointerEvent,
UniverRenderingContext,
UniverRenderingContext2D,
} from '@univerjs/engine-render';
import type { ISetRangeValuesCommandParams } from '@univerjs/sheets';
import type { CheckboxValidator } from '@univerjs/sheets-data-validation';
import {
HorizontalAlign,
ICommandService,
Inject,
isFormulaString,
IUniverInstanceService,
ThemeService,
UniverInstanceType,
VerticalAlign,
} from '@univerjs/core';
import {
CheckboxShape,
CURSOR_TYPE,
fixLineWidthByScale,
getCurrentTypeOfRenderer,
IRenderManagerService,
Transform,
} from '@univerjs/engine-render';
import { SetRangeValuesCommand } from '@univerjs/sheets';
import {
CHECKBOX_FORMULA_1,
CHECKBOX_FORMULA_2,
DataValidationFormulaService,
getCellValueOrigin,
getFormulaResult,
isLegalFormulaResult,
SheetDataValidationModel,
transformCheckboxValue,
} from '@univerjs/sheets-data-validation';
const MARGIN_H = 6;
export class CheckboxRender implements IBaseDataValidationWidget {
private _calc(cellInfo: { startX: number; endX: number; startY: number; endY: number }, style: Nullable<IStyleData>) {
const { vt, ht } = style || {};
const width = cellInfo.endX - cellInfo.startX - (MARGIN_H * 2);
const height = cellInfo.endY - cellInfo.startY;
const size = (style?.fs ?? 10) * 1.6;
let widgetLeft = 0;
let widgetTop = 0;
switch (vt) {
case VerticalAlign.TOP:
widgetTop = 0;
break;
case VerticalAlign.BOTTOM:
widgetTop = 0 + (height - size);
break;
default:
widgetTop = 0 + (height - size) / 2;
break;
}
switch (ht) {
case HorizontalAlign.LEFT:
widgetLeft = MARGIN_H;
break;
case HorizontalAlign.RIGHT:
widgetLeft = MARGIN_H + (width - size);
break;
default:
widgetLeft = MARGIN_H + (width - size) / 2;
break;
}
return {
left: cellInfo.startX + widgetLeft,
top: cellInfo.startY + widgetTop,
width: (style?.fs ?? 10) * 1.6,
height: (style?.fs ?? 10) * 1.6,
};
}
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(DataValidationFormulaService) private readonly _formulaService: DataValidationFormulaService,
@Inject(ThemeService) private readonly _themeService: ThemeService,
@Inject(IRenderManagerService) private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetDataValidationModel) private readonly _dataValidationModel: SheetDataValidationModel
) {
}
calcCellAutoHeight(info: ICellRenderContext): number | undefined {
const { style } = info;
return (style?.fs ?? 10) * 1.6;
}
calcCellAutoWidth(info: ICellRenderContext): number | undefined {
const { style } = info;
return (style?.fs ?? 10) * 1.6;
}
private async _parseFormula(rule: IDataValidationRule, unitId: string, subUnitId: string): Promise<IFormulaResult> {
const { formula1 = CHECKBOX_FORMULA_1, formula2 = CHECKBOX_FORMULA_2 } = rule;
const results = await this._formulaService.getRuleFormulaResult(unitId, subUnitId, rule.uid);
const formulaResult1 = getFormulaResult(results?.[0]?.result?.[0]?.[0]);
const formulaResult2 = getFormulaResult(results?.[1]?.result?.[0]?.[0]);
const isFormulaValid = isLegalFormulaResult(String(formulaResult1)) && isLegalFormulaResult(String(formulaResult2));
return {
formula1: isFormulaString(formula1) ? getFormulaResult(results?.[0]?.result?.[0]?.[0]) : formula1,
formula2: isFormulaString(formula2) ? formulaResult2 : formula2,
isFormulaValid,
};
}
drawWith(ctx: UniverRenderingContext2D, info: ICellRenderContext): void {
const { style, primaryWithCoord, unitId, subUnitId, worksheet, row, col } = info;
const cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const value = getCellValueOrigin(worksheet.getCellRaw(row, col));
const rule = this._dataValidationModel.getRuleByLocation(unitId, subUnitId, row, col);
if (!rule) {
return;
}
const validator = this._dataValidationModel.getValidator(rule.type) as CheckboxValidator;
if (!validator) {
return;
}
if (!validator.skipDefaultFontRender?.(rule, value, { unitId: unitId!, subUnitId, row, column: col })) {
return;
}
const result = validator.parseFormulaSync(rule, unitId, subUnitId);
const { formula1 } = result;
const layout = this._calc(cellBounding, style);
const { a: scaleX, d: scaleY } = ctx.getTransform();
const left = fixLineWidthByScale(layout.left, scaleX);
const top = fixLineWidthByScale(layout.top, scaleY);
const transform = Transform.create().composeMatrix({
left,
top,
scaleX: 1,
scaleY: 1,
angle: 0,
skewX: 0,
skewY: 0,
flipX: false,
flipY: false,
});
const cellWidth = cellBounding.endX - cellBounding.startX;
const cellHeight = cellBounding.endY - cellBounding.startY;
ctx.save();
ctx.beginPath();
ctx.rect(cellBounding.startX, cellBounding.startY, cellWidth, cellHeight);
ctx.clip();
const m = transform.getMatrix();
ctx.transform(m[0], m[1], m[2], m[3], m[4], m[5]);
const size = (style?.fs ?? 10) * 1.6;
const checked = String(value) === String(formula1);
const defaultColor = this._themeService.getColorFromTheme('primary.600');
CheckboxShape.drawWith(ctx as UniverRenderingContext, {
checked,
width: size,
height: size,
fill: style?.cl?.rgb ?? defaultColor,
});
ctx.restore();
}
isHit(evt: { x: number; y: number }, info: ICellRenderContext): boolean {
const cellBounding = info.primaryWithCoord.isMergedMainCell ? info.primaryWithCoord.mergeInfo : info.primaryWithCoord;
const layout = this._calc(cellBounding, info.style);
const startY = layout.top;
const endY = layout.top + layout.height;
const startX = layout.left;
const endX = layout.left + layout.width;
const { x: offsetX, y: offsetY } = evt;
if (offsetX <= endX && offsetX >= startX && offsetY <= endY && offsetY >= startY) {
return true;
}
return false;
}
async onPointerDown(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) {
if (evt.button === 2) {
return;
}
const { primaryWithCoord, unitId, subUnitId, worksheet, row, col } = info;
const value = getCellValueOrigin(worksheet.getCellRaw(row, col));
const rule = this._dataValidationModel.getRuleByLocation(unitId, subUnitId, row, col);
if (!rule) {
return;
}
const validator = this._dataValidationModel.getValidator(rule.type) as CheckboxValidator;
if (!validator) {
return;
}
if (!validator.skipDefaultFontRender?.(rule, value, { unitId, subUnitId, row, column: col })) {
return;
}
const { formula1, formula2 } = await this._parseFormula(rule, unitId!, subUnitId);
const params: ISetRangeValuesCommandParams = {
range: {
startColumn: primaryWithCoord.actualColumn,
endColumn: primaryWithCoord.actualColumn,
startRow: primaryWithCoord.actualRow,
endRow: primaryWithCoord.actualRow,
},
value: {
v: String(value) === transformCheckboxValue(String(formula1)) ? formula2 : formula1,
p: null,
},
};
this._commandService.executeCommand(
SetRangeValuesCommand.id,
params
);
};
onPointerEnter(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) {
getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService)
?.mainComponent
?.setCursor(CURSOR_TYPE.POINTER);
}
onPointerLeave(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) {
getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService)
?.mainComponent
?.setCursor(CURSOR_TYPE.DEFAULT);
}
}

View File

@@ -0,0 +1,272 @@
/**
* 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 { ICellRenderContext } from '@univerjs/core';
import type { IBaseDataValidationWidget } from '@univerjs/data-validation';
import type { IMouseEvent, IPointerEvent, Spreadsheet, SpreadsheetSkeleton, UniverRenderingContext, UniverRenderingContext2D } from '@univerjs/engine-render';
import type { ListMultipleValidator } from '@univerjs/sheets-data-validation';
import type { IShowDataValidationDropdownParams } from '../../commands/operations/data-validation.operation';
import type { IDropdownInfo } from './dropdown-widget';
import { HorizontalAlign, ICommandService, Inject, IUniverInstanceService, UniverInstanceType, VerticalAlign } from '@univerjs/core';
import { CURSOR_TYPE, getCurrentTypeOfRenderer, getFontStyleString, IRenderManagerService } from '@univerjs/engine-render';
import { getCellValueOrigin, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { ShowDataValidationDropdown } from '../../commands/operations/data-validation.operation';
import { DROP_DOWN_ICON_COLOR } from '../../const';
import { CELL_PADDING_H, CELL_PADDING_V, Dropdown, ICON_PLACE, layoutDropdowns, MARGIN_V } from './shape';
const downPath = new Path2D('M3.32201 4.84556C3.14417 5.05148 2.85583 5.05148 2.67799 4.84556L0.134292 1.90016C-0.152586 1.56798 0.0505937 1 0.456301 1L5.5437 1C5.94941 1 6.15259 1.56798 5.86571 1.90016L3.32201 4.84556Z');
export class DropdownMultipleWidget implements IBaseDataValidationWidget {
zIndex?: number | undefined;
private _dropdownInfoMap: Map<string, Map<string, IDropdownInfo>> = new Map();
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(IRenderManagerService) private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetDataValidationModel) private readonly _dataValidationModel: SheetDataValidationModel
) {
// empty
}
private _ensureMap(subUnitId: string) {
let map = this._dropdownInfoMap.get(subUnitId);
if (!map) {
map = new Map();
this._dropdownInfoMap.set(subUnitId, map);
}
return map;
}
private _generateKey(row: number, col: number) {
return `${row}.${col}`;
}
private _drawDownIcon(ctx: UniverRenderingContext2D, cellBounding: { startX: number; startY: number }, cellWidth: number, cellHeight: number, vt: VerticalAlign) {
const left = cellWidth - ICON_PLACE + 4;
let top = 4;
switch (vt) {
case VerticalAlign.MIDDLE:
top = (cellHeight - ICON_PLACE) / 2 + 4;
break;
case VerticalAlign.BOTTOM:
top = (cellHeight - ICON_PLACE) + 4;
break;
default:
break;
}
ctx.save();
ctx.translateWithPrecision(cellBounding.startX + left, cellBounding.startY + top);
ctx.fillStyle = DROP_DOWN_ICON_COLOR;
ctx.fill(downPath);
ctx.restore();
}
// eslint-disable-next-line max-lines-per-function
drawWith(ctx: UniverRenderingContext2D, info: ICellRenderContext, skeleton: SpreadsheetSkeleton, spreadsheets: Spreadsheet): void {
const { primaryWithCoord, row, col, style, data, subUnitId } = info;
const _cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const fontRenderExtension = data?.fontRenderExtension;
const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {};
const map = this._ensureMap(subUnitId);
const key = this._generateKey(row, col);
const _row = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo.startRow : row;
const _col = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo.startColumn : col;
const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, _row, _col);
if (!rule) {
return;
}
const validator = this._dataValidationModel.getValidator(rule.type) as ListMultipleValidator;
if (!validator) {
return;
}
const cellBounding = {
startX: _cellBounding.startX + leftOffset,
endX: _cellBounding.endX - rightOffset,
startY: _cellBounding.startY + topOffset,
endY: _cellBounding.endY - downOffset,
};
const cellWidth = cellBounding.endX - cellBounding.startX;
const cellHeight = cellBounding.endY - cellBounding.startY;
const { cl } = style || {};
const color = (typeof cl === 'object' ? cl?.rgb : cl) ?? '#000';
const fontStyle = getFontStyleString(style ?? undefined);
const { vt: _vt, ht } = style || {};
const vt = _vt ?? VerticalAlign.MIDDLE;
const cellValue = getCellValueOrigin(data) ?? '';
const items = validator.parseCellValue(cellValue);
const labelColorMap = validator.getListWithColorMap(rule);
const layout = layoutDropdowns(items, fontStyle, cellWidth, cellHeight);
this._drawDownIcon(ctx, cellBounding, cellWidth, cellHeight, vt);
ctx.save();
ctx.translateWithPrecision(cellBounding.startX, cellBounding.startY);
ctx.beginPath();
ctx.rect(0, 0, cellWidth - ICON_PLACE, cellHeight);
ctx.clip();
ctx.translateWithPrecision(CELL_PADDING_H, CELL_PADDING_V);
let top = 0;
switch (vt) {
case VerticalAlign.MIDDLE:
top = (layout.contentHeight - layout.totalHeight) / 2;
break;
case VerticalAlign.BOTTOM:
top = (layout.contentHeight - layout.totalHeight);
break;
default:
break;
}
ctx.translateWithPrecision(0, top);
layout.lines.forEach((line, index) => {
ctx.save();
const { width, height, items } = line;
let left = 0;
switch (ht) {
case HorizontalAlign.RIGHT:
left = layout.contentWidth - width;
break;
case HorizontalAlign.CENTER:
left = (layout.contentWidth - width) / 2;
break;
default:
break;
}
ctx.translate(left, index * (height + MARGIN_V));
items.forEach((item) => {
ctx.save();
ctx.translateWithPrecision(item.left, 0);
Dropdown.drawWith(ctx as UniverRenderingContext, {
...fontStyle,
info: item,
color,
fill: labelColorMap[item.text],
});
ctx.restore();
});
ctx.restore();
});
ctx.restore();
map.set(key, {
left: cellBounding.startX,
top: cellBounding.startY,
width: layout.contentWidth + CELL_PADDING_H + ICON_PLACE,
height: layout.contentHeight + CELL_PADDING_V * 2,
});
}
calcCellAutoHeight(info: ICellRenderContext): number | undefined {
const { primaryWithCoord, style, data, row, col } = info;
const fontRenderExtension = data?.fontRenderExtension;
const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {};
const _cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const cellBounding = {
startX: _cellBounding.startX + leftOffset,
endX: _cellBounding.endX - rightOffset,
startY: _cellBounding.startY + topOffset,
endY: _cellBounding.endY - downOffset,
};
const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col);
if (!rule) {
return;
}
const validator = this._dataValidationModel.getValidator(rule.type) as ListMultipleValidator;
if (!validator) {
return;
}
const cellWidth = cellBounding.endX - cellBounding.startX;
const cellHeight = cellBounding.endY - cellBounding.startY;
const cellValue = getCellValueOrigin(data) ?? '';
const items = validator.parseCellValue(cellValue);
const fontStyle = getFontStyleString(style ?? undefined);
const layout = layoutDropdowns(items, fontStyle, cellWidth, cellHeight);
return layout.cellAutoHeight;
}
calcCellAutoWidth(info: ICellRenderContext): number | undefined {
const { primaryWithCoord, style, data, row, col } = info;
const fontRenderExtension = data?.fontRenderExtension;
const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {};
const _cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const cellBounding = {
startX: _cellBounding.startX + leftOffset,
endX: _cellBounding.endX - rightOffset,
startY: _cellBounding.startY + topOffset,
endY: _cellBounding.endY - downOffset,
};
const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col);
if (!rule) {
return;
}
const validator = this._dataValidationModel.getValidator(rule.type) as ListMultipleValidator;
if (!validator) {
return;
}
const cellWidth = cellBounding.endX - cellBounding.startX;
const cellHeight = cellBounding.endY - cellBounding.startY;
const cellValue = getCellValueOrigin(data) ?? '';
const items = validator.parseCellValue(cellValue);
const fontStyle = getFontStyleString(style ?? undefined);
const layout = layoutDropdowns(items, fontStyle, cellWidth, cellHeight);
return layout.calcAutoWidth;
}
isHit(position: { x: number; y: number }, info: ICellRenderContext) {
const { primaryWithCoord } = info;
const cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const { endX } = cellBounding;
const { x } = position;
if (x >= endX - ICON_PLACE && x <= endX) {
return true;
}
return false;
};
onPointerDown(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) {
if (evt.button === 2) {
return;
}
const { unitId, subUnitId, row, col } = info;
const params: IShowDataValidationDropdownParams = {
unitId: unitId!,
subUnitId,
row,
column: col,
};
this._commandService.executeCommand(ShowDataValidationDropdown.id, params);
}
onPointerEnter(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) {
return getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService)
?.mainComponent
?.setCursor(CURSOR_TYPE.POINTER);
}
onPointerLeave(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) {
return getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService)
?.mainComponent
?.setCursor(CURSOR_TYPE.DEFAULT);
}
}

View File

@@ -0,0 +1,478 @@
/**
* 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 { ICellRenderContext, IPaddingData } from '@univerjs/core';
import type { IBaseDataValidationWidget } from '@univerjs/data-validation';
import type { IMouseEvent, IPointerEvent, SpreadsheetSkeleton, UniverRenderingContext, UniverRenderingContext2D } from '@univerjs/engine-render';
import type { ListValidator } from '@univerjs/sheets-data-validation';
import type { IShowDataValidationDropdownParams } from '../../commands/operations/data-validation.operation';
import { DataValidationRenderMode, DataValidationType, DEFAULT_STYLES, HorizontalAlign, ICommandService, Inject, IUniverInstanceService, LocaleService, UniverInstanceType, VerticalAlign, WrapStrategy } from '@univerjs/core';
import { CURSOR_TYPE, DocSimpleSkeleton, getCurrentTypeOfRenderer, getFontStyleString, IRenderManagerService, Rect, Text } from '@univerjs/engine-render';
import { getCellValueOrigin, SheetDataValidationModel } from '@univerjs/sheets-data-validation';
import { ShowDataValidationDropdown } from '../../commands/operations/data-validation.operation';
import { DROP_DOWN_DEFAULT_COLOR, DROP_DOWN_ICON_COLOR } from '../../const';
/**
* padding in Capsule
*/
const PADDING_H = 4;
const ICON_SIZE = 4;
const ICON_PLACE = 14;
const PADDING_V = 1;
/**
* margin for Capsule, that means distance between capsule and cell border
*/
const MARGIN_H = 6;
const MARGIN_V = 3;
const RADIUS_BG = 8;
const downPath = new Path2D('M3.32201 4.84556C3.14417 5.05148 2.85583 5.05148 2.67799 4.84556L0.134292 1.90016C-0.152586 1.56798 0.0505937 1 0.456301 1L5.5437 1C5.94941 1 6.15259 1.56798 5.86571 1.90016L3.32201 4.84556Z');
function calcPadding(cellWidth: number, cellHeight: number, fontWidth: number, fontHeight: number, vt: VerticalAlign, ht: HorizontalAlign, margin = true) {
let paddingTop = 0;
const realMargin = margin ? MARGIN_V : 0;
switch (vt) {
case VerticalAlign.BOTTOM:
paddingTop = cellHeight - fontHeight - realMargin;
break;
case VerticalAlign.MIDDLE:
paddingTop = (cellHeight - fontHeight) / 2;
break;
default:
paddingTop = realMargin;
break;
}
paddingTop = Math.max(MARGIN_V, paddingTop);
let paddingLeft = 0;
switch (ht) {
case HorizontalAlign.CENTER:
paddingLeft = (cellWidth - fontWidth) / 2;
break;
case HorizontalAlign.RIGHT:
paddingLeft = (cellWidth - fontWidth);
break;
default:
break;
}
paddingLeft = Math.max(MARGIN_H, paddingLeft);
return {
paddingLeft,
paddingTop,
};
}
export interface IDropdownInfo {
top: number;
left: number;
width: number;
height: number;
}
export class DropdownWidget implements IBaseDataValidationWidget {
private _dropdownInfoMap: Map<string, Map<string, IDropdownInfo>> = new Map();
constructor(
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(LocaleService) private readonly _localeService: LocaleService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(IRenderManagerService) private readonly _renderManagerService: IRenderManagerService,
@Inject(SheetDataValidationModel) private readonly _dataValidationModel: SheetDataValidationModel
) {
// super
}
zIndex?: number | undefined;
private _ensureMap(subUnitId: string) {
let map = this._dropdownInfoMap.get(subUnitId);
if (!map) {
map = new Map();
this._dropdownInfoMap.set(subUnitId, map);
}
return map;
}
private _generateKey(row: number, col: number) {
return `${row}.${col}`;
}
private _drawDownIcon(
ctx: UniverRenderingContext2D,
cellBounding: { startX: number; startY: number },
cellWidth: number,
cellHeight: number,
fontHeight: number,
vt: VerticalAlign,
pd: IPaddingData
) {
const { t = DEFAULT_STYLES.pd.t, b = DEFAULT_STYLES.pd.b } = pd;
const left = cellWidth - ICON_PLACE;
let top;
switch (vt) {
case VerticalAlign.MIDDLE:
top = (cellHeight - ICON_SIZE) / 2;
break;
case VerticalAlign.BOTTOM:
top = (cellHeight - b - fontHeight - MARGIN_V) + (fontHeight / 2 - ICON_SIZE / 2);
break;
default:
top = t + MARGIN_V + (fontHeight / 2 - ICON_SIZE / 2);
break;
}
ctx.save();
ctx.translateWithPrecision(cellBounding.startX + left, cellBounding.startY + top);
ctx.fillStyle = DROP_DOWN_ICON_COLOR;
ctx.fill(downPath);
ctx.restore();
}
// eslint-disable-next-line max-lines-per-function, complexity
drawWith(ctx: UniverRenderingContext2D, info: ICellRenderContext, skeleton: SpreadsheetSkeleton): void {
const { primaryWithCoord, row, col, style, data, subUnitId } = info;
const _cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const _row = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo.startRow : row;
const _col = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo.startColumn : col;
const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, _row, _col);
if (!rule) {
return;
}
const validator = this._dataValidationModel.getValidator(rule.type) as ListValidator;
if (!validator) {
return;
}
const fontRenderExtension = data?.fontRenderExtension;
const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {};
if (!rule || !validator || !validator || validator.id.indexOf(DataValidationType.LIST) !== 0) {
return;
}
if (!validator.skipDefaultFontRender(rule)) {
return;
}
const cellBounding = {
startX: _cellBounding.startX + leftOffset,
endX: _cellBounding.endX - rightOffset,
startY: _cellBounding.startY + topOffset,
endY: _cellBounding.endY - downOffset,
};
const cellWidth = cellBounding.endX - cellBounding.startX;
const cellHeight = cellBounding.endY - cellBounding.startY;
const map = this._ensureMap(subUnitId);
const key = this._generateKey(row, col);
const colorMap = validator.getListWithColorMap(rule);
const value = getCellValueOrigin(data);
const valueStr = `${value ?? ''}`;
const activeColor = colorMap[valueStr];
let { tb, vt, ht, pd } = style || {};
tb = tb ?? WrapStrategy.WRAP;
vt = vt ?? VerticalAlign.BOTTOM;
ht = ht ?? DEFAULT_STYLES.ht;
pd = pd ?? DEFAULT_STYLES.pd;
const fontStyle = getFontStyleString(style).fontCache;
if (rule.renderMode === DataValidationRenderMode.ARROW) {
const { l = DEFAULT_STYLES.pd.l, t = DEFAULT_STYLES.pd.t, r = DEFAULT_STYLES.pd.r, b = DEFAULT_STYLES.pd.b } = pd;
const realWidth = cellWidth - l - r - ICON_PLACE - 4;
const textSkeleton = new DocSimpleSkeleton(
valueStr,
fontStyle,
Boolean(tb === WrapStrategy.WRAP),
realWidth,
Infinity
);
textSkeleton.calculate();
const fontWidth = textSkeleton.getTotalWidth();
const fontHeight = textSkeleton.getTotalHeight();
const { paddingTop, paddingLeft } = calcPadding(realWidth, cellHeight - t - b, fontWidth, fontHeight, vt, ht, true);
this._drawDownIcon(ctx, cellBounding, cellWidth, cellHeight, fontHeight, vt, pd);
ctx.save();
ctx.translateWithPrecision(cellBounding.startX + l, cellBounding.startY + t);
ctx.beginPath();
ctx.rect(0, 0, cellWidth - l - r, cellHeight - t - b);
ctx.clip();
ctx.translateWithPrecision(0, paddingTop);
ctx.save();
ctx.translateWithPrecision(paddingLeft, 0);
ctx.beginPath();
// Ensure clipping area matches the actual text rendering area
ctx.rect(0, 0, realWidth, fontHeight);
ctx.clip();
Text.drawWith(ctx, {
text: valueStr,
fontStyle,
width: realWidth,
height: fontHeight,
color: style?.cl?.rgb,
strokeLine: Boolean(style?.st?.s),
underline: Boolean(style?.ul?.s),
warp: tb === WrapStrategy.WRAP,
hAlign: HorizontalAlign.LEFT,
}, textSkeleton);
ctx.restore();
ctx.restore();
map.set(key, {
left: cellBounding.endX - ICON_PLACE + skeleton.rowHeaderWidth,
top: cellBounding.startY + t + skeleton.columnHeaderHeight,
width: ICON_PLACE,
height: cellHeight - t - b,
});
} else {
// Capsule
ctx.save();
ctx.translateWithPrecision(cellBounding.startX, cellBounding.startY);
ctx.beginPath();
ctx.rect(0, 0, cellWidth, cellHeight);
ctx.clip();
const realWidth = cellWidth - (MARGIN_H * 2) - PADDING_H - ICON_PLACE - 4;
const textSkeleton = new DocSimpleSkeleton(
valueStr,
fontStyle,
Boolean(tb === WrapStrategy.WRAP),
realWidth,
Infinity
);
textSkeleton.calculate();
const fontWidth = textSkeleton.getTotalWidth();
const fontHeight = textSkeleton.getTotalHeight();
const rectHeight = fontHeight + (PADDING_V * 2);
const rectWidth = Math.max(cellWidth - MARGIN_H * 2, 1);
const { paddingTop } = calcPadding(rectWidth, cellHeight, fontWidth, rectHeight, vt, ht);
ctx.translateWithPrecision(MARGIN_H, paddingTop);
Rect.drawWith(ctx as UniverRenderingContext, {
width: rectWidth,
height: rectHeight,
fill: activeColor || DROP_DOWN_DEFAULT_COLOR,
radius: RADIUS_BG,
});
ctx.save();
ctx.translateWithPrecision(PADDING_H, PADDING_V);
ctx.beginPath();
// Use actual font height for clipping, ensuring wrapped text is not cut off
// The clipping height should match the available text area height
ctx.rect(0, 0, realWidth, fontHeight);
ctx.clip();
// Remove redundant paddingLeft translation as it's already handled by text alignment
// ctx.translateWithPrecision(paddingLeft, 0);
Text.drawWith(ctx, {
text: valueStr,
fontStyle,
width: realWidth,
height: fontHeight,
color: style?.cl?.rgb,
strokeLine: Boolean(style?.st?.s),
underline: Boolean(style?.ul?.s),
warp: tb === WrapStrategy.WRAP,
hAlign: HorizontalAlign.LEFT,
}, textSkeleton);
ctx.restore();
ctx.translateWithPrecision(realWidth + PADDING_H + 4, ((fontHeight) - ICON_SIZE) / 2);
ctx.fillStyle = DROP_DOWN_ICON_COLOR;
ctx.fill(downPath);
ctx.restore();
map.set(key, {
left: cellBounding.startX + MARGIN_H + skeleton.rowHeaderWidth,
top: cellBounding.startY + paddingTop + skeleton.columnHeaderHeight,
width: rectWidth,
height: rectHeight,
});
}
}
calcCellAutoHeight(info: ICellRenderContext): number | undefined {
const { primaryWithCoord, style, data, row, col } = info;
const _cellBounding = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const fontRenderExtension = data?.fontRenderExtension;
const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {};
const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col);
if (!rule) {
return;
}
if (rule.renderMode === DataValidationRenderMode.TEXT) {
return undefined;
}
const cellBounding = {
startX: _cellBounding.startX + leftOffset,
endX: _cellBounding.endX - rightOffset,
startY: _cellBounding.startY + topOffset,
endY: _cellBounding.endY - downOffset,
};
const cellWidth = cellBounding.endX - cellBounding.startX;
const value = getCellValueOrigin(data);
const valueStr = `${value ?? ''}`;
let { tb, pd } = style || {};
const { t = DEFAULT_STYLES.pd.t, b = DEFAULT_STYLES.pd.b } = (pd ?? {});
tb = tb ?? WrapStrategy.WRAP;
if (rule.renderMode === DataValidationRenderMode.ARROW) {
const { l = DEFAULT_STYLES.pd.l, r = DEFAULT_STYLES.pd.r } = (pd ?? {});
const realWidth = cellWidth - l - r - ICON_PLACE - 4;
const skeleton = new DocSimpleSkeleton(
valueStr,
getFontStyleString(style).fontCache,
Boolean(tb === WrapStrategy.WRAP),
realWidth,
Infinity
);
skeleton.calculate();
return skeleton.getTotalHeight() + t + b + (MARGIN_V * 2);
} else {
const realWidth = Math.max(cellWidth - (MARGIN_H * 2) - PADDING_H - ICON_PLACE - 4, 10);
const skeleton = new DocSimpleSkeleton(
valueStr,
getFontStyleString(style).fontCache,
Boolean(tb === WrapStrategy.WRAP),
realWidth,
Infinity
);
skeleton.calculate();
return skeleton.getTotalHeight() + (MARGIN_V * 2) + (PADDING_V * 2);
}
}
calcCellAutoWidth(info: ICellRenderContext): number | undefined {
const { primaryWithCoord, style, data, row, col } = info;
const cellRange = primaryWithCoord.isMergedMainCell ? primaryWithCoord.mergeInfo : primaryWithCoord;
const fontRenderExtension = data?.fontRenderExtension;
const { leftOffset = 0, rightOffset = 0, topOffset = 0, downOffset = 0 } = fontRenderExtension || {};
const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col);
if (!rule) {
return;
}
if (rule.renderMode === DataValidationRenderMode.TEXT) {
return;
}
const cellBounding = {
startX: cellRange.startX + leftOffset,
endX: cellRange.endX - rightOffset,
startY: cellRange.startY + topOffset,
endY: cellRange.endY - downOffset,
};
const cellWidth = cellBounding.endX - cellBounding.startX;
const value = getCellValueOrigin(data);
const valueStr = `${value ?? ''}`;
let { tb, pd } = style || {};
const { l = DEFAULT_STYLES.pd.l, r = DEFAULT_STYLES.pd.r } = (pd ?? {});
tb = tb ?? WrapStrategy.WRAP;
let paddingAll = MARGIN_H * 2 + ICON_PLACE;
switch (rule.renderMode) {
case DataValidationRenderMode.ARROW:
paddingAll = ICON_PLACE + 4 + r + l;
break;
case DataValidationRenderMode.CUSTOM:
// + 1 is must, or last character will be cut
paddingAll = ICON_PLACE + MARGIN_H * 2 + PADDING_H * 2 + r + l + RADIUS_BG / 2 + 1;
break;
// default is CUSTOM
default:
paddingAll = ICON_PLACE + MARGIN_H * 2 + PADDING_H * 2 + r + l + RADIUS_BG / 2 + 1;
}
const widthForTextLayout = cellWidth - paddingAll;
const skeleton = new DocSimpleSkeleton(
valueStr,
getFontStyleString(style).fontCache,
Boolean(tb === WrapStrategy.WRAP),
widthForTextLayout,
Infinity
);
skeleton.calculate();
return skeleton.getTotalWidth() + paddingAll;
}
isHit(position: { x: number; y: number }, info: ICellRenderContext) {
const { subUnitId, row, col } = info;
const map = this._ensureMap(subUnitId);
const dropdownInfo = map.get(this._generateKey(row, col));
const rule = this._dataValidationModel.getRuleByLocation(info.unitId, info.subUnitId, row, col);
if (!rule) {
return false;
}
if (!dropdownInfo) {
return false;
}
if (rule.renderMode === DataValidationRenderMode.TEXT) {
return false;
}
const { top, left, width, height } = dropdownInfo;
const { x, y } = position;
if (x >= left && (x <= (left + width)) && y >= top && (y <= (top + height))) {
return true;
}
return false;
};
onPointerDown(info: ICellRenderContext, evt: IPointerEvent | IMouseEvent) {
if (evt.button === 2) {
return;
}
const { unitId, subUnitId, row, col } = info;
const params: IShowDataValidationDropdownParams = {
unitId: unitId!,
subUnitId,
row,
column: col,
};
this._commandService.executeCommand(ShowDataValidationDropdown.id, params);
};
onPointerEnter(_info: ICellRenderContext, _evt: IPointerEvent | IMouseEvent) {
getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService)
?.mainComponent
?.setCursor(CURSOR_TYPE.POINTER);
}
onPointerLeave(_info: ICellRenderContext, _evt: IPointerEvent | IMouseEvent) {
getCurrentTypeOfRenderer(UniverInstanceType.UNIVER_SHEET, this._univerInstanceService, this._renderManagerService)
?.mainComponent
?.setCursor(CURSOR_TYPE.DEFAULT);
}
}

View File

@@ -0,0 +1,50 @@
/**
* 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 { IShapeProps, UniverRenderingContext } from '@univerjs/engine-render';
import type { IDropdownLayoutInfo } from './layout';
import { Rect, Shape } from '@univerjs/engine-render';
import { DROP_DOWN_DEFAULT_COLOR } from '../../../const';
import { PADDING_H } from './layout';
const RADIUS = 8;
export interface IDropdownProps extends IShapeProps {
fontString: string;
fontFamily: string;
fontSize: number;
info: IDropdownLayoutInfo;
color: string;
}
export class Dropdown extends Shape<IDropdownProps> {
static override drawWith(ctx: UniverRenderingContext, props: IDropdownProps) {
const { fontString, info, fill, color } = props;
const { layout, text } = info;
ctx.save();
Rect.drawWith(ctx, {
width: layout.width,
height: layout.height,
radius: RADIUS,
fill: fill || DROP_DOWN_DEFAULT_COLOR,
});
ctx.translateWithPrecision(PADDING_H, layout.ba);
ctx.font = fontString;
ctx.fillStyle = color;
ctx.fillText(text, 0, 0);
ctx.restore();
}
}

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 { Dropdown } from './dropdown';
export type { IDropdownProps } from './dropdown';
export * from './layout';

View File

@@ -0,0 +1,116 @@
/**
* 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 { IDocumentSkeletonFontStyle } from '@univerjs/engine-render';
import { FontCache, getFontStyleString } from '@univerjs/engine-render';
export const PADDING_H = 4;
export const PADDING_V = 0;
export const MARGIN_H = 4;
export const MARGIN_V = 4;
export const CELL_PADDING_H = 6;
export const CELL_PADDING_V = 6;
export const ICON_PLACE = 14;
export function measureDropdownItemText(text: string, style: Nullable<IStyleData>) {
const fontStyle = getFontStyleString(style ?? undefined);
const bBox = FontCache.getTextSize(text, fontStyle);
return bBox;
}
export function getDropdownItemSize(text: string, fontStyle: IDocumentSkeletonFontStyle) {
const bBox = FontCache.getTextSize(text, fontStyle);
const rectWidth = bBox.width + PADDING_H * 2;
const { ba, bd } = bBox;
const height = ba + bd;
return {
width: rectWidth,
height: height + (PADDING_V * 2),
ba,
};
}
export interface IDropdownLayoutInfo {
layout: {
width: number;
height: number;
ba: number;
};
text: string;
}
export interface IDropdownLine {
width: number;
height: number;
items: (IDropdownLayoutInfo & { left: number })[];
}
export function layoutDropdowns(items: string[], fontStyle: IDocumentSkeletonFontStyle, cellWidth: number, cellHeight: number) {
const cellPaddingH = ICON_PLACE + CELL_PADDING_H * 2;
const widthAvailableForContent = cellWidth - cellPaddingH;
const heightAvailableForContent = cellHeight - CELL_PADDING_V * 2;
const textLayout = items.map((item) => ({
layout: getDropdownItemSize(item, fontStyle),
text: item,
}));
let currentLine: IDropdownLine | undefined;
const lines: IDropdownLine[] = [];
textLayout.forEach((item) => {
const { layout } = item;
const { width, height } = layout;
if (!currentLine || ((currentLine.width + width + MARGIN_H) > widthAvailableForContent)) {
currentLine = {
width,
height,
items: [{
...item,
left: 0,
}],
};
lines.push(currentLine);
} else {
currentLine.items.push({
...item,
left: currentLine.width + MARGIN_H,
});
currentLine.width = currentLine.width + width + MARGIN_H;
}
});
let totalHeight = 0;
let maxLineWidth = 0;
lines.forEach((line, index) => {
maxLineWidth = Math.max(maxLineWidth, line.width);
if (index === lines.length - 1) {
totalHeight += line.height;
} else {
totalHeight += line.height + MARGIN_V;
}
});
return {
lines,
totalHeight,
contentWidth: widthAvailableForContent,
contentHeight: heightAvailableForContent,
cellAutoHeight: totalHeight + CELL_PADDING_V * 2,
calcAutoWidth: maxLineWidth + cellPaddingH,
};
}

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 { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

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

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