Merge pull request #44 from nix-community/feat/autocomplete-suggestions

autocomplete: init
This commit is contained in:
Johannes Kirschbauer 2023-09-21 11:15:15 +02:00 committed by GitHub
commit 5913852836
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 126 additions and 65 deletions

View File

@ -1,50 +1,62 @@
import { Box } from "@mui/system"; import { Box } from "@mui/system";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { PageState } from "../../models/internals"; import { PageState, normalizePath } from "../../models/internals";
import { byType, pipe } from "../../queries"; import { byType, pipe } from "../../queries";
import { DocItem } from "../../models/nix"; import { DocItem } from "../../models/nix";
import { BasicList, BasicListItem } from "../basicList"; import { BasicList, BasicListItem } from "../basicList";
import FunctionItem from "../functionItem/functionItem"; import FunctionItem from "../functionItem/functionItem";
import { SetPageStateVariable } from "../pageContext"; import { SetPageStateVariable } from "../pageContext";
import { useMiniSearch } from "react-minisearch"; import { useMiniSearch } from "react-minisearch";
interface FunctionsProps { interface FunctionsProps {
pageState: PageState; pageState: PageState;
setPageStateVariable: SetPageStateVariable; setPageStateVariable: SetPageStateVariable;
} }
export const commonSearchOptions = {
// allow 22% levenshtein distance (e.g. 2.2 of 10 characters don't match)
fuzzy: 0.22,
// prefer to show builtins first
boostDocument: (id: string, term: string) => {
let boost = 1;
boost *= id.includes("builtins") ? 10 : 1;
boost *= id.includes(term) ? 100 : 1;
return boost;
},
boost: {
id: 1000,
name: 100,
category: 10,
example: 0.5,
fn_type: 10,
description: 1,
},
};
export function NixFunctions(props: FunctionsProps) { export function NixFunctions(props: FunctionsProps) {
const { pageState, setPageStateVariable } = props; const { pageState, setPageStateVariable } = props;
const { data, selected, term, filter } = pageState; const { data, selected, term, filter } = pageState;
const setSelected = setPageStateVariable<string | null>("selected"); const setSelected = setPageStateVariable<string | null>("selected");
const { search, searchResults, rawResults } = useMiniSearch<DocItem>(data, { const minisearch = useMiniSearch<DocItem>(data, {
fields: ["id", "name", "category", "description", "example", "fn_type"], fields: ["id", "name", "category", "description", "example", "fn_type"],
searchOptions: { searchOptions: commonSearchOptions,
// allow 22% levenshtein distance (e.g. 2.2 of 10 characters don't match) idField: "id",
fuzzy: 0.22,
// prefer to show builtins first
boostDocument: (id, term) => {
let boost = 1;
boost *= id.includes("builtins") ? 10 : 1;
boost *= id.includes(term) ? 10 : 1;
return boost;
},
boost: {
id: 1000,
name: 100,
category: 10,
example: 0.5,
fn_type: 10,
description: 1,
},
},
tokenize: (text: string, fieldName): string[] => { tokenize: (text: string, fieldName): string[] => {
let normalizedId = normalizePath(text);
const tokens = [];
if (fieldName === "id") {
tokens.push(text);
tokens.push(normalizedId);
tokens.push(...normalizedId.split("."));
}
//split the text into words //split the text into words
const wordTokens = text.split(/\W/); const wordTokens = text.split(/\W/);
const containsUpper = (w: string) => Boolean(w.match(/[A-Z]/)?.length); const containsUpper = (w: string) => Boolean(w.match(/[A-Z]/)?.length);
const tokens = [ tokens.push(
text,
// include the words itself if they contain upperCharacters // include the words itself if they contain upperCharacters
// mapAttrs -> mapAttrs // mapAttrs -> mapAttrs
...wordTokens.filter(containsUpper), ...wordTokens.filter(containsUpper),
@ -56,15 +68,17 @@ export function NixFunctions(props: FunctionsProps) {
.flat(), .flat(),
// just include lowercase words without further tokenizing // just include lowercase words without further tokenizing
// map -> map // map -> map
...wordTokens.filter((w) => !containsUpper(w)), ...wordTokens.filter((w) => !containsUpper(w))
]; );
return tokens; return tokens.filter(Boolean);
}, },
}); });
const { search, searchResults, rawResults } = minisearch;
//initial site-load is safe to call //initial site-load is safe to call
useEffect(() => { useEffect(() => {
search(term); search(term);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@ -103,7 +117,7 @@ export function NixFunctions(props: FunctionsProps) {
return ( return (
<Box sx={{ ml: { xs: 0, md: 2 } }}> <Box sx={{ ml: { xs: 0, md: 2 } }}>
<BasicList items={preRenderedItems} search={search} /> <BasicList items={preRenderedItems} minisearch={minisearch} />
</Box> </Box>
); );
} }

View File

@ -8,8 +8,9 @@ import { usePageContext } from "../pageContext";
import { useMobile } from "../layout/layout"; import { useMobile } from "../layout/layout";
import { EmptyRecordsPlaceholder } from "../emptyRecordsPlaceholder"; import { EmptyRecordsPlaceholder } from "../emptyRecordsPlaceholder";
import { FunctionOfTheDay } from "../functionOfTheDay"; import { FunctionOfTheDay } from "../functionOfTheDay";
import { Query, SearchOptions } from "minisearch";
import { ViewMode } from "../../models/internals"; import { ViewMode } from "../../models/internals";
import { UseMiniSearch } from "react-minisearch";
import { DocItem } from "../../models/nix";
export type BasicListItem = { export type BasicListItem = {
item: React.ReactNode; item: React.ReactNode;
@ -17,11 +18,12 @@ export type BasicListItem = {
}; };
export type BasicListProps = BasicDataViewProps & { export type BasicListProps = BasicDataViewProps & {
selected?: string | null; selected?: string | null;
search: (query: Query, options?: SearchOptions) => void; minisearch: UseMiniSearch<DocItem>;
}; };
export function BasicList(props: BasicListProps) { export function BasicList(props: BasicListProps) {
const { items, search } = props; const { items, minisearch } = props;
const { search, suggestions, autoSuggest, clearSuggestions } = minisearch;
const { pageState, setPageStateVariable, resetQueries } = usePageContext(); const { pageState, setPageStateVariable, resetQueries } = usePageContext();
const isMobile = useMobile(); const isMobile = useMobile();
const { page, itemsPerPage, FOTD: showFunctionOfTheDay, data } = pageState; const { page, itemsPerPage, FOTD: showFunctionOfTheDay, data } = pageState;
@ -72,6 +74,9 @@ export function BasicList(props: BasicListProps) {
handleClear={handleClear} handleClear={handleClear}
placeholder="search nix functions" placeholder="search nix functions"
handleSearch={handleSearch} handleSearch={handleSearch}
suggestions={suggestions || []}
autoSuggest={autoSuggest}
clearSuggestions={clearSuggestions}
/> />
{showFunctionOfTheDay && ( {showFunctionOfTheDay && (
<FunctionOfTheDay <FunctionOfTheDay

View File

@ -3,12 +3,13 @@ import Paper from "@mui/material/Paper";
import InputBase from "@mui/material/InputBase"; import InputBase from "@mui/material/InputBase";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import ClearIcon from "@mui/icons-material/Clear"; import ClearIcon from "@mui/icons-material/Clear";
import { Box, debounce, Grid, Typography } from "@mui/material"; import { Autocomplete, Box, debounce, Grid, Typography } from "@mui/material";
import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { NixType, nixTypes } from "../../models/nix"; import { NixType, nixTypes } from "../../models/nix";
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import { usePageContext } from "../pageContext"; import { usePageContext } from "../pageContext";
import { SelectOption } from "../selectOption"; import { SelectOption } from "../selectOption";
import { SearchOptions, Suggestion } from "minisearch";
export type Filter = { from: NixType; to: NixType }; export type Filter = { from: NixType; to: NixType };
@ -17,10 +18,21 @@ export interface SearchInputProps {
handleClear: () => void; handleClear: () => void;
handleFilter: (filter: Filter | ((curr: Filter) => Filter)) => void; handleFilter: (filter: Filter | ((curr: Filter) => Filter)) => void;
placeholder: string; placeholder: string;
suggestions: Suggestion[];
autoSuggest: (query: string, options?: SearchOptions) => void;
clearSuggestions: () => void;
} }
export function SearchInput(props: SearchInputProps) { export function SearchInput(props: SearchInputProps) {
const { handleSearch, placeholder, handleFilter, handleClear } = props; const {
handleSearch,
placeholder,
handleFilter,
handleClear,
suggestions,
autoSuggest,
clearSuggestions,
} = props;
const { pageState } = usePageContext(); const { pageState } = usePageContext();
const { filter, term } = pageState; const { filter, term } = pageState;
const [_term, _setTerm] = useState(term); const [_term, _setTerm] = useState(term);
@ -37,14 +49,28 @@ export function SearchInput(props: SearchInputProps) {
const _handleClear = () => { const _handleClear = () => {
_setTerm(""); _setTerm("");
handleClear(); handleClear();
clearSuggestions();
}; };
const handleType = ( const handleType = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement> e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => { ) => {
_setTerm(e.target.value); _setTerm(e.target.value);
autoSuggest(e.target.value, {
fuzzy: 0.25,
fields: ["id", "name", "category"],
});
debouncedSubmit(e.target.value); debouncedSubmit(e.target.value);
}; };
const autoCompleteOptions = useMemo(() => {
const options = suggestions
.slice(0, 5)
.map((s) => s.terms)
.flat();
const sorted = options.sort((a, b) => -b.localeCompare(a));
return [...new Set(sorted)];
}, [suggestions]);
return ( return (
<> <>
<Paper <Paper
@ -65,19 +91,39 @@ export function SearchInput(props: SearchInputProps) {
<IconButton aria-label="clear-button" onClick={_handleClear}> <IconButton aria-label="clear-button" onClick={_handleClear}>
<ClearIcon /> <ClearIcon />
</IconButton> </IconButton>
<InputBase
autoFocus <Autocomplete
sx={{ // disablePortal
ml: 1, // id="combo-box-demo"
flex: 1, options={autoCompleteOptions}
backgroundColor: "paper.main", sx={{ width: "100%" }}
p: 1, onChange={(e, value) => {
handleType({
target: { value: value || "" },
} as React.ChangeEvent<HTMLInputElement>);
}} }}
placeholder={placeholder}
inputProps={{ "aria-label": "search-input" }}
value={_term} value={_term}
onChange={(e) => handleType(e)} renderInput={(params) => (
// <InputBase {...params} {...params.InputProps} />
<InputBase
autoFocus
sx={{
ml: 1,
flex: 1,
backgroundColor: "paper.main",
p: 1,
}}
placeholder={placeholder}
// inputProps={{ "aria-label": "search-input" }}
value={_term}
onChange={(e) => handleType(e)}
{...params}
{...params.InputProps}
endAdornment={undefined}
/>
)}
/> />
<IconButton <IconButton
sx={{ sx={{
p: 1, p: 1,

View File

@ -40,7 +40,6 @@ export const normalizePath = (id: string) => {
const start = substr.slice(0, 1); const start = substr.slice(0, 1);
const end = substr.slice(1); const end = substr.slice(1);
console.log({ start, end });
return start.toUpperCase() + end; return start.toUpperCase() + end;
}) })
.join(""); .join("");

View File

@ -2,26 +2,23 @@ import { MetaData, NixType } from "../models/nix";
import { getTypes } from "./lib"; import { getTypes } from "./lib";
export const byType = export const byType =
({ to, from }: { to: NixType; from: NixType }) => ({ to, from }: { to: NixType; from: NixType }) =>
(data: MetaData): MetaData => { (data: MetaData): MetaData => {
if (to === "any" && from === "any") { if (to === "any" && from === "any") {
return data; return data;
} else { } else {
return data.filter( return data.filter(
// TODO: Implement proper type matching // TODO: Implement proper type matching
({ name, fn_type }) => { ({ name, fn_type }) => {
if (fn_type) { if (fn_type) {
const parsedType = getTypes(name, fn_type); const parsedType = getTypes(name, fn_type);
// if(name === "derivation"){ return (
// console.log({name,parsedType,fn_type}); parsedType.args.includes(from) && parsedType.types.includes(to)
// } );
return ( } else {
parsedType.args.includes(from) && parsedType.types.includes(to) return to === "any" && from === "any";
); }
} else {
return to === "any" && from === "any";
} }
} );
); }
} };
};