@multiplatform.one/forms 7.5.0 → 7.6.1
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/dist/cjs/Button/index.cjs +6 -10
- package/dist/cjs/Button/index.native.js +6 -10
- package/dist/cjs/Button/index.native.js.map +1 -1
- package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.cjs +0 -1
- package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
- package/dist/cjs/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
- package/dist/cjs/FieldDisplay/index.cjs +2 -1
- package/dist/cjs/FieldDisplay/index.native.js +9 -2
- package/dist/cjs/FieldDisplay/index.native.js.map +1 -1
- package/dist/cjs/FloatingPanel/index.cjs +8 -1
- package/dist/cjs/InputParts/index.cjs +64 -2
- package/dist/cjs/InputParts/index.native.js +70 -4
- package/dist/cjs/InputParts/index.native.js.map +1 -1
- package/dist/cjs/fields/Barcode/BarcodePreview.cjs +15 -9
- package/dist/cjs/fields/Barcode/BarcodePreview.native.js +15 -9
- package/dist/cjs/fields/Barcode/BarcodePreview.native.js.map +1 -1
- package/dist/cjs/fields/Combobox/index.cjs +14 -11
- package/dist/cjs/fields/Combobox/index.native.js +16 -11
- package/dist/cjs/fields/Combobox/index.native.js.map +1 -1
- package/dist/cjs/fields/FileUpload/index.cjs +66 -5
- package/dist/cjs/fields/FileUpload/index.native.js +85 -5
- package/dist/cjs/fields/FileUpload/index.native.js.map +1 -1
- package/dist/cjs/fields/Geolocation/geoValue.cjs +122 -0
- package/dist/cjs/fields/Geolocation/geoValue.native.js +151 -0
- package/dist/cjs/fields/Geolocation/geoValue.native.js.map +1 -0
- package/dist/cjs/fields/Geolocation/index.cjs +16 -29
- package/dist/cjs/fields/Geolocation/index.native.js +16 -29
- package/dist/cjs/fields/Geolocation/index.native.js.map +1 -1
- package/dist/cjs/fields/Icon/index.cjs +1 -0
- package/dist/cjs/fields/Icon/index.native.js +1 -0
- package/dist/cjs/fields/Icon/index.native.js.map +1 -1
- package/dist/cjs/fields/Input/index.cjs +175 -95
- package/dist/cjs/fields/Input/index.native.js +83 -4
- package/dist/cjs/fields/Input/index.native.js.map +1 -1
- package/dist/cjs/fields/MentionInput/index.cjs +6 -1
- package/dist/cjs/fields/MentionInput/index.native.js +6 -1
- package/dist/cjs/fields/MentionInput/index.native.js.map +1 -1
- package/dist/cjs/fields/OTPInput/index.cjs +7 -2
- package/dist/cjs/fields/OTPInput/index.native.js +7 -2
- package/dist/cjs/fields/OTPInput/index.native.js.map +1 -1
- package/dist/cjs/fields/PhoneInput/index.native.js +1 -0
- package/dist/cjs/fields/PhoneInput/index.native.js.map +1 -1
- package/dist/cjs/fields/SearchInput/index.native.js.map +1 -1
- package/dist/cjs/shared/utils.cjs +4 -14
- package/dist/cjs/shared/utils.native.js +6 -14
- package/dist/cjs/shared/utils.native.js.map +1 -1
- package/dist/esm/Button/index.mjs +6 -9
- package/dist/esm/Button/index.mjs.map +1 -1
- package/dist/esm/Button/index.native.js +6 -9
- package/dist/esm/Button/index.native.js.map +1 -1
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
- package/dist/esm/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
- package/dist/esm/FieldDisplay/index.mjs +2 -1
- package/dist/esm/FieldDisplay/index.mjs.map +1 -1
- package/dist/esm/FieldDisplay/index.native.js +9 -2
- package/dist/esm/FieldDisplay/index.native.js.map +1 -1
- package/dist/esm/FloatingPanel/index.mjs +8 -1
- package/dist/esm/FloatingPanel/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.mjs +64 -2
- package/dist/esm/InputParts/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.native.js +70 -4
- package/dist/esm/InputParts/index.native.js.map +1 -1
- package/dist/esm/fields/Barcode/BarcodePreview.mjs +15 -9
- package/dist/esm/fields/Barcode/BarcodePreview.mjs.map +1 -1
- package/dist/esm/fields/Barcode/BarcodePreview.native.js +15 -9
- package/dist/esm/fields/Barcode/BarcodePreview.native.js.map +1 -1
- package/dist/esm/fields/Combobox/index.mjs +14 -11
- package/dist/esm/fields/Combobox/index.mjs.map +1 -1
- package/dist/esm/fields/Combobox/index.native.js +16 -11
- package/dist/esm/fields/Combobox/index.native.js.map +1 -1
- package/dist/esm/fields/FileUpload/index.mjs +66 -5
- package/dist/esm/fields/FileUpload/index.mjs.map +1 -1
- package/dist/esm/fields/FileUpload/index.native.js +85 -5
- package/dist/esm/fields/FileUpload/index.native.js.map +1 -1
- package/dist/esm/fields/Geolocation/geoValue.mjs +95 -0
- package/dist/esm/fields/Geolocation/geoValue.mjs.map +1 -0
- package/dist/esm/fields/Geolocation/geoValue.native.js +121 -0
- package/dist/esm/fields/Geolocation/geoValue.native.js.map +1 -0
- package/dist/esm/fields/Geolocation/index.mjs +15 -28
- package/dist/esm/fields/Geolocation/index.mjs.map +1 -1
- package/dist/esm/fields/Geolocation/index.native.js +15 -28
- package/dist/esm/fields/Geolocation/index.native.js.map +1 -1
- package/dist/esm/fields/Icon/index.mjs +1 -0
- package/dist/esm/fields/Icon/index.mjs.map +1 -1
- package/dist/esm/fields/Icon/index.native.js +1 -0
- package/dist/esm/fields/Icon/index.native.js.map +1 -1
- package/dist/esm/fields/Input/index.mjs +177 -98
- package/dist/esm/fields/Input/index.mjs.map +1 -1
- package/dist/esm/fields/Input/index.native.js +85 -7
- package/dist/esm/fields/Input/index.native.js.map +1 -1
- package/dist/esm/fields/MentionInput/index.mjs +6 -1
- package/dist/esm/fields/MentionInput/index.mjs.map +1 -1
- package/dist/esm/fields/MentionInput/index.native.js +6 -1
- package/dist/esm/fields/MentionInput/index.native.js.map +1 -1
- package/dist/esm/fields/OTPInput/index.mjs +7 -3
- package/dist/esm/fields/OTPInput/index.mjs.map +1 -1
- package/dist/esm/fields/OTPInput/index.native.js +7 -3
- package/dist/esm/fields/OTPInput/index.native.js.map +1 -1
- package/dist/esm/fields/PhoneInput/index.mjs.map +1 -1
- package/dist/esm/fields/PhoneInput/index.native.js +1 -0
- package/dist/esm/fields/PhoneInput/index.native.js.map +1 -1
- package/dist/esm/fields/SearchInput/index.mjs.map +1 -1
- package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
- package/dist/esm/shared/utils.mjs +4 -14
- package/dist/esm/shared/utils.mjs.map +1 -1
- package/dist/esm/shared/utils.native.js +6 -14
- package/dist/esm/shared/utils.native.js.map +1 -1
- package/dist/jsx/Button/index.mjs +6 -9
- package/dist/jsx/Button/index.mjs.map +1 -1
- package/dist/jsx/Button/index.native.js +6 -10
- package/dist/jsx/Button/index.native.js.map +1 -1
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs +0 -1
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.mjs.map +1 -1
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js +0 -1
- package/dist/jsx/ContextualSaveBar/UnsavedChangesDialog.native.js.map +1 -1
- package/dist/jsx/FieldDisplay/index.mjs +2 -1
- package/dist/jsx/FieldDisplay/index.mjs.map +1 -1
- package/dist/jsx/FieldDisplay/index.native.js +9 -2
- package/dist/jsx/FieldDisplay/index.native.js.map +1 -1
- package/dist/jsx/FloatingPanel/index.mjs +8 -1
- package/dist/jsx/FloatingPanel/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.mjs +64 -2
- package/dist/jsx/InputParts/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.native.js +70 -4
- package/dist/jsx/InputParts/index.native.js.map +1 -1
- package/dist/jsx/fields/Barcode/BarcodePreview.mjs +15 -9
- package/dist/jsx/fields/Barcode/BarcodePreview.mjs.map +1 -1
- package/dist/jsx/fields/Barcode/BarcodePreview.native.js +15 -9
- package/dist/jsx/fields/Barcode/BarcodePreview.native.js.map +1 -1
- package/dist/jsx/fields/Combobox/index.mjs +14 -11
- package/dist/jsx/fields/Combobox/index.mjs.map +1 -1
- package/dist/jsx/fields/Combobox/index.native.js +16 -11
- package/dist/jsx/fields/Combobox/index.native.js.map +1 -1
- package/dist/jsx/fields/FileUpload/index.mjs +66 -5
- package/dist/jsx/fields/FileUpload/index.mjs.map +1 -1
- package/dist/jsx/fields/FileUpload/index.native.js +85 -5
- package/dist/jsx/fields/FileUpload/index.native.js.map +1 -1
- package/dist/jsx/fields/Geolocation/geoValue.mjs +95 -0
- package/dist/jsx/fields/Geolocation/geoValue.mjs.map +1 -0
- package/dist/jsx/fields/Geolocation/geoValue.native.js +151 -0
- package/dist/jsx/fields/Geolocation/geoValue.native.js.map +1 -0
- package/dist/jsx/fields/Geolocation/index.mjs +15 -28
- package/dist/jsx/fields/Geolocation/index.mjs.map +1 -1
- package/dist/jsx/fields/Geolocation/index.native.js +16 -29
- package/dist/jsx/fields/Geolocation/index.native.js.map +1 -1
- package/dist/jsx/fields/Icon/index.mjs +1 -0
- package/dist/jsx/fields/Icon/index.mjs.map +1 -1
- package/dist/jsx/fields/Icon/index.native.js +1 -0
- package/dist/jsx/fields/Icon/index.native.js.map +1 -1
- package/dist/jsx/fields/Input/index.mjs +177 -98
- package/dist/jsx/fields/Input/index.mjs.map +1 -1
- package/dist/jsx/fields/Input/index.native.js +83 -4
- package/dist/jsx/fields/Input/index.native.js.map +1 -1
- package/dist/jsx/fields/MentionInput/index.mjs +6 -1
- package/dist/jsx/fields/MentionInput/index.mjs.map +1 -1
- package/dist/jsx/fields/MentionInput/index.native.js +6 -1
- package/dist/jsx/fields/MentionInput/index.native.js.map +1 -1
- package/dist/jsx/fields/OTPInput/index.mjs +7 -3
- package/dist/jsx/fields/OTPInput/index.mjs.map +1 -1
- package/dist/jsx/fields/OTPInput/index.native.js +7 -2
- package/dist/jsx/fields/OTPInput/index.native.js.map +1 -1
- package/dist/jsx/fields/PhoneInput/index.mjs.map +1 -1
- package/dist/jsx/fields/PhoneInput/index.native.js +1 -0
- package/dist/jsx/fields/PhoneInput/index.native.js.map +1 -1
- package/dist/jsx/fields/SearchInput/index.mjs.map +1 -1
- package/dist/jsx/fields/SearchInput/index.native.js.map +1 -1
- package/dist/jsx/shared/utils.mjs +4 -14
- package/dist/jsx/shared/utils.mjs.map +1 -1
- package/dist/jsx/shared/utils.native.js +6 -14
- package/dist/jsx/shared/utils.native.js.map +1 -1
- package/package.json +7 -7
- package/src/Button/Button.density.spec.tsx +118 -32
- package/src/Button/Button.labelInk.spec.tsx +72 -19
- package/src/Button/Button.lc71.spec.tsx +10 -3
- package/src/Button/Button.stories.tsx +97 -7
- package/src/Button/index.tsx +22 -12
- package/src/ContextualSaveBar/UnsavedChangesDialog.tsx +0 -1
- package/src/FieldDisplay/FieldDisplay.spec.tsx +11 -2
- package/src/FieldDisplay/index.tsx +5 -0
- package/src/FloatingPanel/index.tsx +22 -1
- package/src/FloatingPanel/panelWidthContract.spec.ts +1 -0
- package/src/InputParts/index.tsx +100 -3
- package/src/fields/Barcode/BarcodePreview.spec.tsx +153 -5
- package/src/fields/Barcode/BarcodePreview.stories.tsx +104 -0
- package/src/fields/Barcode/BarcodePreview.tsx +47 -15
- package/src/fields/ColorPicker/PresetSwatches.spec.tsx +71 -0
- package/src/fields/ColorPicker/PresetSwatches.stories.tsx +94 -0
- package/src/fields/Combobox/Combobox.spec.tsx +64 -1
- package/src/fields/Combobox/index.tsx +22 -11
- package/src/fields/FileUpload/FileUpload.spec.tsx +71 -0
- package/src/fields/FileUpload/index.tsx +100 -3
- package/src/fields/Geolocation/Geolocation.spec.tsx +126 -0
- package/src/fields/Geolocation/geoValue.spec.ts +169 -0
- package/src/fields/Geolocation/geoValue.ts +149 -0
- package/src/fields/Geolocation/index.tsx +15 -24
- package/src/fields/Icon/Icon.stories.tsx +1 -1
- package/src/fields/Icon/index.tsx +1 -0
- package/src/fields/Input/index.tsx +180 -81
- package/src/fields/MentionInput/index.tsx +9 -1
- package/src/fields/OTPInput/OtpInput.spec.tsx +36 -4
- package/src/fields/OTPInput/index.tsx +18 -3
- package/src/fields/OTPInput/otpCellHeightTouch.spec.ts +44 -0
- package/src/fields/PhoneInput/index.tsx +1 -0
- package/src/fields/SearchInput/index.tsx +5 -3
- package/src/shared/utils.spec.ts +46 -0
- package/src/shared/utils.ts +31 -16
- package/types/Button/index.d.ts +8 -3
- package/types/Button/index.d.ts.map +1 -1
- package/types/ContextualSaveBar/UnsavedChangesDialog.d.ts.map +1 -1
- package/types/FieldDisplay/index.d.ts.map +1 -1
- package/types/InputParts/index.d.ts +9 -5
- package/types/InputParts/index.d.ts.map +1 -1
- package/types/fields/Barcode/BarcodePreview.d.ts +20 -9
- package/types/fields/Barcode/BarcodePreview.d.ts.map +1 -1
- package/types/fields/Checkbox/index.d.ts +3 -3
- package/types/fields/Combobox/index.d.ts.map +1 -1
- package/types/fields/FileUpload/index.d.ts.map +1 -1
- package/types/fields/Geolocation/geoValue.d.ts +45 -0
- package/types/fields/Geolocation/geoValue.d.ts.map +1 -0
- package/types/fields/Geolocation/index.d.ts.map +1 -1
- package/types/fields/Icon/index.d.ts.map +1 -1
- package/types/fields/Input/index.d.ts +18 -6
- package/types/fields/Input/index.d.ts.map +1 -1
- package/types/fields/MentionInput/index.d.ts.map +1 -1
- package/types/fields/OTPInput/index.d.ts +12 -0
- package/types/fields/OTPInput/index.d.ts.map +1 -1
- package/types/fields/PhoneInput/index.d.ts.map +1 -1
- package/types/fields/RadioGroup/index.d.ts +2 -2
- package/types/fields/SearchInput/index.d.ts.map +1 -1
- package/types/fields/ToggleGroup/index.d.ts +1 -1
- package/types/shared/utils.d.ts.map +1 -1
|
@@ -6,6 +6,52 @@ import { Geolocation, preflightGeolocation } from "./index";
|
|
|
6
6
|
|
|
7
7
|
const locationIcon = <span data-testid="location-icon" />;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Recorded stock-Frappe Geolocation value: what
|
|
11
|
+
* `JSON.stringify(editableLayers.toGeoJSON())` writes for a circle marker at
|
|
12
|
+
* 37.7749,-122.4194. GeoJSON order is [lng, lat].
|
|
13
|
+
*/
|
|
14
|
+
const stockFrappePoint = JSON.stringify({
|
|
15
|
+
type: "FeatureCollection",
|
|
16
|
+
features: [
|
|
17
|
+
{
|
|
18
|
+
type: "Feature",
|
|
19
|
+
properties: { point_type: "circle", radius: 10 },
|
|
20
|
+
geometry: { type: "Point", coordinates: [-122.4194, 37.7749] },
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Stock Frappe's geolocation.js load path: JSON.parse then L.geoJSON. Compact
|
|
27
|
+
* `{"lat","lng"}` parses as JSON but is not GeoJSON, so Leaflet adds nothing
|
|
28
|
+
* and the desk field renders empty. A FeatureCollection with a Point is what
|
|
29
|
+
* survives a round trip through both clients.
|
|
30
|
+
*/
|
|
31
|
+
function frappeGeoJsonReads(value: string): { lat: number; lng: number } | null {
|
|
32
|
+
let parsed: unknown;
|
|
33
|
+
try {
|
|
34
|
+
parsed = JSON.parse(value);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
39
|
+
const collection = parsed as { type?: unknown; features?: unknown };
|
|
40
|
+
if (collection.type !== "FeatureCollection" || !Array.isArray(collection.features)) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
for (const feature of collection.features) {
|
|
44
|
+
if (!feature || typeof feature !== "object") continue;
|
|
45
|
+
const geometry = (feature as { geometry?: { type?: unknown; coordinates?: unknown } }).geometry;
|
|
46
|
+
if (!geometry || geometry.type !== "Point" || !Array.isArray(geometry.coordinates)) continue;
|
|
47
|
+
const [lng, lat] = geometry.coordinates as [unknown, unknown];
|
|
48
|
+
if (typeof lng !== "number" || typeof lat !== "number") continue;
|
|
49
|
+
if (!Number.isFinite(lng) || !Number.isFinite(lat)) continue;
|
|
50
|
+
return { lat, lng };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
9
55
|
describe("Geolocation", () => {
|
|
10
56
|
describe("without form context", () => {
|
|
11
57
|
it("renders with label", () => {
|
|
@@ -418,4 +464,84 @@ describe("Geolocation", () => {
|
|
|
418
464
|
expect(result.findTextElement("Location")).toBeDefined();
|
|
419
465
|
});
|
|
420
466
|
});
|
|
467
|
+
|
|
468
|
+
describe("MPO-100 stock Frappe FeatureCollection", () => {
|
|
469
|
+
it("paints a recorded stock value instead of rendering empty", () => {
|
|
470
|
+
const result = renderWithProviders(
|
|
471
|
+
<Geolocation label="Location" value={stockFrappePoint} locationIcon={locationIcon} />,
|
|
472
|
+
);
|
|
473
|
+
expect(result.container.textContent).toContain("37.7749, -122.4194");
|
|
474
|
+
expect(result.container.textContent).not.toContain("Set location");
|
|
475
|
+
expect(result.container.querySelector("[data-testid='geolocation-map']")).not.toBeNull();
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("a compact {lat,lng} write is invisible to Frappe's parser (negative control)", () => {
|
|
479
|
+
expect(frappeGeoJsonReads(JSON.stringify({ lat: 37.7749, lng: -122.4194 }))).toBeNull();
|
|
480
|
+
expect(frappeGeoJsonReads(stockFrappePoint)).toEqual({ lat: 37.7749, lng: -122.4194 });
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("editing a stock value still reads in Frappe's parser and keeps properties", async () => {
|
|
484
|
+
const onChange = vi.fn();
|
|
485
|
+
const result = renderWithProviders(
|
|
486
|
+
<Geolocation
|
|
487
|
+
label="Location"
|
|
488
|
+
locationIcon={locationIcon}
|
|
489
|
+
value={stockFrappePoint}
|
|
490
|
+
onChange={onChange}
|
|
491
|
+
/>,
|
|
492
|
+
);
|
|
493
|
+
fireEvent.click(result.container.querySelector("[aria-label='Edit coordinates']") as Element);
|
|
494
|
+
await waitFor(() => {
|
|
495
|
+
expect(document.querySelector("[data-testid='geolocation-coords-panel']")).not.toBeNull();
|
|
496
|
+
});
|
|
497
|
+
const latInput = document.querySelector("input[aria-label='Latitude']") as HTMLInputElement;
|
|
498
|
+
expect(latInput).not.toBeNull();
|
|
499
|
+
fireEvent.change(latInput, { target: { value: "12.34" } });
|
|
500
|
+
await waitFor(() => {
|
|
501
|
+
expect(onChange).toHaveBeenCalled();
|
|
502
|
+
});
|
|
503
|
+
const written = onChange.mock.calls[0][0] as string;
|
|
504
|
+
expect(JSON.parse(written).type).toBe("FeatureCollection");
|
|
505
|
+
expect(frappeGeoJsonReads(written)).toEqual({ lat: 12.34, lng: -122.4194 });
|
|
506
|
+
expect(JSON.parse(written).features[0].properties).toEqual({
|
|
507
|
+
point_type: "circle",
|
|
508
|
+
radius: 10,
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("map pick on a stock value writes FeatureCollection, not compact latlng", () => {
|
|
513
|
+
const onChange = vi.fn();
|
|
514
|
+
const result = renderWithProviders(
|
|
515
|
+
<Geolocation
|
|
516
|
+
label="Location"
|
|
517
|
+
value={stockFrappePoint}
|
|
518
|
+
onChange={onChange}
|
|
519
|
+
locationIcon={locationIcon}
|
|
520
|
+
/>,
|
|
521
|
+
);
|
|
522
|
+
const pick = result.container.querySelector(
|
|
523
|
+
"[data-testid='geolocation-map-pick']",
|
|
524
|
+
) as HTMLElement;
|
|
525
|
+
expect(pick).not.toBeNull();
|
|
526
|
+
pick.getBoundingClientRect = () =>
|
|
527
|
+
({
|
|
528
|
+
width: 200,
|
|
529
|
+
height: 200,
|
|
530
|
+
left: 0,
|
|
531
|
+
top: 0,
|
|
532
|
+
right: 200,
|
|
533
|
+
bottom: 200,
|
|
534
|
+
x: 0,
|
|
535
|
+
y: 0,
|
|
536
|
+
toJSON() {},
|
|
537
|
+
}) as DOMRect;
|
|
538
|
+
fireEvent.click(pick, { clientX: 150, clientY: 50 });
|
|
539
|
+
expect(onChange).toHaveBeenCalled();
|
|
540
|
+
const written = onChange.mock.calls[0][0] as string;
|
|
541
|
+
expect(JSON.parse(written).type).toBe("FeatureCollection");
|
|
542
|
+
const read = frappeGeoJsonReads(written);
|
|
543
|
+
expect(read).not.toBeNull();
|
|
544
|
+
expect(read?.lat).not.toBeCloseTo(37.7749, 3);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
421
547
|
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectGeoShape, formatGeoValue, parseGeoValue } from "./geoValue";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Recorded stock-Frappe value: what `JSON.stringify(editableLayers.toGeoJSON())`
|
|
6
|
+
* writes for a single circle marker dropped at 37.7749,-122.4194. Leaflet emits
|
|
7
|
+
* GeoJSON order, so coordinates are [lng, lat].
|
|
8
|
+
*/
|
|
9
|
+
const stockFrappePoint = JSON.stringify({
|
|
10
|
+
type: "FeatureCollection",
|
|
11
|
+
features: [
|
|
12
|
+
{
|
|
13
|
+
type: "Feature",
|
|
14
|
+
properties: { point_type: "circle", radius: 10 },
|
|
15
|
+
geometry: { type: "Point", coordinates: [-122.4194, 37.7749] },
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const nativePoint = JSON.stringify({ lat: 37.7749, lng: -122.4194 });
|
|
21
|
+
|
|
22
|
+
describe("parseGeoValue", () => {
|
|
23
|
+
it("reads the repo-native {lat,lng} shape", () => {
|
|
24
|
+
expect(parseGeoValue(nativePoint)).toEqual({ lat: 37.7749, lng: -122.4194 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("reads a stock Frappe FeatureCollection and yields the SAME coordinates", () => {
|
|
28
|
+
expect(parseGeoValue(stockFrappePoint)).toEqual(parseGeoValue(nativePoint));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("does not swap lat/lng — GeoJSON stores [lng, lat]", () => {
|
|
32
|
+
const parsed = parseGeoValue(stockFrappePoint);
|
|
33
|
+
expect(parsed?.lat).toBeCloseTo(37.7749, 10);
|
|
34
|
+
expect(parsed?.lng).toBeCloseTo(-122.4194, 10);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("round-trips a stock value through JSON.parse/stringify unchanged", () => {
|
|
38
|
+
const reserialized = JSON.stringify(JSON.parse(stockFrappePoint));
|
|
39
|
+
expect(parseGeoValue(reserialized)).toEqual({ lat: 37.7749, lng: -122.4194 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("picks the first Point when Frappe also stored drawn shapes", () => {
|
|
43
|
+
const mixed = JSON.stringify({
|
|
44
|
+
type: "FeatureCollection",
|
|
45
|
+
features: [
|
|
46
|
+
{
|
|
47
|
+
type: "Feature",
|
|
48
|
+
properties: {},
|
|
49
|
+
geometry: {
|
|
50
|
+
type: "Polygon",
|
|
51
|
+
coordinates: [
|
|
52
|
+
[
|
|
53
|
+
[0, 0],
|
|
54
|
+
[1, 0],
|
|
55
|
+
[1, 1],
|
|
56
|
+
[0, 0],
|
|
57
|
+
],
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "Feature",
|
|
63
|
+
properties: { point_type: "marker" },
|
|
64
|
+
geometry: { type: "Point", coordinates: [12.4924, 41.8902] },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
expect(parseGeoValue(mixed)).toEqual({ lat: 41.8902, lng: 12.4924 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns null for an empty FeatureCollection (Frappe's cleared value)", () => {
|
|
72
|
+
expect(parseGeoValue(JSON.stringify({ type: "FeatureCollection", features: [] }))).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns null for empty, blank, malformed, and non-finite values", () => {
|
|
76
|
+
expect(parseGeoValue(undefined)).toBeNull();
|
|
77
|
+
expect(parseGeoValue("")).toBeNull();
|
|
78
|
+
expect(parseGeoValue(" ")).toBeNull();
|
|
79
|
+
expect(parseGeoValue("{not json")).toBeNull();
|
|
80
|
+
expect(parseGeoValue(JSON.stringify({ lat: "37.7", lng: -122 }))).toBeNull();
|
|
81
|
+
expect(parseGeoValue(JSON.stringify({ lat: Number.NaN, lng: -122 }))).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("detectGeoShape", () => {
|
|
86
|
+
it("reports featurecollection for a stock Frappe value", () => {
|
|
87
|
+
expect(detectGeoShape(stockFrappePoint)).toBe("featurecollection");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("reports latlng for the repo-native value and for unreadable input", () => {
|
|
91
|
+
expect(detectGeoShape(nativePoint)).toBe("latlng");
|
|
92
|
+
expect(detectGeoShape("")).toBe("latlng");
|
|
93
|
+
expect(detectGeoShape("{not json")).toBe("latlng");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("formatGeoValue", () => {
|
|
98
|
+
it("keeps the repo-native shape for a doc that arrived native", () => {
|
|
99
|
+
expect(formatGeoValue({ lat: 1.5, lng: -2.5 }, "latlng", nativePoint)).toBe(
|
|
100
|
+
JSON.stringify({ lat: 1.5, lng: -2.5 }),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("writes a FeatureCollection back when the doc arrived from stock Frappe", () => {
|
|
105
|
+
const written = JSON.parse(
|
|
106
|
+
formatGeoValue({ lat: 1.5, lng: -2.5 }, "featurecollection", stockFrappePoint),
|
|
107
|
+
);
|
|
108
|
+
expect(written.type).toBe("FeatureCollection");
|
|
109
|
+
expect(written.features).toHaveLength(1);
|
|
110
|
+
expect(written.features[0].geometry).toEqual({ type: "Point", coordinates: [-2.5, 1.5] });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("emits [lng, lat] so stock Frappe's parser reads the edit back", () => {
|
|
114
|
+
const written = formatGeoValue({ lat: 1.5, lng: -2.5 }, "featurecollection", stockFrappePoint);
|
|
115
|
+
expect(parseGeoValue(written)).toEqual({ lat: 1.5, lng: -2.5 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("preserves the feature properties Frappe wrote (point_type/radius)", () => {
|
|
119
|
+
const written = JSON.parse(
|
|
120
|
+
formatGeoValue({ lat: 1.5, lng: -2.5 }, "featurecollection", stockFrappePoint),
|
|
121
|
+
);
|
|
122
|
+
expect(written.features[0].properties).toEqual({ point_type: "circle", radius: 10 });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not destroy a drawn Polygon sibling when the point moves", () => {
|
|
126
|
+
const polygon = {
|
|
127
|
+
type: "Feature",
|
|
128
|
+
properties: { name: "zone" },
|
|
129
|
+
geometry: {
|
|
130
|
+
type: "Polygon",
|
|
131
|
+
coordinates: [
|
|
132
|
+
[
|
|
133
|
+
[0, 0],
|
|
134
|
+
[0, 1],
|
|
135
|
+
[1, 1],
|
|
136
|
+
[0, 0],
|
|
137
|
+
],
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const previous = JSON.stringify({
|
|
142
|
+
type: "FeatureCollection",
|
|
143
|
+
features: [polygon, JSON.parse(stockFrappePoint).features[0]],
|
|
144
|
+
});
|
|
145
|
+
const written = JSON.parse(formatGeoValue({ lat: 9, lng: 8 }, "featurecollection", previous));
|
|
146
|
+
expect(written.features).toHaveLength(2);
|
|
147
|
+
expect(written.features[0]).toEqual(polygon);
|
|
148
|
+
expect(written.features[1].geometry.coordinates).toEqual([8, 9]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("appends a plain marker when the collection had no Point to move", () => {
|
|
152
|
+
const previous = JSON.stringify({ type: "FeatureCollection", features: [] });
|
|
153
|
+
const written = JSON.parse(formatGeoValue({ lat: 9, lng: 8 }, "featurecollection", previous));
|
|
154
|
+
expect(written.features).toEqual([
|
|
155
|
+
{ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [8, 9] } },
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("round-trips: parse a stock value, write it back, parse again", () => {
|
|
160
|
+
const read = parseGeoValue(stockFrappePoint);
|
|
161
|
+
expect(read).toEqual({ lat: 37.7749, lng: -122.4194 });
|
|
162
|
+
const written = formatGeoValue(
|
|
163
|
+
read as { lat: number; lng: number },
|
|
164
|
+
detectGeoShape(stockFrappePoint),
|
|
165
|
+
stockFrappePoint,
|
|
166
|
+
);
|
|
167
|
+
expect(JSON.parse(written)).toEqual(JSON.parse(stockFrappePoint));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geolocation value shapes (MPO-100).
|
|
3
|
+
*
|
|
4
|
+
* Stock Frappe stores the Geolocation fieldtype as a GeoJSON FeatureCollection
|
|
5
|
+
* STRING — `geolocation.js` does
|
|
6
|
+
* `set_value(JSON.stringify(this.editableLayers.toGeoJSON()))`, where Leaflet's
|
|
7
|
+
* `toGeoJSON()` emits `[lng, lat]` coordinate pairs.
|
|
8
|
+
*
|
|
9
|
+
* This repo's field stores the compact `{"lat":n,"lng":n}` object. Both are read
|
|
10
|
+
* here so a doc created in stock Frappe renders instead of coming up empty.
|
|
11
|
+
* `detectGeoShape` reports which shape a stored value used, so the write path
|
|
12
|
+
* can persist back in the shape the doc arrived in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface GeoValue {
|
|
16
|
+
lat: number;
|
|
17
|
+
lng: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Which on-disk shape a stored Geolocation value used. */
|
|
21
|
+
export type GeoShape = "latlng" | "featurecollection";
|
|
22
|
+
|
|
23
|
+
interface GeoJsonPoint {
|
|
24
|
+
type: "Point";
|
|
25
|
+
coordinates: [number, number];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFiniteNumber(n: unknown): n is number {
|
|
29
|
+
return typeof n === "number" && Number.isFinite(n);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseJson(value: string | undefined): unknown {
|
|
33
|
+
if (!value || !value.trim()) return null;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(value);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pull the first Point geometry out of a stock Frappe FeatureCollection.
|
|
43
|
+
* Frappe also stores Polygon/LineString features (drawn shapes); those carry no
|
|
44
|
+
* single coordinate for our lat/lng field, so they are skipped rather than
|
|
45
|
+
* approximated.
|
|
46
|
+
*/
|
|
47
|
+
function firstPoint(parsed: unknown): GeoJsonPoint | null {
|
|
48
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
49
|
+
const collection = parsed as { type?: unknown; features?: unknown };
|
|
50
|
+
if (collection.type !== "FeatureCollection" || !Array.isArray(collection.features)) return null;
|
|
51
|
+
for (const feature of collection.features) {
|
|
52
|
+
if (!feature || typeof feature !== "object") continue;
|
|
53
|
+
const geometry = (feature as { geometry?: unknown }).geometry as
|
|
54
|
+
| { type?: unknown; coordinates?: unknown }
|
|
55
|
+
| undefined;
|
|
56
|
+
if (!geometry || geometry.type !== "Point") continue;
|
|
57
|
+
const coordinates = geometry.coordinates;
|
|
58
|
+
if (!Array.isArray(coordinates) || coordinates.length < 2) continue;
|
|
59
|
+
const [lng, lat] = coordinates;
|
|
60
|
+
if (!isFiniteNumber(lng) || !isFiniteNumber(lat)) continue;
|
|
61
|
+
return { type: "Point", coordinates: [lng, lat] };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read a stored Geolocation value in EITHER shape. Returns null when the value
|
|
68
|
+
* is empty, unparseable, or carries no usable point.
|
|
69
|
+
*/
|
|
70
|
+
export function parseGeoValue(value: string | undefined): GeoValue | null {
|
|
71
|
+
const parsed = parseJson(value);
|
|
72
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
73
|
+
|
|
74
|
+
const native = parsed as Record<string, unknown>;
|
|
75
|
+
if (isFiniteNumber(native.lat) && isFiniteNumber(native.lng)) {
|
|
76
|
+
return { lat: native.lat, lng: native.lng };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const point = firstPoint(parsed);
|
|
80
|
+
if (point) {
|
|
81
|
+
const [lng, lat] = point.coordinates;
|
|
82
|
+
return { lat, lng };
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Report which shape a stored value used, so an edit can be written back in the
|
|
89
|
+
* same shape. Unreadable values report the repo-native shape.
|
|
90
|
+
*/
|
|
91
|
+
export function detectGeoShape(value: string | undefined): GeoShape {
|
|
92
|
+
const parsed = parseJson(value);
|
|
93
|
+
if (!parsed || typeof parsed !== "object") return "latlng";
|
|
94
|
+
if ((parsed as { type?: unknown }).type === "FeatureCollection") return "featurecollection";
|
|
95
|
+
return "latlng";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface GeoJsonFeature {
|
|
99
|
+
type: "Feature";
|
|
100
|
+
properties: Record<string, unknown>;
|
|
101
|
+
geometry: { type?: unknown; coordinates?: unknown } | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function asFeatures(parsed: unknown): GeoJsonFeature[] {
|
|
105
|
+
if (!parsed || typeof parsed !== "object") return [];
|
|
106
|
+
const collection = parsed as { type?: unknown; features?: unknown };
|
|
107
|
+
if (collection.type !== "FeatureCollection" || !Array.isArray(collection.features)) return [];
|
|
108
|
+
return collection.features.filter(
|
|
109
|
+
(feature): feature is GeoJsonFeature => !!feature && typeof feature === "object",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isPointFeature(feature: GeoJsonFeature): boolean {
|
|
114
|
+
return !!feature.geometry && (feature.geometry as { type?: unknown }).type === "Point";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Serialise coordinates back to disk in the shape the document arrived in.
|
|
119
|
+
*
|
|
120
|
+
* `latlng` emits this repo's compact `{"lat":n,"lng":n}`. `featurecollection`
|
|
121
|
+
* emits a stock-Frappe GeoJSON FeatureCollection with `[lng, lat]` ordering, so
|
|
122
|
+
* `geolocation.js` reads the edit back.
|
|
123
|
+
*
|
|
124
|
+
* `previous` (the value being replaced) is preserved as far as it can be: the
|
|
125
|
+
* first Point feature is moved in place and keeps its `properties` (Frappe
|
|
126
|
+
* writes `point_type`/`radius` there for circles), and any Polygon/LineString
|
|
127
|
+
* features the user drew in Frappe survive a coordinate edit untouched. With no
|
|
128
|
+
* Point feature to move, one is appended with the empty `properties` a plain
|
|
129
|
+
* `L.marker` serialises to.
|
|
130
|
+
*/
|
|
131
|
+
export function formatGeoValue(coordinates: GeoValue, shape: GeoShape, previous?: string): string {
|
|
132
|
+
if (shape !== "featurecollection") return JSON.stringify(coordinates);
|
|
133
|
+
|
|
134
|
+
const point = {
|
|
135
|
+
type: "Point" as const,
|
|
136
|
+
coordinates: [coordinates.lng, coordinates.lat] as [number, number],
|
|
137
|
+
};
|
|
138
|
+
const existing = asFeatures(parseJson(previous));
|
|
139
|
+
let moved = false;
|
|
140
|
+
const features = existing.map((feature) => {
|
|
141
|
+
if (moved || !isPointFeature(feature)) return feature;
|
|
142
|
+
moved = true;
|
|
143
|
+
return { ...feature, geometry: point };
|
|
144
|
+
});
|
|
145
|
+
if (!moved) {
|
|
146
|
+
features.push({ type: "Feature", properties: {}, geometry: point });
|
|
147
|
+
}
|
|
148
|
+
return JSON.stringify({ type: "FeatureCollection", features });
|
|
149
|
+
}
|
|
@@ -9,6 +9,7 @@ import { Button } from "../../Button";
|
|
|
9
9
|
import { Field, FieldLayout } from "../../fieldLayout";
|
|
10
10
|
import { FloatingPanel } from "../../FloatingPanel";
|
|
11
11
|
import { GeolocationMap } from "./GeolocationMap";
|
|
12
|
+
import { detectGeoShape, formatGeoValue, type GeoValue, parseGeoValue } from "./geoValue";
|
|
12
13
|
import { Input as InputParts } from "../../InputParts";
|
|
13
14
|
import { Skeleton } from "../../Skeleton";
|
|
14
15
|
import {
|
|
@@ -27,11 +28,6 @@ import {
|
|
|
27
28
|
} from "../../shared/utils";
|
|
28
29
|
import type { AnyFormApi } from "../../types";
|
|
29
30
|
|
|
30
|
-
interface GeoValue {
|
|
31
|
-
lat: number;
|
|
32
|
-
lng: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
31
|
type LocatePhase = "idle" | "locating" | "error";
|
|
36
32
|
|
|
37
33
|
/**
|
|
@@ -79,18 +75,6 @@ function mapGeolocationError(
|
|
|
79
75
|
return err.message || t("Failed to get location");
|
|
80
76
|
}
|
|
81
77
|
|
|
82
|
-
function parseGeoValue(value: string | undefined): GeoValue | null {
|
|
83
|
-
if (!value || !value.trim()) return null;
|
|
84
|
-
try {
|
|
85
|
-
const parsed = JSON.parse(value) as Record<string, unknown>;
|
|
86
|
-
if (typeof parsed.lat !== "number" || typeof parsed.lng !== "number") return null;
|
|
87
|
-
if (!Number.isFinite(parsed.lat) || !Number.isFinite(parsed.lng)) return null;
|
|
88
|
-
return { lat: parsed.lat, lng: parsed.lng };
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
78
|
function clampLat(val: number): number {
|
|
95
79
|
return Math.max(-90, Math.min(90, val));
|
|
96
80
|
}
|
|
@@ -639,6 +623,9 @@ export function Geolocation({
|
|
|
639
623
|
const [preflightError, setPreflightError] = useState<string | undefined>();
|
|
640
624
|
const preflightingRef = useRef(false);
|
|
641
625
|
const onValueChangeRef = useRef<(value: string) => void>(() => {});
|
|
626
|
+
// MPO-100: the value being replaced, so an async locate writes back in the
|
|
627
|
+
// shape the doc arrived in instead of forcing this repo's compact shape.
|
|
628
|
+
const currentValueRef = useRef<string>("");
|
|
642
629
|
|
|
643
630
|
const requestPosition = useCallback(() => {
|
|
644
631
|
if (!navigator?.geolocation) {
|
|
@@ -652,7 +639,8 @@ export function Geolocation({
|
|
|
652
639
|
(position) => {
|
|
653
640
|
const lat = clampLat(position.coords.latitude);
|
|
654
641
|
const lng = clampLng(position.coords.longitude);
|
|
655
|
-
|
|
642
|
+
const previous = currentValueRef.current;
|
|
643
|
+
onValueChangeRef.current(formatGeoValue({ lat, lng }, detectGeoShape(previous), previous));
|
|
656
644
|
setLocatePhase("idle");
|
|
657
645
|
setLocateError(null);
|
|
658
646
|
},
|
|
@@ -691,15 +679,18 @@ export function Geolocation({
|
|
|
691
679
|
const makeHandlers = useCallback(
|
|
692
680
|
(currentValue: string, onValueChange: (value: string) => void) => {
|
|
693
681
|
onValueChangeRef.current = onValueChange;
|
|
682
|
+
currentValueRef.current = currentValue;
|
|
694
683
|
const coordinates = parseGeoValue(currentValue);
|
|
684
|
+
const shape = detectGeoShape(currentValue);
|
|
685
|
+
const write = (lat: number, lng: number) => {
|
|
686
|
+
onValueChange(
|
|
687
|
+
formatGeoValue({ lat: clampLat(lat), lng: clampLng(lng) }, shape, currentValue),
|
|
688
|
+
);
|
|
689
|
+
};
|
|
695
690
|
return {
|
|
696
691
|
coordinates,
|
|
697
|
-
onCommitCoordinates:
|
|
698
|
-
|
|
699
|
-
},
|
|
700
|
-
onMapChange: (lat: number, lng: number) => {
|
|
701
|
-
onValueChange(JSON.stringify({ lat: clampLat(lat), lng: clampLng(lng) }));
|
|
702
|
-
},
|
|
692
|
+
onCommitCoordinates: write,
|
|
693
|
+
onMapChange: write,
|
|
703
694
|
onUseMyLocation: () => {
|
|
704
695
|
void startLocate();
|
|
705
696
|
},
|
|
@@ -11,7 +11,7 @@ const meta: Meta<typeof Icon> = {
|
|
|
11
11
|
docs: {
|
|
12
12
|
description: {
|
|
13
13
|
component:
|
|
14
|
-
"Icon picker over FloatingPanel. Options come from the DG-IDN-06 drawn set. Field-shaped panel
|
|
14
|
+
"Icon picker over FloatingPanel. Options come from the DG-IDN-06 drawn set. Field-shaped panel uses widthMode at-least-trigger.",
|
|
15
15
|
},
|
|
16
16
|
},
|
|
17
17
|
},
|