Skip to content

Commit ba80f2e

Browse files
authored
Merge pull request #173 from denis1011101/fix/user-city-from-catalog
fix: город в профиле сохраняем только выбором из справочника
2 parents 3e63b04 + 598d385 commit ba80f2e

12 files changed

Lines changed: 348 additions & 60 deletions

File tree

app/controllers/users_controller.rb

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ class UsersController < ApplicationController
22
include LocationFilters
33

44
CITY_SEARCH_DEFAULT_LIMIT = 5
5+
CITY_SEARCH_MAX_LIMIT = 20
56

67
before_action :authenticate_user!
78

@@ -38,13 +39,13 @@ def update
3839
court_preferences_submitted = user_params.key?(:court_preferences_mode) || user_params.key?(:favorite_court_ids)
3940
court_preferences_mode = user_params[:court_preferences_mode].presence || default_court_preferences_mode(@user)
4041

41-
query = user_attrs["city_name"].to_s.strip
42-
if query.present?
43-
coords_regex = /\A\s*-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?\s*\z/
44-
# for plain names: transliterate immediately so DB shows e.g. "Kurgan"
45-
unless query =~ coords_regex
46-
user_attrs["city_name"] = translit_str(query)
47-
end
42+
# Город принимаем только выбором из подсказок: произвольный текст в поле
43+
# раньше уезжал в city_name как есть и ломал матчинг с courts.city_name.
44+
user_attrs.delete("city_name")
45+
selected_city = City.find_by(id: params[:selected_city_id]) if params[:selected_city_id].present?
46+
if selected_city
47+
user_attrs["city_name"] = selected_city.canonical_name
48+
user_attrs["timezone"] = selected_city.rails_timezone if selected_city.rails_timezone.present?
4849
end
4950

5051
if court_preferences_submitted
@@ -63,12 +64,6 @@ def update
6364
Rails.logger.info "[UsersController#update] saving user_attrs=#{user_attrs.inspect}"
6465

6566
if @user.update(user_attrs)
66-
# enqueue background job to resolve timezone asynchronously (by coords or by name)
67-
if query.present?
68-
ResolveUserCityJob.perform_later(@user.id, query)
69-
Rails.logger.info "[UsersController#update] enqueued ResolveUserCityJob for user_id=#{@user.id} query=#{query.inspect}"
70-
end
71-
7267
respond_to do |format|
7368
format.html { redirect_to update_section_path(section), notice: "Account updated" }
7469
format.json { render json: { success: true, city_name: @user.city_name, timezone: @user.timezone } }
@@ -95,6 +90,19 @@ def dismiss_onboarding
9590
redirect_back fallback_location: root_path
9691
end
9792

93+
# Подсказки для поля города: отдаём только то, что есть в справочнике, —
94+
# сохранить можно лишь выбранную строку.
95+
def city_search
96+
# Потолок обязателен: в SQLite отрицательный LIMIT снимает ограничение
97+
# совсем, и запрос вытащил бы весь справочник городов целиком.
98+
limit = params[:limit].present? ? params[:limit].to_i.clamp(1, CITY_SEARCH_MAX_LIMIT) : CITY_SEARCH_DEFAULT_LIMIT
99+
cities = Cities::SearchService.new(query: params[:q], limit: limit).call
100+
101+
render json: cities.map { |city|
102+
{ id: city.id, name: city.canonical_name, hint: [ city.country_code, city.timezone ].compact_blank.join(" · ") }
103+
}
104+
end
105+
98106
def clear_city
99107
@user = current_user
100108
@user.update(timezone: nil, city_name: nil)
@@ -177,10 +185,6 @@ def destroy
177185

178186
private
179187

