Skip to content

Commit

Permalink
🎁 Add search bar
Browse files Browse the repository at this point in the history
This commit will add a text input search bar for the SearchBar component
so the user can search for the text and data of the Question.

Ref:
- #347
  • Loading branch information
Kirk Wang committed Feb 26, 2025
1 parent 184f1ad commit e0c039c
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 76 deletions.
2 changes: 1 addition & 1 deletion app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def shared_props
selectedSubjects: params[:selected_subjects],
selectedTypes: params[:selected_types],
selectedLevels: params[:selected_levels],
filteredQuestions: Question.filter_as_json(**filter_values),
filteredQuestions: Question.filter_as_json(search: params[:search], **filter_values),
exportHrefs: export_hrefs,
bookmarkedQuestionIds: current_user.bookmarks.pluck(:question_id)
}
Expand Down
237 changes: 165 additions & 72 deletions app/javascript/components/ui/Search/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import React, { useState, useEffect } from 'react'
import {
InputGroup, DropdownButton, Button, Container, Form
} from 'react-bootstrap'
import { MagnifyingGlass } from '@phosphor-icons/react'
import CustomDropdown from '../CustomDropdown'
import { MagnifyingGlass, XCircle } from '@phosphor-icons/react'
import { Inertia } from '@inertiajs/inertia'

const SearchBar = (props) => {
Expand All @@ -12,112 +11,206 @@ const SearchBar = (props) => {
keywords,
types,
levels,
submit,
handleFilters,
processing,
selectedKeywords,
selectedTypes,
selectedSubjects,
selectedLevels,
bookmarkedQuestionIds
bookmarkedQuestionIds,
searchTerm
} = props

const filters = { subjects, keywords, types, levels }
const [query, setQuery] = useState(searchTerm || '')
const [filterState, setFilterState] = useState({
selectedKeywords: selectedKeywords || [],
selectedTypes: selectedTypes || [],
selectedSubjects: selectedSubjects || [],
selectedLevels: selectedLevels || []
})
const [hasBookmarks, setHasBookmarks] = useState(bookmarkedQuestionIds.length > 0)

useEffect(() => {
if (searchTerm !== undefined && searchTerm !== query) {
setQuery(searchTerm)
}
}, [searchTerm])

useEffect(() => {
setHasBookmarks(bookmarkedQuestionIds.length > 0)
}, [bookmarkedQuestionIds])

const handleSearchChange = (event) => {
setQuery(event.target.value)
}

const handleSearchSubmit = (event) => {
event.preventDefault()
console.log('Submitting with filters:', filterState)
Inertia.get(window.location.pathname, {
search: query,
selected_keywords: filterState.selectedKeywords,
selected_subjects: filterState.selectedSubjects,
selected_types: filterState.selectedTypes,
selected_levels: filterState.selectedLevels,
}, {
preserveState: true,
preserveScroll: true
})
}

const handleReset = () => {
setQuery('')
setFilterState({
selectedKeywords: [],
selectedTypes: [],
selectedSubjects: [],
selectedLevels: []
})
Inertia.get(window.location.pathname, {
search: '',
selected_keywords: [],
selected_subjects: [],
selected_types: [],
selected_levels: [],
}, {
preserveState: true,
preserveScroll: true
})
}

const handleFilterChange = (event, filterKey) => {
const { value, checked } = event.target
setFilterState((prevState) => {
const updatedFilters = [...prevState[filterKey]]

if (checked && !updatedFilters.includes(value)) {
updatedFilters.push(value)
} else if (!checked) {
const index = updatedFilters.indexOf(value)
if (index !== -1) {
updatedFilters.splice(index, 1)
}
}

return { ...prevState, [filterKey]: updatedFilters }
})
}

const handleDeleteAllBookmarks = () => {
Inertia.delete('/bookmarks/destroy_all', {
onSuccess: () => {
setHasBookmarks(false)
},
onError: () => {
console.error('Failed to clear all bookmarks')
},
}
})
}

