From d8cb5706b4ec10c001262a782a6fc17d32cc2a46 Mon Sep 17 00:00:00 2001 From: andres Date: Sat, 12 Aug 2023 21:43:52 +0200 Subject: [PATCH] lesson 4 finished --- package.json | 1 + pnpm-lock.yaml | 9 ++ .../recover-password/recover-password.tsx | 6 +- src/components/auth/sign-in/sign-in.tsx | 14 ++-- src/components/auth/sign-up/sign-up.tsx | 4 +- src/components/ui/table/table.stories.tsx | 2 - src/pages/decks/decks.tsx | 83 ++++++++++++------- src/pages/sign-in/sign-in.tsx | 23 +++++ src/router.tsx | 10 ++- src/services/auth/auth.ts | 60 ++++++++++++++ src/services/base-api-with-refetch.ts | 51 ++++++++++++ src/services/base-api.ts | 14 ++-- src/services/decks/decks.slice.ts | 1 + src/services/decks/decks.ts | 77 ++++++++++++++++- src/services/decks/types.ts | 1 + 15 files changed, 296 insertions(+), 60 deletions(-) create mode 100644 src/pages/sign-in/sign-in.tsx create mode 100644 src/services/auth/auth.ts create mode 100644 src/services/base-api-with-refetch.ts diff --git a/package.json b/package.json index 870b6cc..1307602 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-radio-group": "^1.1.3", "@reduxjs/toolkit": "^1.9.5", "@storybook/theming": "^7.2.1", + "async-mutex": "^0.4.0", "clsx": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b09e77..63f565b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: '@storybook/theming': specifier: ^7.2.1 version: 7.2.1(react-dom@18.2.0)(react@18.2.0) + async-mutex: + specifier: ^0.4.0 + version: 0.4.0 clsx: specifier: ^2.0.0 version: 2.0.0 @@ -4646,6 +4649,12 @@ packages: resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} dev: true + /async-mutex@0.4.0: + resolution: {integrity: sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==} + dependencies: + tslib: 2.6.1 + dev: false + /async@3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} dev: true diff --git a/src/components/auth/recover-password/recover-password.tsx b/src/components/auth/recover-password/recover-password.tsx index 75e5ff1..0e9128b 100644 --- a/src/components/auth/recover-password/recover-password.tsx +++ b/src/components/auth/recover-password/recover-password.tsx @@ -8,9 +8,9 @@ import { Button, Card, ControlledTextField, Typography } from '../../ui' import s from './recover-password.module.scss' -import { emailSchema } from '@/components' - -const schema = emailSchema +const schema = z.object({ + email: z.string().email('Invalid email address').nonempty('Enter email'), +}) type FormType = z.infer diff --git a/src/components/auth/sign-in/sign-in.tsx b/src/components/auth/sign-in/sign-in.tsx index e9ec86b..daf7455 100644 --- a/src/components/auth/sign-in/sign-in.tsx +++ b/src/components/auth/sign-in/sign-in.tsx @@ -8,21 +8,17 @@ import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } fro import s from './sign-in.module.scss' -export const emailSchema = z.object({ +const schema = z.object({ + password: z.string().nonempty('Enter password'), email: z.string().email('Invalid email address').nonempty('Enter email'), + rememberMe: z.boolean().optional(), }) -const schema = z - .object({ - password: z.string().nonempty('Enter password'), - rememberMe: z.boolean().optional(), - }) - .merge(emailSchema) - type FormType = z.infer type Props = { onSubmit: (data: FormType) => void + isSubmitting?: boolean } export const SignIn = (props: Props) => { @@ -76,7 +72,7 @@ export const SignIn = (props: Props) => { > Forgot Password? - diff --git a/src/components/auth/sign-up/sign-up.tsx b/src/components/auth/sign-up/sign-up.tsx index 121c96f..1912c6d 100644 --- a/src/components/auth/sign-up/sign-up.tsx +++ b/src/components/auth/sign-up/sign-up.tsx @@ -9,14 +9,12 @@ import { Button, Card, ControlledTextField, Typography } from '../../ui' import s from './sign-up.module.scss' -import { emailSchema } from '@/components' - const schema = z .object({ password: z.string().nonempty('Enter password'), passwordConfirmation: z.string().nonempty('Confirm your password'), + email: z.string().email('Invalid email address').nonempty('Enter email'), }) - .merge(emailSchema) .superRefine((data, ctx) => { if (data.password !== data.passwordConfirmation) { ctx.addIssue({ diff --git a/src/components/ui/table/table.stories.tsx b/src/components/ui/table/table.stories.tsx index 6e88ecc..3b207b7 100644 --- a/src/components/ui/table/table.stories.tsx +++ b/src/components/ui/table/table.stories.tsx @@ -198,8 +198,6 @@ export const WithSort = { return `${sort.key}-${sort.direction}` }, [sort]) - console.log(sortedString) - return ( diff --git a/src/pages/decks/decks.tsx b/src/pages/decks/decks.tsx index 74e2ab0..b618475 100644 --- a/src/pages/decks/decks.tsx +++ b/src/pages/decks/decks.tsx @@ -1,6 +1,8 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' -import { Button, TextField } from '@/components' +import { useForm } from 'react-hook-form' + +import { Button, ControlledTextField, TextField } from '@/components' import { Table, TableBody, @@ -9,59 +11,82 @@ import { TableHeadCell, TableRow, } from '@/components/ui/table' -import { useCreateDeckMutation, useGetDecksQuery } from '@/services/decks' +import { useLogoutMutation } from '@/services/auth/auth.ts' +import { useCreateDeckMutation, useDeleteDeckMutation, useGetDecksQuery } from '@/services/decks' import { decksSlice } from '@/services/decks/decks.slice.ts' +import { CreateDeckArgs } from '@/services/decks/types.ts' import { useAppDispatch, useAppSelector } from '@/services/store.ts' export const Decks = () => { const [cardName, setCardName] = useState('') + const { register, control, handleSubmit } = useForm<{ + name: string + cover: File[] + }>() + const dispatch = useAppDispatch() const itemsPerPage = useAppSelector(state => state.decksSlice.itemsPerPage) const currentPage = useAppSelector(state => state.decksSlice.currentPage) const searchByName = useAppSelector(state => state.decksSlice.searchByName) + const orderBy = useAppSelector(state => state.decksSlice.orderBy) - const setItemsPerPage = (itemsPerPage: number) => + const setItemsPerPage = (itemsPerPage: number) => { dispatch(decksSlice.actions.setItemsPerPage(itemsPerPage)) + } const setCurrentPage = (currentPage: number) => dispatch(decksSlice.actions.setCurrentPage(currentPage)) const setSearch = (search: string) => dispatch(decksSlice.actions.setSearchByName(search)) - const { isLoading, data, refetch } = useGetDecksQuery({ + const { + isLoading, + currentData: data, + refetch, + } = useGetDecksQuery({ itemsPerPage, currentPage, name: searchByName, - orderBy: 'created-desc', + orderBy, }) const [createDeck, { isLoading: isCreateDeckLoading }] = useCreateDeckMutation() + const [deleteDeck, { error }] = useDeleteDeckMutation() + const [logout] = useLogoutMutation() - const handleCreateClicked = () => createDeck({ name: cardName }) + const handleCreateClicked = handleSubmit(data => { + const formData = new FormData() + + console.log(data.cover) + formData.append('name', data.name) + formData.append('cover', data.cover[0]) + + createDeck(formData as unknown as CreateDeckArgs) + }) if (isLoading) return
Loading...
return (
-
- -
-
- - - -
-
- - - -
- setSearch(e.currentTarget.value)} /> - setCardName(e.currentTarget.value)} - label={'card name'} - /> - + {/*
*/} + {/* */} + {/* */} + {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/* */} + {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/* */} + {/*
*/} + {/* setSearch(e.currentTarget.value)} />*/} +
+ + + + isCreateDeckLoading: {isCreateDeckLoading.toString()}
{/* table*/} @@ -81,11 +106,13 @@ export const Decks = () => { {data?.items.map(deck => { return ( - {/* tr*/} {deck.name} {/* td*/} {deck.cardsCount} {new Date(deck.updated).toLocaleString('en-GB')} {deck.author.name} + + + ) })} diff --git a/src/pages/sign-in/sign-in.tsx b/src/pages/sign-in/sign-in.tsx new file mode 100644 index 0000000..55aad6c --- /dev/null +++ b/src/pages/sign-in/sign-in.tsx @@ -0,0 +1,23 @@ +import { Navigate, useNavigate } from 'react-router-dom' + +import { SignIn } from '@/components' +import { useLoginMutation, useMeQuery } from '@/services/auth/auth.ts' + +export const SignInPage = () => { + const { data, isLoading } = useMeQuery() + const [signIn, { isLoading: isSigningIn }] = useLoginMutation() + const navigate = useNavigate() + + if (isLoading) return
Loading...
+ if (data) return + + const handleSignIn = (data: any) => { + signIn(data) + .unwrap() + .then(() => { + navigate('/') + }) + } + + return +} diff --git a/src/router.tsx b/src/router.tsx index abed2b9..50c0e65 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -7,11 +7,13 @@ import { } from 'react-router-dom' import { Decks } from '@/pages/decks/decks.tsx' +import { SignInPage } from '@/pages/sign-in/sign-in.tsx' +import { useMeQuery } from '@/services/auth/auth.ts' const publicRoutes: RouteObject[] = [ { path: '/login', - element:
login
, + element: , }, ] @@ -35,7 +37,11 @@ export const Router = () => { } function PrivateRoutes() { - const isAuthenticated = true + const { data, isLoading } = useMeQuery() + + if (isLoading) return
Loading...
+ + const isAuthenticated = !!data return isAuthenticated ? : } diff --git a/src/services/auth/auth.ts b/src/services/auth/auth.ts new file mode 100644 index 0000000..a1b71f9 --- /dev/null +++ b/src/services/auth/auth.ts @@ -0,0 +1,60 @@ +import { baseApi } from '@/services/base-api.ts' + +const authApi = baseApi.injectEndpoints({ + endpoints: builder => { + return { + me: builder.query({ + query: () => { + return { + url: `v1/auth/me`, + method: 'GET', + } + }, + extraOptions: { + maxRetries: 0, + }, + providesTags: ['Me'], + }), + login: builder.mutation({ + query: args => { + return { + url: `v1/auth/login`, + method: 'POST', + params: args, + } + }, + invalidatesTags: ['Me'], + }), + logout: builder.mutation({ + query: () => { + return { + url: `v1/auth/logout`, + method: 'POST', + } + }, + async onQueryStarted(_, { dispatch, queryFulfilled }) { + const patchResult = dispatch( + authApi.util.updateQueryData('me', undefined, () => { + return null + }) + ) + + try { + await queryFulfilled + } catch { + patchResult.undo() + + /** + * Alternatively, on failure you can invalidate the corresponding cache tags + * to trigger a re-fetch: + * dispatch(api.util.invalidateTags(['Post'])) + */ + } + }, + invalidatesTags: ['Me'], + }), + } + }, +}) + +export const { useLoginMutation, useMeQuery, useLogoutMutation } = authApi diff --git a/src/services/base-api-with-refetch.ts b/src/services/base-api-with-refetch.ts new file mode 100644 index 0000000..121d939 --- /dev/null +++ b/src/services/base-api-with-refetch.ts @@ -0,0 +1,51 @@ +import { BaseQueryFn, FetchArgs, fetchBaseQuery, FetchBaseQueryError } from '@reduxjs/toolkit/query' +import { Mutex } from 'async-mutex' + +const baseUrl = 'http://localhost:3333' + +// Create a new mutex +const mutex = new Mutex() + +const baseQuery = fetchBaseQuery({ + baseUrl, + credentials: 'include', + // prepareHeaders: headers => { + // headers.append('x-short-access-token', 'true') + // }, +}) + +export const customFetchBase: BaseQueryFn< + string | FetchArgs, + unknown, + FetchBaseQueryError +> = async (args, api, extraOptions) => { + // wait until the mutex is available without locking it + await mutex.waitForUnlock() + let result = await baseQuery(args, api, extraOptions) + + if (result.error?.status === 401) { + if (!mutex.isLocked()) { + const release = await mutex.acquire() + + try { + const refreshResult = await baseQuery( + { url: 'v1/auth/refresh-token', method: 'POST' }, + api, + extraOptions + ) + + if (refreshResult?.meta?.response?.status === 204) { + // Retry the initial query + result = await baseQuery(args, api, extraOptions) + } + } finally { + release() + } + } else { + await mutex.waitForUnlock() + result = await baseQuery(args, api, extraOptions) + } + } + + return result +} diff --git a/src/services/base-api.ts b/src/services/base-api.ts index 43a17da..89c58c7 100644 --- a/src/services/base-api.ts +++ b/src/services/base-api.ts @@ -1,14 +1,10 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { createApi } from '@reduxjs/toolkit/query/react' + +import { customFetchBase } from '@/services/base-api-with-refetch.ts' export const baseApi = createApi({ reducerPath: 'baseApi', - tagTypes: ['Decks'], - baseQuery: fetchBaseQuery({ - baseUrl: 'https://api.flashcards.andrii.es', - credentials: 'include', - prepareHeaders: headers => { - headers.append('x-auth-skip', 'true') - }, - }), + tagTypes: ['Decks', 'Me'], + baseQuery: customFetchBase, endpoints: () => ({}), }) diff --git a/src/services/decks/decks.slice.ts b/src/services/decks/decks.slice.ts index b06ad48..3d8de0c 100644 --- a/src/services/decks/decks.slice.ts +++ b/src/services/decks/decks.slice.ts @@ -4,6 +4,7 @@ const initialState = { itemsPerPage: 10, currentPage: 1, searchByName: '', + orderBy: 'created-desc', } export const decksSlice = createSlice({ diff --git a/src/services/decks/decks.ts b/src/services/decks/decks.ts index 1d30c2f..38ea092 100644 --- a/src/services/decks/decks.ts +++ b/src/services/decks/decks.ts @@ -1,5 +1,12 @@ import { baseApi } from '@/services/base-api.ts' -import { CreateDeckArgs, Deck, DecksResponse, GetDecksArgs } from '@/services/decks/types.ts' +import { + CreateDeckArgs, + Deck, + DecksResponse, + DeleteDeckArgs, + GetDecksArgs, +} from '@/services/decks/types.ts' +import { RootState } from '@/services/store.ts' const decksApi = baseApi.injectEndpoints({ endpoints: builder => { @@ -15,11 +22,73 @@ const decksApi = baseApi.injectEndpoints({ providesTags: ['Decks'], }), createDeck: builder.mutation({ - query: ({ name }) => { + query: data => { return { url: 'v1/decks', method: 'POST', - body: { name }, + body: data, + } + }, + async onQueryStarted(_, { dispatch, getState, queryFulfilled }) { + const state = getState() as RootState + + const { searchByName, orderBy, currentPage, itemsPerPage } = state.decksSlice + + try { + const res = await queryFulfilled + + dispatch( + decksApi.util.updateQueryData( + 'getDecks', + { name: searchByName, orderBy, currentPage, itemsPerPage }, + draft => { + draft.items.pop() + draft.items.unshift(res.data) + } + ) + ) + } catch { + // patchResult.undo() + /** + * Alternatively, on failure you can invalidate the corresponding cache tags + * to trigger a re-fetch: + * dispatch(api.util.invalidateTags(['Post'])) + */ + } + }, + invalidatesTags: ['Decks'], + }), + deleteDeck: builder.mutation({ + query: ({ id }) => { + return { + url: `v1/decks/${id}`, + method: 'DELETE', + } + }, + async onQueryStarted({ id }, { dispatch, getState, queryFulfilled }) { + const state = getState() as RootState + + const { searchByName, orderBy, currentPage, itemsPerPage } = state.decksSlice + + const patchResult = dispatch( + decksApi.util.updateQueryData( + 'getDecks', + { name: searchByName, orderBy, currentPage, itemsPerPage }, + draft => { + draft.items = draft.items.filter(deck => deck.id !== id) + } + ) + ) + + try { + await queryFulfilled + } catch { + patchResult.undo() + /** + * Alternatively, on failure you can invalidate the corresponding cache tags + * to trigger a re-fetch: + * dispatch(api.util.invalidateTags(['Post'])) + */ } }, invalidatesTags: ['Decks'], @@ -28,4 +97,4 @@ const decksApi = baseApi.injectEndpoints({ }, }) -export const { useGetDecksQuery, useCreateDeckMutation } = decksApi +export const { useGetDecksQuery, useCreateDeckMutation, useDeleteDeckMutation } = decksApi diff --git a/src/services/decks/types.ts b/src/services/decks/types.ts index 8b2cffd..30c792b 100644 --- a/src/services/decks/types.ts +++ b/src/services/decks/types.ts @@ -11,6 +11,7 @@ export type GetDecksArgs = PaginatedRequest<{ export type CreateDeckArgs = { name: string } +export type DeleteDeckArgs = Pick export interface Author { id: string name: string