feat(core): add number property filter and group by (#12483)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Enhanced filtering options for number properties with multiple comparison operators and empty/not empty checks.
  - Added support for grouping and ordering by number properties.
  - Introduced new UI components for number property filtering and group headers.
  - Improved draft completion behavior for text and tags filters using empty/not empty methods.

- **Bug Fixes**
  - Improved consistency in draft completion behavior when filtering by text or tags using empty/not empty methods.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-23 08:54:22 +00:00
parent 4b9313ce37
commit 797646442d
10 changed files with 343 additions and 6 deletions
@@ -70,7 +70,12 @@ import {
JournalGroupHeader,
JournalValue,
} from './journal';
import { NumberDocListProperty, NumberValue } from './number';
import {
NumberDocListProperty,
NumberFilterValue,
NumberGroupHeader,
NumberValue,
} from './number';
import {
PageWidthDocListProperty,
PageWidthFilterValue,
@@ -158,8 +163,23 @@ export const WorkspacePropertyTypes = {
value: NumberValue,
name: 'com.affine.page-properties.property.number',
description: 'com.affine.page-properties.property.number.tooltips',
filterMethod: {
'<': '<',
'=': '=',
'≠': '≠',
'≥': '≥',
'≤': '≤',
'>': '>',
'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty',
},
allowInGroupBy: true,
allowInOrderBy: true,
filterValue: NumberFilterValue,
defaultFilter: { method: 'is-not-empty' },
showInDocList: 'stack',
docListProperty: NumberDocListProperty,
groupHeader: NumberGroupHeader,
},
checkbox: {
icon: CheckBoxCheckLinearIcon,
@@ -1,14 +1,20 @@
import { PropertyValue } from '@affine/component';
import { Input, Menu, type MenuRef, PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n';
import { NumberIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import {
type ChangeEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property';
import type { GroupHeaderProps } from '../explorer/types';
import type { PropertyValueProps } from '../properties/types';
import * as styles from './number.css';
@@ -58,6 +64,109 @@ export const NumberValue = ({
);
};
export const NumberFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => {
const [tempValue, setTempValue] = useState(filter.value || '');
const [valueMenuOpen, setValueMenuOpen] = useState(false);
const menuRef = useRef<MenuRef>(null);
const t = useI18n();
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
useEffect(() => {
// update temp value with new filter value
setTempValue(filter.value || '');
}, [filter.value]);
const submitTempValue = useCallback(() => {
if (tempValue !== (filter.value || '')) {
onChange?.({
...filter,
value: tempValue,
});
}
}, [filter, onChange, tempValue]);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Escape') return;
submitTempValue();
setValueMenuOpen(false);
onDraftCompleted?.();
},
[submitTempValue, onDraftCompleted]
);
const handleInputEnter = useCallback(() => {
submitTempValue();
setValueMenuOpen(false);
onDraftCompleted?.();
}, [submitTempValue, onDraftCompleted]);
useEffect(() => {
if (
isDraft &&
(filter.method === 'is-not-empty' || filter.method === 'is-empty')
) {
onDraftCompleted?.();
}
}, [isDraft, filter.method, onDraftCompleted]);
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<Menu
ref={menuRef}
rootOptions={{
open: valueMenuOpen,
onOpenChange: setValueMenuOpen,
onClose: onDraftCompleted,
}}
contentOptions={{
onPointerDownOutside: submitTempValue,
sideOffset: -28,
}}
items={
<Input
inputStyle={{
fontSize: cssVar('fontBase'),
}}
type="number"
inputMode="decimal"
autoFocus
autoSelect
value={tempValue}
onChange={value => {
setTempValue(value);
}}
onEnter={handleInputEnter}
onKeyDown={handleInputKeyDown}
style={{ height: 34, borderRadius: 4 }}
/>
}
>
{filter.value ? (
<span>{filter.value}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
) : null;
};
export const NumberDocListProperty = ({ value }: { value: number }) => {
if (value !== 0 && !value) {
return null;
@@ -65,3 +174,13 @@ export const NumberDocListProperty = ({ value }: { value: number }) => {
return <StackProperty icon={<NumberIcon />}>{value}</StackProperty>;
};
export const NumberGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => {
const t = useI18n();
const number = groupId || t['com.affine.filter.empty']();
return (
<PlainTextDocGroupHeader groupId={groupId} docCount={docCount}>
{number}
</PlainTextDocGroupHeader>
);
};
@@ -98,6 +98,16 @@ export const TagsFilterValue = ({
},
[filter, onChange, selectedTags]
);
useEffect(() => {
if (
isDraft &&
(filter.method === 'is-not-empty' || filter.method === 'is-empty')
) {
onDraftCompleted?.();
}
}, [isDraft, filter.method, onDraftCompleted]);
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<WorkspaceTagsInlineEditor
placeholder={
@@ -216,14 +216,25 @@ export const TextFilterValue = ({
if (e.key !== 'Escape') return;
submitTempValue();
setValueMenuOpen(false);
onDraftCompleted?.();
},
[submitTempValue]
[submitTempValue, onDraftCompleted]
);
const handleInputEnter = useCallback(() => {
submitTempValue();
setValueMenuOpen(false);
}, [submitTempValue]);
onDraftCompleted?.();
}, [submitTempValue, onDraftCompleted]);
useEffect(() => {
if (
isDraft &&
(filter.method === 'is-not-empty' || filter.method === 'is-empty')
) {
onDraftCompleted?.();
}
}, [isDraft, filter.method, onDraftCompleted]);
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<Menu
@@ -0,0 +1,97 @@
import type { DocsService } from '@affine/core/modules/doc';
import type { WorkspacePropertyFilter } from '@affine/core/modules/workspace-property';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { FilterProvider } from '../../provider';
import type { FilterParams } from '../../types';
export class NumberPropertyFilterProvider
extends Service
implements FilterProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
filter$(params: FilterParams): Observable<Set<string>> {
const method = params.method as WorkspacePropertyFilter<'number'>;
const values$ = this.docsService.propertyValues$('custom:' + params.key);
const filterValue = Number(params.value);
if (method === 'is-not-empty') {
return values$.pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value !== undefined && value !== null && value !== '') {
match.add(id);
}
}
return match;
})
);
} else if (method === 'is-empty') {
return values$.pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
if (value === undefined || value === null || value === '') {
match.add(id);
}
}
return match;
})
);
} else if (
method === '=' ||
method === '≠' ||
method === '>' ||
method === '<' ||
method === '≥' ||
method === '≤'
) {
return values$.pipe(
map(o => {
const match = new Set<string>();
for (const [id, value] of o) {
const numValue = Number(value);
switch (method) {
case '=':
if (Math.abs(numValue - filterValue) < Number.EPSILON) {
match.add(id);
}
break;
case '≠':
if (Math.abs(numValue - filterValue) >= Number.EPSILON) {
match.add(id);
}
break;
case '>':
if (numValue > filterValue) {
match.add(id);
}
break;
case '<':
if (numValue < filterValue) {
match.add(id);
}
break;
case '≥':
if (numValue >= filterValue) {
match.add(id);
}
break;
case '≤':
if (numValue <= filterValue) {
match.add(id);
}
break;
}
}
return match;
})
);
}
throw new Error(`Unsupported method: ${method}`);
}
}
@@ -0,0 +1,38 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { GroupByProvider } from '../../provider';
import type { GroupByParams } from '../../types';
export class NumberPropertyGroupByProvider
extends Service
implements GroupByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
groupBy$(
_items$: Observable<Set<string>>,
params: GroupByParams
): Observable<Map<string, Set<string>>> {
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
const result = new Map<string, Set<string>>();
for (const [id, value] of o) {
const number = Number(value);
if (Number.isNaN(number)) {
continue;
}
// normalize all number to string
const strValue = String(number);
const set = result.get(strValue) ?? new Set<string>();
set.add(id);
result.set(strValue, set);
}
return result;
})
);
}
}
@@ -0,0 +1,30 @@
import type { DocsService } from '@affine/core/modules/doc';
import { Service } from '@toeverything/infra';
import { map, type Observable } from 'rxjs';
import type { OrderByProvider } from '../../provider';
import type { OrderByParams } from '../../types';
export class NumberPropertyOrderByProvider
extends Service
implements OrderByProvider
{
constructor(private readonly docsService: DocsService) {
super();
}
orderBy$(
_items$: Observable<Set<string>>,
params: OrderByParams
): Observable<string[]> {
const isDesc = params.desc;
return this.docsService.propertyValues$('custom:' + params.key).pipe(
map(o => {
return Array.from(o)
.map(v => [v[0], Number(v[1])])
.filter((i): i is [string, number] => !Number.isNaN(i[1])) // filter NaN value
.sort((a, b) => (a[1] - b[1]) * (isDesc ? -1 : 1))
.map(i => i[0]);
})
);
}
}
@@ -17,6 +17,7 @@ import { EmptyJournalFilterProvider } from './impls/filters/empty-journal';
import { FavoriteFilterProvider } from './impls/filters/favorite';
import { IntegrationTypeFilterProvider } from './impls/filters/integration-type';
import { JournalFilterProvider } from './impls/filters/journal';
import { NumberPropertyFilterProvider } from './impls/filters/number';
import { PageWidthFilterProvider } from './impls/filters/page-width';
import { PropertyFilterProvider } from './impls/filters/property';
import { SharedFilterProvider } from './impls/filters/shared';
@@ -36,6 +37,7 @@ import { DocPrimaryModeGroupByProvider } from './impls/group-by/doc-primary-mode
import { EdgelessThemeGroupByProvider } from './impls/group-by/edgeless-theme';
import { IntegrationTypeGroupByProvider } from './impls/group-by/integration-type';
import { JournalGroupByProvider } from './impls/group-by/journal';
import { NumberPropertyGroupByProvider } from './impls/group-by/number';
import { PageWidthGroupByProvider } from './impls/group-by/page-width';
import { PropertyGroupByProvider } from './impls/group-by/property';
import { SystemGroupByProvider } from './impls/group-by/system';
@@ -52,6 +54,7 @@ import { DocPrimaryModeOrderByProvider } from './impls/order-by/doc-primary-mode
import { EdgelessThemeOrderByProvider } from './impls/order-by/edgeless-theme';
import { IntegrationTypeOrderByProvider } from './impls/order-by/integration-type';
import { JournalOrderByProvider } from './impls/order-by/journal';
import { NumberPropertyOrderByProvider } from './impls/order-by/number';
import { PageWidthOrderByProvider } from './impls/order-by/page-width';
import { PropertyOrderByProvider } from './impls/order-by/property';
import { SystemOrderByProvider } from './impls/order-by/system';
@@ -81,6 +84,9 @@ export function configureCollectionRulesModule(framework: Framework) {
.impl(FilterProvider('property:text'), TextPropertyFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:number'), NumberPropertyFilterProvider, [
DocsService,
])
.impl(FilterProvider('property:tags'), TagsFilterProvider, [
TagService,
DocsService,
@@ -196,6 +202,9 @@ export function configureCollectionRulesModule(framework: Framework) {
.impl(GroupByProvider('property:text'), TextPropertyGroupByProvider, [
DocsService,
])
.impl(GroupByProvider('property:number'), NumberPropertyGroupByProvider, [
DocsService,
])
.impl(
GroupByProvider('property:docPrimaryMode'),
DocPrimaryModeGroupByProvider,
@@ -293,6 +302,9 @@ export function configureCollectionRulesModule(framework: Framework) {
.impl(OrderByProvider('property:text'), TextPropertyOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:number'), NumberPropertyOrderByProvider, [
DocsService,
])
.impl(OrderByProvider('property:date'), DatePropertyOrderByProvider, [
DocsService,
])
@@ -25,7 +25,7 @@ export type WorkspacePropertyTypes = {
filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty';
};
number: {
filter: 'is' | 'is-not' | 'is-not-empty' | 'is-empty';
filter: '=' | '≠' | '>' | '<' | '≥' | '≤' | 'is-not-empty' | 'is-empty';
};
checkbox: {
filter: 'is' | 'is-not';
@@ -5,7 +5,7 @@
"de": 94,
"el-GR": 94,
"en": 100,
"es-AR": 95,
"es-AR": 94,
"es-CL": 96,
"es": 94,
"fa": 94,