forked from MapComplete/MapComplete
refactoring: split all the states
This commit is contained in:
parent
4d48b1cf2b
commit
8e2f04c0d0
32 changed files with 411 additions and 395 deletions
|
@ -148,7 +148,7 @@ export default class OverpassFeatureSource implements FeatureSource {
|
|||
if (typeof layer === "string") {
|
||||
throw "A layer was not expanded!"
|
||||
}
|
||||
if (Constants.priviliged_layers.indexOf(layer.id) >= 0) {
|
||||
if (layer.source === undefined) {
|
||||
continue
|
||||
}
|
||||
if (this.state.locationControl.data.zoom < layer.minzoom) {
|
||||
|
|
|
@ -7,14 +7,9 @@ import { ElementStorage } from "../ElementStorage"
|
|||
import { Utils } from "../../Utils"
|
||||
|
||||
export default class TitleHandler {
|
||||
constructor(state: {
|
||||
selectedElement: Store<any>
|
||||
layoutToUse: LayoutConfig
|
||||
allElements: ElementStorage
|
||||
}) {
|
||||
const currentTitle: Store<string> = state.selectedElement.map(
|
||||
constructor(selectedElement: Store<any>, layout: LayoutConfig, allElements: ElementStorage) {
|
||||
const currentTitle: Store<string> = selectedElement.map(
|
||||
(selected) => {
|
||||
const layout = state.layoutToUse
|
||||
const defaultTitle = layout?.title?.txt ?? "MapComplete"
|
||||
|
||||
if (selected === undefined) {
|
||||
|
@ -28,8 +23,7 @@ export default class TitleHandler {
|
|||
}
|
||||
if (layer.source.osmTags.matchesProperties(tags)) {
|
||||
const tagsSource =
|
||||
state.allElements.getEventSourceById(tags.id) ??
|
||||
new UIEventSource<any>(tags)
|
||||
allElements.getEventSourceById(tags.id) ?? new UIEventSource<any>(tags)
|
||||
const title = new TagRenderingAnswer(tagsSource, layer.title, {})
|
||||
return (
|
||||
new Combine([defaultTitle, " | ", title]).ConstructElement()
|
||||
|
|
|
@ -189,7 +189,7 @@ export class BBox {
|
|||
public asGeoJson<T>(properties: T): Feature<Polygon, T> {
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: properties,
|
||||
properties,
|
||||
geometry: this.asGeometry(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ export default class FeaturePipeline {
|
|||
public readonly relationTracker: RelationsTracker
|
||||
/**
|
||||
* Keeps track of all raw OSM-nodes.
|
||||
* Only initialized if 'type_node' is defined as layer
|
||||
* Only initialized if `ReplaceGeometryAction` is needed somewhere
|
||||
*/
|
||||
public readonly fullNodeDatabase?: FullNodeDatabaseSource
|
||||
private readonly overpassUpdater: OverpassFeatureSource
|
||||
|
@ -132,14 +132,6 @@ export default class FeaturePipeline {
|
|||
// We do not mark as visited here, this is the responsability of the code near the actual loader (e.g. overpassLoader and OSMApiFeatureLoader)
|
||||
}
|
||||
|
||||
function handlePriviligedFeatureSource(src: FeatureSourceForLayer & Tiled) {
|
||||
// Passthrough to passed function, except that it registers as well
|
||||
handleFeatureSource(src)
|
||||
src.features.addCallbackAndRunD((fs) => {
|
||||
fs.forEach((ff) => state.allElements.addOrGetElement(<any>ff))
|
||||
})
|
||||
}
|
||||
|
||||
for (const filteredLayer of state.filteredLayers.data) {
|
||||
const id = filteredLayer.layerDef.id
|
||||
const source = filteredLayer.layerDef.source
|
||||
|
@ -160,36 +152,6 @@ export default class FeaturePipeline {
|
|||
continue
|
||||
}
|
||||
|
||||
if (id === "selected_element") {
|
||||
handlePriviligedFeatureSource(state.selectedElementsLayer)
|
||||
continue
|
||||
}
|
||||
|
||||
if (id === "gps_location") {
|
||||
handlePriviligedFeatureSource(state.currentUserLocation)
|
||||
continue
|
||||
}
|
||||
|
||||
if (id === "gps_location_history") {
|
||||
handlePriviligedFeatureSource(state.historicalUserLocations)
|
||||
continue
|
||||
}
|
||||
|
||||
if (id === "gps_track") {
|
||||
handlePriviligedFeatureSource(state.historicalUserLocationsTrack)
|
||||
continue
|
||||
}
|
||||
|
||||
if (id === "home_location") {
|
||||
handlePriviligedFeatureSource(state.homeLocation)
|
||||
continue
|
||||
}
|
||||
|
||||
if (id === "current_view") {
|
||||
handlePriviligedFeatureSource(state.currentView)
|
||||
continue
|
||||
}
|
||||
|
||||
const localTileSaver = new SaveTileToLocalStorageActor(filteredLayer)
|
||||
this.localStorageSavers.set(filteredLayer.layerDef.id, localTileSaver)
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ import ScrollableFullScreen from "../../UI/Base/ScrollableFullScreen"
|
|||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
|
||||
|
||||
export default class FeaturePipelineState extends MapState {
|
||||
export default class FeaturePipelineState {
|
||||
/**
|
||||
* The piece of code which fetches data from various sources and shows it on the background map
|
||||
*/
|
||||
|
@ -27,8 +27,6 @@ export default class FeaturePipelineState extends MapState {
|
|||
>()
|
||||
|
||||
constructor(layoutToUse: LayoutConfig) {
|
||||
super(layoutToUse)
|
||||
|
||||
const clustering = layoutToUse?.clustering
|
||||
this.featureAggregator = TileHierarchyAggregator.createHierarchy(this)
|
||||
const clusterCounter = this.featureAggregator
|
||||
|
|
145
Logic/State/LayerState.ts
Normal file
145
Logic/State/LayerState.ts
Normal file
|
@ -0,0 +1,145 @@
|
|||
import { UIEventSource } from "../UIEventSource"
|
||||
import { GlobalFilter } from "../../Models/GlobalFilter"
|
||||
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import { OsmConnection } from "../Osm/OsmConnection"
|
||||
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||
import { QueryParameters } from "../Web/QueryParameters"
|
||||
|
||||
/**
|
||||
* The layer state keeps track of:
|
||||
* - Which layers are enabled
|
||||
* - Which filters are used, including 'global' filters
|
||||
*/
|
||||
export default class LayerState {
|
||||
/**
|
||||
* Filters which apply onto all layers
|
||||
*/
|
||||
public readonly globalFilters: UIEventSource<GlobalFilter[]> = new UIEventSource(
|
||||
[],
|
||||
"globalFilters"
|
||||
)
|
||||
|
||||
/**
|
||||
* Which layers are enabled in the current theme and what filters are applied onto them
|
||||
*/
|
||||
public readonly filteredLayers: Map<string, FilteredLayer>
|
||||
private readonly osmConnection: OsmConnection
|
||||
|
||||
/**
|
||||
*
|
||||
* @param osmConnection
|
||||
* @param layers
|
||||
* @param context: the context, probably the name of the theme. Used to disambiguate the upstream user preference
|
||||
*/
|
||||
constructor(osmConnection: OsmConnection, layers: LayerConfig[], context: string) {
|
||||
this.osmConnection = osmConnection
|
||||
this.filteredLayers = new Map()
|
||||
for (const layer of layers) {
|
||||
this.filteredLayers.set(layer.id, this.initFilteredLayer(layer, context))
|
||||
}
|
||||
layers.forEach((l) => this.linkFilterStates(l))
|
||||
}
|
||||
|
||||
private static getPref(
|
||||
osmConnection: OsmConnection,
|
||||
key: string,
|
||||
layer: LayerConfig
|
||||
): UIEventSource<boolean> {
|
||||
return osmConnection.GetPreference(key, layer.shownByDefault + "").sync(
|
||||
(v) => {
|
||||
if (v === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return v === "true"
|
||||
},
|
||||
[],
|
||||
(b) => {
|
||||
if (b === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return "" + b
|
||||
}
|
||||
)
|
||||
}
|
||||
/**
|
||||
* INitializes a filtered layer for the given layer.
|
||||
* @param layer
|
||||
* @param context: probably the theme-name. This is used to disambiguate the user settings; e.g. when using the same layer in different contexts
|
||||
* @private
|
||||
*/
|
||||
private initFilteredLayer(layer: LayerConfig, context: string): FilteredLayer | undefined {
|
||||
let isDisplayed: UIEventSource<boolean>
|
||||
const osmConnection = this.osmConnection
|
||||
if (layer.syncSelection === "local") {
|
||||
isDisplayed = LocalStorageSource.GetParsed(
|
||||
context + "-layer-" + layer.id + "-enabled",
|
||||
layer.shownByDefault
|
||||
)
|
||||
} else if (layer.syncSelection === "theme-only") {
|
||||
isDisplayed = LayerState.getPref(
|
||||
osmConnection,
|
||||
context + "-layer-" + layer.id + "-enabled",
|
||||
layer
|
||||
)
|
||||
} else if (layer.syncSelection === "global") {
|
||||
isDisplayed = LayerState.getPref(osmConnection, "layer-" + layer.id + "-enabled", layer)
|
||||
} else {
|
||||
isDisplayed = QueryParameters.GetBooleanQueryParameter(
|
||||
"layer-" + layer.id,
|
||||
layer.shownByDefault,
|
||||
"Wether or not layer " + layer.id + " is shown"
|
||||
)
|
||||
}
|
||||
|
||||
const flayer: FilteredLayer = {
|
||||
isDisplayed,
|
||||
layerDef: layer,
|
||||
appliedFilters: new UIEventSource<Map<string, FilterState>>(
|
||||
new Map<string, FilterState>()
|
||||
),
|
||||
}
|
||||
layer.filters?.forEach((filterConfig) => {
|
||||
const stateSrc = filterConfig.initState()
|
||||
|
||||
stateSrc.addCallbackAndRun((state) =>
|
||||
flayer.appliedFilters.data.set(filterConfig.id, state)
|
||||
)
|
||||
flayer.appliedFilters
|
||||
.map((dict) => dict.get(filterConfig.id))
|
||||
.addCallback((state) => stateSrc.setData(state))
|
||||
})
|
||||
|
||||
return flayer
|
||||
}
|
||||
|
||||
/**
|
||||
* Some layers copy the filter state of another layer - this is quite often the case for 'sibling'-layers,
|
||||
* (where two variations of the same layer are used, e.g. a specific type of shop on all zoom levels and all shops on high zoom).
|
||||
*
|
||||
* This methods links those states for the given layer
|
||||
*/
|
||||
private linkFilterStates(layer: LayerConfig) {
|
||||
if (layer.filterIsSameAs === undefined) {
|
||||
return
|
||||
}
|
||||
const toReuse = this.filteredLayers.get(layer.filterIsSameAs)
|
||||
if (toReuse === undefined) {
|
||||
throw (
|
||||
"Error in layer " +
|
||||
layer.id +
|
||||
": it defines that it should be use the filters of " +
|
||||
layer.filterIsSameAs +
|
||||
", but this layer was not loaded"
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
"Linking filter and isDisplayed-states of " + layer.id + " and " + layer.filterIsSameAs
|
||||
)
|
||||
this.filteredLayers.set(layer.id, {
|
||||
isDisplayed: toReuse.isDisplayed,
|
||||
layerDef: layer,
|
||||
appliedFilters: toReuse.appliedFilters,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,31 +1,19 @@
|
|||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import Attribution from "../../UI/BigComponents/Attribution"
|
||||
import BaseUIElement from "../../UI/BaseUIElement"
|
||||
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
|
||||
import FilteredLayer from "../../Models/FilteredLayer"
|
||||
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"
|
||||
import { QueryParameters } from "../Web/QueryParameters"
|
||||
import ShowOverlayLayer from "../../UI/ShowDataLayer/ShowOverlayLayer"
|
||||
import { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource"
|
||||
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||
import TitleHandler from "../Actors/TitleHandler"
|
||||
import { BBox } from "../BBox"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import FeatureSource, { FeatureSourceForLayer, Tiled } from "../FeatureSource/FeatureSource"
|
||||
import StaticFeatureSource, {
|
||||
TiledStaticFeatureSource,
|
||||
} from "../FeatureSource/Sources/StaticFeatureSource"
|
||||
import { OsmConnection } from "../Osm/OsmConnection"
|
||||
import { Feature } from "geojson"
|
||||
import { Map as MlMap } from "maplibre-gl"
|
||||
import { GlobalFilter } from "../../Models/GlobalFilter"
|
||||
import { MapProperties } from "../../Models/MapProperties"
|
||||
import ShowDataLayer from "../../UI/Map/ShowDataLayer"
|
||||
|
||||
/**
|
||||
* Contains all the leaflet-map related state
|
||||
*/
|
||||
export default class MapState {
|
||||
|
||||
|
||||
/**
|
||||
* Last location where a click was registered
|
||||
*/
|
||||
|
@ -46,21 +34,6 @@ export default class MapState {
|
|||
*/
|
||||
public selectedElementsLayer: FeatureSourceForLayer & Tiled
|
||||
|
||||
public readonly mainMapObject: BaseUIElement
|
||||
|
||||
/**
|
||||
* Which layers are enabled in the current theme and what filters are applied onto them
|
||||
*/
|
||||
public filteredLayers: UIEventSource<FilteredLayer[]> = new UIEventSource<FilteredLayer[]>(
|
||||
[],
|
||||
"filteredLayers"
|
||||
)
|
||||
|
||||
/**
|
||||
* Filters which apply onto all layers
|
||||
*/
|
||||
public globalFilters: UIEventSource<GlobalFilter[]> = new UIEventSource([], "globalFilters")
|
||||
|
||||
/**
|
||||
* Which overlays are shown
|
||||
*/
|
||||
|
@ -80,16 +53,6 @@ export default class MapState {
|
|||
this.backgroundLayer = new UIEventSource<BaseLayer>(defaultLayer)
|
||||
this.backgroundLayer.addCallbackAndRunD((layer) => self.backgroundLayerId.setData(layer.id))
|
||||
|
||||
// Will write into this.leafletMap
|
||||
this.mainMapObject = Minimap.createMiniMap({
|
||||
background: this.backgroundLayer,
|
||||
location: this.locationControl,
|
||||
leafletMap: this.leafletMap,
|
||||
bounds: this.currentBounds,
|
||||
attribution: attr,
|
||||
lastClickLocation: this.LastClickLocation,
|
||||
})
|
||||
|
||||
this.overlayToggles =
|
||||
this.layoutToUse?.tileLayerSources
|
||||
?.filter((c) => c.name !== undefined)
|
||||
|
@ -101,16 +64,11 @@ export default class MapState {
|
|||
"Wether or not the overlay " + c.id + " is shown"
|
||||
),
|
||||
})) ?? []
|
||||
this.filteredLayers = new UIEventSource<FilteredLayer[]>(
|
||||
MapState.InitializeFilteredLayers(this.layoutToUse, this.osmConnection)
|
||||
)
|
||||
|
||||
this.AddAllOverlaysToMap(this.leafletMap)
|
||||
|
||||
this.initCurrentView()
|
||||
this.initSelectedElement()
|
||||
|
||||
new TitleHandler(this)
|
||||
}
|
||||
|
||||
public AddAllOverlaysToMap(leafletMap: UIEventSource<any>) {
|
||||
|
@ -128,48 +86,23 @@ export default class MapState {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private initCurrentView() {
|
||||
let currentViewLayer: FilteredLayer = this.filteredLayers.data.filter(
|
||||
(l) => l.layerDef.id === "current_view"
|
||||
)[0]
|
||||
|
||||
if (currentViewLayer === undefined) {
|
||||
// This layer is not needed by the theme and thus unloaded
|
||||
return
|
||||
}
|
||||
|
||||
private static initCurrentView(mapproperties: MapProperties): FeatureSource {
|
||||
let i = 0
|
||||
const self = this
|
||||
const features: Store<Feature[]> = this.currentBounds.map((bounds) => {
|
||||
const features: Store<Feature[]> = mapproperties.bounds.map((bounds) => {
|
||||
if (bounds === undefined) {
|
||||
return []
|
||||
}
|
||||
i++
|
||||
const feature = {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
return [
|
||||
bounds.asGeoJson({
|
||||
id: "current_view-" + i,
|
||||
current_view: "yes",
|
||||
zoom: "" + self.locationControl.data.zoom,
|
||||
},
|
||||
geometry: {
|
||||
type: "Polygon",
|
||||
coordinates: [
|
||||
[
|
||||
[bounds.maxLon, bounds.maxLat],
|
||||
[bounds.minLon, bounds.maxLat],
|
||||
[bounds.minLon, bounds.minLat],
|
||||
[bounds.maxLon, bounds.minLat],
|
||||
[bounds.maxLon, bounds.maxLat],
|
||||
],
|
||||
],
|
||||
},
|
||||
}
|
||||
return [feature]
|
||||
zoom: "" + mapproperties.zoom.data,
|
||||
}),
|
||||
]
|
||||
})
|
||||
|
||||
this.currentView = new TiledStaticFeatureSource(features, currentViewLayer)
|
||||
return new StaticFeatureSource(features)
|
||||
}
|
||||
|
||||
private initSelectedElement() {
|
||||
|
@ -197,113 +130,4 @@ export default class MapState {
|
|||
})
|
||||
this.selectedElementsLayer = new TiledStaticFeatureSource(store, layerDef)
|
||||
}
|
||||
|
||||
private static getPref(
|
||||
osmConnection: OsmConnection,
|
||||
key: string,
|
||||
layer: LayerConfig
|
||||
): UIEventSource<boolean> {
|
||||
return osmConnection.GetPreference(key, layer.shownByDefault + "").sync(
|
||||
(v) => {
|
||||
if (v === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return v === "true"
|
||||
},
|
||||
[],
|
||||
(b) => {
|
||||
if (b === undefined) {
|
||||
return undefined
|
||||
}
|
||||
return "" + b
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public static InitializeFilteredLayers(
|
||||
layoutToUse: { layers: LayerConfig[]; id: string },
|
||||
osmConnection: OsmConnection
|
||||
): FilteredLayer[] {
|
||||
if (layoutToUse === undefined) {
|
||||
return []
|
||||
}
|
||||
const flayers: FilteredLayer[] = []
|
||||
for (const layer of layoutToUse.layers) {
|
||||
let isDisplayed: UIEventSource<boolean>
|
||||
if (layer.syncSelection === "local") {
|
||||
isDisplayed = LocalStorageSource.GetParsed(
|
||||
layoutToUse.id + "-layer-" + layer.id + "-enabled",
|
||||
layer.shownByDefault
|
||||
)
|
||||
} else if (layer.syncSelection === "theme-only") {
|
||||
isDisplayed = MapState.getPref(
|
||||
osmConnection,
|
||||
layoutToUse.id + "-layer-" + layer.id + "-enabled",
|
||||
layer
|
||||
)
|
||||
} else if (layer.syncSelection === "global") {
|
||||
isDisplayed = MapState.getPref(
|
||||
osmConnection,
|
||||
"layer-" + layer.id + "-enabled",
|
||||
layer
|
||||
)
|
||||
} else {
|
||||
isDisplayed = QueryParameters.GetBooleanQueryParameter(
|
||||
"layer-" + layer.id,
|
||||
layer.shownByDefault,
|
||||
"Wether or not layer " + layer.id + " is shown"
|
||||
)
|
||||
}
|
||||
|
||||
const flayer: FilteredLayer = {
|
||||
isDisplayed,
|
||||
layerDef: layer,
|
||||
appliedFilters: new UIEventSource<Map<string, FilterState>>(
|
||||
new Map<string, FilterState>()
|
||||
),
|
||||
}
|
||||
layer.filters.forEach((filterConfig) => {
|
||||
const stateSrc = filterConfig.initState()
|
||||
|
||||
stateSrc.addCallbackAndRun((state) =>
|
||||
flayer.appliedFilters.data.set(filterConfig.id, state)
|
||||
)
|
||||
flayer.appliedFilters
|
||||
.map((dict) => dict.get(filterConfig.id))
|
||||
.addCallback((state) => stateSrc.setData(state))
|
||||
})
|
||||
|
||||
flayers.push(flayer)
|
||||
}
|
||||
|
||||
for (const layer of layoutToUse.layers) {
|
||||
if (layer.filterIsSameAs === undefined) {
|
||||
continue
|
||||
}
|
||||
const toReuse = flayers.find((l) => l.layerDef.id === layer.filterIsSameAs)
|
||||
if (toReuse === undefined) {
|
||||
throw (
|
||||
"Error in layer " +
|
||||
layer.id +
|
||||
": it defines that it should be use the filters of " +
|
||||
layer.filterIsSameAs +
|
||||
", but this layer was not loaded"
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
"Linking filter and isDisplayed-states of " +
|
||||
layer.id +
|
||||
" and " +
|
||||
layer.filterIsSameAs
|
||||
)
|
||||
const selfLayer = flayers.findIndex((l) => l.layerDef.id === layer.id)
|
||||
flayers[selfLayer] = {
|
||||
isDisplayed: toReuse.isDisplayed,
|
||||
layerDef: layer,
|
||||
appliedFilters: toReuse.appliedFilters,
|
||||
}
|
||||
}
|
||||
|
||||
return flayers
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import Locale from "../../UI/i18n/Locale"
|
|||
import { Changes } from "../Osm/Changes"
|
||||
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"
|
||||
import FeatureSource from "../FeatureSource/FeatureSource"
|
||||
import { Feature } from "geojson"
|
||||
|
||||
/**
|
||||
* The part of the state which keeps track of user-related stuff, e.g. the OSM-connection,
|
||||
|
@ -182,7 +183,7 @@ export default class UserRelatedState {
|
|||
|
||||
private initHomeLocation(): FeatureSource {
|
||||
const empty = []
|
||||
const feature = Stores.ListStabilized(
|
||||
const feature: Store<Feature[]> = Stores.ListStabilized(
|
||||
this.osmConnection.userDetails.map((userDetails) => {
|
||||
if (userDetails === undefined) {
|
||||
return undefined
|
||||
|
@ -198,21 +199,18 @@ export default class UserRelatedState {
|
|||
return empty
|
||||
}
|
||||
return [
|
||||
{
|
||||
feature: {
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id: "home",
|
||||
"user:home": "yes",
|
||||
_lon: homeLonLat[0],
|
||||
_lat: homeLonLat[1],
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: homeLonLat,
|
||||
},
|
||||
<Feature>{
|
||||
type: "Feature",
|
||||
properties: {
|
||||
id: "home",
|
||||
"user:home": "yes",
|
||||
_lon: homeLonLat[0],
|
||||
_lat: homeLonLat[1],
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: homeLonLat,
|
||||
},
|
||||
freshness: new Date(),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue