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