diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f3d80b5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*] +charset = utf-8 +insert_final_newline = true +end_of_line = auto +indent_style = space +indent_size = 2 +max_line_length = 80 \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..ebc98ea --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +node_modules/ +build/ +vitest.setup.tsx +coverage/ \ No newline at end of file diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..8e99c02 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,53 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'next/core-web-vitals', + 'prettier', + 'plugin:prettier/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + tsconfigRootDir: __dirname, + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ['react', 'react-hooks', '@typescript-eslint'], + settings: { + react: { + version: 'detect', + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + 'react/jsx-uses-react': 'off', + 'react/jsx-uses-vars': 'off', + 'react/react-in-jsx-scope': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'prettier/prettier': [ + 'error', + { + endOfLine: 'auto', + }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: true, + varsIgnorePattern: '_', + }, + ], + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb2b034 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# WebStorm +.idea + +# environment +.env diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..d0a7784 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..e69de29 diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 0000000..7009360 --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,8 @@ +const path = require('path'); + +const buildEslintCommand = (filenames) => + `next lint --fix --file ${filenames.map((f) => path.relative(process.cwd(), f)).join(' --file ')}`; + +module.exports = { + '*.{js,jsx,ts,tsx}': [buildEslintCommand], +}; diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e19e233 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +coverage/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..bbab8d4 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "endOfLine": "auto", + "printWidth": 120, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/__test__/mock/handlers.ts b/__test__/mock/handlers.ts new file mode 100644 index 0000000..47882d7 --- /dev/null +++ b/__test__/mock/handlers.ts @@ -0,0 +1,7 @@ +import { http, HttpResponse } from 'msw'; + +export const handlers = [ + http.get('', () => { + return HttpResponse.json(''); + }), +]; diff --git a/__test__/mock/mockData.ts b/__test__/mock/mockData.ts new file mode 100644 index 0000000..ca2f21e --- /dev/null +++ b/__test__/mock/mockData.ts @@ -0,0 +1,4 @@ +export const mockRequests = [ + { id: '1', method: 'GET', url: 'https://api.example.com', date: '2023-01-01' }, + { id: '2', method: 'POST', url: 'https://api.test.com', date: '2023-01-02' }, +]; diff --git a/__test__/mock/server.ts b/__test__/mock/server.ts new file mode 100644 index 0000000..e52fee0 --- /dev/null +++ b/__test__/mock/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); diff --git a/__test__/test-utils.tsx b/__test__/test-utils.tsx new file mode 100644 index 0000000..c580004 --- /dev/null +++ b/__test__/test-utils.tsx @@ -0,0 +1,79 @@ +import { ReactElement, PropsWithChildren } from 'react'; +import { render, RenderOptions } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore, combineReducers } from '@reduxjs/toolkit'; +import { ThemeProvider } from '@mui/material/styles'; +import theme from '@/theme'; +import userEvent from '@testing-library/user-event'; +import { mainSlice } from '@/redux/features/mainSlice'; +import { graphiqlSlice } from '@/redux/features/graphiqlSlice'; +import { restfulSlice } from '@/redux/features/restfulSlice'; +import { AbstractIntlMessages, NextIntlClientProvider } from 'next-intl'; +import { AppRouterContext } from 'next/dist/shared/lib/app-router-context.shared-runtime'; +import enLocale from '../messages/en.json'; +import ruLocale from '../messages/ru.json'; + +const mockUseRouter = { + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + pathname: '/', + locale: 'en', + locales: ['en', 'ru'], + route: '/', +}; + +const rootReducer = combineReducers({ + main: mainSlice.reducer, + restful: restfulSlice.reducer, + graphiql: graphiqlSlice.reducer, +}); + +export function setupStore(preloadedState?: Partial) { + return configureStore({ + reducer: rootReducer, + preloadedState, + }); +} + +export type RootState = ReturnType; +export type AppStore = ReturnType; +export type AppDispatch = AppStore['dispatch']; + +interface ExtendedRenderOptions extends Omit { + preloadedState?: Partial; + store?: AppStore; + locale?: string; + messages?: Record; +} + +export function customRender(ui: ReactElement, extendedRenderOptions: ExtendedRenderOptions = {}) { + const { + preloadedState = {}, + store = setupStore(preloadedState), + locale = 'en', + messages = locale === 'en' ? enLocale : ruLocale, + ...renderOptions + } = extendedRenderOptions; + + const Wrapper = ({ children }: PropsWithChildren) => ( + + + + + {children} + + + + + ); + + return { + store, + user: userEvent.setup(), + ...render(ui, { wrapper: Wrapper, ...renderOptions }), + }; +} diff --git a/app/[locale]/GRAPHQL/[[...endpoint]]/page.tsx b/app/[locale]/GRAPHQL/[[...endpoint]]/page.tsx new file mode 100644 index 0000000..b4db12a --- /dev/null +++ b/app/[locale]/GRAPHQL/[[...endpoint]]/page.tsx @@ -0,0 +1,36 @@ +import ResponseSection from '@/components/Response'; +import GraphiQLClient from '@/components/GraphiQLClient'; +import { ResizableGroup, ResizableHandle, ResizablePanel } from '@/components/Resizable'; +import { base64ToText } from '@/utils/coderBase64'; + +interface Props { + params?: { + endpoint: string[]; + }; + searchParams?: Record; +} + +function GraphQL({ params, searchParams }: Props) { + const responseData = params?.endpoint; + const url = Array.isArray(responseData) && responseData[0] ? base64ToText(responseData[0]?.replace('%3D', '=')) : ''; + const query = + Array.isArray(responseData) && responseData[1] + ? base64ToText(responseData[1]?.replace('%3D', '='))?.replace(/\\n/g, '\n') + : ''; + + return ( +
+ + + + + + + + + +
+ ); +} + +export default GraphQL; diff --git a/app/[locale]/GRAPHQL/[[...endpoint]]/qraphql.test.tsx b/app/[locale]/GRAPHQL/[[...endpoint]]/qraphql.test.tsx new file mode 100644 index 0000000..2839a71 --- /dev/null +++ b/app/[locale]/GRAPHQL/[[...endpoint]]/qraphql.test.tsx @@ -0,0 +1,40 @@ +import { screen, render } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import GraphQL from '../../../../app/[locale]/GRAPHQL/[[...endpoint]]/page'; +import { base64ToText } from '../../../../utils/coderBase64'; +import '@testing-library/jest-dom'; + +vi.mock('@/components/GraphiQLClient', () => ({ + __esModule: true, + default: () =>
GraphiQLClient Mock
, +})); + +vi.mock('@/components/Response', () => ({ + __esModule: true, + default: () =>
ResponseSection Mock
, +})); + +vi.mock('@/utils/coderBase64', () => ({ + base64ToText: vi.fn((input) => input), +})); + +describe('GraphQL Component', () => { + it('renders correctly with params and searchParams', () => { + const endpoint = ['aHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20=', 'c2VhcmNoIHRlc3Q=']; + + render(); + + expect(screen.getByText('GraphiQLClient Mock')).toBeInTheDocument(); + expect(screen.getByText('ResponseSection Mock')).toBeInTheDocument(); + + expect(base64ToText).toHaveBeenCalledWith('aHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20='); + expect(base64ToText).toHaveBeenCalledWith('c2VhcmNoIHRlc3Q='); + }); + + it('handles missing params and searchParams gracefully', () => { + render(); + + expect(screen.getByText('GraphiQLClient Mock')).toBeInTheDocument(); + expect(screen.getByText('ResponseSection Mock')).toBeInTheDocument(); + }); +}); diff --git a/app/[locale]/StoreProvider.tsx b/app/[locale]/StoreProvider.tsx new file mode 100644 index 0000000..770c790 --- /dev/null +++ b/app/[locale]/StoreProvider.tsx @@ -0,0 +1,28 @@ +'use client'; +import { makeStore } from '@/redux/store'; +import type { AppStore } from '@/redux/store'; +import { setupListeners } from '@reduxjs/toolkit/query'; +import type { ReactNode } from 'react'; +import { useEffect, useRef } from 'react'; +import { Provider } from 'react-redux'; + +interface Props { + readonly children: ReactNode; +} + +export const StoreProvider = ({ children }: Props) => { + const storeRef = useRef(null); + + if (!storeRef.current) { + storeRef.current = makeStore(); + } + + useEffect(() => { + if (storeRef.current != null) { + const unsubscribe = setupListeners(storeRef.current.dispatch); + return unsubscribe; + } + }, []); + + return {children}; +}; diff --git a/app/[locale]/[...rest]/page.tsx b/app/[locale]/[...rest]/page.tsx new file mode 100644 index 0000000..705a83a --- /dev/null +++ b/app/[locale]/[...rest]/page.tsx @@ -0,0 +1,19 @@ +import RestForm from '@/components/RestClient'; +import ResponseSection from '@/components/Response'; +import { ResizableGroup, ResizableHandle, ResizablePanel } from '@/components/Resizable'; + +export default function RESTful() { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/app/[locale]/[...rest]/rest.test.tsx b/app/[locale]/[...rest]/rest.test.tsx new file mode 100644 index 0000000..0a9123a --- /dev/null +++ b/app/[locale]/[...rest]/rest.test.tsx @@ -0,0 +1,24 @@ +import { screen, render } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; + +import '@testing-library/jest-dom'; +import RESTful from '@/app/[locale]/[...rest]/page'; + +vi.mock('@/components/RestClient', () => ({ + __esModule: true, + default: () =>
RestForm Mock
, +})); + +vi.mock('@/components/Response', () => ({ + __esModule: true, + default: () =>
ResponseSection Mock
, +})); + +describe('RESTful Component', () => { + it('renders correctly', () => { + render(); + + expect(screen.getByText('RestForm Mock')).toBeInTheDocument(); + expect(screen.getByText('ResponseSection Mock')).toBeInTheDocument(); + }); +}); diff --git a/app/[locale]/globals.css b/app/[locale]/globals.css new file mode 100644 index 0000000..8d8bf82 --- /dev/null +++ b/app/[locale]/globals.css @@ -0,0 +1,26 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer utilities { + .padding-x { + @apply px-3 sm:px-4 md:px-8 lg:px-12; + } +} + +* { + scrollbar-width: thin; +} + +::-webkit-scrollbar { + width: 15px; +} + +.custom-button { + padding: 6px 10px; + border-radius: 4px; +} + +.custom-button:hover { + background-color: rgba(0, 81, 255, 0.63); +} diff --git a/app/[locale]/history/history.test.tsx b/app/[locale]/history/history.test.tsx new file mode 100644 index 0000000..29cbac7 --- /dev/null +++ b/app/[locale]/history/history.test.tsx @@ -0,0 +1,35 @@ +import { screen } from '@testing-library/react'; +import { customRender } from '@/__test__/test-utils'; +import History from './page'; +import * as useLocalStorageHook from '@/hooks/useLocalStorage'; +import { mockRequests } from '@/__test__/mock/mockData'; + +vi.mock('@/hooks/useLocalStorage'); + +describe('History Page', () => { + it('renders EmptyHistory when no requests are stored', () => { + vi.spyOn(useLocalStorageHook, 'useLocalStorage').mockReturnValue({ + storedValue: [], + setLocalStorageValue: vi.fn(), + }); + + customRender(); + expect(screen.getByText("You haven't executed any requests")).toBeInTheDocument(); + expect(screen.getByText("It's empty here. Try:")).toBeInTheDocument(); + expect(screen.getByText('REST Client')).toBeInTheDocument(); + expect(screen.getByText('GraphiQL Client')).toBeInTheDocument(); + }); + + it('renders DataTable when requests are stored', () => { + vi.spyOn(useLocalStorageHook, 'useLocalStorage').mockReturnValue({ + storedValue: mockRequests, + setLocalStorageValue: vi.fn(), + }); + + customRender(); + expect(screen.queryByText("You haven't executed any requests")).not.toBeInTheDocument(); + expect(screen.queryByText("It's empty here. Try:")).not.toBeInTheDocument(); + expect(screen.queryByText('REST Client')).not.toBeInTheDocument(); + expect(screen.queryByText('GraphiQL Client')).not.toBeInTheDocument(); + }); +}); diff --git a/app/[locale]/history/page.tsx b/app/[locale]/history/page.tsx new file mode 100644 index 0000000..9a9aed6 --- /dev/null +++ b/app/[locale]/history/page.tsx @@ -0,0 +1,9 @@ +import HistoryContainer from '@/components/History'; + +export default function Hisory() { + return ( +
+ +
+ ); +} diff --git a/app/[locale]/i18n.test.tsx b/app/[locale]/i18n.test.tsx new file mode 100644 index 0000000..963cf74 --- /dev/null +++ b/app/[locale]/i18n.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, expect, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import middleware from '../../middleware'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }), +})); + +describe('i18n routing', () => { + it('redirects to default locale', async () => { + const req = new NextRequest(new URL('http://localhost/')); + const res = middleware(req); + expect(res?.status).toBe(307); + expect(res?.headers.get('Location')).toBe('http://localhost/en'); + }); + + it('remembers the last locale', async () => { + const req = new NextRequest(new URL('http://localhost/')); + req.cookies.set('NEXT_LOCALE', 'ru'); + const res = middleware(req); + expect(res?.status).toBe(307); + expect(res?.headers.get('Location')).toBe('http://localhost/ru'); + }); +}); diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..17b922b --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,57 @@ +import type { Metadata } from 'next'; +import { StoreProvider } from './StoreProvider'; +import { AppRouterCacheProvider } from '@mui/material-nextjs/v13-appRouter'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages } from 'next-intl/server'; +import { Inter } from 'next/font/google'; +import { Toaster } from 'sonner'; +import PrivateRoute from '@/components/PrivateRoute'; +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; +import CssBaseline from '@mui/material/CssBaseline'; +import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'; +import theme from '@/theme'; +import './globals.css'; + +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}); + +export const metadata: Metadata = { + title: 'REST GraphQL Client', + description: 'Generated by create next app', +}; + +export default async function RootLayout({ + children, + params: { locale }, +}: Readonly<{ + children: React.ReactNode; + params: { locale: string }; +}>) { + const messages = await getMessages(); + + return ( + + + + + + + + +
+ {children} +