Files
RuvdsTest/src/Client/Features/Teacher/Tests/State.fs
danamir 63bdd7dd74 Allow the quiz-builder picker to expand multiple topics at once
Sources could already draw from any number of topics, but the picker
UI only ever showed one topic's questions at a time, forcing a teacher
to lose their place switching between topics while assembling a quiz.
2026-08-09 00:54:45 +03:00

408 lines
17 KiB
Forth

module Client.Features.Teacher.Tests.State
open Elmish
open Domain.Contracts
open Client.Features.Teacher.Tests.Types
let init () : Model * Cmd<Msg> =
empty, Cmd.batch [ Cmd.ofMsg LoadQuizzes; Cmd.ofMsg LoadStudents; Cmd.ofMsg LoadTopics ]
/// Empty string means "not set" for these optional numeric fields; a
/// non-empty string that fails to parse is a validation error.
let private parseOptionalInt (text: string) : Result<int option, string> =
if System.String.IsNullOrWhiteSpace text then
Ok None
else
match System.Int32.TryParse text with
| true, v -> Ok(Some v)
| false, _ -> Error "Ожидалось целое число"
let private parseOptionalFloat (text: string) : Result<float option, string> =
if System.String.IsNullOrWhiteSpace text then
Ok None
else
match System.Double.TryParse text with
| true, v -> Ok(Some v)
| false, _ -> Error "Ожидалось число"
let private buildQuizFields (form: QuizForm) =
match parseOptionalInt form.TimeLimitText, parseOptionalInt form.MaxAttemptsText, parseOptionalFloat form.PassingScoreText with
| Error err, _, _
| _, Error err, _
| _, _, Error err -> Error err
| Ok timeLimit, Ok maxAttempts, Ok passingScore ->
if System.String.IsNullOrWhiteSpace form.Title then
Error "Название теста не может быть пустым"
elif form.Sources.IsEmpty then
Error "Выберите хотя бы один вопрос"
else
Ok(timeLimit, maxAttempts, passingScore)
let private toDraft (source: QuizQuestionSourceInput) : QuizSourceDraft =
match source with
| FixedQuestionInput qid -> FixedDraft qid
| RandomPoolInput rule -> PoolDraft(rule.TopicId, rule.Count)
let private toSourceInput (draft: QuizSourceDraft) : QuizQuestionSourceInput =
match draft with
| FixedDraft qid -> FixedQuestionInput qid
| PoolDraft(topicId, count) -> RandomPoolInput { TopicId = topicId; Count = count }
let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
match msg with
| LoadQuizzes ->
let cmd =
Cmd.OfAsync.either
Api.listMyQuizzes
token
(function
| Ok quizzes -> QuizzesLoaded quizzes
| Error err -> QuizzesLoadFailed err)
(fun ex -> QuizzesLoadFailed ex.Message)
{ model with QuizzesLoading = true; QuizzesError = None }, cmd
| QuizzesLoaded quizzes -> { model with Quizzes = quizzes; QuizzesLoading = false }, Cmd.none
| QuizzesLoadFailed err -> { model with QuizzesLoading = false; QuizzesError = Some err }, Cmd.none
| LoadStudents ->
let cmd =
Cmd.OfAsync.either
Api.listStudents
token
(function
| Ok students -> StudentsLoaded students
| Error err -> StudentsLoadFailed err)
(fun ex -> StudentsLoadFailed ex.Message)
{ model with StudentsLoading = true; StudentsError = None }, cmd
| StudentsLoaded students -> { model with Students = students; StudentsLoading = false }, Cmd.none
| StudentsLoadFailed err -> { model with StudentsLoading = false; StudentsError = Some err }, Cmd.none
| SelectQuiz quizId ->
let assigned =
model.Quizzes
|> List.tryFind (fun q -> q.Id = quizId)
|> Option.map (fun q -> Set.ofList q.AssignedStudentIds)
|> Option.defaultValue Set.empty
{ model with
SelectedQuizId = Some quizId
SelectedStudentIds = assigned
SaveError = None
ShowQuizForm = false
ResultsQuizId = None },
Cmd.none
| ToggleStudent studentId ->
let next =
if model.SelectedStudentIds.Contains studentId then
model.SelectedStudentIds.Remove studentId
else
model.SelectedStudentIds.Add studentId
{ model with SelectedStudentIds = next }, Cmd.none
| SaveAssignments ->
match model.SelectedQuizId with
| None -> model, Cmd.none
| Some quizId ->
let request: AssignStudentsRequest =
{ QuizId = quizId
StudentIds = Set.toList model.SelectedStudentIds }
let cmd =
Cmd.OfAsync.either
(Api.assignStudents token)
request
(function
| Ok summary -> AssignmentsSaved summary
| Error err -> SaveFailed err)
(fun ex -> SaveFailed ex.Message)
{ model with IsSaving = true; SaveError = None }, cmd
| AssignmentsSaved summary ->
{ model with
Quizzes = model.Quizzes |> List.map (fun q -> if q.Id = summary.Id then summary else q)
IsSaving = false },
Cmd.none
| SaveFailed err -> { model with IsSaving = false; SaveError = Some err }, Cmd.none
| LoadTopics ->
let cmd =
Cmd.OfAsync.either
Api.listTopics
token
(function
| Ok topics -> TopicsLoaded topics
| Error err -> TopicsLoadFailed err)
(fun ex -> TopicsLoadFailed ex.Message)
{ model with TopicsLoading = true; TopicsError = None }, cmd
| TopicsLoaded topics -> { model with Topics = topics; TopicsLoading = false }, Cmd.none
| TopicsLoadFailed err -> { model with TopicsLoading = false; TopicsError = Some err }, Cmd.none
| StartNewQuiz ->
{ model with
ShowQuizForm = true
EditingQuizId = None
QuizForm = emptyQuizForm
SelectedQuizId = None
ResultsQuizId = None },
Cmd.none
| StartEditQuiz quizId ->
match model.Quizzes |> List.tryFind (fun q -> q.Id = quizId) with
| None -> model, Cmd.none
| Some quiz ->
let form =
{ emptyQuizForm with
Title = quiz.Title
Description = quiz.Description
TimeLimitText = quiz.TimeLimitMinutes |> Option.map string |> Option.defaultValue ""
MaxAttemptsText = quiz.MaxAttempts |> Option.map string |> Option.defaultValue ""
PassingScoreText = quiz.PassingScore |> Option.map string |> Option.defaultValue ""
ShuffleQuestions = quiz.ShuffleQuestions
ShuffleAnswers = quiz.ShuffleAnswers
Sources = quiz.Sources |> List.map toDraft }
{ model with
ShowQuizForm = true
EditingQuizId = Some quizId
QuizForm = form },
Cmd.none
| CancelQuizForm -> { model with ShowQuizForm = false; EditingQuizId = None }, Cmd.none
| SetQuizTitle text -> { model with QuizForm = { model.QuizForm with Title = text } }, Cmd.none
| SetQuizDescription text -> { model with QuizForm = { model.QuizForm with Description = text } }, Cmd.none
| SetQuizTimeLimitText text -> { model with QuizForm = { model.QuizForm with TimeLimitText = text } }, Cmd.none
| SetQuizMaxAttemptsText text ->
{ model with QuizForm = { model.QuizForm with MaxAttemptsText = text } }, Cmd.none
| SetQuizPassingScoreText text ->
{ model with QuizForm = { model.QuizForm with PassingScoreText = text } }, Cmd.none
| SetQuizShuffleQuestions value ->
{ model with QuizForm = { model.QuizForm with ShuffleQuestions = value } }, Cmd.none
| SetQuizShuffleAnswers value ->
{ model with QuizForm = { model.QuizForm with ShuffleAnswers = value } }, Cmd.none
| ToggleTopicInPicker topicId ->
let form = model.QuizForm
if form.PickerTopicIds.Contains topicId then
// Deselecting a topic drops it from the quiz entirely its
// fixed picks and pool rule no longer apply.
let belongsToTopic qid =
form.PickerQuestions
|> List.tryFind (fun q -> q.Id = qid)
|> Option.map (fun q -> q.TopicId = topicId)
|> Option.defaultValue false
let sourcesWithoutTopic =
form.Sources
|> List.filter (function
| PoolDraft(tid, _) -> tid <> topicId
| FixedDraft qid -> not (belongsToTopic qid))
{ model with
QuizForm =
{ form with
PickerTopicIds = form.PickerTopicIds.Remove topicId
PickerQuestions = form.PickerQuestions |> List.filter (fun q -> q.TopicId <> topicId)
PoolCountTexts = form.PoolCountTexts.Remove topicId
Sources = sourcesWithoutTopic } },
Cmd.none
else
let cmd =
Cmd.OfAsync.either
(Api.listQuestionsInTopic token)
topicId
(function
| Ok questions -> PickerQuestionsLoaded(topicId, questions)
| Error err -> PickerQuestionsLoadFailed(topicId, err))
(fun ex -> PickerQuestionsLoadFailed(topicId, ex.Message))
let existingPoolCount =
form.Sources
|> List.tryPick (function
| PoolDraft(tid, count) when tid = topicId -> Some count
| _ -> None)
let poolCountTexts =
match existingPoolCount with
| Some count -> form.PoolCountTexts |> Map.add topicId (string count)
| None -> form.PoolCountTexts
{ model with
QuizForm =
{ form with
PickerTopicIds = form.PickerTopicIds.Add topicId
PickerLoadingTopics = form.PickerLoadingTopics.Add topicId
PoolCountTexts = poolCountTexts } },
cmd
| PickerQuestionsLoaded(topicId, questions) ->
{ model with
QuizForm =
{ model.QuizForm with
PickerQuestions = model.QuizForm.PickerQuestions @ questions
PickerLoadingTopics = model.QuizForm.PickerLoadingTopics.Remove topicId } },
Cmd.none
| PickerQuestionsLoadFailed(topicId, err) ->
{ model with
QuizForm =
{ model.QuizForm with
PickerLoadingTopics = model.QuizForm.PickerLoadingTopics.Remove topicId
Error = Some err } },
Cmd.none
| ToggleQuestionPick questionId ->
let form = model.QuizForm
let next =
if form.Sources |> List.exists (function FixedDraft qid -> qid = questionId | _ -> false) then
form.Sources
|> List.filter (function
| FixedDraft qid -> qid <> questionId
| _ -> true)
else
form.Sources @ [ FixedDraft questionId ]
{ model with QuizForm = { form with Sources = next } }, Cmd.none
| SelectAllInTopic topicId ->
let form = model.QuizForm
let alreadySelected =
form.Sources
|> List.choose (function
| FixedDraft qid -> Some qid
| _ -> None)
|> Set.ofList
let toAdd =
form.PickerQuestions
|> List.filter (fun q -> q.TopicId = topicId)
|> List.map (fun q -> q.Id)
|> List.filter (alreadySelected.Contains >> not)
|> List.map FixedDraft
{ model with QuizForm = { form with Sources = form.Sources @ toAdd } }, Cmd.none
| SetPoolCountText(topicId, text) ->
{ model with QuizForm = { model.QuizForm with PoolCountTexts = model.QuizForm.PoolCountTexts |> Map.add topicId text } },
Cmd.none
| AddRandomPool topicId ->
let form = model.QuizForm
let text = form.PoolCountTexts |> Map.tryFind topicId |> Option.defaultValue ""
match System.Int32.TryParse text with
| true, count when count > 0 ->
let withoutOldPool =
form.Sources
|> List.filter (function
| PoolDraft(tid, _) -> tid <> topicId
| _ -> true)
{ model with QuizForm = { form with Sources = withoutOldPool @ [ PoolDraft(topicId, count) ]; Error = None } },
Cmd.none
| _ ->
{ model with QuizForm = { form with Error = Some "Количество случайных вопросов должно быть положительным числом" } },
Cmd.none
| RemoveRandomPool topicId ->
let form = model.QuizForm
let next =
form.Sources
|> List.filter (function
| PoolDraft(tid, _) -> tid <> topicId
| _ -> true)
{ model with QuizForm = { form with Sources = next; PoolCountTexts = form.PoolCountTexts.Remove topicId } }, Cmd.none
| SubmitQuizForm ->
match buildQuizFields model.QuizForm with
| Error err -> { model with QuizForm = { model.QuizForm with Error = Some err } }, Cmd.none
| Ok(timeLimit, maxAttempts, passingScore) ->
let form = { model.QuizForm with IsSubmitting = true; Error = None }
let sources = form.Sources |> List.map toSourceInput
let cmd =
match model.EditingQuizId with
| None ->
let req: CreateQuizRequest =
{ Title = form.Title
Description = form.Description
TimeLimitMinutes = timeLimit
MaxAttempts = maxAttempts
PassingScore = passingScore
ShuffleQuestions = form.ShuffleQuestions
ShuffleAnswers = form.ShuffleAnswers
Sources = sources }
Cmd.OfAsync.either
(Api.createQuiz token)
req
(function
| Ok summary -> QuizSaved summary
| Error err -> QuizSaveFailed err)
(fun ex -> QuizSaveFailed ex.Message)
| Some quizId ->
let req: UpdateQuizRequest =
{ QuizId = quizId
Title = form.Title
Description = form.Description
TimeLimitMinutes = timeLimit
MaxAttempts = maxAttempts
PassingScore = passingScore
ShuffleQuestions = form.ShuffleQuestions
ShuffleAnswers = form.ShuffleAnswers
Sources = sources }
Cmd.OfAsync.either
(Api.updateQuiz token)
req
(function
| Ok summary -> QuizSaved summary
| Error err -> QuizSaveFailed err)
(fun ex -> QuizSaveFailed ex.Message)
{ model with QuizForm = form }, cmd
| QuizSaved summary ->
let alreadyExists = model.Quizzes |> List.exists (fun q -> q.Id = summary.Id)
{ model with
Quizzes =
if alreadyExists then
model.Quizzes |> List.map (fun q -> if q.Id = summary.Id then summary else q)
else
model.Quizzes @ [ summary ]
ShowQuizForm = false
EditingQuizId = None },
Cmd.none
| QuizSaveFailed err -> { model with QuizForm = { model.QuizForm with IsSubmitting = false; Error = Some err } }, Cmd.none
| RequestDeleteQuiz quizId ->
let cmd =
Cmd.OfAsync.either
(Api.deleteQuiz token)
quizId
(function
| Ok() -> QuizDeleted quizId
| Error err -> QuizDeleteFailed err)
(fun ex -> QuizDeleteFailed ex.Message)
model, cmd
| QuizDeleted quizId ->
{ model with
Quizzes = model.Quizzes |> List.filter (fun q -> q.Id <> quizId)
SelectedQuizId = if model.SelectedQuizId = Some quizId then None else model.SelectedQuizId
EditingQuizId = if model.EditingQuizId = Some quizId then None else model.EditingQuizId
ShowQuizForm = if model.EditingQuizId = Some quizId then false else model.ShowQuizForm },
Cmd.none
| QuizDeleteFailed err -> { model with QuizzesError = Some err }, Cmd.none
| ShowResults quizId ->
let cmd =
Cmd.OfAsync.either
(Api.getQuizResults token)
quizId
(function
| Ok results -> ResultsLoaded results
| Error err -> ResultsLoadFailed err)
(fun ex -> ResultsLoadFailed ex.Message)
{ model with
ResultsQuizId = Some quizId
ResultsLoading = true
ResultsError = None
Results = []
SelectedQuizId = None
ShowQuizForm = false },
cmd
| ResultsLoaded results -> { model with Results = results; ResultsLoading = false }, Cmd.none
| ResultsLoadFailed err -> { model with ResultsLoading = false; ResultsError = Some err }, Cmd.none
| CancelResults -> { model with ResultsQuizId = None }, Cmd.none