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", "@radix-ui/react-radio-group": "^1.1.3",
"@reduxjs/toolkit": "^1.9.5", "@reduxjs/toolkit": "^1.9.5",
"@storybook/theming": "^7.2.1", "@storybook/theming": "^7.2.1",
"async-mutex": "^0.4.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

9
pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ dependencies:
'@storybook/theming': '@storybook/theming':
specifier: ^7.2.1 specifier: ^7.2.1
version: 7.2.1(react-dom@18.2.0)(react@18.2.0) version: 7.2.1(react-dom@18.2.0)(react@18.2.0)
async-mutex:
specifier: ^0.4.0
version: 0.4.0
clsx: clsx:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
@@ -4646,6 +4649,12 @@ packages:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
dev: true 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: /async@3.2.4:
resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==}
dev: true dev: true

View File

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

View File

@@ -9,14 +9,12 @@ import { Button, Card, ControlledTextField, Typography } from '../../ui'
import s from './sign-up.module.scss' import s from './sign-up.module.scss'
import { emailSchema } from '@/components'
const schema = z const schema = z
.object({ .object({
password: z.string().nonempty('Enter password'), password: z.string().nonempty('Enter password'),
passwordConfirmation: z.string().nonempty('Confirm your password'), passwordConfirmation: z.string().nonempty('Confirm your password'),
email: z.string().email('Invalid email address').nonempty('Enter email'),
}) })
.merge(emailSchema)
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.password !== data.passwordConfirmation) { if (data.password !== data.passwordConfirmation) {
ctx.addIssue({ ctx.addIssue({

View File

@@ -198,8 +198,6 @@ export const WithSort = {
return `${sort.key}-${sort.direction}` return `${sort.key}-${sort.direction}`
}, [sort]) }, [sort])
console.log(sortedString)
return ( return (
<table> <table>
<TableHeader columns={columns} sort={sort} onSort={setSort} /> <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 { import {
Table, Table,
TableBody, TableBody,
@@ -9,59 +11,82 @@ import {
TableHeadCell, TableHeadCell,
TableRow, TableRow,
} from '@/components/ui/table' } 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 { decksSlice } from '@/services/decks/decks.slice.ts'
import { CreateDeckArgs } from '@/services/decks/types.ts'
import { useAppDispatch, useAppSelector } from '@/services/store.ts' import { useAppDispatch, useAppSelector } from '@/services/store.ts'
export const Decks = () => { export const Decks = () => {
const [cardName, setCardName] = useState('') const [cardName, setCardName] = useState('')
const { register, control, handleSubmit } = useForm<{
name: string
cover: File[]
}>()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const itemsPerPage = useAppSelector(state => state.decksSlice.itemsPerPage) const itemsPerPage = useAppSelector(state => state.decksSlice.itemsPerPage)
const currentPage = useAppSelector(state => state.decksSlice.currentPage) const currentPage = useAppSelector(state => state.decksSlice.currentPage)
const searchByName = useAppSelector(state => state.decksSlice.searchByName) 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)) dispatch(decksSlice.actions.setItemsPerPage(itemsPerPage))
}
const setCurrentPage = (currentPage: number) => const setCurrentPage = (currentPage: number) =>
dispatch(decksSlice.actions.setCurrentPage(currentPage)) dispatch(decksSlice.actions.setCurrentPage(currentPage))
const setSearch = (search: string) => dispatch(decksSlice.actions.setSearchByName(search)) const setSearch = (search: string) => dispatch(decksSlice.actions.setSearchByName(search))
const { isLoading, data, refetch } = useGetDecksQuery({ const {
isLoading,
currentData: data,
refetch,
} = useGetDecksQuery({
itemsPerPage, itemsPerPage,
currentPage, currentPage,
name: searchByName, name: searchByName,
orderBy: 'created-desc', orderBy,
}) })
const [createDeck, { isLoading: isCreateDeckLoading }] = useCreateDeckMutation() 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> if (isLoading) return <div>Loading...</div>
return ( return (
<div> <div>
<div> {/*<div>*/}
<Button onClick={refetch}>refetch</Button> {/* <Button onClick={refetch}>refetch</Button>*/}
</div> {/* <Button onClick={logout}>Logout</Button>*/}
<div> {/*</div>*/}
<Button onClick={() => setItemsPerPage(10)}>itemsPerPage: 10</Button> {/*<div>*/}
<Button onClick={() => setItemsPerPage(20)}>itemsPerPage: 20</Button> {/* <Button onClick={() => setItemsPerPage(10)}>itemsPerPage: 10</Button>*/}
<Button onClick={() => setItemsPerPage(30)}>itemsPerPage: 30</Button> {/* <Button onClick={() => setItemsPerPage(20)}>itemsPerPage: 20</Button>*/}
</div> {/* <Button onClick={() => setItemsPerPage(30)}>itemsPerPage: 30</Button>*/}
<div> {/*</div>*/}
<Button onClick={() => setCurrentPage(1)}>currentPage: 1</Button> {/*<div>*/}
<Button onClick={() => setCurrentPage(2)}>currentPage: 2</Button> {/* <Button onClick={() => setCurrentPage(1)}>currentPage: 1</Button>*/}
<Button onClick={() => setCurrentPage(3)}>currentPage: 3</Button> {/* <Button onClick={() => setCurrentPage(2)}>currentPage: 2</Button>*/}
</div> {/* <Button onClick={() => setCurrentPage(3)}>currentPage: 3</Button>*/}
<TextField value={searchByName} onChange={e => setSearch(e.currentTarget.value)} /> {/*</div>*/}
<TextField {/*<TextField value={searchByName} onChange={e => setSearch(e.currentTarget.value)} />*/}
value={cardName} <form onSubmit={handleCreateClicked}>
onChange={e => setCardName(e.currentTarget.value)} <ControlledTextField name={'name'} control={control} label={'name'} />
label={'card name'} <input type={'file'} {...register('cover')} />
/> <Button>Create deck</Button>
<Button onClick={handleCreateClicked}>Create deck</Button> </form>
isCreateDeckLoading: {isCreateDeckLoading.toString()} isCreateDeckLoading: {isCreateDeckLoading.toString()}
<Table> <Table>
{/* table*/} {/* table*/}
@@ -81,11 +106,13 @@ export const Decks = () => {
{data?.items.map(deck => { {data?.items.map(deck => {
return ( return (
<TableRow key={deck.id}> <TableRow key={deck.id}>
{/* tr*/}
<TableCell>{deck.name}</TableCell> {/* td*/} <TableCell>{deck.name}</TableCell> {/* td*/}
<TableCell>{deck.cardsCount}</TableCell> <TableCell>{deck.cardsCount}</TableCell>
<TableCell>{new Date(deck.updated).toLocaleString('en-GB')}</TableCell> <TableCell>{new Date(deck.updated).toLocaleString('en-GB')}</TableCell>
<TableCell>{deck.author.name}</TableCell> <TableCell>{deck.author.name}</TableCell>
<TableCell>
<Button onClick={() => deleteDeck({ id: deck.id })}>Delete</Button>
</TableCell>
</TableRow> </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' } from 'react-router-dom'
import { Decks } from '@/pages/decks/decks.tsx' 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[] = [ const publicRoutes: RouteObject[] = [
{ {
path: '/login', path: '/login',
element: <div>login</div>, element: <SignInPage />,
}, },
] ]
@@ -35,7 +37,11 @@ export const Router = () => {
} }
function PrivateRoutes() { function PrivateRoutes() {
const isAuthenticated = true const { data, isLoading } = useMeQuery()
if (isLoading) return <div>Loading...</div>
const isAuthenticated = !!data
return isAuthenticated ? <Outlet /> : <Navigate to="/login" /> 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({ export const baseApi = createApi({
reducerPath: 'baseApi', reducerPath: 'baseApi',
tagTypes: ['Decks'], tagTypes: ['Decks', 'Me'],
baseQuery: fetchBaseQuery({ baseQuery: customFetchBase,
baseUrl: 'https://api.flashcards.andrii.es',
credentials: 'include',
prepareHeaders: headers => {
headers.append('x-auth-skip', 'true')
},
}),
endpoints: () => ({}), endpoints: () => ({}),
}) })

View File

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

View File

@@ -1,5 +1,12 @@
import { baseApi } from '@/services/base-api.ts' 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({ const decksApi = baseApi.injectEndpoints({
endpoints: builder => { endpoints: builder => {
@@ -15,11 +22,73 @@ const decksApi = baseApi.injectEndpoints({
providesTags: ['Decks'], providesTags: ['Decks'],
}), }),
createDeck: builder.mutation<Deck, CreateDeckArgs>({ createDeck: builder.mutation<Deck, CreateDeckArgs>({
query: ({ name }) => { query: data => {
return { return {
url: 'v1/decks', url: 'v1/decks',
method: 'POST', 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'], 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 = { export type CreateDeckArgs = {
name: string name: string
} }
export type DeleteDeckArgs = Pick<Deck, 'id'>
export interface Author { export interface Author {
id: string id: string
name: string name: string