@osimatic/helpers-js 1.5.21 → 1.5.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/chartjs.js CHANGED
@@ -1,9 +1,10 @@
1
1
  const { toEl } = require('./util');
2
2
  const deepmerge = require('deepmerge');
3
+ const { DatePeriod } = require('./date_time');
3
4
 
4
5
  class Chartjs {
5
6
  static init() {
6
- if (typeof Chartjs.initialized == 'undefined' || Chartjs.initialized) {
7
+ if (Chartjs.initialized) {
7
8
  return;
8
9
  }
9
10
 
@@ -40,6 +41,7 @@ class Chartjs {
40
41
  }
41
42
 
42
43
  static createStackedChart(chartDiv, chartData, title=null, options={}) {
44
+ Chartjs.init();
43
45
  chartDiv = toEl(chartDiv);
44
46
  chartDiv.innerHTML = '';
45
47
  new Chart(chartDiv.getContext("2d"), deepmerge({
@@ -90,6 +92,7 @@ class Chartjs {
90
92
  }
91
93
 
92
94
  static createBarChart(chartDiv, chartData, title=null, options={}) {
95
+ Chartjs.init();
93
96
  chartDiv = toEl(chartDiv);
94
97
  chartDiv.innerHTML = '';
95
98
  new Chart(chartDiv.getContext("2d"), deepmerge({
package/form_helper.js CHANGED
@@ -239,7 +239,7 @@ class FormHelper {
239
239
  return json.error;
240
240
  }
241
241
 
242
- if (onlyIfUniqueError && !json.length || json.length > 1) {
242
+ if (onlyIfUniqueError && (!json.length || json.length > 1)) {
243
243
  return null;
244
244
  }
245
245
 
@@ -263,7 +263,7 @@ class FormHelper {
263
263
  return json.error;
264
264
  }
265
265
 
266
- if (onlyIfUniqueError && !json.length || json.length > 1) {
266
+ if (onlyIfUniqueError && (!json.length || json.length > 1)) {
267
267
  return null;
268
268
  }
269
269
 
@@ -279,9 +279,10 @@ class FormHelper {
279
279
  }
280
280
 
281
281
  static hideFormErrors(form) {
282
+ const wasJQuery = form && form.jquery;
282
283
  form = toEl(form);
283
284
  form.querySelectorAll('div.form_errors').forEach(el => el.remove());
284
- return form;
285
+ return wasJQuery ? toJquery(form) : form;
285
286
  }
286
287
 
287
288
  static getFormErrorText(errors) {
package/media.js CHANGED
@@ -101,7 +101,7 @@ class AudioMedia {
101
101
  canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
102
102
 
103
103
  function draw() {
104
- let drawVisual = requestAnimationFrame(draw);
104
+ requestAnimationFrame(draw);
105
105
 
106
106
  analyser.getByteFrequencyData(dataArray);
107
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osimatic/helpers-js",
3
- "version": "1.5.21",
3
+ "version": "1.5.23",
4
4
  "main": "main.js",
5
5
  "scripts": {
6
6
  "test": "jest",
package/select_all.js CHANGED
@@ -11,6 +11,9 @@ class SelectAll {
11
11
  linkClone.addEventListener('click', function(e) {
12
12
  e.preventDefault();
13
13
  const formGroup = this.closest('.form-group');
14
+ if (!formGroup) {
15
+ return;
16
+ }
14
17
  const allCheckbox = formGroup.querySelectorAll('input[type="checkbox"]:not(.check_all)');
15
18
  const allCheckboxChecked = formGroup.querySelectorAll('input[type="checkbox"]:not(.check_all):checked');
16
19
  const allCheckboxWithCheckAll = formGroup.querySelectorAll('input[type="checkbox"]');
@@ -20,6 +23,9 @@ class SelectAll {
20
23
  });
21
24
 
22
25
  const formGroup = linkClone.closest('.form-group');
26
+ if (!formGroup) {
27
+ return;
28
+ }
23
29
  formGroup.querySelectorAll('input[type="checkbox"]').forEach(cb => {
24
30
  cb.addEventListener('change', () => {
25
31
  SelectAll.updateFormGroup(cb.closest('.form-group'));
@@ -30,6 +36,9 @@ class SelectAll {
30
36
 
31
37
  static updateFormGroup(formGroup) {
32
38
  formGroup = toEl(formGroup);
39
+ if (!formGroup) {
40
+ return;
41
+ }
33
42
  const allCheckbox = formGroup.querySelectorAll('input[type="checkbox"]:not(.check_all)');
34
43
  const allCheckboxChecked = formGroup.querySelectorAll('input[type="checkbox"]:not(.check_all):checked');
35
44
  const lienSelectAll = formGroup.querySelector('a.check_all');
@@ -48,6 +57,9 @@ class SelectAll {
48
57
 
49
58
  static initInTable(table) {
50
59
  table = toEl(table);
60
+ if (!table) {
61
+ return;
62
+ }
51
63
  const inputCheckAll = table.querySelector('tr input.check_all');
52
64
  if (!inputCheckAll) {
53
65
  return;
@@ -72,6 +84,9 @@ class SelectAll {
72
84
 
73
85
  static updateTable(table) {
74
86
  table = toEl(table);
87
+ if (!table) {
88
+ return;
89
+ }
75
90
  const allCheckbox = table.querySelectorAll('tbody input[type="checkbox"]');
76
91
  const allCheckboxChecked = table.querySelectorAll('tbody input[type="checkbox"]:checked');
77
92
  const checkboxSelectAll = table.querySelector('thead input.check_all');
@@ -85,6 +100,9 @@ class SelectAll {
85
100
 
86
101
  static initDiv(contentDiv) {
87
102
  contentDiv = toEl(contentDiv);
103
+ if (!contentDiv) {
104
+ return;
105
+ }
88
106
  contentDiv.querySelectorAll('input.check_all').forEach(inputCheckAll => {
89
107
  const div = inputCheckAll.closest('div.checkbox_with_check_all');
90
108
 
@@ -92,6 +110,9 @@ class SelectAll {
92
110
  inputCheckAll.parentElement.replaceChild(clone, inputCheckAll);
93
111
  clone.addEventListener('click', function() {
94
112
  const d = this.closest('div.checkbox_with_check_all');
113
+ if (!d) {
114
+ return;
115
+ }
95
116
  const allCheckbox = d.querySelectorAll('input[type="checkbox"]:not(.check_all)');
96
117
  const allCheckboxChecked = d.querySelectorAll('input[type="checkbox"]:not(.check_all):checked');
97
118
  const newState = allCheckbox.length !== allCheckboxChecked.length;
@@ -99,6 +120,9 @@ class SelectAll {
99
120
  SelectAll.updateDiv(d);
100
121
  });
101
122
 
123
+ if (!div) {
124
+ return;
125
+ }
102
126
  div.querySelectorAll('div.checkbox input[type="checkbox"], div.form-check input[type="checkbox"]').forEach(cb => {
103
127
  cb.addEventListener('change', () => {
104
128
  SelectAll.updateDiv(cb.closest('div.checkbox_with_check_all'));
@@ -110,6 +134,9 @@ class SelectAll {
110
134
 
111
135
  static updateDiv(div) {
112
136
  div = toEl(div);
137
+ if (!div) {
138
+ return;
139
+ }
113
140
  // 22/11/2021 : rajout :not(.check_all) sinon si toutes les cases sont coché, la case select all n'est pas coché à l'initialisation
114
141
  const allCheckbox = div.querySelectorAll('div.checkbox input[type="checkbox"]:not(.check_all), div.form-check input[type="checkbox"]:not(.check_all)');
115
142
  const allCheckboxChecked = div.querySelectorAll('div.checkbox input[type="checkbox"]:not(.check_all):checked, div.form-check input[type="checkbox"]:not(.check_all):checked');
@@ -3,8 +3,6 @@
3
3
  */
4
4
  const { Chartjs } = require('../chartjs');
5
5
 
6
- // ─── chart creation helpers ──────────────────────────────────────────────────
7
-
8
6
  let mockChartInstance;
9
7
 
10
8
  beforeEach(() => {
@@ -13,11 +11,14 @@ beforeEach(() => {
13
11
  mockChartInstance.config = config;
14
12
  return mockChartInstance;
15
13
  });
14
+ global.Chart.register = jest.fn();
15
+ Chartjs.initialized = false;
16
16
  HTMLCanvasElement.prototype.getContext = jest.fn(() => ({}));
17
17
  });
18
18
 
19
19
  afterEach(() => {
20
20
  delete global.Chart;
21
+ Chartjs.initialized = false;
21
22
  document.body.innerHTML = '';
22
23
  jest.restoreAllMocks();
23
24
  });
@@ -28,9 +29,150 @@ function makeCanvas() {
28
29
  return canvas;
29
30
  }
30
31
 
31
- // ─── groupByPeriod ───────────────────────────────────────────────────────────
32
-
33
32
  describe('Chartjs', () => {
33
+
34
+ describe('init', () => {
35
+ test('registers centerText plugin on first call', () => {
36
+ Chartjs.init();
37
+ expect(global.Chart.register).toHaveBeenCalledTimes(1);
38
+ expect(global.Chart.register).toHaveBeenCalledWith(
39
+ expect.objectContaining({ id: 'centerText' })
40
+ );
41
+ });
42
+
43
+ test('does not register plugin on subsequent calls', () => {
44
+ Chartjs.init();
45
+ global.Chart.register.mockClear();
46
+ Chartjs.init();
47
+ expect(global.Chart.register).not.toHaveBeenCalled();
48
+ });
49
+
50
+ test('sets Chartjs.initialized to true', () => {
51
+ Chartjs.init();
52
+ expect(Chartjs.initialized).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('createStackedChart', () => {
57
+ const chartData = { labels: ['A', 'B'], datasets: [{ label: 'X', data: [1, 2] }] };
58
+
59
+ test('clears div and calls Chart constructor with type bar', () => {
60
+ const canvas = makeCanvas();
61
+ canvas.innerHTML = '<span>old</span>';
62
+ Chartjs.createStackedChart(canvas, chartData);
63
+ expect(canvas.innerHTML).toBe('');
64
+ expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'bar' }));
65
+ });
66
+
67
+ test('merges user options', () => {
68
+ Chartjs.createStackedChart(makeCanvas(), chartData, null, { options: { responsive: false } });
69
+ expect(mockChartInstance.config.options.responsive).toBe(false);
70
+ });
71
+
72
+ test('title displayed when provided', () => {
73
+ Chartjs.createStackedChart(makeCanvas(), chartData, 'My Title');
74
+ expect(mockChartInstance.config.options.plugins.title.display).toBe(true);
75
+ expect(mockChartInstance.config.options.plugins.title.text).toBe('My Title');
76
+ });
77
+
78
+ test('title not displayed when null', () => {
79
+ Chartjs.createStackedChart(makeCanvas(), chartData, null);
80
+ expect(mockChartInstance.config.options.plugins.title.display).toBe(false);
81
+ });
82
+
83
+ test('tooltip label callback', () => {
84
+ Chartjs.createStackedChart(makeCanvas(), chartData);
85
+ const fn = mockChartInstance.config.options.plugins.tooltip.callbacks.label;
86
+ expect(fn({ dataset: { label: 'Test' }, parsed: { y: 42 } })).toBe('Test: 42');
87
+ });
88
+
89
+ test('accepts jQuery-like object', () => {
90
+ const canvas = makeCanvas();
91
+ const jq = { jquery: '3.6.0', 0: canvas, length: 1 };
92
+ expect(() => Chartjs.createStackedChart(jq, chartData)).not.toThrow();
93
+ expect(global.Chart).toHaveBeenCalled();
94
+ });
95
+ });
96
+
97
+ describe('createBarChart', () => {
98
+ const chartData = { labels: ['A', 'B'], datasets: [{ label: 'X', data: [1, 2] }] };
99
+
100
+ test('clears div and calls Chart constructor with type bar', () => {
101
+ const canvas = makeCanvas();
102
+ canvas.innerHTML = '<span>old</span>';
103
+ Chartjs.createBarChart(canvas, chartData);
104
+ expect(canvas.innerHTML).toBe('');
105
+ expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'bar' }));
106
+ });
107
+
108
+ test('title displayed when provided', () => {
109
+ Chartjs.createBarChart(makeCanvas(), chartData, 'My Title');
110
+ expect(mockChartInstance.config.options.plugins.title.display).toBe(true);
111
+ expect(mockChartInstance.config.options.plugins.title.text).toBe('My Title');
112
+ });
113
+
114
+ test('title not displayed when null', () => {
115
+ Chartjs.createBarChart(makeCanvas(), chartData, null);
116
+ expect(mockChartInstance.config.options.plugins.title.display).toBe(false);
117
+ });
118
+
119
+ test('tooltip label callback', () => {
120
+ Chartjs.createBarChart(makeCanvas(), chartData);
121
+ const fn = mockChartInstance.config.options.plugins.tooltip.callbacks.label;
122
+ expect(fn({ dataset: { label: 'Test' }, parsed: { y: 10 } })).toBe('Test : 10');
123
+ });
124
+
125
+ test('accepts jQuery-like object', () => {
126
+ const canvas = makeCanvas();
127
+ const jq = { jquery: '3.6.0', 0: canvas, length: 1 };
128
+ expect(() => Chartjs.createBarChart(jq, chartData)).not.toThrow();
129
+ expect(global.Chart).toHaveBeenCalled();
130
+ });
131
+ });
132
+
133
+ describe('createLineChart', () => {
134
+ const chartData = { labels: ['A', 'B'], datasets: [{ label: 'X', data: [1, 2] }] };
135
+
136
+ test('clears div and calls Chart constructor with type line', () => {
137
+ const canvas = makeCanvas();
138
+ Chartjs.createLineChart(canvas, chartData);
139
+ expect(canvas.innerHTML).toBe('');
140
+ expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'line' }));
141
+ });
142
+
143
+ test('tooltip label callback', () => {
144
+ Chartjs.createLineChart(makeCanvas(), chartData);
145
+ const fn = mockChartInstance.config.options.plugins.tooltip.callbacks.label;
146
+ expect(fn({ dataset: { label: 'Line' }, parsed: { y: 7 } })).toBe('Line : 7');
147
+ });
148
+ });
149
+
150
+ describe('createDoughnutChart', () => {
151
+ test('clears div and calls Chart constructor with type doughnut', () => {
152
+ const canvas = makeCanvas();
153
+ Chartjs.createDoughnutChart(canvas, { labels: ['A'], values: [1], colors: ['#f00'] });
154
+ expect(canvas.innerHTML).toBe('');
155
+ expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'doughnut' }));
156
+ });
157
+
158
+ test('uses chartData.values as dataset data', () => {
159
+ Chartjs.createDoughnutChart(makeCanvas(), { labels: ['A', 'B'], values: [30, 70], colors: ['#f00', '#00f'] });
160
+ expect(mockChartInstance.config.data.datasets[0].data).toEqual([30, 70]);
161
+ });
162
+
163
+ test('tooltip label shows percentage', () => {
164
+ Chartjs.createDoughnutChart(makeCanvas(), { labels: ['A', 'B'], values: [30, 70], colors: ['#f00', '#00f'] });
165
+ const fn = mockChartInstance.config.options.plugins.tooltip.callbacks.label;
166
+ expect(fn({ label: 'A', raw: 30, dataset: { data: [30, 70] } })).toBe('A: 30 (30.0%)');
167
+ });
168
+
169
+ test('tooltip label for 100%', () => {
170
+ Chartjs.createDoughnutChart(makeCanvas(), { labels: ['Only'], values: [100], colors: ['#f00'] });
171
+ const fn = mockChartInstance.config.options.plugins.tooltip.callbacks.label;
172
+ expect(fn({ label: 'Only', raw: 100, dataset: { data: [100] } })).toBe('Only: 100 (100.0%)');
173
+ });
174
+ });
175
+
34
176
  describe('groupByPeriod', () => {
35
177
  test('should group data by day (default)', () => {
36
178
  const data = {
@@ -38,10 +180,7 @@ describe('Chartjs', () => {
38
180
  '2024-01-16': { views: 20, clicks: 8 },
39
181
  '2024-01-17': { views: 15, clicks: 6 }
40
182
  };
41
- const metrics = ['views', 'clicks'];
42
-
43
- const result = Chartjs.groupByPeriod(data, 'day', metrics);
44
-
183
+ const result = Chartjs.groupByPeriod(data, 'day', ['views', 'clicks']);
45
184
  expect(result).toHaveLength(3);
46
185
  expect(result[0]).toEqual({ label: '2024-01-15', views: 10, clicks: 5 });
47
186
  expect(result[1]).toEqual({ label: '2024-01-16', views: 20, clicks: 8 });
@@ -54,17 +193,12 @@ describe('Chartjs', () => {
54
193
  '2024-01-20': { views: 20, clicks: 8 },
55
194
  '2024-02-05': { views: 15, clicks: 6 }
56
195
  };
57
- const metrics = ['views', 'clicks'];
58
-
59
- const result = Chartjs.groupByPeriod(data, 'month', metrics);
60
-
196
+ const result = Chartjs.groupByPeriod(data, 'month', ['views', 'clicks']);
61
197
  expect(result).toHaveLength(2);
62
198
  expect(result[0].label).toBe('2024-01');
63
199
  expect(result[0].views).toBe(15);
64
200
  expect(result[0].clicks).toBe(6.5);
65
201
  expect(result[1].label).toBe('2024-02');
66
- expect(result[1].views).toBe(15);
67
- expect(result[1].clicks).toBe(6);
68
202
  });
69
203
 
70
204
  test('should group data by week', () => {
@@ -74,10 +208,7 @@ describe('Chartjs', () => {
74
208
  '2024-01-08': { views: 15 },
75
209
  '2024-01-09': { views: 25 }
76
210
  };
77
- const metrics = ['views'];
78
-
79
- const result = Chartjs.groupByPeriod(data, 'week', metrics);
80
-
211
+ const result = Chartjs.groupByPeriod(data, 'week', ['views']);
81
212
  expect(result.length).toBeGreaterThan(0);
82
213
  result.forEach(item => {
83
214
  expect(item.label).toMatch(/^\d{4}-S\d+$/);
@@ -85,34 +216,20 @@ describe('Chartjs', () => {
85
216
  });
86
217
 
87
218
  test('should handle single metric', () => {
88
- const data = {
89
- '2024-01-15': { views: 10 },
90
- '2024-01-16': { views: 20 }
91
- };
92
- const metrics = ['views'];
93
-
94
- const result = Chartjs.groupByPeriod(data, 'day', metrics);
95
-
219
+ const data = { '2024-01-15': { views: 10 }, '2024-01-16': { views: 20 } };
220
+ const result = Chartjs.groupByPeriod(data, 'day', ['views']);
96
221
  expect(result).toHaveLength(2);
97
222
  expect(result[0]).toEqual({ label: '2024-01-15', views: 10 });
98
- expect(result[1]).toEqual({ label: '2024-01-16', views: 20 });
99
223
  });
100
224
 
101
225
  test('should handle multiple metrics', () => {
102
- const data = {
103
- '2024-01-15': { views: 10, clicks: 5, conversions: 2 }
104
- };
105
- const metrics = ['views', 'clicks', 'conversions'];
106
-
107
- const result = Chartjs.groupByPeriod(data, 'day', metrics);
108
-
109
- expect(result).toHaveLength(1);
226
+ const data = { '2024-01-15': { views: 10, clicks: 5, conversions: 2 } };
227
+ const result = Chartjs.groupByPeriod(data, 'day', ['views', 'clicks', 'conversions']);
110
228
  expect(result[0]).toEqual({ label: '2024-01-15', views: 10, clicks: 5, conversions: 2 });
111
229
  });
112
230
 
113
231
  test('should handle empty data', () => {
114
- const result = Chartjs.groupByPeriod({}, 'day', ['views']);
115
- expect(result).toEqual([]);
232
+ expect(Chartjs.groupByPeriod({}, 'day', ['views'])).toEqual([]);
116
233
  });
117
234
 
118
235
  test('should average values when grouping by month', () => {
@@ -122,37 +239,42 @@ describe('Chartjs', () => {
122
239
  '2024-01-20': { score: 300 }
123
240
  };
124
241
  const result = Chartjs.groupByPeriod(data, 'month', ['score']);
125
-
126
- expect(result).toHaveLength(1);
127
- expect(result[0].label).toBe('2024-01');
128
242
  expect(result[0].score).toBe(200);
129
243
  });
130
244
 
131
245
  test('should handle missing metric values', () => {
132
- const data = {
133
- '2024-01-15': { views: 10 },
134
- '2024-01-16': { views: 20, clicks: 5 }
135
- };
246
+ const data = { '2024-01-15': { views: 10 }, '2024-01-16': { views: 20, clicks: 5 } };
136
247
  const result = Chartjs.groupByPeriod(data, 'day', ['views', 'clicks']);
137
-
138
- expect(result).toHaveLength(2);
139
248
  expect(result[0]).toEqual({ label: '2024-01-15', views: 10, clicks: NaN });
140
249
  expect(result[1]).toEqual({ label: '2024-01-16', views: 20, clicks: 5 });
141
250
  });
142
251
 
143
252
  test('should handle dates across different years', () => {
144
- const data = {
145
- '2023-12-30': { count: 5 },
146
- '2024-01-02': { count: 10 }
147
- };
253
+ const data = { '2023-12-30': { count: 5 }, '2024-01-02': { count: 10 } };
148
254
  const result = Chartjs.groupByPeriod(data, 'month', ['count']);
149
-
150
- expect(result).toHaveLength(2);
151
255
  expect(result[0].label).toBe('2023-12');
152
256
  expect(result[1].label).toBe('2024-01');
153
257
  });
154
258
  });
155
259
 
260
+ describe('getPeriodLabels', () => {
261
+ const { DatePeriod } = require('../date_time');
262
+
263
+ test('delegates to DatePeriod.getPeriodLabels with default locale/timezone', () => {
264
+ const spy = jest.spyOn(DatePeriod, 'getPeriodLabels');
265
+ const data = { '2024-01-01': {}, '2024-02-01': {} };
266
+ Chartjs.getPeriodLabels(data, 'month');
267
+ expect(spy).toHaveBeenCalledWith(Object.keys(data), 'month', 'fr-FR', 'Europe/Paris');
268
+ });
269
+
270
+ test('passes custom locale and timezone', () => {
271
+ const spy = jest.spyOn(DatePeriod, 'getPeriodLabels');
272
+ const data = { '2024-01-01': {} };
273
+ Chartjs.getPeriodLabels(data, 'month', 'en-US', 'America/New_York');
274
+ expect(spy).toHaveBeenCalledWith(Object.keys(data), 'month', 'en-US', 'America/New_York');
275
+ });
276
+ });
277
+
156
278
  describe('getAutoGranularity', () => {
157
279
  test('should return day_of_month for data spanning 30 days or less', () => {
158
280
  expect(Chartjs.getAutoGranularity({ '2024-01-01': {}, '2024-01-15': {}, '2024-01-30': {} })).toBe('day_of_month');
@@ -191,99 +313,4 @@ describe('Chartjs', () => {
191
313
  });
192
314
  });
193
315
 
194
- describe('chart creation', () => {
195
- const chartData = { labels: ['A', 'B'], datasets: [{ label: 'X', data: [1, 2] }] };
196
-
197
- test('createStackedChart clears div and calls Chart constructor', () => {
198
- const canvas = makeCanvas();
199
- canvas.innerHTML = '<span>old</span>';
200
-
201
- Chartjs.createStackedChart(canvas, chartData);
202
-
203
- expect(canvas.innerHTML).toBe('');
204
- expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'bar' }));
205
- });
206
-
207
- test('createStackedChart merges user options', () => {
208
- Chartjs.createStackedChart(makeCanvas(), chartData, null, { options: { responsive: false } });
209
-
210
- expect(mockChartInstance.config.options.responsive).toBe(false);
211
- });
212
-
213
- test('createBarChart clears div and calls Chart constructor', () => {
214
- const canvas = makeCanvas();
215
- canvas.innerHTML = '<span>old</span>';
216
-
217
- Chartjs.createBarChart(canvas, chartData);
218
-
219
- expect(canvas.innerHTML).toBe('');
220
- expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'bar' }));
221
- });
222
-
223
- test('createBarChart sets title when provided', () => {
224
- Chartjs.createBarChart(makeCanvas(), chartData, 'My Title');
225
-
226
- expect(mockChartInstance.config.options.plugins.title.display).toBe(true);
227
- expect(mockChartInstance.config.options.plugins.title.text).toBe('My Title');
228
- });
229
-
230
- test('createLineChart clears div and calls Chart constructor', () => {
231
- const canvas = makeCanvas();
232
- Chartjs.createLineChart(canvas, chartData);
233
-
234
- expect(canvas.innerHTML).toBe('');
235
- expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'line' }));
236
- });
237
-
238
- test('createDoughnutChart clears div and calls Chart constructor', () => {
239
- const canvas = makeCanvas();
240
- Chartjs.createDoughnutChart(canvas, { labels: ['A'], values: [1], colors: ['#f00'] });
241
-
242
- expect(canvas.innerHTML).toBe('');
243
- expect(global.Chart).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: 'doughnut' }));
244
- });
245
-
246
- test('createDoughnutChart uses chartData.values as dataset data', () => {
247
- Chartjs.createDoughnutChart(makeCanvas(), { labels: ['A', 'B'], values: [30, 70], colors: ['#f00', '#00f'] });
248
-
249
- expect(mockChartInstance.config.data.datasets[0].data).toEqual([30, 70]);
250
- });
251
-
252
- test('title not displayed when null', () => {
253
- Chartjs.createBarChart(makeCanvas(), chartData, null);
254
-
255
- expect(mockChartInstance.config.options.plugins.title.display).toBe(false);
256
- });
257
-
258
- describe('jQuery compatibility (toEl)', () => {
259
- test('createStackedChart accepts jQuery-like object', () => {
260
- const canvas = makeCanvas();
261
- const jq = { jquery: '3.6.0', 0: canvas, length: 1 };
262
-
263
- expect(() => Chartjs.createStackedChart(jq, chartData)).not.toThrow();
264
- expect(global.Chart).toHaveBeenCalled();
265
- });
266
-
267
- test('createBarChart accepts jQuery-like object', () => {
268
- const canvas = makeCanvas();
269
- const jq = { jquery: '3.6.0', 0: canvas, length: 1 };
270
-
271
- expect(() => Chartjs.createBarChart(jq, chartData)).not.toThrow();
272
- expect(global.Chart).toHaveBeenCalled();
273
- });
274
- });
275
- });
276
-
277
- describe('class structure', () => {
278
- test('should have all expected static methods', () => {
279
- expect(typeof Chartjs.init).toBe('function');
280
- expect(typeof Chartjs.createStackedChart).toBe('function');
281
- expect(typeof Chartjs.createBarChart).toBe('function');
282
- expect(typeof Chartjs.createLineChart).toBe('function');
283
- expect(typeof Chartjs.createDoughnutChart).toBe('function');
284
- expect(typeof Chartjs.groupByPeriod).toBe('function');
285
- expect(typeof Chartjs.getPeriodLabels).toBe('function');
286
- expect(typeof Chartjs.getAutoGranularity).toBe('function');
287
- });
288
- });
289
316
  });
@@ -2,6 +2,7 @@
2
2
  * @jest-environment jsdom
3
3
  */
4
4
  require('../array'); // For removeEmptyValues method
5
+ require('../string'); // For normalizeBreaks method
5
6
  const { FormHelper, ArrayField, EditValue } = require('../form_helper');
6
7
 
7
8
  // Helper functions
@@ -43,9 +44,15 @@ function addSelect(form, name, options = []) {
43
44
  return select;
44
45
  }
45
46
 
47
+ // Simule un objet jQuery wrappant un élément DOM natif
48
+ function mockJQuery(el) {
49
+ return { jquery: '3.0', 0: el, length: 1 };
50
+ }
51
+
46
52
  afterEach(() => {
47
53
  document.body.innerHTML = '';
48
54
  jest.clearAllMocks();
55
+ delete global.$;
49
56
  });
50
57
 
51
58
  describe('FormHelper', () => {
@@ -88,6 +95,18 @@ describe('FormHelper', () => {
88
95
  expect(onSubmitCallback).toHaveBeenCalledWith(form, customBtn);
89
96
  });
90
97
 
98
+ test('should return jQuery wrapper when passed a jQuery object', () => {
99
+ const form = setupForm();
100
+ addButton(form, 'validate', 'Submit');
101
+ const jqForm = mockJQuery(form);
102
+ global.$ = jest.fn(el => mockJQuery(el));
103
+
104
+ const result = FormHelper.init(jqForm, jest.fn());
105
+
106
+ expect(result).toHaveProperty('jquery');
107
+ expect(result[0]).toBe(form);
108
+ });
109
+
91
110
  test('should call buttonLoader with loading on submit', () => {
92
111
  const form = setupForm();
93
112
  const btn = addButton(form, 'validate', 'Submit');
@@ -521,6 +540,17 @@ describe('FormHelper', () => {
521
540
  expect(form.querySelectorAll('div.form_errors').length).toBe(0);
522
541
  expect(result).toBe(form);
523
542
  });
543
+
544
+ test('should return jQuery wrapper when passed a jQuery object', () => {
545
+ const form = setupForm();
546
+ const jqForm = mockJQuery(form);
547
+ global.$ = jest.fn(el => mockJQuery(el));
548
+
549
+ const result = FormHelper.hideFormErrors(jqForm);
550
+
551
+ expect(result).toHaveProperty('jquery');
552
+ expect(result[0]).toBe(form);
553
+ });
524
554
  });
525
555
 
526
556
  describe('displayFormErrorsFromText', () => {
@@ -909,23 +939,306 @@ describe('FormHelper', () => {
909
939
  });
910
940
 
911
941
  describe('ArrayField', () => {
912
- test('should be a class', () => {
913
- expect(typeof ArrayField).toBe('function');
914
- expect(typeof ArrayField.init).toBe('function');
942
+ let container;
943
+
944
+ beforeEach(() => {
945
+ container = document.createElement('div');
946
+ document.body.appendChild(container);
947
+ });
948
+
949
+ afterEach(() => {
950
+ document.body.innerHTML = '';
951
+ });
952
+
953
+ function makeOptions(overrides = {}) {
954
+ return Object.assign({
955
+ entering_field_in_table: false,
956
+ add_one_button_enabled: true,
957
+ add_multi_button_enabled: false,
958
+ input_name: 'items[]',
959
+ item_name: 'Item',
960
+ }, overrides);
961
+ }
962
+
963
+ // jsdom applies HTML5 foster-parenting: <tr> inside <div>.innerHTML gets stripped.
964
+ // ArrayField.init falls back to cloneNode when a .base template row exists,
965
+ // so we pre-populate the container with a base row to make row tests work.
966
+ function addBaseRow(opts = {}) {
967
+ const enteringInTable = opts.entering_field_in_table ?? false;
968
+ const inputName = opts.input_name ?? 'items[]';
969
+ let tdContent, links;
970
+ if (enteringInTable) {
971
+ tdContent = `<input type="text" name="${inputName}" class="form-control">`;
972
+ links = '<a href="#" class="add btn btn-sm btn-success ms-1"></a><a href="#" class="remove btn btn-sm btn-danger ms-1"></a>';
973
+ } else {
974
+ tdContent = `<input type="hidden" name="${inputName}"> <span class="value"></span>`;
975
+ links = '<a href="#" class="remove btn btn-sm btn-danger ms-1"></a>';
976
+ }
977
+ container.innerHTML = `<table class="table table-sm"><tbody><tr class="base hide"><td class="table-input">${tdContent}</td><td class="table-links">${links}</td></tr></tbody></table>`;
978
+ }
979
+
980
+ test('creates table and list_empty if not present', () => {
981
+ ArrayField.init(container, [], makeOptions());
982
+ expect(container.querySelector('table')).not.toBeNull();
983
+ expect(container.querySelector('.list_empty')).not.toBeNull();
984
+ });
985
+
986
+ test('shows list_empty and hides table when no items', () => {
987
+ ArrayField.init(container, [], makeOptions());
988
+ expect(container.querySelector('.list_empty').classList.contains('hide')).toBe(false);
989
+ expect(container.querySelector('table').classList.contains('hide')).toBe(true);
990
+ });
991
+
992
+ test('populates default values as rows', () => {
993
+ addBaseRow();
994
+ ArrayField.init(container, ['foo', 'bar'], makeOptions());
995
+ const rows = container.querySelectorAll('table tbody tr:not(.base)');
996
+ expect(rows).toHaveLength(2);
997
+ });
998
+
999
+ test('table is visible when items are present', () => {
1000
+ addBaseRow();
1001
+ ArrayField.init(container, ['item1'], makeOptions());
1002
+ expect(container.querySelector('table').classList.contains('hide')).toBe(false);
1003
+ expect(container.querySelector('.list_empty').classList.contains('hide')).toBe(true);
1004
+ });
1005
+
1006
+ test('row contains hidden input and span.value for entering_field_in_table=false', () => {
1007
+ addBaseRow({ entering_field_in_table: false });
1008
+ ArrayField.init(container, ['hello'], makeOptions({ entering_field_in_table: false }));
1009
+ const row = container.querySelector('table tbody tr:not(.base)');
1010
+ expect(row.querySelector('input[type="hidden"]')).not.toBeNull();
1011
+ expect(row.querySelector('span.value')).not.toBeNull();
1012
+ expect(row.querySelector('input[type="hidden"]').value).toBe('hello');
1013
+ expect(row.querySelector('span.value').textContent).toBe('hello');
1014
+ });
1015
+
1016
+ test('row contains text input for entering_field_in_table=true', () => {
1017
+ addBaseRow({ entering_field_in_table: true });
1018
+ ArrayField.init(container, ['hello'], makeOptions({ entering_field_in_table: true, nb_min_lines: 0 }));
1019
+ const row = container.querySelector('table tbody tr:not(.base)');
1020
+ expect(row.querySelector('input[type="text"]')).not.toBeNull();
1021
+ });
1022
+
1023
+ test('creates add_one button when add_one_button_enabled', () => {
1024
+ ArrayField.init(container, [], makeOptions({ add_one_button_enabled: true }));
1025
+ expect(container.querySelector('a.add_one')).not.toBeNull();
1026
+ });
1027
+
1028
+ test('does not create add_one button when disabled', () => {
1029
+ ArrayField.init(container, [], makeOptions({ add_one_button_enabled: false }));
1030
+ expect(container.querySelector('a.add_one')).toBeNull();
1031
+ });
1032
+
1033
+ test('clicking add_one shows item_add_one div', () => {
1034
+ ArrayField.init(container, [], makeOptions({ add_one_button_enabled: true }));
1035
+ container.querySelector('a.add_one').click();
1036
+ expect(container.querySelector('.item_add_one').classList.contains('hide')).toBe(false);
1037
+ });
1038
+
1039
+ test('clicking cancel in add_one hides item_add_one div', () => {
1040
+ ArrayField.init(container, [], makeOptions({ add_one_button_enabled: true }));
1041
+ container.querySelector('a.add_one').click();
1042
+ container.querySelector('.item_add_one a.cancel').click();
1043
+ expect(container.querySelector('.item_add_one').classList.contains('hide')).toBe(true);
1044
+ });
1045
+
1046
+ test('clicking add in item_add_one adds a new row', () => {
1047
+ addBaseRow();
1048
+ ArrayField.init(container, [], makeOptions({ add_one_button_enabled: true }));
1049
+ container.querySelector('a.add_one').click();
1050
+ container.querySelector('.item_add_one input.form-control').value = 'newitem';
1051
+ container.querySelector('.item_add_one a.add').click();
1052
+ const rows = container.querySelectorAll('table tbody tr:not(.base)');
1053
+ expect(rows).toHaveLength(1);
1054
+ expect(rows[0].querySelector('span.value').textContent).toBe('newitem');
1055
+ });
1056
+
1057
+ test('remove button removes a row', () => {
1058
+ addBaseRow();
1059
+ ArrayField.init(container, ['a', 'b'], makeOptions());
1060
+ expect(container.querySelectorAll('table tbody tr:not(.base)')).toHaveLength(2);
1061
+ container.querySelector('table tbody tr:not(.base) a.remove').click();
1062
+ expect(container.querySelectorAll('table tbody tr:not(.base)')).toHaveLength(1);
1063
+ });
1064
+
1065
+ test('calls update_list_callback on changes', () => {
1066
+ addBaseRow();
1067
+ const cb = jest.fn();
1068
+ ArrayField.init(container, ['x'], makeOptions({ update_list_callback: cb }));
1069
+ expect(cb).toHaveBeenCalled();
1070
+ });
1071
+
1072
+ test('calls init_callback with container after init', () => {
1073
+ const cb = jest.fn();
1074
+ ArrayField.init(container, [], makeOptions({ init_callback: cb }));
1075
+ expect(cb).toHaveBeenCalledWith(container, expect.any(Function), expect.any(Function));
1076
+ });
1077
+
1078
+ test('uses custom list_empty_text', () => {
1079
+ ArrayField.init(container, [], makeOptions({ list_empty_text: 'Nothing here' }));
1080
+ expect(container.querySelector('.list_empty').textContent).toBe('Nothing here');
1081
+ });
1082
+
1083
+ test('calls get_errors_callback and shows errors on invalid input', () => {
1084
+ addBaseRow();
1085
+ ArrayField.init(container, [], makeOptions({
1086
+ add_one_button_enabled: true,
1087
+ get_errors_callback: () => ['Invalid value'],
1088
+ }));
1089
+ container.querySelector('a.add_one').click();
1090
+ container.querySelector('.item_add_one input.form-control').value = 'bad';
1091
+ container.querySelector('.item_add_one a.add').click();
1092
+ expect(container.querySelectorAll('table tbody tr:not(.base)')).toHaveLength(0);
1093
+ });
1094
+
1095
+ test('does not add duplicate items', () => {
1096
+ addBaseRow();
1097
+ ArrayField.init(container, ['dup'], makeOptions({ add_one_button_enabled: true }));
1098
+ container.querySelector('a.add_one').click();
1099
+ container.querySelector('.item_add_one input.form-control').value = 'dup';
1100
+ container.querySelector('.item_add_one a.add').click();
1101
+ expect(container.querySelectorAll('table tbody tr:not(.base)')).toHaveLength(1);
1102
+ });
1103
+
1104
+ test('applies nb_max_lines limit: disables add button when reached', () => {
1105
+ addBaseRow({ entering_field_in_table: true });
1106
+ ArrayField.init(container, ['a', 'b'], makeOptions({
1107
+ entering_field_in_table: true,
1108
+ nb_max_lines: 2,
1109
+ nb_min_lines: 0,
1110
+ }));
1111
+ const addLinks = container.querySelectorAll('table tbody tr:not(.base) a.add');
1112
+ addLinks.forEach(a => expect(a.classList.contains('disabled')).toBe(true));
1113
+ });
1114
+
1115
+ test('creates add_multi button when add_multi_button_enabled', () => {
1116
+ ArrayField.init(container, [], makeOptions({ add_multi_button_enabled: true, add_one_button_enabled: false }));
1117
+ expect(container.querySelector('a.add_multi')).not.toBeNull();
915
1118
  });
916
1119
 
917
- test('init should be a static method', () => {
918
- expect(typeof ArrayField.init).toBe('function');
1120
+ test('clicking add_multi shows item_add_multi div', () => {
1121
+ ArrayField.init(container, [], makeOptions({ add_multi_button_enabled: true, add_one_button_enabled: false }));
1122
+ container.querySelector('a.add_multi').click();
1123
+ expect(container.querySelector('.item_add_multi').classList.contains('hide')).toBe(false);
1124
+ });
1125
+
1126
+ test('clicking cancel in add_multi hides item_add_multi', () => {
1127
+ ArrayField.init(container, [], makeOptions({ add_multi_button_enabled: true, add_one_button_enabled: false }));
1128
+ container.querySelector('a.add_multi').click();
1129
+ container.querySelector('.item_add_multi a.cancel').click();
1130
+ expect(container.querySelector('.item_add_multi').classList.contains('hide')).toBe(true);
1131
+ });
1132
+
1133
+ test('add_multi adds multiple items from textarea', () => {
1134
+ addBaseRow();
1135
+ ArrayField.init(container, [], makeOptions({ add_multi_button_enabled: true, add_one_button_enabled: false }));
1136
+ container.querySelector('a.add_multi').click();
1137
+ container.querySelector('.item_add_multi textarea').value = 'alpha\nbeta\ngamma';
1138
+ container.querySelector('.item_add_multi a.add').click();
1139
+ expect(container.querySelectorAll('table tbody tr:not(.base)')).toHaveLength(3);
1140
+ });
1141
+
1142
+ test('applies format_entered_value_callback on add', () => {
1143
+ addBaseRow();
1144
+ ArrayField.init(container, [], makeOptions({
1145
+ add_one_button_enabled: true,
1146
+ format_entered_value_callback: v => v.toUpperCase(),
1147
+ }));
1148
+ container.querySelector('a.add_one').click();
1149
+ container.querySelector('.item_add_one input.form-control').value = 'hello';
1150
+ container.querySelector('.item_add_one a.add').click();
1151
+ const row = container.querySelector('table tbody tr:not(.base)');
1152
+ expect(row.querySelector('span.value').textContent).toBe('HELLO');
1153
+ });
1154
+
1155
+ test('entering_field_in_table: remove disabled when only 1 row remains', () => {
1156
+ addBaseRow({ entering_field_in_table: true });
1157
+ ArrayField.init(container, ['only'], makeOptions({ entering_field_in_table: true, nb_min_lines: 0 }));
1158
+ const removeLink = container.querySelector('table tbody tr:not(.base) a.remove');
1159
+ expect(removeLink.classList.contains('disabled')).toBe(true);
919
1160
  });
920
1161
  });
921
1162
 
922
1163
  describe('EditValue', () => {
923
- test('should be a class', () => {
924
- expect(typeof EditValue).toBe('function');
925
- expect(typeof EditValue.init).toBe('function');
1164
+ let valueDiv, parent;
1165
+
1166
+ beforeEach(() => {
1167
+ parent = document.createElement('div');
1168
+ valueDiv = document.createElement('span');
1169
+ valueDiv.textContent = 'original';
1170
+ parent.appendChild(valueDiv);
1171
+ document.body.appendChild(parent);
1172
+ });
1173
+
1174
+ afterEach(() => {
1175
+ document.body.innerHTML = '';
1176
+ });
1177
+
1178
+ test('appends a pencil link next to valueDiv', () => {
1179
+ EditValue.init(valueDiv, jest.fn());
1180
+ expect(parent.querySelector('a')).not.toBeNull();
1181
+ });
1182
+
1183
+ test('clicking pencil link hides spans and links and shows a form', () => {
1184
+ EditValue.init(valueDiv, jest.fn());
1185
+ parent.querySelector('a').click();
1186
+ expect(parent.querySelector('form')).not.toBeNull();
1187
+ expect(valueDiv.classList.contains('hide')).toBe(true);
1188
+ });
1189
+
1190
+ test('form contains input pre-filled with current text', () => {
1191
+ valueDiv.textContent = 'current value';
1192
+ EditValue.init(valueDiv, jest.fn());
1193
+ parent.querySelector('a').click();
1194
+ const input = parent.querySelector('form input');
1195
+ expect(input.value).toBe('current value');
1196
+ });
1197
+
1198
+ test('form uses data-value attribute when present', () => {
1199
+ valueDiv.dataset.value = 'raw-value';
1200
+ valueDiv.textContent = 'Formatted value';
1201
+ EditValue.init(valueDiv, jest.fn());
1202
+ parent.querySelector('a').click();
1203
+ expect(parent.querySelector('form input').value).toBe('raw-value');
1204
+ });
1205
+
1206
+ test('calls onSubmitCallback with new value when submit button clicked', () => {
1207
+ const cb = jest.fn();
1208
+ EditValue.init(valueDiv, cb);
1209
+ parent.querySelector('a').click();
1210
+ parent.querySelector('form input').value = 'new val';
1211
+ parent.querySelector('form button').click();
1212
+ expect(cb).toHaveBeenCalledWith('new val', parent, expect.any(Function));
1213
+ });
1214
+
1215
+ test('on success callback updates span value', () => {
1216
+ let capturedCallback;
1217
+ const cb = jest.fn((newVal, par, done) => { capturedCallback = done; });
1218
+ EditValue.init(valueDiv, cb);
1219
+ parent.querySelector('a').click();
1220
+ parent.querySelector('form input').value = 'updated';
1221
+ parent.querySelector('form button').click();
1222
+ capturedCallback(true);
1223
+ expect(valueDiv.textContent).toBe('updated');
1224
+ });
1225
+
1226
+ test('on failure callback does not update span value', () => {
1227
+ let capturedCallback;
1228
+ const cb = jest.fn((newVal, par, done) => { capturedCallback = done; });
1229
+ EditValue.init(valueDiv, cb);
1230
+ parent.querySelector('a').click();
1231
+ parent.querySelector('form input').value = 'updated';
1232
+ parent.querySelector('form button').click();
1233
+ capturedCallback(false);
1234
+ expect(valueDiv.textContent).toBe('original');
926
1235
  });
927
1236
 
928
- test('init should be a static method', () => {
929
- expect(typeof EditValue.init).toBe('function');
1237
+ test('uses getInputCallback for custom input element', () => {
1238
+ const getInput = jest.fn(() => '<select><option value="x">X</option></select>');
1239
+ EditValue.init(valueDiv, jest.fn(), getInput);
1240
+ parent.querySelector('a').click();
1241
+ expect(parent.querySelector('form select')).not.toBeNull();
1242
+ expect(getInput).toHaveBeenCalled();
930
1243
  });
931
1244
  });
@@ -82,6 +82,10 @@ describe('SelectAll', () => {
82
82
  const formGroup = document.querySelector('.form-group');
83
83
  expect(() => SelectAll.updateFormGroup(formGroup)).not.toThrow();
84
84
  });
85
+
86
+ test('should do nothing when formGroup is null', () => {
87
+ expect(() => SelectAll.updateFormGroup(null)).not.toThrow();
88
+ });
85
89
  });
86
90
 
87
91
  describe('initLinkInFormGroup', () => {
@@ -130,6 +134,20 @@ describe('SelectAll', () => {
130
134
  });
131
135
  expect(formGroup.querySelector('a.check_all').textContent).toBe('Tout désélectionner');
132
136
  });
137
+
138
+ test('should not throw when checkbox has no .form-group ancestor', () => {
139
+ // Simule une checkbox hors de tout .form-group (le cas du bug en prod)
140
+ document.body.innerHTML = `
141
+ <div>
142
+ <a class="check_all" href="#">Tout sélectionner</a>
143
+ <input type="checkbox" id="orphan">
144
+ </div>`;
145
+ const link = document.querySelector('a.check_all');
146
+ SelectAll.initLinkInFormGroup(link);
147
+ const cb = document.getElementById('orphan');
148
+ cb.checked = true;
149
+ expect(() => cb.dispatchEvent(new Event('change'))).not.toThrow();
150
+ });
133
151
  });
134
152
 
135
153
  describe('updateTable', () => {
@@ -159,6 +177,10 @@ describe('SelectAll', () => {
159
177
  </table>`;
160
178
  expect(() => SelectAll.updateTable(document.querySelector('table'))).not.toThrow();
161
179
  });
180
+
181
+ test('should do nothing when table is null', () => {
182
+ expect(() => SelectAll.updateTable(null)).not.toThrow();
183
+ });
162
184
  });
163
185
 
164
186
  describe('initInTable', () => {
@@ -197,6 +219,10 @@ describe('SelectAll', () => {
197
219
  });
198
220
  expect(table.querySelector('thead input.check_all').checked).toBe(true);
199
221
  });
222
+
223
+ test('should do nothing when table is null', () => {
224
+ expect(() => SelectAll.initInTable(null)).not.toThrow();
225
+ });
200
226
  });
201
227
 
202
228
  describe('updateDiv', () => {
@@ -222,6 +248,10 @@ describe('SelectAll', () => {
222
248
  document.body.innerHTML = `<div class="checkbox_with_check_all"><div class="form-check"><input type="checkbox"></div></div>`;
223
249
  expect(() => SelectAll.updateDiv(document.querySelector('.checkbox_with_check_all'))).not.toThrow();
224
250
  });
251
+
252
+ test('should do nothing when div is null', () => {
253
+ expect(() => SelectAll.updateDiv(null)).not.toThrow();
254
+ });
225
255
  });
226
256
 
227
257
  describe('initDiv', () => {
@@ -270,5 +300,23 @@ describe('SelectAll', () => {
270
300
  SelectAll.initDiv(contentDiv);
271
301
  expect(contentDiv.querySelector('input.check_all').checked).toBe(true);
272
302
  });
303
+
304
+ test('should do nothing when contentDiv is null', () => {
305
+ expect(() => SelectAll.initDiv(null)).not.toThrow();
306
+ });
307
+
308
+ test('should not throw when checkbox has no .checkbox_with_check_all ancestor', () => {
309
+ // check_all orpheline — closest('div.checkbox_with_check_all') retourne null
310
+ document.body.innerHTML = `
311
+ <div id="content">
312
+ <input type="checkbox" class="check_all">
313
+ <div class="form-check"><input type="checkbox" id="orphan"></div>
314
+ </div>`;
315
+ const contentDiv = document.getElementById('content');
316
+ SelectAll.initDiv(contentDiv);
317
+ const cb = document.getElementById('orphan');
318
+ cb.checked = true;
319
+ expect(() => cb.dispatchEvent(new Event('change'))).not.toThrow();
320
+ });
273
321
  });
274
322
  });