Skip to content

Commit 9342b31

Browse files
committed
Add data retention to StorageDialog
1 parent a62a2b4 commit 9342b31

2 files changed

Lines changed: 248 additions & 6 deletions

File tree

client/src/common/components/StorageDialog/styles.sass

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@
6666
flex: 1
6767
display: flex
6868
flex-direction: column
69-
gap: 1.25rem
69+
gap: 1rem
70+
min-height: 18rem
7071

7172
.storage-row
7273
display: flex
@@ -103,6 +104,121 @@
103104
.dialog-btn
104105
padding: 0.5rem 1rem
105106

107+
.storage-select,
108+
.storage-input
109+
display: inline-flex
110+
align-items: center
111+
padding: 0.5rem 1rem
112+
background-color: $darker-gray
113+
border: 1px solid $light-gray
114+
border-radius: 0.6rem
115+
color: $white
116+
font-family: inherit
117+
font-size: 1rem
118+
font-weight: 500
119+
line-height: 1.25
120+
cursor: pointer
121+
outline: none
122+
box-sizing: border-box
123+
transition: all 0.15s ease
124+
125+
&:hover
126+
border-color: $accent-primary
127+
128+
&:focus
129+
border-color: $accent-primary
130+
131+
&.input-error,
132+
&.input-error:focus
133+
border-color: $accent-danger
134+
135+
.storage-input
136+
cursor: text
137+
text-align: left
138+
appearance: textfield
139+
-moz-appearance: textfield
140+
141+
&::-webkit-outer-spin-button,
142+
&::-webkit-inner-spin-button
143+
-webkit-appearance: none
144+
margin: 0
145+
146+
.storage-retention-select-wrap
147+
position: relative
148+
display: inline-flex
149+
150+
&::after
151+
content: ""
152+
position: absolute
153+
right: 0.85rem
154+
top: 50%
155+
width: 0.5rem
156+
height: 0.5rem
157+
border-right: 2px solid $subtext
158+
border-bottom: 2px solid $subtext
159+
transform: translateY(-70%) rotate(45deg)
160+
pointer-events: none
161+
transition: border-color 0.15s ease
162+
163+
&:hover::after
164+
border-color: $accent-primary
165+
166+
.storage-select
167+
appearance: none
168+
-webkit-appearance: none
169+
-moz-appearance: none
170+
padding-right: 2.25rem
171+
172+
&::-ms-expand
173+
display: none
174+
175+
.storage-retention-controls
176+
align-items: center
177+
flex-wrap: nowrap
178+
179+
.storage-retention-select-wrap
180+
flex: 0 1 auto
181+
182+
.storage-input-wrap
183+
position: relative
184+
display: inline-flex
185+
align-items: center
186+
flex: 0 1 7.5rem
187+
188+
.storage-input
189+
width: 100%
190+
padding-right: 3rem
191+
192+
.storage-input-suffix
193+
position: absolute
194+
right: 0.85rem
195+
color: $subtext
196+
font-size: 0.875rem
197+
font-weight: 500
198+
pointer-events: none
199+
200+
.storage-icon-btn
201+
display: inline-flex
202+
align-items: center
203+
justify-content: center
204+
width: 2.4rem
205+
height: 2.4rem
206+
padding: 0
207+
flex-shrink: 0
208+
background-color: $darker-gray
209+
border: 1px solid $light-gray
210+
border-radius: 0.6rem
211+
color: $subtext
212+
cursor: pointer
213+
transition: all 0.15s ease
214+
215+
&:hover
216+
border-color: $accent-primary
217+
color: $accent-primary
218+
219+
svg
220+
font-size: 0.9rem
221+
106222
@media (max-width: 781px)
107223
.storage-dialog-wrapper
108224
width: 95vw
@@ -123,6 +239,9 @@
123239
justify-content: center
124240
gap: 0.5rem
125241

242+
.storage-manager
243+
min-height: 0
244+
126245
.storage-row
127246
flex-direction: column
128247
align-items: flex-start
@@ -132,4 +251,12 @@
132251
width: 100%
133252

134253
.dialog-btn
135-
flex: 1
254+
flex: 1
255+
256+
.storage-retention-controls
257+
.storage-retention-select-wrap,
258+
.storage-input-wrap
259+
flex: 1
260+
261+
.storage-select
262+
width: 100%

client/src/common/components/StorageDialog/tabs/Speedtests.jsx

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,76 @@
1-
import React, {useContext, useState} from "react";
2-
import {deleteRequest, downloadRequest, putRequest} from "@/common/utils/RequestUtil";
1+
import React, {useContext, useEffect, useState} from "react";
2+
import {deleteRequest, downloadRequest, patchRequest, putRequest} from "@/common/utils/RequestUtil";
33
import {SpeedtestContext} from "@/common/contexts/Speedtests";
4+
import {ConfigContext} from "@/common/contexts/Config";
45
import {ToastNotificationContext} from "@/common/contexts/ToastNotification";
56
import {t} from "i18next";
67
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
7-
import {faFileExport, faFileImport, faTrashCan, faChartLine} from "@fortawesome/free-solid-svg-icons";
8+
import {faFileExport, faFileImport, faTrashCan, faChartLine, faClockRotateLeft, faCheck, faArrowLeft} from "@fortawesome/free-solid-svg-icons";
9+
10+
const RETENTION_PRESETS = [
11+
{id: "week", days: 7},
12+
{id: "month", days: 30},
13+
{id: "quarter", days: 90},
14+
{id: "half_year", days: 180},
15+
{id: "year", days: 365},
16+
{id: "forever", days: 0}
17+
];
18+
19+
const matchPreset = (days) => RETENTION_PRESETS.find(p => p.days === days)?.id || "custom";
820

