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,283 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector, IWorkbookData, Workbook } from '@univerjs/core';
import {
ICommandService,
Inject,
Injector as InjectorIdentifier,
IUniverInstanceService,
LocaleType,
Plugin,
RANGE_TYPE,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { MarkDirtyFilterChangeMutation, SheetsSelectionsService } from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SheetsFilterService } from '../../../services/sheet-filter.service';
import {
ReCalcSheetsFilterMutation,
RemoveSheetsFilterMutation,
SetSheetsFilterCriteriaMutation,
SetSheetsFilterRangeMutation,
} from '../../mutations/sheets-filter.mutation';
import {
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
RemoveSheetFilterCommand,
SetSheetFilterRangeCommand,
SetSheetsFilterCriteriaCommand,
SmartToggleSheetsFilterCommand,
} from '../sheets-filter.command';
const unitId = 'filter-command-workbook';
const subUnitId = 'sheet1';
function createWorkbookData(): IWorkbookData {
return {
id: unitId,
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: 'filter command workbook',
sheetOrder: [subUnitId],
styles: {},
sheets: {
[subUnitId]: {
id: subUnitId,
name: 'Sheet 1',
cellData: {
0: {
0: { v: 'Fruit' },
},
1: {
0: { v: 'Apple' },
},
2: {
0: { v: 'Banana' },
},
3: {
0: { v: 'Apple' },
},
},
},
},
};
}
class SheetsFilterCommandTestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SheetsFilterCommandTestPlugin';
constructor(_config: unknown, @Inject(InjectorIdentifier) protected readonly _injector: Injector) {
super();
}
override onStarting(): void {
this._injector.add([SheetsFilterService]);
this._injector.add([SheetsSelectionsService]);
}
}
describe('sheet filter commands', () => {
let univer: Univer;
let workbook: Workbook;
let commandService: ICommandService;
let sheetsFilterService: SheetsFilterService;
beforeEach(() => {
univer = new Univer();
univer.registerPlugin(SheetsFilterCommandTestPlugin);
workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
const injector = univer.__getInjector();
injector.get(IUniverInstanceService).focusUnit(unitId);
commandService = injector.get(ICommandService);
sheetsFilterService = injector.get(SheetsFilterService);
const commands = [
SetSheetsFilterRangeMutation,
SetSheetsFilterCriteriaMutation,
RemoveSheetsFilterMutation,
ReCalcSheetsFilterMutation,
MarkDirtyFilterChangeMutation,
SetSheetFilterRangeCommand,
RemoveSheetFilterCommand,
SetSheetsFilterCriteriaCommand,
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
SmartToggleSheetsFilterCommand,
];
for (const command of commands) {
commandService.registerCommand(command);
}
});
afterEach(() => {
univer.dispose();
});
it('sets a filter range and applies criteria to the filtered rows', async () => {
const range = { startRow: 0, startColumn: 0, endRow: 3, endColumn: 0 };
const setRangeResult = await commandService.executeCommand(SetSheetFilterRangeCommand.id, {
unitId,
subUnitId,
range,
});
const criteriaResult = await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId,
subUnitId,
col: 0,
criteria: {
colId: 0,
filters: {
filters: ['Apple'],
},
},
});
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
const filterColumn = filterModel?.getFilterColumn(0);
expect(setRangeResult).toBe(true);
expect(criteriaResult).toBe(true);
expect(filterModel?.getRange()).toEqual(range);
expect(filterColumn?.serialize()).toMatchObject({
colId: 0,
filters: {
filters: ['Apple'],
},
});
expect(filterModel?.isRowFiltered(1)).toBe(false);
expect(filterModel?.isRowFiltered(2)).toBe(true);
expect(filterModel?.isRowFiltered(3)).toBe(false);
});
it('clears criteria without removing the filter range', async () => {
const range = { startRow: 0, startColumn: 0, endRow: 3, endColumn: 0 };
await commandService.executeCommand(SetSheetFilterRangeCommand.id, {
unitId,
subUnitId,
range,
});
await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId,
subUnitId,
col: 0,
criteria: {
colId: 0,
filters: {
filters: ['Apple'],
},
},
});
const result = await commandService.executeCommand(ClearSheetsFilterCriteriaCommand.id, {
unitId,
subUnitId,
});
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
expect(result).toBe(true);
expect(filterModel?.getRange()).toEqual(range);
expect(filterModel?.getFilterColumn(0)).toBeNull();
expect(filterModel?.filteredOutRows.size).toBe(0);
});
it('removes an existing filter model from the worksheet', async () => {
await commandService.executeCommand(SetSheetFilterRangeCommand.id, {
unitId,
subUnitId,
range: { startRow: 0, startColumn: 0, endRow: 3, endColumn: 0 },
});
const result = await commandService.executeCommand(RemoveSheetFilterCommand.id, {
unitId,
subUnitId,
});
expect(result).toBe(true);
expect(sheetsFilterService.getFilterModel(unitId, subUnitId)).toBeNull();
});
it('toggles the current selection into and out of a sheet filter', async () => {
const selectionManager = univer.__getInjector().get(SheetsSelectionsService);
const selectedRange = {
startRow: 0,
startColumn: 0,
endRow: 3,
endColumn: 0,
rangeType: RANGE_TYPE.NORMAL,
};
selectionManager.addSelections([{
range: selectedRange,
primary: {
actualRow: 0,
actualColumn: 0,
startRow: 0,
startColumn: 0,
endRow: 3,
endColumn: 0,
isMerged: false,
isMergedMainCell: false,
},
style: null,
}]);
const openResult = await commandService.executeCommand(SmartToggleSheetsFilterCommand.id);
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
const closeResult = await commandService.executeCommand(SmartToggleSheetsFilterCommand.id);
expect(openResult).toBe(true);
expect(filterModel?.getRange()).toEqual(selectedRange);
expect(closeResult).toBe(true);
expect(sheetsFilterService.getFilterModel(unitId, subUnitId)).toBeNull();
});
it('recalculates an existing filter after sheet data changes', async () => {
const worksheet = workbook.getSheetBySheetId(subUnitId);
await commandService.executeCommand(SetSheetFilterRangeCommand.id, {
unitId,
subUnitId,
range: { startRow: 0, startColumn: 0, endRow: 3, endColumn: 0 },
});
await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId,
subUnitId,
col: 0,
criteria: {
colId: 0,
filters: {
filters: ['Apple'],
},
},
});
worksheet?.getCellMatrix().setValue(2, 0, { v: 'Apple' });
const result = await commandService.executeCommand(ReCalcSheetsFilterCommand.id, {
unitId,
subUnitId,
});
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
expect(result).toBe(true);
expect(filterModel?.isRowFiltered(2)).toBe(false);
});
});

View File

@@ -0,0 +1,447 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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, ICommand, IMutationInfo, IRange, Nullable, Workbook } from '@univerjs/core';
import type { IMarkDirtyFilterChangeMutationParams, ISheetCommandSharedParams } from '@univerjs/sheets';
import type { LocaleKey } from '../../locale/types';
import type { FilterColumn } from '../../models/filter-model';
import type { IAutoFilter, IFilterColumn } from '../../models/types';
import type {
ISetSheetsFilterCriteriaMutationParams,
ISetSheetsFilterRangeMutationParams,
} from '../mutations/sheets-filter.mutation';
import {
CommandType,
ErrorService,
ICommandService,
IUndoRedoService,
IUniverInstanceService,
LocaleService,
sequenceExecute,
UniverInstanceType,
} from '@univerjs/core';
import {
expandToContinuousRange,
getSheetCommandTarget,
isSingleCellSelection,
MarkDirtyFilterChangeMutation,
SheetsSelectionsService,
} from '@univerjs/sheets';
import { SheetsFilterService } from '../../services/sheet-filter.service';
import {
ReCalcSheetsFilterMutation,
RemoveSheetsFilterMutation,
SetSheetsFilterCriteriaMutation,
SetSheetsFilterRangeMutation,
} from '../mutations/sheets-filter.mutation';
/**
* Parameters of command {@link SetSheetFilterRangeCommand}.
* @property {IRange} range - the range to be set as filter range.
*/
export interface ISetSheetFilterRangeCommandParams extends ISheetCommandSharedParams {
range: IRange;
}
/**
* A {@link CommandType.COMMAND} to set filter range in a Worksheet. Its params {@link ISetSheetFilterRangeCommandParams}
* is required. If the {@link FilterModel} does not exist, it will be created.
*/
export const SetSheetFilterRangeCommand: ICommand<ISetSheetFilterRangeCommandParams> = {
id: 'sheet.command.set-filter-range',
type: CommandType.COMMAND,
handler: (accessor, params: ISetSheetFilterRangeCommandParams) => {
const sheetsFilterService = accessor.get(SheetsFilterService);
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const instanceSrv = accessor.get(IUniverInstanceService);
const { unitId, subUnitId, range } = params;
const commandTarget = getSheetCommandTarget(instanceSrv, params);
if (!commandTarget) return false;
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (filterModel) return false;
if (range.endRow === range.startRow) {
const errorService = accessor.get(ErrorService);
const localeService = accessor.get(LocaleService);
errorService.emit(localeService.t<LocaleKey>('sheets-filter.command.not-valid-filter-range'));
return false;
}
// Execute the command to set filter range and prepare undo redo.
const redoMutation = { id: SetSheetsFilterRangeMutation.id, params: { unitId, subUnitId, range } };
const result = commandService.syncExecuteCommand(redoMutation.id, redoMutation.params);
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [{ id: RemoveSheetsFilterMutation.id, params: { unitId, subUnitId } }],
redoMutations: [redoMutation],
});
}
return result;
},
};
/**
* A {@link CommandType.COMMAND} to remove filter in a Worksheet. Its params {@link ISheetCommandSharedParams} is
* required. If the {@link FilterModel} does not exist, it will fail to execute.
*/
export const RemoveSheetFilterCommand: ICommand<ISheetCommandSharedParams> = {
id: 'sheet.command.remove-sheet-filter',
type: CommandType.COMMAND,
handler: (accessor, params: ISheetCommandSharedParams) => {
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), params);
if (!target) return false;
const sheetsFilterService = accessor.get(SheetsFilterService);
const { unitId, subUnitId } = target;
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) return false;
const filterRange = filterModel.getRange();
if (!filterRange) return false;
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const autoFilter = filterModel.serialize();
const undoMutations = destructFilterModel(unitId, subUnitId, autoFilter);
const result = commandService.syncExecuteCommand(RemoveSheetsFilterMutation.id, { unitId, subUnitId });
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations,
redoMutations: [{ id: RemoveSheetsFilterMutation.id, params: { unitId, subUnitId } }],
});
commandService.executeCommand<IMarkDirtyFilterChangeMutationParams>(MarkDirtyFilterChangeMutation.id, {
unitId,
subUnitId,
filterRange,
});
}
return result;
},
};
/**
* A {@link CommandType.COMMAND} to toggle filter in the current {@link Worksheet}.
*/
export const SmartToggleSheetsFilterCommand: ICommand = {
id: 'sheet.command.smart-toggle-filter',
type: CommandType.COMMAND,
handler: async (accessor: IAccessor) => {
const univerInstanceService = accessor.get(IUniverInstanceService);
const sheetsFilterService = accessor.get(SheetsFilterService);
const commandService = accessor.get(ICommandService);
const currentWorkbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const currentWorksheet = currentWorkbook?.getActiveSheet();
if (!currentWorksheet || !currentWorkbook) return false;
const unitId = currentWorkbook.getUnitId();
const subUnitId = currentWorksheet.getSheetId();
// If there is a filter model, we should remove it and prepare undo redo.
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (filterModel) {
return commandService.executeCommand(RemoveSheetFilterCommand.id, { unitId, subUnitId });
}
// Other wise we should set the filter range to the current selection.
const selectionManager = accessor.get(SheetsSelectionsService);
const lastSelection = selectionManager.getCurrentLastSelection();
if (!lastSelection) return false;
const startRange = lastSelection.range;
const targetFilterRange =
// If the selection is a single cell, we should expand it to a continuous range in all directions.
isSingleCellSelection(lastSelection)
? expandToContinuousRange(startRange, { left: true, right: true, up: true, down: true }, currentWorksheet)
// If the selection is only a single row, we should expand it downwards.
: startRange.startRow === startRange.endRow
? expandToContinuousRange(startRange, { down: true }, currentWorksheet)
// Otherwise, we just use the selected range.
: startRange;
return commandService.executeCommand(SetSheetFilterRangeCommand.id, {
unitId,
subUnitId,
range: targetFilterRange,
} as ISetSheetFilterRangeCommandParams);
},
};
/**
* Parameters of command {@link SetSheetsFilterCriteriaCommand}.
* @property {number} col - the column index of the filter criteria
* @property {Nullable<IFilterColumn>} criteria - the filter criteria to be set
*/
export interface ISetSheetsFilterCriteriaCommandParams extends ISheetCommandSharedParams {
col: number;
criteria: Nullable<IFilterColumn>;
}
/**
* A {@link CommandType.COMMAND} to set filter criteria to a column in the targeting {@link FilterModel}. Its params
* {@link ISetSheetsFilterCriteriaCommandParams} is required.
*/
export const SetSheetsFilterCriteriaCommand: ICommand<ISetSheetsFilterCriteriaCommandParams> = {
id: 'sheet.command.set-filter-criteria',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params: ISetSheetsFilterCriteriaCommandParams) => {
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), params);
if (!target) return false;
const sheetsFilterService = accessor.get(SheetsFilterService);
const { unitId, subUnitId } = target;
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) return false;
const { col, criteria } = params;
const filterRange = filterModel.getRange();
if (!filterRange || col < filterRange.startColumn || col > filterRange.endColumn) return false;
const commandService = accessor.get(ICommandService);
const undoRedoService = accessor.get(IUndoRedoService);
const filterColumn = filterModel.getFilterColumn(col);
const undoMutation = destructFilterColumn(unitId, subUnitId, col, filterColumn);
const redoMutation: IMutationInfo<ISetSheetsFilterCriteriaMutationParams> = {
id: SetSheetsFilterCriteriaMutation.id,
params: {
unitId,
subUnitId,
col,
criteria,
},
};
const result = commandService.syncExecuteCommand(redoMutation.id, redoMutation.params);
if (result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations: [undoMutation],
redoMutations: [redoMutation],
});
commandService.executeCommand<IMarkDirtyFilterChangeMutationParams>(MarkDirtyFilterChangeMutation.id, {
unitId,
subUnitId,
filterRange,
});
}
return result;
},
};
/**
* A {@link CommandType.COMMAND} to clear all filter criteria in the targeting {@link FilterModel}. Its params
* {@link ISheetCommandSharedParams} is required.
*/
export const ClearSheetsFilterCriteriaCommand: ICommand<ISheetCommandSharedParams> = {
id: 'sheet.command.clear-filter-criteria',
type: CommandType.COMMAND,
handler: (accessor: IAccessor, params) => {
const target = getSheetCommandTarget(accessor.get(IUniverInstanceService), params);
if (!target) return false;
const sheetsFilterService = accessor.get(SheetsFilterService);
const { unitId, subUnitId } = target;
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) return false;
const filterRange = filterModel.getRange();
if (!filterRange) return false;
const undoRedoService = accessor.get(IUndoRedoService);
const commandService = accessor.get(ICommandService);
const autoFilter = filterModel.serialize();
const undoMutations = destructFilterCriteria(unitId, subUnitId, autoFilter);
const redoMutations = generateRemoveCriteriaMutations(unitId, subUnitId, autoFilter);
const result = sequenceExecute(redoMutations, commandService);
if (result.result) {
undoRedoService.pushUndoRedo({
unitID: unitId,
undoMutations,
redoMutations,
});
commandService.executeCommand<IMarkDirtyFilterChangeMutationParams>(MarkDirtyFilterChangeMutation.id, {
unitId,
subUnitId,
filterRange,
});
return true;
}
return false;
},
};
/**
* A {@link CommandType.COMMAND} forcing the currently active {@link FilterModel} to re-calculate all filter criteria.
* Its params {@link ISheetCommandSharedParams} is required.
*/
export const ReCalcSheetsFilterCommand: ICommand<ISheetCommandSharedParams> = {
id: 'sheet.command.re-calc-filter',
type: CommandType.COMMAND,
handler: (accessor, params) => {
const sheetsFilterService = accessor.get(SheetsFilterService);
const commandService = accessor.get(ICommandService);
const instanceSrv = accessor.get(IUniverInstanceService);
const commandTarget = getSheetCommandTarget(instanceSrv, params);
if (!commandTarget) return false;
const { unitId, subUnitId } = commandTarget;
const filterModel = sheetsFilterService.getFilterModel(commandTarget.unitId, commandTarget.subUnitId);
if (!filterModel) return false;
// No need to handle undo redo for this command.
return commandService.executeCommand(ReCalcSheetsFilterMutation.id, { unitId, subUnitId } as ISheetCommandSharedParams);
},
};
/**
* Destruct a `FilterModel` to a list of mutations.
* @param {string} unitId - the unit id of the Workbook
* @param {string} subUnitId - the sub unit id of the Worksheet
* @param {IAutoFilter} autoFilter - the to be destructed FilterModel
* @returns a list of mutations those can be used to reconstruct the FilterModel
*/
function destructFilterModel(
unitId: string,
subUnitId: string,
autoFilter: IAutoFilter
): IMutationInfo[] {
const mutations: IMutationInfo[] = [];
const setFilterMutation: IMutationInfo<ISetSheetsFilterRangeMutationParams> = {
id: SetSheetsFilterRangeMutation.id,
params: {
unitId,
subUnitId,
range: autoFilter.ref,
},
};
mutations.push(setFilterMutation);
const criteriaMutations = destructFilterCriteria(unitId, subUnitId, autoFilter);
criteriaMutations.forEach((m) => mutations.push(m));
return mutations;
}
/**
* Transform a {@link FilterModel} to a list of mutations to set the filter criteria.
* @param unitId - the unit id of the {@link Workbook}
* @param subUnitId - the sub unit id of the {@link Worksheet}
* @param autoFilter - the to be destructed {@link FilterModel}
* @returns {IMutationInfo<ISetSheetsFilterCriteriaMutationParams>} a list of mutations those can be used to
* reconstruct the {@link FilterModel}
*/
export function destructFilterCriteria(
unitId: string,
subUnitId: string,
autoFilter: IAutoFilter
): IMutationInfo<ISetSheetsFilterCriteriaMutationParams>[] {
const mutations: IMutationInfo<ISetSheetsFilterCriteriaMutationParams>[] = [];
autoFilter.filterColumns?.forEach((filterColumn) => {
const setFilterCriteriaMutation: IMutationInfo<ISetSheetsFilterCriteriaMutationParams> = {
id: SetSheetsFilterCriteriaMutation.id,
params: {
unitId,
subUnitId,
col: filterColumn.colId,
criteria: filterColumn,
},
};
mutations.push(setFilterCriteriaMutation);
});
return mutations;
}
/** Generate mutations to remove all criteria on a `FilterModel` */
function generateRemoveCriteriaMutations(
unitId: string,
subUnitId: string,
autoFilter: IAutoFilter
): IMutationInfo[] {
const mutations: IMutationInfo[] = [];
autoFilter.filterColumns?.forEach((filterColumn) => {
const removeFilterCriteriaMutation: IMutationInfo<ISetSheetsFilterCriteriaMutationParams> = {
id: SetSheetsFilterCriteriaMutation.id,
params: {
unitId,
subUnitId,
col: filterColumn.colId,
criteria: null,
},
};
mutations.push(removeFilterCriteriaMutation);
});
return mutations;
}
/**
* Prepare the undo mutation, it should rollback to the old criteria if there's already a `FilterColumn`,
* or remove the filter criteria when there is no `FilterColumn`.
* @param {string} unitId
* @param {string} subUnitId
* @param {number} colId
* @param {Nullable<FilterColumn>} filterColumn
* @returns {IMutationInfo<ISetSheetsFilterCriteriaMutationParams>} the undo mutation
*/
function destructFilterColumn(
unitId: string,
subUnitId: string,
colId: number,
filterColumn: Nullable<FilterColumn>
): IMutationInfo<ISetSheetsFilterCriteriaMutationParams> {
if (!filterColumn) {
return {
id: SetSheetsFilterCriteriaMutation.id,
params: {
unitId,
subUnitId,
col: colId,
criteria: null,
},
};
}
const serialize = filterColumn.serialize();
return {
id: SetSheetsFilterCriteriaMutation.id,
params: {
unitId,
subUnitId,
col: colId,
criteria: serialize,
},
};
}

View File

