feat: search dropdown (#150)

- new search dropdown component that adds search / sort functionality to dropdowns
- replaces browser select component in select condition type dropdowns
- add font size prop to sort dropdown / search bar to scale size for dropdowns
- fix bad background color on tests page
- add scrollable class description / condition area for format / regex cards
This commit is contained in:
santiagosayshey
2025-02-26 11:17:06 +10:30
committed by GitHub
parent ca1c2bf777
commit 49e36d67a6
12 changed files with 370 additions and 77 deletions

25
CLAUDE.md Normal file
View File

@@ -0,0 +1,25 @@
# Profilarr Development Guide
## Commands
- **Frontend**: `cd frontend && npm run dev` - Start React dev server
- **Backend**: `cd backend && gunicorn -b 0.0.0.0:5000 app.main:app` - Run Flask server
- **Docker**: `docker compose up` - Start both frontend/backend in dev mode
- **Lint**: `cd frontend && npx eslint 'src/**/*.{js,jsx}'` - Check frontend code style
- **Build**: `cd frontend && npm run build` - Build for production
## Code Style
### Frontend (React)
- **Imports**: React first, third-party libs next, components, then utils
- **Components**: Functional components with hooks, PascalCase naming
- **Props**: PropTypes for validation, destructure props in component signature
- **State**: Group related state, useCallback for memoized handlers
- **JSX**: 4-space indentation, attributes on new lines for readability
- **Error Handling**: try/catch for async operations, toast notifications
### Backend (Python)
- **Imports**: Standard lib first, third-party next, local modules last
- **Naming**: snake_case for functions/vars/files, PascalCase for classes
- **Functions**: Single responsibility, descriptive docstrings
- **Error Handling**: Specific exception catches, return (success, message) tuples
- **Indentation**: 4 spaces consistently
- **Modularity**: Related functionality grouped in directories

View File

@@ -168,7 +168,7 @@ function FormatCard({
: 'translate-x-0' : 'translate-x-0'
}`}> }`}>
{/* Conditions */} {/* Conditions */}
<div className='w-full flex-shrink-0 overflow-y-auto'> <div className='w-full flex-shrink-0 overflow-y-auto scrollable'>
<div className='flex flex-wrap gap-1.5 content-start'> <div className='flex flex-wrap gap-1.5 content-start'>
{content.conditions?.map((condition, index) => ( {content.conditions?.map((condition, index) => (
<span <span
@@ -189,7 +189,7 @@ function FormatCard({
: 'translate-x-full' : 'translate-x-full'
}`}> }`}>
{/* Description */} {/* Description */}
<div className='w-full h-full overflow-y-auto'> <div className='w-full h-full overflow-y-auto scrollable'>
{content.description ? ( {content.description ? (
<div className='text-gray-300 text-xs prose prose-invert prose-gray max-w-none'> <div className='text-gray-300 text-xs prose prose-invert prose-gray max-w-none'>
<ReactMarkdown> <ReactMarkdown>

View File

@@ -120,7 +120,7 @@ const FormatTestingTab = ({
))} ))}
</div> </div>
) : ( ) : (
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'> <div className='text-center py-12 rounded-lg'>
<p className='text-gray-500 dark:text-gray-400'> <p className='text-gray-500 dark:text-gray-400'>
No tests added yet No tests added yet
</p> </p>

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {CONDITION_TYPES, createCondition} from './conditionTypes'; import {CONDITION_TYPES, createCondition} from './conditionTypes';
import {ArrowUp, ArrowDown, X, ChevronsUp, ChevronsDown} from 'lucide-react'; import {ArrowUp, ArrowDown, X, ChevronsUp, ChevronsDown} from 'lucide-react';
import BrowserSelect from '@ui/BrowserSelect'; import SearchDropdown from '@ui/SearchDropdown';
const ConditionCard = ({ const ConditionCard = ({
condition, condition,
@@ -21,7 +21,8 @@ const ConditionCard = ({
const typeOptions = Object.values(CONDITION_TYPES).map(type => ({ const typeOptions = Object.values(CONDITION_TYPES).map(type => ({
value: type.id, value: type.id,
label: type.name label: type.name,
description: type.description || ''
})); }));
const handleTypeChange = e => { const handleTypeChange = e => {
@@ -57,14 +58,13 @@ const ConditionCard = ({
<div className='flex items-center gap-4'> <div className='flex items-center gap-4'>
{/* Type Selection */} {/* Type Selection */}
<BrowserSelect <SearchDropdown
value={condition.type || ''} value={condition.type || ''}
onChange={handleTypeChange} onChange={handleTypeChange}
options={typeOptions} options={typeOptions}
placeholder='Select type...' placeholder='Select type...'
className='min-w-[140px] px-3 py-2 text-sm rounded-md className='min-w-[200px] condition-type-dropdown'
bg-gray-700 border border-gray-700 width='w-auto'
text-gray-200'
/> />
{/* Render the specific condition component */} {/* Render the specific condition component */}

View File

@@ -1,25 +1,31 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import BrowserSelect from '@ui/BrowserSelect'; import SearchDropdown from '@ui/SearchDropdown';
const EditionCondition = ({condition, onChange, patterns}) => { const EditionCondition = ({condition, onChange, patterns}) => {
// Convert patterns to options format // Format patterns for the dropdown with descriptions if available
const patternOptions = patterns.map(pattern => ({ const patternOptions = patterns.map(pattern => ({
value: pattern.name, value: pattern.name,
label: pattern.name label: pattern.name,
description: pattern.description || 'No description available',
priority: pattern.priority
})); }));
const handlePatternChange = e => {
onChange({...condition, pattern: e.target.value});
};
return ( return (
<div className='flex-1'> <div className="flex-1">
<BrowserSelect <SearchDropdown
value={condition.pattern || ''} value={condition.pattern || ''}
onChange={e => onChange={handlePatternChange}
onChange({...condition, pattern: e.target.value})
}
options={patternOptions} options={patternOptions}
placeholder='Select edition pattern...' placeholder='Select edition pattern...'
className='w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 searchableFields={['label', 'description']}
rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100' className='min-w-[200px]'
width='w-auto'
dropdownWidth='100%'
/> />
</div> </div>
); );

View File

@@ -1,23 +1,33 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import SearchDropdown from '@ui/SearchDropdown';
const ReleaseGroupCondition = ({condition, onChange, patterns}) => { const ReleaseGroupCondition = ({condition, onChange, patterns}) => {
const sortedPatterns = [...patterns].sort((a, b) => // Format patterns for the dropdown with descriptions if available
a.name.localeCompare(b.name) const patternOptions = patterns.map(pattern => ({
); value: pattern.name,
label: pattern.name,
description: pattern.description || 'No description available',
priority: pattern.priority
}));
const handlePatternChange = e => {
onChange({...condition, pattern: e.target.value});
};
return ( return (
<select <div className="flex-1">
className='flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700' <SearchDropdown
value={condition.pattern || ''} value={condition.pattern || ''}
onChange={e => onChange({...condition, pattern: e.target.value})}> onChange={handlePatternChange}
<option value=''>Select release group pattern...</option> options={patternOptions}
{sortedPatterns.map(pattern => ( placeholder='Select release group pattern...'
<option key={pattern.name} value={pattern.name}> searchableFields={['label', 'description']}
{pattern.name} className='min-w-[200px]'
</option> width='w-auto'
))} dropdownWidth='100%'
</select> />
</div>
); );
}; };

View File

@@ -1,23 +1,33 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import SearchDropdown from '@ui/SearchDropdown';
const ReleaseTitleCondition = ({condition, onChange, patterns}) => { const ReleaseTitleCondition = ({condition, onChange, patterns}) => {
const sortedPatterns = [...patterns].sort((a, b) => // Format patterns for the dropdown with enhanced descriptions
a.name.localeCompare(b.name) const patternOptions = patterns.map(pattern => ({
); value: pattern.name,
label: pattern.name,
description: pattern.description || 'No description available',
priority: pattern.priority
}));
const handlePatternChange = e => {
onChange({...condition, pattern: e.target.value});
};
return ( return (
<select <div className="flex-1">
className='flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700' <SearchDropdown
value={condition.pattern || ''} value={condition.pattern || ''}
onChange={e => onChange({...condition, pattern: e.target.value})}> onChange={handlePatternChange}
<option value=''>Select release title pattern...</option> options={patternOptions}
{sortedPatterns.map(pattern => ( placeholder='Select release title pattern...'
<option key={pattern.name} value={pattern.name}> searchableFields={['label', 'description']}
{pattern.name} className='min-w-[200px]'
</option> width='w-auto'
))} dropdownWidth='100%'
</select> />
</div>
); );
}; };

View File

@@ -142,7 +142,7 @@ const RegexCard = ({
[&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4 [&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4
[&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4 [&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4
[&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5 [&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5
[&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50'> [&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50 scrollable'>
<ReactMarkdown>{pattern.description}</ReactMarkdown> <ReactMarkdown>{pattern.description}</ReactMarkdown>
</div> </div>
)} )}

View File

@@ -148,7 +148,7 @@ const RegexTestingTab = ({
))} ))}
</div> </div>
) : ( ) : (
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'> <div className='text-center py-12 rounded-lg'>
<p className='text-gray-500 dark:text-gray-400'> <p className='text-gray-500 dark:text-gray-400'>
No tests added yet No tests added yet
</p> </p>

View File

@@ -12,7 +12,11 @@ const SearchBar = ({
onInputChange, onInputChange,
onAddTerm, onAddTerm,
onRemoveTerm, onRemoveTerm,
onClearTerms onClearTerms,
textSize = 'text-sm', // Default text size
badgeTextSize = 'text-sm', // Default badge text size
iconSize = 'h-4 w-4', // Default icon size
minHeight = 'min-h-10' // Default min height
}) => { }) => {
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
@@ -47,7 +51,7 @@ const SearchBar = ({
<div className={`relative flex-1 min-w-0 group ${className}`}> <div className={`relative flex-1 min-w-0 group ${className}`}>
<Search <Search
className={` className={`
absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 absolute left-3 top-1/2 -translate-y-1/2 ${iconSize}
transition-colors duration-200 transition-colors duration-200
${ ${
isFocused isFocused
@@ -58,9 +62,14 @@ const SearchBar = ({
/> />
<div <div
className={` className={`
w-full min-h-10 pl-9 pr-8 rounded-md w-full ${minHeight} pl-9 pr-8 rounded-md
transition-all duration-200 ease-in-out transition-all duration-200 ease-in-out
border shadow-sm flex items-center flex-wrap gap-2 p-2 border shadow-sm flex items-center gap-2 p-2
${
minHeight && minHeight.startsWith('h-')
? 'overflow-x-auto overflow-y-hidden whitespace-nowrap'
: ''
}
${ ${
isFocused isFocused
? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5' ? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5'
@@ -71,17 +80,24 @@ const SearchBar = ({
{searchTerms.map((term, index) => ( {searchTerms.map((term, index) => (
<div <div
key={index} key={index}
className='flex items-center gap-1.5 px-2 py-1 className={`
bg-blue-500/10 dark:bg-blue-500/20 flex items-center gap-1.5 px-2
border border-blue-500/20 dark:border-blue-400/20 ${
text-blue-600 dark:text-blue-400 minHeight && minHeight.startsWith('h-')
rounded-md shadow-sm ? 'py-0.5'
hover:bg-blue-500/15 dark:hover:bg-blue-500/25 : 'py-1'
hover:border-blue-500/30 dark:hover:border-blue-400/30 }
group/badge bg-blue-500/10 dark:bg-blue-500/20
transition-all duration-200 border border-blue-500/20 dark:border-blue-400/20
'> text-blue-600 dark:text-blue-400
<span className='text-sm font-medium leading-none'> rounded-md shadow-sm
hover:bg-blue-500/15 dark:hover:bg-blue-500/25
hover:border-blue-500/30 dark:hover:border-blue-400/30
group/badge flex-shrink-0
transition-all duration-200
`}>
<span
className={`${badgeTextSize} font-medium leading-none`}>
{term} {term}
</span> </span>
<button <button
@@ -106,10 +122,10 @@ const SearchBar = ({
? 'Add another filter...' ? 'Add another filter...'
: placeholder : placeholder
} }
className='flex-1 min-w-[200px] bg-transparent className={`flex-1 min-w-[200px] bg-transparent
text-gray-900 dark:text-gray-100 ${textSize} text-gray-900 dark:text-gray-100
placeholder:text-gray-500 dark:placeholder:text-gray-400 placeholder:text-gray-500 dark:placeholder:text-gray-400
focus:outline-none' focus:outline-none`}
/> />
</div> </div>
@@ -122,7 +138,7 @@ const SearchBar = ({
hover:bg-gray-100 dark:hover:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-700
transition-all duration-200 transition-all duration-200
group/clear'> group/clear'>
<X className='h-4 w-4' /> <X className={iconSize} />
</button> </button>
)} )}
</div> </div>

View File

@@ -0,0 +1,223 @@
import React, {useEffect, useRef, useState, useCallback} from 'react';
import PropTypes from 'prop-types';
import {ChevronDown, Check, Search} from 'lucide-react';
import SearchBar from './DataBar/SearchBar';
import SortDropdown from './SortDropdown';
import useSearch from '@hooks/useSearch';
import {useSorting} from '@hooks/useSorting';
const SearchDropdown = ({
value,
onChange,
options,
placeholder,
className,
width = 'w-full',
dropdownWidth,
labelKey = 'label',
valueKey = 'value',
searchableFields = ['label', 'description']
}) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedOption, setSelectedOption] = useState(
options.find(opt => opt[valueKey] === value) || null
);
const dropdownRef = useRef(null);
const menuRef = useRef(null);
const {
searchTerms,
currentInput,
setCurrentInput,
addSearchTerm,
removeSearchTerm,
clearSearchTerms,
items: filteredOptions
} = useSearch(options, {
searchableFields,
initialSortBy: 'label'
});
const {sortConfig, updateSort, sortData} = useSorting({
field: 'label',
direction: 'asc'
});
// Sort options configuration for the dropdown (name only)
const sortOptions = [{value: 'label', label: 'Name (A-Z)'}];
// Update selected option when value changes externally
useEffect(() => {
setSelectedOption(options.find(opt => opt[valueKey] === value) || null);
}, [value, options, valueKey]);
// Handle dropdown visibility
useEffect(() => {
// Handle clicks outside dropdown (close the dropdown)
const handleClickOutside = event => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Apply final sorting to the filtered results
const sortedOptions = useCallback(() => {
return sortData(filteredOptions);
}, [sortData, filteredOptions]);
// Handle selection
const handleSelect = useCallback(
option => {
setSelectedOption(option);
onChange({target: {value: option[valueKey]}});
setIsOpen(false);
},
[onChange, valueKey]
);
return (
<div className={`relative ${width}`} ref={dropdownRef}>
{/* Selected Value Button */}
<button
type='button'
onClick={() => setIsOpen(!isOpen)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm
border rounded-md
bg-gray-700 border-gray-600 text-gray-100 hover:border-gray-500
focus:outline-none focus:ring-2 focus:ring-blue-500
transition-colors ${className}`}>
<span className='truncate'>
{selectedOption
? selectedOption[labelKey]
: placeholder || 'Select option...'}
</span>
<ChevronDown
className={`w-4 h-4 ml-2 transition-transform ${
isOpen ? 'rotate-180' : ''
}`}
/>
</button>
{/* Dropdown Menu */}
{isOpen && (
<div
ref={menuRef}
className='absolute z-50 mt-1
bg-gray-800 border border-gray-700 rounded-md shadow-lg
flex flex-col overflow-hidden'
style={{
width: dropdownWidth || '650px',
maxHeight: '700px',
left: '0'
}}>
<div className='p-3 bg-gray-800 shadow-sm relative'>
<div className='absolute left-0 right-0 bottom-0 h-px bg-gray-700/50'></div>
<div className='flex items-center gap-2'>
<div className='flex-grow'>
<SearchBar
placeholder='Search options...'
searchTerms={searchTerms}
currentInput={currentInput}
onInputChange={setCurrentInput}
onAddTerm={addSearchTerm}
onRemoveTerm={removeSearchTerm}
onClearTerms={clearSearchTerms}
requireEnter={true}
textSize='text-xs'
badgeTextSize='text-xs'
iconSize='h-3.5 w-3.5'
minHeight='h-8'
/>
</div>
<SortDropdown
sortOptions={sortOptions}
currentSort={sortConfig}
onSortChange={updateSort}
className='flex-shrink-0'
textSize='text-xs'
menuTextSize='text-xs'
iconSize={14}
/>
</div>
</div>
{/* Options List */}
<div className='flex-1 p-2 pt-3 overflow-auto'>
{sortedOptions().length > 0 ? (
<div className='flex flex-col'>
{sortedOptions().map(option => (
<div
key={option[valueKey]}
onClick={() => handleSelect(option)}
className={`px-2.5 py-1.5 text-xs cursor-pointer rounded
${
selectedOption?.[valueKey] ===
option[valueKey]
? 'bg-blue-600 text-white'
: 'text-gray-100 hover:bg-gray-700'
}`}>
<div className='flex items-center'>
<div className='flex-grow truncate'>
{option[labelKey]}
</div>
{selectedOption?.[valueKey] ===
option[valueKey] && (
<Check className='w-3.5 h-3.5 ml-1.5 flex-shrink-0' />
)}
</div>
</div>
))}
</div>
) : (
<div className='px-3 py-12 text-center'>
<div className='bg-gray-700/30 rounded-lg p-4 max-w-xs mx-auto'>
<Search className='w-6 h-6 mb-2 mx-auto text-gray-500 opacity-40' />
<p className='text-gray-300 text-xs'>
No options match your search
</p>
<button
onClick={clearSearchTerms}
className='mt-2 text-xs text-blue-400 hover:underline'>
Clear search
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
);
};
SearchDropdown.propTypes = {
value: PropTypes.string,
onChange: PropTypes.func.isRequired,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
description: PropTypes.string
})
).isRequired,
placeholder: PropTypes.string,
className: PropTypes.string,
width: PropTypes.string,
dropdownWidth: PropTypes.string,
labelKey: PropTypes.string,
valueKey: PropTypes.string,
searchableFields: PropTypes.arrayOf(PropTypes.string)
};
export default SearchDropdown;

View File

@@ -7,7 +7,10 @@ const SortDropdown = ({
sortOptions, sortOptions,
currentSort, currentSort,
onSortChange, onSortChange,
className = '' className = '',
textSize = 'text-sm', // Default text size
menuTextSize = 'text-xs', // Default menu text size
iconSize = 16 // Default icon size
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -30,7 +33,7 @@ const SortDropdown = ({
onClick={toggleDropdown} onClick={toggleDropdown}
className={` className={`
inline-flex items-center justify-between inline-flex items-center justify-between
min-h-[40px] px-4 py-2 text-sm px-4 py-2 ${textSize}
bg-white dark:bg-gray-800 bg-white dark:bg-gray-800
border border-gray-300 dark:border-gray-700 border border-gray-300 dark:border-gray-700
text-gray-900 dark:text-gray-100 text-gray-900 dark:text-gray-100
@@ -42,9 +45,9 @@ const SortDropdown = ({
<span className='flex items-center gap-2'> <span className='flex items-center gap-2'>
{getCurrentSortLabel()} {getCurrentSortLabel()}
{currentSort.direction === 'asc' ? ( {currentSort.direction === 'asc' ? (
<ArrowUp size={16} /> <ArrowUp size={iconSize} />
) : ( ) : (
<ArrowDown size={16} /> <ArrowDown size={iconSize} />
)} )}
</span> </span>
</button> </button>
@@ -63,17 +66,17 @@ const SortDropdown = ({
key={option.value} key={option.value}
type='button' type='button'
onClick={() => handleSortClick(option.value)} onClick={() => handleSortClick(option.value)}
className=' className={`
flex items-center justify-between w-full px-4 py-2 flex items-center justify-between w-full px-4 py-2
text-xs text-gray-700 dark:text-gray-200 ${menuTextSize} text-gray-700 dark:text-gray-200
hover:bg-gray-50 dark:hover:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700
'> `}>
<span>{option.label}</span> <span>{option.label}</span>
{currentSort.field === option.value && {currentSort.field === option.value &&
(currentSort.direction === 'asc' ? ( (currentSort.direction === 'asc' ? (
<ArrowUp size={16} /> <ArrowUp size={iconSize} />
) : ( ) : (
<ArrowDown size={16} /> <ArrowDown size={iconSize} />
))} ))}
</button> </button>
))} ))}