Skip to content

Commit adb9b5c

Browse files
authored
feat: Add confirmation dialog before saving a Cloud Config parameter that has been modified since editing it (#2770)
1 parent fac4b48 commit adb9b5c

File tree

5 files changed

+203
-72
lines changed

5 files changed

+203
-72
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"core-js": "3.41.0",
4747
"csurf": "1.11.0",
4848
"express": "4.21.2",
49+
"fast-deep-equal": "3.1.3",
4950
"graphiql": "2.0.8",
5051
"graphql": "16.11.0",
5152
"immutable": "5.1.2",

src/dashboard/Data/Browser/Browser.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,4 +270,8 @@ body:global(.expanded) {
270270

271271
.noScroll {
272272
overflow-x: hidden;
273+
}
274+
275+
.confirmConfig {
276+
padding: 10px 20px;
273277
}

src/dashboard/Data/Config/Config.react.js

Lines changed: 164 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import TableView from 'dashboard/TableView.react';
2121
import Toolbar from 'components/Toolbar/Toolbar.react';
2222
import browserStyles from 'dashboard/Data/Browser/Browser.scss';
2323
import { CurrentApp } from 'context/currentApp';
24+
import Modal from 'components/Modal/Modal.react';
25+
import equal from 'fast-deep-equal';
2426

2527
@subscribeTo('Config', 'config')
2628
class Config extends TableView {
@@ -38,6 +40,7 @@ class Config extends TableView {
3840
modalValue: '',
3941
modalMasterKeyOnly: false,
4042
loading: false,
43+
confirmModalOpen: false,
4144
};
4245
}
4346

@@ -55,11 +58,14 @@ class Config extends TableView {
5558
this.loadData();
5659
}
5760

58-
loadData() {
61+
async loadData() {
5962
this.setState({ loading: true });
60-
this.props.config.dispatch(ActionTypes.FETCH).finally(() => {
63+
try {
64+
await this.props.config.dispatch(ActionTypes.FETCH);
65+
this.cacheData = new Map(this.props.config.data);
66+
} finally {
6167
this.setState({ loading: false });
62-
});
68+
}
6369
}
6470

6571
renderToolbar() {
@@ -90,6 +96,7 @@ class Config extends TableView {
9096
value={this.state.modalValue}
9197
masterKeyOnly={this.state.modalMasterKeyOnly}
9298
parseServerVersion={this.context.serverInfo?.parseServerVersion}
99+
loading={this.state.loading}
93100
/>
94101
);
95102
} else if (this.state.showDeleteParameterDialog) {
@@ -101,11 +108,35 @@ class Config extends TableView {
101108
/>
102109
);
103110
}
111+
112+
if (this.state.confirmModalOpen) {
113+
extras = (
114+
<Modal
115+
type={Modal.Types.INFO}
116+
icon="warn-outline"
117+
title={'Are you sure?'}
118+
confirmText="Continue"
119+
cancelText="Cancel"
120+
onCancel={() => this.setState({ confirmModalOpen: false })}
121+
onConfirm={() => {
122+
this.setState({ confirmModalOpen: false });
123+
this.saveParam({
124+
...this.confirmData,
125+
override: true,
126+
});
127+
}}
128+
>
129+
<div className={[browserStyles.confirmConfig]}>
130+
This parameter changed while you were editing it. If you continue, the latest changes will be lost and replaced with your version. Do you want to proceed?
131+
</div>
132+
</Modal>
133+
);
134+
}
104135
return extras;
105136
}
106137

107-
renderRow(data) {
108-
let value = data.value;
138+
parseValueForModal(dataValue) {
139+
let value = dataValue;
109140
let modalValue = value;
110141
let type = typeof value;
111142

@@ -120,11 +151,11 @@ class Config extends TableView {
120151
} else if (value instanceof Parse.GeoPoint) {
121152
type = 'GeoPoint';
122153
value = `(${value.latitude}, ${value.longitude})`;
123-
modalValue = data.value.toJSON();
124-
} else if (data.value instanceof Parse.File) {
154+
modalValue = dataValue.toJSON();
155+
} else if (dataValue instanceof Parse.File) {
125156
type = 'File';
126157
value = (
127-
<a target="_blank" href={data.value.url()} rel="noreferrer">
158+
<a target="_blank" href={dataValue.url()} rel="noreferrer">
128159
Open in new window
129160
</a>
130161
);
@@ -139,14 +170,53 @@ class Config extends TableView {
139170
}
140171
type = type.substr(0, 1).toUpperCase() + type.substr(1);
141172
}
142-
const openModal = () =>
173+
174+
return {
175+
value: value,
176+
modalValue: modalValue,
177+
type: type,
178+
};
179+
}
180+
181+
renderRow(data) {
182+
// Parse modal data
183+
const { value, modalValue, type } = this.parseValueForModal(data.value);
184+
185+
/**
186+
* Opens the modal dialog to edit the Config parameter.
187+
*/
188+
const openModal = async () => {
189+
190+
// Show dialog
143191
this.setState({
192+
loading: true,
144193
modalOpen: true,
145194
modalParam: data.param,
146195
modalType: type,
147196
modalValue: modalValue,
148197
modalMasterKeyOnly: data.masterKeyOnly,
149198
});
199+
200+
// Fetch config data
201+
await this.loadData();
202+
203+
// Get latest param values
204+
const fetchedParams = this.props.config.data.get('params');
205+
const fetchedValue = fetchedParams.get(this.state.modalParam);
206+
const fetchedMasterKeyOnly = this.props.config.data.get('masterKeyOnly')?.get(this.state.modalParam) || false;
207+
208+
// Parse fetched data
209+
const { modalValue: fetchedModalValue } = this.parseValueForModal(fetchedValue);
210+
211+
// Update dialog
212+
this.setState({
213+
modalValue: fetchedModalValue,
214+
modalMasterKeyOnly: fetchedMasterKeyOnly,
215+
loading: false,
216+
});
217+
};
218+
219+
// Define column styles
150220
const columnStyleLarge = { width: '30%', cursor: 'pointer' };
151221
const columnStyleSmall = { width: '15%', cursor: 'pointer' };
152222

@@ -244,58 +314,95 @@ class Config extends TableView {
244314
return data;
245315
}
246316

247-
saveParam({ name, value, type, masterKeyOnly }) {
248-
this.props.config
249-
.dispatch(ActionTypes.SET, {
317+
async saveParam({ name, value, type, masterKeyOnly, override }) {
318+
try {
319+
this.setState({ loading: true });
320+
321+
const fetchedParams = this.props.config.data.get('params');
322+
const currentValue = fetchedParams.get(name);
323+
await this.props.config.dispatch(ActionTypes.FETCH);
324+
const fetchedParamsAfter = this.props.config.data.get('params');
325+
const currentValueAfter = fetchedParamsAfter.get(name);
326+
const valuesAreEqual = equal(currentValue, currentValueAfter);
327+
328+
if (!valuesAreEqual && !override) {
329+
this.setState({
330+
confirmModalOpen: true,
331+
modalOpen: false,
332+
loading: false,
333+
});
334+
this.confirmData = {
335+
name,
336+
value,
337+
type,
338+
masterKeyOnly,
339+
};
340+
return;
341+
}
342+
343+
await this.props.config.dispatch(ActionTypes.SET, {
250344
param: name,
251345
value: value,
252346
masterKeyOnly: masterKeyOnly,
253-
})
254-
.then(
255-
() => {
256-
this.setState({ modalOpen: false });
257-
const limit = this.context.cloudConfigHistoryLimit;
258-
const applicationId = this.context.applicationId;
259-
let transformedValue = value;
260-
if (type === 'Date') {
261-
transformedValue = { __type: 'Date', iso: value };
262-
}
263-
if (type === 'File') {
264-
transformedValue = { name: value._name, url: value._url };
265-
}
266-
const configHistory = localStorage.getItem(`${applicationId}_configHistory`);
267-
if (!configHistory) {
268-
localStorage.setItem(
269-
`${applicationId}_configHistory`,
270-
JSON.stringify({
271-
[name]: [
272-
{
273-
time: new Date(),
274-
value: transformedValue,
275-
},
276-
],
277-
})
278-
);
279-
} else {
280-
const oldConfigHistory = JSON.parse(configHistory);
281-
localStorage.setItem(
282-
`${applicationId}_configHistory`,
283-
JSON.stringify({
284-
...oldConfigHistory,
285-
[name]: !oldConfigHistory[name]
286-
? [{ time: new Date(), value: transformedValue }]
287-
: [
288-
{ time: new Date(), value: transformedValue },
289-
...oldConfigHistory[name],
290-
].slice(0, limit || 100),
291-
})
292-
);
293-
}
294-
},
295-
() => {
296-
// Catch the error
297-
}
347+
});
348+
349+
// Update the cached data after successful save
350+
const params = this.cacheData.get('params');
351+
params.set(name, value);
352+
if (masterKeyOnly) {
353+
const masterKeyOnlyParams = this.cacheData.get('masterKeyOnly') || new Map();
354+
masterKeyOnlyParams.set(name, masterKeyOnly);
355+
this.cacheData.set('masterKeyOnly', masterKeyOnlyParams);
356+
}
357+
358+
this.setState({ modalOpen: false });
359+
360+
// Update config history in localStorage
361+
const limit = this.context.cloudConfigHistoryLimit;
362+
const applicationId = this.context.applicationId;
363+
let transformedValue = value;
364+
365+
if (type === 'Date') {
366+
transformedValue = { __type: 'Date', iso: value };
367+
}
368+
if (type === 'File') {
369+
transformedValue = { name: value._name, url: value._url };
370+
}
371+
372+
const configHistory = localStorage.getItem(`${applicationId}_configHistory`);
373+
const newHistoryEntry = {
374+
time: new Date(),
375+
value: transformedValue,
376+
};
377+
378+
if (!configHistory) {
379+
localStorage.setItem(
380+
`${applicationId}_configHistory`,
381+
JSON.stringify({
382+
[name]: [newHistoryEntry],
383+
})
384+
);
385+
} else {
386+
const oldConfigHistory = JSON.parse(configHistory);
387+
const updatedHistory = !oldConfigHistory[name]
388+
? [newHistoryEntry]
389+
: [newHistoryEntry, ...oldConfigHistory[name]].slice(0, limit || 100);
390+
391+
localStorage.setItem(
392+
`${applicationId}_configHistory`,
393+
JSON.stringify({
394+
...oldConfigHistory,
395+
[name]: updatedHistory,
396+
})
397+
);
398+
}
399+
} catch (error) {
400+
this.context.showError?.(
401+
`Failed to save parameter: ${error.message || 'Unknown error occurred'}`
298402
);
403+
} finally {
404+
this.setState({ loading: false });
405+
}
299406
}
300407

301408
deleteParam(name) {

0 commit comments

Comments
 (0)