diff --git a/src/dashboard/CustomParseOptions/CustomParseOptions.react.js b/src/dashboard/CustomParseOptions/CustomParseOptions.react.js index c79c4f602a..5573e06a4e 100644 --- a/src/dashboard/CustomParseOptions/CustomParseOptions.react.js +++ b/src/dashboard/CustomParseOptions/CustomParseOptions.react.js @@ -26,6 +26,8 @@ import TextInputSettings from 'components/TextInputSettings/TextInputSettings.re import Button from 'components/Button/Button.react'; import B4aTooltip from 'components/Tooltip/B4aTooltip.react'; import Icon from 'components/Icon/Icon.react'; +import B4aModal from 'components/B4aModal/B4aModal.react'; +import B4aCodeEditor from 'components/CodeEditor/B4aCodeEditor.react'; import { Link } from 'react-router-dom'; import deepmerge from 'deepmerge'; @@ -34,6 +36,162 @@ import CustomParseOptionsValidations from './CustomParseOptionsValidations'; import getError from 'dashboard/Settings/Util/getError'; import semver from 'semver'; +const CUSTOM_PAGES_KEYS = [ + 'choosePassword', + 'verifyEmailSuccess', + 'parseFrameURL', + 'passwordResetSuccess', + 'invalidLink', + 'invalidVerificationLink', + 'linkSendSuccess', + 'linkSendFail', +]; + +// Recursively sort object keys so the JSON editor shows properties in a +// deterministic, alphabetical order whenever we rebuild the text ourselves +// (modal open, reset, etc.). User-driven edits bypass this, preserving caret +// position while they're typing. +const sortKeysDeep = (value) => { + if (Array.isArray(value)) { + return value.map(sortKeysDeep); + } + if (value && typeof value === 'object') { + const sorted = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = sortKeysDeep(value[key]); + } + return sorted; + } + return value; +}; + +const stringifyAlpha = (value) => JSON.stringify(sortKeysDeep(value), null, 2); + +const getActualChanges = (changes, initial) => { + const result = {}; + for (const key of Object.keys(changes)) { + const val = changes[key]; + const ref = initial ? initial[key] : undefined; + if (val && typeof val === 'object' && !Array.isArray(val)) { + const nested = getActualChanges(val, ref || {}); + if (Object.keys(nested).length > 0) { + result[key] = nested; + } + } else if (val !== ref) { + result[key] = val; + } + } + return result; +}; + +const transformCustomOptionsForPayload = (customOptionsInput) => { + if (!customOptionsInput) { + return undefined; + } + const payload = JSON.parse(JSON.stringify(customOptionsInput)); + delete payload.databaseURI; + if (payload.maxUploadSize != null && payload.maxUploadSize !== '') { + payload.maxUploadSize = `${payload.maxUploadSize}mb`; + } + const customPages = { ...(payload.customPages || {}) }; + CUSTOM_PAGES_KEYS.forEach(key => { + if (Object.prototype.hasOwnProperty.call(payload, key)) { + customPages[key] = payload[key] === '' ? undefined : payload[key]; + delete payload[key]; + } + }); + if (Object.keys(customPages).length > 0) { + payload.customPages = customPages; + } + for (const key of Object.keys(payload)) { + if (typeof payload[key] === 'string' && payload[key].trim() === '') { + payload[key] = undefined; + } + } + return payload; +}; + +const buildSaveParseOptionsPayload = (fields, initialFields) => { + const changes = getActualChanges(fields, initialFields); + return { + customOptions: transformCustomOptionsForPayload(changes.customOptions), + clientPush: Object.prototype.hasOwnProperty.call(changes, 'clientPush') + ? changes.clientPush + : undefined, + clientClassCreation: Object.prototype.hasOwnProperty.call(changes, 'clientClassCreation') + ? changes.clientClassCreation + : undefined, + }; +}; + +// Keys that live alongside `customOptions` at the top of the saveParseOptionsAndSettings +// payload. Everything else in the flat JSON editor gets grouped under `customOptions`. +const TOP_LEVEL_PAYLOAD_KEYS = ['clientPush', 'clientClassCreation']; + +// Builds the flat shape shown in the JSON editor: all customOptions keys are +// merged into the root next to clientPush/clientClassCreation so the user +// doesn't need to know which keys belong under `customOptions`. +// The `?? default` guards ensure these two always render (JSON.stringify +// drops properties whose value is `undefined`). +const buildFlatEditorPayload = (fields) => { + const transformed = transformCustomOptionsForPayload(fields.customOptions) || {}; + return { + ...transformed, + clientPush: fields.clientPush ?? false, + clientClassCreation: fields.clientClassCreation ?? true, + }; +}; + +// Inverse of `buildFlatEditorPayload` — groups every non top-level key back +// under `customOptions` so we can send the correct saveParseOptionsAndSettings +// shape. +const unflattenEditorPayload = (flatParsed) => { + const result = { + customOptions: {}, + clientPush: undefined, + clientClassCreation: undefined, + }; + if (!flatParsed || typeof flatParsed !== 'object' || Array.isArray(flatParsed)) { + return result; + } + for (const key of Object.keys(flatParsed)) { + if (TOP_LEVEL_PAYLOAD_KEYS.indexOf(key) !== -1) { + result[key] = flatParsed[key]; + } else { + result.customOptions[key] = flatParsed[key]; + } + } + return result; +}; + +// Reverse of `transformCustomOptionsForPayload` so JSON edits from the modal +// can be mapped back onto the form fields (which use raw shapes like a numeric +// maxUploadSize and flat custom-page keys). +const reverseTransformCustomOptions = (payloadCustomOptions) => { + if (!payloadCustomOptions || typeof payloadCustomOptions !== 'object') { + return {}; + } + const result = JSON.parse(JSON.stringify(payloadCustomOptions)); + + if (typeof result.maxUploadSize === 'string') { + const parsed = parseInt(result.maxUploadSize, 10); + if (!Number.isNaN(parsed)) { + result.maxUploadSize = parsed; + } + } + + if (result.customPages && typeof result.customPages === 'object') { + CUSTOM_PAGES_KEYS.forEach(key => { + if (Object.prototype.hasOwnProperty.call(result.customPages, key)) { + result[key] = result.customPages[key]; + } + }); + delete result.customPages; + } + + return result; +}; + const LabelInfoTooltip = ({ description, children }) => { const [visible, setVisible] = React.useState(false); const [placement, setPlacement] = React.useState('top'); @@ -112,6 +270,11 @@ class CustomParseOptions extends DashboardView { canChangeCustomParseOptions: false, hasOtherConfigsPermission: true, isOwner: false, + showPayloadModal: false, + copyStatus: '', + payloadJson: '', + isSavingPayload: false, + payloadSaveError: '', }; this.onRefresh = this.onRefresh.bind(this); } @@ -222,7 +385,7 @@ class CustomParseOptions extends DashboardView { initialFields: { customOptions, clientPush: response.clientPush ?? false, - clientClassCreation: response.clientClassCreation !== 'undefined' ? response.clientClassCreation : true, + clientClassCreation: response.clientClassCreation ?? true, }, }); } catch (error) { @@ -239,7 +402,7 @@ class CustomParseOptions extends DashboardView { ); } - renderParseOptionsForm({ fields, setField, setFieldJson, errors }) { + renderParseOptionsForm({ fields, setField, setFieldJson, resetFields, errors }) { const customOptions = fields.customOptions || {}; const clientPush = fields.clientPush === true; const clientClassCreation = fields.clientClassCreation; @@ -269,10 +432,22 @@ class CustomParseOptions extends DashboardView { const isOwner = this.state.isOwner; return ( + <>
Parse Server Options
-
Configure advanced settings of your Parse Server instance, including server behavior, authentication, and security rules.
+
+ Configure advanced settings of your Parse Server instance, including server behavior, authentication, and security rules. + Check available option in {' '} + + Documentation + . +
Warning: Changes apply immediately and may affect your app’s availability or client connections.
{!this.state.canChangeCustomParseOptions && ( @@ -1106,11 +1281,310 @@ class CustomParseOptions extends DashboardView { /> }
+ +
+ +
+ + } + input={ +
+
+ } + theme={Field.Theme.BLUE} + /> +
+ + {this.state.showPayloadModal && this.renderPayloadModal(fields, setField, resetFields)} + ) } + renderPayloadModal(fields, setField, resetFields) { + const payloadJson = this.state.payloadJson; + const isSaving = this.state.isSavingPayload; + const payloadSaveError = this.state.payloadSaveError; + + let parseError = ''; + try { + JSON.parse(payloadJson); + } catch (e) { + parseError = e && e.message ? e.message : 'Invalid JSON'; + } + + const closeModal = () => { + if (isSaving) { + return; + } + this.setState({ + showPayloadModal: false, + copyStatus: '', + payloadSaveError: '', + }); + }; + + const resetPayload = () => { + // Revert to the original values from the most recent API response + // (stored in `initialFields`). This drops every form-level and + // JSON-editor change the user has made in this session and surfaces + // every saved key — including any the form doesn't render — so the + // editor matches the GET /parseOptions response. + const apiInitialFields = this.state.initialFields || {}; + const flatPayload = buildFlatEditorPayload(apiInitialFields); + this.setState({ + payloadJson: stringifyAlpha(flatPayload), + copyStatus: '', + payloadSaveError: '', + }); + // Clear FlowView's tracked changes so `fields` = `initialFields` again + // (the form fields revert alongside the JSON). + if (typeof resetFields === 'function') { + resetFields(); + } + }; + + const applyJsonToFields = (value) => { + let parsed; + try { + parsed = JSON.parse(value); + } catch (e) { + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return; + } + const unflat = unflattenEditorPayload(parsed); + setField('customOptions', reverseTransformCustomOptions(unflat.customOptions)); + if (Object.prototype.hasOwnProperty.call(parsed, 'clientPush')) { + setField('clientPush', unflat.clientPush); + } + if (Object.prototype.hasOwnProperty.call(parsed, 'clientClassCreation')) { + setField('clientClassCreation', unflat.clientClassCreation); + } + }; + + const handleCodeChange = (value) => { + this.setState({ payloadJson: value, copyStatus: '', payloadSaveError: '' }); + applyJsonToFields(value); + }; + + const copyToClipboard = async () => { + try { + if (navigator && navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(payloadJson); + } else { + const textarea = document.createElement('textarea'); + textarea.value = payloadJson; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + this.setState({ copyStatus: 'Copied!' }); + setTimeout(() => { + if (this.state.showPayloadModal) { + this.setState({ copyStatus: '' }); + } + }, 1500); + } catch (e) { + this.setState({ copyStatus: 'Failed to copy' }); + setTimeout(() => { + if (this.state.showPayloadModal && this.state.copyStatus === 'Failed to copy') { + this.setState({ copyStatus: '' }); + } + }, 1500); + } + }; + + const handleSave = async () => { + let parsed; + try { + parsed = JSON.parse(payloadJson); + } catch (e) { + this.setState({ + payloadSaveError: `Invalid JSON: ${e && e.message ? e.message : 'parse failed'}`, + }); + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + this.setState({ payloadSaveError: 'Payload must be a JSON object.' }); + return; + } + + const unflat = unflattenEditorPayload(parsed); + + this.setState({ isSavingPayload: true, payloadSaveError: '' }); + + try { + await this.context.saveParseOptionsAndSettings(unflat); + + const prevInitial = this.state.initialFields || {}; + this.setState({ + isSavingPayload: false, + payloadSaveError: '', + showPayloadModal: false, + copyStatus: '', + payloadJson: '', + initialFields: { + customOptions: reverseTransformCustomOptions(unflat.customOptions), + clientPush: Object.prototype.hasOwnProperty.call(parsed, 'clientPush') + ? unflat.clientPush + : prevInitial.clientPush, + clientClassCreation: Object.prototype.hasOwnProperty.call(parsed, 'clientClassCreation') + ? unflat.clientClassCreation + : prevInitial.clientClassCreation, + }, + }); + + if (typeof resetFields === 'function') { + // Clear FlowView's local changes so the dirty-footer resets to the new baseline. + resetFields(); + } + } catch (e) { + const errors = Array.isArray(e && e.errors) ? e.errors : []; + const message = + errors.join(' ') || + (e && (e.error || e.message || e.notice)) || + (typeof e === 'string' ? e : 'Failed to save parse options.'); + this.setState({ + isSavingPayload: false, + payloadSaveError: message, + }); + } + }; + + return ( + +
+
+ +
+
+ + {parseError + ? `Invalid JSON: ${parseError}` + : 'Valid edits sync to the form fields.'} + +
+ + +
+
+ {payloadSaveError && ( +
+ {payloadSaveError} +
+ )} +
+
+ ); + } + renderContent() { const toolbar = this.renderToolbar(); const loading = this.state.isLoading; @@ -1120,22 +1594,6 @@ class CustomParseOptions extends DashboardView { clientClassCreation: true, }; - const getActualChanges = (changes, initial) => { - const result = {}; - for (const key of Object.keys(changes)) { - const val = changes[key]; - const ref = initial ? initial[key] : undefined; - if (val && typeof val === 'object' && !Array.isArray(val)) { - const nested = getActualChanges(val, ref || {}); - if (Object.keys(nested).length > 0) { - result[key] = nested; - } - } else if (val !== ref) { - result[key] = val; - } - } - return result; - }; const customParseOptionsFieldsOptions = { customOptions: { friendlyName: 'custom options', type: 'json' }, clientPush: { friendlyName: 'push notification from client', showTo: true }, @@ -1175,54 +1633,9 @@ class CustomParseOptions extends DashboardView { defaultFooterMessage={You don't have permission to edit this feature.} hideButtonsOnDefaultMessage={true} onSubmit={({ changes: rawChanges }) => { - const changes = getActualChanges(rawChanges, initialFields); - const customPagesKeys = [ - 'choosePassword', - 'verifyEmailSuccess', - 'parseFrameURL', - 'passwordResetSuccess', - 'invalidLink', - 'invalidVerificationLink', - 'linkSendSuccess', - 'linkSendFail', - ]; - const payload = changes.customOptions - ? JSON.parse(JSON.stringify(changes.customOptions)) - : undefined; - - if (payload) { - delete payload.databaseURI; - if (payload.maxUploadSize != null && payload.maxUploadSize !== '') { - payload.maxUploadSize = `${payload.maxUploadSize}mb`; - } - - const customPages = { ...(payload.customPages || {}) }; - customPagesKeys.forEach(key => { - if (Object.prototype.hasOwnProperty.call(payload, key)) { - customPages[key] = payload[key] === '' ? undefined : payload[key]; - delete payload[key]; - } - }); - if (Object.keys(customPages).length > 0) { - payload.customPages = customPages; - } - - for (const key of Object.keys(payload)) { - if (typeof payload[key] === 'string' && payload[key].trim() === '') { - payload[key] = undefined; - } - } - } - - return this.context.saveParseOptionsAndSettings({ - customOptions: payload, - clientPush: Object.prototype.hasOwnProperty.call(changes, 'clientPush') - ? changes.clientPush - : undefined, - clientClassCreation: Object.prototype.hasOwnProperty.call(changes, 'clientClassCreation') - ? changes.clientClassCreation - : undefined, - }); + return this.context.saveParseOptionsAndSettings( + buildSaveParseOptionsPayload(rawChanges, initialFields) + ); }} afterSave={({ fields, resetFields }) => { this.setState({ diff --git a/src/dashboard/CustomParseOptions/CustomParseOptions.scss b/src/dashboard/CustomParseOptions/CustomParseOptions.scss index cdcac3f668..2e8539eadf 100644 --- a/src/dashboard/CustomParseOptions/CustomParseOptions.scss +++ b/src/dashboard/CustomParseOptions/CustomParseOptions.scss @@ -232,4 +232,10 @@ display: inline-flex; align-items: center; line-height: 1; +} + +.docsLink { + cursor: pointer; + text-decoration: underline; + font-weight: 600; } \ No newline at end of file