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,629 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { LocaleType } from '@univerjs/core';
import { CustomFilterOperator, SHEET_FILTER_SNAPSHOT_ID } from '@univerjs/sheets-filter';
/// This file contains mock up snapshots for testing purposes.
export function WithCustomFilterModelFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: { 0: { v: 'Header' } },
1: { 0: { v: 1 } },
2: { 0: { v: 2 } },
3: { 0: { v: 3 } },
4: { 0: { v: 4 } },
5: { 0: { v: 5 } },
6: { 0: { v: 6 } },
7: { 0: { v: 7 } },
8: { 0: { v: 8 } },
9: { 0: { v: 9 } },
},
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
sheet1: {
ref: { startColumn: 0, startRow: 0, endColumn: 5, endRow: 5 },
filterColumns: [
{
colId: 0,
customFilters: {
customFilters: [{ val: 123, operator: CustomFilterOperator.GREATER_THAN }],
},
},
],
},
}),
},
],
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [
'sheet1',
],
styles: {},
};
}
/* eslint-disable max-lines-per-function */
export function WithValuesFilterModelFactory(): IWorkbookData {
return {
id: 'test',
sheetOrder: [
'sheet1',
],
name: '',
appVersion: '3.0.0-alpha',
locale: LocaleType.ZH_CN,
styles: {
GOAhZb: {
n: {
pattern: 'yyyy-mm-dd',
},
},
'A-iqEg': {
n: {
pattern: 'yyyy/mm/dd',
},
},
_yIXzW: {
n: {
pattern: 'yyyy-m-d am/pm h:mm',
},
},
qLSh7Z: {
n: {
pattern: 'yyyy"年"MM"月"dd"日"',
},
},
},
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'Column Header',
},
},
1: {
0: {
v: '1',
},
},
2: {
0: {
v: '2',
},
},
3: {
0: {
v: 'Michael Jackson King of Pop',
},
},
4: {
0: {
v: 'Michael',
},
},
5: {
0: {
v: 'Jackson',
},
},
6: {
0: {
v: 44876,
t: 2,
s: 'GOAhZb',
},
},
7: {
0: {
v: 44877,
t: 2,
s: 'GOAhZb',
},
},
8: {
0: {
v: 44878,
t: 2,
s: 'A-iqEg',
},
},
9: {
0: {
v: 44879,
t: 2,
s: '_yIXzW',
},
},
10: {
0: {
v: 44880,
t: 2,
s: 'qLSh7Z',
},
},
},
name: 'Sheet1',
rowCount: 1000,
columnCount: 20,
mergeData: [],
},
},
resources: [
{
name: 'SHEET_FILTER_PLUGIN',
data: '{"sheet1":{"ref":{"startRow":0,"startColumn":0,"endRow":10,"endColumn":10},"filterColumns":[],"cachedFilteredOut":[]}}',
},
],
};
}
export function WithValuesAndEmptyFilterModelFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: { 0: { v: 'Column Header' } }, // this row should not appear in filter panel
1: { 0: { v: '1' } },
2: { 0: { v: '2' } },
3: { 0: { v: 'Michael Jackson King of Pop' } },
4: { 0: { v: 'Michael' } },
5: { 0: { v: 'Jackson' } },
6: { 0: { v: 'Evan Wallace' } },
7: { 0: { v: 'Evan' } },
8: { 0: { v: 'Wallace' } },
9: { 0: { v: 'Steve' } },
10: { 0: { v: 'Minecraft' } },
11: { 0: { v: '' } },
},
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
sheet1: {
ref: { startColumn: 0, startRow: 0, endColumn: 11, endRow: 11 },
filterColumns: [
{
colId: 0,
filters: { blank: true, filters: ['1'] },
},
],
},
}),
},
],
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [
'sheet1',
],
styles: {},
};
}
export function WithMultiEmptyCellsModelFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: { 0: { v: 'Column Header' } }, // this row should not appear in filter panel
1: { 0: { v: '1' } },
2: { 0: { v: '2' } },
3: { 0: { v: 'Michael Jackson King of Pop' } },
4: { 0: { v: 'Michael' } },
5: { 0: { v: 'Jackson' } },
6: { 0: { v: '' } },
7: { 0: { v: '' } },
8: { 0: { v: '' } },
9: { 0: { v: 'Steve' } },
10: { 0: { v: 'Minecraft' } },
11: { 0: { v: '' } },
},
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
sheet1: {
ref: { startColumn: 0, startRow: 0, endColumn: 11, endRow: 11 },
filterColumns: [
{
colId: 0,
filters: { blank: true, filters: ['1'] },
},
],
},
}),
},
],
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [
'sheet1',
],
styles: {},
};
}
export function WithTwoFilterColumnsFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: { 0: { v: 'Column Header' }, 1: { v: 'Header 2' } }, // this row should not appear in filter panel
1: { 0: { v: '1' }, 1: { v: 'a' } },
2: { 0: { v: '2' }, 1: { v: 'b' } },
3: { 0: { v: '3' }, 1: { v: 'c' } },
4: { 0: { v: '4' }, 1: { v: 'd' } },
5: { 0: { v: '5' }, 1: { v: 'e' } },
6: { 0: { v: '6' }, 1: { v: 'f' } },
7: { 0: { v: '7' }, 1: { v: 'g' } },
8: { 0: { v: '8' }, 1: { v: 'h' } },
},
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
sheet1: {
ref: { startColumn: 0, startRow: 0, endColumn: 11, endRow: 11 },
filterColumns: [
{
colId: 0,
filters: { blank: true, filters: ['1', '2', '3'] },
},
],
},
}),
},
],
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [
'sheet1',
],
styles: {},
};
}
export function WithMergedCellFilterFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: { 0: { v: 'Column Header' }, 1: { v: 'Header 2' } }, // this row should not appear in filter panel
1: { 0: { v: '1' }, 1: { v: 'a' } },
2: { 0: { v: '2' }, 1: { v: 'b' } },
3: { 0: { v: '3' }, 1: { v: 'c' } },
4: { 0: { v: '' }, 1: { v: '' } },
5: { 0: { v: '5' }, 1: { v: 'e' } },
6: { 0: { v: '6' }, 1: { v: 'f' } },
7: { 0: { v: '7' }, 1: { v: 'g' } },
8: { 0: { v: '8' }, 1: { v: 'h' } },
},
mergeData: [
{ startRow: 3, endRow: 4, startColumn: 0, endColumn: 1 },
],
},
},
resources: [
{
name: SHEET_FILTER_SNAPSHOT_ID,
data: JSON.stringify({
sheet1: {
ref: { startColumn: 0, startRow: 0, endColumn: 11, endRow: 11 },
filterColumns: [
{
colId: 0,
filters: { blank: true, filters: ['3', '4', '5'] },
},
],
},
}),
},
],
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [
'sheet1',
],
styles: {},
};
}
export const ITEMS = [
{
title: '2022',
key: '2022',
children: [
{
title: 'sheets-filter-ui.date.11',
key: '2022-11',
children: [
{
title: '11',
key: '2022-11-11',
count: 1,
originValues: new Set(['44876']),
leaf: true,
checked: true,
},
{
title: '12',
key: '2022-11-12',
count: 1,
originValues: new Set(['44877']),
leaf: true,
checked: true,
},
{
title: '13',
key: '2022-11-13',
count: 1,
originValues: new Set(['44878']),
leaf: true,
checked: true,
},
{
title: '14',
key: '2022-11-14',
count: 1,
originValues: new Set(['44879']),
leaf: true,
checked: true,
},
{
title: '15',
key: '2022-11-15',
count: 1,
originValues: new Set(['44880']),
leaf: true,
checked: true,
},
],
count: 5,
leaf: false,
checked: false,
},
],
count: 5,
leaf: false,
checked: false,
},
{
title: '1',
leaf: true,
checked: true,
key: '1',
count: 1,
},
{
title: '2',
leaf: true,
checked: true,
key: '2',
count: 1,
},
{
title: 'Jackson',
leaf: true,
checked: true,
key: 'Jackson',
count: 1,
},
{
title: 'Michael',
leaf: true,
checked: true,
key: 'Michael',
count: 1,
},
{
title: 'Michael Jackson King of Pop',
leaf: true,
checked: true,
key: 'Michael Jackson King of Pop',
count: 1,
},
];
export const ITEMS_WITH_EMPTY = [
{
title: '1',
leaf: true,
checked: true,
key: '1',
count: 1,
},
{
title: '2',
leaf: true,
checked: false,
key: '2',
count: 1,
},
{
title: 'Evan',
leaf: true,
checked: false,
key: 'Evan',
count: 1,
},
{
title: 'Evan Wallace',
leaf: true,
checked: false,
key: 'Evan Wallace',
count: 1,
},
{
title: 'Jackson',
leaf: true,
checked: false,
key: 'Jackson',
count: 1,
},
{
title: 'Michael',
leaf: true,
checked: false,
key: 'Michael',
count: 1,
},
{
title: 'Michael Jackson King of Pop',
leaf: true,
checked: false,
key: 'Michael Jackson King of Pop',
count: 1,
},
{
title: 'Minecraft',
leaf: true,
checked: false,
key: 'Minecraft',
count: 1,
},
{
title: 'sheets-filter-ui.panel.empty',
count: 1,
leaf: true,
checked: true,
key: 'empty',
},
{
title: 'Steve',
leaf: true,
checked: false,
key: 'Steve',
count: 1,
},
{
title: 'Wallace',
leaf: true,
checked: false,
key: 'Wallace',
count: 1,
},
];
export const E_ITEMS = [
{
title: '2022',
key: '2022',
children: [
{
title: 'sheets-filter-ui.date.11',
key: '2022-11',
children: [
{
title: '11',
key: '2022-11-11',
count: 1,
originValues: new Set(['44876']),
leaf: true,
checked: true,
},
{
title: '12',
key: '2022-11-12',
count: 1,
originValues: new Set(['44877']),
leaf: true,
checked: true,
},
{
title: '13',
key: '2022-11-13',
count: 1,
originValues: new Set(['44878']),
leaf: true,
checked: true,
},
{
title: '14',
key: '2022-11-14',
count: 1,
originValues: new Set(['44879']),
leaf: true,
checked: true,
},
{
title: '15',
key: '2022-11-15',
count: 1,
originValues: new Set(['44880']),
leaf: true,
checked: true,
},
],
count: 5,
leaf: false,
checked: false,
},
],
count: 5,
leaf: false,
checked: false,
},
{
title: 'Michael',
leaf: true,
checked: true,
key: 'Michael',
count: 1,
},
{
title: 'Michael Jackson King of Pop',
leaf: true,
checked: true,
key: 'Michael Jackson King of Pop',
count: 1,
},
];

View File

@@ -0,0 +1,352 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IRange, IWorkbookData, Workbook } from '@univerjs/core';
import type { ISetRangeValuesCommandParams } from '@univerjs/sheets';
import {
ICommandService,
Inject,
Injector,
IUniverInstanceService,
LocaleService,
LocaleType,
Plugin,
RANGE_TYPE,
RedoCommand,
toDisposable,
UndoCommand,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { SetRangeValuesCommand, SheetsSelectionsService } from '@univerjs/sheets';
import {
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
SetSheetsFilterCriteriaCommand,
SetSheetsFilterRangeMutation,
SheetsFilterService,
SmartToggleSheetsFilterCommand,
UniverSheetsFilterPlugin,
} from '@univerjs/sheets-filter';
import { IMessageService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
class MockMessageService {
show(): IDisposable {
return toDisposable(() => undefined);
}
remove(): void {}
removeAll(): void {}
setContainer(): void {}
getContainer(): undefined {
return undefined;
}
}
function testWorkbookDataFactory(): IWorkbookData {
return {
id: 'test',
appVersion: '3.0.0-alpha',
sheets: {
sheet1: {
id: 'sheet1',
cellData: {
0: {
0: {
v: 'A1',
},
},
1: {
0: {
v: '1',
},
1: {
v: 'a',
},
},
2: {
0: {
v: '2',
},
1: {
v: 'b',
},
},
3: {
0: {
v: '3',
},
1: {
v: 'c',
},
},
},
name: 'Sheet-001',
},
},
locale: LocaleType.ZH_CN,
name: '',
sheetOrder: [],
styles: {},
};
};
function createFilterCommandTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class SheetsFilterCommandTestPlugin extends Plugin {
static override pluginName = 'sheets-filter-command-test';
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(_config: unknown, @Inject(Injector) protected readonly _injector: Injector) {
super();
}
override onStarting(): void {
this._injector.add([IMessageService, { useClass: MockMessageService }]);
}
}
univer.registerPlugin(UniverSheetsFilterPlugin);
univer.registerPlugin(SheetsFilterCommandTestPlugin);
get(LocaleService).load({});
univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, testWorkbookDataFactory());
get(ICommandService).registerCommand(SmartToggleSheetsFilterCommand);
const univerInstanceService = get(IUniverInstanceService);
univerInstanceService.focusUnit('test');
return { univer, get };
}
describe('test sheets filter commands', () => {
let get: Injector['get'];
let commandService: ICommandService;
let sheetsFilterService: SheetsFilterService;
let selectionManagerService: SheetsSelectionsService;
beforeEach(() => {
const testBed = createFilterCommandTestBed();
get = testBed.get;
commandService = get(ICommandService);
sheetsFilterService = get(SheetsFilterService);
selectionManagerService = get(SheetsSelectionsService);
});
afterEach(() => {
});
function select(range: IRange) {
const { startColumn, startRow, endColumn, endRow } = range;
selectionManagerService.addSelections([
{
range: { startRow, startColumn, endColumn, endRow, rangeType: RANGE_TYPE.NORMAL },
primary: {
startRow,
startColumn,
endColumn,
endRow,
actualRow: startRow,
actualColumn: startColumn,
isMerged: false,
isMergedMainCell: false,
},
style: null,
},
]);
}
function getFilterModel() {
return sheetsFilterService.getFilterModel('test', 'sheet1')!;
}
describe('test "SmartToggleSheetsFilterCommand"', () => {
it('should remove filter when there is one', async () => {
expect(commandService.syncExecuteCommand(SetSheetsFilterRangeMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 },
}));
expect(getFilterModel()).toBeTruthy();
expect(await commandService.executeCommand(SmartToggleSheetsFilterCommand.id)).toBeTruthy();
expect(getFilterModel()).toBeNull();
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
const filterModel = getFilterModel();
expect(filterModel).toBeTruthy();
expect(filterModel!.getRange()).toEqual({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getFilterModel()).toBeNull();
});
it('should add filter when there is no filter', async () => {
expect(getFilterModel()).toBeNull();
// since there is no selection, it should return false
expect(await commandService.executeCommand(SmartToggleSheetsFilterCommand.id)).toBeFalsy();
select({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(await commandService.executeCommand(SmartToggleSheetsFilterCommand.id)).toBeTruthy();
expect(getFilterModel()!.getRange()).toEqual({
startRow: 0,
startColumn: 0,
endRow: 5,
endColumn: 5,
rangeType: RANGE_TYPE.NORMAL,
});
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expect(getFilterModel()).toBeNull();
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getFilterModel()).toBeTruthy();
});
});
describe('test "SetSheetsFilterCriteriaCommand"', () => {
it('should return false when there is not filter', async () => {
expect(await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
criteria: null,
})).toBeFalsy();
});
it('should set filter criteria works', async () => {
select({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(await commandService.executeCommand(SmartToggleSheetsFilterCommand.id)).toBeTruthy();
// set filter criteria
expect(await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
criteria: {
filters: { filters: ['1'] },
},
})).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([2, 3, 4, 5]));
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set());
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([2, 3, 4, 5]));
// manually set criteria to null
expect(await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
criteria: null,
})).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set());
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([2, 3, 4, 5]));
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set());
});
});
describe('test "ClearSheetsFilterConditionsCommand"', () => {
it('should clear all filter criteria', async () => {
select({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(await commandService.executeCommand(SmartToggleSheetsFilterCommand.id)).toBeTruthy();
expect(await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
criteria: {
filters: { filters: ['1'] },
},
})).toBeTruthy();
expect(await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 1,
criteria: {
filters: { filters: ['b'] },
},
})).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([1, 2, 3, 4, 5]));
expect(await commandService.executeCommand(ClearSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set());
expect(await commandService.executeCommand(UndoCommand.id)).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([1, 2, 3, 4, 5]));
expect(await commandService.executeCommand(RedoCommand.id)).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set());
});
});
describe('test "ReCalcSheetsFilterConditionsCommand"', () => {
it('should reCalc after set range values and get correct result', async () => {
select({ startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 });
expect(await commandService.executeCommand(SmartToggleSheetsFilterCommand.id)).toBeTruthy();
expect(await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
criteria: {
filters: { filters: ['1'] },
},
})).toBeTruthy();
expect(await commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 1,
criteria: {
filters: { filters: ['b'] },
},
})).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([1, 2, 3, 4, 5]));
expect(await commandService.executeCommand(SetRangeValuesCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
value: { 1: { 1: { v: 'b' } } },
} as ISetRangeValuesCommandParams)).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([1, 2, 3, 4, 5]));
expect(await commandService.executeCommand(ReCalcSheetsFilterCommand.id, {
unitId: 'test',
subUnitId: 'sheet1',
})).toBeTruthy();
expect(getFilterModel()!.filteredOutRows).toEqual(new Set([2, 3, 4, 5]));
});
});
});

View File

@@ -0,0 +1,205 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IOperation, IWorkbookData, Workbook } from '@univerjs/core';
import type { ISetSheetsFilterRangeMutationParams } from '@univerjs/sheets-filter';
import type { IEditorBridgeServiceVisibleParam } from '@univerjs/sheets-ui';
import {
CommandType,
ICommandService,
IContextService,
Inject,
Injector,
LocaleService,
LocaleType,
Plugin,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FilterBy, SetSheetsFilterRangeMutation, UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ISheetsFilterPanelService, SheetsFilterPanelService } from '../../../services/sheets-filter-panel.service';
import {
ChangeFilterByOperation,
CloseFilterPanelOperation,
FILTER_PANEL_OPENED_KEY,
OpenFilterPanelOperation,
} from '../sheets-filter.operation';
const SetCellEditVisibleOperation: IOperation<IEditorBridgeServiceVisibleParam> = {
id: 'sheet.operation.set-cell-edit-visible',
type: CommandType.OPERATION,
handler: () => {
return true;
},
};
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 createFilterOperationTestBed() {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class SheetsFilterOperationTestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'sheets-filter-operation-test';
constructor(_config: unknown, @Inject(Injector) protected readonly _injector: Injector) {
super();
}
override onStarting(): void {
this._injector.add([ISheetsFilterPanelService, { useClass: SheetsFilterPanelService }]);
}
}
univer.registerPlugin(UniverSheetsFilterPlugin);
univer.registerPlugin(SheetsFilterOperationTestPlugin);
univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, testWorkbookDataFactory());
get(LocaleService).load({});
const commandService = get(ICommandService);
[
OpenFilterPanelOperation,
CloseFilterPanelOperation,
ChangeFilterByOperation,
SetCellEditVisibleOperation,
].forEach((command) => commandService.registerCommand(command));
return { univer, get };
}
describe('test sheets filter ui operations', () => {
let univer: Univer;
let get: Injector['get'];
let contextService: IContextService;
let commandService: ICommandService;
let sheetsFilterPanelService: ISheetsFilterPanelService;
beforeEach(() => {
const testBed = createFilterOperationTestBed();
univer = testBed.univer;
get = testBed.get;
contextService = get(IContextService);
commandService = get(ICommandService);
sheetsFilterPanelService = get(ISheetsFilterPanelService);
});
afterEach(() => {
univer.dispose();
});
describe('test "OpenFilerPanelOperation"', () => {
it('should return false when the filter model is not found', () => {
const spy = vi.spyOn(sheetsFilterPanelService, 'setupCol');
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
})).toBeFalsy();
expect(spy).not.toHaveBeenCalled();
});
it('should open the filter panel and setup the filter model on the specific column', () => {
const spy = vi.spyOn(sheetsFilterPanelService, 'setupCol');
expect(commandService.syncExecuteCommand(SetSheetsFilterRangeMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 },
} as ISetSheetsFilterRangeMutationParams)).toBeTruthy();
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
})).toBeTruthy();
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('test "CloseFilterPanelOperation"', () => {
it('should return false when the filter panel is not activated', () => {
const spy = vi.spyOn(sheetsFilterPanelService, 'terminate');
contextService.setContextValue(FILTER_PANEL_OPENED_KEY, false);
expect(commandService.syncExecuteCommand(CloseFilterPanelOperation.id)).toBeFalsy();
expect(spy).not.toHaveBeenCalled();
});
it('should call sheets filter panel service to close the panel is activated', () => {
const spy = vi.spyOn(sheetsFilterPanelService, 'terminate');
contextService.setContextValue(FILTER_PANEL_OPENED_KEY, true);
expect(commandService.syncExecuteCommand(CloseFilterPanelOperation.id)).toBeTruthy();
expect(contextService.getContextValue(FILTER_PANEL_OPENED_KEY)).toBeFalsy();
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe('test "ChangeFilterByOperation"', () => {
it('should switch the active filter panel to condition filtering', () => {
expect(commandService.syncExecuteCommand(SetSheetsFilterRangeMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: { startRow: 0, startColumn: 0, endRow: 5, endColumn: 5 },
} as ISetSheetsFilterRangeMutationParams)).toBeTruthy();
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
})).toBeTruthy();
expect(commandService.syncExecuteCommand(ChangeFilterByOperation.id, {
filterBy: FilterBy.CONDITIONS,
})).toBe(true);
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.CONDITIONS);
});
it('should not switch filter type before a filter panel column is prepared', () => {
expect(commandService.syncExecuteCommand(ChangeFilterByOperation.id, {
filterBy: FilterBy.CONDITIONS,
})).toBe(false);
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.VALUES);
});
});
});

View File

@@ -0,0 +1,96 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IOperation } from '@univerjs/core';
import type { FilterBy } from '@univerjs/sheets-filter';
import { CommandType, ICommandService, IContextService, Quantity } from '@univerjs/core';
import { SheetsFilterService } from '@univerjs/sheets-filter';
import { IEditorBridgeService, SetCellEditVisibleOperation } from '@univerjs/sheets-ui';
import { ILayoutService } from '@univerjs/ui';
import { ISheetsFilterPanelService } from '../../services/sheets-filter-panel.service';
export const FILTER_PANEL_OPENED_KEY = 'FILTER_PANEL_OPENED';
export interface IOpenFilterPanelOperationParams {
unitId: string;
subUnitId: string;
col: number;
}
/**
* The operation to open the filter panel and prepare for changing the filter conditions on a given column.
*/
export const OpenFilterPanelOperation: IOperation<IOpenFilterPanelOperationParams> = {
id: 'sheet.operation.open-filter-panel',
type: CommandType.OPERATION,
handler: (accessor, params: IOpenFilterPanelOperationParams) => {
const contextService = accessor.get(IContextService);
const sheetsFilterService = accessor.get(SheetsFilterService);
const sheetsFilterPanelService = accessor.get(ISheetsFilterPanelService);
const commandService = accessor.get(ICommandService);
// Close the cell edit if it is opened.
const editorBridgeService = accessor.has(IEditorBridgeService) ? accessor.get(IEditorBridgeService) : null;
if (editorBridgeService?.isVisible().visible) {
commandService.syncExecuteCommand(SetCellEditVisibleOperation.id, { visible: false });
}
const { unitId, subUnitId, col } = params;
const filterModel = sheetsFilterService.getFilterModel(unitId, subUnitId);
if (!filterModel) return false;
sheetsFilterPanelService.setupCol(filterModel, col);
if (!contextService.getContextValue(FILTER_PANEL_OPENED_KEY)) {
contextService.setContextValue(FILTER_PANEL_OPENED_KEY, true);
}
// Set the filter condition to the filter ui service on the specific column.
return true;
},
};
export const CloseFilterPanelOperation: IOperation = {
id: 'sheet.operation.close-filter-panel',
type: CommandType.OPERATION,
handler: (accessor) => {
const contextService = accessor.get(IContextService);
const sheetsFilterPanelService = accessor.get(ISheetsFilterPanelService);
const layoutService = accessor.get(ILayoutService, Quantity.OPTIONAL);
if (contextService.getContextValue(FILTER_PANEL_OPENED_KEY)) {
contextService.setContextValue(FILTER_PANEL_OPENED_KEY, false);
layoutService?.focus();
return sheetsFilterPanelService.terminate();
}
return false;
},
};
export interface IChangeFilterByOperationParams {
filterBy: FilterBy;
}
export const ChangeFilterByOperation: IOperation<IChangeFilterByOperationParams> = {
id: 'sheet.operation.apply-filter',
type: CommandType.OPERATION,
handler: (accessor, params) => {
const { filterBy } = params!;
const sheetsFilterPanelService = accessor.get(ISheetsFilterPanelService);
return sheetsFilterPanelService.changeFilterBy(filterBy);
},
};

View File

@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuConfig } from '@univerjs/ui';
export const SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY = 'sheets-filter-ui.config';
export const configSymbol = Symbol(SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY);
export interface IUniverSheetsFilterUIConfig {
menu?: MenuConfig;
useRemoteFilterValuesGenerator?: boolean;
}
export const defaultPluginConfig: IUniverSheetsFilterUIConfig = {};

View File

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

View File

@@ -0,0 +1,32 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService } from '@univerjs/core';
import { SmartToggleSheetsFilterCommand } from '@univerjs/sheets-filter';
import { IMenuManagerService } from '@univerjs/ui';
import { menuSchema } from '../menu/schema';
export class SheetsFilterMobileMenuController extends Disposable {
constructor(
@ICommandService commandService: ICommandService,
@IMenuManagerService menuManagerService: IMenuManagerService
) {
super();
this.disposeWithMe(commandService.registerCommand(SmartToggleSheetsFilterCommand));
menuManagerService.mergeMenu(menuSchema);
}
}

View File

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

View File

@@ -0,0 +1,85 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { ICommandService, Inject, Injector, LocaleService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
ReCalcSheetsFilterMutation,
RemoveSheetsFilterMutation,
SetSheetsFilterCriteriaMutation,
SetSheetsFilterRangeMutation,
SheetsFilterService,
} from '@univerjs/sheets-filter';
import { SheetsRenderService } from '@univerjs/sheets-ui';
import { BuiltInUIPart, connectInjector, IMessageService, IUIPartsService } from '@univerjs/ui';
import {
ChangeFilterByOperation,
CloseFilterPanelOperation,
OpenFilterPanelOperation,
} from '../../commands/operations/sheets-filter.operation';
import { MobileSheetsFilterPanel } from '../../views/mobile/MobileSheetsFilterPanel';
import { SheetsFilterRenderController } from '../../views/render-modules/sheets-filter.render-controller';
export class SheetsFilterUIMobileController extends RxDisposable {
constructor(
@Inject(Injector) injector: Injector,
@ICommandService commandService: ICommandService,
@Inject(LocaleService) localeService: LocaleService,
@Inject(SheetsFilterService) sheetsFilterService: SheetsFilterService,
@IMessageService messageService: IMessageService,
@IUIPartsService uiPartsService: IUIPartsService,
@IRenderManagerService renderManagerService: IRenderManagerService,
@Inject(SheetsRenderService) sheetsRenderService: SheetsRenderService
) {
super();
[
ChangeFilterByOperation,
OpenFilterPanelOperation,
CloseFilterPanelOperation,
].forEach((command) => {
this.disposeWithMe(commandService.registerCommand(command));
});
[
SetSheetsFilterRangeMutation,
SetSheetsFilterCriteriaMutation,
RemoveSheetsFilterMutation,
ReCalcSheetsFilterMutation,
].forEach((mutation) => {
this.disposeWithMe(sheetsRenderService.registerSkeletonChangingMutations(mutation.id));
});
this.disposeWithMe(renderManagerService.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsFilterRenderController] as Dependency
));
this.disposeWithMe(uiPartsService.registerComponent(
BuiltInUIPart.GLOBAL,
() => connectInjector(MobileSheetsFilterPanel, injector)
));
this.disposeWithMe(sheetsFilterService.errorMsg$.subscribe((content) => {
if (content) {
messageService.show({
type: MessageType.Error,
content: localeService.t(content),
});
}
}));
}
}

View File

@@ -0,0 +1,107 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommandInfo } from '@univerjs/core';
import type { MenuConfig } from '@univerjs/ui';
import type { IOpenFilterPanelOperationParams } from '../commands/operations/sheets-filter.operation';
import type { LocaleKey } from '../locale/types';
import { Disposable, ICommandService, Inject, Injector, IUniverInstanceService, LocaleService, Tools } from '@univerjs/core';
import { expandToContinuousRange, getSheetCommandTarget, RangeProtectionPermissionViewPoint, SheetPermissionCheckController, SheetsSelectionsService, WorksheetFilterPermission, WorksheetViewPermission } from '@univerjs/sheets';
import { SheetsFilterService, SmartToggleSheetsFilterCommand } from '@univerjs/sheets-filter';
import { OpenFilterPanelOperation } from '../commands/operations/sheets-filter.operation';
export interface IUniverSheetsFilterUIConfig {
menu: MenuConfig;
}
export const DefaultSheetFilterUiConfig = {};
/**
* This controller controls the UI of "filter" features. Menus, commands and filter panel etc. Except for the rendering.
*/
export class SheetsFilterPermissionController extends Disposable {
constructor(
@Inject(SheetsFilterService) private _sheetsFilterService: SheetsFilterService,
@Inject(LocaleService) private _localeService: LocaleService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(SheetPermissionCheckController)
private readonly _sheetPermissionCheckPermission: SheetPermissionCheckController,
@Inject(Injector) private _injector: Injector,
@Inject(SheetsSelectionsService) private _sheetsSelectionService: SheetsSelectionsService
) {
super();
this._commandExecutedListener();
}
private _commandExecutedListener() {
this.disposeWithMe(
this._commandService.beforeCommandExecuted((command: ICommandInfo) => {
if (command.id === SmartToggleSheetsFilterCommand.id) {
const univerInstanceService = this._injector.get(IUniverInstanceService);
const target = getSheetCommandTarget(univerInstanceService);
if (!target) return;
const { unitId, subUnitId, worksheet } = target;
const filterRange = this._sheetsFilterService.getFilterModel(unitId, subUnitId)?.getRange();
let permission;
if (filterRange) {
permission = this._sheetPermissionCheckPermission.permissionCheckWithRanges({
rangeTypes: [RangeProtectionPermissionViewPoint],
worksheetTypes: [WorksheetFilterPermission, WorksheetViewPermission],
}, [filterRange], unitId, subUnitId);
} else {
const range = this._sheetsSelectionService.getCurrentLastSelection()?.range;
if (range) {
let newRange = { ...range };
const isCellRange = range.startColumn === range.endColumn && range.startRow === range.endRow;
newRange = isCellRange ? expandToContinuousRange(newRange, { left: true, right: true, up: true, down: true }, worksheet) : newRange;
permission = this._sheetPermissionCheckPermission.permissionCheckWithRanges({
rangeTypes: [RangeProtectionPermissionViewPoint],
worksheetTypes: [WorksheetViewPermission, WorksheetFilterPermission],
}, [newRange], unitId, subUnitId);
} else {
permission = this._sheetPermissionCheckPermission.permissionCheckWithoutRange({
rangeTypes: [RangeProtectionPermissionViewPoint],
worksheetTypes: [WorksheetViewPermission, WorksheetFilterPermission],
});
}
}
if (!permission) {
this._sheetPermissionCheckPermission.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-filter-ui.permission.filterErr'));
}
}
if (command.id === OpenFilterPanelOperation.id) {
const params = command.params as IOpenFilterPanelOperationParams;
const { unitId, subUnitId } = params;
const filterRange = this._sheetsFilterService.getFilterModel(unitId, subUnitId)?.getRange();
const colRange = Tools.deepClone(filterRange);
if (colRange) {
colRange.startColumn = params.col;
colRange.endColumn = params.col;
const permission = this._sheetPermissionCheckPermission.permissionCheckWithRanges({
rangeTypes: [RangeProtectionPermissionViewPoint],
worksheetTypes: [WorksheetFilterPermission, WorksheetViewPermission],
}, [colRange], unitId, subUnitId);
if (!permission) {
this._sheetPermissionCheckPermission.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-filter-ui.permission.filterErr'));
}
}
}
})
);
}
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IShortcutItem } from '@univerjs/ui';
import { SmartToggleSheetsFilterCommand } from '@univerjs/sheets-filter';
import { whenSheetEditorFocused } from '@univerjs/sheets-ui';
import { KeyCode, MetaKeys } from '@univerjs/ui';
export const SmartToggleFilterShortcut: IShortcutItem = {
id: SmartToggleSheetsFilterCommand.id,
binding: KeyCode.L | MetaKeys.CTRL_COMMAND | MetaKeys.SHIFT,
description: 'sheets-filter-ui.shortcut.smart-toggle-filter',
preconditions: whenSheetEditorFocused,
group: '4_sheet-edit',
groupTitle: 'sheets-ui.shortcut.sheet-edit',
};

View File

