@indiscale/linkahead-webui-ext-map 0.5.0
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/.eslintrc.json +45 -0
- package/.gitlab-ci.yml +44 -0
- package/CHANGELOG.md +78 -0
- package/README.md +97 -0
- package/RELEASE_GUIDELINES.md +45 -0
- package/__mocks__/fileMock.js +3 -0
- package/__mocks__/styleMock.js +1 -0
- package/babel.config.js +22 -0
- package/cypress/e2e/standalone-map.cy.js +55 -0
- package/cypress/support/commands.js +25 -0
- package/cypress/support/e2e.js +17 -0
- package/cypress.config.js +10 -0
- package/dist/2b3e1faf89f94a483539.png +0 -0
- package/dist/416d91365b44e4b4f477.png +0 -0
- package/dist/8f2c4d11474275fbc161.png +0 -0
- package/dist/index.html +1 -0
- package/dist/linkahead-webui-ext-map.js +3 -0
- package/dist/linkahead-webui-ext-map.js.LICENSE.txt +45 -0
- package/dist/linkahead-webui-ext-map.js.map +1 -0
- package/iframe/index.html +6 -0
- package/indiscale-linkahead-webui-ext-map-0.4.1.tgz +0 -0
- package/jest.config.js +23 -0
- package/jest.setup.js +2 -0
- package/package.json +105 -0
- package/public/favicon.ico +0 -0
- package/public/index.html +11 -0
- package/public/logo192.png +0 -0
- package/public/logo512.png +0 -0
- package/public/manifest.json +25 -0
- package/public/map_tile_caosdb_logo.png +0 -0
- package/public/mock.js +41 -0
- package/public/robots.txt +3 -0
- package/select_query.json +3 -0
- package/src/AllMapEntities.tsx +294 -0
- package/src/CurrentPageEntities.js +318 -0
- package/src/Map.helpers.css +8 -0
- package/src/Map.helpers.js +536 -0
- package/src/Map.js +288 -0
- package/src/Map.test.js +252 -0
- package/src/MapConfig.js +75 -0
- package/src/__snapshots__/Map.test.js.snap +1725 -0
- package/src/components/Coordinates.js +24 -0
- package/src/components/ErrorComponent.tsx +2 -0
- package/src/components/Graticule.js +27 -0
- package/src/components/Loader.module.css +17 -0
- package/src/components/Loader.tsx +36 -0
- package/src/components/PathDropDown.js +108 -0
- package/src/components/SearchControl.js +502 -0
- package/src/components/ToggleMapButton.js +194 -0
- package/src/components/ViewChangeControl.js +104 -0
- package/src/constants/index.js +1 -0
- package/src/context/ConfigProvider.test.js +232 -0
- package/src/context/ConfigProvider.tsx +189 -0
- package/src/context/LoadingProvider.test.js +124 -0
- package/src/context/LoadingProvider.tsx +117 -0
- package/src/context/PathIdProvider.js +102 -0
- package/src/contrib/latlnggraticule/LICENSE +20 -0
- package/src/contrib/latlnggraticule/README.md +68 -0
- package/src/contrib/latlnggraticule/leaflet.latlng-graticule.js +528 -0
- package/src/contrib/simplegraticule/L.Graticule.js +138 -0
- package/src/default_config.json +57 -0
- package/src/global.d.ts +8 -0
- package/src/index.js +6 -0
- package/src/index.scss +133 -0
- package/src/logging.js +7 -0
- package/src/renderHtmlTemplate.test.js +60 -0
- package/src/select-search.min.svg +1 -0
- package/src/select-search.svg +46 -0
- package/src/setupTests.js +5 -0
- package/src/utils/GenerateQueryString.js +200 -0
- package/src/utils/GenerateQueryString.test.js +304 -0
- package/src/utils/index.ts +3 -0
- package/standalone.config.js +5 -0
- package/static/map_tile_caosdb_logo.png +0 -0
- package/tsconfig.json +25 -0
- package/webpack.config.js +193 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import PropTypes from "prop-types";
|
|
2
|
+
import { logger } from "../logging";
|
|
3
|
+
import L from "leaflet";
|
|
4
|
+
import { useMap } from "react-leaflet";
|
|
5
|
+
import { useConfig } from "../context/ConfigProvider";
|
|
6
|
+
|
|
7
|
+
L.Control.ViewChangeControl = L.Control.extend({
|
|
8
|
+
_button: undefined,
|
|
9
|
+
_make_view_menu: function () {
|
|
10
|
+
var form = L.DomUtil.create("form", "viewMenu d-none");
|
|
11
|
+
|
|
12
|
+
form.addEventListener("click", function (e) {
|
|
13
|
+
// just stop the click here, such that it does not change
|
|
14
|
+
// anything else in the map's state.
|
|
15
|
+
e.stopPropagation();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return form;
|
|
19
|
+
},
|
|
20
|
+
onAdd: function () {
|
|
21
|
+
const view_menu = this._view_menu;
|
|
22
|
+
var click = (e) => {
|
|
23
|
+
e.stopPropagation();
|
|
24
|
+
if (L.DomUtil.hasClass(view_menu, "d-none")) {
|
|
25
|
+
L.DomUtil.removeClass(view_menu, "d-none");
|
|
26
|
+
} else {
|
|
27
|
+
L.DomUtil.addClass(view_menu, "d-none");
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
var button = L.DomUtil.create(
|
|
32
|
+
"div",
|
|
33
|
+
"leaflet-bar leaflet-control leaflet-control-custom caosdb-f-map-change-view-btn"
|
|
34
|
+
);
|
|
35
|
+
button.title = "Change the view";
|
|
36
|
+
button.addEventListener("click", click);
|
|
37
|
+
|
|
38
|
+
button.appendChild(view_menu);
|
|
39
|
+
|
|
40
|
+
const icon = L.DomUtil.create("i", "bi-three-dots-vertical");
|
|
41
|
+
button.appendChild(icon);
|
|
42
|
+
|
|
43
|
+
this._button = button;
|
|
44
|
+
return this._button;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
setViews: function (views, current_view, set_map_view_cb) {
|
|
48
|
+
logger.trace("setViews", views, current_view, set_map_view_cb);
|
|
49
|
+
this._view_menu = this._make_view_menu();
|
|
50
|
+
views.forEach((view) => {
|
|
51
|
+
const option = L.DomUtil.create("div", "caosdb-f-map-view-select");
|
|
52
|
+
option.title = view.description || view.name || view.id;
|
|
53
|
+
|
|
54
|
+
const input = L.DomUtil.create("input");
|
|
55
|
+
input.name = "view";
|
|
56
|
+
input.type = "radio";
|
|
57
|
+
input.value = view.id;
|
|
58
|
+
if (view.id === current_view) {
|
|
59
|
+
input.checked = true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const label = L.DomUtil.create("label");
|
|
63
|
+
label.innerHTML = view.name || view.description || view.id;
|
|
64
|
+
|
|
65
|
+
option.appendChild(input);
|
|
66
|
+
option.appendChild(label);
|
|
67
|
+
this._view_menu.appendChild(option);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this._view_menu.addEventListener("change", (e) => {
|
|
71
|
+
logger.trace("ViewChangeControl.onChange", e);
|
|
72
|
+
set_map_view_cb(e.target.value);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
L.control.viewChangeControl = function (options) {
|
|
78
|
+
return new L.Control.ViewChangeControl(options);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export function ViewChangeControl({ mapView, setMapView }) {
|
|
82
|
+
const {
|
|
83
|
+
config: { views },
|
|
84
|
+
} = useConfig();
|
|
85
|
+
|
|
86
|
+
const map = useMap();
|
|
87
|
+
|
|
88
|
+
const viewChangeControl = L.control.viewChangeControl({
|
|
89
|
+
position: "bottomleft",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!map._viewChangeControl) {
|
|
93
|
+
map._viewChangeControl = viewChangeControl;
|
|
94
|
+
viewChangeControl.setViews(views, mapView, setMapView);
|
|
95
|
+
viewChangeControl.addTo(map);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ViewChangeControl.propTypes = {
|
|
102
|
+
mapView: PropTypes.string,
|
|
103
|
+
setMapView: PropTypes.func,
|
|
104
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_IFRAMESETTINGS_PATHID = "same";
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { render, act } from "@testing-library/react";
|
|
3
|
+
import { ConfigProvider, useConfig } from "./ConfigProvider";
|
|
4
|
+
import default_config from "../default_config.json";
|
|
5
|
+
|
|
6
|
+
describe("ConfigProvider / setConfig", () => {
|
|
7
|
+
// Keep a copy of the original environment so we can restore it after tests.
|
|
8
|
+
const oldEnv = process.env;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Clone process.env for each test so changes don't leak across tests.
|
|
12
|
+
process.env = { ...oldEnv };
|
|
13
|
+
|
|
14
|
+
// Reset any spies/mocks between tests.
|
|
15
|
+
jest.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
// Restore the original process.env after the suite finishes.
|
|
20
|
+
process.env = oldEnv;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Probe is a tiny "test-only" consumer component.
|
|
25
|
+
*
|
|
26
|
+
* It reads the full context value via useConfig():
|
|
27
|
+
* { config, setConfig }
|
|
28
|
+
*
|
|
29
|
+
* Then it passes that object to the test via onValue().
|
|
30
|
+
*
|
|
31
|
+
* Note:
|
|
32
|
+
* - The provider memoizes its context value with useMemo([config, setConfig]).
|
|
33
|
+
* - Whenever `config` changes, the memoized `value` object changes too,
|
|
34
|
+
* causing this effect to run again and letting the test observe updates.
|
|
35
|
+
*/
|
|
36
|
+
function Probe({ onValue }) {
|
|
37
|
+
const value = useConfig();
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
onValue(value);
|
|
41
|
+
}, [value, onValue]);
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("non-standalone: normalizes config (injects select.query default, derives datamodel.role/entity)", async () => {
|
|
47
|
+
// In non-standalone mode we do NOT merge views,
|
|
48
|
+
// but we still normalize:
|
|
49
|
+
// - ensure select.query exists (default_config)
|
|
50
|
+
// - ensure datamodel.role/entity are derived from the query
|
|
51
|
+
process.env.STANDALONE_MODE = "false";
|
|
52
|
+
|
|
53
|
+
let last;
|
|
54
|
+
render(
|
|
55
|
+
<ConfigProvider initialConfig={null}>
|
|
56
|
+
<Probe onValue={(v) => (last = v)} />
|
|
57
|
+
</ConfigProvider>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const configFromApi = {
|
|
61
|
+
foo: "bar",
|
|
62
|
+
select: {}, // no query provided
|
|
63
|
+
datamodel: {}, // exists but empty
|
|
64
|
+
views: [{ id: "v1" }],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await act(async () => {
|
|
68
|
+
last.setConfig(configFromApi);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// keeps unrelated fields
|
|
72
|
+
expect(last.config.foo).toBe("bar");
|
|
73
|
+
|
|
74
|
+
// ensures query default is injected
|
|
75
|
+
expect(last.config.select.query).toEqual(default_config.select.query);
|
|
76
|
+
|
|
77
|
+
// ensures datamodel role/entity are derived from query
|
|
78
|
+
expect(last.config.datamodel.role).toBe(default_config.select.query.role);
|
|
79
|
+
expect(last.config.datamodel.entity).toBe(
|
|
80
|
+
default_config.select.query.entity
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// views unchanged in non-standalone (merge only happens in standalone + default_view)
|
|
84
|
+
expect(last.config.views).toEqual([{ id: "v1" }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("standalone: throws when iframeSettings missing", () => {
|
|
88
|
+
// In standalone mode, iframeSettings.formatString is mandatory.
|
|
89
|
+
// If it's missing, setConfig throws.
|
|
90
|
+
process.env.STANDALONE_MODE = "true";
|
|
91
|
+
jest.spyOn(console, "error").mockImplementation(() => {});
|
|
92
|
+
|
|
93
|
+
let last;
|
|
94
|
+
render(
|
|
95
|
+
<ConfigProvider initialConfig={null}>
|
|
96
|
+
<Probe onValue={(v) => (last = v)} />
|
|
97
|
+
</ConfigProvider>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(() => {
|
|
101
|
+
act(() => {
|
|
102
|
+
last.setConfig({
|
|
103
|
+
select: {},
|
|
104
|
+
datamodel: {},
|
|
105
|
+
views: [],
|
|
106
|
+
// iframeSettings missing
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}).toThrow(/standalone mode/i);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("standalone: merges iframeSettings.view into matching view id (excluding default_view) + normalizes select/datamodel", async () => {
|
|
113
|
+
// In standalone mode, if iframeSettings.view.default_view is present,
|
|
114
|
+
// we merge iframeSettings.view into the view whose id matches default_view,
|
|
115
|
+
// but must NOT copy `default_view` onto the view object.
|
|
116
|
+
process.env.STANDALONE_MODE = "true";
|
|
117
|
+
|
|
118
|
+
let last;
|
|
119
|
+
render(
|
|
120
|
+
<ConfigProvider initialConfig={null}>
|
|
121
|
+
<Probe onValue={(v) => (last = v)} />
|
|
122
|
+
</ConfigProvider>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const configFromApi = {
|
|
126
|
+
iframeSettings: {
|
|
127
|
+
formatString: "q",
|
|
128
|
+
view: {
|
|
129
|
+
default_view: "v2",
|
|
130
|
+
zoom: 7,
|
|
131
|
+
center: [1, 2],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
select: {}, // allow default query injection
|
|
135
|
+
datamodel: {}, // allow role/entity injection
|
|
136
|
+
views: [
|
|
137
|
+
{ id: "v1", zoom: 1, keep: "a" },
|
|
138
|
+
{ id: "v2", zoom: 2, keep: "b" },
|
|
139
|
+
],
|
|
140
|
+
other: 123,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
last.setConfig(configFromApi);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(last.config.other).toBe(123);
|
|
148
|
+
|
|
149
|
+
// non-default view unchanged
|
|
150
|
+
expect(last.config.views[0]).toEqual({ id: "v1", zoom: 1, keep: "a" });
|
|
151
|
+
|
|
152
|
+
// default view merged (default_view removed)
|
|
153
|
+
expect(last.config.views[1]).toEqual({
|
|
154
|
+
id: "v2",
|
|
155
|
+
zoom: 7,
|
|
156
|
+
keep: "b",
|
|
157
|
+
center: [1, 2],
|
|
158
|
+
});
|
|
159
|
+
expect(last.config.views[1]).not.toHaveProperty("default_view");
|
|
160
|
+
|
|
161
|
+
// normalization still happens
|
|
162
|
+
expect(last.config.select.query).toEqual(default_config.select.query);
|
|
163
|
+
expect(last.config.datamodel.role).toBe(default_config.select.query.role);
|
|
164
|
+
expect(last.config.datamodel.entity).toBe(
|
|
165
|
+
default_config.select.query.entity
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("standalone: if default_view missing, does NOT merge views but still normalizes select/datamodel", async () => {
|
|
170
|
+
// Standalone mode without iframeSettings.view.default_view:
|
|
171
|
+
// - no view merge
|
|
172
|
+
// - but we still inject default query + derive datamodel role/entity
|
|
173
|
+
process.env.STANDALONE_MODE = "true";
|
|
174
|
+
|
|
175
|
+
let last;
|
|
176
|
+
render(
|
|
177
|
+
<ConfigProvider initialConfig={null}>
|
|
178
|
+
<Probe onValue={(v) => (last = v)} />
|
|
179
|
+
</ConfigProvider>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const configFromApi = {
|
|
183
|
+
iframeSettings: { formatString: "q", view: { zoom: 9 } }, // no default_view
|
|
184
|
+
select: {}, // allow default injection
|
|
185
|
+
datamodel: {}, // allow role/entity injection
|
|
186
|
+
views: [{ id: "v1" }],
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
await act(async () => {
|
|
190
|
+
last.setConfig(configFromApi);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// views unchanged
|
|
194
|
+
expect(last.config.views).toEqual([{ id: "v1" }]);
|
|
195
|
+
|
|
196
|
+
// normalization still happens
|
|
197
|
+
expect(last.config.select.query).toEqual(default_config.select.query);
|
|
198
|
+
expect(last.config.datamodel.role).toBe(default_config.select.query.role);
|
|
199
|
+
expect(last.config.datamodel.entity).toBe(
|
|
200
|
+
default_config.select.query.entity
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("non-standalone: does NOT crash if datamodel is missing (provider creates/normalizes it)", async () => {
|
|
205
|
+
// This test assumes the provider defensively creates `datamodel`
|
|
206
|
+
// if it is missing from the incoming config.
|
|
207
|
+
process.env.STANDALONE_MODE = "false";
|
|
208
|
+
|
|
209
|
+
let last;
|
|
210
|
+
render(
|
|
211
|
+
<ConfigProvider initialConfig={null}>
|
|
212
|
+
<Probe onValue={(v) => (last = v)} />
|
|
213
|
+
</ConfigProvider>
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const configFromApi = {
|
|
217
|
+
select: {}, // no query
|
|
218
|
+
// datamodel missing entirely
|
|
219
|
+
views: [],
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
await act(async () => {
|
|
223
|
+
last.setConfig(configFromApi);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(last.config.datamodel).toBeTruthy();
|
|
227
|
+
expect(last.config.datamodel.role).toBe(default_config.select.query.role);
|
|
228
|
+
expect(last.config.datamodel.entity).toBe(
|
|
229
|
+
default_config.select.query.entity
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useMemo,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { get } from "lodash";
|
|
10
|
+
import default_config from "../default_config.json";
|
|
11
|
+
|
|
12
|
+
type TConfigContextValue = {
|
|
13
|
+
config: TConfig | null;
|
|
14
|
+
setConfig: (origConfigFromApi: TConfigApi) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const ConfigContext = createContext<TConfigContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
type TConfigProviderProps = {
|
|
20
|
+
initialConfig?: TConfig | null;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type TSelectQuery = {
|
|
25
|
+
role?: string;
|
|
26
|
+
entity?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TSelect = {
|
|
31
|
+
query?: TSelectQuery;
|
|
32
|
+
paths?: Record<string, string[]>;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type TDatamodel = {
|
|
37
|
+
lat?: string;
|
|
38
|
+
lng?: string;
|
|
39
|
+
role?: string;
|
|
40
|
+
entity?: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type TIframeSettingsView = {
|
|
45
|
+
default_view?: string;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type TIframeSettings = {
|
|
50
|
+
formatString?: string;
|
|
51
|
+
view?: TIframeSettingsView;
|
|
52
|
+
selector?: string;
|
|
53
|
+
[key: string]: unknown;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type TView = {
|
|
57
|
+
id?: string;
|
|
58
|
+
[key: string]: unknown;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type TConfig = {
|
|
62
|
+
select?: TSelect;
|
|
63
|
+
datamodel?: TDatamodel;
|
|
64
|
+
iframeSettings?: TIframeSettings;
|
|
65
|
+
views?: TView[];
|
|
66
|
+
entityLayers: any;
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// What we accept from the API (same shape, but we treat it as optional/partial)
|
|
71
|
+
type TConfigApi = Partial<TConfig>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* ConfigProvider
|
|
75
|
+
* --------------
|
|
76
|
+
* Holds the configuration object for the Map.
|
|
77
|
+
*
|
|
78
|
+
* Responsibilities:
|
|
79
|
+
* - Normalizes incoming config:
|
|
80
|
+
* - ensures `select.query` exists (falls back to default_config.select.query)
|
|
81
|
+
* - ensures `datamodel.role/entity` are derived from `select.query`
|
|
82
|
+
* - In standalone mode:
|
|
83
|
+
* - requires `iframeSettings.formatString`
|
|
84
|
+
* - merges `iframeSettings.view` into the matching entry in `views`
|
|
85
|
+
* referenced by `iframeSettings.view.default_view` (excluding `default_view`)
|
|
86
|
+
*/
|
|
87
|
+
export const ConfigProvider = ({
|
|
88
|
+
initialConfig = null,
|
|
89
|
+
children,
|
|
90
|
+
}: TConfigProviderProps) => {
|
|
91
|
+
const [config, setConfigOrig] = useState<TConfig | null>(initialConfig);
|
|
92
|
+
|
|
93
|
+
// useCallback stops endless loop
|
|
94
|
+
const setConfig = useCallback((origConfigFromApi: TConfigApi) => {
|
|
95
|
+
const isStandaloneMode = String(process.env.STANDALONE_MODE) === "true";
|
|
96
|
+
|
|
97
|
+
// Steps to build base config without mutation
|
|
98
|
+
// 1) Build a safe select/query with default
|
|
99
|
+
const select: TSelect = {
|
|
100
|
+
...(origConfigFromApi.select || {}),
|
|
101
|
+
query:
|
|
102
|
+
(origConfigFromApi.select && origConfigFromApi.select.query) ||
|
|
103
|
+
(default_config as any).select?.query,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// 2) Build datamodel
|
|
107
|
+
const datamodel: TDatamodel = {
|
|
108
|
+
...(origConfigFromApi.datamodel || {}),
|
|
109
|
+
role: select.query?.role,
|
|
110
|
+
entity: select.query?.entity,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// 3) Build base config
|
|
114
|
+
const configFromApi: TConfig = {
|
|
115
|
+
...(origConfigFromApi as TConfig),
|
|
116
|
+
select,
|
|
117
|
+
datamodel,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Throw error if missing config for Standalone map
|
|
121
|
+
if (
|
|
122
|
+
isStandaloneMode &&
|
|
123
|
+
(!configFromApi?.iframeSettings ||
|
|
124
|
+
!configFromApi?.iframeSettings.formatString)
|
|
125
|
+
) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"In standalone mode a query must be defined under iframeSettings.formatString"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Find default view for standalone map
|
|
132
|
+
const standaloneDefaultViewKey = get(
|
|
133
|
+
configFromApi,
|
|
134
|
+
"iframeSettings.view.default_view"
|
|
135
|
+
) as string | undefined;
|
|
136
|
+
|
|
137
|
+
// If Standalone Map Mode we overwrite the corresponding view in the views array
|
|
138
|
+
// with the view settings from within iframeSettings.view while keeping the rest
|
|
139
|
+
// of its properties intact.
|
|
140
|
+
if (isStandaloneMode && standaloneDefaultViewKey) {
|
|
141
|
+
const views: TView[] = (configFromApi?.views ?? []).map((view) => {
|
|
142
|
+
if (view?.id === standaloneDefaultViewKey) {
|
|
143
|
+
// Destructure so we don't include default_view
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
145
|
+
const { default_view, ...newView } = (configFromApi?.iframeSettings
|
|
146
|
+
?.view ?? {}) as TIframeSettingsView;
|
|
147
|
+
|
|
148
|
+
// merge
|
|
149
|
+
return {
|
|
150
|
+
...view,
|
|
151
|
+
...newView,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return view;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// config obj with updated views array
|
|
159
|
+
const newConfig: TConfig = {
|
|
160
|
+
...configFromApi,
|
|
161
|
+
views,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Save to context
|
|
165
|
+
setConfigOrig(newConfig);
|
|
166
|
+
} else {
|
|
167
|
+
// Not standalone mode, simply save config untouched
|
|
168
|
+
setConfigOrig(configFromApi);
|
|
169
|
+
}
|
|
170
|
+
}, []);
|
|
171
|
+
|
|
172
|
+
// Memoize to stop needless / endless renders as passing object
|
|
173
|
+
const value = useMemo<TConfigContextValue>(
|
|
174
|
+
() => ({ config, setConfig }),
|
|
175
|
+
[config, setConfig]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const useConfig = (): TConfigContextValue => {
|
|
184
|
+
const context = useContext(ConfigContext);
|
|
185
|
+
if (!context) {
|
|
186
|
+
throw new Error("useConfig must be used within a <ConfigProvider>");
|
|
187
|
+
}
|
|
188
|
+
return context;
|
|
189
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen, act } from "@testing-library/react";
|
|
3
|
+
import "@testing-library/jest-dom";
|
|
4
|
+
|
|
5
|
+
import { LoadingProvider, useLoading } from "./LoadingProvider";
|
|
6
|
+
|
|
7
|
+
function TestConsumer() {
|
|
8
|
+
const { loading, startLoading, stopLoading } = useLoading();
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div>
|
|
12
|
+
<div data-testid="loading">{loading ? "true" : "false"}</div>
|
|
13
|
+
|
|
14
|
+
<button onClick={() => startLoading()} data-testid="start">
|
|
15
|
+
start
|
|
16
|
+
</button>
|
|
17
|
+
|
|
18
|
+
<button onClick={() => stopLoading()} data-testid="stop">
|
|
19
|
+
stop
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("LoadingProvider / useLoading", () => {
|
|
26
|
+
test("defaults to loading=false", () => {
|
|
27
|
+
render(
|
|
28
|
+
<LoadingProvider>
|
|
29
|
+
<TestConsumer />
|
|
30
|
+
</LoadingProvider>
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("startLoading sets loading=true", () => {
|
|
37
|
+
render(
|
|
38
|
+
<LoadingProvider>
|
|
39
|
+
<TestConsumer />
|
|
40
|
+
</LoadingProvider>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
act(() => {
|
|
44
|
+
screen.getByTestId("start").click();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("true");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("counter semantics: stays true until all stops are called", () => {
|
|
51
|
+
render(
|
|
52
|
+
<LoadingProvider>
|
|
53
|
+
<TestConsumer />
|
|
54
|
+
</LoadingProvider>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// start twice
|
|
58
|
+
act(() => {
|
|
59
|
+
screen.getByTestId("start").click();
|
|
60
|
+
screen.getByTestId("start").click();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("true");
|
|
64
|
+
|
|
65
|
+
// stop once -> still true
|
|
66
|
+
act(() => {
|
|
67
|
+
screen.getByTestId("stop").click();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("true");
|
|
71
|
+
|
|
72
|
+
// stop second time -> now false
|
|
73
|
+
act(() => {
|
|
74
|
+
screen.getByTestId("stop").click();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("stopLoading cannot make counter negative", () => {
|
|
81
|
+
render(
|
|
82
|
+
<LoadingProvider>
|
|
83
|
+
<TestConsumer />
|
|
84
|
+
</LoadingProvider>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// stop without start -> should remain false
|
|
88
|
+
act(() => {
|
|
89
|
+
screen.getByTestId("stop").click();
|
|
90
|
+
screen.getByTestId("stop").click();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
|
94
|
+
|
|
95
|
+
// start -> true
|
|
96
|
+
act(() => {
|
|
97
|
+
screen.getByTestId("start").click();
|
|
98
|
+
});
|
|
99
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("true");
|
|
100
|
+
|
|
101
|
+
// stop twice -> false and stays false
|
|
102
|
+
act(() => {
|
|
103
|
+
screen.getByTestId("stop").click();
|
|
104
|
+
screen.getByTestId("stop").click();
|
|
105
|
+
});
|
|
106
|
+
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("useLoading throws if used outside provider", () => {
|
|
110
|
+
// Suppress React error output in test logs
|
|
111
|
+
const spy = jest.spyOn(console, "error").mockImplementation(() => {});
|
|
112
|
+
|
|
113
|
+
function BadConsumer() {
|
|
114
|
+
useLoading();
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
expect(() => render(<BadConsumer />)).toThrow(
|
|
119
|
+
"useLoading must be used within a <LoadingProvider>"
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
spy.mockRestore();
|
|
123
|
+
});
|
|
124
|
+
});
|