Browse Source

refactor: improve birthplace parsing logic, add auto-detection throttling, and include integration tests for country-city state management

master
mortezaei 3 days ago
parent
commit
b609b1363b
  1. 55
      src/components/Componentes/question-birthplace.tsx
  2. 10
      src/components/Componentes/question-number.tsx
  3. 68
      src/components/Componentes/ui-config.test.tsx
  4. 22
      src/data/countries.ts

55
src/components/Componentes/question-birthplace.tsx

@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { getCountryList, resolveCountryName, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import { getCountryList, resolveCountryName, isKnownCountry, COUNTRIES_EN, COUNTRIES_FA } from "@/data/countries";
import type { QuestionField } from "@/lib/schema-adapter"; import type { QuestionField } from "@/lib/schema-adapter";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
@ -30,13 +30,15 @@ function parseValue(rawValue: unknown): { country: string; city: string } {
if (typeof rawValue === "object" && rawValue !== null) { if (typeof rawValue === "object" && rawValue !== null) {
const obj = rawValue as BirthplaceValue; const obj = rawValue as BirthplaceValue;
return { return {
country: typeof obj.country === "string" ? obj.country : "",
city: typeof obj.city === "string" ? obj.city : "",
country: typeof obj.country === "string" ? obj.country.trim() : "",
city: typeof obj.city === "string" ? obj.city.trim() : "",
}; };
} }
if (typeof rawValue === "string") { if (typeof rawValue === "string") {
let str = rawValue.trim(); let str = rawValue.trim();
if (!str) return { country: "", city: "" };
// Clean legacy country names with parentheses containing commas // Clean legacy country names with parentheses containing commas
str = str.replace( str = str.replace(
"United Kingdom (UK, England, Wales, Scotland, Northern Ireland)", "United Kingdom (UK, England, Wales, Scotland, Northern Ireland)",
@ -63,16 +65,33 @@ function parseValue(rawValue: unknown): { country: string; city: string } {
"ایالات متحده آمریکا (US)", "ایالات متحده آمریکا (US)",
); );
let parts: string[] = [];
if (str.includes(",")) { if (str.includes(",")) {
const parts = str.split(",").map((s) => s.trim());
return { city: parts[0] || "", country: parts[1] || "" };
parts = str.split(",").map((s) => s.trim());
} else if (str.includes("،")) {
parts = str.split("،").map((s) => s.trim());
} else if (str.includes(" - ")) {
parts = str.split(" - ").map((s) => s.trim());
} }
if (str.includes(" - ")) {
const parts = str.split(" - ").map((s) => s.trim());
return { country: parts[0] || "", city: parts[1] || "" };
if (parts.length >= 2) {
const part0 = parts[0] || "";
const part1 = parts.slice(1).join(", ").trim();
const isPart0Country = isKnownCountry(part0);
const isPart1Country = isKnownCountry(part1);
if (isPart0Country && !isPart1Country) {
return { country: part0, city: part1 };
} }
const isCountry = COUNTRIES_EN.includes(str) || COUNTRIES_FA.includes(str);
if (isCountry) {
if (isPart1Country && !isPart0Country) {
return { country: part1, city: part0 };
}
// Default standard order: City first, Country second
return { city: part0, country: part1 };
}
if (isKnownCountry(str)) {
return { country: str, city: "" }; return { country: str, city: "" };
} }
return { country: "", city: str }; return { country: "", city: str };
@ -120,6 +139,7 @@ export function QuestionBirthplace({
const [mode, setMode] = useState<"auto" | "manual">("auto"); const [mode, setMode] = useState<"auto" | "manual">("auto");
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [detectedLocation, setDetectedLocation] = useState(initialLoc); const [detectedLocation, setDetectedLocation] = useState(initialLoc);
const hasAutoDetectedRef = useRef(false);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@ -163,8 +183,12 @@ export function QuestionBirthplace({
}, [isOpen, closeSheet]); }, [isOpen, closeSheet]);
const updateAnswers = (country: string, city: string) => { const updateAnswers = (country: string, city: string) => {
const cleanCountry = country?.trim() || "";
const cleanCity = city?.trim() || "";
const formatted = const formatted =
city && country ? `${city}, ${country}` : city || country || null;
cleanCity && cleanCountry
? `${cleanCity}, ${cleanCountry}`
: cleanCity || cleanCountry || null;
setAnswerValue(question, formatted); setAnswerValue(question, formatted);
}; };
@ -176,8 +200,8 @@ export function QuestionBirthplace({
const cName = resolveCountryName(parsed.country, locale); const cName = resolveCountryName(parsed.country, locale);
if (cName || parsed.city) { if (cName || parsed.city) {
const loc = [parsed.city, cName].filter(Boolean).join(", "); const loc = [parsed.city, cName].filter(Boolean).join(", ");
setSelectedCountry(cName);
setCityInput(parsed.city);
if (cName) setSelectedCountry(cName);
if (parsed.city) setCityInput(parsed.city);
setDetectedLocation(loc); setDetectedLocation(loc);
setMode("auto"); setMode("auto");
return; return;
@ -217,7 +241,8 @@ export function QuestionBirthplace({
useEffect(() => { useEffect(() => {
if (isLoading) return; if (isLoading) return;
if (isResidence) {
if (isResidence && !hasAutoDetectedRef.current) {
hasAutoDetectedRef.current = true;
void detectLocation(); void detectLocation();
} }
}, [isResidence, isLoading, detectLocation]); }, [isResidence, isLoading, detectLocation]);
@ -248,7 +273,7 @@ export function QuestionBirthplace({
if (resolvedC && resolvedC !== selectedCountry) { if (resolvedC && resolvedC !== selectedCountry) {
setSelectedCountry(resolvedC); setSelectedCountry(resolvedC);
} }
if (updated.city.trim() !== cityInput.trim()) {
if (updated.city && updated.city.trim() !== cityInput.trim()) {
setCityInput(updated.city); setCityInput(updated.city);
} }
if (resolvedC || updated.city) { if (resolvedC || updated.city) {

10
src/components/Componentes/question-number.tsx

@ -6,6 +6,7 @@ import { useI18n } from "@/translations/provider";
import { useQuestionAnswers } from "./question-answer-storage"; import { useQuestionAnswers } from "./question-answer-storage";
import QuestionTitle from "./question-title"; import QuestionTitle from "./question-title";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { isKnownCountry } from "@/data/countries";
type QuestionNumberProps = { type QuestionNumberProps = {
question: QuestionField; question: QuestionField;
@ -466,8 +467,13 @@ function getCountryFromStorage(): string {
); );
const value = field?.value; const value = field?.value;
if (typeof value === "string") { if (typeof value === "string") {
const parts = value.split(",");
return parts[0]?.trim() || "";
const parts = value.split(",").map((p) => p.trim());
if (parts.length >= 2) {
if (isKnownCountry(parts[1])) return parts[1];
if (isKnownCountry(parts[0])) return parts[0];
return parts[1];
}
return parts[0] || "";
} }
} catch { } catch {
// Ignore // Ignore

68
src/components/Componentes/ui-config.test.tsx

@ -280,4 +280,72 @@ describe("UI Config based behavior", () => {
expect(document.body.classList.contains("dropdown-open")).toBe(false); expect(document.body.classList.contains("dropdown-open")).toBe(false);
}); });
}); });
it("should preserve country when typing city after selecting country", async () => {
const qBirthplace = {
id: "birthplace_select_first",
title: "محل تولد",
type: "birthplace",
order: 1,
required: true,
isVisible: true,
description: "",
tooltip: "",
extras: { placeHolder: "شهر، منطقه یا محله" },
options: [],
ui_config: {},
} as any;
render(
<QueryClientProvider client={queryClient}>
<QuestionAnswersProvider slug="test-select-first" questions={[qBirthplace]}>
<QuestionBirthplace question={qBirthplace} />
</QuestionAnswersProvider>
</QueryClientProvider>,
);
// 1. Open country dropdown first
const countryButton = screen.getByText("انتخاب کشور");
fireEvent.click(countryButton);
// 2. Select Germany (آلمان)
const germanyOption = screen.getByRole("button", { name: "آلمان" });
fireEvent.click(germanyOption);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
}); });
// Country button should show Germany
expect(screen.getByText("آلمان")).toBeDefined();
// 3. Type city in the input field
const cityInput = screen.getByPlaceholderText("شهر، منطقه یا محله");
fireEvent.change(cityInput, { target: { value: "برلین" } });
// Country button MUST still show Germany and city must show Berlin
expect(screen.getByText("آلمان")).toBeDefined();
expect((cityInput as HTMLInputElement).value).toBe("برلین");
// 4. Type more characters into city field
fireEvent.change(cityInput, { target: { value: "برلین مرکزی" } });
expect(screen.getByText("آلمان")).toBeDefined();
expect((cityInput as HTMLInputElement).value).toBe("برلین مرکزی");
// 5. Change country to France (فرانسه)
const updatedCountryButton = screen.getByText("آلمان");
fireEvent.click(updatedCountryButton);
const franceOption = screen.getByRole("button", { name: "فرانسه" });
fireEvent.click(franceOption);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
expect(screen.getByText("فرانسه")).toBeDefined();
expect((cityInput as HTMLInputElement).value).toBe("برلین مرکزی");
});
});

22
src/data/countries.ts

@ -257,7 +257,7 @@ export const COUNTRIES_FA = [
"فنلاند", "فنلاند",
"فرانسه", "فرانسه",
"گابن", "گابن",
"Gambia",
"گامبیا",
"گرجستان", "گرجستان",
"آلمان", "آلمان",
"غنا", "غنا",
@ -448,3 +448,23 @@ export function resolveCountryName(
return trimmed; return trimmed;
} }
export function isKnownCountry(countryOrCode: string | undefined | null): boolean {
if (!countryOrCode) return false;
const trimmed = countryOrCode.trim();
if (!trimmed) return false;
if (trimmed.length === 2 && /^[a-zA-Z]{2}$/.test(trimmed)) {
return true;
}
const lower = trimmed.toLowerCase();
if (COUNTRIES_EN.some((c) => c.toLowerCase() === lower)) {
return true;
}
if (COUNTRIES_FA.some((c) => c.toLowerCase() === lower)) {
return true;
}
return false;
}
Loading…
Cancel
Save