@@ -0,0 +1,273 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IWorkbookData } from '@univerjs/core';
import type { ISetSheetsFilterCriteriaMutationParams, ISetSheetsFilterRangeMutationParams } from '../sheets-filter.mutation';
import { ICommandService, Inject, Injector, LocaleType, Plugin, Univer, UniverInstanceType } from '@univerjs/core';
import { afterEach, describe, expect, it } from 'vitest';
import { SHEET_FILTER_SNAPSHOT_ID, SheetsFilterService } from '../../../services/sheet-filter.service';
import { ReCalcSheetsFilterMutation, RemoveSheetsFilterMutation, SetSheetsFilterCriteriaMutation, SetSheetsFilterRangeMutation } from '../sheets-filter.mutation';
function testWorkbookDataFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'B1',
},
},
},
name: 'Sheet-001',
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
};
function testWorkbookDataWithFilterFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'header',
},
1: {
v: 'A',
},
2: {
v: 'B',
},
3: {
v: 'C',
},
},
},
name: 'Sheet-001',
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
sheet1: {
ref: { startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 },
},
}),
},
],
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
}
function createFilterMutationTestBed(workbookData?: IWorkbookData) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class SheetsFilterTestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SheetsFilterTestPlugin';
constructor(_config: unknown, @Inject(Injector) protected readonly _injector: Injector) {
super();
}
override onStarting(): void {
this._injector.add([SheetsFilterService]);
}
}
univer.registerPlugin(SheetsFilterTestPlugin);
const sheet = univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData || testWorkbookDataFactory());
const sheetsFilterService = get(SheetsFilterService);
const commandService = get(ICommandService);
// It should be registered later in avoid of time sequence problem.
// injector.add([ISnapshotPersistenceService, { useClass: LocalSnapshotService }]);
// injector.get(ISnapshotPersistenceService);
([
SetSheetsFilterRangeMutation,
SetSheetsFilterCriteriaMutation,
RemoveSheetsFilterMutation,
ReCalcSheetsFilterMutation,
]
).forEach((command) => commandService.registerCommand(command));
return {
univer,
get,
sheet,
sheetsFilterService,
commandService,
};
}
describe('test mutations of sheets filter', () => {
let univer: Univer;
afterEach(() => {
univer.dispose();
});
describe('test set sheets filter range mutations', () => {
it('should set filter range work when there is no filter range', () => {
const testBed = createFilterMutationTestBed(testWorkbookDataFactory());
const { commandService, sheetsFilterService } = testBed;
univer = testBed.univer;
expect(sheetsFilterService.getFilterModel('test', 'sheet1')).toBeNull();
expect(commandService.syncExecuteCommand(SetSheetsFilterRangeMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 },
} as ISetSheetsFilterRangeMutationParams)).toBeTruthy();
expect(sheetsFilterService.getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
});
it('should load filter info to the service and support changing its range', () => {
const testBed = createFilterMutationTestBed(testWorkbookDataWithFilterFactory());
const { commandService, sheetsFilterService } = testBed;
univer = testBed.univer;
const filterModel = sheetsFilterService.getFilterModel('test', 'sheet1');
expect(filterModel).toBeTruthy();
expect(filterModel!.getRange()).toEqual({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(commandService.syncExecuteCommand(SetSheetsFilterRangeMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 5, startColumn: 5, endRow: 8, endColumn: 8 },
} as ISetSheetsFilterRangeMutationParams)).toBeTruthy();
expect(sheetsFilterService.getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 5, startColumn: 5, endRow: 8, endColumn: 8 });
});
});
describe('test set sheets filter criteria mutations', () => {
it('should re calc when set filter criteria', () => {
const testBed = createFilterMutationTestBed(testWorkbookDataWithFilterFactory());
const { commandService, sheetsFilterService } = testBed;
univer = testBed.univer;
const filterModel = sheetsFilterService.getFilterModel('test', 'sheet1');
expect(filterModel).toBeTruthy();
expect(filterModel!.getRange()).toEqual({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(filterModel!.filteredOutRows.size).toBe(0);
expect(commandService.syncExecuteCommand(SetSheetsFilterCriteriaMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
criteria: {
colId: 0,
filters: ['A'],
},
} as ISetSheetsFilterCriteriaMutationParams)).toBeTruthy();
expect(filterModel!.filteredOutRows.size).toBe(5);
});
it('should return false when set filter criteria on a non-existing filter model', () => {
const testBed = createFilterMutationTestBed(testWorkbookDataWithFilterFactory());
const { commandService, sheetsFilterService } = testBed;
univer = testBed.univer;
const filterModel = sheetsFilterService.getFilterModel('test', 'sheet1');
expect(filterModel).toBeTruthy();
expect(filterModel!.getRange()).toEqual({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(filterModel!.filteredOutRows.size).toBe(0);
expect(commandService.syncExecuteCommand(SetSheetsFilterCriteriaMutation.id, {
unitId: 'test',
subUnitId: 'sheet2',
colId: 0,
criteria: {
colId: 0,
filters: ['A'],
},
})).toBeFalsy();
});
it('should throw error when set filter criteria outside of filter range', () => {
const testBed = createFilterMutationTestBed(testWorkbookDataWithFilterFactory());
const { commandService, sheetsFilterService } = testBed;
univer = testBed.univer;
const filterModel = sheetsFilterService.getFilterModel('test', 'sheet1');
expect(filterModel).toBeTruthy();
expect(filterModel!.getRange()).toEqual({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(filterModel!.filteredOutRows.size).toBe(0);
expect(() => commandService.syncExecuteCommand(SetSheetsFilterCriteriaMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
colId: 10,
criteria: {
colId: 10,
filters: ['A'],
},
})).toThrowError();
});
});
describe('test remove sheets filter mutations', () => {
it('should return true when there is a filter', () => {
const testBed = createFilterMutationTestBed(testWorkbookDataWithFilterFactory());
const { commandService, sheetsFilterService } = testBed;
univer = testBed.univer;
expect(commandService.syncExecuteCommand(RemoveSheetsFilterMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBeTruthy();
expect(sheetsFilterService.getFilterModel('test', 'sheet1')).toBeNull();
});
it('should return false when there is no filter', () => {
const testBed = createFilterMutationTestBed(testWorkbookDataFactory());
const { commandService } = testBed;
univer = testBed.univer;
expect(commandService.syncExecuteCommand(RemoveSheetsFilterMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,119 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMutation, IRange, Nullable } from '@univerjs/core';
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
import type { IFilterColumn } from '../../models/types';
import { CommandType } from '@univerjs/core';
import {
ReCalcSheetsFilterMutationId,
RemoveSheetsFilterMutationId,
SetSheetsFilterCriteriaMutationId,
SetSheetsFilterRangeMutationId,
} from '../../common/const';
import { SheetsFilterService } from '../../services/sheet-filter.service';
/**
* Parameters of mutation {@link SetSheetsFilterRangeMutation}.
* @property range - the range to be set as filter range.
*/
export interface ISetSheetsFilterRangeMutationParams extends ISheetCommandSharedParams {
range: IRange;
}
/**
* A {@link CommandType.MUTATION} to set filter range in a {@link Worksheet}. If no {@link FilterModel} exists,
* a new `FilterModel` will be created.
*
* Since there could only be a filter on a worksheet, when you want to update the range, you
* don't necessarily need to remove the filter first, you can just execute this mutation.
*/
export const SetSheetsFilterRangeMutation: IMutation<ISetSheetsFilterRangeMutationParams> = {
id: SetSheetsFilterRangeMutationId,
type: CommandType.MUTATION,
handler: (accessor, params) => {
const { subUnitId, unitId, range } = params;
const sheetsFilterService = accessor.get(SheetsFilterService);
// check if the range is in bound?
const filterModel = sheetsFilterService.ensureFilterModel(unitId, subUnitId);
filterModel.setRange(range);
return true;
},
};
/**
* Parameters of mutation {@link SetSheetsFilterCriteriaMutation}.
* @property {number} col - the column index to set filter criteria.
* @property {IFilterColumn | null} criteria - the filter criteria to set. If it is `null`, the criteria will be removed.
* @property {boolean} [reCalc=true] - if it should trigger calculation on this `FilterColumn`.
*/
export interface ISetSheetsFilterCriteriaMutationParams extends ISheetCommandSharedParams {
col: number;
criteria: Nullable<IFilterColumn>;
reCalc?: boolean;
}
/**
* A {@link CommandType.MUTATION} to set filter criteria of a given column of a {@link FilterModel}.
*/
export const SetSheetsFilterCriteriaMutation: IMutation<ISetSheetsFilterCriteriaMutationParams> = {
id: SetSheetsFilterCriteriaMutationId,
type: CommandType.MUTATION,
handler: (accessor, params) => {
const { subUnitId, unitId, criteria, col, reCalc = true } = params;
const sheetsFilterService = accessor.get(SheetsFilterService);
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) return false;
filterModel.setCriteria(col, criteria, reCalc);
return true;
},
};
/**
* A {@link CommandType.MUTATION} to remove a {@link FilterModel} in a {@link Worksheet}.
*/
export const RemoveSheetsFilterMutation: IMutation<ISheetCommandSharedParams> = {
id: RemoveSheetsFilterMutationId,
type: CommandType.MUTATION,
handler: (accessor, params) => {
const { unitId, subUnitId } = params;
const sheetsFilterService = accessor.get(SheetsFilterService);
return sheetsFilterService.removeFilterModel(unitId, subUnitId);
},
};
/**
* A {@link CommandType.MUTATION} to re-calculate a {@link FilterModel}.
*/
export const ReCalcSheetsFilterMutation: IMutation<ISheetCommandSharedParams> = {
id: ReCalcSheetsFilterMutationId,
type: CommandType.MUTATION,
handler: (accessor, params) => {
const { unitId, subUnitId } = params;
const sheetsFilterService = accessor.get(SheetsFilterService);
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) {
return false;
}
filterModel.reCalc();
return true;
},
};

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const SetSheetsFilterRangeMutationId = 'sheet.mutation.set-filter-range';
export const SetSheetsFilterCriteriaMutationId = 'sheet.mutation.set-filter-criteria';
export const RemoveSheetsFilterMutationId = 'sheet.mutation.remove-filter';
export const ReCalcSheetsFilterMutationId = 'sheet.mutation.re-calc-filter';
export const FILTER_MUTATIONS = new Set([
SetSheetsFilterRangeMutationId,
SetSheetsFilterCriteriaMutationId,
RemoveSheetsFilterMutationId,
ReCalcSheetsFilterMutationId,
]);

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.
*/
export const SHEETS_FILTER_PLUGIN_CONFIG_KEY = 'sheets-filter.config';
export const configSymbol = Symbol(SHEETS_FILTER_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsFilterConfig {
/**
* Whether to display the filter sync switch in the filter panel.
* This configuration can be used in a collaboration scenario to allow users to choose whether to enable filter synchronization.
* If set to a defaultValue object, the filter sync switch will be displayed and its initial state will be determined by the defaultValue property.
* @default false
*/
enableSyncSwitch?: boolean | { defaultValue: boolean };
}
export const defaultPluginConfig: IUniverSheetsFilterConfig = {};

View File

@@ -0,0 +1,422 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable perfectionist/sort-imports */
import type { IWorkbookData, Workbook } from '@univerjs/core';
import { ICommandService, Inject, Injector, IUniverInstanceService, LocaleService, LocaleType, Plugin, RANGE_TYPE, UndoCommand, Univer, UniverInstanceType } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ISetRangeValuesMutationParams } from '@univerjs/sheets';
import { CopySheetCommand, InsertColByRangeCommand, InsertColMutation, InsertRowByRangeCommand, InsertSheetMutation, MoveColsCommand, MoveColsMutation, MoveRangeCommand, MoveRangeMutation, MoveRowsCommand, MoveRowsMutation, RefRangeService, RemoveColByRangeCommand, RemoveColCommand, RemoveColMutation, RemoveRowByRangeCommand, RemoveRowCommand, RemoveRowMutation, SetRangeValuesMutation, SetSelectionsOperation, SheetInterceptorService, SheetLazyExecuteScheduleService, SheetRangeThemeModel, SheetsSelectionsService, ZebraCrossingCacheController } from '@univerjs/sheets';
import { SHEET_FILTER_SNAPSHOT_ID, SheetsFilterService } from '../../services/sheet-filter.service';
import { SheetsFilterController } from '../sheets-filter.controller';
import { SetSheetsFilterCriteriaMutation } from '../../commands/mutations/sheets-filter.mutation';
function testWorkbookDataWithFilterFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'B1',
},
2: {
v: 'C1',
},
3: {
v: 'D1',
},
},
1: {
0: {
v: 'A2',
},
1: {
v: 'B2',
},
2: {
v: 'C2',
},
3: {
v: 'D2',
},
},
2: {
0: {
v: 'A3',
},
1: {
v: 'B3',
},
2: {
v: 'C3',
},
3: {
v: 'D3',
},
},
3: {
0: {
v: 'A',
},
1: {
v: 'B',
},
2: {
v: 'C',
},
3: {
v: 'D',
},
},
4: {
0: {
v: 'H',
},
},
},
name: 'Sheet-001',
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
sheet1: {
ref: { startRow: 3, startColumn: 0, endRow: 5, endColumn: 5 },
filterColumns: [{ colId: 0, filters: { blank: true } }],
cachedFilteredOut: [4],
},
}),
},
],
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
}
function createFilterControllerTestBed(workbookData?: IWorkbookData) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class SheetsFilterTestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SheetsFilterTestPlugin';
constructor(_config: unknown, @Inject(Injector) protected readonly _injector: Injector) {
super();
}
override onStarting(): void {
this._injector.add([RefRangeService]);
this._injector.add([SheetsFilterService]);
this._injector.add([SheetRangeThemeModel]);
this._injector.add([ZebraCrossingCacheController]);
this._injector.add([SheetsFilterController]);
this._injector.add([SheetsSelectionsService]);
this._injector.add([SheetInterceptorService]);
this._injector.add([SheetLazyExecuteScheduleService]);
}
override onReady(): void {
this._injector.get(SheetsFilterController);
}
}
univer.registerPlugin(SheetsFilterTestPlugin);
const sheet = univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData ?? testWorkbookDataWithFilterFactory());
const sheetsFilterService = get(SheetsFilterService);
const commandService = get(ICommandService);
([
MoveRowsCommand,
MoveRowsMutation,
MoveColsCommand,
MoveColsMutation,
InsertColMutation,
InsertColByRangeCommand,
InsertRowByRangeCommand,
RemoveRowCommand,
RemoveRowMutation,
RemoveColCommand,
RemoveColMutation,
RemoveColByRangeCommand,
RemoveRowByRangeCommand,
SetSelectionsOperation,
SetRangeValuesMutation,
CopySheetCommand,
InsertSheetMutation,
MoveRangeCommand,
MoveRangeMutation,
]
).forEach((command) => commandService.registerCommand(command));
return {
univer,
get,
sheet,
sheetsFilterService,
commandService,
};
}
describe('test controller of sheets filter', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let sheetsFilterService: SheetsFilterService;
let instanceService: IUniverInstanceService;
beforeEach(() => {
const testBed = createFilterControllerTestBed();
univer = testBed.univer;
get = testBed.get;
get(LocaleService).load({ zhCN: {} });
commandService = get(ICommandService);
sheetsFilterService = get(SheetsFilterService);
instanceService = get(IUniverInstanceService);
instanceService.setCurrentUnitForType('test');
instanceService.focusUnit('test');
});
afterEach(() => {
univer.dispose();
});
describe('test interceptor of move-rows-command', () => {
it('should be success when move row before filter header', async () => {
await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
selections: [
{
range: {
rangeType: RANGE_TYPE.ROW,
startRow: 0,
endRow: 0,
},
},
],
});
const result = await commandService.executeCommand(MoveRowsCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
fromRange: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 5 },
toRange: { startRow: 2, startColumn: 0, endRow: 2, endColumn: 5 },
});
expect(result).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 0, endRow: 5, endColumn: 5 });
const workbook = instanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
expect(workbook.getSheetBySheetId('sheet1')?.getCell(0, 0)?.v).toBe('A2');
expect(workbook.getSheetBySheetId('sheet1')?.getCell(1, 0)?.v).toBe('A1');
});
});
describe('test interceptor of move-cols-command', () => {
it('should be restored when undo move cols command', async () => {
await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
selections: [
{
range: {
rangeType: RANGE_TYPE.COLUMN,
startRow: 0,
endRow: 10,
startColumn: 0,
endColumn: 5,
},
},
],
});
const result = await commandService.executeCommand(MoveColsCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
fromRange: { startRow: 0, startColumn: 0, endRow: 10, endColumn: 5 },
toRange: { startRow: 0, startColumn: 10, endRow: 10, endColumn: 15 },
});
expect(result).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 4, endRow: 5, endColumn: 9 });
expect(await commandService.executeCommand(UndoCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 0, endRow: 5, endColumn: 5 });
});
});
describe('test extend range of filter', () => {
it('should not be extended when edit null', async () => {
expect(await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
6: {
0: {
f: null,
},
},
},
} as ISetRangeValuesMutationParams)).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 0, endRow: 5, endColumn: 5 });
});
it('should be extended when edit not null', async () => {
expect(await commandService.executeCommand(SetRangeValuesMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
cellValue: {
6: {
0: {
v: 'test',
},
},
},
} as ISetRangeValuesMutationParams)).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 0, endRow: 6, endColumn: 5 });
});
});
describe('test interceptor of remove-rows-command', () => {
it('filter range should move up if remove rows before filter header', async () => {
expect(await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
selections: [
{
range: {
rangeType: RANGE_TYPE.ROW,
startRow: 0,
endRow: 0,
},
},
],
})).toBeTruthy();
expect(await commandService.executeCommand(RemoveRowCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 0, endColumn: 5 },
})).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 2, startColumn: 0, endRow: 4, endColumn: 5 });
});
});
describe('test interceptor of remove-cols-command', () => {
it('filter range should move left if remove cols before filter header', async () => {
expect(await commandService.executeCommand(SetSheetsFilterCriteriaMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 1,
criteria: {
colId: 1,
filters: {
filters: ['test'],
},
},
})).toBeTruthy();
expect(await commandService.executeCommand(SetSelectionsOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
selections: [
{
range: {
rangeType: RANGE_TYPE.COLUMN,
startRow: 0,
endRow: 10,
startColumn: 0,
endColumn: 0,
},
},
],
})).toBeTruthy();
expect(await commandService.executeCommand(RemoveColCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 10, endColumn: 0 },
})).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 0, endRow: 5, endColumn: 4 });
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getAllFilterColumns()?.[0][0]).toBe(0);
expect(await commandService.executeCommand(UndoCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 0, endRow: 5, endColumn: 5 });
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getAllFilterColumns()?.[0][0]).toBe(1);
});
});
describe('test copy sheet', () => {
it('should copy filter', async () => {
const res = await commandService.executeCommand(CopySheetCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
});
expect(res).toBeTruthy();
const workbook = instanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const sheet2 = workbook.getSheets()[1];
const filterModel = (sheetsFilterService as SheetsFilterService).getFilterModel('test', sheet2.getSheetId());
expect(filterModel).toBeTruthy();
expect(filterModel?.getFilterColumn(0)?.getColumnData()).toStrictEqual({ colId: 0, filters: { blank: true } });
});
});
describe('test interceptor of move range', () => {
it('moved range intersect with filter range should expand filter', async () => {
const res = await commandService.executeCommand(MoveRangeCommand.id, {
fromRange: {
startRow: 10,
startColumn: 7,
endRow: 16,
endColumn: 7,
rangeType: 0,
},
toRange: {
startRow: 4,
startColumn: 3,
endRow: 10,
endColumn: 3,
rangeType: 0,
},
});
expect(res).toBeTruthy();
expect((sheetsFilterService as SheetsFilterService).getFilterModel('test', 'sheet1')!.getRange())
.toEqual({ startRow: 3, startColumn: 0, endRow: 10, endColumn: 5 });
});
});
});

View File

@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { IInsertColMutationParams, IMoveColumnsMutationParams, IRemoveColMutationParams } from '@univerjs/sheets';
import type { IUniverSheetsFilterConfig } from '../config/config';
import { Disposable, DisposableCollection, ICommandService, IConfigService, Inject, sequenceExecute } from '@univerjs/core';
import { InsertColMutation, MoveColsMutation, RemoveColMutation } from '@univerjs/sheets';
import { BehaviorSubject } from 'rxjs';
import { ReCalcSheetsFilterMutation, SetSheetsFilterCriteriaMutation } from '../commands/mutations/sheets-filter.mutation';
import { SHEETS_FILTER_PLUGIN_CONFIG_KEY } from '../config/config';
import { SheetsFilterController } from './sheets-filter.controller';
const sheetsFilterOnlyLocalMutationIds = [
SetSheetsFilterCriteriaMutation.id,
ReCalcSheetsFilterMutation.id,
];
const effectedByOnlyLocalMutationIds = [
InsertColMutation.id,
RemoveColMutation.id,
MoveColsMutation.id,
];
export class SheetsFilterSyncController extends Disposable {
private _d: DisposableCollection = new DisposableCollection();
private readonly _visible$ = new BehaviorSubject<boolean>(false);
readonly visible$ = this._visible$.asObservable();
get visible(): boolean { return this._visible$.getValue(); }
private readonly _enabled$ = new BehaviorSubject<boolean>(true);
readonly enabled$ = this._enabled$.asObservable();
get enabled(): boolean { return this._enabled$.getValue(); }
constructor(
@Inject(SheetsFilterController) private readonly _sheetsFilterController: SheetsFilterController,
@ICommandService protected readonly _commandService: ICommandService,
@IConfigService private readonly _configService: IConfigService
) {
super();
const config = this._configService.getConfig<IUniverSheetsFilterConfig>(SHEETS_FILTER_PLUGIN_CONFIG_KEY);
if (config?.enableSyncSwitch) {
this._visible$.next(true);
if (typeof config.enableSyncSwitch === 'object') {
this.setEnabled(config.enableSyncSwitch.defaultValue ?? true);
}
}
}
setEnabled(enabled: boolean) {
this._enabled$.next(enabled);
if (enabled) {
this._d.dispose();
} else {
this._initOnlyLocalListener();
}
}
private _initOnlyLocalListener() {
this._d.add(
this._commandService.beforeCommandExecuted((commandInfo, options) => {
if (sheetsFilterOnlyLocalMutationIds.includes(commandInfo.id)) {
if (!options) options = {};
options.onlyLocal = true;
}
})
);
this._d.add(
this._commandService.onCommandExecuted((commandInfo, options) => {
if (effectedByOnlyLocalMutationIds.includes(commandInfo.id) && options?.fromCollab) {
if (commandInfo.id === InsertColMutation.id) {
const { range, unitId, subUnitId } = commandInfo.params as IInsertColMutationParams;
const { redos } = this._sheetsFilterController.handleInsertColCommand(range, unitId, subUnitId);
sequenceExecute(redos, this._commandService, options);
} else if (commandInfo.id === RemoveColMutation.id) {
const { range, unitId, subUnitId } = commandInfo.params as IRemoveColMutationParams;
const { redos } = this._sheetsFilterController.handleRemoveColCommand(range, unitId, subUnitId);
sequenceExecute(redos, this._commandService, options);
} else if (commandInfo.id === MoveColsMutation.id) {
const { sourceRange: fromRange, targetRange: toRange, unitId, subUnitId } = commandInfo.params as IMoveColumnsMutationParams;
const { redos } = this._sheetsFilterController.handleMoveColsCommand({ fromRange, toRange }, unitId, subUnitId);
sequenceExecute(redos, this._commandService, options);
}
}
})
);
}
}

View File

@@ -0,0 +1,995 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
ICellData,
ICommandInfo,
IMutationInfo,
IObjectArrayPrimitiveType,
IRange,
Nullable,
Workbook,
} from '@univerjs/core';
import type {
EffectRefRangeParams,
IAddWorksheetMergeMutationParams,
ICopySheetCommandParams,
IInsertColCommandParams,
IInsertRowCommandParams,
IInsertRowMutationParams,
IMoveColsCommandParams,
IMoveRangeCommandParams,
IMoveRowsCommandParams,
IRemoveColMutationParams,
IRemoveRowsMutationParams,
IRemoveSheetCommandParams,
ISetRangeValuesMutationParams,
ISetWorksheetActiveOperationParams,
ISheetCommandSharedParams,
} from '@univerjs/sheets';
import type {
ISetSheetsFilterCriteriaMutationParams,
ISetSheetsFilterRangeMutationParams,
} from '../commands/mutations/sheets-filter.mutation';
import type { FilterColumn } from '../models/filter-model';
import {
Disposable,
DisposableCollection,
ICommandService,
Inject,
IUniverInstanceService,
moveMatrixArray,
Optional,
Rectangle,
UniverInstanceType,
} from '@univerjs/core';
import { DataSyncPrimaryController } from '@univerjs/rpc';
import {
CopySheetCommand,
EffectRefRangId,
expandToContinuousRange,
getSheetCommandTarget,
InsertColCommand,
InsertRowCommand,
InsertRowMutation,
INTERCEPTOR_POINT,
MoveRangeCommand,
MoveRowsCommand,
RefRangeService,
RemoveColCommand,
RemoveRowCommand,
RemoveRowMutation,
RemoveSheetCommand,
SetRangeValuesMutation,
SetWorksheetActiveOperation,
SheetInterceptorService,
ZebraCrossingCacheController,
} from '@univerjs/sheets';
import {
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
RemoveSheetFilterCommand,
SetSheetFilterRangeCommand,
SetSheetsFilterCriteriaCommand,
} from '../commands/commands/sheets-filter.command';
import {
ReCalcSheetsFilterMutation,
RemoveSheetsFilterMutation,
SetSheetsFilterCriteriaMutation,
SetSheetsFilterRangeMutation,
} from '../commands/mutations/sheets-filter.mutation';
import { SheetsFilterService } from '../services/sheet-filter.service';
import { mergeSetFilterCriteria } from '../utils';
export class SheetsFilterController extends Disposable {
private _disposableCollection: DisposableCollection = new DisposableCollection();
constructor(
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(SheetsFilterService) private readonly _sheetsFilterService: SheetsFilterService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(RefRangeService) private readonly _refRangeService: RefRangeService,
@Optional(DataSyncPrimaryController) private readonly _dataSyncPrimaryController: DataSyncPrimaryController,
@Inject(ZebraCrossingCacheController) private readonly _zebraCrossingCacheController: ZebraCrossingCacheController
) {
super();
this._initCommands();
this._initRowFilteredInterceptor();
this._initInterceptors();
this._commandExecutedListener();
this._initErrorHandling();
this._initZebraCrossingCacheListener();
}
private _initZebraCrossingCacheListener() {
this.disposeWithMe(
this._sheetsFilterService.activeFilterModel$.subscribe((filterModel) => {
if (!filterModel) return;
this.disposeWithMe(
filterModel.filteredOutRows$.subscribe(() => {
this._zebraCrossingCacheController.updateZebraCrossingCache(filterModel.unitId, filterModel.subUnitId);
})
);
})
);
}
private _initCommands(): void {
[
SetSheetFilterRangeCommand,
RemoveSheetFilterCommand,
SetSheetsFilterCriteriaCommand,
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
].forEach((command) => {
this.disposeWithMe(this._commandService.registerCommand(command));
});
[
SetSheetsFilterCriteriaMutation,
SetSheetsFilterRangeMutation,
ReCalcSheetsFilterMutation,
RemoveSheetsFilterMutation,
].forEach((command) => {
this.disposeWithMe(this._commandService.registerCommand(command));
this._dataSyncPrimaryController?.registerSyncingMutations(command);
});
}
private _initInterceptors(): void {
this.disposeWithMe(this._sheetInterceptorService.interceptCommand({
getMutations: (command) => this._getUpdateFilter(command),
}));
this.disposeWithMe(this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetWorksheetActiveOperation.id) {
const params = commandInfo.params as ISetWorksheetActiveOperationParams;
const sheetId = params.subUnitId;
const unitId = params.unitId;
if (!sheetId || !unitId) {
return;
}
this._registerRefRange(unitId, sheetId);
}
if (commandInfo.id === SetSheetsFilterRangeMutation.id) {
const params = commandInfo.params as IAddWorksheetMergeMutationParams;
const sheetId = params.subUnitId;
const unitId = params.unitId;
if (!sheetId || !unitId) {
return;
}
this._registerRefRange(params.unitId, params.subUnitId);
}
}));
this.disposeWithMe(this._sheetsFilterService.loadedUnitId$.subscribe((unitId) => {
if (unitId) {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const sheet = workbook?.getActiveSheet();
if (sheet) {
this._registerRefRange(unitId, sheet.getSheetId());
}
}
}));
}
private _registerRefRange(unitId: string, subUnitId: string): void {
this._disposableCollection.dispose();
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
const workSheet = workbook?.getSheetBySheetId(subUnitId);
if (!workbook || !workSheet) return;
const range = this._sheetsFilterService.getFilterModel(unitId, subUnitId)?.getRange();
const handler = (config: EffectRefRangeParams) => {
switch (config.id) {
case InsertRowCommand.id: {
const params = config.params as IInsertRowCommandParams;
const _unitId = params.unitId || unitId;
const _subUnitId = params.subUnitId || subUnitId;
return this._handleInsertRowCommand(params, _unitId, _subUnitId);
}
case InsertColCommand.id: {
const params = config.params as IInsertColCommandParams;
const _unitId = params.unitId || unitId;
const _subUnitId = params.subUnitId || subUnitId;
return this.handleInsertColCommand(params.range, _unitId, _subUnitId);
}
case RemoveColCommand.id: {
const params = config.params as IRemoveColMutationParams;
return this.handleRemoveColCommand(params.range, unitId, subUnitId);
}
case RemoveRowCommand.id: {
const params = config.params as IRemoveRowsMutationParams;
return this._handleRemoveRowCommand(params, unitId, subUnitId);
}
case EffectRefRangId.MoveColsCommandId: {
const params = config.params as IMoveColsCommandParams;
return this.handleMoveColsCommand({
fromRange: params.fromRange,
toRange: params.toRange,
}, unitId, subUnitId);
}
case EffectRefRangId.MoveRowsCommandId: {
const params = config.params as IMoveRowsCommandParams;
return this._handleMoveRowsCommand(params, unitId, subUnitId);
}
case MoveRangeCommand.id: {
const params = config.params as IMoveRangeCommandParams;
return this._handleMoveRangeCommand(params, unitId, subUnitId);
}
}
return { redos: [], undos: [] };
};
if (range) {
this._disposableCollection.add(this._refRangeService.registerRefRange(range, handler, unitId, subUnitId));
}
}
private _getUpdateFilter(command: ICommandInfo) {
const { id } = command;
switch (id) {
case RemoveSheetCommand.id: {
const params = command.params as ISheetCommandSharedParams;
return this._handleRemoveSheetCommand(params, params.unitId, params.subUnitId);
}
case CopySheetCommand.id: {
const params = command.params as ICopySheetCommandParams & { targetSubUnitId: string };
const { targetSubUnitId, unitId, subUnitId } = params;
if (!unitId || !subUnitId || !targetSubUnitId) {
return this._handleNull();
}
return this._handleCopySheetCommand(unitId, subUnitId, targetSubUnitId);
}
}
return {
redos: [],
undos: [],
};
}
handleInsertColCommand(range: IRange, unitId: string, subUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
const filterRange = filterModel?.getRange() ?? null;
if (!filterModel || !filterRange) {
return this._handleNull();
}
const { startColumn, endColumn } = filterRange;
const { startColumn: insertStartColumn, endColumn: insertEndColumn } = range;
const count = insertEndColumn - insertStartColumn + 1;
if (insertEndColumn > endColumn) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const anchor = insertStartColumn;
const setFilterRangeMutationParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: {
...filterRange,
startColumn: insertStartColumn <= startColumn ? startColumn + count : startColumn,
endColumn: endColumn + count,
},
};
const undoSetFilterRangeMutationParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: filterRange,
};
redos.push({ id: SetSheetsFilterRangeMutation.id, params: setFilterRangeMutationParams });
undos.push({ id: SetSheetsFilterRangeMutation.id, params: undoSetFilterRangeMutationParams });
const filterColumn = filterModel.getAllFilterColumns();
const effected = filterColumn.filter((column) => column[0] >= anchor);
if (effected.length !== 0) {
const { newRange, oldRange } = this._moveCriteria(unitId, subUnitId, effected, count);
redos.push(...oldRange.redos, ...newRange.redos);
undos.push(...newRange.undos, ...oldRange.undos);
}
return { redos: mergeSetFilterCriteria(redos), undos: mergeSetFilterCriteria(undos) };
}
private _handleInsertRowCommand(config: IInsertRowCommandParams, unitId: string, subUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
const filterRange = filterModel?.getRange() ?? null;
if (!filterModel || !filterRange) {
return this._handleNull();
}
const { startRow, endRow } = filterRange;
const { startRow: insertStartRow, endRow: insertEndRow } = config.range;
const rowCount = insertEndRow - insertStartRow + 1;
if (insertEndRow > endRow) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const setFilterRangeParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: {
...filterRange,
startRow: insertStartRow <= startRow ? startRow + rowCount : startRow,
endRow: endRow + rowCount,
},
};
const undoSetFilterRangeMutationParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: filterRange,
};
redos.push({ id: SetSheetsFilterRangeMutation.id, params: setFilterRangeParams });
undos.push({ id: SetSheetsFilterRangeMutation.id, params: undoSetFilterRangeMutationParams });
return {
redos: mergeSetFilterCriteria(redos),
undos: mergeSetFilterCriteria(undos),
};
}
handleRemoveColCommand(range: IRange, unitId: string, subUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
const filterRange = filterModel?.getRange() ?? null;
if (!filterModel || !filterRange) {
return this._handleNull();
}
const { startColumn, endColumn } = filterRange;
const { startColumn: removeStartColumn, endColumn: removeEndColumn } = range;
if (removeStartColumn > endColumn) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const rangeRemoveCount =
removeEndColumn < startColumn
? 0 :
Math.min(removeEndColumn, endColumn) - Math.max(removeStartColumn, startColumn) + 1;
const removeCount = removeEndColumn - removeStartColumn + 1;
const filterColumn = filterModel.getAllFilterColumns();
filterColumn.forEach((column) => {
const [col, filter] = column;
if (col <= removeEndColumn && col >= removeStartColumn) {
redos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId, col, criteria: null } });
undos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId, col, criteria: { ...filter.serialize(), colId: col } } });
}
});
const shifted = filterColumn.filter((column) => {
const [col, _] = column;
return col > removeEndColumn;
});
let newRangeCriteria: { undos: IMutationInfo[]; redos: IMutationInfo[] } = { undos: [], redos: [] };
if (shifted.length > 0) {
const { oldRange, newRange } = this._moveCriteria(unitId, subUnitId, shifted, -removeCount);
newRangeCriteria = newRange;
redos.push(...oldRange.redos);
undos.unshift(...oldRange.undos);
}
if (rangeRemoveCount === endColumn - startColumn + 1) {
const removeFilterRangeMutationParams: ISheetCommandSharedParams = {
unitId,
subUnitId,
};
redos.push({ id: RemoveSheetsFilterMutation.id, params: removeFilterRangeMutationParams });
undos.unshift({ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId } });
} else {
const newStartColumn = startColumn <= removeStartColumn
? startColumn :
(rangeRemoveCount === 0 ? startColumn - removeCount : removeStartColumn);
const newEndColumn = startColumn <= removeStartColumn ? endColumn - rangeRemoveCount : endColumn - removeCount;
const setFilterRangeMutationParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: { ...filterRange, startColumn: newStartColumn, endColumn: newEndColumn },
};
redos.push({ id: SetSheetsFilterRangeMutation.id, params: setFilterRangeMutationParams });
undos.unshift({ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId } });
redos.push(...newRangeCriteria.redos);
undos.unshift(...newRangeCriteria.undos);
}
return {
undos,
redos,
};
}
private _handleRemoveRowCommand(config: IRemoveRowsMutationParams, unitId: string, subUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) {
return this._handleNull();
}
const filterRange = filterModel.getRange();
const { startRow, endRow } = filterRange;
const { startRow: removeStartRow, endRow: removeEndRow } = config.range;
if (removeStartRow > endRow) {
return this._handleNull();
}
if (removeEndRow < startRow) {
return {
undos: [{ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId } }],
redos: [{
id: SetSheetsFilterRangeMutation.id,
params: {
range: {
...filterRange,
startRow: startRow - (removeEndRow - removeStartRow + 1),
endRow: endRow - (removeEndRow - removeStartRow + 1),
},
unitId,
subUnitId,
},
}],
};
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const filterColumn = filterModel.getAllFilterColumns();
const filterHeaderIsRemoved = startRow <= removeEndRow && startRow >= removeStartRow;
undos.push({ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId } });
const count = Math.min(removeEndRow, endRow) - Math.max(removeStartRow, startRow) + 1;
if (count === endRow - startRow + 1 || filterHeaderIsRemoved) {
const removeFilterRangeMutationParams: ISheetCommandSharedParams = {
unitId,
subUnitId,
};
redos.push({ id: RemoveSheetsFilterMutation.id, params: removeFilterRangeMutationParams });
filterColumn.forEach((column) => {
const [offset, filter] = column;
const setCriteriaMutationParams: ISetSheetsFilterCriteriaMutationParams = {
unitId,
subUnitId,
col: offset,
criteria: { ...filter.serialize(), colId: offset },
};
undos.push({ id: SetSheetsFilterCriteriaMutation.id, params: setCriteriaMutationParams });
});
} else {
const worksheet = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET)?.getSheetBySheetId(subUnitId);
if (!worksheet) {
return this._handleNull();
}
const hiddenRowCount = this._getFilteredRowCount(worksheet, removeStartRow, removeEndRow);
const afterStartRow = Math.min(startRow, removeStartRow);
const afterEndRow = afterStartRow + (endRow - startRow) - count + hiddenRowCount;
const setFilterRangeMutationParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: {
...filterRange,
startRow: afterStartRow,
endRow: afterEndRow,
},
};
redos.push({ id: SetSheetsFilterRangeMutation.id, params: setFilterRangeMutationParams });
}
return {
undos: mergeSetFilterCriteria(undos),
redos: mergeSetFilterCriteria(redos),
};
}
private _getFilteredRowCount(worksheet: { getRowFiltered(row: number): boolean }, startRow: number, endRow: number): number {
let count = 0;
for (let row = startRow; row <= endRow; row++) {
if (worksheet.getRowFiltered(row)) {
count++;
}
}
return count;
}
// eslint-disable-next-line max-lines-per-function
handleMoveColsCommand({ fromRange, toRange }: { fromRange: IRange; toRange: IRange }, unitId: string, subUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
const filterRange = filterModel?.getRange() ?? null;
if (!filterModel || !filterRange) {
return this._handleNull();
}
const { startColumn, endColumn } = filterRange;
if ((fromRange.endColumn < startColumn && toRange.startColumn <= startColumn) || (
fromRange.startColumn > endColumn && toRange.endColumn > endColumn
)) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const filterCol: IObjectArrayPrimitiveType<{ colIndex: number; filter: Nullable<FilterColumn> }> = {};
for (let col = startColumn; col <= endColumn; col++) {
filterCol[col] = {
colIndex: col,
filter: filterModel.getFilterColumn(col),
};
}
moveMatrixArray(fromRange.startColumn, fromRange.endColumn - fromRange.startColumn + 1, toRange.startColumn, filterCol);
let startBorder = filterRange.startColumn;
let endBorder = filterRange.endColumn;
// border will change if first col or last col moves.
if (startColumn >= fromRange.startColumn && startColumn <= fromRange.endColumn
&& toRange.startColumn > fromRange.startColumn
&& fromRange.endColumn < endColumn) {
startBorder = fromRange.endColumn + 1;
}
if (endColumn >= fromRange.startColumn && endColumn <= fromRange.endColumn
&& toRange.startColumn < fromRange.startColumn
&& fromRange.startColumn > startColumn) {
endBorder = fromRange.startColumn - 1;
}
const numberCols = Object.keys(filterCol).map((col) => Number(col)) as number[];
// find the start col & end col of new filter range by border.
const newEnd = numberCols.find((col) => filterCol[col].colIndex === endBorder) as number;
const newStart = numberCols.find((col) => filterCol[col].colIndex === startBorder) as number;
numberCols.forEach((col) => {
const { colIndex: oldColIndex, filter } = filterCol[col];
const newColIndex = col;
if (filter) {
if (newColIndex >= newStart && newColIndex <= newEnd) {
const setCriteriaMutationParams: ISetSheetsFilterCriteriaMutationParams = {
unitId,
subUnitId,
col: newColIndex,
criteria: { ...filter.serialize(), colId: newColIndex },
};
const undoSetCriteriaMutationParams: ISetSheetsFilterCriteriaMutationParams = {
unitId,
subUnitId,
col: newColIndex,
criteria: filterModel.getFilterColumn(newColIndex) ?
{ ...filterModel.getFilterColumn(newColIndex)?.serialize(), colId: newColIndex }
: null,
};
redos.push({ id: SetSheetsFilterCriteriaMutation.id, params: setCriteriaMutationParams });
undos.push({ id: SetSheetsFilterCriteriaMutation.id, params: undoSetCriteriaMutationParams });
}
if (!filterCol[oldColIndex]?.filter) {
const setCriteriaMutationParams: ISetSheetsFilterCriteriaMutationParams = {
unitId,
subUnitId,
col: oldColIndex,
criteria: null,
};
redos.push({ id: SetSheetsFilterCriteriaMutation.id, params: setCriteriaMutationParams });
undos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId, col: oldColIndex, criteria: { ...filter.serialize(), colId: oldColIndex } } });
}
}
});
if (startColumn !== newStart || endColumn !== newEnd) {
const setFilterRangeMutationParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: {
...filterRange,
startColumn: newStart,
endColumn: newEnd,
},
};
redos.unshift({ id: SetSheetsFilterRangeMutation.id, params: setFilterRangeMutationParams });
undos.unshift({ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId } });
}
return {
undos,
redos,
};
}
private _handleMoveRowsCommand(config: IMoveRowsCommandParams, unitId: string, subUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
const filterRange = filterModel?.getRange() ?? null;
if (!filterModel || !filterRange) {
return this._handleNull();
}
const { startRow, endRow } = filterRange;
const { fromRange, toRange } = config;
if ((fromRange.endRow < startRow && toRange.startRow <= startRow) || (
fromRange.startRow > endRow && toRange.endRow > endRow
)) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const filterRow: IObjectArrayPrimitiveType<{ oldIndex: number }> = {};
for (let row = startRow; row <= endRow; row++) {
filterRow[row] = {
oldIndex: row,
};
}
const startBorder = startRow;
let endBorder = endRow;
// only need to deal with endBorder, startRow will not be moved.
if (endRow >= fromRange.startRow && endRow <= fromRange.endRow
&& toRange.startRow < fromRange.startRow
&& fromRange.startRow > startRow) {
endBorder = fromRange.startRow - 1;
}
moveMatrixArray(fromRange.startRow, fromRange.endRow - fromRange.startRow + 1, toRange.startRow, filterRow);
const numberRows = Object.keys(filterRow).map((row) => Number(row));
const newEnd = numberRows.find((row) => filterRow[row].oldIndex === endBorder) as number;
const newStart = numberRows.find((row) => filterRow[row].oldIndex === startBorder) as number;
if (startRow !== newStart || endRow !== newEnd) {
const setFilterRangeMutationParams: ISetSheetsFilterRangeMutationParams = {
unitId,
subUnitId,
range: {
...filterRange,
startRow: newStart,
endRow: newEnd,
},
};
redos.push({ id: SetSheetsFilterRangeMutation.id, params: setFilterRangeMutationParams }, { id: ReCalcSheetsFilterMutation.id, params: { unitId, subUnitId } });
undos.push({ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId } }, { id: ReCalcSheetsFilterMutation.id, params: { unitId, subUnitId } });
}
return {
redos,
undos,
};
}
private _handleMoveRangeCommand(config: IMoveRangeCommandParams, unitId: string, subUnitId: string) {
const { fromRange, toRange } = config;
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) {
return this._handleNull();
}
const filterRange = filterModel.getRange();
if (!filterRange) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
if (Rectangle.contains(fromRange, filterRange)) {
const rowOffset = filterRange.startRow - fromRange.startRow;
const colOffset = filterRange.startColumn - fromRange.startColumn;
const newFilterRange = {
startRow: toRange.startRow + rowOffset,
startColumn: toRange.startColumn + colOffset,
endRow: toRange.startRow + rowOffset + (filterRange.endRow - filterRange.startRow),
endColumn: toRange.startColumn + colOffset + (filterRange.endColumn - filterRange.startColumn),
};
const removeFilter = {
id: RemoveSheetsFilterMutation.id,
params: {
unitId,
subUnitId,
},
};
const setNewFilterRange = { id: SetSheetsFilterRangeMutation.id, params: { unitId, subUnitId, range: newFilterRange } as ISetSheetsFilterRangeMutationParams };
const setOldFilterRange = { id: SetSheetsFilterRangeMutation.id, params: { unitId, subUnitId, range: filterRange } as ISetSheetsFilterRangeMutationParams };
redos.push(removeFilter, setNewFilterRange);
undos.push(removeFilter, setOldFilterRange);
const filterColumn = filterModel.getAllFilterColumns();
const moveColDelta = toRange.startColumn - fromRange.startColumn;
filterColumn.forEach((column) => {
const [col, criteria] = column;
if (criteria) {
redos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId, col: col + moveColDelta, criteria: { ...criteria.serialize(), colId: col + moveColDelta } } });
undos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId, col, criteria: { ...criteria.serialize(), colId: col } } });
}
});
// redos.push({ id: ReCalcSheetsFilterMutation.id, params: { unitId, subUnitId } });
// undos.push({ id: ReCalcSheetsFilterMutation.id, params: { unitId, subUnitId } });
} else if (Rectangle.intersects(toRange, filterRange)) {
const newFilterRange: IRange = {
...filterRange,
endRow: Math.max(filterRange.endRow, toRange.endRow),
};
redos.push({ id: SetSheetsFilterRangeMutation.id, params: { unitId, subUnitId, range: newFilterRange } });
undos.push({ id: SetSheetsFilterRangeMutation.id, params: { unitId, subUnitId, range: filterRange } });
}
return {
redos,
undos,
};
}
private _handleRemoveSheetCommand(config: IRemoveSheetCommandParams, unitId: string, subUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) {
return this._handleNull();
}
const filterRange = filterModel.getRange();
if (!filterRange) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const filterCols = filterModel.getAllFilterColumns();
filterCols.forEach(([col, filter]) => {
undos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId, col, criteria: { ...filter.serialize(), colId: col } } });
});
redos.push({ id: RemoveSheetsFilterMutation.id, params: { unitId, subUnitId, range: filterRange } });
undos.unshift({ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId } });
return {
undos,
redos,
};
}
private _handleCopySheetCommand(unitId: string, subUnitId: string, targetSubUnitId: string) {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) {
return this._handleNull();
}
const filterRange = filterModel.getRange();
if (!filterRange) {
return this._handleNull();
}
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
const preUndos: IMutationInfo[] = [];
const preRedos: IMutationInfo[] = [];
const filterCols = filterModel.getAllFilterColumns();
filterCols.forEach(([col, filter]) => {
redos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId: targetSubUnitId, col, criteria: { ...filter.serialize(), colId: col } } });
preUndos.push({ id: SetSheetsFilterCriteriaMutation.id, params: { unitId, subUnitId: targetSubUnitId, col, criteria: null } });
});
preUndos.push({ id: RemoveSheetsFilterMutation.id, params: { unitId, subUnitId: targetSubUnitId, range: filterRange } });
redos.unshift({ id: SetSheetsFilterRangeMutation.id, params: { range: filterRange, unitId, subUnitId: targetSubUnitId } });
return {
undos,
redos,
preUndos,
preRedos,
};
}
private _handleNull() {
return { redos: [], undos: [] };
}
private _initRowFilteredInterceptor(): void {
this.disposeWithMe(this._sheetInterceptorService.intercept(INTERCEPTOR_POINT.ROW_FILTERED, {
// sheet-interceptor.service.ts
handler: (filtered, rowLocation) => {
if (filtered) return true;
return this._sheetsFilterService.getFilterModel(
rowLocation.unitId,
rowLocation.subUnitId
)?.isRowFiltered(rowLocation.row) ?? false;
},
}));
}
private _moveCriteria(unitId: string, subUnitId: string, target: [number, FilterColumn][], step: number) {
const defaultSetCriteriaMutationParams: ISetSheetsFilterCriteriaMutationParams = {
unitId,
subUnitId,
criteria: null,
col: -1,
};
const oldUndos: IMutationInfo[] = [];
const oldRedos: IMutationInfo[] = [];
const newUndos: IMutationInfo[] = [];
const newRedos: IMutationInfo[] = [];
target.forEach((column) => {
const [offset, filter] = column;
oldRedos.push({
id: SetSheetsFilterCriteriaMutation.id,
params: {
...defaultSetCriteriaMutationParams,
col: offset,
},
});
oldUndos.push({
id: SetSheetsFilterCriteriaMutation.id,
params: {
...defaultSetCriteriaMutationParams,
col: offset,
criteria: { ...filter.serialize(), colId: offset },
},
});
});
target.forEach((column) => {
const [offset, filter] = column;
newRedos.push({
id: SetSheetsFilterCriteriaMutation.id,
params: {
...defaultSetCriteriaMutationParams,
col: offset + step,
criteria: { ...filter.serialize(), colId: offset + step },
},
});
newUndos.push({
id: SetSheetsFilterCriteriaMutation.id,
params: {
...defaultSetCriteriaMutationParams,
col: offset + step,
criteria: null,
},
});
});
return {
newRange: {
redos: newRedos,
undos: newUndos,
},
oldRange: {
redos: oldRedos,
undos: oldUndos,
},
};
}
private _commandExecutedListener(): void {
this.disposeWithMe(this._commandService.onCommandExecuted((command: ICommandInfo, options) => {
const { unitId, subUnitId } = command.params as unknown as ISheetCommandSharedParams || {};
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) return;
const filteredOutRows = Array.from(filterModel.filteredOutRows).sort((a, b) => a - b);
const newFilteredOutRows: number[] = [];
let changed = false;
if (command.id === RemoveRowMutation.id) {
const { startRow, endRow } = (command.params as IRemoveRowsMutationParams).range;
const filterOutInRemove = filteredOutRows.filter((row) => row >= startRow && row <= endRow);
filteredOutRows.forEach((row) => {
if (row < startRow) {
newFilteredOutRows.push(row);
} else {
changed = true;
if (row <= endRow) {
const newIndex = Math.max(startRow, newFilteredOutRows.length ? newFilteredOutRows[newFilteredOutRows.length - 1] + 1 : startRow);
newFilteredOutRows.push(newIndex);
} else {
newFilteredOutRows.push(row - (endRow - startRow + 1 - filterOutInRemove.length));
}
}
});
}
if (command.id === InsertRowMutation.id) {
const { startRow, endRow } = (command.params as IInsertRowMutationParams).range;
filteredOutRows.forEach((row) => {
if (row >= startRow) {
changed = true;
newFilteredOutRows.push(row + (endRow - startRow + 1));
} else {
newFilteredOutRows.push(row);
}
});
}
if (changed) {
filterModel.filteredOutRows = new Set(newFilteredOutRows);
}
// extend filter range when set range values
if (command.id === SetRangeValuesMutation.id && !options?.onlyLocal) {
const extendRegion = this._getExtendRegion(unitId, subUnitId);
if (extendRegion) {
const cellValue = (command.params as ISetRangeValuesMutationParams).cellValue;
if (cellValue) {
for (let col = extendRegion.startColumn; col <= extendRegion.endColumn; col++) {
const cell = cellValue?.[extendRegion.startRow]?.[col];
if (cell && this._cellHasValue(cell)) {
const worksheet = (this._univerInstanceService.getUnit(unitId) as Workbook)?.getSheetBySheetId(subUnitId);
if (worksheet) {
const extendedRange = expandToContinuousRange(extendRegion, { down: true }, worksheet);
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId)!;
const filterRange = filterModel.getRange();
filterModel.setRange({
...filterRange,
endRow: extendedRange.endRow,
});
this._registerRefRange(unitId, subUnitId);
}
}
}
}
}
}
}));
}
private _getExtendRegion(unitId: string, subUnitId: string): Nullable<IRange> {
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) {
return null;
}
const worksheet = (this._univerInstanceService.getUnit(unitId) as Workbook)?.getSheetBySheetId(subUnitId);
if (!worksheet) {
return null;
}
const filterRange = filterModel.getRange();
if (!filterRange) {
return null;
}
const maxRowIndex = worksheet.getRowCount() - 1;
const rowManager = worksheet.getRowManager();
for (let row = filterRange.endRow + 1; row <= maxRowIndex; row++) {
if (rowManager.getRowRawVisible(row)) {
return {
startRow: row,
endRow: row,
startColumn: filterRange.startColumn,
endColumn: filterRange.endColumn,
};
}
}
return null;
}
private _initErrorHandling() {
this.disposeWithMe(this._commandService.beforeCommandExecuted((command) => {
const params = command.params as IMoveRowsCommandParams;
const target = getSheetCommandTarget(this._univerInstanceService, params);
if (!target) return;
const { subUnitId, unitId } = target;
const filterModel = this._sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) return;
const filterRange = filterModel.getRange();
if (command.id === MoveRowsCommand.id && params.fromRange.startRow <= filterRange.startRow && params.fromRange.endRow < filterRange.endRow && params.fromRange.endRow >= filterRange.startRow) {
this._sheetsFilterService.setFilterErrorMsg('sheets-filter.msg.filter-header-forbidden');
throw new Error('[SheetsFilterController]: Cannot move header row of filter');
}
}));
}
private _cellHasValue(cell: ICellData): boolean {
const values = Object.values(cell);
if (values.length === 0 || values.every((v) => v == null)) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,126 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector, IWorkbookData, UnitModel } from '@univerjs/core';
import {
ILogService,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
set,
ThemeService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import enUS from '@univerjs/sheets/locale/en-US';
import zhCN from '@univerjs/sheets/locale/zh-CN';
import '@univerjs/sheets/facade';
import '@univerjs/sheets-filter/facade';
function getTestWorkbookDataDemo(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
3: {
f: '=SUM(A1)',
si: '3e4r5t',
},
4: {
v: 123,
t: 2,
},
},
1: {
3: {
f: '=SUM(A2)',
si: 'OSPtzm',
},
},
2: {
3: {
si: 'OSPtzm',
},
},
3: {
3: {
si: 'OSPtzm',
},
},
},
rowCount: 100,
columnCount: 100,
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
}
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: UnitModel<IWorkbookData>;
univerAPI: FUniver;
injector: Injector;
}
export function createFacadeTestBed(workbookData?: IWorkbookData): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
// load i18n
injector.get(LocaleService).load({ zhCN, enUS });
// load theme service
const themeService = injector.get(ThemeService);
const theme = themeService.getCurrentTheme();
const newTheme = set(theme, 'gray.1000', '#35322b');
themeService.setTheme(newTheme);
// register builtin plugins
// note that UI plugins are not registered here, because the unit test environment does not have a UI
univer.registerPlugin(UniverSheetsFilterPlugin);
const sheet = univer.createUnit<IWorkbookData, UnitModel<IWorkbookData>>(UniverInstanceType.UNIVER_SHEET, workbookData || getTestWorkbookDataDemo());
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
// set log level
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT); // NOTE: change this to `LogLevel.VERBOSE` to debug tests via logs
// init data validation
const univerAPI = FUniver.newAPI(injector);
return {
univer,
get: injector.get.bind(injector),
sheet,
univerAPI,
injector,
};
}

View File

@@ -0,0 +1,119 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector, IWorkbookData, UnitModel } from '@univerjs/core';
import {
ILogService,
IUniverInstanceService,
LocaleService,
LocaleType,
LogLevel,
set,
ThemeService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import enUS from '@univerjs/sheets/locale/en-US';
import zhCN from '@univerjs/sheets/locale/zh-CN';
import '@univerjs/sheets/facade';
import '@univerjs/sheets-filter/facade';
function getTestWorkbookDataDemo(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
0: {
v: 1,
},
},
1: {
0: {
v: 2,
},
},
2: {
0: {
v: 3,
},
},
3: {
0: {
v: 4,
},
},
},
rowCount: 100,
columnCount: 100,
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
}
export interface ITestBed {
univer: Univer;
get: Injector['get'];
sheet: UnitModel<IWorkbookData>;
univerAPI: FUniver;
injector: Injector;
}
export function createWorksheetTestBed(workbookData?: IWorkbookData): ITestBed {
const univer = new Univer();
const injector = univer.__getInjector();
// load i18n
injector.get(LocaleService).load({ zhCN, enUS });
// load theme service
const themeService = injector.get(ThemeService);
const theme = themeService.getCurrentTheme();
const newTheme = set(theme, 'gray.1000', '#35322b');
themeService.setTheme(newTheme);
// register builtin plugins
// note that UI plugins are not registered here, because the unit test environment does not have a UI
univer.registerPlugin(UniverSheetsFilterPlugin);
const sheet = univer.createUnit<IWorkbookData, UnitModel<IWorkbookData>>(UniverInstanceType.UNIVER_SHEET, workbookData || getTestWorkbookDataDemo());
const univerInstanceService = injector.get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
// set log level
const logService = injector.get(ILogService);
logService.setLogLevel(LogLevel.SILENT); // NOTE: change this to `LogLevel.VERBOSE` to debug tests via logs
const univerAPI = FUniver.newAPI(injector);
return {
univer,
get: injector.get.bind(injector),
sheet,
univerAPI,
injector,
};
}

View File

@@ -0,0 +1,98 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IRange } from '@univerjs/core';
import type { FUniver } from '@univerjs/core/facade';
import { beforeEach, describe, expect, it } from 'vitest';
import { createFacadeTestBed } from './create-test-bed';
describe('Test FRange', () => {
let univerAPI: FUniver;
beforeEach(() => {
const testBed = createFacadeTestBed();
univerAPI = testBed.univerAPI;
});
describe('FFilter', () => {
it('create, modify and clear filters with Facade API without UI plugin', async () => {
const activeSheet = univerAPI.getActiveWorkbook()!.getActiveSheet();
const filterEvents: string[] = [];
const beforeFilterDisposable = univerAPI.addEvent(univerAPI.Event.SheetBeforeRangeFilter, (params) => {
filterEvents.push(`before:${params.col}`);
});
const filteredDisposable = univerAPI.addEvent(univerAPI.Event.SheetRangeFiltered, (params) => {
filterEvents.push(`after:${params.col}`);
});
const beforeClearDisposable = univerAPI.addEvent(univerAPI.Event.SheetBeforeRangeFilterClear, (params) => {
filterEvents.push(`before-clear:${params.worksheet.getSheetId()}`);
});
const clearedDisposable = univerAPI.addEvent(univerAPI.Event.SheetRangeFilterCleared, (params) => {
filterEvents.push(`after-clear:${params.worksheet.getSheetId()}`);
});
expect(activeSheet.getFilter()).toBeNull();
expect(activeSheet.getRange(0, 0, 1, 1).getFilter()).toBeNull();
const range = activeSheet.getRange(0, 0, 10, 10);
const filter = (await range.createFilter())!;
expect(filter).not.toBeNull();
expect(activeSheet.getFilter()).not.toBeNull();
expect(activeSheet.getRange(0, 0, 1, 1).getFilter()).not.toBeNull();
expect(filter.getRange().getRange()).toStrictEqual({
unitId: univerAPI.getActiveWorkbook()?.getId(),
sheetId: activeSheet.getSheetId(),
startColumn: 0,
startRow: 0,
endColumn: 9,
endRow: 9,
} as IRange);
expect(await filter.setColumnFilterCriteria(1, { colId: 1, filters: { blank: true } })).toBeTruthy();
expect(filter.getColumnFilterCriteria(1)).toEqual({ colId: 1, filters: { blank: true } });
expect(await filter.setColumnFilterCriteria(2, { colId: 2, filters: { filters: ['a'] } })).toBeTruthy();
expect(filter.getColumnFilterCriteria(2)).toEqual({ colId: 2, filters: { filters: ['a'] } });
expect(await filter.removeColumnFilterCriteria(1)).toBeTruthy();
expect(filter.getColumnFilterCriteria(1)).toBeFalsy();
expect(await filter.removeFilterCriteria()).toBeTruthy();
expect(filter.getColumnFilterCriteria(2)).toBeFalsy();
expect(filterEvents).toEqual([
'before:1',
'after:1',
'before:2',
'after:2',
'before:1',
'after:1',
'before-clear:sheet1',
'after-clear:sheet1',
]);
expect(await filter.remove()).toBeTruthy();
expect(activeSheet.getFilter()).toBeNull();
beforeFilterDisposable.dispose();
filteredDisposable.dispose();
beforeClearDisposable.dispose();
clearedDisposable.dispose();
});
});
});

View File

@@ -0,0 +1,34 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { FUniver } from '@univerjs/core/facade';
import { beforeEach, describe, expect, it } from 'vitest';
import { createWorksheetTestBed } from './create-worksheet-test-bed';
describe('Test FWorksheet', () => {
let univerAPI: FUniver;
beforeEach(() => {
const testBed = createWorksheetTestBed();
univerAPI = testBed.univerAPI;
});
it('Worksheet getFilter', async () => {
const activeSheet = univerAPI.getActiveWorkbook()?.getSheetByName('sheet1');
expect(activeSheet?.getFilter()).toBeDefined();
});
});

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 { FEnum } from '@univerjs/core/facade';
import { CustomFilterOperator } from '@univerjs/sheets-filter';
/**
* @ignore
*/
export interface IFSheetsFilterEnumMixin {
/** Please refer to {@link CustomFilterOperator}. */
CustomFilterOperator: typeof CustomFilterOperator;
}
export class FSheetsFilterEnumMixin extends FEnum implements IFSheetsFilterEnumMixin {
override get CustomFilterOperator(): typeof CustomFilterOperator {
return CustomFilterOperator;
};
}
FEnum.extend(FSheetsFilterEnumMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEnum extends IFSheetsFilterEnumMixin {
}
}

View File

@@ -0,0 +1,145 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IEventBase } from '@univerjs/core/facade';
import type { ISetSheetsFilterCriteriaCommandParams } from '@univerjs/sheets-filter';
import type { FWorkbook, FWorksheet } from '@univerjs/sheets/facade';
import { FEventName } from '@univerjs/core/facade';
/**
* The params of SheetRangeFiltered and SheetBeforeRangeFilter events.
* @param workbook - The corresponding workbook wrapped in {@link FWorkbook}.
* @param worksheet - The corresponding worksheet wrapped in {@link FWorksheet}.
* @param col - The column on which the filter criteria is changed.
* @param criteria - Raw filter criteria.
*/
export interface ISheetsRangeFilteredEventParams extends IEventBase, Pick<ISetSheetsFilterCriteriaCommandParams, 'criteria' | 'col'> {
workbook: FWorkbook;
worksheet: FWorksheet;
}
/**
* The params of SheetRangeFilterCleared and SheetBeforeRangeFilterClear events.
* @param workbook - The corresponding workbook wrapped in {@link FWorkbook}.
* @param worksheet - The corresponding worksheet wrapped in {@link FWorksheet}.
*/
export interface ISheetsRangeFilterClearedEventParams extends IEventBase {
workbook: FWorkbook;
worksheet: FWorksheet;
}
/**
* @ignore
*/
interface ISheetsFilterEventParamConfig {
SheetBeforeRangeFilter: ISheetsRangeFilteredEventParams;
SheetRangeFiltered: ISheetsRangeFilteredEventParams;
SheetBeforeRangeFilterClear: ISheetsRangeFilterClearedEventParams;
SheetRangeFilterCleared: ISheetsRangeFilterClearedEventParams;
}
/**
* @ignore
*/
export interface IFSheetsFilterEventNameMixin {
/**
* This event will be emitted when the filter criteria on a column is changed.
* @see {@link ISheetRangeFilteredParams}
* @example
* ```typescript
* const callbackDisposable = univerAPI.addEvent(univerAPI.Event.SheetRangeFiltered, (params) => {
* console.log(params);
* const { workbook, worksheet, col, criteria } = params;
*
* // your custom logic
* });
*
* // Remove the event listener, use `callbackDisposable.dispose()`
* ```
*/
readonly SheetRangeFiltered: 'SheetRangeFiltered';
/**
* This event will be emitted before the filter criteria on a column is changed.
* @see {@link ISheetRangeFilteredParams}
* @example
* ```typescript
* const callbackDisposable = univerAPI.addEvent(univerAPI.Event.SheetBeforeRangeFilter, (params) => {
* console.log(params);
* const { workbook, worksheet, col, criteria } = params;
*
* // your custom logic
*
* // Cancel the filter criteria change operation
* params.cancel = true;
* });
*
* // Remove the event listener, use `callbackDisposable.dispose()`
* ```
*/
readonly SheetBeforeRangeFilter: 'SheetBeforeRangeFilter';
/**
* This event will be emitted when the filter on a worksheet is cleared.
* @see {@link ISheetRangeFilterClearedEventParams}
* @example
* ```typescript
* const callbackDisposable = univerAPI.addEvent(univerAPI.Event.SheetRangeFilterCleared, (params) => {
* console.log(params);
* const { workbook, worksheet } = params;
*
* // your custom logic
* });
*
* // Remove the event listener, use `callbackDisposable.dispose()`
* ```
*/
readonly SheetRangeFilterCleared: 'SheetRangeFilterCleared';
/**
* This event will be emitted after the filter on a worksheet is cleared.
* @see {@link ISheetRangeFilterClearedEventParams}
* @example
* ```typescript
* const callbackDisposable = univerAPI.addEvent(univerAPI.Event.SheetBeforeRangeFilterClear, (params) => {
* console.log(params);
* const { workbook, worksheet } = params;
*
* // your custom logic
*
* // Cancel the filter clear operation
* params.cancel = true;
* });
*
* // Remove the event listener, use `callbackDisposable.dispose()`
* ```
*/
readonly SheetBeforeRangeFilterClear: 'SheetBeforeRangeFilterClear';
}
export class FSheetsFilterEventNameMixin extends FEventName implements IFSheetsFilterEventNameMixin {
override get SheetBeforeRangeFilter(): 'SheetBeforeRangeFilter' { return 'SheetBeforeRangeFilter'; }
override get SheetRangeFiltered(): 'SheetRangeFiltered' { return 'SheetRangeFiltered'; }
override get SheetRangeFilterCleared(): 'SheetRangeFilterCleared' { return 'SheetRangeFilterCleared'; };
override get SheetBeforeRangeFilterClear(): 'SheetBeforeRangeFilterClear' { return 'SheetBeforeRangeFilterClear'; }
}
FEventName.extend(FSheetsFilterEventNameMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEventName extends IFSheetsFilterEventNameMixin { }
interface IEventParamConfig extends ISheetsFilterEventParamConfig { }
}

View File

