feature/deeplinks

* add: deeplinks
* add: enhanced pagination for large datasets
* add more meta tags
* add data to initial state
* remove preview page, instead link directly into the list
This commit is contained in:
Johannes Kirschbauer 2023-01-24 21:02:17 +01:00 committed by GitHub
parent b9a63521af
commit 8679af38a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 509 additions and 629 deletions

View File

@ -0,0 +1 @@
export {NixFunctions as default} from "./nixFunctions";

View File

@ -0,0 +1,56 @@
import { Box } from "@mui/system";
import { useMemo } from "react";
import { PageState } from "../../models/internals";
import { byQuery, byType, pipe } from "../../queries";
import { DocItem } from "../../models/nix";
import { BasicList, BasicListItem } from "../basicList";
import FunctionItem from "../functionItem/functionItem";
import { SetPageStateVariable } from "../pageContext";
interface FunctionsProps {
pageState: PageState;
setPageStateVariable: SetPageStateVariable;
}
export function NixFunctions(props: FunctionsProps) {
const { pageState, setPageStateVariable } = props;
const { data, selected, term, filter } = pageState;
const setSelected = setPageStateVariable<string | null>("selected");
const filteredData = useMemo(
() => pipe(byType(filter), byQuery(term))(data),
[filter, term, data]
);
const preRenderedItems: BasicListItem[] = filteredData.map(
(docItem: DocItem) => {
const key = docItem.id;
return {
item: (
<Box
sx={{
width: "100%",
height: "100%",
}}
onClick={!(selected === key) ? () => setSelected(key) : undefined}
>
<FunctionItem
name={docItem.name}
docItem={docItem}
selected={selected === key}
handleClose={() => setSelected(null)}
/>
</Box>
),
key,
};
}
);
return (
<Box sx={{ ml: { xs: 0, md: 2 } }}>
<BasicList items={preRenderedItems} />
</Box>
);
}

View File

@ -1,103 +0,0 @@
import "@testing-library/jest-dom";
// TODO: important mock zustand!
// State from previous tests leaking into other test cases because zustand is never reset in its own
import * as React from "react";
import { render, screen, act } from "@testing-library/react";
import mockAxios from "jest-mock-axios";
import userEvent from "@testing-library/user-event";
import { ImageList } from "./";
import { QueryClient, QueryClientProvider } from "react-query";
import { SnackbarProvider } from "notistack";
const queryClient = new QueryClient();
const Wrapper = (element: React.ReactNode) => (
<QueryClientProvider client={queryClient}>
<SnackbarProvider>{element}</SnackbarProvider>
</QueryClientProvider>
);
const getImagesResp = [
{
filename: "testfile.iso",
hash: "3993d64556f3ae803986f6d1c2debaa8f5a2a77da70cc35f86af6f99c849fe80",
modify_time: "2022-07-28T07:45:32.331998395Z",
size_bytes: 513,
},
{
filename: "full.img",
hash: "85c6a11c928611caff965aa2fc267b51ad3fc06657d0ee67ae00fec1711e81ef",
modify_time: "2022-07-29T07:33:20.075281078Z",
size_bytes: 1024 * 1024 * 1024,
},
{
filename: "small.img",
hash: "e8edbf1bb6121a38e8ee3a60e34a31887955bc9a292e9ef3bd598e8ccb7fef5a",
modify_time: "2022-07-29T07:34:10.442184606Z",
size_bytes: 1,
},
];
describe("render imageList", () => {
afterEach(() => {
mockAxios.reset();
jest.clearAllMocks();
});
beforeEach(async () => {
await act(async () => {
mockAxios.get.mockResolvedValue({ data: getImagesResp });
render(Wrapper(<ImageList />), {});
});
});
test("list is shown & has proper length", async () => {
const list = await screen.getByRole("list", { name: "image-list" });
const listItems = await screen.getAllByRole("listitem", {
name: "image-entry-item",
});
expect(list).toBeVisible();
expect(listItems).toHaveLength(getImagesResp.length);
expect(mockAxios.get).toBeCalledTimes(1);
});
test("search: a term", async () => {
const searchInput = await screen.getByRole("textbox", {
name: "search-input",
});
const searchButton = await screen.getByRole("button", {
name: "search-button",
});
await userEvent.type(searchInput, ".img");
await userEvent.click(searchButton);
/*
search for filename with ".img" in it
-> should yield:
[ "full.img", "small.img" ]
*/
const listItems = await screen.getAllByRole("listitem", {
name: "image-entry-item",
});
expect(listItems).toHaveLength(2);
expect(listItems[0]).toHaveTextContent("full.img");
expect(listItems[1]).toHaveTextContent("small.img");
});
test("delete an image", async () => {
const deleteButtons = await screen.getAllByRole("button", {
name: "delete-image",
});
const firstDeleteButton = deleteButtons?.[0];
expect(firstDeleteButton).toBeEnabled();
await userEvent.click(firstDeleteButton);
//TODO: dont have delete functionality yet
// expect(mockedDeleteFn).toBeCalled();
});
});

View File

@ -1,72 +1,42 @@
import React, { useState, useMemo, useEffect } from "react";
import {
Box,
IconButton,
List,
ListItem,
Pagination,
Stack,
Typography,
Grid,
Slide,
Collapse,
Grow,
} from "@mui/material";
import { BasicDataItem, BasicDataViewProps } from "../../types/basicDataView";
import React, { useMemo, useState } from "react";
import { Box, List, ListItem, Stack, TablePagination } from "@mui/material";
import { BasicDataViewProps } from "../../types/basicDataView";
import { SearchInput } from "../searchInput";
import Radio from "@mui/material/Radio";
import RadioGroup from "@mui/material/RadioGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import FormControl from "@mui/material/FormControl";
import FormLabel from "@mui/material/FormLabel";
import ClearIcon from "@mui/icons-material/Clear";
import { NixType, nixTypes } from "../../types/nix";
import { Filter } from "../searchInput/searchInput";
import { useRouter } from "next/router";
import { FunctionOfTheDay } from "../functionOfTheDay";
import { usePageContext } from "../pageContext";
import { useMobile } from "../layout/layout";
import { EmptyRecordsPlaceholder } from "../emptyRecordsPlaceholder";
import { FunctionOfTheDay } from "../functionOfTheDay";
export type BasicListItem = {
item: React.ReactNode;
key: string;
};
export type BasicListProps = BasicDataViewProps & {
handleFilter: (filter: Filter | ((curr: Filter) => Filter)) => void;
filter: Filter;
term: string;
selected?: string | null;
itemsPerPage: number;
};
type ViewMode = "explore" | "browse";
export function BasicList(props: BasicListProps) {
const {
items,
pageCount = 1,
itemsPerPage,
handleSearch,
handleFilter,
selected = "",
filter,
term,
} = props;
const [page, setPage] = useState<number>(1);
export function BasicList(props: BasicListProps) {
const { items } = props;
const { pageState, setPageStateVariable, resetQueries } = usePageContext();
const isMobile = useMobile();
const { page, itemsPerPage, filter, term, FOTD, data } = pageState;
const [mode, setMode] = useState<ViewMode>("explore");
const router = useRouter();
useEffect(() => {
const { query } = router;
if (query?.page) {
if (typeof query.page === "string") {
const page = Number(query.page);
setPage(page);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
const setPage = setPageStateVariable<number>("page");
const setTerm = setPageStateVariable<string>("term");
const setFilter = setPageStateVariable<Filter>("filter");
const setItemsPerPage = setPageStateVariable<number>("itemsPerPage");
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setItemsPerPage(parseInt(event.target.value, 10));
setPage(1);
};
const pageItems = useMemo(() => {
const startIdx = (page - 1) * itemsPerPage;
const endIdx = page * itemsPerPage;
@ -74,41 +44,47 @@ export function BasicList(props: BasicListProps) {
}, [page, items, itemsPerPage]);
const handlePageChange = (
_event: React.ChangeEvent<unknown>,
event: React.MouseEvent<HTMLButtonElement> | null,
value: number
) => {
setPage(value);
setPage(value + 1);
};
const handleClear = () => {
resetQueries();
};
const _handleFilter = (filter: Filter | ((curr: Filter) => Filter)) => {
setMode("browse");
handleFilter(filter);
const handleFilter = (filter: Filter | ((curr: Filter) => Filter)) => {
setFilter(filter);
setPage(1);
};
const _handleSearch = (term: string) => {
setMode("browse");
handleSearch && handleSearch(term);
const handleSearch = (term: string) => {
setTerm(term);
setPage(1);
};
const showFunctionExplore =
const showFunctionOfTheDay =
mode === "explore" &&
filter.to === "any" &&
filter.from === "any" &&
term === "";
term === "" &&
FOTD === true;
return (
<Stack>
<SearchInput
handleFilter={_handleFilter}
handleFilter={handleFilter}
handleClear={handleClear}
placeholder="search nix functions"
handleSearch={_handleSearch}
page={page}
clearSearch={() => _handleSearch("")}
handleSearch={handleSearch}
/>
{showFunctionExplore ? (
<FunctionOfTheDay handleClose={() => setMode("browse")} />
{showFunctionOfTheDay ? (
<FunctionOfTheDay
data={data}
handleClose={() => {
setMode("browse");
}}
/>
) : (
<List aria-label="basic-list" sx={{ pt: 0 }}>
{items.length ? (
@ -134,16 +110,20 @@ export function BasicList(props: BasicListProps) {
)}
</List>
)}
{Math.ceil(items.length / itemsPerPage) > 0 && !showFunctionExplore && (
<Pagination
hideNextButton
hidePrevButton
{!showFunctionOfTheDay && (
<TablePagination
component={"div"}
sx={{ display: "flex", justifyContent: "center", mt: 1, mb: 10 }}
count={Math.ceil(items.length / itemsPerPage) || 1}
count={items.length}
color="primary"
page={page}
onChange={handlePageChange}
page={page - 1}
onPageChange={handlePageChange}
rowsPerPage={itemsPerPage}
labelRowsPerPage={"per Page"}
rowsPerPageOptions={[10, 20, 50, 100]}
onRowsPerPageChange={handleChangeRowsPerPage}
showFirstButton={!isMobile}
showLastButton={!isMobile}
/>
)}
</Stack>

View File

@ -0,0 +1,11 @@
.hljs {
padding-left: 0 !important;
padding-top: 0 !important;
}
.hljs .pre {
width: 100%;
}
.hljs .nix {
overflow-x: scroll;
}

View File

@ -0,0 +1,35 @@
import { Box } from "@mui/material";
import { useEffect } from "react";
import Highlight, { HighlightProps } from "react-highlight";
import styles from "./codeHighlight.module.css";
interface CodeHighlightProps {
theme: "light" | "dark";
code: HighlightProps["children"];
}
export const CodeHighlight = (props: CodeHighlightProps) => {
const { theme, code } = props;
useEffect(() => {
if (theme === "dark") {
// @ts-ignore - dont check type of css module
import("highlight.js/styles/github-dark.css");
} else {
// @ts-ignore - dont check type of css module
import("highlight.js/styles/github.css");
}
}, [theme]);
return (
<Box
sx={{
"&.MuiBox-root>pre": {
width: "100%",
marginTop: 0,
},
}}
>
<Highlight className={`nix ${styles.hljs}`}>{code}</Highlight>
</Box>
);
};

View File

@ -0,0 +1 @@
export {CodeHighlight} from "./codeHighlight"

View File

@ -9,7 +9,7 @@ import {
Typography,
} from "@mui/material";
import { useMemo } from "react";
import { DocItem, NixType } from "../../types/nix";
import { DocItem, NixType } from "../../models/nix";
import { Preview } from "../preview/preview";
import StarIcon from "@mui/icons-material/Star";
import ShareIcon from "@mui/icons-material/Share";
@ -31,14 +31,6 @@ export default function FunctionItem(props: FunctionItemProps) {
const { name, docItem, selected, handleClose } = props;
const { fn_type, category, description, id } = docItem;
const { enqueueSnackbar } = useSnackbar();
const [favorites, setfavorites] = useLocalStorage<string[]>(
"personal-favorite",
[]
);
const isFavorit = useMemo(
() => favorites.includes(getKey(docItem)),
[docItem, favorites]
);
const descriptionPreview = useMemo(() => {
const getFirstWords = (s: string) => {
const indexOfDot = s.indexOf(".");
@ -58,25 +50,10 @@ export default function FunctionItem(props: FunctionItemProps) {
}, [description]);
const handleShare = () => {
const queries = [];
const key = getKey(docItem);
if (key) {
queries.push(`fn=${key}`);
}
const handle = `https://noogle.dev/preview?${queries.join("&")}`;
const handle = window.location.href;
navigator.clipboard.writeText(handle);
enqueueSnackbar("link copied to clipboard", { variant: "default" });
};
const handleFavorit = () => {
const key = getKey(docItem);
setfavorites((curr) => {
if (curr.includes(key)) {
return curr.filter((v) => v !== key);
} else {
return [...curr, key];
}
});
};
return (
<Paper
elevation={0}
@ -101,9 +78,6 @@ export default function FunctionItem(props: FunctionItemProps) {
<Stack sx={{ width: "100%" }}>
{!selected && (
<>
<Box sx={{ float: "right", position: "absolute", right: 4 }}>
{isFavorit && <StarIcon />}
</Box>
<ListItemText primary={`${id}`} secondary={category} />
<ListItemText secondary={descriptionPreview} />
<Typography
@ -123,11 +97,6 @@ export default function FunctionItem(props: FunctionItemProps) {
justifyContent: "end",
}}
>
<Tooltip title={`${isFavorit ? "remove" : "add"} favorite`}>
<IconButton onClick={handleFavorit}>
{isFavorit ? <StarIcon /> : <StarBorderIcon />}
</IconButton>
</Tooltip>
<Tooltip title="Share">
<IconButton onClick={handleShare}>
<ShareIcon />

View File

@ -11,10 +11,9 @@ import {
import { useMemo, useState } from "react";
import seedrandom from "seedrandom";
import { data } from "../../models/data";
import { DocItem } from "../../types/nix";
import { Preview } from "../preview/preview";
import ClearIcon from "@mui/icons-material/Clear";
import { DocItem, MetaData } from "../../models/nix";
const date = new Date();
@ -36,18 +35,21 @@ function getRandomIntInclusive(
return Math.floor(generator() * (max - min + 1) + min); // The maximum is inclusive and the minimum is inclusive
}
const todaysIdx = getRandomIntInclusive(0, data.length - 1, rng);
interface FunctionOfTheDayProps {
handleClose: () => void;
data: MetaData;
}
export const FunctionOfTheDay = (props: FunctionOfTheDayProps) => {
const { handleClose } = props;
const { handleClose, data } = props;
const {
palette: { info, error },
} = useTheme();
const todaysIdx = useMemo(
() => getRandomIntInclusive(0, data.length - 1, rng),
[data.length]
);
const [idx, setIdx] = useState<number>(todaysIdx);
const slectedFunction = useMemo(() => data.at(idx) as DocItem, [idx]);
const selectedFunction = useMemo(() => data.at(idx) as DocItem, [idx, data]);
const setNext = () => {
setIdx((curr) => {
@ -101,7 +103,7 @@ export const FunctionOfTheDay = (props: FunctionOfTheDayProps) => {
}
/>
<CardContent>
<Preview docItem={slectedFunction} closeComponent={<></>} />
<Preview docItem={selectedFunction} closeComponent={<></>} />
</CardContent>
<CardActions
sx={{

View File

@ -3,9 +3,10 @@ import {
Typography,
Container,
Link,
useTheme,
IconButton,
Tooltip,
useMediaQuery,
useTheme,
} from "@mui/material";
import { Image } from "../image";
import nixSnowflake from "../../public/nix-snowflake.svg";
@ -15,6 +16,8 @@ export interface LayoutProps {
children: React.ReactNode;
}
export const useMobile = () => useMediaQuery(useTheme().breakpoints.down("md"));
export function Layout(props: LayoutProps) {
const { children } = props;
const theme = useTheme();

View File

@ -0,0 +1,22 @@
import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import nix from "highlight.js/lib/languages/nix";
interface MarkdownPreviewProps {
description: string;
}
export const MarkdownPreview = (props: MarkdownPreviewProps) => {
const { description } = props;
return (
<ReactMarkdown
components={{
h1: "h3",
h2: "h4",
h3: "h5",
}}
rehypePlugins={[[rehypeHighlight, { languages: { nix } }]]}
>
{description}
</ReactMarkdown>
);
};

View File

@ -0,0 +1 @@
export {MarkdownPreview} from "./MarkdownPreview"

View File

@ -0,0 +1,3 @@
export { PageContextProvider, PageContext, usePageContext} from "./pageContext";
export type { SetPageStateVariable } from "./pageContext";

View File

@ -0,0 +1,77 @@
import React, { useState } from "react";
import { useRouter } from "next/router";
import {
InitialPageState,
initialPageState,
PageState,
} from "../../models/internals";
type PageContextType = {
pageState: PageState;
setPageState: React.Dispatch<React.SetStateAction<PageState>>;
setPageStateVariable: SetPageStateVariable;
resetQueries: () => void;
};
export const PageContext = React.createContext<PageContextType>({
pageState: { ...initialPageState, FOTD: true },
setPageState: () => {},
resetQueries: () => {},
setPageStateVariable: function a<T>() {
return () => {};
},
});
interface PageContextProviderProps {
children: React.ReactNode;
pageProps: PageState;
}
export type SetPageStateVariable = <T>(
field: keyof InitialPageState
) => (value: React.SetStateAction<T> | T) => void;
export const PageContextProvider = (props: PageContextProviderProps) => {
const router = useRouter();
const { children, pageProps } = props;
const [pageState, setPageState] = useState<PageState>(pageProps);
function setPageStateVariable<T>(field: keyof InitialPageState) {
return function (value: React.SetStateAction<T> | T) {
{
if (typeof value !== "function") {
setPageState((curr) => {
const query = router.query;
query[field] = JSON.stringify(value);
router.push({ query });
return { ...curr, [field]: value };
});
} else {
const setter = value as Function;
setPageState((curr) => {
const query = router.query;
const newValue = setter(curr[field]);
query[field] = JSON.stringify(newValue);
router.push({ query });
return { ...curr, [field]: newValue };
});
}
}
};
}
function resetQueries() {
if (Object.entries(router.query).length !== 0) {
router.push({ query: undefined });
}
setPageState((curr) => ({ ...curr, ...initialPageState }));
}
return (
<PageContext.Provider
value={{ pageState, setPageState, setPageStateVariable, resetQueries }}
>
{children}
</PageContext.Provider>
);
};
export const usePageContext = () => React.useContext(PageContext);

View File

@ -0,0 +1 @@
export { Preview } from "./preview";

View File

@ -1,11 +0,0 @@
.hljs {
padding-left: 0 !important;
padding-top: 0 !important;
}
.hljs .pre {
width: 100%;
}
.hljs .nix {
overflow-x: scroll;
}

View File

@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React from "react";
import {
Box,
Container,
@ -12,19 +12,13 @@ import {
useTheme,
Link as MuiLink,
} from "@mui/material";
import Highlight from "react-highlight";
import { DocItem } from "../../models/nix";
import CodeIcon from "@mui/icons-material/Code";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import LocalLibraryIcon from "@mui/icons-material/LocalLibrary";
import InputIcon from "@mui/icons-material/Input";
import { DocItem } from "../../types/nix";
import CodeIcon from "@mui/icons-material/Code";
import ReactMarkdown from "react-markdown";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import styles from "./preview.module.css";
import rehypeHighlight from "rehype-highlight";
import nix from "highlight.js/lib/languages/nix";
import Link from "next/link";
import { idText } from "typescript";
import { TrendingUpSharp } from "@mui/icons-material";
import { MarkdownPreview } from "../markdownPreview";
import { CodeHighlight } from "../codeHighlight";
interface PreviewProps {
docItem: DocItem;
@ -37,16 +31,6 @@ export const Preview = (props: PreviewProps) => {
const { name, description, category, example, fn_type, id } = docItem;
const theme = useTheme();
useEffect(() => {
if (theme.palette.mode === "dark") {
// @ts-ignore - dont check type of css module
import("highlight.js/styles/github-dark.css");
} else {
// @ts-ignore - dont check type of css module
import("highlight.js/styles/github.css");
}
}, [theme]);
const prefix = category.split(/([\/.])/gm).at(4) || "builtins";
const libName = category
.match(/(?:[a-zA-Z]*)\.nix/gm)?.[0]
@ -155,33 +139,10 @@ export const Preview = (props: PreviewProps) => {
>
{Array.isArray(description)
? description.map((d, idx) => (
<ReactMarkdown
key={idx}
components={{
h1: "h3",
h2: "h4",
h3: "h5",
}}
rehypePlugins={[
[rehypeHighlight, { languages: { nix } }],
]}
>
{d}
</ReactMarkdown>
<MarkdownPreview key={idx} description={d} />
))
: description && (
<ReactMarkdown
components={{
h1: "h3",
h2: "h4",
h3: "h5",
}}
rehypePlugins={[
[rehypeHighlight, { languages: { nix } }],
]}
>
{description}
</ReactMarkdown>
<MarkdownPreview description={description} />
)}
</Container>
}
@ -248,17 +209,7 @@ export const Preview = (props: PreviewProps) => {
</Typography>
}
secondary={
<Box
sx={{
"&.MuiBox-root>pre": {
width: "100%",
},
}}
>
<Highlight className={`nix ${styles.hljs}`}>
{example}
</Highlight>
</Box>
<CodeHighlight code={example} theme={theme.palette.mode} />
}
/>
</ListItem>

View File

@ -1,56 +0,0 @@
import "@testing-library/jest-dom";
import * as React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchInput } from "./";
describe("render search", () => {
const mockedSearch = jest.fn();
mockedSearch.mockImplementation((term: string) => {
return term;
});
const mockedClear = jest.fn();
mockedClear.mockImplementation(() => {
return undefined;
});
beforeEach(() => {
render(
<SearchInput
handleSearch={mockedSearch}
clearSearch={mockedClear}
placeholder={"Search"}
/>
);
});
test("renders all elements from the searchbar", async () => {
const inputBar = screen.getByRole("textbox");
const clearButton = screen.getByRole("button", { name: "clear-button" });
const searchButton = screen.getByRole("button", { name: "search-button" });
expect(inputBar).toBeVisible();
expect(searchButton).toBeVisible();
expect(clearButton).toBeVisible();
const term = "search for term";
await userEvent.type(inputBar, term);
await userEvent.click(searchButton);
expect(mockedSearch).toBeCalledWith(term);
});
test("search for a term", async () => {
const inputBar = screen.getByRole("textbox");
const searchButton = screen.getByRole("button", { name: "search-button" });
const term = "search for term";
await userEvent.type(inputBar, term);
await userEvent.click(searchButton);
expect(mockedSearch).toBeCalledWith(term);
});
test("clearing search", async () => {
const inputBar = screen.getByRole("textbox");
const clearButton = screen.getByRole("button", { name: "clear-button" });
const term = "search for term";
await userEvent.type(inputBar, term);
await userEvent.click(clearButton);
expect(mockedClear).toBeCalled();
});
});

View File

@ -1,171 +1,50 @@
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useMemo } from "react";
import Paper from "@mui/material/Paper";
import InputBase from "@mui/material/InputBase";
import IconButton from "@mui/material/IconButton";
import ClearIcon from "@mui/icons-material/Clear";
import {
Box,
debounce,
FormControl,
FormControlLabel,
FormLabel,
Grid,
Radio,
RadioGroup,
Tooltip,
Typography,
} from "@mui/material";
import { Box, debounce, Grid, Tooltip, Typography } from "@mui/material";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { NixType, nixTypes } from "../../types/nix";
import { useRouter } from "next/router";
import ShareIcon from "@mui/icons-material/Share";
import { useSnackbar } from "notistack";
interface SelectOptionProps {
label: string;
handleChange: (value: string) => void;
value: string;
options: {
value: string;
label: string;
}[];
}
const SelectOption = (props: SelectOptionProps) => {
const { label, handleChange, options, value } = props;
const _handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newVal = (event.target as HTMLInputElement).value as NixType;
handleChange(newVal);
};
return (
<FormControl
sx={{
flexDirection: "row",
}}
>
<FormLabel
sx={{
width: "7rem",
wordWrap: "unset",
margin: "auto",
padding: 1,
}}
>
<Typography sx={{ minWidth: "max-content" }}>{label}</Typography>
</FormLabel>
<RadioGroup
sx={{
width: "100%",
"&.MuiFormGroup-root": {
flexDirection: "row",
},
}}
value={value}
onChange={_handleChange}
>
{options.map(({ value, label }) => (
<FormControlLabel
key={value}
value={value}
control={<Radio />}
label={label}
/>
))}
</RadioGroup>
</FormControl>
);
};
import { NixType, nixTypes } from "../../models/nix";
import SearchIcon from "@mui/icons-material/Search";
import { usePageContext } from "../pageContext";
import { initialPageState } from "../../models/internals";
import { SelectOption } from "../selectOption";
export type Filter = { from: NixType; to: NixType };
export interface SearchInputProps {
handleSearch: (term: string) => void;
handleClear: () => void;
handleFilter: (filter: Filter | ((curr: Filter) => Filter)) => void;
clearSearch: () => void;
placeholder: string;
page: number;
}
export function SearchInput(props: SearchInputProps) {
const { handleSearch, clearSearch, placeholder, handleFilter, page } = props;
const { enqueueSnackbar } = useSnackbar();
const [term, setTerm] = useState("");
const [to, setTo] = useState<NixType>("any");
const [from, setFrom] = useState<NixType>("any");
const { handleSearch, placeholder, handleFilter, handleClear } = props;
const { pageState } = usePageContext();
const { filter, term } = pageState;
const [_term, _setTerm] = useState(term);
const router = useRouter();
useEffect(() => {
const { query } = router;
if (query?.search) {
if (typeof query.search === "string") {
const search = query.search;
setTerm(search);
handleSearch(search);
}
}
if (query?.to) {
if (typeof query.to === "string") {
const to = query.to as NixType;
setTo(to);
handleFilter((curr) => ({ ...curr, to }));
}
}
if (query?.from) {
if (typeof query.from === "string") {
const from = query.from as NixType;
setFrom(from);
handleFilter((curr) => ({ ...curr, from }));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
const handleShare = () => {
const queries = [];
if (term) {
queries.push(`search=${term}`);
}
queries.push(`page=${page}`);
queries.push(`to=${to}&from=${from}`);
const handle = `https://noogle.dev?${queries.join("&")}`;
navigator.clipboard.writeText(handle);
enqueueSnackbar("link copied to clipboard", { variant: "default" });
};
const handleSubmit = React.useRef((input: string) => {
handleSearch(input);
router.query[term] = term;
}).current;
const debouncedSubmit = useMemo(
() => debounce(handleSubmit, 300),
() => debounce(handleSubmit, 500),
[handleSubmit]
);
const _handleFilter = (t: NixType, mode: "from" | "to") => {
if (mode === "to") {
setTo(t);
handleFilter({ to: t, from });
} else {
setFrom(t);
handleFilter({ to, from: t });
}
const _handleClear = () => {
_setTerm("");
handleClear();
// handleFilter(initialPageState.filter);
// handleSubmit("");
};
const handleClear = () => {
setTerm("");
setFrom("any");
setTo("any");
clearSearch();
};
const handleChange = (
const handleType = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setTerm(e.target.value);
_setTerm(e.target.value);
debouncedSubmit(e.target.value);
};
@ -186,7 +65,7 @@ export function SearchInput(props: SearchInputProps) {
handleSubmit(term);
}}
>
<IconButton aria-label="clear-button" onClick={handleClear}>
<IconButton aria-label="clear-button" onClick={_handleClear}>
<ClearIcon />
</IconButton>
<InputBase
@ -199,18 +78,17 @@ export function SearchInput(props: SearchInputProps) {
}}
placeholder={placeholder}
inputProps={{ "aria-label": "search-input" }}
value={term}
onChange={(e) => handleChange(e)}
value={_term}
onChange={(e) => handleType(e)}
/>
<Tooltip title="share search result">
<IconButton
sx={{
p: 1,
color: "common.black",
}}
onClick={handleShare}
aria-label="search-button"
>
<ShareIcon fontSize="inherit" />
<SearchIcon fontSize="inherit" />
</IconButton>
</Tooltip>
</Paper>
@ -219,10 +97,10 @@ export function SearchInput(props: SearchInputProps) {
<Grid container>
<Grid item xs={12} md={5}>
<SelectOption
value={from}
value={filter.from}
label="from type"
handleChange={(value) => {
_handleFilter(value as NixType, "from");
handleFilter((curr) => ({ ...curr, from: value as NixType }));
}}
options={nixTypes.map((v) => ({ value: v, label: v }))}
/>
@ -245,10 +123,10 @@ export function SearchInput(props: SearchInputProps) {
</Grid>
<Grid item xs={12} md={5}>
<SelectOption
value={to}
value={filter.to}
label="to type"
handleChange={(value) => {
_handleFilter(value as NixType, "to");
handleFilter((curr) => ({ ...curr, to: value as NixType }));
}}
options={nixTypes.map((v) => ({ value: v, label: v }))}
/>

View File

@ -0,0 +1 @@
export {SelectOption} from "./selectOption"

View File

@ -0,0 +1,68 @@
import {
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup,
Typography,
} from "@mui/material";
import { NixType } from "../../models/nix";
interface SelectOptionProps {
label: string;
handleChange: (value: string) => void;
value: string;
options: {
value: string;
label: string;
}[];
}
export const SelectOption = (props: SelectOptionProps) => {
const { label, handleChange, options, value } = props;
const _handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newVal = (event.target as HTMLInputElement).value as NixType;
handleChange(newVal);
};
return (
<FormControl
sx={{
flexDirection: "row",
}}
>
<FormLabel
sx={{
width: "7rem",
wordWrap: "unset",
margin: "auto",
padding: 1,
}}
>
<Typography sx={{ minWidth: "max-content" }}>{label}</Typography>
</FormLabel>
<RadioGroup
sx={{
width: "100%",
"&.MuiFormGroup-root": {
flexDirection: "row",
},
}}
value={value}
onChange={_handleChange}
>
{options.map(({ value, label }) => (
<FormControlLabel
key={value}
value={value}
control={<Radio />}
label={label}
/>
))}
</RadioGroup>
</FormControl>
);
};

View File

@ -1,10 +0,0 @@
import { MetaData } from "../types/nix";
import nixLibs from "../models/lib.json";
import nixBuiltins from "../models/builtins.json";
import nixTrivialBuilders from "../models/trivial-builders.json";
export const data: MetaData = [
...(nixLibs as MetaData),
...(nixBuiltins as MetaData),
...(nixTrivialBuilders as MetaData),
].sort((a, b) => a.name.localeCompare(b.name));

10
models/data/index.ts Normal file
View File

@ -0,0 +1,10 @@
import { MetaData } from "../nix";
import nixLibs from "./lib.json";
import nixBuiltins from "./builtins.json";
import nixTrivialBuilders from "./trivial-builders.json";
export const data: MetaData = [
...(nixLibs as MetaData),
...(nixBuiltins as MetaData),
...(nixTrivialBuilders as MetaData),
].sort((a, b) => a.name.localeCompare(b.name));

28
models/internals.ts Normal file
View File

@ -0,0 +1,28 @@
import { data } from "./data";
import { MetaData, NixType } from "./nix";
export type ComputedState = {
FOTD: boolean;
}
export type PageState = {
data: MetaData;
selected: string | null;
term: string;
filter: Filter;
page: number;
itemsPerPage: number;
} & ComputedState;
export type InitialPageState = Omit<PageState, keyof ComputedState>;
export const initialPageState: InitialPageState = {
data,
selected: null,
term: "",
filter: { from: "any", to: "any" },
page: 1,
itemsPerPage: 10,
};
export type Filter = { to: NixType; from: NixType };

View File

@ -1,4 +1,5 @@
export type NixType = "function" | "attrset" | "list" | "string" | "int" | "bool" | "any";
export const nixTypes: NixType[] = [
"any",
"attrset",

View File

@ -36,11 +36,18 @@ const MyApp: React.FunctionComponent<MyAppProps> = (props) => {
</Layout>
);
};
return (
<>
<Head>
<title>noogle</title>
<meta name="description" content="Search nix functions" />
<meta charSet="utf-8" />
<meta
name="description"
content="Search nix functions. Search functions within the nix ecosystem based on type, name, description, example, category and more."
/>
<meta />
<meta name="robots" content="all" />
<link rel="icon" href="/favicon.png" />
</Head>

View File

@ -1,80 +1,77 @@
import { BasicList, BasicListItem } from "../components/basicList";
import { useState, useMemo } from "react";
import { Box } from "@mui/material";
import FunctionItem from "../components/functionItem/functionItem";
import { NixType, nixTypes, MetaData, DocItem } from "../types/nix";
import { data } from "../models/data";
import { byQuery, byType, pipe } from "../queries";
import React, { useEffect, useState } from "react";
import {
initialPageState,
PageState,
InitialPageState,
} from "../models/internals";
import { PageContext, PageContextProvider } from "../components/pageContext";
import NixFunctions from "../components/NixFunctions";
import { NextRouter, useRouter } from "next/router";
import { LinearProgress } from "@mui/material";
const getInitialProps = async (context: NextRouter) => {
const { query } = context;
const initialProps = { ...initialPageState };
Object.entries(query).forEach(([key, value]) => {
if (value && !Array.isArray(value)) {
try {
const parsedValue = JSON.parse(value) as never;
const initialValue = initialPageState[key as keyof InitialPageState];
if (!initialValue || typeof parsedValue === typeof initialValue) {
initialProps[key as keyof InitialPageState] = JSON.parse(
value
) as never;
} else {
throw "Type of query param does not match the initial values type";
}
} catch (error) {
console.error("Invalid query:", { key, value, error });
}
}
});
const FOTD = Object.entries(query).length === 0;
return {
props: {
...initialProps,
FOTD,
},
};
};
export default function FunctionsPage() {
const [selected, setSelected] = useState<string | null>(null);
const [term, setTerm] = useState<string>("");
const [filter, setFilter] = useState<{ to: NixType; from: NixType }>({
to: "any",
from: "any",
});
const handleSelect = (key: string) => {
console.log({ key });
setSelected((curr: string | null) => {
if (curr === key) {
return null;
} else {
return key;
}
});
};
const filteredData = useMemo(
() => pipe(byType(filter), byQuery(term))(data),
[filter, term]
);
const handleSearch = (term: string) => {
setTerm(term);
};
type Filter = { from: NixType; to: NixType };
const handleFilter = (newFilter: Filter | ((curr: Filter) => Filter)) => {
setFilter(newFilter);
};
const getKey = (item: DocItem) => `${item.category}/${item.name}`;
const preRenderedItems: BasicListItem[] = filteredData.map(
(docItem: DocItem) => {
const key = getKey(docItem);
return {
item: (
<Box
sx={{
width: "100%",
height: "100%",
}}
onClick={!(selected === key) ? () => handleSelect(key) : undefined}
>
<FunctionItem
name={docItem.name}
docItem={docItem}
selected={selected === key}
handleClose={() => setSelected(null)}
/>
</Box>
),
key,
};
const router = useRouter();
const [initialProps, setInitialProps] = useState<PageState | null>(null);
useEffect(() => {
if (router.isReady) {
getInitialProps(router).then((r) => {
const { props } = r;
console.info("Url Query changed\n\nUpdating pageState with delta:", {
props,
});
setInitialProps((curr) => ({ ...curr, ...props }));
});
}
);
}, [router]);
return (
<Box sx={{ ml: { xs: 0, md: 2 }, mb: 5 }}>
<BasicList
term={term}
filter={filter}
selected={selected}
itemsPerPage={8}
items={preRenderedItems}
handleSearch={handleSearch}
handleFilter={handleFilter}
/>
</Box>
<>
{!initialProps ? (
<LinearProgress />
) : (
<PageContextProvider pageProps={initialProps}>
<PageContext.Consumer>
{(context) => (
<NixFunctions
pageState={context.pageState}
setPageStateVariable={context.setPageStateVariable}
/>
)}
</PageContext.Consumer>
</PageContextProvider>
)}
</>
);
}

View File

@ -1,43 +0,0 @@
import { IconButton, Paper, Tooltip } from "@mui/material";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Preview } from "../components/preview/preview";
import { data } from "../models/data";
import { DocItem } from "../types/nix";
import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos";
const getKey = (item: DocItem) => `${item.category}/${item.name}`;
export default function PreviewPage() {
const router = useRouter();
const [item, setItem] = useState<DocItem | null>(null);
useEffect(() => {
const { query } = router;
if (query?.fn) {
if (typeof query.fn === "string") {
const name = query.fn;
setItem(data.find((d) => getKey(d) === name) || null);
}
}
}, [router]);
return (
item && (
<>
<Paper sx={{ py: 4 }} elevation={0}>
<Tooltip title="back to list">
<IconButton
sx={{
mx: 3,
}}
size="small"
onClick={() => router.push("/")}
>
<ArrowBackIosIcon fontSize="large" />
</IconButton>
</Tooltip>
<Preview docItem={item} closeComponent={<span></span>} />
</Paper>
</>
)
);
}

View File

@ -1,4 +1,4 @@
import { DocItem, MetaData } from "../types/nix";
import { DocItem, MetaData } from "../models/nix";
export const byQuery =
(rawTerm: string) =>

View File

@ -1,4 +1,4 @@
import { MetaData, NixType } from "../types/nix";
import { MetaData, NixType } from "../models/nix";
import { getTypes } from "./lib";
export const byType =

View File

@ -1,4 +1,4 @@
import { NixType, nixTypes } from "../types/nix";
import { NixType, nixTypes } from "../models/nix";
export function pipe<T>(...fns: ((arr: T) => T)[]) {
return (x: T) => fns.reduce((v, f) => f(v), x);

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es6",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,