diff --git a/app/src/gui/components/autoincrement/edit-form.test.tsx b/app/src/gui/components/autoincrement/edit-form.test.tsx index 89ad9feab..6d1b18ecd 100644 --- a/app/src/gui/components/autoincrement/edit-form.test.tsx +++ b/app/src/gui/components/autoincrement/edit-form.test.tsx @@ -19,7 +19,7 @@ */ import {fireEvent, render, screen, waitFor} from '@testing-library/react'; -import BasicAutoIncrementer from './edit-form'; +import {AutoIncrementEditForm} from './edit-form'; import {expect, describe, it} from 'vitest'; const props = { @@ -27,11 +27,13 @@ const props = { form_id: '', field_id: '', label: '', + open: true, + handleClose: () => {}, }; describe('Check edit-form component', () => { it('Check add btn', async () => { - render(); + render(); const addRangeBtn = screen.getByTestId('addNewRangeBtn'); await waitFor(() => { @@ -45,7 +47,7 @@ describe('Check edit-form component', () => { }); }); it('Check remove btn', async () => { - render(); + render(); const addRangeBtn = screen.getByTestId('addNewRangeBtn'); await waitFor(() => { @@ -67,7 +69,7 @@ describe('Check edit-form component', () => { }); }); it('Check adding range start and stop fields', async () => { - render(); + render(); const addRangeBtn = screen.getByTestId('addNewRangeBtn'); await waitFor(() => { diff --git a/app/src/gui/components/autoincrement/edit-form.tsx b/app/src/gui/components/autoincrement/edit-form.tsx index be00976e1..db83ef1c6 100644 --- a/app/src/gui/components/autoincrement/edit-form.tsx +++ b/app/src/gui/components/autoincrement/edit-form.tsx @@ -15,31 +15,35 @@ * * Filename: form.tsx * Description: - * TODO + * Defines a form for editing the auto-incrementer ranges for a field */ -import React from 'react'; -import {Formik, Form, Field, yupToFormErrors} from 'formik'; +import AddIcon from '@mui/icons-material/Add'; import { - ButtonGroup, - Box, - Alert, + Badge, Button, - LinearProgress, - Grid, + ButtonGroup, + Dialog, + DialogActions, + DialogContent, + DialogTitle, Divider, + IconButton, + Stack, + TextField, + Typography, } from '@mui/material'; -import AddIcon from '@mui/icons-material/Add'; -import {TextField} from 'formik-mui'; -import * as yup from 'yup'; +import {useContext, useState} from 'react'; +import {LocalAutoIncrementRange, ProjectID} from '@faims3/data-model'; +import CloseIcon from '@mui/icons-material/Close'; +import {useQuery, useQueryClient} from '@tanstack/react-query'; import {ActionType} from '../../../context/actions'; import {store} from '../../../context/store'; -import {ProjectID, LocalAutoIncrementRange} from '@faims3/data-model'; import { + createNewAutoincrementRange, getLocalAutoincrementRangesForField, setLocalAutoincrementRangesForField, - createNewAutoincrementRange, } from '../../../local-data/autoincrement'; interface Props { @@ -47,109 +51,56 @@ interface Props { form_id: string; field_id: string; label: string; + open: boolean; + handleClose: () => void; } -interface State { - ranges: LocalAutoIncrementRange[] | null; - ranges_initialised: boolean; -} - -const FORM_SCHEMA = yup.object().shape({ - start: yup.number().required().positive().integer(), - stop: yup.number().required().positive().integer(), -}); - -export default class BasicAutoIncrementer extends React.Component< - Props, - State -> { - constructor(props: Props) { - super(props); - this.state = { - ranges: null, - ranges_initialised: false, - }; - } +export const AutoIncrementEditForm = ({ + project_id, + form_id, + field_id, + label, + open, + handleClose, +}: Props) => { + const {dispatch} = useContext(store); - async ensure_ranges() { - if (this.state.ranges === null) { + // useQuery to get the current ranges for the field, + // we will invalidate the query when we update the ranges + // so that they get re-fetched + const queryClient = useQueryClient(); + const queryKey = ['autoincrement', project_id, form_id, field_id]; + const {data: ranges} = useQuery({ + queryKey: queryKey, + queryFn: async () => { const ranges = await getLocalAutoincrementRangesForField( - this.props.project_id, - this.props.form_id, - this.props.field_id + project_id, + form_id, + field_id ); - this.setState({ranges: ranges}); - } - } - - async add_new_range() { - await this.ensure_ranges(); - const ranges = [ - ...((this.state.ranges || []) as LocalAutoIncrementRange[]), - ]; - ranges.push(createNewAutoincrementRange(0, 0)); - this.setState({ranges: ranges}); - } - - cloned_ranges(): LocalAutoIncrementRange[] | null { - if (this.state.ranges === null) { - return null; - } - // react requires us to make deep copied if we want to modify state... - const ranges = [] as LocalAutoIncrementRange[]; - for (const old_range of this.state.ranges) { - // This assumes we don't need to go another level down to deep clone - const new_range = {...old_range}; - ranges.push(new_range); - } - return ranges; - } + return ranges; + }, + initialData: [], + enabled: true, + }); - render_ranges() { - const ranges = this.cloned_ranges(); - if (ranges === null || ranges.length === 0) { - return ( - - No ranges allocated yet. - - ); - } - return ( -
- {ranges.map((range, range_index) => { - return this.render_range(range, range_index, ranges); - })} -
- ); - } + const addNewRange = async () => { + const updatedRanges = [...(ranges || [])]; + updatedRanges.push(createNewAutoincrementRange(0, 0)); + updateRanges(updatedRanges); + }; - async update_ranges(ranges: LocalAutoIncrementRange[]) { - const {project_id, form_id, field_id} = this.props; + const updateRanges = async (newRanges: LocalAutoIncrementRange[]) => { try { await setLocalAutoincrementRangesForField( project_id, form_id, field_id, - ranges - ).then(() => { - (this.context as any).dispatch({ - type: ActionType.ADD_ALERT, - payload: { - message: 'Range successfully updated', - severity: 'success', - }, - }); - }); - let ranges_initialised = false; - for (const range of ranges) { - if (range.using || range.fully_used) { - ranges_initialised = true; - break; - } - } - this.setState({ranges: ranges, ranges_initialised: ranges_initialised}); + newRanges + ); + queryClient.invalidateQueries({queryKey: queryKey}); } catch (err: any) { - (this.context as any).dispatch({ + dispatch({ type: ActionType.ADD_ALERT, payload: { message: err.toString(), @@ -157,159 +108,199 @@ export default class BasicAutoIncrementer extends React.Component< }, }); } - } + }; - render_range( - range: LocalAutoIncrementRange, - range_index: number, - ranges: LocalAutoIncrementRange[] - ) { - const range_count = ranges.length; - const start_props = { - id: 'start', - label: 'start', - name: 'start', - required: true, - type: 'number', - readOnly: range.using || range.fully_used, - disabled: range.using || range.fully_used, - }; - const stop_props = { - id: 'stop', - label: 'stop', - name: 'stop', - required: true, - type: 'number', - readOnly: range.fully_used, - disabled: range.fully_used, + const updateRange = (index: number) => { + return (range: LocalAutoIncrementRange) => { + const rangesCopy = [...ranges]; + rangesCopy[index] = range; + updateRanges(rangesCopy); }; + }; - return ( - { - return FORM_SCHEMA.validate(values) - .then(v => { - if (!(v.stop > v.start)) { - return {stop: 'Must be greater than start'}; - } - return {}; - }) - .catch(err => { - return yupToFormErrors(err); - }); - }} - onSubmit={async (values, {setSubmitting}) => { - range.start = values.start; - range.stop = values.stop; - await this.update_ranges(ranges).then(() => { - setSubmitting(false); - }); + const handleRemoveRange = (index: number) => { + const newRanges = ranges?.filter((_, i) => i !== index); + if (newRanges !== undefined) { + updateRanges(newRanges); + } else { + updateRanges([]); + } + }; + + return ( + + Edit Settings for {label} + - {({submitForm, isSubmitting}) => ( - -
- - - - - - - - + + + + + This form uses an auto-increment field to generate new identifiers for + each record. Here you can set a range of numbers to use on this + device. + + + Set a start and end for the range, numbers will be allocated in order + until used up. You must add at least one range. If there is more than + one range, the ranges will be used in order. + + + + + {ranges?.map((range, index) => { + return ( + 1} + /> + ); + })} + + + + + + + +
+ ); +}; - - - {range.using ? ( - - ) : ( - - )} - - - - - {isSubmitting && } - - - )} -
- ); - } + const handleStartChange = (event: any) => { + const newStart = parseInt(event.target.value); + if (newStart >= 0) { + setStart(newStart); + if (newStart >= props.range.stop) { + // initialise a range of 100 if they enter a start > stop + setStop(newStart + 100); + props.updateRange({ + ...props.range, + start: newStart, + stop: newStart + 100, + }); + } else { + props.updateRange({ + ...props.range, + start: newStart, + }); + } + } + }; - async componentDidMount() { - await this.ensure_ranges(); - } + const handleStopChange = (event: any) => { + const newStop = parseInt(event.target.value); + if (newStop > props.range.start) { + setStop(newStop); + props.updateRange({ + ...props.range, + stop: newStop, + }); + } + }; - async componentDidUpdate() { - await this.ensure_ranges(); - } + const handleDisableRange = () => { + props.updateRange({ + ...props.range, + fully_used: true, + }); + }; - render() { - return ( - - {this.render_ranges()} - - - ); - } -} -BasicAutoIncrementer.contextType = store; + {props.range.using && ( + + )} + + {!(props.range.using || props.range.fully_used) && ( + + )} + {props.range.fully_used && ( + + )} + + + + ); +}; diff --git a/app/src/gui/components/notebook/settings/auto_incrementers.tsx b/app/src/gui/components/notebook/settings/auto_incrementers.tsx index 797a3aaf5..d35b87b88 100644 --- a/app/src/gui/components/notebook/settings/auto_incrementers.tsx +++ b/app/src/gui/components/notebook/settings/auto_incrementers.tsx @@ -1,9 +1,9 @@ -import React, {useEffect} from 'react'; -import {Box, Grid, Typography, Paper, Alert} from '@mui/material'; +import React, {useEffect, useState} from 'react'; +import {Box, Grid, Typography, Paper, Button} from '@mui/material'; import {ProjectInformation, ProjectUIModel} from '@faims3/data-model'; import {getAutoincrementReferencesForProject} from '../../../../local-data/autoincrement'; import {AutoIncrementReference} from '@faims3/data-model'; -import AutoIncrementEditForm from '../../autoincrement/edit-form'; +import {AutoIncrementEditForm} from '../../autoincrement/edit-form'; import {logError} from '../../../../logging'; interface AutoIncrementerSettingsListProps { @@ -11,21 +11,11 @@ interface AutoIncrementerSettingsListProps { uiSpec: ProjectUIModel; } -function get_form(section_id: string, uiSpec: ProjectUIModel) { - let form = ''; - uiSpec.visible_types.map(viewset => - uiSpec.viewsets[viewset].views.includes(section_id) - ? (form = uiSpec.viewsets[viewset].label ?? viewset) - : viewset - ); - return form; -} export default function AutoIncrementerSettingsList( props: AutoIncrementerSettingsListProps ) { - const [references, setReferences] = React.useState( - [] as AutoIncrementReference[] - ); + const [open, setOpen] = useState(false); + const [references, setReferences] = useState([] as AutoIncrementReference[]); useEffect(() => { getAutoincrementReferencesForProject(props.project_info.project_id) .then(refs => { @@ -35,62 +25,57 @@ export default function AutoIncrementerSettingsList( }, [props.project_info.project_id]); return ( - + <> {references.length === 0 ? ( - - - - Edit Allocations - - - This project has no Auto-Incrementers - - - + <> ) : ( - '' + + + Edit auto-incrementers + + + View and modify the range settings for auto-incrementers. These are + used to generate unique identifiers for records within a defined + range. + + + {references.map(ai => { + const label = ai.label ?? ''; + return ( + + + setOpen(false)} + /> + + + + ); + })} + )} - {references.map(ai => { - // display form section label for user to fill Auto correctly - const section = - props.uiSpec['views'][ai.form_id] !== undefined - ? (props.uiSpec['views'][ai.form_id]['label'] ?? ai.form_id) - : ai.form_id; - const label = - get_form(ai.form_id, props.uiSpec) + - ' <' + - section + - '> ' + - (ai.label ?? ''); - return ( - - - - Edit Allocations for {label} - - - The allocated range will be ≥ the start value and < the - stop value. e.g., a range allocation of start:1, stop:5 will - generate hrids in the range (1,2,3,4). There must always be a - least one range to ensure that new IDs can be generated. - - - - - ); - })} - + ); } diff --git a/app/src/gui/fields/BasicAutoIncrementer.tsx b/app/src/gui/fields/BasicAutoIncrementer.tsx index 8cc0da06d..5c5724d14 100644 --- a/app/src/gui/fields/BasicAutoIncrementer.tsx +++ b/app/src/gui/fields/BasicAutoIncrementer.tsx @@ -15,29 +15,20 @@ * * Filename: BasicAutoIncrementer.tsx * Description: - * TODO + * Implements an auto-incrementer field that is hidden but provides + * a value that can be used in templated string fields. */ -import React from 'react'; import Input from '@mui/material/Input'; import {FieldProps} from 'formik'; -import {ActionType} from '../../context/actions'; -import {store} from '../../context/store'; +import {useEffect, useState} from 'react'; import { getLocalAutoincrementStateForField, setLocalAutoincrementStateForField, } from '../../local-data/autoincrement'; -import { - Grid, - Button, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, -} from '@mui/material'; -import {NOTEBOOK_NAME, NOTEBOOK_NAME_CAPITALIZED} from '../../buildconfig'; +import {AutoIncrementEditForm} from '../components/autoincrement/edit-form'; + interface Props { num_digits: number; // This could be dropped depending on how multi-stage forms are configured @@ -45,92 +36,25 @@ interface Props { label?: string; } -interface State { - has_run: boolean; - is_ranger: boolean; - label: string; -} - -function AddRangeDialog() { - const [open, setOpen] = React.useState(false); - return ( - - - setOpen(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - Information - - - {`Go to ${NOTEBOOK_NAME_CAPITALIZED} > Settings Tab > Edit Allocations to Add New Range`}{' '} -
-
-
- - - -
-
- ); -} - -export class BasicAutoIncrementer extends React.Component< - FieldProps & Props, - State -> { - constructor(props: FieldProps & Props) { - super(props); - const label = - this.props.label !== '' && this.props.label !== undefined - ? this.props.label - : this.props.form_id; - this.state = { - has_run: false, - is_ranger: true, - label: label ?? this.props.form_id, - }; - } +export const BasicAutoIncrementer = (props: FieldProps & Props) => { + const [showAutoIncrementEditForm, setShowAutoIncrementEditForm] = + useState(false); - async get_id(): Promise { - const project_id = this.props.form.values['_project_id']; - const form_id = this.props.form_id; - const field_id = this.props.field.name; + const get_id = async (): Promise => { + const project_id = props.form.values['_project_id']; + const form_id = props.form_id; + const field_id = props.field.name; const local_state = await getLocalAutoincrementStateForField( project_id, form_id, field_id ); - console.debug( - `local_auto_inc for ${project_id} ${form_id} ${field_id} is`, - local_state - ); if (local_state.last_used_id === null && local_state.ranges.length === 0) { - // We have no range allocations, block - // TODO: add link to range allocation - (this.context as any).dispatch({ - type: ActionType.ADD_ALERT, - payload: { - message: `No ranges exist for this ${NOTEBOOK_NAME} yet. Go to the ${NOTEBOOK_NAME} Settings tab to add/edit ranges.`, - severity: 'error', - }, - }); - this.setState({...this.state, is_ranger: false}); + setShowAutoIncrementEditForm(true); return null; } + if (local_state.last_used_id === null) { - console.debug('local_auto_inc starting with clean slate'); // We've got a clean slate with ranges allocated, start allocating ids const new_id = local_state.ranges[0].start; local_state.ranges[0].using = true; @@ -138,12 +62,12 @@ export class BasicAutoIncrementer extends React.Component< await setLocalAutoincrementStateForField(local_state); return new_id; } + // We're now using the allocated ranges, find where we've up to: // If we're using a range, find it for (const range of local_state.ranges) { if (range.using) { if (local_state.last_used_id + 1 < range.stop) { - console.debug('local_auto_inc using existing range', range); const next_id = local_state.last_used_id + 1; local_state.last_used_id = next_id; await setLocalAutoincrementStateForField(local_state); @@ -151,100 +75,82 @@ export class BasicAutoIncrementer extends React.Component< } range.fully_used = true; range.using = false; - console.debug('local_auto_inc finished with range', range); } } + // find a new range to use for (const range of local_state.ranges) { if (!range.fully_used) { const next_id = range.start; range.using = true; local_state.last_used_id = next_id; - console.debug('local_auto_inc staring with range', range); await setLocalAutoincrementStateForField(local_state); return next_id; } } - // we've got no new ranges to use, either we block, or use the highest range - // as a starting point - // TODO: Add blocking logic - let max_stop = local_state.last_used_id; - for (const range of local_state.ranges) { - if (range.stop > max_stop) { - max_stop = range.stop; - } - } - if (max_stop === local_state.last_used_id) { - max_stop = max_stop + 1; - } - local_state.last_used_id = max_stop; - await setLocalAutoincrementStateForField(local_state); - console.debug('local_auto_inc using overrun', local_state); - return max_stop; - } + // we've got no new ranges to use, ask the user to allocate more + + setShowAutoIncrementEditForm(true); + return null; + }; - async compute_id(num_digits: number): Promise { - const new_id = await this.get_id(); + const compute_id = async ( + num_digits: number + ): Promise => { + const new_id = await get_id(); if (new_id === null || new_id === undefined) { return undefined; } return new_id.toString().padStart(num_digits, '0'); - } - - async update_form() { - const current_value = this.props.form.values[this.props.field.name]; + }; - if (!this.state.has_run) { - this.setState({has_run: true}); - console.debug('running autoinc'); - if (current_value === null) { - const new_id = await this.compute_id(this.props.num_digits || 4); - if (new_id === undefined) { - (this.context as any).dispatch({ - type: ActionType.ADD_ALERT, - payload: { - message: 'Failed to get autoincremented ID', - severity: 'error', - }, - }); - } else { - this.props.form.setFieldValue(this.props.field.name, new_id, true); - if (this.props.form.errors[this.props.field.name] !== undefined) - this.props.form.setFieldError(this.props.field.name, undefined); - } + const update_form = async () => { + const current_value = props.form.values[props.field.name]; + // we'll set the value in a form if the value has not already been set + // assume it has been set if the value is not null, empty or undefined + if ( + current_value === null || + current_value === '' || + current_value === undefined + ) { + const new_id = await compute_id(props.num_digits || 4); + if (new_id === undefined) { + setShowAutoIncrementEditForm(true); } else { - if (this.props.form.errors[this.props.field.name] !== undefined) - this.props.form.setFieldError(this.props.field.name, undefined); + props.form.setFieldValue(props.field.name, new_id, true); } } - } - - async componentDidMount() { - console.debug('did mount', this.props.form.values[this.props.field.name]); - await this.update_form(); - } - // remove the update for form, should only be update once - // async componentDidUpdate() { - // console.debug('did update',this.props.form.values[this.props.field.name]) - // await this.update_form(); - // } + // reset any errors, we don't want to report these to the user + if (props.form.errors[props.field.name] !== undefined) + props.form.setFieldError(props.field.name, undefined); + }; - render() { - return ( - <> - - {this.state.is_ranger === false && } - - ); - } -} + useEffect(() => { + update_form(); + }, [props.form.values[props.field.name]]); -BasicAutoIncrementer.contextType = store; + return ( + <> + + { + await update_form(); + setShowAutoIncrementEditForm(false); + }} + /> + + ); +}; // const uiSpec = { // 'component-namespace': 'faims-custom', // this says what web component to use to render/acquire value from diff --git a/designer/src/state/migrateNotebook.test.ts b/designer/src/state/migrateNotebook.test.ts index e92d41dc0..5b064abad 100644 --- a/designer/src/state/migrateNotebook.test.ts +++ b/designer/src/state/migrateNotebook.test.ts @@ -97,6 +97,13 @@ describe('Migrate Notebook Tests', () => { } }); + test('fix auto incrementer initial value', () => { + const migrated = migrateNotebook(sampleNotebook); + const fields = migrated['ui-specification'].fields; + const targetField = fields['Field-ID']; + expect(targetField.initialValue).toBe(''); + }); + test('update form descriptions', () => { const migrated = migrateNotebook(sampleNotebook); const fviews = migrated['ui-specification'].fviews; diff --git a/designer/src/state/migrateNotebook.ts b/designer/src/state/migrateNotebook.ts index 24a1c53fd..f588cbb62 100644 --- a/designer/src/state/migrateNotebook.ts +++ b/designer/src/state/migrateNotebook.ts @@ -44,6 +44,9 @@ export const migrateNotebook = (notebook: unknown) => { // fix validation for photo fields which had a bad default fixPhotoValidation(notebookCopy); + // fix bad autoincrementer initial value + fixAutoIncrementerInitialValue(notebookCopy); + return notebookCopy; }; @@ -240,3 +243,26 @@ const fixPhotoValidation = (notebook: Notebook) => { notebook['ui-specification'].fields = fields; }; + +/** + * In some old notebooks, the initialValue of an auto incrementer was null + * which conflicts with the validate schema and triggers an error + * message on load in some cases. Here we replace that with the empty string. + * + * @param notebook A notebook that might be out of date, modified + */ +const fixAutoIncrementerInitialValue = (notebook: Notebook) => { + const fields: {[key: string]: FieldType} = {}; + + for (const fieldName in notebook['ui-specification'].fields) { + const field = notebook['ui-specification'].fields[fieldName]; + + if (field['component-name'] === 'BasicAutoIncrementer') { + if (field.initialValue === null) field.initialValue = ''; + } + + fields[fieldName] = field; + } + + notebook['ui-specification'].fields = fields; +};