@@ -0,0 +1,354 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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, Workbook, Worksheet } from '@univerjs/core';
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
import type { FilterModel, IFilterColumn, ISetSheetsFilterCriteriaCommandParams } from '@univerjs/sheets-filter';
import { ICommandService, Inject, Injector } from '@univerjs/core';
import {
ClearSheetsFilterCriteriaCommand,
RemoveSheetFilterCommand,
SetSheetsFilterCriteriaCommand,
} from '@univerjs/sheets-filter';
import { FRange } from '@univerjs/sheets/facade';
/**
* This interface class provides methods to modify the filter settings of a worksheet.
* @hideconstructor
*/
export class FFilter {
constructor(
private readonly _workbook: Workbook,
private readonly _worksheet: Worksheet,
private readonly _filterModel: FilterModel,
@Inject(Injector) private readonly _injector: Injector,
@ICommandService private readonly _commandSrv: ICommandService
) {
// empty
}
/**
* Get the filtered out rows by this filter.
* @returns {number[]} Filtered out rows by this filter.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set some values of the range C1:F10
* const fRange = fWorksheet.getRange('C1:F10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13],
* ]);
*
* // Create a filter on the range C1:F10
* let fFilter = fRange.createFilter();
*
* // If the filter already exists, remove it and create a new one
* if (!fFilter) {
* fRange.getFilter().remove();
* fFilter = fRange.createFilter();
* }
*
* // Set the filter criteria of the column C, filter out the rows that are not 1, 5, 9
* const column = fWorksheet.getRange('C:C').getColumn();
* fFilter.setColumnFilterCriteria(column, {
* colId: 0,
* filters: {
* filters: ['1', '5', '9'],
* },
* });
*
* // Get the filtered out rows
* console.log(fFilter.getFilteredOutRows()); // [1, 2, 3, 5, 6, 7, 9]
* ```
*/
getFilteredOutRows(): number[] {
return Array.from(this._filterModel.filteredOutRows).sort();
}
/**
* Get the filter criteria of a column.
* @param {number} column - The absolute, zero-based worksheet column index, not an index relative to the filter range.
* @returns {Nullable<IFilterColumn>} The filter criteria of the column.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set some values of the range C1:F10
* const fRange = fWorksheet.getRange('C1:F10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13],
* ]);
*
* // Create a filter on the range C1:F10
* let fFilter = fRange.createFilter();
*
* // If the filter already exists, remove it and create a new one
* if (!fFilter) {
* fRange.getFilter().remove();
* fFilter = fRange.createFilter();
* }
*
* // Set the filter criteria of the column C, filter out the rows that are not 1, 5, 9
* const column = fWorksheet.getRange('C:C').getColumn();
* fFilter.setColumnFilterCriteria(column, {
* colId: 0,
* filters: {
* filters: ['1', '5', '9'],
* },
* });
*
* // Print the filter criteria of the column C and D
* console.log(fFilter.getColumnFilterCriteria(column)); // { colId: 0, filters: { filters: ['1', '5', '9'] } }
* console.log(fFilter.getColumnFilterCriteria(column + 1)); // undefined
* ```
*/
getColumnFilterCriteria(column: number): Nullable<IFilterColumn> {
return this._filterModel.getFilterColumn(column)?.getColumnData();
}
/**
* Clear the filter criteria of a column.
* @param {number} column - The absolute, zero-based worksheet column index, not an index relative to the filter range.
* @returns {FFilter} The FFilter instance for chaining.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set some values of the range C1:F10
* const fRange = fWorksheet.getRange('C1:F10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13],
* ]);
*
* // Create a filter on the range C1:F10
* let fFilter = fRange.createFilter();
*
* // If the filter already exists, remove it and create a new one
* if (!fFilter) {
* fRange.getFilter().remove();
* fFilter = fRange.createFilter();
* }
*
* // Set the filter criteria of the column C, filter out the rows that are not 1, 5, 9
* const column = fWorksheet.getRange('C:C').getColumn();
* fFilter.setColumnFilterCriteria(column, {
* colId: 0,
* filters: {
* filters: ['1', '5', '9'],
* },
* });
*
* // Clear the filter criteria of the column C after 3 seconds
* setTimeout(() => {
* fFilter.removeColumnFilterCriteria(column);
* }, 3000);
* ```
*/
removeColumnFilterCriteria(column: number): FFilter {
this._commandSrv.syncExecuteCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
col: column,
criteria: null,
} as ISetSheetsFilterCriteriaCommandParams);
return this;
}
/**
* Set the filter criteria of a column.
* @param {number} column - The absolute, zero-based worksheet column index, not an index relative to the filter range.
* @param {ISetSheetsFilterCriteriaCommandParams['criteria']} criteria - The new filter criteria.
* @returns {FFilter} The FFilter instance for chaining.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set some values of the range C1:F10
* const fRange = fWorksheet.getRange('C1:F10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13],
* ]);
*
* // Create a filter on the range C1:F10
* let fFilter = fRange.createFilter();
*
* // If the filter already exists, remove it and create a new one
* if (!fFilter) {
* fRange.getFilter().remove();
* fFilter = fRange.createFilter();
* }
*
* // Set the filter criteria of the column C, filter out the rows that are not 1, 5, 9
* const column = fWorksheet.getRange('C:C').getColumn();
* fFilter.setColumnFilterCriteria(column, {
* colId: 0,
* filters: {
* filters: ['1', '5', '9'],
* },
* });
* ```
*/
setColumnFilterCriteria(column: number, criteria: ISetSheetsFilterCriteriaCommandParams['criteria']): FFilter {
this._commandSrv.syncExecuteCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
col: column,
criteria,
} as ISetSheetsFilterCriteriaCommandParams);
return this;
}
/**
* Get the range of the filter.
* @returns {FRange} The range of the filter.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fFilter = fWorksheet.getFilter();
* console.log(fFilter?.getRange().getA1Notation());
* ```
*/
getRange(): FRange {
const range = this._filterModel.getRange();
return this._injector.createInstance(FRange, this._workbook, this._worksheet, range);
}
/**
* Remove the filter criteria of all columns.
* @returns {FFilter} The FFilter instance for chaining.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Set some values of the range C1:F10
* const fRange = fWorksheet.getRange('C1:F10');
* fRange.setValues([
* [1, 2, 3, 4],
* [2, 3, 4, 5],
* [3, 4, 5, 6],
* [4, 5, 6, 7],
* [5, 6, 7, 8],
* [6, 7, 8, 9],
* [7, 8, 9, 10],
* [8, 9, 10, 11],
* [9, 10, 11, 12],
* [10, 11, 12, 13],
* ]);
*
* // Create a filter on the range C1:F10
* let fFilter = fRange.createFilter();
*
* // If the filter already exists, remove it and create a new one
* if (!fFilter) {
* fRange.getFilter().remove();
* fFilter = fRange.createFilter();
* }
*
* // Set the filter criteria of the column C, filter out the rows that are not 1, 5, 9
* const column = fWorksheet.getRange('C:C').getColumn();
* fFilter.setColumnFilterCriteria(column, {
* colId: 0,
* filters: {
* filters: ['1', '5', '9'],
* },
* });
*
* // Clear the filter criteria of all columns after 3 seconds
* setTimeout(() => {
* fFilter.removeFilterCriteria();
* }, 3000);
* ```
*/
removeFilterCriteria(): FFilter {
this._commandSrv.syncExecuteCommand(ClearSheetsFilterCriteriaCommand.id);
return this;
}
/**
* Remove the filter from the worksheet.
* @returns {boolean} True if the filter is removed successfully; otherwise, false.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D14');
* let fFilter = fRange.createFilter();
*
* // If the worksheet already has a filter, remove it and create a new filter.
* if (!fFilter) {
* fWorksheet.getFilter().remove();
* fFilter = fRange.createFilter();
* }
* console.log(fFilter);
* ```
*/
remove(): boolean {
return this._commandSrv.syncExecuteCommand(RemoveSheetFilterCommand.id, {
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
} as ISheetCommandSharedParams);
}
}

View File

@@ -0,0 +1,111 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { FilterModel, ISetSheetFilterRangeCommandParams } from '@univerjs/sheets-filter';
import { SetSheetFilterRangeCommand, SheetsFilterService } from '@univerjs/sheets-filter';
import { FRange } from '@univerjs/sheets/facade';
import { FFilter } from './f-filter';
// TODO: add jsdoc comments for the following API
/**
* @ignore
*/
export interface IFRangeSheetsFilterMixin {
/**
* Create a filter for the current range. If the worksheet already has a filter, this method would return `null`.
* @returns {FFilter | null} The FFilter instance to handle the filter.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D14');
* let fFilter = fRange.createFilter();
*
* // If the worksheet already has a filter, remove it and create a new filter.
* if (!fFilter) {
* fWorksheet.getFilter().remove();
* fFilter = fRange.createFilter();
* }
* console.log(fFilter, fFilter.getRange().getA1Notation());
* ```
*/
createFilter(this: FRange): FFilter | null;
/**
* Get the filter in the worksheet to which the range belongs. If the worksheet does not have a filter, this method would return `null`.
* Normally, you can directly call `getFilter` on {@link FWorksheet}.
* @returns {FFilter | null} The FFilter instance to handle the filter.
* @example
* ```typescript
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const fRange = fWorksheet.getRange('A1:D14');
* let fFilter = fRange.getFilter();
*
* // If the worksheet does not have a filter, create a new filter.
* if (!fFilter) {
* fFilter = fRange.createFilter();
* }
* console.log(fFilter, fFilter.getRange().getA1Notation());
* ```
*/
getFilter(): FFilter | null;
}
export class FRangeSheetsFilterMixin extends FRange implements IFRangeSheetsFilterMixin {
override createFilter(): FFilter | null {
if (this._getFilterModel()) return null;
const success = this._commandService.syncExecuteCommand(SetSheetFilterRangeCommand.id, <ISetSheetFilterRangeCommandParams>{
unitId: this._workbook.getUnitId(),
subUnitId: this._worksheet.getSheetId(),
range: this._range,
});
if (!success) return null;
return this.getFilter();
}
/**
* Get the filter for the current range's worksheet.
* @returns {FFilter | null} The interface class to handle the filter. If the worksheet does not have a filter,
* this method would return `null`.
*/
override getFilter(): FFilter | null {
const filterModel = this._getFilterModel();
if (!filterModel) return null;
return this._injector.createInstance(FFilter, this._workbook, this._worksheet, filterModel);
}
private _getFilterModel(): Nullable<FilterModel> {
return this._injector.get(SheetsFilterService).getFilterModel(
this._workbook.getUnitId(),
this._worksheet.getSheetId()
);
}
}
FRange.extend(FRangeSheetsFilterMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FRange extends IFRangeSheetsFilterMixin { }
}

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 { ICommandInfo, Injector } from '@univerjs/core';
import type { ISetSheetsFilterCriteriaCommandParams } from '@univerjs/sheets-filter';
import type { ISheetsRangeFilterClearedEventParams, ISheetsRangeFilteredEventParams } from './f-event';
import { ICommandService } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { ClearSheetsFilterCriteriaCommand, SetSheetsFilterCriteriaCommand } from '@univerjs/sheets-filter';
export class FUniverSheetsFilterMixin extends FUniver {
/**
* @ignore
*/
override _initialize(injector: Injector): void {
const commandService = injector.get(ICommandService);
// Register filter criteria set event handlers
this.disposeWithMe(
this.registerEventHandler(
this.Event.SheetBeforeRangeFilter,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id === SetSheetsFilterCriteriaCommand.id) {
this._beforeRangeFilter(commandInfo as Readonly<ICommandInfo<ISetSheetsFilterCriteriaCommandParams>>);
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.SheetBeforeRangeFilterClear,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id === ClearSheetsFilterCriteriaCommand.id) {
this._beforeRangeFilterClear();
}
})
)
);
// Register filter criteria execution event handlers
this.disposeWithMe(
this.registerEventHandler(
this.Event.SheetRangeFiltered,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetSheetsFilterCriteriaCommand.id) {
this._onRangeFiltered(commandInfo as Readonly<ICommandInfo<ISetSheetsFilterCriteriaCommandParams>>);
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.SheetRangeFilterCleared,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === ClearSheetsFilterCriteriaCommand.id) {
this._onRangeFilterCleared();
}
})
)
);
}
private _beforeRangeFilter(commandInfo: Readonly<ICommandInfo<ISetSheetsFilterCriteriaCommandParams>>): void {
const params = commandInfo.params!;
const fWorkbook = this.getWorkbook(params.unitId)!;
const eventParams: ISheetsRangeFilteredEventParams = {
workbook: fWorkbook,
worksheet: fWorkbook.getSheetBySheetId(params.subUnitId)!,
col: params.col,
criteria: params.criteria,
};
this.fireEvent(this.Event.SheetBeforeRangeFilter, eventParams);
if (eventParams.cancel) {
throw new Error('SetSheetsFilterCriteriaCommand canceled.');
}
}
private _onRangeFiltered(commandInfo: Readonly<ICommandInfo<ISetSheetsFilterCriteriaCommandParams>>): void {
const params = commandInfo.params!;
const fWorkbook = this.getWorkbook(params.unitId)!;
const eventParams: ISheetsRangeFilteredEventParams = {
workbook: fWorkbook,
worksheet: fWorkbook.getSheetBySheetId(params.subUnitId)!,
col: params.col,
criteria: params.criteria,
};
this.fireEvent(this.Event.SheetRangeFiltered, eventParams);
}
private _beforeRangeFilterClear(): void {
const fWorkbook = this.getActiveWorkbook();
if (!fWorkbook) return;
const eventParams: ISheetsRangeFilterClearedEventParams = {
workbook: fWorkbook,
worksheet: fWorkbook.getActiveSheet(),
};
this.fireEvent(this.Event.SheetBeforeRangeFilterClear, eventParams);
if (eventParams.cancel) {
throw new Error('SetSheetsFilterCriteriaCommand canceled.');
}
}
private _onRangeFilterCleared(): void {
const fWorkbook = this.getActiveWorkbook();
if (!fWorkbook) return;
const eventParams: ISheetsRangeFilterClearedEventParams = {
workbook: fWorkbook,
worksheet: fWorkbook.getActiveSheet(),
};
this.fireEvent(this.Event.SheetRangeFilterCleared, eventParams);
}
}
FUniver.extend(FUniverSheetsFilterMixin);

View File

@@ -0,0 +1,63 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { FilterModel } from '@univerjs/sheets-filter';
import { SheetsFilterService } from '@univerjs/sheets-filter';
import { FWorksheet } from '@univerjs/sheets/facade';
import { FFilter } from './f-filter';
/**
* @ignore
*/
export interface IFWorksheetFilterMixin {
/**
* Get the filter for the current worksheet.
* @returns {FFilter | null} The interface class to handle the filter. If the worksheet does not have a filter,
* this method would return `null`.
* @example
* ```typescript
* const workbook = univerAPI.getActiveWorkbook();
* const worksheet = workbook.getSheetByName('Sheet1');
* if (!worksheet) return;
* const filter = worksheet.getFilter();
* console.log(filter, filter?.getRange().getA1Notation());
* ```
*/
getFilter(): FFilter | null;
}
export class FWorksheetFilterMixin extends FWorksheet implements IFWorksheetFilterMixin {
override getFilter(): FFilter | null {
const filterModel = this._getFilterModel();
if (!filterModel) return null;
return this._injector.createInstance(FFilter, this._workbook, this._worksheet, filterModel);
}
private _getFilterModel(): Nullable<FilterModel> {
return this._injector.get(SheetsFilterService).getFilterModel(
this._workbook.getUnitId(),
this._worksheet.getSheetId()
);
}
}
FWorksheet.extend(FWorksheetFilterMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorksheet extends IFWorksheetFilterMixin { }
}

View File

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

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.
*/
export {
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
RemoveSheetFilterCommand,
SetSheetFilterRangeCommand,
SetSheetsFilterCriteriaCommand,
SmartToggleSheetsFilterCommand,
} from './commands/commands/sheets-filter.command';
export type {
ISetSheetFilterRangeCommandParams,
ISetSheetsFilterCriteriaCommandParams,
} from './commands/commands/sheets-filter.command';
export {
ReCalcSheetsFilterMutation,
RemoveSheetsFilterMutation,
SetSheetsFilterCriteriaMutation,
SetSheetsFilterRangeMutation,
} from './commands/mutations/sheets-filter.mutation';
export type {
ISetSheetsFilterCriteriaMutationParams,
ISetSheetsFilterRangeMutationParams,
} from './commands/mutations/sheets-filter.mutation';
export { FILTER_MUTATIONS } from './common/const';
export type { IUniverSheetsFilterConfig } from './config/config';
export { SheetsFilterSyncController } from './controllers/sheets-filter-sync.controller';
export {
equals,
getCustomFilterFn,
greaterThan,
greaterThanOrEqualTo,
lessThan,
lessThanOrEqualTo,
notEquals,
} from './models/custom-filters';
export { FilterColumn, FilterModel } from './models/filter-model';
export type {
IAutoFilter,
IColorFilters,
ICustomFilter,
ICustomFilters,
IFilterColumn,
IFilters,
} from './models/types';
export { CustomFilterOperator, FilterBy } from './models/types';
export { UniverSheetsFilterPlugin } from './plugin';
export { SHEET_FILTER_SNAPSHOT_ID, SheetsFilterService } from './services/sheet-filter.service';

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'يحتوي النطاق المحدد على صف واحد فقط وهو غير صالح للتصفية.',
},
msg: {
'filter-header-forbidden': 'لا يمكنك نقل صف الرأس الخاص بالتصفية.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'L\'interval seleccionat només té una fila i no és vàlid per filtrar.',
},
msg: {
'filter-header-forbidden': 'No pots moure la fila de capçalera d\'un filtre.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'Der ausgewählte Bereich hat nur eine Zeile und ist für den Filter ungültig.',
},
msg: {
'filter-header-forbidden': 'Die Kopfzeile eines Filters kann nicht verschoben werden.',
},
},
};
export default locale;

View File

@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const locale = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'The selected range only has one row and not valid for filter.',
},
msg: {
'filter-header-forbidden': 'You can\'t move the header row of a filter.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'El rango seleccionado solo tiene una fila y no es válido para filtrar.',
},
msg: {
'filter-header-forbidden': 'No puedes mover la fila de encabezado de un filtro.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'محدوده انتخاب شده فقط یک ردیف دارد و برای فیلتر معتبر نیست.',
},
msg: {
'filter-header-forbidden': 'شما نمی‌توانید ردیف هدر یک فیلتر را جابه‌جا کنید.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'La plage sélectionnée n\'a qu\'une seule ligne et n\'est pas valide pour le filtre.',
},
msg: {
'filter-header-forbidden': 'Vous ne pouvez pas déplacer la ligne d\'en-tête d\'un filtre.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'Rentang yang dipilih hanya memiliki satu baris dan tidak valid untuk filter.',
},
msg: {
'filter-header-forbidden': 'Anda tidak dapat memindahkan baris header filter.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': "L'intervallo selezionato ha una sola riga e non è valido per il filtro.",
},
msg: {
'filter-header-forbidden': 'Non è possibile spostare la riga di intestazione di un filtro.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': '選択範囲に1行しか含まれていないため、フィルタを適用できません。',
},
msg: {
'filter-header-forbidden': 'フィルタヘッダー行は移動できません。',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': '선택한 범위에 한 행만 있어 필터를 적용할 수 없습니다.',
},
msg: {
'filter-header-forbidden': '필터 헤더 행은 이동할 수 없습니다.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'Wybrany zakres ma tylko jeden wiersz i nie jest prawidłowy do filtrowania.',
},
msg: {
'filter-header-forbidden': 'Nie można przenieść wiersza nagłówka filtra.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'O intervalo selecionado possui apenas uma linha e não é válido para filtro.',
},
msg: {
'filter-header-forbidden': 'Você não pode mover a linha de cabeçalho de um filtro.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'Выбранный диапазон содержит только одну строку и не подходит для фильтра.',
},
msg: {
'filter-header-forbidden': 'Вы не можете переместить строку заголовка фильтра.',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'Vybraný rozsah má iba jeden riadok a nie je vhodný na filtrovanie.',
},
msg: {
'filter-header-forbidden': 'Riadok hlavičky filtra sa nedá presunúť.',
},
},
};
export default locale;

View File

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

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': 'Khu vực được chọn chỉ có một hàng, không thể lọc',
},
msg: {
'filter-header-forbidden': 'Không thể di chuyển hàng đầu lọc',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': '选中的区域只有一行,无法进行筛选',
},
msg: {
'filter-header-forbidden': '无法移动筛选行头',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': '選取的區域只有一列,無法進行篩選',
},
msg: {
'filter-header-forbidden': '無法移動篩選列頭',
},
},
};
export default locale;

View File

@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter': {
command: {
'not-valid-filter-range': '選取的區域只有一列,無法進行篩選',
},
msg: {
'filter-header-forbidden': '無法移動篩選列頭',
},
},
};
export default locale;

View File

@@ -0,0 +1,130 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { equals, greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo, notEquals, textMatch } from '../custom-filters';
describe('Test custom filter functions', () => {
describe('Test "greaterThan" operator', () => {
it('filter out values if there are not of type number', () => {
expect(greaterThan.fn('1', 0)).toBeFalsy();
});
it('should execute "greaterThan" correctly', () => {
expect(greaterThan.fn(1, 0)).toBeTruthy();
expect(greaterThan.fn(0, 0)).toBeFalsy();
expect(greaterThan.fn(-1, 0)).toBeFalsy();
});
});
describe('Test "greaterThanOrEqual" operator', () => {
it('filter out values if there are not of type number', () => {
expect(greaterThanOrEqualTo.fn('1', 0)).toBeFalsy();
});
it('should execute "greaterThanOrEqual" correctly', () => {
expect(greaterThanOrEqualTo.fn(1, 0)).toBeTruthy();
expect(greaterThanOrEqualTo.fn(0, 0)).toBeTruthy();
expect(greaterThanOrEqualTo.fn(-1, 0)).toBeFalsy();
});
});
describe('Test "lessThan" operator', () => {
it('filter out values if there are not of type number', () => {
expect(lessThan.fn('1', 0)).toBeFalsy();
});
it('should execute "lessThan" correctly', () => {
expect(lessThan.fn(1, 0)).toBeFalsy();
expect(lessThan.fn(0, 0)).toBeFalsy();
expect(lessThan.fn(-1, 0)).toBeTruthy();
});
});
describe('Test "lessThanOrEqual" operator', () => {
it('filter out values if there are not of type number', () => {
expect(lessThanOrEqualTo.fn('1', 0)).toBeFalsy();
});
it('should execute "lessThanOrEqual" correctly', () => {
expect(lessThanOrEqualTo.fn(1, 0)).toBeFalsy();
expect(lessThanOrEqualTo.fn(0, 0)).toBeTruthy();
expect(lessThanOrEqualTo.fn(-1, 0)).toBeTruthy();
});
});
describe('Test "equals" operator', () => {
it('filter out values if there are not of type number', () => {
expect(equals.fn('0', 0)).toBeFalsy();
});
it('should execute "equals" correctly', () => {
expect(equals.fn(1, 0)).toBeFalsy();
expect(equals.fn(0, 0)).toBeTruthy();
expect(equals.fn(-1, 0)).toBeFalsy();
});
});
describe('Test "notEquals" operator', () => {
it('should not filter out values if there are not of type number', () => {
expect(notEquals.fn('0', 0)).toBeTruthy();
});
it('should execute "notEquals" correctly', () => {
expect(notEquals.fn(1, 0)).toBeTruthy();
expect(notEquals.fn(0, 0)).toBeFalsy();
expect(notEquals.fn(-1, 0)).toBeTruthy();
});
it('should "notEquals" implement support matching strings if `compare` is a string', () => {
// equivalent to "notContains"
expect(notEquals.fn(123, '*123*')).toBeFalsy();
expect(notEquals.fn('12', '*123*')).toBeTruthy();
});
});
describe('Test text match operator', () => {
it('should execute "textMatch" correctly', () => {
// equivalent to "startsWith"
expect(textMatch.fn('hello world', 'hello*')).toBeTruthy();
expect(textMatch.fn('hello', 'hello*')).toBeTruthy();
expect(textMatch.fn('hell', 'hello*')).toBeFalsy();
// equivalent to "endsWith"
expect(textMatch.fn('hello world', '*world')).toBeTruthy();
expect(textMatch.fn('world', '*world')).toBeTruthy();
expect(textMatch.fn('wrld', '*world')).toBeFalsy();
// equivalent to "contains"
expect(textMatch.fn('hello world', '*llo w*')).toBeTruthy();
expect(textMatch.fn('hell world', '*llo w*')).toBeFalsy();
});
it('should support "?" wild card', () => {
expect(textMatch.fn('hello', 'hell?')).toBeTruthy();
expect(textMatch.fn('helloooo', 'hell?')).toBeFalsy();
expect(textMatch.fn('helloooo', 'hell?ooo')).toBeTruthy();
expect(textMatch.fn('hello world', 'hell?')).toBeFalsy();
expect(textMatch.fn('hello', '?hell?')).toBeFalsy();
expect(textMatch.fn('yhell', '?hell?')).toBeFalsy();
expect(textMatch.fn('hello111', '*hello?*')).toBeTruthy();
expect(textMatch.fn('hello111\r\n', '*hello?*')).toBeFalsy();
expect(textMatch.fn('hello111\r\n'.trimEnd(), '*hello?*')).toBeTruthy();
expect(textMatch.fn('hello', '*hello?*')).toBeFalsy();
});
});
});