@@ -0,0 +1,155 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency, IDisposable, Nullable } from '@univerjs/core';
import { ICommandService, IContextService, Inject, Injector, LocaleService, RxDisposable, UniverInstanceType } from '@univerjs/core';
import { MessageType } from '@univerjs/design';
import { IRenderManagerService } from '@univerjs/engine-render';
import {
ReCalcSheetsFilterMutation,
RemoveSheetsFilterMutation,
SetSheetsFilterCriteriaMutation,
SetSheetsFilterRangeMutation,
SheetsFilterService,
SmartToggleSheetsFilterCommand,
} from '@univerjs/sheets-filter';
import { SheetCanvasPopManagerService, SheetsRenderService } from '@univerjs/sheets-ui';
import { IMenuManagerService, IMessageService, IShortcutService } from '@univerjs/ui';
import { distinctUntilChanged } from 'rxjs';
import {
ChangeFilterByOperation,
CloseFilterPanelOperation,
FILTER_PANEL_OPENED_KEY,
OpenFilterPanelOperation,
} from '../commands/operations/sheets-filter.operation';
import { menuSchema } from '../menu/schema';
import { ISheetsFilterPanelService } from '../services/sheets-filter-panel.service';
import { FILTER_PANEL_POPUP_KEY } from '../views/components/SheetsFilterPanel';
import { SheetsFilterRenderController } from '../views/render-modules/sheets-filter.render-controller';
import { SmartToggleFilterShortcut } from './sheets-filter.shortcut';
/**
* This controller controls the UI of "filter" features. Menus, commands and filter panel etc. Except for the rendering.
*/
export class SheetsFilterUIDesktopController extends RxDisposable {
constructor(
@Inject(Injector) private readonly _injector: Injector,
@ISheetsFilterPanelService private readonly _sheetsFilterPanelService: ISheetsFilterPanelService,
@Inject(SheetCanvasPopManagerService) private _sheetCanvasPopupService: SheetCanvasPopManagerService,
@Inject(SheetsFilterService) private _sheetsFilterService: SheetsFilterService,
@Inject(LocaleService) private _localeService: LocaleService,
@IShortcutService private readonly _shortcutService: IShortcutService,
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IContextService private readonly _contextService: IContextService,
@IMessageService private readonly _messageService: IMessageService,
@Inject(SheetsRenderService) sheetsRenderService: SheetsRenderService,
@IRenderManagerService renderManagerService: IRenderManagerService
) {
super();
[
SetSheetsFilterRangeMutation,
SetSheetsFilterCriteriaMutation,
RemoveSheetsFilterMutation,
ReCalcSheetsFilterMutation,
].forEach((mutation) => {
this.disposeWithMe(sheetsRenderService.registerSkeletonChangingMutations(mutation.id));
});
this.disposeWithMe(renderManagerService.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsFilterRenderController] as Dependency
));
this._initCommands();
this._initShortcuts();
this._initMenuItems();
this._initUI();
}
override dispose(): void {
super.dispose();
this._closeFilterPopup();
}
private _initShortcuts(): void {
[
SmartToggleFilterShortcut,
].forEach((shortcut) => {
this.disposeWithMe(this._shortcutService.registerShortcut(shortcut));
});
}
private _initCommands(): void {
[
SmartToggleSheetsFilterCommand,
ChangeFilterByOperation,
OpenFilterPanelOperation,
CloseFilterPanelOperation,
].forEach((c) => {
this.disposeWithMe(this._commandService.registerCommand(c));
});
}
private _initMenuItems(): void {
this._menuManagerService.mergeMenu(menuSchema);
}
private _initUI(): void {
this.disposeWithMe(this._contextService.subscribeContextValue$(FILTER_PANEL_OPENED_KEY)
.pipe(distinctUntilChanged())
.subscribe((open) => {
if (open) {
this._openFilterPopup();
} else {
this._closeFilterPopup();
}
}));
this.disposeWithMe(this._sheetsFilterService.errorMsg$.subscribe((content) => {
if (content) {
this._messageService.show({
type: MessageType.Error,
content: this._localeService.t(content),
});
}
}));
}
private _popupDisposable?: Nullable<IDisposable>;
private _openFilterPopup(): void {
const currentFilterModel = this._sheetsFilterPanelService.filterModel;
if (!currentFilterModel) {
throw new Error('[SheetsFilterUIController]: no filter model when opening filter popup!');
}
const range = currentFilterModel.getRange();
const col = this._sheetsFilterPanelService.col;
const { startRow } = range;
this._popupDisposable = this._sheetCanvasPopupService.attachPopupToCell(startRow, col, {
componentKey: FILTER_PANEL_POPUP_KEY,
direction: 'horizontal',
onClickOutside: () => this._commandService.syncExecuteCommand(CloseFilterPanelOperation.id),
offset: [5, 0],
});
}
private _closeFilterPopup(): void {
this._popupDisposable?.dispose();
this._popupDisposable = null;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'تبديل التصفية',
'clear-filter-criteria': 'مسح شروط التصفية',
're-calc-filter-conditions': 'إعادة حساب شروط التصفية',
},
shortcut: {
'smart-toggle-filter': 'تبديل التصفية',
},
permission: {
filterErr: 'ليس لديك إذن لاستخدام التصفية.',
},
panel: {
'clear-filter': 'مسح التصفية',
cancel: 'إلغاء',
confirm: 'تأكيد',
mobile: {
resize: 'تغيير حجم لوحة التصفية',
back: 'رجوع',
column: 'العمود {0}',
'previous-column': 'العمود السابق',
'next-column': 'العمود التالي',
},
'by-values': 'حسب القيم',
'by-colors': 'حسب الألوان',
'filter-by-cell-fill-color': 'تصفية حسب لون تعبئة الخلية',
'filter-by-cell-text-color': 'تصفية حسب لون نص الخلية',
'filter-by-color-none': 'يحتوي العمود على لون واحد فقط',
'by-conditions': 'حسب الشروط',
'filter-only': 'تصفية فقط',
'search-placeholder': 'استخدم المسافة لفصل الكلمات المفتاحية',
'select-all': 'تحديد الكل',
'input-values-placeholder': 'إدخال القيم',
and: 'و',
or: 'أو',
empty: '(فارغ)',
'?': 'استخدم "؟" لتمثيل حرف واحد.',
'*': 'استخدم "*" لتمثيل عدة أحرف.',
},
conditions: {
none: 'لا شيء',
empty: 'فارغ',
'not-empty': 'غير فارغ',
'text-contains': 'النص يحتوي على',
'does-not-contain': 'النص لا يحتوي على',
'starts-with': 'النص يبدأ بـ',
'ends-with': 'النص ينتهي بـ',
equals: 'النص يساوي',
'greater-than': 'أكبر من',
'greater-than-or-equal': 'أكبر من أو يساوي',
'less-than': 'أقل من',
'less-than-or-equal': 'أقل من أو يساوي',
equal: 'يساوي',
'not-equal': 'لا يساوي',
between: 'بين',
'not-between': 'ليس بين',
custom: 'مخصص',
},
date: {
1: 'يناير',
2: 'فبراير',
3: 'مارس',
4: 'أبريل',
5: 'مايو',
6: 'يونيو',
7: 'يوليو',
8: 'أغسطس',
9: 'سبتمبر',
10: 'أكتوبر',
11: 'نوفمبر',
12: 'ديسمبر',
},
sync: {
title: 'التصفية مرئية للجميع',
statusTips: {
on: 'عند التفعيل، سيرى جميع المتعاونين نتائج التصفية',
off: 'عند الإيقاف، سترى نتائج التصفية أنت فقط',
},
switchTips: {
on: '"التصفية مرئية للجميع" مفعّلة، سيرى جميع المتعاونين نتائج التصفية',
off: '"التصفية مرئية للجميع" غير مفعّلة، سترى نتائج التصفية أنت فقط',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Alterna el filtre',
'clear-filter-criteria': 'Neteja les condicions del filtre',
're-calc-filter-conditions': 'Recalcula les condicions del filtre',
},
shortcut: {
'smart-toggle-filter': 'Alterna el filtre',
},
permission: {
filterErr: 'No teniu permís per utilitzar el filtre.',
},
panel: {
'clear-filter': 'Neteja el filtre',
cancel: 'Cancel·la',
confirm: 'Confirma',
mobile: {
resize: 'Redimensiona el tauler de filtre',
back: 'Enrere',
column: 'Columna {0}',
'previous-column': 'Columna anterior',
'next-column': 'Columna següent',
},
'by-values': 'Per valors',
'by-colors': 'Per colors',
'filter-by-cell-fill-color': 'Filtra per color de fons de la cel·la',
'filter-by-cell-text-color': 'Filtra per color de text de la cel·la',
'filter-by-color-none': 'La columna només conté un color',
'by-conditions': 'Per condicions',
'filter-only': 'Només filtra',
'search-placeholder': 'Utilitza espai per separar paraules clau',
'select-all': 'Selecciona-ho tot',
'input-values-placeholder': 'Introdueix valors',
and: 'I',
or: 'O',
empty: '(buit)',
'?': 'Utilitza “?” per representar un sol caràcter.',
'*': 'Utilitza “*” per representar diversos caràcters.',
},
conditions: {
none: 'Cap',
empty: 'Està buit',
'not-empty': 'No està buit',
'text-contains': 'El text conté',
'does-not-contain': 'El text no conté',
'starts-with': 'El text comença amb',
'ends-with': 'El text acaba amb',
equals: 'El text és igual a',
'greater-than': 'Més gran que',
'greater-than-or-equal': 'Més gran o igual que',
'less-than': 'Menys que',
'less-than-or-equal': 'Menys o igual que',
equal: 'Igual',
'not-equal': 'No igual',
between: 'Entre',
'not-between': 'No entre',
custom: 'Personalitzat',
},
date: {
1: 'Gener',
2: 'Febrer',
3: 'Març',
4: 'Abril',
5: 'Maig',
6: 'Juny',
7: 'Juliol',
8: 'Agost',
9: 'Setembre',
10: 'Octubre',
11: 'Novembre',
12: 'Desembre',
},
sync: {
title: 'El filtre és visible per a tothom',
statusTips: {
on: 'Un cop activat, tots els col·laboradors veuran els resultats del filtre',
off: 'Un cop desactivat, només tu veuràs els resultats del filtre',
},
switchTips: {
on: 'S’ha activat "El filtre és visible per a tothom", tots els col·laboradors veuran els resultats del filtre',
off: 'S’ha desactivat "El filtre és visible per a tothom", només tu veuràs els resultats del filtre',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Filter ein-/ausschalten',
'clear-filter-criteria': 'Filterbedingungen löschen',
're-calc-filter-conditions': 'Filterbedingungen neu berechnen',
},
shortcut: {
'smart-toggle-filter': 'Filter ein-/ausschalten',
},
permission: {
filterErr: 'Sie haben keine Berechtigung, den Filter zu verwenden.',
},
panel: {
'clear-filter': 'Filter löschen',
cancel: 'Abbrechen',
confirm: 'Bestätigen',
mobile: {
resize: 'Größe des Filterbereichs ändern',
back: 'Zurück',
column: 'Spalte {0}',
'previous-column': 'Vorherige Spalte',
'next-column': 'Nächste Spalte',
},
'by-values': 'Nach Werten',
'by-colors': 'Nach Farben',
'filter-by-cell-fill-color': 'Nach Zellenfüllfarbe filtern',
'filter-by-cell-text-color': 'Nach Zellentextfarbe filtern',
'filter-by-color-none': 'Die Spalte enthält nur eine Farbe',
'by-conditions': 'Nach Bedingungen',
'filter-only': 'Nur filtern',
'search-placeholder': 'Verwenden Sie Leerzeichen, um Schlüsselwörter zu trennen',
'select-all': 'Alle auswählen',
'input-values-placeholder': 'Werte eingeben',
and: 'UND',
or: 'ODER',
empty: '(leer)',
'?': 'Verwenden Sie "?" für ein einzelnes Zeichen.',
'*': 'Verwenden Sie "*" für mehrere Zeichen.',
},
conditions: {
none: 'Keine',
empty: 'Ist leer',
'not-empty': 'Ist nicht leer',
'text-contains': 'Text enthält',
'does-not-contain': 'Text enthält nicht',
'starts-with': 'Text beginnt mit',
'ends-with': 'Text endet mit',
equals: 'Text ist gleich',
'greater-than': 'Größer als',
'greater-than-or-equal': 'Größer als oder gleich',
'less-than': 'Kleiner als',
'less-than-or-equal': 'Kleiner als oder gleich',
equal: 'Gleich',
'not-equal': 'Ungleich',
between: 'Zwischen',
'not-between': 'Nicht zwischen',
custom: 'Benutzerdefiniert',
},
date: {
1: 'Januar',
2: 'Februar',
3: 'März',
4: 'April',
5: 'Mai',
6: 'Juni',
7: 'Juli',
8: 'August',
9: 'September',
10: 'Oktober',
11: 'November',
12: 'Dezember',
},
sync: {
title: 'Filter ist für alle sichtbar',
statusTips: {
on: 'Wenn eingeschaltet, sehen alle Bearbeiter die Filterergebnisse',
off: 'Wenn ausgeschaltet, sehen nur Sie die Filterergebnisse',
},
switchTips: {
on: '"Filter ist für alle sichtbar" ist eingeschaltet, alle Bearbeiter sehen die Filterergebnisse',
off: '"Filter ist für alle sichtbar" ist ausgeschaltet, nur Sie sehen die Filterergebnisse',
},
},
},
};
export default locale;

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.
*/
const locale = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Toggle Filter',
'clear-filter-criteria': 'Clear Filter Conditions',
're-calc-filter-conditions': 'Re-calc Filter Conditions',
},
shortcut: {
'smart-toggle-filter': 'Toggle Filter',
},
permission: {
filterErr: 'You do not have permission to use the filter.',
},
panel: {
'clear-filter': 'Clear Filter',
cancel: 'Cancel',
confirm: 'Confirm',
mobile: {
resize: 'Resize filter panel',
back: 'Back',
column: 'Column {0}',
'previous-column': 'Previous column',
'next-column': 'Next column',
},
'by-values': 'By Values',
'by-colors': 'By Colors',
'filter-by-cell-fill-color': 'Filter by cell fill color',
'filter-by-cell-text-color': 'Filter by cell text color',
'filter-by-color-none': 'The column contains only one color',
'by-conditions': 'By Conditions',
'filter-only': 'Filter Only',
'search-placeholder': 'Use space to separate keywords',
'select-all': 'Select All',
'input-values-placeholder': 'Input Values',
and: 'AND',
or: 'OR',
empty: '(empty)',
'?': 'Use “?” to represent a single character.',
'*': 'Use “*” to represent multiple characters.',
},
conditions: {
none: 'None',
empty: 'Is Empty',
'not-empty': 'Is Not Empty',
'text-contains': 'Text Contains',
'does-not-contain': 'Text Does Not Contain',
'starts-with': 'Text Starts With',
'ends-with': 'Text Ends With',
equals: 'Text Equals',
'greater-than': 'Greater Than',
'greater-than-or-equal': 'Greater Than Or Equal To',
'less-than': 'Less Than',
'less-than-or-equal': 'Less Than Or Equal To',
equal: 'Equal',
'not-equal': 'Not Equal',
between: 'Between',
'not-between': 'Not Between',
custom: 'Custom',
},
date: {
1: 'January',
2: 'February',
3: 'March',
4: 'April',
5: 'May',
6: 'June',
7: 'July',
8: 'August',
9: 'September',
10: 'October',
11: 'November',
12: 'December',
},
sync: {
title: 'Filter is visible to everyone',
statusTips: {
on: 'Once on, all collaborators will see the filter results',
off: 'Once off, only you will see the filter results',
},
switchTips: {
on: '"Filter is visible to everyone" is turned on, all collaborators will see the filter results',
off: '"Filter is visible to everyone" is turned off, only you will see the filter results',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Alternar filtro',
'clear-filter-criteria': 'Borrar condiciones de filtro',
're-calc-filter-conditions': 'Recalcular condiciones de filtro',
},
shortcut: {
'smart-toggle-filter': 'Alternar filtro',
},
permission: {
filterErr: 'No tiene permiso para usar el filtro.',
},
panel: {
'clear-filter': 'Borrar filtro',
cancel: 'Cancelar',
confirm: 'Confirmar',
mobile: {
resize: 'Cambiar el tamaño del panel de filtro',
back: 'Volver',
column: 'Columna {0}',
'previous-column': 'Columna anterior',
'next-column': 'Columna siguiente',
},
'by-values': 'Por valores',
'by-colors': 'Por colores',
'filter-by-cell-fill-color': 'Filtrar por color de relleno de celda',
'filter-by-cell-text-color': 'Filtrar por color de texto de celda',
'filter-by-color-none': 'La columna contiene solo un color',
'by-conditions': 'Por condiciones',
'filter-only': 'Solo filtrar',
'search-placeholder': 'Usa espacio para separar palabras clave',
'select-all': 'Seleccionar todo',
'input-values-placeholder': 'Introducir valores',
and: 'Y',
or: 'O',
empty: '(vacío)',
'?': 'Usa “?” para representar un solo carácter.',
'*': 'Usa “*” para representar varios caracteres.',
},
conditions: {
none: 'Ninguno',
empty: 'Está vacío',
'not-empty': 'No está vacío',
'text-contains': 'El texto contiene',
'does-not-contain': 'El texto no contiene',
'starts-with': 'El texto comienza con',
'ends-with': 'El texto termina con',
equals: 'El texto es igual a',
'greater-than': 'Mayor que',
'greater-than-or-equal': 'Mayor o igual que',
'less-than': 'Menor que',
'less-than-or-equal': 'Menor o igual que',
equal: 'Igual',
'not-equal': 'No igual',
between: 'Entre',
'not-between': 'No entre',
custom: 'Personalizado',
},
date: {
1: 'Enero',
2: 'Febrero',
3: 'Marzo',
4: 'Abril',
5: 'Mayo',
6: 'Junio',
7: 'Julio',
8: 'Agosto',
9: 'Septiembre',
10: 'Octubre',
11: 'Noviembre',
12: 'Diciembre',
},
sync: {
title: 'El filtro es visible para todos',
statusTips: {
on: 'Una vez activado, todos los colaboradores verán los resultados del filtro',
off: 'Una vez desactivado, solo tú verás los resultados del filtro',
},
switchTips: {
on: '"El filtro es visible para todos" está activado, todos los colaboradores verán los resultados del filtro',
off: '"El filtro es visible para todos" está desactivado, solo tú verás los resultados del filtro',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'تغییر حالت فیلتر',
'clear-filter-criteria': 'پاک کردن شرایط فیلتر',
're-calc-filter-conditions': 'بازمحاسبه شرایط فیلتر',
},
shortcut: {
'smart-toggle-filter': 'تغییر حالت فیلتر',
},
permission: {
filterErr: 'شما مجوز استفاده از فیلتر را ندارید.',
},
panel: {
'clear-filter': 'پاک کردن فیلتر',
cancel: 'انصراف',
confirm: 'تایید',
mobile: {
resize: 'تغییر اندازه پنل فیلتر',
back: 'بازگشت',
column: 'ستون {0}',
'previous-column': 'ستون قبلی',
'next-column': 'ستون بعدی',
},
'by-values': 'بر اساس مقادیر',
'by-colors': 'بر اساس رنگ‌ها',
'filter-by-cell-fill-color': 'فیلتر بر اساس رنگ پر کردن سلول',
'filter-by-cell-text-color': 'فیلتر بر اساس رنگ متن سلول',
'filter-by-color-none': 'این ستون فقط شامل یک رنگ است',
'by-conditions': 'بر اساس شرایط',
'filter-only': 'فقط فیلتر',
'search-placeholder': 'برای جداسازی کلیدواژه‌ها از فاصله استفاده کنید',
'select-all': 'انتخاب همه',
'input-values-placeholder': 'ورود مقادیر',
and: 'و',
or: 'یا',
empty: '(خالی)',
'?': 'برای نمایش یک کاراکتر از “?” استفاده کنید.',
'*': 'برای نمایش چندین کاراکتر از “*” استفاده کنید.',
},
conditions: {
none: 'هیچ کدام',
empty: 'خالی است',
'not-empty': 'خالی نیست',
'text-contains': 'شامل متن است',
'does-not-contain': 'شامل متن نیست',
'starts-with': 'با متن شروع می‌شود',
'ends-with': 'با متن پایان می‌یابد',
equals: 'برابر است با',
'greater-than': 'بزرگتر از',
'greater-than-or-equal': 'بزرگتر از یا برابر با',
'less-than': 'کوچکتر از',
'less-than-or-equal': 'کوچکتر از یا برابر با',
equal: 'برابر است با',
'not-equal': 'برابر نیست با',
between: 'بین',
'not-between': 'بین نیست',
custom: 'سفارشی',
},
date: {
1: 'ژانویه',
2: 'فوریه',
3: 'مارس',
4: 'آوریل',
5: 'مه',
6: 'ژوئن',
7: 'جولای',
8: 'آگوست',
9: 'سپتامبر',
10: 'اکتبر',
11: 'نوامبر',
12: 'دسامبر',
},
sync: {
title: 'فیلتر برای همه قابل مشاهده است',
statusTips: {
on: 'پس از روشن شدن، همه همکاران نتایج فیلتر را مشاهده خواهند کرد',
off: 'پس از خاموش شدن، فقط شما نتایج فیلتر را مشاهده خواهید کرد',
},
switchTips: {
on: 'گزینه "فیلتر برای همه قابل مشاهده است" روشن است، همه همکاران نتایج فیلتر را مشاهده خواهند کرد',
off: 'گزینه "فیلتر برای همه قابل مشاهده است" خاموش است، فقط شما نتایج فیلتر را مشاهده خواهید کرد',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Inverser le filtre',
'clear-filter-criteria': 'Effacer les conditions de filtre',
're-calc-filter-conditions': 'Recalculer les conditions de filtre',
},
shortcut: {
'smart-toggle-filter': 'Inverser le filtre',
},
permission: {
filterErr: 'Vous n’avez pas l’autorisation d’utiliser le filtre.',
},
panel: {
'clear-filter': 'Effacer le filtre',
cancel: 'Annuler',
confirm: 'Confirmer',
mobile: {
resize: 'Redimensionner le panneau de filtre',
back: 'Retour',
column: 'Colonne {0}',
'previous-column': 'Colonne précédente',
'next-column': 'Colonne suivante',
},
'by-values': 'Par valeurs',
'by-colors': 'Par couleurs',
'filter-by-cell-fill-color': 'Filtrer par couleur de remplissage de cellule',
'filter-by-cell-text-color': 'Filtrer par couleur de texte de cellule',
'filter-by-color-none': 'Cette colonne ne contient qu\'une seule couleur',
'by-conditions': 'Par conditions',
'filter-only': 'Filtrer uniquement',
'search-placeholder': 'Utilisez un espace pour séparer les mots-clés',
'select-all': 'Tout sélectionner',
'input-values-placeholder': 'Saisir des valeurs',
and: 'ET',
or: 'OU',
empty: '(vide)',
'?': 'Utilisez “?” pour représenter un seul caractère.',
'*': 'Utilisez “*” pour représenter plusieurs caractères.',
},
conditions: {
none: 'Aucun',
empty: 'Est vide',
'not-empty': 'N\'est pas vide',
'text-contains': 'Le texte contient',
'does-not-contain': 'Le texte ne contient pas',
'starts-with': 'Le texte commence par',
'ends-with': 'Le texte se termine par',
equals: 'Le texte est égal à',
'greater-than': 'Supérieur à',
'greater-than-or-equal': 'Supérieur ou égal à',
'less-than': 'Inférieur à',
'less-than-or-equal': 'Inférieur ou égal à',
equal: 'Égal à',
'not-equal': 'Différent de',
between: 'Entre',
'not-between': 'Pas entre',
custom: 'Personnalisé',
},
date: {
1: 'Jan',
2: 'Fév',
3: 'Mar',
4: 'Avr',
5: 'Mai',
6: 'Juin',
7: 'Juil',
8: 'Août',
9: 'Sept',
10: 'Oct',
11: 'Nov',
12: 'Déc',
},
sync: {
title: 'Le filtre est visible par tous',
statusTips: {
on: 'Une fois activé, tous les collaborateurs verront les résultats du filtre',
off: 'Une fois désactivé, seul vous verrez les résultats du filtre',
},
switchTips: {
on: '“Le filtre est visible par tous” est activé, tous les collaborateurs verront les résultats du filtre',
off: '“Le filtre est visible par tous” est désactivé, seul vous verrez les résultats du filtre',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Aktifkan/Nonaktifkan Filter',
'clear-filter-criteria': 'Hapus Kondisi Filter',
're-calc-filter-conditions': 'Hitung Ulang Kondisi Filter',
},
shortcut: {
'smart-toggle-filter': 'Aktifkan/Nonaktifkan Filter',
},
permission: {
filterErr: 'Anda tidak memiliki izin untuk menggunakan filter.',
},
panel: {
'clear-filter': 'Hapus Filter',
cancel: 'Batal',
confirm: 'Konfirmasi',
mobile: {
resize: 'Ubah ukuran panel filter',
back: 'Kembali',
column: 'Kolom {0}',
'previous-column': 'Kolom sebelumnya',
'next-column': 'Kolom berikutnya',
},
'by-values': 'Berdasarkan Nilai',
'by-colors': 'Berdasarkan Warna',
'filter-by-cell-fill-color': 'Filter berdasarkan warna isi sel',
'filter-by-cell-text-color': 'Filter berdasarkan warna teks sel',
'filter-by-color-none': 'Kolom hanya berisi satu warna',
'by-conditions': 'Berdasarkan Kondisi',
'filter-only': 'Hanya Filter',
'search-placeholder': 'Gunakan spasi untuk memisahkan kata kunci',
'select-all': 'Pilih Semua',
'input-values-placeholder': 'Masukkan Nilai',
and: 'DAN',
or: 'ATAU',
empty: '(kosong)',
'?': 'Gunakan "?" untuk mewakili satu karakter.',
'*': 'Gunakan "*" untuk mewakili banyak karakter.',
},
conditions: {
none: 'Tidak ada',
empty: 'Kosong',
'not-empty': 'Tidak Kosong',
'text-contains': 'Teks Mengandung',
'does-not-contain': 'Teks Tidak Mengandung',
'starts-with': 'Teks Dimulai Dengan',
'ends-with': 'Teks Diakhiri Dengan',
equals: 'Teks Sama Dengan',
'greater-than': 'Lebih Besar Dari',
'greater-than-or-equal': 'Lebih Besar Dari Atau Sama Dengan',
'less-than': 'Lebih Kecil Dari',
'less-than-or-equal': 'Lebih Kecil Dari Atau Sama Dengan',
equal: 'Sama Dengan',
'not-equal': 'Tidak Sama Dengan',
between: 'Di Antara',
'not-between': 'Tidak Di Antara',
custom: 'Kustom',
},
date: {
1: 'Januari',
2: 'Februari',
3: 'Maret',
4: 'April',
5: 'Mei',
6: 'Juni',
7: 'Juli',
8: 'Agustus',
9: 'September',
10: 'Oktober',
11: 'November',
12: 'Desember',
},
sync: {
title: 'Filter terlihat oleh semua orang',
statusTips: {
on: 'Jika diaktifkan, semua kolaborator akan melihat hasil filter',
off: 'Jika dinonaktifkan, hanya Anda yang akan melihat hasil filter',
},
switchTips: {
on: '"Filter terlihat oleh semua orang" diaktifkan, semua kolaborator akan melihat hasil filter',
off: '"Filter terlihat oleh semua orang" dinonaktifkan, hanya Anda yang akan melihat hasil filter',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Attiva/disattiva filtro',
'clear-filter-criteria': 'Cancella condizioni filtro',
're-calc-filter-conditions': 'Ricalcola condizioni filtro',
},
shortcut: {
'smart-toggle-filter': 'Attiva/disattiva filtro',
},
permission: {
filterErr: 'Non hai l’autorizzazione per usare il filtro.',
},
panel: {
'clear-filter': 'Cancella filtro',
cancel: 'Annulla',
confirm: 'Conferma',
mobile: {
resize: 'Ridimensiona il pannello filtro',
back: 'Indietro',
column: 'Colonna {0}',
'previous-column': 'Colonna precedente',
'next-column': 'Colonna successiva',
},
'by-values': 'Per valori',
'by-colors': 'Per colori',
'filter-by-cell-fill-color': 'Filtra per colore di riempimento cella',
'filter-by-cell-text-color': 'Filtra per colore testo cella',
'filter-by-color-none': 'La colonna contiene un solo colore',
'by-conditions': 'Per condizioni',
'filter-only': 'Solo filtro',
'search-placeholder': 'Usa lo spazio per separare le parole chiave',
'select-all': 'Seleziona tutto',
'input-values-placeholder': 'Inserisci valori',
and: 'E',
or: 'O',
empty: '(vuoto)',
'?': 'Usa "?" per rappresentare un singolo carattere.',
'*': 'Usa "*" per rappresentare più caratteri.',
},
conditions: {
none: 'Nessuna',
empty: 'È vuoto',
'not-empty': 'Non è vuoto',
'text-contains': 'Il testo contiene',
'does-not-contain': 'Il testo non contiene',
'starts-with': 'Il testo inizia con',
'ends-with': 'Il testo finisce con',
equals: 'Il testo è uguale a',
'greater-than': 'Maggiore di',
'greater-than-or-equal': 'Maggiore o uguale a',
'less-than': 'Minore di',
'less-than-or-equal': 'Minore o uguale a',
equal: 'Uguale',
'not-equal': 'Diverso',
between: 'Tra',
'not-between': 'Non tra',
custom: 'Personalizzato',
},
date: {
1: 'Gennaio',
2: 'Febbraio',
3: 'Marzo',
4: 'Aprile',
5: 'Maggio',
6: 'Giugno',
7: 'Luglio',
8: 'Agosto',
9: 'Settembre',
10: 'Ottobre',
11: 'Novembre',
12: 'Dicembre',
},
sync: {
title: 'Il filtro è visibile a tutti',
statusTips: {
on: 'Se attivato, tutti i collaboratori vedranno i risultati del filtro',
off: 'Se disattivato, solo tu vedrai i risultati del filtro',
},
switchTips: {
on: '"Il filtro è visibile a tutti" è attivato, tutti i collaboratori vedranno i risultati del filtro',
off: '"Il filtro è visibile a tutti" è disattivato, solo tu vedrai i risultati del filtro',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'フィルタの切替',
'clear-filter-criteria': 'フィルタ条件をクリア',
're-calc-filter-conditions': 'フィルタ条件を再計算',
},
shortcut: {
'smart-toggle-filter': 'フィルタの切替',
},
permission: {
filterErr: 'フィルタを使用する権限がありません。',
},
panel: {
'clear-filter': 'フィルタをクリア',
cancel: 'キャンセル',
confirm: '確認',
mobile: {
resize: 'フィルターパネルのサイズを変更',
back: '戻る',
column: '{0} 列',
'previous-column': '前の列',
'next-column': '次の列',
},
'by-values': '値でフィルタ',
'by-colors': '色でフィルタ',
'filter-by-cell-fill-color': 'セルの塗りつぶし色でフィルタ',
'filter-by-cell-text-color': 'セルの文字色でフィルタ',
'filter-by-color-none': 'この列は単一の色のみを含んでいます',
'by-conditions': '条件でフィルタ',
'filter-only': 'フィルタのみ表示',
'search-placeholder': '検索語はスペースで区切る',
'select-all': 'すべて選択',
'input-values-placeholder': '値を入力',
and: 'AND',
or: 'OR',
empty: '(空白)',
'?': '「?」は任意の1文字',
'*': '「*」は任意の複数文字',
},
conditions: {
none: 'なし',
empty: '空白セル',
'not-empty': '空白ではない',
'text-contains': '文字列を含む',
'does-not-contain': '文字列を含まない',
'starts-with': '前方一致',
'ends-with': '後方一致',
equals: '一致',
'greater-than': 'より大きい',
'greater-than-or-equal': '以上',
'less-than': 'より小さい',
'less-than-or-equal': '以下',
equal: '等しい',
'not-equal': '等しくない',
between: '範囲内',
'not-between': '範囲外',
custom: 'カスタム',
},
date: {
1: '1月',
2: '2月',
3: '3月',
4: '4月',
5: '5月',
6: '6月',
7: '7月',
8: '8月',
9: '9月',
10: '10月',
11: '11月',
12: '12月',
},
sync: {
title: 'フィルタを全員が表示可能',
statusTips: {
on: '有効にすると、すべての共同作業者がフィルタ結果を表示できます。',
off: '無効にすると、自分のみフィルタ結果を表示できます。',
},
switchTips: {
on: '「フィルタを全員が表示可能」が有効な場合、すべての共同作業者がフィルタ結果を表示できます。',
off: '「フィルタを全員が表示可能」が無効な場合、自分のみフィルタ結果を表示できます。',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': '필터 토글',
'clear-filter-criteria': '필터 조건 지우기',
're-calc-filter-conditions': '필터 조건 재계산',
},
shortcut: {
'smart-toggle-filter': '필터 토글',
},
permission: {
filterErr: '필터를 사용할 권한이 없습니다.',
},
panel: {
'clear-filter': '필터 지우기',
cancel: '취소',
confirm: '확인',
mobile: {
resize: '필터 패널 크기 조정',
back: '뒤로',
column: '{0} 열',
'previous-column': '이전 열',
'next-column': '다음 열',
},
'by-values': '값으로 필터',
'by-colors': '색상으로 필터',
'filter-by-cell-fill-color': '셀 채우기 색상으로 필터',
'filter-by-cell-text-color': '셀 텍스트 색상으로 필터',
'filter-by-color-none': '이 열은 단일 색상만 포함합니다',
'by-conditions': '조건으로 필터',
'filter-only': '필터만 보기',
'search-placeholder': '검색어는 공백으로 구분',
'select-all': '전체 선택',
'input-values-placeholder': '값 입력',
and: 'AND',
or: 'OR',
empty: '(빈 값)',
'?': '“?”는 임의의 한 문자',
'*': '“*”는 임의의 여러 문자',
},
conditions: {
none: '없음',
empty: '빈 셀',
'not-empty': '빈 셀 아님',
'text-contains': '텍스트 포함',
'does-not-contain': '텍스트 미포함',
'starts-with': '텍스트 시작',
'ends-with': '텍스트 끝',
equals: '텍스트 일치',
'greater-than': '크다',
'greater-than-or-equal': '크거나 같다',
'less-than': '작다',
'less-than-or-equal': '작거나 같다',
equal: '같음',
'not-equal': '같지 않음',
between: '범위 내',
'not-between': '범위 밖',
custom: '사용자 지정',
},
date: {
1: '1월',
2: '2월',
3: '3월',
4: '4월',
5: '5월',
6: '6월',
7: '7월',
8: '8월',
9: '9월',
10: '10월',
11: '11월',
12: '12월',
},
sync: {
title: '필터를 모든 사용자가 볼 수 있음',
statusTips: {
on: '활성화하면 모든 협업자가 필터 결과를 볼 수 있습니다.',
off: '비활성화하면 나만 필터 결과를 볼 수 있습니다.',
},
switchTips: {
on: '"필터를 모든 사용자가 볼 수 있음"이 활성화되어 모든 협업자가 필터 결과를 볼 수 있습니다.',
off: '"필터를 모든 사용자가 볼 수 있음"이 비활성화되어 나만 필터 결과를 볼 수 있습니다.',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Przełącz filtr',
'clear-filter-criteria': 'Wyczyść warunki filtrowania',
're-calc-filter-conditions': 'Przelicz warunki filtrowania',
},
shortcut: {
'smart-toggle-filter': 'Przełącz filtr',
},
permission: {
filterErr: 'Nie masz uprawnień do używania filtra.',
},
panel: {
'clear-filter': 'Wyczyść filtr',
cancel: 'Anuluj',
confirm: 'Potwierdź',
mobile: {
resize: 'Zmień rozmiar panelu filtra',
back: 'Wstecz',
column: 'Kolumna {0}',
'previous-column': 'Poprzednia kolumna',
'next-column': 'Następna kolumna',
},
'by-values': 'Według wartości',
'by-colors': 'Według kolorów',
'filter-by-cell-fill-color': 'Filtruj według koloru wypełnienia komórki',
'filter-by-cell-text-color': 'Filtruj według koloru tekstu komórki',
'filter-by-color-none': 'Kolumna zawiera tylko jeden kolor',
'by-conditions': 'Według warunków',
'filter-only': 'Tylko filtr',
'search-placeholder': 'Użyj spacji do oddzielenia słów kluczowych',
'select-all': 'Zaznacz wszystko',
'input-values-placeholder': 'Wprowadź wartości',
and: 'ORAZ',
or: 'LUB',
empty: '(puste)',
'?': 'Użyj „?”, aby reprezentować pojedynczy znak.',
'*': 'Użyj „*”, aby reprezentować dowolną liczbę znaków.',
},
conditions: {
none: 'Brak',
empty: 'Jest puste',
'not-empty': 'Nie jest puste',
'text-contains': 'Tekst zawiera',
'does-not-contain': 'Tekst nie zawiera',
'starts-with': 'Tekst zaczyna się od',
'ends-with': 'Tekst kończy się na',
equals: 'Tekst równa się',
'greater-than': 'Większe niż',
'greater-than-or-equal': 'Większe lub równe',
'less-than': 'Mniejsze niż',
'less-than-or-equal': 'Mniejsze lub równe',
equal: 'Równe',
'not-equal': 'Różne od',
between: 'Między',
'not-between': 'Nie między',
custom: 'Niestandardowe',
},
date: {
1: 'Styczeń',
2: 'Luty',
3: 'Marzec',
4: 'Kwiecień',
5: 'Maj',
6: 'Czerwiec',
7: 'Lipiec',
8: 'Sierpień',
9: 'Wrzesień',
10: 'Październik',
11: 'Listopad',
12: 'Grudzień',
},
sync: {
title: 'Filtr jest widoczny dla wszystkich',
statusTips: {
on: 'Po włączeniu wszyscy współpracownicy zobaczą wyniki filtrowania',
off: 'Po wyłączeniu tylko Ty zobaczysz wyniki filtrowania',
},
switchTips: {
on: '„Filtr jest widoczny dla wszystkich” jest włączony, wszyscy współpracownicy zobaczą wyniki filtrowania',
off: '„Filtr jest widoczny dla wszystkich” jest wyłączony, tylko Ty zobaczysz wyniki filtrowania',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Alternar Filtro',
'clear-filter-criteria': 'Limpar Condições de Filtro',
're-calc-filter-conditions': 'Recalcular Condições de Filtro',
},
shortcut: {
'smart-toggle-filter': 'Alternar Filtro',
},
permission: {
filterErr: 'Você não tem permissão para usar o filtro.',
},
panel: {
'clear-filter': 'Limpar Filtro',
cancel: 'Cancelar',
confirm: 'Confirmar',
mobile: {
resize: 'Redimensionar o painel de filtro',
back: 'Voltar',
column: 'Coluna {0}',
'previous-column': 'Coluna anterior',
'next-column': 'Próxima coluna',
},
'by-values': 'Por Valores',
'by-colors': 'Por Cores',
'filter-by-cell-fill-color': 'Filtrar por cor de preenchimento da célula',
'filter-by-cell-text-color': 'Filtrar por cor do texto da célula',
'filter-by-color-none': 'A coluna contém apenas uma cor',
'by-conditions': 'Por Condições',
'filter-only': 'Apenas Filtrar',
'search-placeholder': 'Use espaço para separar palavras-chave',
'select-all': 'Selecionar Todos',
'input-values-placeholder': 'Inserir Valores',
and: 'E',
or: 'OU',
empty: '(vazio)',
'?': 'Use "?" para representar um único caractere.',
'*': 'Use "*" para representar vários caracteres.',
},
conditions: {
none: 'Nenhum',
empty: 'Está Vazio',
'not-empty': 'Não Está Vazio',
'text-contains': 'O Texto Contém',
'does-not-contain': 'O Texto Não Contém',
'starts-with': 'O Texto Começa Com',
'ends-with': 'O Texto Termina Com',
equals: 'O Texto É Igual A',
'greater-than': 'Maior Que',
'greater-than-or-equal': 'Maior Ou Igual A',
'less-than': 'Menor Que',
'less-than-or-equal': 'Menor Ou Igual A',
equal: 'Igual',
'not-equal': 'Diferente',
between: 'Entre',
'not-between': 'Não Está Entre',
custom: 'Personalizado',
},
date: {
1: 'Janeiro',
2: 'Fevereiro',
3: 'Março',
4: 'Abril',
5: 'Maio',
6: 'Junho',
7: 'Julho',
8: 'Agosto',
9: 'Setembro',
10: 'Outubro',
11: 'Novembro',
12: 'Dezembro',
},
sync: {
title: 'O filtro é visível para todos',
statusTips: {
on: 'Uma vez ativado, todos os colaboradores verão os resultados do filtro',
off: 'Uma vez desativado, apenas você verá os resultados do filtro',
},
switchTips: {
on: '"O filtro é visível para todos" está ativado, todos os colaboradores verão os resultados do filtro',
off: '"O filtro é visível para todos" está desativado, apenas você verá os resultados do filtro',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Переключить фильтр',
'clear-filter-criteria': 'Очистить условия фильтра',
're-calc-filter-conditions': 'Пересчитать условия фильтра',
},
shortcut: {
'smart-toggle-filter': 'Переключить фильтр',
},
permission: {
filterErr: 'У вас нет разрешения на использование фильтра.',
},
panel: {
'clear-filter': 'Очистить фильтр',
cancel: 'Отмена',
confirm: 'Подтвердить',
mobile: {
resize: 'Изменить размер панели фильтра',
back: 'Назад',
column: 'Столбец {0}',
'previous-column': 'Предыдущий столбец',
'next-column': 'Следующий столбец',
},
'by-values': 'По значениям',
'by-colors': 'По цветам',
'filter-by-cell-fill-color': 'Фильтр по цвету заливки ячейки',
'filter-by-cell-text-color': 'Фильтр по цвету текста ячейки',
'filter-by-color-none': 'Этот столбец содержит только один цвет',
'by-conditions': 'По условиям',
'filter-only': 'Только фильтр',
'search-placeholder': 'Используйте пробел для разделения ключевых слов',
'select-all': 'Выбрать все',
'input-values-placeholder': 'Введите значения',
and: 'И',
or: 'ИЛИ',
empty: '(пусто)',
'?': 'Используйте “?” для обозначения одного символа.',
'*': 'Используйте “*” для обозначения нескольких символов.',
},
conditions: {
none: 'Нет',
empty: 'Пусто',
'not-empty': 'Не пусто',
'text-contains': 'Содержит текст',
'does-not-contain': 'Не содержит текст',
'starts-with': 'Начинается с',
'ends-with': 'Заканчивается на',
equals: 'Равно',
'greater-than': 'Больше чем',
'greater-than-or-equal': 'Больше или равно',
'less-than': 'Меньше чем',
'less-than-or-equal': 'Меньше или равно',
equal: 'Равно',
'not-equal': 'Не равно',
between: 'Между',
'not-between': 'Не между',
custom: 'Пользовательский',
},
date: {
1: 'Январь',
2: 'Февраль',
3: 'Март',
4: 'Апрель',
5: 'Май',
6: 'Июнь',
7: 'Июль',
8: 'Август',
9: 'Сентябрь',
10: 'Октябрь',
11: 'Ноябрь',
12: 'Декабрь',
},
sync: {
title: 'Фильтр виден всем',
statusTips: {
on: 'После включения все сотрудники увидят результаты фильтрации',
off: 'После отключения только вы увидите результаты фильтрации',
},
switchTips: {
on: 'Функция "Фильтр виден всем" включена, все сотрудники увидят результаты фильтрации',
off: 'Функция "Фильтр виден всем" отключена, только вы увидите результаты фильтрации',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Prepnúť filter',
'clear-filter-criteria': 'Vymazať podmienky filtra',
're-calc-filter-conditions': 'Prepočítať podmienky filtra',
},
shortcut: {
'smart-toggle-filter': 'Prepnúť filter',
},
permission: {
filterErr: 'Nemáte povolenie používať filter.',
},
panel: {
'clear-filter': 'Vymazať filter',
cancel: 'Zrušiť',
confirm: 'Potvrdiť',
mobile: {
resize: 'Zmeniť veľkosť panela filtra',
back: 'Späť',
column: 'Stĺpec {0}',
'previous-column': 'Predchádzajúci stĺpec',
'next-column': 'Nasledujúci stĺpec',
},
'by-values': 'Podľa hodnôt',
'by-colors': 'Podľa farieb',
'filter-by-cell-fill-color': 'Filtrovať podľa farby výplne bunky',
'filter-by-cell-text-color': 'Filtrovať podľa farby textu bunky',
'filter-by-color-none': 'Stĺpec obsahuje iba jednu farbu',
'by-conditions': 'Podľa podmienok',
'filter-only': 'Len filtrovať',
'search-placeholder': 'Použite medzeru na oddelenie kľúčových slov',
'select-all': 'Vybrať všetko',
'input-values-placeholder': 'Zadajte hodnoty',
and: 'A',
or: 'ALEBO',
empty: '(prázdne)',
'?': 'Použite „?“ na označenie jedného znaku.',
'*': 'Použite „*“ na označenie viacerých znakov.',
},
conditions: {
none: 'Žiadne',
empty: 'Je prázdne',
'not-empty': 'Nie je prázdne',
'text-contains': 'Text obsahuje',
'does-not-contain': 'Text neobsahuje',
'starts-with': 'Text začína na',
'ends-with': 'Text končí na',
equals: 'Text sa rovná',
'greater-than': 'Väčšie ako',
'greater-than-or-equal': 'Väčšie alebo rovné',
'less-than': 'Menšie ako',
'less-than-or-equal': 'Menšie alebo rovné',
equal: 'Rovné',
'not-equal': 'Nie je rovné',
between: 'Medzi',
'not-between': 'Nie medzi',
custom: 'Vlastné',
},
date: {
1: 'Január',
2: 'Február',
3: 'Marec',
4: 'Apríl',
5: 'Máj',
6: 'Jún',
7: 'Júl',
8: 'August',
9: 'September',
10: 'Október',
11: 'November',
12: 'December',
},
sync: {
title: 'Filter je viditeľný pre všetkých',
statusTips: {
on: 'Po zapnutí budú všetci spolupracovníci vidieť výsledky filtra',
off: 'Po vypnutí budete výsledky filtra vidieť iba vy',
},
switchTips: {
on: '"Filter je viditeľný pre všetkých" je zapnutý, všetci spolupracovníci uvidia výsledky filtra',
off: '"Filter je viditeľný pre všetkých" je vypnutý, výsledky filtra uvidíte iba vy',
},
},
},
};
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,107 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': 'Lọc',
'clear-filter-criteria': 'Xóa điều kiện lọc',
're-calc-filter-conditions': 'Tính toán lại',
},
shortcut: {
'smart-toggle-filter': 'Chuyển đổi lọc',
},
permission: {
filterErr: 'Bạn không có quyền sử dụng bộ lọc.',
},
panel: {
'clear-filter': 'Xóa lọc',
cancel: 'Hủy bỏ',
confirm: 'Xác nhận',
mobile: {
resize: 'Thay đổi kích thước bảng lọc',
back: 'Quay lại',
column: 'Cột {0}',
'previous-column': 'Cột trước',
'next-column': 'Cột tiếp theo',
},
'by-values': 'Theo giá trị',
'by-colors': 'Theo màu sắc',
'filter-by-cell-fill-color': 'Lọc theo màu nền ô',
'filter-by-cell-text-color': 'Lọc theo màu chữ ô',
'filter-by-color-none': 'Cột này chỉ chứa một màu',
'by-conditions': 'Theo điều kiện',
'filter-only': 'Chỉ lọc',
'search-placeholder': 'Sử dụng khoảng trắng để tách các từ khóa',
'select-all': 'Chọn tất cả',
'input-values-placeholder': 'Vui lòng nhập',
or: 'hoặc',
and: 'và',
empty: '(Trống)',
'?': 'Sử dụng ? để đại diện cho một ký tự',
'*': 'Sử dụng * để đại diện cho nhiều ký tự',
},
conditions: {
none: 'Không',
empty: 'Trống',
'not-empty': 'Không trống',
'text-contains': 'Văn bản chứa',
'does-not-contain': 'Văn bản không chứa',
'starts-with': 'Văn bản bắt đầu với',
'ends-with': 'Văn bản kết thúc với',
equals: 'Văn bản khớp',
'greater-than': 'Lớn hơn',
'greater-than-or-equal': 'Lớn hơn hoặc bằng',
'less-than': 'Nhỏ hơn',
'less-than-or-equal': 'Nhỏ hơn hoặc bằng',
equal: 'Bằng',
'not-equal': 'Không bằng',
between: 'Giữa',
'not-between': 'Không giữa',
custom: 'Tùy chỉnh',
},
date: {
1: 'Tháng 1',
2: 'Tháng 2',
3: 'Tháng 3',
4: 'Tháng 4',
5: 'Tháng 5',
6: 'Tháng 6',
7: 'Tháng 7',
8: 'Tháng 8',
9: 'Tháng 9',
10: 'Tháng 10',
11: 'Tháng 11',
12: 'Tháng 12',
},
sync: {
title: 'Lọc cho tất cả mọi người đều thấy',
statusTips: {
on: 'Khi bật, tất cả cộng tác viên sẽ thấy kết quả lọc',
off: 'Khi tắt, chỉ bạn mới thấy kết quả lọc',
},
switchTips: {
on: 'Đã bật "Lọc cho tất cả mọi người đều thấy", tất cả cộng tác viên sẽ thấy kết quả lọc',
off: 'Đã tắt "Lọc cho tất cả mọi người đều thấy", chỉ bạn mới thấy kết quả lọc',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': '筛选',
'clear-filter-criteria': '清除筛选条件',
're-calc-filter-conditions': '重新计算',
},
shortcut: {
'smart-toggle-filter': '切换筛选',
},
permission: {
filterErr: '你没有权限使用筛选。',
},
panel: {
'clear-filter': '清除筛选',
cancel: '取消',
confirm: '确认',
mobile: {
resize: '调整筛选面板',
back: '返回',
column: '{0} 列',
'previous-column': '上一列',
'next-column': '下一列',
},
'by-values': '按值',
'by-colors': '按颜色',
'filter-by-cell-fill-color': '按单元格填充颜色筛选',
'filter-by-cell-text-color': '按单元格文本颜色筛选',
'filter-by-color-none': '该列仅包含一种颜色',
'by-conditions': '按条件',
'filter-only': '仅筛选',
'search-placeholder': '使用空格分隔关键字',
'select-all': '全选',
'input-values-placeholder': '请输入',
or: '或',
and: '和',
empty: '(空白)',
'?': '可用 ? 代表单个字符',
'*': '可用 * 代表任意多个字符',
},
conditions: {
none: '无',
empty: '为空',
'not-empty': '不为空',
'text-contains': '文本包含',
'does-not-contain': '文本不包含',
'starts-with': '文本开头',
'ends-with': '文本结尾',
equals: '文本相符',
'greater-than': '大于',
'greater-than-or-equal': '大于等于',
'less-than': '小于',
'less-than-or-equal': '小于等于',
equal: '等于',
'not-equal': '不等于',
between: '介于',
'not-between': '不介于',
custom: '自定义',
},
date: {
1: '1月',
2: '2月',
3: '3月',
4: '4月',
5: '5月',
6: '6月',
7: '7月',
8: '8月',
9: '9月',
10: '10月',
11: '11月',
12: '12月',
},
sync: {
title: '筛选对所有人可见',
statusTips: {
on: '开启后,所有协作者都将看到此次筛选结果',
off: '关闭后,仅你可见此次筛选结果',
},
switchTips: {
on: '已开启“筛选对所有人可见”,所有协作者都将看到此次筛选结果',
off: '已关闭“筛选对所有人可见”,仅你可见此次筛选结果',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': '篩選',
'clear-filter-criteria': '清除篩選條件',
're-calc-filter-conditions': '重新計算',
},
shortcut: {
'smart-toggle-filter': '切換篩選',
},
permission: {
filterErr: '你沒有權限使用篩選。',
},
panel: {
'clear-filter': '清除篩選',
cancel: '取消',
confirm: '確認',
mobile: {
resize: '調整篩選面板',
back: '返回',
column: '{0} 欄',
'previous-column': '上一欄',
'next-column': '下一欄',
},
'by-values': '按值',
'by-colors': '按顏色',
'filter-by-cell-fill-color': '按儲存格填充顏色篩選',
'filter-by-cell-text-color': '按儲存格文字顏色篩選',
'filter-by-color-none': '該欄僅包含一種顏色',
'by-conditions': '按條件',
'filter-only': '僅篩選',
'search-placeholder': '使用空格分隔關鍵字',
'select-all': '全選',
'input-values-placeholder': '請輸入',
or: '或',
and: '和',
empty: '(空白)',
'?': '可用 ? 代表單一字元',
'*': '可用 * 代表任意多個字元',
},
conditions: {
none: '無',
empty: '為空',
'not-empty': '不為空',
'text-contains': '文字包含',
'does-not-contain': '文字不包含',
'starts-with': '文字開頭',
'ends-with': '文字結尾',
equals: '文字相符',
'greater-than': '大於',
'greater-than-or-equal': '大於等於',
'less-than': '小於',
'less-than-or-equal': '小於等於',
equal: '等於',
'not-equal': '不等於',
between: '介於',
'not-between': '不介於',
custom: '自訂',
},
date: {
1: '1月',
2: '2月',
3: '3月',
4: '4月',
5: '5月',
6: '6月',
7: '7月',
8: '8月',
9: '9月',
10: '10月',
11: '11月',
12: '12月',
},
sync: {
title: '篩選對所有人可見',
statusTips: {
on: '開啟後,所有協作者都將看到此次篩選結果',
off: '關閉後,僅你可見此次篩選結果',
},
switchTips: {
on: '已開啟“篩選對所有人可見”,所有協作者都將看到此次篩選結果',
off: '已關閉“篩選對所有人可見”,僅你可見此次篩選結果',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,106 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'sheets-filter-ui': {
toolbar: {
'smart-toggle-filter-tooltip': '篩選',
'clear-filter-criteria': '清除篩選條件',
're-calc-filter-conditions': '重新計算',
},
shortcut: {
'smart-toggle-filter': '切換篩選',
},
permission: {
filterErr: '你沒有權限使用篩選。',
},
panel: {
'clear-filter': '清除篩選',
cancel: '取消',
confirm: '確認',
mobile: {
resize: '調整篩選面板',
back: '返回',
column: '{0} 欄',
'previous-column': '上一欄',
'next-column': '下一欄',
},
'by-values': '按值',
'by-colors': '按顏色',
'filter-by-cell-fill-color': '按儲存格填充顏色篩選',
'filter-by-cell-text-color': '按儲存格文字顏色篩選',
'filter-by-color-none': '該欄僅包含一種顏色',
'by-conditions': '按條件',
'filter-only': '僅篩選',
'search-placeholder': '使用空格分隔關鍵字',
'select-all': '全選',
'input-values-placeholder': '請輸入',
or: '或',
and: '和',
empty: '(空白)',
'?': '可用 ? 代表單一字元',
'*': '可用 * 代表任意多個字元',
},
conditions: {
none: '無',
empty: '為空',
'not-empty': '不為空',
'text-contains': '文字包含',
'does-not-contain': '文字不包含',
'starts-with': '文字開頭',
'ends-with': '文字結尾',
equals: '文字相符',
'greater-than': '大於',
'greater-than-or-equal': '大於等於',
'less-than': '小於',
'less-than-or-equal': '小於等於',
equal: '等於',
'not-equal': '不等於',
between: '介於',
'not-between': '不介於',
custom: '自訂',
},
date: {
1: '1月',
2: '2月',
3: '3月',
4: '4月',
5: '5月',
6: '6月',
7: '7月',
8: '8月',
9: '9月',
10: '10月',
11: '11月',
12: '12月',
},
sync: {
title: '篩選對所有人可見',
statusTips: {
on: '開啟後,所有協作者都將看到此次篩選結果',
off: '關閉後,僅你可見此次篩選結果',
},
switchTips: {
on: '已開啟“篩選對所有人可見”,所有協作者都將看到此次篩選結果',
off: '已關閉“篩選對所有人可見”,僅你可見此次篩選結果',
},
},
},
};
export default locale;

View File

@@ -0,0 +1,46 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuSchemaType } from '@univerjs/ui';
import {
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
SmartToggleSheetsFilterCommand,
} from '@univerjs/sheets-filter';
import { RibbonDataGroup } from '@univerjs/ui';
import {
ClearFilterCriteriaMenuItemFactory,
ReCalcFilterMenuItemFactory,
SmartToggleFilterMenuItemFactory,
} from './sheets-filter.menu';
export const menuSchema: MenuSchemaType = {
[RibbonDataGroup.ORGANIZATION]: {
[SmartToggleSheetsFilterCommand.id]: {
order: 2,
gridLayout: { row: 1, column: 2 },
menuItemFactory: SmartToggleFilterMenuItemFactory,
[ClearSheetsFilterCriteriaCommand.id]: {
order: 0,
menuItemFactory: ClearFilterCriteriaMenuItemFactory,
},
[ReCalcSheetsFilterCommand.id]: {
order: 1,
menuItemFactory: ReCalcFilterMenuItemFactory,
},
},
},
};

View File

@@ -0,0 +1,85 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IAccessor } from '@univerjs/core';
import type { IMenuButtonItem, IMenuSelectorItem } from '@univerjs/ui';
import type { LocaleKey } from '../locale/types';
import { UniverInstanceType } from '@univerjs/core';
import {
RangeProtectionPermissionViewPoint,
WorksheetFilterPermission,
WorksheetViewPermission,
} from '@univerjs/sheets';
import {
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
SheetsFilterService,
SmartToggleSheetsFilterCommand,
} from '@univerjs/sheets-filter';
import { getCurrentRangeDisable$, getObservableWithExclusiveRange$ } from '@univerjs/sheets-ui';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { map, of, switchMap } from 'rxjs';
export function SmartToggleFilterMenuItemFactory(accessor: IAccessor): IMenuSelectorItem<LocaleKey> {
const sheetsFilterService = accessor.get(SheetsFilterService);
return {
id: SmartToggleSheetsFilterCommand.id,
type: MenuItemType.BUTTON_SELECTOR,
icon: 'FilterIcon',
tooltip: 'sheets-filter-ui.toolbar.smart-toggle-filter-tooltip',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
activated$: sheetsFilterService.activeFilterModel$.pipe(map((model) => !!model)),
disabled$: getObservableWithExclusiveRange$(
accessor,
getCurrentRangeDisable$(
accessor,
{
worksheetTypes: [WorksheetFilterPermission, WorksheetViewPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
}
)
),
};
}
export function ClearFilterCriteriaMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
const sheetsFilterService = accessor.get(SheetsFilterService);
return {
id: ClearSheetsFilterCriteriaCommand.id,
type: MenuItemType.BUTTON,
title: 'sheets-filter-ui.toolbar.clear-filter-criteria',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: sheetsFilterService
.activeFilterModel$
.pipe(switchMap((model) => model?.hasCriteria$.pipe(map((m) => !m)) ?? of(true))),
};
}
export function ReCalcFilterMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
const sheetsFilterService = accessor.get(SheetsFilterService);
return {
id: ReCalcSheetsFilterCommand.id,
type: MenuItemType.BUTTON,
title: 'sheets-filter-ui.toolbar.re-calc-filter-conditions',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: sheetsFilterService
.activeFilterModel$
.pipe(switchMap((model) => model?.hasCriteria$.pipe(map((m) => !m)) ?? of(true))),
};
}

View File

@@ -0,0 +1,84 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IUniverSheetsFilterUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import { UniverSheetsMobileUIPlugin } from '@univerjs/sheets-ui';
import { UniverMobileUIPlugin } from '@univerjs/ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetsFilterMobileMenuController } from './controllers/mobile-menu.controller';
import { MobileComponentsController } from './controllers/mobile/components.controller';
import { SheetsFilterUIMobileController } from './controllers/mobile/ui.controller';
import { SheetsFilterPermissionController } from './controllers/sheets-filter-permission.controller';
import { ISheetsFilterPanelService, SheetsFilterPanelService } from './services/sheets-filter-panel.service';
@DependentOn(
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverMobileUIPlugin,
UniverSheetsFilterPlugin,
UniverSheetsMobileUIPlugin
)
export class UniverSheetsFilterMobileUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SHEET_FILTER_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverSheetsFilterUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([MobileComponentsController]);
this._injector.get(MobileComponentsController);
([
[ISheetsFilterPanelService, { useClass: SheetsFilterPanelService }],
[SheetsFilterPermissionController],
[SheetsFilterMobileMenuController],
[SheetsFilterUIMobileController],
] as Dependency[]).forEach((d) => this._injector.add(d));
}
override onReady(): void {
this._injector.get(SheetsFilterPermissionController);
this._injector.get(SheetsFilterMobileMenuController);
}
override onRendered(): void {
this._injector.get(SheetsFilterUIMobileController);
}
}

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 { BooleanNumber } from '@univerjs/core';
import { CustomFilterOperator } from '@univerjs/sheets-filter';
import { describe, expect, it } from 'vitest';
import { FilterConditionItems } from '../conditions';
import { ExtendCustomFilterOperator } from '../extended-operators';
describe('test "FilterConditionItems"', () => {
describe('test utils work as expected in different condition format situations', () => {
describe('test NONE condition', () => {
it('should load NONE condition from empty filter column', () => {
expect(FilterConditionItems.testMappingFilterColumn()).toEqual([FilterConditionItems.NONE, {}]);
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: undefined })).toEqual([FilterConditionItems.NONE, {}]);
});
it('should throw error when trying to get default params', () => {
expect(() => FilterConditionItems.NONE.getDefaultFormParams()).toThrowError();
});
it('should mapped to its own operator', () => {
expect(FilterConditionItems.NONE.testMappingParams({ operator1: ExtendCustomFilterOperator.NONE })).toBeTruthy();
expect(FilterConditionItems.NONE.testMappingParams({ operator1: ExtendCustomFilterOperator.NOT_EQUALS })).toBeFalsy();
});
it('should map to empty filter column', () => {
// clear filter column
expect(FilterConditionItems.NONE.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.NONE })).toEqual(null);
});
});
describe('test EMPTY condition', () => {
it('should load EMPTY condition from custom filter with empty string', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '' }] } }))
.toEqual([FilterConditionItems.EMPTY, { operator1: ExtendCustomFilterOperator.EMPTY }]);
});
it('should throw error when trying to get default params', () => {
expect(() => FilterConditionItems.EMPTY.getDefaultFormParams()).toThrowError();
});
it('should map to its own operator', () => {
expect(FilterConditionItems.EMPTY.testMappingParams({ operator1: ExtendCustomFilterOperator.EMPTY })).toBeTruthy();
expect(FilterConditionItems.EMPTY.testMappingParams({ operator1: ExtendCustomFilterOperator.NONE })).toBeFalsy();
});
it('should map to filter column with empty string', () => {
expect(FilterConditionItems.EMPTY.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.EMPTY }))
.toEqual({ customFilters: { customFilters: [{ val: '' }] } });
});
});
describe('test NOT_EMPTY condition', () => {
it('should load NOT_EMPTY condition from custom filter with space string', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ operator: CustomFilterOperator.NOT_EQUALS, val: ' ' }] } }))
.toEqual([FilterConditionItems.NOT_EMPTY, { operator1: ExtendCustomFilterOperator.NOT_EMPTY }]);
// when the val is not a space char, it would map to NOT_EQUAL
expect(FilterConditionItems.testMappingFilterColumn({
customFilters: { customFilters: [{ operator: CustomFilterOperator.NOT_EQUALS, val: '123' }] },
})).toEqual([FilterConditionItems.NOT_EQUAL, { operator1: CustomFilterOperator.NOT_EQUALS, val1: '123' }]);
});
it('should throw error when trying to get default params', () => {
expect(() => FilterConditionItems.NOT_EMPTY.getDefaultFormParams()).toThrowError();
});
it('should map to its own operator', () => {
expect(FilterConditionItems.NOT_EMPTY.testMappingParams({ operator1: ExtendCustomFilterOperator.NOT_EMPTY })).toBeTruthy();
expect(FilterConditionItems.NOT_EMPTY.testMappingParams({ operator1: ExtendCustomFilterOperator.NONE })).toBeFalsy();
});
it('should map to filter column with space string', () => {
expect(FilterConditionItems.NOT_EMPTY.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.NOT_EMPTY }))
.toEqual({ customFilters: { customFilters: [{ operator: CustomFilterOperator.NOT_EQUALS, val: '' }] } });
});
});
describe('test TEXT_CONTAINS condition', () => {
it('should load TEXT_CONTAINS condition from char set "*xxx*"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '*xxx*' }] } }))
.toEqual([FilterConditionItems.TEXT_CONTAINS, { operator1: ExtendCustomFilterOperator.CONTAINS, val1: 'xxx' }]);
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '*xxx' }] } }))
.not
.toEqual([FilterConditionItems.TEXT_CONTAINS, { operator1: ExtendCustomFilterOperator.CONTAINS, val1: 'xxx' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.TEXT_CONTAINS.getDefaultFormParams()).toEqual({ operator1: ExtendCustomFilterOperator.CONTAINS, val1: '' });
});
it('should mapped params with only one operator CONTAINS', () => {
expect(FilterConditionItems.TEXT_CONTAINS.testMappingParams({ operator1: ExtendCustomFilterOperator.CONTAINS })).toBeTruthy();
expect(FilterConditionItems.TEXT_CONTAINS.testMappingParams({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN })).toBeFalsy();
expect(() => FilterConditionItems.TEXT_CONTAINS.testMappingParams({ operator1: ExtendCustomFilterOperator.CONTAINS, operator2: ExtendCustomFilterOperator.CONTAINS }))
.toThrowError();
expect(FilterConditionItems.TEXT_CONTAINS.testMappingParams({ operator2: ExtendCustomFilterOperator.CONTAINS })).toBeTruthy();
});
it('should map to filter column with char set "*xxx*"', () => {
expect(FilterConditionItems.TEXT_CONTAINS.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.CONTAINS, val1: 'univer' }))
.toEqual({ customFilters: { customFilters: [{ val: '*univer*' }] } });
expect(FilterConditionItems.TEXT_CONTAINS.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.CONTAINS, val1: ' ' }))
.toEqual({ customFilters: { customFilters: [{ val: '* *' }] } });
});
});
describe('test DOES_NOT_CONTAIN condition', () => {
it('should load DOES_NOT_CONTAIN condition from char set "*xxx*"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '*xxx*', operator: CustomFilterOperator.NOT_EQUALS }] } }))
.toEqual([FilterConditionItems.DOES_NOT_CONTAIN, { operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN, val1: 'xxx' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.DOES_NOT_CONTAIN.getDefaultFormParams()).toEqual({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN, val1: '' });
});
it('should mapped params with only one operator DOES_NOT_CONTAIN', () => {
expect(FilterConditionItems.DOES_NOT_CONTAIN.testMappingParams({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN })).toBeTruthy();
expect(FilterConditionItems.DOES_NOT_CONTAIN.testMappingParams({ operator1: ExtendCustomFilterOperator.CONTAINS })).toBeFalsy();
expect(() => FilterConditionItems.DOES_NOT_CONTAIN.testMappingParams({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN, operator2: ExtendCustomFilterOperator.DOES_NOT_CONTAIN }))
.toThrowError();
expect(FilterConditionItems.DOES_NOT_CONTAIN.testMappingParams({ operator2: ExtendCustomFilterOperator.DOES_NOT_CONTAIN })).toBeTruthy();
});
it('should map to filter column with char set "*xxx*"', () => {
expect(FilterConditionItems.DOES_NOT_CONTAIN.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN, val1: 'univer' }))
.toEqual({ customFilters: { customFilters: [{ val: '*univer*', operator: CustomFilterOperator.NOT_EQUALS }] } });
expect(FilterConditionItems.DOES_NOT_CONTAIN.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN, val1: ' ' }))
.toEqual({ customFilters: { customFilters: [{ val: '* *', operator: CustomFilterOperator.NOT_EQUALS }] } });
});
});
describe('test STARTS_WITH', () => {
it('should load STARTS_WITH condition from char set "xxx*"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: 'xxx*' }] } }))
.toEqual([FilterConditionItems.STARTS_WITH, { operator1: ExtendCustomFilterOperator.STARTS_WITH, val1: 'xxx' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.STARTS_WITH.getDefaultFormParams()).toEqual({ operator1: ExtendCustomFilterOperator.STARTS_WITH, val1: '' });
});
it('should mapped params with only one operator STARTS_WITH', () => {
expect(FilterConditionItems.STARTS_WITH.testMappingParams({ operator1: ExtendCustomFilterOperator.STARTS_WITH })).toBeTruthy();
expect(FilterConditionItems.STARTS_WITH.testMappingParams({ operator1: ExtendCustomFilterOperator.DOES_NOT_START_WITH })).toBeFalsy();
expect(() => FilterConditionItems.STARTS_WITH.testMappingParams({ operator1: ExtendCustomFilterOperator.STARTS_WITH, operator2: ExtendCustomFilterOperator.STARTS_WITH }))
.toThrowError();
expect(FilterConditionItems.STARTS_WITH.testMappingParams({ operator2: ExtendCustomFilterOperator.STARTS_WITH })).toBeTruthy();
});
it('should map to filter column with char set "xxx*"', () => {
expect(FilterConditionItems.STARTS_WITH.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.STARTS_WITH, val1: 'univer' }))
.toEqual({ customFilters: { customFilters: [{ val: 'univer*' }] } });
expect(FilterConditionItems.STARTS_WITH.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.STARTS_WITH, val1: ' ' }))
.toEqual({ customFilters: { customFilters: [{ val: ' *' }] } });
});
});
describe('test ENDS_WITH', () => {
it('should load ENDS_WITH condition from char set "*xxx"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '*xxx' }] } }))
.toEqual([FilterConditionItems.ENDS_WITH, { operator1: ExtendCustomFilterOperator.ENDS_WITH, val1: 'xxx' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.ENDS_WITH.getDefaultFormParams()).toEqual({ operator1: ExtendCustomFilterOperator.ENDS_WITH, val1: '' });
});
it('should mapped params with only one operator ENDS_WITH', () => {
expect(FilterConditionItems.ENDS_WITH.testMappingParams({ operator1: ExtendCustomFilterOperator.ENDS_WITH })).toBeTruthy();
expect(FilterConditionItems.ENDS_WITH.testMappingParams({ operator1: ExtendCustomFilterOperator.DOES_NOT_END_WITH })).toBeFalsy();
expect(() => FilterConditionItems.ENDS_WITH.testMappingParams({ operator1: ExtendCustomFilterOperator.ENDS_WITH, operator2: ExtendCustomFilterOperator.ENDS_WITH }))
.toThrowError();
expect(FilterConditionItems.ENDS_WITH.testMappingParams({ operator2: ExtendCustomFilterOperator.ENDS_WITH })).toBeTruthy();
});
it('should map to filter column with char set "*xxx"', () => {
expect(FilterConditionItems.ENDS_WITH.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.ENDS_WITH, val1: 'univer' }))
.toEqual({ customFilters: { customFilters: [{ val: '*univer' }] } });
expect(FilterConditionItems.ENDS_WITH.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.ENDS_WITH, val1: ' ' }))
.toEqual({ customFilters: { customFilters: [{ val: '* ' }] } });
});
});
describe('test EQUALS', () => {
it('should load EQUALS condition from char set "xxx"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: 'xxx' }] } }))
.toEqual([FilterConditionItems.EQUALS, { operator1: ExtendCustomFilterOperator.EQUALS, val1: 'xxx' }]);
expect(FilterConditionItems.testMappingFilterColumn({ filters: { filters: [''] } }))
.toEqual([FilterConditionItems.EQUALS, { operator1: ExtendCustomFilterOperator.EQUALS, val1: '' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.EQUALS.getDefaultFormParams()).toEqual({ operator1: ExtendCustomFilterOperator.EQUALS, val1: '' });
});
it('should mapped params with only one operator EQUALS', () => {
expect(FilterConditionItems.EQUALS.testMappingParams({ operator1: ExtendCustomFilterOperator.EQUALS })).toBeTruthy();
expect(FilterConditionItems.EQUALS.testMappingParams({ operator1: ExtendCustomFilterOperator.NOT_EQUALS })).toBeFalsy();
expect(() => FilterConditionItems.EQUALS.testMappingParams({ operator1: ExtendCustomFilterOperator.EQUALS, operator2: ExtendCustomFilterOperator.EQUALS }))
.toThrowError();
expect(FilterConditionItems.EQUALS.testMappingParams({ operator2: ExtendCustomFilterOperator.EQUALS })).toBeTruthy();
});
it('should map to filter column with char set "xxx"', () => {
expect(FilterConditionItems.EQUALS.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.EQUALS, val1: 'univer' }))
.toEqual({ customFilters: { customFilters: [{ val: 'univer' }] } });
expect(FilterConditionItems.EQUALS.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.EQUALS, val1: ' ' }))
.toEqual({ customFilters: { customFilters: [{ val: ' ' }] } });
});
});
describe('test GREATER_THAN', () => {
it('should load GREATER_THAN condition from operator"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.GREATER_THAN }] } }))
.toEqual([FilterConditionItems.GREATER_THAN, { operator1: CustomFilterOperator.GREATER_THAN, val1: '123' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.GREATER_THAN.getDefaultFormParams()).toEqual({ operator1: CustomFilterOperator.GREATER_THAN, val1: '' });
});
it('should mapped params with only one operator GREATER_THAN', () => {
expect(FilterConditionItems.GREATER_THAN.testMappingParams({ operator1: CustomFilterOperator.GREATER_THAN })).toBeTruthy();
expect(FilterConditionItems.GREATER_THAN.testMappingParams({ operator1: CustomFilterOperator.LESS_THAN })).toBeFalsy();
expect(() => FilterConditionItems.GREATER_THAN.testMappingParams({ operator1: CustomFilterOperator.GREATER_THAN, operator2: CustomFilterOperator.GREATER_THAN }))
.toThrowError();
expect(FilterConditionItems.GREATER_THAN.testMappingParams({ operator2: CustomFilterOperator.GREATER_THAN })).toBeTruthy();
});
it('should map to filter column with operator GREATER_THAN', () => {
expect(FilterConditionItems.GREATER_THAN.mapToFilterColumn({ operator1: CustomFilterOperator.GREATER_THAN, val1: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.GREATER_THAN }] } });
});
});
describe('test GREATER_THAN_OR_EQUAL', () => {
it('should load GREATER_THAN_OR_EQUAL condition from operator"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL }] } }))
.toEqual([FilterConditionItems.GREATER_THAN_OR_EQUAL, { operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL, val1: '123' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.GREATER_THAN_OR_EQUAL.getDefaultFormParams()).toEqual({ operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL, val1: '' });
});
it('should mapped params with only one operator GREATER_THAN_OR_EQUAL', () => {
expect(FilterConditionItems.GREATER_THAN_OR_EQUAL.testMappingParams({ operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL })).toBeTruthy();
expect(FilterConditionItems.GREATER_THAN_OR_EQUAL.testMappingParams({ operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL })).toBeFalsy();
expect(() => FilterConditionItems.GREATER_THAN_OR_EQUAL.testMappingParams({ operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL, operator2: CustomFilterOperator.GREATER_THAN_OR_EQUAL }))
.toThrowError();
expect(FilterConditionItems.GREATER_THAN_OR_EQUAL.testMappingParams({ operator2: CustomFilterOperator.GREATER_THAN_OR_EQUAL })).toBeTruthy();
});
it('should map to filter column with operator GREATER_THAN_OR_EQUAL', () => {
expect(FilterConditionItems.GREATER_THAN_OR_EQUAL.mapToFilterColumn({ operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL, val1: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL }] } });
});
});
describe('test LESS_THAN', () => {
it('should load LESS_THAN condition from operator"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.LESS_THAN }] } }))
.toEqual([FilterConditionItems.LESS_THAN, { operator1: CustomFilterOperator.LESS_THAN, val1: '123' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.LESS_THAN.getDefaultFormParams()).toEqual({ operator1: CustomFilterOperator.LESS_THAN, val1: '' });
});
it('should mapped params with only one operator LESS_THAN', () => {
expect(FilterConditionItems.LESS_THAN.testMappingParams({ operator1: CustomFilterOperator.LESS_THAN })).toBeTruthy();
expect(FilterConditionItems.LESS_THAN.testMappingParams({ operator1: CustomFilterOperator.GREATER_THAN })).toBeFalsy();
expect(() => FilterConditionItems.LESS_THAN.testMappingParams({ operator1: CustomFilterOperator.LESS_THAN, operator2: CustomFilterOperator.LESS_THAN }))
.toThrowError();
expect(FilterConditionItems.LESS_THAN.testMappingParams({ operator2: CustomFilterOperator.LESS_THAN })).toBeTruthy();
});
it('should map to filter column with operator LESS_THAN', () => {
expect(FilterConditionItems.LESS_THAN.mapToFilterColumn({ operator1: CustomFilterOperator.LESS_THAN, val1: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.LESS_THAN }] } });
});
});
describe('test LESS_THAN_OR_EQUAL', () => {
it('should load LESS_THAN_OR_EQUAL condition from operator"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.LESS_THAN_OR_EQUAL }] } }))
.toEqual([FilterConditionItems.LESS_THAN_OR_EQUAL, { operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL, val1: '123' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.LESS_THAN_OR_EQUAL.getDefaultFormParams()).toEqual({ operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL, val1: '' });
});
it('should mapped params with only one operator LESS_THAN_OR_EQUAL', () => {
expect(FilterConditionItems.LESS_THAN_OR_EQUAL.testMappingParams({ operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL })).toBeTruthy();
expect(FilterConditionItems.LESS_THAN_OR_EQUAL.testMappingParams({ operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL })).toBeFalsy();
expect(() => FilterConditionItems.LESS_THAN_OR_EQUAL.testMappingParams({ operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL, operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL }))
.toThrowError();
expect(FilterConditionItems.LESS_THAN_OR_EQUAL.testMappingParams({ operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL })).toBeTruthy();
});
it('should map to filter column with operator LESS_THAN_OR_EQUAL', () => {
expect(FilterConditionItems.LESS_THAN_OR_EQUAL.mapToFilterColumn({ operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL, val1: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.LESS_THAN_OR_EQUAL }] } });
});
});
describe('test EQUAL condition', () => {
it('should load EQUAL condition from operator"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.EQUAL }] } }))
.toEqual([FilterConditionItems.EQUAL, { operator1: CustomFilterOperator.EQUAL, val1: '123' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.EQUAL.getDefaultFormParams()).toEqual({ operator1: CustomFilterOperator.EQUAL, val1: '' });
});
it('should mapped params with only one operator EQUAL', () => {
expect(FilterConditionItems.EQUAL.testMappingParams({ operator1: CustomFilterOperator.EQUAL })).toBeTruthy();
expect(FilterConditionItems.EQUAL.testMappingParams({ operator1: CustomFilterOperator.NOT_EQUALS })).toBeFalsy();
expect(() => FilterConditionItems.EQUAL.testMappingParams({ operator1: CustomFilterOperator.EQUAL, operator2: CustomFilterOperator.EQUAL }))
.toThrowError();
expect(FilterConditionItems.EQUAL.testMappingParams({ operator2: CustomFilterOperator.EQUAL })).toBeTruthy();
});
it('should map to filter column with operator EQUAL', () => {
expect(FilterConditionItems.EQUAL.mapToFilterColumn({ operator1: CustomFilterOperator.EQUAL, val1: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.EQUAL }] } });
});
});
describe('test NOT_EQUAL', () => {
it('should load NOT_EQUAL condition from operator"', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.NOT_EQUALS }] } }))
.toEqual([FilterConditionItems.NOT_EQUAL, { operator1: CustomFilterOperator.NOT_EQUALS, val1: '123' }]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.NOT_EQUAL.getDefaultFormParams()).toEqual({ operator1: CustomFilterOperator.NOT_EQUALS, val1: '' });
});
it('should mapped params with only one operator NOT_EQUAL', () => {
expect(FilterConditionItems.NOT_EQUAL.testMappingParams({ operator1: CustomFilterOperator.NOT_EQUALS })).toBeTruthy();
expect(FilterConditionItems.NOT_EQUAL.testMappingParams({ operator1: CustomFilterOperator.EQUAL })).toBeFalsy();
expect(() => FilterConditionItems.NOT_EQUAL.testMappingParams({ operator1: CustomFilterOperator.NOT_EQUALS, operator2: CustomFilterOperator.NOT_EQUALS }))
.toThrowError();
expect(FilterConditionItems.NOT_EQUAL.testMappingParams({ operator2: CustomFilterOperator.NOT_EQUALS })).toBeTruthy();
});
it('should map to filter column with operator NOT_EQUAL', () => {
expect(FilterConditionItems.NOT_EQUAL.mapToFilterColumn({ operator1: CustomFilterOperator.NOT_EQUALS, val1: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '123', operator: CustomFilterOperator.NOT_EQUALS }] } });
});
});
describe('test BETWEEN', () => {
it('should load BETWEEN condition from correct combination of two operators"', () => {
expect(FilterConditionItems.testMappingFilterColumn({
customFilters: {
and: BooleanNumber.TRUE,
customFilters: [
{ val: '123', operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL },
{ val: '456', operator: CustomFilterOperator.LESS_THAN_OR_EQUAL },
],
},
})).toEqual([FilterConditionItems.BETWEEN, {
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: '123',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '456',
}]);
expect(FilterConditionItems.testMappingFilterColumn({
customFilters: {
and: BooleanNumber.TRUE,
customFilters: [
{ val: '456', operator: CustomFilterOperator.LESS_THAN_OR_EQUAL },
{ val: '123', operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL },
],
},
})).toEqual([FilterConditionItems.BETWEEN, {
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: '123',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '456',
}]);
expect(FilterConditionItems.testMappingFilterColumn({
customFilters: {
customFilters: [
{ val: '456', operator: CustomFilterOperator.LESS_THAN_OR_EQUAL },
{ val: '123', operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL },
],
},
})).not.toEqual([FilterConditionItems.BETWEEN, {
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: '123',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '456',
}]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.BETWEEN.getDefaultFormParams()).toEqual({
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: '',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '',
});
});
it('should mapped params with only two operators GREATER_THAN_OR_EQUAL and LESS_THAN_OR_EQUAL', () => {
expect(FilterConditionItems.BETWEEN.testMappingParams({
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
})).toBeTruthy();
expect(FilterConditionItems.BETWEEN.testMappingParams({
and: true,
operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL,
operator2: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
})).toBeTruthy();
expect(FilterConditionItems.BETWEEN.testMappingParams({
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
})).toBeFalsy();
expect(FilterConditionItems.BETWEEN.testMappingParams({
and: true,
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
})).toBeFalsy();
expect(FilterConditionItems.BETWEEN.testMappingParams({
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
operator2: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
})).toBeFalsy();
});
it('should map to filter column with correct combination of two operators', () => {
expect(FilterConditionItems.BETWEEN.mapToFilterColumn({
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: '123',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '456',
})).toEqual({
customFilters: {
and: BooleanNumber.TRUE,
customFilters: [
{ val: '123', operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL },
{ val: '456', operator: CustomFilterOperator.LESS_THAN_OR_EQUAL },
],
},
});
});
});
describe('test NOT_BETWEEN', () => {
it('should load NOT_BETWEEN condition from correct combination of two operators"', () => {
expect(FilterConditionItems.testMappingFilterColumn({
customFilters: {
customFilters: [
{ val: '123', operator: CustomFilterOperator.LESS_THAN },
{ val: '456', operator: CustomFilterOperator.GREATER_THAN },
],
},
})).toEqual([FilterConditionItems.NOT_BETWEEN, {
operator1: CustomFilterOperator.LESS_THAN,
val1: '123',
operator2: CustomFilterOperator.GREATER_THAN,
val2: '456',
}]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.NOT_BETWEEN.getDefaultFormParams()).toEqual({
operator1: CustomFilterOperator.LESS_THAN,
val1: '',
operator2: CustomFilterOperator.GREATER_THAN,
val2: '',
});
});
it('should mapped params with only two operators GREATER_THAN and LESS_THAN', () => {
expect(FilterConditionItems.NOT_BETWEEN.testMappingParams({
operator1: CustomFilterOperator.GREATER_THAN,
operator2: CustomFilterOperator.LESS_THAN,
})).toBeTruthy();
expect(FilterConditionItems.NOT_BETWEEN.testMappingParams({
operator1: CustomFilterOperator.LESS_THAN,
operator2: CustomFilterOperator.GREATER_THAN,
})).toBeTruthy();
expect(FilterConditionItems.NOT_BETWEEN.testMappingParams({
operator1: CustomFilterOperator.GREATER_THAN,
})).toBeFalsy();
expect(FilterConditionItems.NOT_BETWEEN.testMappingParams({
operator2: CustomFilterOperator.LESS_THAN,
})).toBeFalsy();
expect(FilterConditionItems.NOT_BETWEEN.testMappingParams({
operator1: CustomFilterOperator.GREATER_THAN,
operator2: CustomFilterOperator.GREATER_THAN,
})).toBeFalsy();
});
it('should map to filter column with correct combination of two operators', () => {
expect(FilterConditionItems.NOT_BETWEEN.mapToFilterColumn({
operator1: CustomFilterOperator.GREATER_THAN,
val1: '123',
operator2: CustomFilterOperator.LESS_THAN,
val2: '456',
})).toEqual({
customFilters: {
customFilters: [
{ val: '123', operator: CustomFilterOperator.GREATER_THAN },
{ val: '456', operator: CustomFilterOperator.LESS_THAN },
],
},
});
});
});
describe('test CUSTOM', () => {
it('should load CUSTOM condition from two custom filters that cannot map to previous', () => {
expect(FilterConditionItems.testMappingFilterColumn({ customFilters: { and: BooleanNumber.TRUE, customFilters: [
{ val: '*123' },
{ val: '123*' },
] } })).toEqual([FilterConditionItems.CUSTOM, {
and: true,
operator1: ExtendCustomFilterOperator.ENDS_WITH,
val1: '123',
operator2: ExtendCustomFilterOperator.STARTS_WITH,
val2: '123',
}]);
});
it('should support default params with empty string', () => {
expect(FilterConditionItems.CUSTOM.getDefaultFormParams()).toEqual({
operator1: ExtendCustomFilterOperator.NONE,
val1: '',
operator2: ExtendCustomFilterOperator.NONE,
val2: '',
});
});
it('should mapped by any params change', () => {
expect(FilterConditionItems.CUSTOM.testMappingParams({ operator1: ExtendCustomFilterOperator.CUSTOM })).toBeTruthy();
});
it('should map to filter column with empty string', () => {
expect(FilterConditionItems.CUSTOM.mapToFilterColumn({
and: true,
operator1: ExtendCustomFilterOperator.ENDS_WITH,
val1: '123',
operator2: CustomFilterOperator.GREATER_THAN,
val2: '456',
}))
.toEqual({ customFilters: {
and: BooleanNumber.TRUE,
customFilters: [{ val: '*123' }, { val: '456', operator: CustomFilterOperator.GREATER_THAN }],
} });
});
it('should fallback to NONE when no custom filter is provided', () => {
expect(FilterConditionItems.CUSTOM.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.NONE })).toEqual(null);
});
it('should fallback to one conditional filter when only one is provided', () => {
expect(FilterConditionItems.CUSTOM.mapToFilterColumn({ operator1: ExtendCustomFilterOperator.ENDS_WITH, val1: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '*123' }] } });
expect(FilterConditionItems.CUSTOM.mapToFilterColumn({ operator2: ExtendCustomFilterOperator.STARTS_WITH, val2: '123' }))
.toEqual({ customFilters: { customFilters: [{ val: '123*' }] } });
});
});
});
});

View File

@@ -0,0 +1,822 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { ICustomFilters, IFilterColumn } from '@univerjs/sheets-filter';
import type { LocaleKey } from '../locale/types';
import { BooleanNumber } from '@univerjs/core';
import { CustomFilterOperator } from '@univerjs/sheets-filter';
import { ExtendCustomFilterOperator, OperatorOrder } from './extended-operators';
// This file implements a simple form schema system for condition filters (and only for it!)
export type FilterOperator = ExtendCustomFilterOperator | CustomFilterOperator;
export interface IFilterConditionFormParams {
and?: true;
operator1?: FilterOperator;
val1?: string;
operator2?: FilterOperator;
val2?: string;
}
export interface IFilterConditionItem {
operator: FilterOperator;
numOfParameters: number;
order: OperatorOrder;
/**
* Name of the filter condition. Should be an i18n key.
*/
label: LocaleKey;
and?: true;
/**
* Get the initial form parameters for this condition item. This should only be called when `numOfParameters` is not `0`.
*/
getDefaultFormParams(): IFilterConditionFormParams;
/**
* Test if the form params can be mapped to this condition item. This should be called when the
* condition form changes and `numOfParameters` is not `0`.
* @param params
*/
testMappingParams(params: IFilterConditionFormParams): boolean;
/**
* When user confirm changing filter condition, this method will be called to map the form params
* to the filter column data.
* @param mapParams
*/
mapToFilterColumn(mapParams: IFilterConditionFormParams): Nullable<Omit<IFilterColumn, 'colId'>>;
/**
* Test if the filter column data can be mapped to this condition item.
* It should return the mapping parameters if it can be mapped, otherwise `false`.
* This should be called when the filter panel opens.
*
* @param filterColumn
* @returns the mapping parameters if it can be mapped, otherwise `false`
*/
testMappingFilterColumn(filterColumn: Omit<IFilterColumn, 'colId'>): IFilterConditionFormParams | false;
}
// eslint-disable-next-line ts/no-namespace
export namespace FilterConditionItems {
export const NONE: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.none',
operator: ExtendCustomFilterOperator.NONE,
order: OperatorOrder.SECOND,
numOfParameters: 0,
getDefaultFormParams: () => { throw new Error('[FilterConditionItems.NONE]: should not have initial form params!'); },
testMappingParams: (params) => {
return params.operator1 === ExtendCustomFilterOperator.NONE;
},
mapToFilterColumn: () => null,
testMappingFilterColumn: (filterColumn) => {
if (!filterColumn.customFilters && !filterColumn.filters) return {};
return false;
},
};
// ------------------------------
export const EMPTY: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.empty',
operator: ExtendCustomFilterOperator.EMPTY,
order: OperatorOrder.SECOND,
numOfParameters: 0,
getDefaultFormParams: () => { throw new Error('[FilterConditionItems.EMPTY]: should not have initial form params!'); },
testMappingParams: ({ operator1 }) => operator1 === ExtendCustomFilterOperator.EMPTY,
mapToFilterColumn: () => ({ customFilters: { customFilters: [{ val: '' }] } }),
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
const mapped = firstCustomFilter.val === '' && firstCustomFilter.operator === undefined;
if (!mapped) {
return false;
}
return { operator1: ExtendCustomFilterOperator.EMPTY };
},
};
export const NOT_EMPTY: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.not-empty',
operator: ExtendCustomFilterOperator.NOT_EMPTY,
order: OperatorOrder.SECOND,
numOfParameters: 0,
getDefaultFormParams: () => { throw new Error('[FilterConditionItems.NOT_EMPTY]: should not have initial form params!'); },
testMappingParams: ({ operator1 }) => operator1 === ExtendCustomFilterOperator.NOT_EMPTY,
mapToFilterColumn: () => ({ customFilters: { customFilters: [{ val: '', operator: CustomFilterOperator.NOT_EQUALS }] } }),
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
const canMap = firstCustomFilter.val === ' ' && firstCustomFilter.operator === CustomFilterOperator.NOT_EQUALS;
if (!canMap) {
return false;
}
return { operator1: ExtendCustomFilterOperator.NOT_EMPTY };
},
};
// ------------------------------
export const TEXT_CONTAINS: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.text-contains',
operator: ExtendCustomFilterOperator.CONTAINS,
order: OperatorOrder.FIRST,
numOfParameters: 1,
getDefaultFormParams: () => ({ operator1: ExtendCustomFilterOperator.CONTAINS, val1: '' }),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === ExtendCustomFilterOperator.CONTAINS;
},
mapToFilterColumn: (mapParams) => {
const { val1 } = mapParams;
if (val1 === '') return null;
return {
customFilters: { customFilters: [{ val: `*${val1}*` }] },
};
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
const valAsString = firstCustomFilter.val.toString();
if (!firstCustomFilter.operator && valAsString.startsWith('*') && valAsString.endsWith('*')) {
return { operator1: ExtendCustomFilterOperator.CONTAINS, val1: valAsString.slice(1, -1) };
}
return false;
},
};
export const DOES_NOT_CONTAIN: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.does-not-contain',
operator: ExtendCustomFilterOperator.DOES_NOT_CONTAIN,
order: OperatorOrder.FIRST,
numOfParameters: 1,
getDefaultFormParams: () => ({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN, val1: '' }),
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: `*${mapParams.val1}*`, operator: CustomFilterOperator.NOT_EQUALS }] },
}),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === ExtendCustomFilterOperator.DOES_NOT_CONTAIN;
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
const valAsString = firstCustomFilter.val.toString();
if (
firstCustomFilter.operator === CustomFilterOperator.NOT_EQUALS
&& valAsString.startsWith('*')
&& valAsString.endsWith('*')
) {
return { operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN, val1: valAsString.slice(1, -1) };
}
return false;
},
};
export const STARTS_WITH: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.starts-with',
operator: ExtendCustomFilterOperator.STARTS_WITH,
order: OperatorOrder.FIRST,
numOfParameters: 1,
getDefaultFormParams: () => ({ operator1: ExtendCustomFilterOperator.STARTS_WITH, val1: '' }),
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: `${mapParams.val1}*` }] },
}),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === ExtendCustomFilterOperator.STARTS_WITH;
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
const valAsString = firstCustomFilter.val.toString();
if (!firstCustomFilter.operator && valAsString.endsWith('*') && !valAsString.startsWith('*')) {
return { operator1: ExtendCustomFilterOperator.STARTS_WITH, val1: valAsString.slice(0, -1) };
}
return false;
},
};
export const ENDS_WITH: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.ends-with',
operator: ExtendCustomFilterOperator.ENDS_WITH,
order: OperatorOrder.FIRST,
numOfParameters: 1,
getDefaultFormParams: () => ({ operator1: ExtendCustomFilterOperator.ENDS_WITH, val1: '' }),
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: `*${mapParams.val1}` }] },
}),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === ExtendCustomFilterOperator.ENDS_WITH;
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
const valAsString = firstCustomFilter.val.toString();
if (!firstCustomFilter.operator && valAsString.startsWith('*') && !valAsString.endsWith('*')) {
return { operator1: ExtendCustomFilterOperator.ENDS_WITH, val1: valAsString.slice(1) };
}
return false;
},
};
export const EQUALS: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.equals',
operator: ExtendCustomFilterOperator.EQUALS,
order: OperatorOrder.FIRST,
numOfParameters: 1,
getDefaultFormParams: () => ({ operator1: ExtendCustomFilterOperator.EQUALS, val1: '' }),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === ExtendCustomFilterOperator.EQUALS;
},
mapToFilterColumn: (mapParams) => {
const { val1 } = mapParams;
if (val1 === '') return null;
return {
customFilters: { customFilters: [{ val: val1! }] },
};
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.filters?.filters?.length === 1) {
return { operator1: ExtendCustomFilterOperator.EQUALS, val1: '' };
}
if (filterColumn.customFilters?.customFilters.length === 1 && !filterColumn.customFilters.customFilters[0].operator) {
return { operator1: ExtendCustomFilterOperator.EQUALS, val1: filterColumn.customFilters.customFilters[0].val.toString() };
}
return false;
},
};
// #region number conditions
export const GREATER_THAN: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.greater-than',
operator: CustomFilterOperator.GREATER_THAN,
numOfParameters: 1,
order: OperatorOrder.FIRST,
getDefaultFormParams: () => ({ operator1: CustomFilterOperator.GREATER_THAN, val1: '' }),
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: mapParams.val1!, operator: CustomFilterOperator.GREATER_THAN }] },
}),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === CustomFilterOperator.GREATER_THAN;
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
if (firstCustomFilter.operator !== CustomFilterOperator.GREATER_THAN) {
return false;
}
return { operator1: CustomFilterOperator.GREATER_THAN, val1: firstCustomFilter.val.toString() };
},
};
export const GREATER_THAN_OR_EQUAL: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.greater-than-or-equal',
operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
numOfParameters: 1,
order: OperatorOrder.FIRST,
getDefaultFormParams: () => ({ operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL, val1: '' }),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === CustomFilterOperator.GREATER_THAN_OR_EQUAL;
},
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: mapParams.val1!, operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL }] },
}),
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
if (firstCustomFilter.operator !== CustomFilterOperator.GREATER_THAN_OR_EQUAL) {
return false;
}
return { operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL, val1: firstCustomFilter.val.toString() };
},
};
export const LESS_THAN: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.less-than',
operator: CustomFilterOperator.LESS_THAN,
numOfParameters: 1,
order: OperatorOrder.FIRST,
getDefaultFormParams: () => ({ operator1: CustomFilterOperator.LESS_THAN, val1: '' }),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === CustomFilterOperator.LESS_THAN;
},
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: mapParams.val1!, operator: CustomFilterOperator.LESS_THAN }] },
}),
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
if (firstCustomFilter.operator !== CustomFilterOperator.LESS_THAN) {
return false;
}
return { operator1: CustomFilterOperator.LESS_THAN, val1: firstCustomFilter.val.toString() };
},
};
export const LESS_THAN_OR_EQUAL: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.less-than-or-equal',
operator: CustomFilterOperator.LESS_THAN_OR_EQUAL,
numOfParameters: 1,
order: OperatorOrder.FIRST,
getDefaultFormParams: () => ({ operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL, val1: '' }),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === CustomFilterOperator.LESS_THAN_OR_EQUAL;
},
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: mapParams.val1!, operator: CustomFilterOperator.LESS_THAN_OR_EQUAL }] },
}),
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
if (firstCustomFilter.operator !== CustomFilterOperator.LESS_THAN_OR_EQUAL) {
return false;
}
return { operator1: CustomFilterOperator.LESS_THAN_OR_EQUAL, val1: firstCustomFilter.val.toString() };
},
};
export const EQUAL: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.equal',
operator: CustomFilterOperator.EQUAL,
numOfParameters: 1,
order: OperatorOrder.FIRST,
getDefaultFormParams: () => ({ operator1: CustomFilterOperator.EQUAL, val1: '' }),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === CustomFilterOperator.EQUAL;
},
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: mapParams.val1!, operator: CustomFilterOperator.EQUAL }] },
}),
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
if (firstCustomFilter.operator !== CustomFilterOperator.EQUAL) {
return false;
}
return { operator1: CustomFilterOperator.EQUAL, val1: firstCustomFilter.val.toString() };
},
};
export const NOT_EQUAL: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.not-equal',
operator: CustomFilterOperator.NOT_EQUALS,
numOfParameters: 1,
order: OperatorOrder.FIRST,
getDefaultFormParams: () => ({ operator1: CustomFilterOperator.NOT_EQUALS, val1: '' }),
testMappingParams: (params) => {
const [op] = getOnlyOperatorAndVal(params);
return op === CustomFilterOperator.NOT_EQUALS;
},
mapToFilterColumn: (mapParams) => ({
customFilters: { customFilters: [{ val: mapParams.val1!, operator: CustomFilterOperator.NOT_EQUALS }] },
}),
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 1) {
return false;
}
const firstCustomFilter = filterColumn.customFilters.customFilters[0];
if (firstCustomFilter.operator !== CustomFilterOperator.NOT_EQUALS) {
return false;
}
return { operator1: CustomFilterOperator.NOT_EQUALS, val1: firstCustomFilter.val.toString() };
},
};
// #endregion
export const BETWEEN: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.between',
operator: ExtendCustomFilterOperator.BETWEEN,
order: OperatorOrder.SECOND,
numOfParameters: 2,
getDefaultFormParams: () => ({
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: '',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '',
}),
testMappingParams: (params) => {
const { and, operator1, operator2 } = params;
if (!and) return false;
const operators = [operator1, operator2];
return operators.includes(CustomFilterOperator.GREATER_THAN_OR_EQUAL)
&& operators.includes(CustomFilterOperator.LESS_THAN_OR_EQUAL);
},
mapToFilterColumn: (mapParams) => {
const { val1, val2, operator1 } = mapParams;
const operator1IsGreater = operator1 === CustomFilterOperator.GREATER_THAN_OR_EQUAL;
return {
customFilters: {
and: BooleanNumber.TRUE,
customFilters: [
{ val: operator1IsGreater ? val1! : val2!, operator: CustomFilterOperator.GREATER_THAN_OR_EQUAL },
{ val: operator1IsGreater ? val2! : val1!, operator: CustomFilterOperator.LESS_THAN_OR_EQUAL },
],
},
};
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 2) {
return false;
}
const [firstCustomFilter, secondCustomFilter] = filterColumn.customFilters.customFilters;
if (
firstCustomFilter.operator === CustomFilterOperator.GREATER_THAN_OR_EQUAL
&& secondCustomFilter.operator === CustomFilterOperator.LESS_THAN_OR_EQUAL
&& filterColumn.customFilters.and
) {
return {
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: firstCustomFilter.val.toString(),
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: secondCustomFilter.val.toString(),
};
}
if (
secondCustomFilter.operator === CustomFilterOperator.GREATER_THAN_OR_EQUAL
&& firstCustomFilter.operator === CustomFilterOperator.LESS_THAN_OR_EQUAL
&& filterColumn.customFilters.and
) {
return {
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: secondCustomFilter.val.toString(),
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: firstCustomFilter.val.toLocaleString(),
};
}
return false;
},
};
export const NOT_BETWEEN: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.not-between',
operator: ExtendCustomFilterOperator.NOT_BETWEEN,
order: OperatorOrder.SECOND,
numOfParameters: 2,
getDefaultFormParams: () => ({
operator1: CustomFilterOperator.LESS_THAN,
val1: '',
operator2: CustomFilterOperator.GREATER_THAN,
val2: '',
}),
testMappingParams: (params) => {
const { and, operator1, operator2 } = params;
if (and) return false;
const operators = [operator1, operator2];
return operators.includes(CustomFilterOperator.GREATER_THAN) && operators.includes(CustomFilterOperator.LESS_THAN);
},
mapToFilterColumn: (mapParams) => {
const { val1, val2, operator1 } = mapParams;
const operator1IsGreater = operator1 === CustomFilterOperator.GREATER_THAN;
return {
customFilters: {
customFilters: [
{ val: operator1IsGreater ? val1! : val2!, operator: CustomFilterOperator.GREATER_THAN },
{ val: operator1IsGreater ? val2! : val1!, operator: CustomFilterOperator.LESS_THAN },
],
},
};
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 2) {
return false;
}
const [firstCustomFilter, secondCustomFilter] = filterColumn.customFilters.customFilters;
if (
firstCustomFilter.operator === CustomFilterOperator.LESS_THAN
&& secondCustomFilter.operator === CustomFilterOperator.GREATER_THAN
&& !filterColumn.customFilters.and
) {
return {
operator1: CustomFilterOperator.LESS_THAN,
val1: firstCustomFilter.val.toString(),
operator2: CustomFilterOperator.GREATER_THAN,
val2: secondCustomFilter.val.toString(),
};
}
if (
secondCustomFilter.operator === CustomFilterOperator.LESS_THAN
&& firstCustomFilter.operator === CustomFilterOperator.GREATER_THAN
&& !filterColumn.customFilters.and
) {
return {
operator1: CustomFilterOperator.GREATER_THAN,
val1: secondCustomFilter.val.toString(),
operator2: CustomFilterOperator.LESS_THAN,
val2: firstCustomFilter.val.toLocaleString(),
};
}
return false;
},
};
/**
* This should be test last. If no other condition item can be mapped, then it should be mapped.
*/
export const CUSTOM: IFilterConditionItem = {
label: 'sheets-filter-ui.conditions.custom',
operator: ExtendCustomFilterOperator.CUSTOM,
order: OperatorOrder.SECOND,
numOfParameters: 2,
getDefaultFormParams: () => {
return {
operator1: ExtendCustomFilterOperator.NONE,
val1: '',
operator2: ExtendCustomFilterOperator.NONE,
val2: '',
};
},
testMappingParams: () => true,
mapToFilterColumn: (mapParams) => {
const { and, val1, val2, operator1, operator2 } = mapParams;
function mapOperator(operator: FilterOperator | undefined, val: string) {
// eslint-disable-next-line ts/no-use-before-define
for (const condition of ALL_CONDITIONS) {
if (condition.operator === operator) {
return condition.mapToFilterColumn({ val1: val, operator1: operator });
}
}
}
const operator1IsNone = !operator1 || operator1 === FilterConditionItems.NONE.operator;
const operator2IsNone = !operator2 || operator2 === FilterConditionItems.NONE.operator;
if (operator1IsNone && operator2IsNone) {
return NONE.mapToFilterColumn({});
}
if (operator1IsNone) {
return mapOperator(operator2!, val2!)!;
}
if (operator2IsNone) {
return mapOperator(operator1!, val1!)!;
}
const mappedCustomFilter1 = mapOperator(operator1!, val1!)!;
const mappedCustomFilter2 = mapOperator(operator2!, val2!)!;
const customFilters: ICustomFilters = {
customFilters: [
mappedCustomFilter1.customFilters!.customFilters[0],
mappedCustomFilter2.customFilters!.customFilters[0],
],
};
if (and) customFilters.and = BooleanNumber.TRUE;
return { customFilters };
},
testMappingFilterColumn: (filterColumn) => {
if (filterColumn.customFilters?.customFilters.length !== 2) {
return false;
}
const params = filterColumn.customFilters.customFilters.map((customFilter) => {
return testMappingFilterColumn({ customFilters: { customFilters: [customFilter] } });
});
const result: IFilterConditionFormParams = {
operator1: params[0][0].operator,
val1: params[0][1].val1,
operator2: params[1][0].operator,
val2: params[1][1].val1,
};
if (filterColumn.customFilters.and) {
result.and = true;
}
return result;
},
};
export const ALL_CONDITIONS: IFilterConditionItem[] = [
// ------------------------------
NONE,
// ------------------------------
EMPTY,
NOT_EMPTY,
// ------------------------------
TEXT_CONTAINS,
DOES_NOT_CONTAIN,
STARTS_WITH,
ENDS_WITH,
EQUALS,
// ------------------------------
GREATER_THAN,
GREATER_THAN_OR_EQUAL,
LESS_THAN,
LESS_THAN_OR_EQUAL,
EQUAL,
NOT_EQUAL,
BETWEEN,
NOT_BETWEEN,
// ------------------------------
CUSTOM,
];
export function getItemByOperator(operator: FilterOperator): IFilterConditionItem {
const item = ALL_CONDITIONS.find((condition) => condition.operator === operator);
if (!item) {
throw new Error(`[SheetsFilter]: no condition item found for operator: ${operator}`);
}
return item;
}
export function testMappingParams(mapParams: IFilterConditionFormParams, numOfParameters: number): IFilterConditionItem {
// TODO@wzhudev: iteration here can be optimized
// We match operators with same count of parameters first.
for (const condition of ALL_CONDITIONS.filter((condition) => condition.numOfParameters === numOfParameters)) {
if (condition.numOfParameters !== 0 && condition.testMappingParams(mapParams)) {
return condition;
}
}
// And fallback to others.
for (const condition of ALL_CONDITIONS) {
if (condition.testMappingParams(mapParams)) {
return condition;
}
}
throw new Error('[SheetsFilter]: no condition item can be mapped from the filter map params!');
}
export function getInitialFormParams(operator: FilterOperator): IFilterConditionFormParams {
const condition = ALL_CONDITIONS.find((condition) => condition.operator === operator)!;
if (condition?.numOfParameters === 0) {
return { operator1: condition.operator };
}
return condition.getDefaultFormParams();
}
export function mapToFilterColumn(condition: IFilterConditionItem, mapParams: IFilterConditionFormParams): Nullable<Omit<IFilterColumn, 'colId'>> {
return condition.mapToFilterColumn(mapParams);
}
export function testMappingFilterColumn(filterColumn: Nullable<Omit<IFilterColumn, 'colId'>>): [IFilterConditionItem, IFilterConditionFormParams] {
if (!filterColumn) {
return [NONE, {}];
}
for (const condition of ALL_CONDITIONS) {
const mapParams = condition.testMappingFilterColumn(filterColumn);
if (mapParams) {
return [condition, mapParams];
}
}
// Return NONE by default.
return [NONE, {}];
}
}
function getOnlyOperatorAndVal(mapParams: IFilterConditionFormParams): [FilterOperator, string | undefined] {
const { operator1, operator2, val1, val2 } = mapParams;
if (operator1 && operator2) {
throw new Error('Both operator1 and operator2 are set!');
}
if (!operator1 && !operator2) {
throw new Error('Neither operator1 and operator2 and both not set!');
}
return operator1 ? [operator1, val1] : [operator2!, val2];
}

View File

@@ -0,0 +1,101 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT 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 enum OperatorOrder {
FIRST,
SECOND,
}
/**
* Extended custom filter operators.
*
* These operators are not defined in OOXML,
* so when exporting these operators we need to fallback to `CustomFilterOperator` and
* other possible filter types.
*/
export enum ExtendCustomFilterOperator {
NONE = 'none',
/** Fallback to `<customFilter val="123*" />` */
STARTS_WITH = 'startsWith',
/** Fallback to `<customFilter operator="notEqual" val="123*" />` */
DOES_NOT_START_WITH = 'doesNotStartWith',
/** Fallback to `<customFilter val="*123" />` */
ENDS_WITH = 'endsWith',
/** Fallback to `<customFilter operator="notEqual" val="*123" />` */
DOES_NOT_END_WITH = 'doesNotEndWith',
/** Fallback to `<customFilter val="*123*" />` */
CONTAINS = 'contains',
/** Fallback to `<customFilter operator="notEqual" val="*123*" />` */
DOES_NOT_CONTAIN = 'doesNotContain',
/**
* Text equals. It is not same as CustomFilterOperator.EQUAL.
*
* When equals empty, it will be mapped to.
*
* <autoFilter>
* <filterColumn colId="0">
* <filters blank="1"/>
* </filterColumn>
* </autoFilter>
*/
EQUALS = 'equals',
/** Fallback to `<customFilter operator="notEqual" val="" />` */
NOT_EQUALS = 'notEquals',
/**
* Fallback to `<filter blank="1" />`.
*
* It can also fallback to `<customFilter val="" />`, when with another custom filter.
*/
EMPTY = 'empty',
/** Fallback to `<customFilter operator="notEqual" val=" " />` */
NOT_EMPTY = 'notEmpty',
/**
* Falls back to the following XML:
*
* ```xml
* <customFilters and="1">
* <customFilter operator="greaterThanOrEqual" val="123"/>
* <customFilter operator="lessThanOrEqual" val="456"/>
* </customFilters>
* ```
*
* Actually in Microsoft Excel, `NOT_BETWEEN` is still `BETWEEN`, as long as the two operators
* are `greaterThanOrEqual` and `lessThanOrEqual`.
*/
BETWEEN = 'between',
/**
* Falls back to the following XML:
*
* ```xml
* <customFilters> <!-- no `and="1"` means `OR` -->
* <customFilter operator="lessThan" val="456"/>
* <customFilter operator="greaterThan" val="123"/>
* </customFilters>
* ```
*/
NOT_BETWEEN = 'notBetween',
CUSTOM = 'custom',
}

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 { IFilterByValueWithTreeItem } from '../services/sheets-filter-panel.service';
export function statisticFilterByValueItems(items: IFilterByValueWithTreeItem[]): {
checked: number;
unchecked: number;
checkedItems: IFilterByValueWithTreeItem[];
uncheckedItems: IFilterByValueWithTreeItem[];
} {
const checkedItems: IFilterByValueWithTreeItem[] = [];
const uncheckedItems: IFilterByValueWithTreeItem[] = [];
let checked = 0;
let unchecked = 0;
function traverse(node: IFilterByValueWithTreeItem) {
if (node.leaf) {
if (node.checked) {
checkedItems.push(node);
checked += node.count;
} else {
uncheckedItems.push(node);
unchecked += node.count;
}
}
if (node.children) {
node.children.forEach(traverse);
}
}
items.forEach(traverse);
return {
checkedItems,
uncheckedItems,
checked,
unchecked,
};
}

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 { IUniverSheetsFilterUIConfig } from './config/config';
import {
DependentOn,
IConfigService,
Inject,
Injector,
merge,
Optional,
Plugin,
registerDependencies,
touchDependencies,
UniverInstanceType,
} from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { IRPCChannelService, toModule } from '@univerjs/rpc';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { SheetsFilterPermissionController } from './controllers/sheets-filter-permission.controller';
import { SheetsFilterUIDesktopController } from './controllers/ui.controller';
import { ISheetsFilterPanelService, SheetsFilterPanelService } from './services/sheets-filter-panel.service';
import {
ISheetsGenerateFilterValuesService,
SHEETS_GENERATE_FILTER_VALUES_SERVICE_NAME,
} from './worker/generate-filter-values.service';
/**
* The plugin for the desktop version of the sheets filter UI. Its type is {@link UniverInstanceType.UNIVER_SHEET}.
*/
@DependentOn(
UniverRenderEnginePlugin,
UniverSheetsPlugin,
UniverSheetsFilterPlugin,
UniverSheetsUIPlugin
)
export class UniverSheetsFilterUIPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'SHEET_FILTER_UI_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverSheetsFilterUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@Optional(IRPCChannelService) private readonly _rpcChannelService?: IRPCChannelService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
registerDependencies(this._injector, [
[ISheetsFilterPanelService, { useClass: SheetsFilterPanelService }],
[SheetsFilterPermissionController],
[SheetsFilterUIDesktopController],
]);
if (this._config.useRemoteFilterValuesGenerator && this._rpcChannelService) {
this._injector.add([ISheetsGenerateFilterValuesService, {
useFactory: (): ISheetsGenerateFilterValuesService =>
toModule<ISheetsGenerateFilterValuesService>(
this._rpcChannelService!.requestChannel(SHEETS_GENERATE_FILTER_VALUES_SERVICE_NAME)
),
}]);
}
}
override onReady(): void {
touchDependencies(this._injector, [
[SheetsFilterPermissionController],
]);
}
override onRendered(): void {
touchDependencies(this._injector, [
[SheetsFilterUIDesktopController],
]);
}
}

View File

@@ -0,0 +1,711 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IOperation, IWorkbookData, Workbook } from '@univerjs/core';
import type { IInsertColMutationParams } from '@univerjs/sheets';
import type { IEditorBridgeServiceVisibleParam } from '@univerjs/sheets-ui';
import type { IOpenFilterPanelOperationParams } from '../../commands/operations/sheets-filter.operation';
import type { IFilterConditionFormParams } from '../../models/conditions';
import type { IFilterByValueWithTreeItem } from '../sheets-filter-panel.service';
import {
awaitTime,
CommandType,
ICommandService,
Inject,
Injector,
LocaleService,
LocaleType,
Plugin,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { InsertColMutation } from '@univerjs/sheets';
import { CustomFilterOperator, FilterBy, SheetsFilterService, UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import { afterEach, beforeEach, describe, expect, it, vitest } from 'vitest';
import {
E_ITEMS,
ITEMS,
ITEMS_WITH_EMPTY,
WithCustomFilterModelFactory,
WithMergedCellFilterFactory,
WithMultiEmptyCellsModelFactory,
WithTwoFilterColumnsFactory,
WithValuesAndEmptyFilterModelFactory,
WithValuesFilterModelFactory,
} from '../../__tests__/data';
import { CloseFilterPanelOperation, OpenFilterPanelOperation } from '../../commands/operations/sheets-filter.operation';
import { FilterConditionItems } from '../../models/conditions';
import { ExtendCustomFilterOperator } from '../../models/extended-operators';
import {
ByColorsModel,
ByConditionsModel,
ByValuesModel,
ISheetsFilterPanelService,
SheetsFilterPanelService,
} from '../sheets-filter-panel.service';
const SetCellEditVisibleOperation: IOperation<IEditorBridgeServiceVisibleParam> = {
id: 'sheet.operation.set-cell-edit-visible',
type: CommandType.OPERATION,
handler: () => {
return true;
},
};
function countLeafNodesByCheckedStatus(
items: IFilterByValueWithTreeItem[],
checkedStatus: boolean
): number {
let count = 0;
function traverse(node: IFilterByValueWithTreeItem) {
if (node.leaf && node.checked === checkedStatus) {
count++;
}
if (node.children) {
node.children.forEach(traverse);
}
}
items.forEach(traverse);
return count;
}
function getAllLeafNodes(
items: IFilterByValueWithTreeItem[]
): IFilterByValueWithTreeItem[] {
const leafNodes: IFilterByValueWithTreeItem[] = [];
function traverse(node: IFilterByValueWithTreeItem) {
if (node.leaf) {
leafNodes.push(node);
}
if (node.children) {
node.children.forEach(traverse);
}
}
items.forEach(traverse);
return leafNodes;
}
function createSheetsFilterPanelServiceTestBed(workbookData: IWorkbookData) {
const univer = new Univer();
const injector = univer.__getInjector();
const get = injector.get.bind(injector);
class SheetsFilterPanelTestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'sheets-filter-panel-test';
constructor(_config: unknown, @Inject(Injector) protected readonly _injector: Injector) {
super();
}
override onStarting(): void {
this._injector.add([ISheetsFilterPanelService, { useClass: SheetsFilterPanelService }]);
}
}
get(LocaleService).load({});
univer.registerPlugin(UniverSheetsFilterPlugin);
univer.registerPlugin(SheetsFilterPanelTestPlugin);
univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookData);
const commandService = get(ICommandService);
[
OpenFilterPanelOperation,
CloseFilterPanelOperation,
SetCellEditVisibleOperation,
].forEach((command) => commandService.registerCommand(command));
return { univer, get };
}
describe('test "SheetsFilterPanelService"', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let sheetsFilterService: SheetsFilterService;
let sheetsFilterPanelService: ISheetsFilterPanelService;
function prepare(workbookData: IWorkbookData) {
const testBed = createSheetsFilterPanelServiceTestBed(workbookData);
univer = testBed.univer;
get = testBed.get;
commandService = get(ICommandService);
sheetsFilterService = get(SheetsFilterService);
sheetsFilterPanelService = get(ISheetsFilterPanelService);
}
afterEach(() => {
univer.dispose();
});
describe('test filter by conditions', () => {
describe('test initialization behavior', () => {
it('should initialize by conditional model when the filter column is by custom filters', () => {
prepare(WithCustomFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.CONDITIONS);
const filterByModel = sheetsFilterPanelService.filterByModel as ByConditionsModel;
expect(filterByModel instanceof ByConditionsModel).toBeTruthy();
expect(filterByModel.conditionItem.operator).toBe(FilterConditionItems.GREATER_THAN.operator);
expect(filterByModel.filterConditionFormParams).toEqual({ operator1: CustomFilterOperator.GREATER_THAN, val1: '123' });
});
});
describe('test behavior when changing the primary operator', () => {
it('should update the filter condition item when the primary operator changes', () => {
prepare(WithCustomFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
const filterByModel = sheetsFilterPanelService.filterByModel as ByConditionsModel;
filterByModel.onPrimaryConditionChange(ExtendCustomFilterOperator.BETWEEN);
expect(filterByModel.conditionItem.operator).toBe(FilterConditionItems.BETWEEN.operator);
expect(filterByModel.filterConditionFormParams).toEqual({
and: true,
operator1: CustomFilterOperator.GREATER_THAN_OR_EQUAL,
val1: '',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '',
} as IFilterConditionFormParams);
filterByModel.onPrimaryConditionChange(ExtendCustomFilterOperator.ENDS_WITH);
expect(filterByModel.conditionItem.operator).toBe(FilterConditionItems.ENDS_WITH.operator);
expect(filterByModel.filterConditionFormParams).toEqual({
operator1: ExtendCustomFilterOperator.ENDS_WITH,
val1: '',
} as IFilterConditionFormParams);
});
});
describe('test behavior when changing the secondary operators', () => {
it('should update primary operator when the secondary operator changes (in some conditions)', () => {
prepare(WithCustomFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
const filterByModel = sheetsFilterPanelService.filterByModel as ByConditionsModel;
filterByModel.onConditionFormChange({ operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN });
expect(filterByModel.conditionItem.operator).toBe(FilterConditionItems.DOES_NOT_CONTAIN.operator);
expect(filterByModel.filterConditionFormParams).toEqual({
operator1: ExtendCustomFilterOperator.DOES_NOT_CONTAIN,
val1: '123',
} as IFilterConditionFormParams);
filterByModel.onPrimaryConditionChange(ExtendCustomFilterOperator.BETWEEN);
filterByModel.onConditionFormChange({ operator1: ExtendCustomFilterOperator.ENDS_WITH });
expect(filterByModel.conditionItem.operator).toBe(FilterConditionItems.CUSTOM.operator);
expect(filterByModel.filterConditionFormParams).toEqual({
and: true,
operator1: ExtendCustomFilterOperator.ENDS_WITH,
val1: '',
operator2: CustomFilterOperator.LESS_THAN_OR_EQUAL,
val2: '',
} as IFilterConditionFormParams);
});
});
it('should execute command when "apply" is called', async () => {
prepare(WithCustomFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
const filterByModel = sheetsFilterPanelService.filterByModel as ByConditionsModel;
filterByModel.onConditionFormChange({ val1: '5' });
expect(await filterByModel.apply());
const filterModel = sheetsFilterService.activeFilterModel;
expect(filterModel!.filteredOutRows).toEqual(new Set([1, 2, 3, 4, 5]));
});
});
describe('test filter type switching and color filters', () => {
beforeEach(() => {
vitest.useFakeTimers();
});
afterEach(() => {
vitest.useRealTimers();
});
it('should switch the active panel model by filter type for the same filter column', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
await tick();
expect(sheetsFilterPanelService.filterModel).toBe(sheetsFilterService.activeFilterModel);
expect(sheetsFilterPanelService.col).toBe(0);
expect(sheetsFilterPanelService.filterByModel).toBeInstanceOf(ByValuesModel);
expect(sheetsFilterPanelService.changeFilterBy(FilterBy.CONDITIONS)).toBe(true);
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.CONDITIONS);
expect(sheetsFilterPanelService.filterByModel).toBeInstanceOf(ByConditionsModel);
expect(sheetsFilterPanelService.changeFilterBy(FilterBy.COLORS)).toBe(true);
await tick();
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.COLORS);
expect(sheetsFilterPanelService.filterByModel).toBeInstanceOf(ByColorsModel);
expect(sheetsFilterPanelService.changeFilterBy(FilterBy.VALUES)).toBe(true);
await tick();
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.VALUES);
expect(sheetsFilterPanelService.filterByModel).toBeInstanceOf(ByValuesModel);
});
it('should build and apply color filter options from cell fill and text colors', async () => {
prepare(WithColorFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
expect(sheetsFilterPanelService.changeFilterBy(FilterBy.COLORS)).toBe(true);
await tick();
const filterByModel = sheetsFilterPanelService.filterByModel as ByColorsModel;
expect(filterByModel).toBeInstanceOf(ByColorsModel);
expect(filterByModel.cellFillColors).toEqual([
{ color: 'rgb(255,0,0)', checked: false },
{ color: null, checked: false },
]);
expect(filterByModel.cellTextColors).toEqual([
{ color: 'rgb(0,0,0)', checked: false },
{ color: 'rgb(0,0,255)', checked: false },
]);
filterByModel.onFilterCheckToggled({ color: 'rgb(255,0,0)', checked: false });
expect(filterByModel.cellFillColors).toEqual([
{ color: 'rgb(255,0,0)', checked: true },
{ color: null, checked: false },
]);
expect(await filterByModel.apply()).toBe(true);
expect(sheetsFilterService.activeFilterModel!.getFilterColumn(0)!.getColumnData().colorFilters).toEqual({
cellFillColors: ['rgb(255,0,0)'],
});
filterByModel.onFilterCheckToggled({ color: 'rgb(0,0,255)', checked: false }, false);
expect(filterByModel.cellFillColors).toEqual([
{ color: 'rgb(255,0,0)', checked: false },
{ color: null, checked: false },
]);
expect(filterByModel.cellTextColors).toEqual([
{ color: 'rgb(0,0,0)', checked: false },
{ color: 'rgb(0,0,255)', checked: true },
]);
expect(await filterByModel.apply()).toBe(true);
expect(sheetsFilterService.activeFilterModel!.getFilterColumn(0)!.getColumnData().colorFilters).toEqual({
cellTextColors: ['rgb(0,0,255)'],
});
filterByModel.onFilterCheckToggled({ color: 'rgb(0,0,255)', checked: true }, false);
expect(await filterByModel.apply()).toBe(true);
expect(sheetsFilterService.activeFilterModel!.getFilterColumn(0)).toBeNull();
});
it('should keep the panel target column aligned when columns are inserted before the filter header', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
await tick();
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
expect(filterByModel.col).toBe(0);
expect(commandService.syncExecuteCommand(InsertColMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
range: {
startRow: 0,
endRow: 999,
startColumn: 0,
endColumn: 0,
},
} as IInsertColMutationParams)).toBe(true);
expect(filterByModel.col).toBe(1);
});
});
describe('test filter by values', () => {
beforeEach(() => {
vitest.useFakeTimers();
});
afterEach(() => {
vitest.useRealTimers();
});
it('should initialize by filters when the filter column holds "filters"', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBeTruthy();
await tick();
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.VALUES);
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
expect(filterByModel instanceof ByValuesModel).toBeTruthy();
expect(filterByModel.filterItems).toEqual(ITEMS);
});
it('should initialize with blank content', async () => {
prepare(WithValuesAndEmptyFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBeTruthy();
await tick();
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.VALUES);
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
expect(filterByModel instanceof ByValuesModel).toBeTruthy();
expect(filterByModel.filterItems).toEqual(ITEMS_WITH_EMPTY);
});
it('should count empty cells', async () => {
prepare(WithMultiEmptyCellsModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBeTruthy();
await tick();
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.VALUES);
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
expect(filterByModel instanceof ByValuesModel).toBeTruthy();
const filterItems = filterByModel.filterItems;
const emptyItem = filterItems.find((item) => item.key === 'empty');
expect(emptyItem).toEqual({
title: 'sheets-filter-ui.panel.empty',
count: 4,
leaf: true,
checked: true,
key: 'empty',
});
});
it('should filter out the items from the panel if its row is already filtered out by other column', async () => {
prepare(WithTwoFilterColumnsFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 1,
} as IOpenFilterPanelOperationParams)).toBeTruthy();
await tick();
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.VALUES);
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
expect(filterByModel instanceof ByValuesModel).toBeTruthy();
const filterItems = filterByModel.filterItems;
expect(filterItems).toEqual([
{
title: 'a',
leaf: true,
checked: true,
key: 'a',
count: 1,
},
{
title: 'b',
leaf: true,
checked: true,
key: 'b',
count: 1,
},
{
title: 'c',
leaf: true,
checked: true,
key: 'c',
count: 1,
},
{
title: 'sheets-filter-ui.panel.empty',
count: 3,
leaf: true,
checked: true,
key: 'empty',
},
]);
});
it('merged cell should use value of the top left corner', async () => {
prepare(WithMergedCellFilterFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 1,
} as IOpenFilterPanelOperationParams)).toBeTruthy();
await tick();
expect(sheetsFilterPanelService.filterBy).toBe(FilterBy.VALUES);
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
expect(filterByModel instanceof ByValuesModel).toBeTruthy();
const filterItems = filterByModel.filterItems;
expect(filterItems).toEqual([
{
title: '3',
leaf: true,
checked: true,
key: '3',
count: 2,
},
{
title: 'e',
leaf: true,
checked: true,
key: 'e',
count: 1,
},
{
title: 'sheets-filter-ui.panel.empty',
count: 3,
leaf: true,
checked: true,
key: 'empty',
},
]);
});
it('should update the filter items when toggle checked status', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
await tick();
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
filterByModel.onFilterOnly([ITEMS[5].key]);
expect(filterByModel.filterItems[0].checked).toBeFalsy();
expect(filterByModel.filterItems[1].checked).toBeFalsy();
expect(filterByModel.filterItems[5].checked).toBeTruthy();
filterByModel.onCheckAllToggled(true);
expect(countLeafNodesByCheckedStatus(filterByModel.filterItems, true)).toBe(10);
filterByModel.onCheckAllToggled(false);
expect(countLeafNodesByCheckedStatus(filterByModel.filterItems, true)).toBe(0);
});
it('should execute command when "apply" is called', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
await tick();
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
filterByModel.onFilterCheckToggled(ITEMS[0]);
expect(filterByModel.filterItems[0].checked).toBeFalsy();
filterByModel.onFilterOnly([ITEMS[5].key]);
expect(await filterByModel.apply()).toBeTruthy();
const filterModel = sheetsFilterService.activeFilterModel;
expect(filterModel!.filteredOutRows).toEqual(new Set([1, 10, 2, 4, 5, 6, 7, 8, 9, 10]));
});
describe('with searching', async () => {
// use fake timers
// for performance reasons, search string are throttled to change the search results
beforeEach(() => {
vitest.useFakeTimers();
});
afterEach(() => {
vitest.useRealTimers();
});
it('should update the filter items when searching', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
await tick();
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
filterByModel.setSearchString('e');
vitest.advanceTimersByTime(600);
expect(filterByModel.filterItems).toEqual(E_ITEMS);
});
it('should toggle all applied to searched results', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
await tick();
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
filterByModel.setSearchString('e');
vitest.advanceTimersByTime(600);
expect(filterByModel.filterItems.length).toBe(3);
expect(getAllLeafNodes(filterByModel.filterItems).length).toBe(7);
filterByModel.onCheckAllToggled(true);
expect(countLeafNodesByCheckedStatus(filterByModel.filterItems, true)).toBe(7); // the original "1" should be checked as well
});
it('should filter only applied to searched results', async () => {
prepare(WithValuesFilterModelFactory());
expect(commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams)).toBe(true);
await tick();
const filterByModel = sheetsFilterPanelService.filterByModel as ByValuesModel;
filterByModel.setSearchString('e');
vitest.advanceTimersByTime(600);
expect(filterByModel.filterItems.length).toBe(3);
filterByModel.onFilterOnly([filterByModel.filterItems[2].key]);
expect(countLeafNodesByCheckedStatus(filterByModel.filterItems, true)).toBe(1); // the original "1" should be checked as well
});
});
});
});
function tick(milliseconds: number = 0): Promise<void> {
const result = awaitTime(milliseconds);
vitest.advanceTimersByTime(milliseconds + 1);
return result;
}
function WithColorFilterModelFactory(): IWorkbookData {
return {
id: 'test',
sheetOrder: ['sheet1'],
name: '',
appVersion: '3.0.0-alpha',
locale: LocaleType.ZH_CN,
styles: {
redFill: {
bg: {
rgb: '#ff0000',
},
},
blueText: {
cl: {
rgb: '#0000ff',
},
},
},
sheets: {
sheet1: {
id: 'sheet1',
name: 'Sheet1',
rowCount: 20,
columnCount: 5,
cellData: {
0: {
0: { v: 'Status' },
},
1: {
0: { v: 'Blocked', s: 'redFill' },
},
2: {
0: { v: 'Ready', s: 'blueText' },
},
3: {
0: { v: 'Backlog' },
},
},
},
},
resources: [
{
name: 'SHEET_FILTER_PLUGIN',
data: JSON.stringify({
sheet1: {
ref: {
startRow: 0,
startColumn: 0,
endRow: 3,
endColumn: 0,
},
filterColumns: [],
cachedFilteredOut: [],
},
}),
},
],
};
}

View File

@@ -0,0 +1,910 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IRange, Nullable, Workbook } from '@univerjs/core';
import type { FilterColumn, FilterModel, IFilterColumn, ISetSheetsFilterCriteriaCommandParams } from '@univerjs/sheets-filter';
import type { Observable } from 'rxjs';
import type { FilterOperator, IFilterConditionFormParams, IFilterConditionItem } from '../models/conditions';
import { ColorKit, createIdentifier, Disposable, ICommandService, Inject, Injector, IUniverInstanceService, LocaleService, Quantity, Tools, UniverInstanceType } from '@univerjs/core';
import { COLOR_BLACK_RGB } from '@univerjs/engine-render';
import { RefRangeService } from '@univerjs/sheets';
import { FilterBy, SetSheetsFilterCriteriaCommand } from '@univerjs/sheets-filter';
import { BehaviorSubject, combineLatest, map, merge, of, ReplaySubject, shareReplay, startWith, Subject, throttleTime } from 'rxjs';
import { FilterConditionItems } from '../models/conditions';
import { statisticFilterByValueItems } from '../models/utils';
import { getFilterTreeByValueItems, ISheetsGenerateFilterValuesService } from '../worker/generate-filter-values.service';
import { areAllLeafNodesChecked, findObjectByKey, searchTree, updateLeafNodesCheckedStatus } from './util';
export interface IFilterByValueItem {
value: string;
checked: boolean;
count: number;
index: number;
/**
* This property indicates that this is a special item which maps to empty strings or empty cells.
*/
isEmpty: boolean;
}
export interface IFilterByValueWithTreeItem {
title: string;
key: string;
count: number;
checked: boolean;
leaf: boolean;
originValues?: Set<string>;
children?: IFilterByValueWithTreeItem[];
}
export interface IFilterByColorItem {
color: string | null;
checked?: boolean;
}
export interface ISheetsFilterPanelService {
readonly col: number;
readonly col$: Observable<number>;
readonly filterBy: FilterBy;
readonly filterBy$: Observable<FilterBy>;
readonly filterByModel: Nullable<IFilterByModel>;
readonly filterByModel$: Observable<Nullable<IFilterByModel>>;
readonly filterModel: Nullable<FilterModel>;
readonly hasCriteria$: Observable<boolean>;
setupCol(filterModel: FilterModel, col: number): void;
changeFilterBy(filterBy: FilterBy): boolean;
terminate(): boolean;
}
export const ISheetsFilterPanelService = createIdentifier<ISheetsFilterPanelService>('sheets-filter-ui.sheets-filter-panel.service');
export interface IFilterByModel extends IDisposable {
canApply$: Observable<boolean>;
deltaCol(offset: number): void;
clear(): Promise<boolean>;
apply(): Promise<boolean>;
}
/**
* This service controls the state of the filter panel. There should be only one instance of the filter panel
* at one time.
*/
export class SheetsFilterPanelService extends Disposable implements ISheetsFilterPanelService {
private readonly _filterBy$ = new BehaviorSubject<FilterBy>(FilterBy.VALUES);
readonly filterBy$ = this._filterBy$.asObservable();
get filterBy(): FilterBy { return this._filterBy$.getValue(); }
private readonly _filterByModel$ = new ReplaySubject<Nullable<IFilterByModel>>(1);
readonly filterByModel$ = this._filterByModel$.asObservable();
private _filterByModel: Nullable<IFilterByModel> = null;
get filterByModel(): Nullable<IFilterByModel> { return this._filterByModel; }
private set filterByModel(model: Nullable<IFilterByModel>) {
this._filterByModel = model;
this._filterByModel$.next(model);
}
private readonly _hasCriteria$ = new BehaviorSubject<boolean>(false);
readonly hasCriteria$ = this._hasCriteria$.asObservable();
private _filterModel: Nullable<FilterModel> = null;
get filterModel() { return this._filterModel; }
private readonly _col$ = new BehaviorSubject<number>(-1);
readonly col$ = this._col$.asObservable();
get col(): number { return this._col$.getValue(); }
constructor(
@Inject(Injector) private readonly _injector: Injector,
@Inject(RefRangeService) private readonly _refRangeService: RefRangeService
) {
super();
}
override dispose(): void {
this._filterBy$.complete();
this._filterByModel$.complete();
this._hasCriteria$.complete();
}
setupCol(filterModel: FilterModel, col: number): void {
this.terminate();
this._filterModel = filterModel;
this._col$.next(col);
// We use filter type that (if) has been set on the column as the default filter type.
const filterColumn = filterModel.getFilterColumn(col);
if (filterColumn) {
const info = filterColumn.getColumnData();
if (info.customFilters) {
this._hasCriteria$.next(true);
this._setupByConditions(filterModel, col);
return;
}
if (info.colorFilters) {
this._hasCriteria$.next(true);
this._setupByColors(filterModel, col);
return;
}
if (info.filters) {
this._hasCriteria$.next(true);
this._setupByValues(filterModel, col);
return;
}
// Use value values by default.
this._hasCriteria$.next(false);
this._setupByValues(filterModel, col);
return;
}
// By default we filter by values.
this._hasCriteria$.next(false);
this._setupByValues(filterModel, col);
};
changeFilterBy(filterBy: FilterBy): boolean {
if (!this._filterModel || this.col === -1) {
return false;
}
switch (filterBy) {
case FilterBy.VALUES:
this._setupByValues(this._filterModel, this.col);
break;
case FilterBy.COLORS:
this._setupByColors(this._filterModel, this.col);
break;
case FilterBy.CONDITIONS:
this._setupByConditions(this._filterModel, this.col);
break;
}
return true;
}
terminate(): boolean {
this._filterModel = null;
this._col$.next(-1);
this._disposeFilterHeaderChangeListener();
return true;
}
private _filterHeaderListener: Nullable<IDisposable> = null;
private _disposeFilterHeaderChangeListener(): void {
this._filterHeaderListener?.dispose();
this._filterHeaderListener = null;
}
private _listenToFilterHeaderChange(filterModel: FilterModel, col: number): void {
this._disposeFilterHeaderChangeListener();
const unitId = filterModel.unitId;
const subUnitId = filterModel.subUnitId;
const filterRange = filterModel.getRange();
const columnHeaderRange: IRange = {
startColumn: col,
startRow: filterRange.startRow,
endRow: filterRange.startRow,
endColumn: col,
};
this._filterHeaderListener = this._refRangeService.watchRange(unitId, subUnitId, columnHeaderRange, (before, after) => {
if (!after) {
// If the range collapsed, the column header must be deleted.
this.terminate();
} else {
const offset = after.startColumn - before.startColumn;
if (offset !== 0) {
this._filterByModel!.deltaCol(offset);
}
}
});
}
private async _setupByValues(filterModel: FilterModel, col: number): Promise<boolean> {
this._disposePreviousModel();
const range = filterModel.getRange();
if (range.startRow === range.endRow) return false;
const filterByModel = await ByValuesModel.fromFilterColumn(
this._injector,
filterModel,
col
);
this.filterByModel = filterByModel;
this._filterBy$.next(FilterBy.VALUES);
this._listenToFilterHeaderChange(filterModel, col);
return true;
}
private async _setupByColors(filterModel: FilterModel, col: number): Promise<boolean> {
this._disposePreviousModel();
const range = filterModel.getRange();
if (range.startRow === range.endRow) return false;
const filterByModel = await ByColorsModel.fromFilterColumn(
this._injector,
filterModel,
col
);
this.filterByModel = filterByModel;
this._filterBy$.next(FilterBy.COLORS);
this._listenToFilterHeaderChange(filterModel, col);
return true;
}
private _setupByConditions(filterModel: FilterModel, col: number): boolean {
this._disposePreviousModel();
const range = filterModel.getRange();
if (range.startRow === range.endRow) return false;
const model = ByConditionsModel.fromFilterColumn(
this._injector,
filterModel,
col,
filterModel.getFilterColumn(col)
);
this.filterByModel = model;
this._filterBy$.next(FilterBy.CONDITIONS);
this._listenToFilterHeaderChange(filterModel, col);
return true;
}
private _disposePreviousModel(): void {
this._filterByModel?.dispose();
this.filterByModel = null;
}
}
// #region ByConditionsModel
/**
* This model would be used to control the "Filter By Conditions" panel. It should be reconstructed in the following
* situations:
*
* 1. The target `FilterColumn` object is changed
* 2. User toggles "Filter By"
*/
export class ByConditionsModel extends Disposable implements IFilterByModel {
/**
* Create a model with targeting filter column. If there is not a filter column, the model would be created with
* default values.
*
* @param injector
* @param filterModel
* @param col
* @param filterColumn
*
* @returns the model to control the panel's state
*/
static fromFilterColumn(injector: Injector, filterModel: FilterModel, col: number, filterColumn?: Nullable<FilterColumn>): ByConditionsModel {
const [conditionItem, conditionParams] = FilterConditionItems.testMappingFilterColumn(filterColumn?.getColumnData());
const model = injector.createInstance(ByConditionsModel, filterModel, col, conditionItem, conditionParams);
return model;
}
canApply$: Observable<boolean> = of(true);
private readonly _conditionItem$: BehaviorSubject<IFilterConditionItem>;
readonly conditionItem$: Observable<IFilterConditionItem>;
get conditionItem(): IFilterConditionItem { return this._conditionItem$.getValue(); }
private readonly _filterConditionFormParams$: BehaviorSubject<IFilterConditionFormParams>;
readonly filterConditionFormParams$: Observable<IFilterConditionFormParams>;
get filterConditionFormParams(): IFilterConditionFormParams { return this._filterConditionFormParams$.getValue(); }
constructor(
private readonly _filterModel: FilterModel,
public col: number,
conditionItem: IFilterConditionItem,
conditionParams: IFilterConditionFormParams,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._conditionItem$ = new BehaviorSubject<IFilterConditionItem>(conditionItem);
this.conditionItem$ = this._conditionItem$.asObservable();
this._filterConditionFormParams$ = new BehaviorSubject(conditionParams);
this.filterConditionFormParams$ = this._filterConditionFormParams$.asObservable();
}
override dispose(): void {
super.dispose();
this._conditionItem$.complete();
this._filterConditionFormParams$.complete();
}
deltaCol(offset: number): void {
this.col += offset;
}
clear(): Promise<boolean> {
if (this._disposed) return Promise.resolve(false);
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria: null,
} as ISetSheetsFilterCriteriaCommandParams);
}
/**
* Apply the filter condition to the target filter column.
*/
async apply(): Promise<boolean> {
if (this._disposed) return false;
const filterColumn = FilterConditionItems.mapToFilterColumn(this.conditionItem, this.filterConditionFormParams);
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria: filterColumn,
} as ISetSheetsFilterCriteriaCommandParams);
}
/**
* This method would be called when user changes the primary condition. The model would load the corresponding
* `IFilterConditionFormParams` and load default condition form params.
*/
onPrimaryConditionChange(operator: FilterOperator): void {
const conditionItem = FilterConditionItems.ALL_CONDITIONS.find((item) => item.operator === operator);
if (!conditionItem) {
throw new Error(`[ByConditionsModel]: condition item not found for operator: ${operator}!`);
}
this._conditionItem$.next(conditionItem);
this._filterConditionFormParams$.next(FilterConditionItems.getInitialFormParams(operator));
}
/**
* This method would be called when user changes the primary conditions, the input values or "AND" "OR" ratio.
* If the primary conditions or the ratio is changed, the method would load the corresponding `IFilterCondition`.
*
* When the panel call this method, it only has to pass the changed keys.
*
* @param params
*/
onConditionFormChange(params: Partial<Omit<IFilterConditionFormParams, 'and'> & { and: boolean }>): void {
const newParams = { ...this.filterConditionFormParams, ...params };
if (newParams.and !== true) {
delete newParams.and;
}
if (typeof params.and !== 'undefined' || typeof params.operator1 !== 'undefined' || typeof params.operator2 !== 'undefined') {
const conditionItem = FilterConditionItems.testMappingParams(newParams as IFilterConditionFormParams, this.conditionItem.numOfParameters);
this._conditionItem$.next(conditionItem);
}
this._filterConditionFormParams$.next(newParams as IFilterConditionFormParams);
}
}
// #endregion
// #region - ByValuesModel
/**
* This model would be used to control the "Filter By Values" panel. It should be reconstructed in the following
* situations:
*
* 1. The target `FilterColumn` object is changed
* 2. User toggles "Filter By"
*/
export class ByValuesModel extends Disposable implements IFilterByModel {
/**
* Create a model with targeting filter column. If there is not a filter column, the model would be created with
* default values.
*
* @param injector
* @param filterModel
* @param col
*
* @returns the model to control the panel's state
*/
static async fromFilterColumn(injector: Injector, filterModel: FilterModel, col: number): Promise<ByValuesModel> {
const univerInstanceService = injector.get(IUniverInstanceService);
const localeService = injector.get(LocaleService);
const generateFilterValuesService = injector.get(ISheetsGenerateFilterValuesService, Quantity.OPTIONAL);
const { unitId, subUnitId } = filterModel;
const workbook = univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) throw new Error(`[ByValuesModel]: Workbook not found for filter model with unitId: ${unitId}!`);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) throw new Error(`[ByValuesModel]: Worksheet not found for filter model with unitId: ${unitId} and subUnitId: ${subUnitId}!`);
const range = filterModel.getRange();
const column = col;
const filterColumn = filterModel.getFilterColumn(col);
const filters = filterColumn?.getColumnData().filters;
const alreadyChecked = new Set(filters?.filters);
const blankChecked = !!(filters && filters.blank);
const filteredOutRowsByOtherColumns = filterModel.getFilteredOutRowsExceptCol(col);
const iterateRange: IRange = { ...range, startRow: range.startRow + 1, startColumn: column, endColumn: column };
let items: IFilterByValueWithTreeItem[];
let cache: Map<string, string[]> | undefined;
if (generateFilterValuesService) {
const res = await generateFilterValuesService.getFilterValues({
unitId,
subUnitId,
filteredOutRowsByOtherColumns: Array.from(filteredOutRowsByOtherColumns),
filterColumn,
filters: !!filters,
blankChecked,
iterateRange,
alreadyChecked: Array.from(alreadyChecked),
});
items = res.filterTreeItems;
cache = res.filterTreeMapCache;
} else {
// the first row is filter header and should be added to options
const res = getFilterTreeByValueItems(
!!filters,
localeService,
iterateRange,
worksheet,
filteredOutRowsByOtherColumns,
filterColumn,
alreadyChecked,
blankChecked,
workbook.getStyles()
);
items = res.filterTreeItems;
cache = res.filterTreeMapCache;
}
return injector.createInstance(ByValuesModel, filterModel, col, items, cache);
}
private readonly _rawFilterItems$: BehaviorSubject<IFilterByValueWithTreeItem[]>;
readonly rawFilterItems$: Observable<IFilterByValueWithTreeItem[]>;
get rawFilterItems(): IFilterByValueWithTreeItem[] { return this._rawFilterItems$.getValue(); }
readonly filterItems$: Observable<IFilterByValueWithTreeItem[]>;
private _filterItems: IFilterByValueWithTreeItem[] = [];
get filterItems() { return this._filterItems; }
private _treeMapCache: Map<string, string[]>;
get treeMapCache() { return this._treeMapCache; }
readonly canApply$: Observable<boolean>;
private readonly _manuallyUpdateFilterItems$: Subject<IFilterByValueWithTreeItem[]>;
private readonly _searchString$: BehaviorSubject<string>;
readonly searchString$: Observable<string>;
constructor(
private readonly _filterModel: FilterModel,
public col: number,
/**
* Filter items would remain unchanged after we create them,
* though data may change after.
*/
items: IFilterByValueWithTreeItem[],
cache: Map<string, string[]>,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._treeMapCache = cache;
this._searchString$ = new BehaviorSubject<string>('');
this.searchString$ = this._searchString$.asObservable();
this._rawFilterItems$ = new BehaviorSubject<IFilterByValueWithTreeItem[]>(items);
this.rawFilterItems$ = this._rawFilterItems$.asObservable();
this._manuallyUpdateFilterItems$ = new Subject<IFilterByValueWithTreeItem[]>();
this.filterItems$ = merge(
combineLatest([
this._searchString$.pipe(
throttleTime(500, undefined, { leading: true, trailing: true }),
startWith(void 0)
),
this._rawFilterItems$,
]).pipe(
map(([searchString, items]) => {
if (!searchString) return items;
const lowerSearchString = searchString.toLowerCase();
const searchKeyWords = lowerSearchString.split(/\s+/).filter((s) => !!s);
return searchTree(items, searchKeyWords);
})
),
this._manuallyUpdateFilterItems$
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
this.canApply$ = this.filterItems$.pipe(map((items) => {
const stat = statisticFilterByValueItems(items);
return stat.checked > 0;
}));
this.disposeWithMe(this.filterItems$.subscribe((items) => this._filterItems = items));
}
override dispose(): void {
this._rawFilterItems$.complete();
this._searchString$.complete();
}
deltaCol(offset: number): void {
this.col += offset;
}
setSearchString(str: string): void {
this._searchString$.next(str);
}
onCheckAllToggled(checked: boolean) {
const items = Tools.deepClone(this._filterItems);
items.forEach((item) => updateLeafNodesCheckedStatus(item, checked));
this._manuallyUpdateFilterItems(items);
}
/**
* Toggle a filter item.
*/
onFilterCheckToggled(item: IFilterByValueWithTreeItem): void {
const items = Tools.deepClone(this._filterItems);
const changedItem = findObjectByKey(items, item.key);
if (!changedItem) {
return;
}
const allLeafChecked = areAllLeafNodesChecked(changedItem);
updateLeafNodesCheckedStatus(changedItem, !allLeafChecked);
this._manuallyUpdateFilterItems(items);
}
onFilterOnly(itemKeys: string[]) {
const items = Tools.deepClone(this._filterItems);
items.forEach((item) => updateLeafNodesCheckedStatus(item, false));
itemKeys.forEach((key) => {
const changedItem = findObjectByKey(items, key);
if (changedItem) {
updateLeafNodesCheckedStatus(changedItem, true);
}
});
this._manuallyUpdateFilterItems(items);
}
private _manuallyUpdateFilterItems(items: IFilterByValueWithTreeItem[]): void {
this._manuallyUpdateFilterItems$.next(items);
}
// expose method here to let the panel change filter items
// #region ByValuesModel apply methods
clear(): Promise<boolean> {
if (this._disposed) return Promise.resolve(false);
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria: null,
} as ISetSheetsFilterCriteriaCommandParams);
}
/**
* Apply the filter condition to the target filter column.
*/
async apply(): Promise<boolean> {
if (this._disposed) {
return false;
}
const statistics = statisticFilterByValueItems(this._filterItems);
const { checked, checkedItems } = statistics;
const rawFilterItems = this.rawFilterItems;
let rawFilterCount = 0;
for (const item of rawFilterItems) {
rawFilterCount += item.count;
}
const noChecked = checked === 0;
const allChecked = statistics.checked === rawFilterCount;
const criteria: IFilterColumn = { colId: this.col };
if (noChecked) {
throw new Error('[ByValuesModel]: no checked items!');
} else if (allChecked) {
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria: null,
} as ISetSheetsFilterCriteriaCommandParams);
} else {
criteria.filters = {};
const nonEmptyItems = checkedItems.filter((item) => item.key !== 'empty');
if (nonEmptyItems.length > 0) {
criteria.filters = {
filters: nonEmptyItems.flatMap((item) => item.originValues ? Array.from(item.originValues) : [item.title]),
};
}
const hasEmpty = nonEmptyItems.length !== checkedItems.length;
if (hasEmpty) {
criteria.filters.blank = true;
}
}
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria,
} as ISetSheetsFilterCriteriaCommandParams);
}
// #endregion
}
// #endregion
// #region - ByColorsModel
/**
* This model would be used to control the "Filter By Colors" panel. It should be reconstructed in the following
* situations:
*
* 1. The target `FilterColumn` object is changed
* 2. User toggles "Filter By"
*/
export class ByColorsModel extends Disposable implements IFilterByModel {
/**
* Create a model with targeting filter column. If there is not a filter column, the model would be created with
* default values.
*
* @param injector
* @param filterModel
* @param col
*
* @returns the model to control the panel's state
*/
static async fromFilterColumn(injector: Injector, filterModel: FilterModel, col: number): Promise<ByColorsModel> {
const univerInstanceService = injector.get(IUniverInstanceService);
const { unitId, subUnitId } = filterModel;
const workbook = univerInstanceService.getUnit<Workbook>(unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) throw new Error(`[ByColorsModel]: Workbook not found for filter model with unitId: ${unitId}!`);
const worksheet = workbook?.getSheetBySheetId(subUnitId);
if (!worksheet) throw new Error(`[ByColorsModel]: Worksheet not found for filter model with unitId: ${unitId} and subUnitId: ${subUnitId}!`);
const range = filterModel.getRange();
const column = col;
const colorFilters = filterModel.getFilterColumn(col)?.getColumnData().colorFilters;
const filteredOutRowsByOtherColumns = filterModel.getFilteredOutRowsExceptCol(col);
const iterateRange: IRange = { ...range, startRow: range.startRow + 1, startColumn: column, endColumn: column };
const cellFillColors = new Map<string, IFilterByColorItem>();
const cellFillColorsChecked = new Set<string | null>(colorFilters?.cellFillColors ?? []);
const cellTextColors = new Map<string, IFilterByColorItem>();
const cellTextColorsChecked = new Set<string>(colorFilters?.cellTextColors ?? []);
for (const cell of worksheet.iterateByColumn(iterateRange, false, true)) {
const { row, col, value } = cell;
if (filteredOutRowsByOtherColumns.has(row)) {
// If the row is filtered out by other columns, we skip it.
continue;
}
const style = worksheet.getComposedCellStyleByCellData(row, col, value);
if (style.bg && style.bg.rgb) {
const bg = new ColorKit(style.bg.rgb).toRgbString();
if (!cellFillColors.has(bg)) {
cellFillColors.set(bg, { color: bg, checked: cellFillColorsChecked.has(bg) });
}
} else {
cellFillColors.set('default-fill-color', { color: null, checked: cellFillColorsChecked.has(null) });
}
if (style.cl && style.cl.rgb) {
const cl = new ColorKit(style.cl.rgb).toRgbString();
if (!cellTextColors.has(cl)) {
cellTextColors.set(cl, { color: cl, checked: cellTextColorsChecked.has(cl) });
}
} else {
cellTextColors.set('default-font-color', { color: COLOR_BLACK_RGB, checked: cellTextColorsChecked.has(COLOR_BLACK_RGB) });
}
}
return injector.createInstance(ByColorsModel, filterModel, col, cellFillColors, cellTextColors);
}
canApply$: Observable<boolean> = of(true);
private readonly _cellFillColors$: BehaviorSubject<IFilterByColorItem[]>;
readonly cellFillColors$: Observable<IFilterByColorItem[]>;
get cellFillColors(): IFilterByColorItem[] { return this._cellFillColors$.getValue(); }
private readonly _cellTextColors$: BehaviorSubject<IFilterByColorItem[]>;
readonly cellTextColors$: Observable<IFilterByColorItem[]>;
get cellTextColors(): IFilterByColorItem[] { return this._cellTextColors$.getValue(); }
constructor(
private readonly _filterModel: FilterModel,
public col: number,
/**
* Filter items would remain unchanged after we create them,
* though data may change after.
*/
cellFillColors: Map<string, IFilterByColorItem>,
cellTextColors: Map<string, IFilterByColorItem>,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._cellFillColors$ = new BehaviorSubject<IFilterByColorItem[]>(Array.from(cellFillColors.values()));
this.cellFillColors$ = this._cellFillColors$.asObservable();
this._cellTextColors$ = new BehaviorSubject<IFilterByColorItem[]>(Array.from(cellTextColors.values()));
this.cellTextColors$ = this._cellTextColors$.asObservable();
}
override dispose(): void {
super.dispose();
this._cellFillColors$.complete();
}
deltaCol(offset: number): void {
this.col += offset;
}
// expose method here to let the panel change filter items
// #region ByColorsModel apply methods
clear(): Promise<boolean> {
if (this._disposed) return Promise.resolve(false);
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria: null,
} as ISetSheetsFilterCriteriaCommandParams);
}
onFilterCheckToggled(item: IFilterByColorItem, isFillColor: boolean = true): void {
const colors = isFillColor ? this.cellFillColors : this.cellTextColors;
const items: IFilterByColorItem[] = [];
let found = false;
for (let i = 0; i < colors.length; i++) {
const colorItem = colors[i];
if (colorItem.color === item.color) {
found = true;
items.push({
color: colorItem.color,
checked: !colorItem.checked,
});
continue;
}
items.push({
color: colorItem.color,
checked: colorItem.checked,
});
}
if (!found) {
return;
}
// fill color and text color are mutually exclusive
this._resetColorsCheckedStatus(!isFillColor);
if (isFillColor) {
this._cellFillColors$.next([...items]);
} else {
this._cellTextColors$.next([...items]);
}
}
private _resetColorsCheckedStatus(isFillColor: boolean = true): void {
const colors = isFillColor ? this.cellFillColors : this.cellTextColors;
const items: IFilterByColorItem[] = [];
for (let i = 0; i < colors.length; i++) {
items.push({
color: colors[i].color,
checked: false,
});
}
if (isFillColor) {
this._cellFillColors$.next([...items]);
} else {
this._cellTextColors$.next([...items]);
}
}
/**
* Apply the filter condition to the target filter column.
*/
async apply(): Promise<boolean> {
if (this._disposed) {
return false;
}
const cellFillColorsChecked = this.cellFillColors.filter((item) => item.checked).map((item) => item.color);
const cellTextColorsChecked = this.cellTextColors.filter((item) => item.checked).map((item) => item.color);
if (cellFillColorsChecked.length === 0 && cellTextColorsChecked.length === 0) {
// If no colors are checked, we clear the filter.
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria: null,
} as ISetSheetsFilterCriteriaCommandParams);
}
const criteria: IFilterColumn = { colId: this.col };
if (cellFillColorsChecked.length > 0) {
criteria.colorFilters = {
cellFillColors: cellFillColorsChecked,
};
} else if (cellTextColorsChecked.length > 0) {
criteria.colorFilters = {
cellTextColors: cellTextColorsChecked as string[],
};
}
return this._commandService.executeCommand(SetSheetsFilterCriteriaCommand.id, {
unitId: this._filterModel.unitId,
subUnitId: this._filterModel.subUnitId,
col: this.col,
criteria,
} as ISetSheetsFilterCriteriaCommandParams);
}
// #endregion
}
// #endregion

View File

@@ -0,0 +1,87 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IFilterByValueWithTreeItem } from './sheets-filter-panel.service';
export function findObjectByKey(data: IFilterByValueWithTreeItem[], targetKey: string): IFilterByValueWithTreeItem | null {
for (const node of data) {
if (node.key === targetKey) {
return node;
}
if (node.children) {
const result = findObjectByKey(node.children, targetKey);
if (result) {
return result;
}
}
}
return null;
}
export function areAllLeafNodesChecked(node: IFilterByValueWithTreeItem): boolean {
if (node.leaf) {
return node.checked;
}
return node.children ? node.children.every((child) => areAllLeafNodesChecked(child)) : true;
}
export function updateLeafNodesCheckedStatus(node: IFilterByValueWithTreeItem, status?: boolean) {
if (node.leaf) {
if (status !== undefined) {
node.checked = status;
} else {
node.checked = !node.checked;
}
}
if (node.children) {
node.children.forEach((child) => updateLeafNodesCheckedStatus(child, status));
}
}
export function searchTree(items: IFilterByValueWithTreeItem[], searchKeywords: string[]): IFilterByValueWithTreeItem[] {
const result: IFilterByValueWithTreeItem[] = [];
items.forEach((item) => {
const originMatches = item.originValues
? searchKeywords.some((keyword) =>
Array.from(item.originValues!).some((value) =>
value.toLowerCase().includes(keyword.toLowerCase())
)
)
: false;
const titleMatches = !originMatches
&& searchKeywords.some((keyword) =>
item.title.toLowerCase().includes(keyword.toLowerCase())
);
const matches = originMatches || titleMatches;
if (matches) {
result.push({ ...item });
} else if (item.children) {
const filteredChildren = searchTree(item.children, searchKeywords);
if (filteredChildren.length > 0) {
const aggregatedCount = filteredChildren.reduce((sum, child) => sum + child.count, 0);
result.push({ ...item, count: aggregatedCount, children: filteredChildren });
}
}
});
return result;
}

View File

@@ -0,0 +1,175 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import type { ByColorsModel, IFilterByColorItem } from '../../services/sheets-filter-panel.service';
import { LocaleService } from '@univerjs/core';
import { borderClassName, clsx } from '@univerjs/design';
import { AIcon, BanIcon, SuccessIcon } from '@univerjs/icons';
import { useDependency, useObservable } from '@univerjs/ui';
import { useCallback } from 'react';
/**
* Filter by colors.
*/
export function FilterByColor(props: { model: ByColorsModel }) {
const { model } = props;
const localeService = useDependency(LocaleService);
const cellFillColors = useObservable(model.cellFillColors$, [], true);
const cellTextColors = useObservable(model.cellTextColors$, [], true);
const handleSelectCellFillColor = useCallback((color: IFilterByColorItem) => {
model.onFilterCheckToggled(color);
}, [model]);
const handleSelectCellTextColor = useCallback((color: IFilterByColorItem) => {
model.onFilterCheckToggled(color, false);
}, [model]);
return (
<div
data-u-comp="sheets-filter-panel-colors-container"
className="univer-flex univer-h-full univer-min-h-[300px] univer-flex-col"
>
<div
data-u-comp="sheets-filter-panel"
className={clsx(`
univer-mt-2 univer-box-border univer-flex univer-h-[300px] univer-flex-grow univer-flex-col
univer-gap-4 univer-overflow-auto univer-rounded-md univer-px-2 univer-py-2.5
`, borderClassName)}
>
{cellFillColors.length > 1 && (
<div>
<div
className={`
univer-mb-2 univer-text-sm univer-text-gray-900
dark:!univer-text-gray-0
`}
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.filter-by-cell-fill-color')}
</div>
<div
className={`
univer-grid univer-grid-cols-8 univer-items-center univer-justify-start univer-gap-2
`}
>
{cellFillColors.map((color, index) => (
<div
key={`sheets-filter-cell-fill-color-${index}`}
className="univer-relative univer-size-6"
onClick={() => handleSelectCellFillColor(color)}
>
{!color.color
? (
<BanIcon
className={`
univer-size-6 univer-cursor-pointer univer-rounded-full
hover:univer-ring-2 hover:univer-ring-offset-2
hover:univer-ring-offset-gray-0
`}
/>
)
: (
<button
type="button"
className={clsx(`
univer-box-border univer-size-6 univer-cursor-pointer
univer-rounded-full univer-border univer-border-solid
univer-border-transparent univer-bg-gray-300 univer-transition-shadow
hover:univer-ring-2 hover:univer-ring-offset-2
hover:univer-ring-offset-gray-0
`)}
style={{ backgroundColor: color.color }}
/>
)}
{color.checked && <CheckedIcon />}
</div>
))}
</div>
</div>
)}
{cellTextColors.length > 1 && (
<div>
<div
className={`
univer-mb-2 univer-text-sm univer-text-gray-900
dark:!univer-text-gray-0
`}
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.filter-by-cell-text-color')}
</div>
<div
className={`
univer-grid univer-grid-cols-8 univer-items-center univer-justify-start univer-gap-2
`}
>
{cellTextColors.map((color, index) => (
<div
key={`sheets-filter-cell-text-color-${index}`}
className="univer-relative univer-size-6"
onClick={() => handleSelectCellTextColor(color)}
>
<div
className={`
univer-box-border univer-flex univer-size-full univer-cursor-pointer
univer-items-center univer-justify-center univer-rounded-full univer-border
univer-border-solid univer-border-[rgba(13,13,13,0.06)] univer-p-0.5
hover:univer-ring-2 hover:univer-ring-offset-2 hover:univer-ring-offset-gray-0
dark:!univer-border-[rgba(255,255,255,0.06)]
`}
>
<AIcon style={{ color: color.color as string }} />
</div>
{color.checked && <CheckedIcon />}
</div>
))}
</div>
</div>
)}
{cellFillColors.length <= 1 && cellTextColors.length <= 1 && (
<div
className={`
univer-flex univer-size-full univer-items-center univer-justify-center univer-text-sm
univer-text-gray-900
dark:!univer-text-gray-200
`}
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.filter-by-color-none')}
</div>
)}
</div>
</div>
);
}
function CheckedIcon() {
return (
<div
className={`
univer-absolute -univer-bottom-0.5 -univer-right-0.5 univer-flex univer-size-3 univer-cursor-pointer
univer-items-center univer-justify-center univer-rounded-full univer-bg-gray-0
`}
>
<SuccessIcon
className="univer-size-full univer-font-bold univer-text-[#418F1F]"
/>
</div>
);
}

View File

@@ -0,0 +1,150 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import type { FilterOperator, IFilterConditionFormParams } from '../../models/conditions';
import type { ByConditionsModel } from '../../services/sheets-filter-panel.service';
import { LocaleService } from '@univerjs/core';
import { borderClassName, clsx, Input, Radio, RadioGroup, Select } from '@univerjs/design';
import { useDependency, useObservable } from '@univerjs/ui';
import { useCallback } from 'react';
import { FilterConditionItems } from '../../models/conditions';
const PRIMARY_CONDITION_GROUPS = [
[FilterConditionItems.NONE],
[FilterConditionItems.EMPTY, FilterConditionItems.NOT_EMPTY],
[
FilterConditionItems.TEXT_CONTAINS,
FilterConditionItems.DOES_NOT_CONTAIN,
FilterConditionItems.STARTS_WITH,
FilterConditionItems.ENDS_WITH,
FilterConditionItems.EQUALS,
],
[
FilterConditionItems.GREATER_THAN,
FilterConditionItems.GREATER_THAN_OR_EQUAL,
FilterConditionItems.LESS_THAN,
FilterConditionItems.LESS_THAN_OR_EQUAL,
FilterConditionItems.EQUAL,
FilterConditionItems.NOT_EQUAL,
FilterConditionItems.BETWEEN,
FilterConditionItems.NOT_BETWEEN,
],
[FilterConditionItems.CUSTOM],
];
/**
* Filter by conditions.
*/
export function FilterByCondition(props: { model: ByConditionsModel }) {
const { model } = props;
const localeService = useDependency(LocaleService);
// form state is from the model
const condition = useObservable(model.conditionItem$, undefined);
const formParams = useObservable(model.filterConditionFormParams$, undefined);
const radioValue = formParams?.and ? 'AND' : 'OR';
const onRadioChange = useCallback((key: string | number | boolean) => {
model.onConditionFormChange({ and: key === 'AND' });
}, [model]);
const primaryOptions = PRIMARY_CONDITION_GROUPS.map((group) => ({
options: group.map((conditionItem) => ({
label: localeService.t(conditionItem.label),
value: conditionItem.operator,
})),
}));
const onPrimaryConditionChange = useCallback((value: string) => {
model.onPrimaryConditionChange(value as FilterOperator);
}, [model]);
const secondaryOptions = FilterConditionItems.ALL_CONDITIONS
.filter((conditionItem) => conditionItem.numOfParameters !== 2)
.map((conditionItem) => ({
label: localeService.t(conditionItem.label),
value: conditionItem.operator,
}));
const onFormParamsChange = useCallback((diffParams: Partial<IFilterConditionFormParams>) => {
model.onConditionFormChange(diffParams);
}, [model]);
const placeholder = localeService.t<LocaleKey>('sheets-filter-ui.panel.input-values-placeholder');
function renderSecondaryCondition(operator: FilterOperator, val: string, name: 'operator1' | 'operator2') {
const shouldRenderInput = FilterConditionItems.getItemByOperator(operator).numOfParameters === 1;
return (
<>
{name === 'operator2' && (
<RadioGroup value={radioValue} onChange={onRadioChange}>
<Radio value="AND">{localeService.t<LocaleKey>('sheets-filter-ui.panel.and')}</Radio>
<Radio value="OR">{localeService.t<LocaleKey>('sheets-filter-ui.panel.or')}</Radio>
</RadioGroup>
)}
<Select
value={operator}
options={secondaryOptions}
onChange={(operator) => onFormParamsChange({ [name]: operator as FilterOperator })}
/>
{shouldRenderInput && (
<div>
<Input
className="univer-mt-2"
value={val}
placeholder={placeholder}
onChange={(value) => onFormParamsChange({ [name === 'operator1' ? 'val1' : 'val2']: value })}
/>
</div>
)}
</>
);
}
return (
<div
data-u-comp="sheets-filter-panel-conditions-container"
className="univer-flex univer-h-full univer-min-h-[300px] univer-flex-col"
>
{/* primary condition */}
{(condition && formParams) && (
<>
<Select value={condition.operator} options={primaryOptions} onChange={onPrimaryConditionChange} />
{FilterConditionItems.getItemByOperator(condition.operator).numOfParameters !== 0
? (
<div
data-u-comp="sheets-filter-panel-conditions-container-inner"
className={clsx(`
univer-mt-2 univer-flex-grow univer-overflow-hidden univer-rounded-md univer-p-2
`, borderClassName)}
>
{condition.numOfParameters >= 1 && renderSecondaryCondition(formParams.operator1!, formParams.val1 ?? '', 'operator1')}
{condition.numOfParameters >= 2 && renderSecondaryCondition(formParams.operator2!, formParams.val2 ?? '', 'operator2')}
<div
data-u-comp="sheets-filter-panel-conditions-desc"
className="univer-mt-2 univer-text-xs univer-text-gray-500"
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.?')}
<br />
{localeService.t<LocaleKey>('sheets-filter-ui.panel.*')}
</div>
</div>
)
: null}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import type { ByValuesModel, IFilterByValueWithTreeItem } from '../../services/sheets-filter-panel.service';
import { LocaleService } from '@univerjs/core';
import { borderClassName, Checkbox, clsx, Input, Tree } from '@univerjs/design';
import { useDependency, useObservable } from '@univerjs/ui';
import { useCallback } from 'react';
import { statisticFilterByValueItems } from '../../models/utils';
/**
* Filter by values.
*/
export function FilterByValue(props: { model: ByValuesModel }) {
const { model } = props;
const localeService = useDependency(LocaleService);
const searchText = useObservable(model.searchString$, '', true);
const items = useObservable(model.filterItems$, undefined, true);
const filterOnly = localeService.t<LocaleKey>('sheets-filter-ui.panel.filter-only');
const stat = statisticFilterByValueItems(items);
const allChecked = stat.checked > 0 && stat.unchecked === 0;
const indeterminate = stat.checked > 0 && stat.unchecked > 0;
const treeMap = model.treeMapCache;
const onCheckAllToggled = useCallback(() => {
model.onCheckAllToggled(!allChecked);
}, [model, allChecked]);
const onSearchValueChange = useCallback((str: string) => {
model.setSearchString(str);
}, [model]);
function extractCheckedKeys(items: IFilterByValueWithTreeItem[]): string[] {
let checkedKeys: string[] = [];
items.forEach((item) => {
if (item.checked) {
checkedKeys.push(item.key);
}
if (item.children) {
checkedKeys = checkedKeys.concat(extractCheckedKeys(item.children));
}
});
return checkedKeys;
}
return (
<div
data-u-comp="sheets-filter-panel-values-container"
className="univer-flex univer-h-full univer-min-h-[300px] univer-flex-col"
>
<Input
autoFocus
value={searchText}
placeholder={localeService.t<LocaleKey>('sheets-filter-ui.panel.search-placeholder')}
onChange={onSearchValueChange}
/>
<div
data-u-comp="sheets-filter-panel"
className={clsx(`
univer-mt-2 univer-box-border univer-flex univer-flex-grow univer-flex-col univer-overflow-hidden
univer-rounded-md univer-px-2 univer-py-2.5
`, borderClassName)}
>
{/* The on-top "Select All" button */}
<div
data-u-comp="sheets-filter-panel-values-item"
className="univer-box-border univer-h-8 univer-w-full univer-py-0.5"
>
<div
data-u-comp="sheets-filter-panel-values-item-inner"
className={`
univer-box-border univer-flex univer-h-7 univer-items-center univer-rounded-md univer-py-0
univer-pl-5 univer-pr-0.5 univer-text-sm
`}
>
<Checkbox
indeterminate={indeterminate}
disabled={items.length === 0}
checked={allChecked}
onChange={onCheckAllToggled}
/>
<span
data-u-comp="sheets-filter-panel-values-item-text"
className={`
univer-mx-1 univer-inline-block univer-flex-shrink univer-truncate univer-text-gray-900
dark:!univer-text-gray-0
`}
>
{`${localeService.t<LocaleKey>('sheets-filter-ui.panel.select-all')}`}
</span>
<span
data-u-comp="sheets-filter-panel-values-item-count"
className={`
univer-text-gray-400
dark:!univer-text-gray-500
`}
>
{`(${stat.checked}/${stat.checked + stat.unchecked})`}
</span>
</div>
</div>
<div data-u-comp="sheets-filter-panel-values-virtual" className="univer-flex-grow">
<Tree
data={items}
defaultExpandAll={false}
valueGroup={extractCheckedKeys(items)}
// height={180}
onChange={(node) => {
model.onFilterCheckToggled(node as IFilterByValueWithTreeItem);
}}
defaultCache={treeMap}
itemHeight={28}
treeNodeClassName={`
univer-pr-2 univer-border-box univer-rounded-md
[&:hover_a]:univer-inline-block
hover:univer-bg-gray-50 univer-h-full
univer-text-gray-900 dark:hover:!univer-bg-gray-900
dark:!univer-text-gray-0
`}
attachRender={(item) => (
<div
className={`
univer-ml-1 univer-flex univer-h-5 univer-flex-1 univer-cursor-pointer
univer-items-center univer-justify-between univer-text-sm univer-text-primary-500
`}
>
<span
data-u-comp="sheets-filter-panel-values-item-count"
className={`
univer-text-gray-400
dark:!univer-text-gray-500
`}
>
{`(${item.count})`}
</span>
<a
className={`
univer-box-border univer-hidden univer-h-4 univer-whitespace-nowrap univer-px-1.5
`}
onClick={() => {
const filterValues = [];
if (item.children) {
item.children.forEach((child) => {
if (child.children) {
child.children.forEach((subChild) => {
filterValues.push(subChild.key);
});
} else {
filterValues.push(child.key);
}
});
} else {
filterValues.push(item.key);
}
model.onFilterOnly(filterValues);
}}
>
{filterOnly}
</a>
</div>
)}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta } from '@storybook/react';
import type { IWorkbookData } from '@univerjs/core';
import type { IOpenFilterPanelOperationParams } from '../../commands/operations/sheets-filter.operation';
import { CommandType, ICommandService, ILogService, Inject, Injector, LocaleService, LocaleType, LogLevel, Plugin, registerDependencies, Univer, UniverInstanceType } from '@univerjs/core';
import { RefRangeService, SheetInterceptorService, SheetsSelectionsService, WorksheetProtectionPointModel } from '@univerjs/sheets';
import { ClearSheetsFilterCriteriaCommand, ReCalcSheetsFilterCommand, SetSheetsFilterCriteriaCommand, SmartToggleSheetsFilterCommand, UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import { SetCellEditVisibleOperation } from '@univerjs/sheets-ui';
import { IShortcutService, IUIPartsService, RediContext, ShortcutService, UIPartsService } from '@univerjs/ui';
import { useState } from 'react';
import { WithCustomFilterModelFactory, WithValuesFilterModelFactory } from '../../__tests__/data';
import { ChangeFilterByOperation, CloseFilterPanelOperation, OpenFilterPanelOperation } from '../../commands/operations/sheets-filter.operation';
import enUS from '../../locale/en-US';
import ruRU from '../../locale/ru-RU';
import zhCN from '../../locale/zh-CN';
import { ISheetsFilterPanelService, SheetsFilterPanelService } from '../../services/sheets-filter-panel.service';
import { FilterPanel } from './SheetsFilterPanel';
const meta: Meta<typeof FilterPanel> = {
title: 'Components / FilterPanel',
component: FilterPanel,
tags: ['autodocs'],
};
export default meta;
const FakeCetCellEditVisibleOperation = {
id: SetCellEditVisibleOperation.id,
type: CommandType.OPERATION,
handler: () => true,
};
function createFilterStorybookBed(workbookData: IWorkbookData, locale: LocaleType = LocaleType.EN_US) {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'test-plugin';
constructor(
_config: unknown,
@Inject(Injector) protected readonly _injector: Injector
) {
super();
}
override onStarting(): void {
const injector = this._injector;
registerDependencies(injector, [
[SheetsSelectionsService],
[IShortcutService, { useClass: ShortcutService }],
[IUIPartsService, { useClass: UIPartsService }],
[WorksheetProtectionPointModel],
[SheetInterceptorService],
[ISheetsFilterPanelService, { useClass: SheetsFilterPanelService }],
[RefRangeService],
]);
const commandService = injector.get(ICommandService);
[
FakeCetCellEditVisibleOperation,
SmartToggleSheetsFilterCommand,
SetSheetsFilterCriteriaCommand,
ClearSheetsFilterCriteriaCommand,
ReCalcSheetsFilterCommand,
OpenFilterPanelOperation,
CloseFilterPanelOperation,
ChangeFilterByOperation,
].forEach((command) => commandService.registerCommand(command));
}
override onReady(): void {
const commandService = injector.get(ICommandService);
commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: 'test',
subUnitId: 'sheet1',
col: 0,
} as IOpenFilterPanelOperationParams);
}
}
univer.registerPlugin(TestPlugin);
univer.registerPlugin(UniverSheetsFilterPlugin);
injector.get(LocaleService).setLocale(locale);
injector.get(LocaleService).load({ enUS, zhCN, ruRU });
injector.get(ILogService).setLogLevel(LogLevel.VERBOSE);
const sheet = univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData);
return { univer, injector, sheet };
}
export const FilterWithConditions = {
render() {
const [bed] = useState(() => createFilterStorybookBed(WithCustomFilterModelFactory()));
const { injector } = bed;
return (
<RediContext.Provider value={{ injector }}>
<FilterPanel />
</RediContext.Provider>
);
},
};
export const FilterWithValues = {
render() {
const [bed] = useState(() => createFilterStorybookBed(WithValuesFilterModelFactory()));
return (
<RediContext.Provider value={{ injector: bed.injector }}>
<FilterPanel />
</RediContext.Provider>
);
},
};
export const FilterWithChinese = {
render() {
const [bed] = useState(() => createFilterStorybookBed(WithValuesFilterModelFactory(), LocaleType.ZH_CN));
return (
<RediContext.Provider value={{ injector: bed.injector }}>
<FilterPanel />
</RediContext.Provider>
);
},
};

View File

@@ -0,0 +1,139 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import type { ByColorsModel, ByConditionsModel, ByValuesModel } from '../../services/sheets-filter-panel.service';
import { ICommandService, LocaleService } from '@univerjs/core';
import { Button, Segmented } from '@univerjs/design';
import { FilterBy, SheetsFilterService } from '@univerjs/sheets-filter';
import { SheetsUIPart } from '@univerjs/sheets-ui';
import { ComponentContainer, useComponentsOfPart, useDependency, useObservable } from '@univerjs/ui';
import { useCallback } from 'react';
import { of } from 'rxjs';
import { ChangeFilterByOperation, CloseFilterPanelOperation } from '../../commands/operations/sheets-filter.operation';
import { ISheetsFilterPanelService } from '../../services/sheets-filter-panel.service';
import { FilterByColor } from './SheetsFilterByColorsPanel';
import { FilterByCondition } from './SheetsFilterByConditionsPanel';
import { FilterByValue } from './SheetsFilterByValuesPanel';
import { FilterSyncSwitch } from './SheetsFilterSyncSwitch';
const FILTER_BY_OPTIONS: Array<{ label: LocaleKey; value: FilterBy }> = [
{ label: 'sheets-filter-ui.panel.by-values', value: FilterBy.VALUES },
{ label: 'sheets-filter-ui.panel.by-colors', value: FilterBy.COLORS },
{ label: 'sheets-filter-ui.panel.by-conditions', value: FilterBy.CONDITIONS },
];
/**
* This Filter Panel component is used to filter the data in the sheet.
*
* @returns React element
*/
export function FilterPanel() {
const sheetsFilterPanelService = useDependency(ISheetsFilterPanelService);
const localeService = useDependency(LocaleService);
const commandService = useDependency(ICommandService);
const filterBy = useObservable(sheetsFilterPanelService.filterBy$, undefined, true);
const filterByModel = useObservable(sheetsFilterPanelService.filterByModel$, undefined, false);
const canApply = useObservable(() => filterByModel?.canApply$ || of(false), undefined, false, [filterByModel]);
const items = FILTER_BY_OPTIONS.map((option) => ({ ...option, label: localeService.t(option.label) }));
// only can disable clear when there is no criteria
const clearFilterDisabled = !useObservable(sheetsFilterPanelService.hasCriteria$);
const onFilterByTypeChange = useCallback((value: FilterBy) => {
commandService.executeCommand(ChangeFilterByOperation.id, { filterBy: value });
}, [commandService]);
const onClearCriteria = useCallback(async () => {
await filterByModel?.clear();
commandService.executeCommand(CloseFilterPanelOperation.id);
}, [filterByModel, commandService]);
const onCancel = useCallback(() => {
commandService.executeCommand(CloseFilterPanelOperation.id);
}, [commandService]);
const onApply = useCallback(async () => {
await filterByModel?.apply();
commandService.executeCommand(CloseFilterPanelOperation.id);
}, [filterByModel, commandService]);
const filterService = useDependency(SheetsFilterService);
const range = filterService.activeFilterModel?.getRange();
const colIndex = sheetsFilterPanelService.col;
const FilterPanelEmbedPointPart = useComponentsOfPart(SheetsUIPart.FILTER_PANEL_EMBED_POINT);
return (
<div
data-u-comp="sheets-filter-panel"
className={`
univer-box-border univer-flex univer-max-h-[500px] univer-w-[400px] univer-flex-col univer-rounded-lg
univer-bg-gray-0 univer-p-4 univer-shadow-lg
dark:!univer-border-gray-600 dark:!univer-bg-gray-700
`}
>
<ComponentContainer
components={FilterPanelEmbedPointPart}
sharedProps={{ range, colIndex, onClose: onCancel }}
/>
<div className="univer-mb-1 univer-flex-shrink-0 univer-flex-grow-0">
<Segmented
value={filterBy}
items={items}
onChange={(value) => onFilterByTypeChange(value as FilterBy)}
/>
</div>
{filterByModel
? (
<div
data-u-comp="sheets-filter-panel-content"
className="univer-flex-shrink univer-flex-grow univer-pt-2"
>
{filterBy === FilterBy.VALUES
? <FilterByValue model={filterByModel as ByValuesModel} />
: filterBy === FilterBy.COLORS
? <FilterByColor model={filterByModel as ByColorsModel} />
: <FilterByCondition model={filterByModel as ByConditionsModel} />}
</div>
)
: (
<div className="univer-flex-1" />
)}
<FilterSyncSwitch />
<div
data-u-comp="sheets-filter-panel-footer"
className={`
univer-mt-4 univer-inline-flex univer-flex-shrink-0 univer-flex-grow-0 univer-flex-nowrap
univer-justify-between univer-overflow-hidden
`}
>
<Button variant="link" onClick={onClearCriteria} disabled={clearFilterDisabled}>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.clear-filter')}
</Button>
<span className="univer-flex univer-gap-2">
<Button variant="default" onClick={onCancel}>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.cancel')}
</Button>
<Button disabled={!canApply} variant="primary" onClick={onApply}>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.confirm')}
</Button>
</span>
</div>
</div>
);
}
export const FILTER_PANEL_POPUP_KEY = 'FILTER_PANEL_POPUP';

View File

@@ -0,0 +1,67 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import { LocaleService } from '@univerjs/core';
import { MessageType, Switch, Tooltip } from '@univerjs/design';
import { InfoIcon } from '@univerjs/icons';
import { SheetsFilterSyncController } from '@univerjs/sheets-filter';
import { IMessageService, useDependency, useObservable } from '@univerjs/ui';
export function FilterSyncSwitch() {
const sheetsFilterSyncController = useDependency(SheetsFilterSyncController);
const visible = useObservable(sheetsFilterSyncController.visible$, undefined, true);
const localeService = useDependency(LocaleService);
const messageService = useDependency(IMessageService);
const enabled = useObservable(sheetsFilterSyncController.enabled$, undefined, true);
if (!visible) return null;
return (
<div
className={`
univer-mt-2 univer-flex univer-items-center univer-justify-between univer-text-sm univer-text-gray-900
dark:!univer-text-gray-200
`}
>
<div className="univer-flex univer-items-center univer-gap-1">
<span>{localeService.t<LocaleKey>('sheets-filter-ui.sync.title')}</span>
<Tooltip
title={enabled
? localeService.t<LocaleKey>('sheets-filter-ui.sync.statusTips.off')
: localeService.t<LocaleKey>('sheets-filter-ui.sync.statusTips.on')}
asChild
>
<InfoIcon className="univer-block" />
</Tooltip>
</div>
<Switch
defaultChecked={enabled}
onChange={(checked) => {
const message = checked
? localeService.t<LocaleKey>('sheets-filter-ui.sync.switchTips.on')
: localeService.t<LocaleKey>('sheets-filter-ui.sync.switchTips.off');
sheetsFilterSyncController.setEnabled(checked);
messageService.show({
content: message,
type: MessageType.Success,
duration: 2000,
});
}}
/>
</div>
);
}

View File

@@ -0,0 +1,578 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IOperation, IWorkbookData, Workbook } from '@univerjs/core';
import type { IUniverSheetsFilterConfig } from '@univerjs/sheets-filter';
import type { IEditorBridgeServiceVisibleParam } from '@univerjs/sheets-ui';
import type { Root } from 'react-dom/client';
import {
awaitTime,
CommandType,
ICommandService,
IContextService,
ILogService,
Inject,
Injector,
LocaleService,
LocaleType,
LogLevel,
Plugin,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FilterBy, SheetsFilterService, SheetsFilterSyncController, UniverSheetsFilterPlugin } from '@univerjs/sheets-filter';
import { ILayoutService, IMessageService, IUIPartsService, RediContext, UIPartsService } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import { WithValuesAndEmptyFilterModelFactory, WithValuesFilterModelFactory } from '../../../__tests__/data';
import {
ChangeFilterByOperation,
CloseFilterPanelOperation,
FILTER_PANEL_OPENED_KEY,
OpenFilterPanelOperation,
} from '../../../commands/operations/sheets-filter.operation';
import enUS from '../../../locale/en-US';
import { ISheetsFilterPanelService, SheetsFilterPanelService } from '../../../services/sheets-filter-panel.service';
import { FilterPanel } from '../SheetsFilterPanel';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const UNIT_ID = 'test';
const SUB_UNIT_ID = 'sheet1';
const SetCellEditVisibleOperation: IOperation<IEditorBridgeServiceVisibleParam> = {
id: 'sheet.operation.set-cell-edit-visible',
type: CommandType.OPERATION,
handler: () => true,
};
class TestLayoutService {
private _focused = false;
readonly rootContainerElement = document.body;
get isFocused() {
return this._focused;
}
focus() {
this._focused = true;
}
registerFocusHandler(): IDisposable {
return toDisposable(() => undefined);
}
registerRootContainerElement(): IDisposable {
return toDisposable(() => undefined);
}
registerContentElement(): IDisposable {
return toDisposable(() => undefined);
}
registerContainerElement(): IDisposable {
return toDisposable(() => undefined);
}
getContentElement() {
return document.body;
}
checkElementInCurrentContainers() {
return true;
}
checkContentIsFocused() {
return this._focused;
}
}
class TestMessageService {
readonly messages: unknown[] = [];
show(options: unknown): IDisposable {
this.messages.push(options);
return toDisposable(() => undefined);
}
remove() {
// The switch UI is not the behavior under test here.
}
removeAll() {
this.messages.length = 0;
}
}
function createFilterPanelViewTestBed(workbookData: IWorkbookData, filterConfig?: Partial<IUniverSheetsFilterConfig>) {
const univer = new Univer();
const injector = univer.__getInjector();
class TestPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName = 'sheets-filter-ui-panel-view-test-plugin';
constructor(
_config: unknown,
@Inject(Injector) protected readonly _injector: Injector
) {
super();
}
override onStarting(): void {
this._injector.add([ISheetsFilterPanelService, { useClass: SheetsFilterPanelService }]);
this._injector.add([ILayoutService, { useClass: TestLayoutService as never }]);
this._injector.add([IMessageService, { useClass: TestMessageService as never }]);
this._injector.add([IUIPartsService, { useClass: UIPartsService }]);
}
}
injector.get(LocaleService).load({ [LocaleType.EN_US]: enUS });
injector.get(LocaleService).setLocale(LocaleType.EN_US);
if (filterConfig) {
univer.registerPlugin(UniverSheetsFilterPlugin, filterConfig);
} else {
univer.registerPlugin(UniverSheetsFilterPlugin);
}
univer.registerPlugin(TestPlugin);
const workbook = univer.createUnit<IWorkbookData, Workbook>(UniverInstanceType.UNIVER_SHEET, workbookData);
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
const commandService = injector.get(ICommandService);
[
OpenFilterPanelOperation,
CloseFilterPanelOperation,
ChangeFilterByOperation,
SetCellEditVisibleOperation,
].forEach((command) => commandService.registerCommand(command));
return {
univer,
injector,
workbook,
commandService,
contextService: injector.get(IContextService),
filterService: injector.get(SheetsFilterService),
panelService: injector.get(ISheetsFilterPanelService),
messageService: injector.get(IMessageService) as TestMessageService,
};
}
async function openPanel(testBed: ReturnType<typeof createFilterPanelViewTestBed>) {
expect(testBed.commandService.syncExecuteCommand(OpenFilterPanelOperation.id, {
unitId: UNIT_ID,
subUnitId: SUB_UNIT_ID,
col: 0,
})).toBe(true);
await awaitTime(20);
}
async function renderPanel(root: Root, testBed: ReturnType<typeof createFilterPanelViewTestBed>) {
await act(async () => {
root.render(
<RediContext.Provider value={{ injector: testBed.injector }}>
<FilterPanel />
</RediContext.Provider>
);
await awaitTime(20);
});
}
function clickElement(element: Element) {
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
function openSelect(element: Element) {
element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }));
}
function changeCheckbox(input: HTMLInputElement) {
input.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
function changeInput(input: HTMLInputElement, value: string) {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function getButton(container: HTMLElement, text: string): HTMLElement {
const button = Array.from(container.querySelectorAll('[data-u-comp="button"]'))
.find((button) => button.textContent === text);
if (!button) {
throw new Error(`Button "${text}" was not rendered.`);
}
return button as HTMLElement;
}
function getSelect(container: HTMLElement, index = 0): HTMLElement {
const select = container.querySelectorAll<HTMLElement>('[data-u-comp="select"]').item(index);
if (!select) {
throw new Error(`Select at index "${index}" was not rendered.`);
}
return select;
}
function getDropdownOption(text: string): HTMLElement {
const option = Array.from(document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"]'))
.find((item) => item.textContent === text);
if (!option) {
const renderedOptions = Array.from(document.querySelectorAll<HTMLElement>('[data-slot="dropdown-menu-radio-item"]'))
.map((item) => item.textContent)
.join(', ');
throw new Error(`Dropdown option "${text}" was not rendered. Options: ${renderedOptions}`);
}
return option;
}
function getValueItemCheckbox(container: HTMLElement, title: string): HTMLInputElement {
const titleElement = Array.from(container.querySelectorAll('span'))
.find((element) => element.textContent === title);
if (!titleElement) {
throw new Error(`Filter value "${title}" was not rendered.`);
}
let row: Element | null = titleElement;
while (row && !row.querySelector('[data-u-comp="checkbox"] input')) {
row = row.parentElement;
}
const input = row?.querySelector<HTMLInputElement>('[data-u-comp="checkbox"] input');
if (!input) {
throw new Error(`Filter value "${title}" did not render a checkbox.`);
}
return input;
}
function getColorSwatch(container: HTMLElement, color: string): HTMLElement {
const button = Array.from(container.querySelectorAll<HTMLButtonElement>('button'))
.find((button) => button.style.backgroundColor === color);
const swatch = button?.parentElement;
if (!swatch) {
throw new Error(`Color swatch "${color}" was not rendered.`);
}
return swatch;
}
function WithColorOptionsFilterModelFactory(): IWorkbookData {
return {
id: UNIT_ID,
sheetOrder: [SUB_UNIT_ID],
name: '',
appVersion: '3.0.0-alpha',
locale: LocaleType.ZH_CN,
styles: {
redFill: {
bg: {
rgb: '#ff0000',
},
},
blueText: {
cl: {
rgb: '#0000ff',
},
},
},
sheets: {
[SUB_UNIT_ID]: {
id: SUB_UNIT_ID,
name: 'Sheet1',
rowCount: 20,
columnCount: 5,
cellData: {
0: {
0: { v: 'Status' },
},
1: {
0: { v: 'Blocked', s: 'redFill' },
},
2: {
0: { v: 'Ready', s: 'blueText' },
},
3: {
0: { v: 'Backlog' },
},
},
},
},
resources: [
{
name: 'SHEET_FILTER_PLUGIN',
data: JSON.stringify({
[SUB_UNIT_ID]: {
ref: {
startRow: 0,
startColumn: 0,
endRow: 3,
endColumn: 0,
},
filterColumns: [],
cachedFilteredOut: [],
},
}),
},
],
};
}
describe('FilterPanel', () => {
let root: Root | undefined;
let container: HTMLDivElement | undefined;
let currentTestBed: ReturnType<typeof createFilterPanelViewTestBed> | undefined;
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
currentTestBed?.univer.dispose();
root = undefined;
container = undefined;
currentTestBed = undefined;
});
it('clears existing value criteria and closes the panel from the footer action', async () => {
currentTestBed = createFilterPanelViewTestBed(WithValuesAndEmptyFilterModelFactory());
await openPanel(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderPanel(root, currentTestBed);
const filterColumnBefore = currentTestBed.filterService.activeFilterModel?.getFilterColumn(0);
expect(filterColumnBefore?.getColumnData().filters).toBeDefined();
const clearButton = Array.from(container.querySelectorAll('[data-u-comp="button"]'))
.find((button) => button.textContent === 'Clear Filter') as HTMLElement;
await act(async () => {
clearButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await awaitTime(20);
});
expect(currentTestBed.filterService.activeFilterModel?.getFilterColumn(0)).toBeNull();
expect(currentTestBed.contextService.getContextValue(FILTER_PANEL_OPENED_KEY)).toBe(false);
expect(currentTestBed.panelService.col).toBe(-1);
});
it('switches from values to conditions through the segmented control', async () => {
currentTestBed = createFilterPanelViewTestBed(WithValuesFilterModelFactory());
await openPanel(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderPanel(root, currentTestBed);
expect(currentTestBed.panelService.filterBy).toBe(FilterBy.VALUES);
const conditionButton = Array.from(container.querySelectorAll('[data-u-comp="segmented"] button'))
.find((button) => button.textContent === 'By Conditions') as HTMLElement;
await act(async () => {
conditionButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await awaitTime(20);
});
expect(currentTestBed.panelService.filterBy).toBe(FilterBy.CONDITIONS);
expect(container.querySelector('[data-u-comp="sheets-filter-panel-conditions-container"]')).toBeTruthy();
});
it('applies value criteria after unchecking a value from the tree', async () => {
currentTestBed = createFilterPanelViewTestBed(WithValuesFilterModelFactory());
await openPanel(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderPanel(root, currentTestBed);
const michaelCheckbox = getValueItemCheckbox(container, 'Michael');
const confirmButton = getButton(container, 'Confirm');
await act(async () => {
changeCheckbox(michaelCheckbox);
await awaitTime(20);
});
await act(async () => {
clickElement(confirmButton);
await awaitTime(20);
});
const filters = currentTestBed.filterService.activeFilterModel?.getFilterColumn(0)?.getColumnData().filters?.filters;
expect(filters).toContain('Jackson');
expect(filters).not.toContain('Michael');
expect(currentTestBed.contextService.getContextValue(FILTER_PANEL_OPENED_KEY)).toBe(false);
});
it('keeps value criteria unchanged when a changed candidate is cancelled', async () => {
currentTestBed = createFilterPanelViewTestBed(WithValuesAndEmptyFilterModelFactory());
await openPanel(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderPanel(root, currentTestBed);
const michaelCheckbox = getValueItemCheckbox(container, 'Michael');
expect(michaelCheckbox.checked).toBe(false);
await act(async () => {
changeCheckbox(michaelCheckbox);
await awaitTime(20);
});
expect(getValueItemCheckbox(container, 'Michael').checked).toBe(true);
await act(async () => {
clickElement(getButton(container!, 'Cancel'));
await awaitTime(20);
});
expect(currentTestBed.filterService.activeFilterModel?.getFilterColumn(0)?.getColumnData().filters).toEqual({
blank: true,
filters: ['1'],
});
expect(currentTestBed.contextService.getContextValue(FILTER_PANEL_OPENED_KEY)).toBe(false);
});
it('applies text contains criteria from the conditions panel', async () => {
currentTestBed = createFilterPanelViewTestBed(WithValuesFilterModelFactory());
await openPanel(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderPanel(root, currentTestBed);
const conditionButton = Array.from(container.querySelectorAll('[data-u-comp="segmented"] button'))
.find((button) => button.textContent === 'By Conditions') as HTMLElement;
await act(async () => {
clickElement(conditionButton);
await awaitTime(20);
});
await act(async () => {
openSelect(getSelect(container!));
await awaitTime(20);
});
await act(async () => {
clickElement(getDropdownOption('Text Contains'));
await awaitTime(20);
});
const input = container.querySelector<HTMLInputElement>('[data-u-comp="sheets-filter-panel-conditions-container-inner"] input');
if (!input) {
throw new Error('Condition value input was not rendered.');
}
await act(async () => {
changeInput(input, 'Michael');
await awaitTime(20);
});
await act(async () => {
clickElement(getButton(container!, 'Confirm'));
await awaitTime(20);
});
const columnData = currentTestBed.filterService.activeFilterModel?.getFilterColumn(0)?.getColumnData();
expect(columnData?.customFilters).toEqual({
customFilters: [{ val: '*Michael*' }],
});
expect(columnData?.filters).toBeUndefined();
expect(currentTestBed.contextService.getContextValue(FILTER_PANEL_OPENED_KEY)).toBe(false);
});
it('applies the selected cell fill color as color criteria', async () => {
currentTestBed = createFilterPanelViewTestBed(WithColorOptionsFilterModelFactory());
await openPanel(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderPanel(root, currentTestBed);
const colorButton = Array.from(container.querySelectorAll('[data-u-comp="segmented"] button'))
.find((button) => button.textContent === 'By Colors') as HTMLElement;
await act(async () => {
clickElement(colorButton);
await awaitTime(20);
});
await act(async () => {
clickElement(getColorSwatch(container!, 'rgb(255, 0, 0)'));
await awaitTime(20);
});
await act(async () => {
clickElement(getButton(container!, 'Confirm'));
await awaitTime(20);
});
expect(currentTestBed.filterService.activeFilterModel?.getFilterColumn(0)?.getColumnData().colorFilters).toEqual({
cellFillColors: ['rgb(255,0,0)'],
});
expect(currentTestBed.contextService.getContextValue(FILTER_PANEL_OPENED_KEY)).toBe(false);
});
it('turns sync off through the switch and reports the change', async () => {
currentTestBed = createFilterPanelViewTestBed(WithValuesFilterModelFactory(), {
enableSyncSwitch: { defaultValue: true },
});
await openPanel(currentTestBed);
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await renderPanel(root, currentTestBed);
const syncController = currentTestBed.injector.get(SheetsFilterSyncController);
const switchInput = Array.from(container.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')).at(-1);
if (!switchInput) {
throw new Error('Sync switch input was not rendered.');
}
await act(async () => {
changeCheckbox(switchInput);
await awaitTime(20);
});
expect(syncController.enabled).toBe(false);
expect(currentTestBed.messageService.messages).toEqual([{
content: '"Filter is visible to everyone" is turned off, only you will see the filter results',
type: 'success',
duration: 2000,
}]);
});
});

View File

@@ -0,0 +1,115 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import type { ByColorsModel, IFilterByColorItem } from '../../services/sheets-filter-panel.service';
import { LocaleService } from '@univerjs/core';
import { clsx, resetButtonClassName } from '@univerjs/design';
import { AIcon, BanIcon, CheckMarkIcon } from '@univerjs/icons';
import { useDependency, useObservable } from '@univerjs/ui';
function MobileColorOption(props: {
item: IFilterByColorItem;
label: string;
textColor?: boolean;
onSelect: () => void;
}) {
const { item, label, textColor = false, onSelect } = props;
return (
<button
type="button"
aria-label={item.color ?? label}
aria-pressed={item.checked}
className={clsx(resetButtonClassName, `
univer-relative univer-flex univer-size-12 univer-items-center univer-justify-center univer-rounded-full
univer-border univer-border-solid univer-border-gray-200 univer-bg-gray-0
dark:!univer-border-gray-600 dark:!univer-bg-gray-800
`)}
style={!textColor && item.color ? { backgroundColor: item.color } : undefined}
onClick={onSelect}
>
{!item.color && <BanIcon className="univer-size-7" />}
{textColor && item.color && <AIcon className="univer-size-7" style={{ color: item.color }} />}
{item.checked && (
<span
className="
univer-absolute -univer-bottom-1 -univer-right-1 univer-flex univer-size-5 univer-items-center
univer-justify-center univer-rounded-full univer-bg-primary-600 univer-text-gray-0
"
>
<CheckMarkIcon className="univer-size-3" />
</span>
)}
</button>
);
}
export function MobileFilterByColor(props: { model: ByColorsModel }) {
const { model } = props;
const localeService = useDependency(LocaleService);
const cellFillColors = useObservable(model.cellFillColors$, [], true);
const cellTextColors = useObservable(model.cellTextColors$, [], true);
const fillLabel = localeService.t<LocaleKey>('sheets-filter-ui.panel.filter-by-cell-fill-color');
const textLabel = localeService.t<LocaleKey>('sheets-filter-ui.panel.filter-by-cell-text-color');
const hasMultipleColors = cellFillColors.length > 1 || cellTextColors.length > 1;
return (
<div
data-u-comp="mobile-sheets-filter-by-colors"
className="univer-flex univer-min-h-full univer-flex-col univer-gap-6"
>
{cellFillColors.length > 1 && (
<section>
<h3 className="univer-m-0 univer-mb-4 univer-text-base univer-font-medium">{fillLabel}</h3>
<div className="univer-flex univer-flex-wrap univer-gap-4">
{cellFillColors.map((item) => (
<MobileColorOption
key={item.color ?? 'none'}
item={item}
label={fillLabel}
onSelect={() => model.onFilterCheckToggled(item)}
/>
))}
</div>
</section>
)}
{cellTextColors.length > 1 && (
<section>
<h3 className="univer-m-0 univer-mb-4 univer-text-base univer-font-medium">{textLabel}</h3>
<div className="univer-flex univer-flex-wrap univer-gap-4">
{cellTextColors.map((item) => (
<MobileColorOption
key={item.color ?? 'none'}
item={item}
label={textLabel}
textColor
onSelect={() => model.onFilterCheckToggled(item, false)}
/>
))}
</div>
</section>
)}
{!hasMultipleColors && (
<div
className="univer-flex univer-flex-1 univer-items-center univer-justify-center univer-text-gray-500"
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.filter-by-color-none')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,137 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleKey } from '../../locale/types';
import type { FilterOperator } from '../../models/conditions';
import type { ByConditionsModel } from '../../services/sheets-filter-panel.service';
import { LocaleService } from '@univerjs/core';
import { Input, MobileSelect, Radio, RadioGroup } from '@univerjs/design';
import { useDependency, useObservable } from '@univerjs/ui';
import { FilterConditionItems } from '../../models/conditions';
export function MobileFilterByCondition(props: { model: ByConditionsModel }) {
const { model } = props;
const localeService = useDependency(LocaleService);
const condition = useObservable(model.conditionItem$, undefined, true);
const formParams = useObservable(model.filterConditionFormParams$, undefined, true);
const secondaryOptions = FilterConditionItems.ALL_CONDITIONS
.filter((item) => item.numOfParameters !== 2)
.map((item) => ({ label: localeService.t(item.label), value: item.operator }));
if (!condition || !formParams) {
return null;
}
function renderSecondaryCondition(operator: FilterOperator, value: string, name: 'operator1' | 'operator2') {
const needsValue = FilterConditionItems.getItemByOperator(operator).numOfParameters === 1;
return (
<div className="univer-grid univer-gap-3">
{name === 'operator2' && (
<RadioGroup
value={formParams?.and ? 'AND' : 'OR'}
onChange={(join) => model.onConditionFormChange({ and: join === 'AND' })}
>
<Radio value="AND">{localeService.t<LocaleKey>('sheets-filter-ui.panel.and')}</Radio>
<Radio value="OR">{localeService.t<LocaleKey>('sheets-filter-ui.panel.or')}</Radio>
</RadioGroup>
)}
<MobileSelect
value={operator}
options={secondaryOptions}
onChange={(nextOperator) => model.onConditionFormChange({
[name]: nextOperator as FilterOperator,
})}
/>
{needsValue && (
<Input
value={value}
placeholder={localeService.t<LocaleKey>('sheets-filter-ui.panel.input-values-placeholder')}
onChange={(nextValue) => model.onConditionFormChange({
[name === 'operator1' ? 'val1' : 'val2']: nextValue,
})}
/>
)}
</div>
);
}
function renderConditionEditor() {
return (
<div className="univer-grid univer-gap-3 univer-pb-4 univer-pl-7">
{condition!.numOfParameters === 1 && (
<Input
value={formParams!.val1 ?? ''}
placeholder={localeService.t<LocaleKey>('sheets-filter-ui.panel.input-values-placeholder')}
onChange={(value) => model.onConditionFormChange({ val1: value })}
/>
)}
{condition!.numOfParameters >= 2 && renderSecondaryCondition(
formParams!.operator1!,
formParams!.val1 ?? '',
'operator1'
)}
{condition!.numOfParameters >= 2 && renderSecondaryCondition(
formParams!.operator2!,
formParams!.val2 ?? '',
'operator2'
)}
<p className="univer-m-0 univer-text-xs univer-text-gray-500">
{localeService.t<LocaleKey>('sheets-filter-ui.panel.?')}
<br />
{localeService.t<LocaleKey>('sheets-filter-ui.panel.*')}
</p>
</div>
);
}
return (
<div data-u-comp="mobile-sheets-filter-by-conditions" className="univer-grid univer-gap-4">
<div
className="
univer-overflow-hidden univer-rounded-xl univer-bg-gray-0
dark:!univer-bg-gray-800
"
>
{FilterConditionItems.ALL_CONDITIONS.map((item) => {
const selected = condition.operator === item.operator;
return (
<div
key={item.operator}
data-u-comp="mobile-filter-condition-item"
data-selected={selected}
className="
univer-border-0 univer-border-b univer-border-solid univer-border-gray-200 univer-px-4
last:univer-border-b-0
dark:!univer-border-gray-700
"
>
<Radio
value={item.operator}
checked={selected}
onChange={(operator) => model.onPrimaryConditionChange(operator as FilterOperator)}
>
<span className="univer-flex univer-min-h-12 univer-items-center univer-text-base">
{localeService.t(item.label)}
</span>
</Radio>
{selected && condition.numOfParameters > 0 && renderConditionEditor()}
</div>
);
})}
</div>
</div>
);
}

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 type { LocaleKey } from '../../locale/types';
import type { ByValuesModel, IFilterByValueWithTreeItem } from '../../services/sheets-filter-panel.service';
import { LocaleService } from '@univerjs/core';
import { Checkbox, Input, resetButtonClassName, Tree } from '@univerjs/design';
import { CloseIcon, SearchIcon } from '@univerjs/icons';
import { useDependency, useObservable } from '@univerjs/ui';
import { useState } from 'react';
import { statisticFilterByValueItems } from '../../models/utils';
function extractCheckedKeys(items: IFilterByValueWithTreeItem[]): string[] {
let checkedKeys: string[] = [];
items.forEach((item) => {
if (item.checked) {
checkedKeys.push(item.key);
}
if (item.children) {
checkedKeys = checkedKeys.concat(extractCheckedKeys(item.children));
}
});
return checkedKeys;
}
export function MobileFilterByValue(props: { model: ByValuesModel }) {
const { model } = props;
const localeService = useDependency(LocaleService);
const [searching, setSearching] = useState(false);
const searchText = useObservable(model.searchString$, '', true);
const items = useObservable(model.filterItems$, [], true);
const stat = statisticFilterByValueItems(items);
const allChecked = stat.checked > 0 && stat.unchecked === 0;
const indeterminate = stat.checked > 0 && stat.unchecked > 0;
const searchLabel = localeService.t<LocaleKey>('sheets-filter-ui.panel.search-placeholder');
return (
<div data-u-comp="mobile-sheets-filter-by-values" className="univer-flex univer-min-h-full univer-flex-col">
<div
className="
univer-flex univer-min-h-14 univer-items-center univer-border-0 univer-border-b univer-border-solid
univer-border-gray-200 univer-px-2
dark:!univer-border-gray-700
"
>
{searching
? (
<>
<Input
autoFocus
className="univer-flex-1"
value={searchText}
placeholder={searchLabel}
onChange={(value) => model.setSearchString(value)}
/>
<button
type="button"
aria-label={localeService.t<LocaleKey>('sheets-filter-ui.panel.cancel')}
className={`
${resetButtonClassName}
univer-ml-2 univer-flex univer-size-10 univer-items-center univer-justify-center
`}
onClick={() => {
model.setSearchString('');
setSearching(false);
}}
>
<CloseIcon className="univer-size-5" />
</button>
</>
)
: (
<>
<Checkbox
className="univer-min-h-12 univer-flex-1 univer-text-base"
contentClassName="univer-flex univer-items-center univer-gap-1"
indeterminate={indeterminate}
disabled={items.length === 0}
checked={allChecked}
onChange={() => model.onCheckAllToggled(!allChecked)}
>
<span>{localeService.t<LocaleKey>('sheets-filter-ui.panel.select-all')}</span>
<span className="univer-text-sm univer-text-gray-500">
{`(${stat.checked}/${stat.checked + stat.unchecked})`}
</span>
</Checkbox>
<button
type="button"
aria-label={searchLabel}
className={`
${resetButtonClassName}
univer-flex univer-size-12 univer-items-center univer-justify-center
`}
onClick={() => setSearching(true)}
>
<SearchIcon className="univer-size-6" />
</button>
</>
)}
</div>
<div className="univer-min-h-0 univer-flex-1 univer-py-2">
<Tree
data={items}
defaultExpandAll={false}
valueGroup={extractCheckedKeys(items)}
onChange={(node) => model.onFilterCheckToggled(node as IFilterByValueWithTreeItem)}
defaultCache={model.treeMapCache}
itemHeight={48}
treeNodeClassName="univer-h-full univer-pr-2 univer-text-base univer-text-gray-900 dark:!univer-text-gray-0"
attachRender={(item) => (
<span className="univer-ml-auto univer-text-sm univer-text-gray-500">{`(${item.count})`}</span>
)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,320 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { MobileDrawerSnap } from '@univerjs/ui';
import type { LocaleKey } from '../../locale/types';
import type {
ByColorsModel,
ByConditionsModel,
ByValuesModel,
} from '../../services/sheets-filter-panel.service';
import { ICommandService, IContextService, LocaleService, Tools } from '@univerjs/core';
import {
Button,
MessageType,
MobileActionRow,
MobileActionRowGroup,
resetButtonClassName,
Switch,
} from '@univerjs/design';
import { MoreLeftIcon, MoreRightIcon } from '@univerjs/icons';
import { FilterBy, SheetsFilterSyncController } from '@univerjs/sheets-filter';
import { IMessageService, MobileDrawer, useDependency, useObservable } from '@univerjs/ui';
import { useRef, useState } from 'react';
import { map, of } from 'rxjs';
import {
ChangeFilterByOperation,
CloseFilterPanelOperation,
FILTER_PANEL_OPENED_KEY,
} from '../../commands/operations/sheets-filter.operation';
import { ISheetsFilterPanelService } from '../../services/sheets-filter-panel.service';
import { MobileFilterByColor } from './MobileFilterByColor';
import { MobileFilterByCondition } from './MobileFilterByCondition';
import { MobileFilterByValue } from './MobileFilterByValue';
const FILTER_BY_OPTIONS: Array<{ label: LocaleKey; value: FilterBy }> = [
{ label: 'sheets-filter-ui.panel.by-values', value: FilterBy.VALUES },
{ label: 'sheets-filter-ui.panel.by-colors', value: FilterBy.COLORS },
{ label: 'sheets-filter-ui.panel.by-conditions', value: FilterBy.CONDITIONS },
];
function MobileFilterSyncSwitch() {
const localeService = useDependency(LocaleService);
const messageService = useDependency(IMessageService);
const filterSyncController = useDependency(SheetsFilterSyncController);
const visible = useObservable(filterSyncController.visible$, false, true);
const enabled = useObservable(filterSyncController.enabled$, true, true);
if (!visible) {
return null;
}
return (
<div
className="
univer-flex univer-min-h-20 univer-items-center univer-gap-4 univer-rounded-xl univer-bg-gray-0
univer-px-4 univer-py-3
dark:!univer-bg-gray-800
"
>
<div className="univer-min-w-0 univer-flex-1">
<div
className="
univer-text-base univer-font-medium univer-text-gray-900
dark:!univer-text-gray-100
"
>
{localeService.t<LocaleKey>('sheets-filter-ui.sync.title')}
</div>
<div
className="
univer-mt-1 univer-text-sm univer-text-gray-500
dark:!univer-text-gray-400
"
>
{localeService.t<LocaleKey>(enabled
? 'sheets-filter-ui.sync.statusTips.on'
: 'sheets-filter-ui.sync.statusTips.off')}
</div>
</div>
<Switch
defaultChecked={enabled}
onChange={(checked) => {
filterSyncController.setEnabled(checked);
messageService.show({
content: localeService.t<LocaleKey>(checked
? 'sheets-filter-ui.sync.switchTips.on'
: 'sheets-filter-ui.sync.switchTips.off'),
type: MessageType.Success,
duration: 2000,
});
}}
/>
</div>
);
}
export function MobileSheetsFilterPanel() {
const contextService = useDependency(IContextService);
const visible = useObservable(
() => contextService.subscribeContextValue$(FILTER_PANEL_OPENED_KEY).pipe(map(Boolean)),
Boolean(contextService.getContextValue(FILTER_PANEL_OPENED_KEY)),
false,
[contextService]
);
return visible ? <MobileSheetsFilterPanelContent /> : null;
}
function MobileSheetsFilterPanelContent() {
const commandService = useDependency(ICommandService);
const localeService = useDependency(LocaleService);
const panelService = useDependency(ISheetsFilterPanelService);
const filterBy = useObservable(panelService.filterBy$, FilterBy.VALUES, true);
const filterByModel = useObservable(panelService.filterByModel$, null);
const canApply = useObservable(() => filterByModel?.canApply$ ?? of(false), false, true, [filterByModel]);
const hasCriteria = useObservable(panelService.hasCriteria$, false, true);
const col = useObservable(panelService.col$, panelService.col, true);
const [detailOpen, setDetailOpen] = useState(false);
const [drawerSnap, setDrawerSnap] = useState<MobileDrawerSnap>('compact');
const layerRef = useRef<HTMLDivElement>(null);
const filterRange = panelService.filterModel?.getRange();
const title = detailOpen
? localeService.t<LocaleKey>(FILTER_BY_OPTIONS.find((option) => option.value === filterBy)!.label)
: localeService.t<LocaleKey>('sheets-filter-ui.toolbar.smart-toggle-filter-tooltip');
async function closePanel() {
await commandService.executeCommand(CloseFilterPanelOperation.id);
}
async function openFilterBy(nextFilterBy: FilterBy) {
const changed = await commandService.executeCommand(ChangeFilterByOperation.id, { filterBy: nextFilterBy });
if (changed) {
setDetailOpen(true);
setDrawerSnap('expanded');
}
}
async function clearFilter() {
await filterByModel?.clear();
await closePanel();
}
async function applyFilter() {
await filterByModel?.apply();
await closePanel();
}
function goBack() {
if (detailOpen) {
setDetailOpen(false);
setDrawerSnap('compact');
return;
}
closePanel().catch(() => undefined);
}
function changeColumn(offset: number) {
const nextCol = col + offset;
if (!filterRange || nextCol < filterRange.startColumn || nextCol > filterRange.endColumn) {
return;
}
panelService.setupCol(panelService.filterModel!, nextCol);
}
return (
<div ref={layerRef} className="univer-pointer-events-none univer-fixed univer-inset-0 univer-z-[1100]">
<MobileDrawer
layerRef={layerRef}
componentName="mobile-sheets-filter-drawer"
snap={drawerSnap}
expandLabel={localeService.t<LocaleKey>('sheets-filter-ui.panel.mobile.resize')}
collapseLabel={localeService.t<LocaleKey>('sheets-filter-ui.panel.mobile.resize')}
onSnapChange={setDrawerSnap}
onClose={() => closePanel().catch(() => undefined)}
role="dialog"
ariaLabel={title}
panelClassName="univer-pointer-events-auto"
contentClassName="univer-min-h-0 univer-px-4 univer-pb-4"
header={(
<div
className="
univer-grid univer-h-12 univer-flex-1 univer-grid-cols-[56px_minmax(0,1fr)_56px]
univer-items-center univer-px-2
"
>
<button
type="button"
aria-label={localeService.t<LocaleKey>('sheets-filter-ui.panel.mobile.back')}
className={`
${resetButtonClassName}
univer-flex univer-size-12 univer-items-center univer-justify-center
`}
onClick={goBack}
>
<MoreLeftIcon className="univer-size-6" />
</button>
<div className="univer-truncate univer-text-center univer-text-base univer-font-semibold">
{title}
</div>
</div>
)}
footer={detailOpen
? (
<div
className="
univer-shrink-0 univer-border-0 univer-border-t univer-border-solid univer-border-gray-200
univer-p-4
dark:!univer-border-gray-700
"
>
<MobileActionRowGroup>
<Button onClick={() => closePanel().catch(() => undefined)}>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.cancel')}
</Button>
<Button
variant="primary"
disabled={!canApply || !filterByModel}
onClick={() => applyFilter().catch(() => undefined)}
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.confirm')}
</Button>
</MobileActionRowGroup>
</div>
)
: undefined}
>
{detailOpen
? (
<div className="univer-flex univer-min-h-full univer-flex-col">
<div
className="
univer-flex univer-min-h-14 univer-items-center univer-justify-between univer-px-2
"
>
<span className="univer-text-base univer-font-medium">
{localeService.t<LocaleKey>(
'sheets-filter-ui.panel.mobile.column',
Tools.chatAtABC(col)
)}
</span>
<div className="univer-flex univer-gap-2">
<Button
disabled={!filterRange || col <= filterRange.startColumn}
onClick={() => changeColumn(-1)}
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.mobile.previous-column')}
</Button>
<Button
disabled={!filterRange || col >= filterRange.endColumn}
onClick={() => changeColumn(1)}
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.mobile.next-column')}
</Button>
</div>
</div>
<div className="univer-min-h-0 univer-flex-1">
{filterBy === FilterBy.VALUES && filterByModel
? <MobileFilterByValue model={filterByModel as ByValuesModel} />
: null}
{filterBy === FilterBy.COLORS && filterByModel
? <MobileFilterByColor model={filterByModel as ByColorsModel} />
: null}
{filterBy === FilterBy.CONDITIONS && filterByModel
? <MobileFilterByCondition model={filterByModel as ByConditionsModel} />
: null}
</div>
</div>
)
: (
<div className="univer-grid univer-gap-4">
<div
className="
univer-overflow-hidden univer-rounded-xl univer-bg-gray-0
dark:!univer-bg-gray-800
"
>
{FILTER_BY_OPTIONS.map((option) => {
const label = localeService.t<LocaleKey>(option.label);
return (
<MobileActionRow
key={option.value}
title={label}
aria-label={label}
className="univer-rounded-none"
trailing={<MoreRightIcon className="univer-size-5 univer-text-gray-400" />}
onClick={() => openFilterBy(option.value).catch(() => undefined)}
/>
);
})}
</div>
<MobileFilterSyncSwitch />
<Button
size="large"
disabled={!hasCriteria || !filterByModel}
onClick={() => clearFilter().catch(() => undefined)}
>
{localeService.t<LocaleKey>('sheets-filter-ui.panel.clear-filter')}
</Button>
</div>
)}
</MobileDrawer>
</div>
);
}

View File

@@ -0,0 +1,272 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IDisposable, IRange, Workbook } from '@univerjs/core';
import type { IRenderContext, IRenderModule, SpreadsheetSkeleton } from '@univerjs/engine-render';
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
import type { FilterModel } from '@univerjs/sheets-filter';
import type { ISheetsFilterButtonShapeProps } from '../widgets/filter-button.shape';
import {
CommandType,
fromCallback,
ICommandService,
Inject,
Injector,
InterceptorEffectEnum,
RxDisposable,
ThemeService,
VerticalAlign,
} from '@univerjs/core';
import {
attachSelectionWithCoord,
INTERCEPTOR_POINT,
SetRangeValuesMutation,
SheetInterceptorService,
} from '@univerjs/sheets';
import { FILTER_MUTATIONS, SheetsFilterService } from '@univerjs/sheets-filter';
import {
getCoordByCell,
ISheetSelectionRenderService,
SelectionControl,
SheetSkeletonManagerService,
} from '@univerjs/sheets-ui';
import { filter, map, of, startWith, switchMap, takeUntil, throttleTime } from 'rxjs';
import { FILTER_ICON_PADDING, FILTER_ICON_SIZE, SheetsFilterButtonShape } from '../widgets/filter-button.shape';
const DEFAULT_Z_INDEX = 1000;
const SHEETS_FILTER_BUTTON_Z_INDEX = 5000;
interface ISheetsFilterRenderParams {
unitId: string;
worksheetId: string;
filterModel?: FilterModel;
range?: IRange;
skeleton: SpreadsheetSkeleton;
}
function computeIconTop(
startY: number,
endY: number,
cellHeight: number,
verticalAlign?: VerticalAlign
) {
switch (verticalAlign) {
case VerticalAlign.TOP:
return startY + FILTER_ICON_PADDING;
case VerticalAlign.MIDDLE:
return startY + Math.max(0, (cellHeight - FILTER_ICON_SIZE) / 2);
case VerticalAlign.BOTTOM:
default:
return endY - FILTER_ICON_SIZE - FILTER_ICON_PADDING;
}
};
/**
* Show selected range in filter.
*/
export class SheetsFilterRenderController extends RxDisposable implements IRenderModule {
private _currentRenderParams: ISheetsFilterRenderParams | null = null;
private _filterRangeShape: SelectionControl | null = null;
private _buttonRenderDisposable: IDisposable | null = null;
private _filterButtonShapes: SheetsFilterButtonShape[] = [];
constructor(
private readonly _context: IRenderContext<Workbook>,
@Inject(Injector) private readonly _injector: Injector,
@Inject(SheetSkeletonManagerService) private readonly _sheetSkeletonManagerService: SheetSkeletonManagerService,
@Inject(SheetsFilterService) private readonly _sheetsFilterService: SheetsFilterService,
@Inject(ThemeService) private readonly _themeService: ThemeService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@ICommandService private readonly _commandService: ICommandService,
@ISheetSelectionRenderService private readonly _selectionRenderService: ISheetSelectionRenderService
) {
super();
this._initRenderer();
}
override dispose(): void {
super.dispose();
this._disposeRendering();
}
private _initRenderer(): void {
this.disposeWithMe(this._themeService.currentTheme$.subscribe(() => {
this._refreshRendering(this._currentRenderParams);
}));
// Subscribe to skeleton change and filter model change.
this._sheetSkeletonManagerService.currentSkeleton$.pipe(
switchMap((skeletonParams) => {
if (!skeletonParams) return of(null);
const { unit: workbook, unitId } = this._context;
const worksheetId = workbook.getActiveSheet()?.getSheetId() || '';
const filterModel = this._sheetsFilterService.getFilterModel(unitId, worksheetId) ?? undefined;
const getParams = (): ISheetsFilterRenderParams => ({
unitId,
worksheetId,
filterModel,
range: filterModel?.getRange(),
skeleton: skeletonParams.skeleton,
});
return fromCallback(this._commandService.onCommandExecuted.bind(this._commandService)).pipe(
filter(([command]) =>
command.type === CommandType.MUTATION
&& (command.params as Partial<ISheetCommandSharedParams>)?.unitId === workbook.getUnitId()
&& (FILTER_MUTATIONS.has(command.id) || command.id === SetRangeValuesMutation.id)
),
throttleTime(20, undefined, { leading: false, trailing: true }),
map(getParams),
startWith(getParams()) // must trigger once
);
}),
takeUntil(this.dispose$)
).subscribe((renderParams) => {
this._currentRenderParams = renderParams;
this._refreshRendering(renderParams);
});
}
private _refreshRendering(renderParams: ISheetsFilterRenderParams | null): void {
this._disposeRendering();
if (!renderParams || !renderParams.range) {
return;
}
this._renderRange(renderParams.range, renderParams.skeleton);
this._renderButtons(renderParams as Required<ISheetsFilterRenderParams>);
}
private _renderRange(range: IRange, skeleton: SpreadsheetSkeleton): void {
const { scene } = this._context;
const { rowHeaderWidth, columnHeaderHeight } = skeleton;
const filterRangeShape = this._filterRangeShape = new SelectionControl(
scene,
DEFAULT_Z_INDEX,
this._themeService,
{
rowHeaderWidth,
columnHeaderHeight,
enableAutoFill: false,
highlightHeader: false,
}
);
const selectionWithStyle = {
range,
primary: null,
style: { fill: 'rgba(0, 0, 0, 0.0)' },
};
const selectionWithCoord = attachSelectionWithCoord(selectionWithStyle, skeleton);
filterRangeShape.updateRangeBySelectionWithCoord(selectionWithCoord);
filterRangeShape.setEvent(false);
scene.makeDirty(true);
}
private _renderButtons(params: Required<ISheetsFilterRenderParams>): void {
const { range, filterModel, unitId, skeleton, worksheetId } = params;
const { unit: workbook, scene } = this._context;
const worksheet = workbook.getSheetBySheetId(worksheetId);
if (!worksheet) {
return;
}
// Push cell contents to leave space for the filter buttons.
this._interceptCellContent(unitId, worksheetId, params.range);
// Create filter button shapes.
const { startColumn, endColumn, startRow } = range;
for (let col = startColumn; col <= endColumn; col++) {
const key = `sheets-filter-button-${col}`;
const startPosition = getCoordByCell(startRow, col, scene, skeleton);
const cellStyle = worksheet.getComposedCellStyle(startRow, col);
const verticalAlign = cellStyle?.vt || VerticalAlign.BOTTOM;
const { startX, startY, endX, endY } = startPosition;
// Too little space to draw the button, just ignore it.
const cellWidth = endX - startX;
const cellHeight = endY - startY;
if (cellHeight <= FILTER_ICON_PADDING || cellWidth <= FILTER_ICON_PADDING) {
continue;
}
// In other cases we need to draw the button, and we need to take care of the position and clipping.
const hasCriteria = !!filterModel.getFilterColumn(col);
const iconStartX = endX - FILTER_ICON_SIZE - FILTER_ICON_PADDING;
const iconStartY = computeIconTop(startY, endY, cellHeight, verticalAlign);
const props: ISheetsFilterButtonShapeProps = {
left: iconStartX,
top: iconStartY,
height: FILTER_ICON_SIZE,
width: FILTER_ICON_SIZE,
zIndex: SHEETS_FILTER_BUTTON_Z_INDEX,
cellHeight,
cellWidth,
filterParams: { unitId, subUnitId: worksheetId, col, hasCriteria },
};
const buttonShape = this._injector.createInstance(SheetsFilterButtonShape, key, props);
this._filterButtonShapes.push(buttonShape);
}
scene.addObjects(this._filterButtonShapes);
scene.makeDirty();
}
private _interceptCellContent(workbookId: string, worksheetId: string, range: IRange): void {
const { startRow, startColumn, endColumn } = range;
this._buttonRenderDisposable = this._sheetInterceptorService.intercept(INTERCEPTOR_POINT.CELL_CONTENT, {
effect: InterceptorEffectEnum.Style,
handler: (cell, pos, next) => {
const { row, col, unitId, subUnitId } = pos;
if (
unitId !== workbookId ||
subUnitId !== worksheetId ||
row !== startRow ||
col < startColumn ||
col > endColumn
) {
return next(cell);
}
if (!cell || cell === pos.rawData) {
cell = { ...pos.rawData };
}
cell.fontRenderExtension = {
...cell?.fontRenderExtension,
rightOffset: FILTER_ICON_SIZE,
};
return next(cell);
},
priority: 10,
});
}
private _disposeRendering(): void {
this._filterRangeShape?.dispose();
this._filterButtonShapes.forEach((s) => s.dispose());
this._buttonRenderDisposable?.dispose();
this._filterRangeShape = null;
this._buttonRenderDisposable = null;
this._filterButtonShapes = [];
}
}

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 { UniverRenderingContext2D } from '@univerjs/engine-render';
import { Rect } from '@univerjs/engine-render';
const BUTTON_VIEWPORT = 16;
// This path is deprecated. We need to get rounded edge of the stroked line.
// export const FILTER_BUTTON_HAS_CRITERIA = new Path2D('M3 4H13 M4.5 8H11.5 M6 12H10');
export const FILTER_BUTTON_EMPTY = new Path2D('M3.30363 3C2.79117 3 2.51457 3.60097 2.84788 3.99024L6.8 8.60593V12.5662C6.8 12.7184 6.8864 12.8575 7.02289 12.9249L8.76717 13.7863C8.96655 13.8847 9.2 13.7396 9.2 13.5173V8.60593L13.1521 3.99024C13.4854 3.60097 13.2088 3 12.6964 3H3.30363Z');
export class FilterButton {
static drawNoCriteria(ctx: UniverRenderingContext2D, size: number, fgColor: string, bgColor: string): void {
ctx.save();
Rect.drawWith(ctx, {
radius: 2,
width: BUTTON_VIEWPORT,
height: BUTTON_VIEWPORT,
fill: bgColor,
});
ctx.lineCap = 'square';
ctx.strokeStyle = fgColor;
ctx.scale(size / BUTTON_VIEWPORT, size / BUTTON_VIEWPORT);
ctx.beginPath();
ctx.lineWidth = 1;
ctx.lineCap = 'round';
ctx.moveTo(3, 4);
ctx.lineTo(13, 4);
ctx.moveTo(4.5, 8);
ctx.lineTo(11.5, 8);
ctx.moveTo(6, 12);
ctx.lineTo(10, 12);
ctx.stroke();
ctx.restore();
}
static drawHasCriteria(ctx: UniverRenderingContext2D, size: number, fgColor: string, bgColor: string): void {
ctx.save();
Rect.drawWith(ctx, {
radius: 2,
width: BUTTON_VIEWPORT,
height: BUTTON_VIEWPORT,
fill: bgColor,
});
ctx.scale(size / BUTTON_VIEWPORT, size / BUTTON_VIEWPORT);
ctx.fillStyle = fgColor;
ctx.fill(FILTER_BUTTON_EMPTY);
ctx.restore();
}
}

View File

@@ -0,0 +1,139 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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 { IMouseEvent, IPointerEvent, IShapeProps, UniverRenderingContext2D } from '@univerjs/engine-render';
import type { IOpenFilterPanelOperationParams } from '../../commands/operations/sheets-filter.operation';
import { ICommandService, IContextService, Inject, ThemeService } from '@univerjs/core';
import { Shape } from '@univerjs/engine-render';
import { FILTER_PANEL_OPENED_KEY, OpenFilterPanelOperation } from '../../commands/operations/sheets-filter.operation';
import { FilterButton } from './drawings';
export const FILTER_ICON_SIZE = 16;
export const FILTER_ICON_PADDING = 1;
export interface ISheetsFilterButtonShapeProps extends IShapeProps {
cellWidth: number;
cellHeight: number;
filterParams: { col: number; unitId: string; subUnitId: string; hasCriteria: boolean };
}
/**
* The widget to render a filter button on canvas.
*/
export class SheetsFilterButtonShape extends Shape<ISheetsFilterButtonShapeProps> {
private _cellWidth: number = 0;
private _cellHeight: number = 0;
private _filterParams?: { col: number; unitId: string; subUnitId: string; hasCriteria: boolean };
private _hovered = false;
constructor(
key: string,
props: ISheetsFilterButtonShapeProps,
@IContextService private readonly _contextService: IContextService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(ThemeService) private readonly _themeService: ThemeService
) {
super(key, props);
this.setShapeProps(props);
// Here we need to make sure that the event is on the rectangle range.
this.onPointerDown$.subscribeEvent((evt) => this.onPointerDown(evt));
this.onPointerEnter$.subscribeEvent(() => this.onPointerEnter());
this.onPointerLeave$.subscribeEvent(() => this.onPointerLeave());
}
setShapeProps(props: Partial<ISheetsFilterButtonShapeProps>): void {
if (typeof props.cellHeight !== 'undefined') {
this._cellHeight = props.cellHeight;
}
if (typeof props.cellWidth !== 'undefined') {
this._cellWidth = props.cellWidth;
}
if (typeof props.filterParams !== 'undefined') {
this._filterParams = props.filterParams;
}
this.transformByState({
width: props.width!,
height: props.height!,
});
}
protected override _draw(ctx: UniverRenderingContext2D): void {
const cellHeight = this._cellHeight;
const cellWidth = this._cellWidth;
const left = FILTER_ICON_SIZE - cellWidth;
const top = FILTER_ICON_SIZE - cellHeight;
ctx.save();
const cellRegion = new Path2D();
cellRegion.rect(left, top, cellWidth, cellHeight);
ctx.clip(cellRegion);
const { hasCriteria } = this._filterParams!;
const fgColor = this._themeService.getColorFromTheme('primary.600');
const bgColor = this._hovered
? this._themeService.getColorFromTheme('gray.50')
: 'rgba(255, 255, 255, 1.0)';
if (hasCriteria) {
FilterButton.drawHasCriteria(ctx, FILTER_ICON_SIZE, fgColor, bgColor);
} else {
FilterButton.drawNoCriteria(ctx, FILTER_ICON_SIZE, fgColor, bgColor);
}
ctx.restore();
}
onPointerDown(evt: IPointerEvent | IMouseEvent): void {
// Right click not trigger this event.
if (evt.button === 2) {
return;
}
const { col, unitId, subUnitId } = this._filterParams!;
const opened = this._contextService.getContextValue(FILTER_PANEL_OPENED_KEY);
if (opened || !this._commandService.hasCommand(OpenFilterPanelOperation.id)) {
return;
}
setTimeout(() => {
this._commandService.executeCommand(OpenFilterPanelOperation.id, {
unitId,
subUnitId,
col,
} as IOpenFilterPanelOperationParams);
}, 200);
}
onPointerEnter(): void {
this._hovered = true;
this.makeDirty(true);
}
onPointerLeave(): void {
this._hovered = false;
this.makeDirty(true);
}
}

View File

@@ -0,0 +1,45 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DesktopLogService, ILogService, Injector, IUniverInstanceService, LocaleService } from '@univerjs/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { SheetsGenerateFilterValuesService } from '../generate-filter-values.service';
describe('SheetsGenerateFilterValuesService', () => {
let service: SheetsGenerateFilterValuesService;
beforeEach(() => {
const injector = new Injector();
injector.add([LocaleService]);
injector.add([ILogService, { useClass: DesktopLogService }]);
injector.add([IUniverInstanceService, { useValue: { getUnit: () => null } as unknown as IUniverInstanceService }]);
injector.add([SheetsGenerateFilterValuesService]);
service = injector.get(SheetsGenerateFilterValuesService);
});
it('returns no filter value tree when the workbook or worksheet is unavailable', async () => {
await expect(service.getFilterValues({
unitId: 'missing-book',
subUnitId: 'sheet-1',
filteredOutRowsByOtherColumns: [],
filterColumn: null,
filters: false,
blankChecked: true,
iterateRange: { startRow: 0, endRow: 3, startColumn: 0, endColumn: 0 },
alreadyChecked: [],
})).resolves.toEqual([]);
});
});

View File

@@ -0,0 +1,362 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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, Nullable, Styles, Workbook, Worksheet } from '@univerjs/core';
import type { FilterColumn } from '@univerjs/sheets-filter';
import type { LocaleKey } from '../locale/types';
import type { IFilterByValueItem, IFilterByValueWithTreeItem } from '../services/sheets-filter-panel.service';
import {
createIdentifier,
Disposable,
extractPureTextFromCell,
ILogService,
Inject,
isNumeric,
IUniverInstanceService,
LocaleService,
numfmt,
} from '@univerjs/core';
import { FilterBy } from '@univerjs/sheets-filter';
export interface ISheetsGenerateFilterValuesService {
getFilterValues(params: {
unitId: string;
subUnitId: string;
filteredOutRowsByOtherColumns: number[];
filterColumn: Nullable<FilterColumn>;
filters: boolean;
blankChecked: boolean;
iterateRange: IRange;
alreadyChecked: string[];
}): Promise<{
filterTreeItems: IFilterByValueWithTreeItem[];
filterTreeMapCache: Map<string, string[]>;
}>;
}
export const SHEETS_GENERATE_FILTER_VALUES_SERVICE_NAME = 'sheets-filter.generate-filter-values.service';
export const ISheetsGenerateFilterValuesService = createIdentifier<ISheetsGenerateFilterValuesService>(SHEETS_GENERATE_FILTER_VALUES_SERVICE_NAME);
export class SheetsGenerateFilterValuesService extends Disposable {
constructor(
@Inject(LocaleService) private readonly _localeService: LocaleService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ILogService private readonly _logService: ILogService
) {
super();
}
async getFilterValues(params: {
unitId: string;
subUnitId: string;
filteredOutRowsByOtherColumns: number[];
filterColumn: Nullable<FilterColumn>;
filters: boolean;
blankChecked: boolean;
iterateRange: IRange;
alreadyChecked: string[];
}) {
const { unitId, subUnitId, filteredOutRowsByOtherColumns, filterColumn, filters, blankChecked, iterateRange, alreadyChecked } = params;
const workbook = this._univerInstanceService.getUnit<Workbook>(unitId);
const worksheet = this._univerInstanceService.getUnit<Workbook>(unitId)?.getSheetBySheetId(subUnitId);
if (!workbook || !worksheet) return [];
this._logService.debug('[SheetsGenerateFilterValuesService]', 'getFilterValues for', { unitId, subUnitId });
return getFilterTreeByValueItems(
filters,
this._localeService,
iterateRange,
worksheet,
new Set(filteredOutRowsByOtherColumns),
filterColumn,
new Set(alreadyChecked.map(String)),
blankChecked,
workbook.getStyles()
);
}
}
export function getFilterByValueItems(
filters: boolean,
blankChecked: boolean,
localeService: LocaleService,
iterateRange: IRange,
worksheet: Worksheet,
alreadyChecked: Set<string>,
filteredOutRowsByOtherColumns: Set<number>
): IFilterByValueItem[] {
const items: IFilterByValueItem[] = [];
const itemsByKey: Record<string, IFilterByValueItem> = {};
let index = 0;
let emptyCount = 0;
for (const cell of worksheet.iterateByColumn(iterateRange, false, false)) { // iterate and do not skip empty cells
const { row, rowSpan = 1 } = cell;
let rowIndex = 0;
while (rowIndex < rowSpan) {
const targetRow = row + rowIndex;
if (filteredOutRowsByOtherColumns.has(targetRow)) {
rowIndex++;
continue;
}
const value = cell?.value ? extractPureTextFromCell(cell.value) : '';
if (!value) {
emptyCount += 1;
rowIndex += rowSpan;
continue;
}
if (!itemsByKey[value]) {
const item: IFilterByValueItem = {
value,
checked: alreadyChecked.size ? alreadyChecked.has(value) : !blankChecked,
count: 1,
index,
isEmpty: false,
};
itemsByKey[value] = item;
items.push(item);
} else {
itemsByKey[value].count++;
}
rowIndex++;
}
index++;
}
const initialBlankChecked = filters ? blankChecked : true;
if (emptyCount > 0) {
const item: IFilterByValueItem = {
value: localeService.t<LocaleKey>('sheets-filter-ui.panel.empty'),
checked: initialBlankChecked,
count: emptyCount,
index,
isEmpty: true,
};
items.push(item);
}
return items;
}
// eslint-disable-next-line max-lines-per-function, complexity
export function getFilterTreeByValueItems(
filters: boolean,
localeService: LocaleService,
iterateRange: IRange,
worksheet: Worksheet,
filteredOutRowsByOtherColumns: Set<number>,
filterColumn: Nullable<FilterColumn>,
alreadyChecked: Set<string>,
blankChecked: boolean,
styles: Styles
): {
filterTreeItems: IFilterByValueWithTreeItem[];
filterTreeMapCache: Map<string, string[]>;
} {
const items: Map<string, IFilterByValueWithTreeItem> = new Map();
const treeMap: Map<string, string[]> = new Map();
const DefaultPattern = 'yyyy-mm-dd';
const EmptyKey = 'empty';
// filter column is the column that is currently being filtered.
// If the filter column is filter by colors or conditions, it means that the tab is switched from filter by colors or conditions to filter by values,
// so if the filter column has filtered out rows, we need clear all values checked status, refer to excel and wps.
const isNeedClearCheckedStatus = !filters
&& (filterColumn?.filterBy === FilterBy.COLORS || filterColumn?.filterBy === FilterBy.CONDITIONS)
&& filterColumn.filteredOutRows?.size;
let emptyCount = 0;
for (const cell of worksheet.iterateByColumn(iterateRange, false, false)) { // iterate and do not skip empty cells
const { row, rowSpan = 1 } = cell;
let rowIndex = 0;
while (rowIndex < rowSpan) {
const targetRow = row + rowIndex;
if (filteredOutRowsByOtherColumns.has(targetRow)) {
rowIndex++;
continue;
}
const value = cell?.value ? extractPureTextFromCell(cell.value) : '';
if (!value) {
emptyCount += 1;
rowIndex += rowSpan;
continue;
}
const fmtStr = (cell.value?.v && !cell.value.p) ? styles.get(cell.value?.s)?.n?.pattern : '';
const isDateValue = fmtStr && numfmt.getFormatInfo(fmtStr).isDate;
// The date formatted value must include date part (year/month/day) to be treated as date grouping tree item.
let isIncludeDatePart = false;
if (isDateValue) {
const { year, month, day } = numfmt.getFormatDateInfo(fmtStr);
isIncludeDatePart = year || month || day;
}
if (fmtStr && isDateValue && isIncludeDatePart) {
const originValue = worksheet.getCellRaw(cell.row, cell.col)?.v;
if (!originValue) {
rowIndex++;
continue;
}
const valueParsedByDefaultPattern = numfmt.format(DefaultPattern, Number(originValue));
const [year, month, day] = valueParsedByDefaultPattern.split('-').map(Number);
let yearItem = items.get(`${year}`);
if (!yearItem) {
yearItem = {
title: `${year}`,
key: `${year}`,
children: [],
count: 0,
leaf: false,
checked: false,
};
items.set(`${year}`, yearItem);
treeMap.set(`${year}`, [`${year}`]);
}
let monthItem = yearItem.children?.find((item) => item.key === `${year}-${month}`);
if (!monthItem) {
monthItem = {
title: localeService.t(`sheets-filter-ui.date.${month}`),
key: `${year}-${month}`,
children: [],
count: 0,
leaf: false,
checked: false,
};
yearItem.children?.push(monthItem);
treeMap.set(`${year}-${month}`, [`${year}`, `${year}-${month}`]);
}
const dayItem = monthItem?.children?.find((item) => item.key === `${year}-${month}-${day}`);
if (!dayItem) {
monthItem.children?.push({
title: `${day}`,
key: `${year}-${month}-${day}`,
count: 1,
originValues: new Set([value]),
leaf: true,
checked: isNeedClearCheckedStatus
? false
: alreadyChecked.size
? alreadyChecked.has(value)
: !blankChecked,
});
monthItem.count++;
yearItem.count++;
treeMap.set(`${year}-${month}-${day}`, [`${year}`, `${year}-${month}`, `${year}-${month}-${day}`]);
} else {
dayItem.originValues!.add(value);
dayItem.count++;
monthItem.count++;
yearItem.count++;
}
} else {
const key = value;
let item = items.get(key);
if (!item) {
item = {
title: value,
leaf: true,
checked: isNeedClearCheckedStatus
? false
: alreadyChecked.size
? alreadyChecked.has(value)
: !blankChecked,
key,
count: 1,
};
items.set(key, item);
treeMap.set(key, [key]);
} else {
item.count++;
}
}
rowIndex++;
}
}
const initialBlankChecked = isNeedClearCheckedStatus
? false
: filters
? blankChecked
: true;
if (emptyCount > 0) {
const item: IFilterByValueWithTreeItem = {
title: localeService.t<LocaleKey>('sheets-filter-ui.panel.empty'),
count: emptyCount,
leaf: true,
checked: initialBlankChecked,
key: EmptyKey,
};
items.set('empty', item);
treeMap.set('empty', [EmptyKey]);
}
return {
filterTreeItems: generateFilterTreeBySort(Array.from(items.values())),
filterTreeMapCache: treeMap,
};
}
function generateFilterTreeBySort(tree: IFilterByValueWithTreeItem[]) {
return Array.from(tree).sort((a, b) => {
if (a.children && !b.children) return -1;
if (!a.children && b.children) return 1;
return compare(a.title, b.title);
}).map((yearItem) => {
if (yearItem.children) {
yearItem.children.sort((a, b) => {
const monthA = Number.parseInt(a.key.split('-')[1], 10);
const monthB = Number.parseInt(b.key.split('-')[1], 10);
return monthA - monthB;
}).forEach((monthItem) => {
if (monthItem.children) {
monthItem.children.sort((a, b) => {
const dayA = Number.parseInt(a.key.split('-')[2], 10);
const dayB = Number.parseInt(b.key.split('-')[2], 10);
return dayA - dayB;
});
}
});
}
return yearItem;
});
}
function compare(strA: string, strB: string) {
const aIsNumeric = isNumeric(strA);
const bIsNumeric = isNumeric(strB);
if (aIsNumeric && bIsNumeric) {
return Number.parseFloat(strA) - Number.parseFloat(strB);
} else if (aIsNumeric && !bIsNumeric) {
return -1;
} else if (!aIsNumeric && bIsNumeric) {
return 1;
} else {
return strA.localeCompare(strB);
}
}

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 { Dependency } from '@univerjs/core';
import type { IUniverSheetsFilterUIConfig } from '../config/config';
import { IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { fromModule, IRPCChannelService } from '@univerjs/rpc';
import pkg from '../../package.json';
import { defaultPluginConfig, SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY } from '../config/config';
import {
ISheetsGenerateFilterValuesService,
SHEETS_GENERATE_FILTER_VALUES_SERVICE_NAME,
SheetsGenerateFilterValuesService,
} from './generate-filter-values.service';
export class UniverSheetsFilterUIWorkerPlugin extends Plugin {
static override type = UniverInstanceType.UNIVER_SHEET;
static override pluginName: string = 'SHEET_FILTER_UI_WORKER_PLUGIN';
static override packageName = pkg.name;
static override version = pkg.version;
constructor(
private readonly _config: Partial<IUniverSheetsFilterUIConfig> = defaultPluginConfig,
@Inject(Injector) protected readonly _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@IRPCChannelService private readonly _rpcChannelService: IRPCChannelService
) {
super();
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(SHEETS_FILTER_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting() {
([
[ISheetsGenerateFilterValuesService, { useClass: SheetsGenerateFilterValuesService }],
] as Dependency[]).forEach((d) => this._injector.add(d));
}
override onReady(): void {
this._rpcChannelService.registerChannel(
SHEETS_GENERATE_FILTER_VALUES_SERVICE_NAME,
fromModule(this._injector.get(ISheetsGenerateFilterValuesService))
);
}
}