Add filtering and async loading to search filters (#7597)

* Add input within search filters

* Query users on demand

* Enforce input on collection and user filters

* Improve filter matching, reduce flickering
This commit is contained in:
Tom Moor
2024-09-14 11:12:01 -04:00
committed by GitHub
parent 494ef2a6cd
commit 9373da0da6
14 changed files with 256 additions and 48 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
return;
}
if (ev.key === "Escape") {
if (ev.key === "Escape" || ev.key === "Backspace") {
ev.preventDefault();
onEscape(ev);
}
+12 -2
View File
@@ -6,6 +6,7 @@ import { mergeRefs } from "react-merge-refs";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import Text from "../Text";
import MenuIconWrapper from "./MenuIconWrapper";
@@ -74,9 +75,9 @@ const MenuItem = (
])}
>
{selected !== undefined && (
<MenuIconWrapper aria-hidden>
<SelectedWrapper aria-hidden>
{selected ? <CheckmarkIcon /> : <Spacer />}
</MenuIconWrapper>
</SelectedWrapper>
)}
{icon && <MenuIconWrapper aria-hidden>{icon}</MenuIconWrapper>}
<Title>{children}</Title>
@@ -196,4 +197,13 @@ export const MenuAnchor = styled.a`
${MenuAnchorCSS}
`;
const SelectedWrapper = styled.span`
width: 24px;
height: 24px;
margin-right: 4px;
margin-left: -8px;
flex-shrink: 0;
color: ${s("textSecondary")};
`;
export default React.forwardRef<HTMLAnchorElement, Props>(MenuItem);
+23 -1
View File
@@ -51,6 +51,8 @@ type Props = MenuStateReturn & {
onClick?: (ev: React.MouseEvent) => void;
/** The maximum width of the context menu. */
maxWidth?: number;
/** The minimum height of the context menu. */
minHeight?: number;
children?: React.ReactNode;
};
@@ -135,6 +137,7 @@ type InnerContextMenuProps = MenuStateReturn & {
menuProps: { style?: React.CSSProperties; placement: string };
children: React.ReactNode;
maxWidth?: number;
minHeight?: number;
};
/**
@@ -220,6 +223,7 @@ const InnerContextMenu = (props: InnerContextMenuProps) => {
<Background
dir="auto"
maxWidth={props.maxWidth}
minHeight={props.minHeight}
topAnchor={topAnchor}
rightAnchor={rightAnchor}
ref={backgroundRef}
@@ -257,6 +261,23 @@ export const Position = styled.div`
transition-delay: 250ms;
transition-property: outline-width;
transition-duration: 0;
outline: none;
&:after {
content: "";
position: absolute;
top: 1px;
left: 1px;
right: 1px;
bottom: 1px;
pointer-events: none;
border-radius: 4px;
outline-color: ${s("accent")};
outline-width: initial;
outline-offset: -1px;
outline-style: solid;
}
}
/*
@@ -277,6 +298,7 @@ type BackgroundProps = {
topAnchor?: boolean;
rightAnchor?: boolean;
maxWidth?: number;
minHeight?: number;
theme: DefaultTheme;
};
@@ -288,7 +310,7 @@ export const Background = styled(Scrollable)<BackgroundProps>`
border-radius: 6px;
padding: 6px;
min-width: 180px;
min-height: 44px;
min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
+181 -24
View File
@@ -1,18 +1,23 @@
import deburr from "lodash/deburr";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState, MenuButton } from "reakit/Menu";
import styled from "styled-components";
import { s } from "@shared/styles";
import type { FetchPageParams } from "~/stores/base/Store";
import Button, { Inner } from "~/components/Button";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import Text from "~/components/Text";
import Input, { NativeInput, Outline } from "./Input";
import PaginatedList, { PaginatedItem } from "./PaginatedList";
type TFilterOption = {
interface TFilterOption extends PaginatedItem {
key: string;
label: string;
note?: string;
icon?: React.ReactNode;
};
}
type Props = {
options: TFilterOption[];
@@ -21,6 +26,9 @@ type Props = {
selectedPrefix?: string;
className?: string;
onSelect: (key: string | null | undefined) => void;
showFilter?: boolean;
fetchQuery?: (options: FetchPageParams) => Promise<PaginatedItem[]>;
fetchQueryOptions?: Record<string, string>;
};
const FilterOptions = ({
@@ -30,13 +38,20 @@ const FilterOptions = ({
selectedPrefix = "",
className,
onSelect,
showFilter,
fetchQuery,
fetchQueryOptions,
}: Props) => {
const { t } = useTranslation();
const searchInputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement | null>(null);
const menu = useMenuState({
modal: true,
});
const selectedItems = options.filter((option) =>
selectedKeys.includes(option.key)
);
const [query, setQuery] = React.useState("");
const selectedLabel = selectedItems.length
? selectedItems
@@ -44,6 +59,109 @@ const FilterOptions = ({
.join(", ")
: "";
const renderItem = React.useCallback(
(option: TFilterOption) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
),
[menu, onSelect, selectedKeys]
);
const handleFilter = (ev: React.ChangeEvent<HTMLInputElement>) => {
setQuery(ev.target.value);
};
const filteredOptions = React.useMemo(() => {
const normalizedQuery = deburr(query.toLowerCase());
return query
? options
.filter((option) =>
deburr(option.label).toLowerCase().includes(normalizedQuery)
)
// sort options starting with query first
.sort((a, b) => {
const aStartsWith = deburr(a.label)
.toLowerCase()
.startsWith(normalizedQuery);
const bStartsWith = deburr(b.label)
.toLowerCase()
.startsWith(normalizedQuery);
if (aStartsWith && !bStartsWith) {
return -1;
}
if (!aStartsWith && bStartsWith) {
return 1;
}
return 0;
})
: options;
}, [options, query]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent) => {
if (ev.nativeEvent.isComposing || ev.shiftKey) {
return;
}
switch (ev.key) {
case "Escape":
menu.hide();
break;
case "Enter":
if (filteredOptions.length === 1) {
ev.preventDefault();
onSelect(filteredOptions[0].key);
menu.hide();
}
break;
case "ArrowDown":
ev.preventDefault();
(listRef.current?.firstElementChild as HTMLElement)?.focus();
break;
default:
break;
}
},
[filteredOptions, menu, onSelect]
);
const handleEscapeFromList = React.useCallback((ev: React.KeyboardEvent) => {
searchInputRef.current?.focus();
if (ev.key === "Backspace") {
setQuery((prev) => prev.slice(0, -1));
}
}, []);
React.useEffect(() => {
if (menu.visible) {
searchInputRef.current?.focus();
} else {
setQuery("");
}
}, [menu.visible]);
const showFilterInput = showFilter || options.length > 10;
return (
<div>
<MenuButton {...menu}>
@@ -53,33 +171,72 @@ const FilterOptions = ({
</StyledButton>
)}
</MenuButton>
<ContextMenu aria-label={defaultLabel} {...menu}>
{options.map((option) => (
<MenuItem
key={option.key}
onClick={() => {
onSelect(option.key);
menu.hide();
}}
selected={selectedKeys.includes(option.key)}
{...menu}
>
{option.icon && <Icon>{option.icon}</Icon>}
{option.note ? (
<LabelWithNote>
{option.label}
<Note>{option.note}</Note>
</LabelWithNote>
) : (
option.label
)}
</MenuItem>
))}
<ContextMenu aria-label={defaultLabel} minHeight={66} {...menu}>
<PaginatedList
listRef={listRef}
options={{ query, ...fetchQueryOptions }}
items={filteredOptions}
fetch={fetchQuery}
renderItem={renderItem}
onEscape={handleEscapeFromList}
heading={showFilterInput ? <Spacer /> : undefined}
empty={<Empty />}
/>
{showFilterInput && (
<SearchInput
ref={searchInputRef}
value={query}
onChange={handleFilter}
onKeyDown={handleKeyDown}
placeholder={`${t("Filter")}`}
autoFocus
/>
)}
</ContextMenu>
</div>
);
};
const Empty = () => {
const { t } = useTranslation();
return (
<>
<Spacer />
<Text size="small" type="tertiary" style={{ marginLeft: 6 }}>
{t("No results")}
</Text>
</>
);
};
const Spacer = styled.div`
height: 30px;
`;
const SearchInput = styled(Input)`
position: absolute;
width: 100%;
border: none;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
overflow: hidden;
margin: 0;
top: 0;
left: 0;
right: 0;
${Outline} {
border: none;
border-radius: 0;
border-bottom: 1px solid ${s("inputBorder")};
}
${NativeInput} {
font-size: 14px;
}
`;
const Note = styled(Text)`
display: block;
margin: 2px 0;
+12 -4
View File
@@ -13,9 +13,9 @@ import withStores from "~/components/withStores";
import { dateToHeading } from "~/utils/date";
export interface PaginatedItem {
id: string;
createdAt?: string;
id?: string;
updatedAt?: string;
createdAt?: string;
}
type Props<T> = WithTranslation &
@@ -36,6 +36,7 @@ type Props<T> = WithTranslation &
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
listRef?: React.RefObject<HTMLDivElement>;
};
@observer
@@ -196,6 +197,7 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
ref={this.props.listRef}
>
{() => {
let previousHeading = "";
@@ -211,7 +213,11 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
// Our models have standard date fields, updatedAt > createdAt.
// Get what a heading would look like for this item
const currentDate =
item.updatedAt || item.createdAt || previousHeading;
"updatedAt" in item && item.updatedAt
? item.updatedAt
: "createdAt" in item && item.createdAt
? item.createdAt
: previousHeading;
const currentHeading = dateToHeading(
currentDate,
this.props.t,
@@ -227,7 +233,9 @@ class PaginatedList<T extends PaginatedItem> extends React.PureComponent<
) {
previousHeading = currentHeading;
return (
<React.Fragment key={item.id}>
<React.Fragment
key={"id" in item && item.id ? item.id : index}
>
{renderHeading(currentHeading)}
{children}
</React.Fragment>
+4 -4
View File
@@ -9,7 +9,7 @@ import Empty from "~/components/Empty";
import { Outline } from "~/components/Input";
import InputSearch from "~/components/InputSearch";
import Placeholder from "~/components/List/Placeholder";
import PaginatedList, { PaginatedItem } from "~/components/PaginatedList";
import PaginatedList from "~/components/PaginatedList";
import Popover from "~/components/Popover";
import { id as bodyContentId } from "~/components/SkipNavContent";
import useKeyDown from "~/hooks/useKeyDown";
@@ -36,11 +36,11 @@ function SearchPopover({ shareId }: Props) {
const { show, hide } = popover;
const [searchResults, setSearchResults] = React.useState<
PaginatedItem[] | undefined
SearchResult[] | undefined
>();
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
PaginatedItem[] | undefined
SearchResult[] | undefined
>(searchResults);
React.useEffect(() => {
@@ -54,7 +54,7 @@ function SearchPopover({ shareId }: Props) {
const performSearch = React.useCallback(
async ({ query, ...options }) => {
if (query?.length > 0) {
const response: PaginatedItem[] = await documents.search(query, {
const response = await documents.search(query, {
shareId,
...options,
});
@@ -5,7 +5,9 @@ import FilterOptions from "~/components/FilterOptions";
import useStores from "~/hooks/useStores";
type Props = {
/** The currently selected collection ID */
collectionId: string | undefined;
/** Callback to call when a collection is selected */
onSelect: (key: string | undefined) => void;
};
@@ -26,6 +28,7 @@ function CollectionFilter(props: Props) {
...collectionOptions,
];
}, [collections.orderedData, t]);
return (
<FilterOptions
options={options}
@@ -33,6 +36,7 @@ function CollectionFilter(props: Props) {
onSelect={onSelect}
defaultLabel={t("Any collection")}
selectedPrefix={`${t("Collection")}:`}
showFilter
/>
);
}
+3 -1
View File
@@ -4,7 +4,9 @@ import { DateFilter as TDateFilter } from "@shared/types";
import FilterOptions from "~/components/FilterOptions";
type Props = {
dateFilter: string | null | undefined;
/** The selected date filter */
dateFilter?: string | null;
/** Callback when a date filter is selected */
onSelect: (key: TDateFilter) => void;
};
@@ -6,7 +6,9 @@ import { StyledButton } from "~/components/FilterOptions";
import Tooltip from "~/components/Tooltip";
type Props = {
/** The currently selected document */
document: Document;
/** Callback to remove the document filter */
onClick: React.MouseEventHandler;
};
@@ -4,7 +4,9 @@ import { StatusFilter as TStatusFilter } from "@shared/types";
import FilterOptions from "~/components/FilterOptions";
type Props = {
/** The selected status filters */
statusFilter: TStatusFilter[];
/** Callback when a status filter is selected */
onSelect: (option: { statusFilter: TStatusFilter[] }) => void;
};
+7 -6
View File
@@ -8,21 +8,19 @@ import FilterOptions from "~/components/FilterOptions";
import useStores from "~/hooks/useStores";
type Props = {
/** The currently selected user ID */
userId: string | undefined;
/** Callback to call when a user is selected */
onSelect: (key: string | undefined) => void;
};
const fetchQueryOptions = { sort: "name", direction: "ASC" };
function UserFilter(props: Props) {
const { onSelect, userId } = props;
const { t } = useTranslation();
const { users } = useStores();
React.useEffect(() => {
void users.fetchPage({
limit: 100,
});
}, [users]);
const options = React.useMemo(() => {
const userOptions = users.all.map((user) => ({
key: user.id,
@@ -46,6 +44,9 @@ function UserFilter(props: Props) {
onSelect={onSelect}
defaultLabel={t("Any author")}
selectedPrefix={`${t("Author")}:`}
fetchQuery={users.fetchPage}
fetchQueryOptions={fetchQueryOptions}
showFilter
/>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export enum RPCAction {
Count = "count",
}
type FetchPageParams = PaginationParams & Record<string, any>;
export type FetchPageParams = PaginationParams & Record<string, any>;
export const PAGINATION_SYMBOL = Symbol.for("pagination");
+2 -2
View File
@@ -9,13 +9,13 @@ const BaseIdSchema = z.object({
export const UsersListSchema = z.object({
body: z.object({
/** Groups sorting direction */
/** Users sorting direction */
direction: z
.string()
.optional()
.transform((val) => (val !== "ASC" ? "DESC" : val)),
/** Groups sorting column */
/** Users sorting column */
sort: z
.string()
.refine((val) => Object.keys(User.getAttributes()).includes(val), {
+2 -2
View File
@@ -237,6 +237,8 @@
"You will receive an email when it's complete.": "You will receive an email when it's complete.",
"Include attachments": "Include attachments",
"Including uploaded images and files in the exported data": "Including uploaded images and files in the exported data",
"Filter": "Filter",
"No results": "No results",
"{{ count }} member": "{{ count }} member",
"{{ count }} member_plural": "{{ count }} members",
"Group members": "Group members",
@@ -349,7 +351,6 @@
"Installation": "Installation",
"Unstar document": "Unstar document",
"Star document": "Star document",
"No results": "No results",
"Previous page": "Previous page",
"Next page": "Next page",
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
@@ -900,7 +901,6 @@
"Enterprise": "Enterprise",
"Recent imports": "Recent imports",
"Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.": "Everyone that has signed into {{appName}} is listed here. Its possible that there are other users who have access through {{signinMethods}} but havent signed in yet.",
"Filter": "Filter",
"Receive a notification whenever a new document is published": "Receive a notification whenever a new document is published",
"Document updated": "Document updated",
"Receive a notification when a document you are subscribed to is edited": "Receive a notification when a document you are subscribed to is edited",