View File

@@ -0,0 +1,499 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency, IWorkbookData, LocaleType } from '@univerjs/core';
import type {
IInsertColCommandParams,
IInsertRowCommandParams,
IMoveColsCommandParams,
IMoveRowsCommandParams,
IRemoveRowColCommandParams,
IRemoveSheetCommandParams,
ISetSelectionsOperationParams,
} from '@univerjs/sheets';
import { Direction, ICommandService, Inject, Injector, Plugin, RANGE_TYPE, Univer, UniverInstanceType } from '@univerjs/core';
import {
InsertColByRangeCommand,
InsertColCommand,
InsertColMutation,
InsertRowByRangeCommand,
InsertRowCommand,
InsertRowMutation,
MoveColsCommand,
MoveColsMutation,
MoveRowsCommand,
MoveRowsMutation,
RefRangeService,
RemoveColByRangeCommand,
RemoveColCommand,
RemoveColMutation,
RemoveRowByRangeCommand,
RemoveRowCommand,
RemoveRowMutation,
RemoveSheetCommand,
RemoveSheetMutation,
SetSelectionsOperation,
SheetInterceptorService,
SheetRangeThemeModel,
SheetsSelectionsService,
ZebraCrossingCacheController,
} from '@univerjs/sheets';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SheetsFilterController } from '../../controllers/sheets-filter.controller';
import { SHEET_FILTER_SNAPSHOT_ID, SheetsFilterService } from '../../services/sheet-filter.service';
describe('Test "Filter Interceptor"', () => {
let univer: Univer;
let sheetsFilterService: SheetsFilterService;
let commandService: ICommandService;
beforeEach(() => {
const testBed = createFilterTestUniver();
univer = testBed.univer;
sheetsFilterService = testBed.sheetsFilterService;
commandService = testBed.commandService;
univer.__getInjector();
});
afterEach(() => {
univer.dispose();
});
describe('Test "insert Command"', () => {
it('insert col command, in filter range', async () => {
const insertColCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 2,
endColumn: 2,
startRow: 0,
endRow: 4,
type: RANGE_TYPE.COLUMN,
},
direction: Direction.RIGHT,
} as IInsertColCommandParams;
await commandService.executeCommand(InsertColCommand.id, insertColCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 3, startRow: 1, endRow: 2 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([3]);
});
it('insert col command, before filter range', async () => {
const insertColCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 0,
endColumn: 0,
startRow: 0,
endRow: 4,
type: RANGE_TYPE.COLUMN,
},
direction: Direction.LEFT,
} as IInsertColCommandParams;
await commandService.executeCommand(InsertColCommand.id, insertColCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 2, endColumn: 3, startRow: 1, endRow: 2 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([3]);
});
it('insert row command, in filter range', async () => {
const insertRowCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 0,
endColumn: 3,
startRow: 2,
endRow: 2,
type: RANGE_TYPE.ROW,
},
direction: Direction.DOWN,
} as IInsertRowCommandParams;
await commandService.executeCommand(InsertRowCommand.id, insertRowCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 2, startRow: 1, endRow: 3 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([2]);
});
it('insert row command, before filter range', async () => {
const insertRowCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 0,
endColumn: 3,
startRow: 0,
endRow: 0,
type: RANGE_TYPE.ROW,
},
direction: Direction.UP,
} as IInsertRowCommandParams;
await commandService.executeCommand(InsertRowCommand.id, insertRowCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 2, startRow: 2, endRow: 3 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([2]);
});
});
describe('Test "remove Command"', () => {
it('remove col command, in filter range', async () => {
const removeColCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 1,
endColumn: 1,
startRow: 0,
endRow: 4,
type: RANGE_TYPE.COLUMN,
},
direction: Direction.RIGHT,
} as IRemoveRowColCommandParams;
await commandService.executeCommand(RemoveColCommand.id, removeColCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 1, startRow: 1, endRow: 2 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([1]);
});
it('remove col command, before filter range', async () => {
const removeColCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 0,
endColumn: 0,
startRow: 0,
endRow: 4,
type: RANGE_TYPE.COLUMN,
},
direction: Direction.RIGHT,
} as IRemoveRowColCommandParams;
await commandService.executeCommand(RemoveColCommand.id, removeColCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 0, endColumn: 1, startRow: 1, endRow: 2 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([1]);
});
it('remove row command, in filter range', async () => {
const removeRowCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 0,
endColumn: 3,
startRow: 1,
endRow: 1,
type: RANGE_TYPE.ROW,
},
direction: Direction.DOWN,
} as IRemoveRowColCommandParams;
await commandService.executeCommand(RemoveRowCommand.id, removeRowCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')).toBe(null);
});
it('remove row command, before filter range', async () => {
const removeRowCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
range: {
startColumn: 0,
endColumn: 3,
startRow: 0,
endRow: 0,
type: RANGE_TYPE.ROW,
},
direction: Direction.DOWN,
} as IRemoveRowColCommandParams;
await commandService.executeCommand(RemoveRowCommand.id, removeRowCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 2, startRow: 0, endRow: 1 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([2]);
});
});
describe('Test "move Command"', () => {
it('move col command, filter column move to left', async () => {
const moveColCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
fromRange: {
startColumn: 1,
endColumn: 1,
startRow: 0,
endRow: 4,
type: RANGE_TYPE.COLUMN,
},
toRange: { startColumn: 3, endColumn: 3, startRow: 0, endRow: 4, type: RANGE_TYPE.COLUMN },
direction: Direction.RIGHT,
} as IMoveColsCommandParams;
await commandService.executeCommand(SetSelectionsOperation.id, { unitId: 'workbookId', subUnitId: 'worksheetId', selections: [{ style: null, range: { startColumn: 1, endColumn: 1, startRow: 0, endRow: 4, rangeType: RANGE_TYPE.COLUMN }, primary: {} }] } as ISetSelectionsOperationParams);
await commandService.executeCommand(MoveColsCommand.id, moveColCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 1, startRow: 1, endRow: 2 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([1]);
});
it('move col command, filter column move to right', async () => {
const moveColCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
fromRange: {
startColumn: 2,
endColumn: 2,
startRow: 0,
endRow: 4,
type: RANGE_TYPE.COLUMN,
},
toRange: { startColumn: 4, endColumn: 4, startRow: 0, endRow: 4, type: RANGE_TYPE.COLUMN },
direction: Direction.RIGHT,
} as IMoveColsCommandParams;
await commandService.executeCommand(SetSelectionsOperation.id, { unitId: 'workbookId', subUnitId: 'worksheetId', selections: [{ style: null, range: { startColumn: 2, endColumn: 2, startRow: 0, endRow: 4, rangeType: RANGE_TYPE.COLUMN }, primary: {} }] } as ISetSelectionsOperationParams);
await commandService.executeCommand(MoveColsCommand.id, moveColCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 3, startRow: 1, endRow: 2 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([3]);
});
it('move row command', async () => {
const moveRowCommandParams = {
unitId: 'workbookId',
subUnitId: 'worksheetId',
fromRange: {
startColumn: 0,
endColumn: 3,
startRow: 2,
endRow: 2,
type: RANGE_TYPE.ROW,
},
toRange: { startColumn: 0, endColumn: 3, startRow: 4, endRow: 4, type: RANGE_TYPE.ROW },
direction: Direction.RIGHT,
} as IMoveRowsCommandParams;
await commandService.executeCommand(SetSelectionsOperation.id, { unitId: 'workbookId', subUnitId: 'worksheetId', selections: [{ style: null, range: { startColumn: 0, endColumn: 3, startRow: 2, endRow: 2, rangeType: RANGE_TYPE.ROW }, primary: {} }] } as ISetSelectionsOperationParams);
await commandService.executeCommand(MoveRowsCommand.id, moveRowCommandParams);
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getRange()).toStrictEqual({ startColumn: 1, endColumn: 2, startRow: 1, endRow: 3 });
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId')!.getAllFilterColumns().map((x) => x[0])).toStrictEqual([2]);
});
});
describe('Test "remove sheet Command"', () => {
it('remove sheet command', async () => {
const removeSheetCommandParams = { unitId: 'workbookId', subUnitId: 'worksheetId' } as IRemoveSheetCommandParams;
expect(await commandService.executeCommand(RemoveSheetCommand.id, removeSheetCommandParams)).toBeTruthy();
expect(sheetsFilterService.getFilterModel('workbookId', 'worksheetId') === null).toBeTruthy();
});
});
});
function createFilterTestUniver(dependencies?: Dependency[], workbookData?: IWorkbookData) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
/**
* This plugin hooks into Sheet's DI system to expose API to test scripts
*/
class TestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'test-plugin';
constructor(
_config: undefined,
@Inject(Injector) override readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
dependencies?.forEach((d) => injector.add(d));
injector.add([SheetInterceptorService]);
injector.add([SheetsFilterService]);
injector.add([SheetsSelectionsService]);
injector.add([RefRangeService]);
injector.add([SheetRangeThemeModel]);
injector.add([ZebraCrossingCacheController]);
injector.add([SheetsFilterController]);
}
override onReady(): void {
this._injector.get(SheetsFilterController);
}
}
univer.registerPlugin(TestPlugin);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData || testWorkbookDataFactory());
const sheetsFilterService = injector.get(SheetsFilterService);
// It should be registered later in avoid of time sequence problem.
// injector.add([ISnapshotPersistenceService, { useClass: LocalSnapshotService }]);
// injector.get(ISnapshotPersistenceService);
const commandService = get(ICommandService);
[
InsertColMutation,
RemoveColMutation,
InsertColCommand,
InsertRowMutation,
RemoveRowMutation,
InsertRowCommand,
RemoveColCommand,
RemoveRowCommand,
MoveColsMutation,
MoveColsCommand,
MoveRowsCommand,
MoveRowsMutation,
SetSelectionsOperation,
RemoveSheetCommand,
RemoveSheetMutation,
RemoveColByRangeCommand,
RemoveRowByRangeCommand,
InsertRowByRangeCommand,
InsertColByRangeCommand,
].forEach((command) => {
commandService.registerCommand(command);
});
return {
univer,
get,
sheetsFilterService,
commandService,
};
}
function testWorkbookDataFactory(): IWorkbookData {
return {
id: 'workbookId',
sheetOrder: ['worksheetId'],
name: 'UniverSheet Demo',
appVersion: '3.0.0-alpha',
locale: 'zhCN' as LocaleType,
styles: {},
sheets: {
worksheetId: {
name: '工作表1',
id: 'worksheetId',
tabColor: '',
hidden: 0,
rowCount: 1000,
columnCount: 20,
zoomRatio: 1,
scrollTop: 0,
scrollLeft: 0,
defaultColumnWidth: 73,
defaultRowHeight: 19,
mergeData: [],
cellData: {
0: {
0: {
v: 'A1',
},
1: {
v: 'B1',
},
2: {
v: 'C1',
},
3: {
v: 'D1',
},
},
1: {
0: {
v: 'A2',
},
1: {
v: 'B2',
},
2: {
v: 'C2',
},
3: {
v: 'D2',
},
},
2: {
0: {
v: 'A3',
},
1: {
v: 'B3',
},
2: {
v: 'C3',
},
3: {
v: 'D3',
},
},
3: {
0: {
v: 'A4',
},
1: {
v: 'B4',
},
2: {
v: 'C4',
},
3: {
v: 'D4',
},
},
4: {
0: {
v: 'A5',
},
1: {
v: 'B5',
},
2: {
v: 'C5',
},
3: {
v: 'D5',
},
},
},
showGridlines: 1,
rightToLeft: 0,
},
sheet1: {
name: 'sheet1',
id: 'sheet1',
cellData: {},
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
worksheetId: {
ref: {
startColumn: 1,
endColumn: 2,
startRow: 1,
endRow: 2,
},
filterColumns: [{
colId: 2,
filters: {
filters: ['1'],
},
}],
cachedFilteredOut: [],
},
}),
},
],
};
};

View File

@@ -0,0 +1,239 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector, IWorkbookData, Workbook } from '@univerjs/core';
import { ILogService, IUniverInstanceService, LocaleType, LogLevel, Univer, UniverInstanceType } from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { FilterColumn, generateFilterFn } from '../filter-model';
import { CustomFilterOperator } from '../types';
describe('Test filter model and related utils', () => {
describe('Test "FilterFn"s', () => {
it('should AND work', () => {
// equivalent to "between"
const fn = generateFilterFn({
colId: 0,
customFilters: {
and: 1,
customFilters: [
{ operator: CustomFilterOperator.LESS_THAN_OR_EQUAL, val: 456 },
{ operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL, val: 123 },
],
},
});
expect(fn(123)).toBeTruthy();
expect(fn(456)).toBeTruthy();
expect(fn(400)).toBeTruthy();
expect(fn(100)).toBeFalsy();
expect(fn(500)).toBeFalsy();
});
it('should OR work', () => {
// equivalent to "notBetween"
const fn = generateFilterFn({
colId: 0,
customFilters: {
customFilters: [
{ operator: CustomFilterOperator.LESS_THAN, val: 123 },
{ operator: CustomFilterOperator.GREATER_THAN, val: 456 },
],
},
});
expect(fn(123)).toBeFalsy();
expect(fn(456)).toBeFalsy();
expect(fn(400)).toBeFalsy();
expect(fn(100)).toBeTruthy();
expect(fn(500)).toBeTruthy();
});
it('should throw error on not supported yet filter types', () => {
// eslint-disable-next-line ts/no-explicit-any
expect(() => generateFilterFn({ colId: 0, dynamicFilters: {} } as any)).toThrowError();
});
it('should "filter by values" work', () => {
const fn = generateFilterFn({ colId: 0, filters: { filters: ['hello', 'univer'] } });
expect(fn('hello')).toBeTruthy();
expect(fn('univer')).toBeTruthy();
expect(fn('wzhudev')).toBeFalsy();
expect(fn('')).toBeFalsy();
expect(fn(undefined)).toBeFalsy();
});
it('should "filter by values" work with blank values', () => {
const fn = generateFilterFn({ colId: 0, filters: { filters: ['hello', 'univer'], blank: true } });
expect(fn('hello')).toBeTruthy();
expect(fn('univer')).toBeTruthy();
expect(fn('wzhudev')).toBeFalsy();
expect(fn('')).toBeTruthy();
expect(fn(undefined)).toBeTruthy();
});
});
describe('Test "FilterColumn"', () => {
let univer: Univer;
let filterColumn: FilterColumn;
let get: Injector['get'];
beforeEach(() => {
const testBed = createFilterModelTestBed({
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
name: 'sheet1',
cellData: {
0: {
1: { v: 'header' },
},
1: {
1: { v: 123 },
},
2: {
1: { v: 345 },
},
},
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
});
univer = testBed.univer;
get = testBed.get;
});
afterEach(() => {
univer.dispose();
filterColumn.dispose();
});
it('should not calc when filter fn or range is not set', () => {
filterColumn = new FilterColumn(
'test',
'sheet1',
get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()!,
{ colId: 0, customFilters: { customFilters: [{ operator: CustomFilterOperator.LESS_THAN, val: 123 }] } },
{ getAlreadyFilteredOutRows() { return new Set(); } }
);
expect(filterColumn.hasCache()).toBeFalsy();
filterColumn.setRangeAndColumn({ startRow: 0, endRow: 100, startColumn: 0, endColumn: 0 }, 0);
expect(filterColumn.hasCache()).toBeFalsy();
expect(() => filterColumn.reCalc()).toThrowError();
expect(filterColumn.hasCache()).toBeFalsy();
filterColumn.dispose();
// -------------------------------------------------------
filterColumn = new FilterColumn(
'test',
'sheet1',
get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()!,
{ colId: 0, customFilters: { customFilters: [{ operator: CustomFilterOperator.LESS_THAN, val: 123 }] } },
{ getAlreadyFilteredOutRows() { return new Set(); } }
);
expect(filterColumn.hasCache()).toBeFalsy();
filterColumn.setCriteria({ colId: 0, customFilters: { customFilters: [{ operator: CustomFilterOperator.LESS_THAN, val: 123 }] } });
expect(filterColumn.hasCache()).toBeFalsy();
expect(() => filterColumn.reCalc()).toThrowError();
expect(filterColumn.hasCache()).toBeFalsy();
});
it('should not auto calc when set criteria', () => {
filterColumn = new FilterColumn(
'test',
'sheet1',
get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()!,
{ colId: 0, customFilters: { customFilters: [{ operator: CustomFilterOperator.LESS_THAN, val: 123 }] } },
{ getAlreadyFilteredOutRows() { return new Set(); } }
);
expect(filterColumn.hasCache()).toBeFalsy();
filterColumn.setRangeAndColumn({ startRow: 0, endRow: 100, startColumn: 0, endColumn: 0 }, 0);
filterColumn.setCriteria({ colId: 0, customFilters: { customFilters: [{ operator: CustomFilterOperator.LESS_THAN, val: 123 }] } });
expect(filterColumn.hasCache()).toBeFalsy();
expect(filterColumn.filteredOutRows).toBeNull();
filterColumn.reCalc();
expect(filterColumn.hasCache()).toBeTruthy();
expect(filterColumn.filteredOutRows!.size).toBe(100);
});
it('should filtered out rows correctly', () => {
filterColumn = new FilterColumn(
'test',
'sheet1',
get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()!,
{ colId: 0, customFilters: { customFilters: [{ operator: CustomFilterOperator.LESS_THAN, val: 123 }] } },
{ getAlreadyFilteredOutRows() { return new Set(); } }
);
filterColumn.setRangeAndColumn({ startRow: 1, endRow: 3, startColumn: 1, endColumn: 1 }, 1);
filterColumn.setCriteria({ colId: 1, customFilters: { customFilters: [{ operator: CustomFilterOperator.GREATER_THAN, val: 200 }] } });
filterColumn.reCalc();
expect(filterColumn.filteredOutRows!.size).toBe(1);
});
it('should skip rows that already filtered out by other rows', () => {
filterColumn = new FilterColumn(
'test',
'sheet1',
get(IUniverInstanceService).getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()!,
{ colId: 0, customFilters: { customFilters: [{ operator: CustomFilterOperator.LESS_THAN, val: 123 }] } },
{ getAlreadyFilteredOutRows() { return new Set([1]); } }
);
filterColumn.setRangeAndColumn({ startRow: 1, endRow: 3, startColumn: 1, endColumn: 1 }, 1);
filterColumn.setCriteria({ colId: 1, customFilters: { customFilters: [{ operator: CustomFilterOperator.GREATER_THAN, val: 200 }] } });
filterColumn.reCalc();
expect(filterColumn.filteredOutRows!.size).toBe(1);
});
});
});
function createFilterModelTestBed(workbookData: IWorkbookData) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
const sheet = univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData);
const univerInstanceService = get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
const logService = get(ILogService);
logService.setLogLevel(LogLevel.SILENT);
return {
univer,
get,
sheet,
};
}

View File

