Skip to content

Commit f007a68

Browse files
authored
feat: Add diff view to Cloud Config parameter dialog for better conflict handling (#3239)
1 parent 85104d4 commit f007a68

File tree

9 files changed

+536
-78
lines changed

9 files changed

+536
-78
lines changed

package-lock.json

Lines changed: 10 additions & 0 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
@@ -47,6 +47,7 @@
4747
"copy-to-clipboard": "3.3.3",
4848
"core-js": "3.48.0",
4949
"csrf-sync": "4.2.1",
50+
"diff": "8.0.3",
5051
"expr-eval-fork": "3.0.1",
5152
"express": "5.2.1",
5253
"express-session": "1.18.2",

src/components/JsonEditor/JsonEditor.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
.inputLayer {
9191
display: block;
9292
width: 100%;
93-
min-width: calc(var(--modal-min-width) * (1 - var(--modal-label-ratio)));
93+
min-width: 100%;
9494
background: transparent;
9595
color: transparent;
9696
caret-color: #333;

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

Lines changed: 26 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import Toolbar from 'components/Toolbar/Toolbar.react';
2424
import browserStyles from 'dashboard/Data/Browser/Browser.scss';
2525
import configStyles from 'dashboard/Data/Config/Config.scss';
2626
import { CurrentApp } from 'context/currentApp';
27-
import Modal from 'components/Modal/Modal.react';
2827
import equal from 'fast-deep-equal';
2928
import Notification from 'dashboard/Data/Browser/Notification.react';
3029
import ServerConfigStorage from 'lib/ServerConfigStorage';
@@ -46,7 +45,7 @@ class Config extends TableView {
4645
modalValue: '',
4746
modalMasterKeyOnly: false,
4847
loading: false,
49-
confirmModalOpen: false,
48+
modalConflict: false,
5049
lastError: null,
5150
lastNote: null,
5251
showAddEntryDialog: false,
@@ -220,11 +219,12 @@ class Config extends TableView {
220219
extras = (
221220
<ConfigDialog
222221
onConfirm={this.saveParam.bind(this)}
223-
onCancel={() => this.setState({ modalOpen: false })}
222+
onCancel={() => this.setState({ modalOpen: false, modalConflict: false })}
224223
param={this.state.modalParam}
225224
type={this.state.modalType}
226225
value={this.state.modalValue}
227226
masterKeyOnly={this.state.modalMasterKeyOnly}
227+
conflict={this.state.modalConflict}
228228
parseServerVersion={this.context.serverInfo?.parseServerVersion}
229229
loading={this.state.loading}
230230
configHistory={this.state.currentParamHistory}
@@ -271,30 +271,6 @@ class Config extends TableView {
271271
);
272272
}
273273

274-
if (this.state.confirmModalOpen) {
275-
extras = (
276-
<Modal
277-
type={Modal.Types.INFO}
278-
icon="warn-outline"
279-
title={'Are you sure?'}
280-
confirmText="Continue"
281-
cancelText="Cancel"
282-
onCancel={() => this.setState({ confirmModalOpen: false })}
283-
onConfirm={() => {
284-
this.setState({ confirmModalOpen: false });
285-
this.saveParam({
286-
...this.confirmData,
287-
override: true,
288-
});
289-
}}
290-
>
291-
<div className={[browserStyles.confirmConfig]}>
292-
This parameter changed while you were editing it. If you continue, the latest changes
293-
will be lost and replaced with your version. Do you want to proceed?
294-
</div>
295-
</Modal>
296-
);
297-
}
298274
let notification = null;
299275
if (this.state.lastError) {
300276
notification = <Notification note={this.state.lastError} isErrorNote={true} />;
@@ -537,19 +513,28 @@ class Config extends TableView {
537513
const currentValueAfter = fetchedParamsAfter.get(name);
538514
const valuesAreEqual = equal(currentValue, currentValueAfter);
539515

540-
if (!valuesAreEqual && !override) {
541-
this.setState({
542-
confirmModalOpen: true,
543-
modalOpen: false,
544-
loading: false,
545-
});
546-
this.confirmData = {
547-
name,
548-
value,
549-
type,
550-
masterKeyOnly,
551-
};
552-
return;
516+
if (!valuesAreEqual) {
517+
const { modalValue: conflictServerValue } = this.parseValueForModal(currentValueAfter);
518+
519+
if (override) {
520+
// Re-check: has the server value changed again since the user confirmed?
521+
const serverValueChanged = !equal(this.state.modalValue, conflictServerValue);
522+
if (serverValueChanged) {
523+
this.setState({
524+
modalConflict: true,
525+
modalValue: conflictServerValue,
526+
loading: false,
527+
});
528+
return;
529+
}
530+
} else {
531+
this.setState({
532+
modalConflict: true,
533+
modalValue: conflictServerValue,
534+
loading: false,
535+
});
536+
return;
537+
}
553538
}
554539

555540
await this.props.config.dispatch(ActionTypes.SET, {
@@ -567,7 +552,7 @@ class Config extends TableView {
567552
this.cacheData.set('masterKeyOnly', masterKeyOnlyParams);
568553
}
569554

570-
this.setState({ modalOpen: false });
555+
this.setState({ modalOpen: false, modalConflict: false });
571556

572557
// Update config history in localStorage
573558
let transformedValue = value;
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
/*
2+
* Copyright (c) 2016-present, Parse, LLC
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the license found in the LICENSE file in
6+
* the root directory of this source tree.
7+
*/
8+
import React from 'react';
9+
import { diffLines, diffChars } from 'diff';
10+
import styles from 'dashboard/Data/Config/ConfigConflictDiff.scss';
11+
12+
/**
13+
* Serialize a config value to a string suitable for diffing.
14+
* Both server and user values go through this to ensure consistent formatting.
15+
*/
16+
function serializeForDiff(value, type, isUserValue = false) {
17+
if (value === null || value === undefined) {
18+
return 'null';
19+
}
20+
21+
switch (type) {
22+
case 'Object':
23+
case 'Array': {
24+
// User values from ConfigDialog are already JSON strings for Object/Array
25+
if (isUserValue && typeof value === 'string') {
26+
try {
27+
return JSON.stringify(JSON.parse(value), null, 2);
28+
} catch {
29+
return value;
30+
}
31+
}
32+
return JSON.stringify(value, null, 2);
33+
}
34+
case 'Boolean':
35+
return String(value);
36+
case 'Number':
37+
return String(value);
38+
case 'Date': {
39+
if (typeof value === 'string') {
40+
return value;
41+
}
42+
if (value instanceof Date) {
43+
return value.toISOString();
44+
}
45+
if (value && value.iso) {
46+
return value.iso;
47+
}
48+
return String(value);
49+
}
50+
case 'GeoPoint': {
51+
if (value && typeof value.toJSON === 'function') {
52+
const json = value.toJSON();
53+
return JSON.stringify({ latitude: json.latitude, longitude: json.longitude }, null, 2);
54+
}
55+
if (value && (value.latitude !== undefined || value.longitude !== undefined)) {
56+
return JSON.stringify({ latitude: value.latitude, longitude: value.longitude }, null, 2);
57+
}
58+
return JSON.stringify(value, null, 2);
59+
}
60+
case 'File': {
61+
if (value && typeof value.toJSON === 'function') {
62+
const json = value.toJSON();
63+
return JSON.stringify({ name: json.name, url: json.url }, null, 2);
64+
}
65+
if (value && (value._name !== undefined || value.name !== undefined)) {
66+
return JSON.stringify({ name: value._name || value.name, url: value._url || value.url }, null, 2);
67+
}
68+
return JSON.stringify(value, null, 2);
69+
}
70+
case 'String':
71+
default:
72+
return String(value);
73+
}
74+
}
75+
76+
/**
77+
* Render a line with character-level highlighting.
78+
* charDiffs is the result of diffChars() for this line pair.
79+
* side is 'removed' or 'added'.
80+
*/
81+
function renderCharHighlightedContent(charDiffs, side) {
82+
return charDiffs.map((part, i) => {
83+
if (part.added && side === 'added') {
84+
return <span key={i} className={styles.charAdded}>{part.value}</span>;
85+
}
86+
if (part.removed && side === 'removed') {
87+
return <span key={i} className={styles.charRemoved}>{part.value}</span>;
88+
}
89+
if (!part.added && !part.removed) {
90+
return <span key={i}>{part.value}</span>;
91+
}
92+
return null;
93+
});
94+
}
95+
96+
/**
97+
* ConfigConflictDiff displays a GitHub-style unified diff between
98+
* the server's latest value and the user's edited value.
99+
*/
100+
const ConfigConflictDiff = ({ serverValue, userValue, type }) => {
101+
const serverStr = serializeForDiff(serverValue, type, false);
102+
const userStr = serializeForDiff(userValue, type, true);
103+
104+
const lineDiffs = diffLines(serverStr, userStr);
105+
106+
// Build diff lines with character-level highlighting
107+
const rows = [];
108+
let oldLineNum = 1;
109+
let newLineNum = 1;
110+
111+
for (let i = 0; i < lineDiffs.length; i++) {
112+
const part = lineDiffs[i];
113+
const lines = part.value.replace(/\n$/, '').split('\n');
114+
115+
if (part.removed) {
116+
// Check if next part is an addition (paired change for char-level diff)
117+
const nextPart = lineDiffs[i + 1];
118+
const hasCharDiff = nextPart && nextPart.added;
119+
let charDiffResult = null;
120+
121+
if (hasCharDiff) {
122+
charDiffResult = diffChars(part.value, nextPart.value);
123+
}
124+
125+
for (const line of lines) {
126+
rows.push({
127+
type: 'removed',
128+
oldNum: oldLineNum++,
129+
newNum: null,
130+
prefix: '-',
131+
content: line,
132+
charDiffs: charDiffResult,
133+
charSide: 'removed',
134+
singleLineDiff: lines.length === 1,
135+
});
136+
}
137+
138+
if (hasCharDiff) {
139+
const addedLines = nextPart.value.replace(/\n$/, '').split('\n');
140+
for (const line of addedLines) {
141+
rows.push({
142+
type: 'added',
143+
oldNum: null,
144+
newNum: newLineNum++,
145+
prefix: '+',
146+
content: line,
147+
charDiffs: charDiffResult,
148+
charSide: 'added',
149+
singleLineDiff: addedLines.length === 1,
150+
});
151+
}
152+
i++; // Skip the next (added) part since we handled it
153+
}
154+
} else if (part.added) {
155+
for (const line of lines) {
156+
rows.push({
157+
type: 'added',
158+
oldNum: null,
159+
newNum: newLineNum++,
160+
prefix: '+',
161+
content: line,
162+
charDiffs: null,
163+
charSide: null,
164+
singleLineDiff: false,
165+
});
166+
}
167+
} else {
168+
for (const line of lines) {
169+
rows.push({
170+
type: 'context',
171+
oldNum: oldLineNum++,
172+
newNum: newLineNum++,
173+
prefix: ' ',
174+
content: line,
175+
charDiffs: null,
176+
charSide: null,
177+
singleLineDiff: false,
178+
});
179+
}
180+
}
181+
}
182+
183+
if (rows.length === 0 || (rows.length === 1 && rows[0].type === 'context' && rows[0].content === '')) {
184+
return <div className={styles.emptyDiff}>Values are identical — no differences found.</div>;
185+
}
186+
187+
// For character-level highlighting within a single line,
188+
// we need to map charDiffs to individual lines. Since diffChars
189+
// operates on the full block text, for single-line values we can
190+
// highlight directly. For multi-line, we show line-level coloring only.
191+
const renderContent = (row) => {
192+
if (row.charDiffs && row.singleLineDiff) {
193+
return renderCharHighlightedContent(row.charDiffs, row.charSide);
194+
}
195+
return row.content;
196+
};
197+
198+
const lineStyle = (row) => {
199+
if (row.type === 'removed') {
200+
return styles.lineRemoved;
201+
}
202+
if (row.type === 'added') {
203+
return styles.lineAdded;
204+
}
205+
return styles.lineContext;
206+
};
207+
208+
return (
209+
<div className={styles.container}>
210+
<table className={styles.table}>
211+
<tbody>
212+
{rows.map((row, idx) => (
213+
<tr key={idx} className={lineStyle(row)}>
214+
<td className={styles.lineNumber}>{row.oldNum ?? ''}</td>
215+
<td className={styles.lineNumber}>{row.newNum ?? ''}</td>
216+
<td className={styles.prefix}>{row.prefix}</td>
217+
<td className={styles.content}>{renderContent(row)}</td>
218+
</tr>
219+
))}
220+
</tbody>
221+
</table>
222+
</div>
223+
);
224+
};
225+
226+
export default ConfigConflictDiff;

0 commit comments

Comments
 (0)