diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js
index 1f7ddcd2d5..88f0b98a24 100644
--- a/src/dashboard/Data/Views/Views.react.js
+++ b/src/dashboard/Data/Views/Views.react.js
@@ -14,6 +14,7 @@ import Notification from 'dashboard/Data/Browser/Notification.react';
import TableView from 'dashboard/TableView.react';
import tableStyles from 'dashboard/TableView.scss';
import * as ViewPreferences from 'lib/ViewPreferences';
+import ViewPreferencesManager from 'lib/ViewPreferencesManager';
import generatePath from 'lib/generatePath';
import stringCompare from 'lib/stringCompare';
import { ActionTypes as SchemaActionTypes } from 'lib/stores/SchemaStore';
@@ -37,6 +38,7 @@ class Views extends TableView {
this.section = 'Core';
this.subsection = 'Views';
this._isMounted = false;
+ this.viewPreferencesManager = null; // Will be initialized when context is available
this.state = {
views: [],
counts: {},
@@ -83,49 +85,65 @@ class Views extends TableView {
}
}
- loadViews(app) {
- const views = ViewPreferences.getViews(app.applicationId);
- this.setState({ views, counts: {} }, () => {
- views.forEach(view => {
- if (view.showCounter) {
- if (view.cloudFunction) {
- // For Cloud Function views, call the function to get count
- Parse.Cloud.run(view.cloudFunction, {}, { useMasterKey: true })
- .then(res => {
- if (this._isMounted) {
- this.setState(({ counts }) => ({
- counts: { ...counts, [view.name]: Array.isArray(res) ? res.length : 0 },
- }));
- }
- })
- .catch(error => {
- if (this._isMounted) {
- this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
- }
- });
- } else if (view.query && Array.isArray(view.query)) {
- // For aggregation pipeline views, use existing logic
- new Parse.Query(view.className)
- .aggregate(view.query, { useMasterKey: true })
- .then(res => {
- if (this._isMounted) {
- this.setState(({ counts }) => ({
- counts: { ...counts, [view.name]: res.length },
- }));
- }
- })
- .catch(error => {
- if (this._isMounted) {
- this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
- }
- });
+ async loadViews(app) {
+ // Initialize ViewPreferencesManager if not already done or if app changed
+ if (!this.viewPreferencesManager || this.viewPreferencesManager.app !== app) {
+ this.viewPreferencesManager = new ViewPreferencesManager(app);
+ }
+
+ try {
+ const views = await this.viewPreferencesManager.getViews(app.applicationId);
+ this.setState({ views, counts: {} }, () => {
+ views.forEach(view => {
+ if (view.showCounter) {
+ if (view.cloudFunction) {
+ // For Cloud Function views, call the function to get count
+ Parse.Cloud.run(view.cloudFunction, {}, { useMasterKey: true })
+ .then(res => {
+ if (this._isMounted) {
+ this.setState(({ counts }) => ({
+ counts: { ...counts, [view.name]: Array.isArray(res) ? res.length : 0 },
+ }));
+ }
+ })
+ .catch(error => {
+ if (this._isMounted) {
+ this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
+ }
+ });
+ } else if (view.query && Array.isArray(view.query)) {
+ // For aggregation pipeline views, use existing logic
+ new Parse.Query(view.className)
+ .aggregate(view.query, { useMasterKey: true })
+ .then(res => {
+ if (this._isMounted) {
+ this.setState(({ counts }) => ({
+ counts: { ...counts, [view.name]: res.length },
+ }));
+ }
+ })
+ .catch(error => {
+ if (this._isMounted) {
+ this.showNote(`Request failed: ${error.message || 'Unknown error occurred'}`, true);
+ }
+ });
+ }
}
+ });
+ if (this._isMounted) {
+ this.loadData(this.props.params.name);
}
});
- if (this._isMounted) {
- this.loadData(this.props.params.name);
- }
- });
+ } catch (error) {
+ console.error('Failed to load views from server, falling back to local storage:', error);
+ // Fallback to local storage
+ const views = ViewPreferences.getViews(app.applicationId);
+ this.setState({ views, counts: {} }, () => {
+ if (this._isMounted) {
+ this.loadData(this.props.params.name);
+ }
+ });
+ }
}
loadData(name) {
@@ -671,8 +689,15 @@ class Views extends TableView {
onConfirm={view => {
this.setState(
state => ({ showCreate: false, views: [...state.views, view] }),
- () => {
- ViewPreferences.saveViews(this.context.applicationId, this.state.views);
+ async () => {
+ if (this.viewPreferencesManager) {
+ try {
+ await this.viewPreferencesManager.saveViews(this.context.applicationId, this.state.views);
+ } catch (error) {
+ console.error('Failed to save views:', error);
+ this.showNote('Failed to save view changes', true);
+ }
+ }
this.loadViews(this.context);
}
);
@@ -699,8 +724,15 @@ class Views extends TableView {
newViews[state.editIndex] = view;
return { editView: null, editIndex: null, views: newViews };
},
- () => {
- ViewPreferences.saveViews(this.context.applicationId, this.state.views);
+ async () => {
+ if (this.viewPreferencesManager) {
+ try {
+ await this.viewPreferencesManager.saveViews(this.context.applicationId, this.state.views);
+ } catch (error) {
+ console.error('Failed to save views:', error);
+ this.showNote('Failed to save view changes', true);
+ }
+ }
this.loadViews(this.context);
}
);
@@ -719,8 +751,15 @@ class Views extends TableView {
const newViews = state.views.filter((_, i) => i !== state.deleteIndex);
return { deleteIndex: null, views: newViews };
},
- () => {
- ViewPreferences.saveViews(this.context.applicationId, this.state.views);
+ async () => {
+ if (this.viewPreferencesManager) {
+ try {
+ await this.viewPreferencesManager.saveViews(this.context.applicationId, this.state.views);
+ } catch (error) {
+ console.error('Failed to save views:', error);
+ this.showNote('Failed to save view changes', true);
+ }
+ }
if (this.props.params.name === name) {
const path = generatePath(this.context, 'views');
this.props.navigate(path);
diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js
index 25b657b1e9..4e2cd7b0fb 100644
--- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js
+++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js
@@ -17,6 +17,7 @@ import CodeSnippet from 'components/CodeSnippet/CodeSnippet.react';
import Notification from 'dashboard/Data/Browser/Notification.react';
import * as ColumnPreferences from 'lib/ColumnPreferences';
import * as ClassPreferences from 'lib/ClassPreferences';
+import ViewPreferencesManager from 'lib/ViewPreferencesManager';
import bcrypt from 'bcryptjs';
import * as OTPAuth from 'otpauth';
import QRCode from 'qrcode';
@@ -26,6 +27,7 @@ export default class DashboardSettings extends DashboardView {
super();
this.section = 'App Settings';
this.subsection = 'Dashboard Configuration';
+ this.viewPreferencesManager = null;
this.state = {
createUserInput: false,
@@ -39,6 +41,8 @@ export default class DashboardSettings extends DashboardView {
message: null,
passwordInput: '',
passwordHidden: true,
+ migrationLoading: false,
+ storagePreference: 'local', // Will be updated in componentDidMount
copyData: {
data: '',
show: false,
@@ -52,6 +56,81 @@ export default class DashboardSettings extends DashboardView {
};
}
+ componentDidMount() {
+ this.initializeViewPreferencesManager();
+ }
+
+ initializeViewPreferencesManager() {
+ if (this.context) {
+ this.viewPreferencesManager = new ViewPreferencesManager(this.context);
+ this.loadStoragePreference();
+ }
+ }
+
+ loadStoragePreference() {
+ if (this.viewPreferencesManager) {
+ const preference = this.viewPreferencesManager.getStoragePreference(this.context.applicationId);
+ this.setState({ storagePreference: preference });
+ }
+ }
+
+ handleStoragePreferenceChange(preference) {
+ if (this.viewPreferencesManager) {
+ this.viewPreferencesManager.setStoragePreference(this.context.applicationId, preference);
+ this.setState({ storagePreference: preference });
+
+ // Show a notification about the change
+ this.showNote(`Storage preference changed to ${preference === 'server' ? 'server' : 'browser'}`);
+ }
+ }
+
+ async migrateToServer() {
+ if (!this.viewPreferencesManager) {
+ this.showNote('ViewPreferencesManager not initialized');
+ return;
+ }
+
+ if (!this.viewPreferencesManager.isServerConfigEnabled()) {
+ this.showNote('Server configuration is not enabled for this app. Please add a "config" section to your app configuration.');
+ return;
+ }
+
+ this.setState({ migrationLoading: true });
+
+ try {
+ const result = await this.viewPreferencesManager.migrateToServer(this.context.applicationId);
+ if (result.success) {
+ if (result.viewCount > 0) {
+ this.showNote(`Successfully migrated ${result.viewCount} view(s) to server storage.`);
+ } else {
+ this.showNote('No views found to migrate.');
+ }
+ }
+ } catch (error) {
+ this.showNote(`Failed to migrate views: ${error.message}`);
+ } finally {
+ this.setState({ migrationLoading: false });
+ }
+ }
+
+ async deleteFromBrowser() {
+ if (!window.confirm('Are you sure you want to delete all dashboard settings from browser storage? This action cannot be undone.')) {
+ return;
+ }
+
+ if (!this.viewPreferencesManager) {
+ this.showNote('ViewPreferencesManager not initialized');
+ return;
+ }
+
+ const success = this.viewPreferencesManager.deleteFromBrowser(this.context.applicationId);
+ if (success) {
+ this.showNote('Successfully deleted views from browser storage.');
+ } else {
+ this.showNote('Failed to delete views from browser storage.');
+ }
+ }
+
getColumns() {
const data = ColumnPreferences.getAllPreferences(this.context.applicationId);
this.setState({
@@ -382,6 +461,61 @@ export default class DashboardSettings extends DashboardView {
}
/>
+ {this.viewPreferencesManager && this.viewPreferencesManager.isServerConfigEnabled() && (
+
+ )}
{this.state.copyData.show && copyData}
{this.state.createUserInput && createUserInput}
{this.state.newUser.show && userData}
diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js
index 18e4ab02bc..318c07fe87 100644
--- a/src/lib/ParseApp.js
+++ b/src/lib/ParseApp.js
@@ -50,6 +50,7 @@ export default class ParseApp {
classPreference,
enableSecurityChecks,
cloudConfigHistoryLimit,
+ config,
}) {
this.name = appName;
this.createdAt = created_at ? new Date(created_at) : new Date();
@@ -79,6 +80,7 @@ export default class ParseApp {
this.scripts = scripts;
this.enableSecurityChecks = !!enableSecurityChecks;
this.cloudConfigHistoryLimit = cloudConfigHistoryLimit;
+ this.config = config;
if (!supportedPushLocales) {
console.warn(
diff --git a/src/lib/ServerConfigStorage.js b/src/lib/ServerConfigStorage.js
new file mode 100644
index 0000000000..edc5f5f367
--- /dev/null
+++ b/src/lib/ServerConfigStorage.js
@@ -0,0 +1,199 @@
+/*
+ * 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 Parse from 'parse';
+
+/**
+ * Utility class for storing dashboard configuration on Parse Server
+ */
+export default class ServerConfigStorage {
+ constructor(app) {
+ this.app = app;
+ this.className = app.config?.className || 'DashboardConfig';
+
+ // Validate className is a non-empty string
+ if (typeof this.className !== 'string' || !this.className.trim()) {
+ throw new Error('Invalid className for ServerConfigStorage');
+ }
+ }
+
+ /**
+ * Stores a configuration value on the server
+ * @param {string} key - The configuration key
+ * @param {*} value - The configuration value
+ * @param {string} appId - The app ID
+ * @param {string | null} userId - The user ID (optional)
+ * @returns {Promise}
+ */
+ async setConfig(key, value, appId, userId = null) {
+ // First, try to find existing config object to update instead of creating duplicates
+ const query = new Parse.Query(this.className);
+ query.equalTo('appId', appId);
+ query.equalTo('key', key);
+
+ if (userId) {
+ query.equalTo('user', new Parse.User({ objectId: userId }));
+ } else {
+ query.doesNotExist('user');
+ }
+
+ let configObject = await query.first({ useMasterKey: true });
+
+ // If no existing object found, create a new one
+ if (!configObject) {
+ configObject = new Parse.Object(this.className);
+ configObject.set('appId', appId);
+ configObject.set('key', key);
+ if (userId) {
+ configObject.set('user', new Parse.User({ objectId: userId }));
+ }
+ }
+
+ // Set the value in the appropriate typed field based on value type
+ const valueType = this._getValueType(value);
+ this._clearAllValueFields(configObject);
+ configObject.set(valueType, value);
+
+ // Use master key for operations
+ return configObject.save(null, { useMasterKey: true });
+ }
+
+ /**
+ * Gets a configuration value from the server
+ * @param {string} key - The configuration key
+ * @param {string} appId - The app ID
+ * @param {string | null} userId - The user ID (optional)
+ * @returns {Promise<*>} The configuration value
+ */
+ async getConfig(key, appId, userId = null) {
+ const query = new Parse.Query(this.className);
+ query.equalTo('appId', appId);
+ query.equalTo('key', key);
+
+ if (userId) {
+ query.equalTo('user', new Parse.User({ objectId: userId }));
+ } else {
+ query.doesNotExist('user');
+ }
+
+ const result = await query.first({ useMasterKey: true });
+ if (!result) {
+ return null;
+ }
+
+ return this._extractValue(result);
+ }
+
+ /**
+ * Gets all configuration values for an app with a key prefix
+ * @param {string} keyPrefix - The key prefix to filter by
+ * @param {string} appId - The app ID
+ * @param {string | null} userId - The user ID (optional)
+ * @returns {Promise