@@ -0,0 +1,192 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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, Nullable } from '@univerjs/core';
import { isNumeric, regexp } from '@univerjs/core';
import { CustomFilterOperator } from './types';
export interface IFilterFn<P extends unknown[]> {
label?: string;
/**
* Description of this operator. Should be an i18n key.
*/
description?: string;
fn: (...params: P) => boolean;
}
/**
* Custom filter functions normally used in "Filter by Conditions".
*/
export interface ICustomFilterFn<P extends unknown[]> extends IFilterFn<P> {
/**
* Operator of the custom filter function.
*/
operator?: CustomFilterOperator;
/**
* Group of the custom filter belongs to. It would be rendered in the panel by groups.
*/
group?: string;
fn: (...params: P) => boolean;
}
type TwoParameters<C = string> = [value: Nullable<CellValue>, compare: C];
export const greaterThan: ICustomFilterFn<TwoParameters<number>> = {
operator: CustomFilterOperator.GREATER_THAN,
fn: (value, compare): boolean => {
if (!ensureNumber(value)) {
return false;
}
return value > compare;
},
};
export const greaterThanOrEqualTo: ICustomFilterFn<TwoParameters<number>> = {
operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
fn: (value, compare): boolean => {
if (!ensureNumber(value)) {
return false;
}
return value >= compare;
},
};
export const lessThan: ICustomFilterFn<TwoParameters<number>> = {
operator: CustomFilterOperator.LESS_THAN,
fn: (value, compare): boolean => {
if (!ensureNumber(value)) {
return false;
}
return value < compare;
},
};
export const lessThanOrEqualTo: ICustomFilterFn<TwoParameters<number>> = {
operator: CustomFilterOperator.LESS_THAN_OR_EQUAL,
fn: (value, compare): boolean => {
if (!ensureNumber(value)) {
return false;
}
return value <= compare;
},
};
export const equals: ICustomFilterFn<TwoParameters<number>> = {
operator: CustomFilterOperator.EQUAL,
fn: (value, compare): boolean => {
if (!ensureNumber(value)) {
return false;
}
return value === compare;
},
};
export const notEquals: ICustomFilterFn<TwoParameters<number | string>> = {
operator: CustomFilterOperator.NOT_EQUALS,
fn: (value, compare): boolean => {
// As text match.
if (typeof compare === 'string') {
if (compare === ' ') {
if (value !== undefined && value !== null) return true;
return false;
};
const ensuredString = ensureString(value);
if (ensuredString && isWildCardString(compare)) return !regexp.createFromWildcard(compare as string).test(ensuredString);
return ensuredString !== compare;
}
// As numeric match.
if (!ensureNumber(value)) return true;
return value !== compare;
},
};
// Register the custom filter functions to the registry, making it easier to get them.
export const CustomFilterFnRegistry = new Map<CustomFilterOperator, ICustomFilterFn<TwoParameters<number>>>([]);
const ALL_CUSTOM_FILTER_FUNCTIONS = [greaterThan, greaterThanOrEqualTo, lessThan, lessThanOrEqualTo, equals, notEquals];
ALL_CUSTOM_FILTER_FUNCTIONS.forEach((fn) => {
CustomFilterFnRegistry.set(fn.operator!, fn);
});
export function isNumericFilterFn(operator?: CustomFilterOperator): boolean {
return !!operator;
}
/** This operators matches texts. */
export const textMatch: ICustomFilterFn<TwoParameters<string>> = {
fn: (value, compare): boolean => {
const ensured = ensureString(value);
if (ensured === null) {
if (compare === '') return true;
return false;
}
return regexp.createFromWildcard(compare).test(ensured);
},
};
// eslint-disable-next-line ts/no-explicit-any
export function getCustomFilterFn(operator?: CustomFilterOperator): ICustomFilterFn<TwoParameters<any>> {
if (!operator) {
return textMatch;
}
return CustomFilterFnRegistry.get(operator)!;
}
function ensureNumber(value: Nullable<CellValue>): value is number {
return typeof value === 'number';
}
export function ensureNumeric(value: Nullable<CellValue>): boolean {
if (typeof value === 'number') {
return true;
};
if (typeof value === 'string' && isNumeric(value)) {
return true;
}
return false;
}
function ensureString(value: Nullable<CellValue>): string | null {
if (typeof value === 'boolean' || value == null) {
return null;
}
return typeof value === 'string' ? value : value.toString();
}
function isWildCardString(str: string | number): boolean {
if (typeof str === 'number') {
return false;
}
return str.indexOf('*') !== -1 || str.indexOf('?') !== -1;
}

View File

