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() && ( +
+ + } + input={ + this.handleStoragePreferenceChange(preference)} + /> + } + /> + + } + input={ + this.migrateToServer()} + /> + } + /> + + } + input={ + this.deleteFromBrowser()} + /> + } + /> +
+ )} {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} 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 && 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/StoragePreferences.js b/src/lib/StoragePreferences.js new file mode 100644 index 0000000000..1eebf60c26 --- /dev/null +++ b/src/lib/StoragePreferences.js @@ -0,0 +1,83 @@ +/* + * 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. + */ + +/** + * Utility for managing user's storage preferences (server vs local storage) + */ + +const STORAGE_PREFERENCE_KEY = 'ParseDashboard:StoragePreferences'; + +/** + * Storage preference options + */ +export const STORAGE_TYPES = { + LOCAL: 'local', + SERVER: 'server' +}; + +/** + * Gets the storage preference for a specific app + * @param {string} appId - The application ID + * @returns {string} The storage preference ('local' or 'server') + */ +export function getStoragePreference(appId) { + try { + const preferences = localStorage.getItem(STORAGE_PREFERENCE_KEY); + if (preferences) { + const parsed = JSON.parse(preferences); + return parsed[appId] || STORAGE_TYPES.LOCAL; // Default to local storage + } + } catch (error) { + console.warn('Failed to get storage preference:', error); + } + return STORAGE_TYPES.LOCAL; // Default fallback +} + +/** + * Sets the storage preference for a specific app + * @param {string} appId - The application ID + * @param {string} preference - The storage preference ('local' or 'server') + */ +export function setStoragePreference(appId, preference) { + // Validate preference value + if (!Object.values(STORAGE_TYPES).includes(preference)) { + console.warn('Invalid storage preference:', preference); + return; + } + + try { + let preferences = {}; + const existing = localStorage.getItem(STORAGE_PREFERENCE_KEY); + if (existing) { + preferences = JSON.parse(existing); + } + + preferences[appId] = preference; + localStorage.setItem(STORAGE_PREFERENCE_KEY, JSON.stringify(preferences)); + } catch (error) { + console.warn('Failed to set storage preference:', error); + } +} + +/** + * Checks if the user prefers server storage for the given app + * @param {string} appId - The application ID + * @returns {boolean} True if user prefers server storage + */ +export function prefersServerStorage(appId) { + return getStoragePreference(appId) === STORAGE_TYPES.SERVER; +} + +/** + * Checks if the user prefers local storage for the given app + * @param {string} appId - The application ID + * @returns {boolean} True if user prefers local storage + */ +export function prefersLocalStorage(appId) { + return getStoragePreference(appId) === STORAGE_TYPES.LOCAL; +} diff --git a/src/lib/ViewPreferencesManager.js b/src/lib/ViewPreferencesManager.js new file mode 100644 index 0000000000..27558851a9 --- /dev/null +++ b/src/lib/ViewPreferencesManager.js @@ -0,0 +1,307 @@ +/* + * 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'; +import { prefersServerStorage, setStoragePreference } from './StoragePreferences'; + +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 and user preference + * @param {string} appId - The application ID + * @returns {Promise} Array of views + */ + async getViews(appId) { + // Check if server storage is enabled and user prefers it + if (this.serverStorage.isServerConfigEnabled() && prefersServerStorage(appId)) { + try { + const serverViews = await this._getViewsFromServer(appId); + // Always return server views (even if empty) when server storage is preferred + return serverViews || []; + } catch (error) { + console.error('Failed to get views from server:', error); + // When server storage is preferred, return empty array instead of falling back to local + return []; + } + } + + // Use local storage when server storage is not preferred + return this._getViewsFromLocal(appId); + } + + /** + * 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) { + // 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 (either by preference or as fallback) + 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; + } + } + + /** + * 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'; + } + + /** + * Checks if server configuration is enabled for this app + * @returns {boolean} True if server config is enabled + */ + isServerConfigEnabled() { + return this.serverStorage.isServerConfigEnabled(); + } + + /** + * Gets views from server storage + * @private + */ + async _getViewsFromServer(appId) { + try { + const viewConfigs = await this.serverStorage.getConfigsByPrefix('views.view.', appId); + const views = []; + + Object.entries(viewConfigs).forEach(([key, config]) => { + if (config && typeof config === 'object') { + // Extract view ID from key (views.view.{VIEW_ID}) + const viewId = key.replace('views.view.', ''); + + // 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); + console.error(`Skipping view ${viewId} due to corrupted query`); + // Skip views with corrupted queries instead of keeping them as strings + return; + } + } + + 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.', appId); + const existingViewIds = Object.keys(existingViewConfigs).map(key => + key.replace('views.view.', '') + ); + + // 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}`, 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 + + // 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); + } + + return this.serverStorage.setConfig( + `views.view.${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, timestamp, and random component + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substr(2, 5); + const nameHash = view.name ? view.name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase() : 'view'; + return `${nameHash}_${timestamp}_${random}`; + } +} + +// 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`; +}