diff --git a/packages/firecms_core/src/core/field_configs.tsx b/packages/firecms_core/src/core/field_configs.tsx index 7c6ca09da..6fc3aaf77 100644 --- a/packages/firecms_core/src/core/field_configs.tsx +++ b/packages/firecms_core/src/core/field_configs.tsx @@ -6,6 +6,7 @@ import { ArrayOfReferencesFieldBinding, BlockFieldBinding, DateTimeFieldBinding, + GeopointFieldBinding, KeyValueFieldBinding, MapFieldBinding, MarkdownEditorFieldBinding, @@ -32,6 +33,7 @@ import { ListAltIcon, ListIcon, MailIcon, + LocationOnIcon, NumbersIcon, PersonIcon, RepeatIcon, ScheduleIcon, @@ -271,6 +273,17 @@ export const DEFAULT_FIELD_CONFIGS: Record> = { Field: DateTimeFieldBinding } }, + geopoint: { + key: "geopoint", + name: "Geopoint", + description: "Latitude and longitude pair", + Icon: LocationOnIcon, + color: "#0ea5e9", + property: { + dataType: "geopoint", + Field: GeopointFieldBinding + } + }, group: { key: "group", name: "Group", @@ -412,6 +425,8 @@ export function getDefaultFieldId(property: Property | ResolvedProperty) { return "switch"; } else if (property.dataType === "date") { return "date_time"; + } else if (property.dataType === "geopoint") { + return "geopoint"; } else if (property.dataType === "reference") { return "reference"; } diff --git a/packages/firecms_core/src/form/field_bindings/GeopointFieldBinding.tsx b/packages/firecms_core/src/form/field_bindings/GeopointFieldBinding.tsx new file mode 100644 index 000000000..575224c52 --- /dev/null +++ b/packages/firecms_core/src/form/field_bindings/GeopointFieldBinding.tsx @@ -0,0 +1,140 @@ +import React, { useEffect, useRef, useState } from "react"; + +import { CloseIcon, IconButton, TextField } from "@firecms/ui"; +import { FieldProps, GeoPoint } from "../../types"; +import { FieldHelperText, LabelWithIcon } from "../components"; +import { PropertyIdCopyTooltip } from "../../components"; +import { useClearRestoreValue } from "../useClearRestoreValue"; +import { getIconForProperty } from "../../util"; +import { formatGeoPoint, getGeoPointCoordinates, parseGeoPoint } from "../../util/geopoint"; + +interface GeopointFieldBindingProps extends FieldProps { +} + +export function GeopointFieldBinding({ + propertyKey, + value, + setValue, + error, + showError, + disabled, + autoFocus, + property, + includeDescription, + size = "large" + }: GeopointFieldBindingProps) { + + const coordinates = getGeoPointCoordinates(value); + const canClear = Boolean((property as any).clearable); + const [latitude, setLatitude] = useState(coordinates ? coordinates.latitude.toString() : ""); + const [longitude, setLongitude] = useState(coordinates ? coordinates.longitude.toString() : ""); + const [localError, setLocalError] = useState(); + const skipSyncRef = useRef(false); + + useClearRestoreValue({ + property, + value, + setValue + }); + + useEffect(() => { + if (skipSyncRef.current) { + skipSyncRef.current = false; + return; + } + const nextCoordinates = getGeoPointCoordinates(value); + setLatitude(nextCoordinates ? nextCoordinates.latitude.toString() : ""); + setLongitude(nextCoordinates ? nextCoordinates.longitude.toString() : ""); + }, [value]); + + const updateGeoPoint = (nextLatitude: string, nextLongitude: string) => { + skipSyncRef.current = true; + setLatitude(nextLatitude); + setLongitude(nextLongitude); + + const trimmedLatitude = nextLatitude.trim(); + const trimmedLongitude = nextLongitude.trim(); + + if (!trimmedLatitude && !trimmedLongitude) { + setLocalError(undefined); + setValue(null); + return; + } + + const parsed = parseGeoPoint(`${trimmedLatitude}, ${trimmedLongitude}`); + + if (parsed.error) { + setLocalError(parsed.error); + setValue(null); + return; + } + + setLocalError(undefined); + setValue(parsed.point); + }; + + const handleClear = (event?: React.MouseEvent) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + updateGeoPoint("", ""); + }; + + const resolvedError = localError ?? error; + const shouldShowError = Boolean(resolvedError) || Boolean(showError && error); + + return ( + <> + +
+
+ +
+
+ updateGeoPoint(event.target.value, longitude)} + autoFocus={autoFocus} + label={"Latitude"} + type="number" + disabled={disabled} + endAdornment={canClear ? ( + + + + ) : undefined} + error={shouldShowError && Boolean(resolvedError)} + /> + updateGeoPoint(latitude, event.target.value)} + label={"Longitude"} + type="number" + disabled={disabled} + error={shouldShowError && Boolean(resolvedError)} + /> +
+ {value && !resolvedError && ( +
+ {formatGeoPoint(value)} +
+ )} +
+
+ + + + ); +} diff --git a/packages/firecms_core/src/form/index.tsx b/packages/firecms_core/src/form/index.tsx index ee524ef18..38834f048 100644 --- a/packages/firecms_core/src/form/index.tsx +++ b/packages/firecms_core/src/form/index.tsx @@ -11,6 +11,7 @@ export { StorageUploadFieldBinding } from "./field_bindings/StorageUploadFieldBi export { TextFieldBinding } from "./field_bindings/TextFieldBinding"; export { SwitchFieldBinding } from "./field_bindings/SwitchFieldBinding"; export { DateTimeFieldBinding } from "./field_bindings/DateTimeFieldBinding"; +export { GeopointFieldBinding } from "./field_bindings/GeopointFieldBinding"; export { ReferenceFieldBinding } from "./field_bindings/ReferenceFieldBinding"; export { ReferenceAsStringFieldBinding } from "./field_bindings/ReferenceAsStringFieldBinding"; export { MapFieldBinding } from "./field_bindings/MapFieldBinding"; diff --git a/packages/firecms_core/src/preview/PropertyPreview.tsx b/packages/firecms_core/src/preview/PropertyPreview.tsx index 516cc1251..73e0c792d 100644 --- a/packages/firecms_core/src/preview/PropertyPreview.tsx +++ b/packages/firecms_core/src/preview/PropertyPreview.tsx @@ -4,6 +4,7 @@ import equal from "react-fast-compare" import { CMSType, EntityReference, + GeoPoint, ResolvedArrayProperty, ResolvedMapProperty, ResolvedNumberProperty, @@ -26,12 +27,14 @@ import { ArrayPropertyEnumPreview } from "./property_previews/ArrayPropertyEnumP import { ArrayOfStringsPreview } from "./property_previews/ArrayOfStringsPreview"; import { ArrayOneOfPreview } from "./property_previews/ArrayOneOfPreview"; import { MapPropertyPreview } from "./property_previews/MapPropertyPreview"; +import { GeopointPropertyPreview } from "./property_previews/GeopointPropertyPreview"; import { ReferencePreview } from "./components/ReferencePreview"; import { DatePreview } from "./components/DatePreview"; import { BooleanPreview } from "./components/BooleanPreview"; import { NumberPropertyPreview } from "./property_previews/NumberPropertyPreview"; import { ErrorView } from "../components"; import { UserPreview } from "./components/UserPreview"; +import { getGeoPointCoordinates } from "../util"; /** * @group Preview components @@ -192,6 +195,15 @@ export const PropertyPreview = React.memo(function PropertyPreview; + } else { + content = buildWrongValueType(propertyKey, property.dataType, value); + } } else if (property.dataType === "reference") { if (typeof property.path === "string") { if (typeof value === "object" && "isEntityReference" in value && value.isEntityReference()) { diff --git a/packages/firecms_core/src/preview/index.ts b/packages/firecms_core/src/preview/index.ts index bab2bdde5..d10dc65c6 100644 --- a/packages/firecms_core/src/preview/index.ts +++ b/packages/firecms_core/src/preview/index.ts @@ -7,6 +7,7 @@ export * from "./property_previews/ArrayOfStringsPreview"; export * from "./property_previews/ArrayPropertyEnumPreview"; export * from "./property_previews/ArrayOfMapsPreview"; export * from "./property_previews/NumberPropertyPreview"; +export * from "./property_previews/GeopointPropertyPreview"; export * from "./property_previews/StringPropertyPreview"; export * from "./property_previews/ArrayOfStorageComponentsPreview"; export * from "./property_previews/ArrayOfReferencesPreview"; diff --git a/packages/firecms_core/src/preview/property_previews/GeopointPropertyPreview.tsx b/packages/firecms_core/src/preview/property_previews/GeopointPropertyPreview.tsx new file mode 100644 index 000000000..8541e7700 --- /dev/null +++ b/packages/firecms_core/src/preview/property_previews/GeopointPropertyPreview.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import { GeoPoint } from "../../types"; +import { PropertyPreviewProps } from "../PropertyPreviewProps"; +import { formatGeoPoint, getGeoPointCoordinates } from "../../util"; + +export function GeopointPropertyPreview({ + value, + size + }: PropertyPreviewProps): React.ReactElement { + + const coordinates = getGeoPointCoordinates(value); + + if (!coordinates) { + return ; + } + + return ( + + {formatGeoPoint(coordinates)} + + ); +} diff --git a/packages/firecms_core/src/util/entities.ts b/packages/firecms_core/src/util/entities.ts index e00a865f1..9e630bc43 100644 --- a/packages/firecms_core/src/util/entities.ts +++ b/packages/firecms_core/src/util/entities.ts @@ -74,6 +74,8 @@ export function getDefaultValueForDataType(dataType: DataType) { return []; } else if (dataType === "map") { return {}; + } else if (dataType === "geopoint") { + return null; } else { return null; } diff --git a/packages/firecms_core/src/util/geopoint.ts b/packages/firecms_core/src/util/geopoint.ts new file mode 100644 index 000000000..d064de181 --- /dev/null +++ b/packages/firecms_core/src/util/geopoint.ts @@ -0,0 +1,77 @@ +import { GeoPoint } from "../types"; + +export type GeoPointLike = GeoPoint | { + latitude?: number; + longitude?: number; + lat?: number; + lng?: number; + _lat?: number; + _long?: number; +} | null | undefined; + +export interface GeoPointCoordinates { + latitude: number; + longitude: number; +} + +function toNumber(value: any): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function getGeoPointCoordinates(value: GeoPointLike): GeoPointCoordinates | undefined { + if (!value) return undefined; + if (value instanceof GeoPoint) { + return { latitude: value.latitude, longitude: value.longitude }; + } + if (typeof value !== "object") return undefined; + + const latitude = toNumber((value as any).latitude ?? (value as any).lat ?? (value as any)._lat); + const longitude = toNumber((value as any).longitude ?? (value as any).lng ?? (value as any)._long); + + if (latitude === undefined || longitude === undefined) return undefined; + + return { latitude, longitude }; +} + +export function normalizeGeoPoint(value: GeoPointLike): GeoPoint | undefined { + const coordinates = getGeoPointCoordinates(value); + if (!coordinates) return undefined; + if (value instanceof GeoPoint) return value; + return new GeoPoint(coordinates.latitude, coordinates.longitude); +} + +export function formatGeoPoint(value: GeoPointLike, options?: { maximumFractionDigits?: number }): string { + const coordinates = getGeoPointCoordinates(value); + if (!coordinates) return ""; + + const maximumFractionDigits = options?.maximumFractionDigits ?? 6; + const formatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits, + minimumFractionDigits: Math.min(2, maximumFractionDigits) + }); + + return `${formatter.format(coordinates.latitude)}, ${formatter.format(coordinates.longitude)}`; +} + +export function parseGeoPoint(input: string): { point: GeoPoint | null; error?: string } { + const trimmed = input.trim(); + if (!trimmed) return { point: null }; + + const parts = trimmed.split(",").map((part) => part.trim()).filter((part) => part !== ""); + if (parts.length !== 2) return { point: null, error: "Use \"lat, lng\" format" }; + + const latitude = parseFloat(parts[0]); + const longitude = parseFloat(parts[1]); + + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return { point: null, error: "Latitude and longitude must be numbers" }; + } + if (latitude < -90 || latitude > 90) { + return { point: null, error: "Latitude must be between -90 and 90" }; + } + if (longitude < -180 || longitude > 180) { + return { point: null, error: "Longitude must be between -180 and 180" }; + } + + return { point: new GeoPoint(latitude, longitude) }; +} diff --git a/packages/firecms_core/src/util/index.ts b/packages/firecms_core/src/util/index.ts index ce0a4b6b4..8d4adb720 100644 --- a/packages/firecms_core/src/util/index.ts +++ b/packages/firecms_core/src/util/index.ts @@ -12,6 +12,7 @@ export * from "./useDebouncedCallback"; export * from "./property_utils"; export * from "./resolutions"; export * from "./permissions"; +export * from "./geopoint"; export * from "./icon_list"; export * from "./icon_synonyms"; export * from "./icons"; diff --git a/website/docs/properties/config/geopoint.md b/website/docs/properties/config/geopoint.md index 8df9cbc32..a0ce1cd88 100644 --- a/website/docs/properties/config/geopoint.md +++ b/website/docs/properties/config/geopoint.md @@ -4,5 +4,80 @@ title: Geopoint sidebar_label: Geopoint --- -> *THIS PROPERTY IS CURRENTLY NOT SUPPORTED* +The geopoint property is used to store geographic coordinates (latitude and +longitude pairs). + +```tsx +import { buildProperty } from "./builders"; + +const locationProperty = buildProperty({ + name: "Location", + description: "Geographic coordinates", + validation: { required: true }, + dataType: "geopoint" +}); +``` + +The geopoint field renders two number inputs for latitude and longitude with +built-in validation: +- Latitude values must be between -90 and 90 +- Longitude values must be between -180 and 180 +- Both values are displayed in a formatted string (e.g., "37.7749, -122.4194") + +### `clearable` +Add an icon to clear the value and set it to `null`. Defaults to `false` + +### `validation` + +* `required` Should this field be compulsory. +* `requiredMessage` Message to be displayed as a validation error. +* `unique` Unique constraint for this value. + +### Example + +```tsx +import { buildCollection, buildProperty, GeoPoint } from "@firecms/core"; + +interface Venue { + name: string; + location: GeoPoint; +} + +export const venuesCollection = buildCollection({ + name: "Venues", + singularName: "Venue", + path: "venues", + properties: { + name: buildProperty({ + name: "Name", + dataType: "string", + validation: { required: true } + }), + location: buildProperty({ + name: "Location", + description: "Geographic coordinates of the venue", + dataType: "geopoint", + validation: { required: true } + }) + } +}); +``` + +### Working with GeoPoint values + +The `GeoPoint` class is exported from `@firecms/core` and can be used to create +geopoint values programmatically: + +```tsx +import { GeoPoint } from "@firecms/core"; + +const sanFrancisco = new GeoPoint(37.7749, -122.4194); +console.log(sanFrancisco.latitude); // 37.7749 +console.log(sanFrancisco.longitude); // -122.4194 +``` + +--- + +Links: +- [API](../../api/interfaces/GeopointProperty)