@traky-app/ui 1.6.3 → 1.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -1
- package/registry/traky/json/city-select.json +19 -0
- package/registry/traky/json/country-select.json +24 -0
- package/registry/traky/json/geo-search-select.json +24 -0
- package/registry/traky/json/geo-selectors.json +21 -0
- package/registry/traky/json/google-maps-address-picker.json +22 -0
- package/registry/traky/json/lib-geo-city-codes.json +14 -0
- package/registry/traky/json/lib-geo-countries.json +14 -0
- package/registry/traky/json/lib-geo-csc.json +17 -0
- package/registry/traky/json/lib-geo-locations.json +14 -0
- package/registry/traky/json/lib-geo-overrides.json +14 -0
- package/registry/traky/json/lib-geo-types.json +14 -0
- package/registry/traky/json/lib-google-maps-address.json +14 -0
- package/registry/traky/json/lib-phone-countries.json +14 -0
- package/registry/traky/json/lib-phone-e164.json +14 -0
- package/registry/traky/json/lib-utils.json +1 -1
- package/registry/traky/json/office-address-form-dialog.json +17 -0
- package/registry/traky/json/office-address-form.json +31 -0
- package/registry/traky/json/phone-number-input.json +25 -0
- package/registry/traky/json/state-select.json +19 -0
- package/registry/traky/lib/geo-city-codes.ts +234 -0
- package/registry/traky/lib/geo-countries.ts +75 -0
- package/registry/traky/lib/geo-csc.ts +82 -0
- package/registry/traky/lib/geo-locations.ts +171 -0
- package/registry/traky/lib/geo-overrides.ts +375 -0
- package/registry/traky/lib/geo-types.ts +34 -0
- package/registry/traky/lib/google-maps-address.ts +100 -0
- package/registry/traky/lib/phone-countries.ts +380 -0
- package/registry/traky/lib/phone-e164.ts +66 -0
- package/registry/traky/lib/utils.ts +24 -0
- package/registry/traky/registry.json +180 -0
- package/registry/traky/ui/city-select.tsx +113 -0
- package/registry/traky/ui/country-select.tsx +233 -0
- package/registry/traky/ui/geo-search-select.tsx +136 -0
- package/registry/traky/ui/geo-selectors.tsx +162 -0
- package/registry/traky/ui/google-maps-address-picker.tsx +422 -0
- package/registry/traky/ui/office-address-form-dialog.tsx +16 -0
- package/registry/traky/ui/office-address-form.tsx +868 -0
- package/registry/traky/ui/phone-number-input.tsx +253 -0
- package/registry/traky/ui/state-select.tsx +107 -0
- package/src/lib/geo-city-codes.ts +234 -0
- package/src/lib/geo-countries.ts +75 -0
- package/src/lib/geo-csc.ts +82 -0
- package/src/lib/geo-locations.ts +171 -0
- package/src/lib/geo-overrides.ts +375 -0
- package/src/lib/geo-types.ts +34 -0
- package/src/lib/google-maps-address.ts +100 -0
- package/src/lib/phone-countries.ts +380 -0
- package/src/lib/phone-e164.ts +66 -0
- package/src/test/setup.ts +31 -0
- package/src/ui/__tests__/geo-selects.test.tsx +504 -0
- package/src/ui/city-select.tsx +113 -0
- package/src/ui/country-select.tsx +233 -0
- package/src/ui/geo-search-select.tsx +136 -0
- package/src/ui/geo-selectors.tsx +162 -0
- package/src/ui/google-maps-address-picker.tsx +422 -0
- package/src/ui/office-address-form-dialog.tsx +16 -0
- package/src/ui/office-address-form.tsx +877 -0
- package/src/ui/phone-number-input.tsx +253 -0
- package/src/ui/state-select.tsx +107 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { getGeoCountries } from "../../lib/geo-csc";
|
|
6
|
+
import { fetchCities, fetchStates } from "../../lib/geo-locations";
|
|
7
|
+
import { CitySelect } from "../city-select";
|
|
8
|
+
import { CountrySelect } from "../country-select";
|
|
9
|
+
import { StateSelect } from "../state-select";
|
|
10
|
+
|
|
11
|
+
vi.mock("../../lib/geo-csc", () => ({
|
|
12
|
+
getGeoCountries: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock("../../lib/geo-locations", () => ({
|
|
16
|
+
fetchStates: vi.fn(),
|
|
17
|
+
fetchCities: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const countries = [
|
|
21
|
+
{ code: "EC", name: "Ecuador" },
|
|
22
|
+
{ code: "CO", name: "Colombia" },
|
|
23
|
+
{ code: "PE", name: "Peru" },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.mocked(getGeoCountries).mockResolvedValue(countries);
|
|
28
|
+
vi.mocked(fetchStates).mockImplementation(async (countryCode: string) => {
|
|
29
|
+
if (countryCode === "EC") {
|
|
30
|
+
return [
|
|
31
|
+
{ code: "P", name: "Pichincha" },
|
|
32
|
+
{ code: "G", name: "Guayas" },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
if (countryCode === "CO") {
|
|
36
|
+
return [{ code: "ANT", name: "Antioquia" }];
|
|
37
|
+
}
|
|
38
|
+
return [];
|
|
39
|
+
});
|
|
40
|
+
vi.mocked(fetchCities).mockImplementation(
|
|
41
|
+
async (countryCode: string, stateCode: string) => {
|
|
42
|
+
if (countryCode === "EC" && stateCode === "P") {
|
|
43
|
+
return [
|
|
44
|
+
{ code: "UIO", name: "Quito", stateCode: "P", stateName: "Pichincha" },
|
|
45
|
+
{ code: "CAY", name: "Cayambe", stateCode: "P" },
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
if (countryCode === "EC" && stateCode === "G") {
|
|
49
|
+
return [{ code: "GYE", name: "Guayaquil", stateCode: "G" }];
|
|
50
|
+
}
|
|
51
|
+
return [];
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
function trigger() {
|
|
57
|
+
return screen.getByRole("combobox");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function expectTrigger(text: string) {
|
|
61
|
+
await waitFor(() => {
|
|
62
|
+
expect(trigger()).toHaveTextContent(text);
|
|
63
|
+
});
|
|
64
|
+
return trigger();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function pickOption(name: RegExp | string) {
|
|
68
|
+
const user = userEvent.setup();
|
|
69
|
+
await user.click(trigger());
|
|
70
|
+
const option = await screen.findByRole("option", { name });
|
|
71
|
+
await user.click(option);
|
|
72
|
+
return user;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("CountrySelect", () => {
|
|
76
|
+
it("muestra el placeholder y respeta id, className, fileName y disabled", () => {
|
|
77
|
+
const { rerender } = render(
|
|
78
|
+
<CountrySelect id="pais" className="w-64" fileName="Origen" />,
|
|
79
|
+
);
|
|
80
|
+
const trigger = screen.getByRole("combobox");
|
|
81
|
+
expect(trigger).toHaveAttribute("id", "pais");
|
|
82
|
+
expect(trigger).toHaveClass("w-64");
|
|
83
|
+
expect(trigger).toHaveTextContent("Origen");
|
|
84
|
+
expect(trigger).toBeEnabled();
|
|
85
|
+
|
|
86
|
+
rerender(<CountrySelect disabled />);
|
|
87
|
+
expect(screen.getByRole("combobox")).toBeDisabled();
|
|
88
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("País");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("usa el código en sm y md, y el nombre largo en lg", () => {
|
|
92
|
+
const { rerender } = render(<CountrySelect value="EC" size="sm" />);
|
|
93
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("EC");
|
|
94
|
+
|
|
95
|
+
rerender(<CountrySelect value="EC" size="md" />);
|
|
96
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("EC");
|
|
97
|
+
|
|
98
|
+
rerender(<CountrySelect value="EC" size="lg" />);
|
|
99
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Ecuador");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("value controla la selección y defaultValue no la pisa", () => {
|
|
103
|
+
const { rerender } = render(
|
|
104
|
+
<CountrySelect size="lg" value="ec" defaultValue="CO" />,
|
|
105
|
+
);
|
|
106
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Ecuador");
|
|
107
|
+
|
|
108
|
+
rerender(<CountrySelect size="lg" value="" defaultValue="CO" />);
|
|
109
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("País");
|
|
110
|
+
|
|
111
|
+
rerender(<CountrySelect size="lg" value="CO" />);
|
|
112
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Colombia");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("defaultValue inicia la selección y no cambia si la prop cambia después", () => {
|
|
116
|
+
const onChange = vi.fn();
|
|
117
|
+
const { rerender } = render(
|
|
118
|
+
<CountrySelect size="lg" defaultValue="ec" onChange={onChange} />,
|
|
119
|
+
);
|
|
120
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Ecuador");
|
|
121
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
122
|
+
|
|
123
|
+
rerender(<CountrySelect size="lg" defaultValue="CO" onChange={onChange} />);
|
|
124
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Ecuador");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("en modo libre onChange recibe el país y el trigger se queda con la elección", async () => {
|
|
128
|
+
const onChange = vi.fn();
|
|
129
|
+
render(<CountrySelect size="lg" onChange={onChange} />);
|
|
130
|
+
|
|
131
|
+
await pickOption(/Ecuador/);
|
|
132
|
+
|
|
133
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
135
|
+
expect.objectContaining({ code: "EC", short: "EC", long: "Ecuador" }),
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Ecuador");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("en modo controlado onChange no cambia el texto hasta que value cambie", async () => {
|
|
141
|
+
const onChange = vi.fn();
|
|
142
|
+
render(<CountrySelect size="lg" value="EC" onChange={onChange} />);
|
|
143
|
+
|
|
144
|
+
await pickOption(/Colombia/);
|
|
145
|
+
|
|
146
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
147
|
+
expect.objectContaining({ code: "CO", long: "Colombia" }),
|
|
148
|
+
);
|
|
149
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Ecuador");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("allowClear llama onClear y vacía solo el modo libre", async () => {
|
|
153
|
+
const onChange = vi.fn();
|
|
154
|
+
const onClear = vi.fn();
|
|
155
|
+
const { rerender } = render(
|
|
156
|
+
<CountrySelect
|
|
157
|
+
size="lg"
|
|
158
|
+
defaultValue="EC"
|
|
159
|
+
allowClear
|
|
160
|
+
clearLabel="Ninguno"
|
|
161
|
+
onChange={onChange}
|
|
162
|
+
onClear={onClear}
|
|
163
|
+
/>,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const user = userEvent.setup();
|
|
167
|
+
await user.click(screen.getByRole("combobox"));
|
|
168
|
+
await user.click(await screen.findByRole("option", { name: "Ninguno" }));
|
|
169
|
+
|
|
170
|
+
expect(onClear).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
172
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("País");
|
|
173
|
+
|
|
174
|
+
rerender(
|
|
175
|
+
<CountrySelect
|
|
176
|
+
size="lg"
|
|
177
|
+
value="EC"
|
|
178
|
+
allowClear
|
|
179
|
+
clearLabel="Ninguno"
|
|
180
|
+
onClear={onClear}
|
|
181
|
+
/>,
|
|
182
|
+
);
|
|
183
|
+
await user.click(screen.getByRole("combobox"));
|
|
184
|
+
await user.click(await screen.findByRole("option", { name: "Ninguno" }));
|
|
185
|
+
expect(onClear).toHaveBeenCalledTimes(2);
|
|
186
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Ecuador");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("allowedCodes filtra el listado y searchPlaceholder llega al buscador", async () => {
|
|
190
|
+
const user = userEvent.setup();
|
|
191
|
+
render(
|
|
192
|
+
<CountrySelect
|
|
193
|
+
allowedCodes={["CO", "ec"]}
|
|
194
|
+
searchPlaceholder="Busca el país"
|
|
195
|
+
/>,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await user.click(screen.getByRole("combobox"));
|
|
199
|
+
expect(screen.getByPlaceholderText("Busca el país")).toBeInTheDocument();
|
|
200
|
+
expect(await screen.findByRole("option", { name: /Colombia/ })).toBeInTheDocument();
|
|
201
|
+
expect(screen.getByRole("option", { name: /Ecuador/ })).toBeInTheDocument();
|
|
202
|
+
expect(screen.queryByRole("option", { name: /Perú/ })).not.toBeInTheDocument();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("muestra la carga y el vacío del catálogo", async () => {
|
|
206
|
+
let resolveCountries: (value: typeof countries) => void = () => {};
|
|
207
|
+
vi.mocked(getGeoCountries).mockReturnValue(
|
|
208
|
+
new Promise((resolve) => {
|
|
209
|
+
resolveCountries = resolve;
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
const user = userEvent.setup();
|
|
213
|
+
render(<CountrySelect />);
|
|
214
|
+
|
|
215
|
+
await user.click(screen.getByRole("combobox"));
|
|
216
|
+
expect(await screen.findByText("Cargando países…")).toBeInTheDocument();
|
|
217
|
+
|
|
218
|
+
resolveCountries([]);
|
|
219
|
+
expect(await screen.findByText("No se encontró el país.")).toBeInTheDocument();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("StateSelect", () => {
|
|
224
|
+
it("queda inactivo sin país y no pide provincias", () => {
|
|
225
|
+
render(<StateSelect id="provincia" className="w-40" />);
|
|
226
|
+
const trigger = screen.getByRole("combobox");
|
|
227
|
+
expect(trigger).toBeDisabled();
|
|
228
|
+
expect(trigger).toHaveAttribute("id", "provincia");
|
|
229
|
+
expect(trigger).toHaveClass("w-40");
|
|
230
|
+
expect(trigger).toHaveTextContent("Indica un país");
|
|
231
|
+
expect(fetchStates).not.toHaveBeenCalled();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("normaliza el país, usa placeholder y muestra el código", async () => {
|
|
235
|
+
const user = userEvent.setup();
|
|
236
|
+
render(
|
|
237
|
+
<StateSelect countryCode=" ec " placeholder="Elige provincia" showCode />,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(await expectTrigger("Elige provincia")).toBeEnabled();
|
|
241
|
+
expect(fetchStates).toHaveBeenCalledWith("EC");
|
|
242
|
+
|
|
243
|
+
await user.click(screen.getByRole("combobox"));
|
|
244
|
+
const pichincha = await screen.findByRole("option", { name: /^Pichincha/ });
|
|
245
|
+
expect(within(pichincha).getByText("P")).toBeInTheDocument();
|
|
246
|
+
expect(screen.getByPlaceholderText("Buscar estado, región o municipio…")).toBeInTheDocument();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("oculta el código y respeta disabled", async () => {
|
|
250
|
+
const user = userEvent.setup();
|
|
251
|
+
const { rerender } = render(
|
|
252
|
+
<StateSelect countryCode="EC" showCode={false} />,
|
|
253
|
+
);
|
|
254
|
+
await user.click(await expectTrigger("Buscar estado, región o municipio…"));
|
|
255
|
+
const pichincha = await screen.findByRole("option", { name: "Pichincha" });
|
|
256
|
+
expect(within(pichincha).queryByText("P")).not.toBeInTheDocument();
|
|
257
|
+
|
|
258
|
+
rerender(<StateSelect countryCode="EC" disabled />);
|
|
259
|
+
expect(screen.getByRole("combobox")).toBeDisabled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("value controla por código o nombre y gana a defaultValue", async () => {
|
|
263
|
+
const { rerender } = render(
|
|
264
|
+
<StateSelect countryCode="EC" value="G" defaultValue="P" />,
|
|
265
|
+
);
|
|
266
|
+
await expectTrigger("Guayas");
|
|
267
|
+
|
|
268
|
+
rerender(<StateSelect countryCode="EC" value="Pichincha" />);
|
|
269
|
+
await expectTrigger("Pichincha");
|
|
270
|
+
|
|
271
|
+
rerender(<StateSelect countryCode="EC" value="" defaultValue="P" />);
|
|
272
|
+
await expectTrigger("Buscar estado, región o municipio…");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("defaultValue se mantiene al recibir el país y no dispara onChange", async () => {
|
|
276
|
+
const onChange = vi.fn();
|
|
277
|
+
const { rerender } = render(
|
|
278
|
+
<StateSelect defaultValue="P" onChange={onChange} />,
|
|
279
|
+
);
|
|
280
|
+
rerender(<StateSelect countryCode="EC" defaultValue="P" onChange={onChange} />);
|
|
281
|
+
|
|
282
|
+
await expectTrigger("Pichincha");
|
|
283
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("en modo libre onChange recibe la provincia y la conserva", async () => {
|
|
287
|
+
const onChange = vi.fn();
|
|
288
|
+
render(<StateSelect countryCode="EC" onChange={onChange} />);
|
|
289
|
+
await expectTrigger("Buscar estado, región o municipio…");
|
|
290
|
+
|
|
291
|
+
await pickOption(/^Pichincha/);
|
|
292
|
+
|
|
293
|
+
expect(onChange).toHaveBeenCalledWith({ code: "P", name: "Pichincha" });
|
|
294
|
+
await expectTrigger("Pichincha");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("en modo controlado el texto sigue a value", async () => {
|
|
298
|
+
const onChange = vi.fn();
|
|
299
|
+
const { rerender } = render(
|
|
300
|
+
<StateSelect countryCode="EC" value="P" onChange={onChange} />,
|
|
301
|
+
);
|
|
302
|
+
await expectTrigger("Pichincha");
|
|
303
|
+
|
|
304
|
+
await pickOption(/^Guayas/);
|
|
305
|
+
|
|
306
|
+
expect(onChange).toHaveBeenCalledWith({ code: "G", name: "Guayas" });
|
|
307
|
+
await expectTrigger("Pichincha");
|
|
308
|
+
|
|
309
|
+
rerender(<StateSelect countryCode="EC" value="G" onChange={onChange} />);
|
|
310
|
+
await expectTrigger("Guayas");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("al cambiar de país avisa con null y suelta la selección libre", async () => {
|
|
314
|
+
const onChange = vi.fn();
|
|
315
|
+
const { rerender } = render(
|
|
316
|
+
<StateSelect countryCode="EC" defaultValue="P" onChange={onChange} />,
|
|
317
|
+
);
|
|
318
|
+
await expectTrigger("Pichincha");
|
|
319
|
+
|
|
320
|
+
rerender(<StateSelect countryCode="CO" defaultValue="P" onChange={onChange} />);
|
|
321
|
+
|
|
322
|
+
await waitFor(() => {
|
|
323
|
+
expect(onChange).toHaveBeenCalledWith(null);
|
|
324
|
+
});
|
|
325
|
+
await expectTrigger("Buscar estado, región o municipio…");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("muestra el vacío cuando el país no tiene provincias", async () => {
|
|
329
|
+
const user = userEvent.setup();
|
|
330
|
+
render(<StateSelect countryCode="PE" />);
|
|
331
|
+
await user.click(await expectTrigger("Buscar estado, región o municipio…"));
|
|
332
|
+
expect(await screen.findByText("Sin estados, regiones ni municipios.")).toBeInTheDocument();
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("CitySelect", () => {
|
|
337
|
+
it("queda inactivo hasta tener país y provincia", () => {
|
|
338
|
+
const { rerender } = render(<CitySelect countryCode="EC" />);
|
|
339
|
+
expect(screen.getByRole("combobox")).toBeDisabled();
|
|
340
|
+
expect(screen.getByRole("combobox")).toHaveTextContent("Indica país y estado, región o municipio");
|
|
341
|
+
expect(fetchCities).not.toHaveBeenCalled();
|
|
342
|
+
|
|
343
|
+
rerender(<CitySelect stateCode="P" />);
|
|
344
|
+
expect(screen.getByRole("combobox")).toBeDisabled();
|
|
345
|
+
expect(fetchCities).not.toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("pide ciudades con los códigos normalizados y aplica placeholder, id y className", async () => {
|
|
349
|
+
render(
|
|
350
|
+
<CitySelect
|
|
351
|
+
id="ciudad"
|
|
352
|
+
className="w-52"
|
|
353
|
+
countryCode=" ec "
|
|
354
|
+
stateCode=" P "
|
|
355
|
+
placeholder="Elige ciudad"
|
|
356
|
+
/>,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const trigger = await expectTrigger("Elige ciudad");
|
|
360
|
+
expect(trigger).toBeEnabled();
|
|
361
|
+
expect(trigger).toHaveAttribute("id", "ciudad");
|
|
362
|
+
expect(trigger).toHaveClass("w-52");
|
|
363
|
+
expect(fetchCities).toHaveBeenCalledWith("EC", "P");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it("value y defaultValue eligen por código o nombre", async () => {
|
|
367
|
+
const onChange = vi.fn();
|
|
368
|
+
const { rerender } = render(
|
|
369
|
+
<CitySelect
|
|
370
|
+
countryCode="EC"
|
|
371
|
+
stateCode="P"
|
|
372
|
+
value="UIO"
|
|
373
|
+
defaultValue="CAY"
|
|
374
|
+
onChange={onChange}
|
|
375
|
+
/>,
|
|
376
|
+
);
|
|
377
|
+
await expectTrigger("Quito");
|
|
378
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
379
|
+
|
|
380
|
+
rerender(
|
|
381
|
+
<CitySelect countryCode="EC" stateCode="P" value="Cayambe" onChange={onChange} />,
|
|
382
|
+
);
|
|
383
|
+
await expectTrigger("Cayambe");
|
|
384
|
+
|
|
385
|
+
rerender(
|
|
386
|
+
<CitySelect
|
|
387
|
+
countryCode="EC"
|
|
388
|
+
stateCode="P"
|
|
389
|
+
value=""
|
|
390
|
+
defaultValue="UIO"
|
|
391
|
+
onChange={onChange}
|
|
392
|
+
/>,
|
|
393
|
+
);
|
|
394
|
+
await expectTrigger("Buscar ciudad, pueblo o distrito…");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("defaultValue espera a la provincia y no dispara onChange", async () => {
|
|
398
|
+
const onChange = vi.fn();
|
|
399
|
+
const { rerender } = render(
|
|
400
|
+
<CitySelect countryCode="EC" defaultValue="UIO" onChange={onChange} />,
|
|
401
|
+
);
|
|
402
|
+
rerender(
|
|
403
|
+
<CitySelect
|
|
404
|
+
countryCode="EC"
|
|
405
|
+
stateCode="P"
|
|
406
|
+
defaultValue="UIO"
|
|
407
|
+
onChange={onChange}
|
|
408
|
+
/>,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
await expectTrigger("Quito");
|
|
412
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("en modo libre onChange recibe la ciudad y showCode la enseña", async () => {
|
|
416
|
+
const onChange = vi.fn();
|
|
417
|
+
const user = userEvent.setup();
|
|
418
|
+
render(
|
|
419
|
+
<CitySelect countryCode="EC" stateCode="P" showCode onChange={onChange} />,
|
|
420
|
+
);
|
|
421
|
+
await user.click(await expectTrigger("Buscar ciudad, pueblo o distrito…"));
|
|
422
|
+
const quito = await screen.findByRole("option", { name: /^Quito/ });
|
|
423
|
+
expect(within(quito).getByText("UIO")).toBeInTheDocument();
|
|
424
|
+
await user.click(quito);
|
|
425
|
+
|
|
426
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
427
|
+
expect.objectContaining({ code: "UIO", name: "Quito", stateCode: "P" }),
|
|
428
|
+
);
|
|
429
|
+
await expectTrigger("Quito");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("en modo controlado el texto sigue a value", async () => {
|
|
433
|
+
const onChange = vi.fn();
|
|
434
|
+
const { rerender } = render(
|
|
435
|
+
<CitySelect countryCode="EC" stateCode="P" value="UIO" onChange={onChange} />,
|
|
436
|
+
);
|
|
437
|
+
await expectTrigger("Quito");
|
|
438
|
+
|
|
439
|
+
await pickOption(/^Cayambe/);
|
|
440
|
+
|
|
441
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
442
|
+
expect.objectContaining({ code: "CAY", name: "Cayambe" }),
|
|
443
|
+
);
|
|
444
|
+
await expectTrigger("Quito");
|
|
445
|
+
|
|
446
|
+
rerender(
|
|
447
|
+
<CitySelect countryCode="EC" stateCode="P" value="CAY" onChange={onChange} />,
|
|
448
|
+
);
|
|
449
|
+
await expectTrigger("Cayambe");
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("al cambiar la provincia avisa con null y suelta la selección libre", async () => {
|
|
453
|
+
const onChange = vi.fn();
|
|
454
|
+
const { rerender } = render(
|
|
455
|
+
<CitySelect
|
|
456
|
+
countryCode="EC"
|
|
457
|
+
stateCode="P"
|
|
458
|
+
defaultValue="UIO"
|
|
459
|
+
onChange={onChange}
|
|
460
|
+
/>,
|
|
461
|
+
);
|
|
462
|
+
await expectTrigger("Quito");
|
|
463
|
+
|
|
464
|
+
rerender(
|
|
465
|
+
<CitySelect
|
|
466
|
+
countryCode="EC"
|
|
467
|
+
stateCode="G"
|
|
468
|
+
defaultValue="UIO"
|
|
469
|
+
onChange={onChange}
|
|
470
|
+
/>,
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
await waitFor(() => {
|
|
474
|
+
expect(onChange).toHaveBeenCalledWith(null);
|
|
475
|
+
});
|
|
476
|
+
await expectTrigger("Buscar ciudad, pueblo o distrito…");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("muestra el vacío y oculta el código", async () => {
|
|
480
|
+
const user = userEvent.setup();
|
|
481
|
+
render(<CitySelect countryCode="EC" stateCode="X" showCode={false} />);
|
|
482
|
+
await user.click(await expectTrigger("Buscar ciudad, pueblo o distrito…"));
|
|
483
|
+
expect(await screen.findByText("Sin ciudades, pueblos ni distritos.")).toBeInTheDocument();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("un padre controlado refleja value y onChange", async () => {
|
|
487
|
+
function Harness() {
|
|
488
|
+
const [city, setCity] = useState("");
|
|
489
|
+
return (
|
|
490
|
+
<CitySelect
|
|
491
|
+
countryCode="EC"
|
|
492
|
+
stateCode="P"
|
|
493
|
+
value={city}
|
|
494
|
+
onChange={(option) => setCity(option?.code ?? "")}
|
|
495
|
+
/>
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
render(<Harness />);
|
|
500
|
+
await expectTrigger("Buscar ciudad, pueblo o distrito…");
|
|
501
|
+
await pickOption(/^Quito/);
|
|
502
|
+
await expectTrigger("Quito");
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { fetchCities } from "../lib/geo-locations";
|
|
5
|
+
import type { GeoCityOption, GeoOption } from "../lib/geo-types";
|
|
6
|
+
import { GeoSearchSelect } from "./geo-search-select";
|
|
7
|
+
|
|
8
|
+
export type CitySelectProps = {
|
|
9
|
+
id?: string;
|
|
10
|
+
/** ISO-2 del país. */
|
|
11
|
+
countryCode?: string | null;
|
|
12
|
+
/** Código de provincia / estado. */
|
|
13
|
+
stateCode?: string | null;
|
|
14
|
+
/** Código o nombre controlado. `undefined` deja el selector sin controlar; `""` lo deja vacío. */
|
|
15
|
+
value?: string;
|
|
16
|
+
/** Código o nombre inicial cuando `value` no se pasa. */
|
|
17
|
+
defaultValue?: string;
|
|
18
|
+
onChange?: (option: GeoCityOption | null) => void;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
className?: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
showCode?: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function CitySelect({
|
|
26
|
+
id,
|
|
27
|
+
countryCode,
|
|
28
|
+
stateCode,
|
|
29
|
+
value,
|
|
30
|
+
defaultValue,
|
|
31
|
+
onChange,
|
|
32
|
+
disabled = false,
|
|
33
|
+
className,
|
|
34
|
+
placeholder = "Buscar ciudad, pueblo o distrito…",
|
|
35
|
+
showCode = true,
|
|
36
|
+
}: CitySelectProps) {
|
|
37
|
+
const cc = countryCode?.trim().toUpperCase() ?? "";
|
|
38
|
+
const sc = stateCode?.trim() ?? "";
|
|
39
|
+
const ready = Boolean(cc && sc);
|
|
40
|
+
const isControlled = value !== undefined;
|
|
41
|
+
const [options, setOptions] = useState<GeoCityOption[]>([]);
|
|
42
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
43
|
+
const [internal, setInternal] = useState(defaultValue ?? "");
|
|
44
|
+
const onChangeRef = useRef(onChange);
|
|
45
|
+
const controlledRef = useRef(isControlled);
|
|
46
|
+
const selectionRef = useRef("");
|
|
47
|
+
const prevContext = useRef({ cc, sc });
|
|
48
|
+
|
|
49
|
+
onChangeRef.current = onChange;
|
|
50
|
+
controlledRef.current = isControlled;
|
|
51
|
+
selectionRef.current = (isControlled ? value : internal) ?? "";
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const previous = prevContext.current;
|
|
55
|
+
if (previous.cc === cc && previous.sc === sc) return;
|
|
56
|
+
prevContext.current = { cc, sc };
|
|
57
|
+
if (!previous.cc || !previous.sc) return;
|
|
58
|
+
if (!controlledRef.current) setInternal("");
|
|
59
|
+
if (selectionRef.current) onChangeRef.current?.(null);
|
|
60
|
+
}, [cc, sc]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!ready) {
|
|
64
|
+
setOptions([]);
|
|
65
|
+
setIsLoading(false);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cancelled = false;
|
|
70
|
+
setOptions([]);
|
|
71
|
+
setIsLoading(true);
|
|
72
|
+
void fetchCities(cc, sc)
|
|
73
|
+
.then((list) => {
|
|
74
|
+
if (!cancelled) setOptions(list);
|
|
75
|
+
})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
if (!cancelled) setIsLoading(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
cancelled = true;
|
|
82
|
+
};
|
|
83
|
+
}, [cc, sc, ready]);
|
|
84
|
+
|
|
85
|
+
const current = isControlled ? (value ?? "") : internal;
|
|
86
|
+
const selected = useMemo(() => {
|
|
87
|
+
if (!current) return undefined;
|
|
88
|
+
return options.find(
|
|
89
|
+
(option) => option.code === current || option.name === current,
|
|
90
|
+
);
|
|
91
|
+
}, [current, options]);
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<GeoSearchSelect
|
|
95
|
+
id={id}
|
|
96
|
+
className={className}
|
|
97
|
+
placeholder={ready ? placeholder : "Indica país y estado, región o municipio"}
|
|
98
|
+
searchPlaceholder="Buscar ciudad, pueblo o distrito…"
|
|
99
|
+
emptyMessage="Sin ciudades, pueblos ni distritos."
|
|
100
|
+
options={options}
|
|
101
|
+
value={selected?.code}
|
|
102
|
+
displayValue={selected?.name}
|
|
103
|
+
onChange={(option: GeoOption) => {
|
|
104
|
+
const city = option as GeoCityOption;
|
|
105
|
+
if (!isControlled) setInternal(city.code);
|
|
106
|
+
onChange?.(city);
|
|
107
|
+
}}
|
|
108
|
+
disabled={disabled || !ready}
|
|
109
|
+
isLoading={ready && isLoading}
|
|
110
|
+
showCode={showCode}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
}
|