Skip to content

Commit ae50b8d

Browse files
authored
feat: Add confirmation dialog to handle conflicts when migrating settings to server (parse-community#3092)
1 parent abe4647 commit ae50b8d

File tree

4 files changed

+424
-18
lines changed

4 files changed

+424
-18
lines changed

src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js

Lines changed: 161 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import Option from 'components/Dropdown/Option.react';
1515
import Toolbar from 'components/Toolbar/Toolbar.react';
1616
import CodeSnippet from 'components/CodeSnippet/CodeSnippet.react';
1717
import Notification from 'dashboard/Data/Browser/Notification.react';
18+
import Modal from 'components/Modal/Modal.react';
1819
import * as ColumnPreferences from 'lib/ColumnPreferences';
1920
import * as ClassPreferences from 'lib/ClassPreferences';
2021
import ViewPreferencesManager from 'lib/ViewPreferencesManager';
@@ -47,6 +48,8 @@ export default class DashboardSettings extends DashboardView {
4748
passwordHidden: true,
4849
migrationLoading: false,
4950
storagePreference: 'local', // Will be updated in componentDidMount
51+
showConflictModal: false,
52+
migrationConflicts: [],
5053
copyData: {
5154
data: '',
5255
show: false,
@@ -95,7 +98,7 @@ export default class DashboardSettings extends DashboardView {
9598
}
9699
}
97100

98-
async migrateToServer() {
101+
async migrateToServer(overwriteConflicts = false) {
99102
if (!this.viewPreferencesManager) {
100103
this.showNote('ViewPreferencesManager not initialized');
101104
return;
@@ -106,6 +109,11 @@ export default class DashboardSettings extends DashboardView {
106109
return;
107110
}
108111

112+
if (!this.scriptManager) {
113+
this.showNote('ScriptManager not initialized');
114+
return;
115+
}
116+
109117
if (!this.viewPreferencesManager.isServerConfigEnabled()) {
110118
this.showNote('Server configuration is not enabled for this app. Please add a "config" section to your app configuration.');
111119
return;
@@ -115,14 +123,30 @@ export default class DashboardSettings extends DashboardView {
115123

116124
try {
117125
// Migrate views
118-
const viewsResult = await this.viewPreferencesManager.migrateToServer(this.context.applicationId);
126+
const viewsResult = await this.viewPreferencesManager.migrateToServer(this.context.applicationId, overwriteConflicts);
119127

120128
// Migrate filters
121-
const filtersResult = await this.filterPreferencesManager.migrateToServer(this.context.applicationId);
129+
const filtersResult = await this.filterPreferencesManager.migrateToServer(this.context.applicationId, overwriteConflicts);
130+
131+
// Migrate scripts
132+
const scriptsResult = await this.scriptManager.migrateToServer(this.context.applicationId, overwriteConflicts);
133+
134+
// Check for conflicts
135+
const allConflicts = [
136+
...(viewsResult.conflicts || []),
137+
...(filtersResult.conflicts || []),
138+
...(scriptsResult.conflicts || [])
139+
];
140+
141+
if (allConflicts.length > 0 && !overwriteConflicts) {
142+
// Show conflict dialog
143+
this.showConflictDialog(allConflicts);
144+
return;
145+
}
122146

123-
const totalItems = viewsResult.viewCount + filtersResult.filterCount;
147+
const totalItems = viewsResult.viewCount + filtersResult.filterCount + scriptsResult.scriptCount;
124148

125-
if (viewsResult.success && filtersResult.success) {
149+
if (viewsResult.success && filtersResult.success && scriptsResult.success) {
126150
if (totalItems > 0) {
127151
const messages = [];
128152
if (viewsResult.viewCount > 0) {
@@ -131,9 +155,12 @@ export default class DashboardSettings extends DashboardView {
131155
if (filtersResult.filterCount > 0) {
132156
messages.push(`${filtersResult.filterCount} filter(s)`);
133157
}
134-
this.showNote(`Successfully migrated ${messages.join(' and ')} to server storage.`);
158+
if (scriptsResult.scriptCount > 0) {
159+
messages.push(`${scriptsResult.scriptCount} script(s)`);
160+
}
161+
this.showNote(`Successfully migrated ${messages.join(', ')} to server storage.`);
135162
} else {
136-
this.showNote('No views or filters found to migrate.');
163+
this.showNote('No views, filters, or scripts found to migrate.');
137164
}
138165
}
139166
} catch (error) {
@@ -143,6 +170,25 @@ export default class DashboardSettings extends DashboardView {
143170
}
144171
}
145172

173+
showConflictDialog(conflicts) {
174+
this.setState({
175+
migrationLoading: false,
176+
showConflictModal: true,
177+
migrationConflicts: conflicts
178+
});
179+
}
180+
181+
handleConflictOverwrite() {
182+
this.setState({ showConflictModal: false });
183+
// User chose to overwrite - retry migration with overwrite flag
184+
this.migrateToServer(true);
185+
}
186+
187+
handleConflictCancel() {
188+
this.setState({ showConflictModal: false, migrationConflicts: [] });
189+
this.showNote('Migration aborted. Server settings were not modified.');
190+
}
191+
146192
async deleteFromBrowser() {
147193
if (!window.confirm('Are you sure you want to delete all dashboard settings from browser storage? This action cannot be undone.')) {
148194
return;
@@ -533,7 +579,7 @@ export default class DashboardSettings extends DashboardView {
533579
label={
534580
<Label
535581
text="Migrate Settings to Server"
536-
description="Migrates browser-stored settings to the server. ⚠️ This overwrites existing dashboard settings on the server."
582+
description="Migrates browser-stored settings to the server. If conflicts are detected, you'll be asked whether to overwrite or abort."
537583
/>
538584
}
539585
input={
@@ -565,12 +611,119 @@ export default class DashboardSettings extends DashboardView {
565611
{this.state.copyData.show && copyData}
566612
{this.state.createUserInput && createUserInput}
567613
{this.state.newUser.show && userData}
614+
{this.state.showConflictModal && this.renderConflictModal()}
568615
<Toolbar section="Settings" subsection="Dashboard Configuration" />
569616
<Notification note={this.state.message} isErrorNote={false} />
570617
</div>
571618
);
572619
}
573620

621+
renderConflictModal() {
622+
const { migrationConflicts } = this.state;
623+
624+
// Format conflict details
625+
const viewConflicts = migrationConflicts.filter(c => c.type === 'view');
626+
const filterConflicts = migrationConflicts.filter(c => c.type === 'filter');
627+
const scriptConflicts = migrationConflicts.filter(c => c.type === 'script');
628+
629+
const conflictList = (
630+
<div style={{ padding: '20px', fontSize: '14px' }}>
631+
<p style={{ marginBottom: '15px' }}>
632+
The following settings already exist on the server:
633+
</p>
634+
635+
<div style={{
636+
maxHeight: '300px',
637+
overflowY: 'auto',
638+
marginBottom: '15px',
639+
border: '1px solid #e0e0e0',
640+
borderRadius: '4px',
641+
padding: '10px'
642+
}}>
643+
{viewConflicts.length > 0 && (
644+
<div style={{ marginBottom: (filterConflicts.length > 0 || scriptConflicts.length > 0) ? '15px' : '0' }}>
645+
<strong>Views ({viewConflicts.length}):</strong>
646+
<ul style={{ marginTop: '5px', marginBottom: '0', paddingLeft: '20px' }}>
647+
{viewConflicts.map(conflict => {
648+
const viewName = conflict.local?.name || conflict.server?.name || '';
649+
return (
650+
<li key={conflict.id}>
651+
{viewName || 'Unnamed view'}
652+
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: '#666', marginLeft: '8px' }}>
653+
[{conflict.id}]
654+
</span>
655+
</li>
656+
);
657+
})}
658+
</ul>
659+
</div>
660+
)}
661+
662+
{filterConflicts.length > 0 && (
663+
<div style={{ marginBottom: scriptConflicts.length > 0 ? '15px' : '0' }}>
664+
<strong>Filters ({filterConflicts.length}):</strong>
665+
<ul style={{ marginTop: '5px', marginBottom: '0', paddingLeft: '20px' }}>
666+
{filterConflicts.map(conflict => {
667+
const filterName = conflict.local?.name || conflict.server?.name || '';
668+
const className = conflict.className || 'Unknown class';
669+
const displayText = filterName
670+
? `${filterName} (${className})`
671+
: className;
672+
return (
673+
<li key={conflict.id}>
674+
{displayText}
675+
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: '#666', marginLeft: '8px' }}>
676+
[{conflict.id}]
677+
</span>
678+
</li>
679+
);
680+
})}
681+
</ul>
682+
</div>
683+
)}
684+
685+
{scriptConflicts.length > 0 && (
686+
<div>
687+
<strong>Scripts ({scriptConflicts.length}):</strong>
688+
<ul style={{ marginTop: '5px', marginBottom: '0', paddingLeft: '20px' }}>
689+
{scriptConflicts.map(conflict => {
690+
const scriptName = conflict.local?.name || conflict.server?.name || '';
691+
return (
692+
<li key={conflict.id}>
693+
{scriptName || 'Unnamed script'}
694+
<span style={{ fontFamily: 'monospace', fontSize: '12px', color: '#666', marginLeft: '8px' }}>
695+
[{conflict.id}]
696+
</span>
697+
</li>
698+
);
699+
})}
700+
</ul>
701+
</div>
702+
)}
703+
</div>
704+
705+
<p style={{ marginTop: '15px', fontWeight: 'bold' }}>
706+
Do you want to overwrite the server settings with your local settings?
707+
</p>
708+
</div>
709+
);
710+
711+
return (
712+
<Modal
713+
type={Modal.Types.DANGER}
714+
icon="warn-outline"
715+
title="Migration Conflicts Detected"
716+
subtitle="Settings with the same ID already exist on the server"
717+
confirmText="Overwrite Server Settings"
718+
cancelText="Cancel Migration"
719+
onConfirm={() => this.handleConflictOverwrite()}
720+
onCancel={() => this.handleConflictCancel()}
721+
>
722+
{conflictList}
723+
</Modal>
724+
);
725+
}
726+
574727
renderContent() {
575728
return (
576729
<FlowView

src/lib/FilterPreferencesManager.js

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,17 +98,28 @@ export default class FilterPreferencesManager {
9898
/**
9999
* Migrates filters from local storage to server storage for all classes
100100
* @param {string} appId - The application ID
101-
* @returns {Promise<{success: boolean, filterCount: number}>}
101+
* @param {boolean} overwriteConflicts - If true, overwrite server filters with same ID
102+
* @returns {Promise<{success: boolean, filterCount: number, conflicts?: Array}>}
102103
*/
103-
async migrateToServer(appId) {
104+
async migrateToServer(appId, overwriteConflicts = false) {
104105
if (!this.serverStorage.isServerConfigEnabled()) {
105106
throw new Error('Server configuration is not enabled for this app');
106107
}
107108

108109
const allPreferences = getAllPreferences(appId);
109110
let totalFilterCount = 0;
111+
const allConflicts = [];
110112

111113
try {
114+
// Get all existing filters from server
115+
const existingFilterConfigs = await this.serverStorage.getConfigsByPrefix(
116+
'browser.filters.filter.',
117+
appId
118+
);
119+
const existingFilterIds = Object.keys(existingFilterConfigs).map(key =>
120+
key.replace('browser.filters.filter.', '')
121+
);
122+
112123
for (const [className, preferences] of Object.entries(allPreferences)) {
113124
if (preferences.filters && preferences.filters.length > 0) {
114125
// Ensure all filters have UUIDs before migrating
@@ -119,11 +130,42 @@ export default class FilterPreferencesManager {
119130
return filter;
120131
});
121132

122-
await this._saveFiltersToServer(appId, className, filtersWithIds);
123-
totalFilterCount += filtersWithIds.length;
133+
// Check for conflicts in this class
134+
const localFilterIds = filtersWithIds.map(f => f.id);
135+
const conflictingIds = localFilterIds.filter(id => existingFilterIds.includes(id));
136+
137+
if (conflictingIds.length > 0 && !overwriteConflicts) {
138+
// Collect conflicts
139+
conflictingIds.forEach(id => {
140+
const localFilter = filtersWithIds.find(f => f.id === id);
141+
const serverFilterKey = `browser.filters.filter.${id}`;
142+
const serverFilter = existingFilterConfigs[serverFilterKey];
143+
allConflicts.push({
144+
id,
145+
type: 'filter',
146+
className,
147+
local: localFilter,
148+
server: serverFilter
149+
});
150+
});
151+
}
152+
153+
if (overwriteConflicts || conflictingIds.length === 0) {
154+
// Only migrate if no conflicts or overwriting
155+
await this._migrateFiltersToServer(appId, className, filtersWithIds, existingFilterIds, overwriteConflicts);
156+
totalFilterCount += filtersWithIds.length;
157+
}
124158
}
125159
}
126160

161+
if (allConflicts.length > 0 && !overwriteConflicts) {
162+
return {
163+
success: false,
164+
filterCount: 0,
165+
conflicts: allConflicts
166+
};
167+
}
168+
127169
return { success: true, filterCount: totalFilterCount };
128170
} catch (error) {
129171
console.error('Failed to migrate filters to server:', error);
@@ -176,6 +218,54 @@ export default class FilterPreferencesManager {
176218
return this.serverStorage.isServerConfigEnabled();
177219
}
178220

221+
/**
222+
* Migrates filters to server storage with merge/overwrite logic
223+
* @private
224+
*/
225+
async _migrateFiltersToServer(appId, className, filters, existingFilterIds, overwriteConflicts) {
226+
try {
227+
// Save local filters to server
228+
await Promise.all(
229+
filters.map(filter => {
230+
const filterId = filter.id;
231+
const filterConfig = { ...filter };
232+
delete filterConfig.id; // Don't store ID in the config itself
233+
234+
// Add className to the object
235+
filterConfig.className = className;
236+
237+
// Remove null and undefined values to keep the storage clean
238+
Object.keys(filterConfig).forEach(key => {
239+
if (filterConfig[key] === null || filterConfig[key] === undefined) {
240+
delete filterConfig[key];
241+
}
242+
});
243+
244+
// Stringify the filter if it exists and is an array/object
245+
if (filterConfig.filter && (Array.isArray(filterConfig.filter) || typeof filterConfig.filter === 'object')) {
246+
filterConfig.filter = JSON.stringify(filterConfig.filter);
247+
}
248+
249+
// Only save if we're overwriting conflicts or if this filter doesn't exist on server
250+
if (overwriteConflicts || !existingFilterIds.includes(filterId)) {
251+
return this.serverStorage.setConfig(
252+
`browser.filters.filter.${filterId}`,
253+
filterConfig,
254+
appId
255+
);
256+
}
257+
return Promise.resolve(); // Skip conflicting filters when not overwriting
258+
})
259+
);
260+
261+
// Note: We don't delete server filters that aren't in local storage
262+
// This preserves server-side settings that don't conflict with local ones
263+
} catch (error) {
264+
console.error('Failed to migrate filters to server:', error);
265+
throw error;
266+
}
267+
}
268+
179269
/**
180270
* Gets filters for a specific class from server storage
181271
* @private

0 commit comments

Comments
 (0)