diff --git a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionOption/SelectionOption.tsx b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionOption/SelectionOption.tsx index 67815f762f..f9cdf0889c 100644 --- a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionOption/SelectionOption.tsx +++ b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionOption/SelectionOption.tsx @@ -105,7 +105,7 @@ export const SelectionOption = ({ } }; - const handleScoreChange = (event: ChangeEvent) => { + const handleScoreChange = (event: ChangeEvent) => { if (event.target.value === '') return setValue(scoreName, 0); setValue(scoreName, +event.target.value); diff --git a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionRows/Items/Items.tsx b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionRows/Items/Items.tsx index b174221af1..76227fac2b 100644 --- a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionRows/Items/Items.tsx +++ b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SelectionRows/Items/Items.tsx @@ -126,7 +126,7 @@ export const Items = ({ name, isSingle }: ItemsProps) => { const isRemoveButtonVisible = hasRemoveButton && key === options?.length - 1 && index !== 0; - const handleChange = (event: ChangeEvent) => { + const handleChange = (event: ChangeEvent) => { if (event.target.value === '') return setValue(scoreName, 0); setValue(scoreName, +event.target.value); diff --git a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SliderRows/SliderPanel/SliderPanel.tsx b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SliderRows/SliderPanel/SliderPanel.tsx index bc4938a08d..a06791fbe5 100644 --- a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SliderRows/SliderPanel/SliderPanel.tsx +++ b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/SliderRows/SliderPanel/SliderPanel.tsx @@ -123,7 +123,9 @@ export const SliderPanel = ({ hasAlerts && setValue(`${alertsName}.${index}.value`, ''); }; - const handleMinValueChange = async (event: ChangeEvent) => { + const handleMinValueChange = async ( + event: ChangeEvent, + ) => { const value = event.target.value === '' ? '' : +event.target.value; await setValue(minValueName, value); clearErrors([minValueName, maxValueName]); @@ -165,7 +167,7 @@ export const SliderPanel = ({ setDefaultScoresAndAlerts(); }; - const handleMaxValueChange = (event: ChangeEvent) => { + const handleMaxValueChange = (event: ChangeEvent) => { const value = event.target.value === '' ? '' : +event.target.value; clearErrors([minValueName, maxValueName]); setValue(maxValueName, value); diff --git a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/TextResponse/TextResponse.tsx b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/TextResponse/TextResponse.tsx index 512e5da2f0..e694a5e39b 100644 --- a/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/TextResponse/TextResponse.tsx +++ b/src/modules/Builder/features/ActivityItems/ItemConfiguration/InputTypeItems/TextResponse/TextResponse.tsx @@ -45,7 +45,9 @@ export const TextResponse = ({ name, uiType }: TextResponseProps) => { } }; - const handleResponseLengthOnChange = (event: ChangeEvent) => { + const handleResponseLengthOnChange = ( + event: ChangeEvent, + ) => { const newValue = +event.target.value; handleResponseLengthChange(newValue); }; diff --git a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoreContent/ScoreContent.tsx b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoreContent/ScoreContent.tsx index 134928ed4d..87a316795a 100644 --- a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoreContent/ScoreContent.tsx +++ b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoreContent/ScoreContent.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useFieldArray, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Box } from '@mui/material'; +import { Box, Button } from '@mui/material'; +import { generatePath, useNavigate, useParams } from 'react-router-dom'; import { useCheckAndTriggerOnNameUniqueness, @@ -11,22 +12,26 @@ import { import { StyledBodyLarge, StyledFlexColumn, + StyledFlexTopCenter, StyledFlexTopStart, + StyledLabelLarge, StyledObserverTarget, StyledTitleMedium, StyledTitleSmall, theme, + variables, } from 'shared/styles'; import { InputController, + RadioGroupController, SelectController, TransferListController, } from 'shared/components/FormComponents'; import { Svg } from 'shared/components/Svg'; -import { ScoreConditionalLogic, ScoreReport } from 'shared/state'; +import { ScoreConditionalLogic, ScoreReport, ScoreReportScoringType } from 'shared/state'; import { CalculationType, observerStyles } from 'shared/consts'; import { ToggleContainerUiType, ToggleItemContainer } from 'modules/Builder/components'; -import { getEntityKey } from 'shared/utils'; +import { getEntityKey, SettingParam } from 'shared/utils'; import { REACT_HOOK_FORM_KEY_NAME, SCORE_CONDS_COUNT_TO_ACTIVATE_STATIC, @@ -34,6 +39,9 @@ import { import { SelectEvent, isScoreReport } from 'shared/types'; import { getObserverSelector } from 'modules/Builder/utils/getObserverSelector'; import { useStaticContent } from 'shared/hooks/useStaticContent'; +import { SubscaleFormValue } from 'modules/Builder/types'; +import { page } from 'resources'; +import { DataTable } from 'shared/components'; import { StyledButton } from '../ScoresAndReports.styles'; import { SectionScoreHeader } from '../SectionScoreHeader'; @@ -58,6 +66,7 @@ import { } from './ScoreContent.utils'; import { ScoreContentProps } from './ScoreContent.types'; import { StaticScoreContent } from './StaticScoreContent'; +import { getTableScoreItems } from '../ScoresAndReports.utils'; export const ScoreContent = ({ name, @@ -70,6 +79,8 @@ export const ScoreContent = ({ isStaticActive, }: ScoreContentProps) => { const { t } = useTranslation('app'); + const navigate = useNavigate(); + const { appletId, activityId } = useParams(); const { control, setValue, getValues } = useCustomFormContext(); const [isChangeScoreIdPopupVisible, setIsChangeScoreIdPopupVisible] = useState(false); const [isRemoveConditionalPopupVisible, setIsRemoveConditionalPopupVisible] = useState(false); @@ -80,15 +91,50 @@ export const ScoreContent = ({ const reportsName = `${fieldName}.scoresAndReports.reports`; const scoreConditionalsName = `${name}.conditionalLogic`; + const scoreIdField = `${name}.id`; + const scoreNameField = `${name}.name`; + const calculationTypeField = `${name}.calculationType`; + const itemsScoreField = `${name}.itemsScore`; + const scoringTypeField = `${name}.scoringType`; + const subscaleNameField = `${name}.subscaleName`; - const score = useWatch({ name }); - const { name: scoreName, id: scoreId, calculationType, itemsScore } = score || {}; + const subscalesField = `${fieldName}.subscaleSetting.subscales`; + const subscales: SubscaleFormValue[] = useWatch({ name: subscalesField, defaultValue: [] }) ?? []; + + const score: ScoreReport = useWatch({ name }); + const { + name: scoreName, + id: scoreId, + calculationType, + itemsScore, + scoringType, + subscaleName, + } = score || {}; const [prevScoreName, setPrevScoreName] = useState(scoreName); const [prevCalculationType, setPrevCalculationType] = useState(calculationType); - const selectedItems = scoreItems?.filter( - (item) => itemsScore?.includes(getEntityKey(item, true)), - ); - const scoreRange = getScoreRange({ items: selectedItems, calculationType, activity }); + + const selectedItemsPredicate = (item: { id?: string; key?: string }) => + itemsScore?.includes(getEntityKey(item, true)); + const selectedItems = scoreItems?.filter(selectedItemsPredicate); + + const eligibleSubscales = subscales.filter(({ subscaleTableData, items }) => { + const hasLookupTable = !!subscaleTableData && subscaleTableData.length; + + // Subscales can contain only nested subscales, but they need at least one activity item + // because that's what the report score is calculated from + const hasNonSubscaleItems = + scoreItems?.filter((item) => items.includes(getEntityKey(item, true)))?.length > 0; + + return hasLookupTable && hasNonSubscaleItems; + }); + const linkedSubscale = eligibleSubscales.find(({ name }) => name === subscaleName); + + const scoreRange = getScoreRange({ + items: selectedItems, + calculationType, + activity, + lookupTable: scoringType === 'score' ? linkedSubscale?.subscaleTableData : null, + }); const scoreRangeLabel = selectedItems?.length ? getScoreRangeLabel(scoreRange) : EMPTY_SCORE_RANGE_LABEL; @@ -140,55 +186,122 @@ export const ScoreContent = ({ scoreId: newScoreId, }); - setValue(`${name}.id`, newScoreId); + setValue(scoreIdField, newScoreId); setPrevScoreName(scoreName); setPrevCalculationType(calculationType); }; const onCancelChangeScoreId = () => { setIsChangeScoreIdPopupVisible(false); - setValue(`${name}.name`, prevScoreName); - setValue(`${name}.calculationType`, prevCalculationType); + setValue(scoreNameField, prevScoreName); + setValue(calculationTypeField, prevCalculationType); }; - const handleCalculationChange = (event: SelectEvent) => { - const calculationType = event.target.value as CalculationType; - setPrevCalculationType(score.calculationType); + const handleCalculationChange = useCallback( + (event: { target: { value: string } }) => { + const calculationType = event.target.value as CalculationType; + setPrevCalculationType(score.calculationType); - const oldScoreId = getScoreId(prevScoreName, prevCalculationType); - const newScoreId = getScoreId(scoreName, calculationType); + const oldScoreId = getScoreId(prevScoreName, prevCalculationType); + const newScoreId = getScoreId(scoreName, calculationType); - if (oldScoreId !== newScoreId) { - const isVariable = getIsScoreIdVariable({ - id: score.id, - reports: getValues(reportsName), - isScore: true, - }); + if (oldScoreId !== newScoreId) { + const isVariable = getIsScoreIdVariable({ + id: score.id, + reports: getValues(reportsName), + isScore: true, + }); - if (isVariable) { - setIsChangeScoreIdPopupVisible(true); + if (isVariable) { + setIsChangeScoreIdPopupVisible(true); - return; + return; + } } - } - setValue(`${name}.id`, newScoreId); - setPrevCalculationType(calculationType); - updateScoreConditionIds({ - setValue, - conditionsName: scoreConditionalsName, - scoreId: newScoreId, - conditions: getValues(scoreConditionalsName), - }); - updateScoreConditionsPayload({ - setValue, + setValue(scoreIdField, newScoreId); + setPrevCalculationType(calculationType); + updateScoreConditionIds({ + setValue, + conditionsName: scoreConditionalsName, + scoreId: newScoreId, + conditions: getValues(scoreConditionalsName), + }); + updateScoreConditionsPayload({ + setValue, + getValues, + scoreConditionalsName, + selectedItems, + calculationType, + activity, + }); + }, + [ + activity, getValues, + name, + prevCalculationType, + prevScoreName, + reportsName, + score?.calculationType, + score?.id, scoreConditionalsName, + scoreName, selectedItems, + setValue, + ], + ); + + const handleLinkedSubscaleChange = useCallback( + (e: SelectEvent) => { + const subscaleName = e.target.value; + const newLinkedSubscale = eligibleSubscales.find(({ name }) => name === subscaleName); + + if (!newLinkedSubscale) return; + + setValue(subscaleNameField, subscaleName); + + if (scoringType === 'score') { + setValue(calculationTypeField, newLinkedSubscale.scoring); + + const eligibleItems = newLinkedSubscale.items.filter( + (item) => !!activity?.items.some((activityItem) => activityItem.id === item), + ); + setValue(itemsScoreField, eligibleItems); + + if (`${calculationType}` !== `${newLinkedSubscale.scoring}`) { + setValue(calculationTypeField, newLinkedSubscale.scoring); + handleCalculationChange({ target: { value: newLinkedSubscale.scoring } }); + } + } + }, + [ calculationType, - activity, - }); - }; + handleCalculationChange, + subscaleNameField, + name, + scoringType, + setValue, + eligibleSubscales, + ], + ); + + const handleScoreTypeChange = useCallback( + (e: SelectEvent) => { + const scoringType = e.target.value as ScoreReportScoringType; + setValue(scoringTypeField, scoringType); + + if (scoringType === 'score' && linkedSubscale) { + if (`${calculationType}` !== `${linkedSubscale.scoring}`) { + setValue(calculationTypeField, linkedSubscale.scoring); + handleCalculationChange({ target: { value: linkedSubscale.scoring } }); + } + + setValue(itemsScoreField, linkedSubscale.items); + } + }, + [calculationType, handleCalculationChange, linkedSubscale, name, setValue], + ); const handleNameBlur = () => { if (scoreName === prevScoreName) return; @@ -212,7 +325,7 @@ export const ScoreContent = ({ setPrevScoreName(scoreName); - setValue(`${name}.id`, newScoreId); + setValue(scoreIdField, newScoreId); updateScoreConditionIds({ setValue, conditionsName: scoreConditionalsName, @@ -235,6 +348,72 @@ export const ScoreContent = ({ }); }; + useEffect(() => { + // Account for changes made to the linked subscale on the subscale configuration screen + if (scoringType === 'score') { + if (linkedSubscale) { + if (`${calculationType}` !== `${linkedSubscale.scoring}`) { + setValue(calculationTypeField, linkedSubscale.scoring); + handleCalculationChange({ target: { value: linkedSubscale.scoring } }); + } + + setValue(itemsScoreField, linkedSubscale.items); + } else { + if (subscaleName) { + setValue(subscaleNameField, ''); + } + + if (eligibleSubscales.length <= 0) { + setValue(scoringTypeField, 'raw_score'); + } + } + } else { + if (subscaleName === null || subscaleName === undefined) { + // Account for scores that have been saved without a linked subscale + // This will remove the MUI controlled field warnings + setValue(subscaleNameField, ''); + } + + // Account for scores that have been saved without a scoring type + if (!scoringType) { + setValue(scoringTypeField, 'raw_score'); + } + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const ItemsList = () => { + if (scoringType === 'score' && linkedSubscale) { + return ( + + + + ); + } + + return ( + + ); + }; + return ( @@ -246,8 +425,8 @@ export const ScoreContent = ({ @@ -278,20 +459,79 @@ export const ScoreContent = ({ - {t('scoreItems')} - + {eligibleSubscales.length > 0 && ( + + {t('scoreContent.whichScoreType')} + + {scoringType === 'score' && ( + + ({ + value: name, + labelKey: name, + }))} + label={t('scoreContent.linkedSubscaleField.label')} + InputLabelProps={{ shrink: true }} + placeholder={t('scoreContent.linkedSubscaleField.placeholder')} + fullWidth + data-testid={`${dataTestid}-linked-subscale`} + customChange={handleLinkedSubscaleChange} + variant="outlined" + /> + {subscaleName && ( + + )} + + )} + + )} + + {t('scoreItems')} + + { return { maxScore, minScore }; }; -export const getScoreRange = ({ items = [], calculationType, activity }: GetScoreRange) => { +export const getScoreRange = ({ + items = [], + calculationType, + activity, + lookupTable, +}: GetScoreRange) => { let totalMinScore = 0, totalMaxScore = 0; const count = items.length; + const lookupTableScores = lookupTable + ?.map((it) => Number(it.score) || NaN) + ?.filter((score) => !isNaN(score)) ?? [NaN]; + const lookupTableMinScore = Math.min(...lookupTableScores); + const lookupTableMaxScore = Math.max(...lookupTableScores); + items.forEach((item) => { - const { minScore, maxScore } = getItemScoreRange(item); + const { minScore: itemMinScore, maxScore: itemMaxScore } = getItemScoreRange(item); if (!item.config.skippableItem && !activity?.isSkippable) { - totalMinScore += minScore; + totalMinScore += itemMinScore; } - totalMaxScore += maxScore; + totalMaxScore += itemMaxScore; }); + totalMinScore = isNaN(lookupTableMinScore) + ? totalMinScore + : Math.min(totalMinScore, lookupTableMinScore); + + totalMaxScore = isNaN(lookupTableMaxScore) + ? totalMaxScore + : Math.max(totalMaxScore, lookupTableMaxScore); + switch (calculationType) { case CalculationType.Sum: return { minScore: totalMinScore, maxScore: totalMaxScore }; diff --git a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.tsx b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.tsx index 583af1d0e2..211a7b8b84 100644 --- a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.tsx +++ b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.tsx @@ -19,7 +19,7 @@ import { useRedirectIfNoMatchedActivity, useCurrentActivity } from 'modules/Buil import { ToggleItemContainer, DndDroppable } from 'modules/Builder/components'; import { SettingParam, getEntityKey } from 'shared/utils'; import { useIsServerConfigured } from 'shared/hooks'; -import { ScoreOrSection, ScoreReport, SectionReport } from 'shared/state'; +import { ScoreOrSection, SectionReport } from 'shared/state'; import { page } from 'resources'; import { ScoreReportType } from 'shared/consts'; import { REACT_HOOK_FORM_KEY_NAME, REPORTS_COUNT_TO_ACTIVATE_STATIC } from 'modules/Builder/consts'; @@ -85,7 +85,7 @@ export const ScoresAndReports = () => { const dataTestid = 'builder-activity-settings-scores-and-reports'; const handleAddScore = () => { - appendReport(getScoreDefaults() as ScoreReport); + appendReport(getScoreDefaults()); }; const handleAddSection = () => { diff --git a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.test.ts b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.test.ts index bc39e0347f..da22b2a4ce 100644 --- a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.test.ts +++ b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.test.ts @@ -1,5 +1,6 @@ -import { ScoreOrSection } from 'shared/state'; +import { ScoreOrSection, ScoreReport, SectionReport } from 'shared/state'; import { mockedMultiActivityItem, mockedSingleActivityItem } from 'shared/mock'; +import { CalculationType, ScoreReportType } from 'shared/consts'; import { getReportIndex, @@ -13,12 +14,13 @@ jest.mock('uuid', () => ({ v4: () => 'mockedUudv4', })); -const firstScore = { - type: 'score', +const firstScore: ScoreReport = { + type: ScoreReportType.Score, key: 'scoreKey', name: 'firstScore', id: 'sumScore_firstscore', - calculationType: 'sum', + calculationType: CalculationType.Sum, + scoringType: 'raw_score', itemsScore: ['multiple', 'slider'], showMessage: true, printItems: false, @@ -26,22 +28,22 @@ const firstScore = { itemsPrint: [], conditionalLogic: [], }; -const firstSection = { - type: 'section', +const firstSection: SectionReport = { + type: ScoreReportType.Section, name: 'firstSection', - key: 'sectionKey', + id: 'first_section', showMessage: true, printItems: false, message: 'section message', itemsPrint: [], - conditionalLogic: null, }; -const secondScore = { - type: 'score', +const secondScore: ScoreReport = { + type: ScoreReportType.Score, name: 'secondScore', key: 'secondScoreKey', id: 'averageScore_secondscore', - calculationType: 'average', + calculationType: CalculationType.Average, + scoringType: 'raw_score', showMessage: true, printItems: false, itemsScore: ['single', 'multiple'], @@ -61,6 +63,8 @@ describe('getScoreDefaults', () => { id: 'sumScore_', calculationType: 'sum', itemsScore: [], + scoringType: 'raw_score', + subscaleName: '', showMessage: true, printItems: false, message: '', diff --git a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.ts b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.ts index 0c9dd4beea..676873bf10 100644 --- a/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.ts +++ b/src/modules/Builder/features/ActivitySettings/ScoresAndReports/ScoresAndReports.utils.ts @@ -2,19 +2,21 @@ import { v4 as uuidv4 } from 'uuid'; import { DataTableItem } from 'shared/components'; import { CalculationType, ScoreReportType } from 'shared/consts'; -import { ScoreOrSection } from 'shared/state'; +import { ScoreOrSection, ScoreReport, SectionReport } from 'shared/state'; import { ItemFormValues } from 'modules/Builder/types'; import { getEntityKey } from 'shared/utils'; import { removeMarkdown } from 'modules/Builder/utils'; import { getScoreId } from './ScoreContent/ScoreContent.utils'; -export const getScoreDefaults = () => ({ +export const getScoreDefaults = (): ScoreReport => ({ name: '', type: ScoreReportType.Score, key: uuidv4(), id: getScoreId('', CalculationType.Sum), calculationType: CalculationType.Sum, + scoringType: 'raw_score', + subscaleName: '', itemsScore: [], showMessage: true, printItems: false, @@ -22,7 +24,7 @@ export const getScoreDefaults = () => ({ itemsPrint: [], }); -export const getSectionDefaults = () => ({ +export const getSectionDefaults = (): SectionReport => ({ name: '', type: ScoreReportType.Section, id: uuidv4(), diff --git a/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscaleContent/SubscaleContent.tsx b/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscaleContent/SubscaleContent.tsx index 57db1bb9c2..9d35884ce0 100644 --- a/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscaleContent/SubscaleContent.tsx +++ b/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscaleContent/SubscaleContent.tsx @@ -1,5 +1,6 @@ import { useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { useEffect } from 'react'; import { StyledFlexColumn, StyledFlexTopStart, StyledTitleMedium, theme } from 'shared/styles'; import { @@ -15,6 +16,7 @@ import { import { DataTable } from 'shared/components/DataTable'; import { SubscaleFormValue } from 'modules/Builder/types'; import { checkOnItemTypeAndScore } from 'shared/utils/checkOnItemTypeAndScore'; +import { useLinkedScoreReports } from 'modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscalesConfiguration.hooks'; import { scoreValues } from './SubscaleContent.const'; import { SubscaleContentProps } from '../SubscalesConfiguration.types'; @@ -35,18 +37,30 @@ export const SubscaleContent = ({ const { control } = useCustomFormContext(); const { fieldName = '', activity } = useCurrentActivity(); const subscalesField = `${fieldName}.subscaleSetting.subscales`; - const subscales: SubscaleFormValue[] = useWatch({ name: subscalesField }) ?? []; + const subscales: SubscaleFormValue[] = useWatch({ name: subscalesField }); + const subscaleName: string = useWatch({ name: `${name}.name` }); const items = getItemElements( subscaleId, activity?.items.filter(checkOnItemTypeAndScore), - subscales, + subscales || [], ); + const { updateSubscaleNameInReports, hasNonSubscaleItems, removeReportScoreLink } = + useLinkedScoreReports(); + useCheckAndTriggerOnNameUniqueness({ currentPath: name, entitiesFieldPath: subscalesField, }); + useEffect(() => { + const subscale = subscales?.find((subscale) => subscale.id === subscaleId); + + if (subscale && !hasNonSubscaleItems(subscale.items)) { + removeReportScoreLink(subscale); + } + }, [hasNonSubscaleItems, removeReportScoreLink, subscaleId, subscales]); + return ( @@ -56,6 +70,11 @@ export const SubscaleContent = ({ label={t('subscaleName')} data-testid={`${dataTestid}-name`} withDebounce + onChange={(e, onChange) => { + onChange(); + // Also update the name of this subscale in any score reports that are linked to it + updateSubscaleNameInReports(subscaleName, e.target.value); + }} /> ) => void; + + /** + * Update the name of a subscale in any linked score reports + */ + updateSubscaleNameInReports: (oldSubscaleName: string, newSubscaleName: string) => void; + + /** + * Check if there are any non-subscales in the list of subscale items + */ + hasNonSubscaleItems: (subscaleItems: ActivitySettingsSubscale['items']) => boolean; +}; + +/** + * A hook that returns some utility functions for keeping report scores that are linked to subscales + * up to date. It also ensures that linked scores are reset to 'raw_score' if there are no more + * eligible subscales + */ +export const useLinkedScoreReports = (): UseLinkedScoreReportsReturn => { + const { control: appletFormControl } = useFormContext(); + const { fieldName: currentActivityFieldName, activity: currentActivity } = useCurrentActivity(); + const reportsField = `${currentActivityFieldName}.scoresAndReports.reports`; + const { fields: scoreOrSectionArray, update: updateReport } = useFieldArray< + Record, + string, + typeof REACT_HOOK_FORM_KEY_NAME + >({ + control: appletFormControl, + name: reportsField, + keyName: REACT_HOOK_FORM_KEY_NAME, + }); + + const subscalesField = `${currentActivityFieldName}.subscaleSetting.subscales`; + const subscales: SubscaleFormValue[] = useWatch({ name: subscalesField, defaultValue: [] }) ?? []; + + const hasNonSubscaleItems = useCallback( + (subscaleItems: ActivitySettingsSubscale['items']) => { + const nonSubscaleItems = + currentActivity?.items?.filter((item) => + subscaleItems.includes(getEntityKey(item, true)), + ) ?? []; + + return nonSubscaleItems.length > 0; + }, + [currentActivity?.items], + ); + + const eligibleSubscales = subscales.filter(({ subscaleTableData, items }) => { + const hasLookupTable = !!subscaleTableData && subscaleTableData.length; + + return hasLookupTable && hasNonSubscaleItems(items); + }); + + const linkedScores = scoreOrSectionArray.filter( + (report) => report.type === 'score' && report.scoringType === 'score', + ) as ScoreReport[]; + + /** + * Remove this subscale from any report scores that are linked to it + */ + const removeReportScoreLink = useCallback( + (subscale: ActivitySettingsSubscale) => { + linkedScores.forEach((scoreReport, index) => { + if (scoreReport.subscaleName === subscale.name) { + const updatedReport: ScoreReport = { + ...scoreReport, + subscaleName: '', + }; + updateReport(index, updatedReport); + } + }); + }, + [linkedScores, updateReport], + ); + + const updateSubscaleNameInReports = useCallback( + (oldSubscaleName: string, newSubscaleName: string) => { + const isEligibleSubscale = eligibleSubscales.some( + (subscale) => subscale.name === oldSubscaleName, + ); + if (!isEligibleSubscale) return; + + linkedScores.forEach((scoreReport, index) => { + if (scoreReport.subscaleName === oldSubscaleName) { + const updatedReport: ScoreReport = { + ...scoreReport, + subscaleName: newSubscaleName, + }; + updateReport(index, updatedReport); + } + }); + }, + [eligibleSubscales, linkedScores, updateReport], + ); + + useEffect(() => { + if (eligibleSubscales.length === 0) { + // If there are no more eligible subscales, then these linked scores should be reset to + // 'raw_score' instead of 'score'. Otherwise, the admin will see an error reported in the UI + // but there will be nothing to fix when they go back to the reports screen + linkedScores.forEach((scoreReport, index) => { + const updatedReport: ScoreReport = { + ...scoreReport, + subscaleName: '', + scoringType: 'raw_score', + }; + updateReport(index, updatedReport); + }); + } + }, [eligibleSubscales, linkedScores, updateReport]); + + return { removeReportScoreLink, updateSubscaleNameInReports, hasNonSubscaleItems }; +}; diff --git a/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscalesConfiguration.tsx b/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscalesConfiguration.tsx index 0d454048ed..50a547ac55 100644 --- a/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscalesConfiguration.tsx +++ b/src/modules/Builder/features/ActivitySettings/SubscalesConfiguration/SubscalesConfiguration.tsx @@ -38,7 +38,10 @@ import { } from './SubscalesConfiguration.styles'; import { SubscaleContentProps } from './SubscalesConfiguration.types'; import { LookupTable } from './LookupTable'; -import { useSubscalesSystemItemsSetup } from './SubscalesConfiguration.hooks'; +import { + useLinkedScoreReports, + useSubscalesSystemItemsSetup, +} from './SubscalesConfiguration.hooks'; export const SubscalesConfiguration = () => { const { t } = useTranslation('app'); @@ -59,6 +62,9 @@ export const SubscalesConfiguration = () => { control, name: subscalesField, }); + + const { removeReportScoreLink } = useLinkedScoreReports(); + const calculateTotalScore = watch(calculateTotalScoreField); const [calculateTotalScoreSwitch, setCalculateTotalScoreSwitch] = useState(!!calculateTotalScore); const [isLookupTableOpened, setIsLookupTableOpened] = useState(false); @@ -153,6 +159,7 @@ export const SubscalesConfiguration = () => { headerContentProps={{ onRemove: () => { removeSubscale(index); + removeReportScoreLink(subscale); }, name: subscaleField, title, @@ -161,6 +168,10 @@ export const SubscalesConfiguration = () => { ...subscale, subscaleTableData, }); + + if (!subscaleTableData) { + removeReportScoreLink(subscale); + } }, 'data-testid': subscaleDataTestid, }} diff --git a/src/modules/Builder/pages/BuilderApplet/BuilderApplet.schema.ts b/src/modules/Builder/pages/BuilderApplet/BuilderApplet.schema.ts index 87878b80e8..31aaff650e 100644 --- a/src/modules/Builder/pages/BuilderApplet/BuilderApplet.schema.ts +++ b/src/modules/Builder/pages/BuilderApplet/BuilderApplet.schema.ts @@ -20,7 +20,13 @@ import { PerfTaskType, ScoreReportType, } from 'shared/consts'; -import { Condition, Config, PhrasalTemplateField, ScoreOrSection } from 'shared/state'; +import { + Condition, + Config, + PhrasalTemplateField, + ScoreOrSection, + ScoreReportScoringType, +} from 'shared/state'; import { createRegexFromList, getEntityKey, @@ -921,6 +927,14 @@ export const ScoreOrSectionSchema = () => then: (schema) => schema.required(), otherwise: (schema) => schema.nullable(), }), + // Technically this field is required, but it may be null in existing data + // so we enforce this instead in the UI + scoringType: yup.string().oneOf(ScoreReportScoringType).nullable(), + subscaleName: yup.string().when('scoringType', { + is: 'score', + then: (schema) => schema.required(t('subscaleNameRequired')), + otherwise: (schema) => schema.nullable(), + }), id: yup.string().when('type', { is: ScoreReportType.Score, then: (schema) => schema.required(), diff --git a/src/modules/Dashboard/features/Applet/Popups/DuplicatePopups/DuplicatePopups.tsx b/src/modules/Dashboard/features/Applet/Popups/DuplicatePopups/DuplicatePopups.tsx index 9b0dd8a08d..3f26391df3 100644 --- a/src/modules/Dashboard/features/Applet/Popups/DuplicatePopups/DuplicatePopups.tsx +++ b/src/modules/Dashboard/features/Applet/Popups/DuplicatePopups/DuplicatePopups.tsx @@ -174,7 +174,7 @@ export const DuplicatePopups = ({ onCloseCallback }: { onCloseCallback?: () => v await executeGetNameSecond({ name: getValues('name') }); }; - const handleNameChange = (event: ChangeEvent) => { + const handleNameChange = (event: ChangeEvent) => { setValue('name', event.target.value); setNameError(null); trigger('name'); diff --git a/src/modules/Dashboard/features/Applet/TransferOwnership/TransferOwnership.tsx b/src/modules/Dashboard/features/Applet/TransferOwnership/TransferOwnership.tsx index bb51d37116..4267c8f416 100644 --- a/src/modules/Dashboard/features/Applet/TransferOwnership/TransferOwnership.tsx +++ b/src/modules/Dashboard/features/Applet/TransferOwnership/TransferOwnership.tsx @@ -57,7 +57,9 @@ export const TransferOwnership = forwardRef) => { + const handleEmailCustomChange = ( + event: ChangeEvent, + ) => { setValue(emailInputName, event.target.value); if (!error) return; diff --git a/src/modules/Dashboard/features/Respondents/Popups/EditRespondentPopup/EditRespondentPopup.tsx b/src/modules/Dashboard/features/Respondents/Popups/EditRespondentPopup/EditRespondentPopup.tsx index 818801c173..d816354997 100644 --- a/src/modules/Dashboard/features/Respondents/Popups/EditRespondentPopup/EditRespondentPopup.tsx +++ b/src/modules/Dashboard/features/Respondents/Popups/EditRespondentPopup/EditRespondentPopup.tsx @@ -90,7 +90,7 @@ export const EditRespondentPopup = ({ }); }; - const handleChangeSecretId = (event: ChangeEvent) => { + const handleChangeSecretId = (event: ChangeEvent) => { setValue('secretUserId', event.target.value); setIsServerErrorVisible(false); trigger('secretUserId'); diff --git a/src/resources/app-en.json b/src/resources/app-en.json index fa6b0c35a6..ae09f29ced 100644 --- a/src/resources/app-en.json +++ b/src/resources/app-en.json @@ -854,6 +854,7 @@ "linkSuccessfullyCopied": "Link successfully copied", "linkText": "Link text", "linkUrl": "Link url", + "subscaleNameRequired": "Please select a valid Subscale", "liveResponseStreaming": "Live Response Streaming", "liveResponseStreamingDescription": "Send responses to a server of your choice, as they are being entered.", "liveResponseStreamingLabel": "Enable streaming of response data", @@ -1381,6 +1382,22 @@ "submit": "Submit", "subscale": "Subscale", "subscaleHeader": "Subscale {{index}}: {{name}}", + "scoreContent": { + "whichScoreType": "Which score type would you like to use?", + "scoreRadioBtn": { + "label": "Score", + "tooltip": "Select 'Score' to include the converted Score from the Lookup Table in the PDF report. This score will only appear in the PDF report and will not be displayed in the web or mobile app." + }, + "rawScoreRadioBtn": { + "label": "Raw Score", + "tooltip": "Select 'Raw Score' to choose the items used to calculate a raw score." + }, + "linkedSubscaleField": { + "label": "Linked Subscale", + "placeholder": "Select a subscale to link" + }, + "viewSubscaleConfiguration": "View Subscale Configuration" + }, "subscaleLookupTable": { "column": { "age": "Age", diff --git a/src/resources/app-fr.json b/src/resources/app-fr.json index a968f6263e..985f8c8580 100644 --- a/src/resources/app-fr.json +++ b/src/resources/app-fr.json @@ -854,6 +854,7 @@ "linkSuccessfullyCopied": "Lien copié avec succès", "linkText": "Texte du Lien", "linkUrl": "URL du Lien", + "subscaleNameRequired": "Veuillez sélectionner une sous-échelle valide", "liveResponseStreaming": "Diffusion en direct des réponses", "liveResponseStreamingDescription": "Envoyez les réponses à un serveur de votre choix au fur et à mesure de leur saisie.", "liveResponseStreamingLabel": "Activer la diffusion en continu des données de réponse", @@ -1379,6 +1380,22 @@ "submit": "Soumettre", "subscale": "Sous-échelle", "subscaleHeader": "Sous-échelle {{index}} : {{name}}", + "scoreContent": { + "whichScoreType": "Quel type de score souhaitez-vous utiliser ?", + "scoreRadioBtn": { + "label": "Score", + "tooltip": "Sélectionnez « Score » pour inclure le score converti à partir de la table de recherche dans le rapport PDF. Ce score apparaîtra uniquement dans le rapport PDF et ne sera pas affiché dans l'application Web ou mobile." + }, + "rawScoreRadioBtn": { + "label": "Score brut", + "tooltip": "Sélectionnez « Score brut » pour choisir les éléments utilisés pour calculer un score brut." + }, + "linkedSubscaleField": { + "label": "Sous-échelle liée", + "placeholder": "Sélectionnez une sous-échelle à lier" + }, + "viewSubscaleConfiguration": "Afficher la configuration de sous-échelle" + }, "subscaleLookupTable": { "column": { "age": "Âge", diff --git a/src/shared/components/FormComponents/InputController/Input/Input.tsx b/src/shared/components/FormComponents/InputController/Input/Input.tsx index 6052050e25..7e0384debf 100644 --- a/src/shared/components/FormComponents/InputController/Input/Input.tsx +++ b/src/shared/components/FormComponents/InputController/Input/Input.tsx @@ -77,17 +77,23 @@ export const Input = ({ } }; const handleChange = (event: SelectEvent) => { - if (onCustomChange) return onCustomChange(event); - const newValue = event.target.value; - if (restrictExceededValueLength && newValue && maxLength && newValue.length > maxLength) return; - const getNumberValue = () => { - if (!isNumberType) return undefined; - if (isControlledNumberValue) return +newValue; + const handleChangeLogic = () => { + const newValue = event.target.value; + if (restrictExceededValueLength && newValue && maxLength && newValue.length > maxLength) + return; + const getNumberValue = () => { + if (!isNumberType) return undefined; + if (isControlledNumberValue) return +newValue; - return newValue === '' ? '' : +newValue; + return newValue === '' ? '' : +newValue; + }; + + onChange?.(getNumberValue() ?? newValue); }; - onChange?.(getNumberValue() ?? newValue); + if (onCustomChange) return onCustomChange(event, handleChangeLogic); + + handleChangeLogic(); }; const handleDebouncedChange = debounce( (event: SelectEvent) => handleChange(event), diff --git a/src/shared/components/FormComponents/InputController/Input/Input.types.ts b/src/shared/components/FormComponents/InputController/Input/Input.types.ts index f406fe47e4..b76eb2a67e 100644 --- a/src/shared/components/FormComponents/InputController/Input/Input.types.ts +++ b/src/shared/components/FormComponents/InputController/Input/Input.types.ts @@ -7,7 +7,10 @@ import { FormInputProps } from '../InputController.types'; export type InputProps = FormInputProps & { onChange: (value: string | number) => void; value: FieldPathValue>; - onCustomChange?: (event: ChangeEvent) => void; + onCustomChange?: ( + event: ChangeEvent, + onChange: () => void, + ) => void; }; export type GetTextAdornment = { diff --git a/src/shared/components/FormComponents/InputController/InputController.types.ts b/src/shared/components/FormComponents/InputController/InputController.types.ts index 37e91e02a2..a95ca1a04e 100644 --- a/src/shared/components/FormComponents/InputController/InputController.types.ts +++ b/src/shared/components/FormComponents/InputController/InputController.types.ts @@ -1,4 +1,4 @@ -import { FC, PropsWithChildren } from 'react'; +import { ChangeEvent, FC, PropsWithChildren } from 'react'; import { TextFieldProps } from '@mui/material/TextField'; import { FieldValues, UseControllerProps } from 'react-hook-form'; @@ -30,4 +30,10 @@ export type FormInputProps = { 'data-testid'?: string; } & TextFieldProps; -export type InputControllerProps = FormInputProps & UseControllerProps; +export type InputControllerProps = Omit & + UseControllerProps & { + onChange?: ( + event: ChangeEvent, + onChange: () => void, + ) => void; + }; diff --git a/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.tsx b/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.tsx index bb032fcc85..0a0b838c5f 100644 --- a/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.tsx +++ b/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.tsx @@ -2,6 +2,9 @@ import { RadioGroup, Radio } from '@mui/material'; import { Controller, FieldValues } from 'react-hook-form'; import uniqueId from 'lodash.uniqueid'; +import { StyledFlexTopCenter, StyledTooltipSvg } from 'shared/styles'; +import { Tooltip } from 'shared/components/Tooltip'; + import { RadioGroupControllerProps } from './RadioGroupController.types'; import { StyledFormControlLabel } from './RadioGroupController.styles'; @@ -10,6 +13,7 @@ export const RadioGroupController = ({ control, options, defaultValue, + onChange, 'data-testid': dataTestid, }: RadioGroupControllerProps) => ( ({ name={name} defaultValue={defaultValue} render={({ field }) => ( - - {options?.map(({ value, label, disabled }, index) => ( - } - label={label} - checked={value === field.value} - disabled={disabled} - data-testid={`${dataTestid}-${index}`} - /> + { + if (onChange) { + onChange(event, value, field.onChange); + } + + field.onChange(event, value); + }} + > + {options?.map(({ value, label, disabled, tooltipText }, index) => ( + + } + label={label} + checked={value === field.value} + disabled={disabled} + data-testid={`${dataTestid}-${index}`} + /> + {tooltipText && ( + + + + + + )} + ))} )} diff --git a/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.types.ts b/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.types.ts index 452491cf7f..5aa7af5bb9 100644 --- a/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.types.ts +++ b/src/shared/components/FormComponents/RadioGroupController/RadioGroupController.types.ts @@ -1,10 +1,18 @@ +import { ChangeEvent } from 'react'; import { FormControlLabelProps, RadioGroupProps } from '@mui/material'; import { FieldValues, UseControllerProps } from 'react-hook-form'; type FormRadioGroupProps = { - options: Omit[]; + options: (Omit & { + tooltipText?: string; + })[]; 'data-testid'?: string; } & RadioGroupProps; -export type RadioGroupControllerProps = FormRadioGroupProps & - UseControllerProps; +export type RadioGroupControllerProps = Omit< + FormRadioGroupProps, + 'onChange' +> & + UseControllerProps & { + onChange?: (event: ChangeEvent, value: string, onChange: () => void) => void; + }; diff --git a/src/shared/features/AppletSettings/LiveResponseStreamingSetting/LiveResponseStreamingSetting.tsx b/src/shared/features/AppletSettings/LiveResponseStreamingSetting/LiveResponseStreamingSetting.tsx index 9408631674..13908dd593 100644 --- a/src/shared/features/AppletSettings/LiveResponseStreamingSetting/LiveResponseStreamingSetting.tsx +++ b/src/shared/features/AppletSettings/LiveResponseStreamingSetting/LiveResponseStreamingSetting.tsx @@ -66,7 +66,9 @@ export const LiveResponseStreamingSetting = () => { name={ipAddressFieldName} control={control} label={t('defaultIpAddress')} - onChange={({ target: { value } }: ChangeEvent) => + onChange={({ + target: { value }, + }: ChangeEvent) => onInputChange({ value, fieldName: ipAddressFieldName }) } withDebounce @@ -79,7 +81,9 @@ export const LiveResponseStreamingSetting = () => { name={portFieldName} control={control} label={t('defaultPort')} - onChange={({ target: { value } }: ChangeEvent) => + onChange={({ + target: { value }, + }: ChangeEvent) => onInputChange({ value, fieldName: portFieldName }) } withDebounce diff --git a/src/shared/state/Applet/Applet.schema.ts b/src/shared/state/Applet/Applet.schema.ts index 59be559786..52d33ec587 100644 --- a/src/shared/state/Applet/Applet.schema.ts +++ b/src/shared/state/Applet/Applet.schema.ts @@ -815,15 +815,24 @@ export type ScoreConditionalLogic = { conditions: Condition[]; }; +export const ScoreReportScoringType = ['score', 'raw_score'] as const; + +export type ScoreReportScoringType = (typeof ScoreReportScoringType)[number]; + export type ScoreReport = { id: string; key: string; name: string; type: ScoreReportType.Score; + /** Whether to show raw score or T scores in the report */ + scoringType: ScoreReportScoringType; calculationType: CalculationType; showMessage: boolean; printItems: boolean; itemsScore: string[]; + + /** The name of a subscale to use for a lookup table, if `scoringType` is set to "score" */ + subscaleName?: string; itemsPrint?: string[]; message?: string; minScore?: number;