lesson 4 live 30/12

This commit is contained in:
2023-12-30 19:18:54 +01:00
parent 7adcc8d2c9
commit b81d33c1b4
14 changed files with 243 additions and 24 deletions

View File

@@ -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",

9
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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 (
<Provider store={store}>
<ToastContainer />
<Router />
</Provider>
)

View File

@@ -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<typeof newDeckSchema>

View File

@@ -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 | string>(null)
const [deckToEditId, setDeckToEditId] = useState<null | string>(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 <div>loading...</div>
}
@@ -110,14 +115,20 @@ export const DecksPage = () => {
<Button onClick={openCreateModal}>Add new deck</Button>
<DeckDialog
onCancel={() => setShowCreateModal(false)}
onConfirm={createDeck}
onConfirm={data => {
resetFilters()
createDeck(data)
}}
onOpenChange={setShowCreateModal}
open={showCreateModal}
/>
</div>
<div className={s.filters}>
<TextField onValueChange={setSearch} placeholder={'Search'} search value={search} />
<Tabs onValueChange={value => setCurrentTab(value as Tab)} value={currentTab}>
<Tabs
onValueChange={value => setCurrentTab({ authorId: currentUserId, tab: value as Tab })}
value={currentTab}
>
<TabsList>
<TabsTrigger value={'my'}>My decks</TabsTrigger>
<TabsTrigger value={'all'}>All decks</TabsTrigger>
@@ -135,7 +146,7 @@ export const DecksPage = () => {
</Button>
</div>
<DecksTable
currentUserId={currentUserId}
currentUserId={currentUserId ?? ''}
decks={decks?.items}
onDeleteClick={setDeckToDeleteId}
onEditClick={setDeckToEditId}

View File

@@ -1,9 +1,26 @@
import { useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { Page, SignIn } from '@/components'
import { useLoginMutation } from '@/services/auth/auth.service'
import { LoginArgs } from '@/services/auth/auth.types'
export const SignInPage = () => {
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 (
<Page>
<SignIn onSubmit={() => {}} />
<SignIn onSubmit={handleSignIn} />
</Page>
)
}

View File

@@ -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 <div>loading...</div>
}
return <RouterProvider router={router} />
}
function PrivateRoutes() {
const isAuthenticated = true
const { isError, isLoading } = useMeQuery()
if (isLoading) {
return <div>loading...</div>
}
const isAuthenticated = !isError
return isAuthenticated ? <Outlet /> : <Navigate to={'/login'} />
}

View File

@@ -0,0 +1,21 @@
import { baseApi } from '..'
import { LoginArgs, User } from './auth.types'
export const authService = baseApi.injectEndpoints({
endpoints: builder => ({
login: builder.mutation<void, LoginArgs>({
invalidatesTags: ['Me'],
query: body => ({
body,
method: 'POST',
url: '/v1/auth/login',
}),
}),
me: builder.query<User, void>({
providesTags: ['Me'],
query: () => '/v1/auth/me',
}),
}),
})
export const { useLoginMutation, useMeQuery } = authService

View File

@@ -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
}

View File

@@ -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'],
})

View File

@@ -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
}

View File

@@ -7,11 +7,57 @@ import {
UpdateDeckArgs,
baseApi,
} from '@/services'
import { RootState } from '@/services/store'
const decksService = baseApi.injectEndpoints({
endpoints: builder => ({
createDeck: builder.mutation<DeckResponse, CreateDeckArgs>({
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<DeckResponse, UpdateDeckArgs>({
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',

View File

@@ -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<number>) => {
state.currentPage = action.payload
},
setCurrentTab: (state, action: PayloadAction<Tab>) => {
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<number>) => {
state.maxCards = action.payload

View File

@@ -8,7 +8,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,