From 1b0bbcd6f86af6608e01d9fc4ee7704c4e0ccf7d Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:06:37 +0200 Subject: [PATCH 01/17] feat --- src/dashboard/Data/Views/Views.react.js | 128 +++++--- .../DashboardSettings.react.js | 116 +++++++ src/lib/ParseApp.js | 2 + src/lib/ServerConfigStorage.js | 180 +++++++++++ src/lib/ViewPreferencesManager.js | 282 ++++++++++++++++++ 5 files changed, 662 insertions(+), 46 deletions(-) create mode 100644 src/lib/ServerConfigStorage.js create mode 100644 src/lib/ViewPreferencesManager.js diff --git a/src/dashboard/Data/Views/Views.react.js b/src/dashboard/Data/Views/Views.react.js index 1f7ddcd2d5..6a4a8e4474 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,14 @@ 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.loadViews(this.context); } ); @@ -699,8 +723,14 @@ 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.loadViews(this.context); } ); @@ -719,8 +749,14 @@ 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); + } + } 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..1506c75b07 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, + hasServerViews: false, copyData: { data: '', show: false, @@ -52,6 +56,72 @@ export default class DashboardSettings extends DashboardView { }; } + componentDidMount() { + this.initializeViewPreferencesManager(); + } + + initializeViewPreferencesManager() { + if (this.context) { + this.viewPreferencesManager = new ViewPreferencesManager(this.context); + this.checkServerViews(); + } + } + + async checkServerViews() { + if (this.viewPreferencesManager) { + try { + const hasServerViews = await this.viewPreferencesManager.hasServerViews(this.context.applicationId); + this.setState({ hasServerViews }); + } catch (error) { + console.error('Failed to check server views:', error); + } + } + } + + async migrateToServer() { + if (!this.viewPreferencesManager) { + this.showNote('ViewPreferencesManager not initialized'); + return; + } + + if (!this.viewPreferencesManager.serverStorage.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.`); + this.setState({ hasServerViews: true }); + } 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 (!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 +452,52 @@ export default class DashboardSettings extends DashboardView { } /> + {this.viewPreferencesManager && this.viewPreferencesManager.serverStorage.isServerConfigEnabled() && ( +
+ + } + input={ + this.migrateToServer()} + /> + } + /> + + } + input={ + this.deleteFromBrowser()} + /> + } + /> + {this.state.hasServerViews && ( + + } + input={
✓ Using Server Storage
} + /> + )} +
+ )} {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..170da513ee --- /dev/null +++ b/src/lib/ServerConfigStorage.js @@ -0,0 +1,180 @@ +/* + * 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'; + } + + /** + * 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) { + const configObject = new Parse.Object(this.className); + + // Set the fields according to the schema + 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} Object with keys and values + */ + async getConfigsByPrefix(keyPrefix, appId, userId = null) { + const query = new Parse.Query(this.className); + query.equalTo('appId', appId); + query.startsWith('key', keyPrefix); + + if (userId) { + query.equalTo('user', new Parse.User({ objectId: userId })); + } else { + query.doesNotExist('user'); + } + + const results = await query.find({ useMasterKey: true }); + const configs = {}; + + results.forEach(result => { + const key = result.get('key'); + const value = this._extractValue(result); + configs[key] = value; + }); + + return configs; + } + + /** + * Deletes 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} + */ + async deleteConfig(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 result.destroy({ useMasterKey: true }); + } + } + + /** + * Checks if server configuration is available for this app + * @returns {boolean} + */ + isServerConfigEnabled() { + return !!(this.app.config && this.app.config.className); + } + + /** + * Gets the value type for a given value + * @private + */ + _getValueType(value) { + if (typeof value === 'boolean') { + return 'bool'; + } else if (typeof value === 'string') { + return 'string'; + } else if (typeof value === 'number') { + return 'number'; + } else if (Array.isArray(value)) { + return 'array'; + } else if (typeof value === 'object' && value !== null) { + return 'object'; + } + return 'string'; // fallback + } + + /** + * Clears all value fields on a config object + * @private + */ + _clearAllValueFields(configObject) { + configObject.unset('bool'); + configObject.unset('string'); + configObject.unset('number'); + configObject.unset('array'); + configObject.unset('object'); + } + + /** + * Extracts the value from a config object based on its type + * @private + */ + _extractValue(configObject) { + const fields = ['bool', 'string', 'number', 'array', 'object']; + for (const field of fields) { + const value = configObject.get(field); + if (value !== undefined && value !== null) { + return value; + } + } + return null; + } +} diff --git a/src/lib/ViewPreferencesManager.js b/src/lib/ViewPreferencesManager.js new file mode 100644 index 0000000000..b9ddfa0712 --- /dev/null +++ b/src/lib/ViewPreferencesManager.js @@ -0,0 +1,282 @@ +/* + * 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 ServerConfigStorage from './ServerConfigStorage'; + +const VERSION = 1; + +/** + * Enhanced ViewPreferences with server-side storage support + */ +export default class ViewPreferencesManager { + constructor(app) { + this.app = app; + this.serverStorage = new ServerConfigStorage(app); + } + + /** + * Gets views from either server or local storage based on configuration + * @param {string} appId - The application ID + * @returns {Promise} Array of views + */ + async getViews(appId) { + if (this.serverStorage.isServerConfigEnabled()) { + // Check if there are any views stored on the server + const serverViews = await this._getViewsFromServer(appId); + if (serverViews && serverViews.length > 0) { + return serverViews; + } + } + + // Fallback to local storage + return this._getViewsFromLocal(appId); + } + + /** + * Saves views to either server or local storage based on configuration + * @param {string} appId - The application ID + * @param {Array} views - Array of views to save + * @returns {Promise} + */ + async saveViews(appId, views) { + if (this.serverStorage.isServerConfigEnabled()) { + // Check if we should use server storage (if any views exist on server) + const existingServerViews = await this._getViewsFromServer(appId); + if (existingServerViews && existingServerViews.length > 0) { + return this._saveViewsToServer(appId, views); + } + } + + // Use local storage + return this._saveViewsToLocal(appId, views); + } + + /** + * Migrates views from local storage to server storage + * @param {string} appId - The application ID + * @returns {Promise<{success: boolean, viewCount: number}>} + */ + async migrateToServer(appId) { + if (!this.serverStorage.isServerConfigEnabled()) { + throw new Error('Server configuration is not enabled for this app'); + } + + const localViews = this._getViewsFromLocal(appId); + if (!localViews || localViews.length === 0) { + return { success: true, viewCount: 0 }; + } + + try { + await this._saveViewsToServer(appId, localViews); + return { success: true, viewCount: localViews.length }; + } catch (error) { + console.error('Failed to migrate views to server:', error); + throw error; + } + } + + /** + * Deletes views from local storage + * @param {string} appId - The application ID + * @returns {boolean} True if deletion was successful + */ + deleteFromBrowser(appId) { + try { + localStorage.removeItem(this._getLocalPath(appId)); + return true; + } catch (error) { + console.error('Failed to delete views from browser:', error); + return false; + } + } + + /** + * Checks if there are views stored on the server + * @param {string} appId - The application ID + * @returns {Promise} + */ + async hasServerViews(appId) { + if (!this.serverStorage.isServerConfigEnabled()) { + return false; + } + + try { + const serverViews = await this._getViewsFromServer(appId); + return serverViews && serverViews.length > 0; + } catch (error) { + console.error('Failed to check server views:', error); + return false; + } + } + + /** + * Gets views from server storage + * @private + */ + async _getViewsFromServer(appId) { + try { + const viewConfigs = await this.serverStorage.getConfigsByPrefix('views.view.id.', appId); + const views = []; + + Object.entries(viewConfigs).forEach(([key, config]) => { + if (config && typeof config === 'object') { + // Extract view ID from key (views.view.id.{VIEW_ID}) + const viewId = key.replace('views.view.id.', ''); + + // Parse the query if it's a string (it was stringified for storage) + const viewConfig = { ...config }; + if (viewConfig.query && typeof viewConfig.query === 'string') { + try { + viewConfig.query = JSON.parse(viewConfig.query); + } catch (e) { + console.warn('Failed to parse view query from server storage:', e); + // Keep as string if parsing fails + } + } + + views.push({ + id: viewId, + ...viewConfig + }); + } + }); + + return views; + } catch (error) { + console.error('Failed to get views from server:', error); + return []; + } + } + + /** + * Saves views to server storage + * @private + */ + async _saveViewsToServer(appId, views) { + try { + // First, get existing views from server to know which ones to delete + const existingViewConfigs = await this.serverStorage.getConfigsByPrefix('views.view.id.', appId); + const existingViewIds = Object.keys(existingViewConfigs).map(key => + key.replace('views.view.id.', '') + ); + + // Delete views that are no longer in the new views array + const newViewIds = views.map(view => view.id || this._generateViewId(view)); + const viewsToDelete = existingViewIds.filter(id => !newViewIds.includes(id)); + + await Promise.all( + viewsToDelete.map(id => + this.serverStorage.deleteConfig(`views.view.id.${id}`, appId) + ) + ); + + // Save or update current views + await Promise.all( + views.map(view => { + const viewId = view.id || this._generateViewId(view); + const viewConfig = { ...view }; + delete viewConfig.id; // Don't store ID in the config itself + + // Stringify the query if it exists and is an array/object + if (viewConfig.query && (Array.isArray(viewConfig.query) || typeof viewConfig.query === 'object')) { + viewConfig.query = JSON.stringify(viewConfig.query); + } + + return this.serverStorage.setConfig( + `views.view.id.${viewId}`, + viewConfig, + appId + ); + }) + ); + } catch (error) { + console.error('Failed to save views to server:', error); + throw error; + } + } + + /** + * Gets views from local storage (original implementation) + * @private + */ + _getViewsFromLocal(appId) { + let entry; + try { + entry = localStorage.getItem(this._getLocalPath(appId)) || '[]'; + } catch { + entry = '[]'; + } + try { + return JSON.parse(entry); + } catch { + return []; + } + } + + /** + * Saves views to local storage (original implementation) + * @private + */ + _saveViewsToLocal(appId, views) { + try { + localStorage.setItem(this._getLocalPath(appId), JSON.stringify(views)); + } catch { + // ignore write errors + } + } + + /** + * Gets the local storage path for views + * @private + */ + _getLocalPath(appId) { + return `ParseDashboard:${VERSION}:${appId}:Views`; + } + + /** + * Generates a unique ID for a view + * @private + */ + _generateViewId(view) { + if (view.id) { + return view.id; + } + // Generate a unique ID based on view name and timestamp + const timestamp = Date.now().toString(36); + const nameHash = view.name ? view.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() : 'view'; + return `${nameHash}_${timestamp}`; + } +} + +// Legacy API compatibility - these functions will work with local storage only +// for backward compatibility +export function getViews(appId) { + let entry; + try { + entry = localStorage.getItem(path(appId)) || '[]'; + } catch { + entry = '[]'; + } + try { + return JSON.parse(entry); + } catch { + return []; + } +} + +export function saveViews(appId, views) { + try { + localStorage.setItem(path(appId), JSON.stringify(views)); + } catch { + // ignore write errors + } +} + +function path(appId) { + return `ParseDashboard:${VERSION}:${appId}:Views`; +} From ed97c25089b1fc815954d9ba9c315e45047e2571 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:12:43 +0200 Subject: [PATCH 02/17] fix --- src/lib/ServerConfigStorage.js | 24 +++++++++++++++++++----- src/lib/ViewPreferencesManager.js | 7 +++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/lib/ServerConfigStorage.js b/src/lib/ServerConfigStorage.js index 170da513ee..4e993624b3 100644 --- a/src/lib/ServerConfigStorage.js +++ b/src/lib/ServerConfigStorage.js @@ -26,13 +26,27 @@ export default class ServerConfigStorage { * @returns {Promise} */ async setConfig(key, value, appId, userId = null) { - const configObject = new Parse.Object(this.className); + // 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); - // Set the fields according to the schema - configObject.set('appId', appId); - configObject.set('key', key); if (userId) { - configObject.set('user', new Parse.User({ objectId: 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 diff --git a/src/lib/ViewPreferencesManager.js b/src/lib/ViewPreferencesManager.js index b9ddfa0712..7d891e5d44 100644 --- a/src/lib/ViewPreferencesManager.js +++ b/src/lib/ViewPreferencesManager.js @@ -182,6 +182,13 @@ export default class ViewPreferencesManager { const viewConfig = { ...view }; delete viewConfig.id; // Don't store ID in the config itself + // Remove null and undefined values to keep the storage clean + Object.keys(viewConfig).forEach(key => { + if (viewConfig[key] === null || viewConfig[key] === undefined) { + delete viewConfig[key]; + } + }); + // Stringify the query if it exists and is an array/object if (viewConfig.query && (Array.isArray(viewConfig.query) || typeof viewConfig.query === 'object')) { viewConfig.query = JSON.stringify(viewConfig.query); From 3844910f7a57732c0f46e56cd98123fa2c4c2444 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:25:16 +0200 Subject: [PATCH 03/17] fix --- .../DashboardSettings.react.js | 39 ++++++++++ src/lib/StoragePreferences.js | 77 +++++++++++++++++++ src/lib/ViewPreferencesManager.js | 57 ++++++++++---- 3 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 src/lib/StoragePreferences.js diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index 1506c75b07..613713b16b 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -43,6 +43,7 @@ export default class DashboardSettings extends DashboardView { passwordHidden: true, migrationLoading: false, hasServerViews: false, + storagePreference: 'local', // Will be updated in componentDidMount copyData: { data: '', show: false, @@ -64,6 +65,24 @@ export default class DashboardSettings extends DashboardView { if (this.context) { this.viewPreferencesManager = new ViewPreferencesManager(this.context); this.checkServerViews(); + 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'}`); } } @@ -454,6 +473,26 @@ export default class DashboardSettings extends DashboardView { {this.viewPreferencesManager && this.viewPreferencesManager.serverStorage.isServerConfigEnabled() && (
+ + } + input={ + this.handleStoragePreferenceChange(preference)} + /> + } + /> } Array of views */ async getViews(appId) { - if (this.serverStorage.isServerConfigEnabled()) { - // Check if there are any views stored on the server - const serverViews = await this._getViewsFromServer(appId); - if (serverViews && serverViews.length > 0) { - return serverViews; + // Check if server storage is enabled and user prefers it + if (this.serverStorage.isServerConfigEnabled() && prefersServerStorage(appId)) { + try { + const serverViews = await this._getViewsFromServer(appId); + if (serverViews && serverViews.length > 0) { + return serverViews; + } + // If no server views found but user prefers server storage, still return empty array + // This prevents fallback to local when user explicitly chose server storage + return []; + } catch (error) { + console.error('Failed to get views from server:', error); + // On error, fallback to local storage } } - // Fallback to local storage + // Use local storage (either by preference or as fallback) return this._getViewsFromLocal(appId); } /** - * Saves views to either server or local storage based on configuration + * Saves views to either server or local storage based on configuration and user preference * @param {string} appId - The application ID * @param {Array} views - Array of views to save * @returns {Promise} */ async saveViews(appId, views) { - if (this.serverStorage.isServerConfigEnabled()) { - // Check if we should use server storage (if any views exist on server) - const existingServerViews = await this._getViewsFromServer(appId); - if (existingServerViews && existingServerViews.length > 0) { - return this._saveViewsToServer(appId, views); + // Check if server storage is enabled and user prefers it + if (this.serverStorage.isServerConfigEnabled() && prefersServerStorage(appId)) { + try { + return await this._saveViewsToServer(appId, views); + } catch (error) { + console.error('Failed to save views to server:', error); + // On error, fallback to local storage } } - // Use local storage + // Use local storage (either by preference or as fallback) return this._saveViewsToLocal(appId, views); } @@ -114,6 +125,24 @@ export default class ViewPreferencesManager { } } + /** + * Sets the storage preference for the app + * @param {string} appId - The application ID + * @param {string} preference - The storage preference ('local' or 'server') + */ + setStoragePreference(appId, preference) { + setStoragePreference(appId, preference); + } + + /** + * Gets the current storage preference for the app + * @param {string} appId - The application ID + * @returns {string} The storage preference ('local' or 'server') + */ + getStoragePreference(appId) { + return prefersServerStorage(appId) ? 'server' : 'local'; + } + /** * Gets views from server storage * @private From b6f2d463f4fa0700015e12350937890846cce38d Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:33:19 +0200 Subject: [PATCH 04/17] text --- .../DashboardSettings/DashboardSettings.react.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index 613713b16b..0ad0d52a33 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -472,12 +472,12 @@ export default class DashboardSettings extends DashboardView { />
{this.viewPreferencesManager && this.viewPreferencesManager.serverStorage.isServerConfigEnabled() && ( -
+
} input={ @@ -496,8 +496,8 @@ export default class DashboardSettings extends DashboardView { } input={ @@ -512,8 +512,8 @@ export default class DashboardSettings extends DashboardView { } input={ From cecfd6199667a8373fbd83f84a510ff85e11780e Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 31 Jul 2025 11:39:45 +0200 Subject: [PATCH 05/17] remove obsolete --- .../DashboardSettings.react.js | 25 ------------------- src/lib/ViewPreferencesManager.js | 19 -------------- 2 files changed, 44 deletions(-) diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index 0ad0d52a33..42ad62df23 100644 --- a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js +++ b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js @@ -42,7 +42,6 @@ export default class DashboardSettings extends DashboardView { passwordInput: '', passwordHidden: true, migrationLoading: false, - hasServerViews: false, storagePreference: 'local', // Will be updated in componentDidMount copyData: { data: '', @@ -64,7 +63,6 @@ export default class DashboardSettings extends DashboardView { initializeViewPreferencesManager() { if (this.context) { this.viewPreferencesManager = new ViewPreferencesManager(this.context); - this.checkServerViews(); this.loadStoragePreference(); } } @@ -86,17 +84,6 @@ export default class DashboardSettings extends DashboardView { } } - async checkServerViews() { - if (this.viewPreferencesManager) { - try { - const hasServerViews = await this.viewPreferencesManager.hasServerViews(this.context.applicationId); - this.setState({ hasServerViews }); - } catch (error) { - console.error('Failed to check server views:', error); - } - } - } - async migrateToServer() { if (!this.viewPreferencesManager) { this.showNote('ViewPreferencesManager not initialized'); @@ -115,7 +102,6 @@ export default class DashboardSettings extends DashboardView { if (result.success) { if (result.viewCount > 0) { this.showNote(`Successfully migrated ${result.viewCount} view(s) to server storage.`); - this.setState({ hasServerViews: true }); } else { this.showNote('No views found to migrate.'); } @@ -524,17 +510,6 @@ export default class DashboardSettings extends DashboardView { /> } /> - {this.state.hasServerViews && ( - - } - input={
✓ Using Server Storage
} - /> - )}
)} {this.state.copyData.show && copyData} diff --git a/src/lib/ViewPreferencesManager.js b/src/lib/ViewPreferencesManager.js index 78a3797afc..4f22788b0c 100644 --- a/src/lib/ViewPreferencesManager.js +++ b/src/lib/ViewPreferencesManager.js @@ -106,25 +106,6 @@ export default class ViewPreferencesManager { } } - /** - * Checks if there are views stored on the server - * @param {string} appId - The application ID - * @returns {Promise} - */ - async hasServerViews(appId) { - if (!this.serverStorage.isServerConfigEnabled()) { - return false; - } - - try { - const serverViews = await this._getViewsFromServer(appId); - return serverViews && serverViews.length > 0; - } catch (error) { - console.error('Failed to check server views:', error); - return false; - } - } - /** * Sets the storage preference for the app * @param {string} appId - The application ID From 7dad6596f9d533999dd3ca602d0b28e1f67aae22 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Thu, 31 Jul 2025 13:36:14 +0200 Subject: [PATCH 06/17] load filters from server --- .../BrowserFilter/BrowserFilter.react.js | 6 + src/dashboard/Data/Browser/Browser.react.js | 197 +++++++++-- .../DashboardSettings.react.js | 52 ++- src/lib/ClassPreferences.js | 170 ++++++++++ src/lib/FilterPreferencesManager.js | 310 ++++++++++++++++++ src/lib/StoragePreferences.js | 3 +- 6 files changed, 698 insertions(+), 40 deletions(-) create mode 100644 src/lib/FilterPreferencesManager.js diff --git a/src/components/BrowserFilter/BrowserFilter.react.js b/src/components/BrowserFilter/BrowserFilter.react.js index ca6fdd3379..29d678aec6 100644 --- a/src/components/BrowserFilter/BrowserFilter.react.js +++ b/src/components/BrowserFilter/BrowserFilter.react.js @@ -18,6 +18,7 @@ import TextInput from 'components/TextInput/TextInput.react'; import { CurrentApp } from 'context/currentApp'; import { List, Map as ImmutableMap } from 'immutable'; import * as ClassPreferences from 'lib/ClassPreferences'; +import { getClassPreferencesManager } from 'lib/ClassPreferences'; import * as Filters from 'lib/Filters'; import Position from 'lib/Position'; import React from 'react'; @@ -45,6 +46,7 @@ export default class BrowserFilter extends React.Component { }; this.toggle = this.toggle.bind(this); this.wrapRef = React.createRef(); + this.classPreferencesManager = null; } getClassNameFromURL() { @@ -90,6 +92,10 @@ export default class BrowserFilter extends React.Component { } componentDidMount() { + // Initialize class preferences manager if context is available + if (this.context) { + this.classPreferencesManager = getClassPreferencesManager(this.context); + } this.initializeEditFilterMode(); } diff --git a/src/dashboard/Data/Browser/Browser.react.js b/src/dashboard/Data/Browser/Browser.react.js index 014473bc22..94b46d86aa 100644 --- a/src/dashboard/Data/Browser/Browser.react.js +++ b/src/dashboard/Data/Browser/Browser.react.js @@ -29,6 +29,7 @@ import RemoveColumnDialog from 'dashboard/Data/Browser/RemoveColumnDialog.react' import { List, Map } from 'immutable'; import { get } from 'lib/AJAX'; import * as ClassPreferences from 'lib/ClassPreferences'; +import { getClassPreferencesManager } from 'lib/ClassPreferences'; import * as ColumnPreferences from 'lib/ColumnPreferences'; import { DefaultColumns, SpecialClasses } from 'lib/Constants'; import generatePath from 'lib/generatePath'; @@ -129,6 +130,7 @@ class Browser extends DashboardView { this.subsection = 'Browser'; this.noteTimeout = null; this.currentQuery = null; + this.classPreferencesManager = null; const limit = window.localStorage?.getItem('browserLimit'); this.state = { @@ -150,6 +152,8 @@ class Browser extends DashboardView { filteredCounts: {}, clp: {}, filters: new List(), + classFilters: {}, // Store filters for each class from server/local storage + loadingClassFilters: false, // Track loading state for class filters ordering: '-createdAt', skip: 0, limit: limit ? parseInt(limit) : 100, @@ -283,7 +287,14 @@ class Browser extends DashboardView { this.action = new SidebarAction('Create a class', this.showCreateClass.bind(this)); } - this.props.schema.dispatch(ActionTypes.FETCH).then(() => this.handleFetchedSchema()); + // Initialize class preferences manager + this.classPreferencesManager = getClassPreferencesManager(currentApp); + + this.props.schema.dispatch(ActionTypes.FETCH).then(() => { + this.handleFetchedSchema(); + // Load class filters after schema is available and wait for completion + this.loadAllClassFilters(); + }); if (!this.props.params.className && this.props.schema.data.get('classes')) { this.redirectToFirstClass(this.props.schema.data.get('classes')); } else if (this.props.params.className) { @@ -298,6 +309,71 @@ class Browser extends DashboardView { this.setState({ configData: data }); this.classAndCloudFuntionMap(this.state.configData); }); + // Load class filters after manager is initialized + this.loadAllClassFilters(); + } + + /** + * Load filters for all classes + */ + async loadAllClassFilters() { + if (!this.classPreferencesManager) { + return; + } + + const classes = this.props.schema.data.get('classes'); + if (!classes) { + return; + } + + // Set loading state + this.setState({ loadingClassFilters: true }); + + const classFilters = {}; + const classNames = Object.keys(classes.toObject()); + + // Load filters for each class + for (const className of classNames) { + try { + const filters = await this.classPreferencesManager.getFilters(className); + classFilters[className] = filters || []; + } catch (error) { + console.warn(`Failed to load filters for class ${className}:`, error); + classFilters[className] = []; + } + } + + this.setState({ + classFilters, + loadingClassFilters: false + }); + } + + /** + * Load saved filters for a specific class + */ + async loadSavedFilters(className) { + if (!className || !this.classPreferencesManager) { + return new List(); + } + + try { + const savedFilters = await this.classPreferencesManager.getFilters(className); + let filters = new List(); + + if (savedFilters && savedFilters.length > 0) { + savedFilters.forEach(filter => { + // Convert saved filter to the expected format + const processedFilter = { ...filter, class: filter.class || className }; + filters = filters.push(Map(processedFilter)); + }); + } + + return filters; + } catch (error) { + console.warn(`Failed to load saved filters for class ${className}:`, error); + return new List(); + } } componentWillUnmount() { @@ -506,6 +582,7 @@ class Browser extends DashboardView { data: isEditFilterMode ? [] : null, // Set empty array in edit mode to avoid loading newObject: null, lastMax: -1, + filters, ordering: ColumnPreferences.getColumnSort(false, context.applicationId, className), selection: {}, relation: isRelationRoute ? relation : null, @@ -527,6 +604,7 @@ class Browser extends DashboardView { let filters = new List(); //TODO: url limit issues ( we may want to check for url limit), unlikely but possible to run into if (!props || !props.location || !props.location.search) { + // No URL parameters, return empty filters (clean state) return filters; } const query = new URLSearchParams(props.location.search); @@ -1271,10 +1349,30 @@ class Browser extends DashboardView { } const _filters = JSON.stringify(jsonFilters); - const preferences = ClassPreferences.getPreferences( - this.context.applicationId, - this.props.params.className - ); + + // Try to use enhanced manager first, fallback to legacy for compatibility + let preferences; + if (this.classPreferencesManager) { + try { + // For now, use synchronous method to avoid breaking existing behavior + // TODO: Convert this to async when the component architecture supports it + preferences = ClassPreferences.getPreferences( + this.context.applicationId, + this.props.params.className + ); + } catch (error) { + console.warn('Failed to get preferences with enhanced manager, falling back to legacy:', error); + preferences = ClassPreferences.getPreferences( + this.context.applicationId, + this.props.params.className + ); + } + } else { + preferences = ClassPreferences.getPreferences( + this.context.applicationId, + this.props.params.className + ); + } let newFilterId = filterId; @@ -1345,11 +1443,31 @@ class Browser extends DashboardView { } } - ClassPreferences.updatePreferences( - preferences, - this.context.applicationId, - this.props.params.className - ); + // Save preferences using enhanced manager if available + if (this.classPreferencesManager) { + try { + // For now, use synchronous method to avoid breaking existing behavior + // TODO: Convert this to async when the component architecture supports it + ClassPreferences.updatePreferences( + preferences, + this.context.applicationId, + this.props.params.className + ); + } catch (error) { + console.warn('Failed to save preferences with enhanced manager, falling back to legacy:', error); + ClassPreferences.updatePreferences( + preferences, + this.context.applicationId, + this.props.params.className + ); + } + } else { + ClassPreferences.updatePreferences( + preferences, + this.context.applicationId, + this.props.params.className + ); + } super.forceUpdate(); @@ -1358,10 +1476,27 @@ class Browser extends DashboardView { } deleteFilter(filterIdOrObject) { - const preferences = ClassPreferences.getPreferences( - this.context.applicationId, - this.props.params.className - ); + // Try to use enhanced manager first, fallback to legacy for compatibility + let preferences; + if (this.classPreferencesManager) { + try { + preferences = ClassPreferences.getPreferences( + this.context.applicationId, + this.props.params.className + ); + } catch (error) { + console.warn('Failed to get preferences with enhanced manager, falling back to legacy:', error); + preferences = ClassPreferences.getPreferences( + this.context.applicationId, + this.props.params.className + ); + } + } else { + preferences = ClassPreferences.getPreferences( + this.context.applicationId, + this.props.params.className + ); + } if (preferences.filters) { // Try to find by ID first (modern approach) @@ -1382,11 +1517,29 @@ class Browser extends DashboardView { } } - ClassPreferences.updatePreferences( - { ...preferences, filters: updatedFilters }, - this.context.applicationId, - this.props.params.className - ); + // Save preferences using enhanced manager if available + if (this.classPreferencesManager) { + try { + ClassPreferences.updatePreferences( + { ...preferences, filters: updatedFilters }, + this.context.applicationId, + this.props.params.className + ); + } catch (error) { + console.warn('Failed to save preferences with enhanced manager, falling back to legacy:', error); + ClassPreferences.updatePreferences( + { ...preferences, filters: updatedFilters }, + this.context.applicationId, + this.props.params.className + ); + } + } else { + ClassPreferences.updatePreferences( + { ...preferences, filters: updatedFilters }, + this.context.applicationId, + this.props.params.className + ); + } } super.forceUpdate(); @@ -2220,10 +2373,8 @@ class Browser extends DashboardView { } const allCategories = []; for (const row of [...special, ...categories]) { - const { filters = [] } = ClassPreferences.getPreferences( - this.context.applicationId, - row.name - ); + // Use filters from state (loaded with enhanced manager) or fallback to empty array + const filters = this.state.loadingClassFilters ? [] : (this.state.classFilters[row.name] || []); // Set filters sorted alphabetically row.filters = filters.sort((a, b) => a.name.localeCompare(b.name)); allCategories.push(row); diff --git a/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js b/src/dashboard/Settings/DashboardSettings/DashboardSettings.react.js index 42ad62df23..9079b94bea 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 { getClassPreferencesManager } from 'lib/ClassPreferences'; import ViewPreferencesManager from 'lib/ViewPreferencesManager'; import bcrypt from 'bcryptjs'; import * as OTPAuth from 'otpauth'; @@ -28,6 +29,7 @@ export default class DashboardSettings extends DashboardView { this.section = 'App Settings'; this.subsection = 'Dashboard Configuration'; this.viewPreferencesManager = null; + this.classPreferencesManager = null; this.state = { createUserInput: false, @@ -63,6 +65,7 @@ export default class DashboardSettings extends DashboardView { initializeViewPreferencesManager() { if (this.context) { this.viewPreferencesManager = new ViewPreferencesManager(this.context); + this.classPreferencesManager = getClassPreferencesManager(this.context); this.loadStoragePreference(); } } @@ -75,8 +78,9 @@ export default class DashboardSettings extends DashboardView { } handleStoragePreferenceChange(preference) { - if (this.viewPreferencesManager) { + if (this.viewPreferencesManager && this.classPreferencesManager) { this.viewPreferencesManager.setStoragePreference(this.context.applicationId, preference); + this.classPreferencesManager.setStoragePreference(this.context.applicationId, preference); this.setState({ storagePreference: preference }); // Show a notification about the change @@ -98,32 +102,48 @@ export default class DashboardSettings extends DashboardView { 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.`); + // Migrate both views and filters + const viewResult = await this.viewPreferencesManager.migrateToServer(this.context.applicationId); + const filterResult = await this.classPreferencesManager.migrateToServer(this.context.applicationId); + + if (viewResult.success && filterResult.success) { + const totalViews = viewResult.viewCount; + const totalFilters = filterResult.filterCount; + const totalClasses = filterResult.classCount; + + if (totalViews > 0 || totalFilters > 0) { + let message = 'Successfully migrated to server storage: '; + const parts = []; + if (totalViews > 0) parts.push(`${totalViews} view(s)`); + if (totalFilters > 0) parts.push(`${totalFilters} filter(s) across ${totalClasses} class(es)`); + message += parts.join(', ') + '.'; + this.showNote(message); } else { - this.showNote('No views found to migrate.'); + this.showNote('No views or filters found to migrate.'); } } } catch (error) { - this.showNote(`Failed to migrate views: ${error.message}`); + this.showNote(`Failed to migrate settings: ${error.message}`); } finally { this.setState({ migrationLoading: false }); } } async deleteFromBrowser() { - if (!this.viewPreferencesManager) { - this.showNote('ViewPreferencesManager not initialized'); + if (!this.viewPreferencesManager || !this.classPreferencesManager) { + this.showNote('Preferences managers not initialized'); return; } - const success = this.viewPreferencesManager.deleteFromBrowser(this.context.applicationId); - if (success) { - this.showNote('Successfully deleted views from browser storage.'); + const viewSuccess = this.viewPreferencesManager.deleteFromBrowser(this.context.applicationId); + const filterSuccess = this.classPreferencesManager.deleteFromBrowser(this.context.applicationId); + + if (viewSuccess && filterSuccess) { + this.showNote('Successfully deleted all dashboard settings from browser storage.'); + } else if (viewSuccess || filterSuccess) { + this.showNote('Partially deleted dashboard settings from browser storage.'); } else { - this.showNote('Failed to delete views from browser storage.'); + this.showNote('Failed to delete dashboard settings from browser storage.'); } } @@ -463,7 +483,7 @@ export default class DashboardSettings extends DashboardView { label={