From c6243710a94c9f55d865d571948771e300062794 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:25:47 +0100 Subject: [PATCH 1/2] feat --- src/dashboard/Data/Config/Config.react.js | 164 +++++++++++++- src/dashboard/Data/Config/Config.scss | 4 + .../Config/RemoveArrayEntryDialog.react.js | 214 ++++++++++++++++++ 3 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 src/dashboard/Data/Config/RemoveArrayEntryDialog.react.js diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js index a46d70a2c5..85ea70604f 100644 --- a/src/dashboard/Data/Config/Config.react.js +++ b/src/dashboard/Data/Config/Config.react.js @@ -10,6 +10,7 @@ import Button from 'components/Button/Button.react'; import ConfigDialog from 'dashboard/Data/Config/ConfigDialog.react'; import DeleteParameterDialog from 'dashboard/Data/Config/DeleteParameterDialog.react'; import AddArrayEntryDialog from 'dashboard/Data/Config/AddArrayEntryDialog.react'; +import RemoveArrayEntryDialog from 'dashboard/Data/Config/RemoveArrayEntryDialog.react'; import EmptyState from 'components/EmptyState/EmptyState.react'; import Icon from 'components/Icon/Icon.react'; import { isDate } from 'lib/DateUtils'; @@ -49,6 +50,9 @@ class Config extends TableView { showAddEntryDialog: false, addEntryParam: '', addEntryLastType: null, + showRemoveEntryDialog: false, + removeEntryParam: '', + removeEntryArrayValue: [], }; this.noteTimeout = null; } @@ -127,6 +131,17 @@ class Config extends TableView { param={this.state.addEntryParam} /> ); + } else if (this.state.showRemoveEntryDialog) { + extras = ( + + this.removeArrayEntry(this.state.removeEntryParam, removeConfig) + } + param={this.state.removeEntryParam} + arrayValue={this.state.removeEntryArrayValue} + /> + ); } if (this.state.confirmModalOpen) { @@ -290,12 +305,20 @@ class Config extends TableView { {type === 'Array' && ( - this.openAddEntryDialog(data.param)} - > - - + <> + this.openAddEntryDialog(data.param)} + > + + + this.openRemoveEntryDialog(data.param)} + > + + + )} @@ -525,6 +548,24 @@ class Config extends TableView { }); } + openRemoveEntryDialog(param) { + const params = this.props.config.data.get('params'); + const arr = params?.get(param); + this.setState({ + showRemoveEntryDialog: true, + removeEntryParam: param, + removeEntryArrayValue: Array.isArray(arr) ? arr : [], + }); + } + + closeRemoveEntryDialog() { + this.setState({ + showRemoveEntryDialog: false, + removeEntryParam: '', + removeEntryArrayValue: [], + }); + } + async addArrayEntry(param, value) { try { this.setState({ loading: true }); @@ -584,6 +625,117 @@ class Config extends TableView { } this.closeAddEntryDialog(); } + + /** + * Gets a nested value from an object using a dot-notation path. + * @param {Object} obj - The object to extract from + * @param {string} path - The dot-notation path (e.g., "a.b.c") + * @returns {*} - The value at the path, or undefined if not found + */ + getValueAtPath(obj, path) { + if (obj === null || typeof obj !== 'object') { + return undefined; + } + const parts = path.split('.'); + let current = obj; + for (const part of parts) { + if (current === null || typeof current !== 'object') { + return undefined; + } + current = current[part]; + } + return current; + } + + async removeArrayEntry(param, removeConfig) { + try { + this.setState({ loading: true }); + const masterKeyOnlyMap = this.props.config.data.get('masterKeyOnly'); + const masterKeyOnly = masterKeyOnlyMap?.get(param) || false; + + let objectsToRemove; + + if (removeConfig.filterByKey) { + // Filter mode: find all objects where keyPath matches the value + const { keyPath, value } = removeConfig; + const currentArray = this.state.removeEntryArrayValue; + + objectsToRemove = currentArray.filter(item => { + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + return false; + } + const itemValue = this.getValueAtPath(item, keyPath); + return equal(itemValue, value); + }); + + if (objectsToRemove.length === 0) { + this.showNote(`No matching entries found for ${keyPath} = ${JSON.stringify(value)}`, true); + this.closeRemoveEntryDialog(); + return; + } + } else { + // Direct mode: remove the exact value + objectsToRemove = [removeConfig.value]; + } + + await Parse._request( + 'PUT', + 'config', + { + params: { + [param]: { __op: 'Remove', objects: objectsToRemove.map(v => Parse._encode(v)) }, + }, + masterKeyOnly: { [param]: masterKeyOnly }, + }, + { useMasterKey: true } + ); + await this.props.config.dispatch(ActionTypes.FETCH); + + // Update config history + const limit = this.context.cloudConfigHistoryLimit; + const applicationId = this.context.applicationId; + const params = this.props.config.data.get('params'); + const updatedValue = params.get(param); + const configHistory = localStorage.getItem(`${applicationId}_configHistory`); + const newHistoryEntry = { + time: new Date(), + value: updatedValue, + }; + + if (!configHistory) { + localStorage.setItem( + `${applicationId}_configHistory`, + JSON.stringify({ + [param]: [newHistoryEntry], + }) + ); + } else { + const oldConfigHistory = JSON.parse(configHistory); + const updatedHistory = !oldConfigHistory[param] + ? [newHistoryEntry] + : [newHistoryEntry, ...oldConfigHistory[param]].slice(0, limit || 100); + + localStorage.setItem( + `${applicationId}_configHistory`, + JSON.stringify({ + ...oldConfigHistory, + [param]: updatedHistory, + }) + ); + } + + const removedCount = objectsToRemove.length; + const message = removedCount === 1 + ? `Entry removed from ${param}` + : `${removedCount} entries removed from ${param}`; + this.showNote(message); + } catch (e) { + this.showNote(`Failed to remove entry: ${e.message}`, true); + } finally { + this.setState({ loading: false }); + } + this.closeRemoveEntryDialog(); + } } export default Config; diff --git a/src/dashboard/Data/Config/Config.scss b/src/dashboard/Data/Config/Config.scss index 32ae6d0615..dc83755afb 100644 --- a/src/dashboard/Data/Config/Config.scss +++ b/src/dashboard/Data/Config/Config.scss @@ -8,9 +8,13 @@ width: 20px; height: 20px; cursor: pointer; + margin-right: 4px; svg { fill: currentColor; color: $darkBlue; } + &:last-child { + margin-right: 0; + } } diff --git a/src/dashboard/Data/Config/RemoveArrayEntryDialog.react.js b/src/dashboard/Data/Config/RemoveArrayEntryDialog.react.js new file mode 100644 index 0000000000..814b2fa564 --- /dev/null +++ b/src/dashboard/Data/Config/RemoveArrayEntryDialog.react.js @@ -0,0 +1,214 @@ +/* + * 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 Checkbox from 'components/Checkbox/Checkbox.react'; +import Dropdown from 'components/Dropdown/Dropdown.react'; +import Field from 'components/Field/Field.react'; +import Label from 'components/Label/Label.react'; +import Modal from 'components/Modal/Modal.react'; +import NonPrintableHighlighter from 'components/NonPrintableHighlighter/NonPrintableHighlighter.react'; +import Option from 'components/Dropdown/Option.react'; +import React from 'react'; +import TextInput from 'components/TextInput/TextInput.react'; + +export default class RemoveArrayEntryDialog extends React.Component { + constructor() { + super(); + this.state = { + value: '', + useKeyFilter: false, + selectedKeyPath: '', + }; + this.inputRef = React.createRef(); + } + + componentDidMount() { + if (this.inputRef.current) { + this.inputRef.current.focus(); + } + } + + /** + * Extracts all unique key paths from an array of objects. + * Supports nested objects with dot notation (e.g., "a.b.c"). + * @param {Array} arr - The array to extract key paths from + * @returns {string[]} - Sorted array of unique key paths + */ + extractKeyPaths(arr) { + const keyPaths = new Set(); + + const extractFromObject = (obj, prefix = '') => { + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) { + return; + } + for (const key of Object.keys(obj)) { + const fullPath = prefix ? `${prefix}.${key}` : key; + keyPaths.add(fullPath); + if (obj[key] !== null && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { + extractFromObject(obj[key], fullPath); + } + } + }; + + for (const item of arr) { + extractFromObject(item); + } + + return Array.from(keyPaths).sort(); + } + + /** + * Gets a nested value from an object using a dot-notation path. + * @param {Object} obj - The object to extract from + * @param {string} path - The dot-notation path (e.g., "a.b.c") + * @returns {*} - The value at the path, or undefined if not found + */ + getValueAtPath(obj, path) { + if (obj === null || typeof obj !== 'object') { + return undefined; + } + const parts = path.split('.'); + let current = obj; + for (const part of parts) { + if (current === null || typeof current !== 'object') { + return undefined; + } + current = current[part]; + } + return current; + } + + getValue() { + try { + return JSON.parse(this.state.value); + } catch { + return this.state.value; + } + } + + handleConfirm() { + const value = this.getValue(); + const { useKeyFilter, selectedKeyPath } = this.state; + + if (useKeyFilter && selectedKeyPath) { + this.props.onConfirm({ + filterByKey: true, + keyPath: selectedKeyPath, + value: value, + }); + } else { + this.props.onConfirm({ + filterByKey: false, + value: value, + }); + } + + this.setState({ + value: '', + useKeyFilter: false, + selectedKeyPath: '', + }); + } + + render() { + const { param, arrayValue } = this.props; + const { value, useKeyFilter, selectedKeyPath } = this.state; + + // Check if the array contains objects + const containsObjects = arrayValue?.some( + item => item !== null && typeof item === 'object' && !Array.isArray(item) + ); + + // Extract available key paths if array contains objects + const keyPaths = containsObjects ? this.extractKeyPaths(arrayValue) : []; + + const confirmDisabled = + value === '' || (useKeyFilter && !selectedKeyPath); + + return ( + + {containsObjects && keyPaths.length > 0 && ( + + } + input={ + + this.setState({ + useKeyFilter: checked, + selectedKeyPath: checked ? selectedKeyPath : '', + }) + } + /> + } + /> + )} + {useKeyFilter && keyPaths.length > 0 && ( + + } + input={ + this.setState({ selectedKeyPath: path })} + placeHolder="Select key path..." + > + {keyPaths.map(path => ( + + ))} + + } + /> + )} + + } + input={ + + this.setState({ value: val })} + /> + + } + /> + + ); + } +} From ff20a7e81c7019d4a37b59087fa67c6534b4b752 Mon Sep 17 00:00:00 2001 From: Manuel Trezza <5673677+mtrezza@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:27:43 +0100 Subject: [PATCH 2/2] fetch latest --- src/dashboard/Data/Config/Config.react.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/dashboard/Data/Config/Config.react.js b/src/dashboard/Data/Config/Config.react.js index 85ea70604f..f32018daf9 100644 --- a/src/dashboard/Data/Config/Config.react.js +++ b/src/dashboard/Data/Config/Config.react.js @@ -650,6 +650,10 @@ class Config extends TableView { async removeArrayEntry(param, removeConfig) { try { this.setState({ loading: true }); + + // Fetch latest config data to ensure we have the current array value + await this.props.config.dispatch(ActionTypes.FETCH); + const masterKeyOnlyMap = this.props.config.data.get('masterKeyOnly'); const masterKeyOnly = masterKeyOnlyMap?.get(param) || false; @@ -658,7 +662,8 @@ class Config extends TableView { if (removeConfig.filterByKey) { // Filter mode: find all objects where keyPath matches the value const { keyPath, value } = removeConfig; - const currentArray = this.state.removeEntryArrayValue; + const params = this.props.config.data.get('params'); + const currentArray = params?.get(param) || []; objectsToRemove = currentArray.filter(item => { if (item === null || typeof item !== 'object' || Array.isArray(item)) {