From c04674fa74c2e43535181431aef5d891f8839619 Mon Sep 17 00:00:00 2001 From: Kevin Hoerr Date: Sun, 14 Aug 2022 21:35:45 +0000 Subject: Merge planner code (#3) Reviewed-on: https://git.submelon.dev/kjhoerr/pantry/pulls/3 --- src/components/add-item.tsx | 101 +++++++++++++++++++ src/conf/mutator.ts | 29 ++++++ src/conf/openapi-pantry.yaml | 87 ++++++++++++++++ src/index.ts | 2 + src/model/index.ts | 1 + src/model/pantryItem.ts | 14 +++ src/pages/_app.tsx | 16 +++ src/pages/index.tsx | 162 ++++++++++++++++++++++++++++++ src/styles/Home.module.css | 4 + src/styles/globals.css | 16 +++ src/util/pantry-item-resource.ts | 212 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 644 insertions(+) create mode 100644 src/components/add-item.tsx create mode 100644 src/conf/mutator.ts create mode 100644 src/conf/openapi-pantry.yaml create mode 100644 src/index.ts create mode 100644 src/model/index.ts create mode 100644 src/model/pantryItem.ts create mode 100644 src/pages/_app.tsx create mode 100644 src/pages/index.tsx create mode 100644 src/styles/Home.module.css create mode 100644 src/styles/globals.css create mode 100644 src/util/pantry-item-resource.ts (limited to 'src') 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; +} + +const AddItem = ({ addItem }: AddItemProps) => { + const [additionItem, setAdditionItem] = useState(); + const [additionItemLoading, setAdditionItemLoading] = useState(false); + + const handleItemChange = ( + _: ChangeEvent, + { name, value }: InputOnChangeData + ) => + setAdditionItem((item) => ({ + ...(item !== undefined ? item : defaultPantryItem()), + [name]: value, + })); + + const newItem = useMemo( + () => additionItem ?? defaultPantryItem(), + [additionItem] + ); + + return ( + <> + + + + ); +}; + +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 = (): (( + config: AxiosRequestConfig +) => Promise) => { + 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 = AxiosError; 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 ( + + + + ); +} + +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({ + field: "name", + order: "ascending", + }); + + const hasEntries = useMemo( + () => error === null && (data === undefined || data.length > 0), + [error, data] + ); + const entries = useMemo(() => { + const list = List(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 ( + + + Pantry + + + + +
+ Pantry +
+ + Promise.resolve(mutate(newItem))} /> + + + + + handleSortChange("name")} + > + Name + + handleSortChange("description")} + > + Description + + handleSortChange("quantity")} + > + Quantity + + + + + {entries + .valueSeq() + .slice( + (activePage - 1) * ENTRIES_PER_PAGE, + activePage * ENTRIES_PER_PAGE + ) + .map((item: PantryItem) => ( + + {item.name} + + {item.description === "" ? "—" : item.description} + + + {item.quantity} {item.quantityUnitType} + + + ))} + + + + + + setActivePage(Number(activePage ?? 1)) + } + totalPages={Math.max( + 1, + Math.ceil(entries.size / ENTRIES_PER_PAGE) + )} + /> + + + +
+
+ ); +}; + +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(); + + return (signal?: AbortSignal) => { + return getItems({ url: `/items`, method: "get", signal }); + }; +}; + +export const getGetItemsQueryKey = () => [`/items`]; + +export type GetItemsQueryResult = NonNullable< + Awaited>> +>; +export type GetItemsQueryError = ErrorType; + +export const useGetItems = < + TData = Awaited>>, + TError = ErrorType +>(options?: { + query?: UseQueryOptions< + Awaited>>, + TError, + TData + >; +}): UseQueryResult & { queryKey: QueryKey } => { + const { query: queryOptions } = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetItemsQueryKey(); + + const getItems = useGetItemsHook(); + + const queryFn: QueryFunction< + Awaited>> + > = ({ signal }) => getItems(signal); + + const query = useQuery< + Awaited>>, + TError, + TData + >(queryKey, queryFn, queryOptions) as UseQueryResult & { + queryKey: QueryKey; + }; + + query.queryKey = queryKey; + + return query; +}; + +export const usePostItemsHook = () => { + const postItems = useMutator(); + + return (pantryItem: PantryItem) => { + return postItems({ + url: `/items`, + method: "post", + headers: { "Content-Type": "application/json" }, + data: pantryItem, + }); + }; +}; + +export type PostItemsMutationResult = NonNullable< + Awaited>> +>; +export type PostItemsMutationBody = PantryItem; +export type PostItemsMutationError = ErrorType; + +export const usePostItems = < + TError = ErrorType, + TContext = unknown +>(options?: { + mutation?: UseMutationOptions< + Awaited>>, + TError, + { data: PantryItem }, + TContext + >; +}) => { + const { mutation: mutationOptions } = options ?? {}; + + const postItems = usePostItemsHook(); + + const mutationFn: MutationFunction< + Awaited>>, + { data: PantryItem } + > = (props) => { + const { data } = props ?? {}; + + return postItems(data); + }; + + return useMutation< + Awaited>, + TError, + { data: PantryItem }, + TContext + >(mutationFn, mutationOptions); +}; +export const usePutItemsIdHook = () => { + const putItemsId = useMutator(); + + return (id: number, pantryItem: PantryItem) => { + return putItemsId({ + url: `/items/${id}`, + method: "put", + headers: { "Content-Type": "application/json" }, + data: pantryItem, + }); + }; +}; + +export type PutItemsIdMutationResult = NonNullable< + Awaited>> +>; +export type PutItemsIdMutationBody = PantryItem; +export type PutItemsIdMutationError = ErrorType; + +export const usePutItemsId = < + TError = ErrorType, + TContext = unknown +>(options?: { + mutation?: UseMutationOptions< + Awaited>>, + TError, + { id: number; data: PantryItem }, + TContext + >; +}) => { + const { mutation: mutationOptions } = options ?? {}; + + const putItemsId = usePutItemsIdHook(); + + const mutationFn: MutationFunction< + Awaited>>, + { id: number; data: PantryItem } + > = (props) => { + const { id, data } = props ?? {}; + + return putItemsId(id, data); + }; + + return useMutation< + Awaited>, + TError, + { id: number; data: PantryItem }, + TContext + >(mutationFn, mutationOptions); +}; +export const useDeleteItemsIdHook = () => { + const deleteItemsId = useMutator(); + + return (id: number) => { + return deleteItemsId({ url: `/items/${id}`, method: "delete" }); + }; +}; + +export type DeleteItemsIdMutationResult = NonNullable< + Awaited>> +>; + +export type DeleteItemsIdMutationError = ErrorType; + +export const useDeleteItemsId = < + TError = ErrorType, + TContext = unknown +>(options?: { + mutation?: UseMutationOptions< + Awaited>>, + TError, + { id: number }, + TContext + >; +}) => { + const { mutation: mutationOptions } = options ?? {}; + + const deleteItemsId = useDeleteItemsIdHook(); + + const mutationFn: MutationFunction< + Awaited>>, + { id: number } + > = (props) => { + const { id } = props ?? {}; + + return deleteItemsId(id); + }; + + return useMutation< + Awaited>, + TError, + { id: number }, + TContext + >(mutationFn, mutationOptions); +}; -- cgit