return (
<Form onSubmit={submit}>
<Form onSubmit={handleSearchSubmit}>
<Container className='p-0 mt-2 search-bar'>
<InputGroup className='mb-3 flex-column flex-md-row'>
{/* props being passed to this component are each of the filters. the keys are the name of the filter, and the values are the list of items to filter by */}
{/* Search Input */}
<Form.Control
type='text'
name='search'
placeholder='search questions...'
value={query}
onChange={handleSearchChange}
className='border border-light-4 text-black'
/>
<Button
className='d-flex align-items-center fs-6 justify-content-center'
id='button-addon2'
size='lg'
type='submit'
disabled={processing}
>
<span className='me-1'>Search</span>
<MagnifyingGlass size={20} weight='bold' />
</Button>
{query && (
<Button
variant='outline-secondary'
className='d-flex align-items-center fs-6 justify-content-center ms-2'
size='lg'
onClick={handleReset}
>
<span className='me-1'>Reset</span>
<XCircle size={20} weight='bold' />
</Button>
)}
</InputGroup>

{/* Filters */}
<InputGroup className='mb-3 flex-column flex-md-row'>
{Object.keys(filters).map((key, index) => (
<CustomDropdown key={index} dropdownSelector='.dropdown-toggle'>
<DropdownButton
variant='outline-light-4 text-black fs-6 d-flex align-items-center justify-content-between'
title={key}
id={`input-group-dropdown-${index}`}
size='lg'
autoClose='outside'
>
{filters[key].map((item, itemIndex) => (
<Form.Check type='checkbox' id={item} className='p-2' key={itemIndex}>
<Form.Check.Input
type='checkbox'
id={item}
className='mx-0'
value={item}
onChange={(event) => handleFilters(event, key)}
defaultChecked={selectedSubjects.includes(item) || selectedKeywords.includes(item) || selectedTypes.includes(item) || selectedLevels.includes(item)}
/>
<Form.Check.Label className='ps-2'>
{filters[key] === 'types' ? item.substring(10) : item}
</Form.Check.Label>
</Form.Check>
)
)}
</DropdownButton>
</CustomDropdown>
))}
<CustomDropdown key='bookmark' dropdownSelector='.dropdown-toggle'>
<DropdownButton
variant='outline-light-4 text-black fs-6 d-flex align-items-center justify-content-between'
title={`Bookmarks (${bookmarkedQuestionIds.length})`}
id='input-group-dropdown-bookmark'
title={key.toUpperCase()}
id={`input-group-dropdown-${index}`}
size='lg'
autoClose='outside'
key={index}
>
<div className='d-flex flex-column align-items-start'>
<a
href='?bookmarked=true'
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
role='button'
aria-disabled={!hasBookmarks}
>
View Bookmarks
</a>
<a
href={`/.xml?${bookmarkedQuestionIds.map(id => `bookmarked_question_ids[]=${encodeURIComponent(id)}`).join('&')}`}
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
role='button'
aria-disabled={!hasBookmarks}
>
Export Bookmarks
</a>
<Button
variant='danger'
className='p-2 m-2'
onClick={handleDeleteAllBookmarks}
disabled={!hasBookmarks}
{filters[key].map((item, itemIndex) => (
<Form.Check
type='checkbox'
id={item}
className='p-2'
key={itemIndex}
>
Clear Bookmarks
</Button>
</div>
<Form.Check.Input
type='checkbox'
id={item}
className='mx-0'
value={item}
onChange={(event) => handleFilterChange(event, `selected${key.charAt(0).toUpperCase() + key.slice(1)}`)}
checked={filterState[`selected${key.charAt(0).toUpperCase() + key.slice(1)}`].includes(item)}
/>
<Form.Check.Label className='ps-2'>{item}</Form.Check.Label>
</Form.Check>
))}
</DropdownButton>
</CustomDropdown>
<Button
className='d-flex align-items-center fs-6 justify-content-center'
id='button-addon2'
))}

{/* Bookmarks */}
<DropdownButton
variant='outline-light-4 text-black fs-6 d-flex align-items-center justify-content-between'
title={`BOOKMARKS (${bookmarkedQuestionIds.length})`}
id='input-group-dropdown-bookmark'
size='lg'
type='submit'
disabled={processing}
autoClose='outside'
>
<span className='me-1'>Search</span>
<MagnifyingGlass size={20} weight='bold'/>
</Button>
<div className='d-flex flex-column align-items-start'>
<a
href='?bookmarked=true'
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
role='button'
aria-disabled={!hasBookmarks}
>
View Bookmarks
</a>
<a
href={'.xml?bookmarked=true'}
className={`btn btn-primary p-2 m-2 ${!hasBookmarks ? 'disabled' : ''}`}
role='button'
aria-disabled={!hasBookmarks}
>
Export Bookmarks
</a>
<Button
variant='danger'
className='p-2 m-2'
onClick={handleDeleteAllBookmarks}
disabled={!hasBookmarks}
>
Clear Bookmarks
</Button>
</div>
</DropdownButton>
</InputGroup>
</Container>
</Form>
Expand Down
7 changes: 4 additions & 3 deletions app/models/question.rb
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ def self.invalid_question_due_to_missing_headers(row:, required_headers: require
#
# @see .filter
# rubocop:disable Metrics/MethodLength
def self.filter_as_json(select: FILTER_DEFAULT_SELECT, methods: FILTER_DEFAULT_METHODS, **kwargs)
def self.filter_as_json(select: FILTER_DEFAULT_SELECT, methods: FILTER_DEFAULT_METHODS, search: false, **kwargs)
##
# The :data method/field is an interesting creature; we want to "select" it in queries because
# in most cases that is adequate. Yet the {Question::StimulusCaseStudy#data} is unique, in that
Expand All @@ -332,7 +332,7 @@ def self.filter_as_json(select: FILTER_DEFAULT_SELECT, methods: FILTER_DEFAULT_M
only << :data unless only.include?(:data)

# Ensure the `filter` method is called with eager loading for associations
questions = filter(select: only, **kwargs)
questions = filter(select: only, search: search, **kwargs)

# Convert to JSON and manually add image URLs and alt texts if they are included in the methods
questions.map do |question|
Expand Down Expand Up @@ -456,7 +456,7 @@ def find_or_create_subjects_and_keywords
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/ParameterLists
def self.filter(keywords: [], subjects: [], levels: [], bookmarked_question_ids: [], bookmarked: nil, type_name: nil, select: nil, user: nil)
def self.filter(keywords: [], subjects: [], levels: [], bookmarked_question_ids: [], bookmarked: nil, type_name: nil, select: nil, user: nil, search: false)
# By wrapping in an array we ensure that our keywords.size and subjects.size are counting
# the number of keywords given and not the number of characters in a singular keyword that was
# provided.
Expand All @@ -466,6 +466,7 @@ def self.filter(keywords: [], subjects: [], levels: [], bookmarked_question_ids:

# Specifying a very arbitrary order
questions = Question.includes(:keywords, :subjects, images: { file_attachment: :blob }).order(:id)
questions = questions.search(search) if search.present?

# We want a human readable name for filtering and UI work. However, we want to convert that
# into a class. ActiveRecord is mostly smart about Single Table Inheritance (STI). But we're
Expand Down

0 comments on commit e0c039c

Please sign in to comment.