diff --git a/packages/frontend/component/src/ui/menu/desktop/root.tsx b/packages/frontend/component/src/ui/menu/desktop/root.tsx index 6c91d97fb..0509c6af1 100644 --- a/packages/frontend/component/src/ui/menu/desktop/root.tsx +++ b/packages/frontend/component/src/ui/menu/desktop/root.tsx @@ -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 ( void; +} + export interface MenuProps { children: ReactNode; items: ReactNode; title?: string; portalOptions?: Omit; - rootOptions?: Omit; + rootOptions?: Omit & { onClose?: () => void }; contentOptions?: Omit; contentWrapperStyle?: CSSProperties; noPortal?: boolean; + ref?: React.Ref; } export interface MenuItemProps diff --git a/packages/frontend/component/src/ui/menu/mobile/root.tsx b/packages/frontend/component/src/ui/menu/mobile/root.tsx index 1e15bfe23..3f6083750 100644 --- a/packages/frontend/component/src/ui/menu/mobile/root.tsx +++ b/packages/frontend/component/src/ui/menu/mobile/root.tsx @@ -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([]); 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(); diff --git a/packages/frontend/core/src/components/filter/conditions/condition.tsx b/packages/frontend/core/src/components/filter/conditions/condition.tsx index ccd5dfad9..43d92a47f 100644 --- a/packages/frontend/core/src/components/filter/conditions/condition.tsx +++ b/packages/frontend/core/src/components/filter/conditions/condition.tsx @@ -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 &&
{icon}
} {name} - {methods && ( - ( - { - onChange?.({ - ...filter, - method, - }); - }} - selected={filter.method === method} - key={method} - > - {name} - - ))} - > -
- {methods.find(([method]) => method === filter.method)?.[1] ?? - 'unknown'} -
-
- )} - {value && ( -
- {value} -
- )} + { + return ( + ( + { + onChange?.({ + ...filter, + method, + }); + }} + selected={filter.method === method} + key={method} + > + {name} + + ))} + > +
+ {methods.find( + ([method]) => method === filter.method + )?.[1] ?? 'unknown'} +
+
+ ); + }), + Value && + (({ isDraft, onDraftCompleted }) => ( +
+ +
+ )), + ]} + /> ); }; diff --git a/packages/frontend/core/src/components/filter/conditions/property.tsx b/packages/frontend/core/src/components/filter/conditions/property.tsx index ae72dcdb9..6d38a7679 100644 --- a/packages/frontend/core/src/components/filter/conditions/property.tsx +++ b/packages/frontend/core/src/components/filter/conditions/property.tsx @@ -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 ; + return ( + + ); } return ( } name={} methods={Object.entries(methods).map(([key, i18nKey]) => [ key, t.t(i18nKey as string), ])} - value={Value && } + value={Value} onChange={onChange} /> ); diff --git a/packages/frontend/core/src/components/filter/conditions/system.tsx b/packages/frontend/core/src/components/filter/conditions/system.tsx index 122a4ad4f..29d14df8e 100644 --- a/packages/frontend/core/src/components/filter/conditions/system.tsx +++ b/packages/frontend/core/src/components/filter/conditions/system.tsx @@ -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 ; + return ( + + ); } const methods = type.filterMethod; @@ -31,12 +41,14 @@ export const SystemFilterCondition = ({ } + 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={Value} onChange={onChange} /> ); diff --git a/packages/frontend/core/src/components/filter/conditions/unknown.tsx b/packages/frontend/core/src/components/filter/conditions/unknown.tsx index 6fffcd820..eb2964c0c 100644 --- a/packages/frontend/core/src/components/filter/conditions/unknown.tsx +++ b/packages/frontend/core/src/components/filter/conditions/unknown.tsx @@ -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 ( void; onDelete: () => void; onChange: (filter: FilterParams) => void; }) => { const type = filter.type; + + const Condition = + type === 'property' + ? PropertyFilterCondition + : type === 'system' + ? SystemFilterCondition + : null; + return ( -
- {type === 'property' ? ( - - ) : type === 'system' ? ( - +
+ {Condition ? ( + ) : null}
diff --git a/packages/frontend/core/src/components/filter/filters.tsx b/packages/frontend/core/src/components/filter/filters.tsx index 2518df675..8f88f50fa 100644 --- a/packages/frontend/core/src/components/filter/filters.tsx +++ b/packages/frontend/core/src/components/filter/filters.tsx @@ -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( + 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(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 (
{filters.map((filter, index) => { @@ -39,11 +68,21 @@ export const Filters = ({ /> ); })} - { - onChange?.(filters.concat(filter)); - }} - /> + {draftFilter && ( + { + setDraftFilter(null); + }} + onChange={filter => { + setDraftFilter(filter); + }} + onDraftCompleted={handleDraftCompleted} + /> + )} + +
); }; diff --git a/packages/frontend/core/src/components/filter/options.tsx b/packages/frontend/core/src/components/filter/options.tsx new file mode 100644 index 000000000..98e144717 --- /dev/null +++ b/packages/frontend/core/src/components/filter/options.tsx @@ -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; + }) => 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; + }); +}; diff --git a/packages/frontend/core/src/components/filter/styles.css.ts b/packages/frontend/core/src/components/filter/styles.css.ts index a90ecf543..a5e678a86 100644 --- a/packages/frontend/core/src/components/filter/styles.css.ts +++ b/packages/frontend/core/src/components/filter/styles.css.ts @@ -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({ diff --git a/packages/frontend/core/src/components/member-selector/index.tsx b/packages/frontend/core/src/components/member-selector/index.tsx index fc5ee2012..8d2b1ae87 100644 --- a/packages/frontend/core/src/components/member-selector/index.tsx +++ b/packages/frontend/core/src/components/member-selector/index.tsx @@ -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; + onEditorClose?: () => void; } interface MemberSelectItemProps { @@ -219,9 +237,15 @@ export const MemberSelector = ({ /> {BUILD_CONFIG.isMobileEdition ? null : ( - + } + /> )}
+ {BUILD_CONFIG.isMobileEdition ? null : ( + + )}
{ 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?.(); + }} > @@ -303,11 +345,14 @@ const DesktopMemberSelectorInline = ({ menuClassName, style, selected, + ref, + onEditorClose, ...props }: MemberSelectorInlineProps) => { const empty = !selected || selected.length === 0; return ( } > diff --git a/packages/frontend/core/src/components/member-selector/styles.css.ts b/packages/frontend/core/src/components/member-selector/styles.css.ts index 64c962e3d..85ec5bafb 100644 --- a/packages/frontend/core/src/components/member-selector/styles.css.ts +++ b/packages/frontend/core/src/components/member-selector/styles.css.ts @@ -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: { diff --git a/packages/frontend/core/src/components/system-property-types/favorite.tsx b/packages/frontend/core/src/components/system-property-types/favorite.tsx index 6954c1c37..b11b04f54 100644 --- a/packages/frontend/core/src/components/system-property-types/favorite.tsx +++ b/packages/frontend/core/src/components/system-property-types/favorite.tsx @@ -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(null); + + useEffect(() => { + if (isDraft) { + menuRef.current?.changeOpen(true); + } + }, [isDraft]); + return ( { - onChange({ + onChange?.({ ...filter, value: 'true', }); @@ -25,7 +42,7 @@ export const FavoriteFilterValue = ({ { - onChange({ + onChange?.({ ...filter, value: 'false', }); diff --git a/packages/frontend/core/src/components/system-property-types/index.ts b/packages/frontend/core/src/components/system-property-types/index.ts index 0a0067b95..ba5a09ae8 100644 --- a/packages/frontend/core/src/components/system-property-types/index.ts +++ b/packages/frontend/core/src/components/system-property-types/index.ts @@ -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; /** diff --git a/packages/frontend/core/src/components/system-property-types/shared.tsx b/packages/frontend/core/src/components/system-property-types/shared.tsx index 073134a54..cb7db2559 100644 --- a/packages/frontend/core/src/components/system-property-types/shared.tsx +++ b/packages/frontend/core/src/components/system-property-types/shared.tsx @@ -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(null); + + useEffect(() => { + if (isDraft) { + menuRef.current?.changeOpen(true); + } + }, [isDraft]); + return ( { - onChange({ + onChange?.({ ...filter, value: 'true', }); @@ -25,7 +42,7 @@ export const SharedFilterValue = ({ { - onChange({ + onChange?.({ ...filter, value: 'false', }); diff --git a/packages/frontend/core/src/components/tags/styles.css.ts b/packages/frontend/core/src/components/tags/styles.css.ts index db280270b..04753d27d 100644 --- a/packages/frontend/core/src/components/tags/styles.css.ts +++ b/packages/frontend/core/src/components/tags/styles.css.ts @@ -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'), }); diff --git a/packages/frontend/core/src/components/tags/tags-editor.tsx b/packages/frontend/core/src/components/tags/tags-editor.tsx index ec8dc081d..b327eeeac 100644 --- a/packages/frontend/core/src/components/tags/tags-editor.tsx +++ b/packages/frontend/core/src/components/tags/tags-editor.tsx @@ -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; + onEditorClose?: () => void; } type TagOption = TagLike | { readonly create: true; readonly value: string }; @@ -269,10 +280,17 @@ export const TagsEditor = ({ placeholder="Type here ..." /> + {BUILD_CONFIG.isMobileEdition ? null : ( - + } + /> )}
+ {BUILD_CONFIG.isMobileEdition ? null : ( + + )}
{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?.(); + }} > @@ -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 ( } > @@ -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} /> ); diff --git a/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx b/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx index 78b1c82e1..9195e58ae 100644 --- a/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/checkbox.tsx @@ -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(null); + + useEffect(() => { + if (isDraft) { + menuRef.current?.changeOpen(true); + } + }, [isDraft]); + return ( { - onChange({ + onChange?.({ ...filter, value: 'true', }); @@ -62,7 +84,7 @@ export const CheckboxFilterValue = ({ { - onChange({ + onChange?.({ ...filter, value: 'false', }); diff --git a/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx index 62a30f1b5..5c061868e 100644 --- a/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/created-updated-by.tsx @@ -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(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} /> ); }; diff --git a/packages/frontend/core/src/components/workspace-property-types/date.tsx b/packages/frontend/core/src/components/workspace-property-types/date.tsx index 4eac12523..74f5bb4ff 100644 --- a/packages/frontend/core/src/components/workspace-property-types/date.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/date.tsx @@ -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; + 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 ( + } + > + {value ? ( + {value} + ) : ( + + {t['com.affine.filter.empty']()} + + )} + + ); +}; + +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(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 ( + + ); +}; + +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' ? ( - - } - > - {displayDates[0] ? ( - {displayDates[0]} - ) : ( - - {t['com.affine.filter.empty']()} - - )} - + ) : filter.method === 'between' ? ( - <> - ( + handleChange(`${value},${values[1] || ''}`)} + onClose={onDraftCompleted} /> - } - > - {displayDates[0] ? ( - {displayDates[0]} - ) : ( - - {t['com.affine.filter.empty']()} - - )} - -  -  - +  -  + , + ({ onDraftCompleted, menuRef }) => ( + handleChange(`${values[0] || ''},${value}`)} + onClose={onDraftCompleted} /> - } - > - {displayDates[1] ? ( - {displayDates[1]} - ) : ( - - {t['com.affine.filter.empty']()} - - )} - - + ), + ]} + > ) : undefined; }; diff --git a/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx b/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx index 6bba38df1..947e56fa6 100644 --- a/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/doc-primary-mode.tsx @@ -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(null); + + useEffect(() => { + if (isDraft) { + menuRef.current?.changeOpen(true); + } + }, [isDraft]); return ( { - onChange({ + onChange?.({ ...filter, value: 'page', }); @@ -102,7 +118,7 @@ export const DocPrimaryModeFilterValue = ({ { - onChange({ + onChange?.({ ...filter, value: 'edgeless', }); diff --git a/packages/frontend/core/src/components/workspace-property-types/index.ts b/packages/frontend/core/src/components/workspace-property-types/index.ts index 9ac85630d..1dc0f3ded 100644 --- a/packages/frontend/core/src/components/workspace-property-types/index.ts +++ b/packages/frontend/core/src/components/workspace-property-types/index.ts @@ -324,7 +324,8 @@ export const WorkspacePropertyTypes = { filterMethod?: { [key in WorkspacePropertyFilter]: I18nString }; filterValue?: React.FC<{ filter: FilterParams; - onChange: (filter: FilterParams) => void; + isDraft?: boolean; + onChange?: (filter: FilterParams) => void; }>; defaultFilter?: Omit; /** diff --git a/packages/frontend/core/src/components/workspace-property-types/journal.tsx b/packages/frontend/core/src/components/workspace-property-types/journal.tsx index 2112d5244..5a5a4fb03 100644 --- a/packages/frontend/core/src/components/workspace-property-types/journal.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/journal.tsx @@ -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(null); + + useEffect(() => { + if (isDraft) { + menuRef.current?.changeOpen(true); + } + }, [isDraft]); + return ( { - onChange({ + onChange?.({ ...filter, value: 'true', }); @@ -204,7 +221,7 @@ export const JournalFilterValue = ({ { - onChange({ + onChange?.({ ...filter, value: 'false', }); diff --git a/packages/frontend/core/src/components/workspace-property-types/tags.tsx b/packages/frontend/core/src/components/workspace-property-types/tags.tsx index d8fd43349..0ba8b4cb9 100644 --- a/packages/frontend/core/src/components/workspace-property-types/tags.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/tags.tsx @@ -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(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; }; diff --git a/packages/frontend/core/src/components/workspace-property-types/text.tsx b/packages/frontend/core/src/components/workspace-property-types/text.tsx index 364e3a474..fba7723f0 100644 --- a/packages/frontend/core/src/components/workspace-property-types/text.tsx +++ b/packages/frontend/core/src/components/workspace-property-types/text.tsx @@ -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(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' ? ( { ); const [tempFilters, setTempFilters] = useState(null); + const [tempFiltersInitial, setTempFiltersInitial] = + useState(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} />