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) => ( + { + onSelect(option.key); + menu.hide(); + }} + selected={selectedKeys.includes(option.key)} + {...menu} + > + {option.icon && {option.icon}} + {option.note ? ( + + {option.label} + {option.note} + + ) : ( + option.label + )} + + ), + [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) => ( - { - onSelect(option.key); - menu.hide(); - }} - selected={selectedKeys.includes(option.key)} - {...menu} - > - {option.icon && {option.icon}} - {option.note ? ( - - {option.label} - {option.note} - - ) : ( - option.label - )} - - ))} + + : 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",