feat(mera/estimate): предзаполнять этажность, год и тип дома из справочника (UI) (#3259)
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m5s
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / build-backend (push) Has been cancelled
Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m5s
Deploy Trade-In / test (push) Successful in 4m9s
Deploy Trade-In / build-backend (push) Has been cancelled
This commit is contained in:
parent
1d45ac0747
commit
79fc8c45ff
4 changed files with 343 additions and 6 deletions
|
|
@ -35,7 +35,7 @@ import {
|
|||
REPAIR_RU,
|
||||
type MapMarker,
|
||||
} from "./mappers";
|
||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||
import { fetchHouseFacts, useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||
import {
|
||||
createMap,
|
||||
loadLeaflet,
|
||||
|
|
@ -49,6 +49,7 @@ import {
|
|||
UNCONFIRMED_CITY_LABEL,
|
||||
detectCityInText,
|
||||
} from "@/lib/city-registry";
|
||||
import { asHouseType } from "@/types/trade-in";
|
||||
import type {
|
||||
GeocodeSuggestion,
|
||||
HouseType,
|
||||
|
|
@ -401,6 +402,15 @@ const optHint: CSSProperties = {
|
|||
color: tokens.muted4,
|
||||
};
|
||||
|
||||
// #3257 — сдержанная подсказка «из справочника» рядом с полями, которые
|
||||
// автопредзаполнились house-facts по выбранному адресу (см. loadHouseFacts).
|
||||
// Тот же slot, что и optHint ("если знаете"/"опц."), просто акцентный цвет —
|
||||
// не новая сущность, а честное «откуда это число» без паники/ошибки.
|
||||
const prefillHint: CSSProperties = {
|
||||
...optHint,
|
||||
color: tokens.accent,
|
||||
};
|
||||
|
||||
const fieldRow: CSSProperties = {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
|
|
@ -769,6 +779,90 @@ export default function ParamsPanel({
|
|||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// House-facts prefill (#3257) — этажность/год/тип дома из справочника домов
|
||||
// сразу после выбора адреса из автокомплита. `housePrefill` помечает, какие
|
||||
// из этих трёх полей несут именно автоподставленное значение (не ручной
|
||||
// ввод) — используется и для UI-подсказки «из справочника», и как гейт для
|
||||
// перезаписи при повторном выборе адреса (ручная правка снимает пометку и
|
||||
// больше никогда не перезаписывается новым ответом).
|
||||
const [housePrefill, setHousePrefill] = useState<{
|
||||
totalFloors: boolean;
|
||||
year: boolean;
|
||||
houseType: boolean;
|
||||
}>({ totalFloors: false, year: false, houseType: false });
|
||||
// "Свежие" значения полей/пометок, читаемые внутри async-колбэка
|
||||
// fetchHouseFacts().then() ниже — тот может резолвиться через несколько
|
||||
// рендеров (сеть), плоские closure-переменные к тому моменту устареют.
|
||||
const totalFloorsRef = useRef(totalFloors);
|
||||
totalFloorsRef.current = totalFloors;
|
||||
const yearRef = useRef(year);
|
||||
yearRef.current = year;
|
||||
const houseTypeRef = useRef(houseType);
|
||||
houseTypeRef.current = houseType;
|
||||
const housePrefillRef = useRef(housePrefill);
|
||||
housePrefillRef.current = housePrefill;
|
||||
// Гонка запросов: второй выбранный адрес не должен применить ответ на
|
||||
// первый, устаревший. `houseFactsGenRef` — счётчик поколений (инкремент на
|
||||
// каждый вызов, ответ применяется только если поколение всё ещё текущее);
|
||||
// AbortController в паре — отменяет фактический fetch устаревшего запроса.
|
||||
const houseFactsGenRef = useRef(0);
|
||||
const houseFactsAbortRef = useRef<AbortController | null>(null);
|
||||
// Не звать setState после размонтирования панели (тот же класс бага, что и
|
||||
// #3226 debounceRef выше) — при unmount отменяем последний летящий запрос;
|
||||
// сам .then()/.catch() всё равно гейтится houseFactsGenRef на всякий случай.
|
||||
useEffect(
|
||||
() => () => {
|
||||
houseFactsAbortRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadHouseFacts = (
|
||||
lat: number,
|
||||
lon: number,
|
||||
fiasId: string | null,
|
||||
) => {
|
||||
houseFactsAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
houseFactsAbortRef.current = controller;
|
||||
const gen = ++houseFactsGenRef.current;
|
||||
fetchHouseFacts(lat, lon, fiasId, controller.signal)
|
||||
.then((data) => {
|
||||
if (gen !== houseFactsGenRef.current) return; // устаревший ответ
|
||||
if (!data.found) return; // «дома нет в справочнике» — тихий no-op
|
||||
if (
|
||||
data.total_floors != null &&
|
||||
(totalFloorsRef.current.trim() === "" ||
|
||||
housePrefillRef.current.totalFloors)
|
||||
) {
|
||||
setTotalFloors(String(data.total_floors));
|
||||
setHousePrefill((prev) => ({ ...prev, totalFloors: true }));
|
||||
}
|
||||
if (
|
||||
data.year_built != null &&
|
||||
(yearRef.current.trim() === "" || housePrefillRef.current.year)
|
||||
) {
|
||||
setYear(String(data.year_built));
|
||||
setHousePrefill((prev) => ({ ...prev, year: true }));
|
||||
}
|
||||
const ht = asHouseType(data.house_type);
|
||||
if (
|
||||
ht &&
|
||||
(houseTypeRef.current === "Не указано" ||
|
||||
housePrefillRef.current.houseType)
|
||||
) {
|
||||
setHouseType(HOUSE_TYPE_RU[ht]);
|
||||
setHousePrefill((prev) => ({ ...prev, houseType: true }));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// Сбой сети / отменённый устаревший запрос — предзаполнение это
|
||||
// удобство, а не функциональность формы: никаких алертов/красных
|
||||
// сообщений, максимум debug-лог.
|
||||
console.debug("house-facts prefill failed", err);
|
||||
});
|
||||
};
|
||||
// БАЛКОН radiogroup focus targets (roving tabindex).
|
||||
const balNoRef = useRef<HTMLButtonElement>(null);
|
||||
const balYesRef = useRef<HTMLButtonElement>(null);
|
||||
|
|
@ -813,6 +907,10 @@ export default function ParamsPanel({
|
|||
setFias(s.fias_id ?? null);
|
||||
setSuggestOpen(false);
|
||||
setAddressActive(-1);
|
||||
// #3257 — предзаполняем этажность/год/тип дома из справочника домов для
|
||||
// только что выбранного адреса (см. loadHouseFacts выше: race-safe через
|
||||
// поколение + AbortController, тихий no-op на found=false/сеть).
|
||||
loadHouseFacts(s.lat, s.lon, s.fias_id ?? null);
|
||||
// Autodetect (#2576): выбор реального гео-резолвленного адреса — сильный
|
||||
// сигнал подтверждения (даже сильнее, чем просто набранный текст).
|
||||
// full_address обычно содержит город независимо от того, был ли отправлен
|
||||
|
|
@ -1878,7 +1976,9 @@ export default function ParamsPanel({
|
|||
<label htmlFor="pp-total-floors" style={hintLabel}>
|
||||
ВСЕГО ЭТАЖЕЙ
|
||||
</label>
|
||||
<span style={optHint}>если знаете</span>
|
||||
<span style={housePrefill.totalFloors ? prefillHint : optHint}>
|
||||
{housePrefill.totalFloors ? "из справочника" : "если знаете"}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="pp-total-floors"
|
||||
|
|
@ -1892,6 +1992,10 @@ export default function ParamsPanel({
|
|||
}
|
||||
onChange={(e) => {
|
||||
setTotalFloors(e.target.value.replace(/\D/g, ""));
|
||||
// Ручная правка — больше не «из справочника».
|
||||
setHousePrefill((prev) =>
|
||||
prev.totalFloors ? { ...prev, totalFloors: false } : prev,
|
||||
);
|
||||
if (fieldErrors.floor || fieldErrors.totalFloors)
|
||||
setFieldErrors((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -1938,7 +2042,9 @@ export default function ParamsPanel({
|
|||
<label htmlFor="pp-year" style={hintLabel}>
|
||||
ГОД ПОСТРОЙКИ
|
||||
</label>
|
||||
<span style={optHint}>опц.</span>
|
||||
<span style={housePrefill.year ? prefillHint : optHint}>
|
||||
{housePrefill.year ? "из справочника" : "опц."}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="pp-year"
|
||||
|
|
@ -1946,7 +2052,13 @@ export default function ParamsPanel({
|
|||
type="text"
|
||||
inputMode="numeric"
|
||||
value={year}
|
||||
onChange={(e) => setYear(e.target.value.replace(/\D/g, ""))}
|
||||
onChange={(e) => {
|
||||
setYear(e.target.value.replace(/\D/g, ""));
|
||||
// Ручная правка — больше не «из справочника».
|
||||
setHousePrefill((prev) =>
|
||||
prev.year ? { ...prev, year: false } : prev,
|
||||
);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
|
|
@ -1957,14 +2069,23 @@ export default function ParamsPanel({
|
|||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<span style={hintLabel}>ТИП ДОМА</span>
|
||||
<span style={optHint}>опц.</span>
|
||||
<span style={housePrefill.houseType ? prefillHint : optHint}>
|
||||
{housePrefill.houseType ? "из справочника" : "опц."}
|
||||
</span>
|
||||
</div>
|
||||
<Dd
|
||||
open={openDd === "houseType"}
|
||||
onToggle={() => toggle("houseType")}
|
||||
value={houseType}
|
||||
options={dropdownOptions.houseType}
|
||||
onSelect={pick(setHouseType)}
|
||||
onSelect={(v) => {
|
||||
setHouseType(v);
|
||||
setOpenDd(null);
|
||||
// Ручной выбор — больше не «из справочника».
|
||||
setHousePrefill((prev) =>
|
||||
prev.houseType ? { ...prev, houseType: false } : prev,
|
||||
);
|
||||
}}
|
||||
mono={false}
|
||||
triggerFontSize={13}
|
||||
optionFontSize={12}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
// #3257: предзаполнение этажности/года/типа дома из справочника `houses` после
|
||||
// выбора адреса из автокомплита. Здесь закреплены три свойства, которые ломаются
|
||||
// молча и потому опаснее всего:
|
||||
// 1) подставляем в ПУСТОЕ поле,
|
||||
// 2) НЕ затираем то, что человек ввёл руками,
|
||||
// 3) ответ на УСТАРЕВШИЙ адрес не применяется (пользователь успел выбрать другой).
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ParamsPanel from "../ParamsPanel";
|
||||
|
||||
const { fetchHouseFacts, useGeocodeSuggest } = vi.hoisted(() => ({
|
||||
fetchHouseFacts: vi.fn(),
|
||||
useGeocodeSuggest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/trade-in-api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/trade-in-api")>();
|
||||
return { ...actual, fetchHouseFacts, useGeocodeSuggest };
|
||||
});
|
||||
|
||||
const SUGGESTION = {
|
||||
label: "пр-кт Ленина, 5",
|
||||
full_address: "Екатеринбург, пр-кт Ленина, 5",
|
||||
lat: 56.838,
|
||||
lon: 60.595,
|
||||
kind: "house",
|
||||
fias_id: "abc-123",
|
||||
};
|
||||
|
||||
function renderPanel() {
|
||||
// useGeocodeSuggest отдаёт УЖЕ выбранный массив (select: r => r.items),
|
||||
// а не сырой ответ — мок должен повторять именно эту форму.
|
||||
useGeocodeSuggest.mockReturnValue({
|
||||
data: [SUGGESTION],
|
||||
isFetching: false,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ParamsPanel onSubmit={vi.fn()} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Печатает в адрес и кликает подсказку → срабатывает pickSuggestion.
|
||||
*
|
||||
* Дропдаун открыт только когда `addressQuery` уже проставлен, а он идёт через
|
||||
* debounce 200 мс — поэтому таймеры фейковые и время прокручивается вручную.
|
||||
*/
|
||||
async function pickAddress(container: HTMLElement) {
|
||||
const address = container.querySelector("#pp-address") as HTMLInputElement;
|
||||
fireEvent.change(address, { target: { value: "Екатеринбург, Ленина 5" } });
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
// Выбор висит на onMouseDown (не onClick): preventDefault там срабатывает
|
||||
// раньше onBlur инпута, иначе список закрылся бы до выбора.
|
||||
fireEvent.mouseDown(screen.getAllByText(SUGGESTION.label)[0]);
|
||||
await act(async () => {});
|
||||
}
|
||||
|
||||
/** Прокрутить микротаски: промисы моков резолвятся вне таймеров. */
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
const totalFloorsInput = (c: HTMLElement) =>
|
||||
c.querySelector("#pp-total-floors") as HTMLInputElement;
|
||||
|
||||
describe("ParamsPanel — предзаполнение из справочника (#3257)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("подставляет этажность в пустое поле и помечает её как справочную", async () => {
|
||||
fetchHouseFacts.mockResolvedValue({
|
||||
found: true,
|
||||
total_floors: 9,
|
||||
year_built: 1967,
|
||||
house_type: null,
|
||||
source: "houses",
|
||||
});
|
||||
const { container } = renderPanel();
|
||||
await pickAddress(container);
|
||||
await flush();
|
||||
|
||||
expect(totalFloorsInput(container).value).toBe("9");
|
||||
expect(screen.getAllByText("из справочника").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("НЕ затирает этажность, введённую человеком", async () => {
|
||||
fetchHouseFacts.mockResolvedValue({
|
||||
found: true,
|
||||
total_floors: 9,
|
||||
year_built: null,
|
||||
house_type: null,
|
||||
source: "houses",
|
||||
});
|
||||
const { container } = renderPanel();
|
||||
fireEvent.change(totalFloorsInput(container), { target: { value: "16" } });
|
||||
await pickAddress(container);
|
||||
await flush();
|
||||
|
||||
expect(fetchHouseFacts).toHaveBeenCalled();
|
||||
expect(totalFloorsInput(container).value).toBe("16");
|
||||
});
|
||||
|
||||
it("found=false — поле остаётся пустым, без ошибок", async () => {
|
||||
fetchHouseFacts.mockResolvedValue({
|
||||
found: false,
|
||||
total_floors: null,
|
||||
year_built: null,
|
||||
house_type: null,
|
||||
source: null,
|
||||
});
|
||||
const { container } = renderPanel();
|
||||
await pickAddress(container);
|
||||
await flush();
|
||||
|
||||
expect(fetchHouseFacts).toHaveBeenCalled();
|
||||
expect(totalFloorsInput(container).value).toBe("");
|
||||
});
|
||||
|
||||
it("ответ на устаревший адрес игнорируется (гонка двух выборов)", async () => {
|
||||
// Первый запрос отвечает ПОЗЖЕ второго — если бы гейта поколений не было,
|
||||
// в поле осталось бы 99 от уже неактуального адреса.
|
||||
let resolveFirst: ((v: unknown) => void) | undefined;
|
||||
fetchHouseFacts
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((res) => {
|
||||
resolveFirst = res;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
found: true,
|
||||
total_floors: 12,
|
||||
year_built: null,
|
||||
house_type: null,
|
||||
source: "houses",
|
||||
});
|
||||
|
||||
const { container } = renderPanel();
|
||||
await pickAddress(container);
|
||||
await pickAddress(container);
|
||||
await flush();
|
||||
|
||||
expect(fetchHouseFacts).toHaveBeenCalledTimes(2);
|
||||
expect(totalFloorsInput(container).value).toBe("12");
|
||||
|
||||
resolveFirst?.({
|
||||
found: true,
|
||||
total_floors: 99,
|
||||
year_built: null,
|
||||
house_type: null,
|
||||
source: "houses",
|
||||
});
|
||||
await flush();
|
||||
// Устаревший ответ пришёл вторым и не должен ничего переписать.
|
||||
expect(totalFloorsInput(container).value).toBe("12");
|
||||
});
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
GeocodeSuggestion,
|
||||
GeocodeSuggestResponse,
|
||||
HouseAnalyticsResponse,
|
||||
HouseFactsResponse,
|
||||
HouseInfoForEstimate,
|
||||
IMVBenchmarkResponse,
|
||||
LocationIndexResponse,
|
||||
|
|
@ -293,3 +294,29 @@ export function useGeocodeSuggest(
|
|||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/geocode/house-facts?lat=&lon=&fias_id=
|
||||
* Справочник домов — этажность/год постройки/тип дома для предзаполнения
|
||||
* ParamsPanel сразу после выбора адреса из автокомплита (issue #3257).
|
||||
* `found:false` — дома нет в справочнике, честный ответ, не ошибка.
|
||||
*
|
||||
* Плоская функция (не useQuery/useMutation): вызывается императивно из
|
||||
* pickSuggestion (событие выбора подсказки), которая сама управляет
|
||||
* отменой устаревшего запроса через `signal` (race guard) — TanStack
|
||||
* mutationFn в v5 не прокидывает AbortSignal, а декларативный useQuery не
|
||||
* подходит для разового fire-on-event вызова.
|
||||
*/
|
||||
export function fetchHouseFacts(
|
||||
lat: number,
|
||||
lon: number,
|
||||
fiasId: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HouseFactsResponse> {
|
||||
const params = new URLSearchParams({ lat: String(lat), lon: String(lon) });
|
||||
if (fiasId) params.set("fias_id", fiasId);
|
||||
return apiFetch<HouseFactsResponse>(
|
||||
`${GEOCODE_BASE}/house-facts?${params.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -507,6 +507,18 @@ export interface GeocodeSuggestResponse {
|
|||
items: GeocodeSuggestion[];
|
||||
}
|
||||
|
||||
// ── House facts (endpoint: GET /geocode/house-facts?lat=&lon=&fias_id=) ──
|
||||
// Справочник домов (houses) — предзаполнение параметров квартиры (этажность/
|
||||
// год/тип дома) сразу после выбора адреса из автокомплита (issue #3257).
|
||||
// found=false — нормальный ответ «дома нет в справочнике», не ошибка.
|
||||
export interface HouseFactsResponse {
|
||||
found: boolean;
|
||||
total_floors: number | null;
|
||||
year_built: number | null;
|
||||
house_type: string | null;
|
||||
source: "houses" | null;
|
||||
}
|
||||
|
||||
// ── Trade-in lead capture (endpoint: POST /trade-in/lead) ──
|
||||
// Issue #2377 (sub-issue родителя #1971) — заменяет mailto-стаб в HeroTransparency
|
||||
// на реальную заявку. Зеркалит TradeInLeadInput из
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue