Implement search by user's points

This commit is contained in:
Eugene Burmakin 2025-08-31 12:08:33 +02:00
parent 1709aa612d
commit 2d240c2094
9 changed files with 656 additions and 45 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,13 +2,14 @@
class Api::V1::LocationsController < ApiController
before_action :validate_search_params, only: [:index]
before_action :validate_suggestion_params, only: [:suggestions]
def index
if search_query.present?
if search_query.present? || coordinate_search?
search_results = LocationSearch::PointFinder.new(current_api_user, search_params).call
render json: LocationSearchResultSerializer.new(search_results).call
else
render json: { error: 'Search query parameter (q) is required' }, status: :bad_request
render json: { error: 'Search query parameter (q) or coordinates (lat, lon) are required' }, status: :bad_request
end
rescue StandardError => e
Rails.logger.error "Location search error: #{e.message}"
@ -16,6 +17,29 @@ class Api::V1::LocationsController < ApiController
render json: { error: 'Search failed. Please try again.' }, status: :internal_server_error
end
def suggestions
if search_query.present? && search_query.length >= 2
suggestions = LocationSearch::GeocodingService.new.search(search_query)
# Format suggestions for the frontend
formatted_suggestions = suggestions.take(5).map do |suggestion|
{
name: suggestion[:name],
address: suggestion[:address],
coordinates: [suggestion[:lat], suggestion[:lon]],
type: suggestion[:type]
}
end
render json: { suggestions: formatted_suggestions }
else
render json: { suggestions: [] }
end
rescue StandardError => e
Rails.logger.error "Suggestions error: #{e.message}"
render json: { suggestions: [] }
end
private
def search_query
@ -25,6 +49,10 @@ class Api::V1::LocationsController < ApiController
def search_params
{
query: search_query,
latitude: params[:lat]&.to_f,
longitude: params[:lon]&.to_f,
name: params[:name],
address: params[:address],
limit: params[:limit]&.to_i || 50,
date_from: parse_date(params[:date_from]),
date_to: parse_date(params[:date_to]),
@ -32,13 +60,36 @@ class Api::V1::LocationsController < ApiController
}
end
def coordinate_search?
params[:lat].present? && params[:lon].present?
end
def validate_search_params
if search_query.blank?
render json: { error: 'Search query parameter (q) is required' }, status: :bad_request
if search_query.blank? && !coordinate_search?
render json: { error: 'Search query parameter (q) or coordinates (lat, lon) are required' }, status: :bad_request
return false
end
if search_query.length > 200
if search_query.present? && search_query.length > 200
render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request
return false
end
if coordinate_search?
lat = params[:lat]&.to_f
lon = params[:lon]&.to_f
if lat.abs > 90 || lon.abs > 180
render json: { error: 'Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180' }, status: :bad_request
return false
end
end
true
end
def validate_suggestion_params
if search_query.present? && search_query.length > 200
render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request
return false
end

View file

@ -6,9 +6,13 @@ class LocationSearch {
this.searchResults = [];
this.searchMarkersLayer = null;
this.currentSearchQuery = '';
this.searchTimeout = null;
this.suggestionsVisible = false;
this.currentSuggestionIndex = -1;
this.initializeSearchBar();
this.initializeSearchResults();
this.initializeSuggestions();
}
initializeSearchBar() {
@ -101,6 +105,18 @@ class LocationSearch {
this.resultsContainer = resultsContainer;
}
initializeSuggestions() {
// Create suggestions dropdown (positioned below search input)
const suggestionsContainer = document.createElement('div');
suggestionsContainer.className = 'location-search-suggestions fixed z-50 w-80 max-h-48 overflow-y-auto bg-white rounded-lg shadow-xl border hidden';
suggestionsContainer.id = 'location-search-suggestions';
const mapContainer = document.getElementById('map');
mapContainer.appendChild(suggestionsContainer);
this.suggestionsContainer = suggestionsContainer;
}
bindSearchEvents() {
// Toggle search bar visibility
this.toggleButton.addEventListener('click', () => {
@ -124,12 +140,43 @@ class LocationSearch {
this.clearSearch();
});
// Show clear button when input has content
// Show clear button when input has content and handle real-time suggestions
this.searchInput.addEventListener('input', (e) => {
if (e.target.value.length > 0) {
const query = e.target.value.trim();
if (query.length > 0) {
this.clearButton.classList.remove('hidden');
this.debouncedSuggestionSearch(query);
} else {
this.clearButton.classList.add('hidden');
this.hideSuggestions();
}
});
// Handle keyboard navigation for suggestions
this.searchInput.addEventListener('keydown', (e) => {
if (this.suggestionsVisible) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.navigateSuggestions(1);
break;
case 'ArrowUp':
e.preventDefault();
this.navigateSuggestions(-1);
break;
case 'Enter':
e.preventDefault();
if (this.currentSuggestionIndex >= 0) {
this.selectSuggestion(this.currentSuggestionIndex);
} else {
this.performSearch();
}
break;
case 'Escape':
this.hideSuggestions();
break;
}
}
});
@ -137,8 +184,10 @@ class LocationSearch {
document.addEventListener('click', (e) => {
if (!e.target.closest('.location-search-container') &&
!e.target.closest('.location-search-results') &&
!e.target.closest('.location-search-suggestions') &&
!e.target.closest('#location-search-toggle')) {
this.hideResults();
this.hideSuggestions();
if (this.searchVisible) {
this.hideSearchBar();
}
@ -406,6 +455,181 @@ class LocationSearch {
this.resultsContainer.classList.add('hidden');
}
// Suggestion-related methods
debouncedSuggestionSearch(query) {
// Clear existing timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Set new timeout for debounced search
this.searchTimeout = setTimeout(() => {
this.performSuggestionSearch(query);
}, 300); // 300ms debounce delay
}
async performSuggestionSearch(query) {
if (query.length < 2) {
this.hideSuggestions();
return;
}
try {
const response = await fetch(`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Suggestions failed: ${response.status}`);
}
const data = await response.json();
this.displaySuggestions(data.suggestions || []);
} catch (error) {
console.error('Suggestion search error:', error);
this.hideSuggestions();
}
}
displaySuggestions(suggestions) {
if (!suggestions.length) {
this.hideSuggestions();
return;
}
// Position suggestions container below search input, aligned with the search container
const searchRect = this.searchContainer.getBoundingClientRect();
const suggestionsTop = searchRect.bottom + 2;
const suggestionsRight = window.innerWidth - searchRect.left;
this.suggestionsContainer.style.top = suggestionsTop + 'px';
this.suggestionsContainer.style.right = suggestionsRight + 'px';
// Build suggestions HTML
let suggestionsHtml = '';
suggestions.forEach((suggestion, index) => {
const isActive = index === this.currentSuggestionIndex;
suggestionsHtml += `
<div class="suggestion-item p-2 border-b border-gray-100 hover:bg-gray-50 cursor-pointer text-sm ${isActive ? 'bg-blue-50 text-blue-700' : ''}"
data-suggestion-index="${index}">
<div class="font-medium">${this.escapeHtml(suggestion.name)}</div>
<div class="text-xs text-gray-600">${this.escapeHtml(suggestion.address || '')}</div>
</div>
`;
});
this.suggestionsContainer.innerHTML = suggestionsHtml;
this.suggestionsContainer.classList.remove('hidden');
this.suggestionsVisible = true;
this.suggestions = suggestions;
// Bind click events to suggestions
this.bindSuggestionEvents();
}
bindSuggestionEvents() {
const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item');
suggestionItems.forEach(item => {
item.addEventListener('click', (e) => {
const index = parseInt(e.currentTarget.dataset.suggestionIndex);
this.selectSuggestion(index);
});
});
}
navigateSuggestions(direction) {
if (!this.suggestions || !this.suggestions.length) return;
const maxIndex = this.suggestions.length - 1;
if (direction > 0) {
// Arrow down
this.currentSuggestionIndex = this.currentSuggestionIndex < maxIndex
? this.currentSuggestionIndex + 1
: 0;
} else {
// Arrow up
this.currentSuggestionIndex = this.currentSuggestionIndex > 0
? this.currentSuggestionIndex - 1
: maxIndex;
}
this.highlightActiveSuggestion();
}
highlightActiveSuggestion() {
const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item');
suggestionItems.forEach((item, index) => {
if (index === this.currentSuggestionIndex) {
item.classList.add('bg-blue-50', 'text-blue-700');
item.classList.remove('bg-gray-50');
} else {
item.classList.remove('bg-blue-50', 'text-blue-700');
item.classList.add('bg-gray-50');
}
});
}
selectSuggestion(index) {
if (!this.suggestions || index < 0 || index >= this.suggestions.length) return;
const suggestion = this.suggestions[index];
this.searchInput.value = suggestion.name;
this.hideSuggestions();
this.performCoordinateSearch(suggestion); // Use coordinate-based search for selected suggestion
}
async performCoordinateSearch(suggestion) {
this.currentSearchQuery = suggestion.name;
this.showLoading();
try {
const params = new URLSearchParams({
lat: suggestion.coordinates[0],
lon: suggestion.coordinates[1],
name: suggestion.name,
address: suggestion.address || ''
});
const response = await fetch(`/api/v1/locations?${params}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Coordinate search failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.displaySearchResults(data);
} catch (error) {
console.error('Coordinate search error:', error);
this.showError('Failed to search locations. Please try again.');
}
}
hideSuggestions() {
this.suggestionsContainer.classList.add('hidden');
this.suggestionsVisible = false;
this.currentSuggestionIndex = -1;
this.suggestions = [];
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
}
// Utility methods
escapeHtml(text) {
const map = {

View file

@ -29,17 +29,39 @@ module LocationSearch
private
def perform_geocoding_search(query)
Rails.logger.info "LocationSearch::GeocodingService: Searching for '#{query}' using #{provider_name}"
# Try original query first
results = Geocoder.search(query, limit: MAX_RESULTS)
Rails.logger.info "LocationSearch::GeocodingService: Raw geocoder returned #{results.length} results"
# If we got results but they seem too generic (common chain names),
# also try with location context
if results.length > 1 && looks_like_chain_store?(query)
Rails.logger.info "LocationSearch::GeocodingService: Query looks like chain store, trying with Berlin context"
berlin_results = Geocoder.search("#{query} Berlin", limit: MAX_RESULTS)
Rails.logger.info "LocationSearch::GeocodingService: Berlin-specific search returned #{berlin_results.length} results"
# Prioritize Berlin results
results = (berlin_results + results).uniq
end
return [] if results.blank?
normalize_geocoding_results(results)
normalized = normalize_geocoding_results(results)
Rails.logger.info "LocationSearch::GeocodingService: After normalization: #{normalized.length} results"
normalized
end
def normalize_geocoding_results(results)
normalized_results = []
results.each do |result|
next unless valid_result?(result)
results.each_with_index do |result, idx|
unless valid_result?(result)
Rails.logger.warn "LocationSearch::GeocodingService: Result #{idx} is invalid: lat=#{result.latitude}, lon=#{result.longitude}"
next
end
normalized_result = {
lat: result.latitude.to_f,
@ -50,11 +72,16 @@ module LocationSearch
provider_data: extract_provider_data(result)
}
Rails.logger.info "LocationSearch::GeocodingService: Result #{idx}: '#{normalized_result[:name]}' at [#{normalized_result[:lat]}, #{normalized_result[:lon]}]"
normalized_results << normalized_result
end
# Remove duplicates based on coordinates (within 100m)
deduplicate_results(normalized_results)
deduplicated = deduplicate_results(normalized_results)
Rails.logger.info "LocationSearch::GeocodingService: After deduplication: #{deduplicated.length} results"
deduplicated
end
def valid_result?(result)
@ -188,5 +215,17 @@ module LocationSearch
rkm * c
end
def looks_like_chain_store?(query)
chain_patterns = [
/\b(netto|kaufland|rewe|edeka|aldi|lidl|penny|real)\b/i,
/\b(mcdonalds?|burger king|kfc|subway)\b/i,
/\b(shell|aral|esso|bp|total)\b/i,
/\b(dm|rossmann|müller)\b/i,
/\b(h&m|c&a|zara|primark)\b/i
]
chain_patterns.any? { |pattern| query.match?(pattern) }
end
end
end

View file

@ -5,6 +5,10 @@ module LocationSearch
def initialize(user, params = {})
@user = user
@query = params[:query]
@latitude = params[:latitude]
@longitude = params[:longitude]
@name = params[:name] || 'Selected Location'
@address = params[:address] || ''
@limit = params[:limit] || 50
@date_from = params[:date_from]
@date_to = params[:date_to]
@ -12,36 +16,89 @@ module LocationSearch
end
def call
if coordinate_search?
return coordinate_based_search
elsif @query.present?
return text_based_search
else
return empty_result
end
end
private
def coordinate_search?
@latitude.present? && @longitude.present?
end
def coordinate_based_search
Rails.logger.info "LocationSearch: Coordinate-based search at [#{@latitude}, #{@longitude}] for '#{@name}'"
# Create a single location object with the provided coordinates
location = {
lat: @latitude,
lon: @longitude,
name: @name,
address: @address,
type: 'coordinate_search'
}
find_matching_points([location])
end
def text_based_search
return empty_result if @query.blank?
geocoded_locations = geocoding_service.search(@query)
# Debug: Log geocoding results
Rails.logger.info "LocationSearch: Geocoding '#{@query}' returned #{geocoded_locations.length} locations"
geocoded_locations.each_with_index do |loc, idx|
Rails.logger.info "LocationSearch: [#{idx}] #{loc[:name]} at [#{loc[:lat]}, #{loc[:lon]}] - #{loc[:address]}"
end
return empty_result if geocoded_locations.empty?
find_matching_points(geocoded_locations)
end
private
def geocoding_service
@geocoding_service ||= LocationSearch::GeocodingService.new
end
def find_matching_points(geocoded_locations)
results = []
geocoded_locations.each do |location|
# Debug: Log the geocoded location
Rails.logger.info "LocationSearch: Searching for points near #{location[:name]} at [#{location[:lat]}, #{location[:lon]}]"
matching_points = spatial_matcher.find_points_near(
@user,
location[:lat],
location[:lon],
determine_search_radius(location),
@radius_override || 500, # Allow radius override, default 500 meters
date_filter_options
)
next if matching_points.empty?
# Debug: Log the number of matching points found
Rails.logger.info "LocationSearch: Found #{matching_points.length} points within #{@radius_override || 500}m radius"
if matching_points.empty?
# Try with a larger radius to see if there are any points nearby
wider_search = spatial_matcher.find_points_near(
@user,
location[:lat],
location[:lon],
1000, # 1km radius for debugging
date_filter_options
)
Rails.logger.info "LocationSearch: Found #{wider_search.length} points within 1000m radius (debug)"
next
end
visits = result_aggregator.group_points_into_visits(matching_points)
results << {
place_name: location[:name],
coordinates: [location[:lat], location[:lon]],
@ -65,26 +122,6 @@ module LocationSearch
}
end
def determine_search_radius(location)
return @radius_override if @radius_override.present?
# Smart radius selection based on place type
place_type = location[:type]&.downcase || ''
case place_type
when /shop|store|restaurant|cafe|supermarket|mall/
75 # meters - specific businesses
when /street|road|avenue|boulevard/
50 # meters - street addresses
when /neighborhood|district|area/
300 # meters - areas
when /city|town|village/
1000 # meters - cities
else
100 # meters - default for unknown types
end
end
def spatial_matcher
@spatial_matcher ||= LocationSearch::SpatialMatcher.new
end
@ -113,4 +150,4 @@ module LocationSearch
}
end
end
end
end

View file

@ -6,6 +6,38 @@ module LocationSearch
# Using PostGIS for efficient spatial queries
end
# Debug method to test spatial queries directly
def debug_points_near(user, latitude, longitude, radius_meters = 1000)
query = <<~SQL
SELECT
p.id,
p.timestamp,
ST_Y(p.lonlat::geometry) as latitude,
ST_X(p.lonlat::geometry) as longitude,
p.city,
p.country,
ST_Distance(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography) as distance_meters
FROM points p
WHERE p.user_id = #{user.id}
AND ST_DWithin(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography, #{radius_meters})
ORDER BY distance_meters ASC
LIMIT 10;
SQL
puts "=== DEBUG SPATIAL QUERY ==="
puts "Searching for user #{user.id} near [#{latitude}, #{longitude}] within #{radius_meters}m"
puts "Query: #{query}"
results = ActiveRecord::Base.connection.exec_query(query)
puts "Found #{results.count} points:"
results.each do |row|
puts "- Point #{row['id']}: [#{row['latitude']}, #{row['longitude']}] - #{row['distance_meters'].to_f.round(2)}m away"
end
results
end
def find_points_near(user, latitude, longitude, radius_meters, date_options = {})
points_query = build_spatial_query(user, latitude, longitude, radius_meters, date_options)
@ -25,8 +57,8 @@ module LocationSearch
SELECT
p.id,
p.timestamp,
p.latitude,
p.longitude,
ST_Y(p.lonlat::geometry) as latitude,
ST_X(p.lonlat::geometry) as longitude,
p.city,
p.country,
p.altitude,

View file

@ -100,7 +100,11 @@ Rails.application.routes.draw do
get 'users/me', to: 'users#me'
resources :areas, only: %i[index create update destroy]
resources :locations, only: %i[index]
resources :locations, only: %i[index] do
collection do
get 'suggestions'
end
end
resources :points, only: %i[index create update destroy]
resources :visits, only: %i[index create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member

View file

@ -251,4 +251,118 @@ RSpec.describe Api::V1::LocationsController, type: :request do
end
end
end
describe 'GET /api/v1/locations/suggestions' do
context 'with valid authentication' do
let(:mock_suggestions) do
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Kaufland Mitte',
address: 'Alexanderplatz 1, Berlin',
type: 'shop'
},
{
lat: 52.5100,
lon: 13.4000,
name: 'Kaufland Friedrichshain',
address: 'Warschauer Str. 80, Berlin',
type: 'shop'
}
]
end
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return(mock_suggestions)
end
context 'with valid search query' do
it 'returns formatted suggestions' do
get '/api/v1/locations/suggestions', params: { q: 'Kaufland' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_an(Array)
expect(json_response['suggestions'].length).to eq(2)
first_suggestion = json_response['suggestions'].first
expect(first_suggestion).to include(
'name' => 'Kaufland Mitte',
'address' => 'Alexanderplatz 1, Berlin',
'coordinates' => [52.5200, 13.4050],
'type' => 'shop'
)
end
it 'limits suggestions to 5 results' do
large_suggestions = Array.new(10) do |i|
{
lat: 52.5000 + i * 0.001,
lon: 13.4000 + i * 0.001,
name: "Location #{i}",
address: "Address #{i}",
type: 'place'
}
end
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return(large_suggestions)
get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers
json_response = JSON.parse(response.body)
expect(json_response['suggestions'].length).to eq(5)
end
end
context 'with short search query' do
it 'returns empty suggestions for queries shorter than 2 characters' do
get '/api/v1/locations/suggestions', params: { q: 'a' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_empty
end
end
context 'with blank query' do
it 'returns empty suggestions' do
get '/api/v1/locations/suggestions', params: { q: '' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_empty
end
end
context 'when geocoding service raises an error' do
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_raise(StandardError.new('Geocoding error'))
end
it 'returns empty suggestions gracefully' do
get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['suggestions']).to be_empty
end
end
end
context 'without authentication' do
it 'returns unauthorized error' do
get '/api/v1/locations/suggestions', params: { q: 'test' }
expect(response).to have_http_status(:unauthorized)
end
end
end
end

View file

@ -333,12 +333,122 @@ RSpec.describe 'Location Search Feature', type: :system, js: true do
expect(page).to have_content('location(s) for "Kaufland Berlin"')
end
end
context 'with real-time suggestions' do
before do
# Mock the geocoding service to return suggestions
allow_any_instance_of(LocationSearch::GeocodingService).to receive(:search) do |_service, query|
case query.downcase
when /kau/
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Kaufland Mitte',
address: 'Alexanderplatz 1, Berlin',
type: 'shop'
},
{
lat: 52.5100,
lon: 13.4000,
name: 'Kaufland Friedrichshain',
address: 'Warschauer Str. 80, Berlin',
type: 'shop'
}
]
else
[]
end
end
# Show the search bar first
find('#location-search-toggle').click
end
it 'shows suggestions as user types' do
search_input = find('#location-search-input')
search_input.fill_in(with: 'Kau')
# Wait for debounced search to trigger
sleep(0.4)
within('#location-search-suggestions') do
expect(page).to have_content('Kaufland Mitte')
expect(page).to have_content('Alexanderplatz 1, Berlin')
expect(page).to have_content('Kaufland Friedrichshain')
end
end
it 'allows selecting suggestions with mouse click' do
search_input = find('#location-search-input')
search_input.fill_in(with: 'Kau')
sleep(0.4)
within('#location-search-suggestions') do
find('.suggestion-item', text: 'Kaufland Mitte').click
end
expect(search_input.value).to eq('Kaufland Mitte')
expect(page).to have_css('#location-search-suggestions.hidden')
end
it 'allows keyboard navigation through suggestions' do
search_input = find('#location-search-input')
search_input.fill_in(with: 'Kau')
sleep(0.4)
# Navigate down through suggestions
search_input.send_keys(:arrow_down)
within('#location-search-suggestions') do
expect(page).to have_css('.suggestion-item.bg-blue-50', text: 'Kaufland Mitte')
end
search_input.send_keys(:arrow_down)
within('#location-search-suggestions') do
expect(page).to have_css('.suggestion-item.bg-blue-50', text: 'Kaufland Friedrichshain')
end
# Select with Enter
search_input.send_keys(:enter)
expect(search_input.value).to eq('Kaufland Friedrichshain')
end
it 'hides suggestions when input is cleared' do
search_input = find('#location-search-input')
search_input.fill_in(with: 'Kau')
sleep(0.4)
expect(page).to have_css('#location-search-suggestions:not(.hidden)')
search_input.set('')
expect(page).to have_css('#location-search-suggestions.hidden')
end
it 'hides suggestions on Escape key' do
search_input = find('#location-search-input')
search_input.fill_in(with: 'Kau')
sleep(0.4)
expect(page).to have_css('#location-search-suggestions:not(.hidden)')
search_input.send_keys(:escape)
expect(page).to have_css('#location-search-suggestions.hidden')
end
it 'does not show suggestions for queries shorter than 2 characters' do
search_input = find('#location-search-input')
search_input.fill_in(with: 'K')
sleep(0.4)
expect(page).to have_css('#location-search-suggestions.hidden')
end
end
end
private
def sign_in(user)
visit new_user_session_path
visit '/users/sign_in'
fill_in 'Email', with: user.email
fill_in 'Password', with: user.password
click_button 'Log in'