volto-aemet 1.0.0-alpha.0

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.
Files changed (91) hide show
  1. package/.changelog.draft +3 -0
  2. package/.release-it.json +34 -0
  3. package/CHANGELOG.md +11 -0
  4. package/LICENSE +21 -0
  5. package/README.md +323 -0
  6. package/babel.config.js +17 -0
  7. package/locales/de/LC_MESSAGES/volto.po +83 -0
  8. package/locales/en/LC_MESSAGES/volto.po +83 -0
  9. package/locales/es/LC_MESSAGES/volto.po +90 -0
  10. package/locales/pt_BR/LC_MESSAGES/volto.po +88 -0
  11. package/locales/volto.pot +85 -0
  12. package/news/.gitkeep +0 -0
  13. package/package.json +47 -0
  14. package/public/.gitkeep +0 -0
  15. package/src/components/Aemet/Weather.js +289 -0
  16. package/src/components/Aemet/Weather.test.js +398 -0
  17. package/src/components/Aemet/WeatherCurrent.jsx +97 -0
  18. package/src/components/Aemet/WeatherCurrent.test.jsx +282 -0
  19. package/src/components/Aemet/index.js +9 -0
  20. package/src/components/Blocks/AemetWeatherCurrent/View.jsx +11 -0
  21. package/src/components/Blocks/AemetWeatherForecast/Edit.jsx +72 -0
  22. package/src/components/Blocks/AemetWeatherForecast/View.jsx +32 -0
  23. package/src/components/Blocks/AemetWeatherForecast/messages.js +36 -0
  24. package/src/components/Blocks/AemetWeatherForecast/schema.js +37 -0
  25. package/src/config/blocks.ts +70 -0
  26. package/src/config/settings.ts +16 -0
  27. package/src/icons/aemet-logo.svg +311 -0
  28. package/src/icons/weather/temps/11.png +0 -0
  29. package/src/icons/weather/temps/11n.png +0 -0
  30. package/src/icons/weather/temps/12.png +0 -0
  31. package/src/icons/weather/temps/12n.png +0 -0
  32. package/src/icons/weather/temps/13.png +0 -0
  33. package/src/icons/weather/temps/13n.png +0 -0
  34. package/src/icons/weather/temps/14.png +0 -0
  35. package/src/icons/weather/temps/14n.png +0 -0
  36. package/src/icons/weather/temps/15.png +0 -0
  37. package/src/icons/weather/temps/15n.png +0 -0
  38. package/src/icons/weather/temps/16.png +0 -0
  39. package/src/icons/weather/temps/16n.png +0 -0
  40. package/src/icons/weather/temps/17.png +0 -0
  41. package/src/icons/weather/temps/17n.png +0 -0
  42. package/src/icons/weather/temps/23.png +0 -0
  43. package/src/icons/weather/temps/23n.png +0 -0
  44. package/src/icons/weather/temps/24.png +0 -0
  45. package/src/icons/weather/temps/24n.png +0 -0
  46. package/src/icons/weather/temps/25.png +0 -0
  47. package/src/icons/weather/temps/25n.png +0 -0
  48. package/src/icons/weather/temps/26.png +0 -0
  49. package/src/icons/weather/temps/26n.png +0 -0
  50. package/src/icons/weather/temps/33.png +0 -0
  51. package/src/icons/weather/temps/33n.png +0 -0
  52. package/src/icons/weather/temps/34.png +0 -0
  53. package/src/icons/weather/temps/34n.png +0 -0
  54. package/src/icons/weather/temps/35.png +0 -0
  55. package/src/icons/weather/temps/35n.png +0 -0
  56. package/src/icons/weather/temps/36.png +0 -0
  57. package/src/icons/weather/temps/36n.png +0 -0
  58. package/src/icons/weather/temps/43.png +0 -0
  59. package/src/icons/weather/temps/43n.png +0 -0
  60. package/src/icons/weather/temps/44.png +0 -0
  61. package/src/icons/weather/temps/44n.png +0 -0
  62. package/src/icons/weather/temps/45.png +0 -0
  63. package/src/icons/weather/temps/45n.png +0 -0
  64. package/src/icons/weather/temps/46.png +0 -0
  65. package/src/icons/weather/temps/46n.png +0 -0
  66. package/src/icons/weather/temps/51.png +0 -0
  67. package/src/icons/weather/temps/51n.png +0 -0
  68. package/src/icons/weather/temps/52.png +0 -0
  69. package/src/icons/weather/temps/52n.png +0 -0
  70. package/src/icons/weather/temps/53.png +0 -0
  71. package/src/icons/weather/temps/54.png +0 -0
  72. package/src/icons/weather/temps/54n.png +0 -0
  73. package/src/icons/weather/temps/61.png +0 -0
  74. package/src/icons/weather/temps/61n.png +0 -0
  75. package/src/icons/weather/temps/62.png +0 -0
  76. package/src/icons/weather/temps/62n.png +0 -0
  77. package/src/icons/weather/temps/63.png +0 -0
  78. package/src/icons/weather/temps/63n.png +0 -0
  79. package/src/icons/weather/temps/64.png +0 -0
  80. package/src/icons/weather/temps/64n.png +0 -0
  81. package/src/icons/weather/temps/71.png +0 -0
  82. package/src/icons/weather/temps/71n.png +0 -0
  83. package/src/icons/weather/temps/72.png +0 -0
  84. package/src/icons/weather/temps/72n.png +0 -0
  85. package/src/icons/weather/temps/73.png +0 -0
  86. package/src/icons/weather/temps/74.png +0 -0
  87. package/src/index.ts +24 -0
  88. package/src/theme/components/_weather_current.scss +69 -0
  89. package/src/theme/main.scss +3 -0
  90. package/towncrier.toml +33 -0
  91. package/tsconfig.json +30 -0
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Unit tests for Weather.js shared logic.
3
+ * @module components/Aemet/Weather.test
4
+ */
5
+
6
+ import React from 'react';
7
+ import { renderHook, waitFor } from '@testing-library/react';
8
+ import { IntlProvider } from 'react-intl';
9
+ import moment from 'moment';
10
+
11
+ import { getWeatherIcon, weatherMessages, useWeatherData } from './Weather';
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** Wrap a hook under test with an IntlProvider (required by useIntl). */
18
+ const wrapper = ({ children }) => (
19
+ <IntlProvider locale="en" messages={{}}>
20
+ {children}
21
+ </IntlProvider>
22
+ );
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // weatherMessages
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe('weatherMessages', () => {
29
+ it('exports all required message keys', () => {
30
+ const requiredKeys = [
31
+ 'minimumMaximum',
32
+ 'currentDay',
33
+ 'currentLocation',
34
+ 'currentTemperature',
35
+ 'currentTime',
36
+ 'loadingCurrent',
37
+ 'weatherNotAvailable',
38
+ 'weatherDescription',
39
+ ];
40
+ requiredKeys.forEach((key) => {
41
+ expect(weatherMessages).toHaveProperty(key);
42
+ });
43
+ });
44
+
45
+ it('every message has a non-empty string id and defaultMessage', () => {
46
+ Object.values(weatherMessages).forEach((msg) => {
47
+ expect(typeof msg.id).toBe('string');
48
+ expect(msg.id.length).toBeGreaterThan(0);
49
+ expect(typeof msg.defaultMessage).toBe('string');
50
+ expect(msg.defaultMessage.length).toBeGreaterThan(0);
51
+ });
52
+ });
53
+ });
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // getWeatherIcon
57
+ // ---------------------------------------------------------------------------
58
+
59
+ describe('getWeatherIcon', () => {
60
+ it('returns a defined value for all known day codes', () => {
61
+ const dayCodes = [
62
+ '11',
63
+ '12',
64
+ '13',
65
+ '14',
66
+ '15',
67
+ '16',
68
+ '17',
69
+ '23',
70
+ '24',
71
+ '25',
72
+ '26',
73
+ '33',
74
+ '34',
75
+ '35',
76
+ '36',
77
+ '43',
78
+ '44',
79
+ '45',
80
+ '46',
81
+ '51',
82
+ '52',
83
+ '53',
84
+ '54',
85
+ '61',
86
+ '62',
87
+ '63',
88
+ '64',
89
+ '71',
90
+ '72',
91
+ '73',
92
+ '74',
93
+ ];
94
+ dayCodes.forEach((code) => {
95
+ const icon = getWeatherIcon(code);
96
+ expect(icon).toBeDefined();
97
+ expect(icon).not.toBeNull();
98
+ });
99
+ });
100
+
101
+ it('returns a defined value for known night codes', () => {
102
+ const nightCodes = [
103
+ '11n',
104
+ '12n',
105
+ '13n',
106
+ '14n',
107
+ '15n',
108
+ '16n',
109
+ '17n',
110
+ '23n',
111
+ '24n',
112
+ '25n',
113
+ '26n',
114
+ '33n',
115
+ '34n',
116
+ '35n',
117
+ '36n',
118
+ '43n',
119
+ '44n',
120
+ '45n',
121
+ '46n',
122
+ '51n',
123
+ '52n',
124
+ '54n',
125
+ '61n',
126
+ '62n',
127
+ '63n',
128
+ '64n',
129
+ '71n',
130
+ '72n',
131
+ ];
132
+ nightCodes.forEach((code) => {
133
+ const icon = getWeatherIcon(code);
134
+ expect(icon).toBeDefined();
135
+ expect(icon).not.toBeNull();
136
+ });
137
+ });
138
+
139
+ it('returns different icons for day and night when both exist', () => {
140
+ expect(getWeatherIcon('11')).not.toBe(getWeatherIcon('11n'));
141
+ expect(getWeatherIcon('46')).not.toBe(getWeatherIcon('46n'));
142
+ expect(getWeatherIcon('61')).not.toBe(getWeatherIcon('61n'));
143
+ });
144
+
145
+ it('returns the same icon for 53 and 53n (no dedicated night icon)', () => {
146
+ expect(getWeatherIcon('53n')).toBe(getWeatherIcon('53'));
147
+ });
148
+
149
+ it('returns the same icon for 73 and 73n', () => {
150
+ expect(getWeatherIcon('73n')).toBe(getWeatherIcon('73'));
151
+ });
152
+
153
+ it('returns the same icon for 74 and 74n', () => {
154
+ expect(getWeatherIcon('74n')).toBe(getWeatherIcon('74'));
155
+ });
156
+
157
+ it('trims surrounding whitespace from the input value', () => {
158
+ expect(getWeatherIcon(' 46 ')).toBe(getWeatherIcon('46'));
159
+ expect(getWeatherIcon(' 46n ')).toBe(getWeatherIcon('46n'));
160
+ });
161
+
162
+ it('falls back to the day icon when only the night suffix is unknown', () => {
163
+ // '11' is valid; pretend there is a code like '11X' whose day code exists
164
+ // More directly: an uppercase N should not trip up the regex
165
+ expect(getWeatherIcon('11N')).toBe(getWeatherIcon('11'));
166
+ });
167
+
168
+ it('returns the default icon (11) for an unknown sky-state code', () => {
169
+ const defaultIcon = getWeatherIcon('11');
170
+ expect(getWeatherIcon('999')).toBe(defaultIcon);
171
+ expect(getWeatherIcon('abc')).toBe(defaultIcon);
172
+ expect(getWeatherIcon('0')).toBe(defaultIcon);
173
+ });
174
+
175
+ it('returns the default icon for null input', () => {
176
+ expect(getWeatherIcon(null)).toBe(getWeatherIcon('11'));
177
+ });
178
+
179
+ it('returns the default icon for undefined input', () => {
180
+ expect(getWeatherIcon(undefined)).toBe(getWeatherIcon('11'));
181
+ });
182
+
183
+ it('returns the default icon for an empty string', () => {
184
+ expect(getWeatherIcon('')).toBe(getWeatherIcon('11'));
185
+ });
186
+ });
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // useWeatherData
190
+ // ---------------------------------------------------------------------------
191
+
192
+ describe('useWeatherData', () => {
193
+ beforeEach(() => {
194
+ global.fetch = jest.fn();
195
+ // Suppress expected console.error calls from error-path tests
196
+ jest.spyOn(console, 'error').mockImplementation(() => {});
197
+ });
198
+
199
+ afterEach(() => {
200
+ jest.restoreAllMocks();
201
+ });
202
+
203
+ it('starts with isLoading=true and an empty forecast array', () => {
204
+ global.fetch.mockImplementation(() => new Promise(() => {})); // never resolves
205
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
206
+ expect(result.current.isLoading).toBe(true);
207
+ expect(result.current.forecast).toEqual([]);
208
+ });
209
+
210
+ it('populates forecast with today data on a successful fetch', async () => {
211
+ const today = moment().format('YYYY-MM-DD');
212
+ global.fetch.mockResolvedValueOnce({
213
+ ok: true,
214
+ json: async () => ({
215
+ forecast: [
216
+ {
217
+ currentHour: '14',
218
+ date: today,
219
+ name: 'Fuenlabrada',
220
+ province: 'Madrid',
221
+ skyState: 'Partly cloudy',
222
+ skyStateValue: '46',
223
+ tempMin: '19',
224
+ tempMax: '36',
225
+ timePeriod: '12-18',
226
+ },
227
+ ],
228
+ }),
229
+ });
230
+
231
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
232
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
233
+
234
+ expect(result.current.forecast).toHaveLength(1);
235
+ const [day] = result.current.forecast;
236
+ expect(day.currentHour).toBe('14');
237
+ expect(day.date).toBe(today);
238
+ expect(day.name).toBe('Fuenlabrada');
239
+ expect(day.provinceName).toBe('Madrid');
240
+ expect(day.skyStateDescription).toBe('Partly cloudy');
241
+ expect(day.skyStateText).toBe('46');
242
+ expect(day.minTemp).toBe('19');
243
+ expect(day.maxTemp).toBe('36');
244
+ expect(day.timePeriod).toBe('12-18');
245
+ });
246
+
247
+ it('calls the correct API endpoint with the proper headers', async () => {
248
+ const today = moment().format('YYYY-MM-DD');
249
+ global.fetch.mockResolvedValueOnce({
250
+ ok: true,
251
+ json: async () => ({
252
+ forecast: [
253
+ {
254
+ currentHour: '14',
255
+ date: today,
256
+ name: 'Fuenlabrada',
257
+ province: 'Madrid',
258
+ skyState: 'Sunny',
259
+ skyStateValue: '11',
260
+ tempMin: '20',
261
+ tempMax: '38',
262
+ timePeriod: '12-18',
263
+ },
264
+ ],
265
+ }),
266
+ });
267
+
268
+ renderHook(() => useWeatherData(), { wrapper });
269
+
270
+ await waitFor(() =>
271
+ expect(global.fetch).toHaveBeenCalledWith(
272
+ 'http://localhost:8080/Plone/++api++/@aemet-weather-forecast',
273
+ expect.objectContaining({
274
+ headers: expect.objectContaining({
275
+ Accept: 'application/json',
276
+ 'Content-Type': 'application/json',
277
+ }),
278
+ }),
279
+ ),
280
+ );
281
+ });
282
+
283
+ it('sets isLoading to false after a successful fetch', async () => {
284
+ const today = moment().format('YYYY-MM-DD');
285
+ global.fetch.mockResolvedValueOnce({
286
+ ok: true,
287
+ json: async () => ({
288
+ forecast: [
289
+ {
290
+ currentHour: '14',
291
+ date: today,
292
+ name: 'Sevilla',
293
+ province: 'Sevilla',
294
+ skyState: 'Clear',
295
+ skyStateValue: '11',
296
+ tempMin: '18',
297
+ tempMax: '33',
298
+ timePeriod: '12-18',
299
+ },
300
+ ],
301
+ }),
302
+ });
303
+
304
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
305
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
306
+ expect(result.current.isLoading).toBe(false);
307
+ });
308
+
309
+ it('uses fallback data when the HTTP response is not ok', async () => {
310
+ global.fetch.mockResolvedValueOnce({ ok: false, status: 500 });
311
+
312
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
313
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
314
+
315
+ const [fallback] = result.current.forecast;
316
+ expect(fallback.name).toBe('Sevilla');
317
+ expect(fallback.provinceName).toBe('Sevilla');
318
+ expect(fallback.minTemp).toBe('--');
319
+ expect(fallback.maxTemp).toBe('--');
320
+ expect(fallback.skyStateText).toBe('11');
321
+ });
322
+
323
+ it('uses fallback data when fetch throws a network error', async () => {
324
+ global.fetch.mockRejectedValueOnce(new Error('Network failure'));
325
+
326
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
327
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
328
+
329
+ expect(result.current.forecast[0].name).toBe('Sevilla');
330
+ expect(result.current.forecast[0].provinceName).toBe('Sevilla');
331
+ expect(result.current.forecast[0].maxTemp).toBe('--');
332
+ });
333
+
334
+ it('uses fallback data when the API response has no forecast key', async () => {
335
+ global.fetch.mockResolvedValueOnce({
336
+ ok: true,
337
+ json: async () => ({}),
338
+ });
339
+
340
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
341
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
342
+
343
+ expect(result.current.forecast[0].name).toBe('Sevilla');
344
+ expect(result.current.forecast[0].provinceName).toBe('Sevilla');
345
+ expect(result.current.forecast[0].maxTemp).toBe('--');
346
+ });
347
+
348
+ it('uses fallback data when forecast array is empty', async () => {
349
+ global.fetch.mockResolvedValueOnce({
350
+ ok: true,
351
+ json: async () => ({ forecast: [] }),
352
+ });
353
+
354
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
355
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
356
+
357
+ expect(result.current.forecast[0].name).toBe('Sevilla');
358
+ expect(result.current.forecast[0].provinceName).toBe('Sevilla');
359
+ expect(result.current.forecast[0].maxTemp).toBe('--');
360
+ });
361
+
362
+ it('uses fallback data when today is not in the forecast list', async () => {
363
+ global.fetch.mockResolvedValueOnce({
364
+ ok: true,
365
+ json: async () => ({
366
+ forecast: [
367
+ {
368
+ currentHour: '14',
369
+ date: '2000-01-01', // deliberately not today
370
+ name: 'Barcelona',
371
+ province: 'Barcelona',
372
+ skyState: 'Clear',
373
+ skyStateValue: '11',
374
+ tempMin: '10',
375
+ tempMax: '20',
376
+ timePeriod: '12-18',
377
+ },
378
+ ],
379
+ }),
380
+ });
381
+
382
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
383
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
384
+
385
+ expect(result.current.forecast[0].name).toBe('Sevilla');
386
+ expect(result.current.forecast[0].provinceName).toBe('Sevilla');
387
+ expect(result.current.forecast[0].maxTemp).toBe('--');
388
+ expect(result.current.forecast[0].minTemp).toBe('--');
389
+ });
390
+
391
+ it('sets isLoading to false even when the fetch fails', async () => {
392
+ global.fetch.mockRejectedValueOnce(new Error('Timeout'));
393
+
394
+ const { result } = renderHook(() => useWeatherData(), { wrapper });
395
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
396
+ expect(result.current.isLoading).toBe(false);
397
+ });
398
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * WeatherCurrent component — card widget with time, day, city and temperature.
3
+ * @module components/Aemet/WeatherCurrent
4
+ */
5
+
6
+ import React, { useState, useEffect } from 'react';
7
+ import { FormattedMessage, useIntl } from 'react-intl';
8
+ import moment from 'moment';
9
+ import Image from '@plone/volto/components/theme/Image/Image';
10
+ import { weatherMessages, getWeatherIcon, useWeatherData } from './Weather.js'; // explicit .js — shared logic, not the view
11
+
12
+ const WeatherCurrent = (props) => {
13
+ const { forecast, isLoading } = useWeatherData();
14
+ const intl = useIntl();
15
+ const [currentTime, setCurrentTime] = useState(moment());
16
+
17
+ // Update clock every minute
18
+ useEffect(() => {
19
+ const timer = setInterval(() => {
20
+ setCurrentTime(moment());
21
+ }, 60000);
22
+
23
+ return () => clearInterval(timer);
24
+ }, []);
25
+
26
+ return (
27
+ <div className="weather-current-wrapper">
28
+ {isLoading || forecast.length === 0 ? (
29
+ <p>
30
+ 🌡️{' '}
31
+ <FormattedMessage
32
+ id="loading_weather_forecast"
33
+ defaultMessage="Loading weather forecast..."
34
+ />
35
+ </p>
36
+ ) : (
37
+ forecast.slice(0, 1).map((day, i) => (
38
+ <div key={i} className="weather-current-card">
39
+ <div className="weather-current-time-row">
40
+ <span
41
+ className="weather-current-time"
42
+ title={intl.formatMessage(weatherMessages.currentTime)}
43
+ >
44
+ {currentTime.format('HH:mm')}
45
+ </span>
46
+ <span
47
+ className="weather-current-day"
48
+ title={intl.formatMessage(weatherMessages.currentDay)}
49
+ >
50
+ {currentTime.format('ddd').toUpperCase()}
51
+ </span>
52
+ </div>
53
+ <div
54
+ className="weather-current-city"
55
+ title={intl.formatMessage(weatherMessages.currentLocation)}
56
+ >
57
+ {`${day.name}, ${day.provinceName}` || 'Sevilla, Spain.'}
58
+ </div>
59
+ <div className="weather-current-bottom">
60
+ <span
61
+ className="weather-current-temp"
62
+ title={intl.formatMessage(weatherMessages.minimumMaximum)}
63
+ >
64
+ {day.minTemp}-{day.maxTemp}º
65
+ </span>
66
+ </div>
67
+ <div className="weather-current-bottom">
68
+ <span
69
+ className="weather-current-temp"
70
+ title={intl.formatMessage(weatherMessages.currentTemperature)}
71
+ >
72
+ {day.maxTemp}º
73
+ </span>
74
+ <div
75
+ className="weather-current-icon"
76
+ title={
77
+ day.skyStateDescription ||
78
+ intl.formatMessage(weatherMessages.weatherDescription)
79
+ }
80
+ >
81
+ <Image
82
+ src={getWeatherIcon(day.skyStateText)}
83
+ alt={
84
+ day.skyStateDescription ||
85
+ intl.formatMessage(weatherMessages.weatherDescription)
86
+ }
87
+ />
88
+ </div>
89
+ </div>
90
+ </div>
91
+ ))
92
+ )}
93
+ </div>
94
+ );
95
+ };
96
+
97
+ export default WeatherCurrent;