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..7daf827fb5 100644 --- a/src/dashboard/Data/Config/Config.react.js +++ b/src/dashboard/Data/Config/Config.react.js @@ -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'; @@ -46,7 +45,7 @@ class Config extends TableView { modalValue: '', modalMasterKeyOnly: false, loading: false, - confirmModalOpen: false, + modalConflict: false, lastError: null, lastNote: null, showAddEntryDialog: false, @@ -220,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} @@ -271,30 +271,6 @@ class Config extends TableView { ); } - if (this.state.confirmModalOpen) { - extras = ( - this.setState({ confirmModalOpen: false })} - onConfirm={() => { - this.setState({ confirmModalOpen: false }); - this.saveParam({ - ...this.confirmData, - override: true, - }); - }} - > -
- 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? -
-
- ); - } let notification = null; if (this.state.lastError) { notification = ; @@ -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 = !equal(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, { @@ -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; diff --git a/src/dashboard/Data/Config/ConfigConflictDiff.react.js b/src/dashboard/Data/Config/ConfigConflictDiff.react.js new file mode 100644 index 0000000000..335261b5c9 --- /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 ( +
+ + + {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..a3fa52cd2b --- /dev/null +++ b/src/dashboard/Data/Config/ConfigConflictDiff.scss @@ -0,0 +1,77 @@ +/* + * 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%; + overflow: auto; + height: 200px; + min-height: 80px; + resize: both; + text-align: left; +} + +.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: transparent; +} + +.charRemoved { + background-color: #fdaeb7; + border-radius: 2px; +} + +.charAdded { + background-color: #abf2bc; + border-radius: 2px; +} + +.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..03c5327f09 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,8 @@ export default class ConfigDialog extends React.Component { masterKeyOnly: false, selectedIndex: null, wordWrap: false, + showDiff: false, + confirmOverride: false, error: null, syntaxColors: null, }; @@ -142,6 +145,8 @@ export default class ConfigDialog extends React.Component { masterKeyOnly: props.masterKeyOnly, selectedIndex: 0, wordWrap: false, + showDiff: false, + confirmOverride: false, error: initialError, syntaxColors: null, }; @@ -326,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 } : {}), }); } @@ -358,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; @@ -449,6 +463,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,47 +535,77 @@ 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 = ( -
-
- {isJsonType && ( - <> -
-
-
+
+
); @@ -553,7 +618,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 new file mode 100644 index 0000000000..93addd2e69 --- /dev/null +++ b/src/lib/tests/ConfigConflictDiff.test.js @@ -0,0 +1,93 @@ +jest.dontMock('../../dashboard/Data/Config/ConfigConflictDiff.react'); + +import React from 'react'; +import renderer from 'react-test-renderer'; +const ConfigConflictDiff = require('../../dashboard/Data/Config/ConfigConflictDiff.react').default; + +// Mock the diff library +jest.mock('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 table child + expect(tree.children.length).toBe(1); + }); + + 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