From dd02a1a3213c033426bcb529266e1cf85b84cf84 Mon Sep 17 00:00:00 2001 From: Daniel Gibbons Date: Sun, 22 Dec 2024 15:38:12 -0300 Subject: [PATCH 1/6] export option type --- components/predict-form/PredictProvider.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/predict-form/PredictProvider.tsx b/components/predict-form/PredictProvider.tsx index 9e1da81..e4baf66 100644 --- a/components/predict-form/PredictProvider.tsx +++ b/components/predict-form/PredictProvider.tsx @@ -26,6 +26,8 @@ const optionSchema = z.object({ .optional(), }) +export type OptionType = z.infer + const unifiedPredictFormSchema = z .object({ question: z From 45cf930fb08fe5fe07f35fa2192980a8e4114931 Mon Sep 17 00:00:00 2001 From: Daniel Gibbons Date: Sun, 22 Dec 2024 15:38:29 -0300 Subject: [PATCH 2/6] add basic sum to 100 logic --- .../question-types/MultiChoiceQuestion.tsx | 80 ++++++++++++++++--- 1 file changed, 68 insertions(+), 12 deletions(-) diff --git a/components/predict-form/question-types/MultiChoiceQuestion.tsx b/components/predict-form/question-types/MultiChoiceQuestion.tsx index 68c3b6c..5bb9e0a 100644 --- a/components/predict-form/question-types/MultiChoiceQuestion.tsx +++ b/components/predict-form/question-types/MultiChoiceQuestion.tsx @@ -8,6 +8,8 @@ import { PredictButton } from "../PredictButton" import { QuestionOption } from "../QuestionOption" import { ResolveBy } from "../ResolveBy" import { QuestionTypeProps } from "./question-types" +import { OptionType } from "../PredictProvider" +import { formatDecimalNicely } from "../../../lib/_utils_common" export default function MultiChoiceQuestion({ small, @@ -17,7 +19,7 @@ export default function MultiChoiceQuestion({ onSubmit, highlightResolveBy, }: QuestionTypeProps) { - const { trigger, control } = useFormContext() + const { trigger, control, watch, setValue } = useFormContext() const MIN_OPTIONS = 2 const MAX_OPTIONS = 100 @@ -40,6 +42,50 @@ export default function MultiChoiceQuestion({ [remove], ) + const options = watch("options") as OptionType[] + const normalizeToHundred = useCallback(() => { + const optionsWithForecasts = options.filter( + ({ forecast }) => forecast !== undefined && !Number.isNaN(forecast) + ) as Array + + const optionsWithoutForecasts = options.filter( + ({ forecast }) => forecast === undefined || Number.isNaN(forecast) + ) + + const allOptionsHaveForecasts = optionsWithForecasts.length === options.length + + if (allOptionsHaveForecasts) { + const totalPercentage = optionsWithForecasts.reduce( + (acc, option) => acc + option.forecast, + 0, + ) + optionsWithForecasts.forEach((option, index) => { + const scaledValue = (option.forecast! * 100) / totalPercentage + setValue( + `options.${index}.forecast`, + formatDecimalNicely(scaledValue, 0), + ) + }) + } else if (!allOptionsHaveForecasts) { + const totalPercentage = optionsWithForecasts.reduce( + (sum, option) => sum + option.forecast, + 0, + ) + + if (totalPercentage > 100) return + + const remainingMass = Math.max(0, 100 - totalPercentage) + const massPerOption = remainingMass / optionsWithoutForecasts.length + const roundedMassPerOption = formatDecimalNicely(massPerOption, 0) + optionsWithoutForecasts.forEach((option) => { + const originalIndex = options.findIndex((o) => o === option) + setValue(`options.${originalIndex}.forecast`, roundedMassPerOption) + }) + } + + void trigger("options") + }, [options, setValue, trigger]) + return (
))} - {fields.length < MAX_OPTIONS && ( -
-
+
+
+ + + { -
-
-
+ }
- )} +
+
+
{ - void trigger("options") // needed because our superRefine rule for summing to 100% isn't automatically revalidated otherwise + void trigger("options") }} />
From def31eb1e11bace60f36b790d95f10d8b3000655 Mon Sep 17 00:00:00 2001 From: Daniel Gibbons Date: Sun, 22 Dec 2024 16:00:20 -0300 Subject: [PATCH 3/6] seperate logic from display --- .../question-types/MultiChoiceQuestion.tsx | 42 +++-------------- lib/_utils_multiple-choice.ts | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 37 deletions(-) create mode 100644 lib/_utils_multiple-choice.ts diff --git a/components/predict-form/question-types/MultiChoiceQuestion.tsx b/components/predict-form/question-types/MultiChoiceQuestion.tsx index 5bb9e0a..b9a2009 100644 --- a/components/predict-form/question-types/MultiChoiceQuestion.tsx +++ b/components/predict-form/question-types/MultiChoiceQuestion.tsx @@ -9,6 +9,7 @@ import { QuestionOption } from "../QuestionOption" import { ResolveBy } from "../ResolveBy" import { QuestionTypeProps } from "./question-types" import { OptionType } from "../PredictProvider" +import { normalizeOptionsToHundred } from "../../../lib/_utils_multiple-choice" import { formatDecimalNicely } from "../../../lib/_utils_common" export default function MultiChoiceQuestion({ @@ -44,44 +45,11 @@ export default function MultiChoiceQuestion({ const options = watch("options") as OptionType[] const normalizeToHundred = useCallback(() => { - const optionsWithForecasts = options.filter( - ({ forecast }) => forecast !== undefined && !Number.isNaN(forecast) - ) as Array + const normalizedOptions = normalizeOptionsToHundred(options) - const optionsWithoutForecasts = options.filter( - ({ forecast }) => forecast === undefined || Number.isNaN(forecast) - ) - - const allOptionsHaveForecasts = optionsWithForecasts.length === options.length - - if (allOptionsHaveForecasts) { - const totalPercentage = optionsWithForecasts.reduce( - (acc, option) => acc + option.forecast, - 0, - ) - optionsWithForecasts.forEach((option, index) => { - const scaledValue = (option.forecast! * 100) / totalPercentage - setValue( - `options.${index}.forecast`, - formatDecimalNicely(scaledValue, 0), - ) - }) - } else if (!allOptionsHaveForecasts) { - const totalPercentage = optionsWithForecasts.reduce( - (sum, option) => sum + option.forecast, - 0, - ) - - if (totalPercentage > 100) return - - const remainingMass = Math.max(0, 100 - totalPercentage) - const massPerOption = remainingMass / optionsWithoutForecasts.length - const roundedMassPerOption = formatDecimalNicely(massPerOption, 0) - optionsWithoutForecasts.forEach((option) => { - const originalIndex = options.findIndex((o) => o === option) - setValue(`options.${originalIndex}.forecast`, roundedMassPerOption) - }) - } + normalizedOptions.forEach((option, index) => { + setValue(`options.${index}.forecast`, formatDecimalNicely((option.forecast || 0), 0)) + }) void trigger("options") }, [options, setValue, trigger]) diff --git a/lib/_utils_multiple-choice.ts b/lib/_utils_multiple-choice.ts new file mode 100644 index 0000000..23b5760 --- /dev/null +++ b/lib/_utils_multiple-choice.ts @@ -0,0 +1,45 @@ +import { OptionType } from "../components/predict-form/PredictProvider" + +export function normalizeOptionsToHundred(options: OptionType[]) { + const optionsWithForecasts = options.filter( + ({ forecast }) => forecast !== undefined && !Number.isNaN(forecast) + ) as Array + + const optionsWithoutForecasts = options.filter( + ({ forecast }) => forecast === undefined || Number.isNaN(forecast) + ) + + const allOptionsHaveForecasts = optionsWithForecasts.length === options.length + const normalizedOptions = [...options] + + if (allOptionsHaveForecasts) { + const totalPercentage = optionsWithForecasts.reduce( + (acc, option) => acc + option.forecast, + 0 + ) + optionsWithForecasts.forEach((option, index) => { + const scaledValue = (option.forecast! * 100) / totalPercentage + normalizedOptions[index] = { + ...option, + forecast: scaledValue + } + }) + } else { + const totalPercentage = optionsWithForecasts.reduce( + (sum, option) => sum + option.forecast, + 0 + ) + const remainingMass = Math.max(0, 100 - totalPercentage) + const massPerOption = remainingMass / optionsWithoutForecasts.length + + optionsWithoutForecasts.forEach((option) => { + const originalIndex = options.findIndex((o) => o === option) + normalizedOptions[originalIndex] = { + ...option, + forecast: massPerOption + } + }) + } + + return normalizedOptions +} \ No newline at end of file From acd4215fed07500e8e20c3edbc4f474649335d1d Mon Sep 17 00:00:00 2001 From: Daniel Gibbons Date: Sun, 22 Dec 2024 16:18:10 -0300 Subject: [PATCH 4/6] refactor normalizeOptions --- lib/_utils_multiple-choice.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/lib/_utils_multiple-choice.ts b/lib/_utils_multiple-choice.ts index 23b5760..631c601 100644 --- a/lib/_utils_multiple-choice.ts +++ b/lib/_utils_multiple-choice.ts @@ -1,45 +1,40 @@ import { OptionType } from "../components/predict-form/PredictProvider" export function normalizeOptionsToHundred(options: OptionType[]) { - const optionsWithForecasts = options.filter( + const optionsCopy = [...options] + const optionsWithForecasts = optionsCopy.filter( ({ forecast }) => forecast !== undefined && !Number.isNaN(forecast) ) as Array - - const optionsWithoutForecasts = options.filter( + const optionsWithoutForecasts = optionsCopy.filter( ({ forecast }) => forecast === undefined || Number.isNaN(forecast) ) - const allOptionsHaveForecasts = optionsWithForecasts.length === options.length - const normalizedOptions = [...options] + const allOptionsHaveForecasts = optionsWithForecasts.length === optionsCopy.length + const totalPercentage = optionsWithForecasts.reduce( + (acc, option) => acc + option.forecast, + 0 + ) if (allOptionsHaveForecasts) { - const totalPercentage = optionsWithForecasts.reduce( - (acc, option) => acc + option.forecast, - 0 - ) optionsWithForecasts.forEach((option, index) => { - const scaledValue = (option.forecast! * 100) / totalPercentage - normalizedOptions[index] = { + const scaledValue = (option.forecast * 100) / totalPercentage + optionsCopy[index] = { ...option, forecast: scaledValue } }) } else { - const totalPercentage = optionsWithForecasts.reduce( - (sum, option) => sum + option.forecast, - 0 - ) const remainingMass = Math.max(0, 100 - totalPercentage) const massPerOption = remainingMass / optionsWithoutForecasts.length optionsWithoutForecasts.forEach((option) => { const originalIndex = options.findIndex((o) => o === option) - normalizedOptions[originalIndex] = { + optionsCopy[originalIndex] = { ...option, forecast: massPerOption } }) } - return normalizedOptions + return optionsCopy } \ No newline at end of file From c9ef26ce3aba404fcbcca65a4da5a22b83df118e Mon Sep 17 00:00:00 2001 From: Daniel Gibbons Date: Sun, 22 Dec 2024 16:42:44 -0300 Subject: [PATCH 5/6] add (failing) test --- .../MultiChoiceQuestion.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 components/predict-form/question-types/MultiChoiceQuestion.test.tsx diff --git a/components/predict-form/question-types/MultiChoiceQuestion.test.tsx b/components/predict-form/question-types/MultiChoiceQuestion.test.tsx new file mode 100644 index 0000000..cb7c554 --- /dev/null +++ b/components/predict-form/question-types/MultiChoiceQuestion.test.tsx @@ -0,0 +1,47 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import MultiChoiceQuestion from './MultiChoiceQuestion'; +import { PredictProvider } from '../PredictProvider'; + +describe('MultiChoiceQuestion', () => { + const mockQuestion = { + id: 'test-question', + type: 'multiple-choice', + title: 'Test Question', + options: [ + { id: 'option-1', text: 'Option 1' }, + { id: 'option-2', text: 'Option 2' }, + { id: 'option-3', text: 'Option 3' }, + ], + }; + + const defaultProps = { + question: mockQuestion, + predictions: {}, + onChange: jest.fn(), + onSubmit: jest.fn(), + highlightResolveBy: false, + }; + + const renderWithProvider = (props = {}) => { + return render( + + + + ); + }; + + it('automatically adjusts values to maintain 100 total', () => { + const onChange = jest.fn(); + renderWithProvider({ onChange }); + + const inputs = screen.getAllByRole('spinbutton'); + fireEvent.change(inputs[0], { target: { value: '60' } }); + fireEvent.change(inputs[1], { target: { value: '30' } }); + + expect(onChange).toHaveBeenLastCalledWith({ + 'option-1': 60, + 'option-2': 30, + 'option-3': 10, + }); + }); +}); \ No newline at end of file From 33f650d3a38fab7a1d75b63c2fa6a12d8900661c Mon Sep 17 00:00:00 2001 From: Daniel Gibbons Date: Sun, 22 Dec 2024 16:55:52 -0300 Subject: [PATCH 6/6] fix and apply linting rules --- .../MultiChoiceQuestion.test.tsx | 54 +++++++++---------- .../question-types/MultiChoiceQuestion.tsx | 5 +- lib/_utils_multiple-choice.ts | 15 +++--- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/components/predict-form/question-types/MultiChoiceQuestion.test.tsx b/components/predict-form/question-types/MultiChoiceQuestion.test.tsx index cb7c554..ab0894a 100644 --- a/components/predict-form/question-types/MultiChoiceQuestion.test.tsx +++ b/components/predict-form/question-types/MultiChoiceQuestion.test.tsx @@ -1,18 +1,18 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import MultiChoiceQuestion from './MultiChoiceQuestion'; -import { PredictProvider } from '../PredictProvider'; +import { render, screen, fireEvent } from "@testing-library/react" +import MultiChoiceQuestion from "./MultiChoiceQuestion" +import { PredictProvider } from "../PredictProvider" -describe('MultiChoiceQuestion', () => { +describe("MultiChoiceQuestion", () => { const mockQuestion = { - id: 'test-question', - type: 'multiple-choice', - title: 'Test Question', + id: "test-question", + type: "multiple-choice", + title: "Test Question", options: [ - { id: 'option-1', text: 'Option 1' }, - { id: 'option-2', text: 'Option 2' }, - { id: 'option-3', text: 'Option 3' }, + { id: "option-1", text: "Option 1" }, + { id: "option-2", text: "Option 2" }, + { id: "option-3", text: "Option 3" }, ], - }; + } const defaultProps = { question: mockQuestion, @@ -20,28 +20,28 @@ describe('MultiChoiceQuestion', () => { onChange: jest.fn(), onSubmit: jest.fn(), highlightResolveBy: false, - }; + } const renderWithProvider = (props = {}) => { return render( - - ); - }; + , + ) + } - it('automatically adjusts values to maintain 100 total', () => { - const onChange = jest.fn(); - renderWithProvider({ onChange }); + it("automatically adjusts values to maintain 100 total", () => { + const onChange = jest.fn() + renderWithProvider({ onChange }) - const inputs = screen.getAllByRole('spinbutton'); - fireEvent.change(inputs[0], { target: { value: '60' } }); - fireEvent.change(inputs[1], { target: { value: '30' } }); + const inputs = screen.getAllByRole("spinbutton") + fireEvent.change(inputs[0], { target: { value: "60" } }) + fireEvent.change(inputs[1], { target: { value: "30" } }) expect(onChange).toHaveBeenLastCalledWith({ - 'option-1': 60, - 'option-2': 30, - 'option-3': 10, - }); - }); -}); \ No newline at end of file + "option-1": 60, + "option-2": 30, + "option-3": 10, + }) + }) +}) diff --git a/components/predict-form/question-types/MultiChoiceQuestion.tsx b/components/predict-form/question-types/MultiChoiceQuestion.tsx index b9a2009..5a45f26 100644 --- a/components/predict-form/question-types/MultiChoiceQuestion.tsx +++ b/components/predict-form/question-types/MultiChoiceQuestion.tsx @@ -48,7 +48,10 @@ export default function MultiChoiceQuestion({ const normalizedOptions = normalizeOptionsToHundred(options) normalizedOptions.forEach((option, index) => { - setValue(`options.${index}.forecast`, formatDecimalNicely((option.forecast || 0), 0)) + setValue( + `options.${index}.forecast`, + formatDecimalNicely(option.forecast || 0, 0), + ) }) void trigger("options") diff --git a/lib/_utils_multiple-choice.ts b/lib/_utils_multiple-choice.ts index 631c601..856c18f 100644 --- a/lib/_utils_multiple-choice.ts +++ b/lib/_utils_multiple-choice.ts @@ -3,16 +3,17 @@ import { OptionType } from "../components/predict-form/PredictProvider" export function normalizeOptionsToHundred(options: OptionType[]) { const optionsCopy = [...options] const optionsWithForecasts = optionsCopy.filter( - ({ forecast }) => forecast !== undefined && !Number.isNaN(forecast) + ({ forecast }) => forecast !== undefined && !Number.isNaN(forecast), ) as Array const optionsWithoutForecasts = optionsCopy.filter( - ({ forecast }) => forecast === undefined || Number.isNaN(forecast) + ({ forecast }) => forecast === undefined || Number.isNaN(forecast), ) - const allOptionsHaveForecasts = optionsWithForecasts.length === optionsCopy.length + const allOptionsHaveForecasts = + optionsWithForecasts.length === optionsCopy.length const totalPercentage = optionsWithForecasts.reduce( (acc, option) => acc + option.forecast, - 0 + 0, ) if (allOptionsHaveForecasts) { @@ -20,7 +21,7 @@ export function normalizeOptionsToHundred(options: OptionType[]) { const scaledValue = (option.forecast * 100) / totalPercentage optionsCopy[index] = { ...option, - forecast: scaledValue + forecast: scaledValue, } }) } else { @@ -31,10 +32,10 @@ export function normalizeOptionsToHundred(options: OptionType[]) { const originalIndex = options.findIndex((o) => o === option) optionsCopy[originalIndex] = { ...option, - forecast: massPerOption + forecast: massPerOption, } }) } return optionsCopy -} \ No newline at end of file +}