feat(core): support draft filter (#12400)

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

- **New Features**
  - Added support for draft mode and completion callbacks across filter components, enabling stepwise filter creation and editing.
  - Enhanced filter menus and editors with external control via refs and new callback props for open/close state management.
  - Introduced new filter option group component for multi-step filter interactions.
  - Expanded tag filter methods for more granular filtering options.
  - Enabled controlled open state and close event handling for desktop and mobile menus.
  - Added programmatic control and completion callbacks to member selector and tags inline editors.

- **Improvements**
  - Updated filter and tag editors with improved UI layouts and added "Done" buttons for easier completion.
  - Improved menu and editor accessibility by allowing programmatic open/close and completion event handling.
  - Refactored date filter components for modularity and consistent draft handling.
  - Separated draft filter state management in filter UI for clearer user interactions.

- **Bug Fixes**
  - Refined date filter logic for more accurate "after" and "before" comparisons.

- **Style**
  - Adjusted styles for draft filters and editor layouts to enhance visual clarity and user experience.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
EYHN
2025-05-21 04:49:44 +00:00
parent 41ec438df8
commit 8f352580a7
28 changed files with 778 additions and 154 deletions

View File

@@ -1,6 +1,6 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import clsx from 'clsx'; import clsx from 'clsx';
import React from 'react'; import React, { useCallback, useImperativeHandle, useState } from 'react';
import type { MenuProps } from '../menu.types'; import type { MenuProps } from '../menu.types';
import * as styles from '../styles.css'; import * as styles from '../styles.css';
@@ -11,18 +11,52 @@ export const DesktopMenu = ({
items, items,
noPortal, noPortal,
portalOptions, portalOptions,
rootOptions: { defaultOpen, modal, ...rootOptions } = {}, rootOptions: {
defaultOpen,
modal,
open,
onOpenChange,
onClose,
...rootOptions
} = {},
contentOptions: { contentOptions: {
className = '', className = '',
style: contentStyle = {}, style: contentStyle = {},
...otherContentOptions ...otherContentOptions
} = {}, } = {},
ref,
}: MenuProps) => { }: MenuProps) => {
const [innerOpen, setInnerOpen] = useState(defaultOpen);
const finalOpen = open ?? innerOpen;
const handleOpenChange = useCallback(
(open: boolean) => {
setInnerOpen(open);
onOpenChange?.(open);
if (!open) {
onClose?.();
}
},
[onOpenChange, onClose]
);
useImperativeHandle(
ref,
() => ({
changeOpen: (open: boolean) => {
setInnerOpen(open);
onOpenChange?.(open);
},
}),
[onOpenChange]
);
const ContentWrapper = noPortal ? React.Fragment : DropdownMenu.Portal; const ContentWrapper = noPortal ? React.Fragment : DropdownMenu.Portal;
return ( return (
<DropdownMenu.Root <DropdownMenu.Root
defaultOpen={defaultOpen}
modal={modal ?? false} modal={modal ?? false}
open={finalOpen}
onOpenChange={handleOpenChange}
{...rootOptions} {...rootOptions}
> >
<DropdownMenu.Trigger <DropdownMenu.Trigger

View File

@@ -8,15 +8,20 @@ import type {
} from '@radix-ui/react-dropdown-menu'; } from '@radix-ui/react-dropdown-menu';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
export interface MenuRef {
changeOpen: (open: boolean) => void;
}
export interface MenuProps { export interface MenuProps {
children: ReactNode; children: ReactNode;
items: ReactNode; items: ReactNode;
title?: string; title?: string;
portalOptions?: Omit<DropdownMenuPortalProps, 'children'>; portalOptions?: Omit<DropdownMenuPortalProps, 'children'>;
rootOptions?: Omit<DropdownMenuProps, 'children'>; rootOptions?: Omit<DropdownMenuProps, 'children'> & { onClose?: () => void };
contentOptions?: Omit<DropdownMenuContentProps, 'children'>; contentOptions?: Omit<DropdownMenuContentProps, 'children'>;
contentWrapperStyle?: CSSProperties; contentWrapperStyle?: CSSProperties;
noPortal?: boolean; noPortal?: boolean;
ref?: React.Ref<MenuRef>;
} }
export interface MenuItemProps export interface MenuItemProps

View File

@@ -2,7 +2,13 @@ import { useI18n } from '@affine/i18n';
import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc'; import { ArrowLeftSmallIcon } from '@blocksuite/icons/rc';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback, useContext, useEffect, useState } from 'react'; import {
useCallback,
useContext,
useEffect,
useImperativeHandle,
useState,
} from 'react';
import { observeResize } from '../../../utils'; import { observeResize } from '../../../utils';
import { Button } from '../../button'; import { Button } from '../../button';
@@ -34,6 +40,7 @@ export const MobileMenu = ({
} = {}, } = {},
contentWrapperStyle, contentWrapperStyle,
rootOptions, rootOptions,
ref,
}: MenuProps) => { }: MenuProps) => {
const [subMenus, setSubMenus] = useState<SubMenuContent[]>([]); const [subMenus, setSubMenus] = useState<SubMenuContent[]>([]);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -82,10 +89,23 @@ export const MobileMenu = ({
} }
setOpen(open); setOpen(open);
rootOptions?.onOpenChange?.(open); rootOptions?.onOpenChange?.(open);
if (!open) {
rootOptions?.onClose?.();
}
}, },
[onInteractOutside, onPointerDownOutside, removeAllSubMenus, rootOptions] [onInteractOutside, onPointerDownOutside, removeAllSubMenus, rootOptions]
); );
useImperativeHandle(
ref,
() => ({
changeOpen: (open: boolean) => {
onOpenChange(open);
},
}),
[onOpenChange]
);
const onItemClick = useCallback( const onItemClick = useCallback(
(e: any) => { (e: any) => {
e.preventDefault(); e.preventDefault();

View File

@@ -3,22 +3,32 @@ import type { FilterParams } from '@affine/core/modules/collection-rules';
import clsx from 'clsx'; import clsx from 'clsx';
import type React from 'react'; import type React from 'react';
import { FilterOptionsGroup } from '../options';
import * as styles from './styles.css'; import * as styles from './styles.css';
export const Condition = ({ export const Condition = ({
filter, filter,
isDraft,
onDraftCompleted,
icon, icon,
name, name,
methods, methods,
onChange, onChange,
value, value: Value,
}: { }: {
filter: FilterParams; filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
icon?: React.ReactNode; icon?: React.ReactNode;
name: React.ReactNode; name: React.ReactNode;
methods?: [string, React.ReactNode][]; methods?: [string, React.ReactNode][];
onChange?: (filter: FilterParams) => void; onChange?: (filter: FilterParams) => void;
value?: React.ReactNode; value?: React.ElementType<{
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}>;
}) => { }) => {
return ( return (
<> <>
@@ -26,40 +36,68 @@ export const Condition = ({
{icon && <div className={styles.filterTypeIconStyle}>{icon}</div>} {icon && <div className={styles.filterTypeIconStyle}>{icon}</div>}
{name} {name}
</div> </div>
{methods && ( <FilterOptionsGroup
<Menu isDraft={isDraft}
items={methods.map(([method, name]) => ( onDraftCompleted={onDraftCompleted}
<MenuItem items={[
onClick={() => { methods &&
onChange?.({ (({ onDraftCompleted, menuRef }) => {
...filter, return (
method, <Menu
}); key={'method'}
}} ref={menuRef}
selected={filter.method === method} rootOptions={{
key={method} onClose: onDraftCompleted,
> }}
{name} items={methods.map(([method, name]) => (
</MenuItem> <MenuItem
))} onClick={() => {
> onChange?.({
<div ...filter,
className={clsx(styles.switchStyle, styles.ellipsisTextStyle)} method,
data-testid="filter-method" });
> }}
{methods.find(([method]) => method === filter.method)?.[1] ?? selected={filter.method === method}
'unknown'} key={method}
</div> >
</Menu> {name}
)} </MenuItem>
{value && ( ))}
<div >
className={clsx(styles.filterValueStyle, styles.ellipsisTextStyle)} <div
data-testid="filter-method" className={clsx(
> styles.switchStyle,
{value} styles.ellipsisTextStyle
</div> )}
)} data-testid="filter-method"
>
{methods.find(
([method]) => method === filter.method
)?.[1] ?? 'unknown'}
</div>
</Menu>
);
}),
Value &&
(({ isDraft, onDraftCompleted }) => (
<div
key={'value'}
className={clsx(
styles.filterValueStyle,
styles.ellipsisTextStyle
)}
data-testid="filter-method"
>
<Value
filter={filter}
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
onChange={onChange}
/>
</div>
)),
]}
/>
</> </>
); );
}; };

View File

@@ -13,9 +13,13 @@ import { UnknownFilterCondition } from './unknown';
export const PropertyFilterCondition = ({ export const PropertyFilterCondition = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange: (filter: FilterParams) => void; onChange: (filter: FilterParams) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
@@ -34,19 +38,27 @@ export const PropertyFilterCondition = ({
const Value = type?.filterValue; const Value = type?.filterValue;
if (!propertyInfo || !type || !methods) { if (!propertyInfo || !type || !methods) {
return <UnknownFilterCondition filter={filter} />; return (
<UnknownFilterCondition
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
filter={filter}
/>
);
} }
return ( return (
<Condition <Condition
filter={filter} filter={filter}
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
icon={<WorkspacePropertyIcon propertyInfo={propertyInfo} />} icon={<WorkspacePropertyIcon propertyInfo={propertyInfo} />}
name={<WorkspacePropertyName propertyInfo={propertyInfo} />} name={<WorkspacePropertyName propertyInfo={propertyInfo} />}
methods={Object.entries(methods).map(([key, i18nKey]) => [ methods={Object.entries(methods).map(([key, i18nKey]) => [
key, key,
t.t(i18nKey as string), t.t(i18nKey as string),
])} ])}
value={Value && <Value filter={filter} onChange={onChange} />} value={Value}
onChange={onChange} onChange={onChange}
/> />
); );

View File

@@ -10,9 +10,13 @@ import { UnknownFilterCondition } from './unknown';
export const SystemFilterCondition = ({ export const SystemFilterCondition = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange: (filter: FilterParams) => void; onChange: (filter: FilterParams) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
@@ -21,7 +25,13 @@ export const SystemFilterCondition = ({
: undefined; : undefined;
if (!type) { if (!type) {
return <UnknownFilterCondition filter={filter} />; return (
<UnknownFilterCondition
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
filter={filter}
/>
);
} }
const methods = type.filterMethod; const methods = type.filterMethod;
@@ -31,12 +41,14 @@ export const SystemFilterCondition = ({
<Condition <Condition
filter={filter} filter={filter}
icon={<type.icon />} icon={<type.icon />}
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
name={t.t(type.name)} name={t.t(type.name)}
methods={Object.entries(methods).map(([key, i18nKey]) => [ methods={Object.entries(methods).map(([key, i18nKey]) => [
key, key,
t.t(i18nKey as string), t.t(i18nKey as string),
])} ])}
value={Value && <Value filter={filter} onChange={onChange} />} value={Value}
onChange={onChange} onChange={onChange}
/> />
); );

View File

@@ -1,14 +1,26 @@
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { WarningIcon } from '@blocksuite/icons/rc'; import { WarningIcon } from '@blocksuite/icons/rc';
import { useEffect } from 'react';
import { Condition } from './condition'; import { Condition } from './condition';
import * as styles from './styles.css'; import * as styles from './styles.css';
export const UnknownFilterCondition = ({ export const UnknownFilterCondition = ({
filter, filter,
isDraft,
onDraftCompleted,
}: { }: {
filter: FilterParams; filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
}) => { }) => {
useEffect(() => {
if (isDraft) {
// should not reach here
onDraftCompleted?.();
}
}, [isDraft, onDraftCompleted]);
return ( return (
<Condition <Condition
filter={filter} filter={filter}

View File

@@ -7,20 +7,39 @@ import * as styles from './styles.css';
export const Filter = ({ export const Filter = ({
filter, filter,
isDraft,
onDraftCompleted,
onDelete, onDelete,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onDelete: () => void; onDelete: () => void;
onChange: (filter: FilterParams) => void; onChange: (filter: FilterParams) => void;
}) => { }) => {
const type = filter.type; const type = filter.type;
const Condition =
type === 'property'
? PropertyFilterCondition
: type === 'system'
? SystemFilterCondition
: null;
return ( return (
<div className={styles.filterItemStyle}> <div
{type === 'property' ? ( className={styles.filterItemStyle}
<PropertyFilterCondition filter={filter} onChange={onChange} /> data-draft={isDraft}
) : type === 'system' ? ( data-type={type}
<SystemFilterCondition filter={filter} onChange={onChange} /> >
{Condition ? (
<Condition
isDraft={isDraft}
filter={filter}
onChange={onChange}
onDraftCompleted={onDraftCompleted}
/>
) : null} ) : null}
<div className={styles.filterItemCloseStyle} onClick={onDelete}> <div className={styles.filterItemCloseStyle} onClick={onDelete}>
<CloseIcon /> <CloseIcon />

View File

@@ -1,5 +1,6 @@
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AddFilter } from './add-filter'; import { AddFilter } from './add-filter';
import { Filter } from './filter'; import { Filter } from './filter';
@@ -9,11 +10,25 @@ export const Filters = ({
filters, filters,
className, className,
onChange, onChange,
defaultDraftFilter,
}: { }: {
filters: FilterParams[]; filters: FilterParams[];
className?: string; className?: string;
onChange?: (filters: FilterParams[]) => void; onChange?: (filters: FilterParams[]) => void;
defaultDraftFilter?: FilterParams | null;
}) => { }) => {
const [draftFilter, setDraftFilter] = useState<FilterParams | null>(
defaultDraftFilter ?? null
);
// When draftChange and draftCompleted are triggered consecutively,
// we might save an outdated draft filter value.
// Using a ref helps us avoid this issue by always accessing the latest value.
const draftFilterRef = useRef<FilterParams | null>(draftFilter);
useEffect(() => {
draftFilterRef.current = draftFilter;
}, [draftFilter]);
const handleDelete = (index: number) => { const handleDelete = (index: number) => {
onChange?.(filters.filter((_, i) => i !== index)); onChange?.(filters.filter((_, i) => i !== index));
}; };
@@ -22,6 +37,20 @@ export const Filters = ({
onChange?.(filters.map((f, i) => (i === index ? filter : f))); onChange?.(filters.map((f, i) => (i === index ? filter : f)));
}; };
const handleDraftCompleted = useCallback(() => {
if (draftFilterRef.current) {
onChange?.(filters.concat(draftFilterRef.current));
setDraftFilter(null);
}
}, [onChange, filters]);
const handleAdd = useCallback((filter: FilterParams) => {
// Add a small delay to ensure the previous menu is closed before opening the next one
setTimeout(() => {
setDraftFilter(filter);
}, 50);
}, []);
return ( return (
<div className={clsx(styles.container, className)}> <div className={clsx(styles.container, className)}>
{filters.map((filter, index) => { {filters.map((filter, index) => {
@@ -39,11 +68,21 @@ export const Filters = ({
/> />
); );
})} })}
<AddFilter {draftFilter && (
onAdd={filter => { <Filter
onChange?.(filters.concat(filter)); filter={draftFilter}
}} isDraft
/> onDelete={() => {
setDraftFilter(null);
}}
onChange={filter => {
setDraftFilter(filter);
}}
onDraftCompleted={handleDraftCompleted}
/>
)}
<AddFilter onAdd={handleAdd} />
</div> </div>
); );
}; };

View File

@@ -0,0 +1,75 @@
import { type MenuRef } from '@affine/component';
import { useCallback, useEffect, useRef, useState } from 'react';
type FilterOptionsGroupChildren =
| React.ReactNode
| ((args: {
isDraft?: boolean;
onDraftCompleted?: () => void;
menuRef: React.Ref<MenuRef>;
}) => React.ReactNode);
export const FilterOptionsGroup = ({
isDraft,
onDraftCompleted,
items,
}: {
isDraft?: boolean;
onDraftCompleted?: () => void;
items?: FilterOptionsGroupChildren[];
}) => {
const stepCount =
items?.filter(v => {
if (typeof v === 'function') {
return true;
}
return false;
}).length ?? 0;
const childRefs = useRef<(MenuRef | null)[]>([]);
const [currentStep, setCurrentStep] = useState(0);
const handleNextStep = useCallback(() => {
// Add a small delay between steps to prevent the next menu from automatically closing due to the previous menu's close event
setTimeout(() => {
if (currentStep < stepCount - 1) {
setCurrentStep(currentStep + 1);
} else {
onDraftCompleted?.();
}
}, 50);
}, [currentStep, stepCount, onDraftCompleted]);
useEffect(() => {
if (isDraft) {
childRefs.current[currentStep]?.changeOpen(true);
}
return;
}, [isDraft, currentStep]);
let renderStep = 0;
return items?.map(child => {
if (typeof child === 'function') {
const currentRenderStep = renderStep;
renderStep++;
const childIsDraft = isDraft
? currentRenderStep === currentStep
: undefined;
return child({
isDraft: childIsDraft,
onDraftCompleted: () => {
if (childIsDraft) {
handleNextStep();
}
},
menuRef: (ref: MenuRef) => {
childRefs.current[currentRenderStep] = ref;
return () => {
childRefs.current[currentRenderStep] = null;
};
},
});
}
return child;
});
};

View File

@@ -20,6 +20,11 @@ export const filterItemStyle = style({
justifyContent: 'space-between', justifyContent: 'space-between',
userSelect: 'none', userSelect: 'none',
alignItems: 'center', alignItems: 'center',
selectors: {
'&[data-draft="true"]': {
borderStyle: 'dashed',
},
},
}); });
export const filterItemCloseStyle = style({ export const filterItemCloseStyle = style({

View File

@@ -1,13 +1,29 @@
import { Avatar, Divider, Menu, RowInput, Scrollable } from '@affine/component'; import {
Avatar,
Divider,
Menu,
MenuItem,
type MenuRef,
RowInput,
Scrollable,
} from '@affine/component';
import { import {
type Member, type Member,
MemberSearchService, MemberSearchService,
} from '@affine/core/modules/permissions'; } from '@affine/core/modules/permissions';
import { DoneIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { clamp, debounce } from 'lodash-es'; import { clamp, debounce } from 'lodash-es';
import type { KeyboardEvent, ReactNode } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { ConfigModal } from '../mobile'; import { ConfigModal } from '../mobile';
import { InlineMemberList } from './inline-member-list'; import { InlineMemberList } from './inline-member-list';
@@ -26,6 +42,8 @@ export interface MemberSelectorInlineProps extends MemberSelectorProps {
readonly?: boolean; readonly?: boolean;
title?: ReactNode; // only used for mobile title?: ReactNode; // only used for mobile
placeholder?: ReactNode; placeholder?: ReactNode;
ref?: React.Ref<MenuRef>;
onEditorClose?: () => void;
} }
interface MemberSelectItemProps { interface MemberSelectItemProps {
@@ -219,9 +237,15 @@ export const MemberSelector = ({
/> />
</InlineMemberList> </InlineMemberList>
{BUILD_CONFIG.isMobileEdition ? null : ( {BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.memberDivider} /> <MenuItem
className={styles.memberSelectorDoneButton}
prefixIcon={<DoneIcon />}
/>
)} )}
</div> </div>
{BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.memberDivider} />
)}
<div className={styles.memberSelectorBody}> <div className={styles.memberSelectorBody}>
<Scrollable.Root> <Scrollable.Root>
<Scrollable.Viewport <Scrollable.Viewport
@@ -267,10 +291,25 @@ const MobileMemberSelectorInline = ({
className, className,
title, title,
style, style,
onEditorClose,
ref,
...props ...props
}: MemberSelectorInlineProps) => { }: MemberSelectorInlineProps) => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
useImperativeHandle(
ref,
() => ({
changeOpen: (open: boolean) => {
setEditing(open);
if (!open) {
onEditorClose?.();
}
},
}),
[onEditorClose]
);
const empty = !props.selected || props.selected.length === 0; const empty = !props.selected || props.selected.length === 0;
return ( return (
<> <>
@@ -278,7 +317,10 @@ const MobileMemberSelectorInline = ({
title={title} title={title}
open={editing} open={editing}
onOpenChange={setEditing} onOpenChange={setEditing}
onBack={() => setEditing(false)} onBack={() => {
setEditing(false);
onEditorClose?.();
}}
> >
<MemberSelector {...props} /> <MemberSelector {...props} />
</ConfigModal> </ConfigModal>
@@ -303,11 +345,14 @@ const DesktopMemberSelectorInline = ({
menuClassName, menuClassName,
style, style,
selected, selected,
ref,
onEditorClose,
...props ...props
}: MemberSelectorInlineProps) => { }: MemberSelectorInlineProps) => {
const empty = !selected || selected.length === 0; const empty = !selected || selected.length === 0;
return ( return (
<Menu <Menu
ref={ref}
contentOptions={{ contentOptions={{
side: 'bottom', side: 'bottom',
align: 'start', align: 'start',
@@ -321,6 +366,7 @@ const DesktopMemberSelectorInline = ({
rootOptions={{ rootOptions={{
open: readonly ? false : undefined, open: readonly ? false : undefined,
modal: modalMenu, modal: modalMenu,
onClose: onEditorClose,
}} }}
items={<MemberSelector selected={selected} {...props} />} items={<MemberSelector selected={selected} {...props} />}
> >

View File

@@ -37,9 +37,15 @@ export const memberSelectorMenu = style({
minWidth: 400, minWidth: 400,
}); });
export const memberSelectorDoneButton = style({
height: '32px',
width: '28px',
});
export const memberSelectorSelectedTags = style({ export const memberSelectorSelectedTags = style({
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexDirection: 'row',
flexWrap: 'nowrap',
padding: '10px 12px 0px', padding: '10px 12px 0px',
minHeight: 42, minHeight: 42,
selectors: { selectors: {

View File

@@ -1,20 +1,37 @@
import { Menu, MenuItem } from '@affine/component'; import { Menu, MenuItem, type MenuRef } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useEffect, useRef } from 'react';
export const FavoriteFilterValue = ({ export const FavoriteFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
return ( return (
<Menu <Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={ items={
<> <>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'true', value: 'true',
}); });
@@ -25,7 +42,7 @@ export const FavoriteFilterValue = ({
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'false', value: 'false',
}); });

View File

@@ -35,7 +35,10 @@ export const SystemPropertyTypes = {
icon: TagIcon, icon: TagIcon,
name: 'Tags', name: 'Tags',
filterMethod: { filterMethod: {
include: 'com.affine.filter.contains all', 'include-all': 'com.affine.filter.contains all',
'include-any-of': 'com.affine.filter.contains one of',
'not-include-all': 'com.affine.filter.does not contains all',
'not-include-any-of': 'com.affine.filter.does not contains one of',
'is-not-empty': 'com.affine.filter.is not empty', 'is-not-empty': 'com.affine.filter.is not empty',
'is-empty': 'com.affine.filter.is empty', 'is-empty': 'com.affine.filter.is empty',
}, },
@@ -129,7 +132,9 @@ export const SystemPropertyTypes = {
filterMethod: { [key: string]: I18nString }; filterMethod: { [key: string]: I18nString };
filterValue: React.FC<{ filterValue: React.FC<{
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}>; }>;
defaultFilter?: Omit<FilterParams, 'type' | 'key'>; defaultFilter?: Omit<FilterParams, 'type' | 'key'>;
/** /**

View File

@@ -1,20 +1,37 @@
import { Menu, MenuItem } from '@affine/component'; import { Menu, MenuItem, type MenuRef } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useEffect, useRef } from 'react';
export const SharedFilterValue = ({ export const SharedFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
return ( return (
<Menu <Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={ items={
<> <>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'true', value: 'true',
}); });
@@ -25,7 +42,7 @@ export const SharedFilterValue = ({
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'false', value: 'false',
}); });

View File

@@ -39,7 +39,8 @@ export const tagsMenu = style({
export const tagsEditorSelectedTags = style({ export const tagsEditorSelectedTags = style({
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexDirection: 'row',
flexWrap: 'nowrap',
padding: '10px 12px 0px', padding: '10px 12px 0px',
minHeight: 42, minHeight: 42,
selectors: { selectors: {
@@ -51,6 +52,11 @@ export const tagsEditorSelectedTags = style({
}, },
}); });
export const tagsEditorDoneButton = style({
height: '32px',
width: '28px',
});
export const tagDivider = style({ export const tagDivider = style({
borderBottomColor: cssVarV2('tab/divider/divider'), borderBottomColor: cssVarV2('tab/divider/divider'),
}); });

View File

@@ -2,17 +2,26 @@ import {
Divider, Divider,
IconButton, IconButton,
Menu, Menu,
MenuItem,
type MenuRef,
RowInput, RowInput,
Scrollable, Scrollable,
} from '@affine/component'; } from '@affine/component';
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag'; import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc'; import { DoneIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { clamp } from 'lodash-es'; import { clamp } from 'lodash-es';
import type { KeyboardEvent, ReactNode } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'; import {
useCallback,
useImperativeHandle,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import { useAsyncCallback } from '../hooks/affine-async-hooks'; import { useAsyncCallback } from '../hooks/affine-async-hooks';
import { ConfigModal } from '../mobile'; import { ConfigModal } from '../mobile';
@@ -44,6 +53,8 @@ export interface TagsInlineEditorProps extends TagsEditorProps {
modalMenu?: boolean; modalMenu?: boolean;
menuClassName?: string; menuClassName?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
ref?: React.Ref<MenuRef>;
onEditorClose?: () => void;
} }
type TagOption = TagLike | { readonly create: true; readonly value: string }; type TagOption = TagLike | { readonly create: true; readonly value: string };
@@ -269,10 +280,17 @@ export const TagsEditor = ({
placeholder="Type here ..." placeholder="Type here ..."
/> />
</InlineTagList> </InlineTagList>
{BUILD_CONFIG.isMobileEdition ? null : ( {BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.tagDivider} /> <MenuItem
className={styles.tagsEditorDoneButton}
prefixIcon={<DoneIcon />}
/>
)} )}
</div> </div>
{BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.tagDivider} />
)}
<div className={styles.tagsEditorTagsSelector}> <div className={styles.tagsEditorTagsSelector}>
<div className={styles.tagsEditorTagsSelectorHeader}> <div className={styles.tagsEditorTagsSelectorHeader}>
{t['com.affine.page-properties.tags.selector-header-title']()} {t['com.affine.page-properties.tags.selector-header-title']()}
@@ -350,10 +368,25 @@ const MobileInlineEditor = ({
className, className,
title, title,
style, style,
onEditorClose,
ref,
...props ...props
}: TagsInlineEditorProps) => { }: TagsInlineEditorProps) => {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
useImperativeHandle(
ref,
() => ({
changeOpen: (open: boolean) => {
setEditing(open);
if (!open) {
onEditorClose?.();
}
},
}),
[onEditorClose]
);
const empty = !props.selectedTags || props.selectedTags.length === 0; const empty = !props.selectedTags || props.selectedTags.length === 0;
const selectedTags = useMemo(() => { const selectedTags = useMemo(() => {
return props.selectedTags return props.selectedTags
@@ -366,7 +399,10 @@ const MobileInlineEditor = ({
title={title} title={title}
open={editing} open={editing}
onOpenChange={setEditing} onOpenChange={setEditing}
onBack={() => setEditing(false)} onBack={() => {
setEditing(false);
onEditorClose?.();
}}
> >
<TagsEditor {...props} /> <TagsEditor {...props} />
</ConfigModal> </ConfigModal>
@@ -394,6 +430,8 @@ const DesktopTagsInlineEditor = ({
modalMenu, modalMenu,
menuClassName, menuClassName,
style, style,
ref,
onEditorClose,
...props ...props
}: TagsInlineEditorProps) => { }: TagsInlineEditorProps) => {
const empty = !props.selectedTags || props.selectedTags.length === 0; const empty = !props.selectedTags || props.selectedTags.length === 0;
@@ -404,6 +442,7 @@ const DesktopTagsInlineEditor = ({
}, [props.selectedTags, props.tags]); }, [props.selectedTags, props.tags]);
return ( return (
<Menu <Menu
ref={ref}
contentOptions={{ contentOptions={{
side: 'bottom', side: 'bottom',
align: 'start', align: 'start',
@@ -416,6 +455,7 @@ const DesktopTagsInlineEditor = ({
}} }}
rootOptions={{ rootOptions={{
modal: modalMenu, modal: modalMenu,
onClose: onEditorClose,
}} }}
items={<TagsEditor {...props} />} items={<TagsEditor {...props} />}
> >
@@ -447,6 +487,8 @@ export const TagsInlineEditor = BUILD_CONFIG.isMobileEdition
export const WorkspaceTagsInlineEditor = ({ export const WorkspaceTagsInlineEditor = ({
selectedTags, selectedTags,
onDeselectTag, onDeselectTag,
ref,
onEditorClose,
...otherProps ...otherProps
}: Omit< }: Omit<
TagsInlineEditorProps, TagsInlineEditorProps,
@@ -505,6 +547,8 @@ export const WorkspaceTagsInlineEditor = ({
onCreateTag={onCreateTag} onCreateTag={onCreateTag}
onDeleteTag={onDeleteTag} onDeleteTag={onDeleteTag}
onTagChange={onTagChange} onTagChange={onTagChange}
ref={ref}
onEditorClose={onEditorClose}
{...otherProps} {...otherProps}
/> />
); );

View File

@@ -1,8 +1,14 @@
import { Checkbox, Menu, MenuItem, PropertyValue } from '@affine/component'; import {
Checkbox,
Menu,
MenuItem,
type MenuRef,
PropertyValue,
} from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { CheckBoxCheckLinearIcon } from '@blocksuite/icons/rc'; import { CheckBoxCheckLinearIcon } from '@blocksuite/icons/rc';
import { useCallback } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property'; import { StackProperty } from '../explorer/docs-view/stack-property';
@@ -40,18 +46,34 @@ export const CheckboxValue = ({
export const CheckboxFilterValue = ({ export const CheckboxFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
return ( return (
<Menu <Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={ items={
<> <>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'true', value: 'true',
}); });
@@ -62,7 +84,7 @@ export const CheckboxFilterValue = ({
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'false', value: 'false',
}); });

View File

@@ -1,4 +1,4 @@
import { PropertyValue } from '@affine/component'; import { type MenuRef, PropertyValue } from '@affine/component';
import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user'; import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { type DocRecord, DocService } from '@affine/core/modules/doc'; import { type DocRecord, DocService } from '@affine/core/modules/doc';
@@ -6,7 +6,7 @@ import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { type ReactNode, useCallback, useMemo } from 'react'; import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import type { GroupHeaderProps } from '../explorer/types'; import type { GroupHeaderProps } from '../explorer/types';
@@ -100,12 +100,23 @@ export const UpdatedByValue = () => {
export const CreatedByUpdatedByFilterValue = ({ export const CreatedByUpdatedByFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
const selected = useMemo( const selected = useMemo(
() => filter.value?.split(',').filter(Boolean) ?? [], () => filter.value?.split(',').filter(Boolean) ?? [],
@@ -114,7 +125,7 @@ export const CreatedByUpdatedByFilterValue = ({
const handleChange = useCallback( const handleChange = useCallback(
(selected: string[]) => { (selected: string[]) => {
onChange({ onChange?.({
...filter, ...filter,
value: selected.join(','), value: selected.join(','),
}); });
@@ -131,6 +142,8 @@ export const CreatedByUpdatedByFilterValue = ({
} }
selected={selected} selected={selected}
onChange={handleChange} onChange={handleChange}
ref={menuRef}
onEditorClose={onDraftCompleted}
/> />
); );
}; };

View File

@@ -1,13 +1,25 @@
import { DatePicker, Menu, PropertyValue } from '@affine/component'; import {
DatePicker,
Menu,
type MenuRef,
PropertyValue,
} from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { i18nTime, useI18n } from '@affine/i18n'; import { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon } from '@blocksuite/icons/rc'; import { DateTimeIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback } from 'react'; import {
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property'; import { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types'; import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import { FilterOptionsGroup } from '../filter/options';
import type { PropertyValueProps } from '../properties/types'; import type { PropertyValueProps } from '../properties/types';
import * as styles from './date.css'; import * as styles from './date.css';
@@ -64,22 +76,89 @@ export const DateValue = ({
); );
}; };
export const DateFilterValue = ({ const DateSelectorMenu = ({
ref,
value,
onChange,
onClose,
}: {
ref?: React.Ref<MenuRef>;
value?: string;
onChange: (value: string) => void;
onClose?: () => void;
}) => {
const t = useI18n();
const [open, setOpen] = useState(false);
useImperativeHandle(
ref,
() => ({
changeOpen: (open: boolean) => {
setOpen(open);
if (!open) {
onClose?.();
}
},
}),
[onClose]
);
const handleOpenChange = useCallback(
(open: boolean) => {
setOpen(open);
if (!open) {
onClose?.();
}
},
[onClose]
);
const handleChange = useCallback(
(value: string) => {
onChange(value);
setOpen(false);
onClose?.();
},
[onChange, onClose]
);
return (
<Menu
rootOptions={{
open,
onOpenChange: handleOpenChange,
}}
items={<DatePicker value={value || undefined} onChange={handleChange} />}
>
{value ? (
<span>{value}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
);
};
const DateFilterValueAfterBefore = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const t = useI18n(); const menuRef = useRef<MenuRef>(null);
const value = filter.value; const value = filter.value;
const values = value?.split(',') ?? []; const values = value?.split(',') ?? [];
const displayDates =
values.map(t => i18nTime(t, { absolute: { accuracy: 'day' } })) ?? [];
const handleChange = useCallback( const handleChange = useCallback(
(date: string) => { (date: string) => {
onChange({ onChange?.({
...filter, ...filter,
value: date, value: date,
}); });
@@ -87,56 +166,90 @@ export const DateFilterValue = ({
[onChange, filter] [onChange, filter]
); );
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
return (
<DateSelectorMenu
ref={menuRef}
value={values[0]}
onChange={handleChange}
onClose={onDraftCompleted}
/>
);
};
export const DateFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => {
const value = filter.value;
const values = value?.split(',') ?? [];
const handleChange = useCallback(
(date: string) => {
onChange?.({
...filter,
value: date,
});
},
[onChange, filter]
);
useEffect(() => {
if (
isDraft &&
filter.method !== 'after' &&
filter.method !== 'before' &&
filter.method !== 'between'
) {
onDraftCompleted?.();
}
}, [isDraft, filter.method, onDraftCompleted]);
return filter.method === 'after' || filter.method === 'before' ? ( return filter.method === 'after' || filter.method === 'before' ? (
<Menu <DateFilterValueAfterBefore
items={ filter={filter}
<DatePicker value={values[0] || undefined} onChange={handleChange} /> isDraft={isDraft}
} onDraftCompleted={onDraftCompleted}
> onChange={onChange}
{displayDates[0] ? ( />
<span>{displayDates[0]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
) : filter.method === 'between' ? ( ) : filter.method === 'between' ? (
<> <FilterOptionsGroup
<Menu isDraft={isDraft}
items={ onDraftCompleted={onDraftCompleted}
<DatePicker items={[
value={values[0] || undefined} ({ onDraftCompleted, menuRef }) => (
<DateSelectorMenu
ref={menuRef}
value={values[0]}
onChange={value => handleChange(`${value},${values[1] || ''}`)} onChange={value => handleChange(`${value},${values[1] || ''}`)}
onClose={onDraftCompleted}
/> />
} ),
> <span key="between" style={{ color: cssVarV2('text/placeholder') }}>
{displayDates[0] ? ( &nbsp;-&nbsp;
<span>{displayDates[0]}</span> </span>,
) : ( ({ onDraftCompleted, menuRef }) => (
<span style={{ color: cssVarV2('text/placeholder') }}> <DateSelectorMenu
{t['com.affine.filter.empty']()} ref={menuRef}
</span> value={values[1]}
)}
</Menu>
<span style={{ color: cssVarV2('text/placeholder') }}>&nbsp;-&nbsp;</span>
<Menu
items={
<DatePicker
value={values[1] || undefined}
onChange={value => handleChange(`${values[0] || ''},${value}`)} onChange={value => handleChange(`${values[0] || ''},${value}`)}
onClose={onDraftCompleted}
/> />
} ),
> ]}
{displayDates[1] ? ( ></FilterOptionsGroup>
<span>{displayDates[1]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
</>
) : undefined; ) : undefined;
}; };

View File

@@ -1,6 +1,7 @@
import { import {
Menu, Menu,
MenuItem, MenuItem,
type MenuRef,
notify, notify,
PropertyValue, PropertyValue,
type RadioItem, type RadioItem,
@@ -11,7 +12,7 @@ import { useI18n } from '@affine/i18n';
import type { DocMode } from '@blocksuite/affine/model'; import type { DocMode } from '@blocksuite/affine/model';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc'; import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property'; import { StackProperty } from '../explorer/docs-view/stack-property';
@@ -78,20 +79,35 @@ export const DocPrimaryModeValue = ({
export const DocPrimaryModeFilterValue = ({ export const DocPrimaryModeFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
return ( return (
<Menu <Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={ items={
<> <>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'page', value: 'page',
}); });
@@ -102,7 +118,7 @@ export const DocPrimaryModeFilterValue = ({
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'edgeless', value: 'edgeless',
}); });

View File

@@ -324,7 +324,8 @@ export const WorkspacePropertyTypes = {
filterMethod?: { [key in WorkspacePropertyFilter<type>]: I18nString }; filterMethod?: { [key in WorkspacePropertyFilter<type>]: I18nString };
filterValue?: React.FC<{ filterValue?: React.FC<{
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onChange?: (filter: FilterParams) => void;
}>; }>;
defaultFilter?: Omit<FilterParams, 'type' | 'key'>; defaultFilter?: Omit<FilterParams, 'type' | 'key'>;
/** /**

View File

@@ -3,6 +3,7 @@ import {
DatePicker, DatePicker,
Menu, Menu,
MenuItem, MenuItem,
type MenuRef,
PropertyValue, PropertyValue,
} from '@affine/component'; } from '@affine/component';
import { MobileJournalConflictList } from '@affine/core/mobile/pages/workspace/detail/menu/journal-conflicts'; import { MobileJournalConflictList } from '@affine/core/mobile/pages/workspace/detail/menu/journal-conflicts';
@@ -182,18 +183,34 @@ export const JournalValue = ({ readonly }: PropertyValueProps) => {
export const JournalFilterValue = ({ export const JournalFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
return ( return (
<Menu <Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={ items={
<> <>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'true', value: 'true',
}); });
@@ -204,7 +221,7 @@ export const JournalFilterValue = ({
</MenuItem> </MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onChange({ onChange?.({
...filter, ...filter,
value: 'false', value: 'false',
}); });

View File

@@ -1,4 +1,4 @@
import { PropertyValue } from '@affine/component'; import { type MenuRef, PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { type DocRecord, DocService } from '@affine/core/modules/doc'; import { type DocRecord, DocService } from '@affine/core/modules/doc';
import { type Tag, TagService } from '@affine/core/modules/tag'; import { type Tag, TagService } from '@affine/core/modules/tag';
@@ -7,7 +7,7 @@ import { useI18n } from '@affine/i18n';
import { TagsIcon } from '@blocksuite/icons/rc'; import { TagsIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useMemo } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header'; import { PlainTextDocGroupHeader } from '../explorer/docs-view/group-header';
import { StackProperty } from '../explorer/docs-view/stack-property'; import { StackProperty } from '../explorer/docs-view/stack-property';
@@ -51,14 +51,25 @@ export const TagsValue = ({ readonly }: PropertyValueProps) => {
export const TagsFilterValue = ({ export const TagsFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange: (filter: FilterParams) => void; onChange: (filter: FilterParams) => void;
}) => { }) => {
const t = useI18n(); const t = useI18n();
const tagService = useService(TagService); const tagService = useService(TagService);
const allTagMetas = useLiveData(tagService.tagList.tagMetas$); const allTagMetas = useLiveData(tagService.tagList.tagMetas$);
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
const selectedTags = useMemo( const selectedTags = useMemo(
() => () =>
@@ -98,6 +109,8 @@ export const TagsFilterValue = ({
onSelectTag={handleSelectTag} onSelectTag={handleSelectTag}
onDeselectTag={handleDeselectTag} onDeselectTag={handleDeselectTag}
tagMode="inline-tag" tagMode="inline-tag"
ref={menuRef}
onEditorClose={onDraftCompleted}
/> />
) : undefined; ) : undefined;
}; };

View File

@@ -1,4 +1,4 @@
import { Input, Menu, PropertyValue } from '@affine/component'; import { Input, Menu, type MenuRef, PropertyValue } from '@affine/component';
import type { FilterParams } from '@affine/core/modules/collection-rules'; import type { FilterParams } from '@affine/core/modules/collection-rules';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { TextIcon, TextTypeIcon } from '@blocksuite/icons/rc'; import { TextIcon, TextTypeIcon } from '@blocksuite/icons/rc';
@@ -177,15 +177,26 @@ export const TextValue = BUILD_CONFIG.isMobileWeb
export const TextFilterValue = ({ export const TextFilterValue = ({
filter, filter,
isDraft,
onDraftCompleted,
onChange, onChange,
}: { }: {
filter: FilterParams; filter: FilterParams;
onChange: (filter: FilterParams) => void; isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => { }) => {
const [tempValue, setTempValue] = useState(filter.value || ''); const [tempValue, setTempValue] = useState(filter.value || '');
const [valueMenuOpen, setValueMenuOpen] = useState(false); const [valueMenuOpen, setValueMenuOpen] = useState(false);
const menuRef = useRef<MenuRef>(null);
const t = useI18n(); const t = useI18n();
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
useEffect(() => { useEffect(() => {
// update temp value with new filter value // update temp value with new filter value
setTempValue(filter.value || ''); setTempValue(filter.value || '');
@@ -193,7 +204,7 @@ export const TextFilterValue = ({
const submitTempValue = useCallback(() => { const submitTempValue = useCallback(() => {
if (tempValue !== (filter.value || '')) { if (tempValue !== (filter.value || '')) {
onChange({ onChange?.({
...filter, ...filter,
value: tempValue, value: tempValue,
}); });
@@ -216,9 +227,11 @@ export const TextFilterValue = ({
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? ( return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<Menu <Menu
ref={menuRef}
rootOptions={{ rootOptions={{
open: valueMenuOpen, open: valueMenuOpen,
onOpenChange: setValueMenuOpen, onOpenChange: setValueMenuOpen,
onClose: onDraftCompleted,
}} }}
contentOptions={{ contentOptions={{
onPointerDownOutside: submitTempValue, onPointerDownOutside: submitTempValue,

View File

@@ -92,6 +92,8 @@ export const AllPage = () => {
); );
const [tempFilters, setTempFilters] = useState<FilterParams[] | null>(null); const [tempFilters, setTempFilters] = useState<FilterParams[] | null>(null);
const [tempFiltersInitial, setTempFiltersInitial] =
useState<FilterParams | null>(null);
const [explorerContextValue] = useState(() => const [explorerContextValue] = useState(() =>
createDocExplorerContext(initialState) createDocExplorerContext(initialState)
@@ -281,7 +283,8 @@ export const AllPage = () => {
const handleNewTempFilter = useCallback((params: FilterParams) => { const handleNewTempFilter = useCallback((params: FilterParams) => {
setSelectedCollectionId(null); setSelectedCollectionId(null);
setTempFilters([params]); setTempFilters([]);
setTempFiltersInitial(params);
}, []); }, []);
const handleDisplayPreferenceChange = useCallback( const handleDisplayPreferenceChange = useCallback(
@@ -320,6 +323,7 @@ export const AllPage = () => {
className={styles.filters} className={styles.filters}
filters={tempFilters} filters={tempFilters}
onChange={handleFilterChange} onChange={handleFilterChange}
defaultDraftFilter={tempFiltersInitial}
/> />
<Button <Button
variant="plain" variant="plain"

View File

@@ -139,8 +139,8 @@ function isAfter(
: referenceDate; : referenceDate;
return ( return (
targetYear >= refYear || targetYear > refYear ||
(targetYear === refYear && targetMonth >= refMonth) || (targetYear === refYear && targetMonth > refMonth) ||
(targetYear === refYear && targetMonth === refMonth && targetDay >= refDay) (targetYear === refYear && targetMonth === refMonth && targetDay >= refDay)
); );
} }
@@ -153,8 +153,8 @@ function isBefore(
const [refYear, refMonth, refDay] = referenceDate; const [refYear, refMonth, refDay] = referenceDate;
return ( return (
targetYear <= refYear || targetYear < refYear ||
(targetYear === refYear && targetMonth <= refMonth) || (targetYear === refYear && targetMonth < refMonth) ||
(targetYear === refYear && targetMonth === refMonth && targetDay <= refDay) (targetYear === refYear && targetMonth === refMonth && targetDay <= refDay)
); );
} }