diff --git a/docs-users/fr/support/faq.md b/docs-users/fr/support/faq.md index 1a6f7566..f9dcac7e 100644 --- a/docs-users/fr/support/faq.md +++ b/docs-users/fr/support/faq.md @@ -44,6 +44,10 @@ Sur macOS, utliser `Cmd` à la place de `Ctrl`. * `macolonne!=impair` → cible les éléments dont la colonne `macolonne` est absente ou dont la valeur est différente de `impair` * `macolonne>12` → cible les éléments dont la colonne `macolonne` est supérieur au nombre `12` * `macolonne<12.34` → cible les éléments dont la colonne `macolonne` est inférieure au nombre `12.34` +* `macolonne=` → cible les éléments dont la colonne `macolonne` est vide +* `macolonne!=` → cible les éléments dont la colonne `macolonne` n'est pas vide +* `macolonne=true/false` → cible les éléments dont la colonne `macolonne` est explicitement `true` (ou `false`) +* `macolonne!=true/false` → cible les éléments dont la colonne `macolonne` est différente de `true` (ou `false`) Quand la condition est vraie pour un élément donné, le style associé sera appliqué. diff --git a/docs-users/support/faq.md b/docs-users/support/faq.md index 416f8239..68bf59ca 100644 --- a/docs-users/support/faq.md +++ b/docs-users/support/faq.md @@ -44,6 +44,10 @@ With macOS, replace `Ctrl` by `Cmd`. * `mycolumn!=odd` → will match features whose column `mycolumn` is missing or different from `odd` * `mycolumn>12` → will match features whose column `mycolumn` is greater than `12` (as number) * `mycolumn<12.34` → will match features whose column `mycolumn` is lower than `12.34` (as number) +* `mycolumn=` → will match features whose column `mycolumn` has no or null value +* `mycolumn!=` → will match features whose column `mycolumn` has any defined +* `mycolumn=true/false` → will match features whose column `mycolumn` is explicitely `true` (or `false`) +* `mycolumn!=true/false` → will match features whose column `mycolumn` is different from `true` (or `false`) When the condition match, the associated style will be applied to the corresponding feature. diff --git a/umap/static/umap/js/modules/rules.js b/umap/static/umap/js/modules/rules.js index 0c945059..b584d0d8 100644 --- a/umap/static/umap/js/modules/rules.js +++ b/umap/static/umap/js/modules/rules.js @@ -3,6 +3,8 @@ import { translate } from './i18n.js' import * as Utils from './utils.js' import { AutocompleteDatalist } from './autocomplete.js' +const EMPTY_VALUES = ['', undefined, null] + class Rule { get condition() { return this._condition @@ -75,13 +77,22 @@ class Rule { if (vars.length !== 2) return this.key = vars[0] this.expected = vars[1] + if (EMPTY_VALUES.includes(this.expected)) { + this.cast = (v) => EMPTY_VALUES.includes(v) + } // Special cases where we want to be lousy when checking isNaN without // coercing to a Number first because we handle multiple types. // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/ // Reference/Global_Objects/Number/isNaN // biome-ignore lint/suspicious/noGlobalIsNan: expected might not be a number. - if (!isNaN(this.expected)) this.cast = Number.parseFloat - else if (['true', 'false'].includes(this.expected)) this.cast = (v) => !!v + else if (!isNaN(this.expected)) { + this.cast = Number.parseFloat + } else if (['true', 'false'].includes(this.expected)) { + this.cast = (v) => { + if (`${v}`.toLowerCase() === 'true') return true + if (`${v}`.toLowerCase() === 'false') return false + } + } this.expected = this.cast(this.expected) } @@ -133,7 +144,9 @@ class Rule { autocomplete.suggestions = [`${value}=`, `${value}!=`, `${value}>`, `${value}<`] } else if (value.endsWith('=')) { const key = value.split('!')[0].split('=')[0] - autocomplete.suggestions = this.map.sortedValues(key).map((str) => `${value}${str || ''}`) + autocomplete.suggestions = this.map + .sortedValues(key) + .map((str) => `${value}${str || ''}`) } }) this.map.editPanel.open({ content: container }) diff --git a/umap/tests/integration/test_conditional_rules.py b/umap/tests/integration/test_conditional_rules.py index 3db4b5b4..92c64702 100644 --- a/umap/tests/integration/test_conditional_rules.py +++ b/umap/tests/integration/test_conditional_rules.py @@ -24,6 +24,7 @@ DATALAYER_DATA1 = { "mynumber": 10, "myboolean": True, "mydate": "2024/04/14 12:19:17", + "maybeempty": "not empty", }, "geometry": {"type": "Point", "coordinates": [0.065918, 48.385442]}, }, @@ -35,6 +36,7 @@ DATALAYER_DATA1 = { "mynumber": 12, "myboolean": False, "mydate": "2024/03/13 12:20:20", + "maybeempty": "", }, "geometry": {"type": "Point", "coordinates": [3.55957, 49.767074]}, }, @@ -56,6 +58,7 @@ DATALAYER_DATA2 = { "mynumber": 10, "myboolean": "true", "mydate": "2024/08/18 13:14:15", + "maybeempty": None, }, "geometry": {"type": "Point", "coordinates": [0.856934, 45.290347]}, }, @@ -69,6 +72,18 @@ DATALAYER_DATA2 = { }, "geometry": {"type": "Point", "coordinates": [4.372559, 47.945786]}, }, + { + "type": "Feature", + "properties": { + "mytype": "odd", + "name": "Point 5", + "mynumber": 10, + "mydate": "2024-04-14T10:19:17.000Z", + "myboolean": "notaboolean", + "maybeempty": "foo", + }, + "geometry": {"type": "Point", "coordinates": [4.1, 47.3]}, + }, ], "_umap_options": { "name": "Calque 2", @@ -85,9 +100,9 @@ def test_simple_equal_rule_at_load(live_server, page, map): DataLayerFactory(map=map, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) colors = getColors(markers) - assert colors.count("rgb(240, 248, 255)") == 2 + assert colors.count("rgb(240, 248, 255)") == 3 def test_simple_not_equal_rule_at_load(live_server, page, map): @@ -99,9 +114,9 @@ def test_simple_not_equal_rule_at_load(live_server, page, map): DataLayerFactory(map=map, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) colors = getColors(markers) - assert colors.count("rgb(240, 248, 255)") == 2 + assert colors.count("rgb(240, 248, 255)") == 3 def test_gt_rule_with_number_at_load(live_server, page, map): @@ -113,7 +128,7 @@ def test_gt_rule_with_number_at_load(live_server, page, map): DataLayerFactory(map=map, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) colors = getColors(markers) assert colors.count("rgb(240, 248, 255)") == 2 @@ -127,9 +142,9 @@ def test_lt_rule_with_number_at_load(live_server, page, map): DataLayerFactory(map=map, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) colors = getColors(markers) - assert colors.count("rgb(240, 248, 255)") == 3 + assert colors.count("rgb(240, 248, 255)") == 4 def test_lt_rule_with_float_at_load(live_server, page, map): @@ -141,9 +156,9 @@ def test_lt_rule_with_float_at_load(live_server, page, map): DataLayerFactory(map=map, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) colors = getColors(markers) - assert colors.count("rgb(240, 248, 255)") == 3 + assert colors.count("rgb(240, 248, 255)") == 4 def test_equal_rule_with_boolean_at_load(live_server, page, map): @@ -155,7 +170,77 @@ def test_equal_rule_with_boolean_at_load(live_server, page, map): DataLayerFactory(map=map, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 2 + + +def test_equal_rule_with_boolean_not_true_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "myboolean!=true", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(5) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 3 + + +def test_equal_rule_with_boolean_false_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "myboolean=false", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(5) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 1 + + +def test_equal_rule_with_boolean_not_false_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "myboolean!=false", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(5) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 4 + + +def test_empty_rule_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "maybeempty=", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(5) + colors = getColors(markers) + assert colors.count("rgb(240, 248, 255)") == 3 + + +def test_not_empty_rule_at_load(live_server, page, map): + map.settings["properties"]["rules"] = [ + {"condition": "maybeempty!=", "options": {"color": "aliceblue"}} + ] + map.save() + DataLayerFactory(map=map, data=DATALAYER_DATA1) + DataLayerFactory(map=map, data=DATALAYER_DATA2) + page.goto(f"{live_server.url}{map.get_absolute_url()}#6/48.948/1.670") + markers = page.locator(".leaflet-marker-icon .icon_container") + expect(markers).to_have_count(5) colors = getColors(markers) assert colors.count("rgb(240, 248, 255)") == 2 @@ -165,7 +250,7 @@ def test_can_create_new_rule(live_server, page, openmap): DataLayerFactory(map=openmap, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{openmap.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) page.get_by_role("button", name="Edit").click() page.get_by_role("link", name="Map advanced properties").click() page.get_by_text("Conditional style rules").click() @@ -175,7 +260,7 @@ def test_can_create_new_rule(live_server, page, openmap): page.locator(".umap-field-color .define").first.click() page.get_by_title("AliceBlue").first.click() colors = getColors(markers) - assert colors.count("rgb(240, 248, 255)") == 2 + assert colors.count("rgb(240, 248, 255)") == 3 def test_can_deactive_rule_from_list(live_server, page, openmap): @@ -187,9 +272,9 @@ def test_can_deactive_rule_from_list(live_server, page, openmap): DataLayerFactory(map=openmap, data=DATALAYER_DATA2) page.goto(f"{live_server.url}{openmap.get_absolute_url()}#6/48.948/1.670") markers = page.locator(".leaflet-marker-icon .icon_container") - expect(markers).to_have_count(4) + expect(markers).to_have_count(5) colors = getColors(markers) - assert colors.count("rgb(240, 248, 255)") == 2 + assert colors.count("rgb(240, 248, 255)") == 3 page.get_by_role("button", name="Edit").click() page.get_by_role("link", name="Map advanced properties").click() page.get_by_text("Conditional style rules").click() @@ -198,7 +283,7 @@ def test_can_deactive_rule_from_list(live_server, page, openmap): assert colors.count("rgb(240, 248, 255)") == 0 page.get_by_role("button", name="Show/hide layer").click() colors = getColors(markers) - assert colors.count("rgb(240, 248, 255)") == 2 + assert colors.count("rgb(240, 248, 255)") == 3 def test_autocomplete_datalist(live_server, page, openmap): @@ -209,9 +294,9 @@ def test_autocomplete_datalist(live_server, page, openmap): page.get_by_role("button", name="Add rule").click() panel = page.locator(".panel.right.on") datalist = panel.locator(".umap-field-condition datalist option") - expect(datalist).to_have_count(5) + expect(datalist).to_have_count(6) values = {option.inner_text() for option in datalist.all()} - assert values == {"myboolean", "mytype", "mynumber", "mydate", "name"} + assert values == {"myboolean", "mytype", "mynumber", "mydate", "name", "maybeempty"} page.get_by_placeholder("key=value or key!=value").fill("mytype") expect(datalist).to_have_count(4) values = {option.inner_text() for option in datalist.all()}