@@ -0,0 +1,621 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { CellValue, ICellData, IRange, IStyleData, Nullable, Worksheet } from '@univerjs/core';
import type { Observable } from 'rxjs';
import type { IAutoFilter, IColorFilters, ICustomFilter, ICustomFilters, IFilterColumn, IFilters } from './types';
import { CellValueType, ColorKit, Disposable, extractPureTextFromCell, mergeSets, Rectangle, Tools } from '@univerjs/core';
import { COLOR_BLACK_RGB } from '@univerjs/engine-render';
import { BehaviorSubject } from 'rxjs';
import { ensureNumeric, getCustomFilterFn, isNumericFilterFn, notEquals } from './custom-filters';
import { CustomFilterOperator, FilterBy } from './types';
const EMPTY = () => new Set<number>();
/**
* This is the in-memory model of filter.
*/
export class FilterModel extends Disposable {
private readonly _filteredOutRows$ = new BehaviorSubject<Readonly<Set<number>>>(EMPTY());
/** An observable value. A set of filtered out rows. */
readonly filteredOutRows$: Observable<Readonly<Set<number>>> = this._filteredOutRows$.asObservable();
get filteredOutRows() { return this._filteredOutRows$.getValue(); }
set filteredOutRows(rows: Set<number>) {
this._alreadyFilteredOutRows = rows;
this._filteredOutRows$.next(rows);
}
// TODO: we may need to update which cols have criteria rather than simple boolean
private readonly _hasCriteria$ = new BehaviorSubject<boolean>(false);
readonly hasCriteria$: Observable<boolean> = this._hasCriteria$.asObservable();
private _filterColumnByIndex = new Map<number, FilterColumn>();
private _alreadyFilteredOutRows = EMPTY();
private _range: Nullable<IRange>;
constructor(
public readonly unitId: string,
public readonly subUnitId: string,
private readonly _worksheet: Worksheet
) {
super();
}
override dispose(): void {
super.dispose();
this._filteredOutRows$.complete();
this._hasCriteria$.complete();
//@ts-ignore
this._worksheet = null;
}
/**
* Serialize this filter model to the JSON format representation.
*/
serialize(): IAutoFilter {
const result: IAutoFilter = {
ref: Rectangle.clone(this._range!),
filterColumns: this._getAllFilterColumns(true)
.sort(([offset1], [offset2]) => offset1 - offset2)
.map(([_, filterColumn]) => filterColumn.serialize()),
};
if (this._alreadyFilteredOutRows) {
result.cachedFilteredOut = Array.from(this._alreadyFilteredOutRows).sort();
}
return result;
}
/**
* Deserialize auto filter info to construct a `FilterModel` object.
* @param unitId workbook id
* @param subUnitId worksheet id
* @param worksheet the Worksheet object
* @param autoFilter auto filter data
*/
static deserialize(
unitId: string,
subUnitId: string,
worksheet: Worksheet,
autoFilter: IAutoFilter
): FilterModel {
const filterModel = new FilterModel(unitId, subUnitId, worksheet);
filterModel._dump(autoFilter);
return filterModel;
}
private _dump(autoFilter: IAutoFilter) {
this.setRange(autoFilter.ref);
autoFilter.filterColumns?.filter((filterColumn) => {
// we only support 3 types of filters now.Other types of filters are not supported yet.
if (!filterColumn.filters && !filterColumn.colorFilters && !filterColumn.customFilters) {
return false;
}
return true;
}).forEach((filterColumn) => this._setCriteriaWithoutReCalc(filterColumn.colId, filterColumn));
if (autoFilter.cachedFilteredOut) {
this._alreadyFilteredOutRows = new Set(autoFilter.cachedFilteredOut);
this._emit();
} else if (autoFilter.filterColumns && autoFilter.filterColumns.length > 0) {
// If there is no cached filtered out rows, we need to re-calc all columns.
this._reCalcAllColumns();
this._emit();
}
this._emitHasCriteria();
}
isRowFiltered(row: number): boolean {
return this._alreadyFilteredOutRows.has(row);
}
getRange(): IRange {
if (!this._range) {
throw new Error('[FilterModel] could not get range before a range is set!');
}
return this._range;
}
/**
* Get filtered out rows except the specific column. This method is considered as "pure". In
* another word it would not change `filteredOutRows` on `FilterModel` nor `FilterColumn`.
* @param col
*/
getFilteredOutRowsExceptCol(col: number): Set<number> {
return this._getAllFilterColumns(true)
.filter(([colOffset]) => colOffset !== col)
.reduce((acc, [, filterColumn]) => {
const newResult = filterColumn.calc({ getAlreadyFilteredOutRows: () => acc });
if (newResult) return mergeSets(acc, newResult);
return acc;
}, new Set<number>());
}
/**
* Set range of the filter model, this would remove some `IFilterColumn`
* if the new range not overlaps the old range.
*/
setRange(range: IRange): void {
this._range = range;
// TODO@wzhudev: maybe we should remove the FilterColumn that is not in the new range!
// TODO@wzhudev: when a column in the range is deleted, we may need to change some FilterColumns' offset
// set range for each FilterColumn
this._getAllFilterColumns(true)
.forEach(([col, filterColumn]) => {
filterColumn.setRangeAndColumn({
startRow: range.startRow,
endRow: range.endRow,
startColumn: col,
endColumn: col,
}, col);
});
}
/**
* Set or remove filter criteria on a specific row.
*/
setCriteria(col: number, criteria: Nullable<IFilterColumn>, reCalc = false): void {
if (!this._range) {
throw new Error('[FilterModel] could not set criteria before a range is set!');
}
if (!criteria) {
this._removeCriteria(col);
this._rebuildAlreadyFilteredOutRowsWithCache();
if (reCalc) {
this._reCalcAllColumns();
}
this._emit();
this._emitHasCriteria();
return;
}
this._setCriteriaWithoutReCalc(col, criteria);
if (reCalc) {
// save some performance if we can reuse the cached filtered out rows.
this._rebuildAlreadyFilteredOutRowsWithCache();
// but we still need to recalc the all columns.
this._getAllFilterColumns().forEach((filterColumn) => filterColumn.__clearCache());
this._reCalcWithNoCacheColumns();
this._emit();
this._emitHasCriteria();
}
}
getAllFilterColumns(): [number, FilterColumn][] {
return this._getAllFilterColumns(true);
}
getFilterColumn(index: number): Nullable<FilterColumn> {
return this._filterColumnByIndex.get(index) ?? null;
}
reCalc(): void {
this._reCalcAllColumns();
this._emit();
}
private _getAllFilterColumns(): FilterColumn[];
private _getAllFilterColumns(withCol: true): [number, FilterColumn][];
private _getAllFilterColumns(withCol = false): [number, FilterColumn][] | FilterColumn[] {
const columns = Array.from(this._filterColumnByIndex.entries());
if (withCol) {
return columns;
}
return columns.map(([_, filterColumn]) => filterColumn);
}
private _reCalcAllColumns(): void {
this._alreadyFilteredOutRows = EMPTY();
this._getAllFilterColumns().forEach((filterColumn) => filterColumn.__clearCache());
this._reCalcWithNoCacheColumns();
}
private _setCriteriaWithoutReCalc(col: number, criteria: IFilterColumn): void {
const range = this._range;
if (!range) {
throw new Error('[FilterModel] could not set criteria before a range is set!');
}
const { startColumn, endColumn } = range;
if (col > endColumn || col < startColumn) {
throw new Error(`[FilterModel] could not set criteria on column ${col} which is out of range!`);
}
let filterColumn: FilterColumn;
if (this._filterColumnByIndex.has(col)) {
filterColumn = this._filterColumnByIndex.get(col)!;
} else {
filterColumn = new FilterColumn(
this.unitId,
this.subUnitId,
this._worksheet,
criteria,
{ getAlreadyFilteredOutRows: () => this._alreadyFilteredOutRows }
);
filterColumn.setRangeAndColumn(range, col);
this._filterColumnByIndex.set(col, filterColumn);
}
filterColumn.setCriteria(criteria);
}
private _removeCriteria(col: number): void {
const filterColumn = this._filterColumnByIndex.get(col);
if (filterColumn) {
filterColumn.dispose();
this._filterColumnByIndex.delete(col);
}
}
private _emit(): void {
this._filteredOutRows$.next(this._alreadyFilteredOutRows);
}
private _emitHasCriteria(): void {
this._hasCriteria$.next(this._filterColumnByIndex.size > 0);
}
private _rebuildAlreadyFilteredOutRowsWithCache(): void {
const newFilteredOutRows = this._getAllFilterColumns()
.filter((filterColumn) => filterColumn.hasCache())
.reduce((acc, filterColumn) => {
return mergeSets(acc, filterColumn.filteredOutRows!);
}, new Set<number>());
this._alreadyFilteredOutRows = newFilteredOutRows;
}
private _reCalcWithNoCacheColumns(): void {
const noCacheFilteredOutRows = this._getAllFilterColumns().filter((filterColumn) => !filterColumn.hasCache());
for (const filterColumn of noCacheFilteredOutRows) {
const filteredRows = filterColumn.reCalc();
if (filteredRows) {
this._alreadyFilteredOutRows = mergeSets(this._alreadyFilteredOutRows, filteredRows);
}
}
}
}
interface IFilterColumnContext {
getAlreadyFilteredOutRows(): Set<number>;
}
/**
* This is the filter criteria on a specific column.
*/
export class FilterColumn extends Disposable {
private _filteredOutRows: Nullable<Set<number>> = null;
get filteredOutRows(): Readonly<Nullable<Set<number>>> { return this._filteredOutRows; }
/** Cache the filter function. */
private _filterFn: Nullable<FilterFn | ColorFilterFn> = null;
private _range: Nullable<IRange> = null;
private _column: number = 0;
private _filterBy: FilterBy = FilterBy.VALUES;
get filterBy(): Readonly<FilterBy> { return this._filterBy; }
constructor(
public readonly unitId: string,
public readonly subUnitId: string,
private readonly _worksheet: Worksheet,
/**
* A `FilterColumn` instance should not be created without a filter criteria.
*/
private _criteria: IFilterColumn,
private readonly _filterColumnContext: IFilterColumnContext
) {
super();
}
override dispose(): void {
super.dispose();
this._filteredOutRows = null;
}
/**
* @internal
*/
__clearCache(): void {
this._filteredOutRows = null;
}
serialize(): IFilterColumn {
if (!this._criteria) {
throw new Error('[FilterColumn]: could not serialize without a filter column!');
}
return Tools.deepClone({
...this._criteria,
colId: this._column,
});
}
hasCache(): boolean {
return this._filteredOutRows !== null;
}
// The first row should be omitted!
setRangeAndColumn(range: IRange, column: number): void {
this._range = range;
this._column = column;
}
setCriteria(criteria: IFilterColumn): void {
this._criteria = criteria;
this._generateFilterFn();
// clear cache
this._filteredOutRows = null;
}
getColumnData(): Readonly<IFilterColumn> {
return Tools.deepClone(this._criteria);
}
/**
* Trigger new calculation on this `FilterModel` instance.
*
* @external DO NOT EVER call this method from `FilterColumn` itself. The whole process heavily relies on
* `filteredOutByOthers`, and it is more comprehensible if we let `FilterModel` take full control over the process.
*/
reCalc(): Readonly<Nullable<Set<number>>> {
this._filteredOutRows = this.calc(this._filterColumnContext);
return this._filteredOutRows;
}
calc(context: IFilterColumnContext): Readonly<Nullable<Set<number>>> {
if (!this._filterFn) {
throw new Error('[FilterColumn] cannot calculate without a filter fn!');
}
if (!this._range) {
throw new Error('[FilterColumn] cannot calculate without a range!');
}
if (typeof this._column !== 'number') {
throw new TypeError('[FilterColumn] cannot calculate without a column offset!');
}
const column = this._column;
const iterateRange: IRange = { startColumn: column, endColumn: column, startRow: this._range.startRow + 1, endRow: this._range.endRow };
const filteredOutRows = new Set<number>();
const filteredOutByOthers = context.getAlreadyFilteredOutRows();
// Merged cells are take into consideration here.
for (const range of this._worksheet.iterateByColumn(iterateRange, false, false)) {
const { row, rowSpan, col } = range;
// If this row is already filtered out by others, we don't need to check it again.
// But it only works for non-vertically-merged cells.
if (filteredOutByOthers.has(row) && (!rowSpan || rowSpan === 1)) {
continue;
}
const filterResult = this._filterBy === FilterBy.VALUES
? (this._filterFn as FilterFn)(extractPureTextFromCell(this._worksheet.getCell(row, col)))
: this._filterBy === FilterBy.COLORS
? (this._filterFn as ColorFilterFn)(this._worksheet.getComposedCellStyle(row, col))
: (this._filterFn as FilterFn)(getFilterValueForConditionalFiltering(this._worksheet, row, col));
if (!filterResult) {
filteredOutRows.add(row);
// Add all rows into filtered out rows if the cell is a merged cell.
if (rowSpan) {
for (let i = 1; i < rowSpan; i++) {
filteredOutRows.add(row + i);
}
}
}
}
return filteredOutRows;
}
private _generateFilterFn(): void {
if (!this._criteria) {
return;
}
this._filterFn = generateFilterFn(this._criteria);
this._filterBy = this._criteria.filters
? FilterBy.VALUES
: this._criteria.colorFilters
? FilterBy.COLORS
: FilterBy.CONDITIONS;
}
}
/**
* Filter function is a close function which received a cell's content and determine this value is considered as
* "matched" and the corresponding row would not be filtered out.
*/
export type FilterFn = (value: Nullable<CellValue>) => boolean;
export type ColorFilterFn = (value: Nullable<IStyleData>) => boolean;
/**
* This functions take a `IFilterColumn` as input and return a function that can be used to filter rows.
* @param column
* @returns the filter function that takes the cell's value and return a boolean.
*/
export function generateFilterFn(column: IFilterColumn): FilterFn | ColorFilterFn {
if (column.filters) {
return filterByValuesFnFactory(column.filters);
}
if (column.colorFilters) {
return filterByColorsFnFactory(column.colorFilters);
}
if (column.customFilters) {
return customFilterFnFactory(column.customFilters);
}
throw new Error('[FilterModel]: other types of filters are not supported yet.');
}
function filterByValuesFnFactory(values: IFilters): FilterFn {
const includeBlank = !!values.blank;
const valuesSet = new Set(values.filters);
return (value) => {
if (value === undefined || value === '') return includeBlank;
return valuesSet.has(typeof value === 'string' ? value : `${value}`);
};
}
function filterByColorsFnFactory(colorFilters: IColorFilters): ColorFilterFn {
if (colorFilters.cellFillColors) {
const fillColorsSet = new Set(colorFilters.cellFillColors);
return (cellStyle) => {
// default cell fill color is save as null in the color filters.
if (!cellStyle || !cellStyle.bg?.rgb) {
if (fillColorsSet.has(null)) return true;
return false;
}
const bg = new ColorKit(cellStyle.bg.rgb).toRgbString();
return fillColorsSet.has(bg);
};
}
if (colorFilters.cellTextColors) {
const textColorsSet = new Set(colorFilters.cellTextColors);
return (cellStyle) => {
if (!cellStyle || !cellStyle.cl?.rgb) {
if (textColorsSet.has(COLOR_BLACK_RGB)) return true;
return false;
}
const cl = new ColorKit(cellStyle.cl.rgb).toRgbString();
return textColorsSet.has(cl);
};
}
throw new Error('[FilterModel]: color filters are not supported yet.');
}
function customFilterFnFactory(customFilters: ICustomFilters): FilterFn {
const customFilterFns: FilterFn[] = customFilters.customFilters.map((filter) => generateCustomFilterFn(filter));
if (isCompoundCustomFilter(customFilterFns)) {
if (customFilters.and) {
return AND(customFilterFns);
}
return OR(customFilterFns);
}
return customFilterFns[0];
}
function AND(filterFns: [FilterFn, FilterFn]): FilterFn {
const [fn1, fn2] = filterFns;
return (value) => fn1(value) && fn2(value);
}
function OR(filterFns: [FilterFn, FilterFn]): FilterFn {
const [fn1, fn2] = filterFns;
return (value) => fn1(value) || fn2(value);
}
function isCompoundCustomFilter(filter: FilterFn[]): filter is [FilterFn, FilterFn] {
return filter.length === 2;
}
// TODO@wzhudev: this is not ideal
function generateCustomFilterFn(filter: ICustomFilter): FilterFn {
const compare = filter.val;
// Not NOT_EQUALS, if the compare cannot be ensured as number, we should treat it like test not matching.
// Otherwise it goes to numeric match.
if (filter.operator === CustomFilterOperator.NOT_EQUALS) {
const ensured = ensureNumeric(compare);
if (!ensured) return (value) => notEquals.fn(value, compare);
}
// numeric match
if (isNumericFilterFn(filter.operator)) {
const ensured = ensureNumeric(compare);
if (!ensured) return () => false;
const customFilterFn = getCustomFilterFn(filter.operator);
const ensuredNumber = Number(compare);
return (value) => customFilterFn.fn(value, ensuredNumber);
}
// text match
const customFilterFn = getCustomFilterFn(filter.operator);
return (value) => customFilterFn.fn(value, compare);
}
function getFilterValueForConditionalFiltering(worksheet: Worksheet, row: number, col: number): Nullable<string | number | boolean> {
const interceptedCell = worksheet.getCell(row, col);
if (!interceptedCell) return null;
const rawCell = worksheet.getCellRaw(row, col);
if (interceptedCell && !rawCell) return extractFilterValueFromCell(interceptedCell);
if (!rawCell) return null;
if (interceptedCell.t === CellValueType.NUMBER && typeof interceptedCell.v === 'string') {
return rawCell.v as number;
}
// if the type is number, no matter what the raw value is, we should always return a number.
if (interceptedCell.t === CellValueType.NUMBER) {
return Number(rawCell.v);
}
return extractFilterValueFromCell(rawCell);
}
function extractFilterValueFromCell(cell: ICellData): string | number {
const richTextValue = cell.p?.body?.dataStream;
if (richTextValue) return richTextValue.trimEnd();
const rawValue = cell.v;
if (typeof rawValue === 'string') {
if (cell.t === CellValueType.BOOLEAN) return rawValue.toUpperCase();
return rawValue;
};
if (typeof rawValue === 'number') {
if (cell.t === CellValueType.BOOLEAN) return rawValue ? 'TRUE' : 'FALSE';
return rawValue;
};
if (typeof rawValue === 'boolean') return rawValue ? 'TRUE' : 'FALSE';
return '';
}

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 { BooleanNumber, IRange } from '@univerjs/core';
/**
* The filter types.
*/
export enum FilterBy {
VALUES,
COLORS,
CONDITIONS,
}
/**
* Data of a filter in a {@link Worksheet}.
*
* Please refer to 18.3.2 AutoFilter Settings. Properties of this interface would be added in the future.
* Please make sure that it is backward compatible.
*
* @property {IRange} ref The range of the filter.
* @property {IFilterColumn[]} [filterColumns] The filter criteria of each column.
* @property {number[]} [cachedFilteredOut] The cached filtered out row numbers.
*/
export interface IAutoFilter {
ref: IRange;
filterColumns?: IFilterColumn[];
cachedFilteredOut?: number[];
}
/**
* The filter criteria of a column.
*
* @property {number} colId The column number.
* @property {IFilters} [filters] The basic filters (filter by value).
* @property {ICustomFilters} [customFilters] The custom filters.
*/
export interface IFilterColumn {
colId: number;
filters?: IFilters;
colorFilters?: IColorFilters;
customFilters?: ICustomFilters;
};
/**
* Basic filters (filter by value).
*
* @property {true} [blank] Filter by blank. If this field is present, blank cells would be visible.
* @property {Array<string>} [filters] Filter by values. Cells with values in this array would be visible.
*/
export interface IFilters {
blank?: true;
filters?: Array<string>;
}
/**
* Color filters.
*
* @property {Array<string | null>} [cellFillColor] The cell fill colors to filter by. If this field is present, cells with these fill colors would be visible.
* @property {Array<string>} [cellTextColor] The cell text colors to filter by. If this field is present, cells with these text colors would be visible.
*/
export interface IColorFilters {
cellFillColors?: Array<string | null>;
cellTextColors?: Array<string>;
}
/**
* Custom filters.
*
* @property {BooleanNumber.TRUE} and The logical operator of the custom filters. If this field is present,
* the custom filters would be connected by 'and'. Otherwise, they would be connected by 'or'.
* @property {ICustomFilter[]} customFilters The custom filters.
*/
export interface ICustomFilters {
and?: BooleanNumber.TRUE;
customFilters: [ICustomFilter] | [ICustomFilter, ICustomFilter];
}
/**
* A custom filter.
*
* @property {string | number} val The value to be compared.
* @property {CustomFilterOperator} [operator] The operator of the comparison. If this field is empty, the operator would be 'equal'.
*/
export interface ICustomFilter {
val: string | number;
operator?: CustomFilterOperator;
}
/**
* Basic custom filter operators.
*
* @internal
* doesNotContain, isBlank, isNotBlank are not defined in OOXML. They are represented by regex-like values.
*/
export enum CustomFilterOperator {
/** "EQUAL" operator. */
EQUAL = 'equal',
/** "GREATER_THAN" operator. */
GREATER_THAN = 'greaterThan',
/** "GREATER_THAN_OR_EQUAL" operator. */
GREATER_THAN_OR_EQUAL = 'greaterThanOrEqual',
/** "LESS_THAN" operator. */
LESS_THAN = 'lessThan',
/** "LESS_THAN_OR_EQUAL" operator. */
LESS_THAN_OR_EQUAL = 'lessThanOrEqual',
/** "NOT_EQUALS" operator. */
NOT_EQUALS = 'notEqual',
}
export interface IDynamicFilter {
val: string | number;
type: DynamicFilterOperator;
}
/**
* Not used now. Would be used in the future.
*/
export enum DynamicFilterOperator {
ABOVE_AVERAGE = 'aboveAverage',
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsFilterConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, touchDependencies, UniverInstanceType } from '@univerjs/core';
import { UniverFormulaEnginePlugin } from '@univerjs/engine-formula';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_FILTER_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetsFilterSyncController } from './controllers/sheets-filter-sync.controller';
import { SheetsFilterController } from './controllers/sheets-filter.controller';
import { SheetsFilterFormulaService } from './services/sheet-filter-formula.service';
import { SHEET_FILTER_SNAPSHOT_ID, SheetsFilterService } from './services/sheet-filter.service';
@DependentOn(UniverFormulaEnginePlugin, UniverSheetsPlugin)
export class UniverSheetsFilterPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = SHEET_FILTER_SNAPSHOT_ID;
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverSheetsFilterConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(SHEETS_FILTER_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([
[SheetsFilterFormulaService],
[SheetsFilterService],
[SheetsFilterController],
[SheetsFilterSyncController],
] as Dependency[]).forEach((d) => this._injector.add(d));
}
override onReady(): void {
touchDependencies(this._injector, [
[SheetsFilterFormulaService],
[SheetsFilterController],
[SheetsFilterSyncController],
]);
}
}

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 { Injector, IUniverInstanceService } from '@univerjs/core';
import { IActiveDirtyManagerService, ISheetRowFilteredService } from '@univerjs/engine-formula';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SheetsFilterFormulaService } from '../sheet-filter-formula.service';
import { SheetsFilterService } from '../sheet-filter.service';
describe('SheetsFilterFormulaService', () => {
let dirtyConverters: Map<string, { getDirtyData: (commandInfo: unknown) => unknown }>;
let rowFilteredCallback: ((unitId: string, subUnitId: string, row: number) => boolean) | undefined;
beforeEach(() => {
dirtyConverters = new Map();
class TestActiveDirtyManagerService {
register = (commandId: string, converter: never) => dirtyConverters.set(commandId, converter);
}
class TestSheetRowFilteredService {
register = (callback: never) => (rowFilteredCallback = callback);
}
class TestSheetsFilterService {
getFilterModel = vi.fn(() => ({ getRange: () => ({ startRow: 2, endRow: 5 }), isRowFiltered: (row: number) => row === 3 }));
}
class TestUniverInstanceService {
getUnit = () => ({ getSheetBySheetId: () => ({ getColumnCount: () => 10 }) });
}
const injector = new Injector();
injector.add([IActiveDirtyManagerService, { useClass: TestActiveDirtyManagerService as never }]);
injector.add([ISheetRowFilteredService, { useClass: TestSheetRowFilteredService as never }]);
injector.add([SheetsFilterService, { useClass: TestSheetsFilterService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([SheetsFilterFormulaService]);
injector.get(SheetsFilterFormulaService);
});
it('marks the filtered row span dirty for formula recalculation', () => {
const converter = [...dirtyConverters.values()][0];
expect(converter.getDirtyData({ params: { unitId: 'book-1', subUnitId: 'sheet-1' } })).toEqual({
dirtyRanges: [{
unitId: 'book-1',
sheetId: 'sheet-1',
range: { startRow: 2, startColumn: 0, endRow: 5, endColumn: 9 },
}],
clearDependencyTreeCache: { 'book-1': { 'sheet-1': '1' } },
});
});
it('registers formula row-filter lookups against the active sheet filter model', () => {
expect(rowFilteredCallback?.('book-1', 'sheet-1', 3)).toBe(true);
expect(rowFilteredCallback?.('book-1', 'sheet-1', 4)).toBe(false);
});
});

View File

@@ -0,0 +1,170 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 {
CommandService,
ConfigService,
ContextService,
DesktopLogService,
ICommandService,
IConfigService,
IContextService,
ILogService,
Injector,
IResourceManagerService,
IUniverInstanceService,
ResourceManagerService,
UniverInstanceService,
Workbook,
} from '@univerjs/core';
import { afterEach, describe, expect, it } from 'vitest';
import { SheetsFilterService } from '../sheet-filter.service';
const injectors: Injector[] = [];
function createService() {
const injector = new Injector();
injectors.push(injector);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IConfigService, { useClass: ConfigService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([ICommandService, { useClass: CommandService }]);
injector.add([IResourceManagerService, { useClass: ResourceManagerService }]);
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
injector.add([SheetsFilterService]);
const univerInstanceService = injector.get(IUniverInstanceService) as UniverInstanceService;
const workbook = injector.createInstance(Workbook, {
id: 'unit-1',
sheets: { 'sheet-1': { id: 'sheet-1' } },
sheetOrder: ['sheet-1'],
});
univerInstanceService.__addUnit(workbook);
univerInstanceService.focusUnit('unit-1');
return injector.get(SheetsFilterService);
}
function createServiceWithCapturedResource() {
let resourceConfig: any;
class TestResourceManagerService {
registerPluginResource(config: unknown) {
resourceConfig = config;
return { dispose: () => undefined };
}
}
class TestCommandService {
onCommandExecuted = () => ({ dispose: () => undefined });
}
const injector = new Injector();
injectors.push(injector);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IContextService, { useClass: ContextService }]);
injector.add([IResourceManagerService, { useClass: TestResourceManagerService as never }]);
injector.add([IUniverInstanceService, { useClass: UniverInstanceService }]);
injector.add([ICommandService, { useClass: TestCommandService as never }]);
injector.add([SheetsFilterService]);
const univerInstanceService = injector.get(IUniverInstanceService) as UniverInstanceService;
const workbook = injector.createInstance(Workbook, {
id: 'unit-1',
sheets: { 'sheet-1': { id: 'sheet-1' } },
sheetOrder: ['sheet-1'],
});
univerInstanceService.__addUnit(workbook);
univerInstanceService.focusUnit('unit-1');
return { service: injector.get(SheetsFilterService), getResourceConfig: () => resourceConfig };
}
afterEach(() => injectors.splice(0).forEach((injector) => injector.dispose()));
describe('SheetsFilterService', () => {
it('creates and removes filter models for workbook sheets', () => {
const service = createService();
const model = service.ensureFilterModel('unit-1', 'sheet-1');
expect(service.getFilterModel('unit-1', 'sheet-1')).toBe(model);
expect(service.removeFilterModel('unit-1', 'sheet-1')).toBe(true);
expect(service.getFilterModel('unit-1', 'sheet-1')).toBeNull();
expect(service.removeFilterModel('unit-1', 'sheet-1')).toBe(false);
});
it('throws when creating filter models for missing workbook or worksheet', () => {
const service = createService();
expect(() => service.ensureFilterModel('missing', 'sheet-1')).toThrow('non-existing workbook missing');
expect(() => service.ensureFilterModel('unit-1', 'missing')).toThrow('non-existing worksheet missing');
});
it('publishes filter error messages for UI consumers', () => {
const service = createService();
const messages: unknown[] = [];
const sub = service.errorMsg$.subscribe((message) => messages.push(message));
service.setFilterErrorMsg('Invalid filter range');
expect(messages).toEqual([null, 'Invalid filter range']);
sub.unsubscribe();
});
it('disposes cached filter models and completes state streams', () => {
const service = createService();
const model = service.ensureFilterModel('unit-1', 'sheet-1');
const completions: string[] = [];
service.loadedUnitId$.subscribe({ complete: () => completions.push('loaded') });
service.errorMsg$.subscribe({ complete: () => completions.push('error') });
service.activeFilterModel$.subscribe({ complete: () => completions.push('active') });
service.dispose();
expect(service.getFilterModel('unit-1', 'sheet-1')).toBeNull();
expect((model as never as { _disposed: boolean })._disposed).toBe(true);
expect(completions).toEqual(['loaded', 'error', 'active']);
});
it('loads and unloads filter models from snapshot resources and updates active model', () => {
const { service, getResourceConfig } = createServiceWithCapturedResource();
const loaded: unknown[] = [];
const active: unknown[] = [];
service.loadedUnitId$.subscribe((unitId) => loaded.push(unitId));
service.activeFilterModel$.subscribe((model) => active.push(model));
expect(getResourceConfig().toJson('unit-1')).toBe('{}');
getResourceConfig().onLoad('unit-1', {
'sheet-1': {
ref: { startRow: 0, endRow: 3, startColumn: 0, endColumn: 2 },
filterColumns: [],
},
});
expect(loaded).toEqual([null, 'unit-1']);
expect(service.getFilterModel('unit-1', 'sheet-1')).toBe(service.activeFilterModel);
expect(active.at(-1)).toBe(service.activeFilterModel);
expect(JSON.parse(getResourceConfig().toJson('unit-1'))['sheet-1']).toMatchObject({
ref: { startRow: 0, endRow: 3, startColumn: 0, endColumn: 2 },
});
getResourceConfig().onUnLoad('unit-1');
expect(service.getFilterModel('unit-1', 'sheet-1')).toBeNull();
expect(JSON.stringify(getResourceConfig().parseJson('{"sheet-1":{"filterColumns":[]}}'))).toBe('{"sheet-1":{"filterColumns":[]}}');
});
});

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 { ISheetCommandSharedParams } from '@univerjs/sheets';
import { Disposable, Inject, IUniverInstanceService } from '@univerjs/core';
import { IActiveDirtyManagerService, ISheetRowFilteredService } from '@univerjs/engine-formula';
import { FILTER_MUTATIONS } from '../common/const';
import { SheetsFilterService } from './sheet-filter.service';
/**
* Hidden rows after filtering affect formula calculations, such as SUBTOTAL
*/
export class SheetsFilterFormulaService extends Disposable {
constructor(
@Inject(IActiveDirtyManagerService) private _activeDirtyManagerService: IActiveDirtyManagerService,
@Inject(ISheetRowFilteredService) private _sheetRowFilteredService: ISheetRowFilteredService,
@Inject(SheetsFilterService) private _sheetsFilterService: SheetsFilterService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._initFormulaDirtyRange();
this._registerSheetRowFiltered();
}
private _initFormulaDirtyRange() {
FILTER_MUTATIONS.forEach((commandId) => {
this._activeDirtyManagerService.register(
commandId,
{
commandId,
getDirtyData: (commandInfo) => {
const params = commandInfo.params as ISheetCommandSharedParams;
const { unitId, subUnitId } = params;
return {
dirtyRanges: this._getHideRowMutation(unitId, subUnitId),
clearDependencyTreeCache: {
[unitId]: {
[subUnitId]: '1',
},
},
};
},
}
);
});
}
private _getHideRowMutation(unitId: string, subUnitId: string) {
const range = this._sheetsFilterService.getFilterModel(unitId, subUnitId)?.getRange();
const sheet = this._univerInstanceService.getUnit<Workbook>(unitId)?.getSheetBySheetId(subUnitId);
if (range == null || sheet == null) {
return [];
}
const { startRow, endRow } = range;
// covert hidden rows to dirtyRanges
const dirtyRanges = [{
unitId,
sheetId: subUnitId,
range: {
startRow,
startColumn: 0,
endRow,
endColumn: sheet.getColumnCount() - 1,
},
}];
return dirtyRanges;
}
private _registerSheetRowFiltered() {
this._sheetRowFilteredService.register((unitId, subUnitId, row) => {
return this._sheetsFilterService.getFilterModel(unitId, subUnitId)?.isRowFiltered(row) ?? false;
});
}
}

View File

@@ -0,0 +1,216 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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, Workbook } from '@univerjs/core';
import type { IAutoFilter } from '../models/types';
import {
CommandType,
Disposable,
fromCallback,
ICommandService,
IResourceManagerService,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { BehaviorSubject, filter, merge, of, switchMap } from 'rxjs';
import { FILTER_MUTATIONS } from '../common/const';
import { FilterModel } from '../models/filter-model';
type WorksheetID = string;
export interface ISheetsFilterResource {
[key: WorksheetID]: IAutoFilter;
}
export const SHEET_FILTER_SNAPSHOT_ID = 'SHEET_FILTER_PLUGIN';
/**
* This service is responsible for managing filter models, especially their lifecycle.
*/
export class SheetsFilterService extends Disposable {
private readonly _filterModels = new Map<string, Map<string, FilterModel>>();
private readonly _loadedUnitId$ = new BehaviorSubject<Nullable<string>>(null);
readonly loadedUnitId$ = this._loadedUnitId$.asObservable();
private readonly _errorMsg$ = new BehaviorSubject<Nullable<string>>(null);
readonly errorMsg$ = this._errorMsg$.asObservable();
private readonly _activeFilterModel$ = new BehaviorSubject<Nullable<FilterModel>>(null);
/** An observable value emitting the current Workbook's active Worksheet's filter model (if there is one). */
readonly activeFilterModel$ = this._activeFilterModel$.asObservable();
/** The current Workbook's active Worksheet's filter model (if there is one). */
get activeFilterModel(): Nullable<FilterModel> { return this._activeFilterModel$.getValue(); }
constructor(
@IResourceManagerService private readonly _resourcesManagerService: IResourceManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._initModel();
this._initActiveFilterModel();
}
/**
*
* @param unitId
* @param subUnitId
*/
ensureFilterModel(unitId: string, subUnitId: string): FilterModel {
const already = this.getFilterModel(unitId, subUnitId);
if (already) {
return already;
}
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
throw new Error(`[SheetsFilterService]: could not create "FilterModel" on a non-existing workbook ${unitId}!`);
}
const worksheet = workbook.getSheetBySheetId(subUnitId);
if (!worksheet) {
throw new Error(`[SheetsFilterService]: could not create "FilterModel" on a non-existing worksheet ${subUnitId}!`);
}
const filterModel = new FilterModel(unitId, subUnitId, worksheet);
this._cacheFilterModel(unitId, subUnitId, filterModel);
return filterModel;
}
getFilterModel(unitId: string, subUnitId: string): Nullable<FilterModel> {
return this._filterModels.get(unitId)?.get(subUnitId) ?? null;
}
removeFilterModel(unitId: string, subUnitId: string): boolean {
const already = this.getFilterModel(unitId, subUnitId);
if (already) {
already.dispose();
this._filterModels.get(unitId)!.delete(subUnitId);
return true;
}
return false;
}
setFilterErrorMsg(content: string): void {
this._errorMsg$.next(content);
}
private _updateActiveFilterModel(): void {
let workbook: Nullable<Workbook>;
try {
workbook = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
this._activeFilterModel$.next(null);
return;
}
} catch (err) {
console.error('[SheetsFilterService]: could not get active workbook!', err);
return;
}
// Use getActiveSheet to avoid automatically activating the next sheet when deleting the sheet, causing the sheet switching in ActiveWorksheetController to be invalid.
const activeSheet = workbook.getActiveSheet(true);
if (!activeSheet) {
this._activeFilterModel$.next(null);
return;
}
const unitId = activeSheet.getUnitId();
const subUnitId = activeSheet.getSheetId();
const filterModel = this.getFilterModel(unitId, subUnitId);
this._activeFilterModel$.next(filterModel);
}
private _initActiveFilterModel(): void {
this.disposeWithMe(
merge(
// source1: executing filter related mutations
fromCallback(this._commandService.onCommandExecuted.bind(this._commandService))
.pipe(filter(([command]) => command.type === CommandType.MUTATION && FILTER_MUTATIONS.has(command.id))),
// source2: activate sheet changes
this._univerInstanceService.getCurrentTypeOfUnit$<Workbook>(UniverInstanceType.UNIVER_SHEET)
.pipe(switchMap((workbook) => workbook?.activeSheet$ ?? of(null)))
).subscribe(() => this._updateActiveFilterModel())
);
}
private _serializeAutoFiltersForUnit(unitId: string): string {
const allFilterModels = this._filterModels.get(unitId);
if (!allFilterModels) {
return '{}';
}
const json: ISheetsFilterResource = {};
allFilterModels.forEach((model, worksheetId) => {
json[worksheetId] = model.serialize();
});
return JSON.stringify(json);
}
private _deserializeAutoFiltersForUnit(unitId: string, json: ISheetsFilterResource): void {
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET)!;
Object.keys(json).forEach((worksheetId: WorksheetID) => {
const autoFilter = json[worksheetId]!;
const filterModel = FilterModel.deserialize(unitId, worksheetId, workbook.getSheetBySheetId(worksheetId)!, autoFilter);
this._cacheFilterModel(unitId, worksheetId, filterModel);
});
}
override dispose(): void {
super.dispose();
this._loadedUnitId$.complete();
this._errorMsg$.complete();
this._activeFilterModel$.complete();
this._filterModels.forEach((allFilterModels) => {
allFilterModels.forEach((model) => model.dispose());
allFilterModels.clear();
});
this._filterModels.clear();
}
private _initModel(): void {
this._resourcesManagerService.registerPluginResource<ISheetsFilterResource>({
pluginName: SHEET_FILTER_SNAPSHOT_ID,
businesses: [UniverInstanceType.UNIVER_SHEET],
toJson: (id) => this._serializeAutoFiltersForUnit(id),
parseJson: (json) => JSON.parse(json),
onLoad: (unitId, value) => {
this._deserializeAutoFiltersForUnit(unitId, value);
this._loadedUnitId$.next(unitId);
this._updateActiveFilterModel();
},
onUnLoad: (unitId: string) => {
const allFilterModels = this._filterModels.get(unitId);
if (allFilterModels) {
allFilterModels.forEach((model) => model.dispose());
this._filterModels.delete(unitId);
}
},
});
}
private _cacheFilterModel(unitId: string, subUnitId: string, filterModel: FilterModel): void {
if (!this._filterModels.has(unitId)) {
this._filterModels.set(unitId, new Map());
}
this._filterModels.get(unitId)!.set(subUnitId, filterModel);
}
}

View File

@@ -0,0 +1,54 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IMutationInfo, Nullable } from '@univerjs/core';
import type { ISetSheetsFilterCriteriaMutationParams } from './commands/mutations/sheets-filter.mutation';
import { SetSheetsFilterCriteriaMutation } from './commands/mutations/sheets-filter.mutation';
interface ILine {
start: number;
end: number;
}
export function lineIntersect(line1: ILine, line2: ILine): boolean {
return line1.start <= line2.end && line1.end >= line2.start;
}
export function lineContains(line1: ILine, line2: ILine): boolean {
return line1.start <= line2.start && line1.end >= line2.end;
}
export function objectsShaker<T>(target: Nullable<T>[], isEqual: (o1: T, o2: T) => boolean) {
for (let i = 0; i < target.length; i++) {
let cur = i;
if (target[i]) {
for (let j = i + 1; j < target.length; j++) {
if (target[cur] && target[j] && isEqual(target[cur]!, target[j]!)) {
target[cur] = null;
cur = j;
}
}
}
}
return target.filter((o) => o !== null) as T[];
};
export function mergeSetFilterCriteria(mutations: IMutationInfo[]) {
return objectsShaker(mutations, (o1, o2) =>
o1.id === SetSheetsFilterCriteriaMutation.id && o2.id === SetSheetsFilterCriteriaMutation.id
&& (o1.params as ISetSheetsFilterCriteriaMutationParams).unitId === (o2.params as ISetSheetsFilterCriteriaMutationParams).unitId
&& (o1.params as ISetSheetsFilterCriteriaMutationParams).subUnitId === (o2.params as ISetSheetsFilterCriteriaMutationParams).subUnitId
&& (o1.params as ISetSheetsFilterCriteriaMutationParams).col === (o2.params as ISetSheetsFilterCriteriaMutationParams).col);
}