aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/add-item.tsx101
-rw-r--r--src/conf/mutator.ts29
-rw-r--r--src/conf/openapi-pantry.yaml87
-rw-r--r--src/index.ts2
-rw-r--r--src/model/index.ts1
-rw-r--r--src/model/pantryItem.ts14
-rw-r--r--src/pages/_app.tsx16
-rw-r--r--src/pages/index.tsx162
-rw-r--r--src/styles/Home.module.css4
-rw-r--r--src/styles/globals.css16
-rw-r--r--src/util/pantry-item-resource.ts212
11 files changed, 644 insertions, 0 deletions
diff --git a/src/components/add-item.tsx b/src/components/add-item.tsx
new file mode 100644
index 0000000..9c4a998
--- /dev/null
+++ b/src/components/add-item.tsx
@@ -0,0 +1,101 @@
+import { ChangeEvent, useMemo, useState } from "react";
+import { Button, Form, InputOnChangeData, Segment } from "semantic-ui-react";
+import { PantryItem } from "../model";
+
+const defaultPantryItem = () =>
+ ({
+ name: "",
+ description: "",
+ quantity: 1,
+ quantityUnitType: "oz",
+ } as PantryItem);
+
+interface AddItemProps {
+ addItem: (item: PantryItem) => Promise<void>;
+}
+
+const AddItem = ({ addItem }: AddItemProps) => {
+ const [additionItem, setAdditionItem] = useState<PantryItem | undefined>();
+ const [additionItemLoading, setAdditionItemLoading] = useState(false);
+
+ const handleItemChange = (
+ _: ChangeEvent<HTMLInputElement>,
+ { name, value }: InputOnChangeData
+ ) =>
+ setAdditionItem((item) => ({
+ ...(item !== undefined ? item : defaultPantryItem()),
+ [name]: value,
+ }));
+
+ const newItem = useMemo(
+ () => additionItem ?? defaultPantryItem(),
+ [additionItem]
+ );
+
+ return (
+ <>
+ <Segment attached="top" hidden={additionItem !== undefined}>
+ <Button primary onClick={() => setAdditionItem(defaultPantryItem())}>
+ Add Item
+ </Button>
+ </Segment>
+ <Segment attached="top" hidden={additionItem === undefined}>
+ <Form
+ loading={additionItemLoading}
+ onSubmit={() => {
+ setAdditionItemLoading(true);
+ addItem(newItem).then(() => {
+ setAdditionItem(undefined);
+ setAdditionItemLoading(false);
+ });
+ }}
+ >
+ <Form.Group widths="equal">
+ <Form.Input
+ name="name"
+ label="Item Name"
+ placeholder="Item name"
+ value={newItem.name}
+ onChange={handleItemChange}
+ />
+ <Form.Input
+ fluid
+ name="description"
+ label="Item Description"
+ placeholder="Item description"
+ value={newItem.description}
+ onChange={handleItemChange}
+ />
+ <Form.Input
+ fluid
+ name="quantity"
+ label="Item Quantity"
+ placeholder="Item quantity"
+ value={newItem.quantity}
+ onChange={handleItemChange}
+ />
+ <Form.Input
+ name="quantityUnitType"
+ label="Quantity Type"
+ placeholder="Quantity type"
+ value={newItem.quantityUnitType}
+ onChange={handleItemChange}
+ />
+ </Form.Group>
+ <Form.Group>
+ <Form.Button primary content="Submit Item" type="submit" />
+ <Form.Button
+ content="Cancel"
+ onClick={(e) => {
+ e.preventDefault();
+ setAdditionItem(undefined);
+ }}
+ />
+ </Form.Group>
+ </Form>
+ </Segment>
+ </>
+ );
+};
+
+export default AddItem;
diff --git a/src/conf/mutator.ts b/src/conf/mutator.ts
new file mode 100644
index 0000000..74f29e3
--- /dev/null
+++ b/src/conf/mutator.ts
@@ -0,0 +1,29 @@
+import Axios, { AxiosError, AxiosRequestConfig } from "axios";
+
+export const AXIOS_PANTRY_INSTANCE = Axios.create({
+ baseURL: process.env.REACT_APP_API_SERVER!,
+});
+
+export const useMutator = <T>(): ((
+ config: AxiosRequestConfig
+) => Promise<T>) => {
+ return (config: AxiosRequestConfig) => {
+ const source = Axios.CancelToken.source();
+ const promise = AXIOS_PANTRY_INSTANCE({
+ ...config,
+ cancelToken: source.token,
+ }).then(({ data }) => data);
+
+ // @ts-ignore
+ promise.cancel = () => {
+ source.cancel("Query was cancelled by React Query!");
+ };
+
+ return promise;
+ };
+};
+
+export default useMutator;
+
+// In some case with react-query and swr you want to be able to override the return error type so you can also do it here like this
+export type ErrorType<Error> = AxiosError<Error>;
diff --git a/src/conf/openapi-pantry.yaml b/src/conf/openapi-pantry.yaml
new file mode 100644
index 0000000..8409064
--- /dev/null
+++ b/src/conf/openapi-pantry.yaml
@@ -0,0 +1,87 @@
+---
+openapi: 3.0.3
+info:
+ title: pantry API
+ version: 1.0.0-SNAPSHOT
+paths:
+ /items:
+ get:
+ tags:
+ - Pantry Item Resource
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/PantryItem'
+ post:
+ tags:
+ - Pantry Item Resource
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PantryItem'
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PantryItem'
+ /items/{id}:
+ put:
+ tags:
+ - Pantry Item Resource
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ format: int64
+ type: integer
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PantryItem'
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PantryItem'
+ delete:
+ tags:
+ - Pantry Item Resource
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ format: int64
+ type: integer
+ responses:
+ "204":
+ description: No Content
+components:
+ schemas:
+ PantryItem:
+ type: object
+ properties:
+ id:
+ format: int64
+ type: integer
+ name:
+ type: string
+ description:
+ type: string
+ quantity:
+ format: double
+ type: number
+ quantityUnitType:
+ type: string
diff --git a/src/index.ts b/src/index.ts
new file mode 100644
index 0000000..f298f13
--- /dev/null
+++ b/src/index.ts
@@ -0,0 +1,2 @@
+export * from "./model";
+export * from "./util/pantry-item-resource";
diff --git a/src/model/index.ts b/src/model/index.ts
new file mode 100644
index 0000000..a60da79
--- /dev/null
+++ b/src/model/index.ts
@@ -0,0 +1 @@
+export * from "./pantryItem";
diff --git a/src/model/pantryItem.ts b/src/model/pantryItem.ts
new file mode 100644
index 0000000..c3a6347
--- /dev/null
+++ b/src/model/pantryItem.ts
@@ -0,0 +1,14 @@
+/**
+ * Generated by orval v6.9.6 🍺
+ * Do not edit manually.
+ * pantry API
+ * OpenAPI spec version: 1.0.0-SNAPSHOT
+ */
+
+export interface PantryItem {
+ id?: number;
+ name?: string;
+ description?: string;
+ quantity?: number;
+ quantityUnitType?: string;
+}
diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx
new file mode 100644
index 0000000..10121ab
--- /dev/null
+++ b/src/pages/_app.tsx
@@ -0,0 +1,16 @@
+import "semantic-ui-css/semantic.min.css";
+import "../styles/globals.css";
+import type { AppProps } from "next/app";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+const queryClient = new QueryClient();
+
+function MyApp({ Component, pageProps }: AppProps) {
+ return (
+ <QueryClientProvider client={queryClient}>
+ <Component {...pageProps} />
+ </QueryClientProvider>
+ );
+}
+
+export default MyApp;
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
new file mode 100644
index 0000000..674878a
--- /dev/null
+++ b/src/pages/index.tsx
@@ -0,0 +1,162 @@
+import type { NextPage } from "next";
+import Head from "next/head";
+import { List } from "immutable";
+import {
+ Header,
+ Table,
+ Message,
+ Container,
+ Pagination,
+} from "semantic-ui-react";
+
+import styles from "../styles/Home.module.css";
+import { useMemo, useState } from "react";
+import AddItem from "../components/add-item";
+import {
+ getGetItemsQueryKey,
+ useGetItems,
+ usePostItemsHook,
+} from "../util/pantry-item-resource";
+import { PantryItem } from "../model";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+const ENTRIES_PER_PAGE = Number(process.env.ENTRIES_PER_PAGE ?? "10");
+
+interface SortStateProps {
+ field: keyof PantryItem;
+ order: "ascending" | "descending";
+}
+
+const Home: NextPage = () => {
+ const { data, error } = useGetItems();
+ const postItems = usePostItemsHook();
+ const queryClient = useQueryClient();
+ const { mutate } = useMutation(postItems, {
+ onSuccess: async (item) => {
+ queryClient.setQueryData(
+ getGetItemsQueryKey(),
+ (data ?? []).concat(item)
+ );
+ },
+ });
+ const [activePage, setActivePage] = useState(1);
+ const [sortState, setSortState] = useState<SortStateProps>({
+ field: "name",
+ order: "ascending",
+ });
+
+ const hasEntries = useMemo(
+ () => error === null && (data === undefined || data.length > 0),
+ [error, data]
+ );
+ const entries = useMemo(() => {
+ const list = List<PantryItem>(data);
+ // case insensitive sort
+ const sorted = list.sortBy((item) =>
+ item[sortState.field]?.toString().toUpperCase()
+ );
+
+ return sortState.order === "ascending" ? sorted : sorted.reverse();
+ }, [data, sortState]);
+ const handleSortChange = (field: keyof PantryItem) => {
+ setSortState((state) =>
+ state.field === field
+ ? {
+ ...state,
+ order: state.order === "ascending" ? "descending" : "ascending",
+ }
+ : { field: field, order: "ascending" }
+ );
+ };
+
+ return (
+ <Container className={styles.container}>
+ <Head>
+ <title>Pantry</title>
+ <meta
+ name="description"
+ content="Meal planning with inventory management"
+ />
+ <link rel="icon" href="/favicon.ico" />
+ </Head>
+
+ <Header as="h1" className="title">
+ Pantry
+ </Header>
+
+ <AddItem addItem={(newItem) => Promise.resolve(mutate(newItem))} />
+ <Message error={error !== null} attached hidden={hasEntries}>
+ {error !== null
+ ? error.message !== undefined
+ ? `Network error occurred: ${error.message}`
+ : "Unknown network error occurred"
+ : "Nothing's in the pantry at the moment!"}
+ </Message>
+ <Table sortable attached="bottom">
+ <Table.Header>
+ <Table.Row>
+ <Table.HeaderCell
+ sorted={sortState.field === "name" ? sortState.order : undefined}
+ onClick={() => handleSortChange("name")}
+ >
+ Name
+ </Table.HeaderCell>
+ <Table.HeaderCell
+ sorted={
+ sortState.field === "description" ? sortState.order : undefined
+ }
+ onClick={() => handleSortChange("description")}
+ >
+ Description
+ </Table.HeaderCell>
+ <Table.HeaderCell
+ sorted={
+ sortState.field === "quantity" ? sortState.order : undefined
+ }
+ onClick={() => handleSortChange("quantity")}
+ >
+ Quantity
+ </Table.HeaderCell>
+ </Table.Row>
+ </Table.Header>
+ <Table.Body>
+ {entries
+ .valueSeq()
+ .slice(
+ (activePage - 1) * ENTRIES_PER_PAGE,
+ activePage * ENTRIES_PER_PAGE
+ )
+ .map((item: PantryItem) => (
+ <Table.Row key={item.id}>
+ <Table.Cell>{item.name}</Table.Cell>
+ <Table.Cell>
+ {item.description === "" ? "—" : item.description}
+ </Table.Cell>
+ <Table.Cell>
+ {item.quantity} {item.quantityUnitType}
+ </Table.Cell>
+ </Table.Row>
+ ))}
+ </Table.Body>
+ <Table.Footer>
+ <Table.Row>
+ <Table.HeaderCell colSpan="3">
+ <Pagination
+ activePage={activePage}
+ onPageChange={(_, { activePage }) =>
+ setActivePage(Number(activePage ?? 1))
+ }
+ totalPages={Math.max(
+ 1,
+ Math.ceil(entries.size / ENTRIES_PER_PAGE)
+ )}
+ />
+ </Table.HeaderCell>
+ </Table.Row>
+ </Table.Footer>
+ </Table>
+ </Container>
+ );
+};
+
+export default Home;
diff --git a/src/styles/Home.module.css b/src/styles/Home.module.css
new file mode 100644
index 0000000..ae734f6
--- /dev/null
+++ b/src/styles/Home.module.css
@@ -0,0 +1,4 @@
+.action {
+ margin-top: 20px;
+ margin-bottom: 20px;
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
new file mode 100644
index 0000000..344cd26
--- /dev/null
+++ b/src/styles/globals.css
@@ -0,0 +1,16 @@
+html,
+body {
+ padding: 20px 0;
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
+ Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
+}
+
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+* {
+ box-sizing: border-box;
+}
diff --git a/src/util/pantry-item-resource.ts b/src/util/pantry-item-resource.ts
new file mode 100644
index 0000000..aedb286
--- /dev/null
+++ b/src/util/pantry-item-resource.ts
@@ -0,0 +1,212 @@
+/**
+ * Generated by orval v6.9.6 🍺
+ * Do not edit manually.
+ * pantry API
+ * OpenAPI spec version: 1.0.0-SNAPSHOT
+ */
+import { useQuery, useMutation } from "@tanstack/react-query";
+import type {
+ UseQueryOptions,
+ UseMutationOptions,
+ QueryFunction,
+ MutationFunction,
+ UseQueryResult,
+ QueryKey,
+} from "@tanstack/react-query";
+import type { PantryItem } from "../model";
+import { useMutator } from "../conf/mutator";
+import type { ErrorType } from "../conf/mutator";
+
+export const useGetItemsHook = () => {
+ const getItems = useMutator<PantryItem[]>();
+
+ return (signal?: AbortSignal) => {
+ return getItems({ url: `/items`, method: "get", signal });
+ };
+};
+
+export const getGetItemsQueryKey = () => [`/items`];
+
+export type GetItemsQueryResult = NonNullable<
+ Awaited<ReturnType<ReturnType<typeof useGetItemsHook>>>
+>;
+export type GetItemsQueryError = ErrorType<unknown>;
+
+export const useGetItems = <
+ TData = Awaited<ReturnType<ReturnType<typeof useGetItemsHook>>>,
+ TError = ErrorType<unknown>
+>(options?: {
+ query?: UseQueryOptions<
+ Awaited<ReturnType<ReturnType<typeof useGetItemsHook>>>,
+ TError,
+ TData
+ >;
+}): UseQueryResult<TData, TError> & { queryKey: QueryKey } => {
+ const { query: queryOptions } = options ?? {};
+
+ const queryKey = queryOptions?.queryKey ?? getGetItemsQueryKey();
+
+ const getItems = useGetItemsHook();
+
+ const queryFn: QueryFunction<
+ Awaited<ReturnType<ReturnType<typeof useGetItemsHook>>>
+ > = ({ signal }) => getItems(signal);
+
+ const query = useQuery<
+ Awaited<ReturnType<ReturnType<typeof useGetItemsHook>>>,
+ TError,
+ TData
+ >(queryKey, queryFn, queryOptions) as UseQueryResult<TData, TError> & {
+ queryKey: QueryKey;
+ };
+
+ query.queryKey = queryKey;
+
+ return query;
+};
+
+export const usePostItemsHook = () => {
+ const postItems = useMutator<PantryItem>();
+
+ return (pantryItem: PantryItem) => {
+ return postItems({
+ url: `/items`,
+ method: "post",
+ headers: { "Content-Type": "application/json" },
+ data: pantryItem,
+ });
+ };
+};
+
+export type PostItemsMutationResult = NonNullable<
+ Awaited<ReturnType<ReturnType<typeof usePostItemsHook>>>
+>;
+export type PostItemsMutationBody = PantryItem;
+export type PostItemsMutationError = ErrorType<unknown>;
+
+export const usePostItems = <
+ TError = ErrorType<unknown>,
+ TContext = unknown
+>(options?: {
+ mutation?: UseMutationOptions<
+ Awaited<ReturnType<ReturnType<typeof usePostItemsHook>>>,
+ TError,
+ { data: PantryItem },
+ TContext
+ >;
+}) => {
+ const { mutation: mutationOptions } = options ?? {};
+
+ const postItems = usePostItemsHook();
+
+ const mutationFn: MutationFunction<
+ Awaited<ReturnType<ReturnType<typeof usePostItemsHook>>>,
+ { data: PantryItem }
+ > = (props) => {
+ const { data } = props ?? {};
+
+ return postItems(data);
+ };
+
+ return useMutation<
+ Awaited<ReturnType<typeof postItems>>,
+ TError,
+ { data: PantryItem },
+ TContext
+ >(mutationFn, mutationOptions);
+};
+export const usePutItemsIdHook = () => {
+ const putItemsId = useMutator<PantryItem>();
+
+ return (id: number, pantryItem: PantryItem) => {
+ return putItemsId({
+ url: `/items/${id}`,
+ method: "put",
+ headers: { "Content-Type": "application/json" },
+ data: pantryItem,
+ });
+ };
+};
+
+export type PutItemsIdMutationResult = NonNullable<
+ Awaited<ReturnType<ReturnType<typeof usePutItemsIdHook>>>
+>;
+export type PutItemsIdMutationBody = PantryItem;
+export type PutItemsIdMutationError = ErrorType<unknown>;
+
+export const usePutItemsId = <
+ TError = ErrorType<unknown>,
+ TContext = unknown
+>(options?: {
+ mutation?: UseMutationOptions<
+ Awaited<ReturnType<ReturnType<typeof usePutItemsIdHook>>>,
+ TError,
+ { id: number; data: PantryItem },
+ TContext
+ >;
+}) => {
+ const { mutation: mutationOptions } = options ?? {};
+
+ const putItemsId = usePutItemsIdHook();
+
+ const mutationFn: MutationFunction<
+ Awaited<ReturnType<ReturnType<typeof usePutItemsIdHook>>>,
+ { id: number; data: PantryItem }
+ > = (props) => {
+ const { id, data } = props ?? {};
+
+ return putItemsId(id, data);
+ };
+
+ return useMutation<
+ Awaited<ReturnType<typeof putItemsId>>,
+ TError,
+ { id: number; data: PantryItem },
+ TContext
+ >(mutationFn, mutationOptions);
+};
+export const useDeleteItemsIdHook = () => {
+ const deleteItemsId = useMutator<void>();
+
+ return (id: number) => {
+ return deleteItemsId({ url: `/items/${id}`, method: "delete" });
+ };
+};
+
+export type DeleteItemsIdMutationResult = NonNullable<
+ Awaited<ReturnType<ReturnType<typeof useDeleteItemsIdHook>>>
+>;
+
+export type DeleteItemsIdMutationError = ErrorType<unknown>;
+
+export const useDeleteItemsId = <
+ TError = ErrorType<unknown>,
+ TContext = unknown
+>(options?: {
+ mutation?: UseMutationOptions<
+ Awaited<ReturnType<ReturnType<typeof useDeleteItemsIdHook>>>,
+ TError,
+ { id: number },
+ TContext
+ >;
+}) => {
+ const { mutation: mutationOptions } = options ?? {};
+
+ const deleteItemsId = useDeleteItemsIdHook();
+
+ const mutationFn: MutationFunction<
+ Awaited<ReturnType<ReturnType<typeof useDeleteItemsIdHook>>>,
+ { id: number }
+ > = (props) => {
+ const { id } = props ?? {};
+
+ return deleteItemsId(id);
+ };
+
+ return useMutation<
+ Awaited<ReturnType<typeof deleteItemsId>>,
+ TError,
+ { id: number },
+ TContext
+ >(mutationFn, mutationOptions);
+};