diff --git a/package.json b/package.json
index 57345860..0adef5c1 100644
--- a/package.json
+++ b/package.json
@@ -51,10 +51,10 @@
"jest": {
"coverageThreshold": {
"global": {
- "statements": 62,
- "branches": 61,
- "functions": 70,
- "lines": 62
+ "statements": 64,
+ "branches": 65,
+ "functions": 71,
+ "lines": 65
}
},
"transformIgnorePatterns": [
diff --git a/src/components/Toolbar.js b/src/components/Toolbar.js
index b517f20c..ef27461b 100644
--- a/src/components/Toolbar.js
+++ b/src/components/Toolbar.js
@@ -3,6 +3,28 @@ import PropTypes from 'prop-types'
import 'bootstrap/dist/css/bootstrap.min.css'
import { compareVersions } from 'compare-versions'
import styles from './Toolbar.module.css'
+import { toast } from 'react-toastify'
+import Config from '../config'
+import { getLinkAsButton } from '../utilities/uiUtils'
+
+const helpToast = (
+
+
Help
+ View or submit feedback to our {getLinkAsButton(Config.REPO_URL, 'GitHub repository')}:
+
+ {getLinkAsButton(`${Config.REPO_URL}/issues`, 'Issues (bugs or feature requests)', 'View/submit bugs or feature requests', true)}
+ {getLinkAsButton(`${Config.REPO_URL}/discussions`, 'Discussions', 'View/submit discussions', true)}
+
+
+
Getting started
+ Use this tool to create metadata files based on {getLinkAsButton(Config.AIND_DATA_SCHEMA_REPO_URL, 'aind-data-schema')}
+ ({getLinkAsButton(Config.AIND_DATA_SCHEMA_READTHEDOCS_URL, 'readthedocs')}).
+
+ Select a schema from the dropdown. The latest version will be loaded as a fillable form.
+ Or, use the 'Autofill from file' button to load an existing metadata file (must be JSON).
+ The submitted metadata will be validated and saved as a JSON file to your device.
+
+
)
function Toolbar (props) {
/**
@@ -35,6 +57,14 @@ function Toolbar (props) {
event.target.blur()
}
+ const showHelpPopup = (event) => {
+ toast(helpToast, {
+ toastId: 'help-toast', // provide id to disable duplicates
+ autoClose: false
+ })
+ event.target.blur()
+ }
+
return (
))}
+
+ Help
+
{ }
const sampleSchemaList = parseAndFilterSchemas(sampleSchemaLinks)
+jest.mock('react-toastify', () => ({ toast: jest.fn() }))
describe('Toolbar component', () => {
it('renders appropriate inputs on default', () => {
@@ -21,9 +23,11 @@ describe('Toolbar component', () => {
expect(screen.getByTitle('Select a schema')).toBeInTheDocument()
expect(screen.getByTitle('Select a version')).toBeInTheDocument()
expect(screen.getByTitle('Autofill with existing data from local file')).toBeInTheDocument()
+ expect(screen.getByTitle('Get help')).toBeInTheDocument()
expect(screen.getByTitle('Select a schema')).toBeEnabled()
expect(screen.getByTitle('Select a version')).toBeDisabled()
expect(screen.getByTitle('Autofill with existing data from local file')).toBeEnabled()
+ expect(screen.getByTitle('Get help')).toBeEnabled()
})
it('calls ParentTypeCallback and enables version selection dropdown when a schema type is chosen', () => {
@@ -86,4 +90,36 @@ describe('Toolbar component', () => {
fireEvent.click(screen.getByTitle('Autofill with existing data from local file'))
expect(mockRehydrateCallback).toHaveBeenCalled()
})
+
+ it('displays a popup with Help info when the Help button is clicked', () => {
+ const expectedHelpDiv = expect.objectContaining({
+ type: 'div',
+ props: {
+ children: expect.arrayContaining([
+ expect.objectContaining({
+ type: 'h2',
+ props: expect.objectContaining({ children: 'Help' })
+ }),
+ expect.objectContaining({
+ type: 'h4',
+ props: expect.objectContaining({ children: 'Getting started' })
+ })
+ ])
+ }
+ })
+ const expectedToastParams = {
+ toastId: 'help-toast',
+ autoClose: false
+ }
+ render( )
+ fireEvent.click(screen.getByTitle('Get help'))
+ expect(toast).toHaveBeenCalledWith(expectedHelpDiv, expectedToastParams)
+ })
})
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 00000000..5870a311
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,7 @@
+const Config = {
+ REPO_URL: 'https://github.com/AllenNeuralDynamics/aind-metadata-entry-js',
+ AIND_DATA_SCHEMA_REPO_URL: 'https://github.com/AllenNeuralDynamics/aind-data-schema',
+ AIND_DATA_SCHEMA_READTHEDOCS_URL: 'https://aind-data-schema.readthedocs.io/en/latest/'
+}
+
+export default Config
diff --git a/src/config.test.js b/src/config.test.js
new file mode 100644
index 00000000..f9eaf830
--- /dev/null
+++ b/src/config.test.js
@@ -0,0 +1,7 @@
+describe('config', () => {
+ it('has all required properties', () => {
+ const Config = require('./config').default
+ expect(Config.REPO_URL).toBeDefined()
+ expect(Config.AIND_DATA_SCHEMA_REPO_URL).toBeDefined()
+ })
+})
diff --git a/src/custom-ui/CustomWidgets.js b/src/custom-ui/CustomWidgets.js
index 0d309c77..1c3de928 100644
--- a/src/custom-ui/CustomWidgets.js
+++ b/src/custom-ui/CustomWidgets.js
@@ -6,7 +6,6 @@ import CheckboxWidget from '@rjsf/material-ui/lib/CheckboxWidget/CheckboxWidget'
import TextWidget from '@rjsf/core/lib/components/widgets/TextWidget'
import React, { useLayoutEffect } from 'react'
import PropTypes from 'prop-types'
-import { toConstant } from '@rjsf/utils'
const CustomTimeWidget = (props) => {
const onChange = (selectedDate) => {
@@ -46,27 +45,35 @@ const CustomCheckboxWidget = (props) => {
/**
* Custom text widget to enable custom behavior for constants.
* If const, update formData value to const value using onChange callback, render as readonly (grayed out)
+ * If null const, return null (do not display text input)
* Otherwise, return default text widget
* @param {*} props RJSF widget props
* @returns A custom text widget
*/
const CustomTextWidget = (props) => {
+ const { schema, onChange, value } = props
// useLayoutEffect to run effect runs before browser repaints screen (reduce flickering)
useLayoutEffect(() => {
- if (props.schema.const !== undefined) {
- props.onChange(toConstant(props.schema))
+ if (schema.const !== undefined && value !== schema.const) {
+ onChange(schema.const)
}
- }, [props])
- return
+ } else {
+ return
+ }
}
CustomTextWidget.propTypes = {
value: PropTypes.any,
onChange: PropTypes.func,
- schema: PropTypes.object,
- readonly: PropTypes.bool
+ schema: PropTypes.object
}
export const widgets = { checkbox: CustomCheckboxWidget, time: CustomTimeWidget, text: CustomTextWidget }
diff --git a/src/utilities/schemaHandler.test.js b/src/utilities/schemaHandler.test.js
index 54434cd8..7646482a 100644
--- a/src/utilities/schemaHandler.test.js
+++ b/src/utilities/schemaHandler.test.js
@@ -157,12 +157,12 @@ test('Checks preProcessSchema modifies const schema to add missing default or ty
{ key: 'boolean_const', type: 'boolean' },
{ key: 'object_const', type: 'object' },
{ key: 'array_const', type: 'array' },
- { key: 'null_const', type: 'null' }
+ { key: 'null_const', type: ['null', 'string'] }
]
const processedSchema1 = preProcessSchema(testSchema1)
expect(processedSchema1.properties.describedBy.default).toBe(testSchema1.properties.describedBy.const)
for (const expectedType of expectedTypes) {
- expect(processedSchema1.properties[expectedType.key].type).toBe(expectedType.type)
+ expect(processedSchema1.properties[expectedType.key].type).toStrictEqual(expectedType.type)
}
})
diff --git a/src/utilities/schemaHandlers.js b/src/utilities/schemaHandlers.js
index b1218f85..43bd7409 100644
--- a/src/utilities/schemaHandlers.js
+++ b/src/utilities/schemaHandlers.js
@@ -23,7 +23,11 @@ const preProcessHelper = (obj) => {
// If default is undefined or not matching const value, set to const
// Note: We use a custom text widget to autofill the const value and set the field as readonly.
if (prop.const !== undefined) {
- if (prop.type === undefined) { prop.type = guessType(prop.const) }
+ if (prop.type === undefined) {
+ const constType = guessType(prop.const)
+ // use nullable string type for null consts to enable allOf defaults
+ prop.type = constType === 'null' ? ['null', 'string'] : constType
+ }
if (!deepEquals(prop.default, prop.const)) { prop.default = prop.const }
}
diff --git a/src/utilities/uiUtils.js b/src/utilities/uiUtils.js
new file mode 100644
index 00000000..ad9fbf50
--- /dev/null
+++ b/src/utilities/uiUtils.js
@@ -0,0 +1,17 @@
+import React from 'react'
+
+/**
+ * Create an HTML link element formatted as a button or a simple link. Link opens in a new tab.
+ * @param {string} url The link URL
+ * @param {string} text The text to display
+ * @param {string | null} tooltip The tooltip text
+ * @param {boolean | null} displayAsButton Flag to display the link as a button
+ * @returns HTML element for formatted link
+ */
+export function getLinkAsButton (url, text, tooltip = '', displayAsButton = false) {
+ if (displayAsButton) {
+ return ( {text} )
+ } else {
+ return ({text} )
+ }
+}
diff --git a/src/utilities/uiUtils.test.js b/src/utilities/uiUtils.test.js
new file mode 100644
index 00000000..db6d3549
--- /dev/null
+++ b/src/utilities/uiUtils.test.js
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react'
+import { getLinkAsButton } from './uiUtils'
+
+describe('uiUtils', () => {
+ describe('getLinkAsButton', () => {
+ const PLACEHOLDER_URL = 'https://example.com'
+ const PLACEHOLDER_TEXT = 'Example Link'
+ const PLACEHOLDER_TOOLTIP = 'This is an example link'
+ test('should return an element with the correct attributes on default', () => {
+ const linkElement = getLinkAsButton(PLACEHOLDER_URL, PLACEHOLDER_TEXT)
+ render(linkElement)
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toBeInTheDocument()
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toHaveAttribute('href', PLACEHOLDER_URL)
+ expect(screen.getByText(PLACEHOLDER_TEXT)).not.toHaveAttribute('title', PLACEHOLDER_TOOLTIP)
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toHaveAttribute('target', '_blank')
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toHaveAttribute('rel', 'noreferrer')
+ expect(screen.getByText(PLACEHOLDER_TEXT)).not.toHaveAttribute('type', 'button')
+ expect(screen.getByText(PLACEHOLDER_TEXT)).not.toHaveClass('btn')
+ })
+
+ test('should return an element with a tooltip if specified', () => {
+ const linkElement = getLinkAsButton(PLACEHOLDER_URL, PLACEHOLDER_TEXT, PLACEHOLDER_TOOLTIP)
+ render(linkElement)
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toBeInTheDocument()
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toHaveAttribute('title', PLACEHOLDER_TOOLTIP)
+ })
+
+ test('should return an element with the correct button classes and type if displayAsButton is true', () => {
+ const linkElement = getLinkAsButton(PLACEHOLDER_URL, PLACEHOLDER_TEXT, PLACEHOLDER_TOOLTIP, true)
+ render(linkElement)
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toBeInTheDocument()
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toHaveAttribute('type', 'button')
+ expect(screen.getByText(PLACEHOLDER_TEXT)).toHaveClass('btn btn-default')
+ })
+ })
+})