Skip to content
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

feat: add searchable select field and improve unauthorized error handling to prevent app crash #232

Merged
merged 4 commits into from
Oct 28, 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
13 changes: 9 additions & 4 deletions src/app/register/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,15 @@ function Page() {
const router = useRouter();
const storedUserId = getUserDetails().uid;
const isNitR = userDetails.instituteId === nitrID;
const { data: userDataDB, error: userErr } = useSuspenseQuery(
GET_USER_BY_UID,
storedUserId ? { variables: { uid: storedUserId } } : skipToken,
);

const { data: userDataDB, error: userErr } = useSuspenseQuery(GET_USER_BY_UID, {
variables: storedUserId ? { uid: storedUserId } : undefined,
skip: !storedUserId,
errorPolicy: 'all',
onError: (error) => {
console.error('User query error:', error);
},
});

async function handleChange(event) {
const { name, value, type, checked } = event.target;
Expand Down
2 changes: 1 addition & 1 deletion src/app/register/register.styles.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const RegisterInnerContainer = styled.div`
`;

export const RegisterForm = styled.div`
${tw`w-full flex flex-col gap-10 items-center justify-center`}
${tw`w-full flex flex-col gap-10 items-start md:items-center justify-center`}
`;

export const RegsiterButton = styled(PrimaryButton)`
Expand Down
4 changes: 2 additions & 2 deletions src/components/ProfileMenu/ProfileMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ function ProfileMenu({ handleProfileToggle, handleNavClose }) {
const userProfileCookie = Cookies.get('userData');
const userDBCookie = Cookies.get('userDataDB');

if (!userProfileCookie || !userDBCookie) {
if (!userProfileCookie) {
throw new Error('User data not found in cookies');
}

const userProfile = JSON.parse(userProfileCookie);
const userInDB = JSON.parse(userDBCookie);
const userInDB = JSON.parse(userDBCookie || '{}');

setUserDetails({
name: userProfile.name,
Expand Down
106 changes: 106 additions & 0 deletions src/components/Register/SelectField/SearchField/SearchField.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { Search, X } from 'lucide-react';
import {
SearchContainer,
SearchInnerContainer,
SearchFieldInput,
ClearButton,
IconContainer,
ErrorMessage,
} from './SearchField.styles';

function SearchField({
value = '',
onChange,
placeholder = 'Search options...',
className = '',
name = '',
autoFocus = false,
debounceTime = 300,

disabled = false,
error = '',
onFocus,
onBlur,
onClear,
showClearButton = true,

iconSize = 20,
customStyles,
}) {
const [internalValue, setInternalValue] = useState(value);
const searchInputRef = useRef(null);
const debounceTimerRef = useRef(null);

useEffect(() => {
setInternalValue(value);
}, [value]);

useEffect(() => {
if (autoFocus && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [autoFocus]);

const handleInputChange = (e) => {
const newValue = e.target.value;
setInternalValue(newValue);

if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}

debounceTimerRef.current = setTimeout(() => {
onChange?.(newValue);
}, debounceTime);
};

const handleClear = () => {
setInternalValue('');
onChange?.('');
onClear?.();
searchInputRef.current?.focus();
};

useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);

return (
<SearchContainer css={[customStyles]} className={className}>
<SearchInnerContainer>
<SearchFieldInput
ref={searchInputRef}
type='text'
value={internalValue}
onChange={handleInputChange}
placeholder={placeholder}
disabled={disabled}
name={name}
onFocus={onFocus}
onBlur={onBlur}
aria-invalid={!!error}
/>

{showClearButton && internalValue && !disabled && (
<ClearButton type='button' onClick={handleClear} aria-label='Clear search'>
<X size={16} />
</ClearButton>
)}

<IconContainer>
<Search size={iconSize} />
</IconContainer>
</SearchInnerContainer>

{error && <ErrorMessage>{error}</ErrorMessage>}
</SearchContainer>
);
}

export default SearchField;
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import styled from 'styled-components';
import tw from 'twin.macro';
export const SearchContainer = styled.div`
${tw`w-full relative`}
`;

export const SearchInnerContainer = styled.div`
${tw`relative w-full`}
`;

export const SearchFieldInput = styled.input`
${tw`
w-full
py-2
px-3
pr-10
text-sm
rounded-md
transition-all
duration-150
ease-in-out
text-white
font-prompt
bg-black
border-2
border-gray-700
focus:outline-none
`}
`;

export const ClearButton = styled.button`
${tw`
absolute
right-8
top-1/2
-translate-y-1/2
p-1
rounded-full
text-white
hover:(bg-gray-100)
transition-colors
duration-150
focus:(outline-none ring-2 ring-blue-500)
`}
`;

export const IconContainer = styled.div`
${tw`
absolute
right-2
top-1/2
-translate-y-1/2
text-gray-400
pointer-events-none
`}
`;

export const ErrorMessage = styled.p`
${tw`mt-1 text-sm text-red-500`}
`;
58 changes: 43 additions & 15 deletions src/components/Register/SelectField/SelectField.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
'use client';
import { useEffect, useRef, useState } from 'react';

import { toast } from 'react-hot-toast';

import { Label } from '../FileInput/FileInput.styles';
import InputField from '../InputField/InputField';
import SearchField from './SearchField/SearchField';
import { ErrorMessage } from '../InputField/InputField.styles';
import {
DropdownIcon,
Expand All @@ -30,6 +29,7 @@ function SelectField({
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState(value || '');
const [otherInstituteName, setOtherInstituteName] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const ref = useRef(null);
const isOneLine = className?.includes('oneliner');

Expand All @@ -41,7 +41,10 @@ function SelectField({
setSelectedOption(value || '');
}, [value]);

const handleToggle = () => setIsOpen(!isOpen);
const handleToggle = () => {
setIsOpen(!isOpen);
setSearchQuery('');
};

const handleSelectChange = (option, id) => {
if (id === 'notAllowed') {
Expand All @@ -52,6 +55,7 @@ function SelectField({
}
setSelectedOption(option);
setIsOpen(false);
setSearchQuery('');
setErrors((prevState) => ({
...prevState,
[name]: '',
Expand Down Expand Up @@ -93,6 +97,7 @@ function SelectField({
const handleClickOutside = (event) => {
if (ref.current && !ref.current.contains(event.target)) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
Expand All @@ -101,7 +106,7 @@ function SelectField({
};
}, [ref]);

function returnSortedOptions(array) {
function returnSortedAndFilteredOptions(array) {
const sortedOptions = array.sort((a, b) => a.label.localeCompare(b.label));

const othersIndex = sortedOptions.findIndex((option) => option.value === 'others');
Expand All @@ -110,16 +115,24 @@ function SelectField({
sortedOptions.push(othersOption);
}

return sortedOptions;
if (!searchQuery) return sortedOptions;

return sortedOptions.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase()),
);
}

const sortedOptions = returnSortedOptions(options);
const filteredOptions = returnSortedAndFilteredOptions(options);

return (
<div ref={ref}>
<SelectFieldParentContainer>
<LabelAndInputContainer
className={isOneLine ? 'flex-col xxs:flex-row items-center' : 'flex-col items-start'}
className={
isOneLine
? 'flex-col xxs:flex-row xxs:items-center items-start'
: 'flex-col items-start'
}
>
{label && <Label>{label}</Label>}
<SelectFieldContainer $hasError={!!error} onClick={handleToggle}>
Expand All @@ -131,14 +144,29 @@ function SelectField({

{isOpen && (
<DropdownList>
{sortedOptions.map((option, index) => (
<DropdownItem
key={index}
onClick={() => handleSelectChange(option.value, option.id)}
>
{option.label}
</DropdownItem>
))}
<div className='sticky top-0 p-2'>
<SearchField
value={searchQuery}
onChange={setSearchQuery}
placeholder='Search options...'
autoFocus
showClearButton
onClear={() => setSearchQuery('')}
/>
</div>

{filteredOptions.length > 0 ? (
filteredOptions.map((option, index) => (
<DropdownItem
key={index}
onClick={() => handleSelectChange(option.value, option.id)}
>
{option.label}
</DropdownItem>
))
) : (
<div className='px-4 py-3 text-sm text-gray-500'>No options found</div>
)}
</DropdownList>
)}
</LabelAndInputContainer>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Register/SelectField/SelectField.styles.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const DropdownIcon = styled(ChevronDown)`
`;

const DropdownList = styled.ul`
${tw`absolute w-auto md:w-[35rem] mt-1 bg-black/30 backdrop-blur-xl rounded-md z-10 max-h-60 overflow-auto`}
${tw`absolute w-auto xxxxs:w-[18rem] xxs:w-[19rem] 2xs:w-[22rem] ssm:w-[25rem] sm:w-[30rem] md:w-[35rem] mt-1 bg-black/30 backdrop-blur-xl rounded-md z-10 max-h-60 overflow-auto`}
`;

const DropdownItem = styled.li`
Expand Down
1 change: 1 addition & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ module.exports = {
foreground: 'var(--foreground)',
},
screens: {
xxxxs: '310px',
xxxs: '334px',
xxs: '380px',
'2xs': '425px',
Expand Down
Loading