Skip to content

feat: add hook useClientContext #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<h2>2024/2025: 🔋 NextJs 14.x + MUI 5.x + TypeScript Starter</h2>
<p>The scaffold for NextJs 14.x (App Router), React Hook Form, Material UI(MUI 5.x),Typescript and ESLint, and TypeScript with Absolute Import, Seo, Link component, pre-configured with Husky.</p>

<p>With simple example of NextJs API, React-hook-form with zod, fetch remote api, 404/500 error pages, MUI SSR usage, Styled component, MUI AlertBar, MUI confirmation dialog, Loading button, Client-side component</p>
<p>With simple example of NextJs API, React-hook-form with zod, fetch remote api, 404/500 error pages, MUI SSR usage, Styled component, MUI AlertBar, MUI confirmation dialog, Loading button, Client-side component & React Context update hook</p>

🚘🚘🚘 [**Click here to see an online demo**](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘

Expand Down
23 changes: 0 additions & 23 deletions src/__tests__/pages/HomePage.test.tsx

This file was deleted.

38 changes: 23 additions & 15 deletions src/components/Homepage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import PinDropIcon from '@mui/icons-material/PinDrop';
import { Box, Typography } from '@mui/material';
import Link from 'next/link';

import { ClientProvider } from '@/hooks/useClientContext';

import DisplayRandomPicture from '@/components/shared/DisplayRandomPicture';
import PageFooter from '@/components/shared/PageFooter';
import ReactHookForm from '@/components/shared/ReactHookForm';

Expand Down Expand Up @@ -49,38 +52,43 @@ export default function Homepage({
</Box>
</Typography>

<Box sx={{ m: 5 }}>
<Box sx={{ m: 5, a: { color: 'blue' } }}>
<Link
href='https://github.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter'
href='/api/test?from=github&nextjs=yes&mui=yes&tailwind=no'
target='_blank'
>
See the Github repository page
Test local NextJs API /api/test method GET with parameters
</Link>
</Box>

<Box sx={{ m: 5 }}>
<h4>
Test local NextJs API /api/test POST method (client-side
component)
</h4>
<ClientProvider>
<ReactHookForm />
<DisplayRandomPicture />
</ClientProvider>
</Box>

<Box sx={{ m: 5 }}>
<Link
href='https://vercel.com/new/clone?s=https%3A%2F%2Fgithub.com%2FAlexStack%2Fnextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter&showOptionalTeamCreation=false'
href='https://github.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter'
target='_blank'
>
Click here to deploy a demo site to your Vercel in 1 minute
See the Github repository page
</Link>
</Box>
<Box sx={{ m: 5, a: { color: 'blue' } }}>
<Box sx={{ m: 5, a: { color: 'red' } }}>
<Link
href='/api/test?from=github&nextjs=yes&mui=yes&tailwind=no'
href='https://vercel.com/new/clone?s=https%3A%2F%2Fgithub.com%2FAlexStack%2Fnextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter&showOptionalTeamCreation=false'
target='_blank'
>
Test local NextJs API /api/test method GET with parameters
Click here to deploy a demo site to your Vercel in 1 minute
</Link>
</Box>

<Box sx={{ m: 5 }}>
<h4>
Test local NextJs API /api/tes POST method (client-side component)
</h4>
<ReactHookForm />
</Box>

<Box sx={{ m: 5 }}>
<Link href='/test-page-not-exists'>
Test 404 page not found (mock file not exists)
Expand Down
96 changes: 96 additions & 0 deletions src/components/shared/DisplayRandomPicture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/* eslint-disable @next/next/no-img-element */

'use client';
import { Send } from '@mui/icons-material';
import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import React, { useEffect, useState } from 'react';

import { useAlertBar } from '@/hooks/useAlertBar';
import { useClientContext } from '@/hooks/useClientContext';

import SubmitButton from '@/components/shared/SubmitButton';

const DisplayRandomPicture = () => {
const [imageUrl, setImageUrl] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { fetchCount, updateClientCtx } = useClientContext();
const { setAlertBarProps, renderAlertBar } = useAlertBar();
const renderCountRef = React.useRef(0);

const fetchRandomPicture = async () => {
setLoading(true);
setError('');

try {
const response = await fetch('https://picsum.photos/300/150');
if (!response.ok) {
throw new Error('Error fetching the image');
}
setImageUrl(response.url);
updateClientCtx({ fetchCount: fetchCount + 1 });
setAlertBarProps({
message: 'A random picture fetched successfully',
severity: 'info',
});
} catch (error) {
setError('Error fetching the image');
setAlertBarProps({
message: 'Error fetching the image',
severity: 'error',
});
} finally {
setLoading(false);
}
};

useEffect(() => {
if (renderCountRef.current === 0) {
fetchRandomPicture();
}
renderCountRef.current += 1;
});

return (
<Stack
direction='column'
justifyContent='center'
alignItems='center'
spacing={2}
>
{error && <p>{error}</p>}
{imageUrl && (
<Avatar
alt='DisplayRandomPicture'
src={imageUrl}
variant='square'
sx={{ width: 300, height: 150, borderRadius: '10px' }}
/>
)}
<div>
{loading && <span>Loading...</span>} Component Render Count:{' '}
{renderCountRef.current}
</div>

<SubmitButton
isSubmitting={loading}
submittingText='Fetching Picture ...'
>
<Button
variant='contained'
endIcon={<Send />}
onClick={fetchRandomPicture}
disabled={loading}
color='secondary'
>
Get Another Picture
</Button>
</SubmitButton>
{renderAlertBar()}
</Stack>
);
};

export default DisplayRandomPicture;
57 changes: 42 additions & 15 deletions src/components/shared/ReactHookForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@
import styled from '@emotion/styled';
import { zodResolver } from '@hookform/resolvers/zod';
import { NextPlan, Save } from '@mui/icons-material';
import { Box, Button, FormHelperText, TextField } from '@mui/material';
import React, { useEffect, useState } from 'react';
import {
Avatar,
Box,
Button,
FormHelperText,
Stack,
TextField,
} from '@mui/material';
import { purple } from '@mui/material/colors';
import React, { useEffect } from 'react';
import { Controller, SubmitHandler, useForm } from 'react-hook-form';
import { z } from 'zod';

import { useAlertBar } from '@/hooks/useAlertBar';
import { useClientContext } from '@/hooks/useClientContext';
import useConfirmationDialog from '@/hooks/useConfirmDialog';

import { AlertBar, AlertBarProps } from '@/components/shared/AlertBar';
import SubmitButton from '@/components/shared/SubmitButton';

import { consoleLog } from '@/utils/shared/console-log';
Expand Down Expand Up @@ -42,12 +51,10 @@ const ReactHookForm: React.FC = () => {
const [apiResult, setApiResult] = React.useState<FormValues>();
const [isSubmitting, setIsSubmitting] = React.useState(false);

const [alertBarProps, setAlertBarProps] = useState<AlertBarProps>({
message: '',
severity: 'info',
});
const { setAlertBarProps, renderAlertBar } = useAlertBar();

const dialog = useConfirmationDialog();
const { openConfirmDialog, renderConfirmationDialog } =
useConfirmationDialog();

const {
handleSubmit,
Expand All @@ -58,6 +65,8 @@ const ReactHookForm: React.FC = () => {
resolver: zodResolver(zodSchema),
});

const { fetchCount, updateClientCtx } = useClientContext();

const onSubmit: SubmitHandler<FormValues> = async (data) => {
try {
setIsSubmitting(true);
Expand All @@ -77,6 +86,7 @@ const ReactHookForm: React.FC = () => {
message: 'Form submitted successfully',
severity: 'success',
});
updateClientCtx({ fetchCount: fetchCount + 1 });
} catch (error) {
consoleLog('handleSubmit ERROR', error);
setIsSubmitting(false);
Expand All @@ -95,7 +105,7 @@ const ReactHookForm: React.FC = () => {
autoHideSeconds: 4,
});
}
}, [isValid, errors]);
}, [isValid, errors, setAlertBarProps]);

