From b81d33c1b47ffb6b1b593702fbff814f8096f414 Mon Sep 17 00:00:00 2001 From: andres Date: Sat, 30 Dec 2023 19:18:54 +0100 Subject: [PATCH 1/8] 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, From e7ccc59a601065b36eedf43d55b0a5fd335a747c Mon Sep 17 00:00:00 2001 From: andres Date: Sat, 30 Dec 2023 19:19:01 +0100 Subject: [PATCH 2/8] lesson 4 live 30/12 --- src/components/decks/deck-dialog/index.ts | 2 +- src/components/decks/index.ts | 4 ++-- src/components/layout/index.ts | 2 +- src/services/decks/decks.selectors.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/decks/deck-dialog/index.ts b/src/components/decks/deck-dialog/index.ts index 7824814..04c9041 100644 --- a/src/components/decks/deck-dialog/index.ts +++ b/src/components/decks/deck-dialog/index.ts @@ -1 +1 @@ -export * from './deck-dialog.tsx' +export * from './deck-dialog' diff --git a/src/components/decks/index.ts b/src/components/decks/index.ts index 5246fa1..a94adf2 100644 --- a/src/components/decks/index.ts +++ b/src/components/decks/index.ts @@ -1,2 +1,2 @@ -export * from './decks-table.tsx' -export * from './cards-table.tsx' +export * from './decks-table' +export * from './cards-table' diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index 3ca75bf..626835b 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -1 +1 @@ -export * from './layout.tsx' +export * from './layout' diff --git a/src/services/decks/decks.selectors.ts b/src/services/decks/decks.selectors.ts index 7d1e844..800f154 100644 --- a/src/services/decks/decks.selectors.ts +++ b/src/services/decks/decks.selectors.ts @@ -1,4 +1,4 @@ -import { RootState } from '@/services/store.ts' +import { RootState } from '@/services/store' export const selectDecksCurrentPage = (state: RootState) => state.decks.currentPage From 1e0983969690641188edf8b755798482817c8e99 Mon Sep 17 00:00:00 2001 From: andres Date: Sun, 31 Dec 2023 10:55:30 +0100 Subject: [PATCH 3/8] feat: apply layout --- src/components/decks/decks-table.tsx | 1 + .../header/user-dropdown/user-dropdown.tsx | 6 ++++- src/components/layout/layout.stories.tsx | 6 ++--- src/components/layout/layout.tsx | 26 +++++++++++++++++-- src/components/ui/page/page.module.scss | 2 +- src/router.tsx | 12 ++++++--- 6 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/components/decks/decks-table.tsx b/src/components/decks/decks-table.tsx index 9207d55..d81bf9b 100644 --- a/src/components/decks/decks-table.tsx +++ b/src/components/decks/decks-table.tsx @@ -44,6 +44,7 @@ type Props = { onDeleteClick: (id: string) => void onEditClick: (id: string) => void } + export const DecksTable = ({ currentUserId, decks, onDeleteClick, onEditClick }: Props) => { const handleEditClick = (id: string) => () => onEditClick(id) const handleDeleteClick = (id: string) => () => onDeleteClick(id) diff --git a/src/components/layout/header/user-dropdown/user-dropdown.tsx b/src/components/layout/header/user-dropdown/user-dropdown.tsx index 98fd478..cec8b80 100644 --- a/src/components/layout/header/user-dropdown/user-dropdown.tsx +++ b/src/components/layout/header/user-dropdown/user-dropdown.tsx @@ -16,13 +16,17 @@ import { import s from './user-dropdown.module.scss' export type UserDropdownProps = { - avatar: string + avatar: null | string email: string onLogout: ComponentPropsWithoutRef['onSelect'] userName: string } export const UserDropdown = ({ avatar, email, onLogout, userName }: UserDropdownProps) => { + if (!avatar) { + avatar = `https://ui-avatars.com/api/?name=${userName.split(' ').join('+')}` + } + return ( diff --git a/src/components/layout/layout.stories.tsx b/src/components/layout/layout.stories.tsx index 2a67bb3..8577bd0 100644 --- a/src/components/layout/layout.stories.tsx +++ b/src/components/layout/layout.stories.tsx @@ -1,18 +1,18 @@ import type { Meta, StoryObj } from '@storybook/react' -import { Layout } from './' +import { LayoutPrimitive } from './' const meta = { argTypes: { onLogout: { action: 'logout' }, }, - component: Layout, + component: LayoutPrimitive, parameters: { layout: 'fullscreen', }, tags: ['autodocs'], title: 'Components/Layout', -} satisfies Meta +} satisfies Meta export default meta type Story = StoryObj diff --git a/src/components/layout/layout.tsx b/src/components/layout/layout.tsx index 8946495..c0e3434 100644 --- a/src/components/layout/layout.tsx +++ b/src/components/layout/layout.tsx @@ -1,12 +1,34 @@ import { ReactNode } from 'react' +import { Outlet } from 'react-router-dom' import { Header, HeaderProps } from '@/components' +import { useMeQuery } from '@/services/auth/auth.service' import s from './layout.module.scss' -export type LayoutProps = { children: ReactNode } & HeaderProps +export const Layout = () => { + const { data, isError, isLoading } = useMeQuery() -export const Layout = ({ children, ...headerProps }: LayoutProps) => { + if (isLoading) { + return
loading...
+ } + + return ( + {}} + userName={data?.name ?? ''} + > + + + ) +} + +export type LayoutPrimitiveProps = { children: ReactNode } & HeaderProps + +export const LayoutPrimitive = ({ children, ...headerProps }: LayoutPrimitiveProps) => { return (
diff --git a/src/components/ui/page/page.module.scss b/src/components/ui/page/page.module.scss index 077066d..d659099 100644 --- a/src/components/ui/page/page.module.scss +++ b/src/components/ui/page/page.module.scss @@ -1,6 +1,6 @@ .root { display: flex; justify-content: center; + width: 100%; margin-top: 36px; - padding-inline: 24px; } diff --git a/src/router.tsx b/src/router.tsx index d2c75c8..83a4e2f 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,6 +6,7 @@ import { createBrowserRouter, } from 'react-router-dom' +import { Layout } from '@/components/layout' import { DeckPage } from '@/pages/deck-page/deck-page' import { DecksPage, SignInPage } from './pages' @@ -36,10 +37,15 @@ const privateRoutes: RouteObject[] = [ const router = createBrowserRouter([ { - children: privateRoutes, - element: , + children: [ + { + children: privateRoutes, + element: , + }, + ...publicRoutes, + ], + element: , }, - ...publicRoutes, ]) export const Router = () => { From dd9cc3e3aa8f6e76c0878691c66ecf5fbc06f674 Mon Sep 17 00:00:00 2001 From: andres Date: Sun, 31 Dec 2023 11:34:24 +0100 Subject: [PATCH 4/8] feat: add spinner component, add layout context --- src/components/layout/layout.tsx | 19 +++++-- src/components/ui/index.ts | 1 + src/components/ui/spinner/index.ts | 1 + src/components/ui/spinner/spinner.module.scss | 57 +++++++++++++++++++ src/components/ui/spinner/spinner.stories.tsx | 19 +++++++ src/components/ui/spinner/spinner.tsx | 30 ++++++++++ src/pages/decks-page/decks-page.tsx | 4 +- src/router.tsx | 16 +----- 8 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 src/components/ui/spinner/index.ts create mode 100644 src/components/ui/spinner/spinner.module.scss create mode 100644 src/components/ui/spinner/spinner.stories.tsx create mode 100644 src/components/ui/spinner/spinner.tsx diff --git a/src/components/layout/layout.tsx b/src/components/layout/layout.tsx index c0e3434..6d4448c 100644 --- a/src/components/layout/layout.tsx +++ b/src/components/layout/layout.tsx @@ -1,27 +1,36 @@ import { ReactNode } from 'react' -import { Outlet } from 'react-router-dom' +import { Outlet, useOutletContext } from 'react-router-dom' -import { Header, HeaderProps } from '@/components' +import { Header, HeaderProps, Spinner } from '@/components' import { useMeQuery } from '@/services/auth/auth.service' import s from './layout.module.scss' +type AuthContext = { + isAuthenticated: boolean +} + +export function useAuthContext() { + return useOutletContext() +} + export const Layout = () => { const { data, isError, isLoading } = useMeQuery() + const isAuthenticated = !isError && !isLoading if (isLoading) { - return
loading...
+ return } return ( {}} userName={data?.name ?? ''} > - + ) } diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 594e76e..cf20b41 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,3 +1,4 @@ +export * from './spinner' export * from './avatar' export * from './dropdown' export * from '../layout/header' diff --git a/src/components/ui/spinner/index.ts b/src/components/ui/spinner/index.ts new file mode 100644 index 0000000..21591ec --- /dev/null +++ b/src/components/ui/spinner/index.ts @@ -0,0 +1 @@ +export * from './spinner' diff --git a/src/components/ui/spinner/spinner.module.scss b/src/components/ui/spinner/spinner.module.scss new file mode 100644 index 0000000..480017c --- /dev/null +++ b/src/components/ui/spinner/spinner.module.scss @@ -0,0 +1,57 @@ +.fullScreenContainer { + position: fixed; + z-index: 9999; + top: var(--header-height); + left: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 100vw; + height: calc(100vh - var(--header-height)); + + background-color: rgb(0 0 0 / 50%); +} + +.loader { + position: relative; + + display: inline-block; + + box-sizing: border-box; + width: 48px; + height: 48px; + + border: 3px solid #fff; + border-radius: 50%; + + animation: rotation 1s linear infinite; +} + +.loader::after { + content: ''; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + box-sizing: border-box; + width: 40px; + height: 40px; + + border: 3px solid transparent; + border-bottom-color: var(--color-accent-500); + border-radius: 50%; +} + +@keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/ui/spinner/spinner.stories.tsx b/src/components/ui/spinner/spinner.stories.tsx new file mode 100644 index 0000000..038a9e3 --- /dev/null +++ b/src/components/ui/spinner/spinner.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Spinner } from './' + +const meta = { + component: Spinner, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + title: 'Components/Spinner', +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: {}, +} diff --git a/src/components/ui/spinner/spinner.tsx b/src/components/ui/spinner/spinner.tsx new file mode 100644 index 0000000..ff1a335 --- /dev/null +++ b/src/components/ui/spinner/spinner.tsx @@ -0,0 +1,30 @@ +import { CSSProperties, ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react' + +import { clsx } from 'clsx' + +import s from './spinner.module.scss' + +export type SpinnerProps = { + fullScreen?: boolean + size?: CSSProperties['width'] +} & ComponentPropsWithoutRef<'span'> + +export const Spinner = forwardRef, SpinnerProps>( + ({ className, fullScreen, size = '48px', style, ...rest }, ref) => { + const styles = { + height: size, + width: size, + ...style, + } satisfies CSSProperties + + if (fullScreen) { + return ( +
+ +
+ ) + } + + return + } +) diff --git a/src/pages/decks-page/decks-page.tsx b/src/pages/decks-page/decks-page.tsx index 0b11186..0a3f0f4 100644 --- a/src/pages/decks-page/decks-page.tsx +++ b/src/pages/decks-page/decks-page.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' -import { Button, DecksTable, Page, Slider, TextField, Typography } from '@/components' +import { Button, DecksTable, Page, Slider, Spinner, TextField, Typography } from '@/components' import { DeckDialog } from '@/components/decks/deck-dialog' import { DeleteDeckDialog } from '@/components/decks/delete-deck-dialog' import { Pagination } from '@/components/ui/pagination' @@ -81,7 +81,7 @@ export const DecksPage = () => { const openCreateModal = () => setShowCreateModal(true) if (!decks || !me) { - return
loading...
+ return } return ( diff --git a/src/router.tsx b/src/router.tsx index 83a4e2f..18b6627 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -6,11 +6,10 @@ import { createBrowserRouter, } from 'react-router-dom' -import { Layout } from '@/components/layout' +import { Layout, useAuthContext } from '@/components/layout' import { DeckPage } from '@/pages/deck-page/deck-page' import { DecksPage, SignInPage } from './pages' -import { useMeQuery } from './services/auth/auth.service' const publicRoutes: RouteObject[] = [ { @@ -49,22 +48,11 @@ const router = createBrowserRouter([ ]) export const Router = () => { - const { isLoading } = useMeQuery() - - if (isLoading) { - return
loading...
- } - return } function PrivateRoutes() { - const { isError, isLoading } = useMeQuery() - - if (isLoading) { - return
loading...
- } - const isAuthenticated = !isError + const { isAuthenticated } = useAuthContext() return isAuthenticated ? : } From a56ca779fc7f549cda8ba4fbc56a11157aa4fcbc Mon Sep 17 00:00:00 2001 From: andres Date: Mon, 1 Jan 2024 15:17:05 +0100 Subject: [PATCH 5/8] feat: use search params for filters instead of redux --- package.json | 2 +- pnpm-lock.yaml | 8 +- .../auth/check-email/check-email.tsx | 4 +- .../auth/new-password/new-password.tsx | 5 +- src/components/auth/sign-in/sign-in.tsx | 3 +- src/components/auth/sign-up/sign-up.tsx | 3 +- .../decks/deck-dialog/deck-dialog.stories.tsx | 3 +- src/components/decks/decks-table.tsx | 27 ++++-- .../delete-deck-dialog.stories.tsx | 3 +- .../personal-information.tsx | 4 +- src/components/ui/button/button.module.scss | 9 ++ src/components/ui/button/button.tsx | 4 +- src/components/ui/card/card.stories.tsx | 3 +- .../ui/checkbox/checkbox.stories.tsx | 3 +- src/components/ui/dialog/dialog.stories.tsx | 3 +- src/components/ui/pagination/pagination.tsx | 3 +- src/components/ui/slider/slider.tsx | 2 +- src/components/ui/table/table.stories.tsx | 3 +- src/hooks/index.ts | 1 + src/hooks/use-query-param/index.ts | 1 + src/hooks/use-query-param/use-query-param.ts | 36 ++++++++ src/main.tsx | 5 +- src/pages/decks-page/decks-page.tsx | 88 +++++++++++-------- .../decks-page/use-deck-search-params.ts | 75 ++++++++++++++++ src/services/decks/decks.service.ts | 32 +------ src/services/decks/decks.types.ts | 14 +-- src/services/store.ts | 3 +- src/utils/get-valuable.ts | 9 ++ src/utils/index.ts | 1 + 29 files changed, 247 insertions(+), 110 deletions(-) create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/use-query-param/index.ts create mode 100644 src/hooks/use-query-param/use-query-param.ts create mode 100644 src/pages/decks-page/use-deck-search-params.ts create mode 100644 src/utils/get-valuable.ts diff --git a/package.json b/package.json index 6af8023..b54273a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@hookform/devtools": "^4.3.1", "@it-incubator/eslint-config": "^1.0.2", "@it-incubator/prettier-config": "^0.1.2", - "@it-incubator/stylelint-config": "^1.0.1", + "@it-incubator/stylelint-config": "^1.0.2", "@storybook/addon-essentials": "^7.6.6", "@storybook/addon-interactions": "^7.6.6", "@storybook/addon-links": "^7.6.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b59ba5..d9c10c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,8 +83,8 @@ devDependencies: specifier: ^0.1.2 version: 0.1.2 '@it-incubator/stylelint-config': - specifier: ^1.0.1 - version: 1.0.1(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0) + specifier: ^1.0.2 + version: 1.0.2(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0) '@storybook/addon-essentials': specifier: ^7.6.6 version: 7.6.6(@types/react-dom@18.2.18)(@types/react@18.2.46)(react-dom@18.2.0)(react@18.2.0) @@ -2178,8 +2178,8 @@ packages: prettier: 3.0.0 dev: true - /@it-incubator/stylelint-config@1.0.1(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0): - resolution: {integrity: sha512-dZuCX0wXtuNwU0BoNM1fbfo9V9DIG4IhtZ67cOgTsv7/zXFOxXCPiOSx2SguCA7sg4P62/oq2pMQq/x/Y4Edag==} + /@it-incubator/stylelint-config@1.0.2(stylelint-config-clean-order@5.2.0)(stylelint-config-standard-scss@12.0.0)(stylelint@16.1.0): + resolution: {integrity: sha512-2LIducOMyfhFOGV1GvmpWjjvgRGhEtHw/qBv3Vjw+Nel8jCl9cpH2yJrdehK1QJSOkzvQGs2pPQViouW9FQUCA==} peerDependencies: stylelint: 16.1.0 stylelint-config-clean-order: 5.2.0 diff --git a/src/components/auth/check-email/check-email.tsx b/src/components/auth/check-email/check-email.tsx index c34a0b9..d895dfc 100644 --- a/src/components/auth/check-email/check-email.tsx +++ b/src/components/auth/check-email/check-email.tsx @@ -1,10 +1,10 @@ import { Link } from 'react-router-dom' +import s from './check-email.module.scss' + import { Email } from '../../../assets/icons' import { Button, Card, Typography } from '../../ui' -import s from './check-email.module.scss' - type Props = { email: string } diff --git a/src/components/auth/new-password/new-password.tsx b/src/components/auth/new-password/new-password.tsx index a73d999..a91fcec 100644 --- a/src/components/auth/new-password/new-password.tsx +++ b/src/components/auth/new-password/new-password.tsx @@ -1,14 +1,15 @@ import { useForm } from 'react-hook-form' -import { Button, Card, ControlledTextField, Typography } from '../../ui' import { DevTool } from '@hookform/devtools' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import s from './new-password.module.scss' +import { Button, Card, ControlledTextField, Typography } from '../../ui' + const schema = z.object({ - password: z.string().nonempty('Enter password'), + password: z.string().min(1, 'Enter password'), }) 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 2a47d2b..cc12a37 100644 --- a/src/components/auth/sign-in/sign-in.tsx +++ b/src/components/auth/sign-in/sign-in.tsx @@ -1,13 +1,14 @@ import { useForm } from 'react-hook-form' import { Link } from 'react-router-dom' -import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } from '../../ui' import { DevTool } from '@hookform/devtools' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import s from './sign-in.module.scss' +import { Button, Card, ControlledCheckbox, ControlledTextField, Typography } from '../../ui' + const schema = z.object({ email: z.string().email('Invalid email address').nonempty('Enter email'), password: z.string().nonempty('Enter password'), diff --git a/src/components/auth/sign-up/sign-up.tsx b/src/components/auth/sign-up/sign-up.tsx index 57e3faa..909efb5 100644 --- a/src/components/auth/sign-up/sign-up.tsx +++ b/src/components/auth/sign-up/sign-up.tsx @@ -1,7 +1,6 @@ import { useForm } from 'react-hook-form' import { Link } from 'react-router-dom' -import { Button, Card, ControlledTextField, Typography } from '../../ui' import { DevTool } from '@hookform/devtools' import { zodResolver } from '@hookform/resolvers/zod' import { omit } from 'remeda' @@ -9,6 +8,8 @@ import { z } from 'zod' import s from './sign-up.module.scss' +import { Button, Card, ControlledTextField, Typography } from '../../ui' + const schema = z .object({ email: z.string().email('Invalid email address').nonempty('Enter email'), diff --git a/src/components/decks/deck-dialog/deck-dialog.stories.tsx b/src/components/decks/deck-dialog/deck-dialog.stories.tsx index d4d602f..cbbb1a7 100644 --- a/src/components/decks/deck-dialog/deck-dialog.stories.tsx +++ b/src/components/decks/deck-dialog/deck-dialog.stories.tsx @@ -1,9 +1,10 @@ import { useState } from 'react' -import { DeckDialog } from './' import { Button } from '@/components' import { Meta, StoryObj } from '@storybook/react' +import { DeckDialog } from './' + const meta = { component: DeckDialog, tags: ['autodocs'], diff --git a/src/components/decks/decks-table.tsx b/src/components/decks/decks-table.tsx index d81bf9b..a55ad40 100644 --- a/src/components/decks/decks-table.tsx +++ b/src/components/decks/decks-table.tsx @@ -2,7 +2,9 @@ import { Link } from 'react-router-dom' import { Edit2Outline, PlayCircleOutline, TrashOutline } from '@/assets' import { + Button, Column, + Sort, Table, TableBody, TableCell, @@ -43,15 +45,24 @@ type Props = { decks: Deck[] | undefined onDeleteClick: (id: string) => void onEditClick: (id: string) => void + onSort: (key: Sort) => void + sort: Sort } -export const DecksTable = ({ currentUserId, decks, onDeleteClick, onEditClick }: Props) => { +export const DecksTable = ({ + currentUserId, + decks, + onDeleteClick, + onEditClick, + onSort, + sort, +}: Props) => { const handleEditClick = (id: string) => () => onEditClick(id) const handleDeleteClick = (id: string) => () => onDeleteClick(id) return ( - + {decks?.map(deck => ( @@ -65,17 +76,17 @@ export const DecksTable = ({ currentUserId, decks, onDeleteClick, onEditClick }: {deck.author.name}
- + {deck.author.id === currentUserId && ( <> - - + + )}
diff --git a/src/components/decks/delete-deck-dialog/delete-deck-dialog.stories.tsx b/src/components/decks/delete-deck-dialog/delete-deck-dialog.stories.tsx index 22761e3..d3ee01f 100644 --- a/src/components/decks/delete-deck-dialog/delete-deck-dialog.stories.tsx +++ b/src/components/decks/delete-deck-dialog/delete-deck-dialog.stories.tsx @@ -1,9 +1,10 @@ import { useState } from 'react' -import { DeleteDeckDialog } from './' import { Button } from '@/components' import { Meta, StoryObj } from '@storybook/react' +import { DeleteDeckDialog } from './' + const meta = { component: DeleteDeckDialog, tags: ['autodocs'], diff --git a/src/components/profile/personal-information/personal-information.tsx b/src/components/profile/personal-information/personal-information.tsx index 042b931..2803672 100644 --- a/src/components/profile/personal-information/personal-information.tsx +++ b/src/components/profile/personal-information/personal-information.tsx @@ -1,8 +1,8 @@ +import s from './personal-information.module.scss' + import { Camera, Edit, Logout } from '../../../assets/icons' import { Button, Card, Typography } from '../../ui' -import s from './personal-information.module.scss' - type Props = { avatar: string email: string diff --git a/src/components/ui/button/button.module.scss b/src/components/ui/button/button.module.scss index e9540ca..d82cdf3 100644 --- a/src/components/ui/button/button.module.scss +++ b/src/components/ui/button/button.module.scss @@ -44,6 +44,7 @@ } .primary { + composes: button; color: var(--color-light-100); background-color: var(--color-accent-500); box-shadow: 0 4px 18px rgb(140 97 255 / 35%); @@ -58,6 +59,7 @@ } .secondary { + composes: button; color: var(--color-light-100); background-color: var(--color-dark-300); box-shadow: 0 2px 10px 0 #6d6d6d40; @@ -72,6 +74,7 @@ } .tertiary { + composes: button; color: var(--color-accent-500); background-color: var(--color-dark-900); border: 1px solid var(--color-accent-700); @@ -86,6 +89,8 @@ } .link { + composes: button; + padding: 0.375rem 0; font-weight: var(--font-weight-bold); @@ -93,3 +98,7 @@ color: var(--color-accent-500); text-decoration-line: underline; } + +.icon { + border-radius: 9999px; +} diff --git a/src/components/ui/button/button.tsx b/src/components/ui/button/button.tsx index 861fd4c..dbcad6e 100644 --- a/src/components/ui/button/button.tsx +++ b/src/components/ui/button/button.tsx @@ -16,7 +16,7 @@ export type ButtonProps = { children: ReactNode className?: string fullWidth?: boolean - variant?: 'link' | 'primary' | 'secondary' | 'tertiary' + variant?: 'icon' | 'link' | 'primary' | 'secondary' | 'tertiary' } & ComponentPropsWithoutRef const ButtonPolymorph = (props: ButtonProps, ref: any) => { @@ -31,7 +31,7 @@ const ButtonPolymorph = (props: ButtonProps return ( diff --git a/src/components/ui/card/card.stories.tsx b/src/components/ui/card/card.stories.tsx index 04f9afc..cc0656a 100644 --- a/src/components/ui/card/card.stories.tsx +++ b/src/components/ui/card/card.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react' -import { Card } from './' import { Typography } from '@/components' +import { Card } from './' + const meta = { component: Card, tags: ['autodocs'], diff --git a/src/components/ui/checkbox/checkbox.stories.tsx b/src/components/ui/checkbox/checkbox.stories.tsx index 6e50671..00bfbad 100644 --- a/src/components/ui/checkbox/checkbox.stories.tsx +++ b/src/components/ui/checkbox/checkbox.stories.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' -import { Checkbox } from './checkbox' import { Meta, StoryObj } from '@storybook/react' + +import { Checkbox } from './checkbox' const meta = { component: Checkbox, tags: ['autodocs'], diff --git a/src/components/ui/dialog/dialog.stories.tsx b/src/components/ui/dialog/dialog.stories.tsx index 854d88f..b133210 100644 --- a/src/components/ui/dialog/dialog.stories.tsx +++ b/src/components/ui/dialog/dialog.stories.tsx @@ -1,8 +1,9 @@ import { useState } from 'react' -import { Dialog } from './' import { Meta, StoryObj } from '@storybook/react' +import { Dialog } from './' + const meta = { component: Dialog, tags: ['autodocs'], diff --git a/src/components/ui/pagination/pagination.tsx b/src/components/ui/pagination/pagination.tsx index eab5115..9a06780 100644 --- a/src/components/ui/pagination/pagination.tsx +++ b/src/components/ui/pagination/pagination.tsx @@ -1,11 +1,12 @@ import { FC } from 'react' -import { usePagination } from './usePagination' import { KeyboardArrowLeft, KeyboardArrowRight } from '@/assets' import { clsx } from 'clsx' import s from './pagination.module.scss' +import { usePagination } from './usePagination' + type PaginationConditionals = | { onPerPageChange: (itemPerPage: number) => void diff --git a/src/components/ui/slider/slider.tsx b/src/components/ui/slider/slider.tsx index 99d7ebe..0997068 100644 --- a/src/components/ui/slider/slider.tsx +++ b/src/components/ui/slider/slider.tsx @@ -7,7 +7,7 @@ import s from './slider.module.scss' const Slider = forwardRef< ElementRef, Omit, 'value'> & { - value?: (number | undefined)[] + value?: (null | number)[] } >(({ className, max, onValueChange, value, ...props }, ref) => { useEffect(() => { diff --git a/src/components/ui/table/table.stories.tsx b/src/components/ui/table/table.stories.tsx index 9ea1833..01f1efd 100644 --- a/src/components/ui/table/table.stories.tsx +++ b/src/components/ui/table/table.stories.tsx @@ -1,7 +1,8 @@ -import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeadCell, TableRow } from './' import { Typography } from '@/components' import { Meta } from '@storybook/react' +import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeadCell, TableRow } from './' + export default { component: Table, title: 'Components/Table', diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..25b14f1 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './use-query-param' diff --git a/src/hooks/use-query-param/index.ts b/src/hooks/use-query-param/index.ts new file mode 100644 index 0000000..25b14f1 --- /dev/null +++ b/src/hooks/use-query-param/index.ts @@ -0,0 +1 @@ +export * from './use-query-param' diff --git a/src/hooks/use-query-param/use-query-param.ts b/src/hooks/use-query-param/use-query-param.ts new file mode 100644 index 0000000..83591e0 --- /dev/null +++ b/src/hooks/use-query-param/use-query-param.ts @@ -0,0 +1,36 @@ +import { isNil } from 'remeda' + +export function useQueryParam( + searchParams: URLSearchParams, + setSearchParams: (searchParams: URLSearchParams) => void, + param: string, + defaultValue?: T +): [T | null, (value: T | null) => void] { + const paramValue = searchParams.get(param) + const convertedValue = getConvertedValue(paramValue, defaultValue) + + const setParamValue = (value: T | null): void => { + if (isNil(value) || value === '') { + searchParams.delete(param) + } else { + searchParams.set(param, String(value)) + } + setSearchParams(searchParams) + } + + return [convertedValue, setParamValue] +} + +function getConvertedValue(value: null | string, defaultValue: T | undefined): T | null { + if (value === null) { + return defaultValue ?? null + } + if (value === 'true' || value === 'false') { + return (value === 'true') as unknown as T + } + if (!isNaN(Number(value))) { + return Number(value) as unknown as T + } + + return value as unknown as T +} diff --git a/src/main.tsx b/src/main.tsx index c4965f9..f27e98f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,11 +1,12 @@ import { StrictMode } from 'react' -import { App } from './App' import { createRoot } from 'react-dom/client' +import './styles/index.scss' import '@fontsource/roboto/400.css' import '@fontsource/roboto/700.css' -import './styles/index.scss' + +import { App } from './App' createRoot(document.getElementById('root')!).render( diff --git a/src/pages/decks-page/decks-page.tsx b/src/pages/decks-page/decks-page.tsx index 0a3f0f4..700fdf9 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 { useDeckSearchParams } from '@/pages/decks-page/use-deck-search-params' import { useMeQuery } from '@/services/auth/auth.service' import { Tab, @@ -13,15 +14,6 @@ import { useGetDecksQuery, useUpdateDeckMutation, } from '@/services/decks' -import { - selectDecksCurrentPage, - selectDecksCurrentTab, - selectDecksMaxCards, - selectDecksMinCards, - selectDecksSearch, -} from '@/services/decks/decks.selectors' -import { decksSlice } from '@/services/decks/decks.slice' -import { useAppDispatch, useAppSelector } from '@/services/store' import s from './decks-page.module.scss' @@ -33,40 +25,41 @@ export const DecksPage = () => { const showEditModal = !!deckToEditId - const dispatch = useAppDispatch() - const currentPage = useAppSelector(selectDecksCurrentPage) - const minCards = useAppSelector(selectDecksMinCards) - const maxCards = useAppSelector(selectDecksMaxCards) - const currentTab = useAppSelector(selectDecksCurrentTab) - const search = useAppSelector(selectDecksSearch) - const setCurrentPage = (page: number) => dispatch(decksSlice.actions.setCurrentPage(page)) - 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: { authorId?: string; tab: Tab }) => - dispatch(decksSlice.actions.setCurrentTab(tab)) + const { + currentPage, + currentTab, + maxCardsCount, + minCardsCount, + rangeValue, + search, + setCurrentPage, + setCurrentTab, + setMaxCards, + setMinCards, + setRangeValue, + setSearch, + setSort, + sort, + } = useDeckSearchParams() - const resetFilters = () => { - dispatch(decksSlice.actions.resetFilters()) - setRangeValue([0, decks?.maxCardsCount || undefined]) - } - - const [rangeValue, setRangeValue] = useState([minCards, maxCards]) - - const handleSliderCommitted = (value: number[]) => { - setMinCards(value[0]) - setMaxCards(value[1]) - } const currentUserId = me?.id const authorId = currentTab === 'my' ? currentUserId : undefined const { currentData: decksCurrentData, data: decksData } = useGetDecksQuery({ authorId, currentPage, - maxCardsCount: maxCards, - minCardsCount: minCards, + maxCardsCount, + minCardsCount, name: search, + orderBy: sort ? `${sort.key}-${sort.direction}` : undefined, }) - + const resetFilters = () => { + setCurrentPage(null) + setSearch(null) + setMinCards(null) + setMaxCards(null) + setRangeValue([0, decks?.maxCardsCount ?? null]) + setSort(null) + } const decks = decksCurrentData ?? decksData const showConfirmDeleteModal = !!deckToDeleteId @@ -80,6 +73,16 @@ export const DecksPage = () => { const openCreateModal = () => setShowCreateModal(true) + const handleSearch = (search: null | string) => { + setCurrentPage(null) + setSearch(search) + } + const handleSliderCommitted = (value: number[]) => { + setCurrentPage(null) + setMinCards(value[0]) + setMaxCards(value[1]) + } + if (!decks || !me) { return } @@ -124,10 +127,15 @@ export const DecksPage = () => { />
- + setCurrentTab({ authorId: currentUserId, tab: value as Tab })} - value={currentTab} + onValueChange={value => setCurrentTab(value as Tab)} + value={currentTab ?? undefined} > My decks @@ -150,11 +158,13 @@ export const DecksPage = () => { decks={decks?.items} onDeleteClick={setDeckToDeleteId} onEditClick={setDeckToEditId} + onSort={setSort} + sort={sort} />
diff --git a/src/pages/decks-page/use-deck-search-params.ts b/src/pages/decks-page/use-deck-search-params.ts new file mode 100644 index 0000000..118fca3 --- /dev/null +++ b/src/pages/decks-page/use-deck-search-params.ts @@ -0,0 +1,75 @@ +import { useState } from 'react' +import { useSearchParams } from 'react-router-dom' + +import { Sort } from '@/components' +import { useQueryParam } from '@/hooks' + +export function useDeckSearchParams() { + const [searchParams, setSearchParams] = useSearchParams() + const [currentPage, setCurrentPage] = useQueryParam( + searchParams, + setSearchParams, + 'page', + 1 + ) + const [minCardsCount, setMinCards] = useQueryParam( + searchParams, + setSearchParams, + 'minCards', + 0 + ) + const [maxCardsCount, setMaxCards] = useQueryParam( + searchParams, + setSearchParams, + 'maxCards' + ) + const [search, setSearch] = useQueryParam(searchParams, setSearchParams, 'search') + const [currentTab, setCurrentTab] = useQueryParam( + searchParams, + setSearchParams, + 'currentTab', + 'all' + ) + const [rangeValue, setRangeValue] = useState([minCardsCount, maxCardsCount]) + const [sortKey, setSortKey] = useQueryParam(searchParams, setSearchParams, 'sortKey') + const [sortDirection, setSortDirection] = useQueryParam<'asc' | 'desc'>( + searchParams, + setSearchParams, + 'sortDirection' + ) + const setSort = (sort: Sort) => { + if (!sort) { + setSortKey(null) + setSortDirection(null) + + return + } + setSortKey(sort.key) + setSortDirection(sort.direction) + } + + const sort: Sort = + sortDirection === null || sortKey === null + ? null + : { + direction: sortDirection, + key: sortKey, + } + + return { + currentPage, + currentTab, + maxCardsCount, + minCardsCount, + rangeValue, + search, + setCurrentPage, + setCurrentTab, + setMaxCards, + setMinCards, + setRangeValue, + setSearch, + setSort, + sort, + } +} diff --git a/src/services/decks/decks.service.ts b/src/services/decks/decks.service.ts index 7978d20..43184ec 100644 --- a/src/services/decks/decks.service.ts +++ b/src/services/decks/decks.service.ts @@ -8,6 +8,7 @@ import { baseApi, } from '@/services' import { RootState } from '@/services/store' +import { getValuable } from '@/utils' const decksService = baseApi.injectEndpoints({ endpoints: builder => ({ @@ -16,13 +17,10 @@ const decksService = baseApi.injectEndpoints({ 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 } @@ -32,31 +30,6 @@ const decksService = baseApi.injectEndpoints({ }) ) } - - // 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, @@ -81,7 +54,7 @@ const decksService = baseApi.injectEndpoints({ providesTags: ['Decks'], query: args => { return { - params: args ?? undefined, + params: args ? getValuable(args) : undefined, url: `v1/decks`, } }, @@ -91,7 +64,6 @@ const decksService = baseApi.injectEndpoints({ 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 diff --git a/src/services/decks/decks.types.ts b/src/services/decks/decks.types.ts index b8c005c..777c54c 100644 --- a/src/services/decks/decks.types.ts +++ b/src/services/decks/decks.types.ts @@ -54,13 +54,13 @@ export type Card = { } export type GetDecksArgs = { - authorId?: string - currentPage?: number - itemsPerPage?: number - maxCardsCount?: number - minCardsCount?: number - name?: string - orderBy?: string + authorId?: null | string + currentPage?: null | number + itemsPerPage?: null | number + maxCardsCount?: null | number + minCardsCount?: null | number + name?: null | string + orderBy?: null | string } export type CreateDeckArgs = { diff --git a/src/services/store.ts b/src/services/store.ts index 29a6c32..7e3e2ef 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -1,10 +1,11 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' -import { baseApi } from './base-api' import { decksSlice } from '@/services/decks/decks.slice' import { configureStore } from '@reduxjs/toolkit' import { setupListeners } from '@reduxjs/toolkit/query/react' +import { baseApi } from './base-api' + export const store = configureStore({ middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware), reducer: { diff --git a/src/utils/get-valuable.ts b/src/utils/get-valuable.ts new file mode 100644 index 0000000..a41f3dd --- /dev/null +++ b/src/utils/get-valuable.ts @@ -0,0 +1,9 @@ +type Valuable = { [K in keyof T as T[K] extends null | undefined ? never : K]: T[K] } + +export function getValuable>(obj: T): V { + return Object.fromEntries( + Object.entries(obj).filter( + ([, v]) => !((typeof v === 'string' && !v.length) || v === null || typeof v === 'undefined') + ) + ) as V +} diff --git a/src/utils/index.ts b/src/utils/index.ts index edf1e3c..2d200ba 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ export * from './date' +export * from './get-valuable' From 739312091431ebbcaad5de25968cc9f7fa06f5c3 Mon Sep 17 00:00:00 2001 From: andres Date: Mon, 1 Jan 2024 15:27:27 +0100 Subject: [PATCH 6/8] feat: use search params for filters instead of redux --- src/pages/decks-page/decks-page.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/decks-page/decks-page.tsx b/src/pages/decks-page/decks-page.tsx index 700fdf9..55fb6f3 100644 --- a/src/pages/decks-page/decks-page.tsx +++ b/src/pages/decks-page/decks-page.tsx @@ -8,7 +8,6 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useDeckSearchParams } from '@/pages/decks-page/use-deck-search-params' import { useMeQuery } from '@/services/auth/auth.service' import { - Tab, useCreateDeckMutation, useDeleteDeckMutation, useGetDecksQuery, @@ -83,6 +82,11 @@ export const DecksPage = () => { setMaxCards(value[1]) } + const handleTabChange = (tab: string) => { + setCurrentPage(null) + setCurrentTab(tab) + } + if (!decks || !me) { return } @@ -133,10 +137,7 @@ export const DecksPage = () => { search value={search ?? ''} /> - setCurrentTab(value as Tab)} - value={currentTab ?? undefined} - > + My decks All decks From feddf62c85ff52b8078677b164e94577f3f29f50 Mon Sep 17 00:00:00 2001 From: andres Date: Mon, 1 Jan 2024 15:50:42 +0100 Subject: [PATCH 7/8] feat: slider styles --- src/components/ui/slider/slider.module.scss | 37 +++++++++++++++++++-- src/components/ui/slider/slider.stories.tsx | 17 ++++++++++ src/components/ui/slider/slider.tsx | 8 ++--- 3 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 src/components/ui/slider/slider.stories.tsx diff --git a/src/components/ui/slider/slider.module.scss b/src/components/ui/slider/slider.module.scss index e00a847..dd41934 100644 --- a/src/components/ui/slider/slider.module.scss +++ b/src/components/ui/slider/slider.module.scss @@ -7,6 +7,19 @@ width: 100%; } +.valueDisplay { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 44px; + height: 36px; + + border: 1px solid var(--color-dark-300); + border-radius: 2px; +} + .root { touch-action: none; user-select: none; @@ -25,14 +38,14 @@ width: 100%; height: 4px; - opacity: 0.5; - background-color: var(--color-accent-500); + background-color: rgb(140 97 255 / 50%); border-radius: 2px; } .range { position: absolute; height: 100%; + opacity: 1; background-color: var(--color-accent-500); } @@ -40,13 +53,31 @@ touch-action: pan-x; cursor: pointer; + position: relative; + display: block; width: 16px; height: 16px; - background-color: var(--color-light-100); + background-color: var(--color-accent-500); border-radius: 9999px; + outline: none; transition: transform 0.2s ease-in-out; + + &::after { + content: ''; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + width: 8px; + height: 8px; + + background-color: var(--color-light-100); + border-radius: 9999px; + } } diff --git a/src/components/ui/slider/slider.stories.tsx b/src/components/ui/slider/slider.stories.tsx new file mode 100644 index 0000000..4105675 --- /dev/null +++ b/src/components/ui/slider/slider.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Slider } from './' + +const meta = { + component: Slider, + parameters: {}, + tags: ['autodocs'], + title: 'Components/Slider', +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { value: [0, 100] }, +} diff --git a/src/components/ui/slider/slider.tsx b/src/components/ui/slider/slider.tsx index 0997068..520bec1 100644 --- a/src/components/ui/slider/slider.tsx +++ b/src/components/ui/slider/slider.tsx @@ -7,7 +7,7 @@ import s from './slider.module.scss' const Slider = forwardRef< ElementRef, Omit, 'value'> & { - value?: (null | number)[] + value: (null | number)[] } >(({ className, max, onValueChange, value, ...props }, ref) => { useEffect(() => { @@ -18,7 +18,7 @@ const Slider = forwardRef< return (
- {value?.[0]} + {value?.[0]} - + - {value?.[1]} + {value?.[1]}
) }) From 7af2b43feddd25b4b0aa419cf66d9dab0438cea1 Mon Sep 17 00:00:00 2001 From: andres Date: Mon, 1 Jan 2024 16:13:51 +0100 Subject: [PATCH 8/8] feat: decks page styles --- src/components/ui/pagination/pagination.tsx | 14 +++++++++----- src/components/ui/slider/slider.module.scss | 1 + src/components/ui/tabs/tabs.module.scss | 1 + .../ui/text-field/text-field.module.scss | 1 + src/pages/decks-page/decks-page.module.scss | 6 +++++- src/pages/decks-page/decks-page.tsx | 3 ++- src/styles/_boilerplate.scss | 2 ++ 7 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/ui/pagination/pagination.tsx b/src/components/ui/pagination/pagination.tsx index 9a06780..ceb3803 100644 --- a/src/components/ui/pagination/pagination.tsx +++ b/src/components/ui/pagination/pagination.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react' +import { ComponentPropsWithoutRef, FC } from 'react' import { KeyboardArrowLeft, KeyboardArrowRight } from '@/assets' import { clsx } from 'clsx' @@ -27,8 +27,8 @@ export type PaginationProps = { perPage?: number perPageOptions?: number[] siblings?: number -} & PaginationConditionals - +} & PaginationConditionals & + Omit, 'onChange'> const classNames = { container: s.container, dots: s.dots, @@ -37,12 +37,15 @@ const classNames = { pageButton(selected?: boolean) { return clsx(this.item, selected && s.selected) }, - root: s.root, + root(className?: string) { + return clsx(s.root, className) + }, select: s.select, selectBox: s.selectBox, } export const Pagination: FC = ({ + className, count, onChange, onPerPageChange, @@ -50,6 +53,7 @@ export const Pagination: FC = ({ perPage = null, perPageOptions, siblings, + ...rest }) => { const { handleMainPageClicked, @@ -68,7 +72,7 @@ export const Pagination: FC = ({ const showPerPageSelect = !!perPage && !!perPageOptions && !!onPerPageChange return ( -
+
diff --git a/src/components/ui/slider/slider.module.scss b/src/components/ui/slider/slider.module.scss index dd41934..dc1c449 100644 --- a/src/components/ui/slider/slider.module.scss +++ b/src/components/ui/slider/slider.module.scss @@ -5,6 +5,7 @@ justify-content: center; width: 100%; + min-width: 220px; } .valueDisplay { diff --git a/src/components/ui/tabs/tabs.module.scss b/src/components/ui/tabs/tabs.module.scss index 554bf40..8a891ea 100644 --- a/src/components/ui/tabs/tabs.module.scss +++ b/src/components/ui/tabs/tabs.module.scss @@ -1,5 +1,6 @@ .list { display: inline-flex; + flex-shrink: 0; background-color: var(--color-dark-900); } diff --git a/src/components/ui/text-field/text-field.module.scss b/src/components/ui/text-field/text-field.module.scss index eaeb347..85e2d68 100644 --- a/src/components/ui/text-field/text-field.module.scss +++ b/src/components/ui/text-field/text-field.module.scss @@ -1,5 +1,6 @@ .root { width: 100%; + min-width: 200px; } .fieldContainer { diff --git a/src/pages/decks-page/decks-page.module.scss b/src/pages/decks-page/decks-page.module.scss index ca89803..9bf0cb6 100644 --- a/src/pages/decks-page/decks-page.module.scss +++ b/src/pages/decks-page/decks-page.module.scss @@ -13,5 +13,9 @@ display: flex; grid-template-columns: repeat(4, 1fr); column-gap: 16px; - margin-bottom: 16px; + margin: 10px 0 16px; +} + +.pagination { + margin-top: 24px; } diff --git a/src/pages/decks-page/decks-page.tsx b/src/pages/decks-page/decks-page.tsx index 55fb6f3..d8090cb 100644 --- a/src/pages/decks-page/decks-page.tsx +++ b/src/pages/decks-page/decks-page.tsx @@ -137,7 +137,7 @@ export const DecksPage = () => { search value={search ?? ''} /> - + My decks All decks @@ -163,6 +163,7 @@ export const DecksPage = () => { sort={sort} />