eionet2-dashboard 3.2.5 → 3.2.7

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/CHANGELOG.md CHANGED
@@ -4,11 +4,15 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
- ### [3.2.5](https://github.com/eea/eionet2-dashboard/compare/3.2.4...3.2.5) - 14 May 2026
7
+ ### [3.2.7](https://github.com/eea/eionet2-dashboard/compare/3.2.6...3.2.7) - 16 May 2026
8
8
 
9
9
  #### :house: Internal changes
10
10
 
11
- - chore: sonar fix [Mihai Nicolae - [`b4db6f7`](https://github.com/eea/eionet2-dashboard/commit/b4db6f7815887d281702732262f03a9ad404b486)]
11
+ - chore: simplify auth [Mihai Nicolae - [`d1866bb`](https://github.com/eea/eionet2-dashboard/commit/d1866bb85b0e0b99d1732f2cd3407155f489e748)]
12
+
13
+ ### [3.2.6](https://github.com/eea/eionet2-dashboard/compare/3.2.5...3.2.6) - 14 May 2026
14
+
15
+ ### [3.2.5](https://github.com/eea/eionet2-dashboard/compare/3.2.4...3.2.5) - 14 May 2026
12
16
 
13
17
  ### [3.2.4](https://github.com/eea/eionet2-dashboard/compare/3.2.3...3.2.4) - 14 May 2026
14
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eionet2-dashboard",
3
- "version": "3.2.5",
3
+ "version": "3.2.7",
4
4
  "description": "",
5
5
  "author": "",
6
6
  "scripts": {
@@ -1,7 +1,6 @@
1
1
  <!--This file is used during the Teams authentication flow to assist with retrieval of the access token.-->
2
2
  <!--If you're not familiar with this, do not alter or remove this file from your project.-->
3
- <!DOCTYPE html>
4
- <html lang="en">
3
+ <html>
5
4
 
6
5
  <head>
7
6
  <title>Login End Page</title>
@@ -9,68 +8,46 @@
9
8
  </head>
10
9
 
11
10
  <body>
12
- <script src="https://statics.teams.cdn.office.net/sdk/v1.6.0/js/MicrosoftTeams.min.js"
13
- integrity="sha384-mhp2E+BLMiZLe7rDIzj19WjgXJeI32NkPvrvvZBrMi5IvWup/1NUfS5xuYN5S3VT"
11
+ <script src="https://res.cdn.office.net/teams-js/2.22.0/js/MicrosoftTeams.min.js"
12
+ integrity="sha384-WSG/sWulIv7rel5TnFlH8JTpxl2OxzZh9Lux2mIzBFiTRLFvMBeFv9VURu/3vQdx"
14
13
  crossorigin="anonymous"></script>
14
+ <script type="text/javascript" src="https://alcdn.msauth.net/browser/2.35.0/js/msal-browser.min.js"
15
+ integrity="sha384-s/NxjjAgw1QgpDhOlVjTceLl4axrp5nqpUbCPOEQy1PqbFit9On6uw2XmEF1eq0s" crossorigin="anonymous">
16
+ </script>
15
17
  <script type="text/javascript">
16
- microsoftTeams.initialize();
17
- let hashParams = getHashParameters();
18
+ var currentURL = new URL(window.location);
19
+ var clientId = currentURL.searchParams.get("clientId");
18
20
 
19
- if (hashParams["error"]) {
20
- // Authentication failed
21
- microsoftTeams.authentication.notifyFailure(
22
- JSON.stringify({
23
- error: hashParams["error"],
24
- message: JSON.stringify(hashParams),
25
- })
26
- );
27
- } else if (hashParams["code"]) {
28
- // Get the stored state parameter and compare with incoming state
29
- let expectedState = localStorage.getItem("state");
30
- if (expectedState !== hashParams["state"]) {
31
- // State does not match, report error
32
- microsoftTeams.authentication.notifyFailure(
33
- JSON.stringify({
34
- error: "StateDoesNotMatch",
35
- message: JSON.stringify(hashParams),
36
- })
37
- );
38
- } else {
39
- // Success -- return code information to the parent page.
40
- var redirectUri = location.protocol + "//" + location.host + location.pathname;
41
- var result = JSON.stringify({
42
- code: hashParams["code"],
43
- codeVerifier: localStorage.getItem("codeVerifier"),
44
- redirectUri: redirectUri,
45
- });
46
-
47
- microsoftTeams.authentication.notifySuccess(result);
48
- }
49
- } else {
50
- // Unexpected condition: hash does not contain error or access_token parameter
51
- microsoftTeams.authentication.notifyFailure(
52
- JSON.stringify({
53
- error: "UnexpectedFailure",
54
- message: JSON.stringify(hashParams),
55
- })
56
- );
57
- }
21
+ microsoftTeams.app.initialize().then(() => {
22
+ microsoftTeams.app.getContext().then(async (context) => {
23
+ const msalConfig = {
24
+ auth: {
25
+ clientId: clientId,
26
+ authority: `https://login.microsoftonline.com/${context.tid}`,
27
+ navigateToLoginRequestUrl: false
28
+ },
29
+ cache: {
30
+ cacheLocation: "sessionStorage",
31
+ },
32
+ }
58
33
 
59
- // Parse hash parameters into key-value pairs
60
- function getHashParameters() {
61
- let hashParams = {};
62
- location.hash
63
- .substr(1)
64
- .split("&")
65
- .forEach(function (item) {
66
- let s = item.split("="),
67
- k = s[0],
68
- v = s[1] && decodeURIComponent(s[1]);
69
- hashParams[k] = v;
70
- });
71
- return hashParams;
72
- }
34
+ const msalInstance = new window.msal.PublicClientApplication(msalConfig);
35
+ msalInstance.handleRedirectPromise()
36
+ .then((tokenResponse) => {
37
+ if (tokenResponse !== null) {
38
+ microsoftTeams.authentication.notifySuccess(JSON.stringify({
39
+ sessionStorage: sessionStorage
40
+ }));
41
+ } else {
42
+ microsoftTeams.authentication.notifyFailure("Get empty response.");
43
+ }
44
+ })
45
+ .catch((error) => {
46
+ microsoftTeams.authentication.notifyFailure(JSON.stringify(error));
47
+ });
48
+ });
49
+ });
73
50
  </script>
74
51
  </body>
75
52
 
76
- </html>
53
+ </html>
@@ -1,7 +1,6 @@
1
1
  <!--This file is used during the Teams authentication flow to assist with retrieval of the access token.-->
2
2
  <!--If you're not familiar with this, do not alter or remove this file from your project.-->
3
- <!DOCTYPE html>
4
- <html lang="en">
3
+ <html>
5
4
 
6
5
  <head>
7
6
  <title>Login Start Page</title>
@@ -9,169 +8,42 @@
9
8
  </head>
10
9
 
11
10
  <body>
12
- <script src="https://statics.teams.cdn.office.net/sdk/v1.6.0/js/MicrosoftTeams.min.js"
13
- integrity="sha384-mhp2E+BLMiZLe7rDIzj19WjgXJeI32NkPvrvvZBrMi5IvWup/1NUfS5xuYN5S3VT"
11
+ <script src="https://res.cdn.office.net/teams-js/2.22.0/js/MicrosoftTeams.min.js"
12
+ integrity="sha384-WSG/sWulIv7rel5TnFlH8JTpxl2OxzZh9Lux2mIzBFiTRLFvMBeFv9VURu/3vQdx"
14
13
  crossorigin="anonymous"></script>
14
+ <script type="text/javascript" src="https://alcdn.msauth.net/browser/2.35.0/js/msal-browser.min.js"
15
+ integrity="sha384-s/NxjjAgw1QgpDhOlVjTceLl4axrp5nqpUbCPOEQy1PqbFit9On6uw2XmEF1eq0s" crossorigin="anonymous">
16
+ </script>
15
17
  <script type="text/javascript">
16
- microsoftTeams.initialize();
17
-
18
- // Get the tab context, and use the information to navigate to Azure AD login page
19
- microsoftTeams.getContext(async function (context) {
20
- // Generate random state string and store it, so we can verify it in the callback
21
- let state = _guid();
22
- localStorage.setItem("state", state);
23
- localStorage.removeItem("codeVerifier");
24
- var currentURL = new URL(window.location);
25
- var clientId = currentURL.searchParams.get("clientId");
26
-
27
- var scope = currentURL.searchParams.get("scope");
28
-
29
- var originalCode = _guid();
30
- var codeChallenge = await pkceChallengeFromVerifier(originalCode);
31
-
32
- localStorage.setItem("codeVerifier", originalCode);
33
- let queryParams = {
34
- client_id: clientId,
35
- response_type: "code",
36
- response_mode: "fragment",
37
- scope: scope,
38
- redirect_uri: window.location.origin + "/auth-end.html",
39
- nonce: _guid(),
40
- state: state,
41
- login_hint: context.loginHint,
42
- code_challenge: codeChallenge,
43
- code_challenge_method: "S256",
44
- };
45
-
46
- let authorizeEndpoint = `https://login.microsoftonline.com/${context.tid}
47
- /oauth2/v2.0/authorize?${toQueryString(queryParams)}`;
48
- window.location.assign(authorizeEndpoint);
18
+ microsoftTeams.app.initialize().then(() => {
19
+ microsoftTeams.app.getContext().then(async (context) => {
20
+ // Generate random state string and store it, so we can verify it in the callback
21
+ var currentURL = new URL(window.location);
22
+ var clientId = currentURL.searchParams.get("clientId");
23
+ var scope = currentURL.searchParams.get("scope");
24
+ var loginHint = currentURL.searchParams.get("loginHint");
25
+
26
+ const msalConfig = {
27
+ auth: {
28
+ clientId: clientId,
29
+ authority: `https://login.microsoftonline.com/${context.user.tenant.id}`,
30
+ navigateToLoginRequestUrl: false
31
+ },
32
+ cache: {
33
+ cacheLocation: "sessionStorage",
34
+ },
35
+ };
36
+
37
+ const msalInstance = new msal.PublicClientApplication(msalConfig);
38
+ const scopesArray = scope.split(" ");
39
+ const scopesRequest = {
40
+ scopes: scopesArray,
41
+ redirectUri: window.location.origin + `/auth-end.html?clientId=${clientId}`,
42
+ loginHint: loginHint
43
+ };
44
+ await msalInstance.loginRedirect(scopesRequest);
45
+ });
49
46
  });
50
-
51
- // Build query string from map of query parameter
52
- function toQueryString(queryParams) {
53
- let encodedQueryParams = [];
54
- for (let key in queryParams) {
55
- encodedQueryParams.push(key + "=" + encodeURIComponent(queryParams[key]));
56
- }
57
- return encodedQueryParams.join("&");
58
- }
59
-
60
- // Converts decimal to hex equivalent
61
- // (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
62
- function _decimalToHex(number) {
63
- var hex = number.toString(16);
64
- while (hex.length < 2) {
65
- hex = "0" + hex;
66
- }
67
- return hex;
68
- }
69
-
70
- // Generates RFC4122 version 4 guid (128 bits)
71
- // (From ADAL.js: https://github.com/AzureAD/azure-activedirectory-library-for-js/blob/dev/lib/adal.js)
72
- function _guid() {
73
- // RFC4122: The version 4 UUID is meant for generating UUIDs from truly-random or
74
- // pseudo-random numbers.
75
- // The algorithm is as follows:
76
- // Set the two most significant bits (bits 6 and 7) of the
77
- // clock_seq_hi_and_reserved to zero and one, respectively.
78
- // Set the four most significant bits (bits 12 through 15) of the
79
- // time_hi_and_version field to the 4-bit version number from
80
- // Section 4.1.3. Version4
81
- // Set all the other bits to randomly (or pseudo-randomly) chosen
82
- // values.
83
- // UUID = time-low "-" time-mid "-"time-high-and-version "-"clock-seq-reserved and low(2hexOctet)"-" node
84
- // time-low = 4hexOctet
85
- // time-mid = 2hexOctet
86
- // time-high-and-version = 2hexOctet
87
- // clock-seq-and-reserved = hexOctet:
88
- // clock-seq-low = hexOctet
89
- // node = 6hexOctet
90
- // Format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
91
- // y could be 1000, 1001, 1010, 1011 since most significant two bits needs to be 10
92
- // y values are 8, 9, A, B
93
- var cryptoObj = window.crypto || window.msCrypto; // for IE 11
94
- if (cryptoObj?.getRandomValues) {
95
- var buffer = new Uint8Array(16);
96
- cryptoObj.getRandomValues(buffer);
97
- //buffer[6] and buffer[7] represents the time_hi_and_version field. We will set the four most significant bits (4 through 7) of buffer[6] to represent decimal number 4 (UUID version number).
98
- buffer[6] |= 0x40; //buffer[6] | 01000000 will set the 6 bit to 1.
99
- buffer[6] &= 0x4f; //buffer[6] & 01001111 will set the 4, 5, and 7 bit to 0 such that bits 4-7 == 0100 = "4".
100
- //buffer[8] represents the clock_seq_hi_and_reserved field. We will set the two most significant bits (6 and 7) of the clock_seq_hi_and_reserved to zero and one, respectively.
101
- buffer[8] |= 0x80; //buffer[8] | 10000000 will set the 7 bit to 1.
102
- buffer[8] &= 0xbf; //buffer[8] & 10111111 will set the 6 bit to 0.
103
- return (
104
- _decimalToHex(buffer[0]) +
105
- _decimalToHex(buffer[1]) +
106
- _decimalToHex(buffer[2]) +
107
- _decimalToHex(buffer[3]) +
108
- "-" +
109
- _decimalToHex(buffer[4]) +
110
- _decimalToHex(buffer[5]) +
111
- "-" +
112
- _decimalToHex(buffer[6]) +
113
- _decimalToHex(buffer[7]) +
114
- "-" +
115
- _decimalToHex(buffer[8]) +
116
- _decimalToHex(buffer[9]) +
117
- "-" +
118
- _decimalToHex(buffer[10]) +
119
- _decimalToHex(buffer[11]) +
120
- _decimalToHex(buffer[12]) +
121
- _decimalToHex(buffer[13]) +
122
- _decimalToHex(buffer[14]) +
123
- _decimalToHex(buffer[15])
124
- );
125
- } else {
126
- var guidHolder = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
127
- var hex = "0123456789abcdef";
128
- var r = 0;
129
- var guidResponse = "";
130
- for (var i = 0; i < 36; i++) {
131
- if (guidHolder[i] !== "-" && guidHolder[i] !== "4") {
132
- // each x and y needs to be random
133
- r = Math.trunc(Math.random() * 16);
134
- }
135
- if (guidHolder[i] === "x") {
136
- guidResponse += hex[r];
137
- } else if (guidHolder[i] === "y") {
138
- // clock-seq-and-reserved first hex is filtered and remaining hex values are random
139
- r &= 0x3; // bit and with 0011 to set pos 2 to zero ?0??
140
- r |= 0x8; // set pos 3 to 1 as 1???
141
- guidResponse += hex[r];
142
- } else {
143
- guidResponse += guidHolder[i];
144
- }
145
- }
146
- return guidResponse;
147
- }
148
- }
149
-
150
- // Calculate the SHA256 hash of the input text.
151
- // Returns a promise that resolves to an ArrayBuffer
152
- function sha256(plain) {
153
- const encoder = new TextEncoder();
154
- const data = encoder.encode(plain);
155
- return window.crypto.subtle.digest("SHA-256", data);
156
- }
157
-
158
- // Base64-urlencodes the input string
159
- function base64urlencode(str) {
160
- // Convert the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
161
- // btoa accepts chars only within ascii 0-255 and base64 encodes them.
162
- // Then convert the base64 encoded to base64url encoded
163
- // (replace + with -, replace / with _, trim trailing =)
164
- return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
165
- .replace(/\+/g, "-")
166
- .replace(/\//g, "_")
167
- .replace(/=+$/, "");
168
- }
169
-
170
- // Return the base64-urlencoded sha256 hash for the PKCE challenge
171
- async function pkceChallengeFromVerifier(v) {
172
- const hashed = await sha256(v);
173
- return base64urlencode(hashed);
174
- }
175
47
  </script>
176
48
  </body>
177
49
 
@@ -44,8 +44,10 @@ export function BottomMenu({ configuration }) {
44
44
  anchorEl={anchorEl}
45
45
  open={open}
46
46
  onClose={handleClose}
47
- MenuListProps={{
48
- 'aria-labelledby': 'basic-button',
47
+ slotProps={{
48
+ list: {
49
+ 'aria-labelledby': 'basic-button',
50
+ },
49
51
  }}
50
52
  >
51
53
  <MenuItem
@@ -31,10 +31,10 @@ export default function ResizableGrid(props) {
31
31
  return (
32
32
  <DataGrid
33
33
  sx={{ height: '98%' }}
34
- components={{
35
- ColumnResizeIcon: ColumnResizeIcon,
34
+ slots={{
35
+ columnResizeIcon: ColumnResizeIcon,
36
36
  }}
37
- componentsProps={{
37
+ slotProps={{
38
38
  panel: {
39
39
  placement: 'auto',
40
40
  },
@@ -36,11 +36,11 @@ describe('ResizableGrid', () => {
36
36
  const props = DataGrid.mock.calls[0][0];
37
37
  expect(props.columns).toEqual(columns);
38
38
  expect(props.getRowHeight()).toBe(Constants.GridRowHeight);
39
- expect(props.components).toMatchObject({
40
- ColumnResizeIcon: expect.any(Function),
39
+ expect(props.slots).toMatchObject({
40
+ columnResizeIcon: expect.any(Function),
41
41
  });
42
42
 
43
- renderToStaticMarkup(<props.components.ColumnResizeIcon />);
43
+ renderToStaticMarkup(<props.slots.columnResizeIcon />);
44
44
  expect(mockResizeIcon).toHaveBeenCalledWith(
45
45
  expect.objectContaining({ id: 'grid1', onWidthChanged: expect.any(Function) }),
46
46
  );
@@ -350,8 +350,10 @@ export default function Tab() {
350
350
  anchorEl={anchorEl}
351
351
  open={openMobileMenu}
352
352
  onClose={handleMenuClose}
353
- MenuListProps={{
354
- 'aria-labelledby': 'basic-button',
353
+ slotProps={{
354
+ list: {
355
+ 'aria-labelledby': 'basic-button',
356
+ },
355
357
  }}
356
358
  >
357
359
  <MenuItem
@@ -391,7 +393,7 @@ export default function Tab() {
391
393
  width: 80,
392
394
  },
393
395
  }}
394
- componentsProps={{
396
+ slotProps={{
395
397
  paper: {
396
398
  sx: {
397
399
  width: 100,
@@ -10,31 +10,31 @@ import * as microsoftTeams from '@microsoft/teams-js';
10
10
  class TabConfig extends React.Component {
11
11
  render() {
12
12
  // Initialize the Microsoft Teams SDK
13
- microsoftTeams.initialize();
14
-
15
- /**
16
- * When the user clicks "Save", save the url for your configured tab.
17
- * This allows for the addition of query string parameters based on
18
- * the settings selected by the user.
19
- */
20
- microsoftTeams.settings.registerOnSaveHandler((saveEvent) => {
21
- const baseUrl = `https://${window.location.hostname}:${window.location.port}`;
22
- microsoftTeams.settings.setSettings({
23
- suggestedDisplayName: 'Eionet2 Dashboard',
24
- entityId: 'dashboard',
25
- contentUrl: baseUrl + '/index.html#/tab',
26
- websiteUrl: baseUrl + '/index.html#/tab',
13
+ microsoftTeams.app.initialize().then(() => {
14
+ /**
15
+ * When the user clicks "Save", save the url for your configured tab.
16
+ * This allows for the addition of query string parameters based on
17
+ * the settings selected by the user.
18
+ */
19
+ microsoftTeams.pages.config.registerOnSaveHandler((saveEvent) => {
20
+ const baseUrl = `https://${window.location.hostname}:${window.location.port}`;
21
+ microsoftTeams.pages.config.setConfig({
22
+ suggestedDisplayName: 'Eionet2 Dashboard',
23
+ entityId: 'dashboard',
24
+ contentUrl: baseUrl + '/index.html#/tab',
25
+ websiteUrl: baseUrl + '/index.html#/tab',
26
+ });
27
+ saveEvent.notifySuccess();
27
28
  });
28
- saveEvent.notifySuccess();
29
- });
30
29
 
31
- /**
32
- * After verifying that the settings for your tab are correctly
33
- * filled in by the user you need to set the state of the dialog
34
- * to be valid. This will enable the save button in the configuration
35
- * dialog.
36
- */
37
- microsoftTeams.settings.setValidityState(true);
30
+ /**
31
+ * After verifying that the settings for your tab are correctly
32
+ * filled in by the user you need to set the state of the dialog
33
+ * to be valid. This will enable the save button in the configuration
34
+ * dialog.
35
+ */
36
+ microsoftTeams.pages.config.setValidityState(true);
37
+ });
38
38
 
39
39
  return (
40
40
  <div>
@@ -4,24 +4,30 @@ import TabConfig from './TabConfig';
4
4
  import * as microsoftTeams from '@microsoft/teams-js';
5
5
 
6
6
  jest.mock('@microsoft/teams-js', () => ({
7
- initialize: jest.fn(),
8
- settings: {
9
- registerOnSaveHandler: jest.fn(),
10
- setSettings: jest.fn(),
11
- setValidityState: jest.fn(),
7
+ app: {
8
+ initialize: jest.fn(() => Promise.resolve()),
9
+ },
10
+ pages: {
11
+ config: {
12
+ registerOnSaveHandler: jest.fn(),
13
+ setConfig: jest.fn(),
14
+ setValidityState: jest.fn(),
15
+ },
12
16
  },
13
17
  }));
14
18
 
15
19
  describe('TabConfig', () => {
16
- test('renders configuration content and initializes teams settings', () => {
20
+ test('renders configuration content and initializes teams settings', async () => {
17
21
  const originalWindow = global.window;
18
22
  global.window = { location: { hostname: 'localhost', port: '3000' } };
19
23
  const html = renderToStaticMarkup(<TabConfig />);
20
24
 
25
+ await Promise.resolve();
26
+
21
27
  expect(html).toContain('Tab Configuration');
22
- expect(microsoftTeams.initialize).toHaveBeenCalled();
23
- expect(microsoftTeams.settings.registerOnSaveHandler).toHaveBeenCalled();
24
- expect(microsoftTeams.settings.setValidityState).toHaveBeenCalledWith(true);
28
+ expect(microsoftTeams.app.initialize).toHaveBeenCalled();
29
+ expect(microsoftTeams.pages.config.registerOnSaveHandler).toHaveBeenCalled();
30
+ expect(microsoftTeams.pages.config.setValidityState).toHaveBeenCalledWith(true);
25
31
  global.window = originalWindow;
26
32
  });
27
33
  });
@@ -62,12 +62,14 @@ export function UserMenu({
62
62
  </Button>
63
63
  <Menu
64
64
  id="demo-customized-menu"
65
- MenuListProps={{
66
- 'aria-labelledby': 'demo-customized-button',
67
- }}
68
- PaperProps={{
69
- style: {
70
- width: 300,
65
+ slotProps={{
66
+ list: {
67
+ 'aria-labelledby': 'demo-customized-button',
68
+ },
69
+ paper: {
70
+ style: {
71
+ width: 300,
72
+ },
71
73
  },
72
74
  }}
73
75
  anchorEl={anchorEl}
@@ -137,7 +137,7 @@ export function EventExternalRegistration({ event, userInfo }) {
137
137
  onChange={(e) => {
138
138
  participant.ParticipantName = e.target.value;
139
139
  }}
140
- inputProps={{ style: { textTransform: 'capitalize' } }}
140
+ slotProps={{ htmlInput: { style: { textTransform: 'capitalize' } } }}
141
141
  error={Boolean(errors?.name)}
142
142
  helperText={errors?.name}
143
143
  onBlur={validateField}
@@ -14,6 +14,9 @@ import { postParticipant } from '../../data/sharepointProvider';
14
14
  import { getUserByMail } from '../../data/provider';
15
15
 
16
16
  const buttonHandlers = [];
17
+ const fieldHandlers = {};
18
+ const checkboxHandlers = [];
19
+ const stateSetters = [];
17
20
 
18
21
  jest.mock('@mui/material', () => {
19
22
  const ReactLocal = require('react');
@@ -38,14 +41,27 @@ jest.mock('@mui/material', () => {
38
41
  return {
39
42
  Alert: passthrough(),
40
43
  Box: passthrough(),
41
- Checkbox: passthrough('input'),
42
- TextField: ({ label }) => <div>{label || ''}</div>,
43
- Button: ({ onClick, children }) => {
44
+ Checkbox: ({ onChange, checked, disabled }) => {
45
+ checkboxHandlers.push({ onChange, checked, disabled });
46
+ return ReactLocal.createElement('input', { type: 'checkbox' });
47
+ },
48
+ TextField: ({ label, id, onChange, onBlur }) => {
49
+ if (id) {
50
+ fieldHandlers[id] = { onChange, onBlur };
51
+ }
52
+ return <div>{label || ''}</div>;
53
+ },
54
+ Button: ({ onClick, children, endIcon }) => {
44
55
  const label = labelToText(children);
45
56
  if (onClick) {
46
57
  buttonHandlers.push({ label, onClick });
47
58
  }
48
- return <button>{children}</button>;
59
+ return (
60
+ <button>
61
+ {children}
62
+ {endIcon}
63
+ </button>
64
+ );
49
65
  },
50
66
  FormControlLabel: ({ label, control }) => (
51
67
  <div>
@@ -83,17 +99,20 @@ jest.mock('../HtmlBox', () => ({
83
99
 
84
100
  function mockStateSequence(values) {
85
101
  let index = 0;
102
+ stateSetters.length = 0;
86
103
  React.useState.mockImplementation((initialValue) => {
104
+ const setter = jest.fn();
105
+ stateSetters.push(setter);
87
106
  if (index < values.length) {
88
107
  const current = values[index];
89
108
  index += 1;
90
- return [current, jest.fn()];
109
+ return [current, setter];
91
110
  }
92
- return [initialValue, jest.fn()];
111
+ return [initialValue, setter];
93
112
  });
94
113
  }
95
114
 
96
- function buildState(participantOverride = {}) {
115
+ function buildState(participantOverride = {}, overrides = {}) {
97
116
  const participant = {
98
117
  MeetingId: 1,
99
118
  ParticipantName: 'John Doe',
@@ -109,7 +128,15 @@ function buildState(participantOverride = {}) {
109
128
  ...participantOverride,
110
129
  };
111
130
 
112
- return [participant, false, '', {}, false, false, false];
131
+ return [
132
+ participant,
133
+ overrides.loading ?? false,
134
+ overrides.errorText ?? '',
135
+ overrides.errors ?? {},
136
+ overrides.successRegister ?? false,
137
+ overrides.physical ?? false,
138
+ overrides.reimbursement ?? false,
139
+ ];
113
140
  }
114
141
 
115
142
  describe('EventExternalRegistration', () => {
@@ -123,7 +150,14 @@ describe('EventExternalRegistration', () => {
123
150
  beforeEach(() => {
124
151
  jest.clearAllMocks();
125
152
  buttonHandlers.length = 0;
126
- React.useState.mockImplementation((initialValue) => [initialValue, jest.fn()]);
153
+ checkboxHandlers.length = 0;
154
+ stateSetters.length = 0;
155
+ Object.keys(fieldHandlers).forEach((k) => delete fieldHandlers[k]);
156
+ React.useState.mockImplementation((initialValue) => {
157
+ const setter = jest.fn();
158
+ stateSetters.push(setter);
159
+ return [initialValue, setter];
160
+ });
127
161
  getUserByMail.mockResolvedValue(null);
128
162
  postParticipant.mockResolvedValue({ id: 77 });
129
163
  });
@@ -216,4 +250,194 @@ describe('EventExternalRegistration', () => {
216
250
  expect(getUserByMail).toHaveBeenCalledWith('existing@domain.org');
217
251
  expect(postParticipant).not.toHaveBeenCalled();
218
252
  });
253
+
254
+ test('rejects emails containing a plus sign', async () => {
255
+ const state = buildState({ Email: 'user+alias@example.org', ParticipantName: 'Valid Name' });
256
+ mockStateSequence(state);
257
+
258
+ renderToStaticMarkup(
259
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
260
+ );
261
+
262
+ const register = buttonHandlers.find((b) => b.label === 'Register');
263
+ await register.onClick();
264
+
265
+ expect(getUserByMail).not.toHaveBeenCalled();
266
+ expect(postParticipant).not.toHaveBeenCalled();
267
+ });
268
+
269
+ test('skips registration when required fields are empty', async () => {
270
+ const event = { ...baseEvent, Participants: [] };
271
+ const state = buildState({ ParticipantName: '', Email: '' });
272
+ mockStateSequence(state);
273
+
274
+ renderToStaticMarkup(<EventExternalRegistration event={event} userInfo={{ country: 'RO' }} />);
275
+
276
+ const register = buttonHandlers.find((b) => b.label === 'Register');
277
+ await register.onClick();
278
+
279
+ expect(getUserByMail).not.toHaveBeenCalled();
280
+ expect(postParticipant).not.toHaveBeenCalled();
281
+ expect(event.Participants.length).toBe(0);
282
+ });
283
+
284
+ test('does not mutate event when postParticipant returns no response', async () => {
285
+ postParticipant.mockResolvedValueOnce(undefined);
286
+ const event = { ...baseEvent, Participants: [] };
287
+ const state = buildState({ Email: 'new@example.org', ParticipantName: 'New One' });
288
+ const participant = state[0];
289
+ mockStateSequence(state);
290
+
291
+ renderToStaticMarkup(<EventExternalRegistration event={event} userInfo={{ country: 'RO' }} />);
292
+
293
+ const register = buttonHandlers.find((b) => b.label === 'Register');
294
+ await register.onClick();
295
+
296
+ expect(postParticipant).toHaveBeenCalled();
297
+ expect(participant.id).toBeUndefined();
298
+ expect(event.Participants.length).toBe(0);
299
+ });
300
+
301
+ test('field onChange handlers mutate the participant', () => {
302
+ const state = buildState();
303
+ const participant = state[0];
304
+ mockStateSequence(state);
305
+
306
+ renderToStaticMarkup(
307
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
308
+ );
309
+
310
+ fieldHandlers.name.onChange({ target: { value: 'Jane Roe' } });
311
+ fieldHandlers.email.onChange({ target: { value: 'jane.roe@example.org' } });
312
+
313
+ expect(participant.ParticipantName).toBe('Jane Roe');
314
+ expect(participant.Email).toBe('jane.roe@example.org');
315
+ });
316
+
317
+ test('validateField records a name error on blur of an empty name field', () => {
318
+ const state = buildState({ ParticipantName: '' });
319
+ mockStateSequence(state);
320
+
321
+ renderToStaticMarkup(
322
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
323
+ );
324
+
325
+ const setErrors = stateSetters[3];
326
+ fieldHandlers.name.onBlur({ target: { id: 'name' } });
327
+
328
+ expect(setErrors).toHaveBeenCalled();
329
+ expect(setErrors.mock.calls[0][0].name).toBeTruthy();
330
+ });
331
+
332
+ test('validateField records an email error on blur of an empty email field', () => {
333
+ const state = buildState({ Email: '' });
334
+ mockStateSequence(state);
335
+
336
+ renderToStaticMarkup(
337
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
338
+ );
339
+
340
+ const setErrors = stateSetters[3];
341
+ fieldHandlers.email.onBlur({ target: { id: 'email' } });
342
+
343
+ expect(setErrors).toHaveBeenCalled();
344
+ expect(setErrors.mock.calls[0][0].email).toBeTruthy();
345
+ });
346
+
347
+ test('validateField logs a warning for an unknown field id', () => {
348
+ mockStateSequence(buildState());
349
+ const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
350
+
351
+ renderToStaticMarkup(
352
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
353
+ );
354
+
355
+ fieldHandlers.name.onBlur({ target: { id: 'unknown' } });
356
+
357
+ expect(logSpy).toHaveBeenCalledWith('Undefined field for validation');
358
+ logSpy.mockRestore();
359
+ });
360
+
361
+ test('checking physical participation updates the participant', () => {
362
+ const state = buildState();
363
+ const participant = state[0];
364
+ mockStateSequence(state);
365
+
366
+ renderToStaticMarkup(
367
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
368
+ );
369
+
370
+ checkboxHandlers[0].onChange({}, true);
371
+
372
+ expect(participant.PhysicalParticipation).toBe(true);
373
+ expect(stateSetters[5]).toHaveBeenCalledWith(true);
374
+ });
375
+
376
+ test('unchecking physical participation also resets reimbursement', () => {
377
+ const state = buildState(
378
+ { PhysicalParticipation: true, EEAReimbursementRequested: true },
379
+ { physical: true, reimbursement: true },
380
+ );
381
+ const participant = state[0];
382
+ mockStateSequence(state);
383
+
384
+ renderToStaticMarkup(
385
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
386
+ );
387
+
388
+ checkboxHandlers[0].onChange({}, false);
389
+
390
+ expect(participant.PhysicalParticipation).toBe(false);
391
+ expect(participant.EEAReimbursementRequested).toBe(false);
392
+ expect(stateSetters[6]).toHaveBeenCalledWith(false);
393
+ });
394
+
395
+ test('checking reimbursement updates the participant', () => {
396
+ const state = buildState({ PhysicalParticipation: true }, { physical: true });
397
+ const participant = state[0];
398
+ mockStateSequence(state);
399
+
400
+ renderToStaticMarkup(
401
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
402
+ );
403
+
404
+ checkboxHandlers[1].onChange({}, true);
405
+
406
+ expect(participant.EEAReimbursementRequested).toBe(true);
407
+ expect(stateSetters[6]).toHaveBeenCalledWith(true);
408
+ });
409
+
410
+ test('does not render offline checkboxes when the event is online', () => {
411
+ mockStateSequence(buildState());
412
+
413
+ renderToStaticMarkup(
414
+ <EventExternalRegistration
415
+ event={{ ...baseEvent, IsOffline: false }}
416
+ userInfo={{ country: 'RO' }}
417
+ />,
418
+ );
419
+
420
+ expect(checkboxHandlers).toHaveLength(0);
421
+ });
422
+
423
+ test('renders the error alert when errorText is set', () => {
424
+ mockStateSequence(buildState({}, { errorText: 'Something went wrong' }));
425
+
426
+ const html = renderToStaticMarkup(
427
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
428
+ );
429
+
430
+ expect(html).toContain('Something went wrong');
431
+ });
432
+
433
+ test('renders the success alert with a check icon when registration succeeded', () => {
434
+ mockStateSequence(buildState({}, { successRegister: true }));
435
+
436
+ const html = renderToStaticMarkup(
437
+ <EventExternalRegistration event={{ ...baseEvent }} userInfo={{ country: 'RO' }} />,
438
+ );
439
+
440
+ expect(html).toContain('Invitation success');
441
+ expect(html).toContain('check-icon');
442
+ });
219
443
  });
@@ -49,12 +49,14 @@ export function CountryProgress({ lastYears, configuration }) {
49
49
  Yearly overview:
50
50
  </Typography>
51
51
  <Tabs
52
- TabIndicatorProps={{
53
- sx: {
54
- bottom: 0,
55
- height: 10,
56
- backgroundColor: 'secondary.main',
57
- clipPath: 'polygon(50% 0, 0 100%, 100% 100%)',
52
+ slotProps={{
53
+ indicator: {
54
+ sx: {
55
+ bottom: 0,
56
+ height: 10,
57
+ backgroundColor: 'secondary.main',
58
+ clipPath: 'polygon(50% 0, 0 100%, 100% 100%)',
59
+ },
58
60
  },
59
61
  }}
60
62
  value={tabsValue}
@@ -164,7 +164,7 @@ export function UserEdit({ user, configuration }) {
164
164
  user.FirstName = e.target.value;
165
165
  validateField(e);
166
166
  }}
167
- inputProps={{ style: { textTransform: 'capitalize' } }}
167
+ slotProps={{ htmlInput: { style: { textTransform: 'capitalize' } } }}
168
168
  error={Boolean(errors?.firstName)}
169
169
  helperText={errors?.firstName}
170
170
  onBlur={validateField}
@@ -181,7 +181,7 @@ export function UserEdit({ user, configuration }) {
181
181
  user.LastName = e.target.value;
182
182
  validateField(e);
183
183
  }}
184
- inputProps={{ style: { textTransform: 'capitalize' } }}
184
+ slotProps={{ htmlInput: { style: { textTransform: 'capitalize' } } }}
185
185
  error={Boolean(errors?.lastName)}
186
186
  helperText={errors?.lastName}
187
187
  onBlur={validateField}
@@ -197,7 +197,7 @@ export function UserEdit({ user, configuration }) {
197
197
  user.JobTitle = e.target.value;
198
198
  validateField(e);
199
199
  }}
200
- inputProps={{ style: { textTransform: 'capitalize' } }}
200
+ slotProps={{ htmlInput: { style: { textTransform: 'capitalize' } } }}
201
201
  />
202
202
  <TextField
203
203
  autoComplete="off"
@@ -210,7 +210,7 @@ export function UserEdit({ user, configuration }) {
210
210
  user.Phone = e.target.value;
211
211
  validateField(e);
212
212
  }}
213
- inputProps={{ maxLength: 15 }}
213
+ slotProps={{ htmlInput: { maxLength: 15 } }}
214
214
  error={Boolean(errors?.phone)}
215
215
  helperText={errors?.phone}
216
216
  onBlur={validateField}