return (
<StyledForm onSubmit={handleSubmit(onSubmit)}>
Expand Down Expand Up @@ -146,11 +156,31 @@ const ReactHookForm: React.FC = () => {
</SubmitButton>

<Box sx={{ m: 5 }}>
<Stack
sx={{ mb: 3 }}
direction='row'
spacing={1}
justifyContent='center'
alignItems='center'
>
<div>Total fetch count from React Context:</div>
<Avatar
sx={{
bgcolor: purple[500],
width: 22,
height: 22,
fontSize: '0.8rem',
}}
variant='circular'
>
{fetchCount}
</Avatar>
</Stack>
<Button
variant='outlined'
onClick={() => {
const randomNumber = Math.floor(Math.random() * 90) + 10;
dialog.openConfirmDialog({
openConfirmDialog({
title: 'Change form name',
content: `Are you sure to change above form name to Alex ${randomNumber} and submit?`,
onConfirm: () => {
Expand All @@ -166,12 +196,9 @@ const ReactHookForm: React.FC = () => {
</Button>
</Box>

<AlertBar
onClose={() => setAlertBarProps({ message: '' })}
{...alertBarProps}
/>
{renderAlertBar()}

{dialog.renderConfirmationDialog()}
{renderConfirmationDialog()}
</StyledForm>
);
};
Expand Down
24 changes: 24 additions & 0 deletions src/hooks/useAlertBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import React, { useState } from 'react';

import { AlertBar, AlertBarProps } from '@/components/shared/AlertBar';

export const useAlertBar = () => {
const [alertBarProps, setAlertBarProps] = useState<AlertBarProps>({
message: '',
severity: 'info',
});

const renderAlertBar = () => (
<AlertBar
onClose={() => setAlertBarProps({ message: '' })}
{...alertBarProps}
/>
);

return {
setAlertBarProps,
renderAlertBar,
};
};
56 changes: 56 additions & 0 deletions src/hooks/useClientContext.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { renderHook } from '@testing-library/react';
import React, { act } from 'react';

import { ClientProvider, useClientContext } from './useClientContext';

describe('useClientContext', () => {
it('should not be used outside ClientProvider', () => {
const { result } = renderHook(() => useClientContext());
expect(() => {
result.current.updateClientCtx({ fetchCount: 66 });
}).toThrow('Cannot be used outside ClientProvider');
});

it('should provide the correct initial context values', () => {
const ctxValue = {
topError: 'SWW Error',
bmStatus: 'Live',
fetchCount: 85,
};
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ClientProvider value={ctxValue}>{children}</ClientProvider>
);

const { result } = renderHook(() => useClientContext(), {
wrapper,
});

expect(result.current.topError).toBe(ctxValue.topError);
expect(result.current.fetchCount).toBe(ctxValue.fetchCount);
});

it('should update the context values', () => {
const ctxValue = {
topError: 'SWW Error',
fetchCount: 85,
};
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ClientProvider value={ctxValue}>{children}</ClientProvider>
);

const { result } = renderHook(() => useClientContext(), {
wrapper,
});

const newCtxValue = {
topError: '',
};

act(() => {
result.current.updateClientCtx(newCtxValue);
});

expect(result.current.topError).toBe(newCtxValue.topError);
expect(result.current.fetchCount).toBe(ctxValue.fetchCount);
});
});
Loading
Loading