Skip to content

Commit 2e91144

Browse files
authored
Refactor frontend: extract utility functions from App.jsx (#13097)
1 parent edd6352 commit 2e91144

9 files changed

Lines changed: 243 additions & 70 deletions

File tree

components/frontend/src/App.jsx

Lines changed: 43 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,21 @@ import { Component } from "react"
88

99
import { login } from "./api/auth"
1010
import { getDataModel } from "./api/datamodel"
11-
import { nrMeasurementsApi } from "./api/measurement"
11+
import { createNrMeasurementsEventSource } from "./api/measurement"
1212
import { getReport, getReportsOverview } from "./api/report"
1313
import { AppUI } from "./AppUI"
14-
import { registeredURLSearchParams } from "./hooks/url_search_query"
14+
import { registeredURLSearchParams, toSearchString } from "./hooks/url_search_query"
1515
import { showURLAvailabilityMessages } from "./messages"
16+
import { clearStoredSession, loadStoredSession, storeSession } from "./session_storage"
1617
import { theme } from "./theme"
17-
import { isValidISODate, toISODateStringInCurrentTZ } from "./utils"
18+
import { endOfDayFromISODateString, reportUuidFromPath, toISODateStringInCurrentTZ } from "./utils"
1819
import { SnackbarAlerts } from "./widgets/SnackbarAlerts"
1920

2021
class App extends Component {
2122
constructor(props) {
2223
super(props)
23-
const pathname = history.location.pathname
24-
const reportUuid = decodeURI(pathname.slice(1, pathname.length))
25-
const reportDateISOString = registeredURLSearchParams().get("report_date") || ""
26-
let reportDate = null
27-
if (isValidISODate(reportDateISOString)) {
28-
reportDate = new Date(reportDateISOString)
29-
reportDate.setHours(23, 59, 59)
30-
}
24+
const reportUuid = reportUuidFromPath(history.location.pathname)
25+
const reportDate = endOfDayFromISODateString(registeredURLSearchParams().get("report_date") || "")
3126
this.state = {
3227
dataModel: {},
3328
lastUpdate: new Date(),
@@ -47,8 +42,7 @@ class App extends Component {
4742

4843
onHistory({ location, action }) {
4944
if (action === Action.Pop) {
50-
const pathname = location.pathname
51-
const reportUuid = pathname.slice(1, pathname.length)
45+
const reportUuid = reportUuidFromPath(location.pathname)
5246
this.setState({ reportUuid: reportUuid, loading: true }, () => this.reload())
5347
}
5448
}
@@ -153,8 +147,7 @@ class App extends Component {
153147
parsed.delete("report_date")
154148
date = null
155149
}
156-
const search = parsed.toString().replaceAll("%2C", ",") // No need to encode commas
157-
history.replace({ search: search.length > 0 ? "?" + search : "" })
150+
history.replace({ search: toSearchString(parsed) })
158151
this.setState({ reportDate: date, loading: true }, () => this.reload())
159152
}
160153

@@ -171,40 +164,38 @@ class App extends Component {
171164
}
172165

173166
historyPush(target) {
174-
const search = registeredURLSearchParams().toString().replaceAll("%2C", ",") // No need to encode commas
175-
history.push(target + (search.length > 0 ? "?" + search : ""))
167+
history.push(target + toSearchString(registeredURLSearchParams()))
176168
}
177169

178170
connectToNrMeasurementsEventSource() {
179-
this.source = new EventSource(nrMeasurementsApi)
180-
this.source.addEventListener("init", (message) => {
181-
const newNrMeasurements = Number(message.data)
182-
if (this.state.nrMeasurementsStreamConnected) {
183-
this.setState({ nrMeasurements: newNrMeasurements })
184-
} else {
171+
this.source = createNrMeasurementsEventSource({
172+
onInit: (newNrMeasurements) => {
173+
if (this.state.nrMeasurementsStreamConnected) {
174+
this.setState({ nrMeasurements: newNrMeasurements })
175+
} else {
176+
this.showMessage({
177+
severity: "success",
178+
title: "Connected to server",
179+
description: "Successfully reconnected to server",
180+
})
181+
this.setState({ nrMeasurements: newNrMeasurements, nrMeasurementsStreamConnected: true }, () =>
182+
this.reload(),
183+
)
184+
}
185+
},
186+
onDelta: (newNrMeasurements) => {
187+
if (newNrMeasurements !== this.state.nrMeasurements) {
188+
this.setState({ nrMeasurements: newNrMeasurements }, () => this.reload())
189+
}
190+
},
191+
onError: () => {
185192
this.showMessage({
186-
severity: "success",
187-
title: "Connected to server",
188-
description: "Successfully reconnected to server",
193+
severity: "error",
194+
title: "Server unreachable",
195+
description: "Trying to reconnect to server...",
189196
})
190-
this.setState({ nrMeasurements: newNrMeasurements, nrMeasurementsStreamConnected: true }, () =>
191-
this.reload(),
192-
)
193-
}
194-
})
195-
this.source.addEventListener("delta", (message) => {
196-
const newNrMeasurements = Number(message.data)
197-
if (newNrMeasurements !== this.state.nrMeasurements) {
198-
this.setState({ nrMeasurements: newNrMeasurements }, () => this.reload())
199-
}
200-
})
201-
this.source.addEventListener("error", () => {
202-
this.showMessage({
203-
severity: "error",
204-
title: "Server unreachable",
205-
description: "Trying to reconnect to server...",
206-
})
207-
this.setState({ nrMeasurementsStreamConnected: false })
197+
this.setState({ nrMeasurementsStreamConnected: false })
198+
},
208199
})
209200
}
210201

@@ -228,47 +219,32 @@ class App extends Component {
228219
}
229220

230221
initUserSession() {
231-
// Check if there is a session expiration datetime in the local storage. If so, restore the session as long as
232-
// it has not expired. Otherwise, nothing needs to be done.
233-
const sessionExpirationDateTimeISOString = localStorage.getItem("session_expiration_datetime")
234-
if (sessionExpirationDateTimeISOString) {
235-
const sessionExpirationDateTime = new Date(sessionExpirationDateTimeISOString)
236-
if (sessionExpirationDateTime < new Date()) {
237-
// The session expired while the user was away. Reset it and notify the user of the expired session.
238-
this.onUserSessionExpiration()
239-
} else {
240-
// Session is still active, restore it from local storage.
241-
this.setUserSession(
242-
localStorage.getItem("user"),
243-
localStorage.getItem("email"),
244-
sessionExpirationDateTime,
245-
)
246-
}
247-
} else {
222+
const stored = loadStoredSession()
223+
if (!stored) {
248224
this.showMessage({
249225
severity: "info",
250226
title: "Not logged in",
251227
description: "Editing is not possible until you are logged in",
252228
})
229+
} else if (stored.sessionExpirationDateTime < new Date()) {
230+
this.onUserSessionExpiration()
231+
} else {
232+
this.setUserSession(stored.user, stored.email, stored.sessionExpirationDateTime)
253233
}
254234
}
255235

256236
setUserSession(username, email, sessionExpirationDateTime) {
257237
if (username) {
258238
const emailAddress = email?.includes("@") ? email : null
259239
this.setState({ user: username, email: emailAddress })
260-
localStorage.setItem("user", username)
261-
localStorage.setItem("email", emailAddress)
262-
localStorage.setItem("session_expiration_datetime", sessionExpirationDateTime.toISOString())
240+
storeSession(username, emailAddress, sessionExpirationDateTime)
263241
this.sessionExpirationTimeoutId = setTimeout(
264242
() => this.onUserSessionExpiration(),
265243
sessionExpirationDateTime - Date.now(),
266244
)
267245
} else {
268246
this.setState({ user: null, email: null })
269-
localStorage.removeItem("user")
270-
localStorage.removeItem("email")
271-
localStorage.removeItem("session_expiration_datetime")
247+
clearStoredSession()
272248
clearTimeout(this.sessionExpirationTimeoutId)
273249
}
274250
}

components/frontend/src/api/measurement.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,11 @@ export function getMeasurements(minDate, maxDate) {
1111
const sep = api.includes("?") ? "&" : "?"
1212
return fetchServerApi("get", api + `${sep}min_report_date=${minDate.toISOString()}`)
1313
}
14+
15+
export function createNrMeasurementsEventSource({ onInit, onDelta, onError }) {
16+
const source = new EventSource(nrMeasurementsApi)
17+
source.addEventListener("init", (message) => onInit(Number(message.data)))
18+
source.addEventListener("delta", (message) => onDelta(Number(message.data)))
19+
source.addEventListener("error", () => onError())
20+
return source
21+
}

components/frontend/src/api/measurement.test.js

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { vi } from "vitest"
22

3-
import { getMeasurements } from "./measurement"
3+
import { createNrMeasurementsEventSource, getMeasurements } from "./measurement"
44

55
const expectedFetchOptions = {
66
credentials: "include",
@@ -33,3 +33,57 @@ it("fetches the measurements without dates", async () => {
3333
expectedFetchOptions,
3434
)
3535
})
36+
37+
describe("createNrMeasurementsEventSource", () => {
38+
let listeners
39+
let receivedUrl
40+
let closed
41+
42+
beforeEach(() => {
43+
listeners = {}
44+
closed = false
45+
global.EventSource = function (url) {
46+
receivedUrl = url
47+
return {
48+
addEventListener: (event, listener) => {
49+
listeners[event] = listener
50+
},
51+
close: () => {
52+
closed = true
53+
},
54+
}
55+
}
56+
})
57+
58+
it("connects to the nr_measurements endpoint", () => {
59+
createNrMeasurementsEventSource({ onInit: vi.fn(), onDelta: vi.fn(), onError: vi.fn() })
60+
expect(receivedUrl).toBe("/api/internal/nr_measurements")
61+
})
62+
63+
it("returns the underlying EventSource so the caller can close it", () => {
64+
const source = createNrMeasurementsEventSource({ onInit: vi.fn(), onDelta: vi.fn(), onError: vi.fn() })
65+
source.close()
66+
expect(closed).toBe(true)
67+
})
68+
69+
it("invokes onInit with the parsed message data", () => {
70+
const onInit = vi.fn()
71+
createNrMeasurementsEventSource({ onInit, onDelta: vi.fn(), onError: vi.fn() })
72+
listeners["init"]({ data: "42" })
73+
expect(onInit).toHaveBeenCalledWith(42)
74+
})
75+
76+
it("invokes onDelta with the parsed message data", () => {
77+
const onDelta = vi.fn()
78+
createNrMeasurementsEventSource({ onInit: vi.fn(), onDelta, onError: vi.fn() })
79+
listeners["delta"]({ data: "43" })
80+
expect(onDelta).toHaveBeenCalledWith(43)
81+
})
82+
83+
it("invokes onError without arguments", () => {
84+
const onError = vi.fn()
85+
createNrMeasurementsEventSource({ onInit: vi.fn(), onDelta: vi.fn(), onError })
86+
listeners["error"]()
87+
expect(onError).toHaveBeenCalledWith()
88+
})
89+
})

components/frontend/src/hooks/url_search_query.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ export function registeredURLSearchParams() {
1515
return parsed
1616
}
1717

18+
export function toSearchString(params) {
19+
const search = params.toString().replaceAll("%2C", ",") // No need to encode commas
20+
return search.length > 0 ? "?" + search : ""
21+
}
22+
1823
function equals(value1, value2) {
1924
return JSON.stringify(value1) === JSON.stringify(value2)
2025
}
@@ -30,8 +35,7 @@ function setURLSearchQuery(key, newValue, defaultValue, setValue) {
3035
} else {
3136
parsed.set(key, newValue)
3237
}
33-
const search = parsed.toString().replaceAll("%2C", ",") // No need to encode commas
34-
history.replace({ search: search.length > 0 ? "?" + search : "" })
38+
history.replace({ search: toSearchString(parsed) })
3539
setValue(newValue)
3640
}
3741

components/frontend/src/hooks/url_search_query.test.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import history from "history/browser"
33

44
import {
55
registeredURLSearchParams,
6+
toSearchString,
67
useArrayURLSearchQuery,
78
useBooleanURLSearchQuery,
89
useIntegerMappingURLSearchQuery,
@@ -249,3 +250,15 @@ it("returns whether the mapping includes or excludes a key", () => {
249250
expect(result.current.includes("other key")).toBeFalsy()
250251
expect(result.current.excludes("other key")).toBeTruthy()
251252
})
253+
254+
it("returns an empty search string for empty params", () => {
255+
expect(toSearchString(new URLSearchParams())).toBe("")
256+
})
257+
258+
it("returns a search string with leading question mark for non-empty params", () => {
259+
expect(toSearchString(new URLSearchParams({ a: "1" }))).toBe("?a=1")
260+
})
261+
262+
it("does not encode commas in search param values", () => {
263+
expect(toSearchString(new URLSearchParams({ tags: "x,y,z" }))).toBe("?tags=x,y,z")
264+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const USER_KEY = "user"
2+
const EMAIL_KEY = "email"
3+
const SESSION_EXPIRATION_KEY = "session_expiration_datetime"
4+
5+
export function loadStoredSession() {
6+
const expirationISOString = localStorage.getItem(SESSION_EXPIRATION_KEY)
7+
if (!expirationISOString) {
8+
return null
9+
}
10+
return {
11+
user: localStorage.getItem(USER_KEY),
12+
email: localStorage.getItem(EMAIL_KEY),
13+
sessionExpirationDateTime: new Date(expirationISOString),
14+
}
15+
}
16+
17+
export function storeSession(user, email, sessionExpirationDateTime) {
18+
localStorage.setItem(USER_KEY, user)
19+
localStorage.setItem(EMAIL_KEY, email)
20+
localStorage.setItem(SESSION_EXPIRATION_KEY, sessionExpirationDateTime.toISOString())
21+
}
22+
23+
export function clearStoredSession() {
24+
localStorage.removeItem(USER_KEY)
25+
localStorage.removeItem(EMAIL_KEY)
26+
localStorage.removeItem(SESSION_EXPIRATION_KEY)
27+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { clearStoredSession, loadStoredSession, storeSession } from "./session_storage"
2+
3+
beforeEach(() => localStorage.clear())
4+
5+
it("returns null when no session is stored", () => {
6+
expect(loadStoredSession()).toBeNull()
7+
})
8+
9+
it("returns null when the expiration datetime is not stored", () => {
10+
localStorage.setItem("user", "admin")
11+
localStorage.setItem("email", "[email protected]")
12+
expect(loadStoredSession()).toBeNull()
13+
})
14+
15+
it("loads a stored session", () => {
16+
const expiry = new Date("2099-01-01T12:00:00Z")
17+
localStorage.setItem("user", "admin")
18+
localStorage.setItem("email", "[email protected]")
19+
localStorage.setItem("session_expiration_datetime", expiry.toISOString())
20+
const session = loadStoredSession()
21+
expect(session.user).toBe("admin")
22+
expect(session.email).toBe("[email protected]")
23+
expect(session.sessionExpirationDateTime).toEqual(expiry)
24+
})
25+
26+
it("stores a session", () => {
27+
const expiry = new Date("2099-01-01T12:00:00Z")
28+
storeSession("admin", "[email protected]", expiry)
29+
expect(localStorage.getItem("user")).toBe("admin")
30+
expect(localStorage.getItem("email")).toBe("[email protected]")
31+
expect(localStorage.getItem("session_expiration_datetime")).toBe(expiry.toISOString())
32+
})
33+
34+
it("clears a stored session", () => {
35+
localStorage.setItem("user", "admin")
36+
localStorage.setItem("email", "[email protected]")
37+
localStorage.setItem("session_expiration_datetime", new Date().toISOString())
38+
clearStoredSession()
39+
expect(localStorage.getItem("user")).toBeNull()
40+
expect(localStorage.getItem("email")).toBeNull()
41+
expect(localStorage.getItem("session_expiration_datetime")).toBeNull()
42+
})

components/frontend/src/utils.jsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,10 @@ export function days(timeInMs) {
420420
return Math.round(timeInMs / MILLISECONDS_PER_DAY)
421421
}
422422

423+
export function reportUuidFromPath(pathname) {
424+
return decodeURI(pathname.slice(1))
425+
}
426+
423427
export function isValidISODate(string) {
424428
if (/^\d{4}-\d{2}-\d{2}$/.test(string)) {
425429
const millisecondsSinceEpoch = Date.parse(string)
@@ -428,6 +432,15 @@ export function isValidISODate(string) {
428432
return false
429433
}
430434

435+
export function endOfDayFromISODateString(isoString) {
436+
if (!isValidISODate(isoString)) {
437+
return null
438+
}
439+
const date = new Date(isoString)
440+
date.setHours(23, 59, 59)
441+
return date
442+
}
443+
431444
export function toISODateStringInCurrentTZ(date) {
432445
// Return an ISO date string without changing the timezone to UTC as Date.toISOString does
433446
return `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`

0 commit comments

Comments
 (0)