Skip to content

Commit fb864aa

Browse files
committed
feat: multiline text fields can now show and validate maximum length
1 parent 461b463 commit fb864aa

File tree

7 files changed

+206
-12
lines changed

7 files changed

+206
-12
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,15 @@ fun FiledFileUpload(
7575
FieldValidationState.INVALID -> {
7676
Text(
7777
stringResource(Res.string.question_input_invalid),
78-
color = Color.Red,
78+
color = CustomColor.BrandRed.asColor(),
7979
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
8080
)
8181
}
8282

8383
FieldValidationState.MISSING -> {
8484
Text(
8585
stringResource(Res.string.question_input_required),
86-
color = Color.Red,
86+
color = CustomColor.BrandRed.asColor(),
8787
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
8888
)
8989
}

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
1919
import androidx.compose.ui.input.key.type
2020
import androidx.compose.ui.platform.LocalFocusManager
2121
import androidx.compose.ui.text.font.FontWeight
22+
import androidx.compose.ui.text.style.TextAlign
2223
import androidx.compose.ui.unit.dp
2324
import com.composeunstyled.TextField
2425
import com.composeunstyled.TextInput
@@ -39,6 +40,8 @@ fun FieldTextInput(
3940
enabled: Boolean = true,
4041
required: Boolean = false,
4142
validation: FieldValidationState? = null,
43+
maxLength: Int? = null,
44+
showLimitCounter: Boolean = false,
4245
modifier: Modifier = Modifier,
4346
onClick: (() -> Unit)? = null,
4447
) {
@@ -51,6 +54,7 @@ fun FieldTextInput(
5154
onValueChange = {
5255
if (!enabled) return@TextField
5356
if (isMultiline && it.count { c -> c == '\n' } >= maxLines) return@TextField
57+
if (maxLength != null && it.length > maxLength) return@TextField
5458
onValueChange(it)
5559
},
5660
maxLines = maxLines,
@@ -88,20 +92,29 @@ fun FieldTextInput(
8892
trailing = trailing
8993
)
9094

95+
if (maxLength != null && showLimitCounter) {
96+
Text(
97+
"${value.length}/$maxLength",
98+
color = if (value.length >= maxLength) CustomColor.BrandRed.asColor() else CustomColor.BrandDark.asColor(),
99+
textAlign = TextAlign.End,
100+
modifier = Modifier.fillMaxWidth().padding(top = 4.dp)
101+
)
102+
}
103+
91104
if (validation != null) {
92105
when (validation) {
93106
FieldValidationState.INVALID -> {
94107
Text(
95108
stringResource(Res.string.question_input_invalid),
96-
color = Color.Red,
109+
color = CustomColor.BrandRed.asColor(),
97110
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
98111
)
99112
}
100113

101114
FieldValidationState.MISSING -> {
102115
Text(
103116
stringResource(Res.string.question_input_required),
104-
color = Color.Red,
117+
color = CustomColor.BrandRed.asColor(),
105118
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp)
106119
)
107120
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,6 @@ data class ResultStateData(
6767
val answers: Map<Long, String> = emptyMap(),
6868
val isPrintable: Boolean = false,
6969
val badgeLayout: BadgeLayout? = null,
70-
val position: JSONObject? = null
70+
val position: JSONObject? = null,
71+
val questionMaxLengths: Map<Long, Int> = emptyMap()
7172
)

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import eu.pretix.libpretixsync.db.Answer
1616
import eu.pretix.libpretixsync.models.db.toModel
1717
import kotlinx.coroutines.Dispatchers
1818
import kotlinx.coroutines.withContext
19+
import org.json.JSONObject
1920
import org.jetbrains.compose.resources.ExperimentalResourceApi
2021
import org.jetbrains.compose.resources.getString
2122
import pretixscan.composeapp.generated.resources.*
@@ -56,6 +57,16 @@ class TicketCodeHandler(
5657
}
5758
}
5859

60+
val questionMaxLengths = mutableMapOf<Long, Int>()
61+
checkResult.requiredAnswers?.forEach {
62+
try {
63+
val jsonData = JSONObject(it.question.json_data)
64+
if (jsonData.has("valid_string_length_max") && !jsonData.isNull("valid_string_length_max")) {
65+
questionMaxLengths[it.question.server_id] = jsonData.getInt("valid_string_length_max")
66+
}
67+
} catch (_: Exception) { }
68+
}
69+
5970
val badgeLayout = layoutFetcher.getForItemAtEvent(checkResult.positionId, checkResult.eventSlug)
6071
val canPrintBadge =
6172
conf.printBadges && checkResult.scanType != TicketCheckProvider.CheckInType.EXIT && checkResult.position != null && badgeLayout != null
@@ -79,7 +90,8 @@ class TicketCodeHandler(
7990
answers = questionValues,
8091
isPrintable = canPrintBadge,
8192
badgeLayout = badgeLayout,
82-
position = checkResult.position
93+
position = checkResult.position,
94+
questionMaxLengths = questionMaxLengths
8395
)
8496

8597
return resultState

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,24 @@ fun QuestionsDialogView(
124124
},
125125
label = field.label,
126126
maxLines = 1,
127-
required = field.required
127+
required = field.required,
128+
validation = field.validation
129+
)
130+
}
131+
132+
QuestionType.EMAIL -> {
133+
FieldTextInput(
134+
value = field.value ?: "",
135+
onValueChange = { newValue ->
136+
viewModel.updateAnswer(field.id, newValue)
137+
},
138+
label = field.label,
139+
maxLines = 1,
140+
required = field.required,
141+
validation = field.validation
128142
)
129143
}
130144

131-
QuestionType.EMAIL,
132145
QuestionType.S -> {
133146
FieldTextInput(
134147
value = field.value ?: "",
@@ -137,7 +150,9 @@ fun QuestionsDialogView(
137150
},
138151
label = field.label,
139152
maxLines = 1,
140-
required = field.required
153+
required = field.required,
154+
validation = field.validation,
155+
maxLength = field.maxLength
141156
)
142157
}
143158

@@ -151,7 +166,10 @@ fun QuestionsDialogView(
151166
maxLines = 2,
152167
minLines = 2,
153168
label = field.label,
154-
required = field.required
169+
required = field.required,
170+
validation = field.validation,
171+
maxLength = field.maxLength,
172+
showLimitCounter = true
155173
)
156174
}
157175
}

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,6 @@ class QuestionsDialogViewModel(
107107
when (it.type) {
108108
QuestionType.N,
109109
QuestionType.EMAIL,
110-
QuestionType.S,
111-
QuestionType.T,
112110
QuestionType.F,
113111
QuestionType.B -> {
114112
QuestionFormField(
@@ -120,6 +118,18 @@ class QuestionsDialogViewModel(
120118
)
121119
}
122120

121+
QuestionType.S,
122+
QuestionType.T -> {
123+
QuestionFormField(
124+
it.serverId,
125+
it.question,
126+
startingAnswerValue(it, data.answers[it.serverId]),
127+
it.type,
128+
it.required,
129+
maxLength = data.questionMaxLengths[it.serverId]
130+
)
131+
}
132+
123133
QuestionType.TEL -> {
124134
val answerValue = startingAnswerValue(it, data.answers[it.serverId])
125135
val countryCode = if (!answerValue.isNullOrBlank()) {
@@ -316,6 +326,18 @@ class QuestionsDialogViewModel(
316326
}
317327
}
318328

329+
QuestionType.S,
330+
QuestionType.T -> {
331+
val value = it.value
332+
if (value.isNullOrBlank() && it.required) {
333+
it.copy(validation = FieldValidationState.MISSING)
334+
} else if (!value.isNullOrBlank() && it.maxLength != null && value.length > it.maxLength) {
335+
it.copy(validation = FieldValidationState.INVALID)
336+
} else {
337+
it.copy(validation = null)
338+
}
339+
}
340+
319341
else -> {
320342
if (it.required && it.value.isNullOrBlank()) {
321343
it.copy(validation = FieldValidationState.MISSING)
@@ -385,6 +407,7 @@ data class QuestionFormField(
385407
val keyValueOptions: List<SelectableValue>? = null,
386408
val options: List<QuestionOption> = emptyList(),
387409
var validation: FieldValidationState? = null,
410+
val maxLength: Int? = null,
388411
// Extra value used by the UI for form state which isn't part of the pretixsync model
389412
var uiExtra: String? = null
390413
)

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,133 @@ class QuestionsDialogViewModelTest {
852852
assertEquals("https://example.com/photo.jpg", formField.value)
853853
}
854854

855+
@Test
856+
fun `text exceeding maxLength is marked INVALID on validation`() = runTest {
857+
val textQuestion = createTextQuestion(serverId = 1L, type = QuestionType.S, required = false)
858+
859+
val data = ResultStateData(
860+
resultState = ResultState.DIALOG_QUESTIONS,
861+
requiredQuestions = listOf(textQuestion),
862+
answers = emptyMap(),
863+
questionMaxLengths = mapOf(1L to 10)
864+
)
865+
866+
viewModel.buildQuestionsForm(data)
867+
viewModel.updateAnswer(1L, "this exceeds the limit")
868+
viewModel.formatAndValidateForm()
869+
870+
val formField = viewModel.form.value.first { it.id == 1L }
871+
assertEquals(FieldValidationState.INVALID, formField.validation)
872+
}
873+
874+
@Test
875+
fun `text within maxLength passes validation`() = runTest {
876+
val textQuestion = createTextQuestion(serverId = 2L, type = QuestionType.T, required = false)
877+
878+
val data = ResultStateData(
879+
resultState = ResultState.DIALOG_QUESTIONS,
880+
requiredQuestions = listOf(textQuestion),
881+
answers = emptyMap(),
882+
questionMaxLengths = mapOf(2L to 50)
883+
)
884+
885+
viewModel.buildQuestionsForm(data)
886+
viewModel.updateAnswer(2L, "short")
887+
viewModel.formatAndValidateForm()
888+
889+
val formField = viewModel.form.value.first { it.id == 2L }
890+
assertNull(formField.validation)
891+
}
892+
893+
@Test
894+
fun `null maxLength means no length restriction`() = runTest {
895+
val textQuestion = createTextQuestion(serverId = 3L, type = QuestionType.S, required = false)
896+
897+
val data = ResultStateData(
898+
resultState = ResultState.DIALOG_QUESTIONS,
899+
requiredQuestions = listOf(textQuestion),
900+
answers = emptyMap()
901+
)
902+
903+
viewModel.buildQuestionsForm(data)
904+
viewModel.updateAnswer(3L, "a".repeat(1000))
905+
viewModel.formatAndValidateForm()
906+
907+
val formField = viewModel.form.value.first { it.id == 3L }
908+
assertNull(formField.validation)
909+
assertNull(formField.maxLength)
910+
}
911+
912+
@Test
913+
fun `form field correctly receives maxLength from ResultStateData`() = runTest {
914+
val textQuestion = createTextQuestion(serverId = 4L, type = QuestionType.T, required = false)
915+
916+
val data = ResultStateData(
917+
resultState = ResultState.DIALOG_QUESTIONS,
918+
requiredQuestions = listOf(textQuestion),
919+
answers = emptyMap(),
920+
questionMaxLengths = mapOf(4L to 100)
921+
)
922+
923+
viewModel.buildQuestionsForm(data)
924+
925+
val formField = viewModel.form.value.first { it.id == 4L }
926+
assertEquals(100, formField.maxLength)
927+
}
928+
929+
@Test
930+
fun `pre-filled value exceeding maxLength fails validation`() = runTest {
931+
val textQuestion = createTextQuestion(serverId = 5L, type = QuestionType.S, required = true)
932+
933+
val data = ResultStateData(
934+
resultState = ResultState.DIALOG_QUESTIONS,
935+
requiredQuestions = listOf(textQuestion),
936+
answers = mapOf(5L to "this is way too long for the limit"),
937+
questionMaxLengths = mapOf(5L to 5)
938+
)
939+
940+
viewModel.buildQuestionsForm(data)
941+
viewModel.formatAndValidateForm()
942+
943+
val formField = viewModel.form.value.first { it.id == 5L }
944+
assertEquals(FieldValidationState.INVALID, formField.validation)
945+
}
946+
947+
@Test
948+
fun `blank optional text with maxLength does not trigger INVALID`() = runTest {
949+
val textQuestion = createTextQuestion(serverId = 6L, type = QuestionType.T, required = false)
950+
951+
val data = ResultStateData(
952+
resultState = ResultState.DIALOG_QUESTIONS,
953+
requiredQuestions = listOf(textQuestion),
954+
answers = emptyMap(),
955+
questionMaxLengths = mapOf(6L to 10)
956+
)
957+
958+
viewModel.buildQuestionsForm(data)
959+
viewModel.formatAndValidateForm()
960+
961+
val formField = viewModel.form.value.first { it.id == 6L }
962+
assertNull(formField.validation)
963+
}
964+
965+
private fun createTextQuestion(
966+
serverId: Long,
967+
type: QuestionType = QuestionType.S,
968+
required: Boolean = false
969+
) = Question(
970+
id = serverId,
971+
serverId = serverId,
972+
eventSlug = "test-event",
973+
position = 0,
974+
required = required,
975+
question = "Text Question",
976+
identifier = "text-$serverId",
977+
askDuringCheckIn = true,
978+
showDuringCheckIn = true,
979+
type = type
980+
)
981+
855982
private fun createTestQuestion() = Question(
856983
id = 1L,
857984
serverId = 1L,

0 commit comments

Comments
 (0)