921
export default ({tests, close}) => {
1022
const [deleteWarning, setDeleteWarning] = useState(false);
1123
const {updateTests} = useContext(SpeedtestContext);
24+
const [config, reloadConfig] = useContext(ConfigContext);
1225
const updateToast = useContext(ToastNotificationContext);
1326

27+
const initialDays = parseInt(config?.retentionDays ?? "365");
28+
const [retentionSelected, setRetentionSelected] = useState(matchPreset(initialDays));
29+
const [retentionCustom, setRetentionCustom] = useState(String(initialDays));
30+
const [savingRetention, setSavingRetention] = useState(false);
31+
32+
useEffect(() => {
33+
const days = parseInt(config?.retentionDays ?? "365");
34+
setRetentionSelected(matchPreset(days));
35+
setRetentionCustom(String(days));
36+
}, [config?.retentionDays]);
37+
38+
const handleSelectChange = (value) => {
39+
setRetentionSelected(value);
40+
if (value === "custom") {
41+
setRetentionCustom(String(initialDays));
42+
}
43+
};
44+
45+
const exitCustomMode = () => {
46+
const savedPreset = matchPreset(initialDays);
47+
const target = savedPreset === "custom" ? "year" : savedPreset;
48+
setRetentionSelected(target);
49+
setRetentionCustom(String(initialDays));
50+
};
51+
52+
const currentRetentionDays = retentionSelected === "custom"
53+
? parseInt(retentionCustom)
54+
: RETENTION_PRESETS.find(p => p.id === retentionSelected)?.days;
55+
56+
const isRetentionValid = retentionSelected !== "custom"
57+
|| (!isNaN(currentRetentionDays) && currentRetentionDays >= 0 && currentRetentionDays <= 10000);
58+
59+
const isRetentionDirty = String(currentRetentionDays) !== String(initialDays);
60+
61+
const saveRetention = async () => {
62+
if (!isRetentionValid) return;
63+
setSavingRetention(true);
64+
const res = await patchRequest("/config/retentionDays", {value: String(currentRetentionDays)});
65+
setSavingRetention(false);
66+
if (res.ok) {
67+
updateToast(t("storage.retention_saved"), "green", faCheck);
68+
reloadConfig();
69+
} else {
70+
updateToast(t("dropdown.changes_unsaved"), "red");
71+
}
72+
};
73+
1474
const deleteHistory = () => {
1575
if (!deleteWarning) {
1676
setDeleteWarning(true);
@@ -71,6 +131,61 @@ export default ({tests, close}) => {
71131
<p className="storage-row-value">{tests} {t("storage.tests")}</p>
72132
</div>
73133

134+
<div className="storage-row">
135+
<div className="storage-row-label">
136+
<FontAwesomeIcon icon={faClockRotateLeft}/>
137+
<h3 title={t("storage.retention_desc")}>{t("storage.retention")}</h3>
138+
</div>
139+
<div className="storage-row-actions storage-retention-controls">
140+
{retentionSelected === "custom" ? (
141+
<>
142+
<button
143+
type="button"
144+
className="storage-icon-btn"
145+
onClick={exitCustomMode}
146+
title={t("storage.retention_back")}
147+
aria-label={t("storage.retention_back")}
148+
>
149+
<FontAwesomeIcon icon={faArrowLeft}/>
150+
</button>
151+
<div className="storage-input-wrap">
152+
<input
153+
type="number"
154+
min="0"
155+
max="10000"
156+
className={`storage-input${!isRetentionValid ? " input-error" : ""}`}
157+
value={retentionCustom}
158+
onChange={(e) => setRetentionCustom(e.target.value)}
159+
placeholder={t("storage.retention_days_placeholder")}
160+
autoFocus
161+
/>
162+
<span className="storage-input-suffix">{t("storage.retention_days_suffix")}</span>
163+
</div>
164+
</>
165+
) : (
166+
<div className="storage-retention-select-wrap">
167+
<select
168+
className="storage-select"
169+
value={retentionSelected}
170+
onChange={(e) => handleSelectChange(e.target.value)}
171+
>
172+
{RETENTION_PRESETS.map(p => (
173+
<option key={p.id} value={p.id}>{t(`storage.retention_options.${p.id}`)}</option>
174+
))}
175+
<option value="custom">{t("storage.retention_options.custom")}</option>
176+
</select>
177+
</div>
178+
)}
179+
<button
180+
className="dialog-btn"
181+
onClick={saveRetention}
182+
disabled={savingRetention || !isRetentionValid || !isRetentionDirty}
183+
>
184+
{savingRetention ? t("dialog.saving") : t("dialog.save")}
185+
</button>
186+
</div>
187+
</div>
188+
74189
<div className="storage-row">
75190
<div className="storage-row-label">
76191
<FontAwesomeIcon icon={faFileExport}/>
@@ -106,4 +221,4 @@ export default ({tests, close}) => {
106221
</div>
107222
</>
108223
)
109-
}
224+
}

0 commit comments

Comments
 (0)