Compare commits
1 commit
90f163fe1b
...
addabbddc3
Author | SHA1 | Date | |
---|---|---|---|
![]() |
addabbddc3 |
8
.github/workflows/test-docs.yml
vendored
|
@ -20,11 +20,7 @@ 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:
|
||||||
|
@ -52,8 +48,6 @@ 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: {{ include "umap.fullname" . }}-env
|
name: {{ .Release.Name }}-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: {{ include "umap.fullname" . }}-config
|
secretName: {{ .Release.Name }}-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.14.1
|
pymdown-extensions==10.13
|
||||||
mkdocs-material==9.5.50
|
mkdocs-material==9.5.49
|
||||||
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.14.1
|
pymdown-extensions==10.13
|
||||||
mkdocs-material==9.5.50
|
mkdocs-material==9.5.49
|
||||||
mkdocs-static-i18n==1.2.3
|
mkdocs-static-i18n==1.2.3
|
||||||
|
|
|
@ -47,6 +47,7 @@
|
||||||
"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.5",
|
"Django==5.1.4",
|
||||||
"django-agnocomplete==2.2.0",
|
"django-agnocomplete==2.2.0",
|
||||||
"django-environ==0.12.0",
|
"django-environ==0.11.2",
|
||||||
"django-probes==1.7.0",
|
"django-probes==1.7.0",
|
||||||
"Pillow==11.1.0",
|
"Pillow==11.0.0",
|
||||||
"psycopg==3.2.4",
|
"psycopg==3.2.3",
|
||||||
"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,17 +44,16 @@ dependencies = [
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"hatch==1.14.0",
|
"hatch==1.14.0",
|
||||||
"ruff==0.9.2",
|
"ruff==0.8.4",
|
||||||
"djlint==1.36.4",
|
"djlint==1.36.4",
|
||||||
"mkdocs==1.6.1",
|
"mkdocs==1.6.1",
|
||||||
"mkdocs-material==9.5.50",
|
"mkdocs-material==9.5.49",
|
||||||
"mkdocs-static-i18n==1.2.3",
|
"mkdocs-static-i18n==1.2.3",
|
||||||
"vermin==1.6.0",
|
"vermin==1.6.0",
|
||||||
"pymdown-extensions==10.14.1",
|
"pymdown-extensions==10.13",
|
||||||
"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",
|
||||||
|
@ -62,7 +61,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.27"
|
"moto[s3]==5.0.25"
|
||||||
]
|
]
|
||||||
docker = [
|
docker = [
|
||||||
"uwsgi==2.0.28",
|
"uwsgi==2.0.28",
|
||||||
|
@ -71,8 +70,10 @@ s3 = [
|
||||||
"django-storages[s3]==1.14.4",
|
"django-storages[s3]==1.14.4",
|
||||||
]
|
]
|
||||||
sync = [
|
sync = [
|
||||||
"pydantic==2.10.6",
|
"channels==4.2.0",
|
||||||
"redis==5.2.1",
|
"daphne==4.1.2",
|
||||||
|
"pydantic==2.10.4",
|
||||||
|
"websockets==13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
@ -103,6 +104,3 @@ 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,6 +17,7 @@ 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,20 +1,15 @@
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "umap.settings")
|
from channels.routing import ProtocolTypeRouter
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
from .sync.app import application as ws_application
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "umap.settings")
|
||||||
|
|
||||||
# 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):
|
{
|
||||||
if scope["type"] == "http":
|
"http": django_asgi_app,
|
||||||
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']}")
|
|
||||||
|
|
23
umap/management/commands/run_websocket_server.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
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,5 +342,4 @@ 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: 1.4;
|
line-height: 21px;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
BIN
umap/static/umap/bitbucket.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
|
@ -44,11 +44,31 @@ body.login header {
|
||||||
.login-grid span,
|
.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,6 +1,7 @@
|
||||||
.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;
|
||||||
|
@ -10,7 +11,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaflet-container [type=button].umap-help-link {
|
.leaflet-container [type=button].umap-help-link {
|
||||||
padding: 0 var(--text-margin);
|
padding-bottom: 3px;
|
||||||
background-color: inherit;
|
background-color: inherit;
|
||||||
}
|
}
|
||||||
.leaflet-container .edit-save,
|
.leaflet-container .edit-save,
|
||||||
|
@ -19,6 +20,8 @@
|
||||||
.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;
|
||||||
|
@ -34,6 +37,11 @@
|
||||||
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);
|
||||||
|
@ -112,7 +120,7 @@
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
}
|
}
|
||||||
.umap-right-edit-toolbox {
|
.umap-right-edit-toolbox {
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.umap-main-edit-toolbox .logo {
|
.umap-main-edit-toolbox .logo {
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
.umap-form-inline .formbox,
|
|
||||||
.umap-form-inline {
|
.umap-form-inline {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
@ -76,14 +75,15 @@ select[multiple="multiple"] {
|
||||||
.button,
|
.button,
|
||||||
[type="button"],
|
[type="button"],
|
||||||
input[type="submit"] {
|
input[type="submit"] {
|
||||||
display: flex;
|
display: block;
|
||||||
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: 3px 12px;
|
padding: 7px 14px;
|
||||||
|
min-height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
border: none;
|
border: none;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
@ -131,11 +131,6 @@ 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;
|
||||||
|
@ -386,19 +381,16 @@ 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 .buttons {
|
.inheritable .header,
|
||||||
padding: 0;
|
.inheritable {
|
||||||
|
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;
|
||||||
|
@ -408,11 +400,22 @@ 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 {
|
||||||
|
@ -490,15 +493,12 @@ i.info {
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
.flat-tabs {
|
.flat-tabs {
|
||||||
display: none;
|
display: flex;
|
||||||
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: inline-block;
|
display: block;
|
||||||
color: black;
|
color: black;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
@ -559,6 +559,7 @@ 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;
|
||||||
|
@ -576,11 +577,17 @@ 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;
|
||||||
|
@ -589,10 +596,6 @@ 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: 5px;
|
margin-inline-start: 10px;
|
||||||
margin-inline-end: 5px;
|
|
||||||
}
|
}
|
||||||
html[dir="rtl"] .icon {
|
html[dir="rtl"] .icon {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
|
@ -153,12 +153,6 @@ 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,8 +42,7 @@
|
||||||
padding: var(--panel-gutter);
|
padding: var(--panel-gutter);
|
||||||
}
|
}
|
||||||
.panel h3 {
|
.panel h3 {
|
||||||
display: flex;
|
line-height: 120%;
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
.panel .counter::before {
|
.panel .counter::before {
|
||||||
counter-increment: step;
|
counter-increment: step;
|
||||||
|
|
BIN
umap/static/umap/github.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
|
@ -208,7 +208,5 @@
|
||||||
<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: 43 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 608 B |
Before Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.6 KiB |
Before 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.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">
|
<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">
|
||||||
<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,7 +219,5 @@
|
||||||
<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: 75 KiB After Width: | Height: | Size: 74 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
@ -4,7 +4,6 @@ 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) {
|
||||||
|
@ -180,8 +179,9 @@ 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 Form(this, fields)
|
const builder = new L.FormBuilder(this, fields, {
|
||||||
builder.on('set', () => this.onFormChange())
|
callback: () => 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,8 +189,9 @@ 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 Form(this._umap.facets, fields)
|
filtersBuilder = new L.FormBuilder(this._umap.facets, fields, {
|
||||||
filtersBuilder.on('set', () => this.onFormChange())
|
callback: () => this.onFormChange(),
|
||||||
|
})
|
||||||
DomEvent.on(filtersBuilder.form, 'reset', () => {
|
DomEvent.on(filtersBuilder.form, 'reset', () => {
|
||||||
window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder))
|
window.setTimeout(filtersBuilder.syncAll.bind(filtersBuilder))
|
||||||
})
|
})
|
||||||
|
@ -254,7 +255,6 @@ 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,6 +1,5 @@
|
||||||
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">
|
||||||
|
@ -8,9 +7,8 @@ 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>
|
||||||
<p class="dates" data-ref="dates"></p>
|
<h4 data-ref="author"></h4>
|
||||||
<p data-ref="author"></p>
|
<h5 class="dates" data-ref="dates"></h5>
|
||||||
<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>
|
||||||
|
@ -37,14 +35,6 @@ 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() {
|
||||||
|
@ -72,6 +62,10 @@ 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(),
|
||||||
|
@ -83,11 +77,6 @@ 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,7 +16,6 @@ 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) {
|
||||||
|
@ -213,7 +212,6 @@ class Feature {
|
||||||
if (this._umap.currentFeature === this) {
|
if (this._umap.currentFeature === this) {
|
||||||
this.view()
|
this.view()
|
||||||
}
|
}
|
||||||
this.datalayer.indexProperties(this)
|
|
||||||
}
|
}
|
||||||
this.redraw()
|
this.redraw()
|
||||||
}
|
}
|
||||||
|
@ -227,16 +225,20 @@ class Feature {
|
||||||
`icon-${this.getClassName()}`
|
`icon-${this.getClassName()}`
|
||||||
)
|
)
|
||||||
|
|
||||||
let builder = new MutatingForm(this, [
|
let builder = new U.FormBuilder(
|
||||||
['datalayer', { handler: 'DataLayerSwitcher' }],
|
this,
|
||||||
])
|
[['datalayer', { handler: 'DataLayerSwitcher' }]],
|
||||||
// removeLayer step will close the edit panel, let's reopen it
|
{
|
||||||
builder.on('set', () => this.edit(event))
|
callback() {
|
||||||
|
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.allProperties()) {
|
for (const property of this.datalayer._propertiesIndex) {
|
||||||
if (!labelKeyFound && U.LABEL_KEYS.includes(property)) {
|
if (!labelKeyFound && U.LABEL_KEYS.includes(property)) {
|
||||||
labelKeyFound = property
|
labelKeyFound = property
|
||||||
continue
|
continue
|
||||||
|
@ -252,7 +254,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 MutatingForm(this, properties, {
|
builder = new U.FormBuilder(this, properties, {
|
||||||
id: 'umap-feature-properties',
|
id: 'umap-feature-properties',
|
||||||
})
|
})
|
||||||
container.appendChild(builder.build())
|
container.appendChild(builder.build())
|
||||||
|
@ -283,7 +285,7 @@ class Feature {
|
||||||
|
|
||||||
appendEditFieldsets(container) {
|
appendEditFieldsets(container) {
|
||||||
const optionsFields = this.getShapeOptions()
|
const optionsFields = this.getShapeOptions()
|
||||||
let builder = new MutatingForm(this, optionsFields, {
|
let builder = new U.FormBuilder(this, optionsFields, {
|
||||||
id: 'umap-feature-shape-properties',
|
id: 'umap-feature-shape-properties',
|
||||||
})
|
})
|
||||||
const shapeProperties = DomUtil.createFieldset(
|
const shapeProperties = DomUtil.createFieldset(
|
||||||
|
@ -293,7 +295,7 @@ class Feature {
|
||||||
shapeProperties.appendChild(builder.build())
|
shapeProperties.appendChild(builder.build())
|
||||||
|
|
||||||
const advancedOptions = this.getAdvancedOptions()
|
const advancedOptions = this.getAdvancedOptions()
|
||||||
builder = new MutatingForm(this, advancedOptions, {
|
builder = new U.FormBuilder(this, advancedOptions, {
|
||||||
id: 'umap-feature-advanced-properties',
|
id: 'umap-feature-advanced-properties',
|
||||||
})
|
})
|
||||||
const advancedProperties = DomUtil.createFieldset(
|
const advancedProperties = DomUtil.createFieldset(
|
||||||
|
@ -303,7 +305,7 @@ class Feature {
|
||||||
advancedProperties.appendChild(builder.build())
|
advancedProperties.appendChild(builder.build())
|
||||||
|
|
||||||
const interactionOptions = this.getInteractionOptions()
|
const interactionOptions = this.getInteractionOptions()
|
||||||
builder = new MutatingForm(this, interactionOptions)
|
builder = new U.FormBuilder(this, interactionOptions)
|
||||||
const popupFieldset = DomUtil.createFieldset(
|
const popupFieldset = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Interaction options')
|
translate('Interaction options')
|
||||||
|
@ -731,8 +733,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 MutatingForm(this, coordinatesOptions)
|
const builder = new U.FormBuilder(this, coordinatesOptions, {
|
||||||
builder.on('set', () => {
|
callback: () => {
|
||||||
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')
|
||||||
|
@ -740,6 +742,7 @@ 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,3 +1,5 @@
|
||||||
|
// 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,
|
||||||
|
@ -20,7 +22,6 @@ 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,
|
||||||
|
@ -302,19 +303,6 @@ 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
|
||||||
|
@ -323,12 +311,13 @@ 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)
|
||||||
}
|
}
|
||||||
return await this.getUrl(url).then((raw) => {
|
const response = await this._umap.request.get(url)
|
||||||
|
if (response?.ok) {
|
||||||
this.clear()
|
this.clear()
|
||||||
return this._umap.formatter
|
return this._umap.formatter
|
||||||
.parse(raw, this.options.remoteData.format)
|
.parse(await response.text(), this.options.remoteData.format)
|
||||||
.then((geojson) => this.fromGeoJSON(geojson))
|
.then((geojson) => this.fromGeoJSON(geojson))
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoaded() {
|
isLoaded() {
|
||||||
|
@ -462,7 +451,7 @@ export class DataLayer extends ServerStored {
|
||||||
}
|
}
|
||||||
|
|
||||||
sortFeatures(collection) {
|
sortFeatures(collection) {
|
||||||
const sortKeys = this.getOption('sortKey') || U.DEFAULT_LABEL_KEY
|
const sortKeys = this._umap.getProperty('sortKey') || U.DEFAULT_LABEL_KEY
|
||||||
return Utils.sortFeatures(collection, sortKeys, U.lang)
|
return Utils.sortFeatures(collection, sortKeys, U.lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -553,9 +542,10 @@ export class DataLayer extends ServerStored {
|
||||||
|
|
||||||
async importFromUrl(uri, type) {
|
async importFromUrl(uri, type) {
|
||||||
uri = this._umap.renderUrl(uri)
|
uri = this._umap.renderUrl(uri)
|
||||||
return await this.getUrl(uri).then((raw) => {
|
const response = await this._umap.request.get(uri)
|
||||||
return this.importRaw(raw, type)
|
if (response?.ok) {
|
||||||
})
|
return this.importRaw(await response.text(), type)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getColor() {
|
getColor() {
|
||||||
|
@ -669,7 +659,7 @@ export class DataLayer extends ServerStored {
|
||||||
{
|
{
|
||||||
label: translate('Data is browsable'),
|
label: translate('Data is browsable'),
|
||||||
handler: 'Switch',
|
handler: 'Switch',
|
||||||
helpEntries: ['browsable'],
|
helpEntries: 'browsable',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
@ -681,19 +671,20 @@ export class DataLayer extends ServerStored {
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers')
|
DomUtil.createTitle(container, translate('Layer properties'), 'icon-layers')
|
||||||
let builder = new MutatingForm(this, metadataFields)
|
let builder = new U.FormBuilder(this, metadataFields, {
|
||||||
builder.on('set', ({ detail }) => {
|
callback(e) {
|
||||||
this._umap.onDataLayersChanged()
|
this._umap.onDataLayersChanged()
|
||||||
if (detail.helper.field === 'options.type') {
|
if (e.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 MutatingForm(this, layerOptions, {
|
builder = new U.FormBuilder(this, layerOptions, {
|
||||||
id: 'datalayer-layer-properties',
|
id: 'datalayer-layer-properties',
|
||||||
})
|
})
|
||||||
const layerProperties = DomUtil.createFieldset(
|
const layerProperties = DomUtil.createFieldset(
|
||||||
|
@ -716,7 +707,7 @@ export class DataLayer extends ServerStored {
|
||||||
'options.fillOpacity',
|
'options.fillOpacity',
|
||||||
]
|
]
|
||||||
|
|
||||||
builder = new MutatingForm(this, shapeOptions, {
|
builder = new U.FormBuilder(this, shapeOptions, {
|
||||||
id: 'datalayer-advanced-properties',
|
id: 'datalayer-advanced-properties',
|
||||||
})
|
})
|
||||||
const shapeProperties = DomUtil.createFieldset(
|
const shapeProperties = DomUtil.createFieldset(
|
||||||
|
@ -731,17 +722,11 @@ export class DataLayer extends ServerStored {
|
||||||
'options.zoomTo',
|
'options.zoomTo',
|
||||||
'options.fromZoom',
|
'options.fromZoom',
|
||||||
'options.toZoom',
|
'options.toZoom',
|
||||||
'options.sortKey',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
builder = new MutatingForm(this, optionsFields, {
|
builder = new U.FormBuilder(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')
|
||||||
|
@ -758,7 +743,7 @@ export class DataLayer extends ServerStored {
|
||||||
'options.outlinkTarget',
|
'options.outlinkTarget',
|
||||||
'options.interactive',
|
'options.interactive',
|
||||||
]
|
]
|
||||||
builder = new MutatingForm(this, popupFields)
|
builder = new U.FormBuilder(this, popupFields)
|
||||||
const popupFieldset = DomUtil.createFieldset(
|
const popupFieldset = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Interaction options')
|
translate('Interaction options')
|
||||||
|
@ -814,7 +799,7 @@ export class DataLayer extends ServerStored {
|
||||||
container,
|
container,
|
||||||
translate('Remote data')
|
translate('Remote data')
|
||||||
)
|
)
|
||||||
builder = new MutatingForm(this, remoteDataFields)
|
builder = new U.FormBuilder(this, remoteDataFields)
|
||||||
remoteDataContainer.appendChild(builder.build())
|
remoteDataContainer.appendChild(builder.build())
|
||||||
DomUtil.createButton(
|
DomUtil.createButton(
|
||||||
'button umap-verify',
|
'button umap-verify',
|
||||||
|
|
|
@ -1,241 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -228,11 +228,9 @@ 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,7 +3,6 @@ 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.
|
||||||
|
@ -59,7 +58,7 @@ export class MapPermissions extends ServerStored {
|
||||||
selectOptions: this._umap.properties.share_statuses,
|
selectOptions: this._umap.properties.share_statuses,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
const builder = new MutatingForm(this, fields)
|
const builder = new U.FormBuilder(this, fields)
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
container.appendChild(form)
|
container.appendChild(form)
|
||||||
|
|
||||||
|
@ -134,7 +133,7 @@ export class MapPermissions extends ServerStored {
|
||||||
{ handler: 'ManageEditors', label: translate("Map's editors") },
|
{ handler: 'ManageEditors', label: translate("Map's editors") },
|
||||||
])
|
])
|
||||||
|
|
||||||
const builder = new MutatingForm(this, topFields)
|
const builder = new U.FormBuilder(this, topFields)
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
container.appendChild(form)
|
container.appendChild(form)
|
||||||
if (collaboratorsFields.length) {
|
if (collaboratorsFields.length) {
|
||||||
|
@ -142,7 +141,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 MutatingForm(this, collaboratorsFields)
|
const builder = new U.FormBuilder(this, collaboratorsFields)
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
container.appendChild(form)
|
container.appendChild(form)
|
||||||
}
|
}
|
||||||
|
@ -270,7 +269,7 @@ export class DataLayerPermissions extends ServerStored {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
const builder = new MutatingForm(this, fields, {
|
const builder = new U.FormBuilder(this, fields, {
|
||||||
className: 'umap-form datalayer-permissions',
|
className: 'umap-form datalayer-permissions',
|
||||||
})
|
})
|
||||||
const form = builder.build()
|
const form = builder.build()
|
||||||
|
|
|
@ -70,11 +70,6 @@ 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({
|
||||||
|
@ -91,6 +86,7 @@ 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,11 +88,7 @@ const ClassifiedMixin = {
|
||||||
},
|
},
|
||||||
|
|
||||||
getColorSchemes: function (classes) {
|
getColorSchemes: function (classes) {
|
||||||
const found = this.colorSchemes.filter((scheme) =>
|
return this.colorSchemes.filter((scheme) => Boolean(colorbrewer[scheme][classes]))
|
||||||
Boolean(colorbrewer[scheme][classes])
|
|
||||||
)
|
|
||||||
if (found.length) return found
|
|
||||||
return [['', translate('Default')]]
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -195,7 +191,7 @@ export const Choropleth = FeatureGroup.extend({
|
||||||
'options.choropleth.property',
|
'options.choropleth.property',
|
||||||
{
|
{
|
||||||
handler: 'Select',
|
handler: 'Select',
|
||||||
selectOptions: this.datalayer.allProperties(),
|
selectOptions: this.datalayer._propertiesIndex,
|
||||||
label: translate('Choropleth property value'),
|
label: translate('Choropleth property value'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -304,7 +300,7 @@ export const Circles = FeatureGroup.extend({
|
||||||
'options.circles.property',
|
'options.circles.property',
|
||||||
{
|
{
|
||||||
handler: 'Select',
|
handler: 'Select',
|
||||||
selectOptions: this.datalayer.allProperties(),
|
selectOptions: this.datalayer._propertiesIndex,
|
||||||
label: translate('Property name to compute circles'),
|
label: translate('Property name to compute circles'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -381,7 +377,7 @@ export const Categorized = FeatureGroup.extend({
|
||||||
|
|
||||||
_getValue: function (feature) {
|
_getValue: function (feature) {
|
||||||
const key =
|
const key =
|
||||||
this.datalayer.options.categorized.property || this.datalayer.allProperties()[0]
|
this.datalayer.options.categorized.property || this.datalayer._propertiesIndex[0]
|
||||||
return feature.properties[key]
|
return feature.properties[key]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -424,7 +420,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]
|
||||||
: Utils.COLORS
|
: U.COLORS // Fixme: move COLORS to modules/
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -434,7 +430,7 @@ export const Categorized = FeatureGroup.extend({
|
||||||
'options.categorized.property',
|
'options.categorized.property',
|
||||||
{
|
{
|
||||||
handler: 'Select',
|
handler: 'Select',
|
||||||
selectOptions: this.datalayer.allProperties(),
|
selectOptions: this.datalayer._propertiesIndex,
|
||||||
label: translate('Category property'),
|
label: translate('Category property'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -468,7 +464,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') && field !== 'options.type') return
|
if (!field.startsWith('options.categorized')) 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,6 +32,7 @@ const ControlsMixin = {
|
||||||
'locate',
|
'locate',
|
||||||
'measure',
|
'measure',
|
||||||
'editinosm',
|
'editinosm',
|
||||||
|
'star',
|
||||||
'tilelayers',
|
'tilelayers',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
@ -83,6 +84,7 @@ 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,7 +3,6 @@ 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]
|
||||||
|
|
||||||
|
@ -130,7 +129,7 @@ class Rule {
|
||||||
'options.dashArray',
|
'options.dashArray',
|
||||||
]
|
]
|
||||||
const container = DomUtil.create('div')
|
const container = DomUtil.create('div')
|
||||||
const builder = new MutatingForm(this, options)
|
const builder = new U.FormBuilder(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,6 +478,12 @@ 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,7 +2,6 @@ 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) {
|
||||||
|
@ -126,8 +125,9 @@ export default class Share {
|
||||||
exportUrl.value = window.location.protocol + iframeExporter.buildUrl()
|
exportUrl.value = window.location.protocol + iframeExporter.buildUrl()
|
||||||
}
|
}
|
||||||
buildIframeCode()
|
buildIframeCode()
|
||||||
const builder = new MutatingForm(iframeExporter, UIFields)
|
const builder = new U.FormBuilder(iframeExporter, UIFields, {
|
||||||
builder.on('set', buildIframeCode)
|
callback: buildIframeCode,
|
||||||
|
})
|
||||||
const iframeOptions = DomUtil.createFieldset(
|
const iframeOptions = DomUtil.createFieldset(
|
||||||
this.container,
|
this.container,
|
||||||
translate('Embed and link options')
|
translate('Embed and link options')
|
||||||
|
|
|
@ -62,7 +62,6 @@ 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() {
|
||||||
|
@ -77,14 +76,10 @@ 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(
|
||||||
`${protocol}//${window.location.host}${path}`,
|
this._umap.properties.websocketURI,
|
||||||
authToken,
|
authToken,
|
||||||
this,
|
this
|
||||||
this.peerId,
|
|
||||||
this._umap.properties.user?.name
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,7 +125,7 @@ export class SyncEngine {
|
||||||
|
|
||||||
if (this.offline) return
|
if (this.offline) return
|
||||||
if (this.transport) {
|
if (this.transport) {
|
||||||
this.transport.send('OperationMessage', { sender: this.peerId, ...message })
|
this.transport.send('OperationMessage', message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,7 +142,7 @@ export class SyncEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
getNumberOfConnectedPeers() {
|
getNumberOfConnectedPeers() {
|
||||||
if (this.peers) return Object.keys(this.peers).length
|
if (this.peers) return this.peers.length
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,7 +177,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -194,8 +188,9 @@ 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({ peer, peers }) {
|
onJoinResponse({ uuid, peers }) {
|
||||||
debug('received join response', { peer, peers })
|
debug('received join response', { uuid, peers })
|
||||||
|
this.uuid = uuid
|
||||||
this.onListPeersResponse({ peers })
|
this.onListPeersResponse({ peers })
|
||||||
|
|
||||||
// Get one peer at random
|
// Get one peer at random
|
||||||
|
@ -216,7 +211,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' })
|
||||||
}
|
}
|
||||||
|
@ -291,7 +286,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.peerId,
|
sender: this.uuid,
|
||||||
recipient: recipient,
|
recipient: recipient,
|
||||||
message: payload,
|
message: payload,
|
||||||
})
|
})
|
||||||
|
@ -303,7 +298,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 = Object.keys(this.peers).filter((p) => p !== this.peerId)
|
const otherPeers = this.peers.filter((p) => p !== this.uuid)
|
||||||
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]
|
||||||
|
@ -489,7 +484,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, peerId, username) {
|
constructor(webSocketURI, authToken, messagesReceiver) {
|
||||||
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, peer: peerId, username })
|
this.send('JoinRequest', { token: authToken })
|
||||||
this.receiver.onConnection()
|
this.receiver.onConnection()
|
||||||
}
|
}
|
||||||
this.websocket.addEventListener('message', this.onMessage.bind(this))
|
this.websocket.addEventListener('message', this.onMessage.bind(this))
|
||||||
|
@ -21,10 +21,6 @@ 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()
|
||||||
|
@ -38,7 +34,6 @@ 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(() => {
|
||||||
|
@ -68,7 +63,6 @@ export class WebSocketTransport {
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
console.log('Closing')
|
|
||||||
this.receiver.closeRequested = true
|
this.receiver.closeRequested = true
|
||||||
this.websocket.close()
|
this.websocket.close()
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@ 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>
|
||||||
|
@ -104,7 +103,7 @@ export default class TableEditor extends WithTemplate {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetProperties() {
|
resetProperties() {
|
||||||
this.properties = this.datalayer.allProperties()
|
this.properties = this.datalayer._propertiesIndex
|
||||||
if (this.properties.length === 0) {
|
if (this.properties.length === 0) {
|
||||||
this.properties = [U.DEFAULT_LABEL_KEY, 'description']
|
this.properties = [U.DEFAULT_LABEL_KEY, 'description']
|
||||||
}
|
}
|
||||||
|
@ -206,7 +205,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 MutatingForm(feature, [[field, { handler }]], {
|
const builder = new U.FormBuilder(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 flat" type="button" data-ref="name"></button>
|
<button class="map-name" type="button" data-ref="name"></button>
|
||||||
<button class="share-status flat" type="button" data-ref="share"></button>
|
<button class="share-status" 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 flat" type="button" title="${translate('Help')}" data-ref="help">${translate('Help')}</button>
|
<button class="umap-help-link" 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,7 +34,6 @@ 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) {
|
||||||
|
@ -541,13 +540,7 @@ export default class Umap extends ServerStored {
|
||||||
if (SAVEMANAGER.isDirty) this.saveAll()
|
if (SAVEMANAGER.isDirty) this.saveAll()
|
||||||
break
|
break
|
||||||
case 'z':
|
case 'z':
|
||||||
if (Utils.isWritable(event.target)) {
|
if (SAVEMANAGER.isDirty) this.askForReset()
|
||||||
used = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (SAVEMANAGER.isDirty) {
|
|
||||||
this.askForReset()
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
case 'm':
|
case 'm':
|
||||||
this._leafletMap.editTools.startMarker()
|
this._leafletMap.editTools.startMarker()
|
||||||
|
@ -741,7 +734,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 MutatingForm(this, metadataFields, {
|
const builder = new U.FormBuilder(this, metadataFields, {
|
||||||
className: 'map-metadata',
|
className: 'map-metadata',
|
||||||
umap: this,
|
umap: this,
|
||||||
})
|
})
|
||||||
|
@ -756,7 +749,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.permanentCredit',
|
'properties.permanentCredit',
|
||||||
'properties.permanentCreditBackground',
|
'properties.permanentCreditBackground',
|
||||||
]
|
]
|
||||||
const creditsBuilder = new MutatingForm(this, creditsFields, { umap: this })
|
const creditsBuilder = new U.FormBuilder(this, creditsFields, { umap: this })
|
||||||
credits.appendChild(creditsBuilder.build())
|
credits.appendChild(creditsBuilder.build())
|
||||||
this.editPanel.open({ content: container })
|
this.editPanel.open({ content: container })
|
||||||
}
|
}
|
||||||
|
@ -777,7 +770,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.captionBar',
|
'properties.captionBar',
|
||||||
'properties.captionMenus',
|
'properties.captionMenus',
|
||||||
])
|
])
|
||||||
const builder = new MutatingForm(this, UIFields, { umap: this })
|
const builder = new U.FormBuilder(this, UIFields, { umap: this })
|
||||||
const controlsOptions = DomUtil.createFieldset(
|
const controlsOptions = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('User interface options')
|
translate('User interface options')
|
||||||
|
@ -800,7 +793,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.dashArray',
|
'properties.dashArray',
|
||||||
]
|
]
|
||||||
|
|
||||||
const builder = new MutatingForm(this, shapeOptions, { umap: this })
|
const builder = new U.FormBuilder(this, shapeOptions, { umap: this })
|
||||||
const defaultShapeProperties = DomUtil.createFieldset(
|
const defaultShapeProperties = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Default shape properties')
|
translate('Default shape properties')
|
||||||
|
@ -819,7 +812,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.slugKey',
|
'properties.slugKey',
|
||||||
]
|
]
|
||||||
|
|
||||||
const builder = new MutatingForm(this, optionsFields, { umap: this })
|
const builder = new U.FormBuilder(this, optionsFields, { umap: this })
|
||||||
const defaultProperties = DomUtil.createFieldset(
|
const defaultProperties = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Default properties')
|
translate('Default properties')
|
||||||
|
@ -837,7 +830,7 @@ export default class Umap extends ServerStored {
|
||||||
'properties.labelInteractive',
|
'properties.labelInteractive',
|
||||||
'properties.outlinkTarget',
|
'properties.outlinkTarget',
|
||||||
]
|
]
|
||||||
const builder = new MutatingForm(this, popupFields, { umap: this })
|
const builder = new U.FormBuilder(this, popupFields, { umap: this })
|
||||||
const popupFieldset = DomUtil.createFieldset(
|
const popupFieldset = DomUtil.createFieldset(
|
||||||
container,
|
container,
|
||||||
translate('Default interaction options')
|
translate('Default interaction options')
|
||||||
|
@ -894,7 +887,7 @@ export default class Umap extends ServerStored {
|
||||||
container,
|
container,
|
||||||
translate('Custom background')
|
translate('Custom background')
|
||||||
)
|
)
|
||||||
const builder = new MutatingForm(this, tilelayerFields, { umap: this })
|
const builder = new U.FormBuilder(this, tilelayerFields, { umap: this })
|
||||||
customTilelayer.appendChild(builder.build())
|
customTilelayer.appendChild(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -942,7 +935,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 MutatingForm(this, overlayFields, { umap: this })
|
const builder = new U.FormBuilder(this, overlayFields, { umap: this })
|
||||||
overlay.appendChild(builder.build())
|
overlay.appendChild(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -969,7 +962,7 @@ export default class Umap extends ServerStored {
|
||||||
{ handler: 'BlurFloatInput', placeholder: translate('max East') },
|
{ handler: 'BlurFloatInput', placeholder: translate('max East') },
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
const boundsBuilder = new MutatingForm(this, boundsFields, { umap: this })
|
const boundsBuilder = new U.FormBuilder(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(
|
||||||
|
@ -1034,7 +1027,14 @@ 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 MutatingForm(this, slideshowFields, {
|
const slideshowBuilder = new U.FormBuilder(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,9 +1042,7 @@ 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 MutatingForm(this, ['properties.syncEnabled'], {
|
const builder = new U.FormBuilder(this, ['properties.syncEnabled'], { umap: this })
|
||||||
umap: this,
|
|
||||||
})
|
|
||||||
sync.appendChild(builder.build())
|
sync.appendChild(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1350,10 +1348,6 @@ 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) {
|
||||||
|
@ -1366,11 +1360,7 @@ export default class Umap extends ServerStored {
|
||||||
},
|
},
|
||||||
'properties.starred': () => {
|
'properties.starred': () => {
|
||||||
Utils.eachElement('.map-star', (el) => {
|
Utils.eachElement('.map-star', (el) => {
|
||||||
el.classList.toggle('icon-starred', this.properties.starred)
|
el.classList.toggle('starred', this.properties.starred)
|
||||||
el.classList.toggle('icon-star', !this.properties.starred)
|
|
||||||
})
|
|
||||||
Utils.eachElement('.map-stars', (el) => {
|
|
||||||
el.textContent = this.properties.stars || 0
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -1469,7 +1459,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 MutatingForm(
|
const builder = new U.FormBuilder(
|
||||||
datalayer,
|
datalayer,
|
||||||
[['options.name', { handler: 'EditableText' }]],
|
[['options.name', { handler: 'EditableText' }]],
|
||||||
{ className: 'umap-form-inline' }
|
{ className: 'umap-form-inline' }
|
||||||
|
@ -1553,7 +1543,6 @@ 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,11 +416,9 @@ export function loadTemplate(html) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadTemplateWithRefs(html) {
|
export function loadTemplateWithRefs(html) {
|
||||||
const template = document.createElement('template')
|
const element = loadTemplate(html)
|
||||||
template.innerHTML = html
|
|
||||||
const element = template.content.firstElementChild
|
|
||||||
const elements = {}
|
const elements = {}
|
||||||
for (const node of template.content.querySelectorAll('[data-ref]')) {
|
for (const node of element.querySelectorAll('[data-ref]')) {
|
||||||
elements[node.dataset.ref] = node
|
elements[node.dataset.ref] = node
|
||||||
}
|
}
|
||||||
return [element, elements]
|
return [element, elements]
|
||||||
|
@ -448,188 +446,3 @@ 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,6 +491,18 @@ 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',
|
||||||
|
|
1242
umap/static/umap/js/umap.forms.js
Normal file
BIN
umap/static/umap/keycloak.png
Normal file
After Width: | Height: | Size: 16 KiB |
|
@ -134,6 +134,12 @@ 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;
|
||||||
|
@ -697,10 +703,6 @@ 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);
|
||||||
|
|
BIN
umap/static/umap/openstreetmap.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
umap/static/umap/twitter.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
468
umap/static/umap/vendors/formbuilder/Leaflet.FormBuilder.js
vendored
Normal file
|
@ -0,0 +1,468 @@
|
||||||
|
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
|
||||||
|
},
|
||||||
|
})
|
181
umap/sync/app.py
|
@ -1,181 +0,0 @@
|
||||||
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)]
|
|
|
@ -1,49 +0,0 @@
|
||||||
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 static %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block maincontent %}
|
{% block maincontent %}
|
||||||
{% include "umap/dashboard_menu.html" with selected="profile" %}
|
{% include "umap/dashboard_menu.html" with selected="profile" %}
|
||||||
|
@ -29,9 +29,7 @@
|
||||||
<ul>
|
<ul>
|
||||||
{% for name in providers %}
|
{% for name in providers %}
|
||||||
<li class="login-grid">
|
<li class="login-grid">
|
||||||
{% with "umap/img/providers/"|add:name|add:".png" as path %}
|
<span class="login-{{ name }}" title="{{ name }}"></span>
|
||||||
<img src="{% static path %}" alt="{{ name }}" />
|
|
||||||
{% endwith %}
|
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -48,7 +46,9 @@
|
||||||
{% for name in backends.backends %}
|
{% for name in backends.backends %}
|
||||||
{% if name not in providers %}
|
{% if name not in providers %}
|
||||||
<li>
|
<li>
|
||||||
{% include "umap/components/provider.html" with name=name %}
|
<a href="{% url "social:begin" name %}"
|
||||||
|
class="umap-login-popup login-{{ name }}"
|
||||||
|
title="{{ name|title }}"></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -55,7 +55,10 @@
|
||||||
<ul class="login-grid block-grid">
|
<ul class="login-grid block-grid">
|
||||||
{% for name in backends.backends %}
|
{% for name in backends.backends %}
|
||||||
<li>
|
<li>
|
||||||
{% include "umap/components/provider.html" with name=name %}
|
<a rel="nofollow"
|
||||||
|
href="{% url "social:begin" name %}"
|
||||||
|
class="umap-login-popup login-{{ name }}"
|
||||||
|
title="{{ name|title }}"></a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
{% 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,6 +30,8 @@
|
||||||
<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>
|
||||||
|
@ -38,6 +40,7 @@
|
||||||
<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,13 +1,12 @@
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -68,15 +67,23 @@ def login(new_page, settings, live_server):
|
||||||
return do_login
|
return do_login
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture
|
||||||
def asgi_live_server(request, live_server):
|
def websocket_server():
|
||||||
server = DaphneProcess("localhost", lambda: ASGIStaticFilesHandler(application))
|
# Find the test-settings, and put them in the current environment
|
||||||
server.start()
|
settings_path = (Path(__file__).parent.parent / "settings.py").absolute().as_posix()
|
||||||
server.ready.wait()
|
os.environ["UMAP_SETTINGS"] = settings_path
|
||||||
port = server.port.value
|
|
||||||
server.url = f"http://localhost:{port}"
|
|
||||||
|
|
||||||
yield server
|
ds_proc = subprocess.Popen(
|
||||||
|
[
|
||||||
server.terminate()
|
"umap",
|
||||||
server.join()
|
"run_websocket_server",
|
||||||
|
],
|
||||||
|
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 button.define").click()
|
page.locator(".umap-field-iconClass a.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(".panel")
|
page.locator("div")
|
||||||
.filter(has_text=re.compile("Display the zoom control"))
|
.filter(has_text=re.compile(r"^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-container button").click()
|
page.locator(".panel .umap-field-sortKey .blur-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 .show-on-defined label").first.click()
|
page.locator(".umap-field-stroke 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,7 +24,6 @@ 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,7 +285,6 @@ 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-body .umap-pictogram-choice")
|
symbols = page.locator(".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 should open on Recent tab
|
# Map has an icon defined, so it shold open on Recent tab
|
||||||
symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice")
|
symbols = page.locator(".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 shuold open on Recent tab
|
# Map has an icon defined, so it shold open on Recent tab
|
||||||
symbols = page.locator(".umap-pictogram-body .umap-pictogram-choice")
|
symbols = page.locator(".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-body .umap-pictogram-choice")
|
symbols = page.locator(".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(".header .umap-pictogram-choice")
|
preview = page.locator(".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-body .umap-pictogram-choice")
|
symbols = page.locator(".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,7 +24,6 @@ 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,24 +8,20 @@ from umap.models import Star
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def test_star_button_is_active_if_logged_in(map, live_server, page, login, user):
|
def test_star_control_is_visible_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("About").click()
|
page.get_by_title("More controls").click()
|
||||||
button = page.locator(".icon-star")
|
control = page.locator(".leaflet-control-star")
|
||||||
expect(button).to_be_visible()
|
expect(control).to_be_visible()
|
||||||
with page.expect_response(re.compile(".*/star/")):
|
with page.expect_response(re.compile(".*/star/")):
|
||||||
button.click()
|
control.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_star_button_inctive_if_not_logged_in(map, live_server, page):
|
def test_no_star_control_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("About").click()
|
page.get_by_title("More controls").click()
|
||||||
button = page.locator(".icon-star")
|
control = page.locator(".leaflet-control-star")
|
||||||
button.click()
|
expect(control).to_be_hidden()
|
||||||
expect(page.get_by_text("You must be logged in")).to_be_visible()
|
|
||||||
|
|
|
@ -74,7 +74,6 @@ 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,8 +1,6 @@
|
||||||
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
|
||||||
|
@ -11,21 +9,11 @@ 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(new_page, asgi_live_server, tilelayer):
|
def test_websocket_connection_can_sync_markers(
|
||||||
|
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()
|
||||||
|
@ -33,9 +21,9 @@ def test_websocket_connection_can_sync_markers(new_page, asgi_live_server, tilel
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page("Page A")
|
peerA = new_page("Page A")
|
||||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page("Page B")
|
peerB = new_page("Page B")
|
||||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{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")
|
||||||
|
@ -56,7 +44,6 @@ def test_websocket_connection_can_sync_markers(new_page, asgi_live_server, tilel
|
||||||
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()
|
||||||
|
@ -92,7 +79,9 @@ def test_websocket_connection_can_sync_markers(new_page, asgi_live_server, tilel
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_websocket_connection_can_sync_polygons(context, asgi_live_server, tilelayer):
|
def test_websocket_connection_can_sync_polygons(
|
||||||
|
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()
|
||||||
|
@ -100,9 +89,9 @@ def test_websocket_connection_can_sync_polygons(context, asgi_live_server, tilel
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = context.new_page()
|
peerA = context.new_page()
|
||||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = context.new_page()
|
peerB = context.new_page()
|
||||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
b_map_el = peerB.locator("#map")
|
b_map_el = peerB.locator("#map")
|
||||||
|
|
||||||
|
@ -175,7 +164,7 @@ def test_websocket_connection_can_sync_polygons(context, asgi_live_server, tilel
|
||||||
|
|
||||||
@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, 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
|
||||||
|
@ -184,9 +173,9 @@ def test_websocket_connection_can_sync_map_properties(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page()
|
peerA = new_page()
|
||||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page()
|
peerB = new_page()
|
||||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{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()
|
||||||
|
@ -198,18 +187,16 @@ 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()
|
||||||
switch = peerB.locator("div.formbox").filter(
|
peerB.locator("div").filter(
|
||||||
has_text=re.compile("Display the zoom control")
|
has_text=re.compile(r"^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, 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
|
||||||
|
@ -218,9 +205,9 @@ def test_websocket_connection_can_sync_datalayer_properties(
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page()
|
peerA = new_page()
|
||||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page()
|
peerB = new_page()
|
||||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{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()
|
||||||
|
@ -238,7 +225,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, 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
|
||||||
|
@ -247,9 +234,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"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = context.new_page()
|
peerB = context.new_page()
|
||||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
|
|
||||||
b_map_el = peerB.locator("#map")
|
b_map_el = peerB.locator("#map")
|
||||||
|
|
||||||
|
@ -291,7 +278,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(".umap-field-color button.define").first.click()
|
peerB.locator(".header > a:nth-child(2)").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()
|
||||||
|
@ -301,7 +288,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, 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
|
||||||
|
@ -310,7 +297,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"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{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")
|
||||||
|
@ -321,7 +308,6 @@ 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(
|
||||||
|
@ -338,7 +324,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"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{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")
|
||||||
|
@ -363,7 +349,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, asgi_live_server, tilelayer):
|
def test_should_sync_datalayers(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()
|
||||||
|
@ -372,9 +358,9 @@ def test_should_sync_datalayers(new_page, asgi_live_server, tilelayer):
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page("Page A")
|
peerA = new_page("Page A")
|
||||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page("Page B")
|
peerB = new_page("Page B")
|
||||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{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()
|
||||||
|
@ -435,7 +421,9 @@ def test_should_sync_datalayers(new_page, asgi_live_server, tilelayer):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_should_sync_datalayers_delete(new_page, asgi_live_server, tilelayer):
|
def test_should_sync_datalayers_delete(
|
||||||
|
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()
|
||||||
|
@ -474,9 +462,9 @@ def test_should_sync_datalayers_delete(new_page, asgi_live_server, tilelayer):
|
||||||
|
|
||||||
# Create two tabs
|
# Create two tabs
|
||||||
peerA = new_page("Page A")
|
peerA = new_page("Page A")
|
||||||
peerA.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerA.goto(f"{live_server.url}{map.get_absolute_url()}?edit")
|
||||||
peerB = new_page("Page B")
|
peerB = new_page("Page B")
|
||||||
peerB.goto(f"{asgi_live_server.url}{map.get_absolute_url()}?edit")
|
peerB.goto(f"{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()
|
||||||
|
@ -499,10 +487,12 @@ def test_should_sync_datalayers_delete(new_page, asgi_live_server, tilelayer):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xdist_group(name="websockets")
|
@pytest.mark.xdist_group(name="websockets")
|
||||||
def test_create_and_sync_map(new_page, asgi_live_server, tilelayer, login, user):
|
def test_create_and_sync_map(
|
||||||
|
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"{asgi_live_server.url}/en/map/new/")
|
peerA.goto(f"{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,5 +29,3 @@ 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 = set(datalayer.geojson.storage.listdir(root)[1])
|
found = datalayer.geojson.storage.listdir(root)[1]
|
||||||
assert found == {other, f"{other}.gz"}
|
assert found == [other, f"{other}.gz"]
|
||||||
|
|
22
umap/tests/test_websocket_server.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
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,36 +7,23 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.urls import URLPattern, URLResolver, get_resolver
|
from django.urls import URLPattern, URLResolver, get_resolver
|
||||||
|
|
||||||
|
|
||||||
def _get_url_names(module):
|
def _urls_for_js(urls=None):
|
||||||
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.
|
||||||
"""
|
"""
|
||||||
urls = {}
|
if urls is None:
|
||||||
for module in ["umap.urls", "umap.sync.app"]:
|
# prevent circular import
|
||||||
names = _get_url_names(module)
|
from .urls import i18n_urls, urlpatterns
|
||||||
urls.update(
|
|
||||||
dict(zip(names, [get_uri_template(url, module=module) for url in names]))
|
urls = [
|
||||||
)
|
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="", module=None):
|
def get_uri_template(urlname, args=None, prefix=""):
|
||||||
"""
|
"""
|
||||||
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.
|
||||||
|
@ -58,7 +45,7 @@ def get_uri_template(urlname, args=None, prefix="", module=None):
|
||||||
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(module)
|
resolver = get_resolver(None)
|
||||||
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,9 +678,6 @@ 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": {
|
||||||
|
@ -783,9 +780,6 @@ 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
|
||||||
|
@ -1087,9 +1081,7 @@ 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(
|
return simple_json_response(starred=status)
|
||||||
starred=status, stars=Star.objects.filter(map=map_inst).count()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MapShortUrl(RedirectView):
|
class MapShortUrl(RedirectView):
|
||||||
|
|
202
umap/websocket_server.py
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
#!/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")
|