diff --git a/app/components/ArrowKeyNavigation.tsx b/app/components/ArrowKeyNavigation.tsx
index 9a7f693bdc..ffc8375aa2 100644
--- a/app/components/ArrowKeyNavigation.tsx
+++ b/app/components/ArrowKeyNavigation.tsx
@@ -19,7 +19,7 @@ function ArrowKeyNavigation(
return;
}
- if (ev.key === "Escape") {
+ if (ev.key === "Escape" || ev.key === "Backspace") {
ev.preventDefault();
onEscape(ev);
}
diff --git a/app/components/ContextMenu/MenuItem.tsx b/app/components/ContextMenu/MenuItem.tsx
index 97e4cfd30e..2a3e76dd56 100644
--- a/app/components/ContextMenu/MenuItem.tsx
+++ b/app/components/ContextMenu/MenuItem.tsx
@@ -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 && (
-
+
{selected ? : }
-
+
)}
{icon && {icon}}
{children}
@@ -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(MenuItem);
diff --git a/app/components/ContextMenu/index.tsx b/app/components/ContextMenu/index.tsx
index ae77fc8b61..35fe1ee265 100644
--- a/app/components/ContextMenu/index.tsx
+++ b/app/components/ContextMenu/index.tsx
@@ -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) => {
`
border-radius: 6px;
padding: 6px;
min-width: 180px;
- min-height: 44px;
+ min-height: ${(props) => props.minHeight || 44}px;
max-height: 75vh;
font-weight: normal;
diff --git a/app/components/FilterOptions.tsx b/app/components/FilterOptions.tsx
index 537ba1bf96..a66bcc1b6e 100644
--- a/app/components/FilterOptions.tsx
+++ b/app/components/FilterOptions.tsx
@@ -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;
+ fetchQueryOptions?: Record;
};
const FilterOptions = ({
@@ -30,13 +38,20 @@ const FilterOptions = ({
selectedPrefix = "",
className,
onSelect,
+ showFilter,
+ fetchQuery,
+ fetchQueryOptions,
}: Props) => {
+ const { t } = useTranslation();
+ const searchInputRef = React.useRef(null);
+ const listRef = React.useRef(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) => (
+
+ ),
+ [menu, onSelect, selectedKeys]
+ );
+
+ const handleFilter = (ev: React.ChangeEvent) => {
+ 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 (
@@ -53,33 +171,72 @@ const FilterOptions = ({
)}
-
- {options.map((option) => (
-
- ))}
+
+ : undefined}
+ empty={}
+ />
+ {showFilterInput && (
+
+ )}
);
};
+const Empty = () => {
+ const { t } = useTranslation();
+
+ return (
+ <>
+
+
+ {t("No results")}
+
+ >
+ );
+};
+
+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;
diff --git a/app/components/PaginatedList.tsx b/app/components/PaginatedList.tsx
index 7b8fcc267e..dd8ecf474c 100644
--- a/app/components/PaginatedList.tsx
+++ b/app/components/PaginatedList.tsx
@@ -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 = WithTranslation &
@@ -36,6 +36,7 @@ type Props = WithTranslation &
}) => React.ReactNode;
renderHeading?: (name: React.ReactElement | string) => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent) => void;
+ listRef?: React.RefObject;
};
@observer
@@ -196,6 +197,7 @@ class PaginatedList extends React.PureComponent<
onEscape={onEscape}
className={this.props.className}
items={this.itemsToRender}
+ ref={this.props.listRef}
>
{() => {
let previousHeading = "";
@@ -211,7 +213,11 @@ class PaginatedList 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 extends React.PureComponent<
) {
previousHeading = currentHeading;
return (
-
+
{renderHeading(currentHeading)}
{children}
diff --git a/app/components/SearchPopover.tsx b/app/components/SearchPopover.tsx
index 80ac4969ae..0548b91bd1 100644
--- a/app/components/SearchPopover.tsx
+++ b/app/components/SearchPopover.tsx
@@ -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,
});
diff --git a/app/scenes/Search/components/CollectionFilter.tsx b/app/scenes/Search/components/CollectionFilter.tsx
index ca0612d5ea..e7bb01e2a9 100644
--- a/app/scenes/Search/components/CollectionFilter.tsx
+++ b/app/scenes/Search/components/CollectionFilter.tsx
@@ -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 (
);
}
diff --git a/app/scenes/Search/components/DateFilter.tsx b/app/scenes/Search/components/DateFilter.tsx
index 8aed2b70b3..960c932cdb 100644
--- a/app/scenes/Search/components/DateFilter.tsx
+++ b/app/scenes/Search/components/DateFilter.tsx
@@ -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;
};
diff --git a/app/scenes/Search/components/DocumentFilter.tsx b/app/scenes/Search/components/DocumentFilter.tsx
index 6d4a4813d8..943fcf9041 100644
--- a/app/scenes/Search/components/DocumentFilter.tsx
+++ b/app/scenes/Search/components/DocumentFilter.tsx
@@ -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;
};
diff --git a/app/scenes/Search/components/DocumentTypeFilter.tsx b/app/scenes/Search/components/DocumentTypeFilter.tsx
index 7c1b67135b..318d9051f3 100644
--- a/app/scenes/Search/components/DocumentTypeFilter.tsx
+++ b/app/scenes/Search/components/DocumentTypeFilter.tsx
@@ -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;
};
diff --git a/app/scenes/Search/components/UserFilter.tsx b/app/scenes/Search/components/UserFilter.tsx
index de01be699a..ee5ae89ddd 100644
--- a/app/scenes/Search/components/UserFilter.tsx
+++ b/app/scenes/Search/components/UserFilter.tsx
@@ -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
/>
);
}
diff --git a/app/stores/base/Store.ts b/app/stores/base/Store.ts
index 5e470c2106..618eccf939 100644
--- a/app/stores/base/Store.ts
+++ b/app/stores/base/Store.ts
@@ -28,7 +28,7 @@ export enum RPCAction {
Count = "count",
}
-type FetchPageParams = PaginationParams & Record;
+export type FetchPageParams = PaginationParams & Record;
export const PAGINATION_SYMBOL = Symbol.for("pagination");
diff --git a/server/routes/api/users/schema.ts b/server/routes/api/users/schema.ts
index 79f7c9134a..caedad1ad8 100644
--- a/server/routes/api/users/schema.ts
+++ b/server/routes/api/users/schema.ts
@@ -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), {
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index a8142a0109..2cbbf03cfc 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -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. It’s possible that there are other users who have access through {{signinMethods}} but haven’t signed in yet.": "Everyone that has signed into {{appName}} is listed here. It’s possible that there are other users who have access through {{signinMethods}} but haven’t 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",