@firecms/core 3.3.0 → 3.4.0-canary.f6a889a
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/app/useApp.d.ts +1 -0
- package/dist/components/EntityCollectionTable/column_utils.d.ts +4 -3
- package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
- package/dist/components/EntityPreview.d.ts +3 -1
- package/dist/core/DefaultDrawer.d.ts +10 -3
- package/dist/core/field_configs.d.ts +1 -1
- package/dist/form/field_bindings/GeopointFieldBinding.d.ts +5 -0
- package/dist/form/index.d.ts +1 -0
- package/dist/index.es.js +1979 -721
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1978 -720
- package/dist/index.umd.js.map +1 -1
- package/dist/locales/pl.d.ts +2 -0
- package/dist/preview/index.d.ts +1 -0
- package/dist/preview/property_previews/GeopointPropertyPreview.d.ts +4 -0
- package/dist/types/collections.d.ts +17 -0
- package/dist/util/entities.d.ts +1 -0
- package/dist/util/geopoint.d.ts +22 -0
- package/dist/util/index.d.ts +1 -0
- package/dist/util/navigation_blocking.d.ts +22 -0
- package/dist/util/navigation_from_path.d.ts +10 -0
- package/dist/util/navigation_utils.d.ts +20 -0
- package/package.json +16 -9
- package/src/app/Scaffold.tsx +34 -44
- package/src/app/useApp.tsx +1 -0
- package/src/components/EntityCollectionTable/EntityCollectionTable.tsx +2 -1
- package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
- package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
- package/src/components/EntityCollectionView/EntityCollectionViewStartActions.tsx +4 -1
- package/src/components/EntityCollectionView/FiltersDialog.tsx +39 -28
- package/src/components/EntityPreview.tsx +41 -40
- package/src/components/ReferenceWidget.tsx +1 -1
- package/src/components/SelectableTable/filters/ReferenceFilterField.tsx +2 -2
- package/src/components/VirtualTable/VirtualTable.tsx +19 -8
- package/src/components/common/useDataSourceTableController.tsx +66 -10
- package/src/core/DefaultDrawer.tsx +150 -64
- package/src/core/DrawerNavigationGroup.tsx +27 -29
- package/src/core/DrawerNavigationItem.tsx +10 -12
- package/src/core/EntityEditView.tsx +57 -32
- package/src/core/field_configs.tsx +15 -0
- package/src/form/field_bindings/ArrayOfReferencesFieldBinding.tsx +1 -1
- package/src/form/field_bindings/GeopointFieldBinding.tsx +139 -0
- package/src/form/index.tsx +1 -0
- package/src/i18n/FireCMSi18nProvider.tsx +2 -0
- package/src/internal/useBuildSideEntityController.tsx +2 -1
- package/src/locales/de.ts +2 -2
- package/src/locales/en.ts +2 -2
- package/src/locales/es.ts +2 -2
- package/src/locales/fr.ts +2 -2
- package/src/locales/hi.ts +2 -2
- package/src/locales/it.ts +2 -2
- package/src/locales/pl.ts +730 -0
- package/src/locales/pt.ts +2 -2
- package/src/preview/PropertyPreview.tsx +12 -0
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
- package/src/routes/FireCMSRoute.tsx +44 -25
- package/src/types/collections.ts +18 -0
- package/src/util/entities.ts +13 -0
- package/src/util/geopoint.ts +81 -0
- package/src/util/index.ts +1 -0
- package/src/util/navigation_blocking.ts +45 -0
- package/src/util/navigation_from_path.ts +23 -6
- package/src/util/navigation_utils.ts +36 -2
- package/src/util/parent_references_from_path.ts +4 -2
package/src/locales/pt.ts
CHANGED
|
@@ -151,8 +151,8 @@ export const pt: FireCMSTranslations = {
|
|
|
151
151
|
// ─── Error states ─────────────────────────────────────────────
|
|
152
152
|
error: "Erro",
|
|
153
153
|
error_uploading_file: "Erro ao carregar ficheiro",
|
|
154
|
-
error_deleting: "Erro ao eliminar",
|
|
155
|
-
error_before_delete: "Erro antes de eliminar",
|
|
154
|
+
error_deleting: "Erro ao eliminar: {{message}}",
|
|
155
|
+
error_before_delete: "Erro antes de eliminar: {{message}}",
|
|
156
156
|
error_updating_asset: "Erro ao atualizar recurso",
|
|
157
157
|
error_deleting_asset: "Erro ao eliminar recurso",
|
|
158
158
|
error_firestore_index: "É necessário um índice Firestore para esta consulta.",
|
|
@@ -4,6 +4,7 @@ import equal from "react-fast-compare"
|
|
|
4
4
|
import {
|
|
5
5
|
CMSType,
|
|
6
6
|
EntityReference,
|
|
7
|
+
GeoPoint,
|
|
7
8
|
ResolvedArrayProperty,
|
|
8
9
|
ResolvedMapProperty,
|
|
9
10
|
ResolvedNumberProperty,
|
|
@@ -27,12 +28,14 @@ import { ArrayPropertyEnumPreview } from "./property_previews/ArrayPropertyEnumP
|
|
|
27
28
|
import { ArrayOfStringsPreview } from "./property_previews/ArrayOfStringsPreview";
|
|
28
29
|
import { ArrayOneOfPreview } from "./property_previews/ArrayOneOfPreview";
|
|
29
30
|
import { MapPropertyPreview } from "./property_previews/MapPropertyPreview";
|
|
31
|
+
import { GeopointPropertyPreview } from "./property_previews/GeopointPropertyPreview";
|
|
30
32
|
import { ReferencePreview } from "./components/ReferencePreview";
|
|
31
33
|
import { DatePreview } from "./components/DatePreview";
|
|
32
34
|
import { BooleanPreview } from "./components/BooleanPreview";
|
|
33
35
|
import { NumberPropertyPreview } from "./property_previews/NumberPropertyPreview";
|
|
34
36
|
import { ErrorView } from "../components";
|
|
35
37
|
import { UserPreview } from "./components/UserPreview";
|
|
38
|
+
import { getGeoPointCoordinates } from "../util";
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* @group Preview components
|
|
@@ -202,6 +205,15 @@ export const PropertyPreview = React.memo(function PropertyPreview<T extends CMS
|
|
|
202
205
|
} else {
|
|
203
206
|
content = buildWrongValueType(propertyKey, property.dataType, value);
|
|
204
207
|
}
|
|
208
|
+
} else if (property.dataType === "geopoint") {
|
|
209
|
+
const coordinates = getGeoPointCoordinates(value as GeoPoint);
|
|
210
|
+
if (coordinates) {
|
|
211
|
+
content = <GeopointPropertyPreview {...props}
|
|
212
|
+
property={property}
|
|
213
|
+
value={value as GeoPoint}/>;
|
|
214
|
+
} else {
|
|
215
|
+
content = buildWrongValueType(propertyKey, property.dataType, value);
|
|
216
|
+
}
|
|
205
217
|
} else if (property.dataType === "reference") {
|
|
206
218
|
if (typeof property.path === "string") {
|
|
207
219
|
if (typeof value === "object" && "isEntityReference" in value && value.isEntityReference()) {
|
package/src/preview/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./property_previews/ArrayOfStringsPreview";
|
|
|
7
7
|
export * from "./property_previews/ArrayPropertyEnumPreview";
|
|
8
8
|
export * from "./property_previews/ArrayOfMapsPreview";
|
|
9
9
|
export * from "./property_previews/NumberPropertyPreview";
|
|
10
|
+
export * from "./property_previews/GeopointPropertyPreview";
|
|
10
11
|
export * from "./property_previews/StringPropertyPreview";
|
|
11
12
|
export * from "./property_previews/ArrayOfStorageComponentsPreview";
|
|
12
13
|
export * from "./property_previews/ArrayOfReferencesPreview";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
import { GeoPoint } from "../../types";
|
|
4
|
+
import { PropertyPreviewProps } from "../PropertyPreviewProps";
|
|
5
|
+
import { formatGeoPoint, getGeoPointCoordinates } from "../../util";
|
|
6
|
+
|
|
7
|
+
export function GeopointPropertyPreview({
|
|
8
|
+
value,
|
|
9
|
+
size
|
|
10
|
+
}: PropertyPreviewProps<GeoPoint>): React.ReactElement {
|
|
11
|
+
|
|
12
|
+
const coordinates = getGeoPointCoordinates(value);
|
|
13
|
+
|
|
14
|
+
if (!coordinates) {
|
|
15
|
+
return <span className={size === "small" ? "text-sm text-text-secondary dark:text-text-secondary-dark" : "text-text-secondary dark:text-text-secondary-dark"}>—</span>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<span className={size === "small" ? "text-sm font-mono" : "font-mono"}>
|
|
20
|
+
{formatGeoPoint(coordinates)}
|
|
21
|
+
</span>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "../util/navigation_from_path";
|
|
11
11
|
import { useBreadcrumbsController } from "../hooks/useBreadcrumbsController";
|
|
12
12
|
import { toArray } from "../util/arrays";
|
|
13
|
+
import { addInitialSlash, encodeEntityId } from "../util/navigation_utils";
|
|
14
|
+
import { shouldBlockEntityNavigation } from "../util/navigation_blocking";
|
|
13
15
|
import { NotFoundPage } from "../components";
|
|
14
16
|
import { lazyEager } from "../util/lazy_eager";
|
|
15
17
|
|
|
@@ -189,27 +191,51 @@ function EntityFullScreenRoute({
|
|
|
189
191
|
}
|
|
190
192
|
}, [urlTab]);
|
|
191
193
|
|
|
192
|
-
const
|
|
194
|
+
const lastCollectionEntry = navigationEntries.findLast((entry) => entry.type === "collection");
|
|
195
|
+
|
|
196
|
+
// The entity id reaches the URL escaped AND percent-encoded, so it cannot be located by
|
|
197
|
+
// searching `pathname` for the raw id — that misses for any id needing encoding (a "/",
|
|
198
|
+
// but equally a space or a non-ASCII character). Rebuild the URLs from the navigation
|
|
199
|
+
// entries instead, which already carry the escaped chain in `fullPath`.
|
|
200
|
+
const entityEntryIndex = lastEntityEntry ? navigationEntries.indexOf(lastEntityEntry) : -1;
|
|
201
|
+
const parentCollectionEntry = entityEntryIndex > 0
|
|
202
|
+
? navigationEntries[entityEntryIndex - 1] as NavigationViewCollectionInternal<any>
|
|
203
|
+
: undefined;
|
|
204
|
+
|
|
205
|
+
const buildUrl = (escapedPath: string) => addInitialSlash(navigation.buildUrlCollectionPath(escapedPath));
|
|
206
|
+
|
|
207
|
+
const basePath = !entityId || isNew || !parentCollectionEntry
|
|
193
208
|
? pathname
|
|
194
|
-
:
|
|
209
|
+
: buildUrl(parentCollectionEntry.fullPath);
|
|
210
|
+
|
|
211
|
+
const entityPath = lastEntityEntry ? buildUrl(lastEntityEntry.fullPath) : basePath;
|
|
195
212
|
|
|
196
|
-
|
|
213
|
+
/**
|
|
214
|
+
* Build the URL of an entity in the current collection. `id` is raw, so it is escaped
|
|
215
|
+
* before being joined; `buildUrlCollectionPath` then percent-encodes the whole path.
|
|
216
|
+
*/
|
|
217
|
+
const buildEntityUrl = (id: string, tab?: string) => {
|
|
218
|
+
const parentPath = parentCollectionEntry?.fullPath ?? lastCollectionEntry?.fullPath;
|
|
219
|
+
if (!parentPath) return pathname;
|
|
220
|
+
return buildUrl(`${parentPath}/${encodeEntityId(id)}${tab ? "/" + tab : ""}`);
|
|
221
|
+
};
|
|
197
222
|
|
|
198
223
|
let blocker: Blocker | undefined = undefined;
|
|
199
224
|
try {
|
|
200
225
|
blocker = useBlocker(({
|
|
226
|
+
currentLocation,
|
|
201
227
|
nextLocation
|
|
202
|
-
}) => {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
228
|
+
}) => shouldBlockEntityNavigation({
|
|
229
|
+
currentLocation,
|
|
230
|
+
nextLocation,
|
|
231
|
+
entityPath,
|
|
232
|
+
basePath,
|
|
233
|
+
blocked: blocked.current
|
|
234
|
+
}));
|
|
207
235
|
} catch (e) {
|
|
208
236
|
// console.warn("Blocker not available, navigation will not be blocked");
|
|
209
237
|
}
|
|
210
238
|
|
|
211
|
-
const lastCollectionEntry = navigationEntries.findLast((entry) => entry.type === "collection");
|
|
212
|
-
|
|
213
239
|
if (isNew && !lastCollectionEntry) {
|
|
214
240
|
throw new Error("INTERNAL: No collection found in the navigation");
|
|
215
241
|
}
|
|
@@ -219,8 +245,10 @@ function EntityFullScreenRoute({
|
|
|
219
245
|
}
|
|
220
246
|
|
|
221
247
|
const collection = isNew ? lastCollectionEntry!.collection : lastEntityEntry!.parentCollection;
|
|
222
|
-
|
|
223
|
-
|
|
248
|
+
// `fullIdPath` is used downstream to build URLs, so it carries the escaped chain;
|
|
249
|
+
// `collectionPath` addresses the datasource, so it is resolved from the raw one.
|
|
250
|
+
const fullIdPath = isNew ? lastCollectionEntry!.fullPath : (parentCollectionEntry?.fullPath ?? lastEntityEntry!.fullPath);
|
|
251
|
+
const collectionPath = navigation.resolveIdsFrom(isNew ? lastCollectionEntry!.path : lastEntityEntry!.path);
|
|
224
252
|
return <>
|
|
225
253
|
<React.Suspense fallback={null}>
|
|
226
254
|
<EntityEditView
|
|
@@ -234,25 +262,16 @@ function EntityFullScreenRoute({
|
|
|
234
262
|
selectedTab={selectedTab ?? undefined}
|
|
235
263
|
onValuesModified={(modified) => blocked.current = modified}
|
|
236
264
|
onSaved={(params) => {
|
|
237
|
-
const newSelectedTab = params.selectedTab;
|
|
238
265
|
const newEntityId = params.entityId;
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
} else {
|
|
242
|
-
navigate(`${basePath}/${newEntityId}`, { replace: true });
|
|
243
|
-
}
|
|
266
|
+
if (!newEntityId) return;
|
|
267
|
+
navigate(buildEntityUrl(newEntityId, params.selectedTab), { replace: true });
|
|
244
268
|
}}
|
|
245
269
|
onTabChange={(params) => {
|
|
246
270
|
setSelectedTab(params.selectedTab);
|
|
247
|
-
if (isNew) {
|
|
271
|
+
if (isNew || !entityId) {
|
|
248
272
|
return;
|
|
249
273
|
}
|
|
250
|
-
|
|
251
|
-
if (newSelectedTab) {
|
|
252
|
-
navigate(`${basePath}/${entityId}/${newSelectedTab}`, { replace: true });
|
|
253
|
-
} else {
|
|
254
|
-
navigate(`${basePath}/${entityId}`, { replace: true });
|
|
255
|
-
}
|
|
274
|
+
navigate(buildEntityUrl(entityId, params.selectedTab), { replace: true });
|
|
256
275
|
}}
|
|
257
276
|
parentCollectionIds={parentCollectionIds}
|
|
258
277
|
/>
|
package/src/types/collections.ts
CHANGED
|
@@ -261,6 +261,22 @@ export interface EntityCollection<M extends Record<string, any> = any, USER exte
|
|
|
261
261
|
*/
|
|
262
262
|
initialFilter?: FilterValues<Extract<keyof M, string>>; // setting FilterValues<M> can break defining collections by code
|
|
263
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Array of property keys that are allowed for filtering. Allowed filters will be displayed in the collection view, table row headers
|
|
266
|
+
* and will be used to filter data.
|
|
267
|
+
*
|
|
268
|
+
* If not specified, all properties that are filterable will be allowed.
|
|
269
|
+
*
|
|
270
|
+
* If you specify this prop, the filters will be displayed in the collection view if they are filterable.
|
|
271
|
+
*
|
|
272
|
+
* If you set it as an empty array, no filters will be displayed.
|
|
273
|
+
*
|
|
274
|
+
* e.g. `allowedFilters: ["name", "category"]`
|
|
275
|
+
*
|
|
276
|
+
* e.g. `allowedFilters: []`
|
|
277
|
+
*/
|
|
278
|
+
allowedFilters?: (keyof M)[];
|
|
279
|
+
|
|
264
280
|
/**
|
|
265
281
|
* Default sort applied to this collection.
|
|
266
282
|
* When setting this prop, entities will have a default order
|
|
@@ -725,6 +741,8 @@ export type EntityTableController<M extends Record<string, any> = any> = {
|
|
|
725
741
|
dataLoadingError?: Error;
|
|
726
742
|
filterValues?: FilterValues<Extract<keyof M, string>>;
|
|
727
743
|
setFilterValues?: (filterValues: FilterValues<Extract<keyof M, string>>) => void;
|
|
744
|
+
allowedFilters?: (keyof M)[];
|
|
745
|
+
forcedFilters?: (keyof M)[];
|
|
728
746
|
sortBy?: [Extract<keyof M, string>, "asc" | "desc"];
|
|
729
747
|
setSortBy?: (sortBy?: [Extract<keyof M, string>, "asc" | "desc"]) => void;
|
|
730
748
|
searchString?: string;
|
package/src/util/entities.ts
CHANGED
|
@@ -75,6 +75,8 @@ export function getDefaultValueForDataType(dataType: DataType) {
|
|
|
75
75
|
return [];
|
|
76
76
|
} else if (dataType === "map") {
|
|
77
77
|
return {};
|
|
78
|
+
} else if (dataType === "geopoint") {
|
|
79
|
+
return null;
|
|
78
80
|
} else {
|
|
79
81
|
return null;
|
|
80
82
|
}
|
|
@@ -137,6 +139,9 @@ export function sanitizeData<M extends Record<string, any>>
|
|
|
137
139
|
}
|
|
138
140
|
|
|
139
141
|
export function getReferenceFrom<M extends Record<string, any>>(entity: Entity<M>): EntityReference {
|
|
142
|
+
if (!entity) {
|
|
143
|
+
throw new Error("getReferenceFrom: entity is null or undefined");
|
|
144
|
+
}
|
|
140
145
|
return new EntityReference(entity.id, entity.path, entity.databaseId);
|
|
141
146
|
}
|
|
142
147
|
|
|
@@ -196,3 +201,11 @@ export function traverseValueProperty(inputValue: any,
|
|
|
196
201
|
|
|
197
202
|
return value;
|
|
198
203
|
}
|
|
204
|
+
|
|
205
|
+
export function isDataTypeFilterable(dataType: DataType, isPartOfArray = false) {
|
|
206
|
+
if (isPartOfArray) {
|
|
207
|
+
return ["string", "number", "date", "reference"].includes(dataType);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return ["string", "number", "boolean", "date", "reference", "array"].includes(dataType);
|
|
211
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { GeoPoint } from "../types";
|
|
2
|
+
|
|
3
|
+
export type GeoPointLike = GeoPoint | {
|
|
4
|
+
latitude?: number;
|
|
5
|
+
longitude?: number;
|
|
6
|
+
lat?: number;
|
|
7
|
+
lng?: number;
|
|
8
|
+
_lat?: number;
|
|
9
|
+
_long?: number;
|
|
10
|
+
} | null | undefined;
|
|
11
|
+
|
|
12
|
+
export interface GeoPointCoordinates {
|
|
13
|
+
latitude: number;
|
|
14
|
+
longitude: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function toNumber(value: any): number | undefined {
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getGeoPointCoordinates(value: GeoPointLike): GeoPointCoordinates | undefined {
|
|
22
|
+
if (!value) return undefined;
|
|
23
|
+
if (value instanceof GeoPoint) {
|
|
24
|
+
return { latitude: value.latitude, longitude: value.longitude };
|
|
25
|
+
}
|
|
26
|
+
if (typeof value !== "object") return undefined;
|
|
27
|
+
|
|
28
|
+
const latitude = toNumber((value as any).latitude ?? (value as any).lat ?? (value as any)._lat);
|
|
29
|
+
const longitude = toNumber((value as any).longitude ?? (value as any).lng ?? (value as any)._long);
|
|
30
|
+
|
|
31
|
+
if (latitude === undefined || longitude === undefined) return undefined;
|
|
32
|
+
|
|
33
|
+
return { latitude, longitude };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeGeoPoint(value: GeoPointLike): GeoPoint | undefined {
|
|
37
|
+
const coordinates = getGeoPointCoordinates(value);
|
|
38
|
+
if (!coordinates) return undefined;
|
|
39
|
+
if (value instanceof GeoPoint) return value;
|
|
40
|
+
return new GeoPoint(coordinates.latitude, coordinates.longitude);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatGeoPoint(value: GeoPointLike, options?: { maximumFractionDigits?: number }): string {
|
|
44
|
+
const coordinates = getGeoPointCoordinates(value);
|
|
45
|
+
if (!coordinates) return "";
|
|
46
|
+
|
|
47
|
+
const maximumFractionDigits = options?.maximumFractionDigits ?? 6;
|
|
48
|
+
// Coordinates are always formatted with a dot as decimal separator, independently of the
|
|
49
|
+
// user locale. Locale-aware formatting would use a comma in locales such as de or fr,
|
|
50
|
+
// making the "lat, lng" output ambiguous and impossible to parse back.
|
|
51
|
+
const formatter = new Intl.NumberFormat("en-US", {
|
|
52
|
+
maximumFractionDigits,
|
|
53
|
+
minimumFractionDigits: Math.min(2, maximumFractionDigits),
|
|
54
|
+
useGrouping: false
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return `${formatter.format(coordinates.latitude)}, ${formatter.format(coordinates.longitude)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function parseGeoPoint(input: string): { point: GeoPoint | null; error?: string } {
|
|
61
|
+
const trimmed = input.trim();
|
|
62
|
+
if (!trimmed) return { point: null };
|
|
63
|
+
|
|
64
|
+
const parts = trimmed.split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
65
|
+
if (parts.length !== 2) return { point: null, error: "Use \"lat, lng\" format" };
|
|
66
|
+
|
|
67
|
+
const latitude = parseFloat(parts[0]);
|
|
68
|
+
const longitude = parseFloat(parts[1]);
|
|
69
|
+
|
|
70
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
71
|
+
return { point: null, error: "Latitude and longitude must be numbers" };
|
|
72
|
+
}
|
|
73
|
+
if (latitude < -90 || latitude > 90) {
|
|
74
|
+
return { point: null, error: "Latitude must be between -90 and 90" };
|
|
75
|
+
}
|
|
76
|
+
if (longitude < -180 || longitude > 180) {
|
|
77
|
+
return { point: null, error: "Longitude must be between -180 and 180" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { point: new GeoPoint(latitude, longitude) };
|
|
81
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export * from "./useDebouncedCallback";
|
|
|
12
12
|
export * from "./property_utils";
|
|
13
13
|
export * from "./resolutions";
|
|
14
14
|
export * from "./permissions";
|
|
15
|
+
export * from "./geopoint";
|
|
15
16
|
export * from "./icon_list";
|
|
16
17
|
export * from "./icon_synonyms";
|
|
17
18
|
export * from "./icons";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure predicate deciding whether an in-progress navigation away from an open
|
|
3
|
+
* entity form should be blocked (to warn about unsaved changes).
|
|
4
|
+
*
|
|
5
|
+
* Extracted from FireCMSRoute so the branching can be unit-tested without
|
|
6
|
+
* mounting the router. This is an internal helper — it is intentionally not
|
|
7
|
+
* re-exported from the package index.
|
|
8
|
+
*/
|
|
9
|
+
export interface NavigationBlockLocation {
|
|
10
|
+
pathname: string;
|
|
11
|
+
hash: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SIDE_PANEL_HASHES = ["#side", "#new_side"];
|
|
15
|
+
|
|
16
|
+
export function shouldBlockEntityNavigation(params: {
|
|
17
|
+
currentLocation: NavigationBlockLocation;
|
|
18
|
+
nextLocation: NavigationBlockLocation;
|
|
19
|
+
/** Path of the currently open entity (basePath + "/" + entityId). */
|
|
20
|
+
entityPath: string;
|
|
21
|
+
/** Collection path the entity form lives under. */
|
|
22
|
+
basePath: string;
|
|
23
|
+
/** Whether the form currently has unsaved modifications. */
|
|
24
|
+
blocked: boolean;
|
|
25
|
+
}): boolean {
|
|
26
|
+
const { currentLocation, nextLocation, entityPath, basePath, blocked } = params;
|
|
27
|
+
|
|
28
|
+
// Navigating deeper within the same entity — never block.
|
|
29
|
+
if (nextLocation.pathname.startsWith(entityPath))
|
|
30
|
+
return false;
|
|
31
|
+
|
|
32
|
+
// Side panel overlay navigations preserve the underlying form via
|
|
33
|
+
// base_location in router state — no data is lost in either direction.
|
|
34
|
+
|
|
35
|
+
// Opening a side panel (e.g. clicking a relation/reference arrow).
|
|
36
|
+
if (SIDE_PANEL_HASHES.includes(nextLocation.hash))
|
|
37
|
+
return false;
|
|
38
|
+
|
|
39
|
+
// Closing a side panel (navigate(-1) back to the form's own path).
|
|
40
|
+
if (SIDE_PANEL_HASHES.includes(currentLocation.hash) &&
|
|
41
|
+
nextLocation.pathname === basePath)
|
|
42
|
+
return false;
|
|
43
|
+
|
|
44
|
+
return blocked;
|
|
45
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EntityCollection, EntityCustomView } from "../types";
|
|
2
|
-
import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
|
|
2
|
+
import { decodeEntityId, getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
|
|
3
3
|
import { resolveEntityView } from "./resolutions";
|
|
4
4
|
|
|
5
5
|
export type NavigationViewInternal<M extends Record<string, any> = any> =
|
|
@@ -34,11 +34,21 @@ export interface NavigationViewEntityCustomInternal<M extends Record<string, any
|
|
|
34
34
|
view: EntityCustomView<M>;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Note on encoding: `path` arrives from the URL, so any entity id in it is escaped (see
|
|
39
|
+
* `encodeEntityId`). Two chains are threaded through the recursion:
|
|
40
|
+
*
|
|
41
|
+
* - `currentFullPath` carries RAW ids and feeds the `path` / `fullPath` used to address
|
|
42
|
+
* the datasource.
|
|
43
|
+
* - `currentFullUrlPath` carries ESCAPED ids and feeds `fullPath`, which is handed back
|
|
44
|
+
* to `buildUrlCollectionPath` for breadcrumbs and links.
|
|
45
|
+
*/
|
|
37
46
|
export function getNavigationEntriesFromPath(props: {
|
|
38
47
|
path: string,
|
|
39
48
|
collections: EntityCollection[] | undefined,
|
|
40
49
|
currentFullPath?: string,
|
|
41
50
|
currentFullIdPath?: string,
|
|
51
|
+
currentFullUrlPath?: string,
|
|
42
52
|
contextEntityViews?: EntityCustomView<any>[]
|
|
43
53
|
}): NavigationViewInternal [] {
|
|
44
54
|
|
|
@@ -46,7 +56,8 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
46
56
|
path,
|
|
47
57
|
collections = [],
|
|
48
58
|
currentFullPath,
|
|
49
|
-
currentFullIdPath
|
|
59
|
+
currentFullIdPath,
|
|
60
|
+
currentFullUrlPath
|
|
50
61
|
} = props;
|
|
51
62
|
|
|
52
63
|
const subpaths = removeInitialAndTrailingSlashes(path).split("/");
|
|
@@ -66,6 +77,9 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
66
77
|
const collectionPath = currentFullPath && currentFullPath.length > 0
|
|
67
78
|
? (currentFullPath + "/" + collection.path)
|
|
68
79
|
: collection.path;
|
|
80
|
+
const collectionUrlPath = currentFullUrlPath && currentFullUrlPath.length > 0
|
|
81
|
+
? (currentFullUrlPath + "/" + collection.path)
|
|
82
|
+
: collection.path;
|
|
69
83
|
const fullIdPath = currentFullIdPath && currentFullIdPath.length > 0
|
|
70
84
|
? (currentFullIdPath + "/" + collection.id)
|
|
71
85
|
: collection.id;
|
|
@@ -73,21 +87,23 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
73
87
|
type: "collection",
|
|
74
88
|
id: collection.id,
|
|
75
89
|
path: collectionPath,
|
|
76
|
-
fullPath:
|
|
90
|
+
fullPath: collectionUrlPath,
|
|
77
91
|
fullIdPath,
|
|
78
92
|
collection
|
|
79
93
|
});
|
|
80
94
|
const restOfThePath = removeInitialAndTrailingSlashes(removeInitialAndTrailingSlashes(path).replace(subpathCombination, ""));
|
|
81
95
|
const nextSegments = restOfThePath.length > 0 ? restOfThePath.split("/") : [];
|
|
82
96
|
if (nextSegments.length > 0) {
|
|
83
|
-
const
|
|
97
|
+
const encodedEntityId = nextSegments[0];
|
|
98
|
+
const entityId = decodeEntityId(encodedEntityId);
|
|
84
99
|
const fullPath = collectionPath + "/" + entityId;
|
|
100
|
+
const fullUrlPath = collectionUrlPath + "/" + encodedEntityId;
|
|
85
101
|
result.push({
|
|
86
102
|
type: "entity",
|
|
87
103
|
entityId,
|
|
88
104
|
path: collectionPath,
|
|
89
105
|
fullIdPath,
|
|
90
|
-
fullPath:
|
|
106
|
+
fullPath: fullUrlPath,
|
|
91
107
|
parentCollection: collection
|
|
92
108
|
});
|
|
93
109
|
if (nextSegments.length > 1) {
|
|
@@ -106,7 +122,7 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
106
122
|
path: collectionPath,
|
|
107
123
|
entityId: entityId,
|
|
108
124
|
fullIdPath,
|
|
109
|
-
fullPath:
|
|
125
|
+
fullPath: fullUrlPath + "/" + customView.key,
|
|
110
126
|
view: customView
|
|
111
127
|
});
|
|
112
128
|
} else if (collection.subcollections) {
|
|
@@ -115,6 +131,7 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
115
131
|
collections: collection.subcollections,
|
|
116
132
|
currentFullPath: fullPath,
|
|
117
133
|
currentFullIdPath: fullIdPath,
|
|
134
|
+
currentFullUrlPath: fullUrlPath,
|
|
118
135
|
contextEntityViews: props.contextEntityViews
|
|
119
136
|
}));
|
|
120
137
|
}
|
|
@@ -22,6 +22,40 @@ export function addInitialSlash(s: string) {
|
|
|
22
22
|
else return `/${s}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Characters that would otherwise be read as structure once an entity id is joined into a
|
|
27
|
+
* path: "/" separates segments, "?" and "#" start the query and hash in a URL, and "%"
|
|
28
|
+
* has to be escaped first so that no escape sequence we introduce can be misread as one
|
|
29
|
+
* that was already part of the id.
|
|
30
|
+
*
|
|
31
|
+
* Entity ids are escaped ONLY inside URL-facing strings — anything handed to
|
|
32
|
+
* `buildUrlCollectionPath` or `navigate`. Every other path string (the `path` field of a
|
|
33
|
+
* navigation entry, datasource paths, `EntityReference.path`) carries raw ids.
|
|
34
|
+
*
|
|
35
|
+
* @group Hooks and utilities
|
|
36
|
+
*/
|
|
37
|
+
export function encodeEntityId(entityId: string): string {
|
|
38
|
+
return entityId
|
|
39
|
+
.replaceAll("%", "%25")
|
|
40
|
+
.replaceAll("/", "%2F")
|
|
41
|
+
.replaceAll("#", "%23")
|
|
42
|
+
.replaceAll("?", "%3F");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Inverse of {@link encodeEntityId}. "%25" is undone last, mirroring the encoder, so an id
|
|
47
|
+
* that legitimately contains the text "%2F" survives the round trip.
|
|
48
|
+
*
|
|
49
|
+
* @group Hooks and utilities
|
|
50
|
+
*/
|
|
51
|
+
export function decodeEntityId(encodedEntityId: string): string {
|
|
52
|
+
return encodedEntityId
|
|
53
|
+
.replaceAll("%2F", "/")
|
|
54
|
+
.replaceAll("%23", "#")
|
|
55
|
+
.replaceAll("%3F", "?")
|
|
56
|
+
.replaceAll("%25", "%");
|
|
57
|
+
}
|
|
58
|
+
|
|
25
59
|
export function getLastSegment(path: string) {
|
|
26
60
|
const cleanPath = removeInitialAndTrailingSlashes(path);
|
|
27
61
|
if (cleanPath.includes("/")) {
|
|
@@ -160,7 +194,7 @@ export function getCollectionByPathOrId(pathOrId: string, collections: EntityCol
|
|
|
160
194
|
* @param subpaths
|
|
161
195
|
*/
|
|
162
196
|
export function getCollectionPathsCombinations(subpaths: string[]): string[] {
|
|
163
|
-
const entries = subpaths.length > 0 && subpaths.length % 2 === 0 ? subpaths.
|
|
197
|
+
const entries = subpaths.length > 0 && subpaths.length % 2 === 0 ? subpaths.slice(0, subpaths.length - 1) : subpaths;
|
|
164
198
|
|
|
165
199
|
const length = entries.length;
|
|
166
200
|
const result: string[] = [];
|
|
@@ -211,7 +245,7 @@ export function navigateToEntity({
|
|
|
211
245
|
});
|
|
212
246
|
|
|
213
247
|
} else {
|
|
214
|
-
let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${entityId}` : fullIdPath ?? path);
|
|
248
|
+
let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${encodeEntityId(entityId)}` : fullIdPath ?? path);
|
|
215
249
|
if (entityId && selectedTab) {
|
|
216
250
|
to += `/${selectedTab}`;
|
|
217
251
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { EntityCollection, EntityReference } from "../types";
|
|
2
|
-
import { getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
|
|
2
|
+
import { decodeEntityId, getCollectionPathsCombinations, removeInitialAndTrailingSlashes } from "./navigation_utils";
|
|
3
3
|
|
|
4
4
|
export function getParentReferencesFromPath(props: {
|
|
5
5
|
path: string,
|
|
@@ -31,7 +31,9 @@ export function getParentReferencesFromPath(props: {
|
|
|
31
31
|
const restOfThePath = removeInitialAndTrailingSlashes(removeInitialAndTrailingSlashes(path).replace(subpathCombination, ""));
|
|
32
32
|
const nextSegments = restOfThePath.length > 0 ? restOfThePath.split("/") : [];
|
|
33
33
|
if (nextSegments.length > 0) {
|
|
34
|
-
|
|
34
|
+
// `path` comes from the URL, so the id segment is escaped. References are
|
|
35
|
+
// datasource-facing, so they carry the raw id.
|
|
36
|
+
const entityId = decodeEntityId(nextSegments[0]);
|
|
35
37
|
const fullPath = collectionPath + "/" + entityId;
|
|
36
38
|
result.push(new EntityReference(entityId, collectionPath));
|
|
37
39
|
if (nextSegments.length > 1) {
|