Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
},
"dependencies": {
"@apollo/client": "^3.8.5",
"@edsc/metadata-preview": "^1.5.12",
"@edsc/metadata-preview": "^1.5.13",
"@node-saml/node-saml": "^5.1.0",
"@rjsf/core": "^5.15.0",
"@rjsf/utils": "^5.15.0",
Expand Down
2 changes: 1 addition & 1 deletion static/src/css/vendor/bootstrap/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
@import "bootstrap/scss/alert";
// @import "bootstrap/scss/progress";
@import "bootstrap/scss/list-group";
// @import "bootstrap/scss/close";
@import "bootstrap/scss/close";
@import "bootstrap/scss/toasts";
@import "bootstrap/scss/modal"; // Requires transitions
@import "bootstrap/scss/tooltip";
Expand Down
295 changes: 278 additions & 17 deletions static/src/js/components/JsonPreview/JsonPreview.jsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,297 @@
import React from 'react'
import React, { useState } from 'react'
import Accordion from 'react-bootstrap/Accordion'
import Modal from 'react-bootstrap/Modal'
import JSONPretty from 'react-json-pretty'
import { cloneDeep } from 'lodash-es'
import PropTypes from 'prop-types'
import validator from '@rjsf/validator-ajv8'

import useAppContext from '../../hooks/useAppContext'
import removeEmpty from '../../utils/removeEmpty'
import Button from '../Button/Button'

const JsonPreview = () => {
const JsonPreview = ({ schema }) => {
const {
draft = {}
draft = {},
setDraft
} = useAppContext()

// Remove || {} in MMT-4070
const { ummMetadata = {} } = draft || {}

const data = cloneDeep(removeEmpty(ummMetadata))

const [isEditing, setIsEditing] = useState(false)
const [jsonText, setJsonText] = useState('')

// Inline, blocking error -- only ever a JSON.parse failure. There's
// nothing to save yet, so this can't be resolved with a "save anyway"
// confirmation the way schema errors can.
const [parseError, setParseError] = useState(null)

// Schema/structural errors surfaced on Save. These don't block saving --
// they open the confirmation modal below instead, and the user decides
// whether to save despite them.
const [pendingErrors, setPendingErrors] = useState([])
const [pendingParsed, setPendingParsed] = useState(null)
const [showConfirm, setShowConfirm] = useState(false)

const handleEditClick = () => {
setJsonText(JSON.stringify(data, null, 2))
setParseError(null)
setPendingErrors([])
setPendingParsed(null)
setIsEditing(true)
}

const handleCancel = () => {
setJsonText(JSON.stringify(data, null, 2))
setParseError(null)
setPendingErrors([])
setPendingParsed(null)
setShowConfirm(false)
setIsEditing(false)
}

const handleTextChange = (event) => {
setJsonText(event.target.value)
if (parseError) setParseError(null)
}

const commitSave = (parsed) => {
setDraft({
...draft,
ummMetadata: parsed
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

setParseError(null)
setPendingErrors([])
setPendingParsed(null)
setShowConfirm(false)
setIsEditing(false)
}

const handleSaveClick = () => {
let parsed

try {
parsed = JSON.parse(jsonText)
} catch (parseErrorObj) {
setParseError(`Invalid JSON: ${parseErrorObj.message}`)

return
}

setParseError(null)

if (schema) {
const { errors: schemaErrors = [] } = validator.validateFormData(parsed, schema)

// Only surface structural problems (an unknown/typo'd field name, or a
Comment thread
mandyparson marked this conversation as resolved.
Outdated
// value of the wrong type). Missing-required-field errors are ignored
// here so saving through the JSON editor stays as permissive as saving
// through the form fields, which never blocks on incomplete drafts.
// A missing required field inside a oneOf/anyOf branch (e.g. a
// discriminated union) doesn't just produce a 'required' error -- AJV
// also emits a wrapping 'oneOf'/'anyOf' error at the same instancePath
// ("must match a schema in oneOf/anyOf"), so that wrapper needs to be
// ignored too or an otherwise-incomplete-but-valid draft would still
// trigger a confirmation.
//
// However, oneOf/anyOf errors aren't ONLY produced by missing-required
// noise -- some schemas express a controlled vocabulary (effectively
// an enum) as `oneOf: [{ const: 'A' }, { const: 'B' }, ...]` instead of
// a plain `enum`. An invalid value for one of those fields fails with
// a oneOf/anyOf error too, and a blanket filter would silently let it
// through. So only drop a oneOf/anyOf error when a 'required' error
// exists at that same instancePath (i.e. it's the wrapper noise) --
// keep it when it's the only error at that path, since that means it's
// a genuine invalid-value failure.
const requiredPaths = new Set(
schemaErrors
.filter(({ name }) => name === 'required')
.map(({ instancePath }) => instancePath)
)

const structuralErrors = schemaErrors.filter(({ name, instancePath }) => {
Comment thread
mandyparson marked this conversation as resolved.
Outdated
if (name === 'required') return false
if ((name === 'oneOf' || name === 'anyOf') && requiredPaths.has(instancePath)) return false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return true
})

if (structuralErrors.length > 0) {
const messages = structuralErrors.map(({
name,
property,
message,
params
}) => {
// AJV puts the actual bad key in params.additionalProperty for this
// error type -- `property` here refers to the parent object, and
// `message` alone doesn't name the offending field at all.
if (name === 'additionalProperties' && params?.additionalProperty) {
const location = property ? `${property} ` : ''

return `${location}must NOT have additional property '${params.additionalProperty}'`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add a space between location and must

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}

return property ? `${property} ${message}` : message
})

setPendingErrors(messages)
setPendingParsed(parsed)
setShowConfirm(true)

return
}
}

commitSave(parsed)
}

const handleConfirmSaveAnyway = () => {
commitSave(pendingParsed)
}

const handleConfirmBack = () => {
setShowConfirm(false)
setPendingErrors([])
setPendingParsed(null)
}

return (
<Accordion
defaultActiveKey="0"
className="mt-5"
>
<Accordion.Item eventKey="0">
<Accordion.Header>
JSON
</Accordion.Header>
<Accordion.Body>
<JSONPretty data={data} />
</Accordion.Body>
</Accordion.Item>
</Accordion>
<>
<Accordion
defaultActiveKey="0"
className="mt-5"
>
<Accordion.Item eventKey="0">
<Accordion.Header>
JSON
</Accordion.Header>
<Accordion.Body>
<div className="d-flex justify-content-end mb-2">
<Button
variant="secondary"
size="sm"
onClick={handleEditClick}
>
Edit JSON
</Button>
</div>

<JSONPretty data={data} />
</Accordion.Body>
</Accordion.Item>
</Accordion>

<Modal
Comment thread
mandyparson marked this conversation as resolved.
Outdated
show={isEditing}
onHide={handleCancel}
size="lg"
animation={false}
aria-labelledby="json-preview-edit-modal-title"
>
<Modal.Header closeButton>
<Modal.Title id="json-preview-edit-modal-title">
Edit JSON
</Modal.Title>
</Modal.Header>

<Modal.Body>
{
parseError && (
<div className="text-danger small mb-2" role="alert">
{parseError}
</div>
)
}

<textarea
className={`form-control font-monospace ${parseError ? 'is-invalid' : ''}`}
rows={20}
value={jsonText}
onChange={handleTextChange}
spellCheck={false}
aria-label="Editable JSON metadata"
/>
</Modal.Body>

<Modal.Footer>
<Button
variant="secondary"
size="sm"
onClick={handleCancel}
>
Cancel
</Button>
<Button
variant="primary"
size="sm"
onClick={handleSaveClick}
>
Save
Comment thread
mandyparson marked this conversation as resolved.
Outdated
</Button>
</Modal.Footer>
</Modal>

<Modal
show={showConfirm}
onHide={handleConfirmBack}
animation={false}
aria-labelledby="json-preview-confirm-modal-title"
>
<Modal.Header closeButton>
<Modal.Title id="json-preview-confirm-modal-title">
Confirm Save
</Modal.Title>
</Modal.Header>

<Modal.Body>
<p>Your record has following errors:</p>

<ul>
{
pendingErrors.map((message) => (
<li key={message}>{message}</li>
))
}
</ul>

<p>Would you like to proceed?</p>
</Modal.Body>

<Modal.Footer>
<Button
variant="secondary"
size="sm"
onClick={handleConfirmBack}
>
Back
</Button>
<Button
variant="primary"
size="sm"
onClick={handleConfirmSaveAnyway}
>
Save & Continue
</Button>
</Modal.Footer>
</Modal>
</>
)
}

JsonPreview.defaultProps = {
schema: null
}

JsonPreview.propTypes = {
// The full UMM schema (not a section-limited schema) to validate the
// edited JSON against on save. If omitted, only JSON-syntax validation
// is performed.
// eslint-disable-next-line react/forbid-prop-types
schema: PropTypes.object
}

export default JsonPreview
Loading
Loading