mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 08:38:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
)),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
75
packages/frontend/core/src/components/filter/options.tsx
Normal file
75
packages/frontend/core/src/components/filter/options.tsx
Normal 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;
|
||||
});
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />}
|
||||
>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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'>;
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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') }}> - </span>
|
||||
<Menu
|
||||
items={
|
||||
<DatePicker
|
||||
value={values[1] || undefined}
|
||||
),
|
||||
<span key="between" style={{ color: cssVarV2('text/placeholder') }}>
|
||||
-
|
||||
</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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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'>;
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user