Skip to content
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/components/JsonEditor/JsonEditor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
67 changes: 26 additions & 41 deletions src/dashboard/Data/Config/Config.react.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,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';
Expand All @@ -46,7 +45,7 @@ class Config extends TableView {
modalValue: '',
modalMasterKeyOnly: false,
loading: false,
confirmModalOpen: false,
modalConflict: false,
lastError: null,
lastNote: null,
showAddEntryDialog: false,
Expand Down Expand Up @@ -220,11 +219,12 @@ class Config extends TableView {
extras = (
<ConfigDialog
onConfirm={this.saveParam.bind(this)}
onCancel={() => 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}
Expand Down Expand Up @@ -271,30 +271,6 @@ class Config extends TableView {
);
}

if (this.state.confirmModalOpen) {
extras = (
<Modal
type={Modal.Types.INFO}
icon="warn-outline"
title={'Are you sure?'}
confirmText="Continue"
cancelText="Cancel"
onCancel={() => this.setState({ confirmModalOpen: false })}
onConfirm={() => {
this.setState({ confirmModalOpen: false });
this.saveParam({
...this.confirmData,
override: true,
});
}}
>
<div className={[browserStyles.confirmConfig]}>
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?
</div>
</Modal>
);
}
let notification = null;
if (this.state.lastError) {
notification = <Notification note={this.state.lastError} isErrorNote={true} />;
Expand Down Expand Up @@ -537,19 +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,
};
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, {
Expand All @@ -567,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;
Expand Down
222 changes: 222 additions & 0 deletions src/dashboard/Data/Config/ConfigConflictDiff.react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*
* 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 <span key={i} className={styles.charAdded}>{part.value}</span>;
}
if (part.removed && side === 'removed') {
return <span key={i} className={styles.charRemoved}>{part.value}</span>;
}
if (!part.added && !part.removed) {
return <span key={i}>{part.value}</span>;
}
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 <div className={styles.emptyDiff}>Values are identical — no differences found.</div>;
}

// 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;

Check failure on line 199 in src/dashboard/Data/Config/ConfigConflictDiff.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
if (row.type === 'added') return styles.lineAdded;

Check failure on line 200 in src/dashboard/Data/Config/ConfigConflictDiff.react.js

View workflow job for this annotation

GitHub Actions / Lint

Expected { after 'if' condition
return styles.lineContext;
};

return (
<div className={styles.container}>
<table className={styles.table}>
<tbody>
{rows.map((row, idx) => (
<tr key={idx} className={lineStyle(row)}>
<td className={styles.lineNumber}>{row.oldNum ?? ''}</td>
<td className={styles.lineNumber}>{row.newNum ?? ''}</td>
<td className={styles.prefix}>{row.prefix}</td>
<td className={styles.content}>{renderContent(row)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};

export default ConfigConflictDiff;
Loading
Loading