From b81d33c1b47ffb6b1b593702fbff814f8096f414 Mon Sep 17 00:00:00 2001 From: andres Date: Sat, 30 Dec 2023 19:18:54 +0100 Subject: [PATCH] lesson 4 live 30/12 --- package.json | 1 + pnpm-lock.yaml | 9 ++ src/App.tsx | 4 + .../decks/deck-dialog/deck-dialog.tsx | 2 +- src/pages/decks-page/decks-page.tsx | 27 ++++-- src/pages/sign-in-page/sign-in-page.tsx | 19 ++++- src/router.tsx | 17 +++- src/services/auth/auth.service.ts | 21 +++++ src/services/auth/auth.types.ts | 14 ++++ src/services/base-api.ts | 13 +-- src/services/base-query-with-reauth.ts | 47 +++++++++++ src/services/decks/decks.service.ts | 83 +++++++++++++++++++ src/services/decks/decks.slice.ts | 8 +- tsconfig.json | 2 +- 14 files changed, 243 insertions(+), 24 deletions(-) create mode 100644 src/services/auth/auth.service.ts create mode 100644 src/services/auth/auth.types.ts create mode 100644 src/services/base-query-with-reauth.ts diff --git a/package.json b/package.json index bc52933..6af8023 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-tabs": "^1.0.4", "@reduxjs/toolkit": "^2.0.1", "@storybook/theming": "^7.6.6", + "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 d52729a..7b59ba5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ dependencies: '@storybook/theming': specifier: ^7.6.6 version: 7.6.6(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 @@ -4854,6 +4857,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.2 + dev: false + /async@3.2.5: resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==} dev: true diff --git a/src/App.tsx b/src/App.tsx index 6f08196..9435e22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,15 @@ import { Provider } from 'react-redux' +import { ToastContainer } from 'react-toastify' import { Router } from '@/router' import { store } from '@/services/store' +import 'react-toastify/dist/ReactToastify.css' + export function App() { return ( + ) diff --git a/src/components/decks/deck-dialog/deck-dialog.tsx b/src/components/decks/deck-dialog/deck-dialog.tsx index b96999f..a1d1ee0 100644 --- a/src/components/decks/deck-dialog/deck-dialog.tsx +++ b/src/components/decks/deck-dialog/deck-dialog.tsx @@ -8,7 +8,7 @@ import s from './deck-dialog.module.scss' const newDeckSchema = z.object({ isPrivate: z.boolean(), - name: z.string().min(3).max(50), + name: z.string().min(3).max(5000), }) type FormValues = z.infer diff --git a/src/pages/decks-page/decks-page.tsx b/src/pages/decks-page/decks-page.tsx index 1da4d00..0b11186 100644 --- a/src/pages/decks-page/decks-page.tsx +++ b/src/pages/decks-page/decks-page.tsx @@ -5,6 +5,7 @@ import { DeckDialog } from '@/components/decks/deck-dialog' import { DeleteDeckDialog } from '@/components/decks/delete-deck-dialog' import { Pagination } from '@/components/ui/pagination' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { useMeQuery } from '@/services/auth/auth.service' import { Tab, useCreateDeckMutation, @@ -25,6 +26,7 @@ import { useAppDispatch, useAppSelector } from '@/services/store' import s from './decks-page.module.scss' export const DecksPage = () => { + const { data: me } = useMeQuery() const [showCreateModal, setShowCreateModal] = useState(false) const [deckToDeleteId, setDeckToDeleteId] = useState(null) const [deckToEditId, setDeckToEditId] = useState(null) @@ -41,7 +43,8 @@ export const DecksPage = () => { const setMinCards = (minCards: number) => dispatch(decksSlice.actions.setMinCards(minCards)) const setMaxCards = (maxCards: number) => dispatch(decksSlice.actions.setMaxCards(maxCards)) const setSearch = (search: string) => dispatch(decksSlice.actions.setSearch(search)) - const setCurrentTab = (tab: Tab) => dispatch(decksSlice.actions.setCurrentTab(tab)) + const setCurrentTab = (tab: { authorId?: string; tab: Tab }) => + dispatch(decksSlice.actions.setCurrentTab(tab)) const resetFilters = () => { dispatch(decksSlice.actions.resetFilters()) @@ -54,10 +57,9 @@ export const DecksPage = () => { setMinCards(value[0]) setMaxCards(value[1]) } - const currentUserId = 'f2be95b9-4d07-4751-a775-bd612fc9553a' + const currentUserId = me?.id const authorId = currentTab === 'my' ? currentUserId : undefined - - const { data: decks } = useGetDecksQuery({ + const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery({ authorId, currentPage, maxCardsCount: maxCards, @@ -65,6 +67,8 @@ export const DecksPage = () => { name: search, }) + const decks = decksCurrentData ?? decksData + const showConfirmDeleteModal = !!deckToDeleteId const deckToDeleteName = decks?.items?.find(deck => deck.id === deckToDeleteId)?.name @@ -73,9 +77,10 @@ export const DecksPage = () => { const [createDeck] = useCreateDeckMutation() const [deleteDeck] = useDeleteDeckMutation() const [updateDeck] = useUpdateDeckMutation() + const openCreateModal = () => setShowCreateModal(true) - if (!decks) { + if (!decks || !me) { return
loading...
} @@ -110,14 +115,20 @@ export const DecksPage = () => { setShowCreateModal(false)} - onConfirm={createDeck} + onConfirm={data => { + resetFilters() + createDeck(data) + }} onOpenChange={setShowCreateModal} open={showCreateModal} />
- setCurrentTab(value as Tab)} value={currentTab}> + setCurrentTab({ authorId: currentUserId, tab: value as Tab })} + value={currentTab} + > My decks All decks @@ -135,7 +146,7 @@ export const DecksPage = () => {
{ + const [signIn] = useLoginMutation() + const navigate = useNavigate() + const handleSignIn = async (data: LoginArgs) => { + try { + await signIn(data).unwrap() + navigate('/') + } catch (error: any) { + console.log(error) + toast.error(error?.data?.message ?? 'Could not sign in') + } + } + return ( - {}} /> + ) } diff --git a/src/router.tsx b/src/router.tsx index c46122e..d2c75c8 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,9 +6,11 @@ import { createBrowserRouter, } from 'react-router-dom' -import { DecksPage, SignInPage } from './pages' import { DeckPage } from '@/pages/deck-page/deck-page' +import { DecksPage, SignInPage } from './pages' +import { useMeQuery } from './services/auth/auth.service' + const publicRoutes: RouteObject[] = [ { children: [ @@ -41,11 +43,22 @@ const router = createBrowserRouter([ ]) export const Router = () => { + const { isLoading } = useMeQuery() + + if (isLoading) { + return
loading...
+ } + return } function PrivateRoutes() { - const isAuthenticated = true + const { isError, isLoading } = useMeQuery() + + if (isLoading) { + return
loading...
+ } + const isAuthenticated = !isError return isAuthenticated ? : } diff --git a/src/services/auth/auth.service.ts b/src/services/auth/auth.service.ts new file mode 100644 index 0000000..da53d40 --- /dev/null +++ b/src/services/auth/auth.service.ts @@ -0,0 +1,21 @@ +import { baseApi } from '..' +import { LoginArgs, User } from './auth.types' + +export const authService = baseApi.injectEndpoints({ + endpoints: builder => ({ + login: builder.mutation({ + invalidatesTags: ['Me'], + query: body => ({ + body, + method: 'POST', + url: '/v1/auth/login', + }), + }), + me: builder.query({ + providesTags: ['Me'], + query: () => '/v1/auth/me', + }), + }), +}) + +export const { useLoginMutation, useMeQuery } = authService diff --git a/src/services/auth/auth.types.ts b/src/services/auth/auth.types.ts new file mode 100644 index 0000000..fa5be7f --- /dev/null +++ b/src/services/auth/auth.types.ts @@ -0,0 +1,14 @@ +export type LoginArgs = { + email: string + password: string + rememberMe?: boolean +} +export type User = { + avatar: null | string + created: string + email: string + id: string + isEmailVerified: boolean + name: string + updated: string +} diff --git a/src/services/base-api.ts b/src/services/base-api.ts index f2f9776..b78e007 100644 --- a/src/services/base-api.ts +++ b/src/services/base-api.ts @@ -1,14 +1,9 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' +import { baseQueryWithReauth } from '@/services/base-query-with-reauth' +import { createApi } from '@reduxjs/toolkit/query/react' export const baseApi = createApi({ - baseQuery: fetchBaseQuery({ - baseUrl: 'https://api.flashcards.andrii.es', - credentials: 'include', - prepareHeaders: headers => { - headers.append('x-auth-skip', 'true') - }, - }), + baseQuery: baseQueryWithReauth, endpoints: () => ({}), reducerPath: 'baseApi', - tagTypes: ['Decks'], + tagTypes: ['Decks', 'Me'], }) diff --git a/src/services/base-query-with-reauth.ts b/src/services/base-query-with-reauth.ts new file mode 100644 index 0000000..2fa97fb --- /dev/null +++ b/src/services/base-query-with-reauth.ts @@ -0,0 +1,47 @@ +import { + BaseQueryFn, + FetchArgs, + FetchBaseQueryError, + fetchBaseQuery, +} from '@reduxjs/toolkit/query/react' +import { Mutex } from 'async-mutex' + +const mutex = new Mutex() + +const baseQuery = fetchBaseQuery({ + baseUrl: 'https://api.flashcards.andrii.es', + credentials: 'include', +}) + +export const baseQueryWithReauth: BaseQueryFn< + FetchArgs | string, + unknown, + FetchBaseQueryError +> = async (args, api, extraOptions) => { + await mutex.waitForUnlock() + let result = await baseQuery(args, api, extraOptions) + + if (result.error && result.error.status === 401) { + if (!mutex.isLocked()) { + const release = await mutex.acquire() + // try to get a new token + const refreshResult = await baseQuery( + { method: 'POST', url: '/v1/auth/refresh-token' }, + api, + extraOptions + ) + + if (refreshResult.meta?.response?.status === 204) { + // retry the initial query + result = await baseQuery(args, api, extraOptions) + } + release() + } else { + // wait until the mutex is available without locking it + await mutex.waitForUnlock() + result = await baseQuery(args, api, extraOptions) + } + } + + return result +} diff --git a/src/services/decks/decks.service.ts b/src/services/decks/decks.service.ts index 63d0d0c..7978d20 100644 --- a/src/services/decks/decks.service.ts +++ b/src/services/decks/decks.service.ts @@ -7,11 +7,57 @@ import { UpdateDeckArgs, baseApi, } from '@/services' +import { RootState } from '@/services/store' const decksService = baseApi.injectEndpoints({ endpoints: builder => ({ createDeck: builder.mutation({ invalidatesTags: ['Decks'], + async onQueryStarted(_, { dispatch, getState, queryFulfilled }) { + const res = await queryFulfilled + + console.log(decksService.util.selectCachedArgsForQuery(getState(), 'getDecks')) + for (const { endpointName, originalArgs } of decksService.util.selectInvalidatedBy( + getState(), + [{ type: 'Decks' }] + )) { + console.log(endpointName, originalArgs) + // we only want to update `getPosts` here + if (endpointName !== 'getDecks') { + continue + } + dispatch( + decksService.util.updateQueryData(endpointName, originalArgs, draft => { + draft.items.unshift(res.data) + }) + ) + } + + // console.log(args) + // const minCardsCount = state.decks.minCards + // const search = state.decks.search + // const currentPage = state.decks.currentPage + // const maxCardsCount = state.decks.maxCards + // const authorId = state.decks.authorId + // + // console.log(res) + // + // dispatch( + // decksService.util.updateQueryData( + // 'getDecks', + // { + // authorId, + // currentPage, + // maxCardsCount, + // minCardsCount, + // name: search, + // }, + // draft => { + // draft.items.unshift(res.data) + // } + // ) + // ) + }, query: body => ({ body, method: 'POST', @@ -42,6 +88,43 @@ const decksService = baseApi.injectEndpoints({ }), updateDeck: builder.mutation({ invalidatesTags: ['Decks'], + async onQueryStarted({ id, ...patch }, { dispatch, getState, queryFulfilled }) { + const state = getState() as RootState + + console.log(state) + const minCardsCount = state.decks.minCards + const search = state.decks.search + const currentPage = state.decks.currentPage + const maxCardsCount = state.decks.maxCards + const authorId = state.decks.authorId + + const patchResult = dispatch( + decksService.util.updateQueryData( + 'getDecks', + { + authorId, + currentPage, + maxCardsCount, + minCardsCount, + name: search, + }, + draft => { + const deck = draft.items.find(deck => deck.id === id) + + if (!deck) { + return + } + Object.assign(deck, patch) + } + ) + ) + + try { + await queryFulfilled + } catch { + patchResult.undo() + } + }, query: ({ id, ...body }) => ({ body, method: 'PATCH', diff --git a/src/services/decks/decks.slice.ts b/src/services/decks/decks.slice.ts index 0d1f8fb..5df028d 100644 --- a/src/services/decks/decks.slice.ts +++ b/src/services/decks/decks.slice.ts @@ -3,6 +3,7 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit' export const decksSlice = createSlice({ initialState: { + authorId: undefined as string | undefined, currentPage: 1, currentTab: 'all' as Tab, maxCards: undefined as number | undefined, @@ -18,14 +19,17 @@ export const decksSlice = createSlice({ resetFilters: state => { state.search = '' state.currentTab = 'all' + state.authorId = undefined state.minCards = 0 state.maxCards = undefined + state.currentPage = 1 }, setCurrentPage: (state, action: PayloadAction) => { state.currentPage = action.payload }, - setCurrentTab: (state, action: PayloadAction) => { - state.currentTab = action.payload + setCurrentTab: (state, action: PayloadAction<{ authorId?: string; tab: Tab }>) => { + state.currentTab = action.payload.tab + state.authorId = action.payload.authorId }, setMaxCards: (state, action: PayloadAction) => { state.maxCards = action.payload diff --git a/tsconfig.json b/tsconfig.json index 1c8358f..b151dc6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ /* Bundler mode */ "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "allowImportingTsExtensions": false, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true,