Skip to content

Commit e645c5b

Browse files
committed
feat: question type number validates min and max values
1 parent fb864aa commit e645c5b

File tree

8 files changed

+241
-8
lines changed

8 files changed

+241
-8
lines changed

pretixscan/composeApp/src/commonMain/composeResources/values-de/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@
130130
<string name="question_validation_error">Teile der Eingabe waren ungültig.</string>
131131
<string name="question_input_required">Diese Frage muss beantwortet werden.</string>
132132
<string name="question_input_invalid">Die eingegebene Antwort ist nicht gültig.</string>
133+
<string name="question_input_number_too_low">Der Mindestwert ist %1$s.</string>
134+
<string name="question_input_number_too_high">Der Höchstwert ist %1$s.</string>
135+
<string name="question_input_number_out_of_range">Der Wert muss zwischen %1$s und %2$s liegen.</string>
133136
<string name="yes">Ja</string>
134137
<string name="no">Nein</string>
135138
<string name="cancel">Abbrechen</string>

pretixscan/composeApp/src/commonMain/composeResources/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,9 @@
138138
<string name="question_validation_error">Some of your input could not be validated.</string>
139139
<string name="question_input_required">This field is required.</string>
140140
<string name="question_input_invalid">This input is not valid.</string>
141+
<string name="question_input_number_too_low">The minimum value is %1$s.</string>
142+
<string name="question_input_number_too_high">The maximum value is %1$s.</string>
143+
<string name="question_input_number_out_of_range">The value must be between %1$s and %2$s.</string>
141144
<string name="yes">Yes</string>
142145
<string name="no">No</string>
143146
<string name="cancel">Cancel</string>

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/ui/FieldTextInput.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ fun FieldTextInput(
4040
enabled: Boolean = true,
4141
required: Boolean = false,
4242
validation: FieldValidationState? = null,
43+
validationMessage: String? = null,
4344
maxLength: Int? = null,
4445
showLimitCounter: Boolean = false,
4546
modifier: Modifier = Modifier,
@@ -105,7 +106,7 @@ fun FieldTextInput(
105106
when (validation) {
106107
FieldValidationState.INVALID -> {
107108
Text(
108-
stringResource(Res.string.question_input_invalid),
109+
validationMessage ?: stringResource(Res.string.question_input_invalid),
109110
color = CustomColor.BrandRed.asColor(),
110111
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
111112
)

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/data/ResultState.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,7 @@ data class ResultStateData(
6868
val isPrintable: Boolean = false,
6969
val badgeLayout: BadgeLayout? = null,
7070
val position: JSONObject? = null,
71-
val questionMaxLengths: Map<Long, Int> = emptyMap()
71+
val questionMaxLengths: Map<Long, Int> = emptyMap(),
72+
val questionNumberMin: Map<Long, String> = emptyMap(),
73+
val questionNumberMax: Map<Long, String> = emptyMap()
7274
)

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/data/TicketCodeHandler.kt

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,20 @@ class TicketCodeHandler(
5858
}
5959

6060
val questionMaxLengths = mutableMapOf<Long, Int>()
61+
val questionNumberMin = mutableMapOf<Long, String>()
62+
val questionNumberMax = mutableMapOf<Long, String>()
6163
checkResult.requiredAnswers?.forEach {
6264
try {
6365
val jsonData = JSONObject(it.question.json_data)
6466
if (jsonData.has("valid_string_length_max") && !jsonData.isNull("valid_string_length_max")) {
6567
questionMaxLengths[it.question.server_id] = jsonData.getInt("valid_string_length_max")
6668
}
69+
if (jsonData.has("valid_number_min") && !jsonData.isNull("valid_number_min")) {
70+
questionNumberMin[it.question.server_id] = jsonData.getString("valid_number_min")
71+
}
72+
if (jsonData.has("valid_number_max") && !jsonData.isNull("valid_number_max")) {
73+
questionNumberMax[it.question.server_id] = jsonData.getString("valid_number_max")
74+
}
6775
} catch (_: Exception) { }
6876
}
6977

@@ -91,7 +99,9 @@ class TicketCodeHandler(
9199
isPrintable = canPrintBadge,
92100
badgeLayout = badgeLayout,
93101
position = checkResult.position,
94-
questionMaxLengths = questionMaxLengths
102+
questionMaxLengths = questionMaxLengths,
103+
questionNumberMin = questionNumberMin,
104+
questionNumberMax = questionNumberMax
95105
)
96106

97107
return resultState

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/QuestionsDialogView.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ import org.koin.compose.viewmodel.koinViewModel
3636
import pretixscan.composeapp.generated.resources.Res
3737
import pretixscan.composeapp.generated.resources.cancel
3838
import pretixscan.composeapp.generated.resources.cont
39+
import pretixscan.composeapp.generated.resources.question_input_number_out_of_range
40+
import pretixscan.composeapp.generated.resources.question_input_number_too_high
41+
import pretixscan.composeapp.generated.resources.question_input_number_too_low
3942
import pretixscan.composeapp.generated.resources.yes
4043

4144

@@ -117,6 +120,15 @@ fun QuestionsDialogView(
117120
SelectListRow {
118121
when (field.fieldType) {
119122
QuestionType.N -> {
123+
val rangeMessage = when {
124+
field.numberMin != null && field.numberMax != null ->
125+
stringResource(Res.string.question_input_number_out_of_range, field.numberMin.formatNumber(), field.numberMax.formatNumber())
126+
field.numberMin != null ->
127+
stringResource(Res.string.question_input_number_too_low, field.numberMin.formatNumber())
128+
field.numberMax != null ->
129+
stringResource(Res.string.question_input_number_too_high, field.numberMax.formatNumber())
130+
else -> null
131+
}
120132
FieldTextInput(
121133
value = field.value ?: "",
122134
onValueChange = { newValue ->
@@ -125,7 +137,8 @@ fun QuestionsDialogView(
125137
label = field.label,
126138
maxLines = 1,
127139
required = field.required,
128-
validation = field.validation
140+
validation = field.validation,
141+
validationMessage = rangeMessage
129142
)
130143
}
131144

@@ -384,4 +397,11 @@ fun QuestionsDialogView(
384397
if (modalQuestion != null && modalQuestion?.fieldType == QuestionType.F) {
385398
QuestionPhoto(onDismiss = { viewModel.dismissModal(it) })
386399
}
387-
}
400+
}
401+
402+
private fun String.formatNumber(): String =
403+
try {
404+
java.math.BigDecimal(this).stripTrailingZeros().toPlainString()
405+
} catch (_: NumberFormatException) {
406+
this
407+
}

pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/QuestionsDialogViewModel.kt

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,18 @@ class QuestionsDialogViewModel(
105105

106106
val formFields: List<QuestionFormField> = data.requiredQuestions.map {
107107
when (it.type) {
108-
QuestionType.N,
108+
QuestionType.N -> {
109+
QuestionFormField(
110+
it.serverId,
111+
it.question,
112+
startingAnswerValue(it, data.answers[it.serverId]),
113+
it.type,
114+
it.required,
115+
numberMin = data.questionNumberMin[it.serverId],
116+
numberMax = data.questionNumberMax[it.serverId]
117+
)
118+
}
119+
109120
QuestionType.EMAIL,
110121
QuestionType.F,
111122
QuestionType.B -> {
@@ -260,8 +271,9 @@ class QuestionsDialogViewModel(
260271
}
261272

262273
QuestionType.N -> {
263-
if (answer != null && answer.all { it.isDigit() }) {
264-
field.copy(value = answer)
274+
if (answer.isNullOrEmpty() || answer.matches(Regex("^-?\\d*\\.?\\d*$"))) {
275+
val validation = validateNumberRange(answer, field.numberMin, field.numberMax)
276+
field.copy(value = answer, validation = validation)
265277
} else {
266278
field
267279
}
@@ -338,6 +350,18 @@ class QuestionsDialogViewModel(
338350
}
339351
}
340352

353+
QuestionType.N -> {
354+
val value = it.value
355+
if (it.required && value.isNullOrBlank()) {
356+
it.copy(validation = FieldValidationState.MISSING)
357+
} else if (!value.isNullOrBlank() && value.toBigDecimalOrNull() == null) {
358+
it.copy(validation = FieldValidationState.INVALID)
359+
} else {
360+
val validation = validateNumberRange(value, it.numberMin, it.numberMax)
361+
it.copy(validation = validation)
362+
}
363+
}
364+
341365
else -> {
342366
if (it.required && it.value.isNullOrBlank()) {
343367
it.copy(validation = FieldValidationState.MISSING)
@@ -386,6 +410,20 @@ class QuestionsDialogViewModel(
386410
return null
387411
}
388412

413+
private fun validateNumberRange(value: String?, min: String?, max: String?): FieldValidationState? {
414+
if (value.isNullOrEmpty()) return null
415+
val number = value.toBigDecimalOrNull() ?: return null
416+
if (min != null) {
417+
val minVal = min.toBigDecimalOrNull()
418+
if (minVal != null && number < minVal) return FieldValidationState.INVALID
419+
}
420+
if (max != null) {
421+
val maxVal = max.toBigDecimalOrNull()
422+
if (maxVal != null && number > maxVal) return FieldValidationState.INVALID
423+
}
424+
return null
425+
}
426+
389427
private fun formatFileAnswer(answer: String?): String? {
390428
return when {
391429
answer == null -> null
@@ -408,6 +446,8 @@ data class QuestionFormField(
408446
val options: List<QuestionOption> = emptyList(),
409447
var validation: FieldValidationState? = null,
410448
val maxLength: Int? = null,
449+
val numberMin: String? = null,
450+
val numberMax: String? = null,
411451
// Extra value used by the UI for form state which isn't part of the pretixsync model
412452
var uiExtra: String? = null
413453
)

pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/presentation/QuestionsDialogViewModelTest.kt

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,160 @@ class QuestionsDialogViewModelTest {
962962
assertNull(formField.validation)
963963
}
964964

965+
@Test
966+
fun `number exceeding max is marked INVALID on input`() = runTest {
967+
val numberQuestion = createNumberQuestion(serverId = 1L)
968+
969+
val data = ResultStateData(
970+
resultState = ResultState.DIALOG_QUESTIONS,
971+
requiredQuestions = listOf(numberQuestion),
972+
answers = emptyMap(),
973+
questionNumberMax = mapOf(1L to "10")
974+
)
975+
976+
viewModel.buildQuestionsForm(data)
977+
viewModel.updateAnswer(1L, "11")
978+
979+
val formField = viewModel.form.value.first { it.id == 1L }
980+
assertEquals(FieldValidationState.INVALID, formField.validation)
981+
}
982+
983+
@Test
984+
fun `number below min is marked INVALID on input`() = runTest {
985+
val numberQuestion = createNumberQuestion(serverId = 1L)
986+
987+
val data = ResultStateData(
988+
resultState = ResultState.DIALOG_QUESTIONS,
989+
requiredQuestions = listOf(numberQuestion),
990+
answers = emptyMap(),
991+
questionNumberMin = mapOf(1L to "5")
992+
)
993+
994+
viewModel.buildQuestionsForm(data)
995+
viewModel.updateAnswer(1L, "3")
996+
997+
val formField = viewModel.form.value.first { it.id == 1L }
998+
assertEquals(FieldValidationState.INVALID, formField.validation)
999+
}
1000+
1001+
@Test
1002+
fun `number within range has no validation error`() = runTest {
1003+
val numberQuestion = createNumberQuestion(serverId = 1L)
1004+
1005+
val data = ResultStateData(
1006+
resultState = ResultState.DIALOG_QUESTIONS,
1007+
requiredQuestions = listOf(numberQuestion),
1008+
answers = emptyMap(),
1009+
questionNumberMin = mapOf(1L to "1"),
1010+
questionNumberMax = mapOf(1L to "100")
1011+
)
1012+
1013+
viewModel.buildQuestionsForm(data)
1014+
viewModel.updateAnswer(1L, "50")
1015+
1016+
val formField = viewModel.form.value.first { it.id == 1L }
1017+
assertNull(formField.validation)
1018+
}
1019+
1020+
@Test
1021+
fun `number range validated on submit`() = runTest {
1022+
val numberQuestion = createNumberQuestion(serverId = 1L, required = true)
1023+
1024+
val data = ResultStateData(
1025+
resultState = ResultState.DIALOG_QUESTIONS,
1026+
requiredQuestions = listOf(numberQuestion),
1027+
answers = emptyMap(),
1028+
questionNumberMin = mapOf(1L to "10"),
1029+
questionNumberMax = mapOf(1L to "20")
1030+
)
1031+
1032+
viewModel.buildQuestionsForm(data)
1033+
viewModel.updateAnswer(1L, "25")
1034+
val isValid = viewModel.validateForConfirm()
1035+
1036+
assertFalse(isValid)
1037+
val formField = viewModel.form.value.first { it.id == 1L }
1038+
assertEquals(FieldValidationState.INVALID, formField.validation)
1039+
}
1040+
1041+
@Test
1042+
fun `empty optional number with range constraints passes validation`() = runTest {
1043+
val numberQuestion = createNumberQuestion(serverId = 1L, required = false)
1044+
1045+
val data = ResultStateData(
1046+
resultState = ResultState.DIALOG_QUESTIONS,
1047+
requiredQuestions = listOf(numberQuestion),
1048+
answers = emptyMap(),
1049+
questionNumberMin = mapOf(1L to "5"),
1050+
questionNumberMax = mapOf(1L to "10")
1051+
)
1052+
1053+
viewModel.buildQuestionsForm(data)
1054+
val isValid = viewModel.validateForConfirm()
1055+
1056+
assertTrue(isValid)
1057+
val formField = viewModel.form.value.first { it.id == 1L }
1058+
assertNull(formField.validation)
1059+
}
1060+
1061+
@Test
1062+
fun `decimal input is accepted for number questions`() = runTest {
1063+
val numberQuestion = createNumberQuestion(serverId = 1L)
1064+
1065+
val data = ResultStateData(
1066+
resultState = ResultState.DIALOG_QUESTIONS,
1067+
requiredQuestions = listOf(numberQuestion),
1068+
answers = emptyMap(),
1069+
questionNumberMin = mapOf(1L to "0"),
1070+
questionNumberMax = mapOf(1L to "100")
1071+
)
1072+
1073+
viewModel.buildQuestionsForm(data)
1074+
viewModel.updateAnswer(1L, "3.14")
1075+
1076+
val formField = viewModel.form.value.first { it.id == 1L }
1077+
assertEquals("3.14", formField.value)
1078+
assertNull(formField.validation)
1079+
}
1080+
1081+
@Test
1082+
fun `unparseable intermediate value like dash passes during input but fails on submit`() = runTest {
1083+
val numberQuestion = createNumberQuestion(serverId = 1L, required = true)
1084+
1085+
val data = ResultStateData(
1086+
resultState = ResultState.DIALOG_QUESTIONS,
1087+
requiredQuestions = listOf(numberQuestion),
1088+
answers = emptyMap()
1089+
)
1090+
1091+
viewModel.buildQuestionsForm(data)
1092+
viewModel.updateAnswer(1L, "-")
1093+
1094+
val formField = viewModel.form.value.first { it.id == 1L }
1095+
assertEquals("-", formField.value)
1096+
1097+
val isValid = viewModel.validateForConfirm()
1098+
assertFalse(isValid)
1099+
val formFieldAfterSubmit = viewModel.form.value.first { it.id == 1L }
1100+
assertEquals(FieldValidationState.INVALID, formFieldAfterSubmit.validation)
1101+
}
1102+
1103+
private fun createNumberQuestion(
1104+
serverId: Long = 1L,
1105+
required: Boolean = false
1106+
) = Question(
1107+
id = serverId,
1108+
serverId = serverId,
1109+
eventSlug = "test-event",
1110+
position = 0,
1111+
required = required,
1112+
question = "Number",
1113+
identifier = "number-$serverId",
1114+
askDuringCheckIn = true,
1115+
showDuringCheckIn = true,
1116+
type = QuestionType.N
1117+
)
1118+
9651119
private fun createTextQuestion(
9661120
serverId: Long,
9671121
type: QuestionType = QuestionType.S,

0 commit comments

Comments
 (0)