180-
def translit_str(s)
181-
Russian.translit(s.to_s)
182-
end
183-
184188
def user_update_params
185189
params.require(:user).permit(
186190
:name,
@@ -205,15 +209,15 @@ def prepare_notifications_form_state
205209
@registration_token = @user.ensure_telegram_registration_token!
206210
end
207211

212+
# Раньше здесь по таймзоне подбирался «какой-нибудь» город из справочника и
213+
# подставлялся в поле города. Таймзона города не определяет — в
214+
# Asia/Yekaterinburg лежит и Челябинск, и Тюмень, — поэтому не подставляем
215+
# ничего: пустое поле честнее угаданного.
208216
def prepare_profile_form_state
209-
@limit = params[:limit].to_i.nonzero? || CITY_SEARCH_DEFAULT_LIMIT
210-
211-
if params[:selected_city_name].present?
212-
@selected_city_name = params[:selected_city_name]
213-
else
214-
c = City.find_by(timezone: @user.timezone)
215-
@selected_city_name = "#{c.name}, #{c.country_code}#{c.timezone}" if c.present?
216-
end
217+
# После ошибки валидации форму рисуем заново, и выбранный город надо вернуть
218+
# в hidden-поле: в видимом поле он уже стоит, а без id следующая отправка
219+
# город не изменит.
220+
@selected_city_id = params[:selected_city_id].presence
217221
end
218222

219223
def prepare_court_preferences_state
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
// Поле города в профиле. Сохранить можно только город из справочника, поэтому
4+
// видимый текст сам по себе ничего не значит: город несёт hidden-поле с id, и
5+
// любое ручное редактирование текста этот id сбрасывает — иначе к новому тексту
6+
// прицепился бы id старого выбора.
7+
export default class extends Controller {
8+
static targets = ["input", "cityId", "results"]
9+
static values = {
10+
url: String,
11+
searching: String,
12+
noMatches: String,
13+
error: String
14+
}
15+
16+
connect() {
17+
this.searchTimeout = null
18+
this.requestId = 0
19+
this.hideTimeout = null
20+
this.onDocumentClick = (event) => {
21+
if (!this.element.contains(event.target)) this.hide()
22+
}
23+
document.addEventListener("click", this.onDocumentClick)
24+
}
25+
26+
disconnect() {
27+
clearTimeout(this.searchTimeout)
28+
clearTimeout(this.hideTimeout)
29+
document.removeEventListener("click", this.onDocumentClick)
30+
}
31+
32+
// Правка текста отменяет выбор: иначе к новому названию прицепился бы id
33+
// прошлого города.
34+
search() {
35+
this.cityIdTarget.value = ""
36+
this.startRequest()
37+
38+
const query = this.inputTarget.value.trim()
39+
if (query.length < 2) {
40+
this.hide()
41+
return
42+
}
43+
44+
this.searchTimeout = setTimeout(() => this.fetchCities(query), 300)
45+
}
46+
47+
// Фокус в поле лишь возвращает список: выбранный город остаётся выбранным.
48+
reopen() {
49+
if (this.cityIdTarget.value) return
50+
51+
this.search()
52+
}
53+
54+
// Поколение запроса растёт на каждое изменение запроса, включая уход в
55+
// слишком короткую строку, — иначе подвисший ответ снова открыл бы список
56+
// с городами, которых в поле уже нет.
57+
startRequest() {
58+
clearTimeout(this.searchTimeout)
59+
this.requestId = (this.requestId || 0) + 1
60+
return this.requestId
61+
}
62+
63+
async fetchCities(query) {
64+
this.renderMessage(this.searchingValue)
65+
const requestId = this.requestId
66+
67+
try {
68+
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`, {
69+
headers: { Accept: "application/json" }
70+
})
71+
if (!response.ok) throw new Error(response.status)
72+
const cities = await response.json()
73+
// Ответы могут прийти не в том порядке, в каком уходили запросы.
74+
if (requestId !== this.requestId) return
75+
this.renderCities(cities)
76+
} catch (error) {
77+
console.warn("City search failed:", error)
78+
if (requestId === this.requestId) this.renderMessage(this.errorValue)
79+
}
80+
}
81+
82+
renderCities(cities) {
83+
if (!cities.length) {
84+
this.renderMessage(this.noMatchesValue)
85+
return
86+
}
87+
88+
this.resultsTarget.innerHTML = cities
89+
.map(
90+
(city) => `
91+
<button type="button" data-action="click->city-picker#select"
92+
data-id="${city.id}" data-name="${escapeAttribute(city.name)}"
93+
class="block w-full border-b px-3 py-2 text-left text-sm last:border-b-0 hover:bg-gray-100 dark:border-white/10 dark:hover:bg-white/5">
94+
<span class="font-medium">${escapeHtml(city.name)}</span>
95+
<span class="ml-1 text-xs text-gray-500 dark:text-slate-400">${escapeHtml(city.hint)}</span>
96+
</button>`
97+
)
98+
.join("")
99+
this.show()
100+
}
101+
102+
renderMessage(text) {
103+
this.resultsTarget.innerHTML = `<div class="px-3 py-2 text-center text-sm text-gray-500 dark:text-slate-400">${escapeHtml(text)}</div>`
104+
this.show()
105+
}
106+
107+
select(event) {
108+
const { id, name } = event.currentTarget.dataset
109+
this.startRequest()
110+
this.cityIdTarget.value = id
111+
this.inputTarget.value = name
112+
this.hide()
113+
}
114+
115+
show() {
116+
this.resultsTarget.classList.remove("hidden")
117+
}
118+
119+
hide() {
120+
this.resultsTarget.classList.add("hidden")
121+
}
122+
}
123+
124+
function escapeHtml(value) {
125+
const div = document.createElement("div")
126+
div.textContent = value == null ? "" : String(value)
127+
return div.innerHTML
128+
}
129+
130+
function escapeAttribute(value) {
131+
return escapeHtml(value).replace(/"/g, "&quot;")
132+
}

app/jobs/resolve_user_city_job.rb

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,8 @@ def perform(user_id, original_query)
1818

1919
return unless city
2020

21-
new_name = city.respond_to?(:asciiname) ? city.asciiname.presence || translit_str(city.name) : translit_str(city.name)
22-
23-
# map IANA -> Rails display name
24-
rails_zone = ActiveSupport::TimeZone.all.find { |z| z.tzinfo.name == city.timezone } rescue nil
25-
tz_to_set = rails_zone ? rails_zone.name : (ActiveSupport::TimeZone[city.timezone].present? ? city.timezone : nil)
21+
new_name = city.canonical_name
22+
tz_to_set = city.rails_timezone
2623

2724
# avoid clobbering if user changed city manually after save:
2825
expected_current = original_query =~ coords_regex ? original_query : translit_str(original_query)

app/models/city.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,20 @@ def self.country_codes_for(names)
1212
.group_by(&:first)
1313
.transform_values { |rows| rows.max_by { |(_, _, population)| population.to_i }[1] }
1414
end
15+
16+
# Каноническое имя города для профиля и матчинга с courts.city_name: берём
17+
# name, а не asciiname — в кортах города записаны с диакритикой («Båstad»,
18+
# «Acapulco de Juárez»), и asciiname с ними бы не совпал.
19+
def canonical_name
20+
name.presence || asciiname.presence
21+
end
22+
23+
# GeoNames отдаёт IANA-зону, а часть кода ждёт отображаемое имя Rails.
24+
# Возвращаем то, что Rails понимает, иначе — nil, чтобы не записать мусор.
25+
def rails_timezone
26+
zone = ActiveSupport::TimeZone.all.find { |z| z.tzinfo.name == timezone }
27+
return zone.name if zone
28+
29+
timezone if ActiveSupport::TimeZone[timezone.to_s].present?
30+
end
1531
end

app/models/user.rb

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class User < ApplicationRecord
2-
before_validation :normalize_email, :titleize_city_name, :set_default_notification_channel
2+
before_validation :normalize_email, :set_default_notification_channel
33
before_validation :set_default_registration_source, on: :create
44

55
has_one :player_statistic, dependent: :destroy
@@ -198,11 +198,6 @@ def normalize_email
198198
true # явно возвращаем true
199199
end
200200

201-
def titleize_city_name
202-
self.city_name = city_name.to_s.titleize.presence
203-
true # <-- FIX: явно возвращаем true, чтобы не прерывать save
204-
end
205-
206201
def set_default_registration_source
207202
self.registration_source = "email" if registration_source.blank?
208203
true # явно возвращаем true

app/views/users/_city_results.html.erb

Lines changed: 0 additions & 15 deletions
This file was deleted.

app/views/users/_profile_form.html.erb

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,34 @@
3232
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1"><%= t("users.profile.coach_hint") %></p>
3333
</div>
3434

35-
<div>
35+
<%# Город сохраняется только выбором из подсказок: hidden-поле несёт id из
36+
справочника, а видимый текст — лишь то, что человек читает. %>
37+
<div data-controller="city-picker"
38+
data-city-picker-url-value="<%= city_search_account_path %>"
39+
data-city-picker-searching-value="<%= t("users.profile.city_searching") %>"
40+
data-city-picker-no-matches-value="<%= t("users.profile.no_city_matches") %>"
41+
data-city-picker-error-value="<%= t("users.profile.city_search_error") %>">
3642
<%= f.label :city_name, t("users.profile.city"), class: "block text-sm font-medium text-gray-700 dark:text-slate-300" %>
37-
<%= f.text_field :city_name, value: (params[:city_query].presence || @user.city_name || @selected_city_name), placeholder: t("users.profile.city_placeholder"), class: "mt-1 block w-full rounded border px-3 py-2 dark:border-white/15 dark:bg-slate-700 dark:text-slate-100" %>
43+
<%= hidden_field_tag :selected_city_id, @selected_city_id, data: { city_picker_target: "cityId" } %>
44+
<div class="relative">
45+
<%# На фокусе только открываем подсказки: сбрасывать выбранный город
46+
нельзя — возврат в поле без правки текста молча отменял бы выбор. %>
47+
<%= f.text_field :city_name, value: @user.city_name,
48+
placeholder: t("users.profile.city_placeholder"),
49+
autocomplete: "off",
50+
data: { city_picker_target: "input", action: "input->city-picker#search focus->city-picker#reopen" },
51+
class: "mt-1 block w-full rounded border px-3 py-2 dark:border-white/15 dark:bg-slate-700 dark:text-slate-100" %>
52+
<div data-city-picker-target="results"
53+
class="hidden absolute z-10 mt-1 w-full overflow-hidden rounded border bg-white shadow dark:border-white/15 dark:bg-slate-700"></div>
54+
</div>
3855
<p class="text-sm text-gray-600 dark:text-slate-400 mt-2"><%= t("users.profile.city_hint") %></p>
56+
<% if @user.city_name.present? %>
57+
<%# Именно ссылка, а не button_to: поле города живёт внутри формы профиля,
58+
а форма в форме — невалидная разметка. %>
59+
<%= link_to t("users.profile.city_clear"), clear_city_account_path,
60+
data: { turbo_method: :post },
61+
class: "mt-2 inline-block text-sm text-indigo-600 hover:underline dark:text-indigo-400" %>
62+
<% end %>
3963
</div>
4064

4165
<div>

config/locales/web.en.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,9 +389,12 @@ en:
389389
coach_hint: Tick if you coach — this helps others find and hire coaches in the
390390
future.
391391
city: City
392-
city_placeholder: e.g. Moskva
393-
city_hint: Enter a place. The system will transliterate names and try to detect
394-
timezone automatically on save. Press "Save Changes".
392+
city_placeholder: e.g. Moscow
393+
city_hint: Start typing and pick a city from the list — only a picked city is
394+
saved. The timezone is filled in for you.
395+
city_searching: Searching…
396+
city_search_error: Could not load the city list.
397+
city_clear: Clear city
395398
about_me: About me
396399
about_me_placeholder: Tell others a bit about yourself…
397400
no_city_matches: No matches.

config/locales/web.es.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,11 @@ es:
393393
coach_hint: 'Márcalo si entrenas: ayuda a que otros encuentren y contraten entrenadores.'
394394
city: Ciudad
395395
city_placeholder: p. ej. Madrid
396-
city_hint: Escribe un lugar. Al guardar, el sistema transliterará el nombre
397-
e intentará detectar la zona horaria. Pulsa «Guardar cambios».
396+
city_hint: 'Empieza a escribir y elige una ciudad de la lista: solo se guarda
397+
la ciudad elegida. La zona horaria se rellena sola.'
398+
city_searching: Buscando…
399+
city_search_error: No se pudo cargar la lista de ciudades.
400+
city_clear: Quitar ciudad
398401
about_me: Sobre mí
399402
about_me_placeholder: Cuenta algo sobre ti…
400403
no_city_matches: Sin resultados.

config/locales/web.ru.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -394,8 +394,11 @@ ru:
394394
тренера.
395395
city: Город
396396
city_placeholder: например, Москва
397-
city_hint: Укажите место. При сохранении система транслитерирует название и
398-
попробует определить часовой пояс. Нажмите «Сохранить изменения».
397+
city_hint: Начните вводить название и выберите город из списка — сохраняется
398+
только выбранный вариант. Часовой пояс подставится сам.
399+
city_searching: Ищем…
400+
city_search_error: Не удалось загрузить список городов.
401+
city_clear: Убрать город
399402
about_me: О себе
400403
about_me_placeholder: Расскажите немного о себе…
401404
no_city_matches: Ничего не найдено.

0 commit comments

Comments
 (0)