Skip to content

Commit 63bf5a3

Browse files
authored
Merge pull request #4179 from rldhont/dblclick-treeview-legend-group
UI: Double clicking on group propagating the checked state
2 parents 8c14e1e + 9bf44d3 commit 63bf5a3

File tree

5 files changed

+172
-29
lines changed

5 files changed

+172
-29
lines changed

assets/src/components/Treeview.js

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@ export default class Treeview extends HTMLElement {
2121
constructor() {
2222
super();
2323
this._itemNameSelected;
24+
this._clickTimestamp;
2425
}
2526

2627
connectedCallback() {
2728

2829
this._onChange = () => {
30+
if (this._freeze) return;
2931
render(this._rootTemplate(mainLizmap.state.layerTree), this);
3032
};
3133

@@ -96,10 +98,14 @@ export default class Treeview extends HTMLElement {
9698
}
9799
<div class="${layer.checked ? 'checked' : ''} ${layer.type} ${layer.name === this._itemNameSelected ? 'selected' : ''}">
98100
<div class="loading ${layer.loadStatus === MapLayerLoadStatus.Loading ? 'spinner' : ''}"></div>
99-
<input type="checkbox" class="${parent.mutuallyExclusive ? 'rounded-checkbox' : ''}" id="node-${layer.name}" .checked=${layer.checked} @click=${() => layer.checked = !layer.checked} >
101+
<input type="checkbox"
102+
class="${parent.mutuallyExclusive ? 'rounded-checkbox' : ''}"
103+
id="node-${layer.name}"
104+
.checked=${layer.checked}
105+
@click=${() => layer.checked = !layer.checked} >
100106
<div class="node ${layer.isFiltered ? 'filtered' : ''}">
101107
<img class="legend" src="${layer.icon}">
102-
<label for="node-${layer.name}">${layer.layerConfig.title}</label>
108+
<label for="node-${layer.name}" >${layer.layerConfig.title}</label>
103109
<div class="layer-actions">
104110
<a href="${this._createDocLink(layer.name)}" target="_blank" title="${lizDict['tree.button.link']}">
105111
<i class="icon-share"></i>
@@ -131,10 +137,19 @@ export default class Treeview extends HTMLElement {
131137
<div class="${group.checked ? 'checked' : ''} ${group.type} ${group.name === this._itemNameSelected ? 'selected' : ''}">
132138
${mainLizmap.initialConfig.options.hideGroupCheckbox
133139
? ''
134-
: html`<input type="checkbox" class="${parent.mutuallyExclusive ? 'rounded-checkbox' : ''}" id="node-${group.name}" .checked=${group.checked} @click=${() => group.checked = !group.checked} >`
140+
: html`<input type="checkbox" class="${parent.mutuallyExclusive ? 'rounded-checkbox' : ''}"
141+
id="node-${group.name}"
142+
.checked=${group.checked}
143+
@click=${(evt) => this._clickItem(evt, group)}
144+
@dblclick=${() => this._dblclickItem(group)} >`
135145
}
136146
<div class="node ${group.isFiltered ? 'filtered' : ''}">
137-
<label for="node-${group.name}">${group.layerConfig.title}</label>
147+
${mainLizmap.initialConfig.options.hideGroupCheckbox
148+
? html`<label for="node-${group.name}" >${group.layerConfig.title}</label>`
149+
: html`<label
150+
for="node-${group.name}"
151+
@dblclick=${() => this._dblclickItem(group)} } >${group.layerConfig.title}</label>`
152+
}
138153
<div class="layer-actions">
139154
<a href="${this._createDocLink(group.name)}" target="_blank" title="${lizDict['tree.button.link']}">
140155
<i class="icon-share"></i>
@@ -231,6 +246,44 @@ export default class Treeview extends HTMLElement {
231246
return true;
232247
}
233248

249+
_clickItem(evt, item) {
250+
// Freeze or dblclick received
251+
if (this._freeze || evt.detail > 1) {
252+
// Force input element to keep checked status
253+
evt.currentTarget.checked = item.checked;
254+
return false;
255+
}
256+
257+
// It is much more end2end test purpose
258+
// a playwright dblclick is 2 clicks with detail 0
259+
// and the dblclick which is a click with detail 2
260+
if (this._clickTimestamp && evt.timeStamp - this._clickTimestamp < 1) {
261+
// Force input element to keep checked status
262+
evt.currentTarget.checked = item.checked;
263+
return false;
264+
}
265+
this._clickTimestamp = evt.timeStamp;
266+
267+
item.checked = !item.checked;
268+
return false;
269+
}
270+
271+
_dblclickItem(item) {
272+
if (item.type != 'group') {
273+
return false;
274+
}
275+
276+
if (this._freeze) {
277+
return false;
278+
}
279+
280+
this._freeze = true;
281+
item.propagateCheckedState(item.checked);
282+
this._freeze = false;
283+
this._onChange();
284+
return false;
285+
}
286+
234287
_createDocLink(layerName) {
235288
let url = lizMap.config.layers?.[layerName]?.link;
236289

assets/src/modules/state/LayerTree.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,9 +353,29 @@ export class LayerTreeGroupState extends LayerTreeItemState {
353353
}
354354
}
355355

356+
/**
357+
* Propagate throught tree item the new checked state
358+
* @param {boolean} val The new checked state
359+
* @returns {boolean} the new checked state
360+
*/
361+
propagateCheckedState(val) {
362+
for (const item of this._items) {
363+
if (item.type == 'group') {
364+
item.propagateCheckedState(val);
365+
} else {
366+
item.checked = val;
367+
}
368+
if (item.checked && this.mutuallyExclusive) {
369+
break;
370+
}
371+
}
372+
this.checked = val;
373+
return this.checked;
374+
}
375+
356376
/**
357377
* Find layer names
358-
* @returns {string[]} The layer names of all tree layers
378+
* @returns {string[]} List of layer names
359379
*/
360380
findTreeLayerNames() {
361381
let names = []
@@ -371,7 +391,7 @@ export class LayerTreeGroupState extends LayerTreeItemState {
371391

372392
/**
373393
* Find layer items
374-
* @returns {LayerTreeLayerState[]} The tree layer states of all tree layers
394+
* @returns {LayerTreeLayerState[]} List of tree layers (not tree groups)
375395
*/
376396
findTreeLayers() {
377397
let items = []
@@ -387,7 +407,7 @@ export class LayerTreeGroupState extends LayerTreeItemState {
387407

388408
/**
389409
* Find layer and group items
390-
* @returns {LayerTreeLayerState[]} All tThe tree layer and tree group states
410+
* @returns {LayerTreeLayerState[]} List of tree layers and tree groups
391411
*/
392412
findTreeLayersAndGroups() {
393413
let items = []

assets/src/modules/state/MapLayer.js

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80,63 +80,63 @@ export class MapItemState extends EventDispatcher {
8080
layerItemState.addListener(this.dispatch.bind(this), 'layer.filter.token.changed');
8181
}
8282
}
83+
8384
/**
84-
* Config layers
85+
* Map item name
8586
* @type {string}
8687
*/
8788
get name() {
8889
return this._layerItemState.name;
8990
}
9091

9192
/**
92-
* Config layers
93+
* Map item type
9394
* @type {string}
9495
*/
9596
get type() {
9697
return this._type;
9798
}
9899

99100
/**
100-
* the layer tree item level
101+
* the layer item level
101102
* @type {number}
102103
*/
103104
get level() {
104105
return this._layerItemState.level;
105106
}
106107

107108
/**
108-
* WMS layer name
109+
* WMS item name
109110
* @type {?string}
110111
*/
111112
get wmsName() {
112113
return this._layerItemState.wmsName;
113114
}
114115

115116
/**
116-
* WMS layer title
117+
* WMS item title
117118
* @type {string}
118119
*/
119120
get wmsTitle() {
120121
return this._layerItemState.wmsTitle;
121122
}
122123

123124
/**
124-
* WMS layer Geographic Bounding Box
125+
* WMS item Geographic Bounding Box
125126
* @type {?LayerGeographicBoundingBoxConfig}
126127
*/
127128
get wmsGeographicBoundingBox() {
128129
return this._layerItemState.wmsGeographicBoundingBox;
129130
}
130131

131132
/**
132-
* WMS layer Bounding Boxes
133+
* WMS item Bounding Boxes
133134
* @type {LayerBoundingBoxConfig[]}
134135
*/
135136
get wmsBoundingBoxes() {
136137
return this._layerItemState.wmsBoundingBoxes;
137138
}
138139

139-
140140
/**
141141
* WMS Minimum scale denominator
142142
* If the minimum scale denominator is not defined: -1 is returned
@@ -150,7 +150,7 @@ export class MapItemState extends EventDispatcher {
150150
}
151151

152152
/**
153-
* WMS layer maximum scale denominator
153+
* WMS Maximum scale denominator
154154
* If the maximum scale denominator is not defined: -1 is returned
155155
* If the WMS layer is a group, the maximum scale denominator is the largest of the layers in the group
156156
* @type {number}
@@ -160,23 +160,23 @@ export class MapItemState extends EventDispatcher {
160160
}
161161

162162
/**
163-
* Layer tree item is checked
163+
* Map item is checked
164164
* @type {boolean}
165165
*/
166166
get checked() {
167167
return this._layerItemState.checked;
168168
}
169169

170170
/**
171-
* Set layer tree item is checked
171+
* Set map item is checked
172172
* @type {boolean}
173173
*/
174174
set checked(val) {
175175
this._layerItemState.checked = val;
176176
}
177177

178178
/**
179-
* Layer tree item is visible
179+
* Map item is visible
180180
* It depends on the parent visibility
181181
* @type {boolean}
182182
*/
@@ -185,15 +185,15 @@ export class MapItemState extends EventDispatcher {
185185
}
186186

187187
/**
188-
* Layer tree item opacity
188+
* Map item opacity
189189
* @type {number}
190190
*/
191191
get opacity() {
192192
return this._layerItemState.opacity;
193193
}
194194

195195
/**
196-
* Set layer tree item opacity
196+
* Set map item opacity
197197
* @type {number}
198198
*/
199199
set opacity(val) {
@@ -210,7 +210,7 @@ export class MapItemState extends EventDispatcher {
210210

211211
/**
212212
* Lizmap layer item state
213-
* @type {?LayerConfig}
213+
* @type {?LayerItemState}
214214
*/
215215
get itemState() {
216216
return this._layerItemState;
@@ -526,19 +526,19 @@ export class MapLayerState extends MapItemState {
526526
}
527527

528528
/**
529-
* set if the map layer is loaded in a single ImageWMS layer or not
530-
* @param {boolean} val
529+
* vector layer is loaded in a single layer ImageLayer or not
530+
* @type {boolean}
531531
*/
532-
set singleWMSLayer(val){
533-
this._singleWMSLayer = val;
532+
get singleWMSLayer(){
533+
return this._singleWMSLayer;
534534
}
535535

536536
/**
537-
* vector layer is loaded in a single layer ImageLayer or not
537+
* set if the map layer is loaded in a single ImageWMS layer or not
538538
* @type {boolean}
539539
*/
540-
get singleWMSLayer(){
541-
return this._singleWMSLayer;
540+
set singleWMSLayer(val){
541+
this._singleWMSLayer = val;
542542
}
543543

544544
/**

lizmap/www/assets/css/map.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3004,6 +3004,7 @@ lizmap-treeview input.rounded-checkbox:checked {
30043004

30053005
lizmap-treeview .group label {
30063006
font-weight: bold;
3007+
user-select: none;
30073008
}
30083009

30093010
lizmap-treeview label {

tests/end2end/playwright/treeview.spec.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,75 @@ test.describe('Treeview', () => {
9797
test('displays "title" defined in Lizmap plugin', async ({ page }) => {
9898
await expect(page.getByTestId('tramway_lines').locator('label')).toHaveText('Tramway lines');
9999
});
100+
101+
test('double clicking', async ({ page }) => {
102+
// All group1 is checked
103+
await expect(page.locator('#node-group1')).toBeChecked();
104+
await expect(page.locator('#node-sub-group1')).toBeChecked();
105+
await expect(page.locator('#node-subdistricts')).toBeChecked();
106+
// Unchecked all group1 by double clicking the label
107+
await page.getByText('group1', { exact: true }).dblclick();
108+
// All group1 is not checked
109+
await expect(page.locator('#node-group1')).not.toBeChecked();
110+
await expect(page.locator('#node-sub-group1')).not.toBeChecked();
111+
await expect(page.locator('#node-subdistricts')).not.toBeChecked();
112+
// Checked all group1 by double clicking the input
113+
await page.getByLabel('group1', { exact: true }).dblclick();
114+
// All group1 is checked
115+
await expect(page.locator('#node-group1')).toBeChecked();
116+
await expect(page.locator('#node-sub-group1')).toBeChecked();
117+
await expect(page.locator('#node-subdistricts')).toBeChecked();
118+
119+
// Click to uncheck group1
120+
await page.getByLabel('group1', { exact: true }).click();
121+
// Only group1 is not checked
122+
await expect(page.locator('#node-group1')).not.toBeChecked();
123+
await expect(page.locator('#node-sub-group1')).toBeChecked();
124+
await expect(page.locator('#node-subdistricts')).toBeChecked();
125+
126+
// Double clicking sub-group1 does not change the group1 checked state
127+
// Because it because unchecked and group1 is already unchecked
128+
await page.getByLabel('sub-group1', { exact: true }).dblclick();
129+
await expect(page.locator('#node-group1')).not.toBeChecked();
130+
await expect(page.locator('#node-sub-group1')).not.toBeChecked();
131+
await expect(page.locator('#node-subdistricts')).not.toBeChecked();
132+
133+
// Double clicking sub-group1 changes the group1 checked state
134+
await page.getByLabel('sub-group1', { exact: true }).dblclick();
135+
await expect(page.locator('#node-group1')).toBeChecked();
136+
await expect(page.locator('#node-sub-group1')).toBeChecked();
137+
await expect(page.locator('#node-subdistricts')).toBeChecked();
138+
139+
// Verify the status of mutually exclusive group
140+
await expect(page.getByLabel('group with space in name and shortname defined')).toBeChecked();
141+
await expect(page.locator('#node-quartiers')).toBeChecked();
142+
await expect(page.locator('#node-shop_bakery_pg')).not.toBeChecked();
143+
// Unchecked all mutually exclusive group by double clicking the label
144+
await page.getByText('group with space in name and shortname defined').dblclick();
145+
await expect(page.getByLabel('group with space in name and shortname defined')).not.toBeChecked();
146+
await expect(page.locator('#node-quartiers')).not.toBeChecked();
147+
await expect(page.locator('#node-shop_bakery_pg')).not.toBeChecked();
148+
// Checked all mutually exclusive group by double clicking the label, only the first child is clicked
149+
await page.getByLabel('group with space in name and shortname defined').dblclick();
150+
await expect(page.getByLabel('group with space in name and shortname defined')).toBeChecked();
151+
await expect(page.locator('#node-quartiers')).toBeChecked();
152+
await expect(page.locator('#node-shop_bakery_pg')).not.toBeChecked();
153+
// switch visibility in mutually exclusive group
154+
await page.locator('#node-shop_bakery_pg').click();
155+
await expect(page.getByLabel('group with space in name and shortname defined')).toBeChecked();
156+
await expect(page.locator('#node-quartiers')).not.toBeChecked();
157+
await expect(page.locator('#node-shop_bakery_pg')).toBeChecked();
158+
// Unchecked all mutually exclusive group by double clicking the label
159+
await page.getByText('group with space in name and shortname defined').dblclick();
160+
await expect(page.getByLabel('group with space in name and shortname defined')).not.toBeChecked();
161+
await expect(page.locator('#node-quartiers')).not.toBeChecked();
162+
await expect(page.locator('#node-shop_bakery_pg')).not.toBeChecked();
163+
// Checked all mutually exclusive group by double clicking the label, only the first child is clicked
164+
await page.getByLabel('group with space in name and shortname defined').dblclick();
165+
await expect(page.getByLabel('group with space in name and shortname defined')).toBeChecked();
166+
await expect(page.locator('#node-quartiers')).toBeChecked();
167+
await expect(page.locator('#node-shop_bakery_pg')).not.toBeChecked();
168+
});
100169
});
101170

102171
test.describe('Treeview mocked with "Hide checkboxes for groups" option', () => {

0 commit comments

Comments
 (0)