diff --git a/package-lock.json b/package-lock.json index 0eddad0..c98b624 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@mui/material": "^5.14.2", "@reduxjs/toolkit": "^1.9.5", "@vitest/browser": "^1.0.1", + "ajv": "^8.16.0", "lexical": "^0.12.4", "lodash": "^4.17.21", "mdi-material-ui": "^7.7.0", @@ -926,6 +927,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@eslint/js": { "version": "8.46.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.46.0.tgz", @@ -3858,15 +3881,14 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz", + "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.4.1" }, "funding": { "type": "github", @@ -5738,6 +5760,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -5918,8 +5962,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -7616,10 +7659,9 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -9768,7 +9810,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "devOptional": true, "engines": { "node": ">=6" } @@ -10305,6 +10346,14 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -11518,7 +11567,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } diff --git a/package.json b/package.json index b93132b..7d2c39d 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@mui/material": "^5.14.2", "@reduxjs/toolkit": "^1.9.5", "@vitest/browser": "^1.0.1", + "ajv": "^8.16.0", "lexical": "^0.12.4", "lodash": "^4.17.21", "mdi-material-ui": "^7.7.0", diff --git a/src/components/Fields/AdvancedSelectEditor.tsx b/src/components/Fields/AdvancedSelectEditor.tsx index ab03ad4..4347a1c 100644 --- a/src/components/Fields/AdvancedSelectEditor.tsx +++ b/src/components/Fields/AdvancedSelectEditor.tsx @@ -19,7 +19,7 @@ import { Button, Alert, AlertTitle, TextField, Grid, Card, FormControl, FormLabe import { BaseFieldEditor } from "./BaseFieldEditor"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; import { useState } from 'react'; @@ -38,7 +38,7 @@ type newState = { export const AdvancedSelectEditor = ({ fieldName }: { fieldName: string }) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const state = { diff --git a/src/components/Fields/BaseFieldEditor.tsx b/src/components/Fields/BaseFieldEditor.tsx index a5c817c..f5fb001 100644 --- a/src/components/Fields/BaseFieldEditor.tsx +++ b/src/components/Fields/BaseFieldEditor.tsx @@ -14,7 +14,7 @@ import { Checkbox, FormControlLabel, Grid, TextField, Card, Alert } from "@mui/material"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; import { ConditionModal, ConditionTranslation, ConditionType } from "../condition"; type Props = { @@ -38,26 +38,18 @@ type StateType = { export const BaseFieldEditor = ({ fieldName, children }: Props) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); // These are needed because there is no consistency in how // the field label is stored in the notebook const getFieldLabel = () => { - return (field['component-parameters'] && field['component-parameters'].label) || - (field['component-parameters'].InputLabelProps && field['component-parameters'].InputLabelProps.label) || - field['component-parameters'].name; + return (field['component-parameters']?.label) || field['component-parameters'].name; } const setFieldLabel = (newField: FieldType, label: string) => { console.log('setFieldLabel', newField, label); - if (newField['component-parameters'] && 'label' in newField['component-parameters']) - newField['component-parameters'].label = label; - else if (newField['component-parameters'] && - 'InputLabelProps' in newField['component-parameters'] && - newField['component-parameters'].InputLabelProps && - newField['component-parameters'].InputLabelProps.label) - newField['component-parameters'].InputLabelProps.label = label; + newField['component-parameters'].label = label; } const updateField = (fieldName: string, newField: FieldType) => { @@ -70,8 +62,8 @@ export const BaseFieldEditor = ({ fieldName, children }: Props) => { label: getFieldLabel(), helperText: cParams.helperText || "", required: cParams.required || false, - annotation: field.meta ? field.meta.annotation || false : false, - annotationLabel: field.meta ? field.meta.annotation_label || '' : '', + annotation: field.meta ? field.meta.annotation?.include : false, + annotationLabel: field.meta ? field.meta.annotation?.label || '' : '', uncertainty: field.meta ? field.meta.uncertainty.include || false : false, uncertaintyLabel: field.meta ? field.meta.uncertainty.label || '' : '', condition: field.condition, @@ -86,8 +78,10 @@ export const BaseFieldEditor = ({ fieldName, children }: Props) => { newField['component-parameters'].helperText = newState.helperText; newField['component-parameters'].required = newState.required; if (newField.meta) { - newField.meta.annotation = newState.annotation; - newField.meta.annotation_label = newState.annotationLabel || ''; + newField.meta.annotation = { + include: newState.annotation, + label: newState.annotationLabel || '', + } newField.meta.uncertainty = { include: newState.uncertainty, label: newState.uncertaintyLabel || '' diff --git a/src/components/Fields/BasicAutoIncrementer.tsx b/src/components/Fields/BasicAutoIncrementer.tsx index 2a285e1..619c6e1 100644 --- a/src/components/Fields/BasicAutoIncrementer.tsx +++ b/src/components/Fields/BasicAutoIncrementer.tsx @@ -1,6 +1,6 @@ import { Grid, Card, TextField } from "@mui/material"; import { useAppDispatch, useAppSelector } from "../../state/hooks"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; type PropType = { fieldName: string, @@ -9,7 +9,7 @@ type PropType = { export const BasicAutoIncrementerEditor = ({ fieldName, viewId }: PropType) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const label = field['component-parameters'].label; diff --git a/src/components/Fields/DateTimeNowEditor.tsx b/src/components/Fields/DateTimeNowEditor.tsx index bbdf372..5f54235 100644 --- a/src/components/Fields/DateTimeNowEditor.tsx +++ b/src/components/Fields/DateTimeNowEditor.tsx @@ -15,11 +15,11 @@ import { Grid, Card, FormHelperText, FormControlLabel, Checkbox } from "@mui/material"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { BaseFieldEditor } from "./BaseFieldEditor"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; export const DateTimeNowEditor = ({ fieldName }: {fieldName: string}) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const updateIsAutoPick = (value: boolean) => { diff --git a/src/components/Fields/MapFormFieldEditor.tsx b/src/components/Fields/MapFormFieldEditor.tsx index 6c738e3..efab11c 100644 --- a/src/components/Fields/MapFormFieldEditor.tsx +++ b/src/components/Fields/MapFormFieldEditor.tsx @@ -15,11 +15,11 @@ import { Grid, TextField, Card, FormControl, InputLabel, Select, MenuItem } from "@mui/material"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { BaseFieldEditor } from "./BaseFieldEditor"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; export const MapFormFieldEditor = ({ fieldName }: { fieldName: string }) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const initZoom = field['component-parameters'].zoom; diff --git a/src/components/Fields/MultipleTextField.tsx b/src/components/Fields/MultipleTextField.tsx index 1b6877e..6637ca9 100644 --- a/src/components/Fields/MultipleTextField.tsx +++ b/src/components/Fields/MultipleTextField.tsx @@ -15,11 +15,11 @@ import { Grid, Card, TextField } from "@mui/material"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { BaseFieldEditor } from "./BaseFieldEditor"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; export const MultipleTextFieldEditor = ({ fieldName }: { fieldName: string }) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const rows = field['component-parameters'].InputProps?.rows || 4; diff --git a/src/components/Fields/OptionsEditor.tsx b/src/components/Fields/OptionsEditor.tsx index 79fe703..329a02b 100644 --- a/src/components/Fields/OptionsEditor.tsx +++ b/src/components/Fields/OptionsEditor.tsx @@ -19,11 +19,11 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'; import { BaseFieldEditor } from "./BaseFieldEditor" import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { useState } from "react"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; export const OptionsEditor = ({ fieldName }: { fieldName: string }) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]) + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]) const dispatch = useAppDispatch() const [newOption, setNewOption] = useState('') diff --git a/src/components/Fields/RandomStyleEditor.tsx b/src/components/Fields/RandomStyleEditor.tsx index 4958741..bf92e26 100644 --- a/src/components/Fields/RandomStyleEditor.tsx +++ b/src/components/Fields/RandomStyleEditor.tsx @@ -15,11 +15,11 @@ import { Grid, TextField, Card, FormControl, InputLabel, Select, MenuItem } from "@mui/material"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { BaseFieldEditor } from "./BaseFieldEditor"; -import { Notebook, FieldType } from "../../state/initial"; +import { FieldType } from "../../state/initial"; export const RandomStyleEditor = ({ fieldName }: { fieldName: string }) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const initVariantStyle = field['component-parameters'].variant_style || ''; diff --git a/src/components/Fields/RelatedRecordEditor.tsx b/src/components/Fields/RelatedRecordEditor.tsx index 22563d6..8943f9a 100644 --- a/src/components/Fields/RelatedRecordEditor.tsx +++ b/src/components/Fields/RelatedRecordEditor.tsx @@ -19,7 +19,7 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'; import { useState } from "react"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { BaseFieldEditor } from "./BaseFieldEditor"; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; type PairList = [string, string][]; type Props = { @@ -28,8 +28,8 @@ type Props = { export const RelatedRecordEditor = ({ fieldName }: Props) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); - const viewsets = useAppSelector((state: Notebook) => state['ui-specification'].viewsets); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); + const viewsets = useAppSelector((state) => state.notebook['ui-specification'].viewsets); const dispatch = useAppDispatch(); const [newOption1, setNewOption1] = useState('') diff --git a/src/components/Fields/RichTextEditor.tsx b/src/components/Fields/RichTextEditor.tsx index 37f9608..182703b 100644 --- a/src/components/Fields/RichTextEditor.tsx +++ b/src/components/Fields/RichTextEditor.tsx @@ -16,13 +16,13 @@ import { Grid, FormHelperText } from "@mui/material"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { useRef } from "react"; import { MDXEditorMethods } from '@mdxeditor/editor'; -import { FieldType, Notebook } from "../../state/initial"; +import { FieldType } from "../../state/initial"; import { MdxEditor } from "../mdx-editor"; export const RichTextEditor = ({ fieldName }: { fieldName: string }) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const initContent = field['component-parameters'].content || ""; diff --git a/src/components/Fields/TemplatedStringFieldEditor.tsx b/src/components/Fields/TemplatedStringFieldEditor.tsx index 7abde54..a8eac05 100644 --- a/src/components/Fields/TemplatedStringFieldEditor.tsx +++ b/src/components/Fields/TemplatedStringFieldEditor.tsx @@ -1,7 +1,7 @@ import { Alert, Grid, Card, TextField, FormControl, InputLabel, MenuItem, Select, FormControlLabel, Checkbox, Typography } from "@mui/material"; import { useAppDispatch, useAppSelector } from "../../state/hooks"; import { MutableRefObject, useRef, useState } from "react"; -import { ComponentParameters, FieldType, Notebook } from "../../state/initial"; +import { ComponentParameters, FieldType } from "../../state/initial"; type PropType = { fieldName: string, @@ -10,13 +10,13 @@ type PropType = { export const TemplatedStringFieldEditor = ({ fieldName, viewId }: PropType) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); - const formHasHRID = useAppSelector((state: Notebook) => { - return Object.keys(state['ui-specification'].fields).some((fieldName) => { + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); + const formHasHRID = useAppSelector((state) => { + return Object.keys(state.notebook['ui-specification'].fields).some((fieldName) => { return fieldName.startsWith('hrid') && fieldName.endsWith(viewId); }); }); - const allFields = useAppSelector((state: Notebook) => state['ui-specification'].fields); + const allFields = useAppSelector((state) => state.notebook['ui-specification'].fields); const dispatch = useAppDispatch(); const textAreaRef = useRef(null) as MutableRefObject; diff --git a/src/components/Fields/TextFieldEditor.tsx b/src/components/Fields/TextFieldEditor.tsx index 7d4fd6a..5db5c34 100644 --- a/src/components/Fields/TextFieldEditor.tsx +++ b/src/components/Fields/TextFieldEditor.tsx @@ -15,10 +15,10 @@ import { Card, Grid, TextField } from "@mui/material"; import { useAppSelector, useAppDispatch } from "../../state/hooks"; import { BaseFieldEditor } from "./BaseFieldEditor"; -import { FieldType, Notebook, ValidationSchemaElement } from "../../state/initial"; +import { FieldType, ValidationSchemaElement } from "../../state/initial"; export const TextFieldEditor = ({ fieldName }: { fieldName: string }) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const initVal = field['initialValue'] as (string | number); @@ -26,7 +26,7 @@ export const TextFieldEditor = ({ fieldName }: { fieldName: string }) => { const schema = field['validationSchema'] || []; // flattens the validationSchema array of arrays so that I can run the .includes() function on it - const validationArr: (string | number)[] = schema.flat(); + const validationArr: unknown[] = schema.flat(); // flag to tell us if we're dealing with controlled-number / number-field-val let hasMinMax = false; if (validationArr.includes('yup.min') && validationArr.includes('yup.max')) { diff --git a/src/components/condition.test.tsx b/src/components/condition.test.tsx index 8aadf4a..269ec5c 100644 --- a/src/components/condition.test.tsx +++ b/src/components/condition.test.tsx @@ -21,6 +21,7 @@ import { Provider } from 'react-redux'; import { ThemeProvider } from "@mui/material/styles"; import globalTheme from "../theme/index"; import { ReactNode } from 'react'; +import { migrateNotebook } from '../state/migrateNotebook'; const WithProviders = ({children}: {children: ReactNode}) => ( @@ -33,7 +34,8 @@ const WithProviders = ({children}: {children: ReactNode}) => ( describe('ConditionControl', () => { test('render and interact with a field condition', () => { - store.dispatch({ type: 'ui-specification/loaded', payload: sampleNotebook['ui-specification'] }) + const notebook = migrateNotebook(sampleNotebook) + store.dispatch({ type: 'ui-specification/loaded', payload: notebook['ui-specification'] }) const condition = { operator: 'equal', field: 'Sample-Location', @@ -81,13 +83,13 @@ describe('ConditionControl', () => { value: 'Bobalooba', }] ); - }; + } }); }); - test('field condition omits field in select', async () => { - - store.dispatch({ type: 'ui-specification/loaded', payload: sampleNotebook['ui-specification'] }) + test('field condition omits field in select', () => { + const notebook = migrateNotebook(sampleNotebook) + store.dispatch({ type: 'ui-specification/loaded', payload: notebook['ui-specification'] }) const theField = 'New-Text-Field'; const onChangeFn = vi.fn(); @@ -115,7 +117,8 @@ describe('ConditionControl', () => { test('field condition omits all view fields in select', () => { - store.dispatch({ type: 'ui-specification/loaded', payload: sampleNotebook['ui-specification'] }) + const notebook = migrateNotebook(sampleNotebook) + store.dispatch({ type: 'ui-specification/loaded', payload: notebook['ui-specification'] }) const theView = 'Primary-New-Section'; const onChangeFn = vi.fn(); @@ -145,7 +148,9 @@ describe('ConditionControl', () => { test('make a boolean condition from a field', () => { - store.dispatch({ type: 'ui-specification/loaded', payload: sampleNotebook['ui-specification'] }) + const notebook = migrateNotebook(sampleNotebook) + store.dispatch({ type: 'ui-specification/loaded', payload: notebook['ui-specification'] }) + const condition = { operator: 'equal', field: 'Sample-Location', @@ -167,7 +172,7 @@ describe('ConditionControl', () => { const last = onChangeFn.mock.lastCall as ConditionType[]; expect(last[0].operator).toBe('and'); expect(last[0].conditions?.length).toBe(2); - }; + } }); }); }); diff --git a/src/components/condition.tsx b/src/components/condition.tsx index 4715097..003ebb5 100644 --- a/src/components/condition.tsx +++ b/src/components/condition.tsx @@ -14,7 +14,7 @@ import { Grid, Select, FormControl, InputLabel, MenuItem, Stack, Divider, TextField, Button, IconButton, Tooltip, Dialog } from "@mui/material"; import { useAppSelector } from "../state/hooks"; -import { FieldType, Notebook } from "../state/initial"; +import { FieldType } from "../state/initial"; import RemoveCircleIcon from '@mui/icons-material/RemoveCircle'; import SplitscreenIcon from '@mui/icons-material/Splitscreen'; import QuizIcon from '@mui/icons-material/Quiz'; @@ -87,7 +87,7 @@ export const ConditionModal = (props: ConditionProps & {label: string}) => { export const ConditionTranslation = (props: {condition: ConditionType}) => { - const allFields = useAppSelector((state: Notebook) => state['ui-specification'].fields); + const allFields = useAppSelector((state) => state.notebook['ui-specification'].fields); const getFieldName = (field: string | undefined) => { if (field !== undefined && field in allFields) @@ -286,8 +286,8 @@ const FieldConditionControl = (props: ConditionProps) => { }, [props]); const [condition, setCondition] = useState(initialValue); - const allFields = useAppSelector((state: Notebook) => state['ui-specification'].fields); - const views = useAppSelector((state: Notebook) => state['ui-specification'].fviews); + const allFields = useAppSelector((state) => state.notebook['ui-specification'].fields); + const views = useAppSelector((state) => state.notebook['ui-specification'].fviews); // work out which fields to show in the select, remove either // the current field or the fields in the current view diff --git a/src/components/design-panel.tsx b/src/components/design-panel.tsx index 856b044..e44132f 100644 --- a/src/components/design-panel.tsx +++ b/src/components/design-panel.tsx @@ -21,7 +21,6 @@ import { useState } from "react"; import { useAppDispatch, useAppSelector } from "../state/hooks"; import { FormEditor } from "./form-editor"; import { shallowEqual } from "react-redux"; -import { Notebook } from "../state/initial"; import { Link, Route, Routes, useLocation, useNavigate } from "react-router-dom"; export const DesignPanel = () => { @@ -29,8 +28,8 @@ export const DesignPanel = () => { const navigate = useNavigate(); const { pathname } = useLocation(); - const viewSets = useAppSelector((state: Notebook) => state['ui-specification'].viewsets, shallowEqual); - const visibleTypes: string[] = useAppSelector((state: Notebook) => state['ui-specification'].visible_types) + const viewSets = useAppSelector((state) => state.notebook['ui-specification'].viewsets, shallowEqual); + const visibleTypes: string[] = useAppSelector((state) => state.notebook['ui-specification'].visible_types) const dispatch = useAppDispatch(); const startTabIndex = pathname.split('/')[2]; diff --git a/src/components/field-editor.tsx b/src/components/field-editor.tsx index c5a8a10..534e863 100644 --- a/src/components/field-editor.tsx +++ b/src/components/field-editor.tsx @@ -36,8 +36,6 @@ import { RelatedRecordEditor } from "./Fields/RelatedRecordEditor"; import { BasicAutoIncrementerEditor } from "./Fields/BasicAutoIncrementer"; import { TemplatedStringFieldEditor } from "./Fields/TemplatedStringFieldEditor"; import { AdvancedSelectEditor } from "./Fields/AdvancedSelectEditor"; - -import { Notebook } from "../state/initial"; import { useAppDispatch, useAppSelector } from "../state/hooks"; import { styled } from '@mui/material/styles'; @@ -69,7 +67,7 @@ type FieldEditorProps = { export const FieldEditor = ({ fieldName, viewId, expanded, handleExpandChange }: FieldEditorProps) => { - const field = useAppSelector((state: Notebook) => state['ui-specification'].fields[fieldName]); + const field = useAppSelector((state) => state.notebook['ui-specification'].fields[fieldName]); const dispatch = useAppDispatch(); const fieldComponent = field['component-name']; diff --git a/src/components/field-list.tsx b/src/components/field-list.tsx index a94238c..1d0bb7e 100644 --- a/src/components/field-list.tsx +++ b/src/components/field-list.tsx @@ -22,7 +22,6 @@ import { FieldEditor } from "./field-editor"; import { useState } from "react"; import { useAppDispatch, useAppSelector } from "../state/hooks"; import { getFieldNames } from "../fields"; -import { Notebook } from "../state/initial"; type Props = { viewSetId: string, @@ -32,7 +31,7 @@ type Props = { export const FieldList = ({ viewSetId, viewId }: Props) => { const fView = useAppSelector( - (state: Notebook) => state['ui-specification'].fviews[viewId]); + (state) => state.notebook['ui-specification'].fviews[viewId]); const dispatch = useAppDispatch(); const [dialogOpen, setDialogOpen] = useState(false); diff --git a/src/components/form-editor.tsx b/src/components/form-editor.tsx index 39c1cfb..a5107b9 100644 --- a/src/components/form-editor.tsx +++ b/src/components/form-editor.tsx @@ -29,7 +29,6 @@ import { useAppDispatch, useAppSelector } from "../state/hooks"; import { SectionEditor } from "./section-editor"; import { useState } from "react"; import { shallowEqual } from "react-redux"; -import { Notebook } from "../state/initial"; type Props = { viewSetId: string, @@ -41,14 +40,14 @@ type Props = { export const FormEditor = ({ viewSetId, moveCallback, moveButtonsDisabled, handleChangeCallback, handleDeleteCallback }: Props) => { - const visibleTypes = useAppSelector((state: Notebook) => state['ui-specification'].visible_types); - const viewsets = useAppSelector((state: Notebook) => state['ui-specification'].viewsets); - const viewSet = useAppSelector((state: Notebook) => state['ui-specification'].viewsets[viewSetId], + const visibleTypes = useAppSelector((state) => state.notebook['ui-specification'].visible_types); + const viewsets = useAppSelector((state) => state.notebook['ui-specification'].viewsets); + const viewSet = useAppSelector((state) => state.notebook['ui-specification'].viewsets[viewSetId], (left, right) => { return shallowEqual(left, right); }); - const views = useAppSelector((state: Notebook) => state['ui-specification'].fviews); - const fields = useAppSelector((state: Notebook) => state['ui-specification'].fields); + const views = useAppSelector((state) => state.notebook['ui-specification'].fviews); + const fields = useAppSelector((state) => state.notebook['ui-specification'].fields); const dispatch = useAppDispatch(); console.log('FormEditor', viewSetId); diff --git a/src/components/info-panel.test.tsx b/src/components/info-panel.test.tsx index 0e2ee30..db3a4f7 100644 --- a/src/components/info-panel.test.tsx +++ b/src/components/info-panel.test.tsx @@ -43,7 +43,7 @@ describe('Info Panel', () => { const name = screen.getByTestId('name').querySelector('input'); if (name) { fireEvent.change(name, { target: { value: 'New Name' } }); - expect(store.getState().metadata.name).toBe('New Name'); + expect(store.getState().notebook.metadata.name).toBe('New Name'); } // check some content screen.getByText('Enable QR Code Search of Records'); @@ -55,7 +55,7 @@ describe('Info Panel', () => { fireEvent.change(metaValue, { target: { value: 'Bobalooba' } }); const createButton = screen.getByText('Create New Field'); createButton.click(); - expect(store.getState().metadata.Bob).toBe('Bobalooba'); + expect(store.getState().notebook.metadata.Bob).toBe('Bobalooba'); }); // after that, the new metadata field should be visible expect(screen.getByTestId('extra-field-Bob')).toBeDefined(); diff --git a/src/components/info-panel.tsx b/src/components/info-panel.tsx index f3c70dd..70cfb72 100644 --- a/src/components/info-panel.tsx +++ b/src/components/info-panel.tsx @@ -15,14 +15,14 @@ import { Alert, Button, Checkbox, FormControlLabel, FormHelperText, Grid, TextField, Typography, Card } from "@mui/material"; import { useEffect, useState, useRef } from "react"; import { useAppSelector, useAppDispatch } from "../state/hooks"; -import { Notebook, PropertyMap } from "../state/initial"; +import { PropertyMap } from "../state/initial"; import { MdxEditor } from "./mdx-editor"; import { MDXEditorMethods } from '@mdxeditor/editor'; export const InfoPanel = () => { - const metadata = useAppSelector((state: Notebook) => state.metadata); + const metadata = useAppSelector((state) => state.notebook.metadata); const dispatch = useAppDispatch(); const ref = useRef(null); diff --git a/src/components/notebook-loader.tsx b/src/components/notebook-loader.tsx index a3adfe0..beb590f 100644 --- a/src/components/notebook-loader.tsx +++ b/src/components/notebook-loader.tsx @@ -22,6 +22,7 @@ import { useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import CloseIcon from '@mui/icons-material/Close'; import { slugify } from '../state/uiSpec-reducer'; +import { ValidationError, migrateNotebook } from '../state/migrateNotebook'; const VisuallyHiddenInput = styled('input')({ clip: 'rect(0 0 0 0)', @@ -43,14 +44,6 @@ const validateNotebook = (jsonText: string): Notebook => { throw new Error('Invalid notebook file: not an object'); } - if (!Object.prototype.hasOwnProperty.call(data, 'metadata')) { - throw new Error('Invalid notebook file: metadata missing'); - } - - if (!Object.prototype.hasOwnProperty.call(data, 'ui-specification')) { - throw new Error('Invalid notebook file: ui-specification missing'); - } - return data as Notebook; } catch (error) { throw new Error('Invalid notebook file: not JSON'); @@ -60,11 +53,8 @@ const validateNotebook = (jsonText: string): Notebook => { export const NotebookLoader = () => { const dispatch = useAppDispatch(); - const notebookModified = useAppSelector((state: Notebook) => state.modifiedStatus.flag); - const state = useAppSelector((state: Notebook) => state); - const resetModifiedStatus = (newStatus: boolean) => { - dispatch({type: "modifiedStatus/resetFlag", payload: {newStatus}}); - } + const notebookModified = useAppSelector((state) => state.modified); + const notebook = useAppSelector((state) => state.notebook); const navigate = useNavigate(); @@ -73,6 +63,7 @@ export const NotebookLoader = () => { const [alertMsgContext, setAlertMsgContext ] = useState(' '); const [alertBtnLabel, setAlertBtnLabel ] = useState(' '); const [isUpload, setIsUpload ] = useState(false); + const [errors, setErrors] = useState([]); const handleContinue = () => { setOpen(false); @@ -107,9 +98,23 @@ export const NotebookLoader = () => { } const loadFn = useCallback((notebook: Notebook) => { - dispatch({ type: 'metadata/loaded', payload: notebook.metadata }) - dispatch({ type: 'ui-specification/loaded', payload: notebook['ui-specification'] }) - resetModifiedStatus(false) + try { + const updatedNotebook = migrateNotebook(notebook); + dispatch({ type: 'metadata/loaded', payload: updatedNotebook.metadata }) + dispatch({ type: 'ui-specification/loaded', payload: updatedNotebook['ui-specification'] }) + dispatch({ type: "modifiedStatus/resetFlag", payload: false}); + + return true; + } catch (e) { + if (e instanceof ValidationError) { + console.log('Error >>', e.messages); + setErrors(e.messages); + } else { + console.log("SOME OTHER ERROR", e); + setErrors(['unknown error']); + } + return false; + } }, [dispatch]); const afterLoad = () => { @@ -117,35 +122,35 @@ export const NotebookLoader = () => { } const newNotebook = () => { - loadFn(initialState); + loadFn(initialState.notebook); afterLoad(); }; const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.item(0); if (file) { + setErrors([]); file.text() .then(text => { const data = validateNotebook(text); - loadFn(data); - afterLoad(); + if (loadFn(data)) afterLoad(); }) - .catch((error) => { - console.error(error); + .catch(({message}) => { + setErrors([message]); }); } }; const downloadNotebook = () => { const element = document.createElement("a"); - const file = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }); + const file = new Blob([JSON.stringify(notebook, null, 2)], { type: 'application/json' }); element.href = URL.createObjectURL(file); - const name = slugify(state.metadata.name as string); + const name = slugify(notebook.metadata.name as string); element.download = `${name}.json`; document.body.appendChild(element); element.click(); setOpen(false); - resetModifiedStatus(false); + dispatch({ type: "modifiedStatus/resetFlag", payload: false}); }; return ( @@ -159,13 +164,24 @@ export const NotebookLoader = () => { > Upload file {!notebookModified ? ( - - ) : (null)} + { const element = e.target as HTMLInputElement; element.value = ''; }}/>) + : (null)} - - - Upload a notebook file to start editing. - + {notebookModified ? (

Modified

): (

Not Modified

)} + + {errors.length ? + (
+

Errors in notebook format:

+
    {errors.map(e => (
  • {e}
  • ))}
+
+ ) : ( + + Upload a notebook file to start editing. + ) + } diff --git a/src/components/review-panel.tsx b/src/components/review-panel.tsx index ddef3b4..0a9f273 100644 --- a/src/components/review-panel.tsx +++ b/src/components/review-panel.tsx @@ -1,17 +1,16 @@ import { Button } from "@mui/material"; import { useAppSelector } from "../state/hooks"; -import { Notebook } from '../state/initial'; import {slugify} from '../state/uiSpec-reducer'; export const ReviewPanel = () => { - const state = useAppSelector((state: Notebook) => state); + const state = useAppSelector((state) => state); const downloadNotebook = () => { const element = document.createElement("a"); const file = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' }); element.href = URL.createObjectURL(file); - const name = slugify(state.metadata.name as string); + const name = slugify(state.notebook.metadata.name as string); element.download = `${name}.json`; document.body.appendChild(element); element.click(); diff --git a/src/components/roles-panel.tsx b/src/components/roles-panel.tsx index fc0cf7c..4c15b91 100644 --- a/src/components/roles-panel.tsx +++ b/src/components/roles-panel.tsx @@ -19,7 +19,6 @@ import { useAppSelector, useAppDispatch } from "../state/hooks"; import DeleteIcon from '@mui/icons-material/Delete'; import AddCircleIcon from '@mui/icons-material/AddCircle'; -import { Notebook } from '../state/initial'; /** * RolesPanel - edit the user roles associated with this notebook @@ -27,7 +26,7 @@ import { Notebook } from '../state/initial'; */ export const RolesPanel = () => { - const roles = useAppSelector((state: Notebook) => state.metadata.accesses) as string[]; + const roles = useAppSelector((state) => state.notebook.metadata.accesses) as string[]; const dispatch = useAppDispatch(); const [newRole, setNewRole] = useState(''); diff --git a/src/components/section-editor.tsx b/src/components/section-editor.tsx index 75ac9b2..c242671 100644 --- a/src/components/section-editor.tsx +++ b/src/components/section-editor.tsx @@ -24,7 +24,6 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { FieldList } from "./field-list"; import { useAppSelector, useAppDispatch } from "../state/hooks"; -import { Notebook } from '../state/initial'; import { useState } from "react"; import {ConditionModal, ConditionTranslation, ConditionType } from "./condition"; @@ -42,7 +41,7 @@ type Props = { export const SectionEditor = ({ viewSetId, viewId, viewSet, deleteCallback, addCallback, moveCallback }: Props) => { - const fView = useAppSelector((state: Notebook) => state['ui-specification'].fviews[viewId]); + const fView = useAppSelector((state) => state.notebook['ui-specification'].fviews[viewId]); const dispatch = useAppDispatch(); console.log('SectionEditor', viewId, viewSet); diff --git a/src/fields.tsx b/src/fields.tsx index 80bd252..f1b7161 100644 --- a/src/fields.tsx +++ b/src/fields.tsx @@ -20,18 +20,14 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TextField', 'type-returned': 'faims-core::String', 'component-parameters': { + label: 'Text Field', fullWidth: true, - helperText: 'Helper Text', + helperText: 'Enter text', variant: 'outlined', required: false, InputProps: { type: 'text', }, - SelectProps: {}, - InputLabelProps: { - label: 'Text Field', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.string']], initialValue: '', @@ -41,18 +37,14 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TextField', 'type-returned': 'faims-core::Email', 'component-parameters': { + label: 'Email', fullWidth: true, - helperText: 'We can also store Email addresses.', + helperText: 'Enter a valid email address', variant: 'outlined', - required: false, + required: false, InputProps: { type: 'email', }, - SelectProps: {}, - InputLabelProps: { - label: 'Email', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.string'], ['yup.email', 'Enter a valid email']], initialValue: '', @@ -62,6 +54,7 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TextField', 'type-returned': 'faims-core::Integer', 'component-parameters': { + label: 'Number field', fullWidth: true, helperText: 'We have fields for storing Numbers.', variant: 'outlined', @@ -69,11 +62,6 @@ const fields: {[key: string]: FieldType } = { InputProps: { type: 'number', }, - SelectProps: {}, - InputLabelProps: { - label: 'Number field', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.number']], initialValue: '', @@ -83,20 +71,16 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TextField', 'type-returned': 'faims-core::Integer', 'component-parameters': { + label: 'Controlled number', fullWidth: true, helperText: 'This number must be at least 10 and not more than 20.', variant: 'outlined', - required: true, + required: false, InputProps: { type: 'number', }, - SelectProps: {}, - InputLabelProps: { - label: 'Controlled number', - }, - FormHelperTextProps: {}, }, - validationSchema: [['yup.number'], ['yup.min', 10, 'Must be 10 or more'], ['yup.max', 20, 'Must be 20 or less'], ['yup.required', 'You must fill this in!']], + validationSchema: [['yup.number'], ['yup.min', 10, 'Must be 10 or more'], ['yup.max', 20, 'Must be 20 or less'],], initialValue: '', }, 'BasicAutoIncrementer': { @@ -113,13 +97,14 @@ const fields: {[key: string]: FieldType } = { label: 'Auto Incrementing Field', }, validationSchema: [['yup.string'], ['yup.required']], - initialValue: null, + initialValue: '', }, 'MultipleTextField': { 'component-namespace': 'formik-material-ui', 'component-name': 'MultipleTextField', 'type-returned': 'faims-core::String', 'component-parameters': { + label: 'Text Field', fullWidth: true, helperText: 'Helper Text', variant: 'outlined', @@ -129,11 +114,6 @@ const fields: {[key: string]: FieldType } = { type: 'text', rows: 4, }, - SelectProps: {}, - InputLabelProps: { - label: 'Text Field', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.string']], initialValue: '', @@ -143,17 +123,12 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'Checkbox', 'type-returned': 'faims-core::Bool', 'component-parameters': { + label: 'Checkbox', name: 'checkbox-field', id: 'checkbox-field', required: false, type: 'checkbox', - FormControlLabelProps: { - label: 'Terms and Conditions', - }, - FormHelperTextProps: { - children: 'Read the terms and conditions carefully.', - }, - // Label: {label: 'Terms and Conditions'}, + helperText: 'Checkbox help.', }, validationSchema: [ ['yup.bool'], @@ -165,14 +140,12 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'DateTimeNow', 'type-returned': 'faims-core::String', 'component-parameters': { + label: 'Date and Time with Now button', fullWidth: true, helperText: 'Add a datetime stamp (click now to record the current date+time)', variant: 'outlined', required: false, - InputLabelProps: { - label: 'DateTimeNow Field', - }, is_auto_pick: false, }, validationSchema: [['yup.string']], @@ -183,18 +156,14 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TextField', 'type-returned': 'faims-core::Date', 'component-parameters': { + label: 'Date picker', fullWidth: true, - helperText: 'We have a date picker with a calendar prompt.', + helperText: 'Select a date', variant: 'outlined', required: false, InputProps: { type: 'date', }, - SelectProps: {}, - InputLabelProps: { - label: 'Date picker', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.string']], initialValue: '', @@ -204,6 +173,7 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TextField', 'type-returned': 'faims-core::Datetime', 'component-parameters': { + label: 'Date and Time', fullWidth: true, helperText: 'And a calendar prompt with a timestamp.', variant: 'outlined', @@ -211,11 +181,6 @@ const fields: {[key: string]: FieldType } = { InputProps: { type: 'datetime-local', }, - SelectProps: {}, - InputLabelProps: { - label: 'Date and Time picker', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.string']], initialValue: '', @@ -225,18 +190,14 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TextField', 'type-returned': 'faims-core::Date', 'component-parameters': { + label: 'Month picker', fullWidth: true, - helperText: 'And one to select just the month if that is all you need.', + helperText: 'Select a month', variant: 'outlined', required: false, InputProps: { type: 'month', }, - SelectProps: {}, - InputLabelProps: { - label: 'Month picker', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.string']], initialValue: '', @@ -246,6 +207,7 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'FileUploader', 'type-returned': 'faims-attachment::Files', 'component-parameters': { + label: "Upload a File", name: 'file-upload-field', id: 'file-upload-field', helperText: 'Choose a file', @@ -264,23 +226,23 @@ const fields: {[key: string]: FieldType } = { required: false, featureType: 'Point', zoom: 12, - label: '', + label: 'Select a Point', geoTiff: '', }, validationSchema: [['yup.string']], initialValue: '1', }, 'MultiSelect': { - 'component-namespace': 'faims-custom', // this says what web component to use to render/acquire value from + 'component-namespace': 'faims-custom', 'component-name': 'MultiSelect', - 'type-returned': 'faims-core::Array', // matches a type in the Project Model + 'type-returned': 'faims-core::Array', 'component-parameters': { + label: 'Select Multiple', fullWidth: true, helperText: 'Choose items from the dropdown', variant: 'outlined', required: false, select: true, - InputProps: {}, SelectProps: { multiple: true, }, @@ -296,9 +258,6 @@ const fields: {[key: string]: FieldType } = { }, ], }, - InputLabelProps: { - label: 'Select Multiple', - }, }, validationSchema: [['yup.array']], initialValue: [], @@ -308,6 +267,7 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'RadioGroup', 'type-returned': 'faims-core::String', 'component-parameters': { + label: 'Select one option', name: 'radio-group-field', id: 'radio-group-field', variant: 'outlined', @@ -323,12 +283,7 @@ const fields: {[key: string]: FieldType } = { }, ], }, - FormLabelProps: { - children: 'Pick a number', - }, - FormHelperTextProps: { - children: 'Make sure you choose the right one!', - }, + helperText: 'Make sure you choose the right one!', }, validationSchema: [['yup.string']], initialValue: '1', @@ -362,21 +317,13 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'RelatedRecordSelector', 'type-returned': 'faims-core::Relationship', 'component-parameters': { + label: 'Select Related', fullWidth: true, helperText: 'Select or add new related record', - variant: 'outlined', required: true, related_type: '', relation_type: 'faims-core::Child', - InputProps: { - type: 'text', // must be a valid html type - }, multiple: false, - SelectProps: {}, - InputLabelProps: { - label: 'Select Related', - }, - FormHelperTextProps: {}, }, validationSchema: [['yup.string']], initialValue: '', @@ -386,20 +333,13 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'Select', 'type-returned': 'faims-core::String', 'component-parameters': { + label: 'Select Field', fullWidth: true, - helperText: 'Choose a field from the dropdown', - variant: 'outlined', + helperText: 'Choose a value from the dropdown', required: false, - select: true, - InputProps: {}, - SelectProps: {}, ElementProps: { options: [], }, - // select_others:'otherswith', - InputLabelProps: { - label: 'Select Field', - }, }, validationSchema: [['yup.string']], initialValue: '', @@ -409,21 +349,16 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'AdvancedSelect', 'type-returned': 'faims-core::String', 'component-parameters': { + label: 'Select Field', fullWidth: true, helperText: 'Select from list', - variant: 'outlined', required: false, - select: true, - InputProps: {}, - SelectProps: {}, ElementProps: { optiontree: [{ name: 'Default', children: [], }], }, - // select_others:'otherswith', - label: 'Select Field', valuetype: 'full', }, validationSchema: [['yup.string']], @@ -434,14 +369,15 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TakePhoto', 'type-returned': 'faims-attachment::Files', 'component-parameters': { - fullWidth: true, name: 'take-photo-field', - id: 'take-photo-field', helperText: 'Take a photo', - variant: 'outlined', label: 'Take Photo', }, - validationSchema: [['yup.object'], ['yup.nullable']], + validationSchema: [ + ["yup.array"], + ["yup.of", [["yup.object"], ["yup.nullable"]]], + ["yup.nullable"] + ], initialValue: null, }, 'TakePoint': { @@ -451,7 +387,6 @@ const fields: {[key: string]: FieldType } = { 'component-parameters': { fullWidth: true, name: 'take-point-field', - id: 'take-point-field', helperText: 'Click to save current location', variant: 'outlined', label: 'Take point', @@ -464,19 +399,15 @@ const fields: {[key: string]: FieldType } = { 'component-name': 'TemplatedStringField', 'type-returned': 'faims-core::String', 'component-parameters': { + label: 'Human Readable ID', fullWidth: true, name: 'hrid-field', - id: 'hrid-field', helperText: 'Human Readable ID', - variant: 'outlined', required: true, template: ' {{}}', InputProps: { type: 'text', // must be a valid html type }, - InputLabelProps: { - label: 'Human Readable ID', - }, hrid: true, }, validationSchema: [['yup.string'], ['yup.required']], @@ -488,13 +419,9 @@ const fields: {[key: string]: FieldType } = { 'type-returned': 'faims-core::String', 'component-parameters': { name: 'qr-code-field', - id: 'qr-code-field', - variant: 'outlined', required: false, label: 'Scan QR Code', - FormLabelProps: { - children: 'Input a value here', - }, + helperText: 'Scan QR Code on the sample' }, validationSchema: [['yup.string']], initialValue: '1', diff --git a/src/notebook-schema.ts b/src/notebook-schema.ts new file mode 100644 index 0000000..eca5489 --- /dev/null +++ b/src/notebook-schema.ts @@ -0,0 +1,300 @@ +export const schema = { + $schema: "http://json-schema.org/draft-07/schema#", + $ref: "#/definitions/Notebook", + definitions: { + Notebook: { + type: "object", + properties: { + metadata: { + $ref: "#/definitions/NotebookMetadata", + }, + "ui-specification": { + $ref: "#/definitions/NotebookUISpec", + }, + }, + required: ["metadata", "ui-specification"], + additionalProperties: false, + }, + NotebookMetadata: { + type: "object", + properties: { + name: { type: "string" }, + project_id: { type: "string" }, + notebook_version: { type: "string" }, + schema_version: { type: "string" }, + }, + required: ["name", "notebook_version", "schema_version"], + additionalProperties: {}, + }, + NotebookUISpec: { + type: "object", + properties: { + fields: { + type: "object", + additionalProperties: { + $ref: "#/definitions/FieldType", + }, + }, + fviews: { + type: "object", + additionalProperties: { + type: "object", + properties: { + fields: { + type: "array", + items: {type: "string",}, + }, + uidesign: {type: "string",}, + label: {type: "string",}, + condition: { + $ref: "#/definitions/ConditionType", + }, + }, + required: ["fields", "label"], + }, + }, + viewsets: { + type: "object", + additionalProperties: { + type: "object", + properties: { + views: { + type: "array", + items: {type: "string",}, + }, + label: {type: "string",}, + }, + required: ["views", "label"], + }, + }, + visible_types: { + type: "array", + items: {type: "string",}, + }, + }, + required: ["fields", "fviews", "viewsets", "visible_types"], + additionalProperties: false, + }, + FieldType: { + type: "object", + properties: { + "component-namespace": {type: "string",}, + "component-name": {type: "string",}, + "type-returned": {type: "string",}, + "component-parameters": { + $ref: "#/definitions/ComponentParameters", + }, + validationSchema: { + type: "array", + items: { + $ref: "#/definitions/ValidationSchemaElement", + }, + }, + initialValue: {}, + access: { + type: "array", + items: {type: "string",}, + }, + condition: { + anyOf: [ + { + $ref: "#/definitions/ConditionType", + }, + { + type: "null", + }, + ], + }, + persistent: {type: "boolean",}, + displayParent: {type: "boolean",}, + meta: { + type: "object", + properties: { + annotation: { + anyOf: [ + { + type: "boolean", + }, + { + type: "object", + properties: { + include: { + type: "boolean", + }, + label: {type: "string",}, + }, + required: ["include", "label"], + additionalProperties: false, + }, + ], + }, + annotation_label: {type: "string",}, + uncertainty: { + type: "object", + properties: { + include: {type: "boolean",}, + label: {type: "string",}, + }, + required: ["include", "label"], + additionalProperties: false, + }, + }, + required: ["annotation", "uncertainty"], + additionalProperties: false, + }, + }, + required: [ + "component-namespace", + "component-name", + "type-returned", + "component-parameters", + ], + additionalProperties: false, + }, + ComponentParameters: { + type: "object", + properties: { + fullWidth: {type: "boolean",}, + name: {type: "string",}, + id: {type: "string",}, + helperText: {type: "string",}, + helpertext: {type: "string",}, + variant: {type: "string",}, + label: {type: "string",}, + multiline: {type: "boolean",}, + multiple: {type: "boolean",}, + SelectProps: {}, + ElementProps: { + type: "object", + properties: { + options: { + type: "array", + items: { + type: "object", + properties: { + value: {type: "string",}, + label: {type: "string",}, + RadioProps: {}, + }, + required: ["value", "label"], + additionalProperties: false, + }, + }, + optiontree: {}, + }, + additionalProperties: true, + }, + InputLabelProps: { + type: "object", + properties: { + label: {type: "string",}, + }, + required: ["label"], + additionalProperties: true, + }, + InputProps: { + type: "object", + properties: { + rows: { + type: "number", + }, + type: { + type: "string", + }, + }, + }, + FormLabelProps: { + type: "object", + properties: { + children: { + type: "string", + }, + }, + additionalProperties: false, + }, + FormHelperTextProps: { + type: "object", + properties: { + children: {type: "string", + }, + }, + additionalProperties: false, + }, + FormControlLabelProps: { + type: "object", + properties: { + label: {type: "string",}, + }, + required: ["label"], + additionalProperties: false, + }, + initialValue: {}, + related_type: {type: "string",}, + relation_type: {type: "string",}, + related_type_label: {type: "string",}, + relation_linked_vocabPair: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + minItems: 2, + maxItems: 2, + }, + }, + required: {type: "boolean",}, + template: {type: "string",}, + num_digits: {type: "number",}, + form_id: {type: "string",}, + is_auto_pick: {type: "boolean",}, + zoom: {"anyOf": [ + {type: "number",}, + {type: "string"}, + ]}, + featureType: {type: "string",}, + variant_style: {type: "string",}, + html_tag: {type: "string",}, + content: {type: "string",}, + hrid: {type: "boolean",}, + select: {type: "boolean",}, + geoTiff: {type: "string",}, + type: {type: "string",}, + valuetype: {type: "string",}, + }, + additionalProperties: true, + }, + ValidationSchemaElement: { + type: "array", + items: { + anyOf: [ + { + type: "string", + }, + { + type: "number", + }, + { + $ref: "#/definitions/ValidationSchemaElement", + }, + ], + }, + }, + ConditionType: { + type: "object", + properties: { + operator: {type: "string",}, + field: {type: "string",}, + value: {}, + conditions: { + type: "array", + items: { + $ref: "#/definitions/ConditionType", + }, + }, + }, + required: ["operator"], + additionalProperties: false, + }, + }, +}; diff --git a/src/state/hooks.ts b/src/state/hooks.ts index 4b399dc..7ddad76 100644 --- a/src/state/hooks.ts +++ b/src/state/hooks.ts @@ -14,8 +14,8 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux' import type { AppDispatch } from './store'; -import type { Notebook } from './initial'; +import { AppState } from './initial'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch: () => AppDispatch = useDispatch -export const useAppSelector: TypedUseSelectorHook = useSelector +export const useAppSelector: TypedUseSelectorHook = useSelector diff --git a/src/state/initial.ts b/src/state/initial.ts index 0bdc515..51f749b 100644 --- a/src/state/initial.ts +++ b/src/state/initial.ts @@ -26,6 +26,7 @@ export type ComponentParameters = { name?: string, id?: string, helperText?: string, + helpertext?: string, // was allowed for TakePhoto variant?: string, label?: string, multiline?: boolean, @@ -66,7 +67,7 @@ export type ComponentParameters = { valuetype?: string, }; -export type ValidationSchemaElement = (string | number)[]; +export type ValidationSchemaElement = (string | number | unknown[])[]; export type FieldType = { "component-namespace": string, @@ -80,19 +81,22 @@ export type FieldType = { "persistent"?: boolean, "displayParent"?: boolean, "meta"?: { - "annotation_label": string, - "annotation": boolean, + "annotation": { + "include": boolean, + "label": string, + }, "uncertainty": { "include" : boolean, "label": string, } } -}; +}; export type NotebookUISpec = { fields: {[key: string]: FieldType}, fviews: {[key: string]: { "fields": string[], + "description"?: string, "uidesign"?: string, "label": string, "condition"?: ConditionType, @@ -108,36 +112,41 @@ export type NotebookModified = { flag: boolean, } +export type AppState = { + modified: boolean, + notebook: Notebook, +} + export type Notebook = { metadata: NotebookMetadata, - "ui-specification": NotebookUISpec, - modifiedStatus: NotebookModified, + "ui-specification": NotebookUISpec } // an empty notebook -export const initialState: Notebook = { - "metadata": { - "notebook_version": "1.0", - "schema_version": "1.0", - "name": "", - "accesses": ["admin", "moderator", "team"], - "filenames": [], - "ispublic": false, - "isrequest": false, - "lead_institution": "", - "showQRCodeButton": false, - "pre_description": "", - "project_lead": "", - "project_status": "New", - "sections": {} - }, - "ui-specification": { - "fields": {}, - "fviews": {}, - "viewsets": {}, - "visible_types": [] - }, - "modifiedStatus": { - "flag": false +export const initialState: AppState = +{ + modified: false, + notebook: { + "metadata": { + "notebook_version": "1.0", + "schema_version": "1.0", + "name": "", + "accesses": ["admin", "moderator", "team"], + "filenames": [], + "ispublic": false, + "isrequest": false, + "lead_institution": "", + "showQRCodeButton": false, + "pre_description": "", + "project_lead": "", + "project_status": "New", + "sections": {} + }, + "ui-specification": { + "fields": {}, + "fviews": {}, + "viewsets": {}, + "visible_types": [] + }, } } \ No newline at end of file diff --git a/src/state/localStorage.ts b/src/state/localStorage.ts index f11bfd0..17887a9 100644 --- a/src/state/localStorage.ts +++ b/src/state/localStorage.ts @@ -12,21 +12,21 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Notebook } from './initial'; +import { AppState } from './initial'; // The following functions are inspired by Dan Abramov's lesson on persisting redux state to localStorage, // see https://egghead.io/lessons/javascript-redux-persisting-the-state-to-the-local-storage. export const loadState = () => { try { const serializedState = localStorage.getItem('notebook'); - return serializedState ? JSON.parse(serializedState) : undefined; - } + return serializedState ? JSON.parse(serializedState) as AppState : undefined; + } catch (error: unknown) { return undefined; } }; -export const saveState = (state: Notebook) => { +export const saveState = (state: AppState) => { try { const serializedState = JSON.stringify(state); localStorage.setItem('notebook', serializedState); diff --git a/src/state/metadata-reducer.ts b/src/state/metadata-reducer.ts index 585c751..4a326dd 100644 --- a/src/state/metadata-reducer.ts +++ b/src/state/metadata-reducer.ts @@ -20,12 +20,12 @@ const protectedFields = ['meta', 'project_status', 'access', 'accesses', const metadataReducer = createSlice({ name: 'metadata', - initialState: initialState.metadata, + initialState: initialState.notebook.metadata, reducers: { - loaded: (_state: NotebookMetadata, action: PayloadAction) => { + loaded: (_state, action: PayloadAction) => { return action.payload; }, - propertyUpdated: (state: NotebookMetadata, action: PayloadAction<{property: string, value: string}>) => { + propertyUpdated: (state, action: PayloadAction<{property: string, value: string}>) => { const { property, value } = action.payload; if (protectedFields.includes(property)) { throw new Error(`Cannot update protected metadata field ${property} via propertyUpdated action`); @@ -33,7 +33,7 @@ const metadataReducer = createSlice({ state[property] = value; } }, - rolesUpdated: (state: NotebookMetadata, action: PayloadAction<{roles: string[]}>) => { + rolesUpdated: (state, action: PayloadAction<{roles: string[]}>) => { const { roles } = action.payload; state.accesses = roles; }, diff --git a/src/state/migrateNotebook.test.ts b/src/state/migrateNotebook.test.ts new file mode 100644 index 0000000..a06bb13 --- /dev/null +++ b/src/state/migrateNotebook.test.ts @@ -0,0 +1,117 @@ +// Copyright 2023 FAIMS Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +import {describe, expect, test } from 'vitest'; +import { migrateNotebook, validateNotebook } from './migrateNotebook'; +import { sampleNotebook } from '../test-notebook'; + +describe('Migrate Notebook Tests', () => { + + test('validate notebook', () => { + + const valid = validateNotebook(sampleNotebook); + expect(valid).toBeTruthy(); + + const invalidNotebook = { + metadata: {}, + foo: {}, + } + expect(() => validateNotebook(invalidNotebook)) + .toThrowError(""); // message is empty but errors in .messages + }); + + test('update labels', () => { + + const migrated = migrateNotebook(sampleNotebook); + const fields = migrated['ui-specification'].fields; + expect(fields['Type']['component-parameters'].label).toBe('Type'); + expect(fields['Type']['component-parameters'].InputLabelProps).toBe(undefined); + + expect(fields['Length-mm']['component-parameters'].label).toBe('Length (mm)'); + expect(fields['Length-mm']['component-parameters'].InputLabelProps).toBe(undefined); + + expect(fields['safety_hazard']['component-parameters'].label).toBe('Safety Hazard'); + expect(fields['safety_hazard']['component-parameters'].FormControlLabelProps).toBe(undefined); + + expect(fields['IGSN-QR-Code']['component-parameters'].label).toBe('IGSN QR Code'); + expect(fields['IGSN-QR-Code']['component-parameters'].FormLabelProps).toBe(undefined); + + }) + + test('update annotation format', () => { + + const migrated = migrateNotebook(sampleNotebook); + const fields = migrated['ui-specification'].fields; + expect(fields['Type']?.meta?.annotation).toHaveProperty('label'); + expect(fields['Type']?.meta?.annotation).toHaveProperty('include'); + expect(fields['Type']?.meta).not.toHaveProperty('annotation_label'); + + }); + + test('update helperText', () => { + + const migrated = migrateNotebook(sampleNotebook); + const fields = migrated['ui-specification'].fields; + + expect(fields['Sample-Photograph']['component-parameters'].helperText).toBe('Take a photo'); + expect(fields['Sample-Photograph']['component-parameters'].helpertext).toBe(undefined); + + expect(fields['IGSN-QR-Code']['component-parameters'].helperText).toBe('Scan the pre-printed QR Code for this sample.'); + + }); + + + test('fix photo validation', () => { + + const migrated = migrateNotebook(sampleNotebook); + const fields = migrated['ui-specification'].fields; + const validationSchema = fields['Sample-Photograph'].validationSchema; + + + if (validationSchema) { + expect(validationSchema).toHaveLength(3); + expect(validationSchema[0]).toContain("yup.array"); + expect(validationSchema[2]).toContain("yup.nullable"); + + } + }); + + + + test('update form descriptions', () => { + + const migrated = migrateNotebook(sampleNotebook); + const fviews = migrated['ui-specification'].fviews; + + expect(fviews['Primary-New-Section'].description).toBe('This description.'); + expect(fviews['Primary-Next-Section'].description).toBe('That description.'); + + expect(migrated.metadata.sections).toBeUndefined(); + + }); + + + + + + test('not losing properties', () => { + + const migrated = migrateNotebook(sampleNotebook); + const fields = migrated['ui-specification'].fields; + Object.getOwnPropertyNames(fields).forEach((fieldName: string) => { + expect(Object.getOwnPropertyNames(fields)).toContain(fieldName); + }); + }); +}) \ No newline at end of file diff --git a/src/state/migrateNotebook.ts b/src/state/migrateNotebook.ts new file mode 100644 index 0000000..3cda34a --- /dev/null +++ b/src/state/migrateNotebook.ts @@ -0,0 +1,253 @@ +// Copyright 2023 FAIMS Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {FieldType, Notebook} from "./initial"; +import _ from "lodash"; +import {Ajv} from "ajv"; +import {schema} from "../notebook-schema"; + +/** + * Migrate a notebook to the most recent notebook format + * @param notebook - a notebook object that may be out of date or not even a notebook + * @returns an updated version of the same notebook + * @throws an Error if the notebook is not valid + */ +export const migrateNotebook = (notebook: unknown) => { + + // we should maybe in future have validation against alternate notebook schema versions... + validateNotebook(notebook); + // error will be thrown by validateNotebook if invalid, let it go through + + const notebookCopy = JSON.parse(JSON.stringify(notebook)) as Notebook; // deep copy + + // move field labels from old locations to .label + updateFieldLabels(notebookCopy); + + // update format of annotation settings + updateAnnotationFormat(notebookCopy); + + // change `helpertext` in `TakePhoto` to `helperText` + updateHelperText(notebookCopy); + + // move any form descriptions from metadata into the fview + updateFormSectionMeta(notebookCopy); + + // fix validation for photo fields which had a bad default + fixPhotoValidation(notebookCopy); + + return notebookCopy; + +}; + +export class ValidationError extends Error { + messages: string[]; + + constructor(messages: string[]) { + super(); + this.messages = messages; + } +} + +/** + * + * @param pNB - an object that might be a notebook + * @returns a validated Notebook object + * @throws ValidationError if there is one, `messages` property contains error messages for presentation + */ +export const validateNotebook = (n: unknown) => { + + const ajv = new Ajv(); + const validate = ajv.compile(schema); + + const valid = validate(n); + console.log('valid', valid); + if (!valid) { + if (validate.errors) { + console.log("Validation Errors:", validate.errors); + const errorTexts = validate.errors.map(e => `${e.instancePath} ${e.message}`); + throw new ValidationError(errorTexts); + } + } + return valid; +} + + +/** + * Update notebook fields so that labels are directly on component-parameters + * + * @param notebook A notebook that might be out of date, modified + */ +const updateFieldLabels = (notebook: Notebook) => { + + const fields : {[key: string]: FieldType} = {}; + + for(const fieldName in notebook['ui-specification'].fields) { + const field = notebook['ui-specification'].fields[fieldName]; + + // clean up all the different ways that label could be stored + const params = field['component-parameters']; + if (params?.label) fields[fieldName] = {...field}; + else if (params?.InputLabelProps?.label) { + params.label = params.InputLabelProps.label; + delete params.InputLabelProps; + } else if (params?.FormControlLabelProps?.label) { + params.label = params.FormControlLabelProps.label; + delete params.FormControlLabelProps; + } else if (params?.FormLabelProps?.children) { + params.label = params.FormLabelProps.children; + delete params.FormLabelProps; + } else if (params?.name) { + params.label = params.name; + } + fields[fieldName] = { + ...field, + "component-parameters": params, + }; + } + notebook['ui-specification'].fields = fields; +} + +type LabelInclude = { + label: string; + include: boolean; +} + +type OldMetaType = { + annotation_label?: string; + annotation?: boolean | LabelInclude; + uncertainty: { + include: boolean; + label: string; + } +} + +/** + * Update a notebook to use the newer annotation field specification + * + * @param notebook A notebook that might be out of date, modified + */ +const updateAnnotationFormat = (notebook: Notebook) => { + + const fields : {[key: string]: FieldType} = {}; + + for(const fieldName in notebook['ui-specification'].fields) { + const field = notebook['ui-specification'].fields[fieldName]; + const meta = field.meta as OldMetaType; + if (typeof(meta?.annotation) === 'boolean') { + field.meta = { + annotation: { + include: meta.annotation, + label: meta.annotation_label || 'Annotation' + }, + uncertainty: { + include: meta.uncertainty?.include || false, + label: meta.uncertainty?.label || "uncertainty", + }, + } + } + fields[fieldName] = field; + } + + notebook['ui-specification'].fields = fields; +} + +/** + * Update a notebook to use consistent helperText properties + * + * @param notebook A notebook that might be out of date, modified + */ +const updateHelperText = (notebook: Notebook) => { + + const fields : {[key: string]: FieldType} = {}; + + for(const fieldName in notebook['ui-specification'].fields) { + const field = notebook['ui-specification'].fields[fieldName]; + + const params = field['component-parameters']; + const originalValue = params?.helperText; + // TakePhoto used to use this] + if (params?.helpertext) { + params.helperText = originalValue || params.helpertext; + delete params.helpertext; + } else if (params?.FormHelperTextProps) { + params.helperText = originalValue || params.FormHelperTextProps.children; + delete params.FormHelperTextProps; + } + + fields[fieldName] = field; + } + + notebook['ui-specification'].fields = fields; +} + + + +/** + * A couple of types to make the migration code easier below + * since we're removing this stuff it doesn't need to be seen outside here + */ +type StringMap = { + [key: string]: string; +}; +type SectionType = { + [key: string]: StringMap +}; + +/** + * Update a notebook to put form labels in the form section + * + * @param notebook A notebook that might be out of date, modified + */ +const updateFormSectionMeta = (notebook: Notebook) => { + + const sections = notebook.metadata?.sections as SectionType; + const fviews = notebook['ui-specification'].fviews; + const prefix = 'sectiondescription'; + + if (sections) { + for(const sectionId in sections) { + + const description = sections[sectionId][prefix+sectionId] || ""; + + if (fviews[sectionId]) { + fviews[sectionId].description = description; + } + } + delete notebook.metadata.sections; + } +} + +const fixPhotoValidation = (notebook: Notebook) => { + const goodValidation = [ + ["yup.array"], + ["yup.of", [["yup.object"], ["yup.nullable"]]], + ["yup.nullable"] + ]; + + const fields : {[key: string]: FieldType} = {}; + + for(const fieldName in notebook['ui-specification'].fields) { + const field = notebook['ui-specification'].fields[fieldName]; + + if (field['component-name'] === 'TakePhoto') { + if (field.validationSchema?.length === 2) + field.validationSchema = goodValidation; + } + + fields[fieldName] = field; + } + + notebook['ui-specification'].fields = fields; + +} \ No newline at end of file diff --git a/src/state/modifiedStatus-reducer.ts b/src/state/modifiedStatus-reducer.ts index 1739aea..5e89508 100644 --- a/src/state/modifiedStatus-reducer.ts +++ b/src/state/modifiedStatus-reducer.ts @@ -12,74 +12,74 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { initialState, NotebookModified } from "./initial"; +import {createSlice} from "@reduxjs/toolkit"; +import {initialState} from "./initial"; import {propertyUpdated, rolesUpdated} from "./metadata-reducer"; import {fieldAdded, fieldDeleted, fieldMoved, fieldRenamed, fieldUpdated, formVisibilityUpdated, sectionAdded, sectionConditionChanged, sectionDeleted, sectionMoved, sectionRenamed, viewSetAdded, viewSetDeleted, viewSetMoved, viewSetRenamed} from "./uiSpec-reducer"; const modifiedStatusReducer = createSlice({ name: 'modifiedStatus', - initialState: initialState.modifiedStatus, + initialState: initialState.modified, reducers: { - resetFlag: (state: NotebookModified, action: PayloadAction<{newStatus: boolean}>) => { - const { newStatus } = action.payload; + resetFlag: (_state, action) => { + const newStatus = action.payload as boolean; console.log("Reached modified reducer " + newStatus); - state.flag = newStatus; + return newStatus; }, }, extraReducers: builder => { //Metadata reducers - builder.addCase(propertyUpdated, (state) => { - state.flag = true; + builder.addCase(propertyUpdated, () => { + return true; }) - .addCase(rolesUpdated, (state) => { - state.flag = true; + .addCase(rolesUpdated, () => { + return true; }) //UISpec reducers - .addCase(fieldUpdated, (state) => { - state.flag = true; + .addCase(fieldUpdated, () => { + return true; }) - .addCase(fieldMoved, (state) => { - state.flag = true; + .addCase(fieldMoved, () => { + return true; }) - .addCase(fieldRenamed, (state) => { - state.flag = true; + .addCase(fieldRenamed, () => { + return true; }) - .addCase(fieldAdded, (state) => { - state.flag = true; + .addCase(fieldAdded, () => { + return true; }) - .addCase(fieldDeleted, (state) => { - state.flag = true; + .addCase(fieldDeleted, () => { + return true; }) - .addCase(sectionRenamed, (state) => { - state.flag = true; + .addCase(sectionRenamed, () => { + return true; }) - .addCase(sectionAdded, (state) => { - state.flag = true; + .addCase(sectionAdded, () => { + return true; }) - .addCase(sectionDeleted, (state) => { - state.flag = true; + .addCase(sectionDeleted, () => { + return true; }) - .addCase(sectionMoved, (state) => { - state.flag = true; + .addCase(sectionMoved, () => { + return true; }) - .addCase(sectionConditionChanged, (state) => { - state.flag = true; + .addCase(sectionConditionChanged, () => { + return true; }) - .addCase(viewSetAdded, (state) => { - state.flag = true; + .addCase(viewSetAdded, () => { + return true; }) - .addCase(viewSetDeleted, (state) => { - state.flag = true; + .addCase(viewSetDeleted, () => { + return true; }) - .addCase(viewSetMoved, (state) => { - state.flag = true; + .addCase(viewSetMoved, () => { + return true; }) - .addCase(viewSetRenamed, (state) => { - state.flag = true; + .addCase(viewSetRenamed, () => { + return true; }) - .addCase(formVisibilityUpdated, (state) => { - state.flag = true; + .addCase(formVisibilityUpdated, () => { + return true; }) } }); diff --git a/src/state/store.ts b/src/state/store.ts index ca228a3..96eff35 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -12,28 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Middleware, configureStore } from '@reduxjs/toolkit' +import { Middleware, combineReducers, configureStore } from '@reduxjs/toolkit' import metadataReducer from './metadata-reducer' import uiSpecificationReducer from './uiSpec-reducer' import modifiedStatusReducer from './modifiedStatus-reducer'; import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore'; -import { Notebook } from './initial'; +import { AppState, Notebook } from './initial'; import { loadState, saveState } from './localStorage'; import { throttle } from 'lodash'; const persistedState = loadState(); -const loggerMiddleware: Middleware = storeAPI => next => action => { +const loggerMiddleware: Middleware = storeAPI => next => action => { console.log('dispatching', action); next(action); console.log('next state', storeAPI.getState()) } -export const store: ToolkitStore = configureStore({ +export const store: ToolkitStore = configureStore({ reducer: { - metadata: metadataReducer, - "ui-specification": uiSpecificationReducer, - modifiedStatus: modifiedStatusReducer, + notebook: combineReducers({ + metadata: metadataReducer, + "ui-specification": uiSpecificationReducer + }), + modified: modifiedStatusReducer, }, preloadedState: persistedState, middleware: (getDefaultMiddleware) => diff --git a/src/state/uiSpec-reducer.ts b/src/state/uiSpec-reducer.ts index 0718498..c1a2f20 100644 --- a/src/state/uiSpec-reducer.ts +++ b/src/state/uiSpec-reducer.ts @@ -44,21 +44,22 @@ export const slugify = (str: string) => { export const uiSpecificationReducer = createSlice({ name: 'ui-specification', - initialState: initialState['ui-specification'], + initialState: initialState.notebook['ui-specification'], reducers: { - loaded: (_state: NotebookUISpec, action: PayloadAction) => { + loaded: (_state, action: PayloadAction) => { return action.payload; }, - fieldUpdated: (state: NotebookUISpec, + fieldUpdated: (state, action: PayloadAction<{ fieldName: string, newField: FieldType }>) => { const { fieldName, newField } = action.payload; - if (fieldName in state.fields) { - state.fields[fieldName] = newField; + const fields = state.fields as {[key: string]: FieldType}; + if (fieldName in fields) { + fields[fieldName] = newField; } else { throw new Error(`Cannot update unknown field ${fieldName} via fieldUpdated action`); } }, - fieldMoved: (state: NotebookUISpec, + fieldMoved: (state, action: PayloadAction<{ fieldName: string, viewId: string, direction: 'up' | 'down' }>) => { const { fieldName, viewId, direction } = action.payload; @@ -86,7 +87,7 @@ export const uiSpecificationReducer = createSlice({ } state.fviews[viewId].fields = fieldList; }, - fieldRenamed: (state: NotebookUISpec, + fieldRenamed: (state, action: PayloadAction<{ viewId: string, fieldName: string, newFieldName: string }>) => { const { viewId, fieldName, newFieldName } = action.payload; @@ -116,7 +117,7 @@ export const uiSpecificationReducer = createSlice({ throw new Error(`Cannot rename unknown field ${fieldName} via fieldRenamed action`); } }, - fieldAdded: (state: NotebookUISpec, + fieldAdded: (state, action: PayloadAction<{ fieldName: string, fieldType: string, @@ -157,21 +158,17 @@ export const uiSpecificationReducer = createSlice({ // add in the meta field newField.meta = { - "annotation": true, - "annotation_label": "annotation", + "annotation": { + "include": true, + "label": "annotation", + }, "uncertainty": { "include": true, "label": "uncertainty" } }; - // try to set the field label - if (newField['component-parameters'] && 'label' in newField['component-parameters']) { - newField['component-parameters'].label = fieldName; - } else if ('InputLabelProps' in newField['component-parameters'] && - newField['component-parameters'].InputLabelProps && - 'label' in newField['component-parameters'].InputLabelProps) { - newField['component-parameters'].InputLabelProps.label = fieldName; - } + // set the field label + newField['component-parameters'].label = fieldName; // ensure a unique field name let N = 1; @@ -184,7 +181,7 @@ export const uiSpecificationReducer = createSlice({ state.fields[fieldLabel] = newField; state.fviews[viewId].fields.push(fieldLabel); }, - fieldDeleted: (state: NotebookUISpec, + fieldDeleted: (state, action: PayloadAction<{ fieldName: string, viewId: string }>) => { const { fieldName, viewId } = action.payload; // remove the field from fields and the viewSet @@ -196,7 +193,7 @@ export const uiSpecificationReducer = createSlice({ throw new Error(`Cannot delete unknown field ${fieldName} via fieldDeleted action`); } }, - sectionRenamed: (state: NotebookUISpec, + sectionRenamed: (state, action: PayloadAction<{ viewId: string, label: string }>) => { const { viewId, label } = action.payload; if (viewId in state.fviews) { @@ -205,7 +202,7 @@ export const uiSpecificationReducer = createSlice({ throw new Error(`Can't update unknown section ${viewId} via sectionNameUpdated action`); } }, - sectionAdded: (state: NotebookUISpec, + sectionAdded: (state, action: PayloadAction<{ viewSetId: string, sectionLabel: string }>) => { const { viewSetId, sectionLabel } = action.payload; const sectionId = viewSetId + '-' + slugify(sectionLabel); @@ -220,7 +217,7 @@ export const uiSpecificationReducer = createSlice({ state.viewsets[viewSetId].views.push(sectionId); } }, - sectionDeleted: (state: NotebookUISpec, action: PayloadAction<{ viewSetID: string, viewID: string }>) => { + sectionDeleted: (state, action: PayloadAction<{ viewSetID: string, viewID: string }>) => { const { viewSetID, viewID } = action.payload; if (viewID in state.fviews) { @@ -238,7 +235,7 @@ export const uiSpecificationReducer = createSlice({ state.viewsets[viewSetID].views = newViewSetViews; } }, - sectionMoved: (state: NotebookUISpec, + sectionMoved: (state, action: PayloadAction<{ viewSetId: string, viewId: string, direction: 'left' | 'right' }>) => { const { viewSetId, viewId, direction } = action.payload; @@ -266,7 +263,7 @@ export const uiSpecificationReducer = createSlice({ } state.viewsets[viewSetId].views = viewList; }, - sectionConditionChanged: (state: NotebookUISpec, + sectionConditionChanged: (state, action: PayloadAction<{viewId: string, condition: ConditionType}>) => { const {viewId, condition} = action.payload; @@ -274,7 +271,7 @@ export const uiSpecificationReducer = createSlice({ state.fviews[viewId].condition = condition; } }, - viewSetAdded: (state: NotebookUISpec, + viewSetAdded: (state, action: PayloadAction<{ formName: string }>) => { const { formName } = action.payload; const newViewSet = { @@ -290,7 +287,7 @@ export const uiSpecificationReducer = createSlice({ state.visible_types.push(formID); } }, - viewSetDeleted: (state: NotebookUISpec, action: PayloadAction<{ viewSetId: string }>) => { + viewSetDeleted: (state, action: PayloadAction<{ viewSetId: string }>) => { const { viewSetId } = action.payload; if (viewSetId in state.viewsets) { @@ -316,7 +313,7 @@ export const uiSpecificationReducer = createSlice({ state.visible_types = newVisibleTypes; } }, - viewSetMoved: (state: NotebookUISpec, + viewSetMoved: (state, action: PayloadAction<{ viewSetId: string, direction: 'left' | 'right' }>) => { const { viewSetId, direction } = action.payload; @@ -345,14 +342,14 @@ export const uiSpecificationReducer = createSlice({ // update state state.visible_types = formsList; }, - viewSetRenamed: (state: NotebookUISpec, + viewSetRenamed: (state, action: PayloadAction<{ viewSetId: string, label: string }>) => { const { viewSetId, label } = action.payload; if (viewSetId in state.viewsets) { state.viewsets[viewSetId].label = label; } }, - formVisibilityUpdated: (state: NotebookUISpec, + formVisibilityUpdated: (state, action: PayloadAction<{ viewSetId: string, ticked: boolean, initialIndex: number }>) => { const { viewSetId, ticked } = action.payload; diff --git a/src/test-notebook.ts b/src/test-notebook.ts index 6facfd2..f40348b 100644 --- a/src/test-notebook.ts +++ b/src/test-notebook.ts @@ -1,6 +1,6 @@ -import { Notebook } from "./state/initial"; +//import { Notebook } from "./state/initial"; -export const sampleNotebook:Notebook = { +export const sampleNotebook:unknown = { "metadata": { "notebook_version": "1.0", "schema_version": "1.0", @@ -18,7 +18,14 @@ export const sampleNotebook:Notebook = { "pre_description": "Demonstration notebook to help develop an export pipeline from Fieldmark to RSpace.", "project_lead": "Steve Cassidy", "project_status": "New", - "sections": {} + "sections": { + "Primary-New-Section": { + "sectiondescriptionPrimary-New-Section": "This description." + }, + "Primary-Next-Section": { + "sectiondescriptionPrimary-Next-Section": "That description." + }, + }, }, "ui-specification": { "fields": { @@ -134,11 +141,12 @@ export const sampleNotebook:Notebook = { "id": "qr-code-field", "variant": "outlined", "required": true, - "label": "IGSN QR Code", "FormLabelProps": { - "children": "Input a value here" + "children": "IGSN QR Code" }, - "helperText": "Scan the pre-printed QR Code for this sample." + "FormHelperTextProps": { + "children": "Scan the pre-printed QR Code for this sample." + } }, "validationSchema": [ [ @@ -193,7 +201,7 @@ export const sampleNotebook:Notebook = { "fullWidth": true, "name": "Sample-Photograph", "id": "take-photo-field", - "helperText": "Take a photo", + "helpertext": "Take a photo", "variant": "outlined", "label": "Sample Photograph" }, @@ -249,6 +257,47 @@ export const sampleNotebook:Notebook = { } } }, + + "survey-note": { + "component-namespace": "formik-material-ui", + "component-name": "MultipleTextField", + "type-returned": "faims-core::String", + "component-parameters": { + "fullWidth": true, + "helperText": "Note comments about survey area here", + "variant": "outlined", + "required": false, + "multiline": true, + "InputProps": { + "type": "text", + "rows": 4 + }, + "SelectProps": {}, + "InputLabelProps": { + "label": "Survey Note" + }, + "FormHelperTextProps": {}, + "id": "survey-note", + "name": "survey-note" + }, + "validationSchema": [ + [ + "yup.string" + ] + ], + "initialValue": "", + "access": [ + "admin" + ], + "meta": { + "annotation_label": "annotation", + "annotation": false, + "uncertainty": { + "include": false, + "label": "uncertainty" + } + } + }, "Type": { "component-namespace": "faims-custom", "component-name": "Select", @@ -296,7 +345,35 @@ export const sampleNotebook:Notebook = { "label": "uncertainty" } } - } + }, + safety_hazard: { + 'component-namespace': 'faims-custom', + 'component-name': 'Checkbox', + 'type-returned': 'faims-core::Bool', + 'component-parameters': { + name: 'safety_hazard', + id: 'safety_hazard', + required: false, + type: 'checkbox', + FormControlLabelProps: { + label: 'Safety Hazard', + }, + FormHelperTextProps: { + children: 'Selecting this box will alert maintenance (eventually)', + }, + }, + validationSchema: [['yup.bool']], + initialValue: false, + access: ['admin'], + meta: { + annotation_label: 'annotation', + annotation: false, + uncertainty: { + include: false, + label: 'uncertainty', + }, + }, + }, }, "fviews": { "Primary-New-Section": { @@ -306,7 +383,8 @@ export const sampleNotebook:Notebook = { "New-Text-Field", "Sample-Photograph", "Length-mm", - "Type" + "Type", + "safety_hazard" ] }, "Primary-Next-Section": { @@ -314,7 +392,8 @@ export const sampleNotebook:Notebook = { "fields": [ "Field-ID", "hridPrimary-Next-Section", - "IGSN-QR-Code" + "IGSN-QR-Code", + "survey-note" ] } }, @@ -330,8 +409,5 @@ export const sampleNotebook:Notebook = { "visible_types": [ "Primary" ] - }, - "modifiedStatus": { - flag: false, - }, + } } \ No newline at end of file