diff --git a/protocol-designer/src/assets/localization/en/form.json b/protocol-designer/src/assets/localization/en/form.json index 8bfad8971f2..aaa000a1668 100644 --- a/protocol-designer/src/assets/localization/en/form.json +++ b/protocol-designer/src/assets/localization/en/form.json @@ -186,8 +186,9 @@ "add_cycle_step": "Add a cycle step", "add_step": "Add step", "add_step_button": "+ Step", + "cycle": "cycle", "cycle_step": "cycle step", - "cycles": "Cycles {{repetitions}}", + "cycles": "Cycles {{repetitions}}x", "delete": "delete", "edit": "Edit Thermocycler profile steps", "lid_closed": "Closed", diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx index 1d268ea0549..b3236eb1e0e 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepFormToolbox.tsx @@ -90,7 +90,13 @@ export function StepFormToolbox(props: StepFormToolboxProps): JSX.Element { getTimelineWarningsForSelectedStep ) const timeline = useSelector(getRobotStateTimeline) - const [toolboxStep, setToolboxStep] = useState(0) + const [toolboxStep, setToolboxStep] = useState( + // progress to step 2 if thermocycler form is populated + formData.thermocyclerFormType === 'thermocyclerProfile' || + formData.thermocyclerFormType === 'thermocyclerState' + ? 1 + : 0 + ) const icon = stepIconsByType[formData.stepType] const ToolsComponent: typeof STEP_FORM_MAP[keyof typeof STEP_FORM_MAP] = get( diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerCycle.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerCycle.tsx new file mode 100644 index 00000000000..4e2ac9962d2 --- /dev/null +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerCycle.tsx @@ -0,0 +1,548 @@ +import { css } from 'styled-components' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { + ALIGN_CENTER, + ALIGN_FLEX_END, + BORDERS, + Btn, + COLORS, + CURSOR_POINTER, + DIRECTION_COLUMN, + Flex, + Icon, + InputField, + JUSTIFY_SPACE_BETWEEN, + NO_WRAP, + PrimaryButton, + SPACING, + StyledText, + TYPOGRAPHY, +} from '@opentrons/components' +import { + isTimeFormatMinutesSeconds, + temperatureRangeFieldValue, +} from '../../../../../../steplist/fieldLevel/errors' +import { + maskToFloat, + maskToInteger, + maskToTime, +} from '../../../../../../steplist/fieldLevel/processing' +import { uuid } from '../../../../../../utils' +import { getTimeFromString, getStepIndex } from './utils' + +import type { ThermocyclerStepTypeGeneral } from './ThermocyclerProfileModal' +import type { ThermocyclerStepType } from './ThermocyclerStep' + +export interface ThermocyclerCycleType { + id: string + title: string + steps: ThermocyclerStepType[] + type: 'profileCycle' + repetitions: string +} + +interface CycleStepValues { + value: string | null + error: string | null + wasAccessed?: boolean +} +interface CycleStepType { + name: CycleStepValues + temp: CycleStepValues + time: CycleStepValues +} + +interface ThermocyclerCycleProps { + steps: ThermocyclerStepTypeGeneral[] + setSteps: React.Dispatch> + setShowCreateNewCycle: React.Dispatch> + step?: ThermocyclerCycleType + backgroundColor?: string + readOnly?: boolean + setIsInEdit: React.Dispatch> +} + +export function ThermocyclerCycle(props: ThermocyclerCycleProps): JSX.Element { + const { + setShowCreateNewCycle, + step, + steps, + setSteps, + backgroundColor = COLORS.grey30, + setIsInEdit, + readOnly = true, + } = props + const { i18n, t } = useTranslation(['application', 'form']) + const [hover, setHover] = useState(false) + const [showEdit, setShowEditCurrentCycle] = useState(!readOnly) + + const [orderedCycleStepIds, setOrderedCycleStepIds] = useState( + step?.steps.map(cycleStep => cycleStep.id) ?? [] + ) + const [cycleStepsById, setCycleStepsById] = useState( + step?.steps.reduce>( + (acc, { id, title, temperature, durationMinutes, durationSeconds }) => { + return { + ...acc, + [id]: { + name: { value: title ?? null, error: null }, + temp: { + value: temperature ?? null, + error: null, + wasAccessed: false, + }, + time: { + value: + durationMinutes != null && durationSeconds != null + ? `${durationMinutes}:${durationSeconds}` + : null, + error: null, + wasAccessed: false, + }, + }, + } + }, + {} + ) ?? {} + ) + const [repetitions, setRepetitions] = useState( + step?.repetitions + ) + + const cycleId = step?.id ?? null + const isStepStateError = + Object.values(cycleStepsById).some(cycleStep => + Object.values(cycleStep).some( + ({ value, error }) => value == null || value === '' || error != null + ) + ) || + repetitions == null || + repetitions === '' + + const blankStep: CycleStepType = { + name: { + value: null, + error: null, + }, + temp: { + value: null, + error: null, + }, + time: { + value: null, + error: null, + }, + } + + useEffect(() => { + if (orderedCycleStepIds.length === 0) { + // prepopulate with blank step on mount if not editing + handleAddCycleStep() + setIsInEdit(true) + } + }, []) + + const handleAddCycleStep = (): void => { + const newStepId = uuid() + setOrderedCycleStepIds([...orderedCycleStepIds, newStepId]) + setCycleStepsById({ ...cycleStepsById, [newStepId]: blankStep }) + } + + const handleDeleteStep = (stepId: string): void => { + const filteredOrdredCycleStepIds = orderedCycleStepIds.filter( + id => id !== stepId + ) + setOrderedCycleStepIds(filteredOrdredCycleStepIds) + setCycleStepsById( + filteredOrdredCycleStepIds.reduce((acc, id) => { + return id !== stepId + ? { + ...acc, + [id]: cycleStepsById[id], + } + : acc + }, {}) + ) + } + const handleDeleteCycle = (): void => { + if (cycleId != null) { + setSteps( + steps.filter((s: any) => { + return s.id !== cycleId + }) + ) + } else { + setShowCreateNewCycle(false) + } + setIsInEdit(false) + } + const handleValueUpdate = ( + stepId: string, + field: 'name' | 'temp' | 'time', + value: string, + errorCheck?: (value: any) => string | null + ): void => { + setCycleStepsById({ + ...cycleStepsById, + [stepId]: { + ...cycleStepsById[stepId], + [field]: { + value, + error: errorCheck?.(value) ?? null, + }, + }, + }) + } + const handleSaveCycle = (): void => { + const orderedCycleSteps = orderedCycleStepIds.map(cycleStepId => { + const step = cycleStepsById[cycleStepId] + const { minutes, seconds } = getTimeFromString(step.time.value ?? '') + const cycleStepData: ThermocyclerStepType = { + durationMinutes: minutes, + durationSeconds: seconds, + id: cycleStepId, + temperature: step.temp.value ?? '', + title: step.name.value ?? '', + type: 'profileStep', + } + return cycleStepData + }) + const cycleData: ThermocyclerCycleType = { + id: cycleId ?? uuid(), + title: '', + steps: orderedCycleSteps, + type: 'profileCycle', + repetitions: repetitions ?? '', + } + const existingCycleIndex = steps.findIndex(step => step.id === cycleId) + if (existingCycleIndex >= 0) { + // editing a cycle that was already created + setSteps([ + ...steps.slice(0, existingCycleIndex), + cycleData, + ...steps.slice(existingCycleIndex + 1), + ]) + } else { + // append to end of steps + setSteps([...steps, cycleData]) + } + setShowCreateNewCycle(false) + setShowEditCurrentCycle(false) + setIsInEdit(false) + } + + const header = showEdit ? ( + + + + {cycleId != null ? getStepIndex(steps, cycleId) : steps.length + 1} + + + {i18n.format( + t('form:step_edit_form.field.thermocyclerProfile.cycle'), + 'capitalize' + )} + + + + + + {i18n.format( + t('form:step_edit_form.field.thermocyclerProfile.delete'), + 'capitalize' + )} + + + + + {i18n.format(t('save'), 'capitalize')} + + + + + ) : ( + { + setHover(true) + }} + onMouseLeave={() => { + setHover(false) + }} + > + + + {getStepIndex(steps, cycleId ?? '')} + + + {i18n.format( + t('form:step_edit_form.field.thermocyclerProfile.cycles', { + repetitions, + }), + 'capitalize' + )} + + + + {hover ? ( + { + setShowEditCurrentCycle(true) + setIsInEdit(true) + }} + > + + {i18n.format(t('edit'), 'capitalize')} + + + ) : null} + + + + + + ) + const bodyContent = ( + { + setHover(true) + }} + onMouseLeave={() => { + setHover(false) + }} + > + + {orderedCycleStepIds.map((cycleStepId, cycleStepIndex) => { + const stepState = cycleStepsById[cycleStepId] + return showEdit ? ( + + + ) => { + handleValueUpdate( + cycleStepId, + 'name', + e.target.value as string + ) + }} + /> + + + ) => { + handleValueUpdate( + cycleStepId, + 'temp', + maskToFloat(e.target.value), + temperatureRangeFieldValue(4, 96) + ) + }} + onBlur={() => { + setCycleStepsById({ + ...cycleStepsById, + [cycleStepId]: { + ...stepState, + temp: { + ...stepState.temp, + wasAccessed: true, + }, + }, + }) + }} + error={ + stepState.temp.wasAccessed ? stepState.temp.error : null + } + /> + + + ) => { + handleValueUpdate( + cycleStepId, + 'time', + maskToTime(e.target.value), + isTimeFormatMinutesSeconds + ) + }} + onBlur={() => { + setCycleStepsById({ + ...cycleStepsById, + [cycleStepId]: { + ...stepState, + time: { + ...stepState.time, + wasAccessed: true, + }, + }, + }) + }} + error={ + stepState.time.wasAccessed ? stepState.time.error : null + } + /> + + { + handleDeleteStep(cycleStepId) + }} + alignSelf={ALIGN_CENTER} + > + + + + ) : ( + + {`${getStepIndex(steps, cycleId ?? '')}.${ + cycleStepIndex + 1 + }`} + {`${ + stepState.name.value + }, ${stepState.temp.value}${t('units.degrees')}, ${ + stepState.time.value + }, `} + + ) + })} + + {showEdit ? ( + <> + + + {i18n.format( + t( + 'form:step_edit_form.field.thermocyclerProfile.add_cycle_step' + ), + 'capitalize' + )} + + + { + setRepetitions(maskToInteger(e.target.value)) + }} + /> + + ) : null} + + ) + + return ( + + {header} + {bodyContent} + + ) +} diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx index 328125317e2..cb816041010 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/ThermocyclerProfileModal.tsx @@ -13,12 +13,18 @@ import { StyledText, } from '@opentrons/components' +import { ThermocyclerCycle } from './ThermocyclerCycle' import { ThermocyclerStep } from './ThermocyclerStep' import type { FormData } from '../../../../../../form-types' import type { FieldPropsByName } from '../../types' +import type { ThermocyclerCycleType } from './ThermocyclerCycle' import type { ThermocyclerStepType } from './ThermocyclerStep' +export type ThermocyclerStepTypeGeneral = + | ThermocyclerCycleType + | ThermocyclerStepType + interface ThermocyclerModalProps { formData: FormData propsForFields: FieldPropsByName @@ -33,10 +39,11 @@ export function ThermocyclerProfileModal( const [showCreateNewStep, setShowCreateNewStep] = useState(false) const [showCreateNewCycle, setShowCreateNewCycle] = useState(false) - const [steps, setSteps] = useState( + const [isInEdit, setIsInEdit] = useState(false) + const [steps, setSteps] = useState( formData.orderedProfileItems.map( (id: string) => formData.profileItemsById[id] - ) as ThermocyclerStepType[] + ) as ThermocyclerStepTypeGeneral[] ) const canAddStepOrProfile = !(showCreateNewCycle || showCreateNewStep) @@ -71,7 +78,10 @@ export function ThermocyclerProfileModal( {i18n.format(t('cancel'), 'capitalize')} - + {i18n.format(t('save'), 'capitalize')} @@ -111,24 +121,43 @@ export function ThermocyclerProfileModal( ) : ( // TODO (nd: 10/1/2024): add add profile cycle component - <>TODO: wire up cycle + ) })} {showCreateNewStep ? ( + ) : null} + {showCreateNewCycle ? ( + ) : null} - {showCreateNewCycle ? <>TODO: wire up cycle : null} ) : ( > - setShowCreateNewStep: (_: boolean) => void + steps: ThermocyclerStepTypeGeneral[] + setSteps: React.Dispatch> + setShowCreateNewStep: React.Dispatch> + setIsInEdit: React.Dispatch> step?: ThermocyclerStepType backgroundColor?: string - showHeader?: boolean readOnly?: boolean } export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { @@ -54,21 +56,22 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { steps, setSteps, backgroundColor = COLORS.grey30, - showHeader = true, readOnly = true, + setIsInEdit, } = props const { i18n, t } = useTranslation(['application', 'form']) const [hover, setHover] = useState(false) const [showEdit, setShowEditCurrentStep] = useState(!readOnly) const [stepState, setStepState] = useState({ name: { value: step?.title, error: null }, - temp: { value: step?.temperature, error: null }, + temp: { value: step?.temperature, error: null, wasAccessed: false }, time: { value: step?.durationMinutes != null && step?.durationSeconds != null ? `${step.durationMinutes}:${step.durationSeconds}` : undefined, error: null, + wasAccessed: false, }, }) const id = step?.id ?? null @@ -86,6 +89,7 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { } else { setShowCreateNewStep(false) } + setIsInEdit(false) } const handleValueUpdate = ( field: 'name' | 'temp' | 'time', @@ -101,14 +105,13 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { }) } const handleSaveStep = (): void => { - const stepId = uuid() const { minutes, seconds } = getTimeFromString(stepState.time.value ?? '') - const stepBaseData = { + const stepBaseData: ThermocyclerStepType = { durationMinutes: minutes, durationSeconds: seconds, - id: stepId, - temperature: stepState.temp.value, - title: stepState.name.value, + id: id ?? '', + temperature: stepState.temp.value ?? '', + title: stepState.name.value ?? '', type: 'profileStep', } @@ -117,14 +120,15 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { // editing a step already in steps setSteps([ ...steps.slice(0, existingStepIndex), - { ...stepBaseData, id }, + { ...stepBaseData, id: id ?? uuid() }, ...steps.slice(existingStepIndex + 1), ]) } else { - setSteps([...steps, { ...stepBaseData, id: stepId }]) + setSteps([...steps, { ...stepBaseData, id: uuid() }]) } setShowCreateNewStep(false) setShowEditCurrentStep(false) + setIsInEdit(false) } const header = showEdit ? ( @@ -207,6 +211,7 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { textDecoration={TYPOGRAPHY.textDecorationUnderline} onClick={() => { setShowEditCurrentStep(true) + setIsInEdit(true) }} > @@ -269,7 +274,13 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { temperatureRangeFieldValue(4, 96) ) }} - error={stepState.temp.error} + onBlur={() => { + setStepState({ + ...stepState, + temp: { ...stepState.temp, wasAccessed: true }, + }) + }} + error={stepState.temp.wasAccessed ? stepState.temp.error : null} /> { + setStepState({ + ...stepState, + time: { ...stepState.time, wasAccessed: true }, + }) + }} + error={stepState.time.wasAccessed ? stepState.time.error : null} /> @@ -303,7 +320,7 @@ export function ThermocyclerStep(props: ThermocyclerStepProps): JSX.Element { backgroundColor={backgroundColor} borderRadius={BORDERS.borderRadius4} > - {showHeader ? header : null} + {header} {showEdit ? editContent : null} ) diff --git a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx index 071006c4d3f..834306b7b76 100644 --- a/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx +++ b/protocol-designer/src/pages/Designer/ProtocolSteps/StepForm/StepTools/ThermocyclerTools/index.tsx @@ -15,18 +15,13 @@ import { ThermocyclerState } from './ThermocyclerState' import type { StepFormProps } from '../../types' -type ThermocyclerContentType = - | 'thermocyclerState' - | 'thermocyclerProfile' - | null +type ThermocyclerContentType = 'thermocyclerState' | 'thermocyclerProfile' export function ThermocyclerTools(props: StepFormProps): JSX.Element { const { propsForFields, formData, toolboxStep } = props const { t } = useTranslation('form') - console.log(formData) const [contentType, setContentType] = useState( - // (formData.thermocyclerFormType ?? null) as ThermocyclerContentType (formData.thermocyclerFormType as ThermocyclerContentType) ?? 'thermocyclerState' )