diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/add-item.tsx | 101 | ||||
| -rw-r--r-- | src/conf/mutator.ts | 29 | ||||
| -rw-r--r-- | src/conf/openapi-pantry.yaml | 87 | ||||
| -rw-r--r-- | src/index.ts | 2 | ||||
| -rw-r--r-- | src/model/index.ts | 1 | ||||
| -rw-r--r-- | src/model/pantryItem.ts | 14 | ||||
| -rw-r--r-- | src/pages/_app.tsx | 16 | ||||
| -rw-r--r-- | src/pages/index.tsx | 162 | ||||
| -rw-r--r-- | src/styles/Home.module.css | 4 | ||||
| -rw-r--r-- | src/styles/globals.css | 16 | ||||
| -rw-r--r-- | src/util/pantry-item-resource.ts | 212 |
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); +}; |
