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.
408 lines
17 KiB
Forth
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
|