@firecms/core 3.3.0 → 3.4.0-canary.63041d0
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/EntityCollectionTable/internal/CollectionTableToolbar.d.ts +6 -1
- package/dist/components/EntityCollectionView/EntityCollectionView.d.ts +7 -0
- package/dist/components/EntityCollectionView/FiltersDialog.d.ts +2 -1
- package/dist/components/EntityPreview.d.ts +3 -1
- package/dist/components/SelectableTable/filters/filter_text_input.d.ts +30 -0
- package/dist/components/SelectableTable/filters/filter_text_input.test.d.ts +1 -0
- package/dist/components/common/table_url_params.d.ts +24 -0
- package/dist/components/common/useDataSourceTableController.d.ts +7 -1
- package/dist/components/common/useTableSearchHelper.d.ts +7 -1
- package/dist/core/DefaultDrawer.d.ts +10 -3
- package/dist/core/EntityEditView.d.ts +7 -1
- 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/hooks/data/save.d.ts +1 -1
- package/dist/hooks/data/useEntityFetch.d.ts +6 -1
- package/dist/index.es.js +2790 -1270
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2788 -1268
- 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 +24 -0
- package/dist/types/datasource.d.ts +42 -0
- package/dist/types/properties.d.ts +17 -1
- package/dist/types/side_entity_controller.d.ts +6 -0
- package/dist/types/translations.d.ts +6 -0
- package/dist/util/__tests__/collections.test.d.ts +1 -0
- package/dist/util/__tests__/urls.test.d.ts +1 -0
- package/dist/util/collections.d.ts +1 -1
- package/dist/util/entities.d.ts +1 -0
- package/dist/util/geopoint.d.ts +22 -0
- package/dist/util/index.d.ts +2 -0
- package/dist/util/navigation_blocking.d.ts +22 -0
- package/dist/util/navigation_from_path.d.ts +17 -0
- package/dist/util/navigation_utils.d.ts +23 -1
- package/dist/util/urls.d.ts +22 -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 +3 -1
- package/src/components/EntityCollectionTable/column_utils.tsx +11 -19
- package/src/components/EntityCollectionTable/fields/TableReferenceField.tsx +1 -1
- package/src/components/EntityCollectionTable/internal/CollectionTableToolbar.tsx +7 -0
- package/src/components/EntityCollectionView/EntityCollectionView.tsx +19 -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/SelectableTable/filters/StringNumberFilterField.tsx +33 -7
- package/src/components/SelectableTable/filters/filter_text_input.test.ts +184 -0
- package/src/components/SelectableTable/filters/filter_text_input.ts +71 -0
- package/src/components/VirtualTable/VirtualTable.tsx +19 -8
- package/src/components/common/table_url_params.ts +172 -0
- package/src/components/common/useDataSourceTableController.tsx +98 -159
- package/src/components/common/useTableSearchHelper.ts +21 -2
- package/src/contexts/SnackbarProvider.tsx +14 -1
- 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 +75 -32
- package/src/core/EntitySidePanel.tsx +2 -0
- package/src/core/field_configs.tsx +15 -0
- package/src/form/EntityForm.tsx +1 -1
- 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/hooks/data/save.ts +2 -0
- package/src/hooks/data/useEntityFetch.tsx +9 -0
- package/src/hooks/useValidateAuthenticator.tsx +4 -0
- package/src/i18n/FireCMSi18nProvider.tsx +2 -0
- package/src/internal/useBuildDataSource.ts +11 -0
- package/src/internal/useBuildSideEntityController.tsx +8 -1
- package/src/locales/de.ts +7 -2
- package/src/locales/en.ts +7 -2
- package/src/locales/es.ts +7 -2
- package/src/locales/fr.ts +7 -2
- package/src/locales/hi.ts +7 -2
- package/src/locales/it.ts +7 -2
- package/src/locales/pl.ts +735 -0
- package/src/locales/pt.ts +7 -2
- package/src/preview/PropertyPreview.tsx +12 -0
- package/src/preview/components/StorageThumbnail.tsx +24 -2
- package/src/preview/index.ts +1 -0
- package/src/preview/property_previews/GeopointPropertyPreview.tsx +23 -0
- package/src/routes/FireCMSRoute.tsx +47 -25
- package/src/types/collections.ts +26 -0
- package/src/types/datasource.ts +42 -0
- package/src/types/properties.ts +17 -0
- package/src/types/side_entity_controller.tsx +6 -0
- package/src/types/translations.ts +6 -0
- package/src/util/__tests__/collections.test.ts +32 -0
- package/src/util/__tests__/urls.test.ts +131 -0
- package/src/util/collections.ts +1 -1
- package/src/util/entities.ts +13 -0
- package/src/util/geopoint.ts +81 -0
- package/src/util/index.ts +2 -0
- package/src/util/navigation_blocking.ts +45 -0
- package/src/util/navigation_from_path.ts +41 -6
- package/src/util/navigation_utils.ts +40 -2
- package/src/util/parent_references_from_path.ts +4 -2
- package/src/util/previews.ts +8 -1
- package/src/util/resolutions.ts +8 -0
- package/src/util/urls.ts +52 -0
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
|
@@ -6,12 +6,14 @@ export * from "./enums";
|
|
|
6
6
|
export * from "./objects";
|
|
7
7
|
export * from "./paths";
|
|
8
8
|
export * from "./regexp";
|
|
9
|
+
export * from "./urls";
|
|
9
10
|
export * from "./navigation_utils";
|
|
10
11
|
export * from "./entity_actions";
|
|
11
12
|
export * from "./useDebouncedCallback";
|
|
12
13
|
export * from "./property_utils";
|
|
13
14
|
export * from "./resolutions";
|
|
14
15
|
export * from "./permissions";
|
|
16
|
+
export * from "./geopoint";
|
|
15
17
|
export * from "./icon_list";
|
|
16
18
|
export * from "./icon_synonyms";
|
|
17
19
|
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> =
|
|
@@ -13,6 +13,8 @@ export interface NavigationViewEntityInternal<M extends Record<string, any>> {
|
|
|
13
13
|
path: string;
|
|
14
14
|
fullIdPath: string;
|
|
15
15
|
fullPath: string;
|
|
16
|
+
/** `path` split at its real boundaries — see `pathSegments` on the datasource props. */
|
|
17
|
+
pathSegments: string[];
|
|
16
18
|
parentCollection: EntityCollection<M>;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -22,6 +24,8 @@ export interface NavigationViewCollectionInternal<M extends Record<string, any>>
|
|
|
22
24
|
path: string;
|
|
23
25
|
fullIdPath: string;
|
|
24
26
|
fullPath: string;
|
|
27
|
+
/** `path` split at its real boundaries — see `pathSegments` on the datasource props. */
|
|
28
|
+
pathSegments: string[];
|
|
25
29
|
collection: EntityCollection<M>;
|
|
26
30
|
}
|
|
27
31
|
|
|
@@ -30,15 +34,28 @@ export interface NavigationViewEntityCustomInternal<M extends Record<string, any
|
|
|
30
34
|
path: string;
|
|
31
35
|
fullIdPath: string;
|
|
32
36
|
fullPath: string;
|
|
37
|
+
/** `path` split at its real boundaries — see `pathSegments` on the datasource props. */
|
|
38
|
+
pathSegments: string[];
|
|
33
39
|
entityId: string;
|
|
34
40
|
view: EntityCustomView<M>;
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Note on encoding: `path` arrives from the URL, so any entity id in it is escaped (see
|
|
45
|
+
* `encodeEntityId`). Two chains are threaded through the recursion:
|
|
46
|
+
*
|
|
47
|
+
* - `currentFullPath` carries RAW ids and feeds the `path` / `fullPath` used to address
|
|
48
|
+
* the datasource.
|
|
49
|
+
* - `currentFullUrlPath` carries ESCAPED ids and feeds `fullPath`, which is handed back
|
|
50
|
+
* to `buildUrlCollectionPath` for breadcrumbs and links.
|
|
51
|
+
*/
|
|
37
52
|
export function getNavigationEntriesFromPath(props: {
|
|
38
53
|
path: string,
|
|
39
54
|
collections: EntityCollection[] | undefined,
|
|
40
55
|
currentFullPath?: string,
|
|
41
56
|
currentFullIdPath?: string,
|
|
57
|
+
currentFullUrlPath?: string,
|
|
58
|
+
currentPathSegments?: string[],
|
|
42
59
|
contextEntityViews?: EntityCustomView<any>[]
|
|
43
60
|
}): NavigationViewInternal [] {
|
|
44
61
|
|
|
@@ -46,7 +63,9 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
46
63
|
path,
|
|
47
64
|
collections = [],
|
|
48
65
|
currentFullPath,
|
|
49
|
-
currentFullIdPath
|
|
66
|
+
currentFullIdPath,
|
|
67
|
+
currentFullUrlPath,
|
|
68
|
+
currentPathSegments = []
|
|
50
69
|
} = props;
|
|
51
70
|
|
|
52
71
|
const subpaths = removeInitialAndTrailingSlashes(path).split("/");
|
|
@@ -66,28 +85,41 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
66
85
|
const collectionPath = currentFullPath && currentFullPath.length > 0
|
|
67
86
|
? (currentFullPath + "/" + collection.path)
|
|
68
87
|
: collection.path;
|
|
88
|
+
const collectionUrlPath = currentFullUrlPath && currentFullUrlPath.length > 0
|
|
89
|
+
? (currentFullUrlPath + "/" + collection.path)
|
|
90
|
+
: collection.path;
|
|
69
91
|
const fullIdPath = currentFullIdPath && currentFullIdPath.length > 0
|
|
70
92
|
? (currentFullIdPath + "/" + collection.id)
|
|
71
93
|
: collection.id;
|
|
94
|
+
// A collection `path` may itself span several segments (e.g. "users/uid/experiences"),
|
|
95
|
+
// and those are unambiguous, so they are spread rather than kept whole.
|
|
96
|
+
const collectionSegments = [...currentPathSegments, ...collection.path.split("/")];
|
|
72
97
|
result.push({
|
|
73
98
|
type: "collection",
|
|
74
99
|
id: collection.id,
|
|
75
100
|
path: collectionPath,
|
|
76
|
-
fullPath:
|
|
101
|
+
fullPath: collectionUrlPath,
|
|
77
102
|
fullIdPath,
|
|
103
|
+
pathSegments: collectionSegments,
|
|
78
104
|
collection
|
|
79
105
|
});
|
|
80
106
|
const restOfThePath = removeInitialAndTrailingSlashes(removeInitialAndTrailingSlashes(path).replace(subpathCombination, ""));
|
|
81
107
|
const nextSegments = restOfThePath.length > 0 ? restOfThePath.split("/") : [];
|
|
82
108
|
if (nextSegments.length > 0) {
|
|
83
|
-
const
|
|
109
|
+
const encodedEntityId = nextSegments[0];
|
|
110
|
+
const entityId = decodeEntityId(encodedEntityId);
|
|
84
111
|
const fullPath = collectionPath + "/" + entityId;
|
|
112
|
+
const fullUrlPath = collectionUrlPath + "/" + encodedEntityId;
|
|
113
|
+
// The id is ONE segment however many slashes it contains — that is the
|
|
114
|
+
// whole point of this array.
|
|
115
|
+
const entitySegments = [...collectionSegments, entityId];
|
|
85
116
|
result.push({
|
|
86
117
|
type: "entity",
|
|
87
118
|
entityId,
|
|
88
119
|
path: collectionPath,
|
|
89
120
|
fullIdPath,
|
|
90
|
-
fullPath:
|
|
121
|
+
fullPath: fullUrlPath,
|
|
122
|
+
pathSegments: collectionSegments,
|
|
91
123
|
parentCollection: collection
|
|
92
124
|
});
|
|
93
125
|
if (nextSegments.length > 1) {
|
|
@@ -106,7 +138,8 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
106
138
|
path: collectionPath,
|
|
107
139
|
entityId: entityId,
|
|
108
140
|
fullIdPath,
|
|
109
|
-
fullPath:
|
|
141
|
+
fullPath: fullUrlPath + "/" + customView.key,
|
|
142
|
+
pathSegments: collectionSegments,
|
|
110
143
|
view: customView
|
|
111
144
|
});
|
|
112
145
|
} else if (collection.subcollections) {
|
|
@@ -115,6 +148,8 @@ export function getNavigationEntriesFromPath(props: {
|
|
|
115
148
|
collections: collection.subcollections,
|
|
116
149
|
currentFullPath: fullPath,
|
|
117
150
|
currentFullIdPath: fullIdPath,
|
|
151
|
+
currentFullUrlPath: fullUrlPath,
|
|
152
|
+
currentPathSegments: entitySegments,
|
|
118
153
|
contextEntityViews: props.contextEntityViews
|
|
119
154
|
}));
|
|
120
155
|
}
|
|
@@ -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[] = [];
|
|
@@ -178,6 +212,7 @@ export function navigateToEntity({
|
|
|
178
212
|
copy,
|
|
179
213
|
path,
|
|
180
214
|
fullIdPath,
|
|
215
|
+
pathSegments,
|
|
181
216
|
selectedTab,
|
|
182
217
|
sideEntityController,
|
|
183
218
|
onClose,
|
|
@@ -192,6 +227,8 @@ export function navigateToEntity({
|
|
|
192
227
|
copy?: boolean;
|
|
193
228
|
path: string;
|
|
194
229
|
fullIdPath?: string;
|
|
230
|
+
/** `path` split at its real segment boundaries. */
|
|
231
|
+
pathSegments?: string[];
|
|
195
232
|
sideEntityController: SideEntityController;
|
|
196
233
|
onClose?: () => void;
|
|
197
234
|
navigation: NavigationController
|
|
@@ -203,6 +240,7 @@ export function navigateToEntity({
|
|
|
203
240
|
entityId,
|
|
204
241
|
path,
|
|
205
242
|
fullIdPath,
|
|
243
|
+
pathSegments,
|
|
206
244
|
copy,
|
|
207
245
|
selectedTab,
|
|
208
246
|
collection,
|
|
@@ -211,7 +249,7 @@ export function navigateToEntity({
|
|
|
211
249
|
});
|
|
212
250
|
|
|
213
251
|
} else {
|
|
214
|
-
let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${entityId}` : fullIdPath ?? path);
|
|
252
|
+
let to = navigation.buildUrlCollectionPath(entityId ? `${fullIdPath ?? path}/${encodeEntityId(entityId)}` : fullIdPath ?? path);
|
|
215
253
|
if (entityId && selectedTab) {
|
|
216
254
|
to += `/${selectedTab}`;
|
|
217
255
|
}
|
|
@@ -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) {
|
package/src/util/previews.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthController, EntityCollection, Property, PropertyConfig, ResolvedEntityCollection } from "../types";
|
|
1
|
+
import { AuthController, EntityCollection, Property, PropertyConfig, ResolvedEntityCollection, ResolvedProperties } from "../types";
|
|
2
2
|
import { isReferenceProperty } from "./property_utils";
|
|
3
3
|
import { isPropertyBuilder } from "./entities";
|
|
4
4
|
import { getFieldConfig } from "../core";
|
|
@@ -44,6 +44,13 @@ export function getEntityTitlePropertyKey<M extends Record<string, any>>(collect
|
|
|
44
44
|
|
|
45
45
|
export function getEntityImagePreviewPropertyKey<M extends object>(collection: ResolvedEntityCollection<M>): string | undefined {
|
|
46
46
|
|
|
47
|
+
if (collection.imageProperty) {
|
|
48
|
+
const imagePropertyKey = collection.imageProperty as keyof ResolvedProperties<M>;
|
|
49
|
+
if (collection.properties[imagePropertyKey]) {
|
|
50
|
+
return collection.imageProperty as string;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
// find first storage property of type image
|
|
48
55
|
for (const key in collection.properties) {
|
|
49
56
|
const property = collection.properties[key];
|
package/src/util/resolutions.ts
CHANGED
|
@@ -305,7 +305,15 @@ export function resolveArrayProperty<T extends any[], M>({
|
|
|
305
305
|
ignoreMissingFields,
|
|
306
306
|
...props
|
|
307
307
|
});
|
|
308
|
+
// `of` describes the shape of any element of the array, not one
|
|
309
|
+
// specific element, so there is no index to qualify the key with.
|
|
310
|
+
// We pass the array's own key, mirroring what the `oneOf` branch
|
|
311
|
+
// below does for `oneOf.properties`. Without this, a property
|
|
312
|
+
// builder used as `of` (or nested inside it) would be invoked with
|
|
313
|
+
// `propertyKey: undefined`, while the per-index resolutions above
|
|
314
|
+
// receive `${propertyKey}.${index}`.
|
|
308
315
|
const ofProperty = resolveProperty({
|
|
316
|
+
propertyKey,
|
|
309
317
|
propertyOrBuilder: of,
|
|
310
318
|
ignoreMissingFields,
|
|
311
319
|
...props
|
package/src/util/urls.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PreviewType } from "../types";
|
|
2
|
+
|
|
3
|
+
const ABSOLUTE_HTTP_URL_REGEX = /^https?:\/\//i;
|
|
4
|
+
|
|
5
|
+
const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "avif", "bmp", "svg", "ico", "tif", "tiff", "heic", "heif"];
|
|
6
|
+
const VIDEO_EXTENSIONS = ["mp4", "webm", "ogv", "mov", "m4v", "avi", "mkv", "mpeg", "mpg", "3gp"];
|
|
7
|
+
const AUDIO_EXTENSIONS = ["mp3", "wav", "ogg", "oga", "m4a", "aac", "flac", "weba", "opus", "mid", "midi"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Is the given value an absolute `http(s)` URL, as opposed to a relative
|
|
11
|
+
* storage path such as `images/my_image.png`?
|
|
12
|
+
*
|
|
13
|
+
* Values saved in the datasource for storage properties can be either, e.g.
|
|
14
|
+
* when the property is configured with `storeUrl: true`, or when the value was
|
|
15
|
+
* written directly pointing to an externally hosted file.
|
|
16
|
+
*/
|
|
17
|
+
export function isAbsoluteHttpUrl(value?: string | null): boolean {
|
|
18
|
+
if (!value) return false;
|
|
19
|
+
return ABSOLUTE_HTTP_URL_REGEX.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract the lowercase file extension of a URL or path, ignoring any query
|
|
24
|
+
* string or hash fragment. Returns `undefined` when there is no extension.
|
|
25
|
+
*/
|
|
26
|
+
export function getFileExtensionFromUrl(urlOrPath?: string | null): string | undefined {
|
|
27
|
+
if (!urlOrPath) return undefined;
|
|
28
|
+
// Query strings and hash fragments are not part of the file name, and they
|
|
29
|
+
// are common in download URLs, e.g. `?alt=media&token=...`
|
|
30
|
+
const withoutQuery = urlOrPath.split(/[?#]/)[0];
|
|
31
|
+
const lastSegment = withoutQuery.split("/").pop();
|
|
32
|
+
if (!lastSegment) return undefined;
|
|
33
|
+
const lastDot = lastSegment.lastIndexOf(".");
|
|
34
|
+
// No dot, a leading dot (a dotfile), or a trailing dot means no extension
|
|
35
|
+
if (lastDot <= 0 || lastDot === lastSegment.length - 1) return undefined;
|
|
36
|
+
return lastSegment.substring(lastDot + 1).toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Best effort guess of the preview type of a URL or path, based on its file
|
|
41
|
+
* extension. Used when no storage metadata (and therefore no content type) is
|
|
42
|
+
* available, e.g. for externally hosted files.
|
|
43
|
+
* Returns `undefined` when the extension is missing or not recognised.
|
|
44
|
+
*/
|
|
45
|
+
export function getPreviewTypeFromUrl(urlOrPath?: string | null): PreviewType | undefined {
|
|
46
|
+
const extension = getFileExtensionFromUrl(urlOrPath);
|
|
47
|
+
if (!extension) return undefined;
|
|
48
|
+
if (IMAGE_EXTENSIONS.includes(extension)) return "image";
|
|
49
|
+
if (VIDEO_EXTENSIONS.includes(extension)) return "video";
|
|
50
|
+
if (AUDIO_EXTENSIONS.includes(extension)) return "audio";
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|