From fc47a739cdf45634028b199e7d0a23ac335a5f50 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 00:32:41 +0000
Subject: [PATCH 01/12] diff
---
package-lock.json | 10 +
package.json | 1 +
src/components/JsonEditor/JsonEditor.scss | 2 +-
src/dashboard/Data/Config/Config.react.js | 22 +-
.../Data/Config/ConfigConflictDiff.react.js | 226 ++++++++++++++++++
.../Data/Config/ConfigConflictDiff.scss | 82 +++++++
.../Data/Config/ConfigDialog.react.js | 43 +++-
src/lib/tests/ConfigConflictDiff.test.js | 98 ++++++++
testing/preprocessor.js | 1 +
9 files changed, 474 insertions(+), 11 deletions(-)
create mode 100644 src/dashboard/Data/Config/ConfigConflictDiff.react.js
create mode 100644 src/dashboard/Data/Config/ConfigConflictDiff.scss
create mode 100644 src/lib/tests/ConfigConflictDiff.test.js
diff --git a/package-lock.json b/package-lock.json
index 3c36b061f8..3383f1e7ab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"copy-to-clipboard": "3.3.3",
"core-js": "3.48.0",
"csrf-sync": "4.2.1",
+ "diff": "8.0.3",
"expr-eval-fork": "3.0.1",
"express": "5.2.1",
"express-session": "1.18.2",
@@ -14738,6 +14739,15 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/diff": {
+ "version": "8.0.3",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
+ "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
"node_modules/diff-match-patch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
diff --git a/package.json b/package.json
index 1e878adf2f..8b14f19914 100644
--- a/package.json
+++ b/package.json
@@ -47,6 +47,7 @@
"copy-to-clipboard": "3.3.3",
"core-js": "3.48.0",
"csrf-sync": "4.2.1",
+ "diff": "8.0.3",
"expr-eval-fork": "3.0.1",
"express": "5.2.1",
"express-session": "1.18.2",
diff --git a/src/components/JsonEditor/JsonEditor.scss b/src/components/JsonEditor/JsonEditor.scss
index e0d35af85a..cab49ea0df 100644
--- a/src/components/JsonEditor/JsonEditor.scss
+++ b/src/components/JsonEditor/JsonEditor.scss
@@ -90,7 +90,7 @@
.inputLayer {
display: block;
width: 100%;
- min-width: calc(var(--modal-min-width) * (1 - var(--modal-label-ratio)));
+ min-width: 100%;
background: transparent;
color: transparent;
caret-color: #333;
diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js
index a067a4c15c..7c4b09f3a6 100644
--- a/src/dashboard/Data/Config/Config.react.js
+++ b/src/dashboard/Data/Config/Config.react.js
@@ -7,6 +7,7 @@
*/
import { ActionTypes } from 'lib/stores/ConfigStore';
import Button from 'components/Button/Button.react';
+import ConfigConflictDiff from 'dashboard/Data/Config/ConfigConflictDiff.react';
import ConfigDialog from 'dashboard/Data/Config/ConfigDialog.react';
import DeleteParameterDialog from 'dashboard/Data/Config/DeleteParameterDialog.react';
import AddArrayEntryDialog from 'dashboard/Data/Config/AddArrayEntryDialog.react';
@@ -276,10 +277,12 @@ class Config extends TableView {
this.setState({ confirmModalOpen: false })}
+ title={`Conflict: ${this.confirmData?.name || 'parameter'}`}
+ showCancel={false}
+ continueText="Accept server version"
+ showContinue={true}
+ onContinue={() => this.setState({ confirmModalOpen: false })}
+ confirmText="Keep my edits"
onConfirm={() => {
this.setState({ confirmModalOpen: false });
this.saveParam({
@@ -287,11 +290,13 @@ class Config extends TableView {
override: true,
});
}}
+ width={700}
>
-
- 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?
-
+
);
}
@@ -549,6 +554,7 @@ class Config extends TableView {
type,
masterKeyOnly,
};
+ this.confirmServerValue = currentValueAfter;
return;
}
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.react.js b/src/dashboard/Data/Config/ConfigConflictDiff.react.js
new file mode 100644
index 0000000000..6892c30303
--- /dev/null
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.react.js
@@ -0,0 +1,226 @@
+/*
+ * Copyright (c) 2016-present, Parse, LLC
+ * All rights reserved.
+ *
+ * This source code is licensed under the license found in the LICENSE file in
+ * the root directory of this source tree.
+ */
+import React from 'react';
+import { diffLines, diffChars } from 'diff';
+import styles from 'dashboard/Data/Config/ConfigConflictDiff.scss';
+
+/**
+ * Serialize a config value to a string suitable for diffing.
+ * Both server and user values go through this to ensure consistent formatting.
+ */
+function serializeForDiff(value, type, isUserValue = false) {
+ if (value === null || value === undefined) {
+ return 'null';
+ }
+
+ switch (type) {
+ case 'Object':
+ case 'Array': {
+ // User values from ConfigDialog are already JSON strings for Object/Array
+ if (isUserValue && typeof value === 'string') {
+ try {
+ return JSON.stringify(JSON.parse(value), null, 2);
+ } catch {
+ return value;
+ }
+ }
+ return JSON.stringify(value, null, 2);
+ }
+ case 'Boolean':
+ return String(value);
+ case 'Number':
+ return String(value);
+ case 'Date': {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ if (value && value.iso) {
+ return value.iso;
+ }
+ return String(value);
+ }
+ case 'GeoPoint': {
+ if (value && typeof value.toJSON === 'function') {
+ const json = value.toJSON();
+ return JSON.stringify({ latitude: json.latitude, longitude: json.longitude }, null, 2);
+ }
+ if (value && (value.latitude !== undefined || value.longitude !== undefined)) {
+ return JSON.stringify({ latitude: value.latitude, longitude: value.longitude }, null, 2);
+ }
+ return JSON.stringify(value, null, 2);
+ }
+ case 'File': {
+ if (value && typeof value.toJSON === 'function') {
+ const json = value.toJSON();
+ return JSON.stringify({ name: json.name, url: json.url }, null, 2);
+ }
+ if (value && (value._name !== undefined || value.name !== undefined)) {
+ return JSON.stringify({ name: value._name || value.name, url: value._url || value.url }, null, 2);
+ }
+ return JSON.stringify(value, null, 2);
+ }
+ case 'String':
+ default:
+ return String(value);
+ }
+}
+
+/**
+ * Render a line with character-level highlighting.
+ * charDiffs is the result of diffChars() for this line pair.
+ * side is 'removed' or 'added'.
+ */
+function renderCharHighlightedContent(charDiffs, side) {
+ return charDiffs.map((part, i) => {
+ if (part.added && side === 'added') {
+ return {part.value} ;
+ }
+ if (part.removed && side === 'removed') {
+ return {part.value} ;
+ }
+ if (!part.added && !part.removed) {
+ return {part.value} ;
+ }
+ return null;
+ });
+}
+
+/**
+ * ConfigConflictDiff displays a GitHub-style unified diff between
+ * the server's latest value and the user's edited value.
+ */
+const ConfigConflictDiff = ({ serverValue, userValue, type }) => {
+ const serverStr = serializeForDiff(serverValue, type, false);
+ const userStr = serializeForDiff(userValue, type, true);
+
+ const lineDiffs = diffLines(serverStr, userStr);
+
+ // Build diff lines with character-level highlighting
+ const rows = [];
+ let oldLineNum = 1;
+ let newLineNum = 1;
+
+ for (let i = 0; i < lineDiffs.length; i++) {
+ const part = lineDiffs[i];
+ const lines = part.value.replace(/\n$/, '').split('\n');
+
+ if (part.removed) {
+ // Check if next part is an addition (paired change for char-level diff)
+ const nextPart = lineDiffs[i + 1];
+ const hasCharDiff = nextPart && nextPart.added;
+ let charDiffResult = null;
+
+ if (hasCharDiff) {
+ charDiffResult = diffChars(part.value, nextPart.value);
+ }
+
+ for (const line of lines) {
+ rows.push({
+ type: 'removed',
+ oldNum: oldLineNum++,
+ newNum: null,
+ prefix: '-',
+ content: line,
+ charDiffs: charDiffResult,
+ charSide: 'removed',
+ singleLineDiff: lines.length === 1,
+ });
+ }
+
+ if (hasCharDiff) {
+ const addedLines = nextPart.value.replace(/\n$/, '').split('\n');
+ for (const line of addedLines) {
+ rows.push({
+ type: 'added',
+ oldNum: null,
+ newNum: newLineNum++,
+ prefix: '+',
+ content: line,
+ charDiffs: charDiffResult,
+ charSide: 'added',
+ singleLineDiff: addedLines.length === 1,
+ });
+ }
+ i++; // Skip the next (added) part since we handled it
+ }
+ } else if (part.added) {
+ for (const line of lines) {
+ rows.push({
+ type: 'added',
+ oldNum: null,
+ newNum: newLineNum++,
+ prefix: '+',
+ content: line,
+ charDiffs: null,
+ charSide: null,
+ singleLineDiff: false,
+ });
+ }
+ } else {
+ for (const line of lines) {
+ rows.push({
+ type: 'context',
+ oldNum: oldLineNum++,
+ newNum: newLineNum++,
+ prefix: ' ',
+ content: line,
+ charDiffs: null,
+ charSide: null,
+ singleLineDiff: false,
+ });
+ }
+ }
+ }
+
+ if (rows.length === 0 || (rows.length === 1 && rows[0].type === 'context' && rows[0].content === '')) {
+ return
Values are identical — no differences found.
;
+ }
+
+ // For character-level highlighting within a single line,
+ // we need to map charDiffs to individual lines. Since diffChars
+ // operates on the full block text, for single-line values we can
+ // highlight directly. For multi-line, we show line-level coloring only.
+ const renderContent = (row) => {
+ if (row.charDiffs && row.singleLineDiff) {
+ return renderCharHighlightedContent(row.charDiffs, row.charSide);
+ }
+ return row.content;
+ };
+
+ const lineStyle = (row) => {
+ if (row.type === 'removed') return styles.lineRemoved;
+ if (row.type === 'added') return styles.lineAdded;
+ return styles.lineContext;
+ };
+
+ return (
+
+
+ Server version
+ Your edits
+
+
+
+ {rows.map((row, idx) => (
+
+ {row.oldNum ?? ''}
+ {row.newNum ?? ''}
+ {row.prefix}
+ {renderContent(row)}
+
+ ))}
+
+
+
+ );
+};
+
+export default ConfigConflictDiff;
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.scss b/src/dashboard/Data/Config/ConfigConflictDiff.scss
new file mode 100644
index 0000000000..1a4b6cb7c6
--- /dev/null
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss
@@ -0,0 +1,82 @@
+.container {
+ border: 1px solid #d1d5da;
+ border-radius: 4px;
+ margin: 12px 20px;
+ overflow: auto;
+ max-height: 400px;
+}
+
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-size: 12px;
+ line-height: 20px;
+}
+
+.lineNumber {
+ width: 1%;
+ min-width: 40px;
+ padding: 0 8px;
+ text-align: right;
+ color: rgba(27, 31, 36, 0.3);
+ vertical-align: top;
+ user-select: none;
+ border-right: 1px solid #d1d5da;
+}
+
+.prefix {
+ width: 1%;
+ padding: 0 4px;
+ user-select: none;
+ vertical-align: top;
+}
+
+.content {
+ padding: 0 8px;
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+.lineRemoved {
+ background-color: #ffeef0;
+}
+
+.lineAdded {
+ background-color: #e6ffec;
+}
+
+.lineContext {
+ background-color: #ffffff;
+}
+
+.charRemoved {
+ background-color: #fdaeb7;
+ border-radius: 2px;
+}
+
+.charAdded {
+ background-color: #abf2bc;
+ border-radius: 2px;
+}
+
+.header {
+ padding: 8px 16px;
+ background-color: #fafbfc;
+ border-bottom: 1px solid #d1d5da;
+ font-size: 13px;
+ color: #24292e;
+ display: flex;
+ justify-content: space-between;
+}
+
+.headerLabel {
+ font-weight: 600;
+}
+
+.emptyDiff {
+ padding: 16px 20px;
+ color: #586069;
+ font-style: italic;
+ text-align: center;
+}
diff --git a/src/dashboard/Data/Config/ConfigDialog.react.js b/src/dashboard/Data/Config/ConfigDialog.react.js
index a59a6efe55..7f55964e0f 100644
--- a/src/dashboard/Data/Config/ConfigDialog.react.js
+++ b/src/dashboard/Data/Config/ConfigDialog.react.js
@@ -26,6 +26,7 @@ import semver from 'semver/preload.js';
import { dateStringUTC } from 'lib/DateUtils';
import LoaderContainer from 'components/LoaderContainer/LoaderContainer.react';
import ServerConfigStorage from 'lib/ServerConfigStorage';
+import ConfigConflictDiff from 'dashboard/Data/Config/ConfigConflictDiff.react';
import { CurrentApp } from 'context/currentApp';
const FORMATTING_CONFIG_KEY = 'config.formatting.syntax';
@@ -126,6 +127,7 @@ export default class ConfigDialog extends React.Component {
masterKeyOnly: false,
selectedIndex: null,
wordWrap: false,
+ showDiff: false,
error: null,
syntaxColors: null,
};
@@ -142,6 +144,7 @@ export default class ConfigDialog extends React.Component {
masterKeyOnly: props.masterKeyOnly,
selectedIndex: 0,
wordWrap: false,
+ showDiff: false,
error: initialError,
syntaxColors: null,
};
@@ -449,6 +452,27 @@ export default class ConfigDialog extends React.Component {
{ detectNonPrintable: effectiveDetectNonPrintable, detectNonAlphanumeric: effectiveDetectNonAlphanumeric, detectRegex: effectiveDetectRegex }
)}
/>
+ {this.state.showDiff && this.props.param.length > 0 && (
+
+ }
+ input={
+ { try { return JSON.parse(this.props.value); } catch { return this.props.value; } })()
+ : this.props.value
+ }
+ userValue={this.state.value}
+ type={this.state.type}
+ />
+ }
+ />
+ )}
{
/*
@@ -500,6 +524,8 @@ export default class ConfigDialog extends React.Component {
);
const isJsonType = this.state.type === 'Object' || this.state.type === 'Array';
+ const isDiffableType = isJsonType || this.state.type === 'String';
+ const isExistingParam = this.props.param && this.props.param.length > 0;
const customFooter = (
@@ -515,7 +541,7 @@ export default class ConfigDialog extends React.Component {
onClick={this.compactValue.bind(this)}
disabled={!this.canFormatValue()}
/>
-
+
)}
+ {isDiffableType && isExistingParam && (
+
+ this.setState({ showDiff })}
+ additionalStyles={{ margin: '0px' }}
+ colorLeft="#cbcbcb"
+ colorRight="#00db7c"
+ />
+ Diff
+
+ )}
-
+
({
+ diffLines: jest.fn((oldStr, newStr) => {
+ // Simple mock: if strings differ, return removed + added
+ if (oldStr === newStr) {
+ return [{ value: oldStr }];
+ }
+ return [
+ { value: oldStr, removed: true },
+ { value: newStr, added: true },
+ ];
+ }),
+ diffChars: jest.fn((oldStr, newStr) => {
+ if (oldStr === newStr) {
+ return [{ value: oldStr }];
+ }
+ return [
+ { value: oldStr, removed: true },
+ { value: newStr, added: true },
+ ];
+ }),
+}));
+
+describe('ConfigConflictDiff', () => {
+ it('renders a diff for changed string values', () => {
+ const component = renderer.create(
+
+ );
+ const tree = component.toJSON();
+ expect(tree).toBeTruthy();
+ // Should have header + table children
+ expect(tree.children.length).toBe(2);
+ });
+
+ it('renders a diff for changed object values', () => {
+ const component = renderer.create(
+
+ );
+ const tree = component.toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders empty state when values are identical', () => {
+ const component = renderer.create(
+
+ );
+ const tree = component.toJSON();
+ expect(tree.type).toBe('div');
+ expect(tree.children[0]).toContain('identical');
+ });
+
+ it('renders a diff for boolean values', () => {
+ const component = renderer.create(
+
+ );
+ const tree = component.toJSON();
+ expect(tree).toBeTruthy();
+ });
+
+ it('renders a diff for number values', () => {
+ const component = renderer.create(
+
+ );
+ const tree = component.toJSON();
+ expect(tree).toBeTruthy();
+ });
+});
diff --git a/testing/preprocessor.js b/testing/preprocessor.js
index 32a1956367..ef059ee670 100644
--- a/testing/preprocessor.js
+++ b/testing/preprocessor.js
@@ -32,6 +32,7 @@ module.exports = {
src = src.replace(/from 'stylesheets/g, 'from \'' + relPrefix + 'stylesheets');
src = src.replace(/from 'lib/g, 'from \'' + relPrefix + 'lib');
src = src.replace(/from 'components/g, 'from \'' + relPrefix + 'components');
+ src = src.replace(/from 'dashboard/g, 'from \'' + relPrefix + 'dashboard');
// Ignore all files within node_modules
// babel files can be .js, .es, .jsx or .es6
From 1084c9fb038d76f2491729012a0c69ef0d0cbe7c Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 00:35:54 +0000
Subject: [PATCH 02/12] style
---
.../Data/Config/ConfigConflictDiff.react.js | 4 ----
.../Data/Config/ConfigConflictDiff.scss | 19 ++-----------------
2 files changed, 2 insertions(+), 21 deletions(-)
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.react.js b/src/dashboard/Data/Config/ConfigConflictDiff.react.js
index 6892c30303..589de80a8d 100644
--- a/src/dashboard/Data/Config/ConfigConflictDiff.react.js
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.react.js
@@ -203,10 +203,6 @@ const ConfigConflictDiff = ({ serverValue, userValue, type }) => {
return (
-
- Server version
- Your edits
-
{rows.map((row, idx) => (
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.scss b/src/dashboard/Data/Config/ConfigConflictDiff.scss
index 1a4b6cb7c6..0755919865 100644
--- a/src/dashboard/Data/Config/ConfigConflictDiff.scss
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss
@@ -1,9 +1,8 @@
.container {
- border: 1px solid #d1d5da;
- border-radius: 4px;
- margin: 12px 20px;
+ width: 100%;
overflow: auto;
max-height: 400px;
+ text-align: left;
}
.table {
@@ -60,20 +59,6 @@
border-radius: 2px;
}
-.header {
- padding: 8px 16px;
- background-color: #fafbfc;
- border-bottom: 1px solid #d1d5da;
- font-size: 13px;
- color: #24292e;
- display: flex;
- justify-content: space-between;
-}
-
-.headerLabel {
- font-weight: 600;
-}
-
.emptyDiff {
padding: 16px 20px;
color: #586069;
From 9128eb12307a4f298d285052e6026c8821018165 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 00:42:43 +0000
Subject: [PATCH 03/12] resize
---
src/dashboard/Data/Config/ConfigConflictDiff.scss | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.scss b/src/dashboard/Data/Config/ConfigConflictDiff.scss
index 0755919865..b4e1b01b02 100644
--- a/src/dashboard/Data/Config/ConfigConflictDiff.scss
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss
@@ -1,7 +1,10 @@
.container {
width: 100%;
+ min-width: 100%;
overflow: auto;
- max-height: 400px;
+ height: 200px;
+ min-height: 80px;
+ resize: both;
text-align: left;
}
From e6b1e7d2f87c1194577c05691fff9a99069403b8 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 00:43:29 +0000
Subject: [PATCH 04/12] bkg
---
src/dashboard/Data/Config/ConfigConflictDiff.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.scss b/src/dashboard/Data/Config/ConfigConflictDiff.scss
index b4e1b01b02..7c48982ecf 100644
--- a/src/dashboard/Data/Config/ConfigConflictDiff.scss
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss
@@ -49,7 +49,7 @@
}
.lineContext {
- background-color: #ffffff;
+ background-color: transparent;
}
.charRemoved {
From 6b4522a1b399e4015e55877d7c897da793b897a0 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 00:59:45 +0000
Subject: [PATCH 05/12] conflict modal
---
src/dashboard/Data/Config/Config.react.js | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js
index 7c4b09f3a6..aaef13d241 100644
--- a/src/dashboard/Data/Config/Config.react.js
+++ b/src/dashboard/Data/Config/Config.react.js
@@ -278,11 +278,12 @@ class Config extends TableView {
type={Modal.Types.INFO}
icon="warn-outline"
title={`Conflict: ${this.confirmData?.name || 'parameter'}`}
+ subtitle="Parameter was modified on the server while editing - compare your changes below to the server version."
showCancel={false}
- continueText="Accept server version"
+ continueText="Keep server version"
showContinue={true}
onContinue={() => this.setState({ confirmModalOpen: false })}
- confirmText="Keep my edits"
+ confirmText="Save my changes"
onConfirm={() => {
this.setState({ confirmModalOpen: false });
this.saveParam({
From 76df01b8f501a967e89a3c94d49cf2d7b0e3e1d1 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 02:33:40 +0000
Subject: [PATCH 06/12] diff row
---
src/dashboard/Data/Config/Config.react.js | 74 +++++++------------
.../Data/Config/ConfigDialog.react.js | 42 ++++++++++-
src/lib/tests/ConfigConflictDiff.test.js | 4 +-
3 files changed, 66 insertions(+), 54 deletions(-)
diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js
index aaef13d241..11fb98c793 100644
--- a/src/dashboard/Data/Config/Config.react.js
+++ b/src/dashboard/Data/Config/Config.react.js
@@ -7,7 +7,6 @@
*/
import { ActionTypes } from 'lib/stores/ConfigStore';
import Button from 'components/Button/Button.react';
-import ConfigConflictDiff from 'dashboard/Data/Config/ConfigConflictDiff.react';
import ConfigDialog from 'dashboard/Data/Config/ConfigDialog.react';
import DeleteParameterDialog from 'dashboard/Data/Config/DeleteParameterDialog.react';
import AddArrayEntryDialog from 'dashboard/Data/Config/AddArrayEntryDialog.react';
@@ -25,7 +24,6 @@ import Toolbar from 'components/Toolbar/Toolbar.react';
import browserStyles from 'dashboard/Data/Browser/Browser.scss';
import configStyles from 'dashboard/Data/Config/Config.scss';
import { CurrentApp } from 'context/currentApp';
-import Modal from 'components/Modal/Modal.react';
import equal from 'fast-deep-equal';
import Notification from 'dashboard/Data/Browser/Notification.react';
import ServerConfigStorage from 'lib/ServerConfigStorage';
@@ -47,7 +45,7 @@ class Config extends TableView {
modalValue: '',
modalMasterKeyOnly: false,
loading: false,
- confirmModalOpen: false,
+ modalConflict: false,
lastError: null,
lastNote: null,
showAddEntryDialog: false,
@@ -221,11 +219,12 @@ class Config extends TableView {
extras = (
this.setState({ modalOpen: false })}
+ onCancel={() => this.setState({ modalOpen: false, modalConflict: false })}
param={this.state.modalParam}
type={this.state.modalType}
value={this.state.modalValue}
masterKeyOnly={this.state.modalMasterKeyOnly}
+ conflict={this.state.modalConflict}
parseServerVersion={this.context.serverInfo?.parseServerVersion}
loading={this.state.loading}
configHistory={this.state.currentParamHistory}
@@ -272,35 +271,6 @@ class Config extends TableView {
);
}
- if (this.state.confirmModalOpen) {
- extras = (
- this.setState({ confirmModalOpen: false })}
- confirmText="Save my changes"
- onConfirm={() => {
- this.setState({ confirmModalOpen: false });
- this.saveParam({
- ...this.confirmData,
- override: true,
- });
- }}
- width={700}
- >
-
-
- );
- }
let notification = null;
if (this.state.lastError) {
notification = ;
@@ -543,20 +513,28 @@ class Config extends TableView {
const currentValueAfter = fetchedParamsAfter.get(name);
const valuesAreEqual = equal(currentValue, currentValueAfter);
- if (!valuesAreEqual && !override) {
- this.setState({
- confirmModalOpen: true,
- modalOpen: false,
- loading: false,
- });
- this.confirmData = {
- name,
- value,
- type,
- masterKeyOnly,
- };
- this.confirmServerValue = currentValueAfter;
- return;
+ if (!valuesAreEqual) {
+ const { modalValue: conflictServerValue } = this.parseValueForModal(currentValueAfter);
+
+ if (override) {
+ // Re-check: has the server value changed again since the user confirmed?
+ const serverValueChanged = this.state.modalValue !== conflictServerValue;
+ if (serverValueChanged) {
+ this.setState({
+ modalConflict: true,
+ modalValue: conflictServerValue,
+ loading: false,
+ });
+ return;
+ }
+ } else {
+ this.setState({
+ modalConflict: true,
+ modalValue: conflictServerValue,
+ loading: false,
+ });
+ return;
+ }
}
await this.props.config.dispatch(ActionTypes.SET, {
@@ -574,7 +552,7 @@ class Config extends TableView {
this.cacheData.set('masterKeyOnly', masterKeyOnlyParams);
}
- this.setState({ modalOpen: false });
+ this.setState({ modalOpen: false, modalConflict: false });
// Update config history in localStorage
let transformedValue = value;
diff --git a/src/dashboard/Data/Config/ConfigDialog.react.js b/src/dashboard/Data/Config/ConfigDialog.react.js
index 7f55964e0f..2ced5c6e1e 100644
--- a/src/dashboard/Data/Config/ConfigDialog.react.js
+++ b/src/dashboard/Data/Config/ConfigDialog.react.js
@@ -128,6 +128,7 @@ export default class ConfigDialog extends React.Component {
selectedIndex: null,
wordWrap: false,
showDiff: false,
+ confirmOverride: false,
error: null,
syntaxColors: null,
};
@@ -145,6 +146,7 @@ export default class ConfigDialog extends React.Component {
selectedIndex: 0,
wordWrap: false,
showDiff: false,
+ confirmOverride: false,
error: initialError,
syntaxColors: null,
};
@@ -329,6 +331,7 @@ export default class ConfigDialog extends React.Component {
type: this.state.type,
value: GET_VALUE[this.state.type](this.state.value),
masterKeyOnly: this.state.masterKeyOnly,
+ ...(this.props.conflict && this.state.confirmOverride ? { override: true } : {}),
});
}
@@ -361,8 +364,16 @@ export default class ConfigDialog extends React.Component {
}
componentDidUpdate(prevProps) {
- // Update parameter value or masterKeyOnly if they have changed
- if (this.props.value !== prevProps.value || this.props.masterKeyOnly !== prevProps.masterKeyOnly) {
+ // When a conflict is detected (or server value changes during conflict),
+ // don't reset the editor value — preserve user edits.
+ // Auto-enable the Diff toggle and reset the override confirmation.
+ if (this.props.conflict && (!prevProps.conflict || this.props.value !== prevProps.value)) {
+ this.setState({ showDiff: true, confirmOverride: false });
+ return;
+ }
+
+ // Update parameter value or masterKeyOnly if they have changed (non-conflict)
+ if (!this.props.conflict && (this.props.value !== prevProps.value || this.props.masterKeyOnly !== prevProps.masterKeyOnly)) {
let updatedValue = this.props.value;
let error = null;
@@ -452,6 +463,29 @@ export default class ConfigDialog extends React.Component {
{ detectNonPrintable: effectiveDetectNonPrintable, detectNonAlphanumeric: effectiveDetectNonAlphanumeric, detectRegex: effectiveDetectRegex }
)}
/>
+ {this.props.conflict && (
+
+ }
+ input={
+
+
+ Do you want to overwrite the server value with the diff below?
+
+
this.setState({ confirmOverride })}
+ additionalStyles={{ margin: '0px' }}
+ />
+
+ }
+ />
+ )}
{this.state.showDiff && this.props.param.length > 0 && (
@@ -592,7 +626,7 @@ export default class ConfigDialog extends React.Component {
iconSize={30}
subtitle={'Dynamically configure parts of your app'}
customFooter={customFooter}
- disabled={!this.valid() || this.props.loading}
+ disabled={!this.valid() || this.props.loading || (this.props.conflict && !this.state.confirmOverride)}
onCancel={this.props.onCancel}
onConfirm={this.submit.bind(this)}
>
diff --git a/src/lib/tests/ConfigConflictDiff.test.js b/src/lib/tests/ConfigConflictDiff.test.js
index 740c4d0092..c6fa16c96a 100644
--- a/src/lib/tests/ConfigConflictDiff.test.js
+++ b/src/lib/tests/ConfigConflictDiff.test.js
@@ -39,8 +39,8 @@ describe('ConfigConflictDiff', () => {
);
const tree = component.toJSON();
expect(tree).toBeTruthy();
- // Should have header + table children
- expect(tree.children.length).toBe(2);
+ // Should have table child
+ expect(tree.children.length).toBe(1);
});
it('renders a diff for changed object values', () => {
From bf5735ba791b188d1720f39860495d89ba025a18 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 02:48:18 +0000
Subject: [PATCH 07/12] error section
---
.../Data/Config/ConfigDialog.react.js | 40 ++++++++-----------
1 file changed, 16 insertions(+), 24 deletions(-)
diff --git a/src/dashboard/Data/Config/ConfigDialog.react.js b/src/dashboard/Data/Config/ConfigDialog.react.js
index 2ced5c6e1e..b7d21f9a54 100644
--- a/src/dashboard/Data/Config/ConfigDialog.react.js
+++ b/src/dashboard/Data/Config/ConfigDialog.react.js
@@ -463,29 +463,6 @@ export default class ConfigDialog extends React.Component {
{ detectNonPrintable: effectiveDetectNonPrintable, detectNonAlphanumeric: effectiveDetectNonAlphanumeric, detectRegex: effectiveDetectRegex }
)}
/>
- {this.props.conflict && (
-
- }
- input={
-
-
- Do you want to overwrite the server value with the diff below?
-
-
this.setState({ confirmOverride })}
- additionalStyles={{ margin: '0px' }}
- />
-
- }
- />
- )}
{this.state.showDiff && this.props.param.length > 0 && (
0;
const customFooter = (
-
+
+ {this.props.conflict && (
+
+
+ Server value changed while editing, see diff view - overwrite it?
+
+ this.setState({ confirmOverride })}
+ additionalStyles={{ margin: '0px' }}
+ />
+
+ )}
+
{isJsonType && (
<>
@@ -616,6 +607,7 @@ export default class ConfigDialog extends React.Component {
/>
+
);
return (
From 9360187c7d5992a62bfd0602aadaf89248d95da8 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 02:54:06 +0000
Subject: [PATCH 08/12] clean-up
---
src/dashboard/Data/Config/ConfigConflictDiff.scss | 7 +++++++
src/lib/tests/ConfigConflictDiff.test.js | 5 -----
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.scss b/src/dashboard/Data/Config/ConfigConflictDiff.scss
index 7c48982ecf..3d355c074c 100644
--- a/src/dashboard/Data/Config/ConfigConflictDiff.scss
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss
@@ -1,3 +1,10 @@
+/*
+ * Copyright (c) 2016-present, Parse, LLC
+ * All rights reserved.
+ *
+ * This source code is licensed under the license found in the LICENSE file in
+ * the root directory of this source tree.
+ */
.container {
width: 100%;
min-width: 100%;
diff --git a/src/lib/tests/ConfigConflictDiff.test.js b/src/lib/tests/ConfigConflictDiff.test.js
index c6fa16c96a..93addd2e69 100644
--- a/src/lib/tests/ConfigConflictDiff.test.js
+++ b/src/lib/tests/ConfigConflictDiff.test.js
@@ -34,7 +34,6 @@ describe('ConfigConflictDiff', () => {
serverValue="hello"
userValue="world"
type="String"
- paramName="greeting"
/>
);
const tree = component.toJSON();
@@ -49,7 +48,6 @@ describe('ConfigConflictDiff', () => {
serverValue={{ key: 'old' }}
userValue='{"key": "new"}'
type="Object"
- paramName="config"
/>
);
const tree = component.toJSON();
@@ -62,7 +60,6 @@ describe('ConfigConflictDiff', () => {
serverValue=""
userValue=""
type="String"
- paramName="test"
/>
);
const tree = component.toJSON();
@@ -76,7 +73,6 @@ describe('ConfigConflictDiff', () => {
serverValue={true}
userValue={false}
type="Boolean"
- paramName="flag"
/>
);
const tree = component.toJSON();
@@ -89,7 +85,6 @@ describe('ConfigConflictDiff', () => {
serverValue={42}
userValue={99}
type="Number"
- paramName="count"
/>
);
const tree = component.toJSON();
From ecd0d045b5fc0ac6b3e9255eb6e1ee2123e96076 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 02:59:35 +0000
Subject: [PATCH 09/12] fix
https://github.com/parse-community/parse-dashboard/pull/3239#discussion_r2862244850
---
src/dashboard/Data/Config/Config.react.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js
index 11fb98c793..7daf827fb5 100644
--- a/src/dashboard/Data/Config/Config.react.js
+++ b/src/dashboard/Data/Config/Config.react.js
@@ -518,7 +518,7 @@ class Config extends TableView {
if (override) {
// Re-check: has the server value changed again since the user confirmed?
- const serverValueChanged = this.state.modalValue !== conflictServerValue;
+ const serverValueChanged = !equal(this.state.modalValue, conflictServerValue);
if (serverValueChanged) {
this.setState({
modalConflict: true,
From 87c7887ee0d13dc0e289d0da9ec849f41d3438a6 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 03:00:14 +0000
Subject: [PATCH 10/12] fix
https://github.com/parse-community/parse-dashboard/pull/3239#discussion_r2862244854
---
src/dashboard/Data/Config/ConfigConflictDiff.react.js | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.react.js b/src/dashboard/Data/Config/ConfigConflictDiff.react.js
index 589de80a8d..335261b5c9 100644
--- a/src/dashboard/Data/Config/ConfigConflictDiff.react.js
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.react.js
@@ -196,8 +196,12 @@ const ConfigConflictDiff = ({ serverValue, userValue, type }) => {
};
const lineStyle = (row) => {
- if (row.type === 'removed') return styles.lineRemoved;
- if (row.type === 'added') return styles.lineAdded;
+ if (row.type === 'removed') {
+ return styles.lineRemoved;
+ }
+ if (row.type === 'added') {
+ return styles.lineAdded;
+ }
return styles.lineContext;
};
From 0d2226e425bfafa85d131d5d3ba2585fc0c0f550 Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 03:00:52 +0000
Subject: [PATCH 11/12] fix
https://github.com/parse-community/parse-dashboard/pull/3239#discussion_r2862244867
---
src/dashboard/Data/Config/ConfigConflictDiff.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.scss b/src/dashboard/Data/Config/ConfigConflictDiff.scss
index 3d355c074c..a3fa52cd2b 100644
--- a/src/dashboard/Data/Config/ConfigConflictDiff.scss
+++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss
@@ -18,7 +18,7 @@
.table {
width: 100%;
border-collapse: collapse;
- font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+ font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
line-height: 20px;
}
From 6371250430d98f6d5e07aa9332b021a2ecce64cf Mon Sep 17 00:00:00 2001
From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com>
Date: Fri, 27 Feb 2026 03:05:39 +0000
Subject: [PATCH 12/12] lint
---
.../Data/Config/ConfigDialog.react.js | 90 +++++++++----------
1 file changed, 45 insertions(+), 45 deletions(-)
diff --git a/src/dashboard/Data/Config/ConfigDialog.react.js b/src/dashboard/Data/Config/ConfigDialog.react.js
index b7d21f9a54..03c5327f09 100644
--- a/src/dashboard/Data/Config/ConfigDialog.react.js
+++ b/src/dashboard/Data/Config/ConfigDialog.react.js
@@ -553,61 +553,61 @@ export default class ConfigDialog extends React.Component {
)}
-
- {isJsonType && (
- <>
-
-
+
+ {isJsonType && (
+ <>
+
+
+
+ this.setState({ wordWrap })}
+ additionalStyles={{ margin: '0px' }}
+ colorLeft="#cbcbcb"
+ colorRight="#00db7c"
+ />
+ Wrap
+
+ {this.state.error && (
+ {this.state.error}
+ )}
+ >
+ )}
+ {isDiffableType && isExistingParam && (
this.setState({ wordWrap })}
+ value={this.state.showDiff}
+ onChange={showDiff => this.setState({ showDiff })}
additionalStyles={{ margin: '0px' }}
colorLeft="#cbcbcb"
colorRight="#00db7c"
/>
- Wrap
+ Diff
- {this.state.error && (
- {this.state.error}
- )}
- >
- )}
- {isDiffableType && isExistingParam && (
-
- this.setState({ showDiff })}
- additionalStyles={{ margin: '0px' }}
- colorLeft="#cbcbcb"
- colorRight="#00db7c"
- />
- Diff
-
- )}
-
-
-
-
+ )}
+
+
+
+
+
-
);
return (