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 clsx from 'clsx';
import React from 'react';
import React, { useCallback, useImperativeHandle, useState } from 'react';
import type { MenuProps } from '../menu.types';
import * as styles from '../styles.css';
@@ -11,18 +11,52 @@ export const DesktopMenu = ({
items,
noPortal,
portalOptions,
rootOptions: { defaultOpen, modal, ...rootOptions } = {},
rootOptions: {
defaultOpen,
modal,
open,
onOpenChange,
onClose,
...rootOptions
} = {},
contentOptions: {
className = '',
style: contentStyle = {},
...otherContentOptions
} = {},
ref,
}: 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;
return (
<DropdownMenu.Root
defaultOpen={defaultOpen}
modal={modal ?? false}
open={finalOpen}
onOpenChange={handleOpenChange}
{...rootOptions}
>
<DropdownMenu.Trigger

View File

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

View File

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

View File

@@ -3,22 +3,32 @@ import type { FilterParams } from '@affine/core/modules/collection-rules';
import clsx from 'clsx';
import type React from 'react';
import { FilterOptionsGroup } from '../options';
import * as styles from './styles.css';
export const Condition = ({
filter,
isDraft,
onDraftCompleted,
icon,
name,
methods,
onChange,
value,
value: Value,
}: {
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
icon?: React.ReactNode;
name: React.ReactNode;
methods?: [string, React.ReactNode][];
onChange?: (filter: FilterParams) => void;
value?: React.ReactNode;
value?: React.ElementType<{
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}>;
}) => {
return (
<>
@@ -26,40 +36,68 @@ export const Condition = ({
{icon && <div className={styles.filterTypeIconStyle}>{icon}</div>}
{name}
</div>
{methods && (
<Menu
items={methods.map(([method, name]) => (
<MenuItem
onClick={() => {
onChange?.({
...filter,
method,
});
}}
selected={filter.method === method}
key={method}
>
{name}
</MenuItem>
))}
>
<div
className={clsx(styles.switchStyle, styles.ellipsisTextStyle)}
data-testid="filter-method"
>
{methods.find(([method]) => method === filter.method)?.[1] ??
'unknown'}
</div>
</Menu>
)}
{value && (
<div
className={clsx(styles.filterValueStyle, styles.ellipsisTextStyle)}
data-testid="filter-method"
>
{value}
</div>
)}
<FilterOptionsGroup
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
items={[
methods &&
(({ onDraftCompleted, menuRef }) => {
return (
<Menu
key={'method'}
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={methods.map(([method, name]) => (
<MenuItem
onClick={() => {
onChange?.({
...filter,
method,
});
}}
selected={filter.method === method}
key={method}
>
{name}
</MenuItem>
))}
>
<div
className={clsx(
styles.switchStyle,
styles.ellipsisTextStyle
)}
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 = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange: (filter: FilterParams) => void;
}) => {
const t = useI18n();
@@ -34,19 +38,27 @@ export const PropertyFilterCondition = ({
const Value = type?.filterValue;
if (!propertyInfo || !type || !methods) {
return <UnknownFilterCondition filter={filter} />;
return (
<UnknownFilterCondition
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
filter={filter}
/>
);
}
return (
<Condition
filter={filter}
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
icon={<WorkspacePropertyIcon propertyInfo={propertyInfo} />}
name={<WorkspacePropertyName propertyInfo={propertyInfo} />}
methods={Object.entries(methods).map(([key, i18nKey]) => [
key,
t.t(i18nKey as string),
])}
value={Value && <Value filter={filter} onChange={onChange} />}
value={Value}
onChange={onChange}
/>
);

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type { FilterParams } from '@affine/core/modules/collection-rules';
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AddFilter } from './add-filter';
import { Filter } from './filter';
@@ -9,11 +10,25 @@ export const Filters = ({
filters,
className,
onChange,
defaultDraftFilter,
}: {
filters: FilterParams[];
className?: string;
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) => {
onChange?.(filters.filter((_, i) => i !== index));
};
@@ -22,6 +37,20 @@ export const Filters = ({
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 (
<div className={clsx(styles.container, className)}>
{filters.map((filter, index) => {
@@ -39,11 +68,21 @@ export const Filters = ({
/>
);
})}
<AddFilter
onAdd={filter => {
onChange?.(filters.concat(filter));
}}
/>
{draftFilter && (
<Filter
filter={draftFilter}
isDraft
onDelete={() => {
setDraftFilter(null);
}}
onChange={filter => {
setDraftFilter(filter);
}}
onDraftCompleted={handleDraftCompleted}
/>
)}
<AddFilter onAdd={handleAdd} />
</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',
userSelect: 'none',
alignItems: 'center',
selectors: {
'&[data-draft="true"]': {
borderStyle: 'dashed',
},
},
});
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 {
type Member,
MemberSearchService,
} from '@affine/core/modules/permissions';
import { DoneIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { clamp, debounce } from 'lodash-es';
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 { InlineMemberList } from './inline-member-list';
@@ -26,6 +42,8 @@ export interface MemberSelectorInlineProps extends MemberSelectorProps {
readonly?: boolean;
title?: ReactNode; // only used for mobile
placeholder?: ReactNode;
ref?: React.Ref<MenuRef>;
onEditorClose?: () => void;
}
interface MemberSelectItemProps {
@@ -219,9 +237,15 @@ export const MemberSelector = ({
/>
</InlineMemberList>
{BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.memberDivider} />
<MenuItem
className={styles.memberSelectorDoneButton}
prefixIcon={<DoneIcon />}
/>
)}
</div>
{BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.memberDivider} />
)}
<div className={styles.memberSelectorBody}>
<Scrollable.Root>
<Scrollable.Viewport
@@ -267,10 +291,25 @@ const MobileMemberSelectorInline = ({
className,
title,
style,
onEditorClose,
ref,
...props
}: MemberSelectorInlineProps) => {
const [editing, setEditing] = useState(false);
useImperativeHandle(
ref,
() => ({
changeOpen: (open: boolean) => {
setEditing(open);
if (!open) {
onEditorClose?.();
}
},
}),
[onEditorClose]
);
const empty = !props.selected || props.selected.length === 0;
return (
<>
@@ -278,7 +317,10 @@ const MobileMemberSelectorInline = ({
title={title}
open={editing}
onOpenChange={setEditing}
onBack={() => setEditing(false)}
onBack={() => {
setEditing(false);
onEditorClose?.();
}}
>
<MemberSelector {...props} />
</ConfigModal>
@@ -303,11 +345,14 @@ const DesktopMemberSelectorInline = ({
menuClassName,
style,
selected,
ref,
onEditorClose,
...props
}: MemberSelectorInlineProps) => {
const empty = !selected || selected.length === 0;
return (
<Menu
ref={ref}
contentOptions={{
side: 'bottom',
align: 'start',
@@ -321,6 +366,7 @@ const DesktopMemberSelectorInline = ({
rootOptions={{
open: readonly ? false : undefined,
modal: modalMenu,
onClose: onEditorClose,
}}
items={<MemberSelector selected={selected} {...props} />}
>

View File

@@ -37,9 +37,15 @@ export const memberSelectorMenu = style({
minWidth: 400,
});
export const memberSelectorDoneButton = style({
height: '32px',
width: '28px',
});
export const memberSelectorSelectedTags = style({
display: 'flex',
flexWrap: 'wrap',
flexDirection: 'row',
flexWrap: 'nowrap',
padding: '10px 12px 0px',
minHeight: 42,
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 { useEffect, useRef } from 'react';
export const FavoriteFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
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 (
<Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={
<>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
value: 'true',
});
@@ -25,7 +42,7 @@ export const FavoriteFilterValue = ({
</MenuItem>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
value: 'false',
});

View File

@@ -35,7 +35,10 @@ export const SystemPropertyTypes = {
icon: TagIcon,
name: 'Tags',
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-empty': 'com.affine.filter.is empty',
},
@@ -129,7 +132,9 @@ export const SystemPropertyTypes = {
filterMethod: { [key: string]: I18nString };
filterValue: React.FC<{
filter: FilterParams;
onChange: (filter: FilterParams) => void;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}>;
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 { useEffect, useRef } from 'react';
export const SharedFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
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 (
<Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={
<>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
value: 'true',
});
@@ -25,7 +42,7 @@ export const SharedFilterValue = ({
</MenuItem>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
value: 'false',
});

View File

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

View File

@@ -2,17 +2,26 @@ import {
Divider,
IconButton,
Menu,
MenuItem,
type MenuRef,
RowInput,
Scrollable,
} from '@affine/component';
import { TagService, useDeleteTagConfirmModal } from '@affine/core/modules/tag';
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 clsx from 'clsx';
import { clamp } from 'lodash-es';
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 { ConfigModal } from '../mobile';
@@ -44,6 +53,8 @@ export interface TagsInlineEditorProps extends TagsEditorProps {
modalMenu?: boolean;
menuClassName?: string;
style?: React.CSSProperties;
ref?: React.Ref<MenuRef>;
onEditorClose?: () => void;
}
type TagOption = TagLike | { readonly create: true; readonly value: string };
@@ -269,10 +280,17 @@ export const TagsEditor = ({
placeholder="Type here ..."
/>
</InlineTagList>
{BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.tagDivider} />
<MenuItem
className={styles.tagsEditorDoneButton}
prefixIcon={<DoneIcon />}
/>
)}
</div>
{BUILD_CONFIG.isMobileEdition ? null : (
<Divider size="thinner" className={styles.tagDivider} />
)}
<div className={styles.tagsEditorTagsSelector}>
<div className={styles.tagsEditorTagsSelectorHeader}>
{t['com.affine.page-properties.tags.selector-header-title']()}
@@ -350,10 +368,25 @@ const MobileInlineEditor = ({
className,
title,
style,
onEditorClose,
ref,
...props
}: TagsInlineEditorProps) => {
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 selectedTags = useMemo(() => {
return props.selectedTags
@@ -366,7 +399,10 @@ const MobileInlineEditor = ({
title={title}
open={editing}
onOpenChange={setEditing}
onBack={() => setEditing(false)}
onBack={() => {
setEditing(false);
onEditorClose?.();
}}
>
<TagsEditor {...props} />
</ConfigModal>
@@ -394,6 +430,8 @@ const DesktopTagsInlineEditor = ({
modalMenu,
menuClassName,
style,
ref,
onEditorClose,
...props
}: TagsInlineEditorProps) => {
const empty = !props.selectedTags || props.selectedTags.length === 0;
@@ -404,6 +442,7 @@ const DesktopTagsInlineEditor = ({
}, [props.selectedTags, props.tags]);
return (
<Menu
ref={ref}
contentOptions={{
side: 'bottom',
align: 'start',
@@ -416,6 +455,7 @@ const DesktopTagsInlineEditor = ({
}}
rootOptions={{
modal: modalMenu,
onClose: onEditorClose,
}}
items={<TagsEditor {...props} />}
>
@@ -447,6 +487,8 @@ export const TagsInlineEditor = BUILD_CONFIG.isMobileEdition
export const WorkspaceTagsInlineEditor = ({
selectedTags,
onDeselectTag,
ref,
onEditorClose,
...otherProps
}: Omit<
TagsInlineEditorProps,
@@ -505,6 +547,8 @@ export const WorkspaceTagsInlineEditor = ({
onCreateTag={onCreateTag}
onDeleteTag={onDeleteTag}
onTagChange={onTagChange}
ref={ref}
onEditorClose={onEditorClose}
{...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 { useI18n } from '@affine/i18n';
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 { StackProperty } from '../explorer/docs-view/stack-property';
@@ -40,18 +46,34 @@ export const CheckboxValue = ({
export const CheckboxFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
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 (
<Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={
<>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
value: 'true',
});
@@ -62,7 +84,7 @@ export const CheckboxFilterValue = ({
</MenuItem>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
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 type { FilterParams } from '@affine/core/modules/collection-rules';
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 { useLiveData, useService } from '@toeverything/infra';
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 type { GroupHeaderProps } from '../explorer/types';
@@ -100,12 +100,23 @@ export const UpdatedByValue = () => {
export const CreatedByUpdatedByFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange?: (filter: FilterParams) => void;
}) => {
const t = useI18n();
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
const selected = useMemo(
() => filter.value?.split(',').filter(Boolean) ?? [],
@@ -114,7 +125,7 @@ export const CreatedByUpdatedByFilterValue = ({
const handleChange = useCallback(
(selected: string[]) => {
onChange({
onChange?.({
...filter,
value: selected.join(','),
});
@@ -131,6 +142,8 @@ export const CreatedByUpdatedByFilterValue = ({
}
selected={selected}
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 { i18nTime, useI18n } from '@affine/i18n';
import { DateTimeIcon } from '@blocksuite/icons/rc';
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 { StackProperty } from '../explorer/docs-view/stack-property';
import type { DocListPropertyProps, GroupHeaderProps } from '../explorer/types';
import { FilterOptionsGroup } from '../filter/options';
import type { PropertyValueProps } from '../properties/types';
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,
isDraft,
onDraftCompleted,
onChange,
}: {
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 values = value?.split(',') ?? [];
const displayDates =
values.map(t => i18nTime(t, { absolute: { accuracy: 'day' } })) ?? [];
const handleChange = useCallback(
(date: string) => {
onChange({
onChange?.({
...filter,
value: date,
});
@@ -87,56 +166,90 @@ export const DateFilterValue = ({
[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' ? (
<Menu
items={
<DatePicker value={values[0] || undefined} onChange={handleChange} />
}
>
{displayDates[0] ? (
<span>{displayDates[0]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
<DateFilterValueAfterBefore
filter={filter}
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
onChange={onChange}
/>
) : filter.method === 'between' ? (
<>
<Menu
items={
<DatePicker
value={values[0] || undefined}
<FilterOptionsGroup
isDraft={isDraft}
onDraftCompleted={onDraftCompleted}
items={[
({ onDraftCompleted, menuRef }) => (
<DateSelectorMenu
ref={menuRef}
value={values[0]}
onChange={value => handleChange(`${value},${values[1] || ''}`)}
onClose={onDraftCompleted}
/>
}
>
{displayDates[0] ? (
<span>{displayDates[0]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
<span style={{ color: cssVarV2('text/placeholder') }}>&nbsp;-&nbsp;</span>
<Menu
items={
<DatePicker
value={values[1] || undefined}
),
<span key="between" style={{ color: cssVarV2('text/placeholder') }}>
&nbsp;-&nbsp;
</span>,
({ onDraftCompleted, menuRef }) => (
<DateSelectorMenu
ref={menuRef}
value={values[1]}
onChange={value => handleChange(`${values[0] || ''},${value}`)}
onClose={onDraftCompleted}
/>
}
>
{displayDates[1] ? (
<span>{displayDates[1]}</span>
) : (
<span style={{ color: cssVarV2('text/placeholder') }}>
{t['com.affine.filter.empty']()}
</span>
)}
</Menu>
</>
),
]}
></FilterOptionsGroup>
) : undefined;
};

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import {
DatePicker,
Menu,
MenuItem,
type MenuRef,
PropertyValue,
} from '@affine/component';
import { MobileJournalConflictList } from '@affine/core/mobile/pages/workspace/detail/menu/journal-conflicts';
@@ -182,18 +183,34 @@ export const JournalValue = ({ readonly }: PropertyValueProps) => {
export const JournalFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
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 (
<Menu
ref={menuRef}
rootOptions={{
onClose: onDraftCompleted,
}}
items={
<>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
value: 'true',
});
@@ -204,7 +221,7 @@ export const JournalFilterValue = ({
</MenuItem>
<MenuItem
onClick={() => {
onChange({
onChange?.({
...filter,
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 DocRecord, DocService } from '@affine/core/modules/doc';
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 { useLiveData, useService } from '@toeverything/infra';
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 { StackProperty } from '../explorer/docs-view/stack-property';
@@ -51,14 +51,25 @@ export const TagsValue = ({ readonly }: PropertyValueProps) => {
export const TagsFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
filter: FilterParams;
isDraft?: boolean;
onDraftCompleted?: () => void;
onChange: (filter: FilterParams) => void;
}) => {
const t = useI18n();
const tagService = useService(TagService);
const allTagMetas = useLiveData(tagService.tagList.tagMetas$);
const menuRef = useRef<MenuRef>(null);
useEffect(() => {
if (isDraft) {
menuRef.current?.changeOpen(true);
}
}, [isDraft]);
const selectedTags = useMemo(
() =>
@@ -98,6 +109,8 @@ export const TagsFilterValue = ({
onSelectTag={handleSelectTag}
onDeselectTag={handleDeselectTag}
tagMode="inline-tag"
ref={menuRef}
onEditorClose={onDraftCompleted}
/>
) : 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 { useI18n } from '@affine/i18n';
import { TextIcon, TextTypeIcon } from '@blocksuite/icons/rc';
@@ -177,15 +177,26 @@ export const TextValue = BUILD_CONFIG.isMobileWeb
export const TextFilterValue = ({
filter,
isDraft,
onDraftCompleted,
onChange,
}: {
filter: FilterParams;
onChange: (filter: FilterParams) => void;
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 || '');
@@ -193,7 +204,7 @@ export const TextFilterValue = ({
const submitTempValue = useCallback(() => {
if (tempValue !== (filter.value || '')) {
onChange({
onChange?.({
...filter,
value: tempValue,
});
@@ -216,9 +227,11 @@ export const TextFilterValue = ({
return filter.method !== 'is-not-empty' && filter.method !== 'is-empty' ? (
<Menu
ref={menuRef}
rootOptions={{
open: valueMenuOpen,
onOpenChange: setValueMenuOpen,
onClose: onDraftCompleted,
}}
contentOptions={{
onPointerDownOutside: submitTempValue,

View File

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

View File

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