Compare commits
82 commits
addabbddc3
...
90f163fe1b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
90f163fe1b | ||
![]() |
a3baf82b7b | ||
![]() |
cb5e13b218 | ||
![]() |
c42a2b7129 | ||
![]() |
2482111d24 | ||
![]() |
bcd21d3697 | ||
![]() |
b6b47cc0d0 | ||
![]() |
cb4ea1b1d2 | ||
![]() |
99fff916d5 | ||
![]() |
151beb6d4c | ||
![]() |
0e1fa6965d | ||
![]() |
49ea7ed4a5 | ||
![]() |
d20943a487 | ||
![]() |
250579eaa2 | ||
![]() |
60918e6ca5 | ||
![]() |
088f682247 | ||
![]() |
d4afd5646f | ||
![]() |
a2936d74de | ||
![]() |
485bd94531 | ||
![]() |
8603774778 | ||
![]() |
8f2bbc6765 | ||
![]() |
d5efe6b8e2 | ||
![]() |
8111cf5522 | ||
![]() |
609b251303 | ||
![]() |
29d70552dd | ||
![]() |
1d47bfce0a | ||
![]() |
ea2bdba270 | ||
![]() |
22846acb99 | ||
![]() |
693e775ca8 | ||
![]() |
b62085b7aa | ||
![]() |
222213ec87 | ||
![]() |
476c160fd5 | ||
![]() |
0d5e3047f4 | ||
![]() |
11fb29c456 | ||
![]() |
ef7c769abe | ||
![]() |
82342ea00f | ||
![]() |
7e42331533 | ||
![]() |
a07ee482ce | ||
![]() |
1bf100d7a8 | ||
![]() |
36d9e9bf06 | ||
![]() |
acb2e967b8 | ||
![]() |
ab7119e0a4 | ||
![]() |
460a0c9997 | ||
![]() |
698c74b427 | ||
![]() |
a29eae138e | ||
![]() |
31546d6ff4 | ||
![]() |
83c3a41be5 | ||
![]() |
48f9afdedd | ||
![]() |
8a207afaea | ||
![]() |
122d470e31 | ||
![]() |
e7388f6cb0 | ||
![]() |
44dbf2f0df | ||
![]() |
f3b11b03bc | ||
![]() |
bb7cc86538 | ||
![]() |
30690bcb35 | ||
![]() |
f7c9c469d1 | ||
![]() |
3c38a5e55e | ||
![]() |
4430bddcc9 | ||
![]() |
9ba5dda507 | ||
![]() |
b15e333f6c | ||
![]() |
4ce8f6515d | ||
![]() |
dc5a3a6b62 | ||
![]() |
2ff2ee29ed | ||
![]() |
02afc783cf | ||
![]() |
f3fc24addf | ||
![]() |
2beeda3c2f | ||
![]() |
ac6e9a1021 | ||
![]() |
2428b0fd47 | ||
![]() |
20a1cf0c55 | ||
![]() |
f53d435dfd | ||
![]() |
07c29abbec | ||
![]() |
0ba69e41d0 | ||
![]() |
fb4fecd337 | ||
![]() |
b6c8d64c47 | ||
![]() |
63e84d94c4 | ||
![]() |
176b8bdbcc | ||
![]() |
e0fadea749 | ||
![]() |
b88a0cc49f | ||
![]() |
910995291d | ||
![]() |
d4df6aaae5 | ||
![]() |
ed5e0c6aad | ||
![]() |
bf631f07de |
8
.github/workflows/test-docs.yml
vendored
|
@ -20,7 +20,11 @@ jobs:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: postgres
|
||||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
@ -48,6 +52,8 @@ jobs:
|
||||||
DJANGO_SETTINGS_MODULE: 'umap.tests.settings'
|
DJANGO_SETTINGS_MODULE: 'umap.tests.settings'
|
||||||
UMAP_SETTINGS: 'umap/tests/settings.py'
|
UMAP_SETTINGS: 'umap/tests/settings.py'
|
||||||
PLAYWRIGHT_TIMEOUT: '20000'
|
PLAYWRIGHT_TIMEOUT: '20000'
|
||||||
|
REDIS_HOST: localhost
|
||||||
|
REDIS_PORT: 6379
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|
|
@ -66,7 +66,7 @@ spec:
|
||||||
{{- end }}
|
{{- end }}
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: {{ .Release.Name }}-env
|
name: {{ include "umap.fullname" . }}-env
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /etc/umap/
|
mountPath: /etc/umap/
|
||||||
|
@ -80,7 +80,7 @@ spec:
|
||||||
volumes:
|
volumes:
|
||||||
- name: config
|
- name: config
|
||||||
secret:
|
secret:
|
||||||
secretName: {{ .Release.Name }}-config
|
secretName: {{ include "umap.fullname" . }}-config
|
||||||
- name: statics
|
- name: statics
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
{{- if .Values.persistence.enabled }}
|
{{- if .Values.persistence.enabled }}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# Force rtfd to use a recent version of mkdocs
|
# Force rtfd to use a recent version of mkdocs
|
||||||
mkdocs==1.6.1
|
mkdocs==1.6.1
|
||||||
pymdown-extensions==10.13
|
pymdown-extensions==10.14.1
|
||||||
mkdocs-material==9.5.49
|
mkdocs-material==9.5.50
|
||||||
mkdocs-static-i18n==1.2.3
|
mkdocs-static-i18n==1.2.3
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# Force rtfd to use a recent version of mkdocs
|
# Force rtfd to use a recent version of mkdocs
|
||||||
mkdocs==1.6.1
|
mkdocs==1.6.1
|
||||||
pymdown-extensions==10.13
|
pymdown-extensions==10.14.1
|
||||||
mkdocs-material==9.5.49
|
mkdocs-material==9.5.50
|
||||||
mkdocs-static-i18n==1.2.3
|
mkdocs-static-i18n==1.2.3
|
||||||
|
|
|
@ -47,7 +47,6 @@
|
||||||
"leaflet": "1.9.4",
|
"leaflet": "1.9.4",
|
||||||
"leaflet-editable": "^1.3.0",
|
"leaflet-editable": "^1.3.0",
|
||||||
"leaflet-editinosm": "0.2.3",
|
"leaflet-editinosm": "0.2.3",
|
||||||
"leaflet-formbuilder": "0.2.10",
|
|
||||||
"leaflet-fullscreen": "1.0.2",
|
"leaflet-fullscreen": "1.0.2",
|
||||||
"leaflet-hash": "0.2.1",
|
"leaflet-hash": "0.2.1",
|
||||||
"leaflet-i18n": "0.3.5",
|
"leaflet-i18n": "0.3.5",
|
||||||
|
|
|
@ -28,12 +28,12 @@ classifiers = [
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Django==5.1.4",
|
"Django==5.1.5",
|
||||||
"django-agnocomplete==2.2.0",
|
"django-agnocomplete==2.2.0",
|
||||||
"django-environ==0.11.2",
|
"django-environ==0.12.0",
|
||||||
"django-probes==1.7.0",
|
"django-probes==1.7.0",
|
||||||
"Pillow==11.0.0",
|
"Pillow==11.1.0",
|
||||||
"psycopg==3.2.3",
|
"psycopg==3.2.4",
|
||||||
"requests==2.32.3",
|
"requests==2.32.3",
|
||||||
"rcssmin==1.2.0",
|
"rcssmin==1.2.0",
|
||||||
"rjsmin==1.2.3",
|
"rjsmin==1.2.3",
|
||||||
|
@ -44,16 +44,17 @@ dependencies = [
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"hatch==1.14.0",
|
"hatch==1.14.0",
|
||||||
"ruff==0.8.4",
|
"ruff==0.9.2",
|
||||||
"djlint==1.36.4",
|
"djlint==1.36.4",
|
||||||
"mkdocs==1.6.1",
|
"mkdocs==1.6.1",
|
||||||
"mkdocs-material==9.5.49",
|
"mkdocs-material==9.5.50",
|
||||||
"mkdocs-static-i18n==1.2.3",
|
"mkdocs-static-i18n==1.2.3",
|
||||||
"vermin==1.6.0",
|
"vermin==1.6.0",
|
||||||
"pymdown-extensions==10.13",
|
"pymdown-extensions==10.14.1",
|
||||||
"isort==5.13.2",
|
"isort==5.13.2",
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
|
"daphne==4.1.2",
|
||||||
"factory-boy==3.3.1",
|
"factory-boy==3.3.1",
|
||||||
"playwright>=1.39",
|
"playwright>=1.39",
|
||||||
"pytest==8.3.4",
|
"pytest==8.3.4",
|
||||||
|
@ -61,7 +62,7 @@ test = [
|
||||||
"pytest-playwright==0.6.2",
|
"pytest-playwright==0.6.2",
|
||||||
"pytest-rerunfailures==15.0",
|
"pytest-rerunfailures==15.0",
|
||||||
"pytest-xdist>=3.5.0,<4",
|
"pytest-xdist>=3.5.0,<4",
|
||||||
"moto[s3]==5.0.25"
|
"moto[s3]==5.0.27"
|
||||||
]
|
]
|
||||||
docker = [
|
docker = [
|
||||||
"uwsgi==2.0.28",
|
"uwsgi==2.0.28",
|
||||||
|
@ -70,10 +71,8 @@ s3 = [
|
||||||
"django-storages[s3]==1.14.4",
|
"django-storages[s3]==1.14.4",
|
||||||
]
|
]
|
||||||
sync = [
|
sync = [
|
||||||
"channels==4.2.0",
|
"pydantic==2.10.6",
|
||||||
"daphne==4.1.2",
|
"redis==5.2.1",
|
||||||
"pydantic==2.10.4",
|
|
||||||
"websockets==13.1",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
@ -104,3 +103,6 @@ format_css=true
|
||||||
blank_line_after_tag="load,extends"
|
blank_line_after_tag="load,extends"
|
||||||
line_break_after_multiline_tag=true
|
line_break_after_multiline_tag=true
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
# Disable autoremove of unused import.
|
||||||
|
unfixable = ["F401"]
|
||||||
|
|
|
@ -17,7 +17,6 @@ mkdir -p umap/static/umap/vendors/markercluster/ && cp -r node_modules/leaflet.m
|
||||||
mkdir -p umap/static/umap/vendors/heat/ && cp -r node_modules/leaflet.heat/dist/leaflet-heat.js umap/static/umap/vendors/heat/
|
mkdir -p umap/static/umap/vendors/heat/ && cp -r node_modules/leaflet.heat/dist/leaflet-heat.js umap/static/umap/vendors/heat/
|
||||||
mkdir -p umap/static/umap/vendors/fullscreen/ && cp -r node_modules/leaflet-fullscreen/dist/** umap/static/umap/vendors/fullscreen/
|
mkdir -p umap/static/umap/vendors/fullscreen/ && cp -r node_modules/leaflet-fullscreen/dist/** umap/static/umap/vendors/fullscreen/
|
||||||
mkdir -p umap/static/umap/vendors/toolbar/ && cp -r node_modules/leaflet-toolbar/dist/leaflet.toolbar.* umap/static/umap/vendors/toolbar/
|
mkdir -p umap/static/umap/vendors/toolbar/ && cp -r node_modules/leaflet-toolbar/dist/leaflet.toolbar.* umap/static/umap/vendors/toolbar/
|
||||||
mkdir -p umap/static/umap/vendors/formbuilder/ && cp -r node_modules/leaflet-formbuilder/Leaflet.FormBuilder.js umap/static/umap/vendors/formbuilder/
|
|
||||||
mkdir -p umap/static/umap/vendors/measurable/ && cp -r node_modules/leaflet-measurable/Leaflet.Measurable.* umap/static/umap/vendors/measurable/
|
mkdir -p umap/static/umap/vendors/measurable/ && cp -r node_modules/leaflet-measurable/Leaflet.Measurable.* umap/static/umap/vendors/measurable/
|
||||||
mkdir -p umap/static/umap/vendors/photon/ && cp -r node_modules/leaflet.photon/leaflet.photon.js umap/static/umap/vendors/photon/
|
mkdir -p umap/static/umap/vendors/photon/ && cp -r node_modules/leaflet.photon/leaflet.photon.js umap/static/umap/vendors/photon/
|
||||||
mkdir -p umap/static/umap/vendors/csv2geojson/ && cp -r node_modules/csv2geojson/csv2geojson.js umap/static/umap/vendors/csv2geojson/
|
mkdir -p umap/static/umap/vendors/csv2geojson/ && cp -r node_modules/csv2geojson/csv2geojson.js umap/static/umap/vendors/csv2geojson/
|
||||||
|
|
19
umap/asgi.py
|
@ -1,15 +1,20 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from channels.routing import ProtocolTypeRouter
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "umap.settings")
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "umap.settings")
|
from .sync.app import application as ws_application
|
||||||
|
|
||||||
# Initialize Django ASGI application early to ensure the AppRegistry
|
# Initialize Django ASGI application early to ensure the AppRegistry
|
||||||
# is populated before importing code that may import ORM models.
|
# is populated before importing code that may import ORM models.
|
||||||
django_asgi_app = get_asgi_application()
|
django_asgi_app = get_asgi_application()
|
||||||
|
|
||||||
application = ProtocolTypeRouter(
|
|
||||||
{
|
async def application(scope, receive, send):
|
||||||
"http": django_asgi_app,
|
if scope["type"] == "http":
|
||||||
}
|
await django_asgi_app(scope, receive, send)
|
||||||
)
|
elif scope["type"] == "websocket":
|
||||||
|
await ws_application(scope, receive, send)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError(f"Unknown scope type {scope['type']}")
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
from django.conf import settings
|
|
||||||
from django.core.management.base import BaseCommand
|
|
||||||
|
|
||||||
from umap import websocket_server
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Run the websocket server"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"--host",
|
|
||||||
help="The server host to bind to.",
|
|
||||||
default=settings.WEBSOCKET_BACK_HOST,
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--port",
|
|
||||||
help="The server port to bind to.",
|
|
||||||
default=settings.WEBSOCKET_BACK_PORT,
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
websocket_server.run(options["host"], options["port"])
|
|
|
@ -342,4 +342,5 @@ LOGGING = {
|
||||||
WEBSOCKET_ENABLED = env.bool("WEBSOCKET_ENABLED", default=False)
|
WEBSOCKET_ENABLED = env.bool("WEBSOCKET_ENABLED", default=False)
|
||||||
WEBSOCKET_BACK_HOST = env("WEBSOCKET_BACK_HOST", default="localhost")
|
WEBSOCKET_BACK_HOST = env("WEBSOCKET_BACK_HOST", default="localhost")
|
||||||
WEBSOCKET_BACK_PORT = env.int("WEBSOCKET_BACK_PORT", default=8001)
|
WEBSOCKET_BACK_PORT = env.int("WEBSOCKET_BACK_PORT", default=8001)
|
||||||
WEBSOCKET_FRONT_URI = env("WEBSOCKET_FRONT_URI", default="ws://localhost:8001")
|
|
||||||
|
REDIS_URL = "redis://localhost:6379"
|
||||||
|
|
|
@ -46,7 +46,7 @@ h3, h4, h5 {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
line-height: 21px;
|
line-height: 1.4;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 8.9 KiB |
|
@ -41,33 +41,14 @@ body.login header {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-grid span,
|
||||||
.login-grid a {
|
.login-grid a {
|
||||||
border: 1px solid #e5e5e5;
|
border: 1px solid #e5e5e5;
|
||||||
padding: 5px;
|
|
||||||
color: #000;
|
color: #000;
|
||||||
background-position: center bottom;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 92px 92px;
|
|
||||||
height: 92px;
|
height: 92px;
|
||||||
width: 92px;
|
width: 92px;
|
||||||
margin-inline-end: 10px;
|
margin-inline-end: 10px;
|
||||||
}
|
}
|
||||||
.login-grid .login-github {
|
|
||||||
background-image: url("./github.png");
|
|
||||||
}
|
|
||||||
.login-grid .login-bitbucket {
|
|
||||||
background-image: url("./bitbucket.png");
|
|
||||||
}
|
|
||||||
.login-grid .login-twitter-oauth2 {
|
|
||||||
background-image: url("./twitter.png");
|
|
||||||
}
|
|
||||||
.login-grid .login-openstreetmap,
|
|
||||||
.login-grid .login-openstreetmap-oauth2 {
|
|
||||||
background-image: url("./openstreetmap.png");
|
|
||||||
}
|
|
||||||
.login-grid .login-keycloak {
|
|
||||||
background-image: url("./keycloak.png");
|
|
||||||
}
|
|
||||||
|
|
||||||
/* **************************** */
|
/* **************************** */
|
||||||
/* home */
|
/* home */
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
.umap-main-edit-toolbox [type=button] {
|
.umap-main-edit-toolbox [type=button] {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
border: none;
|
|
||||||
background-color: var(--color-darkGray);
|
background-color: var(--color-darkGray);
|
||||||
width: auto;
|
width: auto;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
@ -11,7 +10,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container [type=button].umap-help-link {
|
.leaflet-container [type=button].umap-help-link {
|
||||||
padding-bottom: 3px;
|
padding: 0 var(--text-margin);
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
.leaflet-container .edit-save,
|
.leaflet-container .edit-save,
|
||||||
|
@ -20,8 +19,6 @@
|
||||||
.leaflet-container .connected-peers
|
.leaflet-container .connected-peers
|
||||||
{
|
{
|
||||||
display: block;
|
display: block;
|
||||||
border: none;
|
|
||||||
border-radius: 20px;
|
|
||||||
height: 32px;
|
height: 32px;
|
||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
|
@ -37,11 +34,6 @@
|
||||||
color: var(--color-darkGray);
|
color: var(--color-darkGray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container .edit-cancel,
|
|
||||||
.leaflet-container .edit-disable,
|
|
||||||
.leaflet-container .connected-peers{
|
|
||||||
border: 0.5px solid rgba(153, 153, 153, 0.40);
|
|
||||||
}
|
|
||||||
.leaflet-container .edit-cancel:hover,
|
.leaflet-container .edit-cancel:hover,
|
||||||
.leaflet-container .edit-disable:hover {
|
.leaflet-container .edit-disable:hover {
|
||||||
border: 0.5px solid rgba(153, 153, 153, 0.80);
|
border: 0.5px solid rgba(153, 153, 153, 0.80);
|
||||||
|
@ -120,7 +112,7 @@
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
}
|
}
|
||||||
.umap-right-edit-toolbox {
|
.umap-right-edit-toolbox {
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.umap-main-edit-toolbox .logo {
|
.umap-main-edit-toolbox .logo {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
.umap-form-inline .formbox,
|
||||||
.umap-form-inline {
|
.umap-form-inline {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
@ -75,15 +76,14 @@ select[multiple="multiple"] {
|
||||||
.button,
|
.button,
|
||||||
[type="button"],
|
[type="button"],
|
||||||
input[type="submit"] {
|
input[type="submit"] {
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 7px 14px;
|
padding: 3px 12px;
|
||||||
min-height: 32px;
|
|
||||||
line-height: 32px;
|
|
||||||
border: none;
|
border: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
@ -131,6 +131,11 @@ button.flat:hover,
|
||||||
.dark [type="button"].flat:hover {
|
.dark [type="button"].flat:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
.dark button.round,
|
||||||
|
button.round {
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 0.5px solid rgba(153, 153, 153, 0.40);
|
||||||
|
}
|
||||||
.help-text, .helptext {
|
.help-text, .helptext {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 7px 7px;
|
padding: 7px 7px;
|
||||||
|
@ -381,16 +386,19 @@ input.switch:checked ~ label:after {
|
||||||
box-shadow: inset 0 0 6px 0px #2c3233;
|
box-shadow: inset 0 0 6px 0px #2c3233;
|
||||||
color: var(--color-darkGray);
|
color: var(--color-darkGray);
|
||||||
}
|
}
|
||||||
.inheritable .header,
|
.inheritable .header .buttons {
|
||||||
.inheritable {
|
padding: 0;
|
||||||
clear: both;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.inheritable .header {
|
.inheritable .header {
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-content: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.inheritable .header label {
|
.inheritable .header label {
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
width: initial;
|
||||||
}
|
}
|
||||||
.inheritable + .inheritable {
|
.inheritable + .inheritable {
|
||||||
border-top: 1px solid #222;
|
border-top: 1px solid #222;
|
||||||
|
@ -400,22 +408,11 @@ input.switch:checked ~ label:after {
|
||||||
.umap-field-iconUrl .action-button,
|
.umap-field-iconUrl .action-button,
|
||||||
.inheritable .define,
|
.inheritable .define,
|
||||||
.inheritable .undefine {
|
.inheritable .undefine {
|
||||||
float: inline-end;
|
|
||||||
width: initial;
|
width: initial;
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
.inheritable .quick-actions {
|
|
||||||
float: inline-end;
|
|
||||||
}
|
|
||||||
.inheritable .quick-actions .formbox {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.inheritable .quick-actions input {
|
|
||||||
width: 100px;
|
|
||||||
margin-inline-end: 5px;
|
|
||||||
}
|
|
||||||
.inheritable .define,
|
.inheritable .define,
|
||||||
.inheritable.undefined .undefine,
|
.inheritable.undefined .undefine,
|
||||||
.inheritable.undefined .show-on-defined {
|
.inheritable.undefined .show-on-defined {
|
||||||
|
@ -493,12 +490,15 @@ i.info {
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
.flat-tabs {
|
.flat-tabs {
|
||||||
display: flex;
|
display: none;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border-bottom: 1px solid #bebebe;
|
border-bottom: 1px solid #bebebe;
|
||||||
}
|
}
|
||||||
|
.flat-tabs:has(.flat) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.flat-tabs button {
|
.flat-tabs button {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
@ -534,7 +534,7 @@ i.info {
|
||||||
background-color: #999;
|
background-color: #999;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
display: block;
|
display: inline-block;
|
||||||
color: black;
|
color: black;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
@ -559,7 +559,6 @@ i.info {
|
||||||
clear: both;
|
clear: both;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
.umap-color-picker span {
|
.umap-color-picker span {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
@ -577,17 +576,11 @@ input.blur {
|
||||||
border-start-end-radius: 0;
|
border-start-end-radius: 0;
|
||||||
border-end-end-radius: 0;
|
border-end-end-radius: 0;
|
||||||
}
|
}
|
||||||
.blur + .button:before,
|
|
||||||
.blur + [type="button"]:before {
|
|
||||||
content: '✔';
|
|
||||||
}
|
|
||||||
.blur + .button,
|
.blur + .button,
|
||||||
.blur + [type="button"] {
|
.blur + [type="button"] {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 18px;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
line-height: 18px;
|
|
||||||
border-start-start-radius: 0;
|
border-start-start-radius: 0;
|
||||||
border-end-start-radius: 0;
|
border-end-start-radius: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
@ -596,6 +589,10 @@ input[type=hidden].blur + .button,
|
||||||
input[type=hidden].blur + [type="button"] {
|
input[type=hidden].blur + [type="button"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
.blur-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
.copiable-input {
|
.copiable-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
|
|
|
@ -17,11 +17,11 @@
|
||||||
background-image: url('../img/24.svg');
|
background-image: url('../img/24.svg');
|
||||||
--tile: -36px;
|
--tile: -36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
line-height: 36px;
|
|
||||||
width: 36px;
|
width: 36px;
|
||||||
}
|
}
|
||||||
.icon + span {
|
.icon + span {
|
||||||
margin-inline-start: 10px;
|
margin-inline-start: 5px;
|
||||||
|
margin-inline-end: 5px;
|
||||||
}
|
}
|
||||||
html[dir="rtl"] .icon {
|
html[dir="rtl"] .icon {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
|
@ -153,6 +153,12 @@ html[dir="rtl"] .icon {
|
||||||
.icon-share {
|
.icon-share {
|
||||||
background-position: 0px calc(var(--tile) * 5);
|
background-position: 0px calc(var(--tile) * 5);
|
||||||
}
|
}
|
||||||
|
.icon-star {
|
||||||
|
background-position: var(--tile) calc(var(--tile) * 7);
|
||||||
|
}
|
||||||
|
.icon-starred {
|
||||||
|
background-position: 0 calc(var(--tile) * 7);
|
||||||
|
}
|
||||||
.icon-table {
|
.icon-table {
|
||||||
background-position: calc(var(--tile) * 2) 0px;
|
background-position: calc(var(--tile) * 2) 0px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,8 @@
|
||||||
padding: var(--panel-gutter);
|
padding: var(--panel-gutter);
|
||||||
}
|
}
|
||||||
.panel h3 {
|
.panel h3 {
|
||||||
line-height: 120%;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
.panel .counter::before {
|
.panel .counter::before {
|
||||||
counter-increment: step;
|
counter-increment: step;
|
||||||
|
|
Before Width: | Height: | Size: 1.5 KiB |
|
@ -208,5 +208,7 @@
|
||||||
<g id="g2-67" transform="translate(170.12 814.31)" clip-path="url(#clip0_2695_1939)">
|
<g id="g2-67" transform="translate(170.12 814.31)" clip-path="url(#clip0_2695_1939)">
|
||||||
<path id="path1-5" d="m8.8453 14.83c-0.28116 0.6439-1.1722 0.6439-1.4533 0l-0.73138-1.6751c-0.65086-1.4907-1.8224-2.6774-3.2837-3.326l-2.0131-0.89358c-0.64004-0.28408-0.64004-1.2152 0-1.4993l1.9502-0.86569c1.4989-0.66535 2.6914-1.8959 3.3312-3.4375l0.74086-1.7852c0.27491-0.66247 1.1902-0.66247 1.4652 0l0.74083 1.7852c0.63972 1.5416 1.8322 2.7722 3.3311 3.4375l1.9503 0.86569c0.64 0.2841 0.64 1.2152 0 1.4993l-2.0131 0.89358c-1.4613 0.64864-2.6328 1.8353-3.2837 3.326zm-5.0624-6.6444c1.9049 0.84555 3.4537 2.2354 4.3357 4.1478 0.88202-1.9124 2.4308-3.3022 4.3356-4.1478-1.9276-0.85565-3.4813-2.3132-4.3356-4.2596-0.85434 1.9463-2.4081 3.4039-4.3357 4.2596zm12.385 10.723 0.2057-0.4714c0.3667-0.8405 1.0271-1.5098 1.8511-1.8758l0.6336-0.2816c0.3428-0.1523 0.3428-0.6504 0-0.8026l-0.5981-0.2658c-0.8453-0.3755-1.5175-1.0695-1.8779-1.9386l-0.2112-0.5094c-0.1473-0.355-0.6381-0.355-0.7853 0l-0.2112 0.5094c-0.3603 0.8691-1.0326 1.5631-1.8778 1.9386l-0.5983 0.2658c-0.3427 0.1522-0.3427 0.6503 0 0.8026l0.6337 0.2816c0.8241 0.366 1.4844 1.0353 1.8511 1.8758l0.2057 0.4714c0.1505 0.3451 0.6283 0.3451 0.7789 0zm-0.8557-3.0358 0.4687-0.4655 0.459 0.4655-0.459 0.4524z" fill="#efefef"/>
|
<path id="path1-5" d="m8.8453 14.83c-0.28116 0.6439-1.1722 0.6439-1.4533 0l-0.73138-1.6751c-0.65086-1.4907-1.8224-2.6774-3.2837-3.326l-2.0131-0.89358c-0.64004-0.28408-0.64004-1.2152 0-1.4993l1.9502-0.86569c1.4989-0.66535 2.6914-1.8959 3.3312-3.4375l0.74086-1.7852c0.27491-0.66247 1.1902-0.66247 1.4652 0l0.74083 1.7852c0.63972 1.5416 1.8322 2.7722 3.3311 3.4375l1.9503 0.86569c0.64 0.2841 0.64 1.2152 0 1.4993l-2.0131 0.89358c-1.4613 0.64864-2.6328 1.8353-3.2837 3.326zm-5.0624-6.6444c1.9049 0.84555 3.4537 2.2354 4.3357 4.1478 0.88202-1.9124 2.4308-3.3022 4.3356-4.1478-1.9276-0.85565-3.4813-2.3132-4.3356-4.2596-0.85434 1.9463-2.4081 3.4039-4.3357 4.2596zm12.385 10.723 0.2057-0.4714c0.3667-0.8405 1.0271-1.5098 1.8511-1.8758l0.6336-0.2816c0.3428-0.1523 0.3428-0.6504 0-0.8026l-0.5981-0.2658c-0.8453-0.3755-1.5175-1.0695-1.8779-1.9386l-0.2112-0.5094c-0.1473-0.355-0.6381-0.355-0.7853 0l-0.2112 0.5094c-0.3603 0.8691-1.0326 1.5631-1.8778 1.9386l-0.5983 0.2658c-0.3427 0.1522-0.3427 0.6503 0 0.8026l0.6337 0.2816c0.8241 0.366 1.4844 1.0353 1.8511 1.8758l0.2057 0.4714c0.1505 0.3451 0.6283 0.3451 0.7789 0zm-0.8557-3.0358 0.4687-0.4655 0.459 0.4655-0.459 0.4524z" fill="#efefef"/>
|
||||||
</g>
|
</g>
|
||||||
|
<path id="star" class="sprite" d="m7.6698 998.86 1.3886-5.255-4.0585-3.2468h5.1831l1.8193-5.4949 1.8147 5.4939h5.1829l-4.0615 3.2496 1.3838 5.2564-4.3249-3.0123z" fill="#efefef"/>
|
||||||
|
<path id="starred" class="sprite" d="m31.67 998.86 1.3886-5.255-4.0585-3.2468h5.1831l1.8193-5.4949 1.8147 5.4939h5.1829l-4.0615 3.2496 1.3838 5.2565-4.3249-3.0123z" fill="none" stroke="#efefef" stroke-linecap="square" stroke-linejoin="round"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
BIN
umap/static/umap/img/providers/bitbucket.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
umap/static/umap/img/providers/github.png
Normal file
After Width: | Height: | Size: 608 B |
BIN
umap/static/umap/img/providers/keycloak.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
umap/static/umap/img/providers/openstreetmap-oauth2.png
Normal file
After Width: | Height: | Size: 1.6 KiB |
BIN
umap/static/umap/img/providers/twitter-oauth2.png
Normal file
After Width: | Height: | Size: 545 B |
|
@ -19,7 +19,7 @@
|
||||||
<rect width="20" height="20" fill="#ffffff" id="rect1" x="0" y="0" />
|
<rect width="20" height="20" fill="#ffffff" id="rect1" x="0" y="0" />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="14.041122" inkscape:cx="165.15774" inkscape:cy="24.998002" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" inkscape:window-width="1920" inkscape:window-height="1011" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" showguides="true" inkscape:guide-bbox="true" inkscape:snap-grids="true" inkscape:snap-to-guides="true" inkscape:showpageshadow="2" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1">
|
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="14.412751" inkscape:cx="41.352272" inkscape:cy="165.68662" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="true" inkscape:window-width="1920" inkscape:window-height="1011" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" showguides="true" inkscape:guide-bbox="true" inkscape:snap-grids="true" inkscape:snap-to-guides="true" inkscape:showpageshadow="2" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1">
|
||||||
<inkscape:grid type="xygrid" id="grid3004" empspacing="4" visible="true" enabled="true" snapvisiblegridlinesonly="true" originx="0" originy="0" spacingy="1" spacingx="1" units="px" />
|
<inkscape:grid type="xygrid" id="grid3004" empspacing="4" visible="true" enabled="true" snapvisiblegridlinesonly="true" originx="0" originy="0" spacingy="1" spacingx="1" units="px" />
|
||||||
<inkscape:grid id="grid1" units="px" originx="0" originy="0" spacingx="24" spacingy="24" empcolor="#203fff" empopacity="0.85490196" color="#3f3fff" opacity="0.1254902" empspacing="1" enabled="true" visible="true" />
|
<inkscape:grid id="grid1" units="px" originx="0" originy="0" spacingx="24" spacingy="24" empcolor="#203fff" empopacity="0.85490196" color="#3f3fff" opacity="0.1254902" empspacing="1" enabled="true" visible="true" />
|
||||||
</sodipodi:namedview>
|
</sodipodi:namedview>
|
||||||
|
@ -219,5 +219,7 @@
|
||||||
<g clip-path="url(#clip0_2695_1939)" id="g2-67" transform="translate(170.11621,814.31159)">
|
<g clip-path="url(#clip0_2695_1939)" id="g2-67" transform="translate(170.11621,814.31159)">
|
||||||
<path d="m 8.84533,14.8298 c -0.28116,0.6439 -1.1722,0.6439 -1.45333,0 l -0.73138,-1.6751 c -0.65086,-1.4907 -1.82238,-2.6774 -3.2837,-3.32604 l -2.0131,-0.89358 c -0.64004,-0.28408 -0.64004,-1.21518 0,-1.49928 l 1.95022,-0.86569 c 1.4989,-0.66535 2.69143,-1.89594 3.33118,-3.43751 l 0.74086,-1.78516 c 0.27491,-0.662471 1.19025,-0.662472 1.46517,0 l 0.74083,1.78517 c 0.63972,1.54156 1.83222,2.77215 3.33112,3.4375 l 1.9503,0.86569 c 0.64,0.2841 0.64,1.2152 0,1.49928 l -2.0131,0.89358 c -1.4613,0.64864 -2.6328,1.83534 -3.28374,3.32604 z m -5.06236,-6.64435 c 1.90486,0.84555 3.45371,2.23535 4.33568,4.14775 0.88202,-1.9124 2.43085,-3.3022 4.33565,-4.14775 -1.9276,-0.85565 -3.4813,-2.31323 -4.33564,-4.25955 -0.85434,1.94633 -2.4081,3.4039 -4.33569,4.25955 z m 12.38483,10.72295 0.2057,-0.4714 c 0.3667,-0.8405 1.0271,-1.5098 1.8511,-1.8758 l 0.6336,-0.2816 c 0.3428,-0.1523 0.3428,-0.6504 0,-0.8026 l -0.5981,-0.2658 c -0.8453,-0.3755 -1.5175,-1.0695 -1.8779,-1.9386 l -0.2112,-0.5094 c -0.1473,-0.355 -0.6381,-0.355 -0.7853,0 l -0.2112,0.5094 c -0.3603,0.8691 -1.0326,1.5631 -1.8778,1.9386 l -0.5983,0.2658 c -0.3427,0.1522 -0.3427,0.6503 0,0.8026 l 0.6337,0.2816 c 0.8241,0.366 1.4844,1.0353 1.8511,1.8758 l 0.2057,0.4714 c 0.1505,0.3451 0.6283,0.3451 0.7789,0 z m -0.8557,-3.0358 0.4687,-0.4655 0.459,0.4655 -0.459,0.4524 z" fill="#efefef" id="path1-5" />
|
<path d="m 8.84533,14.8298 c -0.28116,0.6439 -1.1722,0.6439 -1.45333,0 l -0.73138,-1.6751 c -0.65086,-1.4907 -1.82238,-2.6774 -3.2837,-3.32604 l -2.0131,-0.89358 c -0.64004,-0.28408 -0.64004,-1.21518 0,-1.49928 l 1.95022,-0.86569 c 1.4989,-0.66535 2.69143,-1.89594 3.33118,-3.43751 l 0.74086,-1.78516 c 0.27491,-0.662471 1.19025,-0.662472 1.46517,0 l 0.74083,1.78517 c 0.63972,1.54156 1.83222,2.77215 3.33112,3.4375 l 1.9503,0.86569 c 0.64,0.2841 0.64,1.2152 0,1.49928 l -2.0131,0.89358 c -1.4613,0.64864 -2.6328,1.83534 -3.28374,3.32604 z m -5.06236,-6.64435 c 1.90486,0.84555 3.45371,2.23535 4.33568,4.14775 0.88202,-1.9124 2.43085,-3.3022 4.33565,-4.14775 -1.9276,-0.85565 -3.4813,-2.31323 -4.33564,-4.25955 -0.85434,1.94633 -2.4081,3.4039 -4.33569,4.25955 z m 12.38483,10.72295 0.2057,-0.4714 c 0.3667,-0.8405 1.0271,-1.5098 1.8511,-1.8758 l 0.6336,-0.2816 c 0.3428,-0.1523 0.3428,-0.6504 0,-0.8026 l -0.5981,-0.2658 c -0.8453,-0.3755 -1.5175,-1.0695 -1.8779,-1.9386 l -0.2112,-0.5094 c -0.1473,-0.355 -0.6381,-0.355 -0.7853,0 l -0.2112,0.5094 c -0.3603,0.8691 -1.0326,1.5631 -1.8778,1.9386 l -0.5983,0.2658 c -0.3427,0.1522 -0.3427,0.6503 0,0.8026 l 0.6337,0.2816 c 0.8241,0.366 1.4844,1.0353 1.8511,1.8758 l 0.2057,0.4714 c 0.1505,0.3451 0.6283,0.3451 0.7789,0 z m -0.8557,-3.0358 0.4687,-0.4655 0.459,0.4655 -0.459,0.4524 z" fill="#efefef" id="path1-5" />
|
||||||
</g>
|
</g>
|
||||||
|
<path style="fill:#efefef;fill-opacity:1;stroke:none;stroke-width:6.97518;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" inkscape:transform-center-x="-0.0010932573" inkscape:transform-center-y="-0.7377641" d="m 7.6698317,998.8588 1.3886278,-5.25497 -4.0584591,-3.24679 h 5.1830926 l 1.819345,-5.49493 1.81469,5.49392 h 5.182872 l -4.06154,3.24965 1.383849,5.25642 -4.324867,-3.01228 z" id="star" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccccccccc" class="sprite" />
|
||||||
|
<path style="fill:none;fill-opacity:1;stroke:#efefef;stroke-width:1;stroke-linecap:square;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" inkscape:transform-center-x="-0.0010925804" inkscape:transform-center-y="-0.73776941" d="m 31.669832,998.8588 1.388628,-5.25501 -4.05846,-3.24679 h 5.183093 l 1.819345,-5.49493 1.814694,5.49392 h 5.182868 l -4.06154,3.24964 1.383849,5.25647 -4.324874,-3.01232 z" id="starred" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccccccccc" class="sprite" />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 75 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
|
@ -4,6 +4,7 @@ import * as Icon from './rendering/icon.js'
|
||||||
import * as Utils from './utils.js'
|
import * as Utils from './utils.js'
|
||||||
import { EXPORT_FORMATS } from './formatter.js'
|
import { EXPORT_FORMATS } from './formatter.js'
|
||||||
import ContextMenu from './ui/contextmenu.js'
|
import ContextMenu from './ui/contextmenu.js'
|
||||||
|
import { Form } from './form/builder.js'
|
||||||
|
|
||||||
export default class Browser {
|
export default class Browser {
|
||||||
constructor(umap, leafletMap) {
|
constructor(umap, leafletMap) {
|
||||||
|
@ -179,9 +180,8 @@ export default class Browser {
|
||||||
],
|
],
|
||||||
['options.inBbox', { handler: 'Switch', label: translate('Current map view') }],
|
['options.inBbox', { handler: 'Switch', label: translate('Current map view') }],
|
||||||
]
|
]
|
||||||
const builder = new L.FormBuilder(this, fields, {
|
const builder = new Form(this, fields)
|
||||||
callback: () => this.onFormChange(),
|
builder.on('set', () => this.onFormChange())
|
||||||
})
|
|
||||||
let filtersBuilder
|
let filtersBuilder
|
||||||
this.formContainer.appendChild(builder.build())
|
this.formContainer.appendChild(builder.build())
|
||||||
DomEvent.on(builder.form, 'reset', () => {
|
DomEvent.on(builder.form, 'reset', () => {
|
||||||
|
@ -189,9 +189,8 @@ export default class Browser {
|
||||||
})
|
})
|
||||||
if (this._umap.properties.facetKey) {
|
if (this._umap.properties.facetKey) {
|
||||||
fields = this._umap.facets.build()
|
fields = this._umap.facets.build()
|
||||||
filtersBuilder = new L.FormBuilder(this._umap.facets, fields, {
|
filtersBuilder = new Form(this._umap.facets, fields)
|
||||||
callback: () => this.onFormChange(),
|
filtersBuilder.on('set', () => this.onFormChange())
|
||||||
})
|
|
||||||
DomEvent.on(filtersBuilder.form, 'reset', () => {
|
DomEvent.on(filtersBuilder.form, 'reset', () => {
|
||||||
window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder))
|
window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder))
|
||||||
})
|
})
|
||||||
|
@ -255,6 +254,7 @@ export default class Browser {
|
||||||
if (datalayer.isVisible()) allHidden = false
|
if (datalayer.isVisible()) allHidden = false
|
||||||
})
|
})
|
||||||
this._umap.eachBrowsableDataLayer((datalayer) => {
|
this._umap.eachBrowsableDataLayer((datalayer) => {
|
||||||
|
datalayer._forcedVisibility = true
|
||||||
if (allHidden) {
|
if (allHidden) {
|
||||||
datalayer.show()
|
datalayer.show()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { translate } from './i18n.js'
|
import { translate } from './i18n.js'
|
||||||
import * as Utils from './utils.js'
|
import * as Utils from './utils.js'
|
||||||
|
import { uMapAlert as Alert } from '../components/alerts/alert.js'
|
||||||
|
|
||||||
const TEMPLATE = `
|
const TEMPLATE = `
|
||||||
<div class="umap-caption">
|
<div class="umap-caption">
|
||||||
|
@ -7,8 +8,9 @@ const TEMPLATE = `
|
||||||
<i class="icon icon-16 icon-caption icon-block"></i>
|
<i class="icon icon-16 icon-caption icon-block"></i>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
<h3><span class="map-name" data-ref="name"></span></h3>
|
<h3><span class="map-name" data-ref="name"></span></h3>
|
||||||
<h4 data-ref="author"></h4>
|
<p class="dates" data-ref="dates"></p>
|
||||||
<h5 class="dates" data-ref="dates"></h5>
|
<p data-ref="author"></p>
|
||||||
|
<p><button type="button" class="round" data-ref="star" title="${translate('Star this map')}"><i class="icon icon-16 icon-star map-star"></i><span class="map-stars"></span></button></p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
</div>
|
</div>
|
||||||
<div class="umap-map-description text" data-ref="description"></div>
|
<div class="umap-map-description text" data-ref="description"></div>
|
||||||
|
@ -35,6 +37,14 @@ export default class Caption extends Utils.WithTemplate {
|
||||||
this._umap = umap
|
this._umap = umap
|
||||||
this._leafletMap = leafletMap
|
this._leafletMap = leafletMap
|
||||||
this.loadTemplate(TEMPLATE)
|
this.loadTemplate(TEMPLATE)
|
||||||
|
this.elements.star.addEventListener('click', async () => {
|
||||||
|
if (this._umap.properties.user?.id) {
|
||||||
|
await this._umap.star()
|
||||||
|
this.refresh()
|
||||||
|
} else {
|
||||||
|
Alert.error(translate('You must be logged in'))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
isOpen() {
|
isOpen() {
|
||||||
|
@ -62,10 +72,6 @@ export default class Caption extends Utils.WithTemplate {
|
||||||
this.addDataLayer(datalayer, this.elements.datalayersContainer)
|
this.addDataLayer(datalayer, this.elements.datalayersContainer)
|
||||||
)
|
)
|
||||||
this.addCredits()
|
this.addCredits()
|
||||||
this._umap.panel.open({ content: this.element }).then(() => {
|
|
||||||
// Create the legend when the panel is actually on the DOM
|
|
||||||
this._umap.eachDataLayerReverse((datalayer) => datalayer.renderLegend())
|
|
||||||
})
|
|
||||||
if (this._umap.properties.created_at) {
|
if (this._umap.properties.created_at) {
|
||||||
const created_at = translate('Created at {date}', {
|
const created_at = translate('Created at {date}', {
|
||||||
date: new Date(this._umap.properties.created_at).toLocaleDateString(),
|
date: new Date(this._umap.properties.created_at).toLocaleDateString(),
|
||||||
|
@ -77,6 +83,11 @@ export default class Caption extends Utils.WithTemplate {
|
||||||
} else {
|
} else {
|
||||||
this.elements.dates.hidden = true
|
this.elements.dates.hidden = true
|
||||||
}
|
}
|
||||||
|
this._umap.panel.open({ content: this.element }).then(() => {
|
||||||
|
// Create the legend when the panel is actually on the DOM
|
||||||
|
this._umap.eachDataLayerReverse((datalayer) => datalayer.renderLegend())
|
||||||
|
this._umap.propagate()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
addDataLayer(datalayer, parent) {
|
addDataLayer(datalayer, parent) {
|
||||||
|
|
|
@ -16,6 +16,7 @@ import {
|
||||||
MaskPolygon,
|
MaskPolygon,
|
||||||
} from '../rendering/ui.js'
|
} from '../rendering/ui.js'
|
||||||
import loadPopup from '../rendering/popup.js'
|
import loadPopup from '../rendering/popup.js'
|
||||||
|
import { MutatingForm } from '../form/builder.js'
|
||||||
|
|
||||||
class Feature {
|
class Feature {
|
||||||
constructor(umap, datalayer, geojson = {}, id = null) {
|
constructor(umap, datalayer, geojson = {}, id = null) {
|
||||||
|
@ -212,6 +213,7 @@ class Feature {
|
||||||
if (this._umap.currentFeature === this) {
|
if (this._umap.currentFeature === this) {
|
||||||
this.view()
|
this.view()
|
||||||
}
|
}
|
||||||
|
this.datalayer.indexProperties(this)
|
||||||
}
|
}
|
||||||
this.redraw()
|
this.redraw()
|
||||||
}
|
}
|
||||||
|
@ -225,20 +227,16 @@ class Feature {
|
||||||
`icon-${this.getClassName()}`
|
`icon-${this.getClassName()}`
|
||||||
)
|
)
|
||||||
|
|
||||||
let builder = new U.FormBuilder(
|
let builder = new MutatingForm(this, [
|
||||||
this,
|
['datalayer', { handler: 'DataLayerSwitcher' }],
|
||||||
[['datalayer', { handler: 'DataLayerSwitcher' }]],
|
])
|
||||||
{
|
// removeLayer step will close the edit panel, let's reopen it
|
||||||
callback() {
|
builder.on('set', () => this.edit(event))
|
||||||
this.edit(event)
|
|
||||||
}, // removeLayer step will close the edit panel, let's reopen it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
container.appendChild(builder.build())
|
container.appendChild(builder.build())
|
||||||
|
|
||||||
const properties = []
|
const properties = []
|
||||||
let labelKeyFound = undefined
|
let labelKeyFound = undefined
|
||||||
for (const property of this.datalayer._propertiesIndex) {
|
for (const property of this.datalayer.allProperties()) {
|
||||||
if (!labelKeyFound && U.LABEL_KEYS.includes(property)) {
|
if (!labelKeyFound && U.LABEL_KEYS.includes(property)) {
|
||||||
labelKeyFound = property
|
labelKeyFound = property
|
||||||
continue
|
continue
|
||||||
|
@ -254,7 +252,7 @@ class Feature {
|
||||||
labelKeyFound = U.DEFAULT_LABEL_KEY
|
labelKeyFound = U.DEFAULT_LABEL_KEY
|
||||||
}
|
}
|
||||||
properties.unshift([`properties.${labelKeyFound}`, { label: labelKeyFound }])
|
properties.unshift([`properties.${labelKeyFound}`, { label: labelKeyFound }])
|
||||||
builder = new U.FormBuilder(this, properties, {
|
builder = new MutatingForm(this, properties, {
|
||||||
id: 'umap-feature-properties',
|
id: 'umap-feature-properties',
|
||||||
})
|
})
|
||||||
container.appendChild(builder.build())
|
container.appendChild(builder.build())
|
||||||
|
@ -285,7 +283,7 @@ class Feature {
|
||||||
|
|
||||||
appendEditFieldsets(container) {
|
appendEditFieldsets(container) {
|
||||||
const optionsFields = this.getShapeOptions()
|
const optionsFields = this.getShapeOptions()
|
||||||
let builder = new U.FormBuilder(this, optionsFields, {
|
let builder = new MutatingForm(this, optionsFields, {
|
||||||
id: 'umap-feature-shape-properties',
|
id: 'umap-feature-shape-properties',
|
||||||
})
|
})
|
||||||
const shapeProperties = DomUtil.createFieldset(
|
const shapeProperties = DomUtil.createFieldset(
|
||||||
|
@ -295,7 +293,7 @@ class Feature {
|
||||||
shapeProperties.appendChild(builder.build())
|
shapeProperties.appendChild(builder.build())
|
||||||
|
|
||||||
const advancedOptions = this.getAdvancedOptions()
|
const advancedOptions = this.getAdvancedOptions()
|
||||||
builder = new U.FormBuilder(this, advancedOptions, {
|
builder = new MutatingForm(this, advancedOptions, {
|
||||||
id: 'umap-feature-advanced-properties',
|
id: 'umap-feature-advanced-properties',
|
||||||
})
|
})
|
||||||
const advancedProperties = DomUtil.createFieldset(
|
const advancedProperties = DomUtil.createFieldset(
|
||||||
|
@ -305,7 +303,7 @@ class Feature {
|
||||||
advancedProperties.appendChild(builder.build())
|
advancedProperties.appendChild(builder.build())
|
||||||
|
|
||||||
const interactionOptions = this.getInteractionOptions()
|
const interactionOptions = this.getInteractionOptions()
|
||||||
builder = new U.FormBuilder(this, interactionOptions)
|
builder = new MutatingForm(this, interactionOptions)
|
||||||
const popupFieldset = DomUtil.createFieldset(
|
const popupFieldset = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Interaction options')
|
translate('Interaction options')
|
||||||
|
@ -733,8 +731,8 @@ export class Point extends Feature {
|
||||||
['ui._latlng.lat', { handler: 'FloatInput', label: translate('Latitude') }],
|
['ui._latlng.lat', { handler: 'FloatInput', label: translate('Latitude') }],
|
||||||
['ui._latlng.lng', { handler: 'FloatInput', label: translate('Longitude') }],
|
['ui._latlng.lng', { handler: 'FloatInput', label: translate('Longitude') }],
|
||||||
]
|
]
|
||||||
const builder = new U.FormBuilder(this, coordinatesOptions, {
|
const builder = new MutatingForm(this, coordinatesOptions)
|
||||||
callback: () => {
|
builder.on('set', () => {
|
||||||
if (!this.ui._latlng.isValid()) {
|
if (!this.ui._latlng.isValid()) {
|
||||||
Alert.error(translate('Invalid latitude or longitude'))
|
Alert.error(translate('Invalid latitude or longitude'))
|
||||||
builder.restoreField('ui._latlng.lat')
|
builder.restoreField('ui._latlng.lat')
|
||||||
|
@ -742,7 +740,6 @@ export class Point extends Feature {
|
||||||
}
|
}
|
||||||
this.pullGeometry()
|
this.pullGeometry()
|
||||||
this.zoomTo({ easing: false })
|
this.zoomTo({ easing: false })
|
||||||
},
|
|
||||||
})
|
})
|
||||||
const fieldset = DomUtil.createFieldset(container, translate('Coordinates'))
|
const fieldset = DomUtil.createFieldset(container, translate('Coordinates'))
|
||||||
fieldset.appendChild(builder.build())
|
fieldset.appendChild(builder.build())
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
// Uses U.FormBuilder not available as ESM
|
|
||||||
|
|
||||||
// FIXME: this module should not depend on Leaflet
|
// FIXME: this module should not depend on Leaflet
|
||||||
import {
|
import {
|
||||||
DomUtil,
|
DomUtil,
|
||||||
|
@ -22,6 +20,7 @@ import { Point, LineString, Polygon } from './features.js'
|
||||||
import TableEditor from '../tableeditor.js'
|
import TableEditor from '../tableeditor.js'
|
||||||
import { ServerStored } from '../saving.js'
|
import { ServerStored } from '../saving.js'
|
||||||
import * as Schema from '../schema.js'
|
import * as Schema from '../schema.js'
|
||||||
|
import { MutatingForm } from '../form/builder.js'
|
||||||
|
|
||||||
export const LAYER_TYPES = [
|
export const LAYER_TYPES = [
|
||||||
DefaultLayer,
|
DefaultLayer,
|
||||||
|
@ -303,6 +302,19 @@ export class DataLayer extends ServerStored {
|
||||||
return this.isRemoteLayer() && Boolean(this.options.remoteData?.dynamic)
|
return this.isRemoteLayer() && Boolean(this.options.remoteData?.dynamic)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUrl(url) {
|
||||||
|
const response = await this._umap.request.get(url)
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (response?.ok) return resolve(response.text())
|
||||||
|
Alert.error(
|
||||||
|
translate('Cannot load remote data for layer "{layer}" with url "{url}"', {
|
||||||
|
layer: this.getName(),
|
||||||
|
url: url,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fetchRemoteData(force) {
|
async fetchRemoteData(force) {
|
||||||
if (!this.isRemoteLayer()) return
|
if (!this.isRemoteLayer()) return
|
||||||
if (!this.hasDynamicData() && this.hasDataLoaded() && !force) return
|
if (!this.hasDynamicData() && this.hasDataLoaded() && !force) return
|
||||||
|
@ -311,13 +323,12 @@ export class DataLayer extends ServerStored {
|
||||||
if (this.options.remoteData.proxy) {
|
if (this.options.remoteData.proxy) {
|
||||||
url = this._umap.proxyUrl(url, this.options.remoteData.ttl)
|
url = this._umap.proxyUrl(url, this.options.remoteData.ttl)
|
||||||
}
|
}
|
||||||
const response = await this._umap.request.get(url)
|
return await this.getUrl(url).then((raw) => {
|
||||||
if (response?.ok) {
|
|
||||||
this.clear()
|
this.clear()
|
||||||
return this._umap.formatter
|
return this._umap.formatter
|
||||||
.parse(await response.text(), this.options.remoteData.format)
|
.parse(raw, this.options.remoteData.format)
|
||||||
.then((geojson) => this.fromGeoJSON(geojson))
|
.then((geojson) => this.fromGeoJSON(geojson))
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoaded() {
|
isLoaded() {
|
||||||
|
@ -451,7 +462,7 @@ export class DataLayer extends ServerStored {
|
||||||
}
|
}
|
||||||
|
|
||||||
sortFeatures(collection) {
|
sortFeatures(collection) {
|
||||||
const sortKeys = this._umap.getProperty('sortKey') || U.DEFAULT_LABEL_KEY
|
const sortKeys = this.getOption('sortKey') || U.DEFAULT_LABEL_KEY
|
||||||
return Utils.sortFeatures(collection, sortKeys, U.lang)
|
return Utils.sortFeatures(collection, sortKeys, U.lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -542,10 +553,9 @@ export class DataLayer extends ServerStored {
|
||||||
|
|
||||||
async importFromUrl(uri, type) {
|
async importFromUrl(uri, type) {
|
||||||
uri = this._umap.renderUrl(uri)
|
uri = this._umap.renderUrl(uri)
|
||||||
const response = await this._umap.request.get(uri)
|
return await this.getUrl(uri).then((raw) => {
|
||||||
if (response?.ok) {
|
return this.importRaw(raw, type)
|
||||||
return this.importRaw(await response.text(), type)
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getColor() {
|
getColor() {
|
||||||
|
@ -659,7 +669,7 @@ export class DataLayer extends ServerStored {
|
||||||
{
|
{
|
||||||
label: translate('Data is browsable'),
|
label: translate('Data is browsable'),
|
||||||
handler: 'Switch',
|
handler: 'Switch',
|
||||||
helpEntries: 'browsable',
|
helpEntries: ['browsable'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
@ -671,20 +681,19 @@ export class DataLayer extends ServerStored {
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers')
|
DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers')
|
||||||
let builder = new U.FormBuilder(this, metadataFields, {
|
let builder = new MutatingForm(this, metadataFields)
|
||||||
callback(e) {
|
builder.on('set', ({ detail }) => {
|
||||||
this._umap.onDataLayersChanged()
|
this._umap.onDataLayersChanged()
|
||||||
if (e.helper.field === 'options.type') {
|
if (detail.helper.field === 'options.type') {
|
||||||
this.edit()
|
this.edit()
|
||||||
}
|
}
|
||||||
},
|
|
||||||
})
|
})
|
||||||
container.appendChild(builder.build())
|
container.appendChild(builder.build())
|
||||||
|
|
||||||
const layerOptions = this.layer.getEditableOptions()
|
const layerOptions = this.layer.getEditableOptions()
|
||||||
|
|
||||||
if (layerOptions.length) {
|
if (layerOptions.length) {
|
||||||
builder = new U.FormBuilder(this, layerOptions, {
|
builder = new MutatingForm(this, layerOptions, {
|
||||||
id: 'datalayer-layer-properties',
|
id: 'datalayer-layer-properties',
|
||||||
})
|
})
|
||||||
const layerProperties = DomUtil.createFieldset(
|
const layerProperties = DomUtil.createFieldset(
|
||||||
|
@ -707,7 +716,7 @@ export class DataLayer extends ServerStored {
|
||||||
'options.fillOpacity',
|
'options.fillOpacity',
|
||||||
]
|
]
|
||||||
|
|
||||||
builder = new U.FormBuilder(this, shapeOptions, {
|
builder = new MutatingForm(this, shapeOptions, {
|
||||||
id: 'datalayer-advanced-properties',
|
id: 'datalayer-advanced-properties',
|
||||||
})
|
})
|
||||||
const shapeProperties = DomUtil.createFieldset(
|
const shapeProperties = DomUtil.createFieldset(
|
||||||
|
@ -722,11 +731,17 @@ export class DataLayer extends ServerStored {
|
||||||
'options.zoomTo',
|
'options.zoomTo',
|
||||||
'options.fromZoom',
|
'options.fromZoom',
|
||||||
'options.toZoom',
|
'options.toZoom',
|
||||||
|
'options.sortKey',
|
||||||
]
|
]
|
||||||
|
|
||||||
builder = new U.FormBuilder(this, optionsFields, {
|
builder = new MutatingForm(this, optionsFields, {
|
||||||
id: 'datalayer-advanced-properties',
|
id: 'datalayer-advanced-properties',
|
||||||
})
|
})
|
||||||
|
builder.on('set', ({ detail }) => {
|
||||||
|
if (detail.helper.field === 'options.sortKey') {
|
||||||
|
this.reindex()
|
||||||
|
}
|
||||||
|
})
|
||||||
const advancedProperties = DomUtil.createFieldset(
|
const advancedProperties = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Advanced properties')
|
translate('Advanced properties')
|
||||||
|
@ -743,7 +758,7 @@ export class DataLayer extends ServerStored {
|
||||||
'options.outlinkTarget',
|
'options.outlinkTarget',
|
||||||
'options.interactive',
|
'options.interactive',
|
||||||
]
|
]
|
||||||
builder = new U.FormBuilder(this, popupFields)
|
builder = new MutatingForm(this, popupFields)
|
||||||
const popupFieldset = DomUtil.createFieldset(
|
const popupFieldset = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Interaction options')
|
translate('Interaction options')
|
||||||
|
@ -799,7 +814,7 @@ export class DataLayer extends ServerStored {
|
||||||
container,
|
container,
|
||||||
translate('Remote data')
|
translate('Remote data')
|
||||||
)
|
)
|
||||||
builder = new U.FormBuilder(this, remoteDataFields)
|
builder = new MutatingForm(this, remoteDataFields)
|
||||||
remoteDataContainer.appendChild(builder.build())
|
remoteDataContainer.appendChild(builder.build())
|
||||||
DomUtil.createButton(
|
DomUtil.createButton(
|
||||||
'button umap-verify',
|
'button umap-verify',
|
||||||
|
|
241
umap/static/umap/js/modules/form/builder.js
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
import getClass from './fields.js'
|
||||||
|
import * as Utils from '../utils.js'
|
||||||
|
import { SCHEMA } from '../schema.js'
|
||||||
|
import { translate } from '../i18n.js'
|
||||||
|
|
||||||
|
export class Form extends Utils.WithEvents {
|
||||||
|
constructor(obj, fields, properties) {
|
||||||
|
super()
|
||||||
|
this.setProperties(properties)
|
||||||
|
this.defaultProperties = {}
|
||||||
|
this.obj = obj
|
||||||
|
this.form = Utils.loadTemplate('<form></form>')
|
||||||
|
this.setFields(fields)
|
||||||
|
if (this.properties.id) {
|
||||||
|
this.form.id = this.properties.id
|
||||||
|
}
|
||||||
|
if (this.properties.className) {
|
||||||
|
this.form.classList.add(...this.properties.className.split(' '))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setProperties(properties) {
|
||||||
|
this.properties = Object.assign({}, this.properties, properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFields(fields) {
|
||||||
|
this.fields = fields || []
|
||||||
|
this.helpers = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
this.form.innerHTML = ''
|
||||||
|
for (const definition of this.fields) {
|
||||||
|
this.buildField(this.makeField(definition))
|
||||||
|
}
|
||||||
|
return this.form
|
||||||
|
}
|
||||||
|
|
||||||
|
buildField(field) {
|
||||||
|
field.buildTemplate()
|
||||||
|
field.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
makeField(field) {
|
||||||
|
// field can be either a string like "option.name" or a full definition array,
|
||||||
|
// like ['properties.tilelayer.tms', {handler: 'CheckBox', helpText: 'TMS format'}]
|
||||||
|
let properties
|
||||||
|
if (Array.isArray(field)) {
|
||||||
|
properties = field[1] || {}
|
||||||
|
field = field[0]
|
||||||
|
} else {
|
||||||
|
properties = this.defaultProperties[this.getName(field)] || {}
|
||||||
|
}
|
||||||
|
const class_ = getClass(properties.handler || 'Input')
|
||||||
|
this.helpers[field] = new class_(this, field, properties)
|
||||||
|
return this.helpers[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
getter(field) {
|
||||||
|
const path = field.split('.')
|
||||||
|
let value = this.obj
|
||||||
|
for (const sub of path) {
|
||||||
|
try {
|
||||||
|
value = value[sub]
|
||||||
|
} catch {
|
||||||
|
console.log(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
setter(field, value) {
|
||||||
|
const path = field.split('.')
|
||||||
|
let obj = this.obj
|
||||||
|
let what
|
||||||
|
for (let i = 0, l = path.length; i < l; i++) {
|
||||||
|
what = path[i]
|
||||||
|
if (what === path[l - 1]) {
|
||||||
|
if (typeof value === 'undefined') {
|
||||||
|
delete obj[what]
|
||||||
|
} else {
|
||||||
|
obj[what] = value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obj = obj[what]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreField(field) {
|
||||||
|
const initial = this.helpers[field].initial
|
||||||
|
this.setter(field, initial)
|
||||||
|
}
|
||||||
|
|
||||||
|
getName(field) {
|
||||||
|
const fieldEls = field.split('.')
|
||||||
|
return fieldEls[fieldEls.length - 1]
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAll() {
|
||||||
|
for (const helper of Object.values(this.helpers)) {
|
||||||
|
helper.fetch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncAll() {
|
||||||
|
for (const helper of Object.values(this.helpers)) {
|
||||||
|
helper.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPostSync(helper) {
|
||||||
|
if (this.properties.callback) {
|
||||||
|
this.properties.callback(helper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
finish() {}
|
||||||
|
|
||||||
|
getTemplate(helper) {
|
||||||
|
return `
|
||||||
|
<div class="formbox" data-ref=container>
|
||||||
|
${helper.getTemplate()}
|
||||||
|
<small class="help-text" data-ref=helpText></small>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MutatingForm extends Form {
|
||||||
|
constructor(obj, fields, properties) {
|
||||||
|
super(obj, fields, properties)
|
||||||
|
this._umap = obj._umap || properties.umap
|
||||||
|
this.computeDefaultProperties()
|
||||||
|
// this.on('finish', this.finish)
|
||||||
|
}
|
||||||
|
|
||||||
|
computeDefaultProperties() {
|
||||||
|
const customHandlers = {
|
||||||
|
sortKey: 'PropertyInput',
|
||||||
|
easing: 'Switch',
|
||||||
|
facetKey: 'PropertyInput',
|
||||||
|
slugKey: 'PropertyInput',
|
||||||
|
labelKey: 'PropertyInput',
|
||||||
|
}
|
||||||
|
for (const [key, schema] of Object.entries(SCHEMA)) {
|
||||||
|
if (schema.type === Boolean) {
|
||||||
|
if (schema.nullable) schema.handler = 'NullableChoices'
|
||||||
|
else schema.handler = 'Switch'
|
||||||
|
} else if (schema.type === 'Text') {
|
||||||
|
schema.handler = 'Textarea'
|
||||||
|
} else if (schema.type === Number) {
|
||||||
|
if (schema.step) schema.handler = 'Range'
|
||||||
|
else schema.handler = 'IntInput'
|
||||||
|
} else if (schema.choices) {
|
||||||
|
const text_length = schema.choices.reduce(
|
||||||
|
(acc, [_, label]) => acc + label.length,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
// Try to be smart and use MultiChoice only
|
||||||
|
// for choices where labels are shorts…
|
||||||
|
if (text_length < 40) {
|
||||||
|
schema.handler = 'MultiChoice'
|
||||||
|
} else {
|
||||||
|
schema.handler = 'Select'
|
||||||
|
schema.selectOptions = schema.choices
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (key) {
|
||||||
|
case 'color':
|
||||||
|
case 'fillColor':
|
||||||
|
schema.handler = 'ColorPicker'
|
||||||
|
break
|
||||||
|
case 'iconUrl':
|
||||||
|
schema.handler = 'IconUrl'
|
||||||
|
break
|
||||||
|
case 'licence':
|
||||||
|
schema.handler = 'LicenceChooser'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customHandlers[key]) {
|
||||||
|
schema.handler = customHandlers[key]
|
||||||
|
}
|
||||||
|
// Input uses this key for its type attribute
|
||||||
|
delete schema.type
|
||||||
|
this.defaultProperties[key] = schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setter(field, value) {
|
||||||
|
super.setter(field, value)
|
||||||
|
this.obj.isDirty = true
|
||||||
|
if ('render' in this.obj) {
|
||||||
|
this.obj.render([field], this)
|
||||||
|
}
|
||||||
|
if ('sync' in this.obj) {
|
||||||
|
this.obj.sync.update(field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTemplate(helper) {
|
||||||
|
let template
|
||||||
|
if (helper.properties.inheritable) {
|
||||||
|
const extraClassName = helper.get(true) === undefined ? ' undefined' : ''
|
||||||
|
template = `
|
||||||
|
<div class="umap-field-${helper.name} formbox inheritable${extraClassName}">
|
||||||
|
<div class="header" data-ref=header>
|
||||||
|
${helper.getLabelTemplate()}
|
||||||
|
<span class="actions show-on-defined" data-ref=actions></span>
|
||||||
|
<span class="buttons" data-ref=buttons>
|
||||||
|
<button type="button" class="button undefine" data-ref=undefine>${translate('clear')}</button>
|
||||||
|
<button type="button" class="button define" data-ref=define>${translate('define')}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="show-on-defined" data-ref=container>
|
||||||
|
${helper.getTemplate()}
|
||||||
|
<small class="help-text" data-ref=helpText></small>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
} else {
|
||||||
|
template = `
|
||||||
|
<div class="formbox umap-field-${helper.name}" data-ref=container>
|
||||||
|
${helper.getLabelTemplate()}
|
||||||
|
${helper.getTemplate()}
|
||||||
|
<small class="help-text" data-ref=helpText></small>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
super.build()
|
||||||
|
this._umap.help.parse(this.form)
|
||||||
|
return this.form
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(helper) {
|
||||||
|
helper.input?.blur()
|
||||||
|
}
|
||||||
|
}
|
1336
umap/static/umap/js/modules/form/fields.js
Normal file
|
@ -228,9 +228,11 @@ export default class Help {
|
||||||
|
|
||||||
parse(container) {
|
parse(container) {
|
||||||
for (const element of container.querySelectorAll('[data-help]')) {
|
for (const element of container.querySelectorAll('[data-help]')) {
|
||||||
|
if (element.dataset.help) {
|
||||||
this.button(element, element.dataset.help.split(','))
|
this.button(element, element.dataset.help.split(','))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_buildEditEntry() {
|
_buildEditEntry() {
|
||||||
const container = DomUtil.create('div', '')
|
const container = DomUtil.create('div', '')
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { translate } from './i18n.js'
|
||||||
import { uMapAlert as Alert } from '../components/alerts/alert.js'
|
import { uMapAlert as Alert } from '../components/alerts/alert.js'
|
||||||
import { ServerStored } from './saving.js'
|
import { ServerStored } from './saving.js'
|
||||||
import * as Utils from './utils.js'
|
import * as Utils from './utils.js'
|
||||||
|
import { MutatingForm } from './form/builder.js'
|
||||||
|
|
||||||
// Dedicated object so we can deal with a separate dirty status, and thus
|
// Dedicated object so we can deal with a separate dirty status, and thus
|
||||||
// call the endpoint only when needed, saving one call at each save.
|
// call the endpoint only when needed, saving one call at each save.
|
||||||
|
@ -58,7 +59,7 @@ export class MapPermissions extends ServerStored {
|
||||||
selectOptions: this._umap.properties.share_statuses,
|
selectOptions: this._umap.properties.share_statuses,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const builder = new U.FormBuilder(this, fields)
|
const builder = new MutatingForm(this, fields)
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
container.appendChild(form)
|
container.appendChild(form)
|
||||||
|
|
||||||
|
@ -133,7 +134,7 @@ export class MapPermissions extends ServerStored {
|
||||||
{ handler: 'ManageEditors', label: translate("Map's editors") },
|
{ handler: 'ManageEditors', label: translate("Map's editors") },
|
||||||
])
|
])
|
||||||
|
|
||||||
const builder = new U.FormBuilder(this, topFields)
|
const builder = new MutatingForm(this, topFields)
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
container.appendChild(form)
|
container.appendChild(form)
|
||||||
if (collaboratorsFields.length) {
|
if (collaboratorsFields.length) {
|
||||||
|
@ -141,7 +142,7 @@ export class MapPermissions extends ServerStored {
|
||||||
`<fieldset class="separator"><legend>${translate('Manage collaborators')}</legend></fieldset>`
|
`<fieldset class="separator"><legend>${translate('Manage collaborators')}</legend></fieldset>`
|
||||||
)
|
)
|
||||||
container.appendChild(fieldset)
|
container.appendChild(fieldset)
|
||||||
const builder = new U.FormBuilder(this, collaboratorsFields)
|
const builder = new MutatingForm(this, collaboratorsFields)
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
container.appendChild(form)
|
container.appendChild(form)
|
||||||
}
|
}
|
||||||
|
@ -269,7 +270,7 @@ export class DataLayerPermissions extends ServerStored {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
const builder = new U.FormBuilder(this, fields, {
|
const builder = new MutatingForm(this, fields, {
|
||||||
className: 'umap-form datalayer-permissions',
|
className: 'umap-form datalayer-permissions',
|
||||||
})
|
})
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
|
|
|
@ -70,6 +70,11 @@ const BaseIcon = DivIcon.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
onAdd: () => {},
|
onAdd: () => {},
|
||||||
|
|
||||||
|
_setIconStyles: function (img, name) {
|
||||||
|
if (this.feature.isActive()) this.options.className += ' umap-icon-active'
|
||||||
|
DivIcon.prototype._setIconStyles.call(this, img, name)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const DefaultIcon = BaseIcon.extend({
|
const DefaultIcon = BaseIcon.extend({
|
||||||
|
@ -86,7 +91,6 @@ const DefaultIcon = BaseIcon.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
_setIconStyles: function (img, name) {
|
_setIconStyles: function (img, name) {
|
||||||
if (this.feature.isActive()) this.options.className += ' umap-icon-active'
|
|
||||||
BaseIcon.prototype._setIconStyles.call(this, img, name)
|
BaseIcon.prototype._setIconStyles.call(this, img, name)
|
||||||
const color = this._getColor()
|
const color = this._getColor()
|
||||||
const opacity = this._getOpacity()
|
const opacity = this._getOpacity()
|
||||||
|
|
|
@ -88,7 +88,11 @@ const ClassifiedMixin = {
|
||||||
},
|
},
|
||||||
|
|
||||||
getColorSchemes: function (classes) {
|
getColorSchemes: function (classes) {
|
||||||
return this.colorSchemes.filter((scheme) => Boolean(colorbrewer[scheme][classes]))
|
const found = this.colorSchemes.filter((scheme) =>
|
||||||
|
Boolean(colorbrewer[scheme][classes])
|
||||||
|
)
|
||||||
|
if (found.length) return found
|
||||||
|
return [['', translate('Default')]]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,7 +195,7 @@ export const Choropleth = FeatureGroup.extend({
|
||||||
'options.choropleth.property',
|
'options.choropleth.property',
|
||||||
{
|
{
|
||||||
handler: 'Select',
|
handler: 'Select',
|
||||||
selectOptions: this.datalayer._propertiesIndex,
|
selectOptions: this.datalayer.allProperties(),
|
||||||
label: translate('Choropleth property value'),
|
label: translate('Choropleth property value'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -300,7 +304,7 @@ export const Circles = FeatureGroup.extend({
|
||||||
'options.circles.property',
|
'options.circles.property',
|
||||||
{
|
{
|
||||||
handler: 'Select',
|
handler: 'Select',
|
||||||
selectOptions: this.datalayer._propertiesIndex,
|
selectOptions: this.datalayer.allProperties(),
|
||||||
label: translate('Property name to compute circles'),
|
label: translate('Property name to compute circles'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -377,7 +381,7 @@ export const Categorized = FeatureGroup.extend({
|
||||||
|
|
||||||
_getValue: function (feature) {
|
_getValue: function (feature) {
|
||||||
const key =
|
const key =
|
||||||
this.datalayer.options.categorized.property || this.datalayer._propertiesIndex[0]
|
this.datalayer.options.categorized.property || this.datalayer.allProperties()[0]
|
||||||
return feature.properties[key]
|
return feature.properties[key]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -420,7 +424,7 @@ export const Categorized = FeatureGroup.extend({
|
||||||
} else {
|
} else {
|
||||||
this.options.colors = colorbrewer?.Accent[this._classes]
|
this.options.colors = colorbrewer?.Accent[this._classes]
|
||||||
? colorbrewer?.Accent[this._classes]
|
? colorbrewer?.Accent[this._classes]
|
||||||
: U.COLORS // Fixme: move COLORS to modules/
|
: Utils.COLORS
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -430,7 +434,7 @@ export const Categorized = FeatureGroup.extend({
|
||||||
'options.categorized.property',
|
'options.categorized.property',
|
||||||
{
|
{
|
||||||
handler: 'Select',
|
handler: 'Select',
|
||||||
selectOptions: this.datalayer._propertiesIndex,
|
selectOptions: this.datalayer.allProperties(),
|
||||||
label: translate('Category property'),
|
label: translate('Category property'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -464,7 +468,7 @@ export const Categorized = FeatureGroup.extend({
|
||||||
|
|
||||||
onEdit: function (field, builder) {
|
onEdit: function (field, builder) {
|
||||||
// Only compute the categories if we're dealing with categorized
|
// Only compute the categories if we're dealing with categorized
|
||||||
if (!field.startsWith('options.categorized')) return
|
if (!field.startsWith('options.categorized') && field !== 'options.type') return
|
||||||
// If user touches the categories, then force manual mode
|
// If user touches the categories, then force manual mode
|
||||||
if (field === 'options.categorized.categories') {
|
if (field === 'options.categorized.categories') {
|
||||||
this.datalayer.options.categorized.mode = 'manual'
|
this.datalayer.options.categorized.mode = 'manual'
|
||||||
|
|
|
@ -32,7 +32,6 @@ const ControlsMixin = {
|
||||||
'locate',
|
'locate',
|
||||||
'measure',
|
'measure',
|
||||||
'editinosm',
|
'editinosm',
|
||||||
'star',
|
|
||||||
'tilelayers',
|
'tilelayers',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -84,7 +83,6 @@ const ControlsMixin = {
|
||||||
this._controls.search = new U.SearchControl()
|
this._controls.search = new U.SearchControl()
|
||||||
this._controls.embed = new Control.Embed(this._umap)
|
this._controls.embed = new Control.Embed(this._umap)
|
||||||
this._controls.tilelayersChooser = new U.TileLayerChooser(this)
|
this._controls.tilelayersChooser = new U.TileLayerChooser(this)
|
||||||
if (this.options.user?.id) this._controls.star = new U.StarControl(this._umap)
|
|
||||||
this._controls.editinosm = new Control.EditInOSM({
|
this._controls.editinosm = new Control.EditInOSM({
|
||||||
position: 'topleft',
|
position: 'topleft',
|
||||||
widgetOptions: {
|
widgetOptions: {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { translate } from './i18n.js'
|
||||||
import * as Utils from './utils.js'
|
import * as Utils from './utils.js'
|
||||||
import { AutocompleteDatalist } from './autocomplete.js'
|
import { AutocompleteDatalist } from './autocomplete.js'
|
||||||
import Orderable from './orderable.js'
|
import Orderable from './orderable.js'
|
||||||
|
import { MutatingForm } from './form/builder.js'
|
||||||
|
|
||||||
const EMPTY_VALUES = ['', undefined, null]
|
const EMPTY_VALUES = ['', undefined, null]
|
||||||
|
|
||||||
|
@ -129,7 +130,7 @@ class Rule {
|
||||||
'options.dashArray',
|
'options.dashArray',
|
||||||
]
|
]
|
||||||
const container = DomUtil.create('div')
|
const container = DomUtil.create('div')
|
||||||
const builder = new U.FormBuilder(this, options)
|
const builder = new MutatingForm(this, options)
|
||||||
const defaultShapeProperties = DomUtil.add('div', '', container)
|
const defaultShapeProperties = DomUtil.add('div', '', container)
|
||||||
defaultShapeProperties.appendChild(builder.build())
|
defaultShapeProperties.appendChild(builder.build())
|
||||||
const autocomplete = new AutocompleteDatalist(builder.helpers.condition.input)
|
const autocomplete = new AutocompleteDatalist(builder.helpers.condition.input)
|
||||||
|
|
|
@ -478,12 +478,6 @@ export const SCHEMA = {
|
||||||
label: translate('Sort key'),
|
label: translate('Sort key'),
|
||||||
inheritable: true,
|
inheritable: true,
|
||||||
},
|
},
|
||||||
starControl: {
|
|
||||||
type: Boolean,
|
|
||||||
impacts: ['ui'],
|
|
||||||
nullable: true,
|
|
||||||
label: translate('Display the star map button'),
|
|
||||||
},
|
|
||||||
stroke: {
|
stroke: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
impacts: ['data'],
|
impacts: ['data'],
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
|
||||||
import { EXPORT_FORMATS } from './formatter.js'
|
import { EXPORT_FORMATS } from './formatter.js'
|
||||||
import { translate } from './i18n.js'
|
import { translate } from './i18n.js'
|
||||||
import * as Utils from './utils.js'
|
import * as Utils from './utils.js'
|
||||||
|
import { MutatingForm } from './form/builder.js'
|
||||||
|
|
||||||
export default class Share {
|
export default class Share {
|
||||||
constructor(umap) {
|
constructor(umap) {
|
||||||
|
@ -125,9 +126,8 @@ export default class Share {
|
||||||
exportUrl.value = window.location.protocol + iframeExporter.buildUrl()
|
exportUrl.value = window.location.protocol + iframeExporter.buildUrl()
|
||||||
}
|
}
|
||||||
buildIframeCode()
|
buildIframeCode()
|
||||||
const builder = new U.FormBuilder(iframeExporter, UIFields, {
|
const builder = new MutatingForm(iframeExporter, UIFields)
|
||||||
callback: buildIframeCode,
|
builder.on('set', buildIframeCode)
|
||||||
})
|
|
||||||
const iframeOptions = DomUtil.createFieldset(
|
const iframeOptions = DomUtil.createFieldset(
|
||||||
this.container,
|
this.container,
|
||||||
translate('Embed and link options')
|
translate('Embed and link options')
|
||||||
|
|
|
@ -62,6 +62,7 @@ export class SyncEngine {
|
||||||
this._reconnectDelay = RECONNECT_DELAY
|
this._reconnectDelay = RECONNECT_DELAY
|
||||||
this.websocketConnected = false
|
this.websocketConnected = false
|
||||||
this.closeRequested = false
|
this.closeRequested = false
|
||||||
|
this.peerId = Utils.generateId()
|
||||||
}
|
}
|
||||||
|
|
||||||
async authenticate() {
|
async authenticate() {
|
||||||
|
@ -76,10 +77,14 @@ export class SyncEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
start(authToken) {
|
start(authToken) {
|
||||||
|
const path = this._umap.urls.get('ws_sync', { map_id: this._umap.id })
|
||||||
|
const protocol = window.location.protocol === 'http:' ? 'ws:' : 'wss:'
|
||||||
this.transport = new WebSocketTransport(
|
this.transport = new WebSocketTransport(
|
||||||
this._umap.properties.websocketURI,
|
`${protocol}//${window.location.host}${path}`,
|
||||||
authToken,
|
authToken,
|
||||||
this
|
this,
|
||||||
|
this.peerId,
|
||||||
|
this._umap.properties.user?.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -125,7 +130,7 @@ export class SyncEngine {
|
||||||
|
|
||||||
if (this.offline) return
|
if (this.offline) return
|
||||||
if (this.transport) {
|
if (this.transport) {
|
||||||
this.transport.send('OperationMessage', message)
|
this.transport.send('OperationMessage', { sender: this.peerId, ...message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,7 +147,7 @@ export class SyncEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
getNumberOfConnectedPeers() {
|
getNumberOfConnectedPeers() {
|
||||||
if (this.peers) return this.peers.length
|
if (this.peers) return Object.keys(this.peers).length
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,6 +182,7 @@ export class SyncEngine {
|
||||||
* @param {Object} payload
|
* @param {Object} payload
|
||||||
*/
|
*/
|
||||||
onOperationMessage(payload) {
|
onOperationMessage(payload) {
|
||||||
|
if (payload.sender === this.peerId) return
|
||||||
this._operations.storeRemoteOperations([payload])
|
this._operations.storeRemoteOperations([payload])
|
||||||
this._applyOperation(payload)
|
this._applyOperation(payload)
|
||||||
}
|
}
|
||||||
|
@ -188,9 +194,8 @@ export class SyncEngine {
|
||||||
* @param {string} payload.uuid The server-assigned uuid for this peer
|
* @param {string} payload.uuid The server-assigned uuid for this peer
|
||||||
* @param {string[]} payload.peers The list of peers uuids
|
* @param {string[]} payload.peers The list of peers uuids
|
||||||
*/
|
*/
|
||||||
onJoinResponse({ uuid, peers }) {
|
onJoinResponse({ peer, peers }) {
|
||||||
debug('received join response', { uuid, peers })
|
debug('received join response', { peer, peers })
|
||||||
this.uuid = uuid
|
|
||||||
this.onListPeersResponse({ peers })
|
this.onListPeersResponse({ peers })
|
||||||
|
|
||||||
// Get one peer at random
|
// Get one peer at random
|
||||||
|
@ -211,7 +216,7 @@ export class SyncEngine {
|
||||||
* @param {string[]} payload.peers The list of peers uuids
|
* @param {string[]} payload.peers The list of peers uuids
|
||||||
*/
|
*/
|
||||||
onListPeersResponse({ peers }) {
|
onListPeersResponse({ peers }) {
|
||||||
debug('received peerinfo', { peers })
|
debug('received peerinfo', peers)
|
||||||
this.peers = peers
|
this.peers = peers
|
||||||
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
|
this.updaters.map.update({ key: 'numberOfConnectedPeers' })
|
||||||
}
|
}
|
||||||
|
@ -286,7 +291,7 @@ export class SyncEngine {
|
||||||
sendToPeer(recipient, verb, payload) {
|
sendToPeer(recipient, verb, payload) {
|
||||||
payload.verb = verb
|
payload.verb = verb
|
||||||
this.transport.send('PeerMessage', {
|
this.transport.send('PeerMessage', {
|
||||||
sender: this.uuid,
|
sender: this.peerId,
|
||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
message: payload,
|
message: payload,
|
||||||
})
|
})
|
||||||
|
@ -298,7 +303,7 @@ export class SyncEngine {
|
||||||
* @returns {string|bool} the selected peer uuid, or False if none was found.
|
* @returns {string|bool} the selected peer uuid, or False if none was found.
|
||||||
*/
|
*/
|
||||||
_getRandomPeer() {
|
_getRandomPeer() {
|
||||||
const otherPeers = this.peers.filter((p) => p !== this.uuid)
|
const otherPeers = Object.keys(this.peers).filter((p) => p !== this.peerId)
|
||||||
if (otherPeers.length > 0) {
|
if (otherPeers.length > 0) {
|
||||||
const random = Math.floor(Math.random() * otherPeers.length)
|
const random = Math.floor(Math.random() * otherPeers.length)
|
||||||
return otherPeers[random]
|
return otherPeers[random]
|
||||||
|
@ -484,7 +489,7 @@ export class Operations {
|
||||||
return (
|
return (
|
||||||
Utils.deepEqual(local.subject, remote.subject) &&
|
Utils.deepEqual(local.subject, remote.subject) &&
|
||||||
Utils.deepEqual(local.metadata, remote.metadata) &&
|
Utils.deepEqual(local.metadata, remote.metadata) &&
|
||||||
(!shouldCheckKey || (shouldCheckKey && local.key == remote.key))
|
(!shouldCheckKey || (shouldCheckKey && local.key === remote.key))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,13 +3,13 @@ const PING_INTERVAL = 30000
|
||||||
const FIRST_CONNECTION_TIMEOUT = 2000
|
const FIRST_CONNECTION_TIMEOUT = 2000
|
||||||
|
|
||||||
export class WebSocketTransport {
|
export class WebSocketTransport {
|
||||||
constructor(webSocketURI, authToken, messagesReceiver) {
|
constructor(webSocketURI, authToken, messagesReceiver, peerId, username) {
|
||||||
this.receiver = messagesReceiver
|
this.receiver = messagesReceiver
|
||||||
|
|
||||||
this.websocket = new WebSocket(webSocketURI)
|
this.websocket = new WebSocket(webSocketURI)
|
||||||
|
|
||||||
this.websocket.onopen = () => {
|
this.websocket.onopen = () => {
|
||||||
this.send('JoinRequest', { token: authToken })
|
this.send('JoinRequest', { token: authToken, peer: peerId, username })
|
||||||
this.receiver.onConnection()
|
this.receiver.onConnection()
|
||||||
}
|
}
|
||||||
this.websocket.addEventListener('message', this.onMessage.bind(this))
|
this.websocket.addEventListener('message', this.onMessage.bind(this))
|
||||||
|
@ -21,6 +21,10 @@ export class WebSocketTransport {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.websocket.onerror = (error) => {
|
||||||
|
console.log('WS ERROR', error)
|
||||||
|
}
|
||||||
|
|
||||||
this.ensureOpen = setInterval(() => {
|
this.ensureOpen = setInterval(() => {
|
||||||
if (this.websocket.readyState !== WebSocket.OPEN) {
|
if (this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
this.websocket.close()
|
this.websocket.close()
|
||||||
|
@ -34,6 +38,7 @@ export class WebSocketTransport {
|
||||||
// See https://making.close.com/posts/reliable-websockets/ for more details.
|
// See https://making.close.com/posts/reliable-websockets/ for more details.
|
||||||
this.pingInterval = setInterval(() => {
|
this.pingInterval = setInterval(() => {
|
||||||
if (this.websocket.readyState === WebSocket.OPEN) {
|
if (this.websocket.readyState === WebSocket.OPEN) {
|
||||||
|
console.log('sending ping')
|
||||||
this.websocket.send('ping')
|
this.websocket.send('ping')
|
||||||
this.pongReceived = false
|
this.pongReceived = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -63,6 +68,7 @@ export class WebSocketTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
console.log('Closing')
|
||||||
this.receiver.closeRequested = true
|
this.receiver.closeRequested = true
|
||||||
this.websocket.close()
|
this.websocket.close()
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { DomEvent, DomUtil } from '../../vendors/leaflet/leaflet-src.esm.js'
|
||||||
import { translate } from './i18n.js'
|
import { translate } from './i18n.js'
|
||||||
import ContextMenu from './ui/contextmenu.js'
|
import ContextMenu from './ui/contextmenu.js'
|
||||||
import { WithTemplate, loadTemplate } from './utils.js'
|
import { WithTemplate, loadTemplate } from './utils.js'
|
||||||
|
import { MutatingForm } from './form/builder.js'
|
||||||
|
|
||||||
const TEMPLATE = `
|
const TEMPLATE = `
|
||||||
<table>
|
<table>
|
||||||
|
@ -103,7 +104,7 @@ export default class TableEditor extends WithTemplate {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetProperties() {
|
resetProperties() {
|
||||||
this.properties = this.datalayer._propertiesIndex
|
this.properties = this.datalayer.allProperties()
|
||||||
if (this.properties.length === 0) {
|
if (this.properties.length === 0) {
|
||||||
this.properties = [U.DEFAULT_LABEL_KEY, 'description']
|
this.properties = [U.DEFAULT_LABEL_KEY, 'description']
|
||||||
}
|
}
|
||||||
|
@ -205,7 +206,7 @@ export default class TableEditor extends WithTemplate {
|
||||||
const tr = event.target.closest('tr')
|
const tr = event.target.closest('tr')
|
||||||
const feature = this.datalayer.getFeatureById(tr.dataset.feature)
|
const feature = this.datalayer.getFeatureById(tr.dataset.feature)
|
||||||
const handler = property === 'description' ? 'Textarea' : 'Input'
|
const handler = property === 'description' ? 'Textarea' : 'Input'
|
||||||
const builder = new U.FormBuilder(feature, [[field, { handler }]], {
|
const builder = new MutatingForm(feature, [[field, { handler }]], {
|
||||||
id: `umap-feature-properties_${L.stamp(feature)}`,
|
id: `umap-feature-properties_${L.stamp(feature)}`,
|
||||||
})
|
})
|
||||||
cell.innerHTML = ''
|
cell.innerHTML = ''
|
||||||
|
|
|
@ -7,8 +7,8 @@ const TOP_BAR_TEMPLATE = `
|
||||||
<div class="umap-main-edit-toolbox with-transition dark">
|
<div class="umap-main-edit-toolbox with-transition dark">
|
||||||
<div class="umap-left-edit-toolbox" data-ref="left">
|
<div class="umap-left-edit-toolbox" data-ref="left">
|
||||||
<div class="logo"><a class="" href="/" title="${translate('Go to the homepage')}">uMap</a></div>
|
<div class="logo"><a class="" href="/" title="${translate('Go to the homepage')}">uMap</a></div>
|
||||||
<button class="map-name" type="button" data-ref="name"></button>
|
<button class="map-name flat" type="button" data-ref="name"></button>
|
||||||
<button class="share-status" type="button" data-ref="share"></button>
|
<button class="share-status flat" type="button" data-ref="share"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="umap-right-edit-toolbox" data-ref="right">
|
<div class="umap-right-edit-toolbox" data-ref="right">
|
||||||
<button class="connected-peers round" type="button" data-ref="peers">
|
<button class="connected-peers round" type="button" data-ref="peers">
|
||||||
|
@ -19,7 +19,7 @@ const TOP_BAR_TEMPLATE = `
|
||||||
<i class="icon icon-16 icon-profile"></i>
|
<i class="icon icon-16 icon-profile"></i>
|
||||||
<span class="username" data-ref="username"></span>
|
<span class="username" data-ref="username"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="umap-help-link" type="button" title="${translate('Help')}" data-ref="help">${translate('Help')}</button>
|
<button class="umap-help-link flat" type="button" title="${translate('Help')}" data-ref="help">${translate('Help')}</button>
|
||||||
<button class="edit-cancel round" type="button" data-ref="cancel">
|
<button class="edit-cancel round" type="button" data-ref="cancel">
|
||||||
<i class="icon icon-16 icon-restore"></i>
|
<i class="icon icon-16 icon-restore"></i>
|
||||||
<span class="">${translate('Cancel edits')}</span>
|
<span class="">${translate('Cancel edits')}</span>
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {
|
||||||
uMapAlert as Alert,
|
uMapAlert as Alert,
|
||||||
} from '../components/alerts/alert.js'
|
} from '../components/alerts/alert.js'
|
||||||
import Orderable from './orderable.js'
|
import Orderable from './orderable.js'
|
||||||
|
import { MutatingForm } from './form/builder.js'
|
||||||
|
|
||||||
export default class Umap extends ServerStored {
|
export default class Umap extends ServerStored {
|
||||||
constructor(element, geojson) {
|
constructor(element, geojson) {
|
||||||
|
@ -540,7 +541,13 @@ export default class Umap extends ServerStored {
|
||||||
if (SAVEMANAGER.isDirty) this.saveAll()
|
if (SAVEMANAGER.isDirty) this.saveAll()
|
||||||
break
|
break
|
||||||
case 'z':
|
case 'z':
|
||||||
if (SAVEMANAGER.isDirty) this.askForReset()
|
if (Utils.isWritable(event.target)) {
|
||||||
|
used = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (SAVEMANAGER.isDirty) {
|
||||||
|
this.askForReset()
|
||||||
|
}
|
||||||
break
|
break
|
||||||
case 'm':
|
case 'm':
|
||||||
this._leafletMap.editTools.startMarker()
|
this._leafletMap.editTools.startMarker()
|
||||||
|
@ -734,7 +741,7 @@ export default class Umap extends ServerStored {
|
||||||
const metadataFields = ['properties.name', 'properties.description']
|
const metadataFields = ['properties.name', 'properties.description']
|
||||||
|
|
||||||
DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption')
|
DomUtil.createTitle(container, translate('Edit map details'), 'icon-caption')
|
||||||
const builder = new U.FormBuilder(this, metadataFields, {
|
const builder = new MutatingForm(this, metadataFields, {
|
||||||
className: 'map-metadata',
|
className: 'map-metadata',
|
||||||
umap: this,
|
umap: this,
|
||||||
})
|
})
|
||||||
|
@ -749,7 +756,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.permanentCredit',
|
'properties.permanentCredit',
|
||||||
'properties.permanentCreditBackground',
|
'properties.permanentCreditBackground',
|
||||||
]
|
]
|
||||||
const creditsBuilder = new U.FormBuilder(this, creditsFields, { umap: this })
|
const creditsBuilder = new MutatingForm(this, creditsFields, { umap: this })
|
||||||
credits.appendChild(creditsBuilder.build())
|
credits.appendChild(creditsBuilder.build())
|
||||||
this.editPanel.open({ content: container })
|
this.editPanel.open({ content: container })
|
||||||
}
|
}
|
||||||
|
@ -770,7 +777,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.captionBar',
|
'properties.captionBar',
|
||||||
'properties.captionMenus',
|
'properties.captionMenus',
|
||||||
])
|
])
|
||||||
const builder = new U.FormBuilder(this, UIFields, { umap: this })
|
const builder = new MutatingForm(this, UIFields, { umap: this })
|
||||||
const controlsOptions = DomUtil.createFieldset(
|
const controlsOptions = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('User interface options')
|
translate('User interface options')
|
||||||
|
@ -793,7 +800,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.dashArray',
|
'properties.dashArray',
|
||||||
]
|
]
|
||||||
|
|
||||||
const builder = new U.FormBuilder(this, shapeOptions, { umap: this })
|
const builder = new MutatingForm(this, shapeOptions, { umap: this })
|
||||||
const defaultShapeProperties = DomUtil.createFieldset(
|
const defaultShapeProperties = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Default shape properties')
|
translate('Default shape properties')
|
||||||
|
@ -812,7 +819,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.slugKey',
|
'properties.slugKey',
|
||||||
]
|
]
|
||||||
|
|
||||||
const builder = new U.FormBuilder(this, optionsFields, { umap: this })
|
const builder = new MutatingForm(this, optionsFields, { umap: this })
|
||||||
const defaultProperties = DomUtil.createFieldset(
|
const defaultProperties = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Default properties')
|
translate('Default properties')
|
||||||
|
@ -830,7 +837,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.labelInteractive',
|
'properties.labelInteractive',
|
||||||
'properties.outlinkTarget',
|
'properties.outlinkTarget',
|
||||||
]
|
]
|
||||||
const builder = new U.FormBuilder(this, popupFields, { umap: this })
|
const builder = new MutatingForm(this, popupFields, { umap: this })
|
||||||
const popupFieldset = DomUtil.createFieldset(
|
const popupFieldset = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Default interaction options')
|
translate('Default interaction options')
|
||||||
|
@ -887,7 +894,7 @@ export default class Umap extends ServerStored {
|
||||||
container,
|
container,
|
||||||
translate('Custom background')
|
translate('Custom background')
|
||||||
)
|
)
|
||||||
const builder = new U.FormBuilder(this, tilelayerFields, { umap: this })
|
const builder = new MutatingForm(this, tilelayerFields, { umap: this })
|
||||||
customTilelayer.appendChild(builder.build())
|
customTilelayer.appendChild(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -935,7 +942,7 @@ export default class Umap extends ServerStored {
|
||||||
['properties.overlay.tms', { handler: 'Switch', label: translate('TMS format') }],
|
['properties.overlay.tms', { handler: 'Switch', label: translate('TMS format') }],
|
||||||
]
|
]
|
||||||
const overlay = DomUtil.createFieldset(container, translate('Custom overlay'))
|
const overlay = DomUtil.createFieldset(container, translate('Custom overlay'))
|
||||||
const builder = new U.FormBuilder(this, overlayFields, { umap: this })
|
const builder = new MutatingForm(this, overlayFields, { umap: this })
|
||||||
overlay.appendChild(builder.build())
|
overlay.appendChild(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -962,7 +969,7 @@ export default class Umap extends ServerStored {
|
||||||
{ handler: 'BlurFloatInput', placeholder: translate('max East') },
|
{ handler: 'BlurFloatInput', placeholder: translate('max East') },
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
const boundsBuilder = new U.FormBuilder(this, boundsFields, { umap: this })
|
const boundsBuilder = new MutatingForm(this, boundsFields, { umap: this })
|
||||||
limitBounds.appendChild(boundsBuilder.build())
|
limitBounds.appendChild(boundsBuilder.build())
|
||||||
const boundsButtons = DomUtil.create('div', 'button-bar half', limitBounds)
|
const boundsButtons = DomUtil.create('div', 'button-bar half', limitBounds)
|
||||||
DomUtil.createButton(
|
DomUtil.createButton(
|
||||||
|
@ -1027,14 +1034,7 @@ export default class Umap extends ServerStored {
|
||||||
{ handler: 'Switch', label: translate('Autostart when map is loaded') },
|
{ handler: 'Switch', label: translate('Autostart when map is loaded') },
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
const slideshowBuilder = new U.FormBuilder(this, slideshowFields, {
|
const slideshowBuilder = new MutatingForm(this, slideshowFields, {
|
||||||
callback: () => {
|
|
||||||
this.slideshow.load()
|
|
||||||
// FIXME when we refactor formbuilder: this callback is called in a 'postsync'
|
|
||||||
// event, which comes after the call of `setter` method, which will call the
|
|
||||||
// map.render method, which should do this redraw.
|
|
||||||
this.bottomBar.redraw()
|
|
||||||
},
|
|
||||||
umap: this,
|
umap: this,
|
||||||
})
|
})
|
||||||
slideshow.appendChild(slideshowBuilder.build())
|
slideshow.appendChild(slideshowBuilder.build())
|
||||||
|
@ -1042,7 +1042,9 @@ export default class Umap extends ServerStored {
|
||||||
|
|
||||||
_editSync(container) {
|
_editSync(container) {
|
||||||
const sync = DomUtil.createFieldset(container, translate('Real-time collaboration'))
|
const sync = DomUtil.createFieldset(container, translate('Real-time collaboration'))
|
||||||
const builder = new U.FormBuilder(this, ['properties.syncEnabled'], { umap: this })
|
const builder = new MutatingForm(this, ['properties.syncEnabled'], {
|
||||||
|
umap: this,
|
||||||
|
})
|
||||||
sync.appendChild(builder.build())
|
sync.appendChild(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1348,6 +1350,10 @@ export default class Umap extends ServerStored {
|
||||||
}
|
}
|
||||||
this.topBar.redraw()
|
this.topBar.redraw()
|
||||||
},
|
},
|
||||||
|
'properties.slideshow.active': () => {
|
||||||
|
this.slideshow.load()
|
||||||
|
this.bottomBar.redraw()
|
||||||
|
},
|
||||||
numberOfConnectedPeers: () => {
|
numberOfConnectedPeers: () => {
|
||||||
Utils.eachElement('.connected-peers span', (el) => {
|
Utils.eachElement('.connected-peers span', (el) => {
|
||||||
if (this.sync.websocketConnected) {
|
if (this.sync.websocketConnected) {
|
||||||
|
@ -1360,7 +1366,11 @@ export default class Umap extends ServerStored {
|
||||||
},
|
},
|
||||||
'properties.starred': () => {
|
'properties.starred': () => {
|
||||||
Utils.eachElement('.map-star', (el) => {
|
Utils.eachElement('.map-star', (el) => {
|
||||||
el.classList.toggle('starred', this.properties.starred)
|
el.classList.toggle('icon-starred', this.properties.starred)
|
||||||
|
el.classList.toggle('icon-star', !this.properties.starred)
|
||||||
|
})
|
||||||
|
Utils.eachElement('.map-stars', (el) => {
|
||||||
|
el.textContent = this.properties.stars || 0
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1459,7 +1469,7 @@ export default class Umap extends ServerStored {
|
||||||
const row = DomUtil.create('li', 'orderable', ul)
|
const row = DomUtil.create('li', 'orderable', ul)
|
||||||
DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder'))
|
DomUtil.createIcon(row, 'icon-drag', translate('Drag to reorder'))
|
||||||
datalayer.renderToolbox(row)
|
datalayer.renderToolbox(row)
|
||||||
const builder = new U.FormBuilder(
|
const builder = new MutatingForm(
|
||||||
datalayer,
|
datalayer,
|
||||||
[['options.name', { handler: 'EditableText' }]],
|
[['options.name', { handler: 'EditableText' }]],
|
||||||
{ className: 'umap-form-inline' }
|
{ className: 'umap-form-inline' }
|
||||||
|
@ -1543,6 +1553,7 @@ export default class Umap extends ServerStored {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.properties.starred = data.starred
|
this.properties.starred = data.starred
|
||||||
|
this.properties.stars = data.stars
|
||||||
Alert.success(
|
Alert.success(
|
||||||
data.starred
|
data.starred
|
||||||
? translate('Map has been starred')
|
? translate('Map has been starred')
|
||||||
|
|
|
@ -416,9 +416,11 @@ export function loadTemplate(html) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadTemplateWithRefs(html) {
|
export function loadTemplateWithRefs(html) {
|
||||||
const element = loadTemplate(html)
|
const template = document.createElement('template')
|
||||||
|
template.innerHTML = html
|
||||||
|
const element = template.content.firstElementChild
|
||||||
const elements = {}
|
const elements = {}
|
||||||
for (const node of element.querySelectorAll('[data-ref]')) {
|
for (const node of template.content.querySelectorAll('[data-ref]')) {
|
||||||
elements[node.dataset.ref] = node
|
elements[node.dataset.ref] = node
|
||||||
}
|
}
|
||||||
return [element, elements]
|
return [element, elements]
|
||||||
|
@ -446,3 +448,188 @@ export function eachElement(selector, callback) {
|
||||||
callback(el)
|
callback(el)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class WithEvents {
|
||||||
|
constructor() {
|
||||||
|
this._target = new EventTarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
on(eventType, callback) {
|
||||||
|
if (typeof callback !== 'function') return
|
||||||
|
this._target.addEventListener(eventType, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
fire(eventType, detail) {
|
||||||
|
const event = new CustomEvent(eventType, { detail })
|
||||||
|
this._target.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWritable(element) {
|
||||||
|
if (['TEXTAREA', 'INPUT'].includes(element.tagName)) return true
|
||||||
|
if (element.isContentEditable) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// From https://www.joshwcomeau.com/snippets/javascript/debounce/
|
||||||
|
export const debounce = (callback, wait) => {
|
||||||
|
let timeoutId = null
|
||||||
|
|
||||||
|
return (...args) => {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
callback.apply(null, args)
|
||||||
|
}, wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COLORS = [
|
||||||
|
'Black',
|
||||||
|
'Navy',
|
||||||
|
'DarkBlue',
|
||||||
|
'MediumBlue',
|
||||||
|
'Blue',
|
||||||
|
'DarkGreen',
|
||||||
|
'Green',
|
||||||
|
'Teal',
|
||||||
|
'DarkCyan',
|
||||||
|
'DeepSkyBlue',
|
||||||
|
'DarkTurquoise',
|
||||||
|
'MediumSpringGreen',
|
||||||
|
'Lime',
|
||||||
|
'SpringGreen',
|
||||||
|
'Aqua',
|
||||||
|
'Cyan',
|
||||||
|
'MidnightBlue',
|
||||||
|
'DodgerBlue',
|
||||||
|
'LightSeaGreen',
|
||||||
|
'ForestGreen',
|
||||||
|
'SeaGreen',
|
||||||
|
'DarkSlateGray',
|
||||||
|
'DarkSlateGrey',
|
||||||
|
'LimeGreen',
|
||||||
|
'MediumSeaGreen',
|
||||||
|
'Turquoise',
|
||||||
|
'RoyalBlue',
|
||||||
|
'SteelBlue',
|
||||||
|
'DarkSlateBlue',
|
||||||
|
'MediumTurquoise',
|
||||||
|
'Indigo',
|
||||||
|
'DarkOliveGreen',
|
||||||
|
'CadetBlue',
|
||||||
|
'CornflowerBlue',
|
||||||
|
'MediumAquaMarine',
|
||||||
|
'DimGray',
|
||||||
|
'DimGrey',
|
||||||
|
'SlateBlue',
|
||||||
|
'OliveDrab',
|
||||||
|
'SlateGray',
|
||||||
|
'SlateGrey',
|
||||||
|
'LightSlateGray',
|
||||||
|
'LightSlateGrey',
|
||||||
|
'MediumSlateBlue',
|
||||||
|
'LawnGreen',
|
||||||
|
'Chartreuse',
|
||||||
|
'Aquamarine',
|
||||||
|
'Maroon',
|
||||||
|
'Purple',
|
||||||
|
'Olive',
|
||||||
|
'Gray',
|
||||||
|
'Grey',
|
||||||
|
'SkyBlue',
|
||||||
|
'LightSkyBlue',
|
||||||
|
'BlueViolet',
|
||||||
|
'DarkRed',
|
||||||
|
'DarkMagenta',
|
||||||
|
'SaddleBrown',
|
||||||
|
'DarkSeaGreen',
|
||||||
|
'LightGreen',
|
||||||
|
'MediumPurple',
|
||||||
|
'DarkViolet',
|
||||||
|
'PaleGreen',
|
||||||
|
'DarkOrchid',
|
||||||
|
'YellowGreen',
|
||||||
|
'Sienna',
|
||||||
|
'Brown',
|
||||||
|
'DarkGray',
|
||||||
|
'DarkGrey',
|
||||||
|
'LightBlue',
|
||||||
|
'GreenYellow',
|
||||||
|
'PaleTurquoise',
|
||||||
|
'LightSteelBlue',
|
||||||
|
'PowderBlue',
|
||||||
|
'FireBrick',
|
||||||
|
'DarkGoldenRod',
|
||||||
|
'MediumOrchid',
|
||||||
|
'RosyBrown',
|
||||||
|
'DarkKhaki',
|
||||||
|
'Silver',
|
||||||
|
'MediumVioletRed',
|
||||||
|
'IndianRed',
|
||||||
|
'Peru',
|
||||||
|
'Chocolate',
|
||||||
|
'Tan',
|
||||||
|
'LightGray',
|
||||||
|
'LightGrey',
|
||||||
|
'Thistle',
|
||||||
|
'Orchid',
|
||||||
|
'GoldenRod',
|
||||||
|
'PaleVioletRed',
|
||||||
|
'Crimson',
|
||||||
|
'Gainsboro',
|
||||||
|
'Plum',
|
||||||
|
'BurlyWood',
|
||||||
|
'LightCyan',
|
||||||
|
'Lavender',
|
||||||
|
'DarkSalmon',
|
||||||
|
'Violet',
|
||||||
|
'PaleGoldenRod',
|
||||||
|
'LightCoral',
|
||||||
|
'Khaki',
|
||||||
|
'AliceBlue',
|
||||||
|
'HoneyDew',
|
||||||
|
'Azure',
|
||||||
|
'SandyBrown',
|
||||||
|
'Wheat',
|
||||||
|
'Beige',
|
||||||
|
'WhiteSmoke',
|
||||||
|
'MintCream',
|
||||||
|
'GhostWhite',
|
||||||
|
'Salmon',
|
||||||
|
'AntiqueWhite',
|
||||||
|
'Linen',
|
||||||
|
'LightGoldenRodYellow',
|
||||||
|
'OldLace',
|
||||||
|
'Red',
|
||||||
|
'Fuchsia',
|
||||||
|
'Magenta',
|
||||||
|
'DeepPink',
|
||||||
|
'OrangeRed',
|
||||||
|
'Tomato',
|
||||||
|
'HotPink',
|
||||||
|
'Coral',
|
||||||
|
'DarkOrange',
|
||||||
|
'LightSalmon',
|
||||||
|
'Orange',
|
||||||
|
'LightPink',
|
||||||
|
'Pink',
|
||||||
|
'Gold',
|
||||||
|
'PeachPuff',
|
||||||
|
'NavajoWhite',
|
||||||
|
'Moccasin',
|
||||||
|
'Bisque',
|
||||||
|
'MistyRose',
|
||||||
|
'BlanchedAlmond',
|
||||||
|
'PapayaWhip',
|
||||||
|
'LavenderBlush',
|
||||||
|
'SeaShell',
|
||||||
|
'Cornsilk',
|
||||||
|
'LemonChiffon',
|
||||||
|
'FloralWhite',
|
||||||
|
'Snow',
|
||||||
|
'Yellow',
|
||||||
|
'LightYellow',
|
||||||
|
'Ivory',
|
||||||
|
'White',
|
||||||
|
]
|
||||||
|
|
|
@ -491,18 +491,6 @@ U.CaptionControl = L.Control.Button.extend({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
U.StarControl = L.Control.Button.extend({
|
|
||||||
options: {
|
|
||||||
position: 'topleft',
|
|
||||||
title: L._('Star this map'),
|
|
||||||
className: 'leaflet-control-star map-star umap-control',
|
|
||||||
},
|
|
||||||
|
|
||||||
onClick: function () {
|
|
||||||
this._umap.star()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.Control.Embed = L.Control.Button.extend({
|
L.Control.Embed = L.Control.Button.extend({
|
||||||
options: {
|
options: {
|
||||||
position: 'topleft',
|
position: 'topleft',
|
||||||
|
|
Before Width: | Height: | Size: 16 KiB |
|
@ -134,12 +134,6 @@ html[dir="rtl"] .leaflet-tooltip-pane > * {
|
||||||
background-position: -72px -144px;
|
background-position: -72px -144px;
|
||||||
box-shadow: 0 0 4px 0 black inset;
|
box-shadow: 0 0 4px 0 black inset;
|
||||||
}
|
}
|
||||||
.leaflet-control-star [type="button"] {
|
|
||||||
background-position: -144px -144px;
|
|
||||||
}
|
|
||||||
.leaflet-control-star.starred [type="button"] {
|
|
||||||
background-position: -108px -144px;
|
|
||||||
}
|
|
||||||
.leaflet-control-search [type="button"] {
|
.leaflet-control-search [type="button"] {
|
||||||
background-position: -36px -108px;
|
background-position: -36px -108px;
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -703,6 +697,10 @@ a.umap-control-caption,
|
||||||
.umap-caption .header i.icon {
|
.umap-caption .header i.icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.umap-caption hgroup p,
|
||||||
|
.umap-caption hgroup button {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.umap-browser .main-toolbox {
|
.umap-browser .main-toolbox {
|
||||||
padding-left: 4px; /* Align with toolbox below */
|
padding-left: 4px; /* Align with toolbox below */
|
||||||
border-top: 1px solid var(--color-mediumGray);
|
border-top: 1px solid var(--color-mediumGray);
|
||||||
|
|
Before Width: | Height: | Size: 19 KiB |
Before Width: | Height: | Size: 3.1 KiB |
|
@ -1,468 +0,0 @@
|
||||||
L.FormBuilder = L.Evented.extend({
|
|
||||||
options: {
|
|
||||||
className: 'leaflet-form',
|
|
||||||
},
|
|
||||||
|
|
||||||
defaultOptions: {
|
|
||||||
// Eg.:
|
|
||||||
// name: {label: L._('name')},
|
|
||||||
// description: {label: L._('description'), handler: 'Textarea'},
|
|
||||||
// opacity: {label: L._('opacity'), helpText: L._('Opacity, from 0.1 to 1.0 (opaque).')},
|
|
||||||
},
|
|
||||||
|
|
||||||
initialize: function (obj, fields, options) {
|
|
||||||
L.setOptions(this, options)
|
|
||||||
this.obj = obj
|
|
||||||
this.form = L.DomUtil.create('form', this.options.className)
|
|
||||||
this.setFields(fields)
|
|
||||||
if (this.options.id) {
|
|
||||||
this.form.id = this.options.id
|
|
||||||
}
|
|
||||||
if (this.options.className) {
|
|
||||||
L.DomUtil.addClass(this.form, this.options.className)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setFields: function (fields) {
|
|
||||||
this.fields = fields || []
|
|
||||||
this.helpers = {}
|
|
||||||
},
|
|
||||||
|
|
||||||
build: function () {
|
|
||||||
this.form.innerHTML = ''
|
|
||||||
for (const idx in this.fields) {
|
|
||||||
this.buildField(this.fields[idx])
|
|
||||||
}
|
|
||||||
this.on('postsync', this.onPostSync)
|
|
||||||
return this.form
|
|
||||||
},
|
|
||||||
|
|
||||||
buildField: function (field) {
|
|
||||||
// field can be either a string like "option.name" or a full definition array,
|
|
||||||
// like ['options.tilelayer.tms', {handler: 'CheckBox', helpText: 'TMS format'}]
|
|
||||||
let type
|
|
||||||
let helper
|
|
||||||
let options
|
|
||||||
if (Array.isArray(field)) {
|
|
||||||
options = field[1] || {}
|
|
||||||
field = field[0]
|
|
||||||
} else {
|
|
||||||
options = this.defaultOptions[this.getName(field)] || {}
|
|
||||||
}
|
|
||||||
type = options.handler || 'Input'
|
|
||||||
if (typeof type === 'string' && L.FormBuilder[type]) {
|
|
||||||
helper = new L.FormBuilder[type](this, field, options)
|
|
||||||
} else {
|
|
||||||
helper = new type(this, field, options)
|
|
||||||
}
|
|
||||||
this.helpers[field] = helper
|
|
||||||
return helper
|
|
||||||
},
|
|
||||||
|
|
||||||
getter: function (field) {
|
|
||||||
const path = field.split('.')
|
|
||||||
let value = this.obj
|
|
||||||
for (const sub of path) {
|
|
||||||
try {
|
|
||||||
value = value[sub]
|
|
||||||
} catch {
|
|
||||||
console.log(field)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
|
|
||||||
setter: function (field, value) {
|
|
||||||
const path = field.split('.')
|
|
||||||
let obj = this.obj
|
|
||||||
let what
|
|
||||||
for (let i = 0, l = path.length; i < l; i++) {
|
|
||||||
what = path[i]
|
|
||||||
if (what === path[l - 1]) {
|
|
||||||
if (typeof value === 'undefined') {
|
|
||||||
delete obj[what]
|
|
||||||
} else {
|
|
||||||
obj[what] = value
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
obj = obj[what]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
restoreField: function (field) {
|
|
||||||
const initial = this.helpers[field].initial
|
|
||||||
this.setter(field, initial)
|
|
||||||
},
|
|
||||||
|
|
||||||
getName: (field) => {
|
|
||||||
const fieldEls = field.split('.')
|
|
||||||
return fieldEls[fieldEls.length - 1]
|
|
||||||
},
|
|
||||||
|
|
||||||
fetchAll: function () {
|
|
||||||
for (const helper of Object.values(this.helpers)) {
|
|
||||||
helper.fetch()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
syncAll: function () {
|
|
||||||
for (const helper of Object.values(this.helpers)) {
|
|
||||||
helper.sync()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onPostSync: function (e) {
|
|
||||||
if (e.helper.options.callback) {
|
|
||||||
e.helper.options.callback.call(e.helper.options.callbackContext || this.obj, e)
|
|
||||||
}
|
|
||||||
if (this.options.callback) {
|
|
||||||
this.options.callback.call(this.options.callbackContext || this.obj, e)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.Element = L.Evented.extend({
|
|
||||||
initialize: function (builder, field, options) {
|
|
||||||
this.builder = builder
|
|
||||||
this.obj = this.builder.obj
|
|
||||||
this.form = this.builder.form
|
|
||||||
this.field = field
|
|
||||||
L.setOptions(this, options)
|
|
||||||
this.fieldEls = this.field.split('.')
|
|
||||||
this.name = this.builder.getName(field)
|
|
||||||
this.parentNode = this.getParentNode()
|
|
||||||
this.buildLabel()
|
|
||||||
this.build()
|
|
||||||
this.buildHelpText()
|
|
||||||
this.fireAndForward('helper:init')
|
|
||||||
},
|
|
||||||
|
|
||||||
fireAndForward: function (type, e = {}) {
|
|
||||||
e.helper = this
|
|
||||||
this.fire(type, e)
|
|
||||||
this.builder.fire(type, e)
|
|
||||||
if (this.obj.fire) this.obj.fire(type, e)
|
|
||||||
},
|
|
||||||
|
|
||||||
getParentNode: function () {
|
|
||||||
return this.options.wrapper
|
|
||||||
? L.DomUtil.create(
|
|
||||||
this.options.wrapper,
|
|
||||||
this.options.wrapperClass || '',
|
|
||||||
this.form
|
|
||||||
)
|
|
||||||
: this.form
|
|
||||||
},
|
|
||||||
|
|
||||||
get: function () {
|
|
||||||
return this.builder.getter(this.field)
|
|
||||||
},
|
|
||||||
|
|
||||||
toHTML: function () {
|
|
||||||
return this.get()
|
|
||||||
},
|
|
||||||
|
|
||||||
toJS: function () {
|
|
||||||
return this.value()
|
|
||||||
},
|
|
||||||
|
|
||||||
sync: function () {
|
|
||||||
this.fireAndForward('presync')
|
|
||||||
this.set()
|
|
||||||
this.fireAndForward('postsync')
|
|
||||||
},
|
|
||||||
|
|
||||||
set: function () {
|
|
||||||
this.builder.setter(this.field, this.toJS())
|
|
||||||
},
|
|
||||||
|
|
||||||
getLabelParent: function () {
|
|
||||||
return this.parentNode
|
|
||||||
},
|
|
||||||
|
|
||||||
getHelpTextParent: function () {
|
|
||||||
return this.parentNode
|
|
||||||
},
|
|
||||||
|
|
||||||
buildLabel: function () {
|
|
||||||
if (this.options.label) {
|
|
||||||
this.label = L.DomUtil.create('label', '', this.getLabelParent())
|
|
||||||
this.label.innerHTML = this.options.label
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
buildHelpText: function () {
|
|
||||||
if (this.options.helpText) {
|
|
||||||
const container = L.DomUtil.create('small', 'help-text', this.getHelpTextParent())
|
|
||||||
container.innerHTML = this.options.helpText
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
fetch: () => {},
|
|
||||||
|
|
||||||
finish: function () {
|
|
||||||
this.fireAndForward('finish')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.Textarea = L.FormBuilder.Element.extend({
|
|
||||||
build: function () {
|
|
||||||
this.input = L.DomUtil.create(
|
|
||||||
'textarea',
|
|
||||||
this.options.className || '',
|
|
||||||
this.parentNode
|
|
||||||
)
|
|
||||||
if (this.options.placeholder) this.input.placeholder = this.options.placeholder
|
|
||||||
this.fetch()
|
|
||||||
L.DomEvent.on(this.input, 'input', this.sync, this)
|
|
||||||
L.DomEvent.on(this.input, 'keypress', this.onKeyPress, this)
|
|
||||||
},
|
|
||||||
|
|
||||||
fetch: function () {
|
|
||||||
const value = this.toHTML()
|
|
||||||
this.initial = value
|
|
||||||
if (value) {
|
|
||||||
this.input.value = value
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
value: function () {
|
|
||||||
return this.input.value
|
|
||||||
},
|
|
||||||
|
|
||||||
onKeyPress: function (e) {
|
|
||||||
if (e.key === 'Enter' && (e.shiftKey || e.ctrlKey)) {
|
|
||||||
L.DomEvent.stop(e)
|
|
||||||
this.finish()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.Input = L.FormBuilder.Element.extend({
|
|
||||||
build: function () {
|
|
||||||
this.input = L.DomUtil.create(
|
|
||||||
'input',
|
|
||||||
this.options.className || '',
|
|
||||||
this.parentNode
|
|
||||||
)
|
|
||||||
this.input.type = this.type()
|
|
||||||
this.input.name = this.name
|
|
||||||
this.input._helper = this
|
|
||||||
if (this.options.placeholder) {
|
|
||||||
this.input.placeholder = this.options.placeholder
|
|
||||||
}
|
|
||||||
if (this.options.min !== undefined) {
|
|
||||||
this.input.min = this.options.min
|
|
||||||
}
|
|
||||||
if (this.options.max !== undefined) {
|
|
||||||
this.input.max = this.options.max
|
|
||||||
}
|
|
||||||
if (this.options.step) {
|
|
||||||
this.input.step = this.options.step
|
|
||||||
}
|
|
||||||
this.fetch()
|
|
||||||
L.DomEvent.on(this.input, this.getSyncEvent(), this.sync, this)
|
|
||||||
L.DomEvent.on(this.input, 'keydown', this.onKeyDown, this)
|
|
||||||
},
|
|
||||||
|
|
||||||
fetch: function () {
|
|
||||||
const value = this.toHTML() !== undefined ? this.toHTML() : null
|
|
||||||
this.initial = value
|
|
||||||
this.input.value = value
|
|
||||||
},
|
|
||||||
|
|
||||||
getSyncEvent: () => 'input',
|
|
||||||
|
|
||||||
type: function () {
|
|
||||||
return this.options.type || 'text'
|
|
||||||
},
|
|
||||||
|
|
||||||
value: function () {
|
|
||||||
return this.input.value || undefined
|
|
||||||
},
|
|
||||||
|
|
||||||
onKeyDown: function (e) {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
L.DomEvent.stop(e)
|
|
||||||
this.finish()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.BlurInput = L.FormBuilder.Input.extend({
|
|
||||||
getSyncEvent: () => 'blur',
|
|
||||||
|
|
||||||
build: function () {
|
|
||||||
L.FormBuilder.Input.prototype.build.call(this)
|
|
||||||
L.DomEvent.on(this.input, 'focus', this.fetch, this)
|
|
||||||
},
|
|
||||||
|
|
||||||
finish: function () {
|
|
||||||
this.sync()
|
|
||||||
L.FormBuilder.Input.prototype.finish.call(this)
|
|
||||||
},
|
|
||||||
|
|
||||||
sync: function () {
|
|
||||||
// Do not commit any change if user only clicked
|
|
||||||
// on the field than clicked outside
|
|
||||||
if (this.initial !== this.value()) {
|
|
||||||
L.FormBuilder.Input.prototype.sync.call(this)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.IntegerMixin = {
|
|
||||||
value: function () {
|
|
||||||
return !isNaN(this.input.value) && this.input.value !== ''
|
|
||||||
? parseInt(this.input.value, 10)
|
|
||||||
: undefined
|
|
||||||
},
|
|
||||||
|
|
||||||
type: () => 'number',
|
|
||||||
}
|
|
||||||
|
|
||||||
L.FormBuilder.IntInput = L.FormBuilder.Input.extend({
|
|
||||||
includes: [L.FormBuilder.IntegerMixin],
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.BlurIntInput = L.FormBuilder.BlurInput.extend({
|
|
||||||
includes: [L.FormBuilder.IntegerMixin],
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.FloatMixin = {
|
|
||||||
value: function () {
|
|
||||||
return !isNaN(this.input.value) && this.input.value !== ''
|
|
||||||
? parseFloat(this.input.value)
|
|
||||||
: undefined
|
|
||||||
},
|
|
||||||
|
|
||||||
type: () => 'number',
|
|
||||||
}
|
|
||||||
|
|
||||||
L.FormBuilder.FloatInput = L.FormBuilder.Input.extend({
|
|
||||||
options: {
|
|
||||||
step: 'any',
|
|
||||||
},
|
|
||||||
|
|
||||||
includes: [L.FormBuilder.FloatMixin],
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.BlurFloatInput = L.FormBuilder.BlurInput.extend({
|
|
||||||
options: {
|
|
||||||
step: 'any',
|
|
||||||
},
|
|
||||||
|
|
||||||
includes: [L.FormBuilder.FloatMixin],
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.CheckBox = L.FormBuilder.Element.extend({
|
|
||||||
build: function () {
|
|
||||||
const container = L.DomUtil.create('div', 'checkbox-wrapper', this.parentNode)
|
|
||||||
this.input = L.DomUtil.create('input', this.options.className || '', container)
|
|
||||||
this.input.type = 'checkbox'
|
|
||||||
this.input.name = this.name
|
|
||||||
this.input._helper = this
|
|
||||||
this.fetch()
|
|
||||||
L.DomEvent.on(this.input, 'change', this.sync, this)
|
|
||||||
},
|
|
||||||
|
|
||||||
fetch: function () {
|
|
||||||
this.initial = this.toHTML()
|
|
||||||
this.input.checked = this.initial === true
|
|
||||||
},
|
|
||||||
|
|
||||||
value: function () {
|
|
||||||
return this.input.checked
|
|
||||||
},
|
|
||||||
|
|
||||||
toHTML: function () {
|
|
||||||
return [1, true].indexOf(this.get()) !== -1
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.Select = L.FormBuilder.Element.extend({
|
|
||||||
selectOptions: [['value', 'label']],
|
|
||||||
|
|
||||||
build: function () {
|
|
||||||
this.select = L.DomUtil.create('select', '', this.parentNode)
|
|
||||||
this.select.name = this.name
|
|
||||||
this.validValues = []
|
|
||||||
this.buildOptions()
|
|
||||||
L.DomEvent.on(this.select, 'change', this.sync, this)
|
|
||||||
},
|
|
||||||
|
|
||||||
getOptions: function () {
|
|
||||||
return this.options.selectOptions || this.selectOptions
|
|
||||||
},
|
|
||||||
|
|
||||||
fetch: function () {
|
|
||||||
this.buildOptions()
|
|
||||||
},
|
|
||||||
|
|
||||||
buildOptions: function () {
|
|
||||||
this.select.innerHTML = ''
|
|
||||||
for (const option of this.getOptions()) {
|
|
||||||
if (typeof option === 'string') this.buildOption(option, option)
|
|
||||||
else this.buildOption(option[0], option[1])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
buildOption: function (value, label) {
|
|
||||||
this.validValues.push(value)
|
|
||||||
const option = L.DomUtil.create('option', '', this.select)
|
|
||||||
option.value = value
|
|
||||||
option.innerHTML = label
|
|
||||||
if (this.toHTML() === value) {
|
|
||||||
option.selected = 'selected'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
value: function () {
|
|
||||||
if (this.select[this.select.selectedIndex])
|
|
||||||
return this.select[this.select.selectedIndex].value
|
|
||||||
},
|
|
||||||
|
|
||||||
getDefault: function () {
|
|
||||||
return this.getOptions()[0][0]
|
|
||||||
},
|
|
||||||
|
|
||||||
toJS: function () {
|
|
||||||
const value = this.value()
|
|
||||||
if (this.validValues.indexOf(value) !== -1) {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
return this.getDefault()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.IntSelect = L.FormBuilder.Select.extend({
|
|
||||||
value: function () {
|
|
||||||
return parseInt(L.FormBuilder.Select.prototype.value.apply(this), 10)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
L.FormBuilder.NullableBoolean = L.FormBuilder.Select.extend({
|
|
||||||
selectOptions: [
|
|
||||||
[undefined, 'inherit'],
|
|
||||||
[true, 'yes'],
|
|
||||||
[false, 'no'],
|
|
||||||
],
|
|
||||||
|
|
||||||
toJS: function () {
|
|
||||||
let value = this.value()
|
|
||||||
switch (value) {
|
|
||||||
case 'true':
|
|
||||||
case true:
|
|
||||||
value = true
|
|
||||||
break
|
|
||||||
case 'false':
|
|
||||||
case false:
|
|
||||||
value = false
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
value = undefined
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
})
|
|
0
umap/sync/__init__.py
Normal file
181
umap/sync/app.py
Normal file
|
@ -0,0 +1,181 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.signing import TimestampSigner
|
||||||
|
from django.urls import path
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from .payloads import (
|
||||||
|
JoinRequest,
|
||||||
|
JoinResponse,
|
||||||
|
ListPeersResponse,
|
||||||
|
OperationMessage,
|
||||||
|
PeerMessage,
|
||||||
|
Request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def application(scope, receive, send):
|
||||||
|
path = scope["path"].lstrip("/")
|
||||||
|
for pattern in urlpatterns:
|
||||||
|
if matched := pattern.resolve(path):
|
||||||
|
await matched.func(scope, receive, send, **matched.kwargs)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
await send({"type": "websocket.close"})
|
||||||
|
|
||||||
|
|
||||||
|
async def sync(scope, receive, send, **kwargs):
|
||||||
|
peer = Peer(kwargs["map_id"])
|
||||||
|
peer._send = send
|
||||||
|
while True:
|
||||||
|
event = await receive()
|
||||||
|
|
||||||
|
if event["type"] == "websocket.connect":
|
||||||
|
try:
|
||||||
|
await peer.connect()
|
||||||
|
await send({"type": "websocket.accept"})
|
||||||
|
except ValueError:
|
||||||
|
await send({"type": "websocket.close"})
|
||||||
|
|
||||||
|
if event["type"] == "websocket.disconnect":
|
||||||
|
await peer.disconnect()
|
||||||
|
break
|
||||||
|
|
||||||
|
if event["type"] == "websocket.receive":
|
||||||
|
if event["text"] == "ping":
|
||||||
|
await send({"type": "websocket.send", "text": "pong"})
|
||||||
|
else:
|
||||||
|
await peer.receive(event["text"])
|
||||||
|
|
||||||
|
|
||||||
|
class Peer:
|
||||||
|
def __init__(self, map_id, username=None):
|
||||||
|
self.username = username or ""
|
||||||
|
self.map_id = map_id
|
||||||
|
self.is_authenticated = False
|
||||||
|
self._subscriptions = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def room_key(self):
|
||||||
|
return f"umap:{self.map_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def peer_key(self):
|
||||||
|
return f"user:{self.map_id}:{self.peer_id}"
|
||||||
|
|
||||||
|
async def get_peers(self):
|
||||||
|
known = await self.client.hgetall(self.room_key)
|
||||||
|
active = await self.client.pubsub_channels(f"user:{self.map_id}:*")
|
||||||
|
if not active:
|
||||||
|
# Poor man way of deleting stale usernames from the store
|
||||||
|
# HEXPIRE command is not in the open source Redis version
|
||||||
|
await self.client.delete(self.room_key)
|
||||||
|
await self.store_username()
|
||||||
|
active = [name.split(b":")[-1] for name in active]
|
||||||
|
if self.peer_id.encode() not in active:
|
||||||
|
# Our connection may not yet be active
|
||||||
|
active.append(self.peer_id.encode())
|
||||||
|
return {k: v for k, v in known.items() if k in active}
|
||||||
|
|
||||||
|
async def store_username(self):
|
||||||
|
await self.client.hset(self.room_key, self.peer_id, self.username)
|
||||||
|
|
||||||
|
async def listen_to_channel(self, channel_name):
|
||||||
|
async def reader(pubsub):
|
||||||
|
await pubsub.subscribe(channel_name)
|
||||||
|
while True:
|
||||||
|
if pubsub.connection is None:
|
||||||
|
# It has been unsubscribed/closed.
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
message = await pubsub.get_message(ignore_subscribe_messages=True)
|
||||||
|
except Exception as err:
|
||||||
|
print(err)
|
||||||
|
break
|
||||||
|
if message is not None:
|
||||||
|
await self.send(message["data"].decode())
|
||||||
|
await asyncio.sleep(0.001) # Be nice with the server
|
||||||
|
|
||||||
|
async with self.client.pubsub() as pubsub:
|
||||||
|
self._subscriptions.append(pubsub)
|
||||||
|
asyncio.create_task(reader(pubsub))
|
||||||
|
|
||||||
|
async def listen(self):
|
||||||
|
await self.listen_to_channel(self.room_key)
|
||||||
|
await self.listen_to_channel(self.peer_key)
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
self.client = redis.from_url(settings.REDIS_URL)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
await self.client.hdel(self.room_key, self.peer_id)
|
||||||
|
for pubsub in self._subscriptions:
|
||||||
|
await pubsub.unsubscribe()
|
||||||
|
await pubsub.close()
|
||||||
|
await self.send_peers_list()
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def send_peers_list(self):
|
||||||
|
message = ListPeersResponse(peers=await self.get_peers())
|
||||||
|
await self.broadcast(message.model_dump_json())
|
||||||
|
|
||||||
|
async def broadcast(self, message):
|
||||||
|
print("BROADCASTING", message)
|
||||||
|
# Send to all channels (including sender!)
|
||||||
|
await self.client.publish(self.room_key, message)
|
||||||
|
|
||||||
|
async def send_to(self, peer_id, message):
|
||||||
|
print("SEND TO", peer_id, message)
|
||||||
|
# Send to one given channel
|
||||||
|
await self.client.publish(f"user:{self.map_id}:{peer_id}", message)
|
||||||
|
|
||||||
|
async def receive(self, text_data):
|
||||||
|
if not self.is_authenticated:
|
||||||
|
print("AUTHENTICATING", text_data)
|
||||||
|
message = JoinRequest.model_validate_json(text_data)
|
||||||
|
signed = TimestampSigner().unsign_object(message.token, max_age=30)
|
||||||
|
user, map_id, permissions = signed.values()
|
||||||
|
assert str(map_id) == self.map_id
|
||||||
|
if "edit" not in permissions:
|
||||||
|
return await self.disconnect()
|
||||||
|
self.peer_id = message.peer
|
||||||
|
self.username = message.username
|
||||||
|
print("AUTHENTICATED", self.peer_id)
|
||||||
|
await self.store_username()
|
||||||
|
await self.listen()
|
||||||
|
response = JoinResponse(peer=self.peer_id, peers=await self.get_peers())
|
||||||
|
await self.send(response.model_dump_json())
|
||||||
|
await self.send_peers_list()
|
||||||
|
self.is_authenticated = True
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
incoming = Request.model_validate_json(text_data)
|
||||||
|
except ValidationError as error:
|
||||||
|
message = (
|
||||||
|
f"An error occurred when receiving the following message: {text_data!r}"
|
||||||
|
)
|
||||||
|
logging.error(message, error)
|
||||||
|
else:
|
||||||
|
match incoming.root:
|
||||||
|
# Broadcast all operation messages to connected peers
|
||||||
|
case OperationMessage():
|
||||||
|
await self.broadcast(text_data)
|
||||||
|
|
||||||
|
# Send peer messages to the proper peer
|
||||||
|
case PeerMessage():
|
||||||
|
await self.send_to(incoming.root.recipient, text_data)
|
||||||
|
|
||||||
|
async def send(self, text):
|
||||||
|
print(" FORWARDING TO", self.peer_id, text)
|
||||||
|
try:
|
||||||
|
await self._send({"type": "websocket.send", "text": text})
|
||||||
|
except Exception as err:
|
||||||
|
print("Error sending message:", text)
|
||||||
|
print(err)
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [path("ws/sync/<str:map_id>", name="ws_sync", view=sync)]
|
49
umap/sync/payloads.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
from typing import Literal, Optional, Union
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, RootModel
|
||||||
|
|
||||||
|
|
||||||
|
class JoinRequest(BaseModel):
|
||||||
|
kind: Literal["JoinRequest"] = "JoinRequest"
|
||||||
|
token: str
|
||||||
|
peer: str
|
||||||
|
username: Optional[str] = ""
|
||||||
|
|
||||||
|
|
||||||
|
class OperationMessage(BaseModel):
|
||||||
|
"""Message sent from one peer to all the others"""
|
||||||
|
|
||||||
|
kind: Literal["OperationMessage"] = "OperationMessage"
|
||||||
|
verb: Literal["upsert", "update", "delete"]
|
||||||
|
subject: Literal["map", "datalayer", "feature"]
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PeerMessage(BaseModel):
|
||||||
|
"""Message sent from a specific peer to another one"""
|
||||||
|
|
||||||
|
kind: Literal["PeerMessage"] = "PeerMessage"
|
||||||
|
sender: str
|
||||||
|
recipient: str
|
||||||
|
# The message can be whatever the peers want. It's not checked by the server.
|
||||||
|
message: dict
|
||||||
|
|
||||||
|
|
||||||
|
class Request(RootModel):
|
||||||
|
"""Any message coming from the websocket should be one of these, and will be rejected otherwise."""
|
||||||
|
|
||||||
|
root: Union[PeerMessage, OperationMessage] = Field(discriminator="kind")
|
||||||
|
|
||||||
|
|
||||||
|
class JoinResponse(BaseModel):
|
||||||
|
"""Server response containing the list of peers"""
|
||||||
|
|
||||||
|
kind: Literal["JoinResponse"] = "JoinResponse"
|
||||||
|
peers: dict
|
||||||
|
peer: str
|
||||||
|
|
||||||
|
|
||||||
|
class ListPeersResponse(BaseModel):
|
||||||
|
kind: Literal["ListPeersResponse"] = "ListPeersResponse"
|
||||||
|
peers: dict
|
|
@ -1,6 +1,6 @@
|
||||||
{% extends "umap/content.html" %}
|
{% extends "umap/content.html" %}
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n static %}
|
||||||
|
|
||||||
{% block maincontent %}
|
{% block maincontent %}
|
||||||
{% include "umap/dashboard_menu.html" with selected="profile" %}
|
{% include "umap/dashboard_menu.html" with selected="profile" %}
|
||||||
|
@ -28,8 +28,10 @@
|
||||||
</h3>
|
</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{% for name in providers %}
|
{% for name in providers %}
|
||||||
<li>
|
<li class="login-grid">
|
||||||
{{ name|title }}
|
{% with "umap/img/providers/"|add:name|add:".png" as path %}
|
||||||
|
<img src="{% static path %}" alt="{{ name }}" />
|
||||||
|
{% endwith %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -46,9 +48,7 @@
|
||||||
{% for name in backends.backends %}
|
{% for name in backends.backends %}
|
||||||
{% if name not in providers %}
|
{% if name not in providers %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url "social:begin" name %}"
|
{% include "umap/components/provider.html" with name=name %}
|
||||||
class="umap-login-popup login-{{ name }}"
|
|
||||||
title="{{ name|title }}"></a>
|
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -55,10 +55,7 @@
|
||||||
<ul class="login-grid block-grid">
|
<ul class="login-grid block-grid">
|
||||||
{% for name in backends.backends %}
|
{% for name in backends.backends %}
|
||||||
<li>
|
<li>
|
||||||
<a rel="nofollow"
|
{% include "umap/components/provider.html" with name=name %}
|
||||||
href="{% url "social:begin" name %}"
|
|
||||||
class="umap-login-popup login-{{ name }}"
|
|
||||||
title="{{ name|title }}"></a>
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
8
umap/templates/umap/components/provider.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{% load static %}
|
||||||
|
<a href="{% url "social:begin" name %}"
|
||||||
|
class="umap-login-popup"
|
||||||
|
title="{{ name|title }}">
|
||||||
|
{% with "umap/img/providers/"|add:name|add:".png" as path %}
|
||||||
|
<img src="{% static path %}" alt="{{name}}" />
|
||||||
|
{% endwith %}
|
||||||
|
</a>
|
|
@ -30,8 +30,6 @@
|
||||||
<script src="{% static 'umap/vendors/fullscreen/Leaflet.fullscreen.min.js' %}"
|
<script src="{% static 'umap/vendors/fullscreen/Leaflet.fullscreen.min.js' %}"
|
||||||
defer></script>
|
defer></script>
|
||||||
<script src="{% static 'umap/vendors/toolbar/leaflet.toolbar.js' %}" defer></script>
|
<script src="{% static 'umap/vendors/toolbar/leaflet.toolbar.js' %}" defer></script>
|
||||||
<script src="{% static 'umap/vendors/formbuilder/Leaflet.FormBuilder.js' %}"
|
|
||||||
defer></script>
|
|
||||||
<script src="{% static 'umap/vendors/measurable/Leaflet.Measurable.js' %}"
|
<script src="{% static 'umap/vendors/measurable/Leaflet.Measurable.js' %}"
|
||||||
defer></script>
|
defer></script>
|
||||||
<script src="{% static 'umap/vendors/iconlayers/iconLayers.js' %}" defer></script>
|
<script src="{% static 'umap/vendors/iconlayers/iconLayers.js' %}" defer></script>
|
||||||
|
@ -40,7 +38,6 @@
|
||||||
<script src="{% static 'umap/vendors/simple-statistics/simple-statistics.min.js' %}"
|
<script src="{% static 'umap/vendors/simple-statistics/simple-statistics.min.js' %}"
|
||||||
defer></script>
|
defer></script>
|
||||||
<script src="{% static 'umap/js/umap.core.js' %}" defer></script>
|
<script src="{% static 'umap/js/umap.core.js' %}" defer></script>
|
||||||
<script src="{% static 'umap/js/umap.forms.js' %}" defer></script>
|
|
||||||
<script src="{% static 'umap/js/umap.controls.js' %}" defer></script>
|
<script src="{% static 'umap/js/umap.controls.js' %}" defer></script>
|
||||||
<script type="module" src="{% static 'umap/js/components/fragment.js' %}" defer></script>
|
<script type="module" src="{% static 'umap/js/components/fragment.js' %}" defer></script>
|
||||||
{% endautoescape %}
|
{% endautoescape %}
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from daphne.testing import DaphneProcess
|
||||||
|
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
|
from umap.asgi import application
|
||||||
|
|
||||||
from ..base import mock_tiles
|
from ..base import mock_tiles
|
||||||
|
|
||||||
|
|
||||||
|
@ -67,23 +68,15 @@ def login(new_page, settings, live_server):
|
||||||
return do_login
|
return do_login
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture(scope="function")
|
||||||
def websocket_server():
|
def asgi_live_server(request, live_server):
|
||||||
# Find the test-settings, and put them in the current environment
|
server = DaphneProcess("localhost", lambda: ASGIStaticFilesHandler(application))
|
||||||
settings_path = (Path(__file__).parent.parent / "settings.py").absolute().as_posix()
|
server.start()
|
||||||
os.environ["UMAP_SETTINGS"] = settings_path
|
server.ready.wait()
|
||||||
|
port = server.port.value
|
||||||
|
server.url = f"http://localhost:{port}"
|
||||||
|
|
||||||
ds_proc = subprocess.Popen(
|
yield server
|
||||||
[
|
|
||||||
"umap",
|
server.terminate()
|
||||||
"run_websocket_server",
|
server.join()
|
||||||
],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
)
|
|
||||||
time.sleep(2)
|
|
||||||
# Ensure it started properly before yielding
|
|
||||||
assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8")
|
|
||||||
yield ds_proc
|
|
||||||
# Shut it down at the end of the pytest session
|
|
||||||
ds_proc.terminate()
|
|
||||||
|
|
|
@ -103,7 +103,7 @@ def test_can_change_icon_class(live_server, openmap, page):
|
||||||
expect(page.locator(".umap-circle-icon")).to_be_hidden()
|
expect(page.locator(".umap-circle-icon")).to_be_hidden()
|
||||||
page.locator(".panel.right").get_by_title("Edit", exact=True).click()
|
page.locator(".panel.right").get_by_title("Edit", exact=True).click()
|
||||||
page.get_by_text("Shape properties").click()
|
page.get_by_text("Shape properties").click()
|
||||||
page.locator(".umap-field-iconClass a.define").click()
|
page.locator(".umap-field-iconClass button.define").click()
|
||||||
page.get_by_text("Circle", exact=True).click()
|
page.get_by_text("Circle", exact=True).click()
|
||||||
expect(page.locator(".umap-circle-icon")).to_be_visible()
|
expect(page.locator(".umap-circle-icon")).to_be_visible()
|
||||||
expect(page.locator(".umap-div-icon")).to_be_hidden()
|
expect(page.locator(".umap-div-icon")).to_be_hidden()
|
||||||
|
|
|
@ -60,8 +60,8 @@ def test_zoomcontrol_impacts_ui(live_server, page, tilelayer):
|
||||||
# Hide them
|
# Hide them
|
||||||
page.get_by_text("User interface options").click()
|
page.get_by_text("User interface options").click()
|
||||||
hide_zoom_controls = (
|
hide_zoom_controls = (
|
||||||
page.locator("div")
|
page.locator(".panel")
|
||||||
.filter(has_text=re.compile(r"^Display the zoom control"))
|
.filter(has_text=re.compile("Display the zoom control"))
|
||||||
.locator("label")
|
.locator("label")
|
||||||
.nth(2)
|
.nth(2)
|
||||||
)
|
)
|
||||||
|
@ -191,7 +191,7 @@ def test_sortkey_impacts_datalayerindex(map, live_server, page):
|
||||||
page.locator('input[name="sortKey"]').fill("key")
|
page.locator('input[name="sortKey"]').fill("key")
|
||||||
|
|
||||||
# Click the checkmark to apply the changes
|
# Click the checkmark to apply the changes
|
||||||
page.locator(".panel .umap-field-sortKey .blur-button").click()
|
page.locator(".panel .umap-field-sortKey .blur-container button").click()
|
||||||
|
|
||||||
# Features should be sorted by key (First, Second, Third)
|
# Features should be sorted by key (First, Second, Third)
|
||||||
first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0)
|
first_listed_feature = page.locator(".umap-browser .datalayer ul > li").nth(0)
|
||||||
|
|
|
@ -101,7 +101,7 @@ def test_can_remove_stroke(live_server, openmap, page, bootstrap):
|
||||||
page.get_by_role("link", name="Toggle edit mode").click()
|
page.get_by_role("link", name="Toggle edit mode").click()
|
||||||
page.get_by_text("Shape properties").click()
|
page.get_by_text("Shape properties").click()
|
||||||
page.locator(".umap-field-stroke .define").first.click()
|
page.locator(".umap-field-stroke .define").first.click()
|
||||||
page.locator(".umap-field-stroke label").first.click()
|
page.locator(".umap-field-stroke .show-on-defined label").first.click()
|
||||||
expect(page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']")).to_have_count(
|
expect(page.locator(".leaflet-overlay-pane path[stroke='DarkBlue']")).to_have_count(
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
|
|
|
@ -24,6 +24,7 @@ def test_layers_list_is_updated(live_server, tilelayer, page):
|
||||||
page.get_by_role("button", name="Add a layer").click()
|
page.get_by_role("button", name="Add a layer").click()
|
||||||
page.locator('input[name="name"]').click()
|
page.locator('input[name="name"]').click()
|
||||||
page.locator('input[name="name"]').fill("foobar")
|
page.locator('input[name="name"]').fill("foobar")
|
||||||
|
page.wait_for_timeout(300) # Time for the input debounce.
|
||||||
page.get_by_role("link", name=f"Import data ({modifier}+I)").click()
|
page.get_by_role("link", name=f"Import data ({modifier}+I)").click()
|
||||||
# Should still work
|
# Should still work
|
||||||
page.locator("[name=layer-id]").select_option(label="Import in a new layer")
|
page.locator("[name=layer-id]").select_option(label="Import in a new layer")
|
||||||
|
|
|
@ -285,6 +285,7 @@ def test_should_display_alert_on_conflict(context, live_server, datalayer, openm
|
||||||
# Change name on page one and save
|
# Change name on page one and save
|
||||||
page_one.locator(".leaflet-marker-icon").click(modifiers=["Shift"])
|
page_one.locator(".leaflet-marker-icon").click(modifiers=["Shift"])
|
||||||
page_one.locator('input[name="name"]').fill("name from page one")
|
page_one.locator('input[name="name"]').fill("name from page one")
|
||||||
|
page_one.wait_for_timeout(300) # Time for the input debounce.
|
||||||
with page_one.expect_response(re.compile(r".*/datalayer/update/.*")):
|
with page_one.expect_response(re.compile(r".*/datalayer/update/.*")):
|
||||||
page_one.get_by_role("button", name="Save").click()
|
page_one.get_by_role("button", name="Save").click()
|
||||||
|
|
||||||
|
|
|
@ -57,7 +57,7 @@ def test_can_change_picto_at_map_level(openmap, live_server, page, pictos):
|
||||||
define.click()
|
define.click()
|
||||||
# No picto defined yet, so recent should not be visible
|
# No picto defined yet, so recent should not be visible
|
||||||
expect(page.get_by_text("Recent")).to_be_hidden()
|
expect(page.get_by_text("Recent")).to_be_hidden()
|
||||||
symbols = page.locator(".umap-pictogram-choice")
|
symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice")
|
||||||
expect(symbols).to_have_count(2)
|
expect(symbols).to_have_count(2)
|
||||||
search = page.locator(".umap-pictogram-body input")
|
search = page.locator(".umap-pictogram-body input")
|
||||||
search.type("star")
|
search.type("star")
|
||||||
|
@ -90,8 +90,8 @@ def test_can_change_picto_at_datalayer_level(openmap, live_server, page, pictos)
|
||||||
expect(define).to_be_visible()
|
expect(define).to_be_visible()
|
||||||
expect(undefine).to_be_hidden()
|
expect(undefine).to_be_hidden()
|
||||||
define.click()
|
define.click()
|
||||||
# Map has an icon defined, so it shold open on Recent tab
|
# Map has an icon defined, so it should open on Recent tab
|
||||||
symbols = page.locator(".umap-pictogram-choice")
|
symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice")
|
||||||
expect(page.get_by_text("Recent")).to_be_visible()
|
expect(page.get_by_text("Recent")).to_be_visible()
|
||||||
expect(symbols).to_have_count(1)
|
expect(symbols).to_have_count(1)
|
||||||
symbol_tab = page.get_by_role("button", name="Symbol")
|
symbol_tab = page.get_by_role("button", name="Symbol")
|
||||||
|
@ -128,8 +128,8 @@ def test_can_change_picto_at_marker_level(openmap, live_server, page, pictos):
|
||||||
expect(define).to_be_visible()
|
expect(define).to_be_visible()
|
||||||
expect(undefine).to_be_hidden()
|
expect(undefine).to_be_hidden()
|
||||||
define.click()
|
define.click()
|
||||||
# Map has an icon defined, so it shold open on Recent tab
|
# Map has an icon defined, so it shuold open on Recent tab
|
||||||
symbols = page.locator(".umap-pictogram-choice")
|
symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice")
|
||||||
expect(page.get_by_text("Recent")).to_be_visible()
|
expect(page.get_by_text("Recent")).to_be_visible()
|
||||||
expect(symbols).to_have_count(1)
|
expect(symbols).to_have_count(1)
|
||||||
symbol_tab = page.get_by_role("button", name="Symbol")
|
symbol_tab = page.get_by_role("button", name="Symbol")
|
||||||
|
@ -180,7 +180,7 @@ def test_can_use_remote_url_as_picto(openmap, live_server, page, pictos):
|
||||||
expect(modify).to_be_visible()
|
expect(modify).to_be_visible()
|
||||||
modify.click()
|
modify.click()
|
||||||
# Should be on Recent tab
|
# Should be on Recent tab
|
||||||
symbols = page.locator(".umap-pictogram-choice")
|
symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice")
|
||||||
expect(page.get_by_text("Recent")).to_be_visible()
|
expect(page.get_by_text("Recent")).to_be_visible()
|
||||||
expect(symbols).to_have_count(1)
|
expect(symbols).to_have_count(1)
|
||||||
|
|
||||||
|
@ -215,10 +215,10 @@ def test_can_use_char_as_picto(openmap, live_server, page, pictos):
|
||||||
close.click()
|
close.click()
|
||||||
edit_settings.click()
|
edit_settings.click()
|
||||||
shape_settings.click()
|
shape_settings.click()
|
||||||
preview = page.locator(".umap-pictogram-choice")
|
preview = page.locator(".header .umap-pictogram-choice")
|
||||||
expect(preview).to_be_visible()
|
expect(preview).to_be_visible()
|
||||||
preview.click()
|
preview.click()
|
||||||
# Should be on URL tab
|
# Should be on URL tab
|
||||||
symbols = page.locator(".umap-pictogram-choice")
|
symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice")
|
||||||
expect(page.get_by_text("Recent")).to_be_visible()
|
expect(page.get_by_text("Recent")).to_be_visible()
|
||||||
expect(symbols).to_have_count(1)
|
expect(symbols).to_have_count(1)
|
||||||
|
|
|
@ -24,6 +24,7 @@ def test_reseting_map_would_remove_from_save_queue(
|
||||||
page.get_by_role("button", name="Edit", exact=True).click()
|
page.get_by_role("button", name="Edit", exact=True).click()
|
||||||
page.locator('input[name="name"]').click()
|
page.locator('input[name="name"]').click()
|
||||||
page.locator('input[name="name"]').fill("new datalayer name")
|
page.locator('input[name="name"]').fill("new datalayer name")
|
||||||
|
page.wait_for_timeout(300) # Time of the Input debounce
|
||||||
with page.expect_response(re.compile(".*/datalayer/update/.*")):
|
with page.expect_response(re.compile(".*/datalayer/update/.*")):
|
||||||
page.get_by_role("button", name="Save").click()
|
page.get_by_role("button", name="Save").click()
|
||||||
assert len(requests) == 1
|
assert len(requests) == 1
|
||||||
|
|
|
@ -8,20 +8,24 @@ from umap.models import Star
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_star_control_is_visible_if_logged_in(map, live_server, page, login, user):
|
def test_star_button_is_active_if_logged_in(map, live_server, page, login, user):
|
||||||
login(user)
|
login(user)
|
||||||
assert not Star.objects.count()
|
assert not Star.objects.count()
|
||||||
page.goto(f"{live_server.url}{map.get_absolute_url()}")
|
page.goto(f"{live_server.url}{map.get_absolute_url()}")
|
||||||
page.get_by_title("More controls").click()
|
page.get_by_title("About").click()
|
||||||
control = page.locator(".leaflet-control-star")
|
button = page.locator(".icon-star")
|
||||||
expect(control).to_be_visible()
|
expect(button).to_be_visible()
|
||||||
with page.expect_response(re.compile(".*/star/")):
|
with page.expect_response(re.compile(".*/star/")):
|
||||||
control.click()
|
button.click()
|
||||||
|
expect(button).to_be_hidden()
|
||||||
|
# Button has changed
|
||||||
|
expect(page.locator(".icon-starred")).to_be_visible()
|
||||||
assert Star.objects.count() == 1
|
assert Star.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
def test_no_star_control_if_not_logged_in(map, live_server, page):
|
def test_star_button_inctive_if_not_logged_in(map, live_server, page):
|
||||||
page.goto(f"{live_server.url}{map.get_absolute_url()}")
|
page.goto(f"{live_server.url}{map.get_absolute_url()}")
|
||||||
page.get_by_title("More controls").click()
|
page.get_by_title("About").click()
|
||||||
control = page.locator(".leaflet-control-star")
|
button = page.locator(".icon-star")
|
||||||
expect(control).to_be_hidden()
|
button.click()
|
||||||
|
expect(page.get_by_text("You must be logged in")).to_be_visible()
|
||||||
|
|
|
@ -74,6 +74,7 @@ def test_table_editor(live_server, openmap, datalayer, page):
|
||||||
page.locator("dialog").get_by_role("button", name="OK").click()
|
page.locator("dialog").get_by_role("button", name="OK").click()
|
||||||
page.locator("td").nth(2).dblclick()
|
page.locator("td").nth(2).dblclick()
|
||||||
page.locator('input[name="newprop"]').fill("newvalue")
|
page.locator('input[name="newprop"]').fill("newvalue")
|
||||||
|
page.wait_for_timeout(300) # Time for the input debounce.
|
||||||
page.keyboard.press("Enter")
|
page.keyboard.press("Enter")
|
||||||
page.locator("thead button[data-property=name]").click()
|
page.locator("thead button[data-property=name]").click()
|
||||||
page.get_by_role("button", name="Delete this column").click()
|
page.get_by_role("button", name="Delete this column").click()
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import redis
|
||||||
|
from django.conf import settings
|
||||||
from playwright.sync_api import expect
|
from playwright.sync_api import expect
|
||||||
|
|
||||||
from umap.models import DataLayer, Map
|
from umap.models import DataLayer, Map
|
||||||
|
@ -9,11 +11,21 @@ from ..base import DataLayerFactory, MapFactory
|
||||||
|
|
||||||
DATALAYER_UPDATE = re.compile(r".*/datalayer/update/.*")
|
DATALAYER_UPDATE = re.compile(r".*/datalayer/update/.*")
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
# Sync client to prevent headache with pytest / pytest-asyncio and async
|
||||||
|
client = redis.from_url(settings.REDIS_URL)
|
||||||
|
# Make sure there are no dead peers in the Redis hash, otherwise asking for
|
||||||
|
# operations from another peer may never be answered
|
||||||
|
# FIXME this should not happen in an ideal world
|
||||||
|
assert client.connection_pool.connection_kwargs["db"] == 15
|
||||||
|
client.flushdb()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_websocket_connection_can_sync_markers(
|
def test_websocket_connection_can_sync_markers(new_page, asgi_live_server, tilelayer):
|
||||||
new_page, live_server, websocket_server, tilelayer
|
|
||||||
):
|
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
map.save()
|
map.save()
|
||||||
|
@ -21,9 +33,9 @@ def test_websocket_connection_can_sync_markers(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page("Page A")
|
peerA = new_page("Page A")
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page("Page B")
|
peerB = new_page("Page B")
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
a_marker_pane = peerA.locator(".leaflet-marker-pane > div")
|
a_marker_pane = peerA.locator(".leaflet-marker-pane > div")
|
||||||
b_marker_pane = peerB.locator(".leaflet-marker-pane > div")
|
b_marker_pane = peerB.locator(".leaflet-marker-pane > div")
|
||||||
|
@ -44,6 +56,7 @@ def test_websocket_connection_can_sync_markers(
|
||||||
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
|
expect(peerB.get_by_role("button", name="Cancel edits")).to_be_hidden()
|
||||||
peerA.locator("body").type("Synced name")
|
peerA.locator("body").type("Synced name")
|
||||||
peerA.locator("body").press("Escape")
|
peerA.locator("body").press("Escape")
|
||||||
|
peerA.wait_for_timeout(300)
|
||||||
|
|
||||||
peerB.locator(".leaflet-marker-icon").first.click()
|
peerB.locator(".leaflet-marker-icon").first.click()
|
||||||
peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click()
|
peerB.get_by_role("link", name="Toggle edit mode (⇧+Click)").click()
|
||||||
|
@ -79,9 +92,7 @@ def test_websocket_connection_can_sync_markers(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_websocket_connection_can_sync_polygons(
|
def test_websocket_connection_can_sync_polygons(context, asgi_live_server, tilelayer):
|
||||||
context, live_server, websocket_server, tilelayer
|
|
||||||
):
|
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
map.save()
|
map.save()
|
||||||
|
@ -89,9 +100,9 @@ def test_websocket_connection_can_sync_polygons(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = context.new_page()
|
peerA = context.new_page()
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = context.new_page()
|
peerB = context.new_page()
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
b_map_el = peerB.locator("#map")
|
b_map_el = peerB.locator("#map")
|
||||||
|
|
||||||
|
@ -164,7 +175,7 @@ def test_websocket_connection_can_sync_polygons(
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_websocket_connection_can_sync_map_properties(
|
def test_websocket_connection_can_sync_map_properties(
|
||||||
new_page, live_server, websocket_server, tilelayer
|
new_page, asgi_live_server, tilelayer
|
||||||
):
|
):
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
|
@ -173,9 +184,9 @@ def test_websocket_connection_can_sync_map_properties(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page()
|
peerA = new_page()
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page()
|
peerB = new_page()
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
# Name change is synced
|
# Name change is synced
|
||||||
peerA.get_by_role("link", name="Edit map name and caption").click()
|
peerA.get_by_role("link", name="Edit map name and caption").click()
|
||||||
|
@ -187,16 +198,18 @@ def test_websocket_connection_can_sync_map_properties(
|
||||||
# Zoom control is synced
|
# Zoom control is synced
|
||||||
peerB.get_by_role("link", name="Map advanced properties").click()
|
peerB.get_by_role("link", name="Map advanced properties").click()
|
||||||
peerB.locator("summary").filter(has_text="User interface options").click()
|
peerB.locator("summary").filter(has_text="User interface options").click()
|
||||||
peerB.locator("div").filter(
|
switch = peerB.locator("div.formbox").filter(
|
||||||
has_text=re.compile(r"^Display the zoom control")
|
has_text=re.compile("Display the zoom control")
|
||||||
).locator("label").nth(2).click()
|
)
|
||||||
|
expect(switch).to_be_visible()
|
||||||
|
switch.get_by_text("Never").click()
|
||||||
|
|
||||||
expect(peerA.locator(".leaflet-control-zoom")).to_be_hidden()
|
expect(peerA.locator(".leaflet-control-zoom")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_websocket_connection_can_sync_datalayer_properties(
|
def test_websocket_connection_can_sync_datalayer_properties(
|
||||||
new_page, live_server, websocket_server, tilelayer
|
new_page, asgi_live_server, tilelayer
|
||||||
):
|
):
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
|
@ -205,9 +218,9 @@ def test_websocket_connection_can_sync_datalayer_properties(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page()
|
peerA = new_page()
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page()
|
peerB = new_page()
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
# Layer addition, name and type are synced
|
# Layer addition, name and type are synced
|
||||||
peerA.get_by_role("link", name="Manage layers").click()
|
peerA.get_by_role("link", name="Manage layers").click()
|
||||||
|
@ -225,7 +238,7 @@ def test_websocket_connection_can_sync_datalayer_properties(
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_websocket_connection_can_sync_cloned_polygons(
|
def test_websocket_connection_can_sync_cloned_polygons(
|
||||||
context, live_server, websocket_server, tilelayer
|
context, asgi_live_server, tilelayer
|
||||||
):
|
):
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
|
@ -234,9 +247,9 @@ def test_websocket_connection_can_sync_cloned_polygons(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = context.new_page()
|
peerA = context.new_page()
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = context.new_page()
|
peerB = context.new_page()
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
b_map_el = peerB.locator("#map")
|
b_map_el = peerB.locator("#map")
|
||||||
|
|
||||||
|
@ -278,7 +291,7 @@ def test_websocket_connection_can_sync_cloned_polygons(
|
||||||
peerB.locator("path").nth(1).drag_to(b_map_el, target_position={"x": 400, "y": 400})
|
peerB.locator("path").nth(1).drag_to(b_map_el, target_position={"x": 400, "y": 400})
|
||||||
peerB.locator("path").nth(1).click()
|
peerB.locator("path").nth(1).click()
|
||||||
peerB.locator("summary").filter(has_text="Shape properties").click()
|
peerB.locator("summary").filter(has_text="Shape properties").click()
|
||||||
peerB.locator(".header > a:nth-child(2)").first.click()
|
peerB.locator(".umap-field-color button.define").first.click()
|
||||||
peerB.get_by_title("Orchid", exact=True).first.click()
|
peerB.get_by_title("Orchid", exact=True).first.click()
|
||||||
peerB.locator("#map").press("Escape")
|
peerB.locator("#map").press("Escape")
|
||||||
peerB.get_by_role("button", name="Save").click()
|
peerB.get_by_role("button", name="Save").click()
|
||||||
|
@ -288,7 +301,7 @@ def test_websocket_connection_can_sync_cloned_polygons(
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_websocket_connection_can_sync_late_joining_peer(
|
def test_websocket_connection_can_sync_late_joining_peer(
|
||||||
new_page, live_server, websocket_server, tilelayer
|
new_page, asgi_live_server, tilelayer
|
||||||
):
|
):
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
|
@ -297,7 +310,7 @@ def test_websocket_connection_can_sync_late_joining_peer(
|
||||||
|
|
||||||
# Create first peer (A) and have it join immediately
|
# Create first peer (A) and have it join immediately
|
||||||
peerA = new_page("Page A")
|
peerA = new_page("Page A")
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
# Add a marker from peer A
|
# Add a marker from peer A
|
||||||
a_create_marker = peerA.get_by_title("Draw a marker")
|
a_create_marker = peerA.get_by_title("Draw a marker")
|
||||||
|
@ -308,6 +321,7 @@ def test_websocket_connection_can_sync_late_joining_peer(
|
||||||
a_map_el.click(position={"x": 220, "y": 220})
|
a_map_el.click(position={"x": 220, "y": 220})
|
||||||
peerA.locator("body").type("First marker")
|
peerA.locator("body").type("First marker")
|
||||||
peerA.locator("body").press("Escape")
|
peerA.locator("body").press("Escape")
|
||||||
|
peerA.wait_for_timeout(300)
|
||||||
|
|
||||||
# Add a polygon from peer A
|
# Add a polygon from peer A
|
||||||
create_polygon = peerA.locator(".leaflet-control-toolbar ").get_by_title(
|
create_polygon = peerA.locator(".leaflet-control-toolbar ").get_by_title(
|
||||||
|
@ -324,7 +338,7 @@ def test_websocket_connection_can_sync_late_joining_peer(
|
||||||
|
|
||||||
# Now create peer B and have it join
|
# Now create peer B and have it join
|
||||||
peerB = new_page("Page B")
|
peerB = new_page("Page B")
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
# Check if peer B has received all the updates
|
# Check if peer B has received all the updates
|
||||||
b_marker_pane = peerB.locator(".leaflet-marker-pane > div")
|
b_marker_pane = peerB.locator(".leaflet-marker-pane > div")
|
||||||
|
@ -349,7 +363,7 @@ def test_websocket_connection_can_sync_late_joining_peer(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_should_sync_datalayers(new_page, live_server, websocket_server, tilelayer):
|
def test_should_sync_datalayers(new_page, asgi_live_server, tilelayer):
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
map.save()
|
map.save()
|
||||||
|
@ -358,9 +372,9 @@ def test_should_sync_datalayers(new_page, live_server, websocket_server, tilelay
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page("Page A")
|
peerA = new_page("Page A")
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page("Page B")
|
peerB = new_page("Page B")
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
# Create a new layer from peerA
|
# Create a new layer from peerA
|
||||||
peerA.get_by_role("link", name="Manage layers").click()
|
peerA.get_by_role("link", name="Manage layers").click()
|
||||||
|
@ -421,9 +435,7 @@ def test_should_sync_datalayers(new_page, live_server, websocket_server, tilelay
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_should_sync_datalayers_delete(
|
def test_should_sync_datalayers_delete(new_page, asgi_live_server, tilelayer):
|
||||||
new_page, live_server, websocket_server, tilelayer
|
|
||||||
):
|
|
||||||
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
map = MapFactory(name="sync", edit_status=Map.ANONYMOUS)
|
||||||
map.settings["properties"]["syncEnabled"] = True
|
map.settings["properties"]["syncEnabled"] = True
|
||||||
map.save()
|
map.save()
|
||||||
|
@ -462,9 +474,9 @@ def test_should_sync_datalayers_delete(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page("Page A")
|
peerA = new_page("Page A")
|
||||||
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page("Page B")
|
peerB = new_page("Page B")
|
||||||
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
peerA.get_by_role("button", name="Open browser").click()
|
peerA.get_by_role("button", name="Open browser").click()
|
||||||
expect(peerA.get_by_text("datalayer 1")).to_be_visible()
|
expect(peerA.get_by_text("datalayer 1")).to_be_visible()
|
||||||
|
@ -487,12 +499,10 @@ def test_should_sync_datalayers_delete(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_create_and_sync_map(
|
def test_create_and_sync_map(new_page, asgi_live_server, tilelayer, login, user):
|
||||||
new_page, live_server, websocket_server, tilelayer, login, user
|
|
||||||
):
|
|
||||||
# Create a syncable map with peerA
|
# Create a syncable map with peerA
|
||||||
peerA = login(user, prefix="Page A")
|
peerA = login(user, prefix="Page A")
|
||||||
peerA.goto(f"{live_server.url}/en/map/new/")
|
peerA.goto(f"{asgi_live_server.url}/en/map/new/")
|
||||||
with peerA.expect_response(re.compile("./map/create/.*")):
|
with peerA.expect_response(re.compile("./map/create/.*")):
|
||||||
peerA.get_by_role("button", name="Save Draft").click()
|
peerA.get_by_role("button", name="Save Draft").click()
|
||||||
peerA.get_by_role("link", name="Map advanced properties").click()
|
peerA.get_by_role("link", name="Map advanced properties").click()
|
||||||
|
|
|
@ -29,3 +29,5 @@ PASSWORD_HASHERS = [
|
||||||
WEBSOCKET_ENABLED = True
|
WEBSOCKET_ENABLED = True
|
||||||
WEBSOCKET_BACK_PORT = "8010"
|
WEBSOCKET_BACK_PORT = "8010"
|
||||||
WEBSOCKET_FRONT_URI = "ws://localhost:8010"
|
WEBSOCKET_FRONT_URI = "ws://localhost:8010"
|
||||||
|
|
||||||
|
REDIS_URL = "redis://localhost:6379/15"
|
||||||
|
|
|
@ -289,5 +289,5 @@ def test_should_remove_all_versions_on_delete(map, settings):
|
||||||
datalayer.geojson.storage.save(root / f"{path}.gz", ContentFile("{}"))
|
datalayer.geojson.storage.save(root / f"{path}.gz", ContentFile("{}"))
|
||||||
assert len(datalayer.geojson.storage.listdir(root)[1]) == 10 + before
|
assert len(datalayer.geojson.storage.listdir(root)[1]) == 10 + before
|
||||||
datalayer.delete()
|
datalayer.delete()
|
||||||
found = datalayer.geojson.storage.listdir(root)[1]
|
found = set(datalayer.geojson.storage.listdir(root)[1])
|
||||||
assert found == [other, f"{other}.gz"]
|
assert found == {other, f"{other}.gz"}
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
from umap.websocket_server import OperationMessage, PeerMessage, Request, ServerRequest
|
|
||||||
|
|
||||||
|
|
||||||
def test_messages_are_parsed_correctly():
|
|
||||||
server = Request.model_validate(dict(kind="Server", action="list-peers")).root
|
|
||||||
assert type(server) is ServerRequest
|
|
||||||
|
|
||||||
operation = Request.model_validate(
|
|
||||||
dict(
|
|
||||||
kind="OperationMessage",
|
|
||||||
verb="upsert",
|
|
||||||
subject="map",
|
|
||||||
metadata={},
|
|
||||||
key="key",
|
|
||||||
)
|
|
||||||
).root
|
|
||||||
assert type(operation) is OperationMessage
|
|
||||||
|
|
||||||
peer_message = Request.model_validate(
|
|
||||||
dict(kind="PeerMessage", sender="Alice", recipient="Bob", message={})
|
|
||||||
).root
|
|
||||||
assert type(peer_message) is PeerMessage
|
|
|
@ -7,23 +7,36 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import URLPattern, URLResolver, get_resolver
|
from django.urls import URLPattern, URLResolver, get_resolver
|
||||||
|
|
||||||
|
|
||||||
def _urls_for_js(urls=None):
|
def _get_url_names(module):
|
||||||
|
def _get_names(resolver):
|
||||||
|
names = []
|
||||||
|
for pattern in resolver.url_patterns:
|
||||||
|
if getattr(pattern, "url_patterns", None):
|
||||||
|
# Do not add "admin" and other third party apps urls.
|
||||||
|
if not pattern.namespace:
|
||||||
|
names.extend(_get_names(pattern))
|
||||||
|
elif getattr(pattern, "name", None):
|
||||||
|
names.append(pattern.name)
|
||||||
|
return names
|
||||||
|
|
||||||
|
return _get_names(get_resolver(module))
|
||||||
|
|
||||||
|
|
||||||
|
def _urls_for_js():
|
||||||
"""
|
"""
|
||||||
Return templated URLs prepared for javascript.
|
Return templated URLs prepared for javascript.
|
||||||
"""
|
"""
|
||||||
if urls is None:
|
urls = {}
|
||||||
# prevent circular import
|
for module in ["umap.urls", "umap.sync.app"]:
|
||||||
from .urls import i18n_urls, urlpatterns
|
names = _get_url_names(module)
|
||||||
|
urls.update(
|
||||||
urls = [
|
dict(zip(names, [get_uri_template(url, module=module) for url in names]))
|
||||||
url.name for url in urlpatterns + i18n_urls if getattr(url, "name", None)
|
)
|
||||||
]
|
|
||||||
urls = dict(zip(urls, [get_uri_template(url) for url in urls]))
|
|
||||||
urls.update(getattr(settings, "UMAP_EXTRA_URLS", {}))
|
urls.update(getattr(settings, "UMAP_EXTRA_URLS", {}))
|
||||||
return urls
|
return urls
|
||||||
|
|
||||||
|
|
||||||
def get_uri_template(urlname, args=None, prefix=""):
|
def get_uri_template(urlname, args=None, prefix="", module=None):
|
||||||
"""
|
"""
|
||||||
Utility function to return an URI Template from a named URL in django
|
Utility function to return an URI Template from a named URL in django
|
||||||
Copied from django-digitalpaper.
|
Copied from django-digitalpaper.
|
||||||
|
@ -45,7 +58,7 @@ def get_uri_template(urlname, args=None, prefix=""):
|
||||||
paths = template % dict([p, "{%s}" % p] for p in args)
|
paths = template % dict([p, "{%s}" % p] for p in args)
|
||||||
return "%s/%s" % (prefix, paths)
|
return "%s/%s" % (prefix, paths)
|
||||||
|
|
||||||
resolver = get_resolver(None)
|
resolver = get_resolver(module)
|
||||||
parts = urlname.split(":")
|
parts = urlname.split(":")
|
||||||
if len(parts) > 1 and parts[0] in resolver.namespace_dict:
|
if len(parts) > 1 and parts[0] in resolver.namespace_dict:
|
||||||
namespace = parts[0]
|
namespace = parts[0]
|
||||||
|
|
|
@ -605,11 +605,11 @@ class MapDetailMixin(SessionMixin):
|
||||||
"schema": Map.extra_schema,
|
"schema": Map.extra_schema,
|
||||||
"id": self.get_id(),
|
"id": self.get_id(),
|
||||||
"starred": self.is_starred(),
|
"starred": self.is_starred(),
|
||||||
|
"stars": self.stars(),
|
||||||
"licences": dict((l.name, l.json) for l in Licence.objects.all()),
|
"licences": dict((l.name, l.json) for l in Licence.objects.all()),
|
||||||
"umap_version": VERSION,
|
"umap_version": VERSION,
|
||||||
"featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS,
|
"featuresHaveOwner": settings.UMAP_DEFAULT_FEATURES_HAVE_OWNERS,
|
||||||
"websocketEnabled": settings.WEBSOCKET_ENABLED,
|
"websocketEnabled": settings.WEBSOCKET_ENABLED,
|
||||||
"websocketURI": settings.WEBSOCKET_FRONT_URI,
|
|
||||||
"importers": settings.UMAP_IMPORTERS,
|
"importers": settings.UMAP_IMPORTERS,
|
||||||
"defaultLabelKeys": settings.UMAP_LABEL_KEYS,
|
"defaultLabelKeys": settings.UMAP_LABEL_KEYS,
|
||||||
}
|
}
|
||||||
|
@ -678,6 +678,9 @@ class MapDetailMixin(SessionMixin):
|
||||||
def is_starred(self):
|
def is_starred(self):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def stars(self):
|
||||||
|
return 0
|
||||||
|
|
||||||
def get_geojson(self):
|
def get_geojson(self):
|
||||||
return {
|
return {
|
||||||
"geometry": {
|
"geometry": {
|
||||||
|
@ -780,6 +783,9 @@ class MapView(MapDetailMixin, PermissionsMixin, DetailView):
|
||||||
return False
|
return False
|
||||||
return Star.objects.filter(by=user, map=self.object).exists()
|
return Star.objects.filter(by=user, map=self.object).exists()
|
||||||
|
|
||||||
|
def stars(self):
|
||||||
|
return Star.objects.filter(map=self.object).count()
|
||||||
|
|
||||||
|
|
||||||
class MapDownload(DetailView):
|
class MapDownload(DetailView):
|
||||||
model = Map
|
model = Map
|
||||||
|
@ -1081,7 +1087,9 @@ class ToggleMapStarStatus(View):
|
||||||
else:
|
else:
|
||||||
Star.objects.create(map=map_inst, by=self.request.user)
|
Star.objects.create(map=map_inst, by=self.request.user)
|
||||||
status = True
|
status = True
|
||||||
return simple_json_response(starred=status)
|
return simple_json_response(
|
||||||
|
starred=status, stars=Star.objects.filter(map=map_inst).count()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MapShortUrl(RedirectView):
|
class MapShortUrl(RedirectView):
|
||||||
|
|
|
@ -1,202 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from collections import defaultdict
|
|
||||||
from typing import Literal, Optional, Union
|
|
||||||
|
|
||||||
import websockets
|
|
||||||
from django.conf import settings
|
|
||||||
from django.core.signing import TimestampSigner
|
|
||||||
from pydantic import BaseModel, Field, RootModel, ValidationError
|
|
||||||
from websockets import WebSocketClientProtocol
|
|
||||||
from websockets.server import serve
|
|
||||||
|
|
||||||
|
|
||||||
class Connections:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._connections: set[WebSocketClientProtocol] = set()
|
|
||||||
self._ids: dict[WebSocketClientProtocol, str] = dict()
|
|
||||||
|
|
||||||
def join(self, websocket: WebSocketClientProtocol) -> str:
|
|
||||||
self._connections.add(websocket)
|
|
||||||
_id = str(uuid.uuid4())
|
|
||||||
self._ids[websocket] = _id
|
|
||||||
return _id
|
|
||||||
|
|
||||||
def leave(self, websocket: WebSocketClientProtocol) -> None:
|
|
||||||
self._connections.remove(websocket)
|
|
||||||
del self._ids[websocket]
|
|
||||||
|
|
||||||
def get(self, id) -> WebSocketClientProtocol:
|
|
||||||
# use an iterator to stop iterating as soon as we found
|
|
||||||
return next(k for k, v in self._ids.items() if v == id)
|
|
||||||
|
|
||||||
def get_id(self, websocket: WebSocketClientProtocol):
|
|
||||||
return self._ids[websocket]
|
|
||||||
|
|
||||||
def get_other_peers(
|
|
||||||
self, websocket: WebSocketClientProtocol
|
|
||||||
) -> set[WebSocketClientProtocol]:
|
|
||||||
return self._connections - {websocket}
|
|
||||||
|
|
||||||
def get_all_peers(self) -> set[WebSocketClientProtocol]:
|
|
||||||
return self._connections
|
|
||||||
|
|
||||||
|
|
||||||
# Contains the list of websocket connections handled by this process.
|
|
||||||
# It's a mapping of map_id to a set of the active websocket connections
|
|
||||||
CONNECTIONS: defaultdict[int, Connections] = defaultdict(Connections)
|
|
||||||
|
|
||||||
|
|
||||||
class JoinRequest(BaseModel):
|
|
||||||
kind: Literal["JoinRequest"] = "JoinRequest"
|
|
||||||
token: str
|
|
||||||
|
|
||||||
|
|
||||||
class OperationMessage(BaseModel):
|
|
||||||
"""Message sent from one peer to all the others"""
|
|
||||||
|
|
||||||
kind: Literal["OperationMessage"] = "OperationMessage"
|
|
||||||
verb: Literal["upsert", "update", "delete"]
|
|
||||||
subject: Literal["map", "datalayer", "feature"]
|
|
||||||
metadata: Optional[dict] = None
|
|
||||||
key: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class PeerMessage(BaseModel):
|
|
||||||
"""Message sent from a specific peer to another one"""
|
|
||||||
|
|
||||||
kind: Literal["PeerMessage"] = "PeerMessage"
|
|
||||||
sender: str
|
|
||||||
recipient: str
|
|
||||||
# The message can be whatever the peers want. It's not checked by the server.
|
|
||||||
message: dict
|
|
||||||
|
|
||||||
|
|
||||||
class ServerRequest(BaseModel):
|
|
||||||
"""A request towards the server"""
|
|
||||||
|
|
||||||
kind: Literal["Server"] = "Server"
|
|
||||||
action: Literal["list-peers"]
|
|
||||||
|
|
||||||
|
|
||||||
class Request(RootModel):
|
|
||||||
"""Any message coming from the websocket should be one of these, and will be rejected otherwise."""
|
|
||||||
|
|
||||||
root: Union[ServerRequest, PeerMessage, OperationMessage] = Field(
|
|
||||||
discriminator="kind"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JoinResponse(BaseModel):
|
|
||||||
"""Server response containing the list of peers"""
|
|
||||||
|
|
||||||
kind: Literal["JoinResponse"] = "JoinResponse"
|
|
||||||
peers: list
|
|
||||||
uuid: str
|
|
||||||
|
|
||||||
|
|
||||||
class ListPeersResponse(BaseModel):
|
|
||||||
kind: Literal["ListPeersResponse"] = "ListPeersResponse"
|
|
||||||
peers: list
|
|
||||||
|
|
||||||
|
|
||||||
async def join_and_listen(
|
|
||||||
map_id: int, permissions: list, user: str | int, websocket: WebSocketClientProtocol
|
|
||||||
):
|
|
||||||
"""Join a "room" with other connected peers, and wait for messages."""
|
|
||||||
logging.debug(f"{user} joined room #{map_id}")
|
|
||||||
connections: Connections = CONNECTIONS[map_id]
|
|
||||||
_id: str = connections.join(websocket)
|
|
||||||
|
|
||||||
# Assign an ID to the joining peer and return it the list of connected peers.
|
|
||||||
peers: list[WebSocketClientProtocol] = [
|
|
||||||
connections.get_id(p) for p in connections.get_all_peers()
|
|
||||||
]
|
|
||||||
response = JoinResponse(uuid=_id, peers=peers)
|
|
||||||
await websocket.send(response.model_dump_json())
|
|
||||||
|
|
||||||
# Notify all other peers of the new list of connected peers.
|
|
||||||
message = ListPeersResponse(peers=peers)
|
|
||||||
websockets.broadcast(
|
|
||||||
connections.get_other_peers(websocket), message.model_dump_json()
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for raw_message in websocket:
|
|
||||||
if raw_message == "ping":
|
|
||||||
await websocket.send("pong")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# recompute the peers list at the time of message-sending.
|
|
||||||
# as doing so beforehand would miss new connections
|
|
||||||
other_peers = connections.get_other_peers(websocket)
|
|
||||||
try:
|
|
||||||
incoming = Request.model_validate_json(raw_message)
|
|
||||||
except ValidationError as e:
|
|
||||||
error = f"An error occurred when receiving the following message: {raw_message!r}"
|
|
||||||
logging.error(error, e)
|
|
||||||
else:
|
|
||||||
match incoming.root:
|
|
||||||
# Broadcast all operation messages to connected peers
|
|
||||||
case OperationMessage():
|
|
||||||
websockets.broadcast(other_peers, raw_message)
|
|
||||||
|
|
||||||
# Send peer messages to the proper peer
|
|
||||||
case PeerMessage(recipient=_id):
|
|
||||||
peer = connections.get(_id)
|
|
||||||
if peer:
|
|
||||||
await peer.send(raw_message)
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# On disconnect, remove the connection from the pool
|
|
||||||
connections.leave(websocket)
|
|
||||||
|
|
||||||
# TODO: refactor this in a separate method.
|
|
||||||
# Notify all other peers of the new list of connected peers.
|
|
||||||
peers = [connections.get_id(p) for p in connections.get_all_peers()]
|
|
||||||
message = ListPeersResponse(peers=peers)
|
|
||||||
websockets.broadcast(
|
|
||||||
connections.get_other_peers(websocket), message.model_dump_json()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def handler(websocket: WebSocketClientProtocol):
|
|
||||||
"""Main WebSocket handler.
|
|
||||||
|
|
||||||
Check if the permission is granted and let the peer enter a room.
|
|
||||||
"""
|
|
||||||
raw_message = await websocket.recv()
|
|
||||||
|
|
||||||
# The first event should always be 'join'
|
|
||||||
message: JoinRequest = JoinRequest.model_validate_json(raw_message)
|
|
||||||
signed = TimestampSigner().unsign_object(message.token, max_age=30)
|
|
||||||
user, map_id, permissions = signed.values()
|
|
||||||
|
|
||||||
# Check if permissions for this map have been granted by the server
|
|
||||||
if "edit" in signed["permissions"]:
|
|
||||||
await join_and_listen(map_id, permissions, user, websocket)
|
|
||||||
|
|
||||||
|
|
||||||
def run(host: str, port: int):
|
|
||||||
if not settings.WEBSOCKET_ENABLED:
|
|
||||||
msg = (
|
|
||||||
"WEBSOCKET_ENABLED should be set to True to run the WebSocket Server. "
|
|
||||||
"See the documentation at "
|
|
||||||
"https://docs.umap-project.org/en/stable/config/settings/#websocket_enabled "
|
|
||||||
"for more information."
|
|
||||||
)
|
|
||||||
print(msg)
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
async def _serve():
|
|
||||||
async with serve(handler, host, port):
|
|
||||||
logging.debug(f"Waiting for connections on {host}:{port}")
|
|
||||||
await asyncio.Future() # run forever
|
|
||||||
|
|
||||||
try:
|
|
||||||
asyncio.run(_serve())
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("Closing WebSocket server")
|
|