Compare commits

...

82 commits

Author SHA1 Message Date
Yohan Boniface
90f163fe1b
Merge a3baf82b7b into cb5e13b218 2025-01-27 18:04:52 +00:00
Yohan Boniface
a3baf82b7b chore(templates): use images for oauth providers list
Co-authored-by: David Larlet <david@larlet.fr>
2025-01-27 19:04:13 +01:00
Yohan Boniface
cb5e13b218
chore: bump django-environ from 0.11.2 to 0.12.0 (#2454) 2025-01-27 18:41:12 +01:00
Yohan Boniface
c42a2b7129
chore: bump psycopg from 3.2.3 to 3.2.4 (#2453) 2025-01-27 18:40:55 +01:00
Yohan Boniface
2482111d24
chore: bump pydantic from 2.10.5 to 2.10.6 (#2450) 2025-01-27 18:40:31 +01:00
Yohan Boniface
bcd21d3697
feat(forms): add a debounce for Input and Textarea fields (#2445)
fix #2415

I'm a bit afraid this will add more hiccup to the playwright tests,
tough :(
2025-01-27 18:40:11 +01:00
Yohan Boniface
b6b47cc0d0
chore: bump pymdown-extensions from 10.14 to 10.14.1 (#2452) 2025-01-27 18:39:50 +01:00
dependabot[bot]
cb4ea1b1d2
chore: bump django-environ from 0.11.2 to 0.12.0
Bumps [django-environ](https://github.com/joke2k/django-environ) from 0.11.2 to 0.12.0.
- [Release notes](https://github.com/joke2k/django-environ/releases)
- [Changelog](https://github.com/joke2k/django-environ/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/joke2k/django-environ/compare/v0.11.2...v0.12.0)

---
updated-dependencies:
- dependency-name: django-environ
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-27 17:32:18 +00:00
dependabot[bot]
99fff916d5
chore: bump psycopg from 3.2.3 to 3.2.4
Bumps [psycopg](https://github.com/psycopg/psycopg) from 3.2.3 to 3.2.4.
- [Changelog](https://github.com/psycopg/psycopg/blob/master/docs/news.rst)
- [Commits](https://github.com/psycopg/psycopg/compare/3.2.3...3.2.4)

---
updated-dependencies:
- dependency-name: psycopg
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-27 17:31:06 +00:00
dependabot[bot]
151beb6d4c
chore: bump pymdown-extensions from 10.14 to 10.14.1
Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.14 to 10.14.1.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.14...10.14.1)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-27 17:30:59 +00:00
dependabot[bot]
0e1fa6965d
chore: bump pydantic from 2.10.5 to 2.10.6
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.10.5 to 2.10.6.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.10.5...v2.10.6)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-27 17:30:34 +00:00
Yohan Boniface
49ea7ed4a5 feat(forms): add a debounce for Input and Textarea fields
fix #2415
2025-01-27 17:25:56 +01:00
Yohan Boniface
d20943a487
feat: move star button to caption (#2442)
fix #2282


![image](https://github.com/user-attachments/assets/45fda270-035b-4ec2-9ac1-3e2f2798663b)
![Screenshot From 2025-01-23
10-42-23](https://github.com/user-attachments/assets/ef323c82-bb12-41ef-97e6-b21dfeef01ba)
2025-01-27 17:18:28 +01:00
Yohan Boniface
250579eaa2 chore: better styling for star button in caption panel
Co-authored-by: David Larlet <david@larlet.fr>
2025-01-27 16:50:27 +01:00
Yohan Boniface
60918e6ca5
wip(sync): POC of using Redis for pubsub (#2426)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
## TODO

- [x] add expire to peers registry hash in redis, as for now when the
server closes the connection we have extra users (edit: we cleaned
manually, as HEXPIRE is not available in FOSS version of Redis)
- [x] make that the peer uuid is created by the client, so when it
reconnects, it uses the same, and does not create a new one
- [ ] see if we can use a connection_pool
- [x] use dynamic websocket_uri (that must include the map id)
- [x] integrate Redis in playwright tests
2025-01-27 15:58:30 +01:00
David Larlet
088f682247
fix(helm): reference secret-env by fullname instead release-name (#2406)
Related to helm Charts:
Currently the reference to secret is done by
```          
envFrom:
- secretRef:
  name: {{ .Release.Name }}-env
```

[Link](https://github.com/umap-project/umap/blob/master/charts/umap/templates/deployment.yaml#L67C1-L69C44)

but secret object is created like this:
```
metadata:
  name: {{ include "umap.fullname" . }}-env
```

[Link](https://github.com/umap-project/umap/blob/master/charts/umap/templates/secret-env.yaml#L4C3-L4C4)

Works as long as no Sub-Chart feature is used, when individual
configuration is stored within a git repo.
Then the subchart name is added to fullname. (And I think if custom
fullName is set)

To be safe, `{{ include "umap.fullname" . }}` is correct. 

Should be no breaking change, when it is working at the moment. (Because
then both return same value)
It is only a breaking change, if it is not working at the moment.
2025-01-27 09:56:29 -05:00
Yohan Boniface
d4afd5646f
feat: allow to define sortKey at layer level (#2449)
In some situation, we do not want the same sortKey for each layer.
2025-01-27 10:08:53 +01:00
Yohan Boniface
a2936d74de
Fix categorized layers colors palette not updating (#2447)
Some checks are pending
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
fix #2446 

A bunch of small commits, but real fixes are:
29d70552dd
and
d5efe6b8e2
and
8f2bbc6765
2025-01-26 22:21:39 +01:00
Yohan Boniface
485bd94531 feat: allow to define sortKey at layer level
In some situation, we do not want the same sortKey for each layer.
2025-01-26 22:20:19 +01:00
Yohan Boniface
8603774778 chore: do not use hx for non structural headings 2025-01-24 18:52:24 +01:00
Yohan Boniface
8f2bbc6765 fix: re-compute layer settings when type changed 2025-01-24 18:24:33 +01:00
Yohan Boniface
d5efe6b8e2 fix: get COLORS from Utils now that it has been moved there 2025-01-24 18:23:41 +01:00
Yohan Boniface
8111cf5522 fix: add a label for default colors scheme in categorized layer type 2025-01-24 18:22:33 +01:00
Yohan Boniface
609b251303 chore: use datalayer.allProperties() instead of private property 2025-01-24 18:21:29 +01:00
Yohan Boniface
29d70552dd fix: always index properties when feature properties as changed
We want the datalayer index properties to be updated when a user set
a property on the feature, as this property name may not yet be in the
index (which is used later for autocomplete, select…).
2025-01-24 18:19:57 +01:00
Yohan Boniface
1d47bfce0a chore: add redis port in Github workflow 2025-01-24 10:13:00 +01:00
Yohan Boniface
ea2bdba270
fix: display a more usefull message when error in remote data (#2443)
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
Test & Docs / docs (push) Has been cancelled
fix #2379

![Screenshot From 2025-01-23
11-54-29](https://github.com/user-attachments/assets/b2000003-b9c3-404f-be91-0a9b13f512c2)
2025-01-24 09:09:14 +01:00
Yohan Boniface
22846acb99
fix: fix circle icon no longer hihlighted (#2440)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
fix #2429
2025-01-23 18:30:23 +01:00
Yohan Boniface
693e775ca8
fix: keep layer visibility after clicking on toggle all (#2439)
fix #2430

Not exactly sure how to make this DRY. What we want is to mark the layer
visibility as "controlled by user" as soon as they click on a
show/hide/showAll/hideAll button, so we do not try to infer the
visibility from the from/toZoom settings.
2025-01-23 18:29:37 +01:00
Yohan Boniface
b62085b7aa chore: add REDIS_HOST and REDIS_PORT env vars in CI 2025-01-23 18:02:28 +01:00
Yohan Boniface
222213ec87 fix(sync): add python redis package
Co-authored-by: David Larlet <david@larlet.fr>
2025-01-23 17:26:39 +01:00
Yohan Boniface
476c160fd5 wip(sync): clean stale username from redis
We wanted to use the HEXPIRE command, but discovered that this command
is only available since the Redis 7.4 version (the latest), and this version
does not have an OSI compliant licence, so it is generally not installable
through common packages managers. The OSS fork is Valkey, but it still does
not have the HEXPIRE command.

So we decide to clean those keys manually, and in order no do this clean
task at each websocket connection, we only do it when we are the first user
to connect to a given map.

Co-authored-by: David Larlet <david@larlet.fr>
2025-01-23 17:20:21 +01:00
Yohan Boniface
0d5e3047f4 wip(sync): only return peers with an active connection 2025-01-23 17:20:21 +01:00
Yohan Boniface
11fb29c456 wip(sync): log but do not crash when sending fail
This should be a race condition when sending to a closed websocket. Let's
log to track them.
2025-01-23 17:20:21 +01:00
Yohan Boniface
ef7c769abe wip(sync): remove a bit of server prints 2025-01-23 17:20:21 +01:00
Yohan Boniface
82342ea00f wip(sync): try a better pattern to unsubscribe to pubsub channels
When publishing a "STOP", this would unsubscribe every listener of the
channel.
2025-01-23 17:20:21 +01:00
Yohan Boniface
7e42331533 wip(sync): add Redis to CI and configure tests settings 2025-01-23 17:20:21 +01:00
Yohan Boniface
a07ee482ce wip(sync): use our async_live_server for websocket related PW tests
As now both http and ws are on the same domain, let's use one test server
able to serve both.

Co-authored-by: David Larlet <david@larlet.fr>
2025-01-23 17:20:20 +01:00
Yohan Boniface
1bf100d7a8 wip(sync): make the client set its peer id 2025-01-23 17:17:16 +01:00
Yohan Boniface
36d9e9bf06 wip(sync): use the correct URL for websocket
Co-authored-by: David Larlet <david@larlet.fr>
2025-01-23 17:17:16 +01:00
Yohan Boniface
acb2e967b8 wip(sync): POC of using Redis for pubsub
Co-authored-by: David Larlet <david@larlet.fr>
2025-01-23 17:17:16 +01:00
Yohan Boniface
ab7119e0a4 wip(sync): use Daphne as live_server for tests
Also clean dependencies.

We still use the channels live server for our tests, but do not use it
anymore for the actual websocket handling.
2025-01-23 17:17:13 +01:00
Yohan Boniface
460a0c9997 wip(sync): only open listen connections after authentication 2025-01-23 17:16:44 +01:00
Yohan Boniface
698c74b427 wip(sync): only save Peer after authentication 2025-01-23 17:16:44 +01:00
Yohan Boniface
a29eae138e wip(sync): websocket server with ASGI and PostgreSQL LISTEN/NOTIFY 2025-01-23 17:16:44 +01:00
Yohan Boniface
31546d6ff4 wip(sync): use django-channels to serve websockets
Co-authored-by: David Larlet <david@larlet.fr>
2025-01-23 17:16:44 +01:00
Yohan Boniface
83c3a41be5 fix: display a more usefull message when error in remote data
fix #2379
2025-01-23 12:04:25 +01:00
Yohan Boniface
48f9afdedd feat: move star button to caption
fix #2282
2025-01-23 11:25:09 +01:00
Yohan Boniface
8a207afaea
fix: do not consume ctrl-Z in textarea or input (#2441)
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
Test & Docs / docs (push) Has been cancelled
fix #2422
2025-01-22 10:40:26 +01:00
Yohan Boniface
122d470e31 fix: do not consume ctrl-Z in textarea or input
fix #2422
2025-01-21 18:19:16 +01:00
Yohan Boniface
e7388f6cb0
chore: bump pymdown-extensions from 10.13 to 10.14 (#2435)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
2025-01-21 18:02:24 +01:00
Yohan Boniface
44dbf2f0df fix: fix circle icon no longer hihlighted
fix #2429
2025-01-21 11:46:57 +01:00
Yohan Boniface
f3b11b03bc fix: keep layer visibility after clicking on toggle all
fix #2430

Not exactly sure how to make this DRY. What we want is to mark the
layer visibility as "controlled by user" as soon as they click on a
show/hide/showAll/hideAll button, so we do not try to infer the
visibility from the from/toZoom settings.
2025-01-21 11:33:08 +01:00
dependabot[bot]
bb7cc86538
chore: bump pymdown-extensions from 10.13 to 10.14
Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.13 to 10.14.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.13...10.14)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-21 09:53:57 +00:00
Yohan Boniface
30690bcb35
chore: bump mkdocs-material from 9.5.49 to 9.5.50 (#2433)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
2025-01-21 10:52:54 +01:00
dependabot[bot]
f7c9c469d1
chore: bump mkdocs-material from 9.5.49 to 9.5.50
Bumps [mkdocs-material](https://github.com/squidfunk/mkdocs-material) from 9.5.49 to 9.5.50.
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.49...9.5.50)

---
updated-dependencies:
- dependency-name: mkdocs-material
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-20 21:33:09 +00:00
Yohan Boniface
3c38a5e55e
chore: bump moto[s3] from 5.0.25 to 5.0.27 (#2436)
Some checks are pending
Test & Docs / tests (postgresql, 3.10) (push) Waiting to run
Test & Docs / tests (postgresql, 3.12) (push) Waiting to run
Test & Docs / lint (push) Waiting to run
Test & Docs / docs (push) Waiting to run
2025-01-20 22:32:28 +01:00
Yohan Boniface
4430bddcc9
chore: bump ruff from 0.9.1 to 0.9.2 (#2434) 2025-01-20 22:32:06 +01:00
dependabot[bot]
9ba5dda507
chore: bump ruff from 0.9.1 to 0.9.2
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.9.1 to 0.9.2.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.9.1...0.9.2)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-20 20:36:06 +00:00
dependabot[bot]
b15e333f6c
chore: bump moto[s3] from 5.0.25 to 5.0.27
Bumps [moto[s3]](https://github.com/getmoto/moto) from 5.0.25 to 5.0.27.
- [Release notes](https://github.com/getmoto/moto/releases)
- [Changelog](https://github.com/getmoto/moto/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getmoto/moto/compare/5.0.25...5.0.27)

---
updated-dependencies:
- dependency-name: moto[s3]
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-20 20:35:47 +00:00
Yohan Boniface
4ce8f6515d
fix: test failing due to unstable FS ordering between OS (#2438) 2025-01-20 21:34:28 +01:00
Yohan Boniface
dc5a3a6b62 fix: test failing due to unstable FS ordering between OS 2025-01-20 21:12:06 +01:00
David Larlet
2ff2ee29ed
chore: bump pillow from 11.0.0 to 11.1.0 (#2417)
Some checks failed
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
Test & Docs / lint (push) Has been cancelled
Test & Docs / docs (push) Has been cancelled
2025-01-16 10:01:25 -05:00
David Larlet
02afc783cf
chore: bump django from 5.1.4 to 5.1.5 (#2432) 2025-01-16 10:00:56 -05:00
dependabot[bot]
f3fc24addf
chore: bump django from 5.1.4 to 5.1.5
Bumps [django](https://github.com/django/django) from 5.1.4 to 5.1.5.
- [Commits](https://github.com/django/django/compare/5.1.4...5.1.5)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-16 14:52:18 +00:00
David Larlet
2beeda3c2f
chore: bump pydantic from 2.10.4 to 2.10.5 (#2427) 2025-01-16 09:51:48 -05:00
David Larlet
ac6e9a1021
chore: bump ruff from 0.8.4 to 0.9.1 (#2428) 2025-01-16 09:51:22 -05:00
dependabot[bot]
2428b0fd47
chore: bump ruff from 0.8.4 to 0.9.1
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.8.4 to 0.9.1.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.8.4...0.9.1)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-13 17:24:40 +00:00
dependabot[bot]
20a1cf0c55
chore: bump pydantic from 2.10.4 to 2.10.5
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.10.4 to 2.10.5.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/main/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.10.4...v2.10.5)

---
updated-dependencies:
- dependency-name: pydantic
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-13 17:24:27 +00:00
Yohan Boniface
f53d435dfd
chore: internalize FormBuilder (#2420)
Some checks failed
Test & Docs / lint (push) Has been cancelled
Test & Docs / docs (push) Has been cancelled
Test & Docs / tests (postgresql, 3.10) (push) Has been cancelled
Test & Docs / tests (postgresql, 3.12) (push) Has been cancelled
fix #2280 

That's a first step, which:
- internalize Formbuilder as a bunch of modules
- use Javascript classes instead of Leaflet ones
- remove dependencies to Leaflet (L.DomUtil…)
- replaces `L.FormBuilder` by `Form` (in theory generic, but not quite)
and `U.FormBuilder` by `MutatingForm` (knows about isDirty,
`inheritable` and such)

There is much more room for refactor, but let's do it step by step!
2025-01-11 14:29:55 +01:00
Yohan Boniface
07c29abbec chore(utils): use native events instead of array of callbacks for WithEvents
Co-authored-by: David Larlet <david@larlet.fr>
2025-01-10 16:44:48 +01:00
Yohan Boniface
0ba69e41d0 wip(forms): use events instead of callback 2025-01-09 13:02:04 +01:00
Yohan Boniface
fb4fecd337 chore(tests): fix sync tests 2025-01-09 13:00:59 +01:00
Yohan Boniface
b6c8d64c47 chore(forms): remove duplicate NullableBoolean field 2025-01-08 16:15:27 +01:00
Yohan Boniface
63e84d94c4 chore(forms): refactor icon preview of IconURL field 2025-01-08 16:14:36 +01:00
Yohan Boniface
176b8bdbcc wip(forms): refactor forms templating 2025-01-07 18:08:48 +01:00
Yohan Boniface
e0fadea749 chore: remove Leaflet dependency from form modules 2025-01-07 18:07:14 +01:00
Yohan Boniface
b88a0cc49f wip: internalize FormBuilder and move to module 2025-01-07 18:07:14 +01:00
dependabot[bot]
910995291d
chore: bump pillow from 11.0.0 to 11.1.0
Bumps [pillow](https://github.com/python-pillow/Pillow) from 11.0.0 to 11.1.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/11.0.0...11.1.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-06 17:33:24 +00:00
Stefan Warnat
d4df6aaae5
fix: Update ConfigMap reference name to fullname 2024-12-29 17:59:41 +01:00
Stefan Warnat
ed5e0c6aad
fix: reference secret-env by fullname instead release-name 2024-12-29 17:24:49 +01:00
Yohan Boniface
bf631f07de fix: display current configured oauth as icon instead of text 2024-12-17 11:44:13 +01:00
78 changed files with 2407 additions and 2305 deletions

View file

@ -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:

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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"]

View file

@ -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/

View file

@ -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']}")

View file

@ -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"])

View file

@ -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"

View file

@ -46,7 +46,7 @@ h3, h4, h5 {
margin-bottom: 14px;
}
p {
line-height: 21px;
line-height: 1.4;
margin-top: 14px;
margin-bottom: 14px;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View file

@ -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 */

View file

@ -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 {

View file

@ -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;

View file

@ -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;
}

View file

@ -42,7 +42,8 @@
padding: var(--panel-gutter);
}
.panel h3 {
line-height: 120%;
display: flex;
align-items: center;
}
.panel .counter::before {
counter-increment: step;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 608 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 B

View file

@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -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 {

View file

@ -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) {

View file

@ -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())

View file

@ -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',

View 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()
}
}

File diff suppressed because it is too large Load diff

View 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(','))
}
}
}

View file

@ -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()

View file

@ -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()

View file

@ -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'

View file

@ -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: {

View file

@ -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)

View file

@ -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'],

View file

@ -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')

View file

@ -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))
)
}
}

View file

@ -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()
}

View file

@ -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 = ''

View file

@ -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>

View file

@ -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')

View file

@ -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',
]

View file

@ -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',

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -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
View file

181
umap/sync/app.py Normal file
View 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
View 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

View file

@ -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 %}

View file

@ -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>

View 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>

View file

@ -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 %}

View file

@ -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()

View file

@ -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()

View file

@ -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)

View file

@ -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
)

View file

@ -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")

View file

@ -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()

View file

@ -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)

View file

@ -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

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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"

View file

@ -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"}

View file

@ -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

View file

@ -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]

View file

@ -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):

View file

@ -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")