diff --git a/packages/frontend/core/src/components/workspace-property-types/index.ts b/packages/frontend/core/src/components/workspace-property-types/index.ts index c807c72f0c..c02e9f2f15 100644 --- a/packages/frontend/core/src/components/workspace-property-types/index.ts +++ b/packages/frontend/core/src/components/workspace-property-types/index.ts @@ -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, diff --git a/packages/frontend/core/src/components/workspace-property-types/number.tsx b/packages/frontend/core/src/components/workspace-property-types/number.tsx index 4c6a1cd9e8..863847495a 100644 --- a/packages/frontend/core/src/components/workspace-property-types/number.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/number.tsx @@ -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(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) => { + 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' ? ( + { + setTempValue(value); + }} + onEnter={handleInputEnter} + onKeyDown={handleInputKeyDown} + style={{ height: 34, borderRadius: 4 }} + /> + } + > + {filter.value ? ( + {filter.value} + ) : ( + + {t['com.affine.filter.empty']()} + + )} + + ) : null; +}; + export const NumberDocListProperty = ({ value }: { value: number }) => { if (value !== 0 && !value) { return null; @@ -65,3 +174,13 @@ export const NumberDocListProperty = ({ value }: { value: number }) => { return }>{value}; }; + +export const NumberGroupHeader = ({ groupId, docCount }: GroupHeaderProps) => { + const t = useI18n(); + const number = groupId || t['com.affine.filter.empty'](); + return ( + + {number} + + ); +}; diff --git a/packages/frontend/core/src/components/workspace-property-types/tags.tsx b/packages/frontend/core/src/components/workspace-property-types/tags.tsx index 0ba8b4cb92..05a9e3cebd 100644 --- a/packages/frontend/core/src/components/workspace-property-types/tags.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/tags.tsx @@ -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' ? ( { 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' ? ( > { + 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(); + 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(); + 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(); + 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}`); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/group-by/number.ts b/packages/frontend/core/src/modules/collection-rules/impls/group-by/number.ts new file mode 100644 index 0000000000..4140fa2bcb --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/group-by/number.ts @@ -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>, + params: GroupByParams + ): Observable>> { + return this.docsService.propertyValues$('custom:' + params.key).pipe( + map(o => { + const result = new Map>(); + 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(); + set.add(id); + result.set(strValue, set); + } + return result; + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/impls/order-by/number.ts b/packages/frontend/core/src/modules/collection-rules/impls/order-by/number.ts new file mode 100644 index 0000000000..139fc97906 --- /dev/null +++ b/packages/frontend/core/src/modules/collection-rules/impls/order-by/number.ts @@ -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>, + params: OrderByParams + ): Observable { + 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]); + }) + ); + } +} diff --git a/packages/frontend/core/src/modules/collection-rules/index.ts b/packages/frontend/core/src/modules/collection-rules/index.ts index ceab219834..4a56cb4d98 100644 --- a/packages/frontend/core/src/modules/collection-rules/index.ts +++ b/packages/frontend/core/src/modules/collection-rules/index.ts @@ -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, ]) diff --git a/packages/frontend/core/src/modules/workspace-property/types.ts b/packages/frontend/core/src/modules/workspace-property/types.ts index 1e74254648..0e85eb29f1 100644 --- a/packages/frontend/core/src/modules/workspace-property/types.ts +++ b/packages/frontend/core/src/modules/workspace-property/types.ts @@ -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'; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 9e0aa95b47..3d1521714d 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -5,7 +5,7 @@ "de": 94, "el-GR": 94, "en": 100, - "es-AR": 95, + "es-AR": 94, "es-CL": 96, "es": 94, "fa": 94,