lesson 4 finished

This commit is contained in:
andres
2023-08-12 21:43:52 +02:00
parent 8c3caf9983
commit d8cb5706b4
15 changed files with 296 additions and 60 deletions

View File

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

9
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -8,21 +8,17 @@ import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } fro
import s from './sign-in.module.scss'
export const emailSchema = z.object({
email: z.string().email('Invalid email address').nonempty('Enter email'),
})
const schema = 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(),
})
.merge(emailSchema)
type FormType = z.infer<typeof schema>
type Props = {
onSubmit: (data: FormType) => void
isSubmitting?: boolean
}
export const SignIn = (props: Props) => {
@@ -76,7 +72,7 @@ export const SignIn = (props: Props) => {
>
Forgot Password?
</Typography>
<Button className={s.button} fullWidth type={'submit'}>
<Button className={s.button} fullWidth type={'submit'} disabled={props.isSubmitting}>
Sign In
</Button>
</form>

View File

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

View File

@@ -198,8 +198,6 @@ export const WithSort = {
return `${sort.key}-${sort.direction}`
}, [sort])
console.log(sortedString)
return (
<table>
<TableHeader columns={columns} sort={sort} onSort={setSort} />

View File

@@ -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 <div>Loading...</div>
return (
<div>
<div>
<Button onClick={refetch}>refetch</Button>
</div>
<div>
<Button onClick={() => setItemsPerPage(10)}>itemsPerPage: 10</Button>
<Button onClick={() => setItemsPerPage(20)}>itemsPerPage: 20</Button>
<Button onClick={() => setItemsPerPage(30)}>itemsPerPage: 30</Button>
</div>
<div>
<Button onClick={() => setCurrentPage(1)}>currentPage: 1</Button>
<Button onClick={() => setCurrentPage(2)}>currentPage: 2</Button>
<Button onClick={() => setCurrentPage(3)}>currentPage: 3</Button>
</div>
<TextField value={searchByName} onChange={e => setSearch(e.currentTarget.value)} />
<TextField
value={cardName}
onChange={e => setCardName(e.currentTarget.value)}
label={'card name'}
/>
<Button onClick={handleCreateClicked}>Create deck</Button>
{/*<div>*/}
{/* <Button onClick={refetch}>refetch</Button>*/}
{/* <Button onClick={logout}>Logout</Button>*/}
{/*</div>*/}
{/*<div>*/}
{/* <Button onClick={() => setItemsPerPage(10)}>itemsPerPage: 10</Button>*/}
{/* <Button onClick={() => setItemsPerPage(20)}>itemsPerPage: 20</Button>*/}
{/* <Button onClick={() => setItemsPerPage(30)}>itemsPerPage: 30</Button>*/}
{/*</div>*/}
{/*<div>*/}
{/* <Button onClick={() => setCurrentPage(1)}>currentPage: 1</Button>*/}
{/* <Button onClick={() => setCurrentPage(2)}>currentPage: 2</Button>*/}
{/* <Button onClick={() => setCurrentPage(3)}>currentPage: 3</Button>*/}
{/*</div>*/}
{/*<TextField value={searchByName} onChange={e => setSearch(e.currentTarget.value)} />*/}
<form onSubmit={handleCreateClicked}>
<ControlledTextField name={'name'} control={control} label={'name'} />
<input type={'file'} {...register('cover')} />
<Button>Create deck</Button>
</form>
isCreateDeckLoading: {isCreateDeckLoading.toString()}
<Table>
{/* table*/}
@@ -81,11 +106,13 @@ export const Decks = () => {
{data?.items.map(deck => {
return (
<TableRow key={deck.id}>
{/* tr*/}
<TableCell>{deck.name}</TableCell> {/* td*/}
<TableCell>{deck.cardsCount}</TableCell>
<TableCell>{new Date(deck.updated).toLocaleString('en-GB')}</TableCell>
<TableCell>{deck.author.name}</TableCell>
<TableCell>
<Button onClick={() => deleteDeck({ id: deck.id })}>Delete</Button>
</TableCell>
</TableRow>
)
})}

View File

@@ -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 <div>Loading...</div>
if (data) return <Navigate to="/" />
const handleSignIn = (data: any) => {
signIn(data)
.unwrap()
.then(() => {
navigate('/')
})
}
return <SignIn onSubmit={handleSignIn} isSubmitting={isSigningIn} />
}

View File

@@ -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: <div>login</div>,
element: <SignInPage />,
},
]
@@ -35,7 +37,11 @@ export const Router = () => {
}
function PrivateRoutes() {
const isAuthenticated = true
const { data, isLoading } = useMeQuery()
if (isLoading) return <div>Loading...</div>
const isAuthenticated = !!data
return isAuthenticated ? <Outlet /> : <Navigate to="/login" />
}

60
src/services/auth/auth.ts Normal file
View File

@@ -0,0 +1,60 @@
import { baseApi } from '@/services/base-api.ts'
const authApi = baseApi.injectEndpoints({
endpoints: builder => {
return {
me: builder.query<any, void>({
query: () => {
return {
url: `v1/auth/me`,
method: 'GET',
}
},
extraOptions: {
maxRetries: 0,
},
providesTags: ['Me'],
}),
login: builder.mutation<any, any>({
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

View File

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

View File

@@ -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: () => ({}),
})

View File

@@ -4,6 +4,7 @@ const initialState = {
itemsPerPage: 10,
currentPage: 1,
searchByName: '',
orderBy: 'created-desc',
}
export const decksSlice = createSlice({

View File

@@ -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<Deck, CreateDeckArgs>({
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<void, DeleteDeckArgs>({
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

View File

@@ -11,6 +11,7 @@ export type GetDecksArgs = PaginatedRequest<{
export type CreateDeckArgs = {
name: string
}
export type DeleteDeckArgs = Pick<Deck, 'id'>
export interface Author